summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/server/src/attestation/verifications/verifyApple.test.ts25
-rw-r--r--packages/server/src/attestation/verifications/verifyApple.ts101
-rw-r--r--packages/server/src/attestation/verifyAttestationResponse.ts8
-rw-r--r--packages/server/src/helpers/decodeAttestationObject.ts1
4 files changed, 135 insertions, 0 deletions
diff --git a/packages/server/src/attestation/verifications/verifyApple.test.ts b/packages/server/src/attestation/verifications/verifyApple.test.ts
new file mode 100644
index 0000000..8490520
--- /dev/null
+++ b/packages/server/src/attestation/verifications/verifyApple.test.ts
@@ -0,0 +1,25 @@
+import verifyAttestationResponse from '../verifyAttestationResponse';
+import base64url from 'base64url';
+
+test('should verify Apple attestation', async () => {
+ const expectedChallenge = 'h5xSyIRMx2IQPr1mQk6GD98XSQOBHgMHVpJIkMV9Nkc';
+ jest.spyOn(base64url, 'encode').mockReturnValueOnce(expectedChallenge);
+ const verification = await verifyAttestationResponse({
+ credential: {
+ id: 'J4lAqPXhefDrUD7oh5LQMbBH5TE',
+ rawId: 'J4lAqPXhefDrUD7oh5LQMbBH5TE',
+ response: {
+ attestationObject:
+ 'o2NmbXRlYXBwbGVnYXR0U3RtdKJjYWxnJmN4NWOCWQJHMIICQzCCAcmgAwIBAgIGAXSFZw11MAoGCCqGSM49BAMCMEgxHDAaBgNVBAMME0FwcGxlIFdlYkF1dGhuIENBIDExEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwHhcNMjAwOTEzMDI0OTE3WhcNMjAwOTE0MDI1OTE3WjCBkTFJMEcGA1UEAwxAMzI3ZWI1ODhmMTU3ZDZiYjY0NTRmOTdmNWU1NmM4NmY0NGI1MDdjODgxOGZmMjMwYmQwZjYyNWJkYjY1YmNiNjEaMBgGA1UECwwRQUFBIENlcnRpZmljYXRpb24xEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARiAlQ11YPbcpjmwM93iOefyu00h8-4BALNKnBDB5I9n17wD5wNqP0hYua340eB75Z1L_V6I7R4qraq7763zj9mo1UwUzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB_wQEAwIE8DAzBgkqhkiG92NkCAIEJjAkoSIEIPuwR1EQvcCtYCRahnJWisqz6YYLEAXH16p0WXbLfY6tMAoGCCqGSM49BAMCA2gAMGUCMDpEvt_ifVr8uu1rnLykezfrHBXwLL-D6DO73l_sX_DLRwXDmqTiPSx0WHiB554m5AIxAIAXIId3WdSC2B2zYFm4ZsJP_jAgjTL1GguZ-Ae78AN2AcjKblEabOdkbKr0aL_M9FkCODCCAjQwggG6oAMCAQICEFYlU5XHp_tA6-Io2CYIU7YwCgYIKoZIzj0EAwMwSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEGA1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODM4MDFaFw0zMDAzMTMwMDAwMDBaMEgxHDAaBgNVBAMME0FwcGxlIFdlYkF1dGhuIENBIDExEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASDLocvJhSRgQIlufX81rtjeLX1Xz_LBFvHNZk0df1UkETfm_4ZIRdlxpod2gULONRQg0AaQ0-yTREtVsPhz7_LmJH-wGlggb75bLx3yI3dr0alruHdUVta-quTvpwLJpGjZjBkMBIGA1UdEwEB_wQIMAYBAf8CAQAwHwYDVR0jBBgwFoAUJtdk2cV4wlpn0afeaxLQG2PxxtcwHQYDVR0OBBYEFOuugsT_oaxbUdTPJGEFAL5jvXeIMA4GA1UdDwEB_wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjEA3YsaNIGl-tnbtOdle4QeFEwnt1uHakGGwrFHV1Azcifv5VRFfvZIlQxjLlxIPnDBAjAsimBE3CAfz-Wbw00pMMFIeFHZYO1qdfHrSsq-OM0luJfQyAW-8Mf3iwelccboDgdoYXV0aERhdGFYmD3cRxDpwIiyKduonVYyILs59yKa_0ZbCmVrGvuaivigRQAAAAAAAAAAAAAAAAAAAAAAAAAAABQniUCo9eF58OtQPuiHktAxsEflMaUBAgMmIAEhWCBiAlQ11YPbcpjmwM93iOefyu00h8-4BALNKnBDB5I9nyJYIF7wD5wNqP0hYua340eB75Z1L_V6I7R4qraq7763zj9m',
+ clientDataJSON:
+ 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiaDV4U3lJUk14MklRUHIxbVFrNkdEOThYU1FPQkhnTUhWcEpJa01WOU5rYyIsIm9yaWdpbiI6Imh0dHBzOi8vZGV2LmRvbnRuZWVkYS5wdyJ9',
+ },
+ type: 'public-key',
+ },
+ expectedChallenge,
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: 'dev.dontneeda.pw',
+ });
+
+ expect(verification.verified).toEqual(true);
+});
diff --git a/packages/server/src/attestation/verifications/verifyApple.ts b/packages/server/src/attestation/verifications/verifyApple.ts
new file mode 100644
index 0000000..89ad540
--- /dev/null
+++ b/packages/server/src/attestation/verifications/verifyApple.ts
@@ -0,0 +1,101 @@
+import { AsnParser } from '@peculiar/asn1-schema';
+import { Certificate } from '@peculiar/asn1-x509';
+
+import type { AttestationStatement } from '../../helpers/decodeAttestationObject';
+import validateCertificatePath from '../../helpers/validateCertificatePath';
+import convertX509CertToPEM from '../../helpers/convertX509CertToPEM';
+import toHash from '../../helpers/toHash';
+import convertCOSEtoPKCS from '../../helpers/convertCOSEtoPKCS';
+
+type Options = {
+ attStmt: AttestationStatement;
+ authData: Buffer;
+ clientDataHash: Buffer;
+ credentialPublicKey: Buffer;
+};
+
+export default async function verifyApple(options: Options): Promise<boolean> {
+ const { attStmt, authData, clientDataHash, credentialPublicKey } = options;
+ const { x5c } = attStmt;
+
+ if (!x5c) {
+ throw new Error('No attestation certificate provided in attestation statement (Apple)');
+ }
+
+ /**
+ * Verify certificate path
+ */
+ const certPath = x5c.map(convertX509CertToPEM);
+ certPath.push(AppleWebAuthnRootCertificate);
+
+ try {
+ await validateCertificatePath(certPath);
+ } catch (err) {
+ throw new Error(`${err.message} (Apple)`);
+ }
+
+ /**
+ * Compare nonce in certificate extension to computed nonce
+ */
+ const parsedCredCert = AsnParser.parse(x5c[0], Certificate);
+ const { extensions, subjectPublicKeyInfo } = parsedCredCert.tbsCertificate;
+
+ if (!extensions) {
+ throw new Error('credCert missing extensions (Apple)');
+ }
+
+ const extCertNonce = extensions.find(ext => ext.extnID === '1.2.840.113635.100.8.2');
+
+ if (!extCertNonce) {
+ throw new Error('credCert missing "1.2.840.113635.100.8.2" extension (Apple)');
+ }
+
+ const nonceToHash = Buffer.concat([authData, clientDataHash]);
+ const nonce = toHash(nonceToHash, 'SHA256');
+ /**
+ * Ignore the first six ASN.1 structure bytes that define the nonce as an OCTET STRING. Should
+ * trim off <Buffer 30 24 a1 22 04 20>
+ *
+ * TODO: Try and get @peculiar (GitHub) to add a schema for "1.2.840.113635.100.8.2" when we
+ * find out where it's defined (doesn't seem to be publicly documented at the moment...)
+ */
+ const extNonce = Buffer.from(extCertNonce.extnValue).slice(6);
+
+ if (!nonce.equals(extNonce)) {
+ throw new Error(`credCert nonce was not expected value (Apple)`);
+ }
+
+ /**
+ * Verify credential public key matches the Subject Public Key of credCert
+ */
+ const credPubKeyPKCS = convertCOSEtoPKCS(credentialPublicKey);
+ const credCertSubjectPublicKey = Buffer.from(subjectPublicKeyInfo.subjectPublicKey);
+
+ if (!credPubKeyPKCS.equals(credCertSubjectPublicKey)) {
+ throw new Error('Credential public key does not equal credCert public key (Apple)');
+ }
+
+ return true;
+}
+
+/**
+ * Apple WebAuthn Root CA PEM
+ *
+ * Downloaded from https://www.apple.com/certificateauthority/Apple_WebAuthn_Root_CA.pem
+ *
+ * Valid until 03/14/2045 @ 5:00 PM PST
+ */
+const AppleWebAuthnRootCertificate = `-----BEGIN CERTIFICATE-----
+MIICEjCCAZmgAwIBAgIQaB0BbHo84wIlpQGUKEdXcTAKBggqhkjOPQQDAzBLMR8w
+HQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ
+bmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MjEzMloXDTQ1MDMx
+NTAwMDAwMFowSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEG
+A1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49
+AgEGBSuBBAAiA2IABCJCQ2pTVhzjl4Wo6IhHtMSAzO2cv+H9DQKev3//fG59G11k
+xu9eI0/7o6V5uShBpe1u6l6mS19S1FEh6yGljnZAJ+2GNP1mi/YK2kSXIuTHjxA/
+pcoRf7XkOtO4o1qlcaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJtdk
+2cV4wlpn0afeaxLQG2PxxtcwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA
+MGQCMFrZ+9DsJ1PW9hfNdBywZDsWDbWFp28it1d/5w2RPkRX3Bbn/UbDTNLx7Jr3
+jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B
+1bWeT0vT
+-----END CERTIFICATE-----`;
diff --git a/packages/server/src/attestation/verifyAttestationResponse.ts b/packages/server/src/attestation/verifyAttestationResponse.ts
index c6531a7..8acd028 100644
--- a/packages/server/src/attestation/verifyAttestationResponse.ts
+++ b/packages/server/src/attestation/verifyAttestationResponse.ts
@@ -14,6 +14,7 @@ import verifyPacked from './verifications/verifyPacked';
import verifyAndroidSafetynet from './verifications/verifyAndroidSafetyNet';
import verifyTPM from './verifications/tpm/verifyTPM';
import verifyAndroidKey from './verifications/verifyAndroidKey';
+import verifyApple from './verifications/verifyApple';
type Options = {
credential: AttestationCredentialJSON;
@@ -193,6 +194,13 @@ export default async function verifyAttestationResponse(
credentialPublicKey,
clientDataHash,
});
+ } else if (fmt === ATTESTATION_FORMATS.APPLE) {
+ verified = await verifyApple({
+ attStmt,
+ authData,
+ clientDataHash,
+ credentialPublicKey,
+ });
} else if (fmt === ATTESTATION_FORMATS.NONE) {
if (Object.keys(attStmt).length > 0) {
throw new Error('None attestation had unexpected attestation statement');
diff --git a/packages/server/src/helpers/decodeAttestationObject.ts b/packages/server/src/helpers/decodeAttestationObject.ts
index a692d6f..09d95fb 100644
--- a/packages/server/src/helpers/decodeAttestationObject.ts
+++ b/packages/server/src/helpers/decodeAttestationObject.ts
@@ -20,6 +20,7 @@ export enum ATTESTATION_FORMATS {
ANDROID_SAFETYNET = 'android-safetynet',
ANDROID_KEY = 'android-key',
TPM = 'tpm',
+ APPLE = 'apple',
NONE = 'none',
}