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
20 changes: 20 additions & 0 deletions packages/db-migrations/databases/fxa/patches/patch-195-196.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- Add security event names for the passkey wrap lifecycle. A wrap is the
-- envelope in passkeyWraps that lets a passkey unlock kB without a password.
--
-- Named for the wrap rather than the route, following account.two_factor_*
-- (/totp). Which step failed goes in additionalInfo.reason, so a new failure
-- mode needs no migration.

SET NAMES utf8mb4 COLLATE utf8mb4_bin;

CALL assertPatchLevel('195');

INSERT INTO securityEventNames (name) VALUES
('account.passkey.wrap_created'),
('account.passkey.wrap_creation_failure'),
('account.passkey.wrap_retrieved'),
('account.passkey.wrap_retrieval_failure'),
('account.passkey.wrap_deleted'),
('account.passkey.wrap_invalidated');

UPDATE dbMetadata SET value = '196' WHERE name = 'schema-patch-level';
10 changes: 10 additions & 0 deletions packages/db-migrations/databases/fxa/patches/patch-196-195.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- SET NAMES utf8mb4 COLLATE utf8mb4_bin;
--
-- DELETE FROM securityEventNames WHERE name = 'account.passkey.wrap_created';
-- DELETE FROM securityEventNames WHERE name = 'account.passkey.wrap_creation_failure';
-- DELETE FROM securityEventNames WHERE name = 'account.passkey.wrap_retrieved';
-- DELETE FROM securityEventNames WHERE name = 'account.passkey.wrap_retrieval_failure';
-- DELETE FROM securityEventNames WHERE name = 'account.passkey.wrap_deleted';
-- DELETE FROM securityEventNames WHERE name = 'account.passkey.wrap_invalidated';
--
-- UPDATE dbMetadata SET value = '195' WHERE name = 'schema-patch-level';
2 changes: 1 addition & 1 deletion packages/db-migrations/databases/fxa/target-patch.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"level": 195
"level": 196
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,18 +64,28 @@ export enum SecurityEventName {
PasswordlessLoginOtpVerified = 'account.passwordless_login_otp_verified',
PasswordlessRegistrationComplete = 'account.passwordless_registration_complete',
RecoveryCodesSet = 'account.recovery_codes_set',
PasskeyWrapCreated = 'account.passkey.wrap_created',
PasskeyWrapCreationFailure = 'account.passkey.wrap_creation_failure',
PasskeyWrapRetrieved = 'account.passkey.wrap_retrieved',
PasskeyWrapRetrievalFailure = 'account.passkey.wrap_retrieval_failure',
PasskeyWrapDeleted = 'account.passkey.wrap_deleted',
PasskeyWrapInvalidated = 'account.passkey.wrap_invalidated',
}

// Internal server decisions, not user-triggered actions. Filtered before
// rendering so they don't clutter the security audit log or duplicate
// account.login (the signin_confirm_bypass_* trio fires on every login
// where confirmation was skipped).
export const HIDDEN_SECURITY_EVENT_NAMES: ReadonlySet<string> = new Set([
SecurityEventName.PasswordUpgradeSuccess,
SecurityEventName.SigninConfirmBypassKnownIp,
SecurityEventName.SigninConfirmBypassNewAccount,
SecurityEventName.SigninConfirmBypassKnownDevice,
]);
// Recorded for the audit trail but not shown: each either restates a row the
// user already has (signin_confirm_bypass_* duplicates account.login,
// wrap_retrieved restates passkey.authentication_success) or is a side effect
// they did not take (wrap_deleted rides the passkey-deletion cascade).
export const HIDDEN_SECURITY_EVENT_NAMES: ReadonlySet<string> =
new Set<SecurityEventName>([
SecurityEventName.PasswordUpgradeSuccess,
SecurityEventName.SigninConfirmBypassKnownIp,
SecurityEventName.SigninConfirmBypassNewAccount,
SecurityEventName.SigninConfirmBypassKnownDevice,
SecurityEventName.PasskeyWrapDeleted,
SecurityEventName.PasskeyWrapRetrieved,
SecurityEventName.PasskeyWrapRetrievalFailure,
]);

