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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/hungry-bats-shout.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 11 additions & 11 deletions docs/configuration.md

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions docs/security-posture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
39 changes: 36 additions & 3 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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]$" },
Expand Down Expand Up @@ -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]$" },
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/config/systemConfig.defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export const SYSTEM_CONFIG_DEFAULTS: Partial<SystemConfig> = {
userVerification: 'required',
attestation: 'none',
requireKnownAuthenticator: false,
syncedPasskeys: 'block',
aaguidAllowList: [],
aaguidDenyList: [],
},
session_idle_ttl: '8h',
passkey_login_fallback_enabled: true,
Expand Down
24 changes: 24 additions & 0 deletions src/controllers/webauthn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
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';
Expand Down Expand Up @@ -224,7 +225,7 @@
return res.status(403).json({ error: 'Not allowed' });
}

if (!verifiedUser.email || !attestationResponse) {

Check failure

Code scanning / CodeQL

User-controlled bypass of security check High

This condition guards a sensitive
action
, but a
user-provided value
controls it.
This condition guards a sensitive
action
, but a
user-provided value
controls it.
logger.warn('Missing verified user email or attestation response');
await AuthEventService.log({
userId: null,
Expand Down Expand Up @@ -306,6 +307,29 @@
}

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;
Expand Down
23 changes: 22 additions & 1 deletion src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6853,7 +6853,10 @@ export interface paths {
* "attachment": "any",
* "userVerification": "required",
* "attestation": "none",
* "requireKnownAuthenticator": false
* "requireKnownAuthenticator": false,
* "syncedPasskeys": "block",
* "aaguidAllowList": [],
* "aaguidDenyList": []
* }
*/
authenticator_policy: {
Expand All @@ -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 */
Expand Down Expand Up @@ -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;
Expand Down
97 changes: 97 additions & 0 deletions src/services/authenticatorPolicyService.ts
Original file line number Diff line number Diff line change
@@ -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';
}
Loading
Loading