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 <patrick@tailscale.com>
pull/17527/head
Patrick O'Doherty 2 months ago committed by GitHub
parent a2dc517d7d
commit e45557afc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -121,7 +121,12 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
continue continue
} }
if !hasBasicUnderlying(ft) { if !hasBasicUnderlying(ft) {
// 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) writef("dst.%s = *src.%s.Clone()", fname, fname)
}
continue continue
} }
} }

@ -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")
}
}
}
}
})
}
}

@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // 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 is an example package for the cloner tool.
package clonerex package clonerex
@ -9,3 +9,26 @@ package clonerex
type SliceContainer struct { type SliceContainer struct {
Slice []*int 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
}

@ -35,9 +35,28 @@ var _SliceContainerCloneNeedsRegeneration = SliceContainer(struct {
Slice []*int 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. // Clone duplicates src into dst and reports whether it succeeded.
// To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>, // To succeed, <src, dst> 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 { func Clone(dst, src any) bool {
switch src := src.(type) { switch src := src.(type) {
case *SliceContainer: case *SliceContainer:
@ -49,6 +68,15 @@ func Clone(dst, src any) bool {
*dst = src.Clone() *dst = src.Clone()
return true return true
} }
case *InterfaceContainer:
switch dst := dst.(type) {
case *InterfaceContainer:
*dst = *src.Clone()
return true
case **InterfaceContainer:
*dst = src.Clone()
return true
}
} }
return false return false
} }

@ -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/logger from tailscale.com/cmd/derper+
tailscale.com/types/netmap from tailscale.com/ipn tailscale.com/types/netmap from tailscale.com/ipn
tailscale.com/types/opt from tailscale.com/envknob+ 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/preftype from tailscale.com/ipn
tailscale.com/types/ptr from tailscale.com/hostinfo+ tailscale.com/types/ptr from tailscale.com/hostinfo+
tailscale.com/types/result from tailscale.com/util/lineiter tailscale.com/types/result from tailscale.com/util/lineiter

@ -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/stunserver from tailscale.com/cmd/stund
tailscale.com/net/tsaddr from tailscale.com/tsweb tailscale.com/net/tsaddr from tailscale.com/tsweb
tailscale.com/syncs from tailscale.com/metrics+ 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 from tailscale.com/cmd/stund+
tailscale.com/tsweb/promvarz from tailscale.com/cmd/stund tailscale.com/tsweb/promvarz from tailscale.com/cmd/stund
tailscale.com/tsweb/varz from tailscale.com/tsweb+ tailscale.com/tsweb/varz from tailscale.com/tsweb+
tailscale.com/types/dnstype from tailscale.com/tailcfg tailscale.com/types/dnstype from tailscale.com/tailcfg
tailscale.com/types/ipproto 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/lazy from tailscale.com/version+
tailscale.com/types/logger from tailscale.com/tsweb+ tailscale.com/types/logger from tailscale.com/tsweb+
tailscale.com/types/opt from tailscale.com/envknob+ 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/ptr from tailscale.com/tailcfg+
tailscale.com/types/result from tailscale.com/util/lineiter tailscale.com/types/result from tailscale.com/util/lineiter
tailscale.com/types/structs from tailscale.com/tailcfg+ tailscale.com/types/structs from tailscale.com/tailcfg+

@ -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/netmap from tailscale.com/ipn+
tailscale.com/types/nettype from tailscale.com/net/netcheck+ tailscale.com/types/nettype from tailscale.com/net/netcheck+
tailscale.com/types/opt from tailscale.com/client/tailscale+ 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/preftype from tailscale.com/cmd/tailscale/cli+
tailscale.com/types/ptr from tailscale.com/hostinfo+ tailscale.com/types/ptr from tailscale.com/hostinfo+
tailscale.com/types/result from tailscale.com/util/lineiter tailscale.com/types/result from tailscale.com/util/lineiter

