summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorPatrick O'Doherty <patrick@tailscale.com>2025-08-29 13:07:39 -0700
committerPatrick O'Doherty <patrick@tailscale.com>2025-09-10 14:49:16 -0700
commit1df3b314612123255f4376a11aaf5c1b293b19d8 (patch)
treea9bbdc0966596abbfaf992be2ccf6eb09b42b2a8
parent6feb6f3c753aca44d284a3b1a103692e96c62aee (diff)
downloadtailscale-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.go4
-rw-r--r--control/controlclient/direct.go30
-rw-r--r--tailcfg/tailcfg.go10
-rw-r--r--types/key/hardware_attestation.go77
-rw-r--r--types/persist/persist.go1
-rw-r--r--types/persist/persist_clone.go4
-rw-r--r--types/persist/persist_view.go10
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
}{})