From 839ad2e5c26249b270e7ab87402ca72196954ed4 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Fri, 24 Apr 2026 22:30:49 -0700 Subject: [PATCH] fix(vault): use practical password complexity rule Replace the 16-character hard floor with an 8-character minimum plus three character classes for new vault passwords. Made-with: Cursor --- src/components/ChangePasswordCard.test.js | 6 +++--- src/components/VaultImportModal.test.js | 10 ++++----- src/context/VaultContext.test.js | 8 +++---- src/lib/passwordPolicy.js | 17 ++++++++++++--- src/pages/Register.js | 2 +- src/pages/Register.test.js | 26 ++++++++++++----------- 6 files changed, 41 insertions(+), 28 deletions(-) diff --git a/src/components/ChangePasswordCard.test.js b/src/components/ChangePasswordCard.test.js index 9bc4a78..ff2e629 100644 --- a/src/components/ChangePasswordCard.test.js +++ b/src/components/ChangePasswordCard.test.js @@ -202,9 +202,9 @@ describe('ChangePasswordCard', () => { fireEvent.submit(screen.getByTestId('change-password-card')); }); - expect(screen.getByTestId('change-password-local-error')).toHaveTextContent( - /at least 16/i - ); + const error = screen.getByTestId('change-password-local-error'); + expect(error).toHaveTextContent(/at least 8/i); + expect(error).toHaveTextContent(/3 of/i); expect(cardAuthService.deriveChangePasswordKeys).not.toHaveBeenCalled(); expect(cardAuthService.changePassword).not.toHaveBeenCalled(); }); diff --git a/src/components/VaultImportModal.test.js b/src/components/VaultImportModal.test.js index 598e984..1fd6c30 100644 --- a/src/components/VaultImportModal.test.js +++ b/src/components/VaultImportModal.test.js @@ -347,9 +347,9 @@ describe('VaultImportModal — save flow (EMPTY, first write)', () => { ); await userEvent.click(screen.getByTestId('vault-import-save')); - expect(await screen.findByTestId('vault-import-error')).toHaveTextContent( - /at least 16/i - ); + const error = await screen.findByTestId('vault-import-error'); + expect(error).toHaveTextContent(/at least 8/i); + expect(error).toHaveTextContent(/3 of/i); expect(vault.save).not.toHaveBeenCalled(); }); @@ -359,7 +359,7 @@ describe('VaultImportModal — save flow (EMPTY, first write)', () => { pasteInto(screen.getByTestId('vault-import-paste'), `${VALID_WIF_1},MN 1`); await userEvent.type( screen.getByTestId('vault-import-password'), - 'my-secret-passphrase' + 'My-secret-passphrase1' ); await waitFor(() => expect(screen.getByTestId('vault-import-save')).not.toBeDisabled() @@ -367,7 +367,7 @@ describe('VaultImportModal — save flow (EMPTY, first write)', () => { await userEvent.click(screen.getByTestId('vault-import-save')); await waitFor(() => expect(vault.save).toHaveBeenCalledTimes(1)); expect(vault.save.mock.calls[0][1]).toEqual({ - password: 'my-secret-passphrase', + password: 'My-secret-passphrase1', email: 'user@example.com', }); await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); diff --git a/src/context/VaultContext.test.js b/src/context/VaultContext.test.js index 8831584..66e5982 100644 --- a/src/context/VaultContext.test.js +++ b/src/context/VaultContext.test.js @@ -955,7 +955,7 @@ describe('VaultProvider — load() single-flight + cache (Codex round 1)', () => // --------------------------------------------------------------------------- describe('VaultProvider — save() first write (EMPTY → UNLOCKED)', () => { test('encrypts under a fresh DK, PUTs with *, installs keys, transitions UNLOCKED', async () => { - const password = 'correct horse battery'; + const password = 'Correct horse battery 1'; const email = 'new@example.com'; const saltV = SALT_A; const data = { keys: [{ label: 'mn1', wif: 'KxFoo...' }] }; @@ -1092,7 +1092,7 @@ describe('VaultProvider — save() first write (EMPTY → UNLOCKED)', () => { act(async () => { await last.save( { keys: [] }, - { password: 'correct horse battery', email: 'x@y.com' } + { password: 'Correct horse battery 1', email: 'x@y.com' } ); }) ).rejects.toMatchObject({ code: 'missing_salt_v' }); @@ -1122,7 +1122,7 @@ describe('VaultProvider — save() first write (EMPTY → UNLOCKED)', () => { try { await last.save( { k: 1 }, - { password: 'correct horse battery', email: 'user@example.com' } + { password: 'Correct horse battery 1', email: 'user@example.com' } ); } catch (e) { caught = e; @@ -1326,7 +1326,7 @@ describe('VaultProvider — save() update (UNLOCKED → UNLOCKED)', () => { try { await last.save( { version: 1, keys: [] }, - { password: 'correct horse battery' } + { password: 'Correct horse battery 1' } ); } catch (err) { caught = err; diff --git a/src/lib/passwordPolicy.js b/src/lib/passwordPolicy.js index 6407e33..fb3ef21 100644 --- a/src/lib/passwordPolicy.js +++ b/src/lib/passwordPolicy.js @@ -1,12 +1,23 @@ -export const MIN_VAULT_PASSWORD_LENGTH = 16; +export const MIN_VAULT_PASSWORD_LENGTH = 8; +export const MIN_VAULT_PASSWORD_CLASSES = 3; export const VAULT_PASSWORD_HINT = - 'Use at least 16 characters. A long passphrase is best; this protects your encrypted voting-key vault if server blobs ever leak.'; + 'Use at least 8 characters with at least 3 of: lowercase, uppercase, number, symbol.'; + +function countCharacterClasses(password) { + let count = 0; + if (/[a-z]/.test(password)) count += 1; + if (/[A-Z]/.test(password)) count += 1; + if (/[0-9]/.test(password)) count += 1; + if (/[^A-Za-z0-9]/.test(password)) count += 1; + return count; +} export function validateVaultPassword(password) { if ( typeof password !== 'string' || - password.length < MIN_VAULT_PASSWORD_LENGTH + password.length < MIN_VAULT_PASSWORD_LENGTH || + countCharacterClasses(password) < MIN_VAULT_PASSWORD_CLASSES ) { return { code: 'password_too_short', diff --git a/src/pages/Register.js b/src/pages/Register.js index 8218894..a3656bb 100644 --- a/src/pages/Register.js +++ b/src/pages/Register.js @@ -19,7 +19,7 @@ const ERROR_COPY = { server_misconfigured: 'The sysnode server is temporarily unavailable. Please try again in a moment.', invalid_body: - 'Please enter a valid email and a password of at least 16 characters.', + 'Please enter a valid email and a password that meets the requirements.', // Thrown client-side from kdf.js:subtleCrypto when window.crypto.subtle // is missing — which in practice means the SPA is being served over // plain HTTP from a non-localhost origin. Give the user the actionable diff --git a/src/pages/Register.test.js b/src/pages/Register.test.js index a8e4bdf..48514ff 100644 --- a/src/pages/Register.test.js +++ b/src/pages/Register.test.js @@ -41,15 +41,17 @@ test('validates password length and mismatch client-side', async () => { await userEvent.type(screen.getByLabelText(/^password/i), 'short'); await userEvent.type(screen.getByLabelText(/confirm password/i), 'short'); await userEvent.click(screen.getByRole('button', { name: /create account/i })); - expect(await screen.findByRole('alert')).toHaveTextContent(/at least 16/i); + const weakAlert = await screen.findByRole('alert'); + expect(weakAlert).toHaveTextContent(/at least 8/i); + expect(weakAlert).toHaveTextContent(/3 of/i); expect(service.register).not.toHaveBeenCalled(); const pw = screen.getByLabelText(/^password/i); const cf = screen.getByLabelText(/confirm password/i); await userEvent.clear(pw); await userEvent.clear(cf); - await userEvent.type(pw, 'correct horse battery staple'); - await userEvent.type(cf, 'correct horse battery mismatch'); + await userEvent.type(pw, 'Correct horse battery 1'); + await userEvent.type(cf, 'Correct horse battery 2'); await userEvent.click(screen.getByRole('button', { name: /create account/i })); expect(await screen.findByRole('alert')).toHaveTextContent(/don'?t match/i); expect(service.register).not.toHaveBeenCalled(); @@ -69,8 +71,8 @@ test('flags the offending fields aria-invalid on client-side validation errors', // Password mismatch should flag BOTH password and confirm — we don't // know which one the user mistyped. await userEvent.type(email, 'a@b.com'); - await userEvent.type(pw, 'correct horse battery staple'); - await userEvent.type(cf, 'correct horse battery mismatch'); + await userEvent.type(pw, 'Correct horse battery 1'); + await userEvent.type(cf, 'Correct horse battery 2'); await userEvent.click(screen.getByRole('button', { name: /create account/i })); await screen.findByRole('alert'); @@ -98,11 +100,11 @@ test('renders the WebCrypto-unavailable copy with an actionable fix', async () = await userEvent.type(screen.getByLabelText(/^email/i), 'a@b.com'); await userEvent.type( screen.getByLabelText(/^password/i), - 'correct horse battery staple' + 'Correct horse battery 1' ); await userEvent.type( screen.getByLabelText(/confirm password/i), - 'correct horse battery staple' + 'Correct horse battery 1' ); await userEvent.click(screen.getByRole('button', { name: /create account/i })); @@ -128,11 +130,11 @@ test('surfaces an unknown error\'s own message rather than the generic fallback' await userEvent.type(screen.getByLabelText(/^email/i), 'a@b.com'); await userEvent.type( screen.getByLabelText(/^password/i), - 'correct horse battery staple' + 'Correct horse battery 1' ); await userEvent.type( screen.getByLabelText(/confirm password/i), - 'correct horse battery staple' + 'Correct horse battery 1' ); await userEvent.click(screen.getByRole('button', { name: /create account/i })); @@ -148,11 +150,11 @@ test('shows the "check your inbox" screen on success', async () => { await userEvent.type(screen.getByLabelText(/^email/i), 'a@b.com'); await userEvent.type( screen.getByLabelText(/^password/i), - 'correct horse battery staple' + 'Correct horse battery 1' ); await userEvent.type( screen.getByLabelText(/confirm password/i), - 'correct horse battery staple' + 'Correct horse battery 1' ); await userEvent.click(screen.getByRole('button', { name: /create account/i })); @@ -161,6 +163,6 @@ test('shows the "check your inbox" screen on success', async () => { ); expect(service.register).toHaveBeenCalledWith( 'a@b.com', - 'correct horse battery staple' + 'Correct horse battery 1' ); });