Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/olive-pianos-tickle.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 36 additions & 3 deletions docs/fido-conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions src/controllers/conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ function asString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}

function asObject(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}

/**
* The challenge the client says it signed.
*
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -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');
Expand Down
24 changes: 23 additions & 1 deletion src/services/conformanceMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 [];
Expand Down
15 changes: 15 additions & 0 deletions tests/integration/conformance/conformance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/services/conformanceMetadata.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading