From 6b73a020e3688f688974ece1d9aab025ef29e090 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Thu, 15 Sep 2022 18:19:52 -0700 Subject: [PATCH] Add unit tests for apple and step attestations --- acme/challenge.go | 36 ++-- acme/challenge_test.go | 390 ++++++++++++++++++++++++++++++++++++++++- acme/common.go | 9 + 3 files changed, 419 insertions(+), 16 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index cc860f95..47c46490 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -350,7 +350,7 @@ func deviceAttest01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose switch att.Format { case "apple": - data, err := doAppleAttestationFormat(ctx, ch, db, &att) + data, err := doAppleAttestationFormat(ctx, prov, ch, &att) if err != nil { var acmeError *Error if errors.As(err, &acmeError) { @@ -378,7 +378,7 @@ func deviceAttest01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose return storeError(ctx, db, ch, true, NewError(ErrorBadAttestationStatementType, "permanent identifier does not match")) } case "step": - data, err := doStepAttestationFormat(ctx, ch, jwk, &att) + data, err := doStepAttestationFormat(ctx, prov, ch, jwk, &att) if err != nil { var acmeError *Error if errors.As(err, &acmeError) { @@ -444,13 +444,17 @@ type appleAttestationData struct { Certificate *x509.Certificate } -func doAppleAttestationFormat(ctx context.Context, ch *Challenge, db DB, att *AttestationObject) (*appleAttestationData, error) { - root, err := pemutil.ParseCertificate([]byte(appleEnterpriseAttestationRootCA)) - if err != nil { - return nil, WrapErrorISE(err, "error parsing apple enterprise ca") +func doAppleAttestationFormat(ctx context.Context, prov Provisioner, ch *Challenge, att *AttestationObject) (*appleAttestationData, error) { + // Use configured or default attestation roots if none is configured. + roots, ok := prov.GetAttestationRoots() + if !ok { + root, err := pemutil.ParseCertificate([]byte(appleEnterpriseAttestationRootCA)) + if err != nil { + return nil, WrapErrorISE(err, "error parsing apple enterprise ca") + } + roots = x509.NewCertPool() + roots.AddCert(root) } - roots := x509.NewCertPool() - roots.AddCert(root) x5c, ok := att.AttStatement["x5c"].([]interface{}) if !ok { @@ -541,13 +545,17 @@ type stepAttestationData struct { SerialNumber string } -func doStepAttestationFormat(ctx context.Context, ch *Challenge, jwk *jose.JSONWebKey, att *AttestationObject) (*stepAttestationData, error) { - root, err := pemutil.ParseCertificate([]byte(yubicoPIVRootCA)) - if err != nil { - return nil, WrapErrorISE(err, "error parsing root ca") +func doStepAttestationFormat(ctx context.Context, prov Provisioner, ch *Challenge, jwk *jose.JSONWebKey, att *AttestationObject) (*stepAttestationData, error) { + // Use configured or default attestation roots if none is configured. + roots, ok := prov.GetAttestationRoots() + if !ok { + root, err := pemutil.ParseCertificate([]byte(yubicoPIVRootCA)) + if err != nil { + return nil, WrapErrorISE(err, "error parsing root ca") + } + roots = x509.NewCertPool() + roots.AddCert(root) } - roots := x509.NewCertPool() - roots.AddCert(root) // Extract x5c and verify certificate x5c, ok := att.AttStatement["x5c"].([]interface{}) diff --git a/acme/challenge_test.go b/acme/challenge_test.go index 3f7e214d..32d78166 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "crypto" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/sha256" @@ -13,6 +15,7 @@ import ( "encoding/asn1" "encoding/base64" "encoding/hex" + "encoding/pem" "errors" "fmt" "io" @@ -20,13 +23,18 @@ import ( "net" "net/http" "net/http/httptest" + "reflect" "strings" "testing" "time" - "go.step.sm/crypto/jose" - + "github.com/fxamacker/cbor/v2" "github.com/smallstep/assert" + "github.com/smallstep/certificates/authority/config" + "github.com/smallstep/certificates/authority/provisioner" + "go.step.sm/crypto/jose" + "go.step.sm/crypto/keyutil" + "go.step.sm/crypto/minica" ) type mockClient struct { @@ -2400,3 +2408,381 @@ func Test_http01ChallengeHost(t *testing.T) { }) } } + +func Test_doAppleAttestationFormat(t *testing.T) { + makeProvisioner := func(roots []byte) Provisioner { + prov := &provisioner.ACME{ + Type: "ACME", + Name: "acme", + Challenges: []provisioner.ACMEChallenge{provisioner.DEVICE_ATTEST_01}, + AttestationRoots: roots, + } + if err := prov.Init(provisioner.Config{ + Claims: config.GlobalProvisionerClaims, + }); err != nil { + t.Fatal(err) + } + return prov + } + + ctx := context.Background() + ca, err := minica.New() + if err != nil { + t.Fatal(err) + } + caRoot := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Root.Raw}) + signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + leaf, err := ca.Sign(&x509.Certificate{ + Subject: pkix.Name{CommonName: "attestation cert"}, + PublicKey: signer.Public(), + ExtraExtensions: []pkix.Extension{ + {Id: oidAppleSerialNumber, Value: []byte("serial-number")}, + {Id: oidAppleUniqueDeviceIdentifier, Value: []byte("udid")}, + {Id: oidAppleSecureEnclaveProcessorOSVersion, Value: []byte("16.0")}, + {Id: oidAppleNonce, Value: []byte("nonce")}, + }, + }) + if err != nil { + t.Fatal(err) + } + + type args struct { + ctx context.Context + prov Provisioner + ch *Challenge + att *AttestationObject + } + tests := []struct { + name string + args args + want *appleAttestationData + wantErr bool + }{ + {"ok", args{ctx, makeProvisioner(caRoot), &Challenge{}, &AttestationObject{ + Format: "apple", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw, ca.Intermediate.Raw}, + }, + }}, &appleAttestationData{ + Nonce: []byte("nonce"), + SerialNumber: "serial-number", + UDID: "udid", + SEPVersion: "16.0", + Certificate: leaf, + }, false}, + {"fail apple issuer", args{ctx, makeProvisioner(nil), &Challenge{}, &AttestationObject{ + Format: "apple", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw, ca.Intermediate.Raw}, + }, + }}, nil, true}, + {"fail missing x5c", args{ctx, makeProvisioner(caRoot), &Challenge{}, &AttestationObject{ + Format: "apple", + AttStatement: map[string]interface{}{ + "foo": "bar", + }, + }}, nil, true}, + {"fail empty issuer", args{ctx, makeProvisioner(caRoot), &Challenge{}, &AttestationObject{ + Format: "apple", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{}, + }, + }}, nil, true}, + {"fail leaf type", args{ctx, makeProvisioner(caRoot), &Challenge{}, &AttestationObject{ + Format: "apple", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{"leaf", ca.Intermediate.Raw}, + }, + }}, nil, true}, + {"fail leaf parse", args{ctx, makeProvisioner(caRoot), &Challenge{}, &AttestationObject{ + Format: "apple", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw[:100], ca.Intermediate.Raw}, + }, + }}, nil, true}, + {"fail intermediate type", args{ctx, makeProvisioner(caRoot), &Challenge{}, &AttestationObject{ + Format: "apple", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw, "intermediate"}, + }, + }}, nil, true}, + {"fail intermediate parse", args{ctx, makeProvisioner(caRoot), &Challenge{}, &AttestationObject{ + Format: "apple", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw, ca.Intermediate.Raw[:100]}, + }, + }}, nil, true}, + {"fail verify", args{ctx, makeProvisioner(caRoot), &Challenge{}, &AttestationObject{ + Format: "apple", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw}, + }, + }}, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := doAppleAttestationFormat(tt.args.ctx, tt.args.prov, tt.args.ch, tt.args.att) + if (err != nil) != tt.wantErr { + t.Errorf("doAppleAttestationFormat() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("doAppleAttestationFormat() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_doStepAttestationFormat(t *testing.T) { + ctx := context.Background() + ca, err := minica.New() + if err != nil { + t.Fatal(err) + } + caRoot := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Root.Raw}) + + makeProvisioner := func(roots []byte) Provisioner { + prov := &provisioner.ACME{ + Type: "ACME", + Name: "acme", + Challenges: []provisioner.ACMEChallenge{provisioner.DEVICE_ATTEST_01}, + AttestationRoots: roots, + } + if err := prov.Init(provisioner.Config{ + Claims: config.GlobalProvisionerClaims, + }); err != nil { + t.Fatal(err) + } + return prov + } + makeLeaf := func(signer crypto.Signer, serialNumber []byte) *x509.Certificate { + leaf, err := ca.Sign(&x509.Certificate{ + Subject: pkix.Name{CommonName: "attestation cert"}, + PublicKey: signer.Public(), + ExtraExtensions: []pkix.Extension{ + {Id: oidYubicoSerialNumber, Value: serialNumber}, + }, + }) + if err != nil { + t.Fatal(err) + } + return leaf + } + mustSigner := func(kty, crv string, size int) crypto.Signer { + s, err := keyutil.GenerateSigner(kty, crv, size) + if err != nil { + t.Fatal(err) + } + return s + } + + signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + serialNumber, err := asn1.Marshal(1234) + if err != nil { + t.Fatal(err) + } + leaf := makeLeaf(signer, serialNumber) + + jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + if err != nil { + t.Fatal(err) + } + keyAuth, err := KeyAuthorization("token", jwk) + if err != nil { + t.Fatal(err) + } + keyAuthSum := sha256.Sum256([]byte(keyAuth)) + sig, err := signer.Sign(rand.Reader, keyAuthSum[:], crypto.SHA256) + if err != nil { + t.Fatal(err) + } + cborSig, err := cbor.Marshal(sig) + if err != nil { + t.Fatal(err) + } + + otherSigner, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + otherSig, err := otherSigner.Sign(rand.Reader, keyAuthSum[:], crypto.SHA256) + if err != nil { + t.Fatal(err) + } + otherCBORSig, err := cbor.Marshal(otherSig) + if err != nil { + t.Fatal(err) + } + + type args struct { + ctx context.Context + prov Provisioner + ch *Challenge + jwk *jose.JSONWebKey + att *AttestationObject + } + tests := []struct { + name string + args args + want *stepAttestationData + wantErr bool + }{ + {"ok", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw, ca.Intermediate.Raw}, + "alg": -7, + "sig": cborSig, + }, + }}, &stepAttestationData{ + SerialNumber: "1234", + Certificate: leaf, + }, false}, + {"fail yubico issuer", args{ctx, makeProvisioner(nil), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw, ca.Intermediate.Raw}, + "alg": -7, + "sig": cborSig, + }, + }}, nil, true}, + {"fail x5c type", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": [][]byte{leaf.Raw, ca.Intermediate.Raw}, + "alg": -7, + "sig": cborSig, + }, + }}, nil, true}, + {"fail x5c empty", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{}, + "alg": -7, + "sig": cborSig, + }, + }}, nil, true}, + {"fail leaf type", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{"leaf", ca.Intermediate.Raw}, + "alg": -7, + "sig": cborSig, + }, + }}, nil, true}, + {"fail leaf parse", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw[:100], ca.Intermediate.Raw}, + "alg": -7, + "sig": cborSig, + }, + }}, nil, true}, + {"fail intermediate type", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw, "intermediate"}, + "alg": -7, + "sig": cborSig, + }, + }}, nil, true}, + {"fail intermediate parse", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw, ca.Intermediate.Raw[:100]}, + "alg": -7, + "sig": cborSig, + }, + }}, nil, true}, + {"fail verify", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw}, + "alg": -7, + "sig": cborSig, + }, + }}, nil, true}, + {"fail sig type", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw, ca.Intermediate.Raw}, + "alg": -7, + "sig": string(cborSig), + }, + }}, nil, true}, + {"fail sig unmarshal", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw, ca.Intermediate.Raw}, + "alg": -7, + "sig": []byte("bad-sig"), + }, + }}, nil, true}, + {"fail keyAuthorization", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, &jose.JSONWebKey{Key: []byte("not an asymmetric key")}, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw, ca.Intermediate.Raw}, + "alg": -7, + "sig": cborSig, + }, + }}, nil, true}, + {"fail sig verify P-256", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw, ca.Intermediate.Raw}, + "alg": -7, + "sig": otherCBORSig, + }, + }}, nil, true}, + {"fail sig verify P-384", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{makeLeaf(mustSigner("EC", "P-384", 0), serialNumber).Raw, ca.Intermediate.Raw}, + "alg": -7, + "sig": cborSig, + }, + }}, nil, true}, + {"fail sig verify RSA", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{makeLeaf(mustSigner("RSA", "", 2048), serialNumber).Raw, ca.Intermediate.Raw}, + "alg": -7, + "sig": cborSig, + }, + }}, nil, true}, + {"fail sig verify Ed25519", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{makeLeaf(mustSigner("OKP", "Ed25519", 0), serialNumber).Raw, ca.Intermediate.Raw}, + "alg": -7, + "sig": cborSig, + }, + }}, nil, true}, + {"fail unmarshal serial number", args{ctx, makeProvisioner(caRoot), &Challenge{Token: "token"}, jwk, &AttestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{makeLeaf(signer, []byte("bad-serial")).Raw, ca.Intermediate.Raw}, + "alg": -7, + "sig": cborSig, + }, + }}, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := doStepAttestationFormat(tt.args.ctx, tt.args.prov, tt.args.ch, tt.args.jwk, tt.args.att) + if (err != nil) != tt.wantErr { + t.Errorf("doStepAttestationFormat() error = %#v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("doStepAttestationFormat() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/acme/common.go b/acme/common.go index b7260386..91cf772b 100644 --- a/acme/common.go +++ b/acme/common.go @@ -73,6 +73,7 @@ type Provisioner interface { AuthorizeRevoke(ctx context.Context, token string) error IsChallengeEnabled(ctx context.Context, challenge provisioner.ACMEChallenge) bool IsAttestationFormatEnabled(ctx context.Context, format provisioner.ACMEAttestationFormat) bool + GetAttestationRoots() (*x509.CertPool, bool) GetID() string GetName() string DefaultTLSCertDuration() time.Duration @@ -113,6 +114,7 @@ type MockProvisioner struct { MauthorizeRevoke func(ctx context.Context, token string) error MisChallengeEnabled func(ctx context.Context, challenge provisioner.ACMEChallenge) bool MisAttFormatEnabled func(ctx context.Context, format provisioner.ACMEAttestationFormat) bool + MgetAttestationRoots func() (*x509.CertPool, bool) MdefaultTLSCertDuration func() time.Duration MgetOptions func() *provisioner.Options } @@ -165,6 +167,13 @@ func (m *MockProvisioner) IsAttestationFormatEnabled(ctx context.Context, format return m.Merr == nil } +func (m *MockProvisioner) GetAttestationRoots() (*x509.CertPool, bool) { + if m.MgetAttestationRoots != nil { + return m.MgetAttestationRoots() + } + return m.Mret1.(*x509.CertPool), m.Mret1 != nil +} + // DefaultTLSCertDuration mock func (m *MockProvisioner) DefaultTLSCertDuration() time.Duration { if m.MdefaultTLSCertDuration != nil {