diff --git a/acme/challenge.go b/acme/challenge.go index c0f9425cf..c40f99c38 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -37,6 +37,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" @@ -835,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)) } @@ -844,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 (TrustedEnvironment or Strongbox) + if data.Attestation.AttestationSecurityLevel < 1 { + return storeError(ctx, db, ch, true, NewDetailedError(ErrorBadAttestationStatementType, "security level does not match")) + } + + // 2. validate teeEnforced device identifier against permanent-identifier + 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 { @@ -1368,6 +1399,312 @@ func doAppleAttestationFormat(_ context.Context, prov Provisioner, _ *Challenge, return data, nil } +// Android Root CA for RSA +// https://developer.android.com/privacy-and-security/security-key-attestation#root_certificate +// 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 +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 + Fingerprint string + Attestation *attestation.KeyDescription +} + +func hasAndroidAttestation(cert *x509.Certificate) bool { + for _, ext := range cert.Extensions { + if ext.Id.Equal(oidAndroidAttestation) { + return true + } + } + 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. +// 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") + } + 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") + } + leaf, err := x509.ParseCertificate(der) + 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 + intermediates := x509.NewCertPool() + var root *x509.Certificate + for i, v := range x5c[1:] { + der, ok := v.([]byte) + if !ok { + return nil, NewDetailedError(ErrorBadAttestationStatementType, "x5c is malformed") + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "failed parsing certificate in chain") + } + // Verify CRL + if acme.IsCertificateRevoked(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") + } + + // 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") + } + // 1. verify public key + 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") + } + // 2. validate root certificate + if _, err := root.Verify(x509.VerifyOptions{ + Roots: attestationRoots, + CurrentTime: time.Now(), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + }); 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.ExtKeyUsageAny}, + }); err != nil { + return nil, WrapDetailedError(ErrorBadAttestationStatementType, err, "root certificate not signed by provided roots") + } + } + + // 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(), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + }); 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 nearest 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 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 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 0e630637a..bb19608fa 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" @@ -39,10 +43,6 @@ import ( "go.step.sm/crypto/minica" "go.step.sm/crypto/pemutil" "go.step.sm/crypto/x509util" - - "github.com/smallstep/certificates/authority/config" - "github.com/smallstep/certificates/authority/provisioner" - wireprovisioner "github.com/smallstep/certificates/authority/provisioner/wire" ) type mockClient struct { @@ -98,6 +98,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, + RevokedCertificateSerials: 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 +127,62 @@ func mustAccountAndKeyAuthorization(t *testing.T, token string) (*jose.JSONWebKe return jwk, keyAuth } +func mustAttestAndroid(t *testing.T, keyAuthorization string) ([]byte, *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) + fatalError(t, err) + + leaf, err := ca.Sign(&x509.Certificate{ + Subject: pkix.Name{CommonName: "attestation cert"}, + PublicKey: signer.Public(), + ExtraExtensions: []pkix.Extension{ + {Id: oidAndroidAttestation, Value: attestByte}, + }, + }) + fatalError(t, err) + + attObj, err := cbor.Marshal(struct { + Format string `json:"fmt"` + AttStatement map[string]any `json:"attStmt,omitempty"` + }{ + Format: "android-key", + AttStatement: map[string]any{ + "x5c": []any{leaf.Raw, ca.Intermediate.Raw, ca.Root.Raw}, + }, + }) + fatalError(t, err) + + payload, err := json.Marshal(struct { + AttObj string `json:"attObj"` + }{ + AttObj: base64.RawURLEncoding.EncodeToString(attObj), + }) + require.NoError(t, err) + + return payload, leaf, ca.Root +} + func mustAttestApple(t *testing.T, nonce string) ([]byte, *x509.Certificate, *x509.Certificate) { t.Helper() @@ -4503,6 +4577,95 @@ 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 := mustAttestAndroid(t, keyAuth) + caRoot := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: root.Raw}) + ctx := NewProvisionerContext(context.Background(), mustNonCRLAttestationProvisioner(t, caRoot, []string{root.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..776f0bb38 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,10 @@ func (c ACMEChallenge) Validate() error { type ACMEAttestationFormat string const ( + // 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" @@ -74,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: + case APPLE, STEP, TPM, ANDROIDKEY: return nil default: return fmt.Errorf("acme attestation format %q is not supported", f) @@ -117,11 +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"` - 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. @@ -216,10 +227,51 @@ 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.RevokedCertificateSerials) == 0 { + p.fetchAndroidCRL() + } + p.ctl, err = NewController(p, p.Claims, config, p.Options) return } +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 { + Entries map[string]struct { + Status string `json:"status"` + Reason string `json:"reason"` + } `json:"entries"` + } + // 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) + } + 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.RevokedCertificateSerials = 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 +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, + APPLE, STEP, TPM, ANDROIDKEY, } if len(p.AttestationFormats) > 0 { enabledFormats = p.AttestationFormats @@ -392,3 +444,13 @@ func (p *ACME) IsAttestationFormatEnabled(_ context.Context, format ACMEAttestat func (p *ACME) GetAttestationRoots() (*x509.CertPool, bool) { return p.attestationRootPool, p.attestationRootPool != nil } + +// 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) IsCertificateRevoked(serialNumber string) bool { + if slices.Contains(p.AttestationFormats, ANDROIDKEY) && !p.androidCRLTimeout.IsZero() && time.Now().After(p.androidCRLTimeout) { + p.fetchAndroidCRL() + } + return slices.Contains(p.RevokedCertificateSerials, serialNumber) +} diff --git a/authority/provisioner/acme_test.go b/authority/provisioner/acme_test.go index 96f4bd8b3..779d0d3bf 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", ANDROIDKEY, 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, ANDROIDKEY}, 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, 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}, + {"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, 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 43a14da0e..37b22d792 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.ANDROIDKEY) 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.ANDROIDKEY: + 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 b773222a7..67df1eabc 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.40.1 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 diff --git a/go.sum b/go.sum index dcb0db831..b3e17d3e2 100644 --- a/go.sum +++ b/go.sum @@ -287,6 +287,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=