feature/tpm: implement key.HardwareAttestationKey (#17256)

Updates #15830

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
This commit is contained in:
Andrew Lytvynov 2025-09-25 11:54:41 -07:00 committed by GitHub
parent a40f23ad4a
commit c49ed5dd5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 367 additions and 0 deletions

264
feature/tpm/attestation.go Normal file
View File

@ -0,0 +1,264 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tpm
import (
"crypto"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"github.com/google/go-tpm/tpm2"
"github.com/google/go-tpm/tpm2/transport"
"golang.org/x/crypto/cryptobyte"
"golang.org/x/crypto/cryptobyte/asn1"
"tailscale.com/types/key"
)
type attestationKey struct {
tpm transport.TPMCloser
// private and public parts of the TPM key as returned from tpm2.Create.
// These are used for serialization.
tpmPrivate tpm2.TPM2BPrivate
tpmPublic tpm2.TPM2BPublic
// handle of the loaded TPM key.
handle *tpm2.NamedHandle
// pub is the parsed *ecdsa.PublicKey.
pub crypto.PublicKey
}
func newAttestationKey() (ak *attestationKey, retErr error) {
tpm, err := open()
if err != nil {
return nil, key.ErrUnsupported
}
defer func() {
if retErr != nil {
tpm.Close()
}
}()
ak = &attestationKey{tpm: tpm}
// Create a key under the storage hierarchy.
if err := withSRK(log.Printf, ak.tpm, func(srk tpm2.AuthHandle) error {
resp, err := tpm2.Create{
ParentHandle: tpm2.NamedHandle{
Handle: srk.Handle,
Name: srk.Name,
},
InPublic: tpm2.New2B(
tpm2.TPMTPublic{
Type: tpm2.TPMAlgECC,
NameAlg: tpm2.TPMAlgSHA256,
ObjectAttributes: tpm2.TPMAObject{
SensitiveDataOrigin: true,
UserWithAuth: true,
AdminWithPolicy: true,
NoDA: true,
FixedTPM: true,
FixedParent: true,
SignEncrypt: true,
},
Parameters: tpm2.NewTPMUPublicParms(
tpm2.TPMAlgECC,
&tpm2.TPMSECCParms{
CurveID: tpm2.TPMECCNistP256,
Scheme: tpm2.TPMTECCScheme{
Scheme: tpm2.TPMAlgECDSA,
Details: tpm2.NewTPMUAsymScheme(
tpm2.TPMAlgECDSA,
&tpm2.TPMSSigSchemeECDSA{
// Unfortunately, TPMs don't let us use
// TPMAlgNull here to make the hash
// algorithm dynamic higher in the
// stack. We have to hardcode it here.
HashAlg: tpm2.TPMAlgSHA256,
},
),
},
},
),
},
),
}.Execute(ak.tpm)
if err != nil {
return fmt.Errorf("tpm2.Create: %w", err)
}
ak.tpmPrivate = resp.OutPrivate
ak.tpmPublic = resp.OutPublic
return nil
}); err != nil {
return nil, err
}
return ak, ak.load()
}
func (ak *attestationKey) loaded() bool {
return ak.tpm != nil && ak.handle != nil && ak.pub != nil
}
// load the key into the TPM from its public/private components. Must be called
// before Sign or Public.
func (ak *attestationKey) load() error {
if ak.loaded() {
return nil
}
if len(ak.tpmPrivate.Buffer) == 0 || len(ak.tpmPublic.Bytes()) == 0 {
return fmt.Errorf("attestationKey.load called without tpmPrivate or tpmPublic")
}
return withSRK(log.Printf, ak.tpm, func(srk tpm2.AuthHandle) error {
resp, err := tpm2.Load{
ParentHandle: tpm2.NamedHandle{
Handle: srk.Handle,
Name: srk.Name,
},
InPrivate: ak.tpmPrivate,
InPublic: ak.tpmPublic,
}.Execute(ak.tpm)
if err != nil {
return fmt.Errorf("tpm2.Load: %w", err)
}
ak.handle = &tpm2.NamedHandle{
Handle: resp.ObjectHandle,
Name: resp.Name,
}
pub, err := ak.tpmPublic.Contents()
if err != nil {
return err
}
ak.pub, err = tpm2.Pub(*pub)
return err
})
}
// attestationKeySerialized is the JSON-serialized representation of
// attestationKey.
type attestationKeySerialized struct {
TPMPrivate []byte `json:"tpmPrivate"`
TPMPublic []byte `json:"tpmPublic"`
}
func (ak *attestationKey) MarshalJSON() ([]byte, error) {
return json.Marshal(attestationKeySerialized{
TPMPublic: ak.tpmPublic.Bytes(),
TPMPrivate: ak.tpmPrivate.Buffer,
})
}
func (ak *attestationKey) UnmarshalJSON(data []byte) (retErr error) {
var aks attestationKeySerialized
if err := json.Unmarshal(data, &aks); err != nil {
return err
}
ak.tpmPrivate = tpm2.TPM2BPrivate{Buffer: aks.TPMPrivate}
ak.tpmPublic = tpm2.BytesAs2B[tpm2.TPMTPublic, *tpm2.TPMTPublic](aks.TPMPublic)
tpm, err := open()
if err != nil {
return key.ErrUnsupported
}
defer func() {
if retErr != nil {
tpm.Close()
}
}()
ak.tpm = tpm
return ak.load()
}
func (ak *attestationKey) Public() crypto.PublicKey {
return ak.pub
}
func (ak *attestationKey) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) {
if !ak.loaded() {
return nil, errors.New("tpm2 attestation key is not loaded during Sign")
}
// Unfortunately, TPMs don't let us make keys with dynamic hash algorithms.
// The hash algorithm is fixed at key creation time (tpm2.Create).
if opts != crypto.SHA256 {
return nil, fmt.Errorf("tpm2 key is restricted to SHA256, have %q", opts)
}
resp, err := tpm2.Sign{
KeyHandle: ak.handle,
Digest: tpm2.TPM2BDigest{
Buffer: digest,
},
InScheme: tpm2.TPMTSigScheme{
Scheme: tpm2.TPMAlgECDSA,
Details: tpm2.NewTPMUSigScheme(
tpm2.TPMAlgECDSA,
&tpm2.TPMSSchemeHash{
HashAlg: tpm2.TPMAlgSHA256,
},
),
},
Validation: tpm2.TPMTTKHashCheck{
Tag: tpm2.TPMSTHashCheck,
},
}.Execute(ak.tpm)
if err != nil {
return nil, fmt.Errorf("tpm2.Sign: %w", err)
}
sig, err := resp.Signature.Signature.ECDSA()
if err != nil {
return nil, err
}
return encodeSignature(sig.SignatureR.Buffer, sig.SignatureS.Buffer)
}
// Copied from crypto/ecdsa.
func encodeSignature(r, s []byte) ([]byte, error) {
var b cryptobyte.Builder
b.AddASN1(asn1.SEQUENCE, func(b *cryptobyte.Builder) {
addASN1IntBytes(b, r)
addASN1IntBytes(b, s)
})
return b.Bytes()
}
// addASN1IntBytes encodes in ASN.1 a positive integer represented as
// a big-endian byte slice with zero or more leading zeroes.
func addASN1IntBytes(b *cryptobyte.Builder, bytes []byte) {
for len(bytes) > 0 && bytes[0] == 0 {
bytes = bytes[1:]
}
if len(bytes) == 0 {
b.SetError(errors.New("invalid integer"))
return
}
b.AddASN1(asn1.INTEGER, func(c *cryptobyte.Builder) {
if bytes[0]&0x80 != 0 {
c.AddUint8(0)
}
c.AddBytes(bytes)
})
}
func (ak *attestationKey) Close() error {
var errs []error
if ak.handle != nil && ak.tpm != nil {
_, err := tpm2.FlushContext{FlushHandle: ak.handle.Handle}.Execute(ak.tpm)
errs = append(errs, err)
}
if ak.tpm != nil {
errs = append(errs, ak.tpm.Close())
}
return errors.Join(errs...)
}
func (ak *attestationKey) Clone() key.HardwareAttestationKey {
return &attestationKey{
tpm: ak.tpm,
tpmPrivate: ak.tpmPrivate,
tpmPublic: ak.tpmPublic,
handle: ak.handle,
pub: ak.pub,
}
}

View File

@ -0,0 +1,98 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tpm
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"testing"
)
func TestAttestationKeySign(t *testing.T) {
skipWithoutTPM(t)
ak, err := newAttestationKey()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := ak.Close(); err != nil {
t.Errorf("ak.Close: %v", err)
}
})
data := []byte("secrets")
digest := sha256.Sum256(data)
// Check signature/validation round trip.
sig, err := ak.Sign(rand.Reader, digest[:], crypto.SHA256)
if err != nil {
t.Fatal(err)
}
if !ecdsa.VerifyASN1(ak.Public().(*ecdsa.PublicKey), digest[:], sig) {
t.Errorf("ecdsa.VerifyASN1 failed")
}
// Create a different key.
ak2, err := newAttestationKey()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := ak2.Close(); err != nil {
t.Errorf("ak2.Close: %v", err)
}
})
// Make sure that the keys are distinct via their public keys and the
// signatures they produce.
if ak.Public().(*ecdsa.PublicKey).Equal(ak2.Public()) {
t.Errorf("public keys of distinct attestation keys are the same")
}
sig2, err := ak2.Sign(rand.Reader, digest[:], crypto.SHA256)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(sig, sig2) {
t.Errorf("signatures from distinct attestation keys are the same")
}
}
func TestAttestationKeyUnmarshal(t *testing.T) {
skipWithoutTPM(t)
ak, err := newAttestationKey()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := ak.Close(); err != nil {
t.Errorf("ak.Close: %v", err)
}
})
buf, err := ak.MarshalJSON()
if err != nil {
t.Fatal(err)
}
var ak2 attestationKey
if err := json.Unmarshal(buf, &ak2); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := ak2.Close(); err != nil {
t.Errorf("ak2.Close: %v", err)
}
})
if !ak2.loaded() {
t.Error("unmarshalled key is not loaded")
}
if !ak.Public().(*ecdsa.PublicKey).Equal(ak2.Public()) {
t.Error("unmarshalled public key is not the same as the original public key")
}
}

View File

@ -28,6 +28,7 @@ import (
"tailscale.com/ipn/store"
"tailscale.com/paths"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/logger"
)
@ -39,6 +40,10 @@ func init() {
hi.TPM = infoOnce()
})
store.Register(store.TPMPrefix, newStore)
key.RegisterHardwareAttestationKeyFns(
func() key.HardwareAttestationKey { return &attestationKey{} },
func() (key.HardwareAttestationKey, error) { return newAttestationKey() },
)
}
func info() *tailcfg.TPMInfo {