From 617cfc44fa6dfc8e3df8cbf332b7d19ccab11bfa Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 30 Aug 2026 07:32:30 -0400 Subject: [PATCH] feat(webauthn): support attestation and validate it against FIDO metadata Registration hardcoded attestationType 'none', so authenticators never identified themselves and there was nothing for the FIDO Metadata Service to validate. FIDO Server Requirements v2.3 requires validating attestation certificate chains and supporting validation through that service. authenticator_policy.attestation now chooses, defaulting to none so consumer deployments are unaffected and nobody pays the privacy cost of identifying a user's hardware without needing to. Under direct the metadata service is prepared at startup and the library's attestation verifiers validate against it. requireKnownAuthenticator decides what happens to an authenticator the metadata service does not list. That was the open positioning question in the readiness analysis, and it maps onto the library's permissive and strict verification modes, so it is exposed as configuration rather than decided here. It defaults to permissive. Credentials record attestationFormat and attestationVerified, so an audit can tell an unattested credential from one whose attestation was checked. The metadata service never blocks startup. A blob that cannot be fetched is a degraded state, not a reason to refuse to start, and requireKnownAuthenticator is deliberately not honoured in that state because refusing every registration on a transient network failure is worse than the risk it guards against. Migration verified up and down against Postgres 17. Closes #168 Closes #169 --- .changeset/eager-falcons-repeat.md | 37 ++++ .env.example | 7 +- docs/configuration.md | 56 +++--- openapi.json | 23 ++- package-lock.json | 8 +- package.json | 2 +- src/config/systemConfig.defaults.ts | 2 + src/controllers/webauthn.ts | 10 +- src/generated/api.ts | 18 +- ...60830120000-add-credential-attestation.cjs | 32 ++++ src/models/credentials.ts | 14 ++ src/server.ts | 6 + src/services/metadataServiceBootstrap.ts | 84 +++++++++ tests/integration/webauthn/webauthn.spec.ts | 159 ++++++++++++++++-- .../services/metadataServiceBootstrap.spec.ts | 80 +++++++++ 15 files changed, 488 insertions(+), 50 deletions(-) create mode 100644 .changeset/eager-falcons-repeat.md create mode 100644 src/migrations/20260830120000-add-credential-attestation.cjs create mode 100644 src/services/metadataServiceBootstrap.ts create mode 100644 tests/unit/services/metadataServiceBootstrap.spec.ts diff --git a/.changeset/eager-falcons-repeat.md b/.changeset/eager-falcons-repeat.md new file mode 100644 index 0000000..0d3e956 --- /dev/null +++ b/.changeset/eager-falcons-repeat.md @@ -0,0 +1,37 @@ +--- +'seamless-auth-api': minor +--- + +Support attestation, and validate it against the FIDO Metadata Service. + +Registration hardcoded `attestationType: 'none'`, so authenticators never +identified themselves and there was nothing for the FIDO Metadata Service to +validate. FIDO Server Requirements v2.3 requires a server to validate +attestation certificate chains and to support validation through that service. + +`authenticator_policy.attestation` now chooses. `none` stays the default, which +suits a consumer deployment: attestation identifies a user's hardware and most +relying parties have no use for it. `direct` requests a statement, and the +metadata service is prepared at startup so the attestation verifiers validate +against it. + +`authenticator_policy.requireKnownAuthenticator` decides what happens to an +authenticator the metadata service does not list. False, the default, registers +it anyway; true refuses it. + +Credentials now record `attestationFormat` and `attestationVerified`, so an audit +can tell an unattested credential from one whose attestation was actually +checked. Neither is recoverable after the fact, so existing credentials report +neither. + +The metadata service never blocks startup. A blob that cannot be fetched is a +degraded state, not a reason an authentication server should refuse to start, so +it is logged and registration continues without metadata validation. +`requireKnownAuthenticator` is deliberately not honoured in that state, because +refusing every registration on a transient network failure is worse than the risk +it guards against. + +Changing `attestation` needs a restart, because the metadata service is prepared +once at boot. + +Requires `@seamless-auth/types` 0.14.0. diff --git a/.env.example b/.env.example index eea6b9b..87c53ed 100644 --- a/.env.example +++ b/.env.example @@ -88,7 +88,12 @@ LOCKOUT_POLICY={"enabled":true,"maxFailures":10,"windowSeconds":900,"lockoutSeco # naming one narrows the browser picker and rejects a request asking for the other. # userVerification is required, preferred or discouraged. It drives both what the # browser is asked for and what the server enforces, so the two cannot disagree. -AUTHENTICATOR_POLICY={"attachment":"any","userVerification":"required"} +# attestation is none or direct. direct asks the authenticator to identify itself, +# which enables validation against the FIDO Metadata Service and any allow list of +# approved models. It carries a privacy cost, so it is off unless you need it. +# requireKnownAuthenticator refuses an authenticator the metadata service does not +# list, and only applies under direct. Changing attestation needs a restart. +AUTHENTICATOR_POLICY={"attachment":"any","userVerification":"required","attestation":"none","requireKnownAuthenticator":false} # SERVICE TOKENS # Required for trusted server adapters and internal bearer validation. diff --git a/docs/configuration.md b/docs/configuration.md index b021f84..5aa8086 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -65,17 +65,17 @@ first boot. ### Auth and tokens -| Variable | Required | Default | Seeds `system_config` | Notes | -| -------------------------------- | -------- | ---------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ACCESS_TOKEN_TTL` | Yes | - | `access_token_ttl` | Format `\d+[smhd]`, e.g. `30m`. | -| `REFRESH_TOKEN_TTL` | Yes | - | `refresh_token_ttl` | Format `\d+[smhd]`, e.g. `1d`. Falls back to `1d` when unset. | -| `RATE_LIMIT` | Yes | - | `rate_limit` | Global limit, positive integer. | -| `DELAY_AFTER` | Yes | - | `delay_after` | Slow-down threshold, non-negative integer. | -| `LOGIN_METHODS` | No | `passkey,magic_link` | `login_methods` | Any of `passkey,magic_link,email_otp,phone_otp,oauth`. `.env.example` ships `passkey,magic_link,email_otp` so a stock instance is CLI/headless-loginable; `email_otp` needs a configured messaging transport or the external-delivery path. | -| `PASSKEY_LOGIN_FALLBACK_ENABLED` | No | `true` | `passkey_login_fallback_enabled` | When `false`, passkey-capable sessions continue with passkey only. | -| `LOCKOUT_POLICY` | No | `{"enabled":true,"maxFailures":10,"windowSeconds":900,"lockoutSeconds":900}` | `lockout_policy` | JSON. Set `enabled:false` only when an upstream policy handles lockout. | -| `SESSION_IDLE_TTL` | No | `8h` | `session_idle_ttl` | Format `\d+[smhd]`. How long a session may go unrefreshed. The absolute session lifetime is `REFRESH_TOKEN_TTL`; this only binds while it is the shorter of the two. | -| `AUTHENTICATOR_POLICY` | No | `{"attachment":"any"}` | `authenticator_policy` | JSON. `attachment` is `any`, `platform` or `cross-platform`. `any` offers both built-in authenticators and roaming security keys at registration. Naming one narrows the browser picker and rejects a request asking for the other. | +| Variable | Required | Default | Seeds `system_config` | Notes | +| -------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ACCESS_TOKEN_TTL` | Yes | - | `access_token_ttl` | Format `\d+[smhd]`, e.g. `30m`. | +| `REFRESH_TOKEN_TTL` | Yes | - | `refresh_token_ttl` | Format `\d+[smhd]`, e.g. `1d`. Falls back to `1d` when unset. | +| `RATE_LIMIT` | Yes | - | `rate_limit` | Global limit, positive integer. | +| `DELAY_AFTER` | Yes | - | `delay_after` | Slow-down threshold, non-negative integer. | +| `LOGIN_METHODS` | No | `passkey,magic_link` | `login_methods` | Any of `passkey,magic_link,email_otp,phone_otp,oauth`. `.env.example` ships `passkey,magic_link,email_otp` so a stock instance is CLI/headless-loginable; `email_otp` needs a configured messaging transport or the external-delivery path. | +| `PASSKEY_LOGIN_FALLBACK_ENABLED` | No | `true` | `passkey_login_fallback_enabled` | When `false`, passkey-capable sessions continue with passkey only. | +| `LOCKOUT_POLICY` | No | `{"enabled":true,"maxFailures":10,"windowSeconds":900,"lockoutSeconds":900}` | `lockout_policy` | JSON. Set `enabled:false` only when an upstream policy handles lockout. | +| `SESSION_IDLE_TTL` | No | `8h` | `session_idle_ttl` | Format `\d+[smhd]`. How long a session may go unrefreshed. The absolute session lifetime is `REFRESH_TOKEN_TTL`; this only binds while it is the shorter of the two. | +| `AUTHENTICATOR_POLICY` | No | `{"attachment":"any","userVerification":"required","attestation":"none","requireKnownAuthenticator":false}` | `authenticator_policy` | JSON. `attachment` is `any`, `platform` or `cross-platform`; `any` offers both built-in authenticators and roaming security keys, and naming one narrows the browser picker and rejects a request asking for the other. `userVerification` is `required`, `preferred` or `discouraged` and drives both what the browser is asked for and what the server enforces. `attestation` is `none` or `direct`; `direct` asks the authenticator to identify itself, which is what enables validation against the FIDO Metadata Service, and carries a privacy cost so it is off unless needed. `requireKnownAuthenticator` refuses an authenticator the metadata service does not list, and only applies under `direct`. Changing `attestation` needs a restart, because the metadata service is prepared at startup. | ### Service tokens and secrets @@ -260,23 +260,23 @@ its mapped environment variable ([`systemConfig.envMap.ts`](../src/config/system or from a built-in default ([`systemConfig.defaults.ts`](../src/config/systemConfig.defaults.ts)). Validation is enforced by [`systemConfig.schema.ts`](../src/schemas/systemConfig.schema.ts). -| Key | Type | Seeded from env | Default | -| -------------------------------- | -------------------- | -------------------------------- | --------------------------------------------------------------- | -| `app_name` | string (min 3) | `APP_NAME` | - | -| `default_roles` | string[] | `DEFAULT_ROLES` | - | -| `available_roles` | string[] | `AVAILABLE_ROLES` | - | -| `login_methods` | enum[] | `LOGIN_METHODS` | `["passkey","magic_link"]` | -| `passkey_login_fallback_enabled` | boolean | `PASSKEY_LOGIN_FALLBACK_ENABLED` | `true` | -| `oauth_providers` | provider[] | `OAUTH_PROVIDERS` | `[]` | -| `lockout_policy` | object | `LOCKOUT_POLICY` | `{enabled,maxFailures:10,windowSeconds:900,lockoutSeconds:900}` | -| `authenticator_policy` | object | `AUTHENTICATOR_POLICY` | `{attachment:"any",userVerification:"required"}` | -| `session_idle_ttl` | string (`\d+[smhd]`) | `SESSION_IDLE_TTL` | `8h` | -| `access_token_ttl` | string (`\d+[smhd]`) | `ACCESS_TOKEN_TTL` | - | -| `refresh_token_ttl` | string (`\d+[smhd]`) | `REFRESH_TOKEN_TTL` | - | -| `rate_limit` | integer > 0 | `RATE_LIMIT` | - | -| `delay_after` | integer >= 0 | `DELAY_AFTER` | - | -| `rpid` | string | `RPID` | - | -| `origins` | url[] | `ORIGINS` | - | +| Key | Type | Seeded from env | Default | +| -------------------------------- | -------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------- | +| `app_name` | string (min 3) | `APP_NAME` | - | +| `default_roles` | string[] | `DEFAULT_ROLES` | - | +| `available_roles` | string[] | `AVAILABLE_ROLES` | - | +| `login_methods` | enum[] | `LOGIN_METHODS` | `["passkey","magic_link"]` | +| `passkey_login_fallback_enabled` | boolean | `PASSKEY_LOGIN_FALLBACK_ENABLED` | `true` | +| `oauth_providers` | provider[] | `OAUTH_PROVIDERS` | `[]` | +| `lockout_policy` | object | `LOCKOUT_POLICY` | `{enabled,maxFailures:10,windowSeconds:900,lockoutSeconds:900}` | +| `authenticator_policy` | object | `AUTHENTICATOR_POLICY` | `{attachment:"any",userVerification:"required",attestation:"none",requireKnownAuthenticator:false}` | +| `session_idle_ttl` | string (`\d+[smhd]`) | `SESSION_IDLE_TTL` | `8h` | +| `access_token_ttl` | string (`\d+[smhd]`) | `ACCESS_TOKEN_TTL` | - | +| `refresh_token_ttl` | string (`\d+[smhd]`) | `REFRESH_TOKEN_TTL` | - | +| `rate_limit` | integer > 0 | `RATE_LIMIT` | - | +| `delay_after` | integer >= 0 | `DELAY_AFTER` | - | +| `rpid` | string | `RPID` | - | +| `origins` | url[] | `ORIGINS` | - | ## Environment vs `system_config` diff --git a/openapi.json b/openapi.json index 8a23a6a..19d1e58 100644 --- a/openapi.json +++ b/openapi.json @@ -6338,9 +6338,20 @@ "type": "string", "enum": ["required", "preferred", "discouraged"], "default": "required" - } + }, + "attestation": { + "type": "string", + "enum": ["none", "direct"], + "default": "none" + }, + "requireKnownAuthenticator": { "type": "boolean", "default": false } }, - "default": { "attachment": "any", "userVerification": "required" } + "default": { + "attachment": "any", + "userVerification": "required", + "attestation": "none", + "requireKnownAuthenticator": false + } }, "access_token_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" }, "session_idle_ttl": { @@ -6546,7 +6557,13 @@ "type": "string", "enum": ["required", "preferred", "discouraged"], "default": "required" - } + }, + "attestation": { + "type": "string", + "enum": ["none", "direct"], + "default": "none" + }, + "requireKnownAuthenticator": { "type": "boolean", "default": false } } }, "access_token_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" }, diff --git a/package-lock.json b/package-lock.json index 727a67d..95e9165 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@seamless-auth/messaging": "^0.1.0", "@seamless-auth/messaging-aws": "^0.1.0", "@seamless-auth/messaging-twilio": "^0.1.0", - "@seamless-auth/types": "^0.13.0", + "@seamless-auth/types": "^0.14.0", "@simplewebauthn/server": "^13.1.1", "base64url": "^3.0.1", "bcrypt-ts": "^7.1.0", @@ -3393,9 +3393,9 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.13.0.tgz", - "integrity": "sha512-dkgTljGcMur/U93pGz9aU3I3xMNtSLE5ZECx/AGqVl8Kj3V/2MDV6FLspTb5qN81foilMJUBVMkPSzsE28vt/A==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.14.0.tgz", + "integrity": "sha512-iEefOPQpRElLuf+eoBYYIPneVpmUVpil66KIy+4y32ClyC3nXu5UH26ADiIS8lswDzNiKoJ9kI8zI1WDp1lBVA==", "license": "AGPL-3.0-only", "dependencies": { "zod": "^4.3.6" diff --git a/package.json b/package.json index 2327da8..3758495 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@seamless-auth/messaging": "^0.1.0", "@seamless-auth/messaging-aws": "^0.1.0", "@seamless-auth/messaging-twilio": "^0.1.0", - "@seamless-auth/types": "^0.13.0", + "@seamless-auth/types": "^0.14.0", "@simplewebauthn/server": "^13.1.1", "base64url": "^3.0.1", "bcrypt-ts": "^7.1.0", diff --git a/src/config/systemConfig.defaults.ts b/src/config/systemConfig.defaults.ts index 1de1e85..dd18508 100644 --- a/src/config/systemConfig.defaults.ts +++ b/src/config/systemConfig.defaults.ts @@ -18,6 +18,8 @@ export const SYSTEM_CONFIG_DEFAULTS: Partial = { authenticator_policy: { attachment: 'any', userVerification: 'required', + attestation: 'none', + requireKnownAuthenticator: false, }, session_idle_ttl: '8h', passkey_login_fallback_enabled: true, diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index af0e0f0..9535a48 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -27,6 +27,7 @@ import { User } from '../models/users.js'; import type { WebAuthnAuthenticatorAttachment } from '../schemas/webauthn.requests.js'; import { AuthEventService } from '../services/authEventService.js'; import { rejectIfUserLocked } from '../services/lockoutPolicyService.js'; +import { isMetadataServiceReady } from '../services/metadataServiceBootstrap.js'; import { issueSessionAndRespond } from '../services/sessionIssuance.js'; import { consumeChallenge, issueChallenge } from '../services/webauthnChallengeService.js'; import { AuthenticatedRequest } from '../types/types.js'; @@ -155,7 +156,7 @@ const registerWebAuthn = async (req: Request, res: Response) => { rpID: rpid, userName: verifiedUser.email, timeout: 60000, - attestationType: 'none', + attestationType: authenticator_policy.attestation, supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS, excludeCredentials: existingCredentials.map((cred) => ({ id: cred.id, @@ -304,7 +305,7 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { return res.status(403).json({ error: 'Registration failed verification' }); } - const { aaguid, credential, credentialBackedUp, credentialDeviceType } = registrationInfo; + const { aaguid, credential, credentialBackedUp, credentialDeviceType, fmt } = registrationInfo; const challengeContext = getRegistrationChallengeContext(issued?.context); const prfCapable = getRegistrationPrfCapable(attestationResponse) || metadata.prfCapable === true; @@ -334,6 +335,11 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { // declined to identify itself, which is a different fact from never having // recorded one, and policy has to tell them apart. aaguid: aaguid ?? null, + // 'none' when this deployment did not ask. Recording it means a later + // audit can tell an unattested credential from one whose attestation was + // checked, which is not recoverable after the fact. + attestationFormat: fmt ?? null, + attestationVerified: fmt !== undefined && fmt !== 'none' && isMetadataServiceReady(), friendlyName: metadata.friendlyName || null, platform: metadata.platform || null, browser: metadata.browser || null, diff --git a/src/generated/api.ts b/src/generated/api.ts index 9dc97c3..9e899e8 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -6851,7 +6851,9 @@ export interface paths { /** * @default { * "attachment": "any", - * "userVerification": "required" + * "userVerification": "required", + * "attestation": "none", + * "requireKnownAuthenticator": false * } */ authenticator_policy: { @@ -6865,6 +6867,13 @@ export interface paths { * @enum {string} */ userVerification: 'required' | 'preferred' | 'discouraged'; + /** + * @default none + * @enum {string} + */ + attestation: 'none' | 'direct'; + /** @default false */ + requireKnownAuthenticator: boolean; }; access_token_ttl: string; /** @default 8h */ @@ -6996,6 +7005,13 @@ export interface paths { * @enum {string} */ userVerification?: 'required' | 'preferred' | 'discouraged'; + /** + * @default none + * @enum {string} + */ + attestation?: 'none' | 'direct'; + /** @default false */ + requireKnownAuthenticator?: boolean; }; access_token_ttl?: string; session_idle_ttl?: string; diff --git a/src/migrations/20260830120000-add-credential-attestation.cjs b/src/migrations/20260830120000-add-credential-attestation.cjs new file mode 100644 index 0000000..ae477f8 --- /dev/null +++ b/src/migrations/20260830120000-add-credential-attestation.cjs @@ -0,0 +1,32 @@ +'use strict'; + +/** + * Records how a credential identified itself at registration. + * + * `attestation_format` is the statement format the authenticator returned, or + * 'none' when this deployment did not ask for one. `attestation_verified` + * records whether that statement was checked against the FIDO Metadata Service. + * + * Both nullable with no backfill: neither was captured for existing credentials + * and neither can be recovered afterwards. + * + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('credentials', 'attestationFormat', { + type: Sequelize.STRING, + allowNull: true, + }); + + await queryInterface.addColumn('credentials', 'attestationVerified', { + type: Sequelize.BOOLEAN, + allowNull: true, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('credentials', 'attestationVerified'); + await queryInterface.removeColumn('credentials', 'attestationFormat'); + }, +}; diff --git a/src/models/credentials.ts b/src/models/credentials.ts index 64c4d42..4502bb4 100644 --- a/src/models/credentials.ts +++ b/src/models/credentials.ts @@ -18,6 +18,10 @@ export class Credential extends Model { declare deviceType: CredentialDeviceType; /** The authenticator's model identifier, as it reported at registration. */ declare aaguid: string | null; + /** The attestation statement format, or 'none' when none was requested. */ + declare attestationFormat: string | null; + /** Whether that statement was checked against the FIDO Metadata Service. */ + declare attestationVerified: boolean | null; declare backedup: boolean; declare prfCapable: boolean; @@ -82,6 +86,16 @@ export default (sequelize: Sequelize) => { allowNull: true, defaultValue: null, }, + attestationFormat: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + attestationVerified: { + type: DataTypes.BOOLEAN, + allowNull: true, + defaultValue: null, + }, friendlyName: { type: DataTypes.STRING, allowNull: true, diff --git a/src/server.ts b/src/server.ts index 93995c7..3fa638b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,6 +10,7 @@ import { createApp } from './app.js'; import { bootstrapSystemConfig } from './config/bootstrapSystemConfig.js'; import { connectToDb } from './db.js'; import { initializeModels } from './models/index.js'; +import { initializeMetadataService } from './services/metadataServiceBootstrap.js'; import getLogger from './utils/logger.js'; const logger = getLogger('server'); @@ -24,6 +25,11 @@ async function startServer() { await connectToDb(models); await bootstrapSystemConfig(); + // After config is bootstrapped, since it decides whether attestation is + // requested at all. Never throws: a metadata blob that cannot be fetched is + // a degraded state, not a reason to refuse to start. + await initializeMetadataService(); + const app: Application = await createApp(); app.listen(PORT as number, HOST, () => { diff --git a/src/services/metadataServiceBootstrap.ts b/src/services/metadataServiceBootstrap.ts new file mode 100644 index 0000000..dc7cc18 --- /dev/null +++ b/src/services/metadataServiceBootstrap.ts @@ -0,0 +1,84 @@ +/* + * 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 } from '@simplewebauthn/server'; + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import getLogger from '../utils/logger.js'; + +const logger = getLogger('metadataService'); + +let initialized = false; + +/** + * Whether the FIDO Metadata Service is available to validate attestations against. + * + * False either because this deployment does not request attestation, or because + * the blob could not be fetched at startup. + */ +export function isMetadataServiceReady() { + return initialized; +} + +/** Test seam. Startup calls this once, so state has to be resettable. */ +export function resetMetadataServiceForTests() { + initialized = false; +} + +/** + * Prepares attestation validation against the FIDO Metadata Service. + * + * Only does anything when the deployment asks for attestation. Under the default + * `none` there is no statement to validate, so downloading the blob would be a + * network dependency at boot bought for nothing. + * + * Never throws. A metadata blob that cannot be fetched is a degraded state, not a + * reason an authentication server should refuse to start, so the failure is + * logged and registration continues without metadata validation. `requireKnown` + * is deliberately not honoured in that case, because refusing every registration + * on a transient network failure is worse than the risk it guards against. + * `isMetadataServiceReady` reports which state the process is in. + */ +export async function initializeMetadataService(): Promise { + let attestation: string; + let requireKnown: boolean; + + try { + const { authenticator_policy } = await getSystemConfig(); + attestation = authenticator_policy.attestation; + requireKnown = authenticator_policy.requireKnownAuthenticator; + } catch (error) { + logger.error(`Could not read the authenticator policy, skipping metadata setup: ${error}`); + return false; + } + + if (attestation !== 'direct') { + logger.info('Attestation is not requested, so the metadata service is not initialized.'); + return false; + } + + try { + await MetadataService.initialize({ + // 'strict' makes the library refuse an authenticator the blob does not + // list; 'permissive' registers it anyway. + verificationMode: requireKnown ? 'strict' : 'permissive', + }); + + initialized = true; + logger.info( + `Metadata service ready, unlisted authenticators are ${requireKnown ? 'refused' : 'allowed'}.`, + ); + + return true; + } catch (error) { + initialized = false; + logger.error( + `Metadata service failed to initialize, attestation will be verified without it: ${error}`, + ); + + return false; + } +} diff --git a/tests/integration/webauthn/webauthn.spec.ts b/tests/integration/webauthn/webauthn.spec.ts index 63beaac..9a1648c 100644 --- a/tests/integration/webauthn/webauthn.spec.ts +++ b/tests/integration/webauthn/webauthn.spec.ts @@ -76,7 +76,12 @@ beforeEach(() => { access_token_ttl: '15m', refresh_token_ttl: '1h', session_idle_ttl: '8h', - authenticator_policy: { attachment: 'any', userVerification: 'required' }, + authenticator_policy: { + attachment: 'any', + userVerification: 'required', + attestation: 'none', + requireKnownAuthenticator: false, + }, }); (Credential.findAll as any).mockResolvedValue([]); (Credential.findOne as any).mockResolvedValue(null); @@ -88,7 +93,12 @@ describe('GET /webauthn/register/start', () => { (getSystemConfig as any).mockResolvedValue({ app_name: 'SeamlessAuth', rpid: 'localhost', - authenticator_policy: { attachment: 'any', userVerification: 'required' }, + authenticator_policy: { + attachment: 'any', + userVerification: 'required', + attestation: 'none', + requireKnownAuthenticator: false, + }, }); const { generateRegistrationOptions } = await import('@simplewebauthn/server'); @@ -108,7 +118,12 @@ describe('GET /webauthn/register/start', () => { (getSystemConfig as any).mockResolvedValue({ app_name: 'SeamlessAuth', rpid: 'localhost', - authenticator_policy: { attachment: 'any', userVerification: 'required' }, + authenticator_policy: { + attachment: 'any', + userVerification: 'required', + attestation: 'none', + requireKnownAuthenticator: false, + }, }); const { generateRegistrationOptions } = await import('@simplewebauthn/server'); @@ -203,7 +218,12 @@ describe('GET /webauthn/register/start', () => { (getSystemConfig as any).mockResolvedValue({ app_name: 'SeamlessAuth', rpid: 'localhost', - authenticator_policy: { attachment: 'cross-platform', userVerification: 'required' }, + authenticator_policy: { + attachment: 'cross-platform', + userVerification: 'required', + attestation: 'none', + requireKnownAuthenticator: false, + }, }); const { generateRegistrationOptions } = await import('@simplewebauthn/server'); (generateRegistrationOptions as any).mockResolvedValue({ challenge: 'challenge' }); @@ -224,7 +244,12 @@ describe('GET /webauthn/register/start', () => { (getSystemConfig as any).mockResolvedValue({ app_name: 'SeamlessAuth', rpid: 'localhost', - authenticator_policy: { attachment: 'cross-platform', userVerification: 'required' }, + authenticator_policy: { + attachment: 'cross-platform', + userVerification: 'required', + attestation: 'none', + requireKnownAuthenticator: false, + }, }); const { generateRegistrationOptions } = await import('@simplewebauthn/server'); (generateRegistrationOptions as any).mockResolvedValue({ challenge: 'challenge' }); @@ -240,7 +265,12 @@ describe('GET /webauthn/register/start', () => { (getSystemConfig as any).mockResolvedValue({ app_name: 'SeamlessAuth', rpid: 'localhost', - authenticator_policy: { attachment: 'cross-platform', userVerification: 'required' }, + authenticator_policy: { + attachment: 'cross-platform', + userVerification: 'required', + attestation: 'none', + requireKnownAuthenticator: false, + }, }); const { generateRegistrationOptions } = await import('@simplewebauthn/server'); (generateRegistrationOptions as any).mockResolvedValue({ challenge: 'challenge' }); @@ -323,7 +353,12 @@ describe('GET /webauthn/register/start', () => { (getSystemConfig as any).mockResolvedValue({ app_name: 'SeamlessAuth', rpid: 'localhost', - authenticator_policy: { attachment: 'any', userVerification: 'discouraged' }, + authenticator_policy: { + attachment: 'any', + userVerification: 'discouraged', + attestation: 'none', + requireKnownAuthenticator: false, + }, }); const { generateRegistrationOptions } = await import('@simplewebauthn/server'); (generateRegistrationOptions as any).mockResolvedValue({ challenge: 'challenge' }); @@ -335,6 +370,38 @@ describe('GET /webauthn/register/start', () => { expect(options.authenticatorSelection.userVerification).toBe('discouraged'); }); + it('asks for no attestation by default', async () => { + const { generateRegistrationOptions } = await import('@simplewebauthn/server'); + (generateRegistrationOptions as any).mockResolvedValue({ challenge: 'challenge' }); + + await request(app).get('/webauthn/register/start'); + + const [options] = (generateRegistrationOptions as any).mock.calls.at(-1); + + expect(options.attestationType).toBe('none'); + }); + + it('requests attestation when the deployment asks for it', async () => { + (getSystemConfig as any).mockResolvedValue({ + app_name: 'SeamlessAuth', + rpid: 'localhost', + authenticator_policy: { + attachment: 'any', + userVerification: 'required', + attestation: 'direct', + requireKnownAuthenticator: false, + }, + }); + const { generateRegistrationOptions } = await import('@simplewebauthn/server'); + (generateRegistrationOptions as any).mockResolvedValue({ challenge: 'challenge' }); + + await request(app).get('/webauthn/register/start'); + + const [options] = (generateRegistrationOptions as any).mock.calls.at(-1); + + expect(options.attestationType).toBe('direct'); + }); + it('returns 500 when credential lookup fails', async () => { (Credential.findAll as any).mockRejectedValue(new Error('db down')); @@ -436,7 +503,12 @@ describe('verification policy reaches both verifiers', () => { app_name: 'SeamlessAuth', rpid: 'localhost', origins: ['http://localhost:5137'], - authenticator_policy: { attachment: 'any', userVerification: 'discouraged' }, + authenticator_policy: { + attachment: 'any', + userVerification: 'discouraged', + attestation: 'none', + requireKnownAuthenticator: false, + }, }); (User.findOne as any).mockResolvedValue(buildUser()); const { verifyRegistrationResponse } = await import('@simplewebauthn/server'); @@ -499,6 +571,63 @@ describe('POST /webauthn/register/finish', () => { ); }); + it('records how the credential identified itself', async () => { + (User.findOne as any).mockResolvedValue(buildUser()); + (Credential.findAll as any).mockResolvedValue([]); + const { verifyRegistrationResponse } = await import('@simplewebauthn/server'); + + (verifyRegistrationResponse as any).mockResolvedValue({ + verified: true, + registrationInfo: { + fmt: 'packed', + aaguid: 'ee882879-721c-4913-9775-3dfcce97072a', + credential: { id: 'cred-1', publicKey: Buffer.from('key'), counter: 0, transports: [] }, + credentialBackedUp: false, + credentialDeviceType: 'platform', + }, + }); + + (Credential.create as any).mockResolvedValue({}); + (Session.create as any).mockResolvedValue({ id: 'session-1' }); + + await request(app) + .post('/webauthn/register/finish') + .send({ attestationResponse: {}, metadata: {} }); + + expect(Credential.create).toHaveBeenCalledWith( + expect.objectContaining({ attestationFormat: 'packed' }), + ); + }); + + it('does not claim an unattested credential was verified against metadata', async () => { + (User.findOne as any).mockResolvedValue(buildUser()); + (Credential.findAll as any).mockResolvedValue([]); + const { verifyRegistrationResponse } = await import('@simplewebauthn/server'); + + (verifyRegistrationResponse as any).mockResolvedValue({ + verified: true, + registrationInfo: { + // What a deployment on the default posture gets: no statement to check. + fmt: 'none', + aaguid: '00000000-0000-0000-0000-000000000000', + credential: { id: 'cred-1', publicKey: Buffer.from('key'), counter: 0, transports: [] }, + credentialBackedUp: false, + credentialDeviceType: 'platform', + }, + }); + + (Credential.create as any).mockResolvedValue({}); + (Session.create as any).mockResolvedValue({ id: 'session-1' }); + + await request(app) + .post('/webauthn/register/finish') + .send({ attestationResponse: {}, metadata: {} }); + + expect(Credential.create).toHaveBeenCalledWith( + expect.objectContaining({ attestationFormat: 'none', attestationVerified: false }), + ); + }); + it('records the authenticator model the credential came from', async () => { (User.findOne as any).mockResolvedValue(buildUser()); (Credential.findAll as any).mockResolvedValue([]); @@ -963,7 +1092,12 @@ describe('POST /webauthn/login/finish', () => { (getSystemConfig as any).mockResolvedValue({ origins: ['http://localhost:5137'], rpid: 'localhost', - authenticator_policy: { attachment: 'any', userVerification: 'required' }, + authenticator_policy: { + attachment: 'any', + userVerification: 'required', + attestation: 'none', + requireKnownAuthenticator: false, + }, }); const { verifyAuthenticationResponse } = await import('@simplewebauthn/server'); (verifyAuthenticationResponse as any).mockRejectedValue(new Error('bad assertion')); @@ -1022,7 +1156,12 @@ describe('POST /webauthn/login/finish', () => { (getSystemConfig as any).mockResolvedValue({ origins: ['http://localhost:5137'], rpid: 'localhost', - authenticator_policy: { attachment: 'any', userVerification: 'required' }, + authenticator_policy: { + attachment: 'any', + userVerification: 'required', + attestation: 'none', + requireKnownAuthenticator: false, + }, }); const { verifyAuthenticationResponse } = await import('@simplewebauthn/server'); (verifyAuthenticationResponse as any).mockResolvedValue({ diff --git a/tests/unit/services/metadataServiceBootstrap.spec.ts b/tests/unit/services/metadataServiceBootstrap.spec.ts new file mode 100644 index 0000000..1a2b90c --- /dev/null +++ b/tests/unit/services/metadataServiceBootstrap.spec.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { + initializeMetadataService, + isMetadataServiceReady, + resetMetadataServiceForTests, +} from '../../../src/services/metadataServiceBootstrap.js'; + +const { metadataInitialize } = vi.hoisted(() => ({ metadataInitialize: vi.fn() })); + +vi.mock('@simplewebauthn/server', () => ({ + MetadataService: { initialize: metadataInitialize }, +})); + +function policy(overrides: Record = {}) { + return { + authenticator_policy: { + attachment: 'any', + userVerification: 'required', + attestation: 'none', + requireKnownAuthenticator: false, + ...overrides, + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + resetMetadataServiceForTests(); +}); + +describe('initializeMetadataService', () => { + it('does nothing when the deployment does not ask for attestation', async () => { + (getSystemConfig as any).mockResolvedValue(policy({ attestation: 'none' })); + + expect(await initializeMetadataService()).toBe(false); + // No blob download, so no network dependency at boot for deployments that + // would never consult it. + expect(metadataInitialize).not.toHaveBeenCalled(); + expect(isMetadataServiceReady()).toBe(false); + }); + + it('initializes permissively when unlisted authenticators are allowed', async () => { + (getSystemConfig as any).mockResolvedValue(policy({ attestation: 'direct' })); + metadataInitialize.mockResolvedValue(undefined); + + expect(await initializeMetadataService()).toBe(true); + expect(metadataInitialize).toHaveBeenCalledWith({ verificationMode: 'permissive' }); + expect(isMetadataServiceReady()).toBe(true); + }); + + it('initializes strictly when only known authenticators are allowed', async () => { + (getSystemConfig as any).mockResolvedValue( + policy({ attestation: 'direct', requireKnownAuthenticator: true }), + ); + metadataInitialize.mockResolvedValue(undefined); + + await initializeMetadataService(); + + expect(metadataInitialize).toHaveBeenCalledWith({ verificationMode: 'strict' }); + }); + + // An auth server that will not start because a metadata blob is unreachable is + // worse than one that starts without metadata validation. + it('survives a metadata blob that cannot be fetched', async () => { + (getSystemConfig as any).mockResolvedValue(policy({ attestation: 'direct' })); + metadataInitialize.mockRejectedValue(new Error('network down')); + + expect(await initializeMetadataService()).toBe(false); + expect(isMetadataServiceReady()).toBe(false); + }); + + it('survives config being unreadable', async () => { + (getSystemConfig as any).mockRejectedValue(new Error('no database')); + + expect(await initializeMetadataService()).toBe(false); + expect(metadataInitialize).not.toHaveBeenCalled(); + }); +});