From e45557afc0e46c9148a4e509e639b4024cf6f197 Mon Sep 17 00:00:00 2001 From: Patrick O'Doherty Date: Fri, 10 Oct 2025 10:28:36 -0700 Subject: [PATCH] types/persist: add AttestationKey (#17281) Extend Persist with AttestationKey to record a hardware-backed attestation key for the node's identity. Add a flag to tailscaled to allow users to control the use of hardware-backed keys to bind node identity to individual machines. Updates tailscale/corp#31269 Change-Id: Idcf40d730a448d85f07f1bebf387f086d4c58be3 Signed-off-by: Patrick O'Doherty --- cmd/cloner/cloner.go | 7 +++- cmd/cloner/cloner_test.go | 49 ++++++++++++++++++++++++ cmd/cloner/clonerex/clonerex.go | 25 +++++++++++- cmd/cloner/clonerex/clonerex_clone.go | 30 ++++++++++++++- cmd/derper/depaware.txt | 2 +- cmd/stund/depaware.txt | 5 ++- cmd/tailscale/depaware.txt | 2 +- cmd/tailscaled/tailscaled.go | 55 +++++++++++++++++++-------- control/controlclient/direct.go | 24 ++++++++++++ feature/hooks.go | 19 +++++++++ feature/tpm/attestation.go | 15 +++++++- feature/tpm/tpm.go | 2 + ipn/ipnlocal/hwattest.go | 48 +++++++++++++++++++++++ ipn/ipnlocal/local.go | 38 +++++++++++++++--- ipn/ipnlocal/local_test.go | 21 ++++++++++ ipn/ipnlocal/profiles.go | 16 ++++++-- ipn/ipnlocal/profiles_test.go | 1 + ipn/prefs.go | 1 + ipn/prefs_test.go | 2 +- tailcfg/tailcfg.go | 11 ++++-- types/persist/persist.go | 18 ++++++++- types/persist/persist_clone.go | 4 ++ types/persist/persist_test.go | 2 +- types/persist/persist_view.go | 10 +++-- util/syspolicy/pkey/pkey.go | 4 ++ util/syspolicy/policy_keys.go | 1 + 26 files changed, 370 insertions(+), 42 deletions(-) create mode 100644 ipn/ipnlocal/hwattest.go diff --git a/cmd/cloner/cloner.go b/cmd/cloner/cloner.go index 15a808141..544d00518 100644 --- a/cmd/cloner/cloner.go +++ b/cmd/cloner/cloner.go @@ -121,7 +121,12 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) { continue } if !hasBasicUnderlying(ft) { - writef("dst.%s = *src.%s.Clone()", fname, fname) + // don't dereference if the underlying type is an interface + if _, isInterface := ft.Underlying().(*types.Interface); isInterface { + writef("if src.%s != nil { dst.%s = src.%s.Clone() }", fname, fname, fname) + } else { + writef("dst.%s = *src.%s.Clone()", fname, fname) + } continue } } diff --git a/cmd/cloner/cloner_test.go b/cmd/cloner/cloner_test.go index cf1063714..3556c14bc 100644 --- a/cmd/cloner/cloner_test.go +++ b/cmd/cloner/cloner_test.go @@ -59,3 +59,52 @@ func TestSliceContainer(t *testing.T) { }) } } + +func TestInterfaceContainer(t *testing.T) { + examples := []struct { + name string + in *clonerex.InterfaceContainer + }{ + { + name: "nil", + in: nil, + }, + { + name: "zero", + in: &clonerex.InterfaceContainer{}, + }, + { + name: "with_interface", + in: &clonerex.InterfaceContainer{ + Interface: &clonerex.CloneableImpl{Value: 42}, + }, + }, + { + name: "with_nil_interface", + in: &clonerex.InterfaceContainer{ + Interface: nil, + }, + }, + } + + for _, ex := range examples { + t.Run(ex.name, func(t *testing.T) { + out := ex.in.Clone() + if !reflect.DeepEqual(ex.in, out) { + t.Errorf("Clone() = %v, want %v", out, ex.in) + } + + // Verify no aliasing: modifying the clone should not affect the original + if ex.in != nil && ex.in.Interface != nil { + if impl, ok := out.Interface.(*clonerex.CloneableImpl); ok { + impl.Value = 999 + if origImpl, ok := ex.in.Interface.(*clonerex.CloneableImpl); ok { + if origImpl.Value == 999 { + t.Errorf("Clone() aliased memory with original") + } + } + } + } + }) + } +} diff --git a/cmd/cloner/clonerex/clonerex.go b/cmd/cloner/clonerex/clonerex.go index 96bf8a0bd..6463f9144 100644 --- a/cmd/cloner/clonerex/clonerex.go +++ b/cmd/cloner/clonerex/clonerex.go @@ -1,7 +1,7 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer +//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer,InterfaceContainer // Package clonerex is an example package for the cloner tool. package clonerex @@ -9,3 +9,26 @@ package clonerex type SliceContainer struct { Slice []*int } + +// Cloneable is an interface with a Clone method. +type Cloneable interface { + Clone() Cloneable +} + +// CloneableImpl is a concrete type that implements Cloneable. +type CloneableImpl struct { + Value int +} + +func (c *CloneableImpl) Clone() Cloneable { + if c == nil { + return nil + } + return &CloneableImpl{Value: c.Value} +} + +// InterfaceContainer has a pointer to an interface field, which tests +// the special handling for interface types in the cloner. +type InterfaceContainer struct { + Interface Cloneable +} diff --git a/cmd/cloner/clonerex/clonerex_clone.go b/cmd/cloner/clonerex/clonerex_clone.go index e334a4e3a..533d7e723 100644 --- a/cmd/cloner/clonerex/clonerex_clone.go +++ b/cmd/cloner/clonerex/clonerex_clone.go @@ -35,9 +35,28 @@ var _SliceContainerCloneNeedsRegeneration = SliceContainer(struct { Slice []*int }{}) +// Clone makes a deep copy of InterfaceContainer. +// The result aliases no memory with the original. +func (src *InterfaceContainer) Clone() *InterfaceContainer { + if src == nil { + return nil + } + dst := new(InterfaceContainer) + *dst = *src + if src.Interface != nil { + dst.Interface = src.Interface.Clone() + } + return dst +} + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _InterfaceContainerCloneNeedsRegeneration = InterfaceContainer(struct { + Interface Cloneable +}{}) + // Clone duplicates src into dst and reports whether it succeeded. // To succeed, must be of types <*T, *T> or <*T, **T>, -// where T is one of SliceContainer. +// where T is one of SliceContainer,InterfaceContainer. func Clone(dst, src any) bool { switch src := src.(type) { case *SliceContainer: @@ -49,6 +68,15 @@ func Clone(dst, src any) bool { *dst = src.Clone() return true } + case *InterfaceContainer: + switch dst := dst.(type) { + case *InterfaceContainer: + *dst = *src.Clone() + return true + case **InterfaceContainer: + *dst = src.Clone() + return true + } } return false } diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index 2fa1fed45..b8dd28e6b 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -132,7 +132,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa tailscale.com/types/logger from tailscale.com/cmd/derper+ tailscale.com/types/netmap from tailscale.com/ipn tailscale.com/types/opt from tailscale.com/envknob+ - tailscale.com/types/persist from tailscale.com/ipn + tailscale.com/types/persist from tailscale.com/ipn+ tailscale.com/types/preftype from tailscale.com/ipn tailscale.com/types/ptr from tailscale.com/hostinfo+ tailscale.com/types/result from tailscale.com/util/lineiter diff --git a/cmd/stund/depaware.txt b/cmd/stund/depaware.txt index be3e0e0cf..bd8eebb7b 100644 --- a/cmd/stund/depaware.txt +++ b/cmd/stund/depaware.txt @@ -59,16 +59,17 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar tailscale.com/net/stunserver from tailscale.com/cmd/stund tailscale.com/net/tsaddr from tailscale.com/tsweb tailscale.com/syncs from tailscale.com/metrics+ - tailscale.com/tailcfg from tailscale.com/version + tailscale.com/tailcfg from tailscale.com/version+ tailscale.com/tsweb from tailscale.com/cmd/stund+ tailscale.com/tsweb/promvarz from tailscale.com/cmd/stund tailscale.com/tsweb/varz from tailscale.com/tsweb+ tailscale.com/types/dnstype from tailscale.com/tailcfg tailscale.com/types/ipproto from tailscale.com/tailcfg - tailscale.com/types/key from tailscale.com/tailcfg + tailscale.com/types/key from tailscale.com/tailcfg+ tailscale.com/types/lazy from tailscale.com/version+ tailscale.com/types/logger from tailscale.com/tsweb+ tailscale.com/types/opt from tailscale.com/envknob+ + tailscale.com/types/persist from tailscale.com/feature tailscale.com/types/ptr from tailscale.com/tailcfg+ tailscale.com/types/result from tailscale.com/util/lineiter tailscale.com/types/structs from tailscale.com/tailcfg+ diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 0d3a006a1..d5b7b059f 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -162,7 +162,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/types/netmap from tailscale.com/ipn+ tailscale.com/types/nettype from tailscale.com/net/netcheck+ tailscale.com/types/opt from tailscale.com/client/tailscale+ - tailscale.com/types/persist from tailscale.com/ipn + tailscale.com/types/persist from tailscale.com/ipn+ tailscale.com/types/preftype from tailscale.com/cmd/tailscale/cli+ tailscale.com/types/ptr from tailscale.com/hostinfo+ tailscale.com/types/result from tailscale.com/util/lineiter diff --git a/cmd/tailscaled/tailscaled.go b/cmd/tailscaled/tailscaled.go index 92c44f4c1..f14cdcff0 100644 --- a/cmd/tailscaled/tailscaled.go +++ b/cmd/tailscaled/tailscaled.go @@ -52,6 +52,7 @@ import ( "tailscale.com/syncs" "tailscale.com/tsd" "tailscale.com/types/flagtype" + "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/logid" "tailscale.com/util/osshare" @@ -111,19 +112,20 @@ var args struct { // or comma-separated list thereof. tunname string - cleanUp bool - confFile string // empty, file path, or "vm:user-data" - debug string - port uint16 - statepath string - encryptState boolFlag - statedir string - socketpath string - birdSocketPath string - verbose int - socksAddr string // listen address for SOCKS5 server - httpProxyAddr string // listen address for HTTP proxy server - disableLogs bool + cleanUp bool + confFile string // empty, file path, or "vm:user-data" + debug string + port uint16 + statepath string + encryptState boolFlag + statedir string + socketpath string + birdSocketPath string + verbose int + socksAddr string // listen address for SOCKS5 server + httpProxyAddr string // listen address for HTTP proxy server + disableLogs bool + hardwareAttestation boolFlag } var ( @@ -204,6 +206,9 @@ func main() { flag.BoolVar(&printVersion, "version", false, "print version information and exit") flag.BoolVar(&args.disableLogs, "no-logs-no-support", false, "disable log uploads; this also disables any technical support") flag.StringVar(&args.confFile, "config", "", "path to config file, or 'vm:user-data' to use the VM's user-data (EC2)") + if buildfeatures.HasTPM { + flag.Var(&args.hardwareAttestation, "hardware-attestation", "use hardware-backed keys to bind node identity to this device when supported by the OS and hardware. Uses TPM 2.0 on Linux and Windows; SecureEnclave on macOS and iOS; and Keystore on Android") + } if f, ok := hookRegisterOutboundProxyFlags.GetOk(); ok { f() } @@ -667,6 +672,9 @@ func getLocalBackend(ctx context.Context, logf logger.Logf, logID logid.PublicID log.Fatalf("failed to start netstack: %v", err) } } + if buildfeatures.HasTPM && args.hardwareAttestation.v { + lb.SetHardwareAttested() + } return lb, nil } @@ -879,9 +887,26 @@ func applyIntegrationTestEnvKnob() { } } -// handleTPMFlags validates the --encrypt-state flag if set, and defaults -// state encryption on if it's supported and compatible with other settings. +// handleTPMFlags validates the --encrypt-state and --hardware-attestation flags +// if set, and defaults both to on if supported and compatible with other +// settings. func handleTPMFlags() { + switch { + case args.hardwareAttestation.v: + if _, err := key.NewEmptyHardwareAttestationKey(); err == key.ErrUnsupported { + log.SetFlags(0) + log.Fatalf("--hardware-attestation is not supported on this platform or in this build of tailscaled") + } + case !args.hardwareAttestation.set: + policyHWAttestation, _ := policyclient.Get().GetBoolean(pkey.HardwareAttestation, feature.HardwareAttestationAvailable()) + if !policyHWAttestation { + break + } + if feature.TPMAvailable() { + args.hardwareAttestation.v = true + } + } + switch { case args.encryptState.v: // Explicitly enabled, validate. diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index 61886482d..63a12b249 100644 --- a/control/controlclient/direct.go +++ b/control/controlclient/direct.go @@ -7,6 +7,8 @@ import ( "bytes" "cmp" "context" + "crypto" + "crypto/sha256" "encoding/binary" "encoding/json" "errors" @@ -604,6 +606,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new if persist.NetworkLockKey.IsZero() { persist.NetworkLockKey = key.NewNLPrivate() } + nlPub := persist.NetworkLockKey.Public() if tryingNewKey.IsZero() { @@ -944,6 +947,27 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap TKAHead: tkaHead, ConnectionHandleForTest: connectionHandleForTest, } + + // If we have a hardware attestation key, sign the node key with it and send + // the key & signature in the map request. + if buildfeatures.HasTPM { + if k := persist.AsStruct().AttestationKey; k != nil && !k.IsZero() { + hwPub := key.HardwareAttestationPublicFromPlatformKey(k) + request.HardwareAttestationKey = hwPub + + t := c.clock.Now() + msg := fmt.Sprintf("%d|%s", t.Unix(), nodeKey.String()) + digest := sha256.Sum256([]byte(msg)) + sig, err := k.Sign(nil, digest[:], crypto.SHA256) + if err != nil { + c.logf("failed to sign node key with hardware attestation key: %v", err) + } else { + request.HardwareAttestationKeySignature = sig + request.HardwareAttestationKeySignatureTimestamp = t + } + } + } + var extraDebugFlags []string if buildfeatures.HasAdvertiseRoutes && hi != nil && c.netMon != nil && !c.skipIPForwardingCheck && ipForwardingBroken(hi.RoutableIPs, c.netMon.InterfaceState()) { diff --git a/feature/hooks.go b/feature/hooks.go index 2eade1ead..a3c6c0395 100644 --- a/feature/hooks.go +++ b/feature/hooks.go @@ -6,6 +6,9 @@ package feature import ( "net/http" "net/url" + + "tailscale.com/types/logger" + "tailscale.com/types/persist" ) // HookCanAutoUpdate is a hook for the clientupdate package @@ -45,6 +48,8 @@ var HookProxySetTransportGetProxyConnectHeader Hook[func(*http.Transport)] // and available. var HookTPMAvailable Hook[func() bool] +var HookGenerateAttestationKeyIfEmpty Hook[func(p *persist.Persist, logf logger.Logf) (bool, error)] + // TPMAvailable reports whether a TPM device is supported and available. func TPMAvailable() bool { if f, ok := HookTPMAvailable.GetOk(); ok { @@ -52,3 +57,17 @@ func TPMAvailable() bool { } return false } + +// HookHardwareAttestationAvailable is a hook that reports whether hardware +// attestation is supported and available. +var HookHardwareAttestationAvailable Hook[func() bool] + +// HardwareAttestationAvailable reports whether hardware attestation is +// supported and available (TPM on Windows/Linux, Secure Enclave on macOS|iOS, +// KeyStore on Android) +func HardwareAttestationAvailable() bool { + if f, ok := HookHardwareAttestationAvailable.GetOk(); ok { + return f() + } + return false +} diff --git a/feature/tpm/attestation.go b/feature/tpm/attestation.go index 92617f995..5fbda3b17 100644 --- a/feature/tpm/attestation.go +++ b/feature/tpm/attestation.go @@ -142,13 +142,18 @@ type attestationKeySerialized struct { TPMPublic []byte `json:"tpmPublic"` } +// MarshalJSON implements json.Marshaler. func (ak *attestationKey) MarshalJSON() ([]byte, error) { + if ak == nil || ak.IsZero() { + return []byte("null"), nil + } return json.Marshal(attestationKeySerialized{ TPMPublic: ak.tpmPublic.Bytes(), TPMPrivate: ak.tpmPrivate.Buffer, }) } +// UnmarshalJSON implements json.Unmarshaler. func (ak *attestationKey) UnmarshalJSON(data []byte) (retErr error) { var aks attestationKeySerialized if err := json.Unmarshal(data, &aks); err != nil { @@ -254,6 +259,9 @@ func (ak *attestationKey) Close() error { } func (ak *attestationKey) Clone() key.HardwareAttestationKey { + if ak == nil { + return nil + } return &attestationKey{ tpm: ak.tpm, tpmPrivate: ak.tpmPrivate, @@ -263,4 +271,9 @@ func (ak *attestationKey) Clone() key.HardwareAttestationKey { } } -func (ak *attestationKey) IsZero() bool { return !ak.loaded() } +func (ak *attestationKey) IsZero() bool { + if ak == nil { + return true + } + return !ak.loaded() +} diff --git a/feature/tpm/tpm.go b/feature/tpm/tpm.go index b67cb4e3b..dd37b0506 100644 --- a/feature/tpm/tpm.go +++ b/feature/tpm/tpm.go @@ -40,6 +40,8 @@ var infoOnce = sync.OnceValue(info) func init() { feature.Register("tpm") feature.HookTPMAvailable.Set(tpmSupported) + feature.HookHardwareAttestationAvailable.Set(tpmSupported) + hostinfo.RegisterHostinfoNewHook(func(hi *tailcfg.Hostinfo) { hi.TPM = infoOnce() }) diff --git a/ipn/ipnlocal/hwattest.go b/ipn/ipnlocal/hwattest.go new file mode 100644 index 000000000..2c93cad4c --- /dev/null +++ b/ipn/ipnlocal/hwattest.go @@ -0,0 +1,48 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_tpm + +package ipnlocal + +import ( + "errors" + + "tailscale.com/feature" + "tailscale.com/types/key" + "tailscale.com/types/logger" + "tailscale.com/types/persist" +) + +func init() { + feature.HookGenerateAttestationKeyIfEmpty.Set(generateAttestationKeyIfEmpty) +} + +// generateAttestationKeyIfEmpty generates a new hardware attestation key if +// none exists. It returns true if a new key was generated and stored in +// p.AttestationKey. +func generateAttestationKeyIfEmpty(p *persist.Persist, logf logger.Logf) (bool, error) { + // attempt to generate a new hardware attestation key if none exists + var ak key.HardwareAttestationKey + if p != nil { + ak = p.AttestationKey + } + + if ak == nil || ak.IsZero() { + var err error + ak, err = key.NewHardwareAttestationKey() + if err != nil { + if !errors.Is(err, key.ErrUnsupported) { + logf("failed to create hardware attestation key: %v", err) + } + } else if ak != nil { + logf("using new hardware attestation key: %v", ak.Public()) + if p == nil { + p = &persist.Persist{} + } + p.AttestationKey = ak + return true, nil + } + } + return false, nil +} diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index e04ef9e6c..8cc74c41e 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -392,6 +392,23 @@ type LocalBackend struct { // // See tailscale/corp#29969. overrideExitNodePolicy bool + + // hardwareAttested is whether backend should use a hardware-backed key to + // bind the node identity to this device. + hardwareAttested atomic.Bool +} + +// SetHardwareAttested enables hardware attestation key signatures in map +// requests, if supported on this platform. SetHardwareAttested should be called +// before Start. +func (b *LocalBackend) SetHardwareAttested() { + b.hardwareAttested.Store(true) +} + +// HardwareAttested reports whether hardware-backed attestation keys should be +// used to bind the node's identity to this device. +func (b *LocalBackend) HardwareAttested() bool { + return b.hardwareAttested.Load() } // HealthTracker returns the health tracker for the backend. @@ -2455,10 +2472,23 @@ func (b *LocalBackend) Start(opts ipn.Options) error { if b.reconcilePrefsLocked(newPrefs) { prefsChanged = true } + + // neither UpdatePrefs or reconciliation should change Persist + newPrefs.Persist = b.pm.CurrentPrefs().Persist().AsStruct() + + if buildfeatures.HasTPM { + if genKey, ok := feature.HookGenerateAttestationKeyIfEmpty.GetOk(); ok { + newKey, err := genKey(newPrefs.Persist, b.logf) + if err != nil { + b.logf("failed to populate attestation key from TPM: %v", err) + } + if newKey { + prefsChanged = true + } + } + } + if prefsChanged { - // Neither opts.UpdatePrefs nor prefs reconciliation - // is allowed to modify Persist; retain the old value. - newPrefs.Persist = b.pm.CurrentPrefs().Persist().AsStruct() if err := b.pm.SetPrefs(newPrefs.View(), cn.NetworkProfile()); err != nil { b.logf("failed to save updated and reconciled prefs: %v", err) } @@ -2491,8 +2521,6 @@ func (b *LocalBackend) Start(opts ipn.Options) error { discoPublic := b.MagicConn().DiscoPublicKey() - var err error - isNetstack := b.sys.IsNetstackRouter() debugFlags := controlDebugFlags if isNetstack { diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index c8367d14d..33ecb688c 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -7030,6 +7030,27 @@ func TestDisplayMessageIPNBus(t *testing.T) { } } +func TestHardwareAttested(t *testing.T) { + b := new(LocalBackend) + + // default false + if got := b.HardwareAttested(); got != false { + t.Errorf("HardwareAttested() = %v, want false", got) + } + + // set true + b.SetHardwareAttested() + if got := b.HardwareAttested(); got != true { + t.Errorf("HardwareAttested() = %v, want true after SetHardwareAttested()", got) + } + + // repeat calls are safe; still true + b.SetHardwareAttested() + if got := b.HardwareAttested(); got != true { + t.Errorf("HardwareAttested() = %v, want true after second SetHardwareAttested()", got) + } +} + func TestDeps(t *testing.T) { deptest.DepChecker{ OnImport: func(pkg string) { diff --git a/ipn/ipnlocal/profiles.go b/ipn/ipnlocal/profiles.go index 67e71aa70..9c2176378 100644 --- a/ipn/ipnlocal/profiles.go +++ b/ipn/ipnlocal/profiles.go @@ -19,7 +19,9 @@ import ( "tailscale.com/ipn" "tailscale.com/ipn/ipnext" "tailscale.com/tailcfg" + "tailscale.com/types/key" "tailscale.com/types/logger" + "tailscale.com/types/persist" "tailscale.com/util/clientmetric" "tailscale.com/util/eventbus" ) @@ -645,8 +647,8 @@ func (pm *profileManager) setProfileAsUserDefault(profile ipn.LoginProfileView) return pm.WriteState(k, []byte(profile.Key())) } -func (pm *profileManager) loadSavedPrefs(key ipn.StateKey) (ipn.PrefsView, error) { - bs, err := pm.store.ReadState(key) +func (pm *profileManager) loadSavedPrefs(k ipn.StateKey) (ipn.PrefsView, error) { + bs, err := pm.store.ReadState(k) if err == ipn.ErrStateNotExist || len(bs) == 0 { return defaultPrefs, nil } @@ -654,10 +656,18 @@ func (pm *profileManager) loadSavedPrefs(key ipn.StateKey) (ipn.PrefsView, error return ipn.PrefsView{}, err } savedPrefs := ipn.NewPrefs() + + // if supported by the platform, create an empty hardware attestation key to use when deserializing + // to avoid type exceptions from json.Unmarshaling into an interface{}. + hw, _ := key.NewEmptyHardwareAttestationKey() + savedPrefs.Persist = &persist.Persist{ + AttestationKey: hw, + } + if err := ipn.PrefsFromBytes(bs, savedPrefs); err != nil { return ipn.PrefsView{}, fmt.Errorf("parsing saved prefs: %v", err) } - pm.logf("using backend prefs for %q: %v", key, savedPrefs.Pretty()) + pm.logf("using backend prefs for %q: %v", k, savedPrefs.Pretty()) // Ignore any old stored preferences for https://login.tailscale.com // as the control server that would override the new default of diff --git a/ipn/ipnlocal/profiles_test.go b/ipn/ipnlocal/profiles_test.go index 60c92ff8d..deeab2ade 100644 --- a/ipn/ipnlocal/profiles_test.go +++ b/ipn/ipnlocal/profiles_test.go @@ -151,6 +151,7 @@ func TestProfileDupe(t *testing.T) { ID: tailcfg.UserID(user), LoginName: fmt.Sprintf("user%d@example.com", user), }, + AttestationKey: nil, } } user1Node1 := newPersist(1, 1) diff --git a/ipn/prefs.go b/ipn/prefs.go index 4a0680bba..81dd1c1c3 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -709,6 +709,7 @@ func NewPrefs() *Prefs { // Provide default values for options which might be missing // from the json data for any reason. The json can still // override them to false. + p := &Prefs{ // ControlURL is explicitly not set to signal that // it's not yet configured, which relaxes the CLI "up" diff --git a/ipn/prefs_test.go b/ipn/prefs_test.go index 3339a631c..233616409 100644 --- a/ipn/prefs_test.go +++ b/ipn/prefs_test.go @@ -501,7 +501,7 @@ func TestPrefsPretty(t *testing.T) { }, }, "linux", - `Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist{o=, n=[B1VKl] u=""}}`, + `Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist{o=, n=[B1VKl] u="" ak=-}}`, }, { Prefs{ diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index e9f97bdc4..ea4a9d1fa 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -176,7 +176,8 @@ type CapabilityVersion int // - 127: 2025-09-19: can handle C2N /debug/netmap. // - 128: 2025-10-02: can handle C2N /debug/health. // - 129: 2025-10-04: Fixed sleep/wake deadlock in magicsock when using peer relay (PR #17449) -const CurrentCapabilityVersion CapabilityVersion = 129 +// - 130: 2025-10-06: client can send key.HardwareAttestationPublic and key.HardwareAttestationKeySignature in MapRequest +const CurrentCapabilityVersion CapabilityVersion = 130 // ID is an integer ID for a user, node, or login allocated by the // control plane. @@ -1372,9 +1373,13 @@ type MapRequest struct { // HardwareAttestationKey is the public key of the node's hardware-backed // identity attestation key, if any. HardwareAttestationKey key.HardwareAttestationPublic `json:",omitzero"` - // HardwareAttestationKeySignature is the signature of the NodeKey - // serialized using MarshalText using its hardware attestation key, if any. + // HardwareAttestationKeySignature is the signature of + // "$UNIX_TIMESTAMP|$NODE_KEY" using its hardware attestation key, if any. HardwareAttestationKeySignature []byte `json:",omitempty"` + // HardwareAttestationKeySignatureTimestamp is the time at which the + // HardwareAttestationKeySignature was created, if any. This UNIX timestamp + // value is prepended to the node key when signing. + HardwareAttestationKeySignatureTimestamp time.Time `json:",omitzero"` // Stream is whether the client wants to receive multiple MapResponses over // the same HTTP connection. diff --git a/types/persist/persist.go b/types/persist/persist.go index d888a6afb..4b62c79dd 100644 --- a/types/persist/persist.go +++ b/types/persist/persist.go @@ -26,6 +26,7 @@ type Persist struct { UserProfile tailcfg.UserProfile NetworkLockKey key.NLPrivate NodeID tailcfg.StableNodeID + AttestationKey key.HardwareAttestationKey `json:",omitempty"` // DisallowedTKAStateIDs stores the tka.State.StateID values which // this node will not operate network lock on. This is used to @@ -84,11 +85,20 @@ func (p *Persist) Equals(p2 *Persist) bool { return false } + var pub, p2Pub key.HardwareAttestationPublic + if p.AttestationKey != nil && !p.AttestationKey.IsZero() { + pub = key.HardwareAttestationPublicFromPlatformKey(p.AttestationKey) + } + if p2.AttestationKey != nil && !p2.AttestationKey.IsZero() { + p2Pub = key.HardwareAttestationPublicFromPlatformKey(p2.AttestationKey) + } + return p.PrivateNodeKey.Equal(p2.PrivateNodeKey) && p.OldPrivateNodeKey.Equal(p2.OldPrivateNodeKey) && p.UserProfile.Equal(&p2.UserProfile) && p.NetworkLockKey.Equal(p2.NetworkLockKey) && p.NodeID == p2.NodeID && + pub.Equal(p2Pub) && reflect.DeepEqual(nilIfEmpty(p.DisallowedTKAStateIDs), nilIfEmpty(p2.DisallowedTKAStateIDs)) } @@ -96,12 +106,16 @@ func (p *Persist) Pretty() string { var ( ok, nk key.NodePublic ) + akString := "-" if !p.OldPrivateNodeKey.IsZero() { ok = p.OldPrivateNodeKey.Public() } if !p.PrivateNodeKey.IsZero() { nk = p.PublicNodeKey() } - return fmt.Sprintf("Persist{o=%v, n=%v u=%#v}", - ok.ShortString(), nk.ShortString(), p.UserProfile.LoginName) + if p.AttestationKey != nil && !p.AttestationKey.IsZero() { + akString = fmt.Sprintf("%v", p.AttestationKey.Public()) + } + return fmt.Sprintf("Persist{o=%v, n=%v u=%#v ak=%s}", + ok.ShortString(), nk.ShortString(), p.UserProfile.LoginName, akString) } diff --git a/types/persist/persist_clone.go b/types/persist/persist_clone.go index 680419ff2..9dbe7e0f6 100644 --- a/types/persist/persist_clone.go +++ b/types/persist/persist_clone.go @@ -19,6 +19,9 @@ func (src *Persist) Clone() *Persist { } dst := new(Persist) *dst = *src + if src.AttestationKey != nil { + dst.AttestationKey = src.AttestationKey.Clone() + } dst.DisallowedTKAStateIDs = append(src.DisallowedTKAStateIDs[:0:0], src.DisallowedTKAStateIDs...) return dst } @@ -31,5 +34,6 @@ var _PersistCloneNeedsRegeneration = Persist(struct { UserProfile tailcfg.UserProfile NetworkLockKey key.NLPrivate NodeID tailcfg.StableNodeID + AttestationKey key.HardwareAttestationKey DisallowedTKAStateIDs []string }{}) diff --git a/types/persist/persist_test.go b/types/persist/persist_test.go index dbf2a6d8c..713114b74 100644 --- a/types/persist/persist_test.go +++ b/types/persist/persist_test.go @@ -21,7 +21,7 @@ func fieldsOf(t reflect.Type) (fields []string) { } func TestPersistEqual(t *testing.T) { - persistHandles := []string{"PrivateNodeKey", "OldPrivateNodeKey", "UserProfile", "NetworkLockKey", "NodeID", "DisallowedTKAStateIDs"} + persistHandles := []string{"PrivateNodeKey", "OldPrivateNodeKey", "UserProfile", "NetworkLockKey", "NodeID", "AttestationKey", "DisallowedTKAStateIDs"} if have := fieldsOf(reflect.TypeFor[Persist]()); !reflect.DeepEqual(have, persistHandles) { t.Errorf("Persist.Equal check might be out of sync\nfields: %q\nhandled: %q\n", have, persistHandles) diff --git a/types/persist/persist_view.go b/types/persist/persist_view.go index 7d1507468..dbf8294ef 100644 --- a/types/persist/persist_view.go +++ b/types/persist/persist_view.go @@ -89,10 +89,11 @@ func (v *PersistView) UnmarshalJSONFrom(dec *jsontext.Decoder) error { func (v PersistView) PrivateNodeKey() key.NodePrivate { return v.ж.PrivateNodeKey } // needed to request key rotation -func (v PersistView) OldPrivateNodeKey() key.NodePrivate { return v.ж.OldPrivateNodeKey } -func (v PersistView) UserProfile() tailcfg.UserProfile { return v.ж.UserProfile } -func (v PersistView) NetworkLockKey() key.NLPrivate { return v.ж.NetworkLockKey } -func (v PersistView) NodeID() tailcfg.StableNodeID { return v.ж.NodeID } +func (v PersistView) OldPrivateNodeKey() key.NodePrivate { return v.ж.OldPrivateNodeKey } +func (v PersistView) UserProfile() tailcfg.UserProfile { return v.ж.UserProfile } +func (v PersistView) NetworkLockKey() key.NLPrivate { return v.ж.NetworkLockKey } +func (v PersistView) NodeID() tailcfg.StableNodeID { return v.ж.NodeID } +func (v PersistView) AttestationKey() tailcfg.StableNodeID { panic("unsupported") } // DisallowedTKAStateIDs stores the tka.State.StateID values which // this node will not operate network lock on. This is used to @@ -110,5 +111,6 @@ var _PersistViewNeedsRegeneration = Persist(struct { UserProfile tailcfg.UserProfile NetworkLockKey key.NLPrivate NodeID tailcfg.StableNodeID + AttestationKey key.HardwareAttestationKey DisallowedTKAStateIDs []string }{}) diff --git a/util/syspolicy/pkey/pkey.go b/util/syspolicy/pkey/pkey.go index 79b4af1e6..e450625cd 100644 --- a/util/syspolicy/pkey/pkey.go +++ b/util/syspolicy/pkey/pkey.go @@ -141,6 +141,10 @@ const ( // It's a noop on other platforms. EncryptState Key = "EncryptState" + // HardwareAttestation is a boolean key that controls whether to use a + // hardware-backed key to bind the node identity to this device. + HardwareAttestation Key = "HardwareAttestation" + // PostureChecking indicates if posture checking is enabled and the client shall gather // posture data. // Key is a string value that specifies an option: "always", "never", "user-decides". diff --git a/util/syspolicy/policy_keys.go b/util/syspolicy/policy_keys.go index ae902e8c4..3a54f9dde 100644 --- a/util/syspolicy/policy_keys.go +++ b/util/syspolicy/policy_keys.go @@ -43,6 +43,7 @@ var implicitDefinitions = []*setting.Definition{ setting.NewDefinition(pkey.PostureChecking, setting.DeviceSetting, setting.PreferenceOptionValue), setting.NewDefinition(pkey.ReconnectAfter, setting.DeviceSetting, setting.DurationValue), setting.NewDefinition(pkey.Tailnet, setting.DeviceSetting, setting.StringValue), + setting.NewDefinition(pkey.HardwareAttestation, setting.DeviceSetting, setting.BooleanValue), // User policy settings (can be configured on a user- or device-basis): setting.NewDefinition(pkey.AdminConsoleVisibility, setting.UserSetting, setting.VisibilityValue),