From 5e3b8433cf4ad549732f7ad4314870da61caa76c Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin <> Date: Mon, 30 Jun 2025 11:14:47 +0200 Subject: [PATCH 01/12] Add android-key attestation format support Add a new attestationFormat for ACME device-attest-01 challenge to support Android attestation (android-key) as defined by WebAuthn and modify by RFC (sig use key authorization). The implementation involve adding a CRL. ACME provider support a new configuration key called RootCRLs (rootCRLs in json). When 'android-key' is specified in attestationFormat and the list is not provided by the configuration, the list will be populated and updated automatically based on the validation implementation procedure. Other ACME challenge could use IsRootRevoked and RootCRLs in the future independantly to android-key or device-attest-01 challenge. --- acme/challenge.go | 223 ++++++++++++++++++++++++++++- acme/challenge_test.go | 178 +++++++++++++++++++++++ authority/provisioner/acme.go | 63 +++++++- authority/provisioner/acme_test.go | 7 +- authority/provisioners.go | 4 + go.mod | 1 + go.sum | 2 + 7 files changed, 473 insertions(+), 5 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index f328a25ef..33283599a 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -16,6 +16,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "encoding/pem" "errors" "fmt" "io" @@ -37,6 +38,7 @@ import ( "go.step.sm/crypto/pemutil" "go.step.sm/crypto/x509util" + "github.com/mbreban/attestation" "github.com/smallstep/certificates/acme/wire" "github.com/smallstep/certificates/authority/provisioner" wireprovisioner "github.com/smallstep/certificates/authority/provisioner/wire" @@ -838,7 +840,7 @@ func deviceAttest01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose format := att.Format prov := MustProvisionerFromContext(ctx) if !prov.IsAttestationFormatEnabled(ctx, provisioner.ACMEAttestationFormat(format)) { - if format != "apple" && format != "step" && format != "tpm" { + if format != "apple" && format != "step" && format != "tpm" && format != "android-key" { return storeError(ctx, db, ch, true, NewDetailedError(ErrorBadAttestationStatementType, "unsupported attestation object format %q", format)) } @@ -847,6 +849,36 @@ func deviceAttest01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose } switch format { + case "android-key": + data, err := doAndroidKeyAttestionFormat(ctx, prov, ch, jwk, &att) + if err != nil { + var acmeError *Error + if errors.As(err, &acmeError) { + if acmeError.Status == 500 { + return acmeError + } + return storeError(ctx, db, ch, true, acmeError) + } + return WrapErrorISE(err, "error validating attestation") + } + + // 1. attestationSecurityLevel > 0 + if data.Attestation.AttestationSecurityLevel < 1 { + return storeError(ctx, db, ch, true, NewDetailedError(ErrorBadAttestationStatementType, "Security Level does not match")) + } + + // 2. hardwareEnforced + if ch.Value != string(data.Attestation.TeeEnforced.AttestationIdSerial) { + subproblem := NewSubproblemWithIdentifier( + ErrorRejectedIdentifierType, + Identifier{Type: "permanent-identifier", Value: ch.Value}, + "challenge identifier %q doesn't match any of the attested hardware identifiers %q", ch.Value, []string{string(data.Attestation.TeeEnforced.AttestationIdSerial)}, + ) + return storeError(ctx, db, ch, true, NewDetailedError(ErrorBadAttestationStatementType, "permanent identifier does not match").AddSubproblems(subproblem)) + } + + // Update attestation key fingerprint to compare against the CSR + az.Fingerprint = data.Fingerprint case "apple": data, err := doAppleAttestationFormat(ctx, prov, ch, &att) if err != nil { @@ -1370,6 +1402,195 @@ func doAppleAttestationFormat(_ context.Context, prov Provisioner, _ *Challenge, return data, nil } +// Android Root CA +// https://developer.android.com/privacy-and-security/security-key-attestation#root_certificate +const AndroidRootCAPubKey = `-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xU +FmOr75gvMsd/dTEDDJdSSxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5j +lRfdnJLmN0pTy/4lj4/7tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y +//0rb+T+W8a9nsNL/ggjnar86461qO0rOs2cXjp3kOG1FEJ5MVmFmBGtnrKpa73X +pXyTqRxB/M0n1n/W9nGqC4FSYa04T6N5RIZGBN2z2MT5IKGbFlbC8UrW0DxW7AYI +mQQcHtGl/m00QLVWutHQoVJYnFPlXTcHYvASLu+RhhsbDmxMgJJ0mcDpvsC4PjvB ++TxywElgS70vE0XmLD+OJtvsBslHZvPBKCOdT0MS+tgSOIfga+z1Z1g7+DVagf7q +uvmag8jfPioyKvxnK/EgsTUVi2ghzq8wm27ud/mIM7AY2qEORR8Go3TVB4HzWQgp +Zrt3i5MIlCaY504LzSRiigHCzAPlHws+W0rB5N+er5/2pJKnfBSDiCiFAVtCLOZ7 +gLiMm0jhO2B6tUXHI/+MRPjy02i59lINMRRev56GKtcd9qO/0kUJWdZTdA2XoS82 +ixPvZtXQpUpuL12ab+9EaDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+ +NpUFgNPN9PvQi8WEg5UmAGMCAwEAAQ== +-----END PUBLIC KEY-----` + +// Attestion oid for Android, encoded as an integer. +// https://source.android.com/docs/security/features/keystore/attestation#id-attestation +var oidAndroidAttestation = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 1, 17} + +type androidKeyAttestationData struct { + Certificate *x509.Certificate + Fingerprint string + Attestation *attestation.KeyDescription +} + +func findAndroidAttestationCert(intermediates []*x509.Certificate) (*x509.Certificate, error) { + for _, cert := range intermediates { + for _, ext := range cert.Extensions { + if ext.Id.Equal(oidAndroidAttestation) { + return cert, nil + } + } + } + return nil, errors.New("no attestation certificate with OID 1.3.6.1.4.1.11129.2.1.17 found in the cert chain") +} + +// https://developer.android.com/privacy-and-security/security-key-attestation +// 3. Verify that the root public certificate is trustworthy and that each certificate signs the next certificate in the chain. +// 4. Check each certificate's revocation status to ensure that none of the certificates have been revoked. +// 5. Optionally, inspect the provisioning information certificate extension that is only present in newer certificate chains. +// Obtain a reference to the CBOR parser library that is most appropriate for your toolset. Find the nearest certificate to the root that contains the provisioning information certificate extension. Use the parser to extract the provisioning information certificate extension data from that certificate. +// See the section about the provisioning information extension for more details. +// 6. Find the nearest certificate to the root that contains the key attestation certificate extension. If the provisioning information certificate extension was present, the key attestation certificate extension must be in the immediately subsequent certificate. Use the parser to extract the key attestation certificate extension data from that certificate. +// 7. Check the extension data that you've retrieved in the previous steps for consistency and compare with the set of values that you expect the hardware-backed key to contain. + +func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challenge, jwk *jose.JSONWebKey, att *attestationObject) (*androidKeyAttestationData, error) { + // Extract x5c and verify certificate + acme := prov.(*provisioner.ACME) + certs := []*x509.Certificate{} + x5c, ok := att.AttStatement["x5c"].([]any) + if !ok { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c not present") + } + if len(x5c) == 0 { + return nil, NewDetailedError(ErrorRejectedIdentifierType, "x5c is empty") + } + der, ok := x5c[0].([]byte) + if !ok { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c[0] is not a DER []byte") + } + leaf, err := x509.ParseCertificate(der) + if err != nil { + return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "failed to parse leaf certificate") + } + certs = append(certs, leaf) + + // Parse intermediates and root + intermediates := x509.NewCertPool() + var root *x509.Certificate + for i, v := range x5c[1:] { + der, ok := v.([]byte) + if !ok { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c element is not a DER []byte") + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "failed to parse intermediate/root certificate") + } + // Verify CRL + if acme.IsRootRevoked(cert.SerialNumber.String()) { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c element contain a revoked certificate") + } + if i == len(x5c)-2 { + // Last cert = root + certs = append(certs, cert) + root = cert + } else { + certs = append(certs, cert) + intermediates.AddCert(cert) + } + } + + if root == nil { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "missing root certificate in x5c chain") + } + + block, _ := pem.Decode([]byte(AndroidRootCAPubKey)) + trustedPubKey, err := x509.ParsePKIXPublicKey(block.Bytes) + switch root.PublicKey.(type) { + case *rsa.PublicKey: + if !root.PublicKey.(*rsa.PublicKey).Equal(trustedPubKey) { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "Root certificate not signed by Android") + } + default: + return nil, NewDetailedError(ErrorBadAttestationStatementType, "Invalid root certificate signature algorithm") + } + + // Validate the full chain including root as trust anchor + roots := x509.NewCertPool() + roots.AddCert(root) + + if _, err := leaf.Verify(x509.VerifyOptions{ + Intermediates: intermediates, + Roots: roots, + CurrentTime: time.Now().Add(2 * time.Second).Truncate(time.Second), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + }); err != nil { + return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "x5c chain verification failed") + } + + // Get signature + sig, ok := att.AttStatement["sig"].([]byte) + if !ok { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "sig not present") + } + + keyAuth, err := KeyAuthorization(ch.Token, jwk) + if err != nil { + return nil, err + } + + // Parse attestation data: + // find the attestation certificate + attCert, err := findAndroidAttestationCert(certs) + if err != nil { + return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "") + } + + switch pub := attCert.PublicKey.(type) { + case *ecdsa.PublicKey: + if pub.Curve != elliptic.P256() { + return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "unsupported elliptic curve %s", pub.Curve) + } + sum := sha256.Sum256([]byte(keyAuth)) + if !ecdsa.VerifyASN1(pub, sum[:], sig) { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "failed to validate signature") + } + case *rsa.PublicKey: + sum := sha256.Sum256([]byte(keyAuth)) + if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig); err != nil { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "failed to validate signature") + } + case ed25519.PublicKey: + if !ed25519.Verify(pub, []byte(keyAuth), sig) { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "failed to validate signature") + } + default: + return nil, NewDetailedError(ErrorBadAttestationStatementType, "unsupported public key type %T", pub) + } + + data := &androidKeyAttestationData{ + Certificate: attCert, + } + if data.Fingerprint, err = keyutil.Fingerprint(attCert.PublicKey); err != nil { + return nil, WrapErrorISE(err, "error calculating key fingerprint") + } + + for _, ext := range attCert.Extensions { + if !ext.Id.Equal(oidAndroidAttestation) { + continue + } + keyDesc, err := attestation.ParseExtension(ext.Value) + if err != nil { + return nil, WrapError(ErrorBadAttestationStatementType, err, "error parsing attestation") + } + data.Attestation = keyDesc + break + } + + // validate challenge + if string(data.Attestation.AttestationChallenge) != keyAuth { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "Challenge mismatach: "+string(data.Attestation.AttestationChallenge)) + } + + return data, nil +} + // Yubico PIV Root CA Serial 263751 // https://developers.yubico.com/PIV/Introduction/piv-attestation-ca.pem const yubicoPIVRootCA = `-----BEGIN CERTIFICATE----- diff --git a/acme/challenge_test.go b/acme/challenge_test.go index 50af568c3..4f6d7e4af 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -31,6 +31,7 @@ import ( "time" "github.com/fxamacker/cbor/v2" + "github.com/mbreban/attestation" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -98,6 +99,24 @@ func mustAttestationProvisioner(t *testing.T, roots []byte) Provisioner { return prov } +func mustNonCRLAttestationProvisioner(t *testing.T, roots []byte, CRLs []string) Provisioner { + t.Helper() + + prov := &provisioner.ACME{ + Type: "ACME", + Name: "acme", + Challenges: []provisioner.ACMEChallenge{provisioner.DEVICE_ATTEST_01}, + AttestationRoots: roots, + RootCRLs: CRLs, + } + if err := prov.Init(provisioner.Config{ + Claims: config.GlobalProvisionerClaims, + }); err != nil { + t.Fatal(err) + } + return prov +} + func mustAccountAndKeyAuthorization(t *testing.T, token string) (*jose.JSONWebKey, string) { t.Helper() @@ -109,6 +128,75 @@ func mustAccountAndKeyAuthorization(t *testing.T, token string) (*jose.JSONWebKe return jwk, keyAuth } +func mustAttestAndroid(t *testing.T, keyAuthorization string) ([]byte, *x509.Certificate, *x509.Certificate, *x509.Certificate) { + t.Helper() + + ca, err := minica.New() + fatalError(t, err) + + signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + fatalError(t, err) + + keyAuthSum := sha256.Sum256([]byte(keyAuthorization)) + fatalError(t, err) + + sig, err := signer.Sign(rand.Reader, keyAuthSum[:], crypto.SHA256) + fatalError(t, err) + + atts := attestation.KeyDescription{ + AttestationVersion: 300, + AttestationSecurityLevel: 1, + AttestationChallenge: sig, + TeeEnforced: attestation.AuthorizationList{ + AttestationIdSerial: []byte("serial-number"), + }, + } + attestByte, err := attestation.CreateKeyDescription(&atts) + if err != nil { + fatalError(t, err) + } + + block, _ := pem.Decode([]byte(AndroidRootCAPubKey)) + trustedPubKey, err := x509.ParsePKIXPublicKey(block.Bytes) + + rootAndroid, err := ca.Sign(&x509.Certificate{ + Subject: pkix.Name{CommonName: "attestation cert"}, + PublicKey: trustedPubKey, + Extensions: []pkix.Extension{ + {Id: oidAndroidAttestation, Value: attestByte}, + }, + }) + + leaf, err := ca.Sign(&x509.Certificate{ + Subject: pkix.Name{CommonName: "attestation cert"}, + PublicKey: signer.Public(), + Extensions: []pkix.Extension{ + {Id: oidAndroidAttestation, Value: attestByte}, + }, + }) + fatalError(t, err) + + attObj, err := cbor.Marshal(struct { + Format string `json:"fmt"` + AttStatement map[string]interface{} `json:"attStmt,omitempty"` + }{ + Format: "android-key", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw, ca.Intermediate.Raw, rootAndroid}, + }, + }) + fatalError(t, err) + + payload, err := json.Marshal(struct { + AttObj string `json:"attObj"` + }{ + AttObj: base64.RawURLEncoding.EncodeToString(attObj), + }) + fatalError(t, err) + + return payload, leaf, ca.Root, rootAndroid +} + func mustAttestApple(t *testing.T, nonce string) ([]byte, *x509.Certificate, *x509.Certificate) { t.Helper() @@ -4503,6 +4591,96 @@ func Test_deviceAttest01Validate(t *testing.T) { wantErr: nil, } }, + "ok/doAndroidAttestationFormat": func(t *testing.T) test { + + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + payload, _, root, _ := mustAttestAndroid(t, keyAuth) + + caRoot := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: root.Raw}) + ctx := NewProvisionerContext(context.Background(), mustAttestationProvisioner(t, caRoot)) + return test{ + args: args{ + ctx: ctx, + jwk: jwk, + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + Token: "nonce", + Type: "device-attest-01", + Status: StatusPending, + Value: "serial-number", + }, + payload: payload, + db: &MockDB{ + MockGetAuthorization: func(ctx context.Context, id string) (*Authorization, error) { + assert.Equal(t, "azID", id) + return &Authorization{ID: "azID"}, nil + }, + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "nonce", updch.Token) + assert.Equal(t, StatusInvalid, updch.Status) + assert.Equal(t, ChallengeType("device-attest-01"), updch.Type) + assert.Equal(t, "serial-number", updch.Value) + assert.Nil(t, updch.Payload) + assert.Empty(t, updch.PayloadFormat) + + return nil + }, + }, + }, + wantErr: nil, + } + }, + "ok/doAndroidAttestationFormat-invalid-root": func(t *testing.T) test { + + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + payload, _, root, attestationRoot := mustAttestAndroid(t, keyAuth) + + caRoot := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: root.Raw}) + ctx := NewProvisionerContext(context.Background(), mustNonCRLAttestationProvisioner(t, caRoot, []string{attestationRoot.SerialNumber.String()})) + return test{ + args: args{ + ctx: ctx, + jwk: jwk, + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + Token: "nonce", + Type: "device-attest-01", + Status: StatusPending, + Value: "serial-number", + }, + payload: payload, + db: &MockDB{ + MockGetAuthorization: func(ctx context.Context, id string) (*Authorization, error) { + assert.Equal(t, "azID", id) + return &Authorization{ID: "azID"}, nil + }, + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "nonce", updch.Token) + assert.Equal(t, StatusInvalid, updch.Status) + assert.Equal(t, ChallengeType("device-attest-01"), updch.Type) + assert.Equal(t, "serial-number", updch.Value) + assert.Nil(t, updch.Payload) + assert.Empty(t, updch.PayloadFormat) + + err := NewDetailedError(ErrorBadAttestationStatementType, "x5c element contain a revoked certificate") + + assert.EqualError(t, updch.Error.Err, err.Err.Error()) + assert.Equal(t, err.Type, updch.Error.Type) + assert.Equal(t, err.Detail, updch.Error.Detail) + assert.Equal(t, err.Status, updch.Error.Status) + assert.Equal(t, err.Subproblems, updch.Error.Subproblems) + + return nil + }, + }, + }, + wantErr: nil, + } + }, "ok/doStepAttestationFormat-storeError": func(t *testing.T) test { ca, err := minica.New() require.NoError(t, err) diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index b014e6754..8106a5d42 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -3,9 +3,14 @@ package provisioner import ( "context" "crypto/x509" + "encoding/json" "encoding/pem" "fmt" + "io" + "log" "net" + "net/http" + "slices" "strings" "time" @@ -53,6 +58,9 @@ func (c ACMEChallenge) Validate() error { type ACMEAttestationFormat string const ( + // APPLE is the format used to enable device-attest-01 on Apple devices. + ANDROID ACMEAttestationFormat = "android-key" + // APPLE is the format used to enable device-attest-01 on Apple devices. APPLE ACMEAttestationFormat = "apple" @@ -74,7 +82,7 @@ func (f ACMEAttestationFormat) String() string { // Validate returns an error if the attestation format is not a valid one. func (f ACMEAttestationFormat) Validate() error { switch ACMEAttestationFormat(f.String()) { - case APPLE, STEP, TPM: + case APPLE, STEP, TPM, ANDROID: return nil default: return fmt.Errorf("acme attestation format %q is not supported", f) @@ -120,6 +128,8 @@ type ACME struct { AttestationRoots []byte `json:"attestationRoots,omitempty"` Claims *Claims `json:"claims,omitempty"` Options *Options `json:"options,omitempty"` + RootCRLs []string `json:"rootCRLs,omitempty"` + androidCRLTimeout time.Time attestationRootPool *x509.CertPool ctl *Controller } @@ -217,10 +227,50 @@ func (p *ACME) Init(config Config) (err error) { return fmt.Errorf("failed initializing Wire options: %w", err) } + if slices.Contains(p.AttestationFormats, "android-key") && len(p.RootCRLs) == 0 { + p.initializeAndroidCRL() + } + p.ctl, err = NewController(p, p.Claims, config, p.Options) return } +const ANDROID_ATTESTATION_STATUS_URL = "https://android.googleapis.com/attestation/status" + +// fetch CRL https://android.googleapis.com/attestation/status and build a list of serial number +func (p *ACME) initializeAndroidCRL() error { + log.Printf("Updating Android CRL list for %s ACME provisioner", p.Name) + var CRLResponse struct { + Entries map[string]struct { + Status string `json:"status"` + Reason string `json:"reason"` + } `json:"entries"` + } + res, err := http.Get(ANDROID_ATTESTATION_STATUS_URL) + if err != nil { + return fmt.Errorf("client: error making Android CRL request: %s\n", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(res.Body) + return fmt.Errorf("unexpected Android CRL response %d: %s", res.StatusCode, string(bodyBytes)) + } + + if err := json.NewDecoder(res.Body).Decode(&CRLResponse); err != nil { + return fmt.Errorf("error decoding Android CRL JSON: %w", err) + } + + // Extract keys into a slice + keys := make([]string, 0, len(CRLResponse.Entries)) + for k := range CRLResponse.Entries { + keys = append(keys, k) + } + p.RootCRLs = keys + p.androidCRLTimeout = time.Now().Add(24 * time.Hour) + return nil +} + // initializeWireOptions initializes the options for the ACME Wire // integration. It'll return early if no Wire challenge types are // enabled. @@ -372,7 +422,7 @@ func (p *ACME) IsChallengeEnabled(_ context.Context, challenge ACMEChallenge) bo // AttestationFormat provisioner property should have at least one element. func (p *ACME) IsAttestationFormatEnabled(_ context.Context, format ACMEAttestationFormat) bool { enabledFormats := []ACMEAttestationFormat{ - APPLE, STEP, TPM, + APPLE, STEP, TPM, ANDROID, } if len(p.AttestationFormats) > 0 { enabledFormats = p.AttestationFormats @@ -393,3 +443,12 @@ func (p *ACME) IsAttestationFormatEnabled(_ context.Context, format ACMEAttestat func (p *ACME) GetAttestationRoots() (*x509.CertPool, bool) { return p.attestationRootPool, p.attestationRootPool != nil } + +// IsRootRevoked return a true if the serialNumber is part of the list +// It will also be in charge of updating the list periodically if no CRL list is provided at configuration. +func (p *ACME) IsRootRevoked(serialNumber string) bool { + if slices.Contains(p.AttestationFormats, "android-key") && !p.androidCRLTimeout.IsZero() && time.Now().After(p.androidCRLTimeout) { + p.initializeAndroidCRL() + } + return len(p.RootCRLs) > 0 && slices.Contains(p.RootCRLs, serialNumber) +} diff --git a/authority/provisioner/acme_test.go b/authority/provisioner/acme_test.go index f51698091..3aaa88768 100644 --- a/authority/provisioner/acme_test.go +++ b/authority/provisioner/acme_test.go @@ -51,6 +51,7 @@ func TestACMEAttestationFormat_Validate(t *testing.T) { f ACMEAttestationFormat wantErr bool }{ + {"android", ANDROID, false}, {"apple", APPLE, false}, {"step", STEP, false}, {"tpm", TPM, false}, @@ -201,7 +202,7 @@ MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k= Name: "foo", Type: "ACME", Challenges: []ACMEChallenge{DNS_01, DEVICE_ATTEST_01}, - AttestationFormats: []ACMEAttestationFormat{APPLE, STEP}, + AttestationFormats: []ACMEAttestationFormat{APPLE, STEP, ANDROID}, AttestationRoots: bytes.Join([][]byte{appleCA, yubicoCA}, []byte("\n")), }, } @@ -429,14 +430,16 @@ func TestACME_IsAttestationFormatEnabled(t *testing.T) { args args want bool }{ - {"ok", fields{[]ACMEAttestationFormat{APPLE, STEP, TPM}}, args{ctx, TPM}, true}, + {"ok", fields{[]ACMEAttestationFormat{APPLE, STEP, TPM, ANDROID}}, args{ctx, TPM}, true}, {"ok empty apple", fields{nil}, args{ctx, APPLE}, true}, {"ok empty step", fields{nil}, args{ctx, STEP}, true}, {"ok empty tpm", fields{[]ACMEAttestationFormat{}}, args{ctx, "tpm"}, true}, + {"ok empty android", fields{[]ACMEAttestationFormat{}}, args{ctx, "android-key"}, true}, {"ok uppercase", fields{[]ACMEAttestationFormat{APPLE, STEP, TPM}}, args{ctx, "STEP"}, true}, {"fail apple", fields{[]ACMEAttestationFormat{STEP, TPM}}, args{ctx, APPLE}, false}, {"fail step", fields{[]ACMEAttestationFormat{APPLE, TPM}}, args{ctx, STEP}, false}, {"fail step", fields{[]ACMEAttestationFormat{APPLE, STEP}}, args{ctx, TPM}, false}, + {"fail android", fields{[]ACMEAttestationFormat{APPLE, STEP}}, args{ctx, ANDROID}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/authority/provisioners.go b/authority/provisioners.go index d01048564..4107baa8b 100644 --- a/authority/provisioners.go +++ b/authority/provisioners.go @@ -1360,6 +1360,8 @@ func attestationFormatsToCertificates(formats []linkedca.ACMEProvisioner_Attesta ret := make([]provisioner.ACMEAttestationFormat, 0, len(formats)) for _, f := range formats { switch f { + case 4: + ret = append(ret, provisioner.ANDROID) case linkedca.ACMEProvisioner_APPLE: ret = append(ret, provisioner.APPLE) case linkedca.ACMEProvisioner_STEP: @@ -1377,6 +1379,8 @@ func attestationFormatsToLinkedca(formats []provisioner.ACMEAttestationFormat) [ ret := make([]linkedca.ACMEProvisioner_AttestationFormatType, 0, len(formats)) for _, f := range formats { switch provisioner.ACMEAttestationFormat(f.String()) { + case provisioner.ANDROID: + ret = append(ret, 4) case provisioner.APPLE: ret = append(ret, linkedca.ACMEProvisioner_APPLE) case provisioner.STEP: diff --git a/go.mod b/go.mod index 5a26552b9..de82d3c0e 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/hashicorp/vault/api/auth/approle v0.12.0 github.com/hashicorp/vault/api/auth/aws v0.12.0 github.com/hashicorp/vault/api/auth/kubernetes v0.12.0 + github.com/mbreban/attestation v0.1.0 github.com/newrelic/go-agent/v3 v3.44.1 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.24.0 diff --git a/go.sum b/go.sum index 47ea88d4c..1365414ab 100644 --- a/go.sum +++ b/go.sum @@ -293,6 +293,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mbreban/attestation v0.1.0 h1:oNPb7tdTboEw14lXdXCAvzd2fq/W5yyVlbO+01kAb0w= +github.com/mbreban/attestation v0.1.0/go.mod h1:YWaxLRaBYCI4+EvJIOaMtEiP/8m9XTN3u0ltPWbfZ1Y= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/miekg/pkcs11 v1.1.2 h1:/VxmeAX5qU6Q3EwafypogwWbYryHFmF2RpkJmw3m4MQ= From 06a185f2b87d2e2de3799badb10dcf3df1448a28 Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Fri, 10 Jul 2026 10:52:19 +0200 Subject: [PATCH 02/12] Use `ANDROID_KEY` enum, and do appropriate `linkedca` mapping --- acme/challenge_test.go | 8 +-- authority/provisioner/acme.go | 8 +-- authority/provisioner/acme_test.go | 8 +-- authority/provisioners.go | 8 +-- go.mod | 46 +++++++-------- go.sum | 91 +++++++++++++++--------------- 6 files changed, 86 insertions(+), 83 deletions(-) diff --git a/acme/challenge_test.go b/acme/challenge_test.go index 4f6d7e4af..2fe22cedf 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -177,12 +177,12 @@ func mustAttestAndroid(t *testing.T, keyAuthorization string) ([]byte, *x509.Cer fatalError(t, err) attObj, err := cbor.Marshal(struct { - Format string `json:"fmt"` - AttStatement map[string]interface{} `json:"attStmt,omitempty"` + Format string `json:"fmt"` + AttStatement map[string]any `json:"attStmt,omitempty"` }{ Format: "android-key", - AttStatement: map[string]interface{}{ - "x5c": []interface{}{leaf.Raw, ca.Intermediate.Raw, rootAndroid}, + AttStatement: map[string]any{ + "x5c": []any{leaf.Raw, ca.Intermediate.Raw, rootAndroid}, }, }) fatalError(t, err) diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index 8106a5d42..1741baeab 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -58,8 +58,8 @@ func (c ACMEChallenge) Validate() error { type ACMEAttestationFormat string const ( - // APPLE is the format used to enable device-attest-01 on Apple devices. - ANDROID ACMEAttestationFormat = "android-key" + // ANDROID_KEY is the format used to enable device-attest-01 on Android devices. + ANDROID_KEY ACMEAttestationFormat = "android-key" // APPLE is the format used to enable device-attest-01 on Apple devices. APPLE ACMEAttestationFormat = "apple" @@ -82,7 +82,7 @@ func (f ACMEAttestationFormat) String() string { // Validate returns an error if the attestation format is not a valid one. func (f ACMEAttestationFormat) Validate() error { switch ACMEAttestationFormat(f.String()) { - case APPLE, STEP, TPM, ANDROID: + case APPLE, STEP, TPM, ANDROID_KEY: return nil default: return fmt.Errorf("acme attestation format %q is not supported", f) @@ -422,7 +422,7 @@ func (p *ACME) IsChallengeEnabled(_ context.Context, challenge ACMEChallenge) bo // AttestationFormat provisioner property should have at least one element. func (p *ACME) IsAttestationFormatEnabled(_ context.Context, format ACMEAttestationFormat) bool { enabledFormats := []ACMEAttestationFormat{ - APPLE, STEP, TPM, ANDROID, + APPLE, STEP, TPM, ANDROID_KEY, } if len(p.AttestationFormats) > 0 { enabledFormats = p.AttestationFormats diff --git a/authority/provisioner/acme_test.go b/authority/provisioner/acme_test.go index 3aaa88768..3288183f9 100644 --- a/authority/provisioner/acme_test.go +++ b/authority/provisioner/acme_test.go @@ -51,7 +51,7 @@ func TestACMEAttestationFormat_Validate(t *testing.T) { f ACMEAttestationFormat wantErr bool }{ - {"android", ANDROID, false}, + {"android-key", ANDROID_KEY, false}, {"apple", APPLE, false}, {"step", STEP, false}, {"tpm", TPM, false}, @@ -202,7 +202,7 @@ MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k= Name: "foo", Type: "ACME", Challenges: []ACMEChallenge{DNS_01, DEVICE_ATTEST_01}, - AttestationFormats: []ACMEAttestationFormat{APPLE, STEP, ANDROID}, + AttestationFormats: []ACMEAttestationFormat{APPLE, STEP, ANDROID_KEY}, AttestationRoots: bytes.Join([][]byte{appleCA, yubicoCA}, []byte("\n")), }, } @@ -430,7 +430,7 @@ func TestACME_IsAttestationFormatEnabled(t *testing.T) { args args want bool }{ - {"ok", fields{[]ACMEAttestationFormat{APPLE, STEP, TPM, ANDROID}}, args{ctx, TPM}, true}, + {"ok", fields{[]ACMEAttestationFormat{APPLE, STEP, TPM, ANDROID_KEY}}, args{ctx, TPM}, true}, {"ok empty apple", fields{nil}, args{ctx, APPLE}, true}, {"ok empty step", fields{nil}, args{ctx, STEP}, true}, {"ok empty tpm", fields{[]ACMEAttestationFormat{}}, args{ctx, "tpm"}, true}, @@ -439,7 +439,7 @@ func TestACME_IsAttestationFormatEnabled(t *testing.T) { {"fail apple", fields{[]ACMEAttestationFormat{STEP, TPM}}, args{ctx, APPLE}, false}, {"fail step", fields{[]ACMEAttestationFormat{APPLE, TPM}}, args{ctx, STEP}, false}, {"fail step", fields{[]ACMEAttestationFormat{APPLE, STEP}}, args{ctx, TPM}, false}, - {"fail android", fields{[]ACMEAttestationFormat{APPLE, STEP}}, args{ctx, ANDROID}, false}, + {"fail android", fields{[]ACMEAttestationFormat{APPLE, STEP}}, args{ctx, ANDROID_KEY}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/authority/provisioners.go b/authority/provisioners.go index 4107baa8b..d9f60c344 100644 --- a/authority/provisioners.go +++ b/authority/provisioners.go @@ -1360,8 +1360,8 @@ func attestationFormatsToCertificates(formats []linkedca.ACMEProvisioner_Attesta ret := make([]provisioner.ACMEAttestationFormat, 0, len(formats)) for _, f := range formats { switch f { - case 4: - ret = append(ret, provisioner.ANDROID) + case linkedca.ACMEProvisioner_ANDROID_KEY: + ret = append(ret, provisioner.ANDROID_KEY) case linkedca.ACMEProvisioner_APPLE: ret = append(ret, provisioner.APPLE) case linkedca.ACMEProvisioner_STEP: @@ -1379,8 +1379,8 @@ func attestationFormatsToLinkedca(formats []provisioner.ACMEAttestationFormat) [ ret := make([]linkedca.ACMEProvisioner_AttestationFormatType, 0, len(formats)) for _, f := range formats { switch provisioner.ACMEAttestationFormat(f.String()) { - case provisioner.ANDROID: - ret = append(ret, 4) + case provisioner.ANDROID_KEY: + ret = append(ret, linkedca.ACMEProvisioner_ANDROID_KEY) case provisioner.APPLE: ret = append(ret, linkedca.ACMEProvisioner_APPLE) case provisioner.STEP: diff --git a/go.mod b/go.mod index de82d3c0e..de6db04c5 100644 --- a/go.mod +++ b/go.mod @@ -32,13 +32,13 @@ require ( github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 github.com/smallstep/cli-utils v0.12.2 github.com/smallstep/go-attestation v0.4.4-0.20260603212853-e1a87a0b07d9 - github.com/smallstep/linkedca v0.25.0 + github.com/smallstep/linkedca v0.26.0 github.com/smallstep/nosql v0.8.0 github.com/smallstep/pkcs7 v0.2.1 github.com/smallstep/scep v0.0.0-20250318231241-a25cabb69492 github.com/stretchr/testify v1.11.1 github.com/urfave/cli v1.22.17 - go.step.sm/crypto v0.84.1 + go.step.sm/crypto v0.85.0 go.uber.org/mock v0.6.0 golang.org/x/crypto v0.54.0 golang.org/x/net v0.57.0 @@ -67,23 +67,23 @@ require ( github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.3.1 // indirect - github.com/ThalesGroup/crypto11 v1.6.0 // indirect + github.com/ThalesGroup/crypto11 v1.6.2 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect - github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.25 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect - github.com/aws/aws-sdk-go-v2/service/kms v1.53.4 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect - github.com/aws/smithy-go v1.27.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.30 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.29 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect + github.com/aws/aws-sdk-go-v2/service/kms v1.54.1 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect + github.com/aws/smithy-go v1.27.3 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash v1.1.0 // indirect @@ -105,8 +105,8 @@ require ( github.com/golang/glog v1.2.5 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect - github.com/google/btree v1.1.2 // indirect - github.com/google/certificate-transparency-go v1.1.7 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/certificate-transparency-go v1.3.2 // indirect github.com/google/go-tpm-tools v0.4.9 // indirect github.com/google/go-tspi v0.3.0 // indirect github.com/google/s2a-go v0.1.9 // indirect @@ -128,7 +128,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 // indirect github.com/klauspost/compress v1.19.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/manifoldco/promptui v0.9.0 // indirect @@ -172,6 +172,8 @@ require ( google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect - google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect + google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/mbreban/attestation => ./../attestation // TODO: remove; use hslatman or smallstep fork diff --git a/go.sum b/go.sum index 1365414ab..e17406d1b 100644 --- a/go.sum +++ b/go.sum @@ -50,42 +50,42 @@ github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/ThalesGroup/crypto11 v1.6.0 h1:Og9EMn44fBS4GNnGnH1aqHnF2wL6F7IU/RhpJajWX/4= -github.com/ThalesGroup/crypto11 v1.6.0/go.mod h1:H6LRjN5R5SHxTrLqGNteisLDI0/IC6+SGx1pHtbwizE= +github.com/ThalesGroup/crypto11 v1.6.2 h1:X+JsrOlKanaIlHgwV/3MJ/cFivbEaQ8kpXRPoiC2T+c= +github.com/ThalesGroup/crypto11 v1.6.2/go.mod h1:fQ61t02lJdXD2HrG7upt/VRlDECsipwtqpPcZJEjBKg= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/aws/aws-sdk-go v1.34.0/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= -github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= -github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM= -github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs= -github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I= -github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= -github.com/aws/aws-sdk-go-v2/service/kms v1.53.4 h1:PEgVSsWtR8NNxsDxFL2Ywisi7R+1EFQARGsT4q3mWwI= -github.com/aws/aws-sdk-go-v2/service/kms v1.53.4/go.mod h1:3EeKyDGPGSCEphG2OolwNGNF45RvQIfm27AYYpfEWrw= -github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8= -github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4= -github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI= -github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= -github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= -github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= +github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/config v1.32.30 h1:XwsEzpTJfQYJbFicz/QMLwAZdyeNVVoOEkbF7R3gPJk= +github.com/aws/aws-sdk-go-v2/config v1.32.30/go.mod h1:Ud32SuMc+/9BGxfpSVld7HrE2o05JwKmXY4M3jOQNZU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.29 h1:WHZGssHH887cO0ox07SIQZsFx3MKD4ps6w0xUEmnKYQ= +github.com/aws/aws-sdk-go-v2/credentials v1.19.29/go.mod h1:Mhl0xR6zjguiuj00XRx2wMx22sAltk7oya39sT7fdg8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= +github.com/aws/aws-sdk-go-v2/service/kms v1.54.1 h1:aeJAJyvWS3gQ679pJbz8ZdOh3MViD1zvEdoZMVEawbg= +github.com/aws/aws-sdk-go-v2/service/kms v1.54.1/go.mod h1:0RXNc6Yf3AvSMldGD6Lcch96Ojlw2TtGnHsqfD/L4u8= +github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 h1:V7ZZ300WPXGjvkyore5DGe0ljVPOxCXie/thWdtSBXE= +github.com/aws/aws-sdk-go-v2/service/signin v1.4.1/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg= +github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 h1:gYFYh4iLLcAOJRLNPY2aD2g9DIhKn4eof8UkIrr1rTk= +github.com/aws/aws-sdk-go-v2/service/sso v1.32.1/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 h1:arjT9Cm3/WYbGmD5TUZHk4UQn4Lle1fUNZs5FC6CtF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84= +github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 h1:RvfHDg+xvAeZ+5741vUEjpOVtYSIm93W2zhx10Xtydw= +github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/ccoveille/go-safecast/v2 v2.0.1 h1:2+mIu3gXtwmWelBia2kkxfB8eP4orTHDH7ClSlWkd6I= @@ -182,11 +182,11 @@ github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= -github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= -github.com/google/certificate-transparency-go v1.1.7 h1:IASD+NtgSTJLPdzkthwvAG1ZVbF2WtFg4IvoA68XGSw= -github.com/google/certificate-transparency-go v1.1.7/go.mod h1:FSSBo8fyMVgqptbfF6j5p/XNdgQftAhSmXcIxV9iphE= +github.com/google/certificate-transparency-go v1.3.2 h1:9ahSNZF2o7SYMaKaXhAumVEzXB2QaayzII9C8rv7v+A= +github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCYsxg6sELw3Flkl7pGZzWdBoYLXs= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -262,8 +262,9 @@ github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= @@ -293,8 +294,6 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mbreban/attestation v0.1.0 h1:oNPb7tdTboEw14lXdXCAvzd2fq/W5yyVlbO+01kAb0w= -github.com/mbreban/attestation v0.1.0/go.mod h1:YWaxLRaBYCI4+EvJIOaMtEiP/8m9XTN3u0ltPWbfZ1Y= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/miekg/pkcs11 v1.1.2 h1:/VxmeAX5qU6Q3EwafypogwWbYryHFmF2RpkJmw3m4MQ= @@ -355,12 +354,14 @@ github.com/slackhq/nebula v1.10.3 h1:EstYj8ODEcv6T0R9X5BVq1zgWZnyU5gtPzk99QF1PMU github.com/slackhq/nebula v1.10.3/go.mod h1:IL5TUQm4x9IFx2kCKPYm1gP47pwd5b8QGnnBH2RHnvs= github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY= github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc= +github.com/smallstep/certinfo v1.16.0 h1:ZxDI9EDmCh4B/j9YtlTk/6ut+H/Gi0N3d0TwHv7F2YY= +github.com/smallstep/certinfo v1.16.0/go.mod h1:OPwtFVAOx29OjOYsVtj9cDliDFywkVYPt+ExDg43kPs= github.com/smallstep/cli-utils v0.12.2 h1:lGzM9PJrH/qawbzMC/s2SvgLdJPKDWKwKzx9doCVO+k= github.com/smallstep/cli-utils v0.12.2/go.mod h1:uCPqefO29goHLGqFnwk0i8W7XJu18X3WHQFRtOm/00Y= github.com/smallstep/go-attestation v0.4.4-0.20260603212853-e1a87a0b07d9 h1:n+X1wnMKJMcCRd98YKAo/56tMRSPUg+qjAvNNS1EZeM= github.com/smallstep/go-attestation v0.4.4-0.20260603212853-e1a87a0b07d9/go.mod h1:vNAduivU014fubg6ewygkAvQC0IQVXqdc8vaGl/0er4= -github.com/smallstep/linkedca v0.25.0 h1:txT9QHGbCsJq0MhAghBq7qhurGY727tQuqUi+n4BVBo= -github.com/smallstep/linkedca v0.25.0/go.mod h1:Q3jVAauFKNlF86W5/RFtgQeyDKz98GL/KN3KG4mJOvc= +github.com/smallstep/linkedca v0.26.0 h1:NsxTVo3zI3KwOFFiVodeHtuGgKq0b5kOUXVLjjNTvXY= +github.com/smallstep/linkedca v0.26.0/go.mod h1:Z8c7EgVrSHNhshIhRnUGfKfE796+TwF2eyErhqANmJQ= github.com/smallstep/nosql v0.8.0 h1:FBTCUfKPmWYbrozW+RBKu+fnvbn+zr5rVli/XB4Jp4A= github.com/smallstep/nosql v0.8.0/go.mod h1:5dUpNotHLHhOUapP0PLBVVfp3tG1DFC31VRccg+Cqwo= github.com/smallstep/pkcs7 v0.2.1 h1:6Kfzr/QizdIuB6LSv8y1LJdZ3aPSfTNhTLqAx9CTLfA= @@ -422,8 +423,8 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -go.step.sm/crypto v0.84.1 h1:i0JNkcLT7LcXef00TNpckjoTTH0QP6REHcAvnY3qrNY= -go.step.sm/crypto v0.84.1/go.mod h1:T54MIdw42uZnz3+mjOIOpJbsbTZF+O3IOLhvU9lBoJk= +go.step.sm/crypto v0.85.0 h1:h3U9gzCcxP1fWbseqy+CwsTxzHFNuoblefy7YP4jBHk= +go.step.sm/crypto v0.85.0/go.mod h1:Rp/BdP0/ZJ7eYREhXcE4izCABLp0e8ziFqb0LBNG7Cc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -540,8 +541,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 h1:rgSNvqscFZ1JgV/4wH5GOsZFSFkR2Eua9As3KIr2LlM= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2/go.mod h1:iMEtFwDlAhjDU9L5mY6U1XLwlIId/G3h+QcBHDIvrJ8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From f500b3baad24297f7298cf0642335bd50e327d6b Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Tue, 21 Jul 2026 23:47:38 +0200 Subject: [PATCH 03/12] Fix `android-key` test cases --- acme/challenge.go | 39 +++++++++++++---------- acme/challenge_test.go | 72 +++++++++++++++++++----------------------- 2 files changed, 55 insertions(+), 56 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index 33283599a..c491c1a3a 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -862,11 +862,13 @@ func deviceAttest01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose return WrapErrorISE(err, "error validating attestation") } + // Enforce hardware security level (TrustedEnvironment or StrongBox; Software not allowed) // 1. attestationSecurityLevel > 0 if data.Attestation.AttestationSecurityLevel < 1 { - return storeError(ctx, db, ch, true, NewDetailedError(ErrorBadAttestationStatementType, "Security Level does not match")) + return storeError(ctx, db, ch, true, NewDetailedError(ErrorBadAttestationStatementType, "insufficient security level: %d", data.Attestation.AttestationSecurityLevel)) } + // Enforce hardware backed device serial // 2. hardwareEnforced if ch.Value != string(data.Attestation.TeeEnforced.AttestationIdSerial) { subproblem := NewSubproblemWithIdentifier( @@ -1404,7 +1406,7 @@ func doAppleAttestationFormat(_ context.Context, prov Provisioner, _ *Challenge, // Android Root CA // https://developer.android.com/privacy-and-security/security-key-attestation#root_certificate -const AndroidRootCAPubKey = `-----BEGIN PUBLIC KEY----- +var AndroidRootCAPubKey = `-----BEGIN PUBLIC KEY----- MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xU FmOr75gvMsd/dTEDDJdSSxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5j lRfdnJLmN0pTy/4lj4/7tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y @@ -1419,7 +1421,7 @@ ixPvZtXQpUpuL12ab+9EaDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+ NpUFgNPN9PvQi8WEg5UmAGMCAwEAAQ== -----END PUBLIC KEY-----` -// Attestion oid for Android, encoded as an integer. +// Attestion OID for Android Key Attestation // https://source.android.com/docs/security/features/keystore/attestation#id-attestation var oidAndroidAttestation = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 1, 17} @@ -1450,9 +1452,9 @@ func findAndroidAttestationCert(intermediates []*x509.Certificate) (*x509.Certif // 7. Check the extension data that you've retrieved in the previous steps for consistency and compare with the set of values that you expect the hardware-backed key to contain. func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challenge, jwk *jose.JSONWebKey, att *attestationObject) (*androidKeyAttestationData, error) { - // Extract x5c and verify certificate - acme := prov.(*provisioner.ACME) - certs := []*x509.Certificate{} + acmeProv := prov.(*provisioner.ACME) + + // extract x5c and verify certificate x5c, ok := att.AttStatement["x5c"].([]any) if !ok { return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c not present") @@ -1468,6 +1470,8 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe if err != nil { return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "failed to parse leaf certificate") } + + var certs = make([]*x509.Certificate, 0, len(x5c)) certs = append(certs, leaf) // Parse intermediates and root @@ -1483,8 +1487,8 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "failed to parse intermediate/root certificate") } // Verify CRL - if acme.IsRootRevoked(cert.SerialNumber.String()) { - return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c element contain a revoked certificate") + if acmeProv.IsRootRevoked(cert.SerialNumber.String()) { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c element contains a revoked certificate") } if i == len(x5c)-2 { // Last cert = root @@ -1502,9 +1506,13 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe block, _ := pem.Decode([]byte(AndroidRootCAPubKey)) trustedPubKey, err := x509.ParsePKIXPublicKey(block.Bytes) - switch root.PublicKey.(type) { + switch pk := root.PublicKey.(type) { case *rsa.PublicKey: - if !root.PublicKey.(*rsa.PublicKey).Equal(trustedPubKey) { + if !pk.Equal(trustedPubKey) { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "Root certificate not signed by Android") + } + case *ecdsa.PublicKey: + if !pk.Equal(trustedPubKey) { return nil, NewDetailedError(ErrorBadAttestationStatementType, "Root certificate not signed by Android") } default: @@ -1518,7 +1526,7 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe if _, err := leaf.Verify(x509.VerifyOptions{ Intermediates: intermediates, Roots: roots, - CurrentTime: time.Now().Add(2 * time.Second).Truncate(time.Second), + CurrentTime: time.Now().Truncate(time.Second), KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, }); err != nil { return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "x5c chain verification failed") @@ -1535,11 +1543,10 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe return nil, err } - // Parse attestation data: - // find the attestation certificate + // Find the attestation certificate attCert, err := findAndroidAttestationCert(certs) if err != nil { - return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "") + return nil, NewDetailedError(ErrorBadAttestationStatementType, "%s", err.Error()) } switch pub := attCert.PublicKey.(type) { @@ -1583,9 +1590,9 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe break } - // validate challenge + // Validate challenge if string(data.Attestation.AttestationChallenge) != keyAuth { - return nil, NewDetailedError(ErrorBadAttestationStatementType, "Challenge mismatach: "+string(data.Attestation.AttestationChallenge)) + return nil, NewDetailedError(ErrorBadAttestationStatementType, "challenge mismatch; expected %q, got %q", keyAuth, string(data.Attestation.AttestationChallenge)) } return data, nil diff --git a/acme/challenge_test.go b/acme/challenge_test.go index 2fe22cedf..a3f040059 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -128,53 +128,45 @@ func mustAccountAndKeyAuthorization(t *testing.T, token string) (*jose.JSONWebKe return jwk, keyAuth } -func mustAttestAndroid(t *testing.T, keyAuthorization string) ([]byte, *x509.Certificate, *x509.Certificate, *x509.Certificate) { +func mustAttestAndroid(t *testing.T, keyAuthorization string) ([]byte, *x509.Certificate, *x509.Certificate) { t.Helper() ca, err := minica.New() - fatalError(t, err) + require.NoError(t, err) signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - fatalError(t, err) + require.NoError(t, err) keyAuthSum := sha256.Sum256([]byte(keyAuthorization)) - fatalError(t, err) + require.NoError(t, err) sig, err := signer.Sign(rand.Reader, keyAuthSum[:], crypto.SHA256) - fatalError(t, err) + require.NoError(t, err) atts := attestation.KeyDescription{ AttestationVersion: 300, AttestationSecurityLevel: 1, - AttestationChallenge: sig, + AttestationChallenge: []byte(keyAuthorization), TeeEnforced: attestation.AuthorizationList{ AttestationIdSerial: []byte("serial-number"), }, } attestByte, err := attestation.CreateKeyDescription(&atts) - if err != nil { - fatalError(t, err) - } - - block, _ := pem.Decode([]byte(AndroidRootCAPubKey)) - trustedPubKey, err := x509.ParsePKIXPublicKey(block.Bytes) + require.NoError(t, err) - rootAndroid, err := ca.Sign(&x509.Certificate{ - Subject: pkix.Name{CommonName: "attestation cert"}, - PublicKey: trustedPubKey, - Extensions: []pkix.Extension{ - {Id: oidAndroidAttestation, Value: attestByte}, - }, - }) + pemBlock, err := pemutil.Serialize(ca.Root.PublicKey) + require.NoError(t, err) + b := pem.EncodeToMemory(pemBlock) + AndroidRootCAPubKey = string(b) // TODO: fix; make this some type of test configuration? leaf, err := ca.Sign(&x509.Certificate{ Subject: pkix.Name{CommonName: "attestation cert"}, PublicKey: signer.Public(), - Extensions: []pkix.Extension{ + ExtraExtensions: []pkix.Extension{ {Id: oidAndroidAttestation, Value: attestByte}, }, }) - fatalError(t, err) + require.NoError(t, err) attObj, err := cbor.Marshal(struct { Format string `json:"fmt"` @@ -182,19 +174,20 @@ func mustAttestAndroid(t *testing.T, keyAuthorization string) ([]byte, *x509.Cer }{ Format: "android-key", AttStatement: map[string]any{ - "x5c": []any{leaf.Raw, ca.Intermediate.Raw, rootAndroid}, + "x5c": []any{leaf.Raw, ca.Intermediate.Raw, ca.Root.Raw}, + "sig": sig, }, }) - fatalError(t, err) + require.NoError(t, err) payload, err := json.Marshal(struct { AttObj string `json:"attObj"` }{ AttObj: base64.RawURLEncoding.EncodeToString(attObj), }) - fatalError(t, err) + require.NoError(t, err) - return payload, leaf, ca.Root, rootAndroid + return payload, leaf, ca.Root } func mustAttestApple(t *testing.T, nonce string) ([]byte, *x509.Certificate, *x509.Certificate) { @@ -4592,9 +4585,8 @@ func Test_deviceAttest01Validate(t *testing.T) { } }, "ok/doAndroidAttestationFormat": func(t *testing.T) test { - jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") - payload, _, root, _ := mustAttestAndroid(t, keyAuth) + payload, _, root := mustAttestAndroid(t, keyAuth) caRoot := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: root.Raw}) ctx := NewProvisionerContext(context.Background(), mustAttestationProvisioner(t, caRoot)) @@ -4605,7 +4597,7 @@ func Test_deviceAttest01Validate(t *testing.T) { ch: &Challenge{ ID: "chID", AuthorizationID: "azID", - Token: "nonce", + Token: "token", Type: "device-attest-01", Status: StatusPending, Value: "serial-number", @@ -4618,12 +4610,12 @@ func Test_deviceAttest01Validate(t *testing.T) { }, MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { assert.Equal(t, "chID", updch.ID) - assert.Equal(t, "nonce", updch.Token) - assert.Equal(t, StatusInvalid, updch.Status) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusValid, updch.Status) assert.Equal(t, ChallengeType("device-attest-01"), updch.Type) assert.Equal(t, "serial-number", updch.Value) - assert.Nil(t, updch.Payload) - assert.Empty(t, updch.PayloadFormat) + assert.NotNil(t, updch.Payload) // TODO: validate payload? + assert.Equal(t, "android-key", updch.PayloadFormat) return nil }, @@ -4633,12 +4625,11 @@ func Test_deviceAttest01Validate(t *testing.T) { } }, "ok/doAndroidAttestationFormat-invalid-root": func(t *testing.T) test { - jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") - payload, _, root, attestationRoot := mustAttestAndroid(t, keyAuth) + payload, _, root := mustAttestAndroid(t, keyAuth) caRoot := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: root.Raw}) - ctx := NewProvisionerContext(context.Background(), mustNonCRLAttestationProvisioner(t, caRoot, []string{attestationRoot.SerialNumber.String()})) + ctx := NewProvisionerContext(context.Background(), mustNonCRLAttestationProvisioner(t, caRoot, []string{root.SerialNumber.String()})) return test{ args: args{ ctx: ctx, @@ -4663,10 +4654,10 @@ func Test_deviceAttest01Validate(t *testing.T) { assert.Equal(t, StatusInvalid, updch.Status) assert.Equal(t, ChallengeType("device-attest-01"), updch.Type) assert.Equal(t, "serial-number", updch.Value) - assert.Nil(t, updch.Payload) + assert.NotNil(t, updch.Payload) // TODO: validate payload? assert.Empty(t, updch.PayloadFormat) - err := NewDetailedError(ErrorBadAttestationStatementType, "x5c element contain a revoked certificate") + err := NewDetailedError(ErrorBadAttestationStatementType, "x5c element contains a revoked certificate") assert.EqualError(t, updch.Error.Err, err.Err.Error()) assert.Equal(t, err.Type, updch.Error.Type) @@ -5096,14 +5087,15 @@ func Test_deviceAttest01Validate(t *testing.T) { t.Run(name, func(t *testing.T) { tc := run(t) - if err := deviceAttest01Validate(tc.args.ctx, tc.args.ch, tc.args.db, tc.args.jwk, tc.args.payload); err != nil { - if assert.Error(t, tc.wantErr) { + err := deviceAttest01Validate(tc.args.ctx, tc.args.ch, tc.args.db, tc.args.jwk, tc.args.payload) + if tc.wantErr != nil { + if assert.Error(t, err) { assert.ErrorContains(t, err, tc.wantErr.Error()) } return } - assert.Nil(t, tc.wantErr) + assert.NoError(t, err) }) } } From 9e8986c9c49801ba268e888880de0cbc56854bdb Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Wed, 22 Jul 2026 00:16:36 +0200 Subject: [PATCH 04/12] Improve some wording and style in `doAndroidKeyAttestationFormat` --- acme/challenge.go | 45 ++++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index c491c1a3a..ffae36b33 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -1431,8 +1431,8 @@ type androidKeyAttestationData struct { Attestation *attestation.KeyDescription } -func findAndroidAttestationCert(intermediates []*x509.Certificate) (*x509.Certificate, error) { - for _, cert := range intermediates { +func findAndroidAttestationCert(certs []*x509.Certificate) (*x509.Certificate, error) { + for _, cert := range certs { for _, ext := range cert.Extensions { if ext.Id.Equal(oidAndroidAttestation) { return cert, nil @@ -1442,15 +1442,18 @@ func findAndroidAttestationCert(intermediates []*x509.Certificate) (*x509.Certif return nil, errors.New("no attestation certificate with OID 1.3.6.1.4.1.11129.2.1.17 found in the cert chain") } -// https://developer.android.com/privacy-and-security/security-key-attestation -// 3. Verify that the root public certificate is trustworthy and that each certificate signs the next certificate in the chain. -// 4. Check each certificate's revocation status to ensure that none of the certificates have been revoked. -// 5. Optionally, inspect the provisioning information certificate extension that is only present in newer certificate chains. -// Obtain a reference to the CBOR parser library that is most appropriate for your toolset. Find the nearest certificate to the root that contains the provisioning information certificate extension. Use the parser to extract the provisioning information certificate extension data from that certificate. -// See the section about the provisioning information extension for more details. -// 6. Find the nearest certificate to the root that contains the key attestation certificate extension. If the provisioning information certificate extension was present, the key attestation certificate extension must be in the immediately subsequent certificate. Use the parser to extract the key attestation certificate extension data from that certificate. -// 7. Check the extension data that you've retrieved in the previous steps for consistency and compare with the set of values that you expect the hardware-backed key to contain. - +// doAndroidKeyAttestionFormat handles ACME Device Attestation requests for +// the "android-key" attestation format. Its verification logic is based on +// the documentation at https://developer.android.com/privacy-and-security/security-key-attestation +// +// This function performs the below steps +// 3. Verify that the root public certificate is trustworthy and that each certificate signs the next certificate in the chain. +// 4. Check each certificate's revocation status to ensure that none of the certificates have been revoked. +// 5. Optionally, inspect the provisioning information certificate extension that is only present in newer certificate chains. +// Obtain a reference to the CBOR parser library that is most appropriate for your toolset. Find the nearest certificate to the root that contains the provisioning information certificate extension. Use the parser to extract the provisioning information certificate extension data from that certificate. +// See the section about the provisioning information extension for more details. +// 6. Find the nearest certificate to the root that contains the key attestation certificate extension. If the provisioning information certificate extension was present, the key attestation certificate extension must be in the immediately subsequent certificate. Use the parser to extract the key attestation certificate extension data from that certificate. +// 7. Check the extension data that you've retrieved in the previous steps for consistency and compare with the set of values that you expect the hardware-backed key to contain. func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challenge, jwk *jose.JSONWebKey, att *attestationObject) (*androidKeyAttestationData, error) { acmeProv := prov.(*provisioner.ACME) @@ -1464,7 +1467,7 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe } der, ok := x5c[0].([]byte) if !ok { - return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c[0] is not a DER []byte") + return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c[0] is malformed") } leaf, err := x509.ParseCertificate(der) if err != nil { @@ -1480,11 +1483,11 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe for i, v := range x5c[1:] { der, ok := v.([]byte) if !ok { - return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c element is not a DER []byte") + return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c element is malformed") } cert, err := x509.ParseCertificate(der) if err != nil { - return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "failed to parse intermediate/root certificate") + return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "failed parsing certificate at index %d", i+1) } // Verify CRL if acmeProv.IsRootRevoked(cert.SerialNumber.String()) { @@ -1509,14 +1512,14 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe switch pk := root.PublicKey.(type) { case *rsa.PublicKey: if !pk.Equal(trustedPubKey) { - return nil, NewDetailedError(ErrorBadAttestationStatementType, "Root certificate not signed by Android") + return nil, NewDetailedError(ErrorBadAttestationStatementType, "root certificate not signed by Android") } case *ecdsa.PublicKey: if !pk.Equal(trustedPubKey) { - return nil, NewDetailedError(ErrorBadAttestationStatementType, "Root certificate not signed by Android") + return nil, NewDetailedError(ErrorBadAttestationStatementType, "root certificate not signed by Android") } default: - return nil, NewDetailedError(ErrorBadAttestationStatementType, "Invalid root certificate signature algorithm") + return nil, NewDetailedError(ErrorBadAttestationStatementType, "invalid root certificate key type") } // Validate the full chain including root as trust anchor @@ -1527,7 +1530,7 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe Intermediates: intermediates, Roots: roots, CurrentTime: time.Now().Truncate(time.Second), - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, }); err != nil { return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "x5c chain verification failed") } @@ -1556,16 +1559,16 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe } sum := sha256.Sum256([]byte(keyAuth)) if !ecdsa.VerifyASN1(pub, sum[:], sig) { - return nil, NewDetailedError(ErrorBadAttestationStatementType, "failed to validate signature") + return nil, NewDetailedError(ErrorBadAttestationStatementType, "failed validating signature") } case *rsa.PublicKey: sum := sha256.Sum256([]byte(keyAuth)) if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig); err != nil { - return nil, NewDetailedError(ErrorBadAttestationStatementType, "failed to validate signature") + return nil, NewDetailedError(ErrorBadAttestationStatementType, "failed validating signature") } case ed25519.PublicKey: if !ed25519.Verify(pub, []byte(keyAuth), sig) { - return nil, NewDetailedError(ErrorBadAttestationStatementType, "failed to validate signature") + return nil, NewDetailedError(ErrorBadAttestationStatementType, "failed validating signature") } default: return nil, NewDetailedError(ErrorBadAttestationStatementType, "unsupported public key type %T", pub) From 7370ea1c851a7c410ebefd65453c5826bb1aeae7 Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Wed, 22 Jul 2026 01:01:39 +0200 Subject: [PATCH 05/12] Update trusted Google roots and support custom attestation roots --- acme/challenge.go | 118 +++++++++++++++++++++++++++-------------- acme/challenge_test.go | 5 -- go.mod | 3 +- go.sum | 4 +- 4 files changed, 82 insertions(+), 48 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index ffae36b33..931cf480d 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -16,7 +16,6 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" - "encoding/pem" "errors" "fmt" "io" @@ -1404,22 +1403,54 @@ func doAppleAttestationFormat(_ context.Context, prov Provisioner, _ *Challenge, return data, nil } -// Android Root CA +// Android RSA Root CA // https://developer.android.com/privacy-and-security/security-key-attestation#root_certificate -var AndroidRootCAPubKey = `-----BEGIN PUBLIC KEY----- -MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xU -FmOr75gvMsd/dTEDDJdSSxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5j -lRfdnJLmN0pTy/4lj4/7tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y -//0rb+T+W8a9nsNL/ggjnar86461qO0rOs2cXjp3kOG1FEJ5MVmFmBGtnrKpa73X -pXyTqRxB/M0n1n/W9nGqC4FSYa04T6N5RIZGBN2z2MT5IKGbFlbC8UrW0DxW7AYI -mQQcHtGl/m00QLVWutHQoVJYnFPlXTcHYvASLu+RhhsbDmxMgJJ0mcDpvsC4PjvB -+TxywElgS70vE0XmLD+OJtvsBslHZvPBKCOdT0MS+tgSOIfga+z1Z1g7+DVagf7q -uvmag8jfPioyKvxnK/EgsTUVi2ghzq8wm27ud/mIM7AY2qEORR8Go3TVB4HzWQgp -Zrt3i5MIlCaY504LzSRiigHCzAPlHws+W0rB5N+er5/2pJKnfBSDiCiFAVtCLOZ7 -gLiMm0jhO2B6tUXHI/+MRPjy02i59lINMRRev56GKtcd9qO/0kUJWdZTdA2XoS82 -ixPvZtXQpUpuL12ab+9EaDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+ -NpUFgNPN9PvQi8WEg5UmAGMCAwEAAQ== ------END PUBLIC KEY-----` +var androidRSARootCA = `-----BEGIN CERTIFICATE----- +MIIFHDCCAwSgAwIBAgIJAPHBcqaZ6vUdMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV +BAUTEGY5MjAwOWU4NTNiNmIwNDUwHhcNMjIwMzIwMTgwNzQ4WhcNNDIwMzE1MTgw +NzQ4WjAbMRkwFwYDVQQFExBmOTIwMDllODUzYjZiMDQ1MIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xUFmOr75gvMsd/dTEDDJdS +Sxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5jlRfdnJLmN0pTy/4lj4/7 +tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y//0rb+T+W8a9nsNL/ggj +nar86461qO0rOs2cXjp3kOG1FEJ5MVmFmBGtnrKpa73XpXyTqRxB/M0n1n/W9nGq +C4FSYa04T6N5RIZGBN2z2MT5IKGbFlbC8UrW0DxW7AYImQQcHtGl/m00QLVWutHQ +oVJYnFPlXTcHYvASLu+RhhsbDmxMgJJ0mcDpvsC4PjvB+TxywElgS70vE0XmLD+O +JtvsBslHZvPBKCOdT0MS+tgSOIfga+z1Z1g7+DVagf7quvmag8jfPioyKvxnK/Eg +sTUVi2ghzq8wm27ud/mIM7AY2qEORR8Go3TVB4HzWQgpZrt3i5MIlCaY504LzSRi +igHCzAPlHws+W0rB5N+er5/2pJKnfBSDiCiFAVtCLOZ7gLiMm0jhO2B6tUXHI/+M +RPjy02i59lINMRRev56GKtcd9qO/0kUJWdZTdA2XoS82ixPvZtXQpUpuL12ab+9E +aDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+NpUFgNPN9PvQi8WEg5Um +AGMCAwEAAaNjMGEwHQYDVR0OBBYEFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMB8GA1Ud +IwQYMBaAFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMA8GA1UdEwEB/wQFMAMBAf8wDgYD +VR0PAQH/BAQDAgIEMA0GCSqGSIb3DQEBCwUAA4ICAQB8cMqTllHc8U+qCrOlg3H7 +174lmaCsbo/bJ0C17JEgMLb4kvrqsXZs01U3mB/qABg/1t5Pd5AORHARs1hhqGIC +W/nKMav574f9rZN4PC2ZlufGXb7sIdJpGiO9ctRhiLuYuly10JccUZGEHpHSYM2G +tkgYbZba6lsCPYAAP83cyDV+1aOkTf1RCp/lM0PKvmxYN10RYsK631jrleGdcdkx +oSK//mSQbgcWnmAEZrzHoF1/0gso1HZgIn0YLzVhLSA/iXCX4QT2h3J5z3znluKG +1nv8NQdxei2DIIhASWfu804CA96cQKTTlaae2fweqXjdN1/v2nqOhngNyz1361mF +mr4XmaKH/ItTwOe72NI9ZcwS1lVaCvsIkTDCEXdm9rCNPAY10iTunIHFXRh+7KPz +lHGewCq/8TOohBRn0/NNfh7uRslOSZ/xKbN9tMBtw37Z8d2vvnXq/YWdsm1+JLVw +n6yYD/yacNJBlwpddla8eaVMjsF6nBnIgQOf9zKSe06nSTqvgwUHosgOECZJZ1Eu +zbH4yswbt02tKtKEFhx+v+OTge/06V+jGsqTWLsfrOCNLuA8H++z+pUENmpqnnHo +vaI47gC+TNpkgYGkkBT6B/m/U01BuOBBTzhIlMEZq9qkDWuM2cA5kW5V3FJUcfHn +w1IdYIg2Wxg7yHcQZemFQg== +-----END CERTIFICATE-----` + +// Android ECDSA (secp384r1) Root CA +var androidECDSARootCA = `-----BEGIN CERTIFICATE----- +MIICIjCCAaigAwIBAgIRAISp0Cl7DrWK5/8OgN52BgUwCgYIKoZIzj0EAwMwUjEc +MBoGA1UEAwwTS2V5IEF0dGVzdGF0aW9uIENBMTEQMA4GA1UECwwHQW5kcm9pZDET +MBEGA1UECgwKR29vZ2xlIExMQzELMAkGA1UEBhMCVVMwHhcNMjUwNzE3MjIzMjE4 +WhcNMzUwNzE1MjIzMjE4WjBSMRwwGgYDVQQDDBNLZXkgQXR0ZXN0YXRpb24gQ0Ex +MRAwDgYDVQQLDAdBbmRyb2lkMRMwEQYDVQQKDApHb29nbGUgTExDMQswCQYDVQQG +EwJVUzB2MBAGByqGSM49AgEGBSuBBAAiA2IABCPaI3FO3z5bBQo8cuiEas4HjqCt +G/mLFfRT0MsIssPBEEU5Cfbt6sH5yOAxqEi5QagpU1yX4HwnGb7OtBYpDTB57uH5 +Eczm34A5FNijV3s0/f0UPl7zbJcTx6xwqMIRq6NCMEAwDwYDVR0TAQH/BAUwAwEB +/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFFIyuyz7RkOb3NaBqQ5lZuA0QepA +MAoGCCqGSM49BAMDA2gAMGUCMETfjPO/HwqReR2CS7p0ZWoD/LHs6hDi422opifH +EUaYLxwGlT9SLdjkVpz0UUOR5wIxAIoGyxGKRHVTpqpGRFiJtQEOOTp/+s1GcxeY +uR2zh/80lQyu9vAFCj6E4AXc+osmRg== +-----END CERTIFICATE-----` // Attestion OID for Android Key Attestation // https://source.android.com/docs/security/features/keystore/attestation#id-attestation @@ -1431,15 +1462,16 @@ type androidKeyAttestationData struct { Attestation *attestation.KeyDescription } -func findAndroidAttestationCert(certs []*x509.Certificate) (*x509.Certificate, error) { +func findAndroidAttestationCert(certs []*x509.Certificate) *x509.Certificate { for _, cert := range certs { for _, ext := range cert.Extensions { if ext.Id.Equal(oidAndroidAttestation) { - return cert, nil + return cert } } } - return nil, errors.New("no attestation certificate with OID 1.3.6.1.4.1.11129.2.1.17 found in the cert chain") + + return nil } // doAndroidKeyAttestionFormat handles ACME Device Attestation requests for @@ -1457,6 +1489,21 @@ func findAndroidAttestationCert(certs []*x509.Certificate) (*x509.Certificate, e func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challenge, jwk *jose.JSONWebKey, att *attestationObject) (*androidKeyAttestationData, error) { acmeProv := prov.(*provisioner.ACME) + roots, ok := prov.GetAttestationRoots() + if !ok { + rsaRoot, err := pemutil.ParseCertificate([]byte(androidRSARootCA)) + if err != nil { + return nil, WrapErrorISE(err, "error parsing Android RSA root CA") + } + ecdsaRoot, err := pemutil.ParseCertificate([]byte(androidECDSARootCA)) + if err != nil { + return nil, WrapErrorISE(err, "error parsing Android ECDSA root CA") + } + roots = x509.NewCertPool() + roots.AddCert(rsaRoot) + roots.AddCert(ecdsaRoot) + } + // extract x5c and verify certificate x5c, ok := att.AttStatement["x5c"].([]any) if !ok { @@ -1496,36 +1543,27 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe if i == len(x5c)-2 { // Last cert = root certs = append(certs, cert) - root = cert + root = cert // TODO: perform additional validation? } else { certs = append(certs, cert) intermediates.AddCert(cert) } } + // Require a root in the chain if root == nil { return nil, NewDetailedError(ErrorBadAttestationStatementType, "missing root certificate in x5c chain") } - block, _ := pem.Decode([]byte(AndroidRootCAPubKey)) - trustedPubKey, err := x509.ParsePKIXPublicKey(block.Bytes) - switch pk := root.PublicKey.(type) { - case *rsa.PublicKey: - if !pk.Equal(trustedPubKey) { - return nil, NewDetailedError(ErrorBadAttestationStatementType, "root certificate not signed by Android") - } - case *ecdsa.PublicKey: - if !pk.Equal(trustedPubKey) { - return nil, NewDetailedError(ErrorBadAttestationStatementType, "root certificate not signed by Android") - } - default: - return nil, NewDetailedError(ErrorBadAttestationStatementType, "invalid root certificate key type") + // Verify the root is one of the trusted roots + if _, err := root.Verify(x509.VerifyOptions{ + Roots: roots, // TODO: ensure Verify does the right thing for this case + CurrentTime: time.Now().Truncate(time.Second), + }); err != nil { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "root certificate in chain is not trusted") } // Validate the full chain including root as trust anchor - roots := x509.NewCertPool() - roots.AddCert(root) - if _, err := leaf.Verify(x509.VerifyOptions{ Intermediates: intermediates, Roots: roots, @@ -1547,9 +1585,9 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe } // Find the attestation certificate - attCert, err := findAndroidAttestationCert(certs) - if err != nil { - return nil, NewDetailedError(ErrorBadAttestationStatementType, "%s", err.Error()) + attCert := findAndroidAttestationCert(certs) // TODO: Google docs describe "closest to the root" + if attCert == nil { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "no attestation certificate with OID 1.3.6.1.4.1.11129.2.1.17 found in the cert chain") } switch pub := attCert.PublicKey.(type) { @@ -1593,7 +1631,7 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe break } - // Validate challenge + // Validate key authorization if string(data.Attestation.AttestationChallenge) != keyAuth { return nil, NewDetailedError(ErrorBadAttestationStatementType, "challenge mismatch; expected %q, got %q", keyAuth, string(data.Attestation.AttestationChallenge)) } diff --git a/acme/challenge_test.go b/acme/challenge_test.go index a3f040059..1207467b9 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -154,11 +154,6 @@ func mustAttestAndroid(t *testing.T, keyAuthorization string) ([]byte, *x509.Cer attestByte, err := attestation.CreateKeyDescription(&atts) require.NoError(t, err) - pemBlock, err := pemutil.Serialize(ca.Root.PublicKey) - require.NoError(t, err) - b := pem.EncodeToMemory(pemBlock) - AndroidRootCAPubKey = string(b) // TODO: fix; make this some type of test configuration? - leaf, err := ca.Sign(&x509.Certificate{ Subject: pkix.Name{CommonName: "attestation cert"}, PublicKey: signer.Public(), diff --git a/go.mod b/go.mod index de6db04c5..a904e2fe3 100644 --- a/go.mod +++ b/go.mod @@ -176,4 +176,5 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/mbreban/attestation => ./../attestation // TODO: remove; use hslatman or smallstep fork +// patched to make tests pass; TODO: decide whether to fork it under smallstep +replace github.com/mbreban/attestation => github.com/hslatman/attestation v0.0.0-20260715144435-9a27f052f96a diff --git a/go.sum b/go.sum index e17406d1b..3f27e232f 100644 --- a/go.sum +++ b/go.sum @@ -250,6 +250,8 @@ github.com/hashicorp/vault/api/auth/aws v0.12.0 h1:onkMrv49rQCF5Zx1/BdIEvwyhh9R2 github.com/hashicorp/vault/api/auth/aws v0.12.0/go.mod h1:Cuyla0RLfTnPkaJCaHGfNGsNIY1GqB2G79T7XI/9N+I= github.com/hashicorp/vault/api/auth/kubernetes v0.12.0 h1:DTrUMNXjpWEFMcU0FY1Eza+l4nSSz/+yUr6JN2GpzF0= github.com/hashicorp/vault/api/auth/kubernetes v0.12.0/go.mod h1:njyxrmFPtMuEPpPMZeemwhHovzC22hq2OuJtScI3iFc= +github.com/hslatman/attestation v0.0.0-20260715144435-9a27f052f96a h1:HF0KXAITuyKFoVMNos1UhnTmjNAjyQJH3Yrxfh83QMc= +github.com/hslatman/attestation v0.0.0-20260715144435-9a27f052f96a/go.mod h1:YWaxLRaBYCI4+EvJIOaMtEiP/8m9XTN3u0ltPWbfZ1Y= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= @@ -354,8 +356,6 @@ github.com/slackhq/nebula v1.10.3 h1:EstYj8ODEcv6T0R9X5BVq1zgWZnyU5gtPzk99QF1PMU github.com/slackhq/nebula v1.10.3/go.mod h1:IL5TUQm4x9IFx2kCKPYm1gP47pwd5b8QGnnBH2RHnvs= github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY= github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc= -github.com/smallstep/certinfo v1.16.0 h1:ZxDI9EDmCh4B/j9YtlTk/6ut+H/Gi0N3d0TwHv7F2YY= -github.com/smallstep/certinfo v1.16.0/go.mod h1:OPwtFVAOx29OjOYsVtj9cDliDFywkVYPt+ExDg43kPs= github.com/smallstep/cli-utils v0.12.2 h1:lGzM9PJrH/qawbzMC/s2SvgLdJPKDWKwKzx9doCVO+k= github.com/smallstep/cli-utils v0.12.2/go.mod h1:uCPqefO29goHLGqFnwk0i8W7XJu18X3WHQFRtOm/00Y= github.com/smallstep/go-attestation v0.4.4-0.20260603212853-e1a87a0b07d9 h1:n+X1wnMKJMcCRd98YKAo/56tMRSPUg+qjAvNNS1EZeM= From e3d6949bfc952ccae728ea144d2555ed99e57434 Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Wed, 22 Jul 2026 12:02:43 +0200 Subject: [PATCH 06/12] Make Android Key Attestation CRL check (partially) configurable --- acme/challenge.go | 12 +++-- acme/challenge_test.go | 28 ++++++---- authority/provisioner/acme.go | 71 +++++++++++++++---------- authority/provisioner/androidkey/crl.go | 10 ++++ authority/provisioner/controller.go | 3 ++ authority/provisioner/provisioner.go | 5 ++ 6 files changed, 90 insertions(+), 39 deletions(-) create mode 100644 authority/provisioner/androidkey/crl.go diff --git a/acme/challenge.go b/acme/challenge.go index 931cf480d..f6f36402c 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -1486,7 +1486,7 @@ func findAndroidAttestationCert(certs []*x509.Certificate) *x509.Certificate { // See the section about the provisioning information extension for more details. // 6. Find the nearest certificate to the root that contains the key attestation certificate extension. If the provisioning information certificate extension was present, the key attestation certificate extension must be in the immediately subsequent certificate. Use the parser to extract the key attestation certificate extension data from that certificate. // 7. Check the extension data that you've retrieved in the previous steps for consistency and compare with the set of values that you expect the hardware-backed key to contain. -func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challenge, jwk *jose.JSONWebKey, att *attestationObject) (*androidKeyAttestationData, error) { +func doAndroidKeyAttestionFormat(ctx context.Context, prov Provisioner, ch *Challenge, jwk *jose.JSONWebKey, att *attestationObject) (*androidKeyAttestationData, error) { acmeProv := prov.(*provisioner.ACME) roots, ok := prov.GetAttestationRoots() @@ -1536,10 +1536,16 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe if err != nil { return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "failed parsing certificate at index %d", i+1) } - // Verify CRL - if acmeProv.IsRootRevoked(cert.SerialNumber.String()) { + + // Verify certificate serial number against CRL + revoked, err := acmeProv.IsAndroidCertificateRevoked(ctx, cert) + if err != nil { + return nil, WrapDetailedError(ErrorServerInternalType, err, "failed checking certificate revocation status") + } + if revoked { return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c element contains a revoked certificate") } + if i == len(x5c)-2 { // Last cert = root certs = append(certs, cert) diff --git a/acme/challenge_test.go b/acme/challenge_test.go index 1207467b9..5295beac5 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -25,6 +25,7 @@ import ( "net/http" "net/http/httptest" "reflect" + "slices" "strconv" "strings" "testing" @@ -43,6 +44,7 @@ import ( "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/authority/provisioner/androidkey" wireprovisioner "github.com/smallstep/certificates/authority/provisioner/wire" ) @@ -99,18 +101,25 @@ func mustAttestationProvisioner(t *testing.T, roots []byte) Provisioner { return prov } -func mustNonCRLAttestationProvisioner(t *testing.T, roots []byte, CRLs []string) Provisioner { +type fakeAndroidKeyCRLChecker []string + +func (p fakeAndroidKeyCRLChecker) IsRevoked(_ context.Context, cert *x509.Certificate) (bool, error) { + return slices.Contains(p, cert.SerialNumber.String()), nil +} + +func mustAndroidAttestationProvisioner(t *testing.T, roots []byte, androidKeyCRLChecker androidkey.CRLChecker) Provisioner { t.Helper() prov := &provisioner.ACME{ - Type: "ACME", - Name: "acme", - Challenges: []provisioner.ACMEChallenge{provisioner.DEVICE_ATTEST_01}, - AttestationRoots: roots, - RootCRLs: CRLs, + Type: "ACME", + Name: "acme", + Challenges: []provisioner.ACMEChallenge{provisioner.DEVICE_ATTEST_01}, + AttestationFormats: []provisioner.ACMEAttestationFormat{provisioner.ANDROID_KEY}, + AttestationRoots: roots, } if err := prov.Init(provisioner.Config{ - Claims: config.GlobalProvisionerClaims, + Claims: config.GlobalProvisionerClaims, + AndroidKeyCRLChecker: androidKeyCRLChecker, }); err != nil { t.Fatal(err) } @@ -4584,7 +4593,7 @@ func Test_deviceAttest01Validate(t *testing.T) { payload, _, root := mustAttestAndroid(t, keyAuth) caRoot := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: root.Raw}) - ctx := NewProvisionerContext(context.Background(), mustAttestationProvisioner(t, caRoot)) + ctx := NewProvisionerContext(context.Background(), mustAndroidAttestationProvisioner(t, caRoot, nil)) return test{ args: args{ ctx: ctx, @@ -4624,7 +4633,8 @@ func Test_deviceAttest01Validate(t *testing.T) { payload, _, root := mustAttestAndroid(t, keyAuth) caRoot := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: root.Raw}) - ctx := NewProvisionerContext(context.Background(), mustNonCRLAttestationProvisioner(t, caRoot, []string{root.SerialNumber.String()})) + androidKeyCRLChecker := fakeAndroidKeyCRLChecker([]string{root.SerialNumber.String()}) + ctx := NewProvisionerContext(context.Background(), mustAndroidAttestationProvisioner(t, caRoot, androidKeyCRLChecker)) return test{ args: args{ ctx: ctx, diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index 1741baeab..584b59190 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -7,7 +7,6 @@ import ( "encoding/pem" "fmt" "io" - "log" "net" "net/http" "slices" @@ -125,13 +124,13 @@ type ACME struct { // AttestationRoots contains a bundle of root certificates in PEM format // that will be used to verify the attestation certificates. If provided, // this bundle will be used even for well-known CAs like Apple and Yubico. - AttestationRoots []byte `json:"attestationRoots,omitempty"` - Claims *Claims `json:"claims,omitempty"` - Options *Options `json:"options,omitempty"` - RootCRLs []string `json:"rootCRLs,omitempty"` - androidCRLTimeout time.Time - attestationRootPool *x509.CertPool - ctl *Controller + AttestationRoots []byte `json:"attestationRoots,omitempty"` + Claims *Claims `json:"claims,omitempty"` + Options *Options `json:"options,omitempty"` + androidRevokedSerials []string + androidCRLFetchedAt time.Time + attestationRootPool *x509.CertPool + ctl *Controller } // GetID returns the provisioner unique identifier. @@ -227,26 +226,22 @@ func (p *ACME) Init(config Config) (err error) { return fmt.Errorf("failed initializing Wire options: %w", err) } - if slices.Contains(p.AttestationFormats, "android-key") && len(p.RootCRLs) == 0 { - p.initializeAndroidCRL() - } - p.ctl, err = NewController(p, p.Claims, config, p.Options) return } -const ANDROID_ATTESTATION_STATUS_URL = "https://android.googleapis.com/attestation/status" +const androidAttestationStatusURL = "https://android.googleapis.com/attestation/status" // TODO: make configurable? -// fetch CRL https://android.googleapis.com/attestation/status and build a list of serial number -func (p *ACME) initializeAndroidCRL() error { - log.Printf("Updating Android CRL list for %s ACME provisioner", p.Name) +// loadAndroidCRL fetches the CRL at https://android.googleapis.com/attestation/status +// and builds a list of serial numbers for each certificate that has been revoked. +func (p *ACME) loadAndroidCRL(ctx context.Context) error { var CRLResponse struct { Entries map[string]struct { Status string `json:"status"` Reason string `json:"reason"` } `json:"entries"` } - res, err := http.Get(ANDROID_ATTESTATION_STATUS_URL) + res, err := p.ctl.GetHTTPClient().Get(androidAttestationStatusURL) if err != nil { return fmt.Errorf("client: error making Android CRL request: %s\n", err) } @@ -262,12 +257,14 @@ func (p *ACME) initializeAndroidCRL() error { } // Extract keys into a slice - keys := make([]string, 0, len(CRLResponse.Entries)) + serials := make([]string, 0, len(CRLResponse.Entries)) for k := range CRLResponse.Entries { - keys = append(keys, k) + serials = append(serials, k) } - p.RootCRLs = keys - p.androidCRLTimeout = time.Now().Add(24 * time.Hour) + + p.androidRevokedSerials = serials + p.androidCRLFetchedAt = time.Now() + return nil } @@ -444,11 +441,31 @@ func (p *ACME) GetAttestationRoots() (*x509.CertPool, bool) { return p.attestationRootPool, p.attestationRootPool != nil } -// IsRootRevoked return a true if the serialNumber is part of the list -// It will also be in charge of updating the list periodically if no CRL list is provided at configuration. -func (p *ACME) IsRootRevoked(serialNumber string) bool { - if slices.Contains(p.AttestationFormats, "android-key") && !p.androidCRLTimeout.IsZero() && time.Now().After(p.androidCRLTimeout) { - p.initializeAndroidCRL() +// IsAndroidCertificateRevoked returns whether the serial number is +// revoked or not. +func (p *ACME) IsAndroidCertificateRevoked(ctx context.Context, cert *x509.Certificate) (bool, error) { + // the check only has to be performed when the "android-key" attestation + // format is enabled + if !slices.Contains(p.AttestationFormats, ANDROID_KEY) { + return false, nil + } + + if p.ctl.androidKeyCRLChecker != nil { + revoked, err := p.ctl.androidKeyCRLChecker.IsRevoked(ctx, cert) + if err != nil { + return true, fmt.Errorf("failed checking certificate against Android CRL: %w", err) + } + + return revoked, nil + } + + // TODO(hs): refactor, so that the period between loads becomes configurable + if p.androidCRLFetchedAt.IsZero() || time.Now().After(p.androidCRLFetchedAt.Add(24*time.Hour)) { + // reinitialize the Android CRL + if err := p.loadAndroidCRL(ctx); err != nil { + return false, err + } } - return len(p.RootCRLs) > 0 && slices.Contains(p.RootCRLs, serialNumber) + + return slices.Contains(p.androidRevokedSerials, cert.SerialNumber.String()), nil } diff --git a/authority/provisioner/androidkey/crl.go b/authority/provisioner/androidkey/crl.go new file mode 100644 index 000000000..fb75bf40f --- /dev/null +++ b/authority/provisioner/androidkey/crl.go @@ -0,0 +1,10 @@ +package androidkey + +import ( + "context" + "crypto/x509" +) + +type CRLChecker interface { + IsRevoked(ctx context.Context, cert *x509.Certificate) (bool, error) +} diff --git a/authority/provisioner/controller.go b/authority/provisioner/controller.go index ae3b1a247..70f5ab452 100644 --- a/authority/provisioner/controller.go +++ b/authority/provisioner/controller.go @@ -12,6 +12,7 @@ import ( "github.com/smallstep/linkedca" + "github.com/smallstep/certificates/authority/provisioner/androidkey" "github.com/smallstep/certificates/errs" "github.com/smallstep/certificates/internal/cast" "github.com/smallstep/certificates/internal/httptransport" @@ -32,6 +33,7 @@ type Controller struct { webhookClient HTTPClient webhooks []*Webhook wrapTransport httptransport.Wrapper + androidKeyCRLChecker androidkey.CRLChecker } // NewController initializes a new provisioner controller. @@ -66,6 +68,7 @@ func NewController(p Interface, claims *Claims, config Config, options *Options) webhooks: options.GetWebhooks(), httpClient: config.HTTPClient, wrapTransport: wt, + androidKeyCRLChecker: config.AndroidKeyCRLChecker, }, nil } diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index 33d75fe9a..47742d580 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -13,6 +13,7 @@ import ( kmsapi "go.step.sm/crypto/kms/apiv1" "golang.org/x/crypto/ssh" + "github.com/smallstep/certificates/authority/provisioner/androidkey" "github.com/smallstep/certificates/errs" ) @@ -281,6 +282,10 @@ type Config struct { // WrapTransport references the function that should wrap any [http.Transport] initialized // down the Config's chain. WrapTransport TransportWrapper + // AndroidKeyCRLProvider references an implementation of [androidkey.CRLProvider] + // that is responsible for resolving revoked Android Key Attestation certificate + // numbers. + AndroidKeyCRLChecker androidkey.CRLChecker } type provisioner struct { From b335becce2bd1e119e4508dd6685b4a75ae45a46 Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Wed, 22 Jul 2026 13:18:30 +0200 Subject: [PATCH 07/12] Improve `android-key` x5c verification The x5c verification now also checks the leaf to not have been revoked, and the Android Key Attestation extension is searched for in reverse order. --- acme/challenge.go | 71 ++++++++++++++++------------ authority/provisioner/acme.go | 15 ++++-- authority/provisioner/provisioner.go | 6 +-- 3 files changed, 55 insertions(+), 37 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index f6f36402c..75dc97dae 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -849,7 +849,7 @@ func deviceAttest01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose switch format { case "android-key": - data, err := doAndroidKeyAttestionFormat(ctx, prov, ch, jwk, &att) + data, err := doAndroidKeyAttestationFormat(ctx, prov, ch, jwk, &att) if err != nil { var acmeError *Error if errors.As(err, &acmeError) { @@ -1462,8 +1462,12 @@ type androidKeyAttestationData struct { Attestation *attestation.KeyDescription } +// findAndroidAttestationCert traverses the slice of [*x509.Certificate] from +// end to start (root -> intermediate -> leaf) to locate the certificate closest +// to the root carrying an Android Key Attestation extension. func findAndroidAttestationCert(certs []*x509.Certificate) *x509.Certificate { - for _, cert := range certs { + for i := len(certs) - 1; i >= 0; i-- { + cert := certs[i] for _, ext := range cert.Extensions { if ext.Id.Equal(oidAndroidAttestation) { return cert @@ -1474,7 +1478,7 @@ func findAndroidAttestationCert(certs []*x509.Certificate) *x509.Certificate { return nil } -// doAndroidKeyAttestionFormat handles ACME Device Attestation requests for +// doAndroidKeyAttestationFormat handles ACME Device Attestation requests for // the "android-key" attestation format. Its verification logic is based on // the documentation at https://developer.android.com/privacy-and-security/security-key-attestation // @@ -1486,8 +1490,11 @@ func findAndroidAttestationCert(certs []*x509.Certificate) *x509.Certificate { // See the section about the provisioning information extension for more details. // 6. Find the nearest certificate to the root that contains the key attestation certificate extension. If the provisioning information certificate extension was present, the key attestation certificate extension must be in the immediately subsequent certificate. Use the parser to extract the key attestation certificate extension data from that certificate. // 7. Check the extension data that you've retrieved in the previous steps for consistency and compare with the set of values that you expect the hardware-backed key to contain. -func doAndroidKeyAttestionFormat(ctx context.Context, prov Provisioner, ch *Challenge, jwk *jose.JSONWebKey, att *attestationObject) (*androidKeyAttestationData, error) { - acmeProv := prov.(*provisioner.ACME) +func doAndroidKeyAttestationFormat(ctx context.Context, prov Provisioner, ch *Challenge, jwk *jose.JSONWebKey, att *attestationObject) (*androidKeyAttestationData, error) { + acmeProv, ok := prov.(*provisioner.ACME) + if !ok { + return nil, NewErrorISE("provisioner in context is not an ACME provisioner") + } roots, ok := prov.GetAttestationRoots() if !ok { @@ -1512,29 +1519,18 @@ func doAndroidKeyAttestionFormat(ctx context.Context, prov Provisioner, ch *Chal if len(x5c) == 0 { return nil, NewDetailedError(ErrorRejectedIdentifierType, "x5c is empty") } - der, ok := x5c[0].([]byte) - if !ok { - return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c[0] is malformed") - } - leaf, err := x509.ParseCertificate(der) - if err != nil { - return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "failed to parse leaf certificate") - } - - var certs = make([]*x509.Certificate, 0, len(x5c)) - certs = append(certs, leaf) // Parse intermediates and root intermediates := x509.NewCertPool() - var root *x509.Certificate - for i, v := range x5c[1:] { + var leaf, root *x509.Certificate + for i, v := range x5c { der, ok := v.([]byte) if !ok { return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c element is malformed") } cert, err := x509.ParseCertificate(der) if err != nil { - return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "failed parsing certificate at index %d", i+1) + return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "failed parsing certificate at index %d", i) } // Verify certificate serial number against CRL @@ -1546,39 +1542,54 @@ func doAndroidKeyAttestionFormat(ctx context.Context, prov Provisioner, ch *Chal return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c element contains a revoked certificate") } - if i == len(x5c)-2 { - // Last cert = root - certs = append(certs, cert) - root = cert // TODO: perform additional validation? - } else { - certs = append(certs, cert) + switch i { + case 0: // leaf + leaf = cert + case len(x5c) - 1: // root + root = cert + default: // intermediates intermediates.AddCert(cert) } } + // Require a leaf in the chain + if leaf == nil { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "missing leaf certificate in x5c chain") + } + // Require a root in the chain if root == nil { return nil, NewDetailedError(ErrorBadAttestationStatementType, "missing root certificate in x5c chain") } // Verify the root is one of the trusted roots - if _, err := root.Verify(x509.VerifyOptions{ + _, err := root.Verify(x509.VerifyOptions{ Roots: roots, // TODO: ensure Verify does the right thing for this case CurrentTime: time.Now().Truncate(time.Second), - }); err != nil { + }) + if err != nil { return nil, NewDetailedError(ErrorBadAttestationStatementType, "root certificate in chain is not trusted") } // Validate the full chain including root as trust anchor - if _, err := leaf.Verify(x509.VerifyOptions{ + chains, err := leaf.Verify(x509.VerifyOptions{ Intermediates: intermediates, Roots: roots, CurrentTime: time.Now().Truncate(time.Second), KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, - }); err != nil { + }) + if err != nil { return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "x5c chain verification failed") } + switch { + case len(chains) == 0: + return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c does not contain a valid certificate chain") + case len(chains) > 1: + // currently we strictly prohibit multiple valid signing chains + return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c contains multiple (%d) valid certificate chains", len(chains)) + } + // Get signature sig, ok := att.AttStatement["sig"].([]byte) if !ok { @@ -1591,7 +1602,7 @@ func doAndroidKeyAttestionFormat(ctx context.Context, prov Provisioner, ch *Chal } // Find the attestation certificate - attCert := findAndroidAttestationCert(certs) // TODO: Google docs describe "closest to the root" + attCert := findAndroidAttestationCert(chains[0]) if attCert == nil { return nil, NewDetailedError(ErrorBadAttestationStatementType, "no attestation certificate with OID 1.3.6.1.4.1.11129.2.1.17 found in the cert chain") } diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index 584b59190..ea2df0f7c 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -241,9 +241,13 @@ func (p *ACME) loadAndroidCRL(ctx context.Context) error { Reason string `json:"reason"` } `json:"entries"` } - res, err := p.ctl.GetHTTPClient().Get(androidAttestationStatusURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, androidAttestationStatusURL, http.NoBody) if err != nil { - return fmt.Errorf("client: error making Android CRL request: %s\n", err) + return fmt.Errorf("failed creating Android CRL request: %w", err) + } + res, err := p.ctl.GetHTTPClient().Do(req) + if err != nil { + return fmt.Errorf("failed performing Android CRL request: %w", err) } defer res.Body.Close() @@ -256,7 +260,10 @@ func (p *ACME) loadAndroidCRL(ctx context.Context) error { return fmt.Errorf("error decoding Android CRL JSON: %w", err) } - // Extract keys into a slice + // Extract serials into a slice. Currently no distinction is being made + // based on the status of the certificate. The status can be "REVOKED" + // and "SUSPENDED". Both statuses will result in a certificate to be + // considered revoked. serials := make([]string, 0, len(CRLResponse.Entries)) for k := range CRLResponse.Entries { serials = append(serials, k) @@ -463,7 +470,7 @@ func (p *ACME) IsAndroidCertificateRevoked(ctx context.Context, cert *x509.Certi if p.androidCRLFetchedAt.IsZero() || time.Now().After(p.androidCRLFetchedAt.Add(24*time.Hour)) { // reinitialize the Android CRL if err := p.loadAndroidCRL(ctx); err != nil { - return false, err + return true, err } } diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index 47742d580..ae6e0f978 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -282,9 +282,9 @@ type Config struct { // WrapTransport references the function that should wrap any [http.Transport] initialized // down the Config's chain. WrapTransport TransportWrapper - // AndroidKeyCRLProvider references an implementation of [androidkey.CRLProvider] - // that is responsible for resolving revoked Android Key Attestation certificate - // numbers. + // AndroidKeyCRLChecker references an implementation of [androidkey.CRLChecker] + // that is responsible for checking revoked Android Key Attestation certificate + // serial numbers. AndroidKeyCRLChecker androidkey.CRLChecker } From 415969f86e90a5602e918773c396a53db21b2ac1 Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Wed, 22 Jul 2026 14:21:23 +0200 Subject: [PATCH 08/12] Make Android Key Attestation CRL retrieval more robust --- acme/challenge.go | 31 ++--- authority/provisioner/acme.go | 121 ++++++++++++------ authority/provisioner/acme_test.go | 185 +++++++++++++++++++++++++++- authority/provisioner/controller.go | 5 +- go.mod | 2 +- 5 files changed, 291 insertions(+), 53 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index 75dc97dae..d486a9d2e 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -862,13 +862,11 @@ func deviceAttest01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose } // Enforce hardware security level (TrustedEnvironment or StrongBox; Software not allowed) - // 1. attestationSecurityLevel > 0 if data.Attestation.AttestationSecurityLevel < 1 { return storeError(ctx, db, ch, true, NewDetailedError(ErrorBadAttestationStatementType, "insufficient security level: %d", data.Attestation.AttestationSecurityLevel)) } // Enforce hardware backed device serial - // 2. hardwareEnforced if ch.Value != string(data.Attestation.TeeEnforced.AttestationIdSerial) { subproblem := NewSubproblemWithIdentifier( ErrorRejectedIdentifierType, @@ -1465,6 +1463,10 @@ type androidKeyAttestationData struct { // findAndroidAttestationCert traverses the slice of [*x509.Certificate] from // end to start (root -> intermediate -> leaf) to locate the certificate closest // to the root carrying an Android Key Attestation extension. +// +// TODO(hs): implement the optional step of locating the provisioning information +// extension? That should immediately precede the cert with the key attestation +// extension. func findAndroidAttestationCert(certs []*x509.Certificate) *x509.Certificate { for i := len(certs) - 1; i >= 0; i-- { cert := certs[i] @@ -1482,14 +1484,15 @@ func findAndroidAttestationCert(certs []*x509.Certificate) *x509.Certificate { // the "android-key" attestation format. Its verification logic is based on // the documentation at https://developer.android.com/privacy-and-security/security-key-attestation // -// This function performs the below steps -// 3. Verify that the root public certificate is trustworthy and that each certificate signs the next certificate in the chain. -// 4. Check each certificate's revocation status to ensure that none of the certificates have been revoked. -// 5. Optionally, inspect the provisioning information certificate extension that is only present in newer certificate chains. -// Obtain a reference to the CBOR parser library that is most appropriate for your toolset. Find the nearest certificate to the root that contains the provisioning information certificate extension. Use the parser to extract the provisioning information certificate extension data from that certificate. -// See the section about the provisioning information extension for more details. -// 6. Find the nearest certificate to the root that contains the key attestation certificate extension. If the provisioning information certificate extension was present, the key attestation certificate extension must be in the immediately subsequent certificate. Use the parser to extract the key attestation certificate extension data from that certificate. -// 7. Check the extension data that you've retrieved in the previous steps for consistency and compare with the set of values that you expect the hardware-backed key to contain. +// This function performs the below steps: +// - Verifies that the root public certificate is trustworthy and that each certificate signs +// the next certificate in the chain. +// - Checks each certificate's revocation status to ensure that none of the certificates have +// been revoked. +// - Find the nearest certificate to the root that contains the key attestation certificate +// extension. +// - Check the extension data that you've retrieved in the previous steps for consistency and +// compare with the set of values that you expect the hardware-backed key to contain. func doAndroidKeyAttestationFormat(ctx context.Context, prov Provisioner, ch *Challenge, jwk *jose.JSONWebKey, att *attestationObject) (*androidKeyAttestationData, error) { acmeProv, ok := prov.(*provisioner.ACME) if !ok { @@ -1520,7 +1523,7 @@ func doAndroidKeyAttestationFormat(ctx context.Context, prov Provisioner, ch *Ch return nil, NewDetailedError(ErrorRejectedIdentifierType, "x5c is empty") } - // Parse intermediates and root + // Parse leaf, intermediates and root intermediates := x509.NewCertPool() var leaf, root *x509.Certificate for i, v := range x5c { @@ -1564,7 +1567,7 @@ func doAndroidKeyAttestationFormat(ctx context.Context, prov Provisioner, ch *Ch // Verify the root is one of the trusted roots _, err := root.Verify(x509.VerifyOptions{ - Roots: roots, // TODO: ensure Verify does the right thing for this case + Roots: roots, CurrentTime: time.Now().Truncate(time.Second), }) if err != nil { @@ -1610,7 +1613,7 @@ func doAndroidKeyAttestationFormat(ctx context.Context, prov Provisioner, ch *Ch switch pub := attCert.PublicKey.(type) { case *ecdsa.PublicKey: if pub.Curve != elliptic.P256() { - return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "unsupported elliptic curve %s", pub.Curve) + return nil, NewDetailedError(ErrorBadAttestationStatementType, "unsupported elliptic curve %s", pub.Curve) } sum := sha256.Sum256([]byte(keyAuth)) if !ecdsa.VerifyASN1(pub, sum[:], sig) { @@ -1790,7 +1793,7 @@ func doStepAttestationFormat(_ context.Context, prov Provisioner, ch *Challenge, switch pub := leaf.PublicKey.(type) { case *ecdsa.PublicKey: if pub.Curve != elliptic.P256() { - return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "unsupported elliptic curve %s", pub.Curve) + return nil, NewDetailedError(ErrorBadAttestationStatementType, "unsupported elliptic curve %s", pub.Curve) } sum := sha256.Sum256([]byte(keyAuth)) if !ecdsa.VerifyASN1(pub, sum[:], sig) { diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index ea2df0f7c..389586e83 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -6,16 +6,19 @@ import ( "encoding/json" "encoding/pem" "fmt" - "io" "net" "net/http" "slices" "strings" + "sync/atomic" "time" "github.com/pkg/errors" - "github.com/smallstep/certificates/acme/wire" + "golang.org/x/sync/singleflight" + "github.com/smallstep/linkedca" + + "github.com/smallstep/certificates/acme/wire" ) // ACMEChallenge represents the supported acme challenges. @@ -124,13 +127,26 @@ type ACME struct { // AttestationRoots contains a bundle of root certificates in PEM format // that will be used to verify the attestation certificates. If provided, // this bundle will be used even for well-known CAs like Apple and Yubico. - AttestationRoots []byte `json:"attestationRoots,omitempty"` - Claims *Claims `json:"claims,omitempty"` - Options *Options `json:"options,omitempty"` - androidRevokedSerials []string - androidCRLFetchedAt time.Time - attestationRootPool *x509.CertPool - ctl *Controller + AttestationRoots []byte `json:"attestationRoots,omitempty"` + Claims *Claims `json:"claims,omitempty"` + Options *Options `json:"options,omitempty"` + androidCRL *androidCRLCache + attestationRootPool *x509.CertPool + ctl *Controller +} + +// androidCRLCache caches the Android attestation revocation list. +type androidCRLCache struct { + current atomic.Pointer[androidCRL] + group singleflight.Group +} + +// androidCRL is an immutable snapshot of the Android attestation revocation +// list. A new snapshot is swapped in atomically on each refresh, so readers +// never observe a partially-updated set. +type androidCRL struct { + serials map[string]struct{} // set of revoked serial numbers + fetchedAt time.Time } // GetID returns the provisioner unique identifier. @@ -226,15 +242,50 @@ func (p *ACME) Init(config Config) (err error) { return fmt.Errorf("failed initializing Wire options: %w", err) } + p.androidCRL = &androidCRLCache{} + p.ctl, err = NewController(p, p.Claims, config, p.Options) return } -const androidAttestationStatusURL = "https://android.googleapis.com/attestation/status" // TODO: make configurable? +const ( + androidAttestationStatusURL = "https://android.googleapis.com/attestation/status" // TODO(hs): make configurable through options? + androidCRLTTL = 24 * time.Hour // TODO(hs): make configurable through options and/or Cache-Control header? +) + +// snapshot returns a fresh-enough snapshot of the Android revocation list, +// fetching a new one if the cached copy is missing or older than androidCRLTTL. +// Concurrent refreshes are coalesced into a single upstream request, so a burst +// of validations results in at most one call to the Android status endpoint. +func (c *androidCRLCache) snapshot(ctx context.Context, client HTTPClient) (*androidCRL, error) { + if crl := c.current.Load(); crl != nil && time.Since(crl.fetchedAt) < androidCRLTTL { + return crl, nil + } + + // The cache is missing or stale. Coalesce concurrent refreshes so that only + // one upstream request is performed; all callers share its result. + v, err, _ := c.group.Do("android-crl", func() (any, error) { + // Re-check under the singleflight leader: another goroutine may have + // refreshed the snapshot while we were queued behind it. + if crl := c.current.Load(); crl != nil && time.Since(crl.fetchedAt) < androidCRLTTL { + return crl, nil + } + + // load the CRL. We choose to let all followers get a context.Cancelled when the + // leader's ctx is cancelled. Clients will retry after. + return c.load(ctx, client) + }) + if err != nil { + return nil, err + } -// loadAndroidCRL fetches the CRL at https://android.googleapis.com/attestation/status -// and builds a list of serial numbers for each certificate that has been revoked. -func (p *ACME) loadAndroidCRL(ctx context.Context) error { + return v.(*androidCRL), nil +} + +// load fetches the CRL at https://android.googleapis.com/attestation/status, +// builds the set of revoked serial numbers, and atomically publishes it as the +// current snapshot. +func (c *androidCRLCache) load(ctx context.Context, client HTTPClient) (*androidCRL, error) { var CRLResponse struct { Entries map[string]struct { Status string `json:"status"` @@ -243,36 +294,35 @@ func (p *ACME) loadAndroidCRL(ctx context.Context) error { } req, err := http.NewRequestWithContext(ctx, http.MethodGet, androidAttestationStatusURL, http.NoBody) if err != nil { - return fmt.Errorf("failed creating Android CRL request: %w", err) + return nil, fmt.Errorf("failed creating Android CRL request: %w", err) } - res, err := p.ctl.GetHTTPClient().Do(req) + res, err := client.Do(req) if err != nil { - return fmt.Errorf("failed performing Android CRL request: %w", err) + return nil, fmt.Errorf("failed performing Android CRL request: %w", err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(res.Body) - return fmt.Errorf("unexpected Android CRL response %d: %s", res.StatusCode, string(bodyBytes)) + return nil, fmt.Errorf("unexpected Android CRL response %d", res.StatusCode) } if err := json.NewDecoder(res.Body).Decode(&CRLResponse); err != nil { - return fmt.Errorf("error decoding Android CRL JSON: %w", err) + return nil, fmt.Errorf("error decoding Android CRL JSON: %w", err) } - // Extract serials into a slice. Currently no distinction is being made - // based on the status of the certificate. The status can be "REVOKED" - // and "SUSPENDED". Both statuses will result in a certificate to be - // considered revoked. - serials := make([]string, 0, len(CRLResponse.Entries)) + // Build the set of revoked serials. Currently no distinction is being made + // based on the status of the certificate. The status can be "REVOKED" and + // "SUSPENDED"; both statuses result in a certificate being considered + // revoked. + serials := make(map[string]struct{}, len(CRLResponse.Entries)) for k := range CRLResponse.Entries { - serials = append(serials, k) + serials[k] = struct{}{} } - p.androidRevokedSerials = serials - p.androidCRLFetchedAt = time.Now() + crl := &androidCRL{serials: serials, fetchedAt: time.Now()} + c.current.Store(crl) - return nil + return crl, nil } // initializeWireOptions initializes the options for the ACME Wire @@ -466,13 +516,14 @@ func (p *ACME) IsAndroidCertificateRevoked(ctx context.Context, cert *x509.Certi return revoked, nil } - // TODO(hs): refactor, so that the period between loads becomes configurable - if p.androidCRLFetchedAt.IsZero() || time.Now().After(p.androidCRLFetchedAt.Add(24*time.Hour)) { - // reinitialize the Android CRL - if err := p.loadAndroidCRL(ctx); err != nil { - return true, err - } + // Fall back to the built-in CRL. A fetch error fails closed: the + // certificate is reported as revoked so the attestation is rejected. + crl, err := p.androidCRL.snapshot(ctx, p.ctl.GetHTTPClient()) + if err != nil { + return true, err } - return slices.Contains(p.androidRevokedSerials, cert.SerialNumber.String()), nil + _, revoked := crl.serials[cert.SerialNumber.String()] + + return revoked, nil } diff --git a/authority/provisioner/acme_test.go b/authority/provisioner/acme_test.go index 3288183f9..f70cebfe4 100644 --- a/authority/provisioner/acme_test.go +++ b/authority/provisioner/acme_test.go @@ -6,15 +6,22 @@ import ( "crypto/x509" "errors" "fmt" + "io" + "math/big" "net/http" "os" + "slices" + "strings" + "sync" + "sync/atomic" "testing" "time" - "github.com/smallstep/certificates/api/render" - "github.com/smallstep/certificates/authority/provisioner/wire" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/smallstep/certificates/api/render" + "github.com/smallstep/certificates/authority/provisioner/wire" ) func TestACMEChallenge_Validate(t *testing.T) { @@ -451,3 +458,177 @@ func TestACME_IsAttestationFormatEnabled(t *testing.T) { }) } } + +// countingHTTPClient is an HTTPClient that records how many upstream requests +// it serves and returns a fixed Android CRL response after an optional delay. +type countingHTTPClient struct { + calls atomic.Int64 + body string + delay time.Duration +} + +func (c *countingHTTPClient) Get(string) (*http.Response, error) { + return c.Do(&http.Request{}) +} + +func (c *countingHTTPClient) Do(*http.Request) (*http.Response, error) { + c.calls.Add(1) + if c.delay > 0 { + time.Sleep(c.delay) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(c.body)), + Header: make(http.Header), + }, nil +} + +func certWithSerial(serial int64) *x509.Certificate { + return &x509.Certificate{SerialNumber: big.NewInt(serial)} +} + +func TestACME_IsAndroidCertificateRevoked(t *testing.T) { + // The CRL reports serial number 42 as revoked. The delay ensures a burst + // of concurrent lookups overlaps on the initial fetch so that the + // singleflight coalescing is actually exercised. + client := &countingHTTPClient{ + body: `{"entries":{"42":{"status":"REVOKED"}}}`, + delay: 50 * time.Millisecond, + } + + p := &ACME{ + Type: "ACME", + Name: "acme", + AttestationFormats: []ACMEAttestationFormat{ANDROID_KEY}, + } + require.NoError(t, p.Init(Config{ + Claims: globalProvisionerClaims, + Audiences: testAudiences, + HTTPClient: client, + })) + + ctx := t.Context() + + // Fire a burst of concurrent lookups against the cold cache. This must not + // race (run with -race) and, thanks to singleflight, must result in a + // single upstream request. + const goroutines = 50 + var wg sync.WaitGroup + wg.Add(goroutines) + for range goroutines { + go func() { + defer wg.Done() + revoked, err := p.IsAndroidCertificateRevoked(ctx, certWithSerial(42)) + assert.NoError(t, err) + assert.True(t, revoked) + }() + } + wg.Wait() + + assert.Equal(t, int64(1), client.calls.Load(), "concurrent refreshes should coalesce into a single upstream request") + + // A non-revoked serial is served from the same cached snapshot without a + // new upstream request. + revoked, err := p.IsAndroidCertificateRevoked(ctx, certWithSerial(7)) + assert.NoError(t, err) + assert.False(t, revoked) + assert.Equal(t, int64(1), client.calls.Load(), "cached snapshot should not trigger another request") +} + +func TestACME_IsAndroidCertificateRevoked_controller(t *testing.T) { + p := &ACME{ + Type: "ACME", + Name: "acme", + AttestationFormats: []ACMEAttestationFormat{ANDROID_KEY}, + } + require.NoError(t, p.Init(Config{ + Claims: globalProvisionerClaims, + Audiences: testAudiences, + AndroidKeyCRLChecker: fakeAndroidKeyCRLChecker([]string{"42"}), + })) + + revoked, err := p.IsAndroidCertificateRevoked(t.Context(), certWithSerial(42)) + assert.NoError(t, err) + assert.True(t, revoked) + + revoked, err = p.IsAndroidCertificateRevoked(t.Context(), certWithSerial(7)) + assert.NoError(t, err) + assert.False(t, revoked) +} + +type fakeAndroidKeyCRLChecker []string + +func (p fakeAndroidKeyCRLChecker) IsRevoked(_ context.Context, cert *x509.Certificate) (bool, error) { + return slices.Contains(p, cert.SerialNumber.String()), nil +} + +func TestACME_IsAndroidCertificateRevoked_controllerError(t *testing.T) { + p := &ACME{ + Type: "ACME", + Name: "acme", + AttestationFormats: []ACMEAttestationFormat{ANDROID_KEY}, + } + require.NoError(t, p.Init(Config{ + Claims: globalProvisionerClaims, + Audiences: testAudiences, + AndroidKeyCRLChecker: fakeAndroidKeyCRLCheckerWithError{}, + })) + + revoked, err := p.IsAndroidCertificateRevoked(t.Context(), certWithSerial(42)) + assert.Error(t, err) + assert.True(t, revoked) +} + +type fakeAndroidKeyCRLCheckerWithError []string + +func (p fakeAndroidKeyCRLCheckerWithError) IsRevoked(_ context.Context, cert *x509.Certificate) (bool, error) { + return true, errors.New("fail!") +} + +func TestACME_IsAndroidCertificateRevoked_disabled(t *testing.T) { + client := &countingHTTPClient{body: `{"entries":{}}`} + p := &ACME{ + Type: "ACME", + Name: "acme", + AttestationFormats: []ACMEAttestationFormat{APPLE}, + } + require.NoError(t, p.Init(Config{ + Claims: globalProvisionerClaims, + Audiences: testAudiences, + HTTPClient: client, + })) + + // android-key is not enabled, so no CRL lookup should happen at all. + revoked, err := p.IsAndroidCertificateRevoked(t.Context(), certWithSerial(42)) + assert.NoError(t, err) + assert.False(t, revoked) + assert.Equal(t, int64(0), client.calls.Load()) +} + +func TestACME_IsAndroidCertificateRevoked_failsClosed(t *testing.T) { + // An upstream error must fail closed: the certificate is reported revoked + // and the error is surfaced. + p := &ACME{ + Type: "ACME", + Name: "acme", + AttestationFormats: []ACMEAttestationFormat{ANDROID_KEY}, + } + require.NoError(t, p.Init(Config{ + Claims: globalProvisionerClaims, + Audiences: testAudiences, + HTTPClient: &erroringHTTPClient{}, + })) + + revoked, err := p.IsAndroidCertificateRevoked(t.Context(), certWithSerial(42)) + assert.Error(t, err) + assert.True(t, revoked) +} + +// erroringHTTPClient is an HTTPClient whose requests always fail. +type erroringHTTPClient struct{} + +func (erroringHTTPClient) Get(string) (*http.Response, error) { return nil, errors.New("fail!") } + +func (erroringHTTPClient) Do(*http.Request) (*http.Response, error) { + return nil, errors.New("fail!") +} diff --git a/authority/provisioner/controller.go b/authority/provisioner/controller.go index 70f5ab452..b03b297df 100644 --- a/authority/provisioner/controller.go +++ b/authority/provisioner/controller.go @@ -78,7 +78,10 @@ func (c *Controller) GetHTTPClient() HTTPClient { if c.httpClient != nil { return c.httpClient } - return &http.Client{} + + return &http.Client{ + Timeout: 10 * time.Second, + } } // GetIdentity returns the identity for a given email. diff --git a/go.mod b/go.mod index a904e2fe3..c4a930ff0 100644 --- a/go.mod +++ b/go.mod @@ -176,5 +176,5 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -// patched to make tests pass; TODO: decide whether to fork it under smallstep +// patched to make tests pass; TODO: fork under Smallstep and use that replace github.com/mbreban/attestation => github.com/hslatman/attestation v0.0.0-20260715144435-9a27f052f96a From 82bf34423c8a5328dfef9f25342d2b4ac1b410a4 Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Wed, 22 Jul 2026 15:14:07 +0200 Subject: [PATCH 09/12] Fix linter issues --- acme/challenge.go | 4 ++-- acme/challenge_test.go | 2 +- authority/provisioner/acme.go | 22 +++++++++++----------- authority/provisioner/acme_test.go | 16 ++++++++-------- authority/provisioners.go | 4 ++-- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index d486a9d2e..eb7a670dd 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -1527,8 +1527,8 @@ func doAndroidKeyAttestationFormat(ctx context.Context, prov Provisioner, ch *Ch intermediates := x509.NewCertPool() var leaf, root *x509.Certificate for i, v := range x5c { - der, ok := v.([]byte) - if !ok { + der, dok := v.([]byte) + if !dok { return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c element is malformed") } cert, err := x509.ParseCertificate(der) diff --git a/acme/challenge_test.go b/acme/challenge_test.go index 5295beac5..b1907bc1f 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -114,7 +114,7 @@ func mustAndroidAttestationProvisioner(t *testing.T, roots []byte, androidKeyCRL Type: "ACME", Name: "acme", Challenges: []provisioner.ACMEChallenge{provisioner.DEVICE_ATTEST_01}, - AttestationFormats: []provisioner.ACMEAttestationFormat{provisioner.ANDROID_KEY}, + AttestationFormats: []provisioner.ACMEAttestationFormat{provisioner.ANDROIDKEY}, AttestationRoots: roots, } if err := prov.Init(provisioner.Config{ diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index 389586e83..83161edb9 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -60,8 +60,8 @@ func (c ACMEChallenge) Validate() error { type ACMEAttestationFormat string const ( - // ANDROID_KEY is the format used to enable device-attest-01 on Android devices. - ANDROID_KEY ACMEAttestationFormat = "android-key" + // ANDROIDKEY is the format used to enable device-attest-01 on Android devices. + ANDROIDKEY ACMEAttestationFormat = "android-key" // APPLE is the format used to enable device-attest-01 on Apple devices. APPLE ACMEAttestationFormat = "apple" @@ -84,7 +84,7 @@ func (f ACMEAttestationFormat) String() string { // Validate returns an error if the attestation format is not a valid one. func (f ACMEAttestationFormat) Validate() error { switch ACMEAttestationFormat(f.String()) { - case APPLE, STEP, TPM, ANDROID_KEY: + case APPLE, STEP, TPM, ANDROIDKEY: return nil default: return fmt.Errorf("acme attestation format %q is not supported", f) @@ -271,8 +271,8 @@ func (c *androidCRLCache) snapshot(ctx context.Context, client HTTPClient) (*and return crl, nil } - // load the CRL. We choose to let all followers get a context.Cancelled when the - // leader's ctx is cancelled. Clients will retry after. + // load the CRL. We choose to let all followers get a context.Canceled when the + // leader's ctx is canceled. Clients will retry after. return c.load(ctx, client) }) if err != nil { @@ -286,7 +286,7 @@ func (c *androidCRLCache) snapshot(ctx context.Context, client HTTPClient) (*and // builds the set of revoked serial numbers, and atomically publishes it as the // current snapshot. func (c *androidCRLCache) load(ctx context.Context, client HTTPClient) (*androidCRL, error) { - var CRLResponse struct { + var crlResponse struct { Entries map[string]struct { Status string `json:"status"` Reason string `json:"reason"` @@ -306,7 +306,7 @@ func (c *androidCRLCache) load(ctx context.Context, client HTTPClient) (*android return nil, fmt.Errorf("unexpected Android CRL response %d", res.StatusCode) } - if err := json.NewDecoder(res.Body).Decode(&CRLResponse); err != nil { + if err := json.NewDecoder(res.Body).Decode(&crlResponse); err != nil { return nil, fmt.Errorf("error decoding Android CRL JSON: %w", err) } @@ -314,8 +314,8 @@ func (c *androidCRLCache) load(ctx context.Context, client HTTPClient) (*android // based on the status of the certificate. The status can be "REVOKED" and // "SUSPENDED"; both statuses result in a certificate being considered // revoked. - serials := make(map[string]struct{}, len(CRLResponse.Entries)) - for k := range CRLResponse.Entries { + serials := make(map[string]struct{}, len(crlResponse.Entries)) + for k := range crlResponse.Entries { serials[k] = struct{}{} } @@ -476,7 +476,7 @@ func (p *ACME) IsChallengeEnabled(_ context.Context, challenge ACMEChallenge) bo // AttestationFormat provisioner property should have at least one element. func (p *ACME) IsAttestationFormatEnabled(_ context.Context, format ACMEAttestationFormat) bool { enabledFormats := []ACMEAttestationFormat{ - APPLE, STEP, TPM, ANDROID_KEY, + APPLE, STEP, TPM, ANDROIDKEY, } if len(p.AttestationFormats) > 0 { enabledFormats = p.AttestationFormats @@ -503,7 +503,7 @@ func (p *ACME) GetAttestationRoots() (*x509.CertPool, bool) { func (p *ACME) IsAndroidCertificateRevoked(ctx context.Context, cert *x509.Certificate) (bool, error) { // the check only has to be performed when the "android-key" attestation // format is enabled - if !slices.Contains(p.AttestationFormats, ANDROID_KEY) { + if !slices.Contains(p.AttestationFormats, ANDROIDKEY) { return false, nil } diff --git a/authority/provisioner/acme_test.go b/authority/provisioner/acme_test.go index f70cebfe4..3f30feea1 100644 --- a/authority/provisioner/acme_test.go +++ b/authority/provisioner/acme_test.go @@ -58,7 +58,7 @@ func TestACMEAttestationFormat_Validate(t *testing.T) { f ACMEAttestationFormat wantErr bool }{ - {"android-key", ANDROID_KEY, false}, + {"android-key", ANDROIDKEY, false}, {"apple", APPLE, false}, {"step", STEP, false}, {"tpm", TPM, false}, @@ -209,7 +209,7 @@ MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k= Name: "foo", Type: "ACME", Challenges: []ACMEChallenge{DNS_01, DEVICE_ATTEST_01}, - AttestationFormats: []ACMEAttestationFormat{APPLE, STEP, ANDROID_KEY}, + AttestationFormats: []ACMEAttestationFormat{APPLE, STEP, ANDROIDKEY}, AttestationRoots: bytes.Join([][]byte{appleCA, yubicoCA}, []byte("\n")), }, } @@ -437,7 +437,7 @@ func TestACME_IsAttestationFormatEnabled(t *testing.T) { args args want bool }{ - {"ok", fields{[]ACMEAttestationFormat{APPLE, STEP, TPM, ANDROID_KEY}}, args{ctx, TPM}, true}, + {"ok", fields{[]ACMEAttestationFormat{APPLE, STEP, TPM, ANDROIDKEY}}, args{ctx, TPM}, true}, {"ok empty apple", fields{nil}, args{ctx, APPLE}, true}, {"ok empty step", fields{nil}, args{ctx, STEP}, true}, {"ok empty tpm", fields{[]ACMEAttestationFormat{}}, args{ctx, "tpm"}, true}, @@ -446,7 +446,7 @@ func TestACME_IsAttestationFormatEnabled(t *testing.T) { {"fail apple", fields{[]ACMEAttestationFormat{STEP, TPM}}, args{ctx, APPLE}, false}, {"fail step", fields{[]ACMEAttestationFormat{APPLE, TPM}}, args{ctx, STEP}, false}, {"fail step", fields{[]ACMEAttestationFormat{APPLE, STEP}}, args{ctx, TPM}, false}, - {"fail android", fields{[]ACMEAttestationFormat{APPLE, STEP}}, args{ctx, ANDROID_KEY}, false}, + {"fail android", fields{[]ACMEAttestationFormat{APPLE, STEP}}, args{ctx, ANDROIDKEY}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -499,7 +499,7 @@ func TestACME_IsAndroidCertificateRevoked(t *testing.T) { p := &ACME{ Type: "ACME", Name: "acme", - AttestationFormats: []ACMEAttestationFormat{ANDROID_KEY}, + AttestationFormats: []ACMEAttestationFormat{ANDROIDKEY}, } require.NoError(t, p.Init(Config{ Claims: globalProvisionerClaims, @@ -539,7 +539,7 @@ func TestACME_IsAndroidCertificateRevoked_controller(t *testing.T) { p := &ACME{ Type: "ACME", Name: "acme", - AttestationFormats: []ACMEAttestationFormat{ANDROID_KEY}, + AttestationFormats: []ACMEAttestationFormat{ANDROIDKEY}, } require.NoError(t, p.Init(Config{ Claims: globalProvisionerClaims, @@ -566,7 +566,7 @@ func TestACME_IsAndroidCertificateRevoked_controllerError(t *testing.T) { p := &ACME{ Type: "ACME", Name: "acme", - AttestationFormats: []ACMEAttestationFormat{ANDROID_KEY}, + AttestationFormats: []ACMEAttestationFormat{ANDROIDKEY}, } require.NoError(t, p.Init(Config{ Claims: globalProvisionerClaims, @@ -611,7 +611,7 @@ func TestACME_IsAndroidCertificateRevoked_failsClosed(t *testing.T) { p := &ACME{ Type: "ACME", Name: "acme", - AttestationFormats: []ACMEAttestationFormat{ANDROID_KEY}, + AttestationFormats: []ACMEAttestationFormat{ANDROIDKEY}, } require.NoError(t, p.Init(Config{ Claims: globalProvisionerClaims, diff --git a/authority/provisioners.go b/authority/provisioners.go index d9f60c344..132aa2ab7 100644 --- a/authority/provisioners.go +++ b/authority/provisioners.go @@ -1361,7 +1361,7 @@ func attestationFormatsToCertificates(formats []linkedca.ACMEProvisioner_Attesta for _, f := range formats { switch f { case linkedca.ACMEProvisioner_ANDROID_KEY: - ret = append(ret, provisioner.ANDROID_KEY) + ret = append(ret, provisioner.ANDROIDKEY) case linkedca.ACMEProvisioner_APPLE: ret = append(ret, provisioner.APPLE) case linkedca.ACMEProvisioner_STEP: @@ -1379,7 +1379,7 @@ func attestationFormatsToLinkedca(formats []provisioner.ACMEAttestationFormat) [ ret := make([]linkedca.ACMEProvisioner_AttestationFormatType, 0, len(formats)) for _, f := range formats { switch provisioner.ACMEAttestationFormat(f.String()) { - case provisioner.ANDROID_KEY: + case provisioner.ANDROIDKEY: ret = append(ret, linkedca.ACMEProvisioner_ANDROID_KEY) case provisioner.APPLE: ret = append(ret, linkedca.ACMEProvisioner_APPLE) From 2e708c9593609a22e9289fe5d8012ac1574f190f Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Wed, 22 Jul 2026 22:29:13 +0200 Subject: [PATCH 10/12] Disable CRL lookup when custom roots are configured for `android-key` --- acme/challenge.go | 2 +- authority/provisioner/acme.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/acme/challenge.go b/acme/challenge.go index eb7a670dd..76a652f7f 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -1450,7 +1450,7 @@ EUaYLxwGlT9SLdjkVpz0UUOR5wIxAIoGyxGKRHVTpqpGRFiJtQEOOTp/+s1GcxeY uR2zh/80lQyu9vAFCj6E4AXc+osmRg== -----END CERTIFICATE-----` -// Attestion OID for Android Key Attestation +// OID for Android Key Attestation // https://source.android.com/docs/security/features/keystore/attestation#id-attestation var oidAndroidAttestation = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 1, 17} diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index 83161edb9..d571ab400 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -516,6 +516,12 @@ func (p *ACME) IsAndroidCertificateRevoked(ctx context.Context, cert *x509.Certi return revoked, nil } + // No CRL check is performed using Google's CRL if custom roots + // are configured. + if p.attestationRootPool != nil { + return false, nil + } + // Fall back to the built-in CRL. A fetch error fails closed: the // certificate is reported as revoked so the attestation is rejected. crl, err := p.androidCRL.snapshot(ctx, p.ctl.GetHTTPClient()) From b4518162981a56081f0286a7beea15057092d33e Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Wed, 22 Jul 2026 22:36:14 +0200 Subject: [PATCH 11/12] Use `github.com/smallstep/android-attestation` fork --- acme/challenge.go | 2 +- acme/challenge_test.go | 2 +- go.mod | 5 +---- go.sum | 4 ++-- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index 76a652f7f..5d7a4fbf6 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -31,13 +31,13 @@ import ( "github.com/fxamacker/cbor/v2" "github.com/google/go-tpm/legacy/tpm2" + attestation "github.com/smallstep/android-attestation" "github.com/smallstep/go-attestation/attest" "go.step.sm/crypto/jose" "go.step.sm/crypto/keyutil" "go.step.sm/crypto/pemutil" "go.step.sm/crypto/x509util" - "github.com/mbreban/attestation" "github.com/smallstep/certificates/acme/wire" "github.com/smallstep/certificates/authority/provisioner" wireprovisioner "github.com/smallstep/certificates/authority/provisioner/wire" diff --git a/acme/challenge_test.go b/acme/challenge_test.go index b1907bc1f..0cd29a463 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -32,7 +32,7 @@ import ( "time" "github.com/fxamacker/cbor/v2" - "github.com/mbreban/attestation" + attestation "github.com/smallstep/android-attestation" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/go.mod b/go.mod index c4a930ff0..241580b6e 100644 --- a/go.mod +++ b/go.mod @@ -22,13 +22,13 @@ require ( github.com/hashicorp/vault/api/auth/approle v0.12.0 github.com/hashicorp/vault/api/auth/aws v0.12.0 github.com/hashicorp/vault/api/auth/kubernetes v0.12.0 - github.com/mbreban/attestation v0.1.0 github.com/newrelic/go-agent/v3 v3.44.1 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.24.0 github.com/rs/xid v1.6.0 github.com/sirupsen/logrus v1.9.4 github.com/slackhq/nebula v1.10.3 + github.com/smallstep/android-attestation v0.0.0-20260722204520-a51b1564ebda github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 github.com/smallstep/cli-utils v0.12.2 github.com/smallstep/go-attestation v0.4.4-0.20260603212853-e1a87a0b07d9 @@ -175,6 +175,3 @@ require ( google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -// patched to make tests pass; TODO: fork under Smallstep and use that -replace github.com/mbreban/attestation => github.com/hslatman/attestation v0.0.0-20260715144435-9a27f052f96a diff --git a/go.sum b/go.sum index 3f27e232f..91272588e 100644 --- a/go.sum +++ b/go.sum @@ -250,8 +250,6 @@ github.com/hashicorp/vault/api/auth/aws v0.12.0 h1:onkMrv49rQCF5Zx1/BdIEvwyhh9R2 github.com/hashicorp/vault/api/auth/aws v0.12.0/go.mod h1:Cuyla0RLfTnPkaJCaHGfNGsNIY1GqB2G79T7XI/9N+I= github.com/hashicorp/vault/api/auth/kubernetes v0.12.0 h1:DTrUMNXjpWEFMcU0FY1Eza+l4nSSz/+yUr6JN2GpzF0= github.com/hashicorp/vault/api/auth/kubernetes v0.12.0/go.mod h1:njyxrmFPtMuEPpPMZeemwhHovzC22hq2OuJtScI3iFc= -github.com/hslatman/attestation v0.0.0-20260715144435-9a27f052f96a h1:HF0KXAITuyKFoVMNos1UhnTmjNAjyQJH3Yrxfh83QMc= -github.com/hslatman/attestation v0.0.0-20260715144435-9a27f052f96a/go.mod h1:YWaxLRaBYCI4+EvJIOaMtEiP/8m9XTN3u0ltPWbfZ1Y= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= @@ -354,6 +352,8 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/slackhq/nebula v1.10.3 h1:EstYj8ODEcv6T0R9X5BVq1zgWZnyU5gtPzk99QF1PMU= github.com/slackhq/nebula v1.10.3/go.mod h1:IL5TUQm4x9IFx2kCKPYm1gP47pwd5b8QGnnBH2RHnvs= +github.com/smallstep/android-attestation v0.0.0-20260722204520-a51b1564ebda h1:IfbkxDvPbdr00RD5kgdzdCkPp4ANOLgXyVI8NkWjQ+I= +github.com/smallstep/android-attestation v0.0.0-20260722204520-a51b1564ebda/go.mod h1:WEOYAQ4qnt6LhEcreLGA0woxzE8k5e0vjGuIq72J5tw= github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY= github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc= github.com/smallstep/cli-utils v0.12.2 h1:lGzM9PJrH/qawbzMC/s2SvgLdJPKDWKwKzx9doCVO+k= From c4ca1a6a4df86f3a49a74bd459120e85feeb5bf4 Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Thu, 23 Jul 2026 11:31:25 +0200 Subject: [PATCH 12/12] Pass Android Key Attestation CRL checker from authority creation --- authority/authority.go | 24 +++++++++++++----------- authority/options.go | 10 ++++++++++ authority/provisioners.go | 1 + 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/authority/authority.go b/authority/authority.go index 98dd68968..8dd6a0b8e 100644 --- a/authority/authority.go +++ b/authority/authority.go @@ -30,6 +30,7 @@ import ( "github.com/smallstep/certificates/authority/internal/constraints" "github.com/smallstep/certificates/authority/policy" "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/authority/provisioner/androidkey" "github.com/smallstep/certificates/cas" casapi "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/db" @@ -41,17 +42,18 @@ import ( // Authority implements the Certificate Authority internal interface. type Authority struct { - config *config.Config - keyManager kms.KeyManager - provisioners *provisioner.Collection - admins *administrator.Collection - db db.AuthDB - adminDB admin.DB - templates *templates.Templates - linkedCAToken string - wrapTransport httptransport.Wrapper - webhookClient provisioner.HTTPClient - httpClient provisioner.HTTPClient + config *config.Config + keyManager kms.KeyManager + provisioners *provisioner.Collection + admins *administrator.Collection + db db.AuthDB + adminDB admin.DB + templates *templates.Templates + linkedCAToken string + wrapTransport httptransport.Wrapper + webhookClient provisioner.HTTPClient + httpClient provisioner.HTTPClient + androidKeyCRLChecker androidkey.CRLChecker // X509 CA password []byte diff --git a/authority/options.go b/authority/options.go index a5e50df1b..b12f83887 100644 --- a/authority/options.go +++ b/authority/options.go @@ -14,6 +14,7 @@ import ( "github.com/smallstep/certificates/authority/admin" "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/authority/provisioner/androidkey" "github.com/smallstep/certificates/cas" casapi "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/db" @@ -103,6 +104,15 @@ func WithWebhookClient(c provisioner.HTTPClient) Option { } } +// WithAndroidKeyCRLChecker sets the [androidkey.CRLChecker] to be +// used to check Android Key Attestation certificate revocation status. +func WithAndroidKeyCRLChecker(c androidkey.CRLChecker) Option { + return func(a *Authority) error { + a.androidKeyCRLChecker = c + return nil + } +} + // Wrapper wraps the set of functions mapping [http.Transport] references to [http.RoundTripper]. type TransportWrapper = httptransport.Wrapper diff --git a/authority/provisioners.go b/authority/provisioners.go index 132aa2ab7..3413ab309 100644 --- a/authority/provisioners.go +++ b/authority/provisioners.go @@ -205,6 +205,7 @@ func (a *Authority) generateProvisionerConfig(ctx context.Context) (provisioner. HTTPClient: a.httpClient, WrapTransport: a.wrapTransport, SCEPKeyManager: a.scepKeyManager, + AndroidKeyCRLChecker: a.androidKeyCRLChecker, }, nil }