Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ beforeAll(async () => {
challengeTimeout: 1000 * 60 * 5,
requestPrfAtRegistration: false,
prfSalt: '',
requestPrfAtAuthentication: 'off',
});

const moduleRef = await buildTestModule(redis, config, mockLogger);
Expand Down Expand Up @@ -149,6 +150,7 @@ describe('PasskeyChallengeManager (integration)', () => {
challengeTimeout: 1000,
requestPrfAtRegistration: false,
prfSalt: '',
requestPrfAtAuthentication: 'off',
});

const moduleRef = await buildTestModule(redis, shortConfig, mockLogger);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function makeConfig(overrides: Partial<PasskeyConfig> = {}): PasskeyConfig {
rpId: 'accounts.firefox.com',
requestPrfAtRegistration: false,
prfSalt: '',
requestPrfAtAuthentication: 'off',
...overrides,
});

Expand Down
20 changes: 20 additions & 0 deletions libs/accounts/passkey/src/lib/passkey.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ import type {
*/
export const MAX_PASSKEY_NAME_LENGTH = 255;

/**
* Scope of the PRF-at-authentication request (see
* {@link PasskeyConfig.requestPrfAtAuthentication}).
*
* - `off`: never request PRF at sign-in (kill switch).
* - `keys-required`: request PRF only for keys-required (Sync) sign-ins — the
* Phase-2-relevant cohort, lowest blast radius.
* - `all`: request PRF for every passkey sign-in, for fuller telemetry.
*/
export const PRF_AUTH_SCOPES = ['off', 'keys-required', 'all'] as const;
export type PrfAuthScope = (typeof PRF_AUTH_SCOPES)[number];

/**
* Configuration for passkey (WebAuthn) functionality.
*
Expand Down Expand Up @@ -117,6 +129,13 @@ export class PasskeyConfig {
})
public prfSalt!: string;

/**
* Scope of the PRF request at sign-in. Off by default (kill switch).
* @see {@link PrfAuthScope}
*/
@IsIn(PRF_AUTH_SCOPES)
public requestPrfAtAuthentication!: PrfAuthScope;

/**
* Creates a new PasskeyConfig instance by copying all fields from the
* provided options object.
Expand All @@ -131,6 +150,7 @@ export class PasskeyConfig {
this.maxPasskeysPerUser = opts.maxPasskeysPerUser;
this.requestPrfAtRegistration = opts.requestPrfAtRegistration;
this.prfSalt = opts.prfSalt;
this.requestPrfAtAuthentication = opts.requestPrfAtAuthentication;
this.residentKey = opts.residentKey;
this.rpId = opts.rpId;
}
Expand Down
1 change: 1 addition & 0 deletions libs/accounts/passkey/src/lib/passkey.manager.in.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ describe('PasskeyManager (Integration)', () => {
challengeTimeout: 30_000,
requestPrfAtRegistration: false,
prfSalt: '',
requestPrfAtAuthentication: 'off',
});

const mockMetrics = {
Expand Down
32 changes: 32 additions & 0 deletions libs/accounts/passkey/src/lib/passkey.manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jest.mock('./passkey.repository', () => ({
insertPasskey: jest.fn(),
updatePasskeyCounterAndLastUsed: jest.fn(),
updatePasskeyName: jest.fn(),
updatePasskeyPrfEnabled: jest.fn(),
}));

const mockDb = {} as unknown as AccountDatabase;
Expand All @@ -43,6 +44,7 @@ const mockConfig = new PasskeyConfig({
maxPasskeysPerUser: MOCK_MAX_PASSKEYS_PER_USER,
requestPrfAtRegistration: false,
prfSalt: '',
requestPrfAtAuthentication: 'off',
});

const mockMetrics = {
Expand Down Expand Up @@ -247,4 +249,34 @@ describe('PasskeyManager', () => {
expect(PasskeyRepository.updatePasskeyName).not.toHaveBeenCalled();
});
});

describe('setPasskeyPrfEnabled', () => {
const uid = Buffer.alloc(16, 1).toString('hex');
const credentialId = Buffer.alloc(32, 2).toString('base64url');

it('returns true when the repository rolls the flag forward', async () => {
(
PasskeyRepository.updatePasskeyPrfEnabled as jest.Mock
).mockResolvedValue(1);

const result = await manager.setPasskeyPrfEnabled(uid, credentialId);

expect(result).toBe(true);
expect(PasskeyRepository.updatePasskeyPrfEnabled).toHaveBeenCalledWith(
mockDb,
uid,
credentialId
);
});

it('returns false when no row was updated (already true, not found, or wrong user)', async () => {
(
PasskeyRepository.updatePasskeyPrfEnabled as jest.Mock
).mockResolvedValue(0);

const result = await manager.setPasskeyPrfEnabled(uid, credentialId);

expect(result).toBe(false);
});
});
});
23 changes: 23 additions & 0 deletions libs/accounts/passkey/src/lib/passkey.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
PasskeyRecord,
updatePasskeyCounterAndLastUsed,
updatePasskeyName,
updatePasskeyPrfEnabled,
} from './passkey.repository';
import { PasskeyConfig, MAX_PASSKEY_NAME_LENGTH } from './passkey.config';
import { AppError } from '@fxa/accounts/errors';
Expand Down Expand Up @@ -166,6 +167,28 @@ export class PasskeyManager {
return rowsUpdated > 0;
}

/**
* Roll a passkey's `prfEnabled` flag forward from false to true.
*
* Monotonic: never flips an already-true flag back to false. Both uid and
* credentialId must match to prevent one user from flipping another user's
* passkey.
*
* @returns true if the flag was rolled false→true; false if it was already
* true, the credential was not found, or it belongs to another user.
*/
async setPasskeyPrfEnabled(
uid: string,
credentialId: string
): Promise<boolean> {
const rowsUpdated = await updatePasskeyPrfEnabled(
this.db,
uid,
credentialId
);
return rowsUpdated > 0;
}

/**
* Delete a passkey for a user.
*
Expand Down
14 changes: 14 additions & 0 deletions libs/accounts/passkey/src/lib/passkey.provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const VALID_RAW_CONFIG: RawPasskeyConfig = {
authenticatorAttachment: '',
requestPrfAtRegistration: false,
prfSalt: '',
requestPrfAtAuthentication: 'off',
};

function buildModule(rawPasskeys: unknown) {
Expand Down Expand Up @@ -106,6 +107,7 @@ describe('PasskeyConfigProvider', () => {
expect(config!.residentKey).toBe('required');
expect(config!.requestPrfAtRegistration).toBe(false);
expect(config!.prfSalt).toBe('');
expect(config!.requestPrfAtAuthentication).toBe('off');
});

it('copies a configured prfSalt', async () => {
Expand Down Expand Up @@ -197,5 +199,17 @@ describe('PasskeyConfigProvider', () => {
'property prfSalt has failed the following constraints'
);
});

it('rejects a requestPrfAtAuthentication value outside the allowed scopes', async () => {
await expect(() =>
buildModule({
...VALID_RAW_CONFIG,
requestPrfAtAuthentication:
'sometimes' as unknown as RawPasskeyConfig['requestPrfAtAuthentication'],
})
).rejects.toThrow(
'property requestPrfAtAuthentication has failed the following constraints'
);
});
});
});
3 changes: 2 additions & 1 deletion libs/accounts/passkey/src/lib/passkey.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { LoggerService } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LOGGER_PROVIDER } from '@fxa/shared/log';
import Redis from 'ioredis';
import { PasskeyConfig } from './passkey.config';
import { PasskeyConfig, PrfAuthScope } from './passkey.config';
import { validateSync } from 'class-validator';
import type {
AuthenticatorAttachment,
Expand All @@ -32,6 +32,7 @@ export type RawPasskeyConfig = {
authenticatorAttachment: AuthenticatorAttachment | '';
requestPrfAtRegistration: boolean;
prfSalt: string;
requestPrfAtAuthentication: PrfAuthScope;
};

/**
Expand Down
115 changes: 114 additions & 1 deletion libs/accounts/passkey/src/lib/passkey.repository.in.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,11 @@ describe('PasskeyRepository (Integration)', () => {
const uid1 = await createTestAccount();
const uid2 = await createTestAccount();
const passkey = PasskeyFactory({ uid: uidBuffer(uid1), signCount: 0 });
await PasskeyRepository.insertPasskey(db, uid1, toNewPasskeyData(passkey));
await PasskeyRepository.insertPasskey(
db,
uid1,
toNewPasskeyData(passkey)
);

// Wrong uid — WHERE clause should prevent the update
const success = await PasskeyRepository.updatePasskeyCounterAndLastUsed(
Expand All @@ -163,4 +167,113 @@ describe('PasskeyRepository (Integration)', () => {
expect(found?.signCount).toBe(0);
});
});

describe('updatePasskeyPrfEnabled (monotonic)', () => {
it('rolls prfEnabled forward from false to true', async () => {
const uid = await createTestAccount();
const passkey = PasskeyFactory({
uid: uidBuffer(uid),
prfEnabled: false,
});
await PasskeyRepository.insertPasskey(db, uid, toNewPasskeyData(passkey));

const rows = await PasskeyRepository.updatePasskeyPrfEnabled(
db,
uid,
passkey.credentialId.toString('base64url')
);

expect(rows).toBe(1);
const found = await PasskeyRepository.findPasskeyByCredentialId(
db,
passkey.credentialId.toString('base64url')
);
expect(found?.prfEnabled).toBe(true);
});

it('is a no-op (0 rows) when prfEnabled is already true', async () => {
const uid = await createTestAccount();
const passkey = PasskeyFactory({ uid: uidBuffer(uid), prfEnabled: true });
await PasskeyRepository.insertPasskey(db, uid, toNewPasskeyData(passkey));

const rows = await PasskeyRepository.updatePasskeyPrfEnabled(
db,
uid,
passkey.credentialId.toString('base64url')
);

expect(rows).toBe(0);
const found = await PasskeyRepository.findPasskeyByCredentialId(
db,
passkey.credentialId.toString('base64url')
);
expect(found?.prfEnabled).toBe(true);
});

it('does not update when uid does not match the credential owner', async () => {
const uid1 = await createTestAccount();
const uid2 = await createTestAccount();
const passkey = PasskeyFactory({
uid: uidBuffer(uid1),
prfEnabled: false,
});
await PasskeyRepository.insertPasskey(
db,
uid1,
toNewPasskeyData(passkey)
);

const rows = await PasskeyRepository.updatePasskeyPrfEnabled(
db,
uid2,
passkey.credentialId.toString('base64url')
);

expect(rows).toBe(0);
const found = await PasskeyRepository.findPasskeyByCredentialId(
db,
passkey.credentialId.toString('base64url')
);
expect(found?.prfEnabled).toBe(false);
});
it('does not update when the credential does not match', async () => {
Comment thread
nshirley marked this conversation as resolved.
const uid = await createTestAccount();
const passkey1 = PasskeyFactory({
uid: uidBuffer(uid),
prfEnabled: false,
});
const passkey2 = PasskeyFactory({
uid: uidBuffer(uid),
prfEnabled: false,
});
await PasskeyRepository.insertPasskey(
db,
uid,
toNewPasskeyData(passkey1)
);
await PasskeyRepository.insertPasskey(
db,
uid,
toNewPasskeyData(passkey2)
);

const rows = await PasskeyRepository.updatePasskeyPrfEnabled(
db,
uid,
passkey1.credentialId.toString('base64url')
);
// Should update passkey1 but not passkey2
expect(rows).toBe(1);
const found1 = await PasskeyRepository.findPasskeyByCredentialId(
db,
passkey1.credentialId.toString('base64url')
);
const found2 = await PasskeyRepository.findPasskeyByCredentialId(
db,
passkey2.credentialId.toString('base64url')
);
expect(found1?.prfEnabled).toBe(true);
expect(found2?.prfEnabled).toBe(false);
});
});
});
25 changes: 25 additions & 0 deletions libs/accounts/passkey/src/lib/passkey.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,31 @@ export async function updatePasskeyName(
return Number(result.numUpdatedRows);
}

/**
* Roll a passkey's `prfEnabled` flag forward from false to true. Monotonic: the
* `prfEnabled = false` guard means an already-true flag is never rewritten, so
* capability detected on one device can't be erased by a device lacking PRF.
* Scoped to both uid and credentialId so one user can't flip another's passkey.
*
* @returns Rows updated: 1 on a false→true flip, 0 when already true, not found,
* or owned by another user.
*/
export async function updatePasskeyPrfEnabled(
db: AccountDatabase,
uid: string,
credentialId: string
): Promise<number> {
const result = await db
.updateTable('passkeys')
.set({ prfEnabled: 1 })
.where('uid', '=', uuidTransformer.to(uid))
.where('credentialId', '=', base64urlToBuffer(credentialId))
.where('prfEnabled', '=', false)
.executeTakeFirst();

return Number(result.numUpdatedRows);
}

/**
* Delete a specific passkey for a user.
*
Expand Down
Loading
Loading