From c6cb243a2a496ab79c2d6735d6ef16f111f2b13a Mon Sep 17 00:00:00 2001 From: Valerie Pomerleau Date: Fri, 6 Feb 2026 15:40:59 -0800 Subject: [PATCH 1/2] task(libs): Add passkeys data model Because: * We are adding WebAuthn/FIDO2 passkey support This commit: * Adds database migration creating passkeys table with composite primary key and proper indexing * Generates Kysely types (Passkey, NewPasskey, PasskeyUpdate) with type guards for validation * Implements 8 pure repository functions for CRUD operations following FxA patterns * Creates PasskeyFactory for generating test data and updates integration test infrastructure * Provides comprehensive field documentation in PASSKEY_FIELDS.md covering WebAuthn spec and usage Closes #FXA-12901 --- libs/accounts/passkey/PASSKEY_FIELDS.md | 370 ++++++++++++++++++ libs/accounts/passkey/README.md | 10 + libs/accounts/passkey/src/index.ts | 7 + .../src/lib/passkey.manager.in.spec.ts | 8 +- .../passkey/src/lib/passkey.repository.ts | 197 +++++++++- .../passkey/src/lib/passkey.service.ts | 37 +- .../accounts/passkey/src/lib/passkey.types.ts | 9 - libs/shared/db/mysql/account/src/index.ts | 1 + .../mysql/account/src/lib/associated-types.ts | 5 + .../db/mysql/account/src/lib/factories.ts | 28 ++ .../db/mysql/account/src/lib/kysely-types.ts | 16 + libs/shared/db/mysql/account/src/lib/tests.ts | 3 +- .../db/mysql/account/src/test/passkeys.sql | 17 + .../databases/fxa/patches/patch-184-185.sql | 28 ++ .../databases/fxa/patches/patch-185-184.sql | 7 + .../databases/fxa/target-patch.json | 2 +- 16 files changed, 717 insertions(+), 28 deletions(-) create mode 100644 libs/accounts/passkey/PASSKEY_FIELDS.md delete mode 100644 libs/accounts/passkey/src/lib/passkey.types.ts create mode 100644 libs/shared/db/mysql/account/src/test/passkeys.sql create mode 100644 packages/db-migrations/databases/fxa/patches/patch-184-185.sql create mode 100644 packages/db-migrations/databases/fxa/patches/patch-185-184.sql diff --git a/libs/accounts/passkey/PASSKEY_FIELDS.md b/libs/accounts/passkey/PASSKEY_FIELDS.md new file mode 100644 index 00000000000..e560e56d158 --- /dev/null +++ b/libs/accounts/passkey/PASSKEY_FIELDS.md @@ -0,0 +1,370 @@ +# Passkey Database Schema Reference + +This document provides detailed information about the passkey data model and field usage for Mozilla Firefox Accounts. + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [Core WebAuthn Fields](#core-webauthn-fields) +- [Metadata Fields](#metadata-fields) +- [User Management Fields](#user-management-fields) +- [Backup Flags (WebAuthn Level 3)](#backup-flags-webauthn-level-3) +- [Type Usage Examples](#type-usage-examples) + +## Quick Reference + +| Field | Type | Required | Description | +| -------------- | ------------------ | --------------------------- | ----------------------------------------- | +| uid | Buffer(16) | Yes | User ID (FK to accounts.uid) | +| credentialId | Buffer(1-1023) | Yes | WebAuthn credential ID | +| publicKey | Buffer | Yes | COSE-encoded public key | +| signCount | number | Yes | Signature counter (default 0) | +| transports | string \| null | No | JSON array of transport types | +| aaguid | Buffer(16) \| null | No | Authenticator AAGUID | +| name | string \| null | No | Friendly name (recommended auto-generate) | +| createdAt | number | Yes | Unix timestamp (ms) | +| lastUsedAt | number \| null | Field: Yes, Value: Nullable | Last auth timestamp (ms) | +| backupEligible | boolean\* | No (default: 0) | Can be backed up | +| backupState | boolean\* | No (default: 0) | Is currently backed up | +| prfEnabled | boolean\* | No (default: 0) | PRF extension enabled | + +\*Stored as TINYINT(1) in MySQL with DEFAULT 0, converted to boolean by Kysely + +## Core WebAuthn Fields + +### uid + +- **Type**: Buffer (16 bytes) +- **Required**: Yes +- **Description**: User identifier, foreign key to `accounts.uid` +- **Note**: FxA-specific field, not part of WebAuthn spec +- **Composite Primary Key**: Part of (uid, credentialId) + +### credentialId + +- **Type**: Buffer (1-1023 bytes) +- **Required**: Yes +- **Storage**: VARBINARY(1023) +- **Description**: WebAuthn credential ID, uniquely identifies this passkey +- **Spec**: [PublicKeyCredentialDescriptor.id](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialdescriptor-id) +- **Max Length**: 1023 bytes per [WebAuthn specification](https://www.w3.org/TR/webauthn-3/#credential-id) +- **Typical Length**: 16-64 bytes for most authenticators +- **Real-world Example**: SoloKey v2 generates 270-byte credentials +- **Index**: Unique index with 255-byte prefix for efficient lookups +- **Composite Primary Key**: Part of (uid, credentialId) + +### publicKey + +- **Type**: Buffer (variable length) +- **Required**: Yes +- **Storage**: BLOB +- **Description**: COSE-encoded public key for credential verification +- **Spec**: [Credential Public Key](https://www.w3.org/TR/webauthn-3/#credential-public-key) +- **Format**: CBOR Object Signing and Encryption (COSE) key format per [RFC 8152](https://www.rfc-editor.org/rfc/rfc8152.html) +- **Encoding**: Stored as-is from authenticator's [attestedCredentialData](https://www.w3.org/TR/webauthn-3/#attested-credential-data) +- **Usage**: Used to verify authentication signatures from authenticator + +### signCount + +- **Type**: number (unsigned 32-bit integer) +- **Required**: Yes +- **Default**: 0 +- **Description**: Signature counter for replay attack protection +- **Spec**: [Signature Counter](https://www.w3.org/TR/webauthn-3/#signature-counter) +- **Behavior**: + - Increments on each authentication per [§6.1.2](https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data) + - Some authenticators always return 0 (batch attestation, per spec) + - **Rollback Detection**: PasskeyService validates new signCount >= old signCount + - If rollback detected (new < old when old > 0): Log security warning (potential cloning) + - Authenticators using 0 (batch attestation) are allowed + - Logs event: `passkey.signCount.rollback` for security monitoring +- **Update Pattern**: Update on successful authentication only (never on failure) + +## Metadata Fields + +### transports + +- **Type**: string | null +- **Storage**: VARCHAR(255) +- **Description**: JSON-encoded array of authenticator transport methods +- **Spec**: [AuthenticatorTransport enum](https://www.w3.org/TR/webauthn-3/#enum-transport) +- **Source**: From [PublicKeyCredentialDescriptor.transports](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialdescriptor-transports) +- **Validation**: Provided by WebAuthn library (e.g., @simplewebauthn/server) which validates spec compliance +- **Valid Values**: + - `"internal"` - Platform authenticator (Touch ID, Windows Hello) + - `"usb"` - USB security key + - `"nfc"` - NFC-enabled authenticator + - `"ble"` - Bluetooth Low Energy + - `"hybrid"` - Cloud-assisted BLE (formerly caBLE), per [CTAP 2.2](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html) + - `"smart-card"` - Smart card authenticator +- **Example**: `'["internal","hybrid"]'` for iCloud Keychain +- **Usage**: Helps UI show appropriate icons and authentication prompts +- **Storage Format**: JSON array as string for efficient querying + +### aaguid + +- **Type**: Buffer(16) | null +- **Storage**: BINARY(16) +- **Description**: Authenticator Attestation GUID +- **Spec**: [AAGUID](https://www.w3.org/TR/webauthn-3/#aaguid) +- **Source**: From authenticator's [attestedCredentialData](https://www.w3.org/TR/webauthn-3/#attested-credential-data) +- **Purpose**: Identifies the authenticator model/type per [WebAuthn Authenticator Model](https://www.w3.org/TR/webauthn-3/#sctn-authenticator-model) +- **Format**: 128-bit UUID (RFC 4122) + +**Important - All-Zeros AAGUID:** + +- The AAGUID field is **always present** in authenticator data (16-byte fixed structure) +- Many authenticators return `00000000-0000-0000-0000-000000000000` for **privacy reasons**: + - Software authenticators (browser built-in passkey managers) + - Privacy-focused hardware keys + - Platform authenticators (depending on policy) +- **Normalization**: The PasskeyService normalizes all-zeros AAGUID to `NULL` before storage + - WebAuthn libraries return the raw AAGUID buffer from authenticator data + - Service layer converts all-zeros to NULL (no meaningful identifier) + - Only actual AAGUID values that identify specific authenticator models are stored + +**When to expect meaningful AAGUIDs:** + +- Hardware security keys (YubiKey, Titan Key, Feitian) +- Some platform authenticators (Windows Hello, Touch ID on certain configurations) +- Enterprise authenticators + +- **Usage**: + - Track which authenticator models are in use + - Apply vendor-specific quirks if needed + - Analytics on authenticator adoption + - Security policies (e.g., allow/deny specific authenticator models) + - Auto-generate passkey names via FIDO MDS lookup (when not all-zeros) +- **Example**: YubiKey 5 has AAGUID `2fc0579f-8113-47ea-b116-bb5a8db9202a`, Windows Hello uses `08987058-cadc-4b81-b6e1-30de50dcbe96` +- **Registry**: [FIDO Alliance Metadata Service](https://fidoalliance.org/metadata/) provides AAGUID registry + +## User Management Fields + +### name + +- **Type**: string | null +- **Storage**: VARCHAR(255) +- **Description**: User-friendly name for the passkey +- **Note**: FxA-specific field for UX, not part of WebAuthn spec +- **Examples**: + - "Touch ID" (auto-generated from platform) + - "YubiKey 5" (auto-generated from AAGUID) + - "iPhone 15" (user-customized) + - "Work Security Key" (user-customized) + +**Recommended Pattern - Auto-generate on Registration:** + +Generate a descriptive default name using available metadata: + +1. **Use AAGUID** → Query [FIDO Metadata Service](https://fidoalliance.org/metadata/) for authenticator description + - AAGUID `2fc0579f-8113-47ea-b116-bb5a8db9202a` → "YubiKey 5 Series" + - AAGUID `08987058-cadc-4b81-b6e1-30de50dcbe96` → "Windows Hello" + +2. **Use Transport** as fallback: + - `["internal"]` → "Touch ID" or "Face ID" (if platform known) + - `["internal","hybrid"]` → "iCloud Keychain" + - `["usb"]` → "USB Security Key" + - `["nfc"]` → "NFC Security Key" + - `["ble"]` → "Bluetooth Security Key" + +3. **Combine with Device Info** (from User-Agent): + - "MacBook Touch ID" + - "iPhone Face ID" + - "YubiKey 5 (Work Laptop)" + +**Best Practices:** + +- Always set a default name on registration (never leave NULL) +- Allow users to rename credentials later +- Include date/device info for multiple credentials of same type +- Example: "Touch ID (MacBook Pro, Jan 2025)" + +### createdAt + +- **Type**: number (unsigned 64-bit integer) +- **Required**: Yes +- **Storage**: BIGINT UNSIGNED +- **Description**: Unix timestamp in milliseconds when credential was registered +- **Set**: Once at registration, never updated +- **Usage**: + - Display credential age + - Sort credentials by creation date + - Audit trail + +### lastUsedAt + +- **Type**: number | null (unsigned 64-bit integer) +- **Field Required**: Yes (column must exist in database) +- **Value Required**: No (can be NULL) +- **Storage**: BIGINT UNSIGNED NULL +- **Description**: Unix timestamp in milliseconds of last successful authentication +- **Values**: + - `NULL` - Credential has never been used for authentication (only registered) + - `> 0` - Timestamp of last successful authentication +- **Update Pattern**: + - Set to NULL on registration + - Update on successful authentication only + - Do NOT update on failed authentication +- **Usage**: + - Identify stale credentials (e.g., unused for 90+ days) + - Display "Last used" information to users + - Prompt users to remove old credentials + +## Backup Flags (WebAuthn Level 3) + +### Overview + +These flags are part of the authenticator data received during WebAuthn registration and authentication. They indicate whether credentials are backed up/synced by the authenticator platform (e.g., iCloud Keychain, Google Password Manager). + +**Spec References**: + +- [Authenticator Data Flags](https://www.w3.org/TR/webauthn-3/#flags) +- [§6.1.2 Authenticator Data](https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data) +- [Backup Eligibility (BE) flag - bit 3](https://www.w3.org/TR/webauthn-3/#concept-be-flag) +- [Backup State (BS) flag - bit 4](https://www.w3.org/TR/webauthn-3/#concept-bs-flag) + +**Important**: Mozilla FxA is a **Relying Party** - we receive these flags from authenticators but do NOT control the actual syncing. The syncing is handled by: + +- Apple (iCloud Keychain) +- Google (Password Manager) +- Microsoft (Windows Hello with sync) +- Third-party password managers (1Password, Dashlane, etc.) + +### backupEligible + +- **Type**: boolean (stored as TINYINT(1), 0=false, 1=true) +- **Required**: No (defaults to 0/false if omitted) +- **Default**: 0 (false) +- **WebAuthn**: Backup Eligible (BE) flag from authenticator data +- **Spec**: [BE flag (bit 3)](https://www.w3.org/TR/webauthn-3/#concept-be-flag) in authenticator data flags +- **Set**: Once at registration, does not change +- **Meaning**: This credential **CAN** be backed up/synced by the authenticator + +**Examples:** + +| Authenticator | backupEligible | Reason | +| ----------------------- | -------------- | --------------------------- | +| iCloud Keychain | `true` | Syncs across Apple devices | +| Google Password Manager | `true` | Syncs across Chrome/Android | +| 1Password | `true` | Third-party sync | +| YubiKey | `false` | Hardware-bound, cannot sync | +| Windows Hello (no sync) | `false` | Device-bound | + +**Use Cases:** + +1. **User Experience**: Show badge "Synced across devices" vs "This device only" +2. **Account Recovery**: Warn users who only have device-bound credentials +3. **Analytics**: Track adoption of synced vs device-bound passkeys +4. **Security Policy**: Some high-security flows might prefer device-bound + +### backupState + +- **Type**: boolean (stored as TINYINT(1), 0=false, 1=true) +- **Required**: No (defaults to 0/false if omitted) +- **Default**: 0 (false) +- **WebAuthn**: Backup State (BS) flag from authenticator data +- **Spec**: [BS flag (bit 4)](https://www.w3.org/TR/webauthn-3/#concept-bs-flag) in authenticator data flags +- **Set**: At registration, **updated on every authentication** +- **Meaning**: This credential **IS** currently backed up +- **Can Change**: Yes, if user enables/disables sync in their authenticator + +**State Transitions:** + +``` +User enables iCloud Keychain sync: + backupEligible=true, backupState=false → backupState=true + +User disables sync: + backupEligible=true, backupState=true → backupState=false + +Device-bound key (always): + backupEligible=false, backupState=false (never changes) +``` + +**Use Cases:** + +1. **User Experience**: Show current sync status icon (synced, not synced, device-bound) +2. **Risk Assessment**: Know if credential will survive device loss +3. **Security Policy**: Require device-bound credentials for sensitive operations +4. **Analytics**: Track how many users have sync enabled + +**Important**: `backupState` is only meaningful when `backupEligible=true`. If `backupEligible=false`, `backupState` will always be `false`. + +### prfEnabled + +- **Type**: boolean (stored as TINYINT(1), 0=false, 1=true) +- **Required**: No (defaults to 0/false if omitted) +- **Default**: 0 (false) +- **WebAuthn**: Indicates PRF (Pseudo-Random Function) extension is enabled +- **Spec**: [PRF Extension](https://w3c.github.io/webauthn/#prf-extension) +- **Set**: Once at registration based on extension support +- **Meaning**: This credential can generate deterministic pseudo-random values + +**What is PRF?** + +The PRF (Pseudo-Random Function) extension allows authenticators to derive deterministic cryptographic outputs from a passkey. Whether or not the passkey is PRF-enabled is stored to determine if the passkey can later be used to wrap account keys. + +**Storage Pattern:** + +- Set `prfEnabled=1` if authenticator returned PRF extension output during registration +- Use during authentication to derive encryption keys on-the-fly +- Never store the PRF output itself (regenerate on each auth) + +## Storage Details + +### Type Conversion (Kysely ColumnType) + +Backup flags use `Generated>`: + +- **SELECT**: Returns `boolean` (true/false) - always present +- **INSERT**: Accepts `number | undefined` (0, 1, or omit for default 0) - optional +- **UPDATE**: Accepts `number | undefined` (0, 1, or omit to keep current) - optional + +The `Generated<...>` wrapper makes these fields optional on INSERT/UPDATE (matching the database DEFAULT 0), while ensuring they're always present as booleans on SELECT. + +This provides ergonomic boolean usage in application code while storing efficiently in MySQL. + +### Indexes + +1. **Primary Key**: `(uid, credentialId)` - Composite key +2. **Unique Index**: `idx_credentialId` on `credentialId` - Full VARBINARY index (MySQL 8.0.13+) + +Note: No additional indexes needed. The number of passkeys per user is constrained (typically < 10), so queries filtering by uid are fast enough without additional indexes. + +### Foreign Keys + +- `uid` references `accounts(uid)` with `ON DELETE CASCADE` +- When an account is deleted, all associated passkeys are automatically removed + +## Implementation Resources + +### FIDO Metadata Service + +For auto-generating friendly authenticator names from AAGUIDs: + +- **Service**: [FIDO Alliance Metadata Service (MDS)](https://fidoalliance.org/metadata/) +- **Spec**: [FIDO Metadata Service v3.0](https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html) +- **Endpoint**: `https://mds3.fidoalliance.org/` +- **Libraries**: + - [@simplewebauthn/server](https://www.npmjs.com/package/@simplewebauthn/server) includes MDS support + - [@hexagon/webauthn-metadata](https://www.npmjs.com/package/@hexagon/webauthn-metadata) + +The MDS provides: + +- Authenticator descriptions (e.g., "YubiKey 5 Series") +- Icon URLs +- Supported features and capabilities +- Security certifications + +### Transport Detection + +For generating fallback names when AAGUID is unavailable: + +- Check `transports` array in credential descriptor +- Use User-Agent to detect platform (iOS/macOS/Windows/Android) +- Combine with device model if available from UA parsing + +## Migration References + +- **Forward Migration**: `packages/db-migrations/databases/fxa/patches/patch-182-183.sql` +- **Rollback Migration**: `packages/db-migrations/databases/fxa/patches/patch-183-182.sql` diff --git a/libs/accounts/passkey/README.md b/libs/accounts/passkey/README.md index 3e941df9884..2e1ed0c99d6 100644 --- a/libs/accounts/passkey/README.md +++ b/libs/accounts/passkey/README.md @@ -63,6 +63,15 @@ Unlike `libs/shared/nestjs/*`, this library **does not export a NestJS module**. This pattern gives consuming applications full control over DI setup. +## Database Schema + +See [PASSKEY_FIELDS.md](./PASSKEY_FIELDS.md) for complete field documentation including: + +- Field types and constraints +- WebAuthn backup flags (BE/BS) +- Type conversion details (ColumnType) +- Usage examples and patterns + ## WebAuthn / Passkey Background Passkeys are a WebAuthn-based authentication method that replaces passwords: @@ -84,6 +93,7 @@ Key WebAuthn concepts: - [WebAuthn Spec](https://www.w3.org/TR/webauthn-3/) - [Passkey Developer Guide](https://passkeys.dev/) +- [Field Documentation](./PASSKEY_FIELDS.md) (detailed schema reference) ## Error Handling diff --git a/libs/accounts/passkey/src/index.ts b/libs/accounts/passkey/src/index.ts index 07ef88e6d4f..d05ffd19b0b 100644 --- a/libs/accounts/passkey/src/index.ts +++ b/libs/accounts/passkey/src/index.ts @@ -9,12 +9,19 @@ * Usage: * - PasskeyService: High-level business logic for passkey operations * - PasskeyManager: Database access layer for passkey storage + * - Repository functions: Pure data access functions (findPasskeysByUid, etc.) * - PasskeyError: Base error class for passkey-specific errors * - PasskeyConfig: Configuration class * + * Types (import directly from shared): + * ```typescript + * import { Passkey, NewPasskey, PasskeyUpdate } from '@fxa/shared/db/mysql/account'; + * ``` + * * @packageDocumentation */ export * from './lib/passkey.service'; export * from './lib/passkey.manager'; +export * from './lib/passkey.repository'; export * from './lib/passkey.errors'; export * from './lib/passkey.config'; diff --git a/libs/accounts/passkey/src/lib/passkey.manager.in.spec.ts b/libs/accounts/passkey/src/lib/passkey.manager.in.spec.ts index 0f7e0be8420..1dc08d0f45a 100644 --- a/libs/accounts/passkey/src/lib/passkey.manager.in.spec.ts +++ b/libs/accounts/passkey/src/lib/passkey.manager.in.spec.ts @@ -16,9 +16,8 @@ describe('PasskeyManager (Integration)', () => { beforeAll(async () => { // Set up real database connection for integration tests - // TODO: Add 'passkeys' table to the setup array once the table schema is created try { - db = await testAccountDatabaseSetup(['accounts']); + db = await testAccountDatabaseSetup(['accounts', 'passkeys']); const moduleRef = await Test.createTestingModule({ providers: [ @@ -48,10 +47,7 @@ describe('PasskeyManager (Integration)', () => { } }); - // TODO: Add actual integration tests once: - // 1. Passkey database schema is defined - // 2. PasskeyManager methods are implemented - // 3. Test data factories are created + // TODO: Add actual integration tests once PasskeyManager methods are implemented it('should be defined', () => { expect(manager).toBeDefined(); }); diff --git a/libs/accounts/passkey/src/lib/passkey.repository.ts b/libs/accounts/passkey/src/lib/passkey.repository.ts index b1be644c75c..4f919ba0007 100644 --- a/libs/accounts/passkey/src/lib/passkey.repository.ts +++ b/libs/accounts/passkey/src/lib/passkey.repository.ts @@ -5,13 +5,192 @@ /** * Pure data access functions for passkey storage using Kysely query builder. * - * TODO: Implement repository functions once database migration is applied in FXA-12901. - * Expected functions: - * - findPasskeysByUid(db, uid) - * - findPasskeyByCredentialId(db, credentialId) - * - insertPasskey(db, passkey) - * - updatePasskeyCounterAndLastUsed(db, credentialId, signCount) - * - deletePasskey(db, uid, credentialId) - * - deleteAllPasskeysForUser(db, uid) - * - countPasskeysByUid(db, uid) + * These are pure functions that take the database instance as the first parameter, + * following the functional repository pattern used throughout FxA. + * + * Note: On successful authentication, update: + * - lastUsedAt: Current timestamp + * - signCount: Value from authenticator data (should increment) + * - backupState: Current backup state flag (BS) from authenticator data (0 or 1) + * + * On registration: + * - Set backupEligible from authenticator data (BE flag) - 0 or 1 + * - Set backupState from authenticator data (BS flag) - 0 or 1 + * - Leave lastUsedAt as NULL (never used for authentication) + * + * On failed authentication, do not update anything. + * + * Type conversion: Backup flags are stored as TINYINT(1) in MySQL (0 or 1). + * - For INSERT/UPDATE: Pass numbers (0 or 1) + * - For SELECT: Kysely returns booleans (false or true) + */ + +import type { + AccountDatabase, + Passkey, + NewPasskey, +} from '@fxa/shared/db/mysql/account'; + +/** + * Find all passkeys for a given user. + * + * Note: Results are not ordered. The number of passkeys per user will be + * constrained (typically < 10), so ordering is not necessary and clients + * can sort as needed. + * + * @param db - Database instance + * @param uid - User ID (16-byte Buffer) + * @returns Array of passkeys for the user (unordered) + */ +export async function findPasskeysByUid( + db: AccountDatabase, + uid: Buffer +): Promise { + return await db + .selectFrom('passkeys') + .selectAll() + .where('uid', '=', uid) + .execute(); +} + +/** + * Find a passkey by its credential ID. + * + * @param db - Database instance + * @param credentialId - WebAuthn credential ID (Buffer) + * @returns Passkey if found, undefined otherwise */ +export async function findPasskeyByCredentialId( + db: AccountDatabase, + credentialId: Buffer +): Promise { + return await db + .selectFrom('passkeys') + .selectAll() + .where('credentialId', '=', credentialId) + .executeTakeFirst(); +} + +/** + * Insert a new passkey record. + * + * @param db - Database instance + * @param passkey - New passkey data to insert + */ +export async function insertPasskey( + db: AccountDatabase, + passkey: NewPasskey +): Promise { + await db.insertInto('passkeys').values(passkey).execute(); +} + +/** + * Update passkey metadata after successful authentication. + * + * Updates lastUsedAt, signCount, and backupState for a passkey. + * Should only be called after successful authentication. + * + * @param db - Database instance + * @param credentialId - WebAuthn credential ID (Buffer) + * @param signCount - New signature count from authenticator data + * @param backupState - Current backup state flag (0 or 1) from authenticator data + */ +export async function updatePasskeyCounterAndLastUsed( + db: AccountDatabase, + credentialId: Buffer, + signCount: number, + backupState: number +): Promise { + await db + .updateTable('passkeys') + .set({ + lastUsedAt: Date.now(), + signCount: signCount, + backupState: backupState, + }) + .where('credentialId', '=', credentialId) + .execute(); +} + +/** + * Update the friendly name for a passkey. + * + * @param db - Database instance + * @param credentialId - WebAuthn credential ID (Buffer) + * @param name - New friendly name for the passkey + * @returns Number of rows updated (should be 1 if successful) + */ +export async function updatePasskeyName( + db: AccountDatabase, + credentialId: Buffer, + name: string +): Promise { + const result = await db + .updateTable('passkeys') + .set({ name }) + .where('credentialId', '=', credentialId) + .execute(); + + return result.length; +} + +/** + * Delete a specific passkey for a user. + * + * @param db - Database instance + * @param uid - User ID (16-byte Buffer) + * @param credentialId - WebAuthn credential ID (Buffer) + * @returns true if a passkey was deleted, false otherwise + */ +export async function deletePasskey( + db: AccountDatabase, + uid: Buffer, + credentialId: Buffer +): Promise { + const result = await db + .deleteFrom('passkeys') + .where('uid', '=', uid) + .where('credentialId', '=', credentialId) + .executeTakeFirst(); + + return result.numDeletedRows === BigInt(1); +} + +/** + * Delete all passkeys for a user. + * + * @param db - Database instance + * @param uid - User ID (16-byte Buffer) + * @returns Number of passkeys deleted + */ +export async function deleteAllPasskeysForUser( + db: AccountDatabase, + uid: Buffer +): Promise { + const result = await db + .deleteFrom('passkeys') + .where('uid', '=', uid) + .executeTakeFirst(); + + return Number(result.numDeletedRows); +} + +/** + * Count the number of passkeys for a user. + * + * @param db - Database instance + * @param uid - User ID (16-byte Buffer) + * @returns Number of passkeys for the user + */ +export async function countPasskeysByUid( + db: AccountDatabase, + uid: Buffer +): Promise { + const result = await db + .selectFrom('passkeys') + .select(db.fn.count('credentialId').as('count')) + .where('uid', '=', uid) + .executeTakeFirst(); + + return Number(result?.count ?? 0); +} diff --git a/libs/accounts/passkey/src/lib/passkey.service.ts b/libs/accounts/passkey/src/lib/passkey.service.ts index 8190ca6a670..472f9e3b1ea 100644 --- a/libs/accounts/passkey/src/lib/passkey.service.ts +++ b/libs/accounts/passkey/src/lib/passkey.service.ts @@ -16,6 +16,25 @@ import { PasskeyManager } from './passkey.manager'; * - Passkey management (list, rename, delete) * - Challenge generation and verification * + * ## WebAuthn Library Integration + * + * This service will use a WebAuthn library (e.g., @simplewebauthn/server) for: + * - Challenge generation and validation + * - Cryptographic signature verification + * - CBOR/COSE parsing (publicKey, credentialId, authenticator data) + * - Extracting: signCount, transports, aaguid, backup flags (BE/BS) + * + * The library handles WebAuthn spec compliance and crypto operations. + * This service translates between WebAuthn responses and our database model. + * + * ## Data Normalization + * + * When storing passkey data from WebAuthn library responses: + * - **AAGUID**: Normalize all-zeros (00000000-0000-0000-0000-000000000000) to NULL + * Many authenticators return all-zeros for privacy. Store NULL when meaningless. + * - **transports**: Trust library-provided JSON array string (validated by library) + * - **backupEligible/backupState**: Extract from authenticator data flags (BE/BS bits) + * */ @Injectable() export class PasskeyService { @@ -27,10 +46,24 @@ export class PasskeyService { // TODO: Add methods for passkey operations such as: // - generateRegistrationChallenge - // - verifyRegistrationResponse + // - verifyRegistrationResponse (normalize AAGUID here before storing) // - generateAuthenticationChallenge - // - verifyAuthenticationResponse + // - verifyAuthenticationResponse (extract backup state, signCount, validate rollback) // - listPasskeysForUser // - renamePasskey // - deletePasskey + // + // TODO: Add normalizeAaguid() helper: + // function normalizeAaguid(aaguid: Buffer | null | undefined): Buffer | null { + // if (!aaguid || aaguid.length !== 16) return null; + // if (aaguid.every(byte => byte === 0)) return null; + // return aaguid; + // } + // + // TODO: Add signCount rollback detection in verifyAuthenticationResponse(): + // - Fetch existing passkey with current signCount + // - Compare new signCount from authenticator response + // - If new < old AND old > 0: Log security warning (potential cloning attack) + // - Allow authenticators that always return 0 (batch attestation per spec) + // - Log event: 'passkey.signCount.rollback' with uid, credentialId, oldCount, newCount } diff --git a/libs/accounts/passkey/src/lib/passkey.types.ts b/libs/accounts/passkey/src/lib/passkey.types.ts deleted file mode 100644 index 025adfedd46..00000000000 --- a/libs/accounts/passkey/src/lib/passkey.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -/** - * Type definitions for Passkey entities. - * - * TODO: Define types matching the passkeys database table schema once migration is created. - */ diff --git a/libs/shared/db/mysql/account/src/index.ts b/libs/shared/db/mysql/account/src/index.ts index e8a85f5135b..9b3ea79fb22 100644 --- a/libs/shared/db/mysql/account/src/index.ts +++ b/libs/shared/db/mysql/account/src/index.ts @@ -9,6 +9,7 @@ export { AccountFactory, AccountCustomerFactory, PaypalCustomerFactory, + PasskeyFactory, RecoveryPhoneFactory, } from './lib/factories'; export { setupAccountDatabase, AccountDbProvider } from './lib/setup'; diff --git a/libs/shared/db/mysql/account/src/lib/associated-types.ts b/libs/shared/db/mysql/account/src/lib/associated-types.ts index b30afb2c642..3037c6e4075 100644 --- a/libs/shared/db/mysql/account/src/lib/associated-types.ts +++ b/libs/shared/db/mysql/account/src/lib/associated-types.ts @@ -10,6 +10,7 @@ import { AccountCustomers, Accounts, Carts, + Passkeys, PaypalCustomers, SessionTokens, UnverifiedTokens, @@ -43,3 +44,7 @@ export type CartUpdate = Updateable; export type RecoveryPhone = Selectable; export type NewRecoveryPhone = Insertable; export type RecoveryPhoneUpdate = Updateable; + +export type Passkey = Selectable; +export type NewPasskey = Insertable; +export type PasskeyUpdate = Updateable; diff --git a/libs/shared/db/mysql/account/src/lib/factories.ts b/libs/shared/db/mysql/account/src/lib/factories.ts index ec71767e560..7620a7ee079 100644 --- a/libs/shared/db/mysql/account/src/lib/factories.ts +++ b/libs/shared/db/mysql/account/src/lib/factories.ts @@ -8,6 +8,7 @@ import { Account, AccountCustomer, NewCart, + NewPasskey, PaypalCustomer, SessionToken, UnverifiedToken, @@ -182,3 +183,30 @@ export const RecoveryPhoneFactory = (override?: Partial) => ({ }), ...override, }); + +export const PasskeyFactory = (override?: Partial): NewPasskey => ({ + uid: getHexBuffer(32), + credentialId: getHexBuffer(faker.number.int({ min: 32, max: 128 })), + publicKey: getHexBuffer(128), + signCount: 0, + transports: faker.helpers.arrayElement([ + '["internal"]', + '["usb"]', + '["internal","hybrid"]', + null, + ]), + aaguid: faker.datatype.boolean() ? getHexBuffer(32) : null, + name: faker.helpers.arrayElement([ + 'Touch ID', + 'YubiKey 5', + 'Security Key', + 'iPhone Face ID', + null, + ]), + createdAt: faker.date.recent().getTime(), + lastUsedAt: faker.datatype.boolean() ? faker.date.recent().getTime() : null, + backupEligible: faker.helpers.arrayElement([0, 1]), + backupState: faker.helpers.arrayElement([0, 1]), + prfEnabled: faker.helpers.arrayElement([0, 1]), + ...override, +}); diff --git a/libs/shared/db/mysql/account/src/lib/kysely-types.ts b/libs/shared/db/mysql/account/src/lib/kysely-types.ts index 1921f6a38f7..9bf3d2f5cec 100644 --- a/libs/shared/db/mysql/account/src/lib/kysely-types.ts +++ b/libs/shared/db/mysql/account/src/lib/kysely-types.ts @@ -243,6 +243,21 @@ export interface RecoveryPhones { uid: Buffer; } +export interface Passkeys { + uid: Buffer; + credentialId: Buffer; + publicKey: Buffer; + signCount: number; + transports: string | null; + aaguid: Buffer | null; + name: string | null; + createdAt: number; + lastUsedAt: number | null; + backupEligible: Generated>; + backupState: Generated>; + prfEnabled: Generated>; +} + export interface SecurityEventNames { id: Generated; name: string; @@ -336,6 +351,7 @@ export interface DB { emailTypes: EmailTypes; keyFetchTokens: KeyFetchTokens; linkedAccounts: LinkedAccounts; + passkeys: Passkeys; passwordChangeTokens: PasswordChangeTokens; passwordForgotTokens: PasswordForgotTokens; paypalCustomers: PaypalCustomers; diff --git a/libs/shared/db/mysql/account/src/lib/tests.ts b/libs/shared/db/mysql/account/src/lib/tests.ts index a4f5b9aeeec..9c63fbc593e 100644 --- a/libs/shared/db/mysql/account/src/lib/tests.ts +++ b/libs/shared/db/mysql/account/src/lib/tests.ts @@ -17,7 +17,8 @@ export type ACCOUNT_TABLES = | 'carts' | 'recoveryCodes' | 'recoveryPhones' - | 'emails'; + | 'emails' + | 'passkeys'; export async function testAccountDatabaseSetup( tables: ACCOUNT_TABLES[] diff --git a/libs/shared/db/mysql/account/src/test/passkeys.sql b/libs/shared/db/mysql/account/src/test/passkeys.sql new file mode 100644 index 00000000000..79fbf86b55f --- /dev/null +++ b/libs/shared/db/mysql/account/src/test/passkeys.sql @@ -0,0 +1,17 @@ +CREATE TABLE `passkeys` ( + `uid` binary(16) NOT NULL, + `credentialId` varbinary(1023) NOT NULL, + `publicKey` blob NOT NULL, + `signCount` int unsigned NOT NULL DEFAULT '0', + `transports` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `aaguid` binary(16) DEFAULT NULL, + `name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `createdAt` bigint(20) unsigned NOT NULL, + `lastUsedAt` bigint(20) unsigned DEFAULT NULL, + `backupEligible` tinyint(1) NOT NULL DEFAULT '0', + `backupState` tinyint(1) NOT NULL DEFAULT '0', + `prfEnabled` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`uid`,`credentialId`), + UNIQUE KEY `idx_credentialId` (`credentialId`), + CONSTRAINT `passkeys_ibfk_1` FOREIGN KEY (`uid`) REFERENCES `accounts` (`uid`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/packages/db-migrations/databases/fxa/patches/patch-184-185.sql b/packages/db-migrations/databases/fxa/patches/patch-184-185.sql new file mode 100644 index 00000000000..3f48176c0fa --- /dev/null +++ b/packages/db-migrations/databases/fxa/patches/patch-184-185.sql @@ -0,0 +1,28 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_bin; + +CALL assertPatchLevel('184'); + +-- Create the 'passkeys' table for WebAuthn/FIDO2 passkey credentials. +-- Each passkey is uniquely identified by credentialId and associated with a user (uid). +-- The table stores the public key, signature counter, and metadata for each passkey. +CREATE TABLE passkeys ( + uid BINARY(16) NOT NULL, + -- WebAuthn spec defines maximum credential ID length as 1023 bytes + -- https://www.w3.org/TR/webauthn-3/#credential-id + credentialId VARBINARY(1023) NOT NULL, + publicKey BLOB NOT NULL, + signCount INT UNSIGNED NOT NULL DEFAULT 0, + transports VARCHAR(255), + aaguid BINARY(16), + name VARCHAR(255), + createdAt BIGINT UNSIGNED NOT NULL, + lastUsedAt BIGINT UNSIGNED NULL, + backupEligible TINYINT(1) NOT NULL DEFAULT 0, + backupState TINYINT(1) NOT NULL DEFAULT 0, + prfEnabled TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (uid, credentialId), + UNIQUE KEY idx_credentialId (credentialId), + FOREIGN KEY (uid) REFERENCES accounts(uid) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +UPDATE dbMetadata SET value = '185' WHERE name = 'schema-patch-level'; diff --git a/packages/db-migrations/databases/fxa/patches/patch-185-184.sql b/packages/db-migrations/databases/fxa/patches/patch-185-184.sql new file mode 100644 index 00000000000..b444d584078 --- /dev/null +++ b/packages/db-migrations/databases/fxa/patches/patch-185-184.sql @@ -0,0 +1,7 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_bin; + +CALL assertPatchLevel('185'); + +DROP TABLE `passkeys`; + +UPDATE dbMetadata SET value = '184' WHERE name = 'schema-patch-level'; diff --git a/packages/db-migrations/databases/fxa/target-patch.json b/packages/db-migrations/databases/fxa/target-patch.json index 6096eb615a9..ed079af43ec 100644 --- a/packages/db-migrations/databases/fxa/target-patch.json +++ b/packages/db-migrations/databases/fxa/target-patch.json @@ -1,3 +1,3 @@ { - "level": 184 + "level": 185 } From 512c441783955f722431349f94fc3f71ed37e3d7 Mon Sep 17 00:00:00 2001 From: Valerie Pomerleau Date: Wed, 11 Feb 2026 12:25:20 -0800 Subject: [PATCH 2/2] address PR review comments --- libs/accounts/passkey/PASSKEY_FIELDS.md | 119 ++++++++----- .../src/lib/passkey.repository.in.spec.ts | 156 ++++++++++++++++++ .../passkey/src/lib/passkey.repository.ts | 13 +- .../passkey/src/lib/passkey.service.ts | 22 ++- .../db/mysql/account/src/lib/factories.ts | 14 +- .../db/mysql/account/src/lib/kysely-types.ts | 6 +- .../databases/fxa/patches/patch-184-185.sql | 47 +++++- .../databases/fxa/patches/patch-185-184.sql | 8 +- 8 files changed, 309 insertions(+), 76 deletions(-) create mode 100644 libs/accounts/passkey/src/lib/passkey.repository.in.spec.ts diff --git a/libs/accounts/passkey/PASSKEY_FIELDS.md b/libs/accounts/passkey/PASSKEY_FIELDS.md index e560e56d158..066ca00fdfc 100644 --- a/libs/accounts/passkey/PASSKEY_FIELDS.md +++ b/libs/accounts/passkey/PASSKEY_FIELDS.md @@ -13,20 +13,20 @@ This document provides detailed information about the passkey data model and fie ## Quick Reference -| Field | Type | Required | Description | -| -------------- | ------------------ | --------------------------- | ----------------------------------------- | -| uid | Buffer(16) | Yes | User ID (FK to accounts.uid) | -| credentialId | Buffer(1-1023) | Yes | WebAuthn credential ID | -| publicKey | Buffer | Yes | COSE-encoded public key | -| signCount | number | Yes | Signature counter (default 0) | -| transports | string \| null | No | JSON array of transport types | -| aaguid | Buffer(16) \| null | No | Authenticator AAGUID | -| name | string \| null | No | Friendly name (recommended auto-generate) | -| createdAt | number | Yes | Unix timestamp (ms) | -| lastUsedAt | number \| null | Field: Yes, Value: Nullable | Last auth timestamp (ms) | -| backupEligible | boolean\* | No (default: 0) | Can be backed up | -| backupState | boolean\* | No (default: 0) | Is currently backed up | -| prfEnabled | boolean\* | No (default: 0) | PRF extension enabled | +| Field | Type | Required | Description | +| -------------- | -------------- | --------------------------- | --------------------------------------- | +| uid | Buffer(16) | Yes | User ID (FK to accounts.uid) | +| credentialId | Buffer(1-1023) | Yes | WebAuthn credential ID | +| publicKey | Buffer | Yes | COSE-encoded public key | +| signCount | number | Yes | Signature counter (default 0) | +| transports | string[] | Yes | Array of transport types (JSON in DB) | +| aaguid | Buffer(16) | Yes | Authenticator AAGUID (may be all-zeros) | +| name | string | Yes | Friendly name (auto-generate required) | +| createdAt | number | Yes | Unix timestamp (ms) | +| lastUsedAt | number \| null | Field: Yes, Value: Nullable | Last auth timestamp (ms) | +| backupEligible | boolean\* | No (default: 0) | Can be backed up | +| backupState | boolean\* | No (default: 0) | Is currently backed up | +| prfEnabled | boolean\* | No (default: 0) | PRF extension enabled | \*Stored as TINYINT(1) in MySQL with DEFAULT 0, converted to boolean by Kysely @@ -84,12 +84,14 @@ This document provides detailed information about the passkey data model and fie ### transports -- **Type**: string | null -- **Storage**: VARCHAR(255) -- **Description**: JSON-encoded array of authenticator transport methods +- **Type**: string[] (JSON array in database) +- **Required**: Yes +- **Storage**: JSON NOT NULL +- **Description**: Array of authenticator transport methods - **Spec**: [AuthenticatorTransport enum](https://www.w3.org/TR/webauthn-3/#enum-transport) - **Source**: From [PublicKeyCredentialDescriptor.transports](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialdescriptor-transports) - **Validation**: Provided by WebAuthn library (e.g., @simplewebauthn/server) which validates spec compliance +- **Storage Pattern**: Store `[]` (empty array) when not provided, or JSON array like `["internal","hybrid"]` - **Valid Values**: - `"internal"` - Platform authenticator (Touch ID, Windows Hello) - `"usb"` - USB security key @@ -97,14 +99,15 @@ This document provides detailed information about the passkey data model and fie - `"ble"` - Bluetooth Low Energy - `"hybrid"` - Cloud-assisted BLE (formerly caBLE), per [CTAP 2.2](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html) - `"smart-card"` - Smart card authenticator -- **Example**: `'["internal","hybrid"]'` for iCloud Keychain +- **Example**: `["internal","hybrid"]` for iCloud Keychain - **Usage**: Helps UI show appropriate icons and authentication prompts -- **Storage Format**: JSON array as string for efficient querying +- **Database Type**: MySQL JSON for automatic validation and efficient JSON queries ### aaguid -- **Type**: Buffer(16) | null -- **Storage**: BINARY(16) +- **Type**: Buffer(16) +- **Required**: Yes +- **Storage**: BINARY(16) NOT NULL - **Description**: Authenticator Attestation GUID - **Spec**: [AAGUID](https://www.w3.org/TR/webauthn-3/#aaguid) - **Source**: From authenticator's [attestedCredentialData](https://www.w3.org/TR/webauthn-3/#attested-credential-data) @@ -118,23 +121,24 @@ This document provides detailed information about the passkey data model and fie - Software authenticators (browser built-in passkey managers) - Privacy-focused hardware keys - Platform authenticators (depending on policy) -- **Normalization**: The PasskeyService normalizes all-zeros AAGUID to `NULL` before storage - - WebAuthn libraries return the raw AAGUID buffer from authenticator data - - Service layer converts all-zeros to NULL (no meaningful identifier) - - Only actual AAGUID values that identify specific authenticator models are stored +- **Storage**: Store the AAGUID exactly as provided by the authenticator (including all-zeros) + - No normalization needed - all-zeros is a valid value per WebAuthn spec + - All-zeros means "privacy-preserving, no authenticator identifier" + - Simplifies implementation by avoiding special handling -**When to expect meaningful AAGUIDs:** +**When AAGUIDs identify specific authenticators:** - Hardware security keys (YubiKey, Titan Key, Feitian) - Some platform authenticators (Windows Hello, Touch ID on certain configurations) - Enterprise authenticators -- **Usage**: - - Track which authenticator models are in use - - Apply vendor-specific quirks if needed - - Analytics on authenticator adoption - - Security policies (e.g., allow/deny specific authenticator models) - - Auto-generate passkey names via FIDO MDS lookup (when not all-zeros) +**Usage:** + +- Track which authenticator models are in use +- Apply vendor-specific quirks if needed +- Analytics on authenticator adoption +- Security policies (e.g., allow/deny specific authenticator models) +- Auto-generate passkey names via FIDO MDS lookup (skip if all-zeros) - **Example**: YubiKey 5 has AAGUID `2fc0579f-8113-47ea-b116-bb5a8db9202a`, Windows Hello uses `08987058-cadc-4b81-b6e1-30de50dcbe96` - **Registry**: [FIDO Alliance Metadata Service](https://fidoalliance.org/metadata/) provides AAGUID registry @@ -142,10 +146,12 @@ This document provides detailed information about the passkey data model and fie ### name -- **Type**: string | null -- **Storage**: VARCHAR(255) +- **Type**: string +- **Required**: Yes +- **Storage**: VARCHAR(255) NOT NULL - **Description**: User-friendly name for the passkey - **Note**: FxA-specific field for UX, not part of WebAuthn spec +- **Important**: Must always be provided during registration (auto-generated or user-provided) - **Examples**: - "Touch ID" (auto-generated from platform) - "YubiKey 5" (auto-generated from AAGUID) @@ -172,12 +178,19 @@ Generate a descriptive default name using available metadata: - "iPhone Face ID" - "YubiKey 5 (Work Laptop)" +**Database Requirement:** + +- The `name` field is **NOT NULL** in the database schema +- Implementation **MUST** always provide a name during registration +- Never attempt to insert a passkey without a name (will fail DB constraint) + **Best Practices:** -- Always set a default name on registration (never leave NULL) -- Allow users to rename credentials later +- Auto-generate descriptive names using the strategy above +- Allow users to rename credentials after registration - Include date/device info for multiple credentials of same type - Example: "Touch ID (MacBook Pro, Jan 2025)" +- Minimum fallback: Use "Passkey" if all metadata lookups fail ### createdAt @@ -314,7 +327,7 @@ The PRF (Pseudo-Random Function) extension allows authenticators to derive deter ### Type Conversion (Kysely ColumnType) -Backup flags use `Generated>`: +**Backup flags** use `Generated>`: - **SELECT**: Returns `boolean` (true/false) - always present - **INSERT**: Accepts `number | undefined` (0, 1, or omit for default 0) - optional @@ -322,7 +335,29 @@ Backup flags use `Generated>`: The `Generated<...>` wrapper makes these fields optional on INSERT/UPDATE (matching the database DEFAULT 0), while ensuring they're always present as booleans on SELECT. -This provides ergonomic boolean usage in application code while storing efficiently in MySQL. +**JSON fields** (`transports`): + +- **Database**: MySQL JSON type for automatic validation +- **TypeScript**: `Json` type from Kysely (represents JSON values) +- **INSERT/UPDATE**: Provide as JavaScript value (e.g., `['internal', 'hybrid']`) or JSON string +- **SELECT**: Returns parsed JSON value +- **Important**: Always provide at least `[]` (empty array) - never undefined/null +- **Example**: + + ```typescript + // Insert + await db.insertInto('passkeys').values({ + transports: ['internal', 'hybrid'], // Kysely handles serialization + // ... other fields + }); + + // Query result + const passkey = await db + .selectFrom('passkeys') + .selectAll() + .executeTakeFirst(); + const transports = passkey.transports; // Already parsed, e.g., ['internal', 'hybrid'] + ``` ### Indexes @@ -333,8 +368,8 @@ Note: No additional indexes needed. The number of passkeys per user is constrain ### Foreign Keys -- `uid` references `accounts(uid)` with `ON DELETE CASCADE` -- When an account is deleted, all associated passkeys are automatically removed +- `uid` references `accounts(uid)` (no CASCADE - handled by deleteAccount stored procedure) +- When an account is deleted, passkeys are explicitly deleted via `deleteAccount_22` stored procedure ## Implementation Resources @@ -366,5 +401,5 @@ For generating fallback names when AAGUID is unavailable: ## Migration References -- **Forward Migration**: `packages/db-migrations/databases/fxa/patches/patch-182-183.sql` -- **Rollback Migration**: `packages/db-migrations/databases/fxa/patches/patch-183-182.sql` +- **Forward Migration**: `packages/db-migrations/databases/fxa/patches/patch-184-185.sql` +- **Rollback Migration**: `packages/db-migrations/databases/fxa/patches/patch-185-184.sql` diff --git a/libs/accounts/passkey/src/lib/passkey.repository.in.spec.ts b/libs/accounts/passkey/src/lib/passkey.repository.in.spec.ts new file mode 100644 index 00000000000..f1c0d75ac6e --- /dev/null +++ b/libs/accounts/passkey/src/lib/passkey.repository.in.spec.ts @@ -0,0 +1,156 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { faker } from '@faker-js/faker'; +import { + AccountDatabase, + testAccountDatabaseSetup, + PasskeyFactory, +} from '@fxa/shared/db/mysql/account'; +import { AccountManager } from '@fxa/shared/account/account'; +import * as PasskeyRepository from './passkey.repository'; + +describe('PasskeyRepository (Integration)', () => { + let db: AccountDatabase; + let accountManager: AccountManager; + + beforeAll(async () => { + try { + db = await testAccountDatabaseSetup(['accounts', 'emails', 'passkeys']); + accountManager = new AccountManager(db); + } catch (error) { + console.warn('\n⚠️ Integration tests require database infrastructure.'); + console.warn( + ' Run "yarn start infrastructure" to enable these tests.\n' + ); + throw error; + } + }); + + // Helper to create an account for testing + async function createTestAccount() { + const email = faker.internet.email(); + const uidHex = await accountManager.createAccountStub(email, 1, 'en-US'); + return Buffer.from(uidHex, 'hex'); + } + + afterAll(async () => { + if (db) { + await db.destroy(); + } + }); + + describe('insert and find operations', () => { + it('should insert and retrieve a passkey', async () => { + const uid = await createTestAccount(); + const passkey = PasskeyFactory({ uid }); + + await PasskeyRepository.insertPasskey(db, passkey); + + const found = await PasskeyRepository.findPasskeyByCredentialId( + db, + passkey.credentialId + ); + + expect(found).toBeDefined(); + expect(found?.uid).toEqual(passkey.uid); + expect(found?.name).toBe(passkey.name); + expect(found?.transports).toBeDefined(); // JSON field + expect(found?.aaguid).toEqual(passkey.aaguid); // NOT NULL + }); + + it('should find all passkeys for a user', async () => { + const uid = await createTestAccount(); + const passkey1 = PasskeyFactory({ uid }); + const passkey2 = PasskeyFactory({ uid }); + + await PasskeyRepository.insertPasskey(db, passkey1); + await PasskeyRepository.insertPasskey(db, passkey2); + + const passkeys = await PasskeyRepository.findPasskeysByUid(db, uid); + + expect(passkeys.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('update operations', () => { + it('should update passkey name', async () => { + const uid = await createTestAccount(); + const passkey = PasskeyFactory({ uid }); + await PasskeyRepository.insertPasskey(db, passkey); + + const rowsUpdated = await PasskeyRepository.updatePasskeyName( + db, + passkey.credentialId, + 'New Name' + ); + + expect(rowsUpdated).toBe(1); + + const updated = await PasskeyRepository.findPasskeyByCredentialId( + db, + passkey.credentialId + ); + expect(updated?.name).toBe('New Name'); + }); + + it('should update counter and lastUsed after authentication', async () => { + const uid = await createTestAccount(); + const passkey = PasskeyFactory({ uid }); + await PasskeyRepository.insertPasskey(db, passkey); + + const success = await PasskeyRepository.updatePasskeyCounterAndLastUsed( + db, + passkey.credentialId, + 5, + 1 + ); + + expect(success).toBe(true); + + const updated = await PasskeyRepository.findPasskeyByCredentialId( + db, + passkey.credentialId + ); + expect(updated?.signCount).toBe(5); + expect(updated?.backupState).toBe(true); + expect(updated?.lastUsedAt).toBeGreaterThan(0); + }); + }); + + describe('delete operations', () => { + it('should delete a specific passkey', async () => { + const uid = await createTestAccount(); + const passkey = PasskeyFactory({ uid }); + await PasskeyRepository.insertPasskey(db, passkey); + + const deleted = await PasskeyRepository.deletePasskey( + db, + passkey.uid, + passkey.credentialId + ); + + expect(deleted).toBe(true); + + const found = await PasskeyRepository.findPasskeyByCredentialId( + db, + passkey.credentialId + ); + expect(found).toBeUndefined(); + }); + + it('should count passkeys for a user', async () => { + const uid = await createTestAccount(); + const passkey1 = PasskeyFactory({ uid }); + const passkey2 = PasskeyFactory({ uid }); + + await PasskeyRepository.insertPasskey(db, passkey1); + await PasskeyRepository.insertPasskey(db, passkey2); + + const count = await PasskeyRepository.countPasskeysByUid(db, uid); + + expect(count).toBeGreaterThanOrEqual(2); + }); + }); +}); diff --git a/libs/accounts/passkey/src/lib/passkey.repository.ts b/libs/accounts/passkey/src/lib/passkey.repository.ts index 4f919ba0007..b7041d5d340 100644 --- a/libs/accounts/passkey/src/lib/passkey.repository.ts +++ b/libs/accounts/passkey/src/lib/passkey.repository.ts @@ -94,14 +94,15 @@ export async function insertPasskey( * @param credentialId - WebAuthn credential ID (Buffer) * @param signCount - New signature count from authenticator data * @param backupState - Current backup state flag (0 or 1) from authenticator data + * @returns true if a passkey was updated, false otherwise */ export async function updatePasskeyCounterAndLastUsed( db: AccountDatabase, credentialId: Buffer, signCount: number, backupState: number -): Promise { - await db +): Promise { + const result = await db .updateTable('passkeys') .set({ lastUsedAt: Date.now(), @@ -109,7 +110,9 @@ export async function updatePasskeyCounterAndLastUsed( backupState: backupState, }) .where('credentialId', '=', credentialId) - .execute(); + .executeTakeFirst(); + + return result.numUpdatedRows === BigInt(1); } /** @@ -129,9 +132,9 @@ export async function updatePasskeyName( .updateTable('passkeys') .set({ name }) .where('credentialId', '=', credentialId) - .execute(); + .executeTakeFirst(); - return result.length; + return Number(result.numUpdatedRows); } /** diff --git a/libs/accounts/passkey/src/lib/passkey.service.ts b/libs/accounts/passkey/src/lib/passkey.service.ts index 472f9e3b1ea..8dba738bfb8 100644 --- a/libs/accounts/passkey/src/lib/passkey.service.ts +++ b/libs/accounts/passkey/src/lib/passkey.service.ts @@ -27,12 +27,12 @@ import { PasskeyManager } from './passkey.manager'; * The library handles WebAuthn spec compliance and crypto operations. * This service translates between WebAuthn responses and our database model. * - * ## Data Normalization + * ## Data Storage * * When storing passkey data from WebAuthn library responses: - * - **AAGUID**: Normalize all-zeros (00000000-0000-0000-0000-000000000000) to NULL - * Many authenticators return all-zeros for privacy. Store NULL when meaningless. - * - **transports**: Trust library-provided JSON array string (validated by library) + * - **AAGUID**: Store exactly as provided (including all-zeros for privacy-preserving authenticators) + * - **transports**: Store as JSON array (use [] if not provided) + * - **name**: Always generate a default if not provided (use "Passkey" as fallback) * - **backupEligible/backupState**: Extract from authenticator data flags (BE/BS bits) * */ @@ -46,20 +46,18 @@ export class PasskeyService { // TODO: Add methods for passkey operations such as: // - generateRegistrationChallenge - // - verifyRegistrationResponse (normalize AAGUID here before storing) + // - verifyRegistrationResponse + // CRITICAL: Must ALWAYS generate a name (NOT NULL in database). + // Strategy: 1) AAGUID → FIDO MDS lookup (skip if all-zeros), 2) transport-based fallback, + // 3) generic fallback ("Passkey"). See PASSKEY_FIELDS.md for details. + // CRITICAL: Must normalize transports - use [] (empty array) if undefined/null. + // Note: Store AAGUID as-is (no normalization needed, all-zeros is valid). // - generateAuthenticationChallenge // - verifyAuthenticationResponse (extract backup state, signCount, validate rollback) // - listPasskeysForUser // - renamePasskey // - deletePasskey // - // TODO: Add normalizeAaguid() helper: - // function normalizeAaguid(aaguid: Buffer | null | undefined): Buffer | null { - // if (!aaguid || aaguid.length !== 16) return null; - // if (aaguid.every(byte => byte === 0)) return null; - // return aaguid; - // } - // // TODO: Add signCount rollback detection in verifyAuthenticationResponse(): // - Fetch existing passkey with current signCount // - Compare new signCount from authenticator response diff --git a/libs/shared/db/mysql/account/src/lib/factories.ts b/libs/shared/db/mysql/account/src/lib/factories.ts index 7620a7ee079..746e4d49f17 100644 --- a/libs/shared/db/mysql/account/src/lib/factories.ts +++ b/libs/shared/db/mysql/account/src/lib/factories.ts @@ -190,18 +190,20 @@ export const PasskeyFactory = (override?: Partial): NewPasskey => ({ publicKey: getHexBuffer(128), signCount: 0, transports: faker.helpers.arrayElement([ - '["internal"]', - '["usb"]', - '["internal","hybrid"]', - null, + JSON.stringify(['internal']), + JSON.stringify(['usb']), + JSON.stringify(['internal', 'hybrid']), + JSON.stringify([]), ]), - aaguid: faker.datatype.boolean() ? getHexBuffer(32) : null, + aaguid: faker.datatype.boolean() + ? getHexBuffer(32) // Real AAGUID (32 hex chars = 16 bytes) + : Buffer.alloc(16, 0), // All-zeros for privacy-preserving authenticators name: faker.helpers.arrayElement([ 'Touch ID', 'YubiKey 5', 'Security Key', 'iPhone Face ID', - null, + 'Passkey', ]), createdAt: faker.date.recent().getTime(), lastUsedAt: faker.datatype.boolean() ? faker.date.recent().getTime() : null, diff --git a/libs/shared/db/mysql/account/src/lib/kysely-types.ts b/libs/shared/db/mysql/account/src/lib/kysely-types.ts index 9bf3d2f5cec..c4824958b72 100644 --- a/libs/shared/db/mysql/account/src/lib/kysely-types.ts +++ b/libs/shared/db/mysql/account/src/lib/kysely-types.ts @@ -248,9 +248,9 @@ export interface Passkeys { credentialId: Buffer; publicKey: Buffer; signCount: number; - transports: string | null; - aaguid: Buffer | null; - name: string | null; + transports: Json; + aaguid: Buffer; + name: string; createdAt: number; lastUsedAt: number | null; backupEligible: Generated>; diff --git a/packages/db-migrations/databases/fxa/patches/patch-184-185.sql b/packages/db-migrations/databases/fxa/patches/patch-184-185.sql index 3f48176c0fa..df477ed8824 100644 --- a/packages/db-migrations/databases/fxa/patches/patch-184-185.sql +++ b/packages/db-migrations/databases/fxa/patches/patch-184-185.sql @@ -12,9 +12,9 @@ CREATE TABLE passkeys ( credentialId VARBINARY(1023) NOT NULL, publicKey BLOB NOT NULL, signCount INT UNSIGNED NOT NULL DEFAULT 0, - transports VARCHAR(255), - aaguid BINARY(16), - name VARCHAR(255), + transports JSON NOT NULL, + aaguid BINARY(16) NOT NULL, + name VARCHAR(255) NOT NULL, createdAt BIGINT UNSIGNED NOT NULL, lastUsedAt BIGINT UNSIGNED NULL, backupEligible TINYINT(1) NOT NULL DEFAULT 0, @@ -22,7 +22,46 @@ CREATE TABLE passkeys ( prfEnabled TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (uid, credentialId), UNIQUE KEY idx_credentialId (credentialId), - FOREIGN KEY (uid) REFERENCES accounts(uid) ON DELETE CASCADE + FOREIGN KEY (uid) REFERENCES accounts(uid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +-- Update deleteAccount stored procedure to explicitly handle passkeys deletion +CREATE PROCEDURE `deleteAccount_22` ( + IN `uidArg` BINARY(16) +) +BEGIN + DECLARE EXIT HANDLER FOR SQLEXCEPTION + BEGIN + ROLLBACK; + RESIGNAL; + END; + + START TRANSACTION; + + DELETE FROM sessionTokens WHERE uid = uidArg; + DELETE FROM keyFetchTokens WHERE uid = uidArg; + DELETE FROM accountResetTokens WHERE uid = uidArg; + DELETE FROM passwordChangeTokens WHERE uid = uidArg; + DELETE FROM passwordForgotTokens WHERE uid = uidArg; + DELETE FROM accounts WHERE uid = uidArg; + DELETE devices, deviceCommands FROM devices LEFT JOIN deviceCommands + ON (deviceCommands.uid = devices.uid AND deviceCommands.deviceId = devices.id) + WHERE devices.uid = uidArg; + DELETE FROM unverifiedTokens WHERE uid = uidArg; + DELETE FROM unblockCodes WHERE uid = uidArg; + DELETE FROM emails WHERE uid = uidArg; + DELETE FROM signinCodes WHERE uid = uidArg; + DELETE FROM totp WHERE uid = uidArg; + DELETE FROM recoveryKeys WHERE uid = uidArg; + DELETE FROM recoveryCodes WHERE uid = uidArg; + DELETE FROM securityEvents WHERE uid = uidArg; + DELETE FROM sentEmails WHERE uid = uidArg; + DELETE FROM linkedAccounts WHERE uid = uidArg; + DELETE FROM passkeys WHERE uid = uidArg; + + INSERT IGNORE INTO deletedAccounts (uid, deletedAt) VALUES (uidArg, (UNIX_TIMESTAMP(NOW(3)) * 1000)); + + COMMIT; +END; + UPDATE dbMetadata SET value = '185' WHERE name = 'schema-patch-level'; diff --git a/packages/db-migrations/databases/fxa/patches/patch-185-184.sql b/packages/db-migrations/databases/fxa/patches/patch-185-184.sql index b444d584078..da1e67f1b0e 100644 --- a/packages/db-migrations/databases/fxa/patches/patch-185-184.sql +++ b/packages/db-migrations/databases/fxa/patches/patch-185-184.sql @@ -1,7 +1,7 @@ -SET NAMES utf8mb4 COLLATE utf8mb4_bin; +-- SET NAMES utf8mb4 COLLATE utf8mb4_bin; -CALL assertPatchLevel('185'); +-- DROP PROCEDURE `deleteAccount_22`; -DROP TABLE `passkeys`; +-- DROP TABLE `passkeys`; -UPDATE dbMetadata SET value = '184' WHERE name = 'schema-patch-level'; +-- UPDATE dbMetadata SET value = '184' WHERE name = 'schema-patch-level';