From 1baf1b15af89ec0c3f3a764287daa5678c19e55e Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 2 Sep 2026 23:56:42 -0400 Subject: [PATCH] fix(conformance): load the metadata the tools ship, echo only requested extensions The first real FIDO2 conformance runs surfaced three defects, all confined to FIDO_CONFORMANCE_MODE, which is refused under a production NODE_ENV. The tools' metadata archive unzips to a nested metadataStatements/ directory and the loader only read the top level, so it silently loaded nothing. With requireKnownAuthenticator set the metadata service runs strict, so every conformance authenticator was refused as unlisted and every registration failed. That cascaded into the tests which depend on a registration having succeeded, rather than showing up as a metadata failure. The directory is now read recursively, so the archive can be dropped in unedited as the docs already said. The tools sign their Apple, Android Key and SafetyNet statements with their own test roots, so validating those against the real vendor roots could never succeed. Those preset roots are now cleared in conformance mode, which lets the roots carried in the metadata statement apply instead. Android Key attestation verifies for the first time as a result. generateRegistrationOptions always appends its own credProps, and the tools compare the echoed extensions against the requested set for exact equality, so every options request carrying extensions failed. The requested set is now echoed verbatim over the library's answer. The authentication options path was already correct, since the library passes extensions through there unchanged. A conformance run now reports zero failures. --- .changeset/olive-pianos-tickle.md | 33 ++++++++++++++++ docs/fido-conformance.md | 39 +++++++++++++++++-- src/controllers/conformance.ts | 19 ++++++++- src/services/conformanceMetadata.ts | 24 +++++++++++- .../conformance/conformance.spec.ts | 15 +++++++ .../unit/services/conformanceMetadata.spec.ts | 25 ++++++++++++ 6 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 .changeset/olive-pianos-tickle.md diff --git a/.changeset/olive-pianos-tickle.md b/.changeset/olive-pianos-tickle.md new file mode 100644 index 0000000..d607451 --- /dev/null +++ b/.changeset/olive-pianos-tickle.md @@ -0,0 +1,33 @@ +--- +'seamless-auth-api': patch +--- + +Load the conformance metadata statements the FIDO tools actually ship, and let +their attestation statements validate. + +Two defects in conformance mode, both found by the first real run of the FIDO2 +Conformance Test Tools: + +- **Statements were never loaded.** The tools' "DOWNLOAD SERVER METADATA" archive + unzips to a nested `metadataStatements/` directory, and the loader only read + JSON files at the top level of `FIDO_CONFORMANCE_METADATA_DIR`. It silently + found none. With `requireKnownAuthenticator` set, the metadata service runs in + strict mode, so every conformance authenticator was refused as unlisted and + every registration failed. The loader now recurses, so the archive can be + dropped in unedited as the documentation already promised. +- **Vendor attestation roots blocked their own tests.** The tools sign Apple, + Android Key and SafetyNet statements with their own test roots, so validating + them against the real vendor roots could never succeed. Those preset roots are + now cleared in conformance mode, which lets the library fall back to the roots + carried in the metadata statement. + +- **Registration options advertised an extension nobody asked for.** + `generateRegistrationOptions` always appends its own `credProps`, and the tools + compare the echoed extensions to the requested set for exact equality, so a + request for `{"example.extension.bool": true}` came back as that plus + `credProps` and failed. The requested set is now echoed verbatim. The + authentication options path never had the problem, since the library passes + extensions through there unchanged. + +All three are confined to `FIDO_CONFORMANCE_MODE`, which is refused under a +production `NODE_ENV`. No deployed behaviour changes. diff --git a/docs/fido-conformance.md b/docs/fido-conformance.md index e724bbf..fd27d60 100644 --- a/docs/fido-conformance.md +++ b/docs/fido-conformance.md @@ -71,6 +71,12 @@ answered twice. RPID=localhost ``` + `FRONTEND_URL` is also required, or the process exits at startup before any of + this matters. Set `AUTHENTICATOR_POLICY` with `attestation` set to `direct`: the + conformance surface honours whatever conveyance the tools ask for, but + `requireKnownAuthenticator` is what puts the metadata service into strict mode, + which is the posture a run is meant to exercise. + 3. Turn the interface on, and turn the auth rate limiters off. A conformance run drives hundreds of ceremonies from one IP and will otherwise be throttled. @@ -105,9 +111,36 @@ at the run instead, and all three are read only in conformance mode: | `FIDO_CONFORMANCE_MDS_ROOT_CERT_FILE` | PEM the run signs its blobs with | | `FIDO_CONFORMANCE_METADATA_DIR` | Directory of metadata statement JSON files | -Statements are accepted both bare and wrapped in an MDS entry, so the files the tools -hand you can be dropped in unedited. A statement that cannot be read is skipped with -a line in the log rather than taken down the whole boot. +Get the endpoint list by asking the tools' own service for it, passing the same base +URL you will give the tools: + +``` +curl -X POST https://mds3.fido.tools/getEndpoints \ + -H 'Content-Type: application/json' \ + -d '{"endpoint":"http://localhost:5313/conformance"}' +``` + +The root certificate is the FIDO test root published at +`https://mds3.fido.tools/pki/MDS3ROOT.crt`. That page asks not to be fetched at +runtime, so save a copy and point the variable at the file. + +Statements are accepted both bare and wrapped in an MDS entry, and the directory is +read recursively, so the "DOWNLOAD SERVER METADATA" archive can be unzipped and +dropped in unedited: it expands to a nested `metadataStatements/` directory. A +statement that cannot be read is skipped with a line in the log rather than taken +down the whole boot. + +Check the log for `Loaded N local conformance metadata statements` before running. +Loading nothing is the failure that looks least like one: with +`requireKnownAuthenticator` set the metadata service runs in strict mode, so every +conformance authenticator is refused as unlisted, every registration fails, and the +tests that fail are the ones that depend on a registration having succeeded rather +than the metadata tests themselves. + +Conformance mode also clears the preset Apple, Android Key and SafetyNet root +certificates. The tools sign those statements with their own test roots, so +validating against the real vendor roots cannot succeed; cleared, the roots carried +in the metadata statement are used instead. Conformance mode also brings the metadata service up regardless of whether this deployment requests attestation, because the conformance surface honours the diff --git a/src/controllers/conformance.ts b/src/controllers/conformance.ts index a700bab..5c89c0a 100644 --- a/src/controllers/conformance.ts +++ b/src/controllers/conformance.ts @@ -45,6 +45,12 @@ function asString(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined; } +function asObject(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + /** * The challenge the client says it signed. * @@ -98,6 +104,8 @@ const attestationOptions = async (req: Request, res: Response) => { // unchanged because that is what the tools assert on. const attestationType = requestedAttestation === 'none' ? 'none' : 'direct'; + const requestedExtensions = asObject(body.extensions); + const options = await generateRegistrationOptions({ rpName: app_name, rpID: rpid, @@ -114,7 +122,7 @@ const attestationOptions = async (req: Request, res: Response) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any authenticatorSelection: authenticatorSelection as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any - extensions: body.extensions as any, + extensions: requestedExtensions as any, }); rememberCeremony({ @@ -124,7 +132,14 @@ const attestationOptions = async (req: Request, res: Response) => { requireUserVerification: authenticatorSelection.userVerification === 'required', }); - return ok(res, { ...options, attestation: requestedAttestation }); + // The tools require the echoed extensions to equal the requested set + // exactly, and generateRegistrationOptions always appends credProps of its + // own, so what was asked for is put back over the library's answer. + return ok(res, { + ...options, + ...(requestedExtensions ? { extensions: requestedExtensions } : {}), + attestation: requestedAttestation, + }); } catch (error) { logger.error(`Conformance attestation options failed: ${error}`); return failed(res, 'Could not generate attestation options'); diff --git a/src/services/conformanceMetadata.ts b/src/services/conformanceMetadata.ts index c391eec..0ff1b03 100644 --- a/src/services/conformanceMetadata.ts +++ b/src/services/conformanceMetadata.ts @@ -13,6 +13,16 @@ import getLogger from '../utils/logger.js'; const logger = getLogger('conformanceMetadata'); +/** + * Attestation formats whose preset root certificates have to be cleared. + * + * The tools sign their Apple, Android Key and SafetyNet statements with their + * own test roots, so validating those against the real vendor roots fails every + * one of those tests. Cleared, the library falls back to the roots carried in + * the metadata statement, which is what a run supplies. + */ +const VENDOR_ROOT_IDENTIFIERS = ['apple', 'android-key', 'android-safetynet'] as const; + export interface ConformanceMetadataOverrides { /** MDS3 endpoints the tools stand up for a run. Undefined leaves the official server in place. */ mdsServers?: string[]; @@ -51,6 +61,13 @@ export function applyConformanceMetadataOverrides(): ConformanceMetadataOverride logger.info('Conformance MDS root certificate installed, replacing the FIDO production root.'); } + for (const identifier of VENDOR_ROOT_IDENTIFIERS) { + SettingsService.setRootCertificates({ identifier, certificates: [] }); + } + logger.info( + 'Vendor attestation roots cleared, so conformance statements supply the trust anchor.', + ); + const statements = readStatements(process.env.FIDO_CONFORMANCE_METADATA_DIR); if (statements.length > 0) { overrides.statements = statements; @@ -88,7 +105,12 @@ function readStatements(dir: string | undefined): MetadataStatement[] { let files: string[]; try { - files = fs.readdirSync(dir).filter((file) => file.endsWith('.json')); + // Recursive because the tools' metadata download unzips to a nested + // metadataStatements/ directory, and is meant to be dropped in unedited. + files = fs + .readdirSync(dir, { recursive: true }) + .map((entry) => entry.toString()) + .filter((file) => file.endsWith('.json')); } catch (error) { logger.error(`Could not read the conformance metadata directory ${dir}: ${error}`); return []; diff --git a/tests/integration/conformance/conformance.spec.ts b/tests/integration/conformance/conformance.spec.ts index 01ba1c9..a61e379 100644 --- a/tests/integration/conformance/conformance.spec.ts +++ b/tests/integration/conformance/conformance.spec.ts @@ -124,6 +124,21 @@ describe('conformance interface with the flag set', () => { expect(res.body.excludeCredentials).toEqual([]); }); + it('echoes the requested extensions exactly, without the library adding its own', async () => { + const res = await request(app) + .post('/conformance/attestation/options') + .send({ + username: 'extensions@example.com', + displayName: 'Extensions', + extensions: { 'example.extension.bool': true }, + }); + + expect(res.status).toBe(200); + // The tools compare this for deep equality, so an extra credProps of the + // library's own fails the check. + expect(res.body.extensions).toEqual({ 'example.extension.bool': true }); + }); + it('advertises every algorithm the FIDO server requirements demand', async () => { const res = await request(app) .post('/conformance/attestation/options') diff --git a/tests/unit/services/conformanceMetadata.spec.ts b/tests/unit/services/conformanceMetadata.spec.ts index 393c11d..4fe180d 100644 --- a/tests/unit/services/conformanceMetadata.spec.ts +++ b/tests/unit/services/conformanceMetadata.spec.ts @@ -82,6 +82,31 @@ describe('applyConformanceMetadataOverrides', () => { ]); }); + it('loads statements from the nested directory the tools ship', () => { + const nested = path.join(tmpDir, 'metadataStatements'); + fs.mkdirSync(nested); + fs.writeFileSync(path.join(nested, 'deep.json'), JSON.stringify({ aaguid: 'deep' })); + fs.writeFileSync(path.join(tmpDir, 'top.json'), JSON.stringify({ aaguid: 'top' })); + process.env.FIDO_CONFORMANCE_METADATA_DIR = tmpDir; + + const statements = applyConformanceMetadataOverrides().statements ?? []; + + expect(statements.map((statement: any) => statement.aaguid).sort()).toEqual(['deep', 'top']); + }); + + it('clears the vendor attestation roots so statements supply the trust anchor', () => { + const spy = vi + .spyOn(SettingsService, 'setRootCertificates') + .mockImplementation(() => undefined); + + applyConformanceMetadataOverrides(); + + for (const identifier of ['apple', 'android-key', 'android-safetynet']) { + expect(spy).toHaveBeenCalledWith({ identifier, certificates: [] }); + } + spy.mockRestore(); + }); + it('skips a statement file that parses to something that is not an object', () => { fs.writeFileSync(path.join(tmpDir, 'scalar.json'), '"just a string"'); process.env.FIDO_CONFORMANCE_METADATA_DIR = tmpDir;