From 62915f5c42a865cdfbf4a8df138b387117610f1a Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin <> Date: Mon, 30 Jun 2025 11:14:47 +0200 Subject: [PATCH 01/10] 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 df81ecd2a..f9a108409 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" @@ -36,6 +37,7 @@ import ( "go.step.sm/crypto/x509util" "golang.org/x/exp/slices" + "github.com/mbreban/attestation" "github.com/smallstep/certificates/acme/wire" "github.com/smallstep/certificates/authority/provisioner" wireprovisioner "github.com/smallstep/certificates/authority/provisioner/wire" @@ -834,7 +836,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)) } @@ -843,6 +845,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 { @@ -1367,6 +1399,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 f0c7ae28f..77227724f 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/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/provisioner" wireprovisioner "github.com/smallstep/certificates/authority/provisioner/wire" @@ -96,6 +97,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() @@ -107,6 +126,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() @@ -4436,6 +4524,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 32a0bdf0d..29ba530f4 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 } @@ -216,10 +226,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. @@ -371,7 +421,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 @@ -392,3 +442,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 96f4bd8b3..231b9d55a 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}, @@ -198,7 +199,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")), }, } @@ -426,14 +427,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 43a14da0e..05c74eb73 100644 --- a/authority/provisioners.go +++ b/authority/provisioners.go @@ -1358,6 +1358,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: @@ -1375,6 +1377,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 b479e51d9..0b49acc6c 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/hashicorp/vault/api/auth/approle v0.10.0 github.com/hashicorp/vault/api/auth/aws v0.10.0 github.com/hashicorp/vault/api/auth/kubernetes v0.10.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.22.0 diff --git a/go.sum b/go.sum index 990ead9eb..0613238db 100644 --- a/go.sum +++ b/go.sum @@ -289,6 +289,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 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 801e176c293b09e839990cd2d2924a5124724aeb Mon Sep 17 00:00:00 2001 From: Jean-Baptiste PIN Date: Tue, 26 Aug 2025 16:16:03 +0200 Subject: [PATCH 02/10] 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 1adb159fe..40898fb68 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -860,7 +860,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 @@ -1416,7 +1416,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} @@ -1426,8 +1426,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 @@ -1459,11 +1459,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) @@ -1473,11 +1473,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()) { @@ -1502,10 +1502,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 @@ -1582,7 +1582,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 77227724f..ec3c28fcb 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -190,7 +190,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 29ba530f4..098ba6cf3 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -236,8 +236,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 { @@ -256,13 +256,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 64899836e01cef2fdc532fe5de7010465465210f Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin <> Date: Tue, 26 Aug 2025 16:16:58 +0200 Subject: [PATCH 03/10] 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 098ba6cf3..41618ff26 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) @@ -421,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, ANDROIDKEY, } if len(p.AttestationFormats) > 0 { enabledFormats = p.AttestationFormats diff --git a/authority/provisioner/acme_test.go b/authority/provisioner/acme_test.go index 231b9d55a..779d0d3bf 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}, @@ -199,7 +199,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")), }, } @@ -427,7 +427,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}, @@ -436,7 +436,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 05c74eb73..37b22d792 100644 --- a/authority/provisioners.go +++ b/authority/provisioners.go @@ -1359,7 +1359,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: @@ -1377,7 +1377,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 d45eb9d79e21ed7f391a406be6cabfd5e8cb5089 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste PIN Date: Tue, 26 Aug 2025 17:02:42 +0200 Subject: [PATCH 04/10] 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 41618ff26..ddaca0b7d 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -444,7 +444,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 035c62e191468f58b0d70be05ec8194ec5868a27 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin <> Date: Tue, 26 Aug 2025 17:04:08 +0200 Subject: [PATCH 05/10] 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 40898fb68..62f817313 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -1515,7 +1515,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") @@ -1582,7 +1582,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 ddaca0b7d..28be8e1a7 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -227,26 +227,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) } @@ -444,12 +444,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 f3f0916981974632ada9a52e701d8d54e654ad51 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin <> Date: Tue, 26 Aug 2025 17:06:56 +0200 Subject: [PATCH 06/10] 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 28be8e1a7..88fa25a75 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -448,8 +448,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 fa2ed566127a8ddaf30ba91d7fc5915edb66ecc8 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin <> Date: Tue, 26 Aug 2025 17:10:40 +0200 Subject: [PATCH 07/10] 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 62f817313..5d13e4001 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -1581,7 +1581,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 e892f4fa08e5df829dac46a27085ab97e635886e Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin <> Date: Wed, 27 Aug 2025 13:52:34 +0200 Subject: [PATCH 08/10] 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 5d13e4001..134941979 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" @@ -1399,22 +1398,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 @@ -1480,7 +1544,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 { @@ -1497,15 +1561,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 ec3c28fcb..613d8f26f 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -150,19 +150,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{ @@ -175,12 +170,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) @@ -4569,7 +4564,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 88fa25a75..544e9f08c 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -246,7 +246,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) } @@ -444,10 +445,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 04bbf23b9b74ba6cf321cb3659cf62bd9e266a32 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin <> Date: Wed, 27 Aug 2025 16:41:15 +0200 Subject: [PATCH 09/10] 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 134941979..42220612d 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -857,12 +857,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, @@ -1483,6 +1483,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 @@ -1490,15 +1491,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 @@ -1521,6 +1520,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") @@ -1529,6 +1531,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 @@ -1573,6 +1579,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") } @@ -1590,10 +1597,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") } @@ -1601,7 +1609,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") } @@ -1615,7 +1623,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") } @@ -1633,10 +1641,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 613d8f26f..07797e922 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -101,11 +101,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, @@ -126,7 +126,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() @@ -152,18 +152,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}, }, }) @@ -175,7 +167,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) @@ -187,7 +179,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) { @@ -4522,7 +4514,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)) @@ -4563,9 +4555,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 544e9f08c..776f0bb38 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. @@ -227,7 +227,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() } @@ -267,7 +267,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 } @@ -452,5 +452,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 8548092e2bdce6f67206f4286564a716cf6cd823 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Pin <> Date: Thu, 11 Sep 2025 15:40:58 +0200 Subject: [PATCH 10/10] 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 42220612d..05c650ad6 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -1500,6 +1500,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. @@ -1640,12 +1649,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: