diff --git a/packages/db-migrations/databases/fxa/patches/patch-195-196.sql b/packages/db-migrations/databases/fxa/patches/patch-195-196.sql new file mode 100644 index 00000000000..f19e6b2ee30 --- /dev/null +++ b/packages/db-migrations/databases/fxa/patches/patch-195-196.sql @@ -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'; diff --git a/packages/db-migrations/databases/fxa/patches/patch-196-195.sql b/packages/db-migrations/databases/fxa/patches/patch-196-195.sql new file mode 100644 index 00000000000..3c4317568a3 --- /dev/null +++ b/packages/db-migrations/databases/fxa/patches/patch-196-195.sql @@ -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'; diff --git a/packages/db-migrations/databases/fxa/target-patch.json b/packages/db-migrations/databases/fxa/target-patch.json index 4cbaf4bbb77..ecc8fa140a6 100644 --- a/packages/db-migrations/databases/fxa/target-patch.json +++ b/packages/db-migrations/databases/fxa/target-patch.json @@ -1,3 +1,3 @@ { - "level": 195 + "level": 196 } diff --git a/packages/fxa-settings/src/components/Settings/PageRecentActivity/SecurityEvent.tsx b/packages/fxa-settings/src/components/Settings/PageRecentActivity/SecurityEvent.tsx index 264cc9f046d..452b6b41639 100644 --- a/packages/fxa-settings/src/components/Settings/PageRecentActivity/SecurityEvent.tsx +++ b/packages/fxa-settings/src/components/Settings/PageRecentActivity/SecurityEvent.tsx @@ -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 = 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 = + new Set([ + SecurityEventName.PasswordUpgradeSuccess, + SecurityEventName.SigninConfirmBypassKnownIp, + SecurityEventName.SigninConfirmBypassNewAccount, + SecurityEventName.SigninConfirmBypassKnownDevice, + SecurityEventName.PasskeyWrapDeleted, + SecurityEventName.PasskeyWrapRetrieved, + SecurityEventName.PasskeyWrapRetrievalFailure, + ]); const getSecurityEventNameL10n = (name: string) => { switch (name) { @@ -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', diff --git a/packages/fxa-settings/src/components/Settings/PageRecentActivity/en.ftl b/packages/fxa-settings/src/components/Settings/PageRecentActivity/en.ftl index 08d4d008fc7..707f420df6c 100644 --- a/packages/fxa-settings/src/components/Settings/PageRecentActivity/en.ftl +++ b/packages/fxa-settings/src/components/Settings/PageRecentActivity/en.ftl @@ -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 diff --git a/packages/fxa-settings/src/components/Settings/PageRecentActivity/index.test.tsx b/packages/fxa-settings/src/components/Settings/PageRecentActivity/index.test.tsx index 466eb405da2..a397c983aa7 100644 --- a/packages/fxa-settings/src/components/Settings/PageRecentActivity/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/PageRecentActivity/index.test.tsx @@ -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', () => { @@ -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' }, diff --git a/packages/fxa-shared/db/models/auth/security-event.ts b/packages/fxa-shared/db/models/auth/security-event.ts index 23e92d3a132..4071c13dccd 100644 --- a/packages/fxa-shared/db/models/auth/security-event.ts +++ b/packages/fxa-shared/db/models/auth/security-event.ts @@ -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; export type SecurityEventNames = keyof typeof EVENT_NAMES; diff --git a/packages/fxa-shared/test/db/models/auth/security-event-names.sql b/packages/fxa-shared/test/db/models/auth/security-event-names.sql index 167bb3d6116..5e07639a98f 100644 --- a/packages/fxa-shared/test/db/models/auth/security-event-names.sql +++ b/packages/fxa-shared/test/db/models/auth/security-event-names.sql @@ -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'); @@ -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');