diff --git a/packages/server/src/helpers/fetch.ts b/packages/server/src/helpers/fetch.ts index 539ed599..c19fd221 100644 --- a/packages/server/src/helpers/fetch.ts +++ b/packages/server/src/helpers/fetch.ts @@ -2,8 +2,8 @@ * A simple method for requesting data via standard `fetch`. Should work * across multiple runtimes. */ -export function fetch(url: string): Promise { - return _fetchInternals.stubThis(url); +export function fetch(...args: Parameters): Promise { + return _fetchInternals.stubThis(...args); } /** @@ -11,5 +11,5 @@ export function fetch(url: string): Promise { * @ignore Don't include this in docs output */ export const _fetchInternals = { - stubThis: (url: string) => globalThis.fetch(url), + stubThis: (...args: Parameters) => globalThis.fetch(...args), }; diff --git a/packages/server/src/helpers/isCertRevoked.test.ts b/packages/server/src/helpers/isCertRevoked.test.ts index 971cb607..3be8c503 100644 --- a/packages/server/src/helpers/isCertRevoked.test.ts +++ b/packages/server/src/helpers/isCertRevoked.test.ts @@ -315,7 +315,7 @@ Deno.test('should check all CRL URLs in issuer cert for revocation status', asyn using _mockFetch = stub( _fetchInternals, 'stubThis', - (url: string) => { + (url: RequestInfo | URL) => { if (url === CRL_URL) { return Promise.resolve(new Response(crl1.rawData)); } else { diff --git a/packages/server/src/metadata/convenienceMDSTypes.ts b/packages/server/src/metadata/convenienceMDSTypes.ts new file mode 100644 index 00000000..c077c394 --- /dev/null +++ b/packages/server/src/metadata/convenienceMDSTypes.ts @@ -0,0 +1,35 @@ +import type { DataURLPNGOrSVGString, DataURLSVGString, FriendlyNames } from './mdsTypes.ts'; + +/** + * Passkey Provider metadata, including localized names, and light and dark mode icons. + * + * See https://fidoalliance.org/specs/mds/fido-convenience-metadata-service-v1.0-ps-20250521.html#sctn-convenience-details + */ +export type ProviderMetadata = { + friendlyNames?: FriendlyNames; + icon?: DataURLPNGOrSVGString; + iconDark?: DataURLSVGString; + providerLogoLight?: DataURLSVGString; + providerLogoDark?: DataURLSVGString; +}; + +/** + * A dictionary of payload data within a Convenience MDS blob. Except for the `"no"` property, + * keys in this dictionary are AAGUIDs that map to `ProviderMetadata` dictionaries. + * + * Example: + * ```json + * { + * "no": 1, + * "12345678-1234-1234-1234-12345678": {...}, + * "87654321-4321-4321-4321-87654321": {...} + * } + * ``` + * + * See https://fidoalliance.org/specs/mds/fido-convenience-metadata-service-v1.0-ps-20250521.html#sctn-cmds-pe + * + * @property no - A serial number that increments whenever the convenience metadata payload is updated + */ +export type ConvenienceMDSPayload = + & { no: number } + & { [aaguid: string]: ProviderMetadata }; diff --git a/packages/server/src/metadata/mdsTypes.ts b/packages/server/src/metadata/mdsTypes.ts index 51728da5..fe5344a9 100644 --- a/packages/server/src/metadata/mdsTypes.ts +++ b/packages/server/src/metadata/mdsTypes.ts @@ -169,9 +169,14 @@ export type MetadataStatement = { tcDisplayPNGCharacteristics?: DisplayPNGCharacteristicsDescriptor[]; attestationRootCertificates: string[]; ecdaaTrustAnchors?: EcdaaTrustAnchor[]; - icon?: string; supportedExtensions?: ExtensionDescriptor[]; authenticatorGetInfo?: AuthenticatorGetInfo; + // Same values as in Convenience Metadata + friendlyNames?: FriendlyNames; + icon?: DataURLPNGOrSVGString; + iconDark?: DataURLSVGString; + providerLogoLight?: DataURLSVGString; + providerLogoDark?: DataURLSVGString; }; /** @@ -314,3 +319,43 @@ export type AuthenticatorGetInfo = { pinProtocols?: number[]; algorithms?: { type: 'public-key'; alg: number }[]; }; + +/** + * Localized names, typically a company's trade name for a given region. + * + * Example: + * ```json + * { + * "en-US": "SimpleWebAuthn", + * "ja-JP": "シンプルウェブオースン" + * } + * ``` + * + * See https://fidoalliance.org/specs/mds/fido-convenience-metadata-service-v1.0-ps-20250521.html#sctn-types-fn + */ +export type FriendlyNames = { + [ietfLanguageCode: string]: string; +}; + +/** + * A `data:` URL-encoded string containing base64-encoded PNG or SVG data. + * + * Example: + * ```json + * "data:image/svg+xml;base64,..." + * ``` + * ```json + * "data:image/png;base64,..." + * ``` + */ +export type DataURLPNGOrSVGString = string; + +/** + * A `data:` URL-encoded string containing base64-encoded SVG data. + * + * Example: + * ```json + * "data:image/svg+xml;base64,..." + * ``` + */ +export type DataURLSVGString = string; diff --git a/packages/server/src/services/metadataService.test.ts b/packages/server/src/services/metadataService.test.ts index b7631ad1..4ed52668 100644 --- a/packages/server/src/services/metadataService.test.ts +++ b/packages/server/src/services/metadataService.test.ts @@ -1,11 +1,23 @@ -import { assertEquals, assertRejects } from '@std/assert'; +import { assertEquals, assertNotEquals, assertRejects } from '@std/assert'; import { afterEach, beforeEach, describe, it } from '@std/testing/bdd'; -import { assertSpyCallArg, assertSpyCalls, spy, type Stub, stub } from '@std/testing/mock'; +import { + assertSpyCallArg, + assertSpyCalls, + returnsNext, + spy, + type Stub, + stub, +} from '@std/testing/mock'; +import { FakeTime } from '@std/testing/time'; import { _fetchInternals } from '../helpers/fetch.ts'; +import { isoBase64URL } from '../helpers/iso/index.ts'; +import { generateLeafCert, generateRootCert } from '../helpers/tests/x509Utils.ts'; import { BaseMetadataService, MetadataService } from './metadataService.ts'; +import { SettingsService } from './settingsService.ts'; import type { MetadataStatement } from '../metadata/mdsTypes.ts'; +import type { ConvenienceMDSPayload, ProviderMetadata } from '../metadata/convenienceMDSTypes.ts'; let mockFetch: Stub; @@ -18,11 +30,12 @@ describe('Method: initialize()', () => { mockFetch.restore(); }); - it('should default to querying MDS v3', async () => { + it('should default to querying MDS v3 and convenience MDS', async () => { await MetadataService.initialize(); - assertSpyCalls(mockFetch, 1); + assertSpyCalls(mockFetch, 2); assertSpyCallArg(mockFetch, 0, 0, 'https://mds.fidoalliance.org/'); + assertSpyCallArg(mockFetch, 1, 0, 'https://c-mds.fidoalliance.org/?localCopySerial=0'); }); it('should query provided MDS server URLs', async () => { @@ -30,6 +43,7 @@ describe('Method: initialize()', () => { await MetadataService.initialize({ mdsServers, + convenienceMDSServers: [], }); assertSpyCalls(mockFetch, mdsServers.length); @@ -38,7 +52,7 @@ describe('Method: initialize()', () => { }); it('should not query any servers on empty list of URLs', async () => { - await MetadataService.initialize({ mdsServers: [] }); + await MetadataService.initialize({ mdsServers: [], convenienceMDSServers: [] }); assertSpyCalls(mockFetch, 0); }); @@ -52,6 +66,159 @@ describe('Method: initialize()', () => { assertEquals(statement, localStatement); }); + + it('should query provided convenience MDS server URLs', async () => { + const convenienceMDSServers = ['https://custom-cmds1.com', 'https://custom-cmds2.com']; + + await MetadataService.initialize({ mdsServers: [], convenienceMDSServers }); + + assertSpyCalls(mockFetch, convenienceMDSServers.length); + assertSpyCallArg(mockFetch, 0, 0, 'https://custom-cmds1.com/?localCopySerial=0'); + assertSpyCallArg(mockFetch, 1, 0, 'https://custom-cmds2.com/?localCopySerial=0'); + }); + + it('should not query any convenience MDS servers on empty list of URLs', async () => { + await MetadataService.initialize({ mdsServers: [], convenienceMDSServers: [] }); + + assertSpyCalls(mockFetch, 0); + }); + + it('should load local convenience payloads', async () => { + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: [], + conveniencePayloads: [localConveniencePayload], + }); + + const metadata = await MetadataService.getProviderMetadata(localStatementAAGUID); + + assertEquals(metadata, localProviderMetadata); + }); + + it('should load convenience payloads without querying any servers', async () => { + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: [], + conveniencePayloads: [localConveniencePayload], + }); + + assertSpyCalls(mockFetch, 0); + }); + + it('should load metadata from multiple convenience payloads', async () => { + const otherAAGUID = '00000000-0000-0000-0000-000000000002'; + const otherMetadata: ProviderMetadata = { friendlyNames: { en: 'Other Provider' } }; + + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: [], + conveniencePayloads: [ + localConveniencePayload, + { no: 1, [otherAAGUID]: otherMetadata } as ConvenienceMDSPayload, + ], + }); + + assertEquals( + await MetadataService.getProviderMetadata(localStatementAAGUID), + localProviderMetadata, + ); + assertEquals(await MetadataService.getProviderMetadata(otherAAGUID), otherMetadata); + }); + + it('should load metadata from a convenience MDS server', async () => { + mockFetch.restore(); + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + Promise.resolve(Response.json(localConveniencePayload)), + ]), + ); + + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: ['https://custom-cmds.com'], + }); + + const metadata = await MetadataService.getProviderMetadata(localStatementAAGUID); + + assertEquals(metadata, localProviderMetadata); + }); + + it('should move on if a convenience MDS server cannot be reached', async () => { + mockFetch.restore(); + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + Promise.reject(new Error('Network down')), + Promise.resolve(Response.json(localConveniencePayload)), + ]), + ); + + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: ['https://broken-cmds.com', 'https://working-cmds.com'], + }); + + const metadata = await MetadataService.getProviderMetadata(localStatementAAGUID); + + assertEquals(metadata, localProviderMetadata); + }); + + it('should use local convenience payloads as a fallback to convenience MDS servers', async () => { + const serverOnlyAAGUID = '00000000-0000-0000-0000-000000000004'; + const serverMetadata: ProviderMetadata = { friendlyNames: { en: 'From Server' } }; + + mockFetch.restore(); + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + Promise.resolve(Response.json({ + no: 1, + [localStatementAAGUID]: serverMetadata, + [serverOnlyAAGUID]: serverMetadata, + })), + ]), + ); + + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: ['https://custom-cmds.com'], + conveniencePayloads: [ + localConveniencePayload, + { + no: 1, + '00000000-0000-0000-0000-000000000005': localProviderMetadata, + } as ConvenienceMDSPayload, + ], + }); + + // The server wins on a shared AAGUID + assertEquals( + await MetadataService.getProviderMetadata(localStatementAAGUID), + serverMetadata, + ); + assertEquals(await MetadataService.getProviderMetadata(serverOnlyAAGUID), serverMetadata); + // The local payload still fills in AAGUIDs the server doesn't know about + assertEquals( + await MetadataService.getProviderMetadata('00000000-0000-0000-0000-000000000005'), + localProviderMetadata, + ); + }); + + it('should cache convenience metadata from local statements', async () => { + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: [], + statements: [{ ...localStatement, friendlyNames: { en: 'From Statement' } }], + }); + + const metadata = await MetadataService.getProviderMetadata(localStatementAAGUID); + + assertEquals(metadata?.friendlyNames, { en: 'From Statement' }); + }); }); describe('Method: getStatement()', () => { @@ -95,12 +262,415 @@ describe('Method: getStatement()', () => { }); }); +describe('Method: getProviderMetadata()', () => { + const cmdsURL = 'https://cmds.example.com/'; + const aaguid = '00000000-0000-0000-0000-000000000003'; + const originalMetadata: ProviderMetadata = { friendlyNames: { en: 'Original Name' } }; + const updatedMetadata: ProviderMetadata = { friendlyNames: { en: 'Updated Name' } }; + const eightDaysMS = 1000 * 60 * 60 * 24 * 8; + let mockFetch: Stub; + + beforeEach(() => { + mockFetch = stub(_fetchInternals, 'stubThis'); + }); + + afterEach(() => { + mockFetch.restore(); + }); + + /** + * Initialize with a convenience MDS server that responds with `no: 1` and `originalMetadata`. + * `mockFetch` will be incremented by `1` after the execution of this method + */ + async function _initializeWithServer(...followUps: Promise[]) { + mockFetch.restore(); + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + Promise.resolve(Response.json({ no: 1, [aaguid]: originalMetadata })), + ...followUps, + ]), + ); + + await MetadataService.initialize({ mdsServers: [], convenienceMDSServers: [cmdsURL] }); + } + + it('should return undefined if service not initialized', async () => { + const service = new BaseMetadataService(); + const metadata = await service.getProviderMetadata('not-a-real-aaguid'); + + assertEquals(metadata, undefined); + }); + + it('should return undefined if aaguid is undefined', async () => { + // @ts-ignore: intentionally passing a bad value + const metadata = await MetadataService.getProviderMetadata(undefined); + + assertEquals(metadata, undefined); + }); + + it('should return undefined for an AAGUID with no metadata', async () => { + await MetadataService.initialize({ mdsServers: [], convenienceMDSServers: [] }); + + const metadata = await MetadataService.getProviderMetadata('not-a-real-aaguid'); + + assertEquals(metadata, undefined); + }); + + it('should accept the AAGUID as a Uint8Array', async () => { + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: [], + conveniencePayloads: [localConveniencePayload], + }); + + const aaguidBytes = new Uint8Array([ + 0x91, + 0xdf, + 0xea, + 0xd7, + 0x95, + 0x9e, + 0x44, + 0x75, + 0xad, + 0x26, + 0x9b, + 0x0d, + 0x48, + 0x2b, + 0xe0, + 0x89, + ]); + const metadata = await MetadataService.getProviderMetadata(aaguidBytes); + + assertEquals(metadata, localProviderMetadata); + }); + + it('should not refresh metadata from a current convenience MDS', async () => { + await _initializeWithServer(); + + const metadata = await MetadataService.getProviderMetadata(aaguid); + + assertSpyCalls(mockFetch, 1); + assertEquals(metadata, originalMetadata); + }); + + it('should return up-to-date metadata after refreshing stale convenience metadata', async () => { + using _fakedNow = new FakeTime(); + await _initializeWithServer( + Promise.resolve(Response.json({ no: 2, [aaguid]: updatedMetadata })), + ); + + _fakedNow.tick(eightDaysMS); + + const metadata = await MetadataService.getProviderMetadata(aaguid); + + assertSpyCalls(mockFetch, 2); + // Confirm a check for an updated payload using the old serial + assertSpyCallArg(mockFetch, 1, 0, `${cmdsURL}?localCopySerial=1`); + // Getting the updated metadata means the internal cache was refreshed correctly + assertEquals(metadata, updatedMetadata); + }); + + it('should keep cached metadata when the stale convenience MDS responds with 304', async () => { + using _fakedNow = new FakeTime(); + await _initializeWithServer(Promise.resolve(new Response(null, { status: 304 }))); + + _fakedNow.tick(eightDaysMS); + + const metadata = await MetadataService.getProviderMetadata(aaguid); + + assertSpyCalls(mockFetch, 2); + assertEquals(metadata, originalMetadata); + }); + + it('should keep cached metadata when the stale convenience MDS responds with 429', async () => { + using _fakedNow = new FakeTime(); + await _initializeWithServer(Promise.resolve(new Response(null, { status: 429 }))); + + _fakedNow.tick(eightDaysMS); + + const metadata = await MetadataService.getProviderMetadata(aaguid); + + assertSpyCalls(mockFetch, 2); + assertEquals(metadata, originalMetadata); + }); + + it('should evict metadata for AAGUIDs removed from the convenience MDS on refresh', async () => { + using _fakedNow = new FakeTime(); + const removedAAGUID = '00000000-0000-0000-0000-000000000004'; + const removedMetadata: ProviderMetadata = { friendlyNames: { en: 'Removed Name' } }; + + mockFetch.restore(); + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + // Initial payload includes two AAGUIDs + Promise.resolve( + Response.json({ no: 1, [aaguid]: originalMetadata, [removedAAGUID]: removedMetadata }), + ), + // The refreshed payload no longer includes `removedAAGUID` + Promise.resolve(Response.json({ no: 2, [aaguid]: updatedMetadata })), + ]), + ); + await MetadataService.initialize({ mdsServers: [], convenienceMDSServers: [cmdsURL] }); + + // Sanity check: the second AAGUID is cached before the refresh + assertEquals(await MetadataService.getProviderMetadata(removedAAGUID), removedMetadata); + + _fakedNow.tick(eightDaysMS); + + // Trigger the refresh via the AAGUID that's still present + assertEquals(await MetadataService.getProviderMetadata(aaguid), updatedMetadata); + assertSpyCalls(mockFetch, 2); + + // The server dropped this AAGUID, so it shouldn't linger in the cache + assertEquals(await MetadataService.getProviderMetadata(removedAAGUID), undefined); + }); + + it('should keep local payload metadata when a convenience MDS refresh evicts entries', async () => { + using _fakedNow = new FakeTime(); + + mockFetch.restore(); + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + Promise.resolve(Response.json({ no: 1, [aaguid]: originalMetadata })), + Promise.resolve(Response.json({ no: 2, [aaguid]: updatedMetadata })), + ]), + ); + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: [cmdsURL], + conveniencePayloads: [localConveniencePayload], + }); + + _fakedNow.tick(eightDaysMS); + + // Trigger the refresh + assertEquals(await MetadataService.getProviderMetadata(aaguid), updatedMetadata); + assertSpyCalls(mockFetch, 2); + + // Evicting stale entries from the server shouldn't touch entries that came from local data + assertEquals( + await MetadataService.getProviderMetadata(localStatementAAGUID), + localProviderMetadata, + ); + }); + + it('should not refresh metadata from local payloads', async () => { + using _fakedNow = new FakeTime(); + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: [], + conveniencePayloads: [localConveniencePayload], + }); + + _fakedNow.tick(eightDaysMS); + + const metadata = await MetadataService.getProviderMetadata(localStatementAAGUID); + + assertSpyCalls(mockFetch, 0); + assertEquals(metadata, localProviderMetadata); + }); + + it('should keep cached metadata and remain usable if a refresh throws', async () => { + using _fakedNow = new FakeTime(); + await _initializeWithServer(Promise.reject(new Error('Network down'))); + + _fakedNow.tick(eightDaysMS); + + const metadata = await MetadataService.getProviderMetadata(aaguid); + + assertSpyCalls(mockFetch, 2); + assertEquals(metadata, originalMetadata); + + // A subsequent call shouldn't hang waiting for a ready state + const metadata2 = await MetadataService.getProviderMetadata(aaguid); + assertEquals(metadata2, originalMetadata); + }); + + it('should keep cached metadata if a refresh returns invalid JSON', async () => { + using _fakedNow = new FakeTime(); + await _initializeWithServer(Promise.resolve(new Response('not json', { status: 200 }))); + + _fakedNow.tick(eightDaysMS); + + const metadata = await MetadataService.getProviderMetadata(aaguid); + + assertSpyCalls(mockFetch, 2); + assertEquals(metadata, originalMetadata); + }); + + it('should keep cached metadata when a convenience MDS refresh attempt gets back an HTTP 500', async () => { + using _fakedNow = new FakeTime(); + await _initializeWithServer(Promise.resolve(new Response(null, { status: 500 }))); + + _fakedNow.tick(eightDaysMS); + + const metadata = await MetadataService.getProviderMetadata(aaguid); + + assertSpyCalls(mockFetch, 2); + assertEquals(metadata, originalMetadata); + }); + + describe('refresh delays', () => { + const hourMS = 1000 * 60 * 60; + + /** + * Make a stale refresh attempt that gets `followUp`, then confirm no further requests happen + * until `delayHours` has passed, and that one more happens right after. + */ + async function _assertDelay(followUp: Promise, delayHours: number) { + using _fakedNow = new FakeTime(); + await _initializeWithServer(followUp); + + _fakedNow.tick(eightDaysMS); + await MetadataService.getProviderMetadata(aaguid); + // `mockFetch` incremented from `1` to `2` because of a network request to refresh data + assertSpyCalls(mockFetch, 2); + + // Still within the delay, so no new request + _fakedNow.tick((delayHours * hourMS) - 1000); + await MetadataService.getProviderMetadata(aaguid); + assertSpyCalls(mockFetch, 2); + + // Delay elapsed, so another attempt is made + mockFetch.restore(); + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([Promise.resolve(new Response(null, { status: 304 }))]), + ); + _fakedNow.tick(2000); + await MetadataService.getProviderMetadata(aaguid); + assertSpyCalls(mockFetch, 1); + } + + it('should wait 24 hours after an HTTP 304', async () => { + await _assertDelay(Promise.resolve(new Response(null, { status: 304 })), 24); + }); + + it('should wait 4 hours after an HTTP 429', async () => { + await _assertDelay(Promise.resolve(new Response(null, { status: 429 })), 4); + }); + + it('should wait 4 hours after an HTTP 500', async () => { + await _assertDelay(Promise.resolve(new Response(null, { status: 500 })), 4); + }); + + it('should wait 4 hours after a fetch error', async () => { + await _assertDelay(Promise.reject(new Error('Network down')), 4); + }); + + it('should wait 24 hours after a successful refresh', async () => { + await _assertDelay( + Promise.resolve(Response.json({ no: 2, [aaguid]: updatedMetadata })), + 24, + ); + }); + }); +}); + +describe('Behavior: BLOB refresh', () => { + const mdsServerURL = 'https://mds.example.com/'; + let mockFetch: Stub; + let originalMDSRootCerts: string[]; + const refreshableAAGUID = '00000000-0000-0000-0000-000000000001'; + + beforeEach(async () => { + /** + * Our BLOBs are signed by certs generated at runtime, so temporarily trust the root cert they + * chain back to. No CRL is specified for these certs, so revocation checking won't reach out + * to the network either. + */ + const { rootCertPEM } = await getBLOBSigner(); + originalMDSRootCerts = SettingsService.getRootCertificates({ identifier: 'mds' }); + SettingsService.setRootCertificates({ identifier: 'mds', certificates: [rootCertPEM] }); + }); + + afterEach(() => { + SettingsService.setRootCertificates({ + identifier: 'mds', + certificates: originalMDSRootCerts, + }); + mockFetch.restore(); + }); + + it('should return an up-to-date statement after refreshing a stale BLOB', async () => { + const staleStatement: MetadataStatement = { + ...localStatement, + aaguid: refreshableAAGUID, + description: 'Statement from the stale BLOB', + authenticatorVersion: 1, + }; + const freshStatement: MetadataStatement = { + ...localStatement, + aaguid: refreshableAAGUID, + description: 'Statement from the refreshed BLOB', + authenticatorVersion: 2, + }; + + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + // The BLOB cached during initialization, which expired in the past + generateBLOBResponse({ no: 1, nextUpdate: '2023-01-01', statement: staleStatement }), + // The BLOB that should get downloaded when `getStatement()` notices the staleness + generateBLOBResponse({ no: 2, nextUpdate: '2999-01-01', statement: freshStatement }), + ]), + ); + + await MetadataService.initialize({ mdsServers: [mdsServerURL], convenienceMDSServers: [] }); + + assertSpyCalls(mockFetch, 1); + + const statement = await MetadataService.getStatement(refreshableAAGUID); + + // The stale BLOB should have been re-downloaded... + assertSpyCalls(mockFetch, 2); + assertSpyCallArg(mockFetch, 1, 0, mdsServerURL); + // ...and the statement from the newer BLOB returned + assertEquals(statement, freshStatement); + assertNotEquals(statement, staleStatement); + }); + + it('should not refresh a BLOB that is still current', async () => { + const statement: MetadataStatement = { + ...localStatement, + aaguid: refreshableAAGUID, + description: 'Statement from a current BLOB', + }; + + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + generateBLOBResponse({ no: 3, nextUpdate: '2999-01-01', statement }), + ]), + ); + + await MetadataService.initialize({ mdsServers: [mdsServerURL], convenienceMDSServers: [] }); + + const returnedStatement = await MetadataService.getStatement(refreshableAAGUID); + + assertSpyCalls(mockFetch, 1); + assertEquals(returnedStatement, statement); + }); +}); + describe('Behavior: logging', () => { it('should use provided logger', async () => { const _debugSpy = spy(); await MetadataService.initialize({ mdsServers: [], + convenienceMDSServers: [], statements: [], logger: { debug: _debugSpy }, }); @@ -115,6 +685,13 @@ describe('Behavior: logging', () => { }); const localStatementAAGUID = '91dfead7-959e-4475-ad26-9b0d482be089'; +const localProviderMetadata: ProviderMetadata = { + friendlyNames: { en: 'Local Provider', fr: 'Fournisseur local' }, +}; +const localConveniencePayload: ConvenienceMDSPayload = { + no: 1, + [localStatementAAGUID]: localProviderMetadata, +} as ConvenienceMDSPayload; const localStatement: MetadataStatement = { legalHeader: 'https://fidoalliance.org/metadata/metadata-statement-legal-header/', description: 'Virtual FIDO2 EdDSA25519 SHA512 Conformance Testing CTAP2 Authenticator', @@ -164,3 +741,94 @@ const localStatement: MetadataStatement = { maxMsgSize: 1200, }, }; + +type BLOBSigner = { + /** The trust anchor that the BLOB's leaf cert chains back to */ + rootCertPEM: string; + /** The BLOB's leaf cert, ready to drop into a JWT header's `x5c` */ + leafCertBase64: string; + /** The leaf cert's keys, used to sign the BLOB itself */ + leafKeys: CryptoKeyPair; +}; + +/** + * A root cert, and the leaf cert it signed, for signing the MDS BLOBs generated below. + */ +let blobSigner: BLOBSigner | undefined; +/** + * Generate a BLOB signer once, and then reuse it so that every use of `generateBLOBResponse()` in + * here generates a BLOB that can chains back to the same trust anchor. + */ +async function getBLOBSigner(): Promise { + if (!blobSigner) { + const notBefore = new Date(Date.now() - 60_000); + const notAfter = new Date(Date.now() + 3_600_000); + + const root = await generateRootCert({ + notBefore, + notAfter, + name: 'CN=FAKE Metadata BLOB Root FAKE', + }); + const leaf = await generateLeafCert({ + notBefore, + notAfter, + issuer: root, + subject: 'CN=FAKE Metadata BLOB Signer FAKE', + }); + + blobSigner = { + rootCertPEM: root.certificate.toString('pem'), + leafCertBase64: isoBase64URL.fromBuffer( + new Uint8Array(leaf.certificate.rawData), + 'base64', + ), + leafKeys: leaf.keys, + }; + } + + return blobSigner; +} + +/** + * Generate a `Response` containing a signed, verifiable MDS BLOB with a single entry in it, so + * that BLOB downloads can be exercised without network access + */ +async function generateBLOBResponse(opts: { + no: number; + nextUpdate: string; + statement: MetadataStatement; +}): Promise { + const { no, nextUpdate, statement } = opts; + + const { leafCertBase64, leafKeys } = await getBLOBSigner(); + + const header = isoBase64URL.fromUTF8String(JSON.stringify({ + typ: 'JWT', + alg: 'ES256', + x5c: [leafCertBase64], + })); + const payload = isoBase64URL.fromUTF8String(JSON.stringify({ + legalHeader: 'https://fidoalliance.org/metadata/metadata-legal-header/', + no, + nextUpdate, + entries: [ + { + aaguid: statement.aaguid, + metadataStatement: statement, + statusReports: [], + timeOfLastStatusChange: '1970-01-01', + }, + ], + })); + + const signatureBytes = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + leafKeys.privateKey, + new TextEncoder().encode(`${header}.${payload}`), + ); + const signature = isoBase64URL.fromBuffer(new Uint8Array(signatureBytes)); + + const blob = `${header}.${payload}.${signature}`; + + return new Response(blob); +} diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index 4c6add66..1337afe4 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -8,6 +8,7 @@ import { } from '../helpers/logging.ts'; import { fetch } from '../helpers/fetch.ts'; import type { Uint8Array_ } from '../types/index.ts'; +import type { ConvenienceMDSPayload, ProviderMetadata } from '../metadata/convenienceMDSTypes.ts'; // Cached MDS APIs from which BLOBs are downloaded type CachedMDS = { @@ -34,7 +35,13 @@ type CachedBLOBEntry = { url: CachedMDS['url']; }; +type CachedProviderMetadata = { + metadata: ProviderMetadata; + url: CachedMDS['url']; +}; + const defaultURLMDS = 'https://mds.fidoalliance.org/'; // v3 +const defaultURLConvenienceMDS = 'https://c-mds.fidoalliance.org/'; enum SERVICE_STATE { DISABLED, @@ -56,26 +63,38 @@ interface MetadataService { * * @param opts.mdsServers An array of URLs to FIDO Alliance Metadata Service * (version 3.0)-compatible servers. Defaults to the official FIDO MDS server + * @param opts.convenienceMDSServers An array of URLs to FIDO Alliance Metadata Convenience + * Service-compatible servers. Defaults to the official FIDO Convenience MDS * @param opts.statements An array of local metadata statements. Statements will be loaded but - * not refreshed + * not refreshed. When combined with `mdsServers`, a statement for the same AAGUID downloaded + * from an MDS server will replace the local statement and get refreshed from then on + * @param opts.conveniencePayloads An array of local convenience MDS metadata. Metadata loaded in + * this way will be loaded but not refreshed. When combined with `convenienceMDSServers`, metadata + * for the same AAGUID downloaded from a convenience MDS server will replace the local metadata + * and get refreshed from then on * @param opts.verificationMode How MetadataService will handle unregistered AAGUIDs. Defaults to * `"strict"` which throws errors during registration response verification when an * unregistered AAGUID is encountered. Set to `"permissive"` to allow registration by * authenticators with unregistered AAGUIDs + * @param opts.logger An instance of `SimpleWebAuthnLogger` that allows for capturing + * MetadataService log output in a project-specific way */ initialize(opts?: { mdsServers?: string[]; + convenienceMDSServers?: string[]; statements?: MetadataStatement[]; + conveniencePayloads?: ConvenienceMDSPayload[]; verificationMode?: VerificationMode; logger?: SimpleWebAuthnLogger; }): Promise; /** - * Get a metadata statement for a given AAGUID. - * - * This method will coordinate updating the cache as per the `nextUpdate` property in the initial - * BLOB download. + * Get additional information about the authenticator associated with the given AAGUID. */ getStatement(aaguid: string | Uint8Array): Promise; + /** + * Get localized names and icons for the credential provider associated with the given AAGUID. + */ + getProviderMetadata(aaguid: string | Uint8Array_): Promise; } /** @@ -85,26 +104,43 @@ interface MetadataService { * https://fidoalliance.org/metadata/ */ export class BaseMetadataService implements MetadataService { + /** + * Internal caches + */ private mdsCache: { [url: string]: CachedMDS } = {}; - private statementCache: { [aaguid: string]: CachedBLOBEntry } = {}; + private statementCache: { [aaguid: string]: CachedBLOBEntry | undefined } = {}; + private convenienceMetadataCache: { [aaguid: string]: CachedProviderMetadata | undefined } = {}; + /** + * Default service state and configuration + */ private state: SERVICE_STATE = SERVICE_STATE.DISABLED; private verificationMode: VerificationMode = 'strict'; private logger: Required = DefaultNoopLogger; + /** How many hours to wait to refresh Convenience MDS data */ + private readonly cmdsDefaultRefreshDelayHours = 24; + /** How many hours to wait to retry a _failed_ attempt to refresh Convenience MDS data */ + private readonly cmdsDefaultRetryDelayHours = 4; async initialize( opts: { mdsServers?: string[]; + convenienceMDSServers?: string[]; statements?: MetadataStatement[]; + conveniencePayloads?: ConvenienceMDSPayload[]; verificationMode?: VerificationMode; logger?: SimpleWebAuthnLogger; } = {}, ): Promise { - // Reset statement cache + // Reset caches + this.mdsCache = {}; this.statementCache = {}; + this.convenienceMetadataCache = {}; const { mdsServers = [defaultURLMDS], + convenienceMDSServers = [defaultURLConvenienceMDS], statements, + conveniencePayloads, verificationMode, logger = DefaultNoopLogger, } = opts; @@ -122,18 +158,15 @@ export class BaseMetadataService implements MetadataService { statements.forEach((statement) => { // Only cache statements that are for FIDO2-compatible authenticators - if (statement.aaguid) { - this.statementCache[statement.aaguid] = { - entry: { - metadataStatement: statement, - statusReports: [], - timeOfLastStatusChange: '1970-01-01', - }, - url: NonRefreshingMDS.url, - }; - - statementsAdded += 1; - } + statementsAdded += this.cacheMetadataBlobEntry( + NonRefreshingMDS.url, + { + aaguid: statement.aaguid, + metadataStatement: statement, + statusReports: [], + timeOfLastStatusChange: '1970-01-01', + }, + ); }); this.logger.info(`Cached ${statementsAdded} local statements`); @@ -174,6 +207,51 @@ export class BaseMetadataService implements MetadataService { ); } + /** + * If passkey provider metadata is provided, load them into the cache first. + */ + if (conveniencePayloads?.length) { + let entriesAdded = 0; + + conveniencePayloads.forEach((payload) => { + entriesAdded += this.cacheConvenienceMDS(NonRefreshingMDS.url, payload); + }); + + this.logger.info(`Cached ${entriesAdded} local metadata entries`); + } + + /** + * If Convenience MDS server URLs are provided, then download and cache the current collection + * of metadata from them. + */ + if (convenienceMDSServers?.length) { + // Get a current count so we know how many new statements we've added from MDS servers + const currentCacheCount = Object.keys(this.convenienceMetadataCache).length; + let numServers = convenienceMDSServers.length; + + for (const url of convenienceMDSServers) { + // Pull convenience metadata + try { + const payload = await this.downloadConvenienceMDS(url, 0); + + if (payload) { + this.cacheConvenienceMDS(url, payload); + } + } catch (err) { + // Notify of the error and move on + this.logger.error(`Could not download convenience metadata from ${url}:`, err); + numServers -= 1; + } + } + + // Calculate the total number of new metadata entries we successfully added + const newCacheCount = Object.keys(this.convenienceMetadataCache).length; + const cacheDiff = newCacheCount - currentCacheCount; + this.logger.info( + `Cached ${cacheDiff} metadata entries from ${numServers} convenience metadata server(s)`, + ); + } + if (verificationMode) { this.verificationMode = verificationMode; } @@ -181,9 +259,7 @@ export class BaseMetadataService implements MetadataService { this.setState(SERVICE_STATE.READY); } - async getStatement( - aaguid: string | Uint8Array_, - ): Promise { + async getStatement(aaguid: string | Uint8Array_): Promise { if (this.state === SERVICE_STATE.DISABLED) { return; } @@ -200,20 +276,10 @@ export class BaseMetadataService implements MetadataService { await this.pauseUntilReady(); // Try to grab a cached statement - const cachedStatement = this.statementCache[aaguid]; - - if (!cachedStatement) { - if (this.verificationMode === 'strict') { - // FIDO conformance requires RP's to only support registered AAGUID's - throw new Error(`No metadata statement found for aaguid "${aaguid}"`); - } - - // Allow registration verification to continue without using metadata - return; - } + let cachedStatement = this.statementCache[aaguid]; // If the statement points to an MDS API, check the MDS' nextUpdate to see if we need to refresh - if (cachedStatement.url) { + if (cachedStatement?.url) { const mds = this.mdsCache[cachedStatement.url]; const now = new Date(); if (now > mds.nextUpdate) { @@ -221,12 +287,24 @@ export class BaseMetadataService implements MetadataService { this.setState(SERVICE_STATE.REFRESHING); const blob = await this.downloadBlob(mds); await this.verifyBlob(blob, mds); + // Refresh the cached statement in case the cache got updated just now + cachedStatement = this.statementCache[aaguid]; } finally { this.setState(SERVICE_STATE.READY); } } } + if (!cachedStatement) { + if (this.verificationMode === 'strict') { + // FIDO conformance requires RP's to only support registered AAGUID's + throw new Error(`No metadata statement found for aaguid "${aaguid}"`); + } + + // Allow registration verification to continue without using metadata + return; + } + const { entry } = cachedStatement; // Check to see if the this aaguid has a status report with a "compromised" status @@ -245,6 +323,58 @@ export class BaseMetadataService implements MetadataService { return entry.metadataStatement; } + async getProviderMetadata(aaguid: string | Uint8Array_): Promise { + if (this.state === SERVICE_STATE.DISABLED) { + return; + } + + if (!aaguid) { + return; + } + + if (aaguid instanceof Uint8Array) { + aaguid = convertAAGUIDToString(aaguid); + } + + // If a cache refresh is in progress then pause this until the service is ready + await this.pauseUntilReady(); + + // Try to grab a cached statement + let cachedMetadata = this.convenienceMetadataCache[aaguid]; + + // If the metadata points to a convenience MDS API, then check the nextUpdate to see if we need + // to refresh it + if (cachedMetadata?.url) { + const mds = this.mdsCache[cachedMetadata.url]; + const now = new Date(); + if (now > mds.nextUpdate) { + try { + this.setState(SERVICE_STATE.REFRESHING); + const payload = await this.downloadConvenienceMDS(mds.url, mds.no); + if (payload) { + this.cacheConvenienceMDS(mds.url, payload); + } + + // Refresh the cached metadata in case the cache got updated just now + cachedMetadata = this.convenienceMetadataCache[aaguid]; + } catch (err) { + this.logger.error(`Could not download convenience metadata from ${mds.url}:`, err); + this.delayNextRefresh(mds.url, this.cmdsDefaultRetryDelayHours); + } finally { + this.setState(SERVICE_STATE.READY); + } + } + } + + if (!cachedMetadata) { + return; + } + + const { metadata } = cachedMetadata; + + return metadata; + } + /** * Download and process the latest BLOB from MDS */ @@ -259,9 +389,10 @@ export class BaseMetadataService implements MetadataService { } /** - * Verify and process the MDS metadata blob + * Verify and process the MDS metadata blob. Caches metadata and sets up refreshing when blobs + * grow stale. */ - private async verifyBlob(blob: string, cachedMDS: CachedMDS) { + private async verifyBlob(blob: string, cachedMDS: CachedMDS): Promise { const { url, no } = cachedMDS; const { payload, parsedNextUpdate } = await verifyMDSBlob(blob); @@ -276,10 +407,7 @@ export class BaseMetadataService implements MetadataService { // Cache statements for FIDO2 devices for (const entry of payload.entries) { - // Only cache entries with an `aaguid` - if (entry.aaguid) { - this.statementCache[entry.aaguid] = { entry, url }; - } + this.cacheMetadataBlobEntry(url, entry); } if (url) { @@ -307,6 +435,172 @@ export class BaseMetadataService implements MetadataService { } } + /** + * Download the convenience metadata, which is surprisingly just a JSON document + */ + private async downloadConvenienceMDS( + url: string, + no: number, + ): Promise { + // Get latest convenience MDS JSON, with a query param that can short-circuit the download + // if we already have the latest version of metadata + const _url = new URL(`/?localCopySerial=${no}`, url); + const resp = await fetch(_url.toString(), { signal: AbortSignal.timeout(10_000) }); + + /** + * We have the latest data, nothing more to do. + * + * See https://fidoalliance.org/specs/mds/fido-convenience-metadata-service-v1.0-ps-20250521.html#sctn-mds-blob-proc-rules + */ + if (resp.status === 304) { + await resp.body?.cancel(); + this.logger.info( + `The Convenience MDS API at ${_url.toString()} reported nothing new to download (HTTP 304)`, + ); + // There's nothing new to download, wait another interval before checking again + this.delayNextRefresh(url, this.cmdsDefaultRefreshDelayHours); + return; + } + + /** + * The FIDO convenience MDS API aggressively rate-limits so be prepared for that. We'll have to + * try again later. + */ + if (resp.status === 429) { + await resp.body?.cancel(); + this.logger.warn( + `The Convenience MDS API at ${_url.toString()} rate-limited this request (HTTP 429)`, + ); + // We checked too soon, wait a bit before checking again + this.delayNextRefresh(url, this.cmdsDefaultRetryDelayHours); + return; + } + + if (!resp.ok) { + await resp.body?.cancel(); + this.logger.warn( + `The Convenience MDS API at ${_url.toString()} reported an unsuccessful request (HTTP ${resp.status})`, + ); + // Likely an HTTP 500 error, but anything not within HTTP 200-299. Wait a bit before trying + // again + this.delayNextRefresh(url, this.cmdsDefaultRetryDelayHours); + return; + } + + const data: ConvenienceMDSPayload = await resp.json(); + return data; + } + + /** + * Cache the AAGUID-keyed entries in a Convenience MDS payload. Returns the number of entries + * cached (so you can `count += cacheConvenienceMDS(...)`) + */ + private cacheConvenienceMDS(url: string, payload: ConvenienceMDSPayload): number { + let entriesAdded = 0; + + // Remove existing entries for this URL + this.clearCacheEntriesByURL(url); + + // Cache entries + Object.keys(payload).forEach((aaguid) => { + // Skip the versioning property + if (aaguid === 'no') { + return; + } + + // The rest of the keys will be AAGUIDs + this.cacheProviderMetadata(aaguid, payload[aaguid], url); + entriesAdded += 1; + }); + + // Set up convenience metadata refresh + this.mdsCache[url] = { + url, + // Store the payload `no` to skip re-downloading metadata we already have + no: payload.no, + // Convenience MDS returns no nextUpdate, so pick a time in the future + nextUpdate: new Date(Date.now() + (1000 * 60 * 60 * this.cmdsDefaultRefreshDelayHours)), + }; + + return entriesAdded; + } + + /** + * A central place to contain logic and side effects of caching a BLOB entry. Only caches FIDO2 + * statements (i.e. ones with `aaguid` as UAF authenticators won't have one.) Returns `1` if the + * statement was cached, or `0` if it wasn't (so you can `count += cacheMetadataBlobEntry(...)`) + */ + private cacheMetadataBlobEntry(url: string, entry: MetadataBLOBPayloadEntry): number { + // Only cache entries with an `aaguid`, which will weed out UAF statements + if (!entry.aaguid) { + return 0; + } + + this.statementCache[entry.aaguid] = { entry, url }; + + // Cache the convenience metadata contained within the fuller MetadataStatement + const { metadataStatement } = entry; + if (metadataStatement) { + this.cacheProviderMetadata( + entry.aaguid, + { + friendlyNames: metadataStatement.friendlyNames, + icon: metadataStatement.icon, + iconDark: metadataStatement.iconDark, + providerLogoDark: metadataStatement.providerLogoDark, + providerLogoLight: metadataStatement.providerLogoLight, + }, + /** + * We didn't actually fetch this from a Convenience MDS API, so don't try to update this + * metadata like we did. It'll get updated the next time the regular MDS payload gets pulled + * down + */ + NonRefreshingMDS.url, + ); + } + + return 1; + } + + /** + * Cache convenience metadata, whether pulled out of a full-fat `MetadataStatement` or retrieved + * directly from the Convenience MDS + */ + private cacheProviderMetadata(aaguid: string, metadata: ProviderMetadata, url: string): void { + this.convenienceMetadataCache[aaguid] = { metadata, url }; + } + + /** + * Remove cached Convenience Metadata service data by the URL. If the URL indicates data that + * should not be refreshed, then simply exit. + */ + private clearCacheEntriesByURL(url: string): void { + // Do _not_ clear out entries that aren't associated with a Convenience MDS service + if (url === NonRefreshingMDS.url) { + return; + } + + for (const [aaguid, cached] of Object.entries(this.convenienceMetadataCache)) { + if (cached?.url === url) { + delete this.convenienceMetadataCache[aaguid]; + } + } + } + + /** + * Delay the next refresh attempt of this server by the specified number of hours + */ + private delayNextRefresh(url: string, numHours: number): void { + // This should never happen, but just in case... + if (!this.mdsCache[url]) { + return; + } + + this.logger.debug(`Delaying next refresh of ${url} by ${numHours} hours`); + const numHoursMS = 1000 * 60 * 60 * numHours; + this.mdsCache[url].nextUpdate = new Date(Date.now() + numHoursMS); + } + /** * A helper method to pause execution until the service is ready */