const getSecurityEventNameL10n = (name: string) => {
switch (name) {
Expand Down Expand Up @@ -409,6 +419,24 @@ const getSecurityEventNameL10n = (name: string) => {
fallbackText: 'Recovery codes set',
};
}
case SecurityEventName.PasskeyWrapCreated: {
return {
ftlId: 'recent-activity-account-passkey-wrap-created',
fallbackText: 'Passkey enabled for syncing',
};
}
case SecurityEventName.PasskeyWrapCreationFailure: {
return {
ftlId: 'recent-activity-account-passkey-wrap-creation-failure',
fallbackText: 'Sync setup with passkey failed',
};
}
case SecurityEventName.PasskeyWrapInvalidated: {
return {
ftlId: 'recent-activity-account-passkey-wrap-invalidated',
fallbackText: 'Passkey sync access removed after password reset',
};
}
default: {
return {
ftlId: 'recent-activity-unknown',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ recent-activity-account-passwordless-login-otp-failed = Passwordless sign-in cod
recent-activity-account-passwordless-login-otp-verified = Passwordless sign-in code verified
recent-activity-account-passwordless-registration-complete = Passwordless account registration completed
recent-activity-account-recovery-codes-set = Recovery codes set
# A passkey is a sign-in method that replaces a password. This string is shown when a passkey was set up so it can also unlock the user's synced browser data (bookmarks, history, open tabs), which previously required their password.
recent-activity-account-passkey-wrap-created = Passkey enabled for syncing
# A passkey is a sign-in method that replaces a password. This string is shown when an attempt to set a passkey up to unlock the user's synced browser data did not complete.
recent-activity-account-passkey-wrap-creation-failure = Sync setup with passkey failed
# A passkey is a sign-in method that replaces a password. Resetting a forgotten password re-encrypts the user's synced browser data, which their passkeys can no longer unlock. This string is shown when that happened and the passkeys need to be set up for syncing again.
recent-activity-account-passkey-wrap-invalidated = Passkey sync access removed after password reset

# Security event was recorded, but the activity details are unknown or not shown to user
recent-activity-unknown = Other account activity
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ const expectedLabels = [
'Passwordless sign-in code verified',
'Passwordless account registration completed',
'Recovery codes set',
'Passkey enabled for syncing',
'Sync setup with passkey failed',
'Passkey sync access removed after password reset',
];

describe('Recent Account Activity', () => {
Expand Down Expand Up @@ -148,6 +151,23 @@ describe('Recent Account Activity', () => {
).not.toBeInTheDocument();
});

// Guards the hand-maintained list above: a new SecurityEventName with no
// case in getSecurityEventNameL10n renders the unknown fallback, which
// adding a label to expectedLabels would not catch. MOCK_SECURITY_EVENTS
// covers every enum member, so an unlabelled one surfaces here.
it('renders a real label, not the unknown fallback, for every event name that is not hidden', async () => {
const user = userEvent.setup();
render();

// Without this, only the first INITIAL_EVENT_COUNT events are in the DOM
// and an unlabelled name later in the enum would go unnoticed.
await user.click(await screen.findByRole('button', { name: 'Show more' }));

// queryAll, not query: queryByText throws on multiple matches, which would
// mask the count when more than one name is missing a label.
expect(screen.queryAllByText('Other account activity')).toHaveLength(0);
});

it('filters HIDDEN_SECURITY_EVENT_NAMES before rendering', async () => {
const accountWithHiddenAndVisible = {
primaryEmail: { email: 'foxy@mozilla.com' },
Expand Down
6 changes: 6 additions & 0 deletions packages/fxa-shared/db/models/auth/security-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ export const EVENT_NAMES = {
'account.passwordless_login_otp_verified': 58,
'account.passwordless_registration_complete': 59,
'account.recovery_codes_set': 60,
'account.passkey.wrap_created': 61,
'account.passkey.wrap_creation_failure': 62,
'account.passkey.wrap_retrieved': 63,
'account.passkey.wrap_retrieval_failure': 64,
'account.passkey.wrap_deleted': 65,
'account.passkey.wrap_invalidated': 66,
} as const satisfies Record<string, number>;

export type SecurityEventNames = keyof typeof EVENT_NAMES;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ INSERT INTO securityEventNames (id, name) VALUES (45, 'account.mfa_send_otp_code
INSERT INTO securityEventNames (id, name) VALUES (47, 'account.mfa_verify_otp_code_failed');
INSERT INTO securityEventNames (id, name) VALUES (46, 'account.mfa_verify_otp_code_success');
INSERT INTO securityEventNames (id, name) VALUES (35, 'account.must_reset');
INSERT INTO securityEventNames (id, name) VALUES (55, 'account.passkey.authentication_failure');
INSERT INTO securityEventNames (id, name) VALUES (54, 'account.passkey.authentication_success');
INSERT INTO securityEventNames (id, name) VALUES (52, 'account.passkey.registration_failure');
INSERT INTO securityEventNames (id, name) VALUES (51, 'account.passkey.registration_success');
INSERT INTO securityEventNames (id, name) VALUES (53, 'account.passkey.removed');
INSERT INTO securityEventNames (id, name) VALUES (61, 'account.passkey.wrap_created');
INSERT INTO securityEventNames (id, name) VALUES (62, 'account.passkey.wrap_creation_failure');
INSERT INTO securityEventNames (id, name) VALUES (65, 'account.passkey.wrap_deleted');
INSERT INTO securityEventNames (id, name) VALUES (66, 'account.passkey.wrap_invalidated');
INSERT INTO securityEventNames (id, name) VALUES (64, 'account.passkey.wrap_retrieval_failure');
INSERT INTO securityEventNames (id, name) VALUES (63, 'account.passkey.wrap_retrieved');
INSERT INTO securityEventNames (id, name) VALUES (19, 'account.password_added');
INSERT INTO securityEventNames (id, name) VALUES (20, 'account.password_changed');
INSERT INTO securityEventNames (id, name) VALUES (24, 'account.password_reset_otp_sent');
Expand All @@ -20,9 +31,14 @@ INSERT INTO securityEventNames (id, name) VALUES (13, 'account.password_reset_re
INSERT INTO securityEventNames (id, name) VALUES (14, 'account.password_reset_success');
INSERT INTO securityEventNames (id, name) VALUES (42, 'account.password_upgrade_success');
INSERT INTO securityEventNames (id, name) VALUES (43, 'account.password_upgraded');
INSERT INTO securityEventNames (id, name) VALUES (57, 'account.passwordless_login_otp_failed');
INSERT INTO securityEventNames (id, name) VALUES (56, 'account.passwordless_login_otp_sent');
INSERT INTO securityEventNames (id, name) VALUES (58, 'account.passwordless_login_otp_verified');
INSERT INTO securityEventNames (id, name) VALUES (59, 'account.passwordless_registration_complete');
INSERT INTO securityEventNames (id, name) VALUES (23, 'account.primary_secondary_swapped');
INSERT INTO securityEventNames (id, name) VALUES (33, 'account.recovery_codes_created');
INSERT INTO securityEventNames (id, name) VALUES (32, 'account.recovery_codes_replaced');
INSERT INTO securityEventNames (id, name) VALUES (60, 'account.recovery_codes_set');
INSERT INTO securityEventNames (id, name) VALUES (34, 'account.recovery_codes_signin_complete');
INSERT INTO securityEventNames (id, name) VALUES (15, 'account.recovery_key_added');
INSERT INTO securityEventNames (id, name) VALUES (16, 'account.recovery_key_challenge_failure');
Expand Down
Loading