diff --git a/README.md b/README.md index 0826ba3..e668436 100644 --- a/README.md +++ b/README.md @@ -683,13 +683,13 @@ Due to an inability to generate responses with all formats, not all are supporte | Format | Supported | Spec | Notes | | --- | --- | --- | --- | -| `packed` | ✅⚠️ | 8.2 | Parses fine, attestation trust path support is limited. | +| `packed` | ✅⚠️[^limited-trust-path] | 8.2 | | | `tpm` | ❌ | 8.3 | | | `android-key` | ❌ | 8.4 | | | `android-safetynet` | ❌ | 8.5 | | | `fido-u2f` | ✅ | 8.6 | YubiKeys and similar U2F stateless devices. | | `none` | ✅ | 8.7 | Used by Apple in Safari when using Passkeys (even when direct attestation is requested) | -| `apple` | ❌ | 8.8 | Apple does not appear to use this format, instead providing non-attested credentials. | +| `apple` | ✅⚠️ [^ext-vec], [^limited-trust-path] | 8.8 | Apple no longer appears to use this format, instead providing non-attested credentials (fmt=none). | | `compound` | ❌ | 8.9 | This format only appears in the editor's draft of the spec and is not yet on the official registry. | If you receive an `UnhandledMatchError` from the library pertaining to a format, please file an issue. @@ -716,3 +716,6 @@ General quickstart guide: Intro to passkeys: - https://developer.apple.com/videos/play/wwdc2021/10106/ + +[^ext-vec]: Support is based on [unofficial test vectors](https://github.com/w3c/webauthn/issues/1633). +[^limited-trust-path]: Handling of attestation trust path is limited. diff --git a/src/Attestations/Apple.php b/src/Attestations/Apple.php new file mode 100644 index 0000000..7b06cf5 --- /dev/null +++ b/src/Attestations/Apple.php @@ -0,0 +1,109 @@ +getRaw()->unwrap() . $clientDataHash->unwrap() + ); + // ¶3 + $nonce = hash('sha256', $nonceToHash->unwrap(), binary: true); + + // ¶4 - there's a whole lot of validation and data extraction that the + // spec glosses over. + $certs = array_map(self::parseDer(...), $this->data['x5c']); + assert(count($certs) >= 1); + $credCert = array_shift($certs); + $info = openssl_x509_parse($credCert); + if ($info === false) { + throw new Exception('Invalid certificate'); + } + if (!array_key_exists('extensions', $info)) { + throw new Exception('No extensions in credential cert'); + } + $certExt = $info['extensions']; + if (!array_key_exists(self::OID, $certExt)) { + throw new Exception('Expected OID not present in cert extensions'); + } + $nonceInCert = $certExt[self::OID]; + // This isn't clear in the spec, but the value arrives ASN.1-encoded. + // Manually verify some length assumptions instead of doing some + // full-on parsing. + if (strlen($nonceInCert) !== 38) { + throw new Exception("Malformed nonce in cert"); + } + // 0x3024 SEQUENCE (constructed) legnth 36 + // 0xA122 Element 1, length 34 + // 0x0420 OCTET STRING length 32 + if (!str_starts_with(needle: "\x30\x24\xA1\x22\x04\x20", haystack: $nonceInCert)) { + throw new Exception("Cert nonce has weird encoding"); + } + // (finally, the actual verification procedure) + $decodedNonceInCert = substr($nonceInCert, 6); + if (!hash_equals($nonce, $decodedNonceInCert)) { + throw new Exception('Nonce mismatch of expected value'); + } + + // ¶5 + $pubKey = openssl_pkey_get_public($credCert); + if ($pubKey === false) { + throw new Exception('Could not read pubkey of certificate'); + } + $pkd = openssl_pkey_get_details($pubKey); + if ($pkd === false) { + throw new Exception('Could not extract public key info'); + } + $credPK = $data->getAttestedCredentialData()->coseKey; + $credPKPem = $credPK->getPublicKey()->getPemFormatted(); + // ¶6 + if (trim($pkd['key']) !== trim($credPKPem)) { + throw new Exception('Credential public key does not match cert subject'); + } + return new VerificationResult( + AttestationType::AnonymizationCA, + // trustPath: rest of x5c + ); + } + + private static function parseDer(string $certificate): OpenSSLCertificate + { + // Convert DER to PEM format + $certificate = "-----BEGIN CERTIFICATE-----\n" + . chunk_split(base64_encode($certificate), 64, "\n") + . "-----END CERTIFICATE-----"; + + // Read and parse the certificate + $certResource = openssl_x509_read($certificate); + if ($certResource === false) { + throw new Exception('Certiticate parsing error'); + } + return $certResource; + } +} diff --git a/src/Attestations/AttestationObject.php b/src/Attestations/AttestationObject.php index e845a62..fa97450 100644 --- a/src/Attestations/AttestationObject.php +++ b/src/Attestations/AttestationObject.php @@ -30,6 +30,7 @@ public function __construct( assert(array_key_exists('authData', $decoded)); $stmt = match (Format::tryFrom($decoded['fmt'])) { // @phpstan-ignore-line + Format::Apple => new Apple($decoded['attStmt']), Format::None => new None($decoded['attStmt']), Format::Packed => new Packed($decoded['attStmt']), Format::U2F => new FidoU2F($decoded['attStmt']), diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 630b024..5114553 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -11,6 +11,7 @@ * Performs general integration testing with known-good data covering various * formats, attestation requirements, etc. * + * @covers \Firehed\WebAuthn\Attestations\Apple * @covers \Firehed\WebAuthn\Attestations\FidoU2F * @covers \Firehed\WebAuthn\Attestations\None * @covers \Firehed\WebAuthn\Attestations\Packed diff --git a/tests/integration/1633-ios/metadata.json b/tests/integration/1633-ios/metadata.json new file mode 100644 index 0000000..a61bcfc --- /dev/null +++ b/tests/integration/1633-ios/metadata.json @@ -0,0 +1,4 @@ +{ + "id": "f25BX23OovR-Ti84WI6lQcp_qfc", + "origin": "https://mobilepki.org" +} diff --git a/tests/integration/1633-ios/reg-req.json b/tests/integration/1633-ios/reg-req.json new file mode 100644 index 0000000..5a9d5bc --- /dev/null +++ b/tests/integration/1633-ios/reg-req.json @@ -0,0 +1,5 @@ +{ + "publicKey": { + "challenge": "1Z4m-O7Lmi762WffOu5jSXSnJfF93HHwWd52BhNhrUQ" + } +} diff --git a/tests/integration/1633-ios/reg-res.json b/tests/integration/1633-ios/reg-res.json new file mode 100644 index 0000000..2881c4f --- /dev/null +++ b/tests/integration/1633-ios/reg-res.json @@ -0,0 +1,11 @@ +{ + "id": "f25BX23OovR-Ti84WI6lQcp_qfc", + "rawId": "f25BX23OovR-Ti84WI6lQcp_qfc", + "response": { + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiMVo0bS1PN0xtaTc2MldmZk91NWpTWFNuSmZGOTNISHdXZDUyQmhOaHJVUSIsIm9yaWdpbiI6Imh0dHBzOi8vbW9iaWxlcGtpLm9yZyJ9", + "attestationObject": "o2NmbXRlYXBwbGVnYXR0U3RtdKJjYWxnJmN4NWOCWQJGMIICQjCCAcmgAwIBAgIGAXmASIscMAoGCCqGSM49BAMCMEgxHDAaBgNVBAMME0FwcGxlIFdlYkF1dGhuIENBIDExEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwHhcNMjEwNTE3MTYyMTQ4WhcNMjEwNTIwMTYyMTQ4WjCBkTFJMEcGA1UEAwxAYzUxZGUwY2QwNjBkMTlmZGU5NWFlNmI1YWNlYjRmNGQ0NjA5YTUzYzBiNjJjMGQ2N2JiYWU1NTg5OGQ0NDIxMTEaMBgGA1UECwwRQUFBIENlcnRpZmljYXRpb24xEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASsbmkJXOuTCECtxpUV8hIpXddsOUFDlV6ecQqxOBdrY8-MjNLS0p5nz8v5bGI3kXR6C1FeJzzXx7nU7cOfprQ_o1UwUzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB_wQEAwIE8DAzBgkqhkiG92NkCAIEJjAkoSIEIHcxH4L2XBi_OttASQZ8wqaTnb6PnGlh5mOwNfQGktYqMAoGCCqGSM49BAMCA2cAMGQCMHNr9RnVEWfKNtrG5ovjBic8WSEQE2T-CWmZ3nSO740N41kkQ6q5kx3iW-qThQ4_nwIwAyM4XCTtmH-AB_UmqN2AbPqvb0N57vVyQpey7Yxj9fKIJ6ALDOux0T1c9F-HteYrWQI4MIICNDCCAbqgAwIBAgIQViVTlcen-0Dr4ijYJghTtjAKBggqhkjOPQQDAzBLMR8wHQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MzgwMVoXDTMwMDMxMzAwMDAwMFowSDEcMBoGA1UEAwwTQXBwbGUgV2ViQXV0aG4gQ0EgMTETMBEGA1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49AgEGBSuBBAAiA2IABIMuhy8mFJGBAiW59fzWu2N4tfVfP8sEW8c1mTR1_VSQRN-b_hkhF2XGmh3aBQs41FCDQBpDT7JNES1Ww-HPv8uYkf7AaWCBvvlsvHfIjd2vRqWu4d1RW1r6q5O-nAsmkaNmMGQwEgYDVR0TAQH_BAgwBgEB_wIBADAfBgNVHSMEGDAWgBQm12TZxXjCWmfRp95rEtAbY_HG1zAdBgNVHQ4EFgQU666CxP-hrFtR1M8kYQUAvmO9d4gwDgYDVR0PAQH_BAQDAgEGMAoGCCqGSM49BAMDA2gAMGUCMQDdixo0gaX62du052V7hB4UTCe3W4dqQYbCsUdXUDNyJ-_lVEV-9kiVDGMuXEg-cMECMCyKYETcIB_P5ZvDTSkwwUh4Udlg7Wp18etKyr44zSW4l9DIBb7wx_eLB6VxxugOB2hhdXRoRGF0YViYKSqtX-Wo3JpWQpsrCGT2kSTRHZYWuoNy4MTSFTN75b1FAAAAAAAAAAAAAAAAAAAAAAAAAAAAFH9uQV9tzqL0fk4vOFiOpUHKf6n3pQECAyYgASFYIKxuaQlc65MIQK3GlRXyEild12w5QUOVXp5xCrE4F2tjIlggz4yM0tLSnmfPy_lsYjeRdHoLUV4nPNfHudTtw5-mtD8", + "transports": [] + }, + "authenticatorAttachment": "", + "type": "public-key" +}