From cb6fa123cb5d41717c0ca1d056f1608266a1adf1 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 3 Sep 2026 00:15:41 -0400 Subject: [PATCH] test(webauthn): exercise the attestation formats against real conformance statements Registration attestation was wired up in #168 and #169, but no test drove the individual statement formats. Hand-rolling one is close to reimplementing the authenticator, so these statements were captured from a FIDO2 conformance run and committed as fixtures. Packed, TPM and fido-u2f each verify end to end through the same call the WebAuthn controller makes, reporting the format and the model they came from. Each is then refused twice over, once with the attestation signature altered and once with the authenticator data altered. Android Key is covered alongside them because it proves the vendor root handling from #253: it is refused against the real Google roots and verifies once the preset roots are cleared, which is what conformance mode does. The metadata-absent path is covered in both requireKnownAuthenticator postures, offline. Passing no MDS servers and no statements makes the fixture's model unlisted by construction rather than by network luck, so strict refuses it and permissive registers it with no statement. Android SafetyNet is left as a todo with the reasoning recorded next to it. The conformance tools never emit one, and the verifier refuses any statement more than sixty seconds old, so a committed fixture could only pass against a faked clock. One thing worth stating, since it looks like a bug and is not: fido-u2f signs an explicit list of fields rather than the authenticator data as a whole, and the signature counter is not among them, so altering the counter verifies happily. The RP ID hash is altered instead. --- tests/fixtures/attestation/android-key.json | 19 ++ tests/fixtures/attestation/fido-u2f.json | 19 ++ tests/fixtures/attestation/packed.json | 19 ++ tests/fixtures/attestation/tpm.json | 19 ++ .../unit/services/attestationFormats.spec.ts | 218 ++++++++++++++++++ 5 files changed, 294 insertions(+) create mode 100644 tests/fixtures/attestation/android-key.json create mode 100644 tests/fixtures/attestation/fido-u2f.json create mode 100644 tests/fixtures/attestation/packed.json create mode 100644 tests/fixtures/attestation/tpm.json create mode 100644 tests/unit/services/attestationFormats.spec.ts diff --git a/tests/fixtures/attestation/android-key.json b/tests/fixtures/attestation/android-key.json new file mode 100644 index 0000000..91b2ffd --- /dev/null +++ b/tests/fixtures/attestation/android-key.json @@ -0,0 +1,19 @@ +{ + "format": "android-key", + "aaguid": "550e4b54-aa47-409f-9a95-1ab76c130131", + "source": "FIDO Alliance Conformance Test Tools, server tests", + "capturedAt": "2026-09-03T03:46:46.273Z", + "expectedChallenge": "pKuACh7MupbQPXSBmHahZWd9LYhgxv2cJxmte37jq9M", + "expectedOrigin": "http://localhost:5313", + "expectedRPID": "localhost", + "response": { + "id": "PDoDtPnEp-VsQNfkN5XkhSXMt_tLF_CSv1QAYKNX3nU", + "rawId": "PDoDtPnEp-VsQNfkN5XkhSXMt_tLF_CSv1QAYKNX3nU", + "response": { + "attestationObject": "o2NmbXRrYW5kcm9pZC1rZXlnYXR0U3RtdKNjYWxnJmNzaWdYRzBFAiEA99JlisDUICeAhoc_tG1GcoPNwtAchBIDcC5KfTriqKQCIACwbG8wDd0aKpDFU3YyOKxvXfT3CTPkxHaLiKnHjod5Y3g1Y4JZAxswggMXMIICvaADAgECAgEBMAoGCCqGSM49BAMCMIHkMUUwQwYDVQQDDDxGQUtFIEFuZHJvaWQgS2V5c3RvcmUgU29mdHdhcmUgQXR0ZXN0YXRpb24gSW50ZXJtZWRpYXRlIEZBS0UxMTAvBgkqhkiG9w0BCQEWImNvbmZvcm1hbmNlLXRvb2xzQGZpZG9hbGxpYW5jZS5vcmcxFjAUBgNVBAoMDUZJRE8gQWxsaWFuY2UxIjAgBgNVBAsMGUF1dGhlbnRpY2F0b3IgQXR0ZXN0YXRpb24xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJNWTESMBAGA1UEBwwJV2FrZWZpZWxkMCAXDTcwMDIwMTAwMDAwMFoYDzIwOTkwMTMxMjM1OTU5WjApMScwJQYDVQQDDB5GQUtFIEFuZHJvaWQgS2V5c3RvcmUgS2V5IEZBS0UwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARJnVTz1FIwyJpDfGpgZC8jYVsSfx2uRtw2KaJrWJHXEIjyfvSOUUcEqe3NX9rhZTDOsnDm-bOVGu_ARv2XejLjo4IBFjCCARIwCwYDVR0PBAQDAgeAMIHhBgorBgEEAdZ5AgERBIHSMIHPAgECCgEAAgEBCgEABCB8JLtuL53X2lvPuEiiex_vfN93kielJhQoLEJt2FuaKgQAMGm_hT0IAgYBXtPjz6C_hUVZBFcwVTEvMC0EKGNvbS5hbmRyb2lkLmtleXN0b3JlLmFuZHJvaWRrZXlzdG9yZWRlbW8CAQExIgQgdM_LUHSI9SkQhZHHpQWRnzJ3MvvB2ANSauqYAAbS2JgwMqEFMQMCAQKiAwIBA6MEAgIBAKUFMQMCAQSqAwIBAb-DeAMCAQK_hT4DAgEAv4U_AgUAMB8GA1UdIwQYMBaAFKPSqizvDYzyJALVHLRgvL9qWyQUMAoGCCqGSM49BAMCA0gAMEUCIDvdAMEwn-vbyFa1hmBc5Of_Zng6k5JRrNEiMPHLExg9AiEA50rMSNKApTfXZSNdwyKQKe8EMYd-99zMQKiH_4z8J59ZAxgwggMUMIICuqADAgECAgECMAoGCCqGSM49BAMCMIHcMT0wOwYDVQQDDDRGQUtFIEFuZHJvaWQgS2V5c3RvcmUgU29mdHdhcmUgQXR0ZXN0YXRpb24gUm9vdCBGQUtFMTEwLwYJKoZIhvcNAQkBFiJjb25mb3JtYW5jZS10b29sc0BmaWRvYWxsaWFuY2Uub3JnMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMSIwIAYDVQQLDBlBdXRoZW50aWNhdG9yIEF0dGVzdGF0aW9uMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTVkxEjAQBgNVBAcMCVdha2VmaWVsZDAeFw0xOTA0MjUwNTQ5MzJaFw00NjA5MTAwNTQ5MzJaMIHkMUUwQwYDVQQDDDxGQUtFIEFuZHJvaWQgS2V5c3RvcmUgU29mdHdhcmUgQXR0ZXN0YXRpb24gSW50ZXJtZWRpYXRlIEZBS0UxMTAvBgkqhkiG9w0BCQEWImNvbmZvcm1hbmNlLXRvb2xzQGZpZG9hbGxpYW5jZS5vcmcxFjAUBgNVBAoMDUZJRE8gQWxsaWFuY2UxIjAgBgNVBAsMGUF1dGhlbnRpY2F0b3IgQXR0ZXN0YXRpb24xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJNWTESMBAGA1UEBwwJV2FrZWZpZWxkMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEq1BhK2JNF7vDtRsESTsFSQuMH4udvPN5st7coHxSode2DdMhddwrft28JtsI1V-G9nG2lNwwTaSiioxOA6b1x6NjMGEwDwYDVR0TAQH_BAUwAwEB_zAOBgNVHQ8BAf8EBAMCAoQwHQYDVR0OBBYEFKPSqizvDYzyJALVHLRgvL9qWyQUMB8GA1UdIwQYMBaAFFKaGzLgVqrNUQ_vX4A3BovykSMdMAoGCCqGSM49BAMCA0gAMEUCIQCxby5AL7R6zm05jfD9PsiyE-Pej_8HkhBGwerwiVkofQIgbvlDqKwni3BeFosThvROS4tp13uLjNqupmyhpJZfLvloYXV0aERhdGFYpUmWDeWIDoxodDQXD2R2YFuP5K65ooYyx5lc87qDHZdjxQAAAB1VDktUqkdAn5qVGrdsEwExACA8OgO0-cSn5WxA1-Q3leSFJcy3-0sX8JK_VABgo1fedaUBAgMmIAEhWCBJnVTz1FIwyJpDfGpgZC8jYVsSfx2uRtw2KaJrWJHXECJYIIjyfvSOUUcEqe3NX9rhZTDOsnDm-bOVGu_ARv2XejLjoA", + "clientDataJSON": "eyJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUzMTMiLCJjaGFsbGVuZ2UiOiJwS3VBQ2g3TXVwYlFQWFNCbUhhaFpXZDlMWWhneHYyY0p4bXRlMzdqcTlNIiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9" + }, + "getClientExtensionResults": {}, + "type": "public-key" + } +} diff --git a/tests/fixtures/attestation/fido-u2f.json b/tests/fixtures/attestation/fido-u2f.json new file mode 100644 index 0000000..43de888 --- /dev/null +++ b/tests/fixtures/attestation/fido-u2f.json @@ -0,0 +1,19 @@ +{ + "format": "fido-u2f", + "aaguid": "00000000-0000-0000-0000-000000000000", + "source": "FIDO Alliance Conformance Test Tools, server tests", + "capturedAt": "2026-09-03T03:46:43.152Z", + "expectedChallenge": "byIfux6An_GWzzJaNupDrMfp959R2dOSJwXtcU92EbQ", + "expectedOrigin": "http://localhost:5313", + "expectedRPID": "localhost", + "response": { + "id": "GGAimCmxEBUciX7R7ygFE9pW-2T7LJGc3IqcKhJlwZw", + "rawId": "GGAimCmxEBUciX7R7ygFE9pW-2T7LJGc3IqcKhJlwZw", + "response": { + "attestationObject": "o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEcwRQIgH5pNLysrINX2cw1wv1XzHpeVUuX9I9wmhCP-V5p8TEsCIQCFbmyTpReCyN89vR8EMwzJWRGJ8u8mu5aEi3JXFzGTt2N4NWOBWQQvMIIEKzCCAhOgAwIBAgIBATANBgkqhkiG9w0BAQUFADCBoTEYMBYGA1UEAwwPRklETzIgVEVTVCBST09UMTEwLwYJKoZIhvcNAQkBFiJjb25mb3JtYW5jZS10b29sc0BmaWRvYWxsaWFuY2Uub3JnMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMQwwCgYDVQQLDANDV0cxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJNWTESMBAGA1UEBwwJV2FrZWZpZWxkMB4XDTE4MDMxNjE0MzUyN1oXDTI4MDMxMzE0MzUyN1owgawxIzAhBgNVBAMMGkZJRE8yIEJBVENIIEtFWSBwcmltZTI1NnYxMTEwLwYJKoZIhvcNAQkBFiJjb25mb3JtYW5jZS10b29sc0BmaWRvYWxsaWFuY2Uub3JnMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMQwwCgYDVQQLDANDV0cxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJNWTESMBAGA1UEBwwJV2FrZWZpZWxkMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAETzpeXqtsH7yul_bfZEmWdix773IAQCp2xvIw9lVvF6qZm1l_xL9Qiq-OnvDNAT9aub0nkUvwgEN4y8yxG4m1RqMsMCowCQYDVR0TBAIwADAdBgNVHQ4EFgQUVk33wPjGVbahH2xNGfO_QeL9AXkwDQYJKoZIhvcNAQEFBQADggIBAI-_jI31FB-8J2XxzBXMuI4Yg-vAtq07ABHJqnQpUmt8lpOzmvJ0COKcwtq_7bpsgSVBJ26zhnyWcm1q8V0ZbxUvN2kH8N7nteIGn-CJOJkHDII-IbiH4-TUQCJjuCB52duUWL0fGVw2R13J6V-K7U5r0OWBzmtmwwiRVTggVbjDpbx2oqGAwzupG3RmBFDX1M92s3tgywnLr-e6NZal5yZdS8VblJGjswDZbdY-Qobo2DCN6vxvn5TVkukAHiArjpBBpAmuQfKa52vqSCYRpTCm57fQUZ1c1n29OsvDw1x9ckyH8j_9Xgk0AG-MlQ9Rdg3hCb7LkSPvC_zYDeS2Cj_yFw6OWahnnIRwO6t4UtLuRAkLrjP1T7nk0zu1whwj7YEwtva45niWWh6rdyg_SZlfsph3o_MZN5DwKaSrUaEO6b-numELH5GWjjiPgfgPKkIof-D40xaKUFBpNJzorQkAZCJWuHvXRpBZWFVh_UhNlGhX0mhz2yFlBrujYa9BgvIkdJ8Keok6qfAn-r5EEFXcSI8vGY7OEF01QKXVpu8-FW0uSxtQ991AcFD6KjvR51l7e61visUgduhZRIq9bYzeCIxnK5Jhm3o_NJE2bOp2NmVwVe4kjuJX87wo3Ba41bXgwIpdiLWyWJhSHPmJI_1ibRTZ5XO92xbPPSnnkXrFaGF1dGhEYXRhWKRJlg3liA6MaHQ0Fw9kdmBbj-SuuaKGMseZXPO6gx2XY0EAAAACAAAAAAAAAAAAAAAAAAAAAAAgGGAimCmxEBUciX7R7ygFE9pW-2T7LJGc3IqcKhJlwZylAQIDJiABIVggyofFJsu2PrVjfPS76gN0NxpbsgTxX5cIIhS3OUnbgT8iWCBbu-VQOsrmUXo1LvxGeL6vSY42ytwzUme5zwZPsg4xiA", + "clientDataJSON": "eyJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUzMTMiLCJjaGFsbGVuZ2UiOiJieUlmdXg2QW5fR1d6ekphTnVwRHJNZnA5NTlSMmRPU0p3WHRjVTkyRWJRIiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9" + }, + "getClientExtensionResults": {}, + "type": "public-key" + } +} diff --git a/tests/fixtures/attestation/packed.json b/tests/fixtures/attestation/packed.json new file mode 100644 index 0000000..4d83306 --- /dev/null +++ b/tests/fixtures/attestation/packed.json @@ -0,0 +1,19 @@ +{ + "format": "packed", + "aaguid": "326adcf0-0cef-46d0-9392-98d6c4a84a72", + "source": "FIDO Alliance Conformance Test Tools, server tests", + "capturedAt": "2026-09-03T03:46:30.985Z", + "expectedChallenge": "46x_N9S6COjmbfaZJiNW1BhrNFUxTTsNIdml_M4yQkc", + "expectedOrigin": "http://localhost:5313", + "expectedRPID": "localhost", + "response": { + "id": "uUlnOWAB3Y3w_aDc7d3XvRiV3Hj5AXCoG6m8NeXCLc0", + "rawId": "uUlnOWAB3Y3w_aDc7d3XvRiV3Hj5AXCoG6m8NeXCLc0", + "response": { + "attestationObject": "o2NmbXRmcGFja2VkZ2F0dFN0bXSjY2FsZyZjc2lnWEgwRgIhAL0BCUFTPa9I7bGp06Ye6r60szwZz_Sn-EGJIf4oUP6ZAiEA3IxyAAC7nJVqUzBuJHQjcmtoVNkNLM5obX7NIo9guDJjeDVjgVkERTCCBEEwggIpoAMCAQICAQEwDQYJKoZIhvcNAQELBQAwgaExGDAWBgNVBAMMD0ZJRE8yIFRFU1QgUk9PVDExMC8GCSqGSIb3DQEJARYiY29uZm9ybWFuY2UtdG9vbHNAZmlkb2FsbGlhbmNlLm9yZzEWMBQGA1UECgwNRklETyBBbGxpYW5jZTEMMAoGA1UECwwDQ1dHMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTVkxEjAQBgNVBAcMCVdha2VmaWVsZDAeFw0xODA1MjMxNDM5NDNaFw0yODA1MjAxNDM5NDNaMIHCMSMwIQYDVQQDDBpGSURPMiBCQVRDSCBLRVkgcHJpbWUyNTZ2MTExMC8GCSqGSIb3DQEJARYiY29uZm9ybWFuY2UtdG9vbHNAZmlkb2FsbGlhbmNlLm9yZzEWMBQGA1UECgwNRklETyBBbGxpYW5jZTEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1ZMRIwEAYDVQQHDAlXYWtlZmllbGQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARPOl5eq2wfvK6X9t9kSZZ2LHvvcgBAKnbG8jD2VW8XqpmbWX_Ev1CKr46e8M0BP1q5vSeRS_CAQ3jLzLEbibVGoywwKjAJBgNVHRMEAjAAMB0GA1UdDgQWBBRWTffA-MZVtqEfbE0Z879B4v0BeTANBgkqhkiG9w0BAQsFAAOCAgEAh92gn_ggiQXRLUHOCaTp1HpcBhsOw8ZwTKJBxwYK8ycQ5_QRXBcMRi8axVexH2HoUDTg_u-DkvH2UYGYjX_RAxgGIh4dPgrKXwVndtMwiI5QnQwXMocKtzyyeuSQv6INwk_QCuJL5LOAyPtNUWMTb_UvCcdYWjtZYFOeYQSK9T_6dtWSp6XAhIT4wf3CBaxyai-YiRn3nfi154vUrqtuDh56eODK7-Iezg9npbucln3XxW_kRhtk2FERSBmBoo7IotPd8NGTATnwUvt16vw6x3mW2a6zZGOOeYCQmeXlfNza7fSff1BdFWR5f4cJ0gFAv297Tf5dGZQvZD3DcyQ9OJeJ3RQQ9inX0Nhxk1-6cm1i2e8h9gTN7otjqYmnGjs3ezhPdax2AdrmckO43YNuchfTPECPTRzP4rQo3QbwGLeEAk_HV-oJmYiBkdhf2F2QLMm7SdeqZ1Jjg1W1vNJT288vj1EGF-_aKXg_bujAaK86_YNPBJaW9Rdw4EnfFUi5bEdkD5ZSpeAHCQzCDn2RzkBjs2rTFe4qRFUWtC-RZ4wFqRx70jXLIw-ArpeetpjtzJSNqQsqPlEvpyMxuV2ZjnruA2_ysP3RDzqNs7R8JVNKiie0RAbG7et43ULZcC7oix8JKYsJ6wDmX8Gyy7vWM-LS9XiZUH37sEvwKJbM-xxoYXV0aERhdGFYpUmWDeWIDoxodDQXD2R2YFuP5K65ooYyx5lc87qDHZdjxQAAAFMyatzwDO9G0JOSmNbEqEpyACC5SWc5YAHdjfD9oNzt3de9GJXcePkBcKgbqbw15cItzaUBAgMmIAEhWCCJ3L-pgI-f6hUa3_yLZ1CrV5AyAMhzfpMYUXJJeZpA4CJYIHYX9UBilVL9_diddpxtz146YKclDHem1ft9Uy9gISHAoA", + "clientDataJSON": "eyJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUzMTMiLCJjaGFsbGVuZ2UiOiI0NnhfTjlTNkNPam1iZmFaSmlOVzFCaHJORlV4VFRzTklkbWxfTTR5UWtjIiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9" + }, + "getClientExtensionResults": {}, + "type": "public-key" + } +} diff --git a/tests/fixtures/attestation/tpm.json b/tests/fixtures/attestation/tpm.json new file mode 100644 index 0000000..62dfe6b --- /dev/null +++ b/tests/fixtures/attestation/tpm.json @@ -0,0 +1,19 @@ +{ + "format": "tpm", + "aaguid": "a7d6d93a-8a0d-11e8-9a94-a6cf71072f73", + "source": "FIDO Alliance Conformance Test Tools, server tests", + "capturedAt": "2026-09-03T03:46:44.775Z", + "expectedChallenge": "LlG8LXfoBjXEHLTBKUudiTwRix47-i20qHEKdtWm-RM", + "expectedOrigin": "http://localhost:5313", + "expectedRPID": "localhost", + "response": { + "id": "VMFQzNkgboHu6y-Hs9BvAZ1LCjcrulttSeq2YNw1nvw", + "rawId": "VMFQzNkgboHu6y-Hs9BvAZ1LCjcrulttSeq2YNw1nvw", + "response": { + "attestationObject": "o2NmbXRjdHBtZ2F0dFN0bXSmY2FsZzkBAGNzaWdZAQB1SMdVfBninqZEB6EbR0QB2LEQGeHPhtOckZxuA38tikuydjv7c7QXWlc5kmVR8N7MEG2W9zs3lE89teub2dpzi5bBG4yMpJ8kVyr7HcggUkBJI_sqs_dnDy_6jWT1vUt2p4hJ59Gcp2yT_iDsXeVeGdAjrYvTI18_fBRITGcWxzrr2A0tRStyFKNNVYQN3XbNd6h_rg_bfC1DEmqmQPAHjdWXkaoHHLMm66RxUkZZfs3Z_zbl2DnBieXyKn6v9prJaIYeIw2dmfeovg6pd81lqJS1b3vcXp46fJ4rZ_VuZIQn2uljiXbAMI2kQzrHh6CQFw67kU1APHG2G3_TBw-8Y3ZlcmMyLjBjeDVjglkFDTCCBQkwggPxoAMCAQICDwTU0QE5E7SFz47yWEUqHzANBgkqhkiG9w0BAQsFADBBMT8wPQYDVQQDEzZOQ1UtTlRDLUtFWUlELUZGOTkwMzM4RTE4NzA3OUE2Q0Q2QTAzQURDNTcyMzc0NDVGNkE0OUEwHhcNMjUwOTAzMDM0NjQzWhcNMjcwOTAzMDM0NjQzWjAAMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArA0ESqJYrg4REF8hpebm3TCAE1E54v_EvV5dhQklI961WCCt_5j2J2dVeOtSN4_JmQNFOJhkJ52fpYNeoyu_dbEx7FsDIjWmwdi_pXNO8CmbePBIVvKMeJbuqjAPgxrMxTtggYKJDrdjaPoHBVomrt_6T55PpYvxTCRGLG7Ylc_0jFJLLWvxEubhwdi2chPHhyNgKF1AFZ1FnZH8N7Brw_aGq_4cn3YG5q5eOKUvMDFIaxL5Vf7cYyqZR4-mpkht8KAHtNI-Amuby3XeQSuQ4WaD4acRyO0qbQLyfNizGl4eMAysxX3ZcJyK43_vJvvCZT6QMe5XCLH6_J-F2MsyowIDAQABo4ICPTCCAjkwDgYDVR0PAQH_BAQDAgeAMAwGA1UdEwEB_wQCMAAwewYDVR0gAQH_BHEwbzBtBgkrBgEEAYI3FR8wYDBeBggrBgEFBQcCAjBSHlAARgBBAEsARQAgAEYASQBEAE8AIABUAEMAUABBACAAVAByAHUAcwB0AGUAZAAgAFAAbABhAHQAZgBvAHIAbQAgAEkAZABlAG4AdABpAHQAeTAQBgNVHSUECTAHBgVngQUIAzBKBgNVHREBAf8EQDA-pDwwOjE4MA4GBWeBBQIDDAVpZDoxMzAQBgVngQUCAgwHTlBDVDZ4eDAUBgVngQUCAQwLaWQ6RkZGRkYxRDAwHwYDVR0jBBgwFoAUdOhwbuNi8U8_KoCvb3uGHTvHco0wHQYDVR0OBBYEFJGAsnI2ZklZnEKGEKn3PDD0mQBGMIGBBggrBgEFBQcBAQR1MHMwcQYIKwYBBQUHMAKGZWh0dHBzOi8vcGtpLmNlcnRpbmZyYS5maWRvYWxsaWFuY2Uub3JnL3RwbS9OQ1UtTlRDLUtFWUlELUZGOTkwMzM4RTE4NzA3OUE2Q0Q2QTAzQURDNTcyMzc0NDVGNkE0OUEuY3J0MHoGA1UdHwRzMHEwb6BtoGuGaWh0dHBzOi8vcGtpLmNlcnRpbmZyYS5maWRvYWxsaWFuY2Uub3JnL3RwbS9jcmwvTkNVLU5UQy1LRVlJRC1GRjk5MDMzOEUxODcwNzlBNkNENkEwM0FEQzU3MjM3NDQ1RjZBNDlBLmNybDANBgkqhkiG9w0BAQsFAAOCAQEAzNACKofmiN1SepPjVS1iyM14uNtAf6KM4y8TfXGA7iMA05GRCp6Gp90h_ZYNaOGivsrBeyF3gD-TOme6l6EsQLW7Bme32tnW-FdfIoVU_5pyhPj-LBXxpOehuBtwRp5B5x6pFcEL314JTvFhGo1M20ufRQY-IXEvES9N9lc2zBzyFFpDUKkIxRY-eAoPKF0LO1pZRi0S0zefUufIjBHQOme7OVipyrcpL4Z-Gx1A4r4NhWELy34fNjNokWfTWlb7xO2dMgIoFCEFT3iNB0J6-oNiM8pEtNKlldzK4fqLnGaOyjcIcFUfh81HTBhLSPU0NikWGS89_zHxRiFdWX6kdlkGMDCCBiwwggQUoAMCAQICDwRfOPZjZRSeuzRzUQmrKDANBgkqhkiG9w0BAQsFADCBvzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1ZMRIwEAYDVQQHDAlXYWtlZmllbGQxFjAUBgNVBAoMDUZJRE8gQWxsaWFuY2UxDDAKBgNVBAsMA0NXRzE2MDQGA1UEAwwtRklETyBGYWtlIFRQTSBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDE4MTEwLwYJKoZIhvcNAQkBFiJjb25mb3JtYW5jZS10b29sc0BmaWRvYWxsaWFuY2Uub3JnMB4XDTIyMDIwMTAwMDAwMFoXDTM1MDEzMTIzNTk1OVowQTE_MD0GA1UEAxM2TkNVLU5UQy1LRVlJRC1GRjk5MDMzOEUxODcwNzlBNkNENkEwM0FEQzU3MjM3NDQ1RjZBNDlBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1z5zfRGlB378sKiIsltfPe3--pyI4ii_CN7Pv38O1WCLwb0-H608v6ULS54UYExI9wp39EN1IV7ZAJhB317QO2IXGtr65YZrSiUjWEcjtsrYbM0BlJMoRvCCql_y9cxD7tI9qODo9_FI4sf82PbJ3q2Q2RRJ2Ua1Z9jxcYHef6r76e3dQd1RBYS2BShN7lpsFewbsxBK5M2hp3e_q-T60t3RbjiK8zqlsWoVQdyHHyYYsCkKmOVVet9E5oKhz0T3WEb0H5Y296QnR0zqQnrnwfLvKxxDol2ogSiViiMK1ZpY2bBx-_744TUDH_hRvbiCXeGkH0Q5wELOlvhQWYthQQIDAQABo4IBoDCCAZwwCwYDVR0PBAQDAgGGMBYGA1UdIAQPMA0wCwYJKwYBBAGCNxUfMBsGA1UdJQQUMBIGCSsGAQQBgjcVJAYFZ4EFCAMwEgYDVR0TAQH_BAgwBgEB_wIBADAdBgNVHQ4EFgQUdOhwbuNi8U8_KoCvb3uGHTvHco0wHwYDVR0jBBgwFoAUQxEWmZrunVA3wk_-4lsVyQkzPzIwfQYDVR0fBHYwdDByoHCgboZsaHR0cHM6Ly9wa2kuY2VydGluZnJhLmZpZG9hbGxpYW5jZS5vcmcvdHBtL2NybC9GSURPJTIwRmFrZSUyMFRQTSUyMFJvb3QlMjBDZXJ0aWZpY2F0ZSUyMEF1dGhvcml0eSUyMDIwMTguY3JsMIGEBggrBgEFBQcBAQR4MHYwdAYIKwYBBQUHMAKGaGh0dHBzOi8vcGtpLmNlcnRpbmZyYS5maWRvYWxsaWFuY2Uub3JnL3RwbS9GSURPJTIwRmFrZSUyMFRQTSUyMFJvb3QlMjBDZXJ0aWZpY2F0ZSUyMEF1dGhvcml0eSUyMDIwMTguY3J0MA0GCSqGSIb3DQEBCwUAA4ICAQBLI3MbuqcaT_BuAE4WKBcIucXm6Ff3e8pYWeiOFW7eSJnmBFIVlUXhXwasoufracT09_aiSE-goQC48VzFpJZXaYzO4DBCOR-VZj5IBfYDYNKQyzD28EZPWmFs5BAJnDh3pBwxfYtNZuxx0MARBJ1DC9v_kQJ4HZGS0xh8Is2f6rKNRb5L1D7iBZfkVzXSNMp_1OVpXR8eNqwutZDE_h7Nj74aMTMKuthL7AHcn2ZazDiIKLM5CO9D0f0N9sruICaoo7ScRZ7jeTR5YzPq30GN4iMeVSO_v_vx1QoCADpt2y-91rbfF0qGtXJUEsON0oyiF6AzCyafMlw7P-E9IbNwKFWELYFZlFeMawtyHcJNC0hLbmwuh9pUSdckWvnYQZW99EtoDsS014PV9Svw8Jp0z2opGA5EaaNFl4klp2-tsn_nF3lXs21NJuKwY2Ghwhcj0YeYz2kv71JRL1NTZEbk961eXqMx2oIyZ-t15OAExPa1Cg6aVsYT78ugIhI-3AXg_JbscqYJs0047NPZpjw6g2a0_fpuHWTyFzhmI4kDxPXRsw_GutWGC3sBJ2_QT9pfYQJx9krZKvRs1XpJfA8gvZWDZEYo2d1wctCyOCMVfYt6KQQAXgBCYVP1NwONLh3ttxBVb4HfSKLZpkhDjkzTNZWjlISg2NLyXwqkhPmT-GdwdWJBcmVhWQE2AAEACwAGBHIAIJ3_y_NsODrmmfuYaNxty4nXFTiEvigDkiwSQVi_rSKuABAAEAgAAAAAAAEAqVH2jmtxtJuq9BOldIaHNib4jT1J2ivX9EP-uxj28M_uuXcUCInzPuksVs-fMBM5_Ift4tCA7E9QJCaNfYQdSYCNtP5nXqn59HdzTP1If8zJhX-b-GJ7RUTNDVoYcYHTnJ07IwD4xSCrJC24_wv_wpHNIhYiCAsOb8H3vBQ3Po39CltTR-pFqxcPFiaymKwbLtdHg1loa7V4ImxGnxu4xa_nCaxfWJwWvsM7qRSn5ef3XFGX8i5Y30t0hJiNkitQT9xjLOm1qFCvD8Qqi9MdEsO6hFPYEX3SILcwhUMT9qb1NuuccLACtVFmXcwi1TRieYq8uNIWMW9q74I0hqKyp2hjZXJ0SW5mb1it_1RDR4AXACIACxHmjtRNtTcuFCluL4Ssx4OYdRiBkh4w_CKgb4tzx5RTACCnUjtx4ni16ldKD4RZkaYI221-Y14-9GrCOse1_Tj-1gAAAAFHcBdIVWl7S8aFYKUBc375jTRWVfsAIgALNYvIHQ6HMHmkabSl06H2sAux90aXU1-2_4dYihdC0fwAIgALUhiv0Sf1aFI8QRpjFDnL4ZkiAJxoPj6DGwWiKttacjRoYXV0aERhdGFZAWhJlg3liA6MaHQ0Fw9kdmBbj-SuuaKGMseZXPO6gx2XY8UAAAAep9bZOooNEeialKbPcQcvcwAgVMFQzNkgboHu6y-Hs9BvAZ1LCjcrulttSeq2YNw1nvykAQMDOQEAIFkBAKlR9o5rcbSbqvQTpXSGhzYm-I09Sdor1_RD_rsY9vDP7rl3FAiJ8z7pLFbPnzATOfyH7eLQgOxPUCQmjX2EHUmAjbT-Z16p-fR3c0z9SH_MyYV_m_hie0VEzQ1aGHGB05ydOyMA-MUgqyQtuP8L_8KRzSIWIggLDm_B97wUNz6N_QpbU0fqRasXDxYmspisGy7XR4NZaGu1eCJsRp8buMWv5wmsX1icFr7DO6kUp-Xn91xRl_IuWN9LdISYjZIrUE_cYyzptahQrw_EKovTHRLDuoRT2BF90iC3MIVDE_am9TbrnHCwArVRZl3MItU0YnmKvLjSFjFvau-CNIaisqchQwEAAaA", + "clientDataJSON": "eyJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUzMTMiLCJjaGFsbGVuZ2UiOiJMbEc4TFhmb0JqWEVITFRCS1V1ZGlUd1JpeDQ3LWkyMHFIRUtkdFdtLVJNIiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9" + }, + "getClientExtensionResults": {}, + "type": "public-key" + } +} diff --git a/tests/unit/services/attestationFormats.spec.ts b/tests/unit/services/attestationFormats.spec.ts new file mode 100644 index 0000000..3b05f4c --- /dev/null +++ b/tests/unit/services/attestationFormats.spec.ts @@ -0,0 +1,218 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { + MetadataService, + SettingsService, + verifyRegistrationResponse, +} from '@simplewebauthn/server'; +import { isoCBOR } from '@simplewebauthn/server/helpers'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { SUPPORTED_ALGORITHM_IDS } from '../../../src/lib/webauthnAlgorithms'; + +// The library's own verifiers are what a conformance run exercises, and the +// shared setup stubs them out. +vi.unmock('@simplewebauthn/server'); + +const FIXTURE_DIR = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../../fixtures/attestation', +); + +interface Fixture { + format: string; + aaguid: string; + expectedChallenge: string; + expectedOrigin: string; + expectedRPID: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + response: any; +} + +function load(name: string): Fixture { + return JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, `${name}.json`), 'utf8')); +} + +function verify(fixture: Fixture) { + return verifyRegistrationResponse({ + response: fixture.response, + expectedChallenge: fixture.expectedChallenge, + expectedOrigin: fixture.expectedOrigin, + expectedRPID: fixture.expectedRPID, + requireUserVerification: false, + supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS, + }); +} + +/** + * The same statement with one byte of the signature flipped. + * + * Re-encoding the attestation object rather than corrupting the base64url keeps + * the response well formed, so what the verifier rejects is the cryptography and + * not the framing. + */ +function withTamperedSignature(fixture: Fixture): Fixture { + const decoded = isoCBOR.decodeFirst>( + new Uint8Array(Buffer.from(fixture.response.response.attestationObject, 'base64url')), + ); + + const attStmt = decoded.get('attStmt') as Map; + const signature = Uint8Array.from(attStmt.get('sig') as Uint8Array); + signature[signature.length - 1] ^= 0xff; + attStmt.set('sig', signature); + + const reencoded = isoCBOR.encode(decoded as never); + + return { + ...fixture, + response: { + ...fixture.response, + response: { + ...fixture.response.response, + attestationObject: Buffer.from(reencoded).toString('base64url'), + }, + }, + }; +} + +/** The same statement with the authenticator data corrupted instead. */ +function withTamperedAuthData(fixture: Fixture): Fixture { + const decoded = isoCBOR.decodeFirst>( + new Uint8Array(Buffer.from(fixture.response.response.attestationObject, 'base64url')), + ); + + const authData = Uint8Array.from(decoded.get('authData') as Uint8Array); + // The RP ID hash, which the relying party check catches ahead of any + // signature. The signature counter looks like the more natural target and is + // the wrong one: fido-u2f signs an explicit list of fields rather than the + // authenticator data as a whole, and the counter is not on that list, so + // moving it verifies happily. Signature coverage is what the tampered + // signature case above exercises. + authData[0] ^= 0xff; + decoded.set('authData', authData); + + return { + ...fixture, + response: { + ...fixture.response, + response: { + ...fixture.response.response, + attestationObject: Buffer.from(isoCBOR.encode(decoded as never)).toString('base64url'), + }, + }, + }; +} + +async function refuses(fixture: Fixture) { + try { + const result = await verify(fixture); + return result.verified === false; + } catch { + return true; + } +} + +describe('attestation formats, against statements captured from the FIDO conformance tools', () => { + // FIDO Server Requirements v2.3 names Packed, TPM, U2F and Android SafetyNet. + // SafetyNet is absent on purpose, see the final describe block for why. + describe.each([ + { name: 'packed', aaguid: '326adcf0-0cef-46d0-9392-98d6c4a84a72' }, + { name: 'tpm', aaguid: 'a7d6d93a-8a0d-11e8-9a94-a6cf71072f73' }, + { name: 'fido-u2f', aaguid: '00000000-0000-0000-0000-000000000000' }, + ])('$name', ({ name, aaguid }) => { + it('verifies, and reports the format and model it came from', async () => { + const result = await verify(load(name)); + + expect(result.verified).toBe(true); + expect(result.registrationInfo?.fmt).toBe(name); + expect(result.registrationInfo?.aaguid).toBe(aaguid); + }); + + it('is refused once the attestation signature is tampered with', async () => { + expect(await refuses(withTamperedSignature(load(name)))).toBe(true); + }); + + it('is refused once the authenticator data is tampered with', async () => { + expect(await refuses(withTamperedAuthData(load(name)))).toBe(true); + }); + }); + + describe('android-key, whose chain only validates against the conformance roots', () => { + let productionRoots: string[]; + + beforeAll(() => { + productionRoots = SettingsService.getRootCertificates({ identifier: 'android-key' }); + }); + + afterAll(() => { + SettingsService.setRootCertificates({ + identifier: 'android-key', + certificates: productionRoots, + }); + }); + + it('is refused against the real Google roots', async () => { + SettingsService.setRootCertificates({ + identifier: 'android-key', + certificates: productionRoots, + }); + + await expect(verify(load('android-key'))).rejects.toThrow(/root certificate/i); + }); + + it('verifies once the preset roots are cleared, which is what conformance mode does', async () => { + SettingsService.setRootCertificates({ identifier: 'android-key', certificates: [] }); + + const result = await verify(load('android-key')); + + expect(result.verified).toBe(true); + expect(result.registrationInfo?.fmt).toBe('android-key'); + }); + }); + + describe('an authenticator the metadata service does not list', () => { + // No mdsServers and no statements: the blob is never fetched, and the + // fixture's model is absent by construction rather than by network luck. + async function initializeMetadata(verificationMode: 'strict' | 'permissive') { + await MetadataService.initialize({ mdsServers: [], statements: [], verificationMode }); + } + + it('is refused when requireKnownAuthenticator is on', async () => { + await initializeMetadata('strict'); + + await expect(verify(load('packed'))).rejects.toThrow(/No metadata statement found/i); + }); + + it('registers without a metadata statement when requireKnownAuthenticator is off', async () => { + await initializeMetadata('permissive'); + + const result = await verify(load('packed')); + + expect(result.verified).toBe(true); + await expect(MetadataService.getStatement(load('packed').aaguid)).resolves.toBeUndefined(); + }); + }); + + describe('android-safetynet', () => { + // Not covered, and not coverable by a fixture. Two independent reasons: + // + // The conformance tools never send one. Three runs on 2026-09-02 and + // 2026-09-03, with every test group selected, produced no SafetyNet + // statement at all, though its metadata statement loads with the rest. + // + // Even given one, the verifier refuses a SafetyNet statement more than + // sixty seconds after the authenticator produced it, so a committed fixture + // would be dead on arrival and could only pass with a faked clock, which + // proves nothing about the format. + // + // Tracked on #210 rather than left implicit here. + it.todo('is exercised against a real statement'); + }); +});