@ -52,6 +52,7 @@ import (
"tailscale.com/syncs" "tailscale.com/syncs"
"tailscale.com/tsd" "tailscale.com/tsd"
"tailscale.com/types/flagtype" "tailscale.com/types/flagtype"
"tailscale.com/types/key"
"tailscale.com/types/logger" "tailscale.com/types/logger"
"tailscale.com/types/logid" "tailscale.com/types/logid"
"tailscale.com/util/osshare" "tailscale.com/util/osshare"
@ -124,6 +125,7 @@ var args struct {
socksAddr string // listen address for SOCKS5 server socksAddr string // listen address for SOCKS5 server
httpProxyAddr string // listen address for HTTP proxy server httpProxyAddr string // listen address for HTTP proxy server
disableLogs bool disableLogs bool
hardwareAttestation boolFlag
} }
var ( var (
@ -204,6 +206,9 @@ func main() {
flag.BoolVar(&printVersion, "version", false, "print version information and exit") 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.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)") 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 { if f, ok := hookRegisterOutboundProxyFlags.GetOk(); ok {
f() f()
} }
@ -667,6 +672,9 @@ func getLocalBackend(ctx context.Context, logf logger.Logf, logID logid.PublicID
log.Fatalf("failed to start netstack: %v", err) log.Fatalf("failed to start netstack: %v", err)
} }
} }
if buildfeatures.HasTPM && args.hardwareAttestation.v {
lb.SetHardwareAttested()
}
return lb, nil return lb, nil
} }
@ -879,9 +887,26 @@ func applyIntegrationTestEnvKnob() {
} }
} }
// handleTPMFlags validates the --encrypt-state flag if set, and defaults // handleTPMFlags validates the --encrypt-state and --hardware-attestation flags
// state encryption on if it's supported and compatible with other settings. // if set, and defaults both to on if supported and compatible with other
// settings.
func handleTPMFlags() { 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 { switch {
case args.encryptState.v: case args.encryptState.v:
// Explicitly enabled, validate. // Explicitly enabled, validate.

@ -7,6 +7,8 @@ import (
"bytes" "bytes"
"cmp" "cmp"
"context" "context"
"crypto"
"crypto/sha256"
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"errors" "errors"
@ -604,6 +606,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
if persist.NetworkLockKey.IsZero() { if persist.NetworkLockKey.IsZero() {
persist.NetworkLockKey = key.NewNLPrivate() persist.NetworkLockKey = key.NewNLPrivate()
} }
nlPub := persist.NetworkLockKey.Public() nlPub := persist.NetworkLockKey.Public()
if tryingNewKey.IsZero() { if tryingNewKey.IsZero() {
@ -944,6 +947,27 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
TKAHead: tkaHead, TKAHead: tkaHead,
ConnectionHandleForTest: connectionHandleForTest, 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 var extraDebugFlags []string
if buildfeatures.HasAdvertiseRoutes && hi != nil && c.netMon != nil && !c.skipIPForwardingCheck && if buildfeatures.HasAdvertiseRoutes && hi != nil && c.netMon != nil && !c.skipIPForwardingCheck &&
ipForwardingBroken(hi.RoutableIPs, c.netMon.InterfaceState()) { ipForwardingBroken(hi.RoutableIPs, c.netMon.InterfaceState()) {

@ -6,6 +6,9 @@ package feature
import ( import (
"net/http" "net/http"
"net/url" "net/url"
"tailscale.com/types/logger"
"tailscale.com/types/persist"
) )
// HookCanAutoUpdate is a hook for the clientupdate package // HookCanAutoUpdate is a hook for the clientupdate package
@ -45,6 +48,8 @@ var HookProxySetTransportGetProxyConnectHeader Hook[func(*http.Transport)]
// and available. // and available.
var HookTPMAvailable Hook[func() bool] 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. // TPMAvailable reports whether a TPM device is supported and available.
func TPMAvailable() bool { func TPMAvailable() bool {
if f, ok := HookTPMAvailable.GetOk(); ok { if f, ok := HookTPMAvailable.GetOk(); ok {
@ -52,3 +57,17 @@ func TPMAvailable() bool {
} }
return false 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
}

@ -142,13 +142,18 @@ type attestationKeySerialized struct {
TPMPublic []byte `json:"tpmPublic"` TPMPublic []byte `json:"tpmPublic"`
} }
// MarshalJSON implements json.Marshaler.
func (ak *attestationKey) MarshalJSON() ([]byte, error) { func (ak *attestationKey) MarshalJSON() ([]byte, error) {
if ak == nil || ak.IsZero() {
return []byte("null"), nil
}
return json.Marshal(attestationKeySerialized{ return json.Marshal(attestationKeySerialized{
TPMPublic: ak.tpmPublic.Bytes(), TPMPublic: ak.tpmPublic.Bytes(),
TPMPrivate: ak.tpmPrivate.Buffer, TPMPrivate: ak.tpmPrivate.Buffer,
}) })
} }
// UnmarshalJSON implements json.Unmarshaler.
func (ak *attestationKey) UnmarshalJSON(data []byte) (retErr error) { func (ak *attestationKey) UnmarshalJSON(data []byte) (retErr error) {
var aks attestationKeySerialized var aks attestationKeySerialized
if err := json.Unmarshal(data, &aks); err != nil { if err := json.Unmarshal(data, &aks); err != nil {
@ -254,6 +259,9 @@ func (ak *attestationKey) Close() error {
} }
func (ak *attestationKey) Clone() key.HardwareAttestationKey { func (ak *attestationKey) Clone() key.HardwareAttestationKey {
if ak == nil {
return nil
}
return &attestationKey{ return &attestationKey{
tpm: ak.tpm, tpm: ak.tpm,
tpmPrivate: ak.tpmPrivate, 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()
}

@ -40,6 +40,8 @@ var infoOnce = sync.OnceValue(info)
func init() { func init() {
feature.Register("tpm") feature.Register("tpm")
feature.HookTPMAvailable.Set(tpmSupported) feature.HookTPMAvailable.Set(tpmSupported)
feature.HookHardwareAttestationAvailable.Set(tpmSupported)
hostinfo.RegisterHostinfoNewHook(func(hi *tailcfg.Hostinfo) { hostinfo.RegisterHostinfoNewHook(func(hi *tailcfg.Hostinfo) {
hi.TPM = infoOnce() hi.TPM = infoOnce()
}) })

@ -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
}

@ -392,6 +392,23 @@ type LocalBackend struct {
// //
// See tailscale/corp#29969. // See tailscale/corp#29969.
overrideExitNodePolicy bool 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. // HealthTracker returns the health tracker for the backend.
@ -2455,10 +2472,23 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
if b.reconcilePrefsLocked(newPrefs) { if b.reconcilePrefsLocked(newPrefs) {
prefsChanged = true prefsChanged = true
} }
if prefsChanged {
// Neither opts.UpdatePrefs nor prefs reconciliation // neither UpdatePrefs or reconciliation should change Persist
// is allowed to modify Persist; retain the old value.
newPrefs.Persist = b.pm.CurrentPrefs().Persist().AsStruct() 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 {
if err := b.pm.SetPrefs(newPrefs.View(), cn.NetworkProfile()); err != nil { if err := b.pm.SetPrefs(newPrefs.View(), cn.NetworkProfile()); err != nil {
b.logf("failed to save updated and reconciled prefs: %v", err) 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() discoPublic := b.MagicConn().DiscoPublicKey()
var err error
isNetstack := b.sys.IsNetstackRouter() isNetstack := b.sys.IsNetstackRouter()
debugFlags := controlDebugFlags debugFlags := controlDebugFlags
if isNetstack { if isNetstack {

@ -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) { func TestDeps(t *testing.T) {
deptest.DepChecker{ deptest.DepChecker{
OnImport: func(pkg string) { OnImport: func(pkg string) {

@ -19,7 +19,9 @@ import (
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/ipnext" "tailscale.com/ipn/ipnext"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/logger" "tailscale.com/types/logger"
"tailscale.com/types/persist"
"tailscale.com/util/clientmetric" "tailscale.com/util/clientmetric"
"tailscale.com/util/eventbus" "tailscale.com/util/eventbus"
) )
@ -645,8 +647,8 @@ func (pm *profileManager) setProfileAsUserDefault(profile ipn.LoginProfileView)
return pm.WriteState(k, []byte(profile.Key())) return pm.WriteState(k, []byte(profile.Key()))
} }
func (pm *profileManager) loadSavedPrefs(key ipn.StateKey) (ipn.PrefsView, error) { func (pm *profileManager) loadSavedPrefs(k ipn.StateKey) (ipn.PrefsView, error) {
bs, err := pm.store.ReadState(key) bs, err := pm.store.ReadState(k)
if err == ipn.ErrStateNotExist || len(bs) == 0 { if err == ipn.ErrStateNotExist || len(bs) == 0 {
return defaultPrefs, nil return defaultPrefs, nil
} }
@ -654,10 +656,18 @@ func (pm *profileManager) loadSavedPrefs(key ipn.StateKey) (ipn.PrefsView, error
return ipn.PrefsView{}, err return ipn.PrefsView{}, err
} }
savedPrefs := ipn.NewPrefs() 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 { if err := ipn.PrefsFromBytes(bs, savedPrefs); err != nil {
return ipn.PrefsView{}, fmt.Errorf("parsing saved prefs: %v", err) 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 // Ignore any old stored preferences for https://login.tailscale.com
// as the control server that would override the new default of // as the control server that would override the new default of

@ -151,6 +151,7 @@ func TestProfileDupe(t *testing.T) {
ID: tailcfg.UserID(user), ID: tailcfg.UserID(user),
LoginName: fmt.Sprintf("user%d@example.com", user), LoginName: fmt.Sprintf("user%d@example.com", user),
}, },
AttestationKey: nil,
} }
} }
user1Node1 := newPersist(1, 1) user1Node1 := newPersist(1, 1)

@ -709,6 +709,7 @@ func NewPrefs() *Prefs {
// Provide default values for options which might be missing // Provide default values for options which might be missing
// from the json data for any reason. The json can still // from the json data for any reason. The json can still
// override them to false. // override them to false.
p := &Prefs{ p := &Prefs{
// ControlURL is explicitly not set to signal that // ControlURL is explicitly not set to signal that
// it's not yet configured, which relaxes the CLI "up" // it's not yet configured, which relaxes the CLI "up"

@ -501,7 +501,7 @@ func TestPrefsPretty(t *testing.T) {
}, },
}, },
"linux", "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{ Prefs{

@ -176,7 +176,8 @@ type CapabilityVersion int
// - 127: 2025-09-19: can handle C2N /debug/netmap. // - 127: 2025-09-19: can handle C2N /debug/netmap.
// - 128: 2025-10-02: can handle C2N /debug/health. // - 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) // - 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 // ID is an integer ID for a user, node, or login allocated by the
// control plane. // control plane.
@ -1372,9 +1373,13 @@ type MapRequest struct {
// HardwareAttestationKey is the public key of the node's hardware-backed // HardwareAttestationKey is the public key of the node's hardware-backed
// identity attestation key, if any. // identity attestation key, if any.
HardwareAttestationKey key.HardwareAttestationPublic `json:",omitzero"` HardwareAttestationKey key.HardwareAttestationPublic `json:",omitzero"`
// HardwareAttestationKeySignature is the signature of the NodeKey // HardwareAttestationKeySignature is the signature of
// serialized using MarshalText using its hardware attestation key, if any. // "$UNIX_TIMESTAMP|$NODE_KEY" using its hardware attestation key, if any.
HardwareAttestationKeySignature []byte `json:",omitempty"` 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 // Stream is whether the client wants to receive multiple MapResponses over
// the same HTTP connection. // the same HTTP connection.

@ -26,6 +26,7 @@ type Persist struct {
UserProfile tailcfg.UserProfile UserProfile tailcfg.UserProfile
NetworkLockKey key.NLPrivate NetworkLockKey key.NLPrivate
NodeID tailcfg.StableNodeID NodeID tailcfg.StableNodeID
AttestationKey key.HardwareAttestationKey `json:",omitempty"`
// DisallowedTKAStateIDs stores the tka.State.StateID values which // DisallowedTKAStateIDs stores the tka.State.StateID values which
// this node will not operate network lock on. This is used to // 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 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) && return p.PrivateNodeKey.Equal(p2.PrivateNodeKey) &&
p.OldPrivateNodeKey.Equal(p2.OldPrivateNodeKey) && p.OldPrivateNodeKey.Equal(p2.OldPrivateNodeKey) &&
p.UserProfile.Equal(&p2.UserProfile) && p.UserProfile.Equal(&p2.UserProfile) &&
p.NetworkLockKey.Equal(p2.NetworkLockKey) && p.NetworkLockKey.Equal(p2.NetworkLockKey) &&
p.NodeID == p2.NodeID && p.NodeID == p2.NodeID &&
pub.Equal(p2Pub) &&
reflect.DeepEqual(nilIfEmpty(p.DisallowedTKAStateIDs), nilIfEmpty(p2.DisallowedTKAStateIDs)) reflect.DeepEqual(nilIfEmpty(p.DisallowedTKAStateIDs), nilIfEmpty(p2.DisallowedTKAStateIDs))
} }
@ -96,12 +106,16 @@ func (p *Persist) Pretty() string {
var ( var (
ok, nk key.NodePublic ok, nk key.NodePublic
) )
akString := "-"
if !p.OldPrivateNodeKey.IsZero() { if !p.OldPrivateNodeKey.IsZero() {
ok = p.OldPrivateNodeKey.Public() ok = p.OldPrivateNodeKey.Public()
} }
if !p.PrivateNodeKey.IsZero() { if !p.PrivateNodeKey.IsZero() {
nk = p.PublicNodeKey() nk = p.PublicNodeKey()
} }
return fmt.Sprintf("Persist{o=%v, n=%v u=%#v}", if p.AttestationKey != nil && !p.AttestationKey.IsZero() {
ok.ShortString(), nk.ShortString(), p.UserProfile.LoginName) 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)
} }

@ -19,6 +19,9 @@ func (src *Persist) Clone() *Persist {
} }
dst := new(Persist) dst := new(Persist)
*dst = *src *dst = *src
if src.AttestationKey != nil {
dst.AttestationKey = src.AttestationKey.Clone()
}
dst.DisallowedTKAStateIDs = append(src.DisallowedTKAStateIDs[:0:0], src.DisallowedTKAStateIDs...) dst.DisallowedTKAStateIDs = append(src.DisallowedTKAStateIDs[:0:0], src.DisallowedTKAStateIDs...)
return dst return dst
} }
@ -31,5 +34,6 @@ var _PersistCloneNeedsRegeneration = Persist(struct {
UserProfile tailcfg.UserProfile UserProfile tailcfg.UserProfile
NetworkLockKey key.NLPrivate NetworkLockKey key.NLPrivate
NodeID tailcfg.StableNodeID NodeID tailcfg.StableNodeID
AttestationKey key.HardwareAttestationKey
DisallowedTKAStateIDs []string DisallowedTKAStateIDs []string
}{}) }{})

@ -21,7 +21,7 @@ func fieldsOf(t reflect.Type) (fields []string) {
} }
func TestPersistEqual(t *testing.T) { 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) { 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", t.Errorf("Persist.Equal check might be out of sync\nfields: %q\nhandled: %q\n",
have, persistHandles) have, persistHandles)

@ -93,6 +93,7 @@ func (v PersistView) OldPrivateNodeKey() key.NodePrivate { return v.ж.OldPrivat
func (v PersistView) UserProfile() tailcfg.UserProfile { return v.ж.UserProfile } func (v PersistView) UserProfile() tailcfg.UserProfile { return v.ж.UserProfile }
func (v PersistView) NetworkLockKey() key.NLPrivate { return v.ж.NetworkLockKey } func (v PersistView) NetworkLockKey() key.NLPrivate { return v.ж.NetworkLockKey }
func (v PersistView) NodeID() tailcfg.StableNodeID { return v.ж.NodeID } 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 // DisallowedTKAStateIDs stores the tka.State.StateID values which
// this node will not operate network lock on. This is used to // this node will not operate network lock on. This is used to
@ -110,5 +111,6 @@ var _PersistViewNeedsRegeneration = Persist(struct {
UserProfile tailcfg.UserProfile UserProfile tailcfg.UserProfile
NetworkLockKey key.NLPrivate NetworkLockKey key.NLPrivate
NodeID tailcfg.StableNodeID NodeID tailcfg.StableNodeID
AttestationKey key.HardwareAttestationKey
DisallowedTKAStateIDs []string DisallowedTKAStateIDs []string
}{}) }{})

@ -141,6 +141,10 @@ const (
// It's a noop on other platforms. // It's a noop on other platforms.
EncryptState Key = "EncryptState" 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 // PostureChecking indicates if posture checking is enabled and the client shall gather
// posture data. // posture data.
// Key is a string value that specifies an option: "always", "never", "user-decides". // Key is a string value that specifies an option: "always", "never", "user-decides".

@ -43,6 +43,7 @@ var implicitDefinitions = []*setting.Definition{
setting.NewDefinition(pkey.PostureChecking, setting.DeviceSetting, setting.PreferenceOptionValue), setting.NewDefinition(pkey.PostureChecking, setting.DeviceSetting, setting.PreferenceOptionValue),
setting.NewDefinition(pkey.ReconnectAfter, setting.DeviceSetting, setting.DurationValue), setting.NewDefinition(pkey.ReconnectAfter, setting.DeviceSetting, setting.DurationValue),
setting.NewDefinition(pkey.Tailnet, setting.DeviceSetting, setting.StringValue), 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): // User policy settings (can be configured on a user- or device-basis):
setting.NewDefinition(pkey.AdminConsoleVisibility, setting.UserSetting, setting.VisibilityValue), setting.NewDefinition(pkey.AdminConsoleVisibility, setting.UserSetting, setting.VisibilityValue),

Loading…
Cancel
Save