diff --git a/client/internal/certproof/store_tpm_test.go b/client/internal/certproof/store_tpm_test.go index 09f54e0b0..5794ba24c 100644 --- a/client/internal/certproof/store_tpm_test.go +++ b/client/internal/certproof/store_tpm_test.go @@ -13,7 +13,6 @@ import ( "github.com/google/go-tpm/legacy/tpm2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.step.sm/crypto/tpm/tss2" "github.com/netbirdio/netbird/client/internal/tpm" "github.com/netbirdio/netbird/client/internal/tpm/tpmtest" @@ -34,9 +33,8 @@ func TestFileStore_TPMKeyFile(t *testing.T) { // A key that needs a password can never be used silently, so its certificate is skipped. locked := certtest.ECDSAKey(t) - withAuth := func(k *tss2.TPMKey) { k.EmptyAuth = false } writeFile(t, dir, "locked.pem", certtest.CertPEM(ca.Issue(t, locked, "locked"))) - writeFile(t, dir, "locked.key", tpmtest.KeyPEM(t, locked.Public().(*ecdsa.PublicKey), withAuth)) + writeFile(t, dir, "locked.key", tpmtest.KeyPEM(t, locked.Public().(*ecdsa.PublicKey), tpmtest.WithAuth())) candidates, err := NewFileStore(dir).Candidates(context.Background()) require.NoError(t, err) @@ -88,7 +86,7 @@ func createTPMKey(t *testing.T) (public, private []byte) { require.NoError(t, err) defer func() { _ = rwc.Close() }() - parent, _, err := tpm2.CreatePrimary(rwc, tpm2.HandleOwner, tpm2.PCRSelection{}, "", "", tss2.ECCSRKTemplate) + parent, _, err := tpm2.CreatePrimary(rwc, tpm2.HandleOwner, tpm2.PCRSelection{}, "", "", tpmtest.ECCSRKTemplate) require.NoError(t, err) defer func() { _ = tpm2.FlushContext(rwc, parent) }() diff --git a/client/internal/tpm/signer.go b/client/internal/tpm/signer.go index b36097538..f9f36ec03 100644 --- a/client/internal/tpm/signer.go +++ b/client/internal/tpm/signer.go @@ -2,11 +2,17 @@ package tpm import ( "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/asn1" "errors" "fmt" "io" + "math/big" - "go.step.sm/crypto/tpm/tss2" + legacy "github.com/google/go-tpm/legacy/tpm2" + "github.com/google/go-tpm/tpmutil" ) // KeyPEMType is the PEM block type of a TPM 2.0 key file as defined by @@ -15,29 +21,46 @@ const KeyPEMType = "TSS2 PRIVATE KEY" var ErrKeyNeedsAuth = errors.New("TPM key requires an authorization value") +// eccSRKTemplate is the TCG reference ECC-P256 storage root key. A key whose parent is +// a hierarchy rather than a persistent handle was wrapped by the primary this template +// derives, and tpm2-openssl and tpm2-tss-engine derive the same one, so the TPM +// reproduces the identical parent from the hierarchy seed without anything being stored. +var eccSRKTemplate = legacy.Public{ + Type: legacy.AlgECC, + NameAlg: legacy.AlgSHA256, + Attributes: legacy.FlagStorageDefault | legacy.FlagNoDA, + ECCParameters: &legacy.ECCParams{ + Symmetric: &legacy.SymScheme{Alg: legacy.AlgAES, KeyBits: 128, Mode: legacy.AlgCFB}, + Sign: &legacy.SigScheme{Alg: legacy.AlgNull}, + CurveID: legacy.CurveNISTP256, + }, +} + // ParseKey reads a TSS2 key file and returns a signer that produces every signature -// inside the TPM; only the digest goes in and only the signature comes out. A key with -// a persistent parent is loaded under it, a key whose parent is a hierarchy under the -// TCG default ECC primary that tpm2-openssl and tpm2-tss-engine derive as well. Keys -// guarded by an authorization value are rejected, since nothing can supply it without -// prompting. +// inside the TPM; only the digest goes in and only the signature comes out. Keys guarded +// by an authorization value are rejected, since nothing can supply it without prompting. func ParseKey(der []byte) (crypto.Signer, error) { - key, err := tss2.ParsePrivateKey(der) + key, err := parseTSS2(der) if err != nil { - return nil, fmt.Errorf("parse TSS2 key: %w", err) + return nil, err } if !key.EmptyAuth { return nil, ErrKeyNeedsAuth } - public, err := key.Public() + + public, err := legacy.DecodePublic(key.PublicKey[2:]) + if err != nil { + return nil, fmt.Errorf("decode TSS2 public area: %w", err) + } + pub, err := public.Key() if err != nil { return nil, fmt.Errorf("decode TSS2 public key: %w", err) } - return &keySigner{key: key, public: public}, nil + return &keySigner{key: key, public: pub}, nil } type keySigner struct { - key *tss2.TPMKey + key *tss2Key public crypto.PublicKey } @@ -45,17 +68,96 @@ func (s *keySigner) Public() crypto.PublicKey { return s.public } -func (s *keySigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { +// Sign loads the key under its parent, signs, and releases both handles. The TPM is +// opened per signature so no handle outlives the call, which matters on a device whose +// transient object slots are few and shared with everything else on the host. +func (s *keySigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { rwc, err := Open() if err != nil { return nil, err } defer func() { _ = rwc.Close() }() - signer, err := tss2.CreateSigner(rwc, s.key) + parent := tpmutil.Handle(uint32(s.key.Parent)) //nolint:gosec // validParent bounds it + if !persistentHandle(s.key.Parent) { + parent, _, err = legacy.CreatePrimary(rwc, parent, legacy.PCRSelection{}, "", "", eccSRKTemplate) + if err != nil { + return nil, fmt.Errorf("create TPM primary: %w", err) + } + defer func() { _ = legacy.FlushContext(rwc, parent) }() + } + + public, private := s.key.blobs() + handle, _, err := legacy.Load(rwc, parent, "", public, private) if err != nil { - return nil, fmt.Errorf("load TSS2 key: %w", err) + return nil, fmt.Errorf("load TPM key: %w", err) + } + defer func() { _ = legacy.FlushContext(rwc, handle) }() + + switch pub := s.public.(type) { + case *ecdsa.PublicKey: + return signECDSA(rwc, handle, digest, pub.Curve) + case *rsa.PublicKey: + return signRSA(rwc, handle, digest, opts) + default: + return nil, fmt.Errorf("unsupported TPM key type %T", s.public) + } +} + +// signECDSA returns the signature as the ASN.1 sequence crypto.Signer is defined to +// return; the TPM hands back the two integers on their own. +func signECDSA(rw io.ReadWriter, handle tpmutil.Handle, digest []byte, curve elliptic.Curve) ([]byte, error) { + hash, err := eccHash(curve) + if err != nil { + return nil, err + } + sig, err := legacy.Sign(rw, handle, "", digest, nil, &legacy.SigScheme{Alg: legacy.AlgECDSA, Hash: hash}) + if err != nil { + return nil, fmt.Errorf("TPM ECDSA signature: %w", err) + } + if sig.ECC == nil { + return nil, fmt.Errorf("TPM returned a %v signature for an ECDSA key", sig.Alg) + } + return asn1.Marshal(struct{ R, S *big.Int }{sig.ECC.R, sig.ECC.S}) +} + +func signRSA(rw io.ReadWriter, handle tpmutil.Handle, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + hash, err := legacy.HashToAlgorithm(opts.HashFunc()) + if err != nil { + return nil, fmt.Errorf("TPM hash algorithm: %w", err) + } + + scheme := &legacy.SigScheme{Alg: legacy.AlgRSASSA, Hash: hash} + if pss, ok := opts.(*rsa.PSSOptions); ok { + // The TPM always salts to the hash length, so a caller asking for anything + // else would get a signature it did not ask for. + if pss.SaltLength != rsa.PSSSaltLengthAuto && + pss.SaltLength != rsa.PSSSaltLengthEqualsHash && + pss.SaltLength != len(digest) { + return nil, fmt.Errorf("TPM cannot produce a PSS signature with salt length %d", pss.SaltLength) + } + scheme.Alg = legacy.AlgRSAPSS + } + + sig, err := legacy.Sign(rw, handle, "", digest, nil, scheme) + if err != nil { + return nil, fmt.Errorf("TPM RSA signature: %w", err) + } + if sig.RSA == nil { + return nil, fmt.Errorf("TPM returned a %v signature for an RSA key", sig.Alg) + } + return sig.RSA.Signature, nil +} + +func eccHash(curve elliptic.Curve) (legacy.Algorithm, error) { + switch curve { + case elliptic.P256(): + return legacy.AlgSHA256, nil + case elliptic.P384(): + return legacy.AlgSHA384, nil + case elliptic.P521(): + return legacy.AlgSHA512, nil + default: + return 0, fmt.Errorf("unsupported curve %s", curve.Params().Name) } - signer.SetSRKTemplate(tss2.ECCSRKTemplate) - return signer.Sign(rand, digest, opts) } diff --git a/client/internal/tpm/signer_test.go b/client/internal/tpm/signer_test.go index d1be2636c..9a0be2dac 100644 --- a/client/internal/tpm/signer_test.go +++ b/client/internal/tpm/signer_test.go @@ -12,7 +12,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.step.sm/crypto/tpm/tss2" "github.com/netbirdio/netbird/client/internal/tpm/tpmtest" ) @@ -27,9 +26,7 @@ func TestParseKey_ReportsPublicKeyWithoutTouchingTPM(t *testing.T) { func TestParseKey_RejectsKeyWithAuthorization(t *testing.T) { key := newP256Key(t) - withAuth := func(k *tss2.TPMKey) { k.EmptyAuth = false } - - _, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey, withAuth))) + _, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey, tpmtest.WithAuth()))) assert.ErrorIs(t, err, ErrKeyNeedsAuth) } diff --git a/client/internal/tpm/tpmtest/tpmtest.go b/client/internal/tpm/tpmtest/tpmtest.go index c119eb55d..6711ddf43 100644 --- a/client/internal/tpm/tpmtest/tpmtest.go +++ b/client/internal/tpm/tpmtest/tpmtest.go @@ -4,14 +4,49 @@ package tpmtest import ( "crypto/ecdsa" "crypto/elliptic" + "encoding/asn1" + "encoding/pem" "testing" "github.com/google/go-tpm/legacy/tpm2" "github.com/stretchr/testify/require" - "go.step.sm/crypto/tpm/tss2" ) -const p256Bytes = 32 +const ( + p256Bytes = 32 + + // KeyPEMType is the PEM block type of a TPM 2.0 key file. + KeyPEMType = "TSS2 PRIVATE KEY" +) + +// oidLoadableKey is the key type of draft-bottomley-tpm2-keys that a parent wraps. +var oidLoadableKey = asn1.ObjectIdentifier{2, 23, 133, 10, 1, 3} + +// tss2KeyDER is the ASN.1 container, written out independently of the parser under test +// so that an encoder bug and a decoder bug cannot cancel out. Policy, secret and auth +// policy are left out entirely: they are optional, and a key that carries them is +// refused anyway. +type tss2KeyDER struct { + Type asn1.ObjectIdentifier + EmptyAuth bool `asn1:"optional,explicit,tag:0"` + Parent int + PublicKey []byte + PrivateKey []byte +} + +// Option adjusts a key before it is encoded. +type Option func(*tss2KeyDER) + +// WithParent names the handle the key is wrapped by, instead of the owner hierarchy. +func WithParent(handle int) Option { + return func(k *tss2KeyDER) { k.Parent = handle } +} + +// WithAuth marks the key as guarded by an authorization value, which this client refuses +// because nothing can supply one without prompting. +func WithAuth() Option { + return func(k *tss2KeyDER) { k.EmptyAuth = false } +} // SigningTemplate is the public area of an unrestricted P-256 signing key with no fixed // scheme, the shape tpm2-openssl creates certificate keys in. @@ -26,7 +61,7 @@ func SigningTemplate() tpm2.Public { // KeyPEM encodes pub as a TSS2 PRIVATE KEY over a placeholder private blob: it parses // and reports pub, but no TPM can load it. -func KeyPEM(t *testing.T, pub *ecdsa.PublicKey, opts ...tss2.TPMOption) string { +func KeyPEM(t *testing.T, pub *ecdsa.PublicKey, opts ...Option) string { t.Helper() require.Equal(t, elliptic.P256(), pub.Curve, "fixture keys must be P-256") area := SigningTemplate() @@ -39,10 +74,43 @@ func KeyPEM(t *testing.T, pub *ecdsa.PublicKey, opts ...tss2.TPMOption) string { return EncodePEM(t, encoded, []byte("placeholder"), opts...) } -// EncodePEM wraps the public and private blobs TPM2_Create returned into a TSS2 PRIVATE KEY. -func EncodePEM(t *testing.T, public, private []byte, opts ...tss2.TPMOption) string { +// EncodePEM wraps the public and private blobs TPM2_Create returned into a TSS2 PRIVATE +// KEY, giving each the TPM2B length prefix the format carries them with. +func EncodePEM(t *testing.T, public, private []byte, opts ...Option) string { t.Helper() - pemBytes, err := tss2.New(public, private, opts...).EncodeToMemory() + + key := tss2KeyDER{ + Type: oidLoadableKey, + EmptyAuth: true, + Parent: int(tpm2.HandleOwner), + PublicKey: prefixTPM2B(public), + PrivateKey: prefixTPM2B(private), + } + for _, opt := range opts { + opt(&key) + } + + der, err := asn1.Marshal(key) require.NoError(t, err) - return string(pemBytes) + return string(pem.EncodeToMemory(&pem.Block{Type: KeyPEMType, Bytes: der})) +} + +func prefixTPM2B(b []byte) []byte { + out := make([]byte, 0, len(b)+2) + out = append(out, byte(len(b)>>8), byte(len(b))) + return append(out, b...) +} + +// ECCSRKTemplate is the TCG reference ECC-P256 storage root key, the parent a key under +// a hierarchy is wrapped by. Tests that create a key in a real TPM have to use the same +// template the signer re-derives it with. +var ECCSRKTemplate = tpm2.Public{ + Type: tpm2.AlgECC, + NameAlg: tpm2.AlgSHA256, + Attributes: tpm2.FlagStorageDefault | tpm2.FlagNoDA, + ECCParameters: &tpm2.ECCParams{ + Symmetric: &tpm2.SymScheme{Alg: tpm2.AlgAES, KeyBits: 128, Mode: tpm2.AlgCFB}, + Sign: &tpm2.SigScheme{Alg: tpm2.AlgNull}, + CurveID: tpm2.CurveNISTP256, + }, } diff --git a/client/internal/tpm/tss2.go b/client/internal/tpm/tss2.go new file mode 100644 index 000000000..f6f411b7e --- /dev/null +++ b/client/internal/tpm/tss2.go @@ -0,0 +1,90 @@ +package tpm + +import ( + "encoding/asn1" + "errors" + "fmt" + + legacy "github.com/google/go-tpm/legacy/tpm2" +) + +// oidLoadableKey marks a key wrapped by a parent inside the TPM, which is the only kind +// enrollment tooling writes for a signing key and the only kind that can be loaded under +// an SRK. The sealed and importable variants carry different material and are refused. +var oidLoadableKey = asn1.ObjectIdentifier{2, 23, 133, 10, 1, 3} + +var ( + errNotLoadable = errors.New("TSS2 key is not a loadable key") + errKeyHasPolicy = errors.New("TSS2 key carries a policy, which is not supported") + errKeyHasSecret = errors.New("TSS2 key carries a secret, which is not supported") + errBadParent = errors.New("TSS2 key names a parent that is neither persistent nor a hierarchy") + errBadBlob = errors.New("TSS2 key blob is malformed") +) + +// tss2Key is the ASN.1 container of draft-bottomley-tpm2-keys, the format tpm2-openssl, +// tpm2-tss-engine and tpm2_encodeobject write. PublicKey and PrivateKey hold TPM2B +// structures, so each is its own two-byte length followed by that many bytes. +type tss2Key struct { + Type asn1.ObjectIdentifier + EmptyAuth bool `asn1:"optional,explicit,tag:0"` + Policy []asn1.RawValue `asn1:"optional,explicit,tag:1"` + Secret []byte `asn1:"optional,explicit,tag:2"` + AuthPolicy []asn1.RawValue `asn1:"optional,explicit,tag:3"` + Parent int + PublicKey []byte + PrivateKey []byte +} + +// parseTSS2 decodes a TSS2 key file and rejects everything this client cannot honour, +// so a key that parses here is one the TPM can be asked to load. +func parseTSS2(der []byte) (*tss2Key, error) { + key := new(tss2Key) + rest, err := asn1.Unmarshal(der, key) + if err != nil { + return nil, fmt.Errorf("parse TSS2 key: %w", err) + } + if len(rest) > 0 { + return nil, fmt.Errorf("parse TSS2 key: %d trailing bytes", len(rest)) + } + + switch { + case !key.Type.Equal(oidLoadableKey): + return nil, fmt.Errorf("%w: %s", errNotLoadable, key.Type) + case len(key.Policy) > 0 || len(key.AuthPolicy) > 0: + return nil, errKeyHasPolicy + case len(key.Secret) > 0: + return nil, errKeyHasSecret + case !validParent(key.Parent): + return nil, fmt.Errorf("%w: %d", errBadParent, key.Parent) + case !validTPM2B(key.PublicKey) || !validTPM2B(key.PrivateKey): + return nil, errBadBlob + } + return key, nil +} + +// blobs returns the public and private areas with their TPM2B length prefix removed, +// which is the form the load command takes them in. +func (k *tss2Key) blobs() (public, private []byte) { + return k.PublicKey[2:], k.PrivateKey[2:] +} + +// validParent accepts a persistent handle, under which the key was wrapped directly, or +// one of the four hierarchies, under which the key is wrapped by a primary the TPM +// re-derives from the hierarchy seed. +func validParent(parent int) bool { + return persistentHandle(parent) || + parent == int(legacy.HandleOwner) || + parent == int(legacy.HandleNull) || + parent == int(legacy.HandleEndorsement) || + parent == int(legacy.HandlePlatform) +} + +func persistentHandle(h int) bool { + return h>>24 == int(legacy.HandleTypePersistent) +} + +// validTPM2B reports whether b is a TPM2B structure: a two-byte big-endian length +// followed by exactly that many bytes. +func validTPM2B(b []byte) bool { + return len(b) >= 2 && len(b)-2 == int(b[0])<<8+int(b[1]) +} diff --git a/client/internal/tpm/tss2_differential_test.go b/client/internal/tpm/tss2_differential_test.go new file mode 100644 index 000000000..21db85a8b --- /dev/null +++ b/client/internal/tpm/tss2_differential_test.go @@ -0,0 +1,101 @@ +package tpm + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/pem" + "testing" + + legacy "github.com/google/go-tpm/legacy/tpm2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.step.sm/crypto/tpm/tss2" + + "github.com/netbirdio/netbird/client/internal/tpm/tpmtest" +) + +// publicArea builds the public area of a P-256 signing key holding pub. +func publicArea(t *testing.T, pub *ecdsa.PublicKey) []byte { + t.Helper() + area := tpmtest.SigningTemplate() + area.ECCParameters.Point = legacy.ECPoint{ + XRaw: pub.X.FillBytes(make([]byte, 32)), + YRaw: pub.Y.FillBytes(make([]byte, 32)), + } + encoded, err := area.Encode() + require.NoError(t, err) + return encoded +} + +// TestParseTSS2_AgreesWithStep is scaffolding for one commit: it holds the replacement +// parser against the library it replaces, over bytes that library itself wrote, so the +// swap is checked rather than asserted. It goes away with the dependency. +func TestParseTSS2_AgreesWithStep(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + public := publicArea(t, &key.PublicKey) + + for _, tc := range []struct { + name string + opts []tss2.TPMOption + }{ + {name: "owner hierarchy parent"}, + {name: "persistent parent", opts: []tss2.TPMOption{tss2.WithParent(0x81000001)}}, + } { + t.Run(tc.name, func(t *testing.T) { + encoded, err := tss2.New(public, []byte("placeholder"), tc.opts...).EncodeToMemory() + require.NoError(t, err) + block, _ := pem.Decode(encoded) + require.NotNil(t, block) + + want, err := tss2.ParsePrivateKey(block.Bytes) + require.NoError(t, err) + got, err := parseTSS2(block.Bytes) + require.NoError(t, err, "the replacement must accept what the library writes") + + assert.Equal(t, want.Type, got.Type, "key type") + assert.Equal(t, want.EmptyAuth, got.EmptyAuth, "emptyAuth") + assert.Equal(t, want.Parent, got.Parent, "parent handle") + assert.Equal(t, want.PublicKey, got.PublicKey, "public area") + assert.Equal(t, want.PrivateKey, got.PrivateKey, "private area") + + wantPub, err := want.Public() + require.NoError(t, err) + signer, err := ParseKey(block.Bytes) + require.NoError(t, err) + assert.Equal(t, wantPub, signer.Public(), "the decoded public key must be identical") + assert.True(t, key.PublicKey.Equal(signer.Public()), "and must be the key the fixture was built from") + }) + } +} + +// TestEncodeTSS2_ReadableByStep checks the other direction: the fixtures the tests are +// built on are the shape the format calls for, not merely the shape this package reads. +func TestEncodeTSS2_ReadableByStep(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + for _, tc := range []struct { + name string + opts []tpmtest.Option + }{ + {name: "owner hierarchy parent"}, + {name: "persistent parent", opts: []tpmtest.Option{tpmtest.WithParent(0x81000001)}}, + {name: "needs authorization", opts: []tpmtest.Option{tpmtest.WithAuth()}}, + } { + t.Run(tc.name, func(t *testing.T) { + block, _ := pem.Decode([]byte(tpmtest.KeyPEM(t, &key.PublicKey, tc.opts...))) + require.NotNil(t, block) + + want, err := tss2.ParsePrivateKey(block.Bytes) + require.NoError(t, err, "the library under replacement must accept our fixtures") + got, err := parseTSS2(block.Bytes) + require.NoError(t, err) + + assert.Equal(t, want.EmptyAuth, got.EmptyAuth, "emptyAuth") + assert.Equal(t, want.Parent, got.Parent, "parent handle") + assert.Equal(t, want.PublicKey, got.PublicKey, "public area") + }) + } +}