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(); + }); +});