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) {
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 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"
@ -111,19 +112,20 @@ var args struct {
// or comma-separated list thereof. // or comma-separated list thereof.
tunname string tunname string
cleanUp bool cleanUp bool
confFile string // empty, file path, or "vm:user-data" confFile string // empty, file path, or "vm:user-data"
debug string debug string
port uint16 port uint16
statepath string statepath string
encryptState boolFlag encryptState boolFlag
statedir string statedir string
socketpath string socketpath string
birdSocketPath string birdSocketPath string
verbose int verbose int
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
} }
// 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 { 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 { 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)

@ -89,10 +89,11 @@ func (v *PersistView) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
func (v PersistView) PrivateNodeKey() key.NodePrivate { return v.ж.PrivateNodeKey } func (v PersistView) PrivateNodeKey() key.NodePrivate { return v.ж.PrivateNodeKey }
// needed to request key rotation // needed to request key rotation
func (v PersistView) OldPrivateNodeKey() key.NodePrivate { return v.ж.OldPrivateNodeKey } func (v PersistView) OldPrivateNodeKey() key.NodePrivate { return v.ж.OldPrivateNodeKey }
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