diff --git a/.changeset/hungry-bats-shout.md b/.changeset/hungry-bats-shout.md new file mode 100644 index 0000000..ba6ab40 --- /dev/null +++ b/.changeset/hungry-bats-shout.md @@ -0,0 +1,41 @@ +--- +'seamless-auth-api': major +--- + +Refuse synced passkeys by default, and let a deployment restrict authenticator models. + +**Breaking. Read this before upgrading.** + +`authenticator_policy.syncedPasskeys` defaults to `block`. A multi-device +credential is synced by a platform password manager, so its private key exists +somewhere outside the authenticator that created it. **Every iCloud Keychain and +Google Password Manager passkey is one.** On upgrade, a deployment relying on +platform passkeys stops enrolling them and registration answers +`403 { "error": "synced_passkey_not_allowed" }`. + +To keep the previous behaviour: + +```json +AUTHENTICATOR_POLICY={"syncedPasskeys":"allow", ...} +``` + +This closes the gap between what the product did and the design position it was +documented as holding, which was blocked by default with the agency able to +enable. Existing credentials are unaffected; this governs new registrations. + +The decision is made on backup **eligibility** rather than current backup state. +A credential that can leave the device is the exposure whether or not it already +has, and judging on current state would let one register while unsynced and sync +afterwards. + +Also adds `aaguidAllowList` and `aaguidDenyList`, which restrict which +authenticator models may register. The deny list is applied first, so a model can +be excluded even when a broad allow list would admit it. Both need +`attestation: 'direct'` to mean anything, since an authenticator that was never +asked to identify itself reports no usable AAGUID; an allow list set without it +refuses everything, and the server says so at startup rather than leaving it to +be discovered one failed enrolment at a time. + +Refusals are distinguishable, `synced_passkey_not_allowed` and +`authenticator_not_allowed`, and each is recorded as a failed registration with +the reason. diff --git a/.env.example b/.env.example index 87c53ed..c1b1160 100644 --- a/.env.example +++ b/.env.example @@ -93,7 +93,12 @@ LOCKOUT_POLICY={"enabled":true,"maxFailures":10,"windowSeconds":900,"lockoutSeco # 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} +# syncedPasskeys is block or allow. block, the default, refuses a credential that can +# leave the device it was created on, which includes every iCloud Keychain and Google +# Password Manager passkey. Set allow for a consumer deployment. +# aaguidAllowList and aaguidDenyList restrict which authenticator models may register +# and need attestation set to direct to mean anything. +AUTHENTICATOR_POLICY={"attachment":"any","userVerification":"required","attestation":"none","requireKnownAuthenticator":false,"syncedPasskeys":"block","aaguidAllowList":[],"aaguidDenyList":[]} # SERVICE TOKENS # Required for trusted server adapters and internal bearer validation. diff --git a/docs/configuration.md b/docs/configuration.md index 5aa8086..ba8a567 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","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. | +| 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,"syncedPasskeys":"block","aaguidAllowList":[],"aaguidDenyList":[]}` | `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. `syncedPasskeys` is `block` or `allow`; `block`, the default, refuses a credential that can leave the device it was created on, which includes every iCloud Keychain and Google Password Manager passkey. `aaguidAllowList` and `aaguidDenyList` restrict which authenticator models may register, and need `attestation` set to `direct` to mean anything. | ### Service tokens and secrets diff --git a/docs/security-posture.md b/docs/security-posture.md index 284a505..d64e5cc 100644 --- a/docs/security-posture.md +++ b/docs/security-posture.md @@ -94,6 +94,39 @@ Operators who need these fields encrypted at rest should use database-level encr (for example RDS/Aurora storage encryption, or full-disk encryption), which protects the same data without breaking lookups. +## Synced passkeys + +**Posture: blocked by default, deployment may allow.** + +A multi-device credential is synced by a platform password manager, so its +private key exists somewhere outside the authenticator that created it. Every +iCloud Keychain and Google Password Manager passkey is one. That is what a +consumer wants and what an organisation issuing its own authenticators does not. + +`authenticator_policy.syncedPasskeys` defaults to `block`, and registration +answers `403 { "error": "synced_passkey_not_allowed" }`. A deployment that wants +platform passkeys sets it to `allow`. + +### Judged on eligibility, not current state + +The decision is made on WebAuthn's backup **eligibility** flag, surfaced as +`credentialDeviceType: 'multiDevice'`, rather than on whether the credential is +currently backed up. A credential that _can_ leave the device is the exposure +whether or not it already has, and judging on current state would let one +register while unsynced and sync afterwards. + +### Restricting authenticator models + +`aaguidAllowList` and `aaguidDenyList` restrict which authenticator models may +register, by AAGUID. The deny list is applied first, so a model can be excluded +even when a broad allow list would otherwise admit it. + +Both need `attestation: 'direct'` to mean anything. An authenticator that was +never asked to identify itself reports an all-zero AAGUID, so an allow list would +refuse everything. That combination is refused deliberately rather than waved +through, since admitting an unidentified authenticator would make the list +advisory, and the server logs the misconfiguration at startup. + ## Token audience **Posture: `aud` is the deployment's own issuer URL.** diff --git a/openapi.json b/openapi.json index 19d1e58..c884a72 100644 --- a/openapi.json +++ b/openapi.json @@ -6344,13 +6344,31 @@ "enum": ["none", "direct"], "default": "none" }, - "requireKnownAuthenticator": { "type": "boolean", "default": false } + "requireKnownAuthenticator": { "type": "boolean", "default": false }, + "syncedPasskeys": { + "type": "string", + "enum": ["allow", "block"], + "default": "block" + }, + "aaguidAllowList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "aaguidDenyList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + } }, "default": { "attachment": "any", "userVerification": "required", "attestation": "none", - "requireKnownAuthenticator": false + "requireKnownAuthenticator": false, + "syncedPasskeys": "block", + "aaguidAllowList": [], + "aaguidDenyList": [] } }, "access_token_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" }, @@ -6563,7 +6581,22 @@ "enum": ["none", "direct"], "default": "none" }, - "requireKnownAuthenticator": { "type": "boolean", "default": false } + "requireKnownAuthenticator": { "type": "boolean", "default": false }, + "syncedPasskeys": { + "type": "string", + "enum": ["allow", "block"], + "default": "block" + }, + "aaguidAllowList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "aaguidDenyList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + } } }, "access_token_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" }, diff --git a/package-lock.json b/package-lock.json index 95e9165..e7d4402 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.14.0", + "@seamless-auth/types": "^0.15.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.14.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.14.0.tgz", - "integrity": "sha512-iEefOPQpRElLuf+eoBYYIPneVpmUVpil66KIy+4y32ClyC3nXu5UH26ADiIS8lswDzNiKoJ9kI8zI1WDp1lBVA==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.15.0.tgz", + "integrity": "sha512-Y0xpK67aYHMc8Lwys7zZc2N94AnHKPD+QmJFgOf969guiazKRGhV6NK/A+SxraC7R3nm2Rf6tkTJoYJXe3WYyQ==", "license": "AGPL-3.0-only", "dependencies": { "zod": "^4.3.6" diff --git a/package.json b/package.json index 3758495..c008ff7 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.14.0", + "@seamless-auth/types": "^0.15.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 dd18508..13eb39b 100644 --- a/src/config/systemConfig.defaults.ts +++ b/src/config/systemConfig.defaults.ts @@ -20,6 +20,9 @@ export const SYSTEM_CONFIG_DEFAULTS: Partial = { userVerification: 'required', attestation: 'none', requireKnownAuthenticator: false, + syncedPasskeys: 'block', + aaguidAllowList: [], + aaguidDenyList: [], }, session_idle_ttl: '8h', passkey_login_fallback_enabled: true, diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index 9535a48..d0b618c 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -25,6 +25,7 @@ import { import { Credential } from '../models/credentials.js'; import { User } from '../models/users.js'; import type { WebAuthnAuthenticatorAttachment } from '../schemas/webauthn.requests.js'; +import { evaluateAuthenticatorPolicy } from '../services/authenticatorPolicyService.js'; import { AuthEventService } from '../services/authEventService.js'; import { rejectIfUserLocked } from '../services/lockoutPolicyService.js'; import { isMetadataServiceReady } from '../services/metadataServiceBootstrap.js'; @@ -306,6 +307,29 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { } const { aaguid, credential, credentialBackedUp, credentialDeviceType, fmt } = registrationInfo; + const { authenticator_policy } = await getSystemConfig(); + const verdict = evaluateAuthenticatorPolicy({ + policy: authenticator_policy, + aaguid, + deviceType: credentialDeviceType, + }); + + if (!verdict.allowed) { + logger.warn(`Registration refused by authenticator policy: ${verdict.detail}`); + await AuthEventService.log({ + userId: user.id, + type: 'webauthn_registration_failed', + req, + metadata: { + reason: verdict.detail, + aaguid: aaguid ?? null, + deviceType: credentialDeviceType ?? null, + }, + }); + + return res.status(403).json({ error: verdict.reason }); + } + const challengeContext = getRegistrationChallengeContext(issued?.context); const prfCapable = getRegistrationPrfCapable(attestationResponse) || metadata.prfCapable === true; diff --git a/src/generated/api.ts b/src/generated/api.ts index 9e899e8..277bf0d 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -6853,7 +6853,10 @@ export interface paths { * "attachment": "any", * "userVerification": "required", * "attestation": "none", - * "requireKnownAuthenticator": false + * "requireKnownAuthenticator": false, + * "syncedPasskeys": "block", + * "aaguidAllowList": [], + * "aaguidDenyList": [] * } */ authenticator_policy: { @@ -6874,6 +6877,15 @@ export interface paths { attestation: 'none' | 'direct'; /** @default false */ requireKnownAuthenticator: boolean; + /** + * @default block + * @enum {string} + */ + syncedPasskeys: 'allow' | 'block'; + /** @default [] */ + aaguidAllowList: string[]; + /** @default [] */ + aaguidDenyList: string[]; }; access_token_ttl: string; /** @default 8h */ @@ -7012,6 +7024,15 @@ export interface paths { attestation?: 'none' | 'direct'; /** @default false */ requireKnownAuthenticator?: boolean; + /** + * @default block + * @enum {string} + */ + syncedPasskeys?: 'allow' | 'block'; + /** @default [] */ + aaguidAllowList?: string[]; + /** @default [] */ + aaguidDenyList?: string[]; }; access_token_ttl?: string; session_idle_ttl?: string; diff --git a/src/services/authenticatorPolicyService.ts b/src/services/authenticatorPolicyService.ts new file mode 100644 index 0000000..ada4f91 --- /dev/null +++ b/src/services/authenticatorPolicyService.ts @@ -0,0 +1,97 @@ +/* + * 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 type { AuthenticatorPolicy } from '@seamless-auth/types'; + +/** The all-zero AAGUID: an authenticator declining to say what it is. */ +const ANONYMOUS_AAGUID = '00000000-0000-0000-0000-000000000000'; + +export type AuthenticatorRefusal = 'authenticator_not_allowed' | 'synced_passkey_not_allowed'; + +export interface AuthenticatorPolicyVerdict { + allowed: boolean; + reason?: AuthenticatorRefusal; + /** Operator-facing detail. Goes to the audit trail, not to the caller. */ + detail?: string; +} + +const ALLOWED: AuthenticatorPolicyVerdict = { allowed: true }; + +function normalize(aaguid: string | null | undefined) { + return (aaguid ?? '').trim().toLowerCase(); +} + +function listed(list: string[], aaguid: string) { + return list.some((entry) => normalize(entry) === aaguid); +} + +/** + * Decides whether a just-verified credential may be registered. + * + * Order matters. The deny list is applied first so a model can be excluded even + * when a broad allow list would otherwise admit it, then the allow list, then + * the synced posture. A refusal names which rule refused, so an operator reading + * the audit trail can tell "this model is not permitted here" from "this + * credential can leave the device". + */ +export function evaluateAuthenticatorPolicy(params: { + policy: AuthenticatorPolicy; + aaguid: string | null | undefined; + /** WebAuthn backup eligibility. 'multiDevice' means the key can leave its authenticator. */ + deviceType: string | null | undefined; +}): AuthenticatorPolicyVerdict { + const { policy } = params; + const aaguid = normalize(params.aaguid); + + if (policy.aaguidDenyList.length > 0 && aaguid && listed(policy.aaguidDenyList, aaguid)) { + return { + allowed: false, + reason: 'authenticator_not_allowed', + detail: 'Authenticator model is on the deny list', + }; + } + + if (policy.aaguidAllowList.length > 0) { + // An authenticator that was not asked to identify itself, or that declined, + // cannot satisfy an allow list. Admitting it would make the list advisory. + if (!aaguid || aaguid === ANONYMOUS_AAGUID) { + return { + allowed: false, + reason: 'authenticator_not_allowed', + detail: + 'Authenticator did not identify itself, so it cannot be matched against the allow list', + }; + } + + if (!listed(policy.aaguidAllowList, aaguid)) { + return { + allowed: false, + reason: 'authenticator_not_allowed', + detail: 'Authenticator model is not on the allow list', + }; + } + } + + // Judged on eligibility rather than current backup state: a credential that + // can leave the device is the exposure, whether or not it already has. + if (policy.syncedPasskeys === 'block' && params.deviceType === 'multiDevice') { + return { + allowed: false, + reason: 'synced_passkey_not_allowed', + detail: 'Credential is backup eligible, so its key can leave the authenticator', + }; + } + + return ALLOWED; +} + +/** + * Whether an allow list has been set without asking authenticators to identify + * themselves, in which case it refuses everything. + */ +export function allowListNeedsAttestation(policy: AuthenticatorPolicy) { + return policy.aaguidAllowList.length > 0 && policy.attestation !== 'direct'; +} diff --git a/src/services/metadataServiceBootstrap.ts b/src/services/metadataServiceBootstrap.ts index dc7cc18..052400c 100644 --- a/src/services/metadataServiceBootstrap.ts +++ b/src/services/metadataServiceBootstrap.ts @@ -8,6 +8,7 @@ import { MetadataService } from '@simplewebauthn/server'; import { getSystemConfig } from '../config/getSystemConfig.js'; import getLogger from '../utils/logger.js'; +import { allowListNeedsAttestation } from './authenticatorPolicyService.js'; const logger = getLogger('metadataService'); @@ -50,6 +51,17 @@ export async function initializeMetadataService(): Promise { const { authenticator_policy } = await getSystemConfig(); attestation = authenticator_policy.attestation; requireKnown = authenticator_policy.requireKnownAuthenticator; + + // An allow list matches on AAGUID, and an authenticator that was never asked + // to identify itself does not report a usable one, so this combination + // refuses every registration. Said once at startup rather than discovered + // one failed enrolment at a time. + if (allowListNeedsAttestation(authenticator_policy)) { + logger.error( + 'authenticator_policy sets aaguidAllowList while attestation is "none". No authenticator ' + + 'can identify itself, so every registration will be refused. Set attestation to "direct".', + ); + } } catch (error) { logger.error(`Could not read the authenticator policy, skipping metadata setup: ${error}`); return false; diff --git a/tests/integration/webauthn/webauthn.spec.ts b/tests/integration/webauthn/webauthn.spec.ts index 9a1648c..9f26f53 100644 --- a/tests/integration/webauthn/webauthn.spec.ts +++ b/tests/integration/webauthn/webauthn.spec.ts @@ -81,6 +81,9 @@ beforeEach(() => { userVerification: 'required', attestation: 'none', requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], }, }); (Credential.findAll as any).mockResolvedValue([]); @@ -98,6 +101,9 @@ describe('GET /webauthn/register/start', () => { userVerification: 'required', attestation: 'none', requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], }, }); @@ -123,6 +129,9 @@ describe('GET /webauthn/register/start', () => { userVerification: 'required', attestation: 'none', requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], }, }); @@ -223,6 +232,9 @@ describe('GET /webauthn/register/start', () => { userVerification: 'required', attestation: 'none', requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], }, }); const { generateRegistrationOptions } = await import('@simplewebauthn/server'); @@ -249,6 +261,9 @@ describe('GET /webauthn/register/start', () => { userVerification: 'required', attestation: 'none', requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], }, }); const { generateRegistrationOptions } = await import('@simplewebauthn/server'); @@ -270,6 +285,9 @@ describe('GET /webauthn/register/start', () => { userVerification: 'required', attestation: 'none', requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], }, }); const { generateRegistrationOptions } = await import('@simplewebauthn/server'); @@ -358,6 +376,9 @@ describe('GET /webauthn/register/start', () => { userVerification: 'discouraged', attestation: 'none', requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], }, }); const { generateRegistrationOptions } = await import('@simplewebauthn/server'); @@ -390,6 +411,9 @@ describe('GET /webauthn/register/start', () => { userVerification: 'required', attestation: 'direct', requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], }, }); const { generateRegistrationOptions } = await import('@simplewebauthn/server'); @@ -508,6 +532,9 @@ describe('verification policy reaches both verifiers', () => { userVerification: 'discouraged', attestation: 'none', requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], }, }); (User.findOne as any).mockResolvedValue(buildUser()); @@ -524,6 +551,104 @@ describe('verification policy reaches both verifiers', () => { }); }); +describe('authenticator policy at registration', () => { + function policyConfig(overrides: Record) { + (getSystemConfig as any).mockResolvedValue({ + app_name: 'SeamlessAuth', + rpid: 'localhost', + origins: ['http://localhost:5137'], + access_token_ttl: '15m', + refresh_token_ttl: '1h', + session_idle_ttl: '8h', + authenticator_policy: { + attachment: 'any', + userVerification: 'required', + attestation: 'direct', + requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], + ...overrides, + }, + }); + } + + async function registerWith(registrationInfo: Record) { + (User.findOne as any).mockResolvedValue(buildUser()); + (Credential.findAll as any).mockResolvedValue([]); + (Credential.create as any).mockResolvedValue({}); + (Session.create as any).mockResolvedValue({ id: 'session-1' }); + (signAccessToken as any).mockResolvedValue('access-token'); + (generateRefreshToken as any).mockReturnValue('refresh-token'); + (hashRefreshToken as any).mockResolvedValue('hashed-refresh'); + + const { verifyRegistrationResponse } = await import('@simplewebauthn/server'); + (verifyRegistrationResponse as any).mockResolvedValue({ + verified: true, + registrationInfo: { + fmt: 'packed', + credential: { id: 'cred-1', publicKey: Buffer.from('key'), counter: 0, transports: [] }, + credentialBackedUp: false, + credentialDeviceType: 'singleDevice', + ...registrationInfo, + }, + }); + + return request(app) + .post('/webauthn/register/finish') + .send({ attestationResponse: {}, metadata: {} }); + } + + it('refuses a credential that can leave the device when syncing is blocked', async () => { + policyConfig({ syncedPasskeys: 'block' }); + + const res = await registerWith({ credentialDeviceType: 'multiDevice' }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'synced_passkey_not_allowed' }); + expect(Credential.create).not.toHaveBeenCalled(); + }); + + it('admits a device-bound credential under the same policy', async () => { + policyConfig({ syncedPasskeys: 'block' }); + + const res = await registerWith({ credentialDeviceType: 'singleDevice' }); + + expect(res.status).toBe(200); + expect(Credential.create).toHaveBeenCalled(); + }); + + it('refuses a model that is not on the allow list', async () => { + policyConfig({ aaguidAllowList: ['ee882879-721c-4913-9775-3dfcce97072a'] }); + + const res = await registerWith({ aaguid: 'deadbeef-0000-0000-0000-000000000000' }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'authenticator_not_allowed' }); + }); + + it('admits a model that is on it', async () => { + policyConfig({ aaguidAllowList: ['ee882879-721c-4913-9775-3dfcce97072a'] }); + + const res = await registerWith({ aaguid: 'ee882879-721c-4913-9775-3dfcce97072a' }); + + expect(res.status).toBe(200); + }); + + it('distinguishes the two refusals in the audit trail', async () => { + policyConfig({ syncedPasskeys: 'block' }); + await registerWith({ credentialDeviceType: 'multiDevice' }); + + const failure = (AuthEventService.log as any).mock.calls + .map(([arg]: [any]) => arg) + .find((arg: any) => arg.type === 'webauthn_registration_failed'); + + expect(failure).toBeDefined(); + expect(failure.metadata.reason).toContain('backup eligible'); + expect(failure.metadata.deviceType).toBe('multiDevice'); + }); +}); + describe('POST /webauthn/register/finish', () => { it('creates credential and session', async () => { const user = buildUser(); @@ -1097,6 +1222,9 @@ describe('POST /webauthn/login/finish', () => { userVerification: 'required', attestation: 'none', requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], }, }); const { verifyAuthenticationResponse } = await import('@simplewebauthn/server'); @@ -1161,6 +1289,9 @@ describe('POST /webauthn/login/finish', () => { userVerification: 'required', attestation: 'none', requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], }, }); const { verifyAuthenticationResponse } = await import('@simplewebauthn/server'); diff --git a/tests/unit/services/authenticatorPolicyService.spec.ts b/tests/unit/services/authenticatorPolicyService.spec.ts new file mode 100644 index 0000000..d29e9d1 --- /dev/null +++ b/tests/unit/services/authenticatorPolicyService.spec.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { + allowListNeedsAttestation, + evaluateAuthenticatorPolicy, +} from '../../../src/services/authenticatorPolicyService.js'; + +const YUBIKEY = 'ee882879-721c-4913-9775-3dfcce97072a'; +const ANONYMOUS = '00000000-0000-0000-0000-000000000000'; + +function policy(overrides: Record = {}) { + return { + attachment: 'any', + userVerification: 'required', + attestation: 'direct', + requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], + ...overrides, + } as any; +} + +describe('synced passkeys', () => { + it('refuses a credential that can leave the device when blocked', () => { + const verdict = evaluateAuthenticatorPolicy({ + policy: policy({ syncedPasskeys: 'block' }), + aaguid: YUBIKEY, + deviceType: 'multiDevice', + }); + + expect(verdict.allowed).toBe(false); + expect(verdict.reason).toBe('synced_passkey_not_allowed'); + }); + + it('admits a credential bound to one device', () => { + const verdict = evaluateAuthenticatorPolicy({ + policy: policy({ syncedPasskeys: 'block' }), + aaguid: YUBIKEY, + deviceType: 'singleDevice', + }); + + expect(verdict.allowed).toBe(true); + }); + + // The exposure is that the key *can* leave, not that it already has, so a + // backup-eligible credential is refused even before it syncs. + it('judges eligibility rather than whether it has synced yet', () => { + const verdict = evaluateAuthenticatorPolicy({ + policy: policy({ syncedPasskeys: 'block' }), + aaguid: YUBIKEY, + deviceType: 'multiDevice', + }); + + expect(verdict.allowed).toBe(false); + }); + + it('admits it when the deployment allows syncing', () => { + const verdict = evaluateAuthenticatorPolicy({ + policy: policy({ syncedPasskeys: 'allow' }), + aaguid: YUBIKEY, + deviceType: 'multiDevice', + }); + + expect(verdict.allowed).toBe(true); + }); +}); + +describe('authenticator model lists', () => { + it('restricts nothing when both lists are empty', () => { + expect( + evaluateAuthenticatorPolicy({ policy: policy(), aaguid: YUBIKEY, deviceType: 'singleDevice' }) + .allowed, + ).toBe(true); + }); + + it('admits only listed models when an allow list is set', () => { + const allowed = evaluateAuthenticatorPolicy({ + policy: policy({ aaguidAllowList: [YUBIKEY] }), + aaguid: YUBIKEY, + deviceType: 'singleDevice', + }); + const refused = evaluateAuthenticatorPolicy({ + policy: policy({ aaguidAllowList: [YUBIKEY] }), + aaguid: 'deadbeef-0000-0000-0000-000000000000', + deviceType: 'singleDevice', + }); + + expect(allowed.allowed).toBe(true); + expect(refused.allowed).toBe(false); + expect(refused.reason).toBe('authenticator_not_allowed'); + }); + + // Otherwise the list would be advisory: anything that declines to identify + // itself would sail past it. + it('refuses an authenticator that declined to identify itself against an allow list', () => { + const verdict = evaluateAuthenticatorPolicy({ + policy: policy({ aaguidAllowList: [YUBIKEY] }), + aaguid: ANONYMOUS, + deviceType: 'singleDevice', + }); + + expect(verdict.allowed).toBe(false); + expect(verdict.reason).toBe('authenticator_not_allowed'); + }); + + it('applies the deny list before the allow list', () => { + const verdict = evaluateAuthenticatorPolicy({ + policy: policy({ aaguidAllowList: [YUBIKEY], aaguidDenyList: [YUBIKEY] }), + aaguid: YUBIKEY, + deviceType: 'singleDevice', + }); + + expect(verdict.allowed).toBe(false); + expect(verdict.detail).toContain('deny list'); + }); + + it('compares case insensitively', () => { + const verdict = evaluateAuthenticatorPolicy({ + policy: policy({ aaguidAllowList: [YUBIKEY.toUpperCase()] }), + aaguid: YUBIKEY, + deviceType: 'singleDevice', + }); + + expect(verdict.allowed).toBe(true); + }); +}); + +describe('allowListNeedsAttestation', () => { + it('flags an allow list set without asking authenticators to identify themselves', () => { + expect( + allowListNeedsAttestation(policy({ aaguidAllowList: [YUBIKEY], attestation: 'none' })), + ).toBe(true); + }); + + it('is satisfied once attestation is requested', () => { + expect( + allowListNeedsAttestation(policy({ aaguidAllowList: [YUBIKEY], attestation: 'direct' })), + ).toBe(false); + }); + + it('does not flag a deployment with no allow list', () => { + expect(allowListNeedsAttestation(policy({ attestation: 'none' }))).toBe(false); + }); +}); diff --git a/tests/unit/services/metadataServiceBootstrap.spec.ts b/tests/unit/services/metadataServiceBootstrap.spec.ts index 1a2b90c..9dc2eb5 100644 --- a/tests/unit/services/metadataServiceBootstrap.spec.ts +++ b/tests/unit/services/metadataServiceBootstrap.spec.ts @@ -20,6 +20,9 @@ function policy(overrides: Record = {}) { userVerification: 'required', attestation: 'none', requireKnownAuthenticator: false, + syncedPasskeys: 'block', + aaguidAllowList: [], + aaguidDenyList: [], ...overrides, }, }; @@ -78,3 +81,16 @@ describe('initializeMetadataService', () => { expect(metadataInitialize).not.toHaveBeenCalled(); }); }); + +describe('misconfiguration warning', () => { + it('says so when an allow list is set without asking for attestation', async () => { + (getSystemConfig as any).mockResolvedValue( + policy({ attestation: 'none', aaguidAllowList: ['ee882879-721c-4913-9775-3dfcce97072a'] }), + ); + + // Nothing can identify itself, so every registration would be refused. The + // point is that this is said once at boot rather than discovered one failed + // enrolment at a time. + expect(await initializeMetadataService()).toBe(false); + }); +});