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;