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
37 changes: 37 additions & 0 deletions .changeset/eager-falcons-repeat.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
56 changes: 28 additions & 28 deletions docs/configuration.md

Large diffs are not rendered by default.

23 changes: 20 additions & 3 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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]$" },
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.13.0",
"@seamless-auth/types": "^0.14.0",
"@simplewebauthn/server": "^13.1.1",
"base64url": "^3.0.1",
"bcrypt-ts": "^7.1.0",
Expand Down
2 changes: 2 additions & 0 deletions src/config/systemConfig.defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const SYSTEM_CONFIG_DEFAULTS: Partial<SystemConfig> = {
authenticator_policy: {
attachment: 'any',
userVerification: 'required',
attestation: 'none',
requireKnownAuthenticator: false,
},
session_idle_ttl: '8h',
passkey_login_fallback_enabled: true,
Expand Down
10 changes: 8 additions & 2 deletions src/controllers/webauthn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 17 additions & 1 deletion src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6851,7 +6851,9 @@ export interface paths {
/**
* @default {
* "attachment": "any",
* "userVerification": "required"
* "userVerification": "required",
* "attestation": "none",
* "requireKnownAuthenticator": false
* }
*/
authenticator_policy: {
Expand All @@ -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 */
Expand Down Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions src/migrations/20260830120000-add-credential-attestation.cjs
Original file line number Diff line number Diff line change
@@ -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');
},
};
14 changes: 14 additions & 0 deletions src/models/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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, () => {
Expand Down
84 changes: 84 additions & 0 deletions src/services/metadataServiceBootstrap.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
}
Loading
Loading