From 348dbd2b04255854ecd9eb6e28414e7bf1aece02 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin Date: Mon, 30 Mar 2026 16:00:51 +0200 Subject: [PATCH 01/11] 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 | 181 +++++++++++++++++++++++ authority/provisioner/acme.go | 63 +++++++- authority/provisioner/acme_test.go | 7 +- authority/provisioners.go | 4 + go.mod | 3 +- go.sum | 2 + 7 files changed, 477 insertions(+), 6 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..22d84afb2 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -31,6 +31,10 @@ import ( "time" "github.com/fxamacker/cbor/v2" + "github.com/mbreban/attestation" + "github.com/smallstep/certificates/authority/config" + "github.com/smallstep/certificates/authority/provisioner" + wireprovisioner "github.com/smallstep/certificates/authority/provisioner/wire" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -98,6 +102,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 +131,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 +4594,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 b7f8b1855..f2386a8d4 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,8 @@ require ( github.com/hashicorp/vault/api/auth/approle v0.11.0 github.com/hashicorp/vault/api/auth/aws v0.11.0 github.com/hashicorp/vault/api/auth/kubernetes v0.10.0 - github.com/newrelic/go-agent/v3 v3.42.0 + github.com/mbreban/attestation v0.1.0 + github.com/newrelic/go-agent/v3 v3.39.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 github.com/rs/xid v1.6.0 diff --git a/go.sum b/go.sum index 200fd5604..e4bce92d5 100644 --- a/go.sum +++ b/go.sum @@ -288,6 +288,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.0.3-0.20190429190417-a667d056470f/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= From 13a82be0587f459c6109d181ce6fbc49815a48a0 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin Date: Mon, 30 Mar 2026 16:00:51 +0200 Subject: [PATCH 02/11] Apply suggestions from code review Co-authored-by: Herman Slatman --- acme/challenge.go | 22 +++++++++++----------- acme/challenge_test.go | 2 +- authority/provisioner/acme.go | 10 +++++----- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index 33283599a..da1b054ed 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -864,7 +864,7 @@ func deviceAttest01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose // 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, "security level does not match")) } // 2. hardwareEnforced @@ -1419,7 +1419,7 @@ ixPvZtXQpUpuL12ab+9EaDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+ NpUFgNPN9PvQi8WEg5UmAGMCAwEAAQ== -----END PUBLIC KEY-----` -// Attestion oid for Android, encoded as an integer. +// OID for the Android attestation extension // 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} @@ -1429,8 +1429,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 @@ -1462,11 +1462,11 @@ 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 is malformed") } leaf, err := x509.ParseCertificate(der) if err != nil { - return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "failed to parse leaf certificate") + return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "failed parsing leaf certificate") } certs = append(certs, leaf) @@ -1476,11 +1476,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 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 in chain") } // Verify CRL if acme.IsRootRevoked(cert.SerialNumber.String()) { @@ -1505,10 +1505,10 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe switch root.PublicKey.(type) { case *rsa.PublicKey: if !root.PublicKey.(*rsa.PublicKey).Equal(trustedPubKey) { - return nil, NewDetailedError(ErrorBadAttestationStatementType, "Root certificate not signed by Android") + return nil, NewDetailedError(ErrorBadAttestationStatementType, "root certificate not signed by Google") } 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 @@ -1585,7 +1585,7 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe // validate challenge if string(data.Attestation.AttestationChallenge) != keyAuth { - return nil, NewDetailedError(ErrorBadAttestationStatementType, "Challenge mismatach: "+string(data.Attestation.AttestationChallenge)) + return nil, NewDetailedError(ErrorBadAttestationStatementType, fmt.Sprintf("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 22d84afb2..bd36481c4 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -195,7 +195,7 @@ func mustAttestAndroid(t *testing.T, keyAuthorization string) ([]byte, *x509.Cer }{ AttObj: base64.RawURLEncoding.EncodeToString(attObj), }) - fatalError(t, err) + require.NoError(t, err) return payload, leaf, ca.Root, rootAndroid } diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index 8106a5d42..3380ebba8 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -237,8 +237,8 @@ func (p *ACME) Init(config Config) (err error) { 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 { +// fetch CRL https://android.googleapis.com/attestation/status and build a list of revoked serial numbers +func (p *ACME) fetchAndroidCRL() error { log.Printf("Updating Android CRL list for %s ACME provisioner", p.Name) var CRLResponse struct { Entries map[string]struct { @@ -257,13 +257,13 @@ func (p *ACME) initializeAndroidCRL() error { return fmt.Errorf("unexpected Android CRL response %d: %s", res.StatusCode, string(bodyBytes)) } - if err := json.NewDecoder(res.Body).Decode(&CRLResponse); err != nil { + 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 := make([]string, 0, len(crlResponse.Entries)) + for k := range crlResponse.Entries { keys = append(keys, k) } p.RootCRLs = keys From 9d8d62bcd0546231d212c02944c1684da6e85feb Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin Date: Mon, 30 Mar 2026 16:00:51 +0200 Subject: [PATCH 03/11] apply suggestion from merge request --- authority/provisioner/acme.go | 9 +++++---- authority/provisioner/acme_test.go | 8 ++++---- authority/provisioners.go | 4 ++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index 3380ebba8..7d073350a 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -58,8 +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" + // ANDROIDKEY is the format used to enable device-attest-01 for Android + // devices using Android Key Attestation. + ANDROIDKEY ACMEAttestationFormat = "android-key" // APPLE is the format used to enable device-attest-01 on Apple devices. APPLE ACMEAttestationFormat = "apple" @@ -82,7 +83,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, ANDROIDKEY: return nil default: return fmt.Errorf("acme attestation format %q is not supported", f) @@ -422,7 +423,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, ANDROIDKEY, } if len(p.AttestationFormats) > 0 { enabledFormats = p.AttestationFormats diff --git a/authority/provisioner/acme_test.go b/authority/provisioner/acme_test.go index 3aaa88768..440356623 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", ANDROIDKEY, 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, ANDROIDKEY}, 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, 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}, @@ -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, ANDROIDKEY}, 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..15469ddc3 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 4: - ret = append(ret, provisioner.ANDROID) + 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: + case provisioner.ANDROIDKEY: ret = append(ret, 4) case provisioner.APPLE: ret = append(ret, linkedca.ACMEProvisioner_APPLE) From 0d95296e83ed3a2298c3da85cdacedf52ce4345c Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin Date: Mon, 30 Mar 2026 16:00:51 +0200 Subject: [PATCH 04/11] Update authority/provisioner/acme.go Co-authored-by: Herman Slatman --- authority/provisioner/acme.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index 7d073350a..bf30a63ed 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -445,7 +445,8 @@ 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 +// IsRootRevoked returns true if the provided serialNumber is in the list of revoked +// certificate serial number. // 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) { From bb57d6a5019cf554a38c27f379e2d8b5a6061352 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin Date: Mon, 30 Mar 2026 16:00:51 +0200 Subject: [PATCH 05/11] apply suggestion to MR --- acme/challenge.go | 4 ++-- authority/provisioner/acme.go | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index da1b054ed..75f35291d 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -1518,7 +1518,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(), KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, }); err != nil { return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "x5c chain verification failed") @@ -1585,7 +1585,7 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe // validate challenge if string(data.Attestation.AttestationChallenge) != keyAuth { - return nil, NewDetailedError(ErrorBadAttestationStatementType, fmt.Sprintf("challenge mismatch; expected %q, got %q", keyAuth, string(data.Attestation.AttestationChallenge)) + return nil, NewDetailedError(ErrorBadAttestationStatementType, fmt.Sprintf("challenge mismatch; expected %q, got %q", keyAuth, string(data.Attestation.AttestationChallenge))) } return data, nil diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index bf30a63ed..69c225f8d 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -228,26 +228,26 @@ 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() + if slices.Contains(p.AttestationFormats, ANDROIDKEY) && len(p.RootCRLs) == 0 { + p.fetchAndroidCRL() } 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" // fetch CRL https://android.googleapis.com/attestation/status and build a list of revoked serial numbers func (p *ACME) fetchAndroidCRL() error { log.Printf("Updating Android CRL list for %s ACME provisioner", p.Name) - var CRLResponse struct { + 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.httpClient.Get(androidAttestationStatusURL) if err != nil { return fmt.Errorf("client: error making Android CRL request: %s\n", err) } @@ -445,12 +445,12 @@ func (p *ACME) GetAttestationRoots() (*x509.CertPool, bool) { return p.attestationRootPool, p.attestationRootPool != nil } -// IsRootRevoked returns true if the provided serialNumber is in the list of revoked +// IsRootRevoked returns true if the provided serialNumber is in the list of revoked // certificate serial number. // 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() + p.fetchAndroidCRL() } return len(p.RootCRLs) > 0 && slices.Contains(p.RootCRLs, serialNumber) } From 2116c7889c26494d689d653e39f5f57cba630b24 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin Date: Mon, 30 Mar 2026 16:00:51 +0200 Subject: [PATCH 06/11] impl. suggestin from MR --- authority/provisioner/acme.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index 69c225f8d..950d84ea9 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -449,8 +449,8 @@ func (p *ACME) GetAttestationRoots() (*x509.CertPool, bool) { // certificate serial number. // 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) { + if slices.Contains(p.AttestationFormats, ANDROIDKEY) && !p.androidCRLTimeout.IsZero() && time.Now().After(p.androidCRLTimeout) { p.fetchAndroidCRL() } - return len(p.RootCRLs) > 0 && slices.Contains(p.RootCRLs, serialNumber) + return slices.Contains(p.RootCRLs, serialNumber) } From 1e0b9d33261557d1b599682d10011346c2be8947 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin Date: Mon, 30 Mar 2026 16:00:51 +0200 Subject: [PATCH 07/11] switch compare to subtle --- acme/challenge.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/challenge.go b/acme/challenge.go index 75f35291d..9d2b875e1 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -1584,7 +1584,7 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe } // validate challenge - if string(data.Attestation.AttestationChallenge) != keyAuth { + if subtle.ConstantTimeCompare([]byte(keyAuth), data.Attestation.AttestationChallenge) != 1 { return nil, NewDetailedError(ErrorBadAttestationStatementType, fmt.Sprintf("challenge mismatch; expected %q, got %q", keyAuth, string(data.Attestation.AttestationChallenge))) } From 65278e4fa7ac69a161a1e12ed3433d36fec8f2d8 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin Date: Mon, 30 Mar 2026 16:00:51 +0200 Subject: [PATCH 08/11] implement certificat based validation for root certificate in android attestation --- acme/challenge.go | 149 ++++++++++++++++++++++++++++------ acme/challenge_test.go | 22 ++--- authority/provisioner/acme.go | 7 +- 3 files changed, 136 insertions(+), 42 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index 9d2b875e1..61ca130c4 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" @@ -1402,22 +1401,87 @@ func doAppleAttestationFormat(_ context.Context, prov Provisioner, _ *Challenge, return data, nil } -// Android Root CA +// Android Root CA for RSA // 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-----` +// Note: Update your attestation processes to trust both the new and existing root certificates. Older devices with factory-provisioned keys don't support key rotation and continue to use the old root. +const OldAndroidRootCARSA = `-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIJAOj6GWMU0voYMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV +BAUTEGY5MjAwOWU4NTNiNmIwNDUwHhcNMTYwNTI2MTYyODUyWhcNMjYwNTI0MTYy +ODUyWjAbMRkwFwYDVQQFExBmOTIwMDllODUzYjZiMDQ1MIICIjANBgkqhkiG9w0B +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 +AGMCAwEAAaOBpjCBozAdBgNVHQ4EFgQUNmHhAHyIBQlRi0RsR/8aTMnqTxIwHwYD +VR0jBBgwFoAUNmHhAHyIBQlRi0RsR/8aTMnqTxIwDwYDVR0TAQH/BAUwAwEB/zAO +BgNVHQ8BAf8EBAMCAYYwQAYDVR0fBDkwNzA1oDOgMYYvaHR0cHM6Ly9hbmRyb2lk +Lmdvb2dsZWFwaXMuY29tL2F0dGVzdGF0aW9uL2NybC8wDQYJKoZIhvcNAQELBQAD +ggIBACDIw41L3KlXG0aMiS//cqrG+EShHUGo8HNsw30W1kJtjn6UBwRM6jnmiwfB +Pb8VA91chb2vssAtX2zbTvqBJ9+LBPGCdw/E53Rbf86qhxKaiAHOjpvAy5Y3m00m +qC0w/Zwvju1twb4vhLaJ5NkUJYsUS7rmJKHHBnETLi8GFqiEsqTWpG/6ibYCv7rY +DBJDcR9W62BW9jfIoBQcxUCUJouMPH25lLNcDc1ssqvC2v7iUgI9LeoM1sNovqPm +QUiG9rHli1vXxzCyaMTjwftkJLkf6724DFhuKug2jITV0QkXvaJWF4nUaHOTNA4u +JU9WDvZLI1j83A+/xnAJUucIv/zGJ1AMH2boHqF8CY16LpsYgBt6tKxxWH00XcyD +CdW2KlBCeqbQPcsFmWyWugxdcekhYsAWyoSf818NUsZdBWBaR/OukXrNLfkQ79Iy +ZohZbvabO/X+MVT3rriAoKc8oE2Uws6DF+60PV7/WIPjNvXySdqspImSN78mflxD +qwLqRBYkA3I75qppLGG9rp7UCdRjxMl8ZDBld+7yvHVgt1cVzJx9xnyGCC23Uaic +MDSXYrB4I4WHXPGjxhZuCuPBLTdOLU8YRvMYdEvYebWHMpvwGCF6bAx3JBpIeOQ1 +wDB5y0USicV3YgYGmi+NZfhA4URSh77Yd6uuJOJENRaNVTzk +-----END CERTIFICATE-----` + +const AndroidRootCARSA = `-----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 Root CA for ECDSA (starting feb 26) +// https://developer.android.com/privacy-and-security/security-key-attestation#root_certificate +const AndroidRootCAECDSA = `-----BEGIN CERTIFICATE----- +MIICIjCCAaigAwIBAgIRAISp0Cl7DrWK5/8OgN52BgUwCgYIKoZIzj0EAwMwUjEc +MBoGA1UEAwwTS2V5IEF0dGVzdGF0aW9uIENBMTEQMA4GA1UECwwHQW5kcm9pZDET +MBEGA1UECgwKR29vZ2xlIExMQzELMAkGA1UEBhMCVVMwHhcNMjUwNzE3MjIzMjE4 +WhcNMzUwNzE1MjIzMjE4WjBSMRwwGgYDVQQDDBNLZXkgQXR0ZXN0YXRpb24gQ0Ex +MRAwDgYDVQQLDAdBbmRyb2lkMRMwEQYDVQQKDApHb29nbGUgTExDMQswCQYDVQQG +EwJVUzB2MBAGByqGSM49AgEGBSuBBAAiA2IABCPaI3FO3z5bBQo8cuiEas4HjqCt +G/mLFfRT0MsIssPBEEU5Cfbt6sH5yOAxqEi5QagpU1yX4HwnGb7OtBYpDTB57uH5 +Eczm34A5FNijV3s0/f0UPl7zbJcTx6xwqMIRq6NCMEAwDwYDVR0TAQH/BAUwAwEB +/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFFIyuyz7RkOb3NaBqQ5lZuA0QepA +MAoGCCqGSM49BAMDA2gAMGUCMETfjPO/HwqReR2CS7p0ZWoD/LHs6hDi422opifH +EUaYLxwGlT9SLdjkVpz0UUOR5wIxAIoGyxGKRHVTpqpGRFiJtQEOOTp/+s1GcxeY +uR2zh/80lQyu9vAFCj6E4AXc+osmRg==` // OID for the Android attestation extension // https://source.android.com/docs/security/features/keystore/attestation#id-attestation @@ -1483,7 +1547,7 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "failed parsing certificate in chain") } // Verify CRL - if acme.IsRootRevoked(cert.SerialNumber.String()) { + if acme.IsCertificateRevoked(cert.SerialNumber.String()) { return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c element contain a revoked certificate") } if i == len(x5c)-2 { @@ -1500,15 +1564,50 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe 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 Google") + // Verify attestation root signature + // Use configured or default attestation roots if none is configured. + attestationRoots, attOk := prov.GetAttestationRoots() + if !attOk { + attestationRoots = x509.NewCertPool() + switch root.PublicKey.(type) { + case *rsa.PublicKey: + rsaRoot, err := pemutil.ParseCertificate([]byte(AndroidRootCARSA)) + oldRsaRoot, err := pemutil.ParseCertificate([]byte(OldAndroidRootCARSA)) + if err != nil { + return nil, WrapErrorISE(err, "error parsing root ca") + } + if !root.PublicKey.(*rsa.PublicKey).Equal(rsaRoot.PublicKey) { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "root certificate not signed by Google") + } + attestationRoots.AddCert(rsaRoot) + attestationRoots.AddCert(oldRsaRoot) + case *ecdsa.PublicKey: + ecdsaRoot, err := pemutil.ParseCertificate([]byte(AndroidRootCAECDSA)) + if err != nil { + return nil, WrapErrorISE(err, "error parsing root ca") + } + if !root.PublicKey.(*rsa.PublicKey).Equal(ecdsaRoot.PublicKey) { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "root certificate not signed by Google") + } + attestationRoots.AddCert(ecdsaRoot) + default: + return nil, NewDetailedError(ErrorBadAttestationStatementType, "invalid root certificate key type") + } + if _, err := root.Verify(x509.VerifyOptions{ + Roots: attestationRoots, + CurrentTime: time.Now(), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + }); err != nil { + return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "root certificate not signed by Google") + } + } else { + if _, err := root.Verify(x509.VerifyOptions{ + Roots: attestationRoots, + CurrentTime: time.Now(), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + }); err != nil { + return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "root certificate not signed by provided roots") } - default: - return nil, NewDetailedError(ErrorBadAttestationStatementType, "invalid root certificate key type") } // Validate the full chain including root as trust anchor diff --git a/acme/challenge_test.go b/acme/challenge_test.go index bd36481c4..73262c974 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -155,19 +155,14 @@ func mustAttestAndroid(t *testing.T, keyAuthorization string) ([]byte, *x509.Cer }, } attestByte, err := attestation.CreateKeyDescription(&atts) - if err != nil { - fatalError(t, err) - } + fatalError(t, err) - block, _ := pem.Decode([]byte(AndroidRootCAPubKey)) - trustedPubKey, err := x509.ParsePKIXPublicKey(block.Bytes) + root, err := pemutil.ParseCertificate([]byte(AndroidRootCARSA)) + fatalError(t, err) rootAndroid, err := ca.Sign(&x509.Certificate{ Subject: pkix.Name{CommonName: "attestation cert"}, - PublicKey: trustedPubKey, - Extensions: []pkix.Extension{ - {Id: oidAndroidAttestation, Value: attestByte}, - }, + PublicKey: root.PublicKey, }) leaf, err := ca.Sign(&x509.Certificate{ @@ -180,12 +175,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.Raw}, }, }) fatalError(t, err) @@ -4639,7 +4634,6 @@ func Test_deviceAttest01Validate(t *testing.T) { 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{ diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index 950d84ea9..fcdb187c7 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -247,7 +247,8 @@ func (p *ACME) fetchAndroidCRL() error { Reason string `json:"reason"` } `json:"entries"` } - res, err := p.ctl.httpClient.Get(androidAttestationStatusURL) + // res, err := p.ctl.GetHTTPClient().Get(androidAttestationStatusURL) + res, err := http.Get(androidAttestationStatusURL) if err != nil { return fmt.Errorf("client: error making Android CRL request: %s\n", err) } @@ -445,10 +446,10 @@ func (p *ACME) GetAttestationRoots() (*x509.CertPool, bool) { return p.attestationRootPool, p.attestationRootPool != nil } -// IsRootRevoked returns true if the provided serialNumber is in the list of revoked +// IsCertificateRevoked returns true if the provided serialNumber is in the list of revoked // certificate serial number. // 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 { +func (p *ACME) IsCertificateRevoked(serialNumber string) bool { if slices.Contains(p.AttestationFormats, ANDROIDKEY) && !p.androidCRLTimeout.IsZero() && time.Now().After(p.androidCRLTimeout) { p.fetchAndroidCRL() } From c3c87d4235e38191947180d435cd364f6c3e2f09 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin Date: Mon, 30 Mar 2026 16:00:51 +0200 Subject: [PATCH 09/11] apply suggestion and refactor from MR --- acme/challenge.go | 41 +++++++++++++++++++++-------------- acme/challenge_test.go | 32 ++++++++++----------------- authority/provisioner/acme.go | 20 ++++++++--------- 3 files changed, 47 insertions(+), 46 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index 61ca130c4..43adf352e 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -861,12 +861,12 @@ func deviceAttest01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose return WrapErrorISE(err, "error validating attestation") } - // 1. attestationSecurityLevel > 0 + // 1. attestationSecurityLevel > 0 (TrustedEnvironment or Strongbox) if data.Attestation.AttestationSecurityLevel < 1 { return storeError(ctx, db, ch, true, NewDetailedError(ErrorBadAttestationStatementType, "security level does not match")) } - // 2. hardwareEnforced + // 2. validate teeEnforced device identifier against permanent-identifier if ch.Value != string(data.Attestation.TeeEnforced.AttestationIdSerial) { subproblem := NewSubproblemWithIdentifier( ErrorRejectedIdentifierType, @@ -1486,6 +1486,7 @@ uR2zh/80lQyu9vAFCj6E4AXc+osmRg==` // OID for the Android attestation extension // 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} +var oidAndroidProvisionningAttestation = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 1, 30} type androidKeyAttestationData struct { Certificate *x509.Certificate @@ -1493,15 +1494,13 @@ type androidKeyAttestationData struct { Attestation *attestation.KeyDescription } -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 - } +func hasAndroidAttestation(cert *x509.Certificate) bool { + for _, ext := range cert.Extensions { + if ext.Id.Equal(oidAndroidAttestation) { + return true } } - 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 false } // https://developer.android.com/privacy-and-security/security-key-attestation @@ -1524,6 +1523,9 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe if len(x5c) == 0 { return nil, NewDetailedError(ErrorRejectedIdentifierType, "x5c is empty") } + if len(x5c) < 3 { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "at least 3 certificates are required") + } der, ok := x5c[0].([]byte) if !ok { return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c is malformed") @@ -1532,6 +1534,10 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe if err != nil { return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "failed parsing leaf certificate") } + if !hasAndroidAttestation(leaf) { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "leaf certificate do not contains attestation") + } + certs = append(certs, leaf) // Parse intermediates and root @@ -1576,6 +1582,7 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe if err != nil { return nil, WrapErrorISE(err, "error parsing root ca") } + // 1. verify public key if !root.PublicKey.(*rsa.PublicKey).Equal(rsaRoot.PublicKey) { return nil, NewDetailedError(ErrorBadAttestationStatementType, "root certificate not signed by Google") } @@ -1593,10 +1600,11 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe default: return nil, NewDetailedError(ErrorBadAttestationStatementType, "invalid root certificate key type") } + // 2. validate root certificate if _, err := root.Verify(x509.VerifyOptions{ Roots: attestationRoots, CurrentTime: time.Now(), - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, }); err != nil { return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "root certificate not signed by Google") } @@ -1604,7 +1612,7 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe if _, err := root.Verify(x509.VerifyOptions{ Roots: attestationRoots, CurrentTime: time.Now(), - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, }); err != nil { return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "root certificate not signed by provided roots") } @@ -1618,7 +1626,7 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe Intermediates: intermediates, Roots: roots, CurrentTime: time.Now(), - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, }); err != nil { return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "x5c chain verification failed") } @@ -1636,10 +1644,11 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe // Parse attestation data: // find the attestation certificate - attCert, err := findAndroidAttestationCert(certs) - if err != nil { - return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "") - } + // attCert, err := findAndroidAttestationCert(certs) + // if err != nil { + // return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "") + // } + attCert := leaf switch pub := attCert.PublicKey.(type) { case *ecdsa.PublicKey: diff --git a/acme/challenge_test.go b/acme/challenge_test.go index 73262c974..57c71a73e 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -106,11 +106,11 @@ func mustNonCRLAttestationProvisioner(t *testing.T, roots []byte, CRLs []string) 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}, + AttestationRoots: roots, + RevokedCertificateSerials: CRLs, } if err := prov.Init(provisioner.Config{ Claims: config.GlobalProvisionerClaims, @@ -131,7 +131,7 @@ 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() @@ -157,18 +157,10 @@ func mustAttestAndroid(t *testing.T, keyAuthorization string) ([]byte, *x509.Cer attestByte, err := attestation.CreateKeyDescription(&atts) fatalError(t, err) - root, err := pemutil.ParseCertificate([]byte(AndroidRootCARSA)) - fatalError(t, err) - - rootAndroid, err := ca.Sign(&x509.Certificate{ - Subject: pkix.Name{CommonName: "attestation cert"}, - PublicKey: root.PublicKey, - }) - 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}, }, }) @@ -180,7 +172,7 @@ 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.Raw}, + "x5c": []any{leaf.Raw, ca.Intermediate.Raw, ca.Root.Raw}, }, }) fatalError(t, err) @@ -192,7 +184,7 @@ func mustAttestAndroid(t *testing.T, keyAuthorization string) ([]byte, *x509.Cer }) 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,7 +4584,7 @@ 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)) @@ -4633,9 +4625,9 @@ 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, diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index fcdb187c7..8968f588c 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -126,13 +126,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"` + RevokedCertificateSerials []string `json:"revokedCertificateSerials,omitempty"` + androidCRLTimeout time.Time + attestationRootPool *x509.CertPool + ctl *Controller } // GetID returns the provisioner unique identifier. @@ -228,7 +228,7 @@ func (p *ACME) Init(config Config) (err error) { return fmt.Errorf("failed initializing Wire options: %w", err) } - if slices.Contains(p.AttestationFormats, ANDROIDKEY) && len(p.RootCRLs) == 0 { + if slices.Contains(p.AttestationFormats, ANDROIDKEY) && len(p.RevokedCertificateSerials) == 0 { p.fetchAndroidCRL() } @@ -268,7 +268,7 @@ func (p *ACME) fetchAndroidCRL() error { for k := range crlResponse.Entries { keys = append(keys, k) } - p.RootCRLs = keys + p.RevokedCertificateSerials = keys p.androidCRLTimeout = time.Now().Add(24 * time.Hour) return nil } @@ -453,5 +453,5 @@ func (p *ACME) IsCertificateRevoked(serialNumber string) bool { if slices.Contains(p.AttestationFormats, ANDROIDKEY) && !p.androidCRLTimeout.IsZero() && time.Now().After(p.androidCRLTimeout) { p.fetchAndroidCRL() } - return slices.Contains(p.RootCRLs, serialNumber) + return slices.Contains(p.RevokedCertificateSerials, serialNumber) } From 307a50a8c59b3a803b80234a4ce1c97d7b15f1a8 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin Date: Mon, 30 Mar 2026 16:00:51 +0200 Subject: [PATCH 10/11] revert findAndroidAttestationCert to follow guideline --- acme/challenge.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index 43adf352e..c2a7adc5c 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -1503,6 +1503,15 @@ func hasAndroidAttestation(cert *x509.Certificate) bool { return false } +func findAndroidAttestationCert(intermediates []*x509.Certificate) (*x509.Certificate, error) { + for _, cert := range intermediates { + if hasAndroidAttestation(cert) { + 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. @@ -1643,12 +1652,11 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe } // Parse attestation data: - // find the attestation certificate - // attCert, err := findAndroidAttestationCert(certs) - // if err != nil { - // return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "") - // } - attCert := leaf + // find the nearest attestation certificate + attCert, err := findAndroidAttestationCert(certs) + if err != nil { + return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "") + } switch pub := attCert.PublicKey.(type) { case *ecdsa.PublicKey: From 50add865fc6ee0e4fcc3d0e1a9f7c74bf57f7fe5 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin Date: Mon, 30 Mar 2026 16:00:51 +0200 Subject: [PATCH 11/11] fix: android root CA error --- acme/challenge.go | 12 +++++--- acme/challenge_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index c2a7adc5c..77ad4239b 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -1481,7 +1481,8 @@ Eczm34A5FNijV3s0/f0UPl7zbJcTx6xwqMIRq6NCMEAwDwYDVR0TAQH/BAUwAwEB /zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFFIyuyz7RkOb3NaBqQ5lZuA0QepA MAoGCCqGSM49BAMDA2gAMGUCMETfjPO/HwqReR2CS7p0ZWoD/LHs6hDi422opifH EUaYLxwGlT9SLdjkVpz0UUOR5wIxAIoGyxGKRHVTpqpGRFiJtQEOOTp/+s1GcxeY -uR2zh/80lQyu9vAFCj6E4AXc+osmRg==` +uR2zh/80lQyu9vAFCj6E4AXc+osmRg== +-----END CERTIFICATE-----` // OID for the Android attestation extension // https://source.android.com/docs/security/features/keystore/attestation#id-attestation @@ -1587,9 +1588,12 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe switch root.PublicKey.(type) { case *rsa.PublicKey: rsaRoot, err := pemutil.ParseCertificate([]byte(AndroidRootCARSA)) + if err != nil { + return nil, WrapErrorISE(err, "error parsing root CA RSA") + } oldRsaRoot, err := pemutil.ParseCertificate([]byte(OldAndroidRootCARSA)) if err != nil { - return nil, WrapErrorISE(err, "error parsing root ca") + return nil, WrapErrorISE(err, "error parsing old root CA RSA") } // 1. verify public key if !root.PublicKey.(*rsa.PublicKey).Equal(rsaRoot.PublicKey) { @@ -1602,7 +1606,7 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe if err != nil { return nil, WrapErrorISE(err, "error parsing root ca") } - if !root.PublicKey.(*rsa.PublicKey).Equal(ecdsaRoot.PublicKey) { + if !root.PublicKey.(*ecdsa.PublicKey).Equal(ecdsaRoot.PublicKey) { return nil, NewDetailedError(ErrorBadAttestationStatementType, "root certificate not signed by Google") } attestationRoots.AddCert(ecdsaRoot) @@ -1701,7 +1705,7 @@ func doAndroidKeyAttestionFormat(_ context.Context, prov Provisioner, ch *Challe // validate challenge if subtle.ConstantTimeCompare([]byte(keyAuth), data.Attestation.AttestationChallenge) != 1 { - return nil, NewDetailedError(ErrorBadAttestationStatementType, fmt.Sprintf("challenge mismatch; expected %q, got %q", keyAuth, 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 57c71a73e..a970e308c 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -5097,6 +5097,74 @@ func Test_deviceAttest01Validate(t *testing.T) { } } +func Test_doAndroidKeyAttestionFormat_noAttestationRoots(t *testing.T) { + // This test exercises the fallback path when no attestation roots + // are configured (the !attOk branch), verifying that: + // 1. The ECDSA root parsing and type assertion works correctly + // 2. Non-Google roots are properly rejected + ca, err := minica.New() + require.NoError(t, err) + + signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + keyAuthSum := sha256.Sum256([]byte(keyAuth)) + sig, err := signer.Sign(rand.Reader, keyAuthSum[:], crypto.SHA256) + require.NoError(t, err) + + atts := attestation.KeyDescription{ + AttestationVersion: 300, + AttestationSecurityLevel: 1, + AttestationChallenge: sig, + TeeEnforced: attestation.AuthorizationList{ + AttestationIdSerial: []byte("serial-number"), + }, + } + attestByte, err := attestation.CreateKeyDescription(&atts) + require.NoError(t, err) + + leaf, err := ca.Sign(&x509.Certificate{ + Subject: pkix.Name{CommonName: "attestation cert"}, + PublicKey: signer.Public(), + ExtraExtensions: []pkix.Extension{ + {Id: oidAndroidAttestation, Value: attestByte}, + }, + }) + require.NoError(t, err) + + att := &attestationObject{ + Format: "android-key", + AttStatement: map[string]any{ + "x5c": []any{leaf.Raw, ca.Intermediate.Raw, ca.Root.Raw}, + }, + } + + // Create provisioner without attestation roots + prov := &provisioner.ACME{ + Type: "ACME", + Name: "acme", + Challenges: []provisioner.ACMEChallenge{provisioner.DEVICE_ATTEST_01}, + } + require.NoError(t, prov.Init(provisioner.Config{ + Claims: config.GlobalProvisionerClaims, + })) + + ch := &Challenge{ + ID: "chID", + Token: "nonce", + Type: "device-attest-01", + Value: "serial-number", + } + + _, err = doAndroidKeyAttestionFormat(context.Background(), prov, ch, jwk, att) + require.Error(t, err) + + var acmeErr *Error + require.ErrorAs(t, err, &acmeErr) + assert.Contains(t, acmeErr.Error(), "root certificate not signed by Google") +} + var ( oidTPMManufacturer = asn1.ObjectIdentifier{2, 23, 133, 2, 1} oidTPMModel = asn1.ObjectIdentifier{2, 23, 133, 2, 2}