diff options
| author | Patrick O'Doherty <patrick@tailscale.com> | 2025-08-29 13:07:39 -0700 |
|---|---|---|
| committer | Patrick O'Doherty <patrick@tailscale.com> | 2025-09-10 14:49:16 -0700 |
| commit | 1df3b314612123255f4376a11aaf5c1b293b19d8 (patch) | |
| tree | a9bbdc0966596abbfaf992be2ccf6eb09b42b2a8 | |
| parent | 6feb6f3c753aca44d284a3b1a103692e96c62aee (diff) | |
| download | tailscale-patrickod/hardware-attestation-key.tar.xz tailscale-patrickod/hardware-attestation-key.zip | |
control/controlclient: add HardwareAttestationKey to MapRequestpatrickod/hardware-attestation-key
Extend the client state management to generate a hardware attestation
key if none exists.
Extend MapRequest with HardwareAttestationKey{,Signature} fields that
optionally contain the public component of the hardware attestation key
and a signature of the node's node key using it. This will be used by
control to associate hardware attesation keys with node identities on a
TOFU basis.
Updates #31269
Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
| -rw-r--r-- | cmd/cloner/cloner.go | 4 | ||||
| -rw-r--r-- | control/controlclient/direct.go | 30 | ||||
| -rw-r--r-- | tailcfg/tailcfg.go | 10 | ||||
| -rw-r--r-- | types/key/hardware_attestation.go | 77 | ||||
| -rw-r--r-- | types/persist/persist.go | 1 | ||||
| -rw-r--r-- | types/persist/persist_clone.go | 4 | ||||
| -rw-r--r-- | types/persist/persist_view.go | 10 |
7 files changed, 127 insertions, 9 deletions
diff --git a/cmd/cloner/cloner.go b/cmd/cloner/cloner.go index 15a808141..bb1fff325 100644 --- a/cmd/cloner/cloner.go +++ b/cmd/cloner/cloner.go @@ -231,7 +231,7 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) { // If ft is an interface with a "Clone() ft" method, it can be used to clone the field. // This includes scenarios where ft is a constrained type parameter. if cloneResultType := methodResultType(ft, "Clone"); cloneResultType.Underlying() == ft { - writef("dst.%s = src.%s.Clone()", fname, fname) + writef("if src.%s != nil { dst.%s = src.%s.Clone() }", fname, fname, fname) continue } writef(`panic("%s (%v) does not have a compatible Clone method")`, fname, ft) @@ -248,7 +248,7 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) { // hasBasicUnderlying reports true when typ.Underlying() is a slice or a map. func hasBasicUnderlying(typ types.Type) bool { switch typ.Underlying().(type) { - case *types.Slice, *types.Map: + case *types.Slice, *types.Map, *types.Pointer, *types.Interface: return true default: return false diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index 47283a673..db513a4a7 100644 --- a/control/controlclient/direct.go +++ b/control/controlclient/direct.go @@ -592,6 +592,17 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new if persist.NetworkLockKey.IsZero() { persist.NetworkLockKey = key.NewNLPrivate() } + + // attempt to generate a new hardware attestion key if none exists + if persist.AttestationKey == nil { + if ak, err := key.NewHardwareAttestationKey(); err != nil { + c.logf("failed to create hardware attestation key: %v", err) + } else if ak != nil { + persist.AttestationKey = ak + c.logf("using new hardware attestation key: %v", ak.Public()) + } + } + nlPub := persist.NetworkLockKey.Public() if tryingNewKey.IsZero() { @@ -915,6 +926,25 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap TKAHead: tkaHead, ConnectionHandleForTest: connectionHandleForTest, } + + // If we have a hardware attestation key, sing the node key with it and send + // the key & sig in the map request. + if persist.AsStruct().AttestationKey != nil { + k := persist.AsStruct().AttestationKey + hwPub := key.HardwareAttestationPublicFromPlatformKey(k) + request.HardwareAttestationKey = hwPub + + // nb: there is no need to compute a SHA256 digest of the nodeKey as + // one will be performed within the client hardware key implementation. + nkBytes, _ := json.Marshal(nodeKey) + sig, err := k.Sign(nil, nkBytes, nil) + if err != nil { + c.logf("failed to sign node key with hardware attestation key: %v", err) + } else { + request.HardwareAttestationKeySignature = sig + } + } + var extraDebugFlags []string if hi != nil && c.netMon != nil && !c.skipIPForwardingCheck && ipForwardingBroken(hi.RoutableIPs, c.netMon.InterfaceState()) { diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 94d0b19d5..b6382cf95 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -170,7 +170,8 @@ type CapabilityVersion int // - 123: 2025-07-28: fix deadlock regression from cryptokey routing change (issue #16651) // - 124: 2025-08-08: removed NodeAttrDisableMagicSockCryptoRouting support, crypto routing is now mandatory // - 125: 2025-08-11: dnstype.Resolver adds UseWithExitNode field. -const CurrentCapabilityVersion CapabilityVersion = 125 +// - 126: 2025-08-22: Client supports key.HardwareAttestationKey in Persist and sends a copy in RegisterRequest. +const CurrentCapabilityVersion CapabilityVersion = 126 // ID is an integer ID for a user, node, or login allocated by the // control plane. @@ -1360,6 +1361,13 @@ type MapRequest struct { NodeKey key.NodePublic DiscoKey key.DiscoPublic + // HardwareAttestationKey is the public key of the node's hardware-backed + // identity attestation key, if any. + HardwareAttestationKey key.HardwareAttestationPublic `json:",omitempty"` + // HardwareAttestationKeySignature is the signature of the node's node-key + // using its hardware attestation key, if any. + HardwareAttestationKeySignature []byte `json:",omitempty"` + // Stream is whether the client wants to receive multiple MapResponses over // the same HTTP connection. // diff --git a/types/key/hardware_attestation.go b/types/key/hardware_attestation.go index be2eefb78..28ba90fae 100644 --- a/types/key/hardware_attestation.go +++ b/types/key/hardware_attestation.go @@ -5,25 +5,98 @@ package key import ( "crypto" + "crypto/ecdsa" + "crypto/elliptic" "encoding/json" "fmt" + "io" ) var ErrUnsupported = fmt.Errorf("key type not supported on this platform") +const hardwareAttestPublicHexPrefix = "hwattestpub:" + // HardwareAttestationKey describes a hardware-backed key that is used to // identify a node. Implementation details will // vary based on the platform in use (SecureEnclave for Apple, TPM for // Windows/Linux, Android Hardware-backed Keystore). // This key can only be marshalled and unmarshalled on the same machine. +// +// NB: Due to fixed cryptographic primitives used in client platform +// implementations the "Sign" method should be passed a complete message and +// `nil` crypto.SignerOpts as all clients will compute a SHA256 digest of the +// message internally. type HardwareAttestationKey interface { crypto.Signer json.Marshaler json.Unmarshaler + io.Closer + Clone() HardwareAttestationKey +} + +func HardwareAttestationPublicFromPlatformKey(k HardwareAttestationKey) HardwareAttestationPublic { + if k == nil { + return HardwareAttestationPublic{} + } + pub := k.Public() + ecdsaPub, ok := pub.(*ecdsa.PublicKey) + if !ok { + panic("hardware attestation key is not ECDSA") + } + bytes, err := ecdsaPub.Bytes() + if err != nil { + panic(err) + } + var kb [64]byte + copy(kb[:], bytes) + return HardwareAttestationPublic{k: kb} +} + +// HardwareAttestationPublic is the public key counterpart to +// HardwareAttestationKey. +type HardwareAttestationPublic struct { + k [64]byte +} + +func (k HardwareAttestationPublic) Equal(o HardwareAttestationPublic) bool { + return k.k == o.k +} + +// IsZero reports whether k is the zero value. +func (k HardwareAttestationPublic) IsZero() bool { + return k.k == [64]byte{} +} + +// String returns the hex-encoded public key with a type prefix. +func (k HardwareAttestationPublic) String() string { + bs, err := k.MarshalText() + if err != nil { + panic(err) + } + return string(bs) +} + +// MarshalText implements encoding.TextMarshaler. +func (k HardwareAttestationPublic) MarshalText() ([]byte, error) { + return k.AppendText(nil) +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (k HardwareAttestationPublic) AppendText(dst []byte) ([]byte, error) { + return appendHexKey(dst, hardwareAttestPublicHexPrefix, k.k[:]), nil +} + +// Verifier returns the ECDSA public key for verifying signatures made by k. +func (k HardwareAttestationPublic) Verifier() *ecdsa.PublicKey { + pub, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), k.k[:]) + if err != nil { + panic(err) + } + return pub } // emptyHardwareAttestationKey is a function that returns an empty -// HardwareAttestationKey suitable for use with JSON unmarshalling. +// HardwareAttestationKey suitable for use with JSON unmarshaling. var emptyHardwareAttestationKey func() HardwareAttestationKey // createHardwareAttestationKey is a function that creates a new @@ -50,7 +123,7 @@ func RegisterHardwareAttestationKeyFns(emptyFn func() HardwareAttestationKey, cr } // NewEmptyHardwareAttestationKey returns an empty HardwareAttestationKey -// suitable for JSON unmarshalling. +// suitable for JSON unmarshaling. func NewEmptyHardwareAttestationKey() (HardwareAttestationKey, error) { if emptyHardwareAttestationKey == nil { return nil, ErrUnsupported diff --git a/types/persist/persist.go b/types/persist/persist.go index d888a6afb..3b96dcf98 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 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_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 }{}) |
