diff --git a/src/components/VaultImportModal.js b/src/components/VaultImportModal.js index 00981ff..299d460 100644 --- a/src/components/VaultImportModal.js +++ b/src/components/VaultImportModal.js @@ -14,13 +14,9 @@ import { summariseRows, validateImportEntryAsync, } from '../lib/vaultData'; -import { - MIN_VAULT_PASSWORD_LENGTH, - validateVaultPassword, - VAULT_PASSWORD_HINT, -} from '../lib/passwordPolicy'; import { useAuth } from '../context/AuthContext'; import { useVault } from '../context/VaultContext'; +import { authService as defaultAuthService } from '../lib/authService'; // VaultImportModal // ----------------------------------------------------------------------- @@ -91,7 +87,11 @@ function lineBounds(text, lineNo) { return { start, end: start + lines[lineNo - 1].length }; } -export default function VaultImportModal({ open, onClose }) { +export default function VaultImportModal({ + open, + onClose, + authService = defaultAuthService, +}) { const vault = useVault(); const { user } = useAuth(); @@ -172,8 +172,25 @@ export default function VaultImportModal({ open, onClose }) { // focused) instead of a silently-disabled button — clicking a disabled // button leaves the user with no signal about which field is missing. const canSave = !saving && !validating && summary.valid > 0; + // Password-field failures: empty is a local pre-flight failure; + // password_mismatch is the server-side verification result for the + // EMPTY first-write path. + // A mismatch is far more likely than a network blip — so we treat + // it as a password-field error rather than a generic banner. const passwordError = - error === 'password_required' || error === 'password_too_short'; + error === 'password_required' || error === 'password_mismatch'; + + // verifyAuthHash callback for VaultContext.save() on the EMPTY path. + // The vault password and the account password are the same secret; + // VaultContext derives an authHash from the typed password and asks + // us to confirm it against the server before encrypting under the + // matching vaultKey. authService.verifyPassword normalises a 401 to + // `{ code: 'invalid_credentials' }`, which VaultContext re-tags to + // `password_mismatch`. We pass it through unchanged. + const verifyAuthHash = useCallback( + (authHash) => authService.verifyPassword({ authHash }), + [authService] + ); // ESC closes, focus lands in the textarea on open, state resets on // close. Done as a single effect keyed on `open` so the lifecycle @@ -286,39 +303,56 @@ export default function VaultImportModal({ open, onClose }) { try { if (requiresPassword) { // Empty input gets the explicit "please enter your password" - // copy; a non-empty but too-weak password gets the full policy - // hint. Both cases pull focus into the field so the user's - // next keystroke lands in the right place. + // copy. Non-empty values are checked by /auth/verify-password: + // this is current-password re-auth, not a new-password policy + // gate, so the server is authoritative. if (password === '') { setError('password_required'); if (passwordRef.current) passwordRef.current.focus(); return; } - const policyError = validateVaultPassword(password); - if (policyError) { - setError(policyError.code); - if (passwordRef.current) passwordRef.current.focus(); - return; - } } const nowMs = Date.now(); const newKeys = buildKeysFromValidRows(rows, nowMs); const basePayload = vault.data || { version: 1, keys: [] }; const nextPayload = addKeys(basePayload, newKeys); const saveOpts = requiresPassword - ? { password, email: user && user.email } + ? { + password, + email: user && user.email, + verifyAuthHash, + } : undefined; await vault.save(nextPayload, saveOpts); setPassword(''); setPaste(''); onClose(); } catch (e) { - setError((e && e.code) || 'save_failed'); + const code = (e && e.code) || 'save_failed'; + setError(code); + // For the password-field error class (empty / mismatch) + // we pull focus straight into the input so the user's next + // keystroke lands there rather than getting lost in the + // banner. The empty-string check already does this for the + // local pre-flight failure; this branch covers the asynchronous + // server-verification result. + if (code === 'password_mismatch' && passwordRef.current) { + passwordRef.current.focus(); + } } finally { setSaving(false); } }, - [canSave, rows, vault, requiresPassword, password, user, onClose] + [ + canSave, + rows, + vault, + requiresPassword, + password, + user, + onClose, + verifyAuthHash, + ] ); if (!open) return null; @@ -485,7 +519,7 @@ export default function VaultImportModal({ open, onClose }) { {requiresPassword ? (
- {VAULT_PASSWORD_HINT} It stays on this device — the server never - sees it. + Use the password you signed in with — your account password + is also the key that encrypts your vault on this device. + It is never sent to the server.
) : null} @@ -525,9 +559,9 @@ export default function VaultImportModal({ open, onClose }) { data-testid="vault-import-error" > {error === 'password_required' - ? 'Please enter your password.' - : error === 'password_too_short' - ? VAULT_PASSWORD_HINT + ? 'Please enter your current password.' + : error === 'password_mismatch' + ? "That doesn't match your account password. Enter the password you used to sign in." : error === 'vault_stale' ? 'Your vault changed in another tab. Close this dialog and try again.' : error === 'network_error' diff --git a/src/components/VaultImportModal.test.js b/src/components/VaultImportModal.test.js index 38ebf28..3280aa9 100644 --- a/src/components/VaultImportModal.test.js +++ b/src/components/VaultImportModal.test.js @@ -53,7 +53,7 @@ function descriptorFixtures() { }; } -function mount({ vault, user, onClose = jest.fn() } = {}) { +function mount({ vault, user, onClose = jest.fn(), authService } = {}) { const auth = { user: user || { id: 1, @@ -64,8 +64,16 @@ function mount({ vault, user, onClose = jest.fn() } = {}) { }; jest.spyOn(VaultCtx, 'useVault').mockReturnValue(vault); jest.spyOn(AuthCtx, 'useAuth').mockReturnValue(auth); - const utils = render(); - return { ...utils, onClose }; + // Default authService stub: verifyPassword resolves on every call. The + // EMPTY-flow tests assert the callback shape, and the + // mismatch test injects a rejecting stub. + const svc = authService || { + verifyPassword: jest.fn().mockResolvedValue(true), + }; + const utils = render( + + ); + return { ...utils, onClose, authService: svc }; } afterEach(() => { @@ -331,7 +339,7 @@ describe('VaultImportModal — save flow (EMPTY, first write)', () => { await userEvent.click(screen.getByTestId('vault-import-save')); const banner = await screen.findByTestId('vault-import-error'); - expect(banner).toHaveTextContent(/please enter your password/i); + expect(banner).toHaveTextContent(/please enter your current password/i); const pwInput = screen.getByTestId('vault-import-password'); expect(pwInput).toHaveClass('auth-input--error'); @@ -346,26 +354,29 @@ describe('VaultImportModal — save flow (EMPTY, first write)', () => { expect(pwInput).not.toHaveClass('auth-input--error'); }); - test('rejects short first-write passwords before vault.save', async () => { + test('does not locally policy-reject a non-empty current password', async () => { const vault = emptyVault(); - mount({ vault }); + const { onClose } = mount({ vault }); pasteInto(screen.getByTestId('vault-import-paste'), VALID_WIF_1); await userEvent.type( screen.getByTestId('vault-import-password'), - 'too-short' + 'short' ); await waitFor(() => expect(screen.getByTestId('vault-import-save')).not.toBeDisabled() ); await userEvent.click(screen.getByTestId('vault-import-save')); - 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(); + await waitFor(() => expect(vault.save).toHaveBeenCalledTimes(1)); + expect(vault.save.mock.calls[0][1]).toMatchObject({ + password: 'short', + email: 'user@example.com', + }); + expect(screen.queryByTestId('vault-import-error')).toBeNull(); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); }); - test('forwards {password, email} to vault.save on first write', async () => { + test('forwards {password, email, verifyAuthHash} to vault.save on first write', async () => { const vault = emptyVault(); const { onClose } = mount({ vault }); pasteInto(screen.getByTestId('vault-import-paste'), `${VALID_WIF_1},MN 1`); @@ -378,12 +389,84 @@ 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({ + const opts = vault.save.mock.calls[0][1]; + expect(opts).toMatchObject({ password: 'My-secret-passphrase1', email: 'user@example.com', }); + // The verifyAuthHash callback is the bridge that prevents password + // divergence on first write — it must be a function so VaultContext + // can hit POST /auth/verify-password before persisting the vault. + expect(typeof opts.verifyAuthHash).toBe('function'); await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); }); + + test('verifyAuthHash callback delegates to authService.verifyPassword', async () => { + // We don't run the full VaultContext here — just confirm the + // callback the modal hands to vault.save calls into the injected + // authService with the exact authHash payload shape the backend + // expects. This protects the contract between the modal and the + // service layer without depending on real crypto. + const verifyPassword = jest.fn().mockResolvedValue(true); + const vault = emptyVault(); + mount({ vault, authService: { verifyPassword } }); + pasteInto(screen.getByTestId('vault-import-paste'), VALID_WIF_1); + await userEvent.type( + screen.getByTestId('vault-import-password'), + 'My-secret-passphrase1' + ); + await waitFor(() => + expect(screen.getByTestId('vault-import-save')).not.toBeDisabled() + ); + await userEvent.click(screen.getByTestId('vault-import-save')); + await waitFor(() => expect(vault.save).toHaveBeenCalledTimes(1)); + const { verifyAuthHash } = vault.save.mock.calls[0][1]; + const probe = 'a'.repeat(64); + await verifyAuthHash(probe); + expect(verifyPassword).toHaveBeenCalledWith({ authHash: probe }); + }); + + test('surfaces password_mismatch with corrective copy and refocuses the field', async () => { + const vault = emptyVault({ + // Mirror the real VaultContext behaviour: re-throw the typed + // password_mismatch error from the EMPTY save path. + save: jest.fn().mockRejectedValue( + Object.assign(new Error('password_mismatch'), { + code: 'password_mismatch', + }) + ), + }); + const { onClose } = mount({ vault }); + // The modal auto-focuses the paste textarea via setTimeout(0) on + // mount. Under microtask pressure (async typing + click + save + // rejection in the catch block), that setTimeout can fire AFTER + // the catch block focuses the password input, stealing focus + // back to the textarea. Wait for the autofocus to land before + // doing anything else so the catch-block focus call wins cleanly. + const textarea = screen.getByTestId('vault-import-paste'); + await waitFor(() => expect(document.activeElement).toBe(textarea)); + + pasteInto(textarea, VALID_WIF_1); + const pwInput = screen.getByTestId('vault-import-password'); + await userEvent.type(pwInput, 'Wrong-account-password1'); + await waitFor(() => + expect(screen.getByTestId('vault-import-save')).not.toBeDisabled() + ); + await userEvent.click(screen.getByTestId('vault-import-save')); + + const banner = await screen.findByTestId('vault-import-error'); + expect(banner).toHaveTextContent(/account password/i); + expect(pwInput).toHaveClass('auth-input--error'); + expect(pwInput).toHaveAttribute('aria-invalid', 'true'); + await waitFor(() => expect(document.activeElement).toBe(pwInput)); + expect(onClose).not.toHaveBeenCalled(); + + // Typing a correction clears the error state, matching the + // password_required UX. + await userEvent.type(pwInput, '!'); + expect(screen.queryByTestId('vault-import-error')).toBeNull(); + expect(pwInput).not.toHaveClass('auth-input--error'); + }); }); describe('VaultImportModal — dismiss', () => { diff --git a/src/context/VaultContext.js b/src/context/VaultContext.js index 93ae4a1..3c1d30d 100644 --- a/src/context/VaultContext.js +++ b/src/context/VaultContext.js @@ -10,14 +10,13 @@ import React, { import { useAuth } from './AuthContext'; import { vaultService as defaultVaultService } from '../lib/vaultService'; -import { deriveVaultKey, deriveMaster } from '../lib/crypto/kdf'; +import { deriveVaultKey, deriveMaster, deriveAuthHash } from '../lib/crypto/kdf'; import { decryptEnvelope, encryptEnvelope, generateDataKey, rewrapEnvelope, } from '../lib/crypto/envelope'; -import { validateVaultPassword } from '../lib/passwordPolicy'; // --------------------------------------------------------------------------- // VaultContext @@ -473,11 +472,19 @@ export function VaultProvider({ // - UNLOCKED path: re-encrypt under the cached (dk, vaultKey). No // password prompt; no KDF; just AES-GCM + one PUT. Uses the // current etag for optimistic concurrency. - // - EMPTY path: requires opts.password + opts.email (both strings). - // Derive master → vaultKey (from user.saltV) → generate a fresh - // DK → encrypt → PUT with If-Match: '*'. On success, install dk - // and vaultKey in refs and transition EMPTY → UNLOCKED in one - // shot. + // - EMPTY path: requires opts.password + opts.email + opts.verifyAuthHash. + // Derive master → derive authHash → call verifyAuthHash(authHash) + // to confirm the typed password is the user's CURRENT account + // password — without this step the same password also derives a + // vaultKey, and a typo would silently lock the vault under a + // credential that diverges from the account password (the user + // could still log in with their real password but never unlock + // their vault). On verification success: derive vaultKey (from + // user.saltV) → generate a fresh DK → encrypt → PUT with + // If-Match: '*' → install dk + vaultKey in refs → transition + // EMPTY → UNLOCKED. verifyAuthHash MUST throw `invalid_credentials` + // on mismatch (we re-tag it as `password_mismatch` so the import + // UI can render dedicated copy). // - Any other status: throws 'vault_not_ready'. // // Errors are NEVER committed to state.error — save() is a foreground @@ -539,10 +546,19 @@ export function VaultProvider({ e.code = 'password_required'; throw e; } - const passwordError = validateVaultPassword(opts.password); - if (passwordError) { - const e = new Error(passwordError.code); - e.code = passwordError.code; + // verifyAuthHash is REQUIRED on the EMPTY path. The vault + // password and the account password are the same secret — + // they go through the same PBKDF2 → HKDF chain. Skipping + // this check means a typo silently creates a vaultKey + // derived from a password that doesn't match the user's + // stored credential; the next session lands LOCKED and + // every unlock attempt with the real account password + // fails with envelope_decrypt_failed. We surface that as + // a programmer error (not a runtime guard) so callers + // are forced to wire it up at compile time. + if (typeof opts.verifyAuthHash !== 'function') { + const e = new Error('verify_required'); + e.code = 'verify_required'; throw e; } const email = opts.email || userEmailRef.current; @@ -558,6 +574,25 @@ export function VaultProvider({ throw e; } const master = await deriveMaster(opts.password, email); + // Derive the authHash and verify it against the server's + // stored credential BEFORE we derive vaultKey or generate a + // DK. If it doesn't match we have nothing to roll back — + // no PUT has happened, no keys are installed, no state is + // mutated. Re-tag the server's `invalid_credentials` as + // `password_mismatch` so the import modal can render copy + // tailored to the "this isn't your account password" + // case rather than a generic auth error. + const authHash = await deriveAuthHash(master); + try { + await opts.verifyAuthHash(authHash); + } catch (err) { + if (err && err.code === 'invalid_credentials') { + const e = new Error('password_mismatch'); + e.code = 'password_mismatch'; + throw e; + } + throw err; + } vaultKey = await deriveVaultKey(master, saltV); dk = generateDataKey(); ifMatch = '*'; diff --git a/src/context/VaultContext.test.js b/src/context/VaultContext.test.js index 66e5982..d41a94c 100644 --- a/src/context/VaultContext.test.js +++ b/src/context/VaultContext.test.js @@ -953,7 +953,19 @@ describe('VaultProvider — load() single-flight + cache (Codex round 1)', () => // rejects with vault_stale if someone else // wrote in between. // --------------------------------------------------------------------------- +// Fixture helper: a verifyAuthHash callback that always accepts the +// supplied authHash. The vast majority of first-write tests are +// concerned with downstream behavior (encryption, etag, key install, +// reconciliation) and not with the verification ceremony itself — +// they need a "yes, the password matches" stub. Tests that +// specifically exercise verification supply their own. +const acceptVerify = jest.fn().mockResolvedValue(true); + describe('VaultProvider — save() first write (EMPTY → UNLOCKED)', () => { + beforeEach(() => { + acceptVerify.mockClear(); + }); + test('encrypts under a fresh DK, PUTs with *, installs keys, transitions UNLOCKED', async () => { const password = 'Correct horse battery 1'; const email = 'new@example.com'; @@ -982,11 +994,24 @@ describe('VaultProvider — save() first write (EMPTY → UNLOCKED)', () => { await waitFor(() => expect(last.status).toBe(STATUS.EMPTY)); await act(async () => { - const out = await last.save(data, { password, email }); + const out = await last.save(data, { + password, + email, + verifyAuthHash: acceptVerify, + }); expect(out.status).toBe(STATUS.UNLOCKED); expect(out.etag).toBe('NEW_ETAG'); }); + // The verification step ran exactly once, with a 64-char hex + // authHash that matches what /login would have submitted for + // this (password, email) pair. This is the linchpin of the + // anti-divergence guarantee — callers that wire it up know the + // typed password was confirmed against the server credential + // BEFORE we encrypted under a key derived from the same secret. + expect(acceptVerify).toHaveBeenCalledTimes(1); + expect(acceptVerify).toHaveBeenCalledWith(expect.stringMatching(/^[0-9a-f]{64}$/)); + // After first-write we MUST be UNLOCKED with the payload available // — no intermediate LOCKED state that would force the user to // unlock again. @@ -1035,10 +1060,10 @@ describe('VaultProvider — save() first write (EMPTY → UNLOCKED)', () => { expect(last._hasDataKeyForTest()).toBe(false); }); - test('first write rejects weak passwords before encrypting', async () => { + test('first write accepts any non-empty current password after server verification', async () => { const vaultService = { load: jest.fn().mockResolvedValue({ empty: true }), - save: jest.fn(), + save: jest.fn().mockResolvedValue({ etag: 'NEW_ETAG' }), }; let last; renderWithProviders({ @@ -1050,16 +1075,21 @@ describe('VaultProvider — save() first write (EMPTY → UNLOCKED)', () => { }); await waitFor(() => expect(last.status).toBe(STATUS.EMPTY)); - await expect( - act(async () => { - await last.save( - { keys: [] }, - { password: 'short-password', email: 'x@y.com' } - ); - }) - ).rejects.toMatchObject({ code: 'password_too_short' }); - expect(vaultService.save).not.toHaveBeenCalled(); - expect(last.status).toBe(STATUS.EMPTY); + await act(async () => { + const out = await last.save( + { keys: [] }, + { + password: 'short', + email: 'x@y.com', + verifyAuthHash: acceptVerify, + } + ); + expect(out.status).toBe(STATUS.UNLOCKED); + }); + + expect(acceptVerify).toHaveBeenCalledTimes(1); + expect(vaultService.save).toHaveBeenCalledTimes(1); + expect(last.status).toBe(STATUS.UNLOCKED); }); test('first write rejects when user has no saltV on their identity', async () => { @@ -1092,7 +1122,11 @@ describe('VaultProvider — save() first write (EMPTY → UNLOCKED)', () => { act(async () => { await last.save( { keys: [] }, - { password: 'Correct horse battery 1', email: 'x@y.com' } + { + password: 'Correct horse battery 1', + email: 'x@y.com', + verifyAuthHash: acceptVerify, + } ); }) ).rejects.toMatchObject({ code: 'missing_salt_v' }); @@ -1122,7 +1156,11 @@ describe('VaultProvider — save() first write (EMPTY → UNLOCKED)', () => { try { await last.save( { k: 1 }, - { password: 'Correct horse battery 1', email: 'user@example.com' } + { + password: 'Correct horse battery 1', + email: 'user@example.com', + verifyAuthHash: acceptVerify, + } ); } catch (e) { caught = e; @@ -1138,6 +1176,159 @@ describe('VaultProvider — save() first write (EMPTY → UNLOCKED)', () => { expect(last._hasVaultKeyForTest()).toBe(false); expect(last.isSaving).toBe(false); }, PBKDF2_TIMEOUT_MS); + + // --------------------------------------------------------------------- + // Anti-divergence guard. The vault password and the account password + // are the same secret feeding two different KDF outputs (authHash for + // the server, vaultKey client-side). If we don't verify the typed + // password against the server BEFORE encrypting, a typo at first + // import locks the vault under a key that doesn't match the user's + // actual account credential — every subsequent unlock with the real + // password fails forever. (Apr 2026 prod bug.) + // --------------------------------------------------------------------- + + test('first write requires a verifyAuthHash callback (programmer-error guard)', async () => { + const vaultService = { + load: jest.fn().mockResolvedValue({ empty: true }), + save: jest.fn(), + }; + let last; + renderWithProviders({ + authService: authedAuthService(), + vaultService, + onVault: (v) => { + last = v; + }, + }); + await waitFor(() => expect(last.status).toBe(STATUS.EMPTY)); + + // Good password but no verifyAuthHash — must NOT silently proceed. + let caught; + await act(async () => { + try { + await last.save( + { keys: [] }, + { + password: 'Correct horse battery 1', + email: 'user@example.com', + } + ); + } catch (e) { + caught = e; + } + }); + expect(caught).toBeTruthy(); + expect(caught.code).toBe('verify_required'); + expect(vaultService.save).not.toHaveBeenCalled(); + // No state change: still EMPTY, no keys installed. + expect(last.status).toBe(STATUS.EMPTY); + expect(last._hasDataKeyForTest()).toBe(false); + expect(last._hasVaultKeyForTest()).toBe(false); + }); + + test('first write surfaces password_mismatch when verifyAuthHash rejects with invalid_credentials', async () => { + const vaultService = { + load: jest.fn().mockResolvedValue({ empty: true }), + save: jest.fn(), + }; + let last; + renderWithProviders({ + authService: authedAuthService(), + vaultService, + onVault: (v) => { + last = v; + }, + }); + await waitFor(() => expect(last.status).toBe(STATUS.EMPTY)); + + let verifyCalls = 0; + const verifyReject = async () => { + verifyCalls += 1; + const e = new Error('invalid_credentials'); + e.code = 'invalid_credentials'; + throw e; + }; + + // Use an explicit try/catch inside act() instead of + // `expect(act(...)).rejects` — the latter pattern flushes act's + // pending-state reconciliation differently and the assertion + // can land before save's outer catch finishes its `finish()` + // commit. The existing "PUT failure" test uses this same shape. + let caught; + await act(async () => { + try { + await last.save( + { keys: [] }, + { + password: 'Wrong but well-formed password 1', + email: 'user@example.com', + verifyAuthHash: verifyReject, + } + ); + } catch (e) { + caught = e; + } + }); + expect(caught).toBeTruthy(); + expect(caught.code).toBe('password_mismatch'); + expect(verifyCalls).toBe(1); + + // The PUT must NOT have happened. The whole point of the + // verification step is to catch a divergent password BEFORE we + // commit ciphertext under a divergent key. + expect(vaultService.save).not.toHaveBeenCalled(); + expect(last.status).toBe(STATUS.EMPTY); + expect(last._hasDataKeyForTest()).toBe(false); + expect(last._hasVaultKeyForTest()).toBe(false); + expect(last.isSaving).toBe(false); + }, PBKDF2_TIMEOUT_MS); + + test('first write propagates non-credentials errors from verifyAuthHash unchanged', async () => { + const vaultService = { + load: jest.fn().mockResolvedValue({ empty: true }), + save: jest.fn(), + }; + let last; + renderWithProviders({ + authService: authedAuthService(), + vaultService, + onVault: (v) => { + last = v; + }, + }); + await waitFor(() => expect(last.status).toBe(STATUS.EMPTY)); + + // Network / 5xx: must not be silently swallowed as a password + // mismatch. The caller surfaces a "couldn't reach server" toast, + // not a wrong-password message. + const networkErr = Object.assign(new Error('network_unavailable'), { + code: 'network_unavailable', + }); + const verifyNetworkFail = async () => { + throw networkErr; + }; + + let caught; + await act(async () => { + try { + await last.save( + { keys: [] }, + { + password: 'Correct horse battery 1', + email: 'user@example.com', + verifyAuthHash: verifyNetworkFail, + } + ); + } catch (e) { + caught = e; + } + }); + expect(caught).toBeTruthy(); + expect(caught.code).toBe('network_unavailable'); + + expect(vaultService.save).not.toHaveBeenCalled(); + expect(last.status).toBe(STATUS.EMPTY); + }, PBKDF2_TIMEOUT_MS); }); describe('VaultProvider — save() update (UNLOCKED → UNLOCKED)', () => { @@ -1326,7 +1517,10 @@ describe('VaultProvider — save() update (UNLOCKED → UNLOCKED)', () => { try { await last.save( { version: 1, keys: [] }, - { password: 'Correct horse battery 1' } + { + password: 'Correct horse battery 1', + verifyAuthHash: acceptVerify, + } ); } catch (err) { caught = err; diff --git a/src/lib/apiClient.js b/src/lib/apiClient.js index 9987149..e2775b5 100644 --- a/src/lib/apiClient.js +++ b/src/lib/apiClient.js @@ -182,10 +182,20 @@ export function createApiClient({ function normalise(error) { const apiError = toApiError(error); if (apiError.status === 401 && typeof onAuthLost === 'function') { - // Don't fire on the auth endpoints themselves — a 401 on /login is - // a credential error, not a session expiry. + // Don't fire on auth endpoints that are expected to return + // credential errors, such as /auth/login. /auth/verify-password is + // the one exception: invalid_credentials is a local step-up failure, + // but any other 401 means the authenticated session is gone and the + // global auth-loss path should run. const url = (error.config && error.config.url) || ''; - if (!url.startsWith('/auth/')) { + const isAuthEndpoint = url.startsWith('/auth/'); + const isVerifyPassword = url.startsWith('/auth/verify-password'); + const isVerifyPasswordMismatch = + isVerifyPassword && apiError.code === 'invalid_credentials'; + if ( + !isAuthEndpoint || + (isVerifyPassword && !isVerifyPasswordMismatch) + ) { try { onAuthLost(apiError); } catch (_) { diff --git a/src/lib/apiClient.test.js b/src/lib/apiClient.test.js index 3d5750d..455e971 100644 --- a/src/lib/apiClient.test.js +++ b/src/lib/apiClient.test.js @@ -172,6 +172,48 @@ describe('createApiClient — 401 handling', () => { expect(onAuthLost).not.toHaveBeenCalled(); }); + test('invokes onAuthLost for non-credential /auth/verify-password 401s', async () => { + const onAuthLost = jest.fn(); + const client = createApiClient({ + baseURL: 'http://test', + readCsrf: () => null, + onAuthLost, + }); + const adapter = new MockAdapter(client); + adapter + .onPost('/auth/verify-password') + .reply(401, { error: 'unauthorized' }); + + await expect( + client.post('/auth/verify-password', {}) + ).rejects.toMatchObject({ + code: 'unauthorized', + status: 401, + }); + expect(onAuthLost).toHaveBeenCalledTimes(1); + }); + + test('does NOT invoke onAuthLost for verify-password password mismatches', async () => { + const onAuthLost = jest.fn(); + const client = createApiClient({ + baseURL: 'http://test', + readCsrf: () => null, + onAuthLost, + }); + const adapter = new MockAdapter(client); + adapter + .onPost('/auth/verify-password') + .reply(401, { error: 'invalid_credentials' }); + + await expect( + client.post('/auth/verify-password', {}) + ).rejects.toMatchObject({ + code: 'invalid_credentials', + status: 401, + }); + expect(onAuthLost).not.toHaveBeenCalled(); + }); + test('onAuthLost exception does not crash the error path', async () => { const client = createApiClient({ baseURL: 'http://test', diff --git a/src/lib/authService.js b/src/lib/authService.js index 526815f..80a4942 100644 --- a/src/lib/authService.js +++ b/src/lib/authService.js @@ -127,6 +127,51 @@ export function createAuthService(client = defaultClient) { return authHash; } + // ------------------------------------------------------------------ + // verifyPassword — read-only "is this still my current password?" + // probe. The server returns 204 on match, 401 on mismatch. + // + // Used by callers that need to confirm a typed password matches the + // stored credential BEFORE acting on something derived from the same + // password (e.g. the vault first-write flow, where the same password + // also derives the client-only vaultKey, and an unverified mismatch + // would silently lock the vault under a credential that diverges + // from the account password). + // + // Returns: + // true on 204 + // Throws: + // { code: 'invalid_credentials' } when the backend explicitly says + // the password hash mismatched + // the original API error otherwise (expired session, network…) + // ------------------------------------------------------------------ + async function verifyPassword({ authHash }) { + if (typeof authHash !== 'string' || authHash.length === 0) { + throw new Error('verifyPassword: authHash required'); + } + try { + await client.post('/auth/verify-password', { authHash }); + return true; + } catch (err) { + const status = + (err && err.status) || (err && err.response && err.response.status); + const responseData = err && err.response && err.response.data; + const code = + (err && err.code) || + (responseData && + typeof responseData === 'object' && + (responseData.error || responseData.code)); + if (status === 401 && code === 'invalid_credentials') { + const e = new Error('invalid_credentials'); + e.code = 'invalid_credentials'; + e.status = status; + e.response = err.response; + throw e; + } + throw err; + } + } + // ------------------------------------------------------------------ // PR 7 — notification preferences // ------------------------------------------------------------------ @@ -198,6 +243,7 @@ export function createAuthService(client = defaultClient) { me, deriveChangePasswordKeys, deriveStepUpAuthHash, + verifyPassword, changePassword, getPrefs, updatePrefs, diff --git a/src/lib/authService.test.js b/src/lib/authService.test.js index c897936..cfbdebb 100644 --- a/src/lib/authService.test.js +++ b/src/lib/authService.test.js @@ -135,6 +135,53 @@ describe('authService.me / logout', () => { }); }); +describe('authService.verifyPassword', () => { + const AUTH_HASH = + 'a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4'; + + test('posts authHash and resolves true on 204', async () => { + const { service, adapter } = makeService(); + let captured; + adapter.onPost('/auth/verify-password').reply((config) => { + captured = JSON.parse(config.data); + return [204]; + }); + + await expect(service.verifyPassword({ authHash: AUTH_HASH })).resolves.toBe( + true + ); + expect(captured).toEqual({ authHash: AUTH_HASH }); + }); + + test('preserves explicit invalid_credentials from the backend', async () => { + const { service, adapter } = makeService(); + adapter + .onPost('/auth/verify-password') + .reply(401, { error: 'invalid_credentials' }); + + await expect( + service.verifyPassword({ authHash: AUTH_HASH }) + ).rejects.toMatchObject({ + code: 'invalid_credentials', + status: 401, + }); + }); + + test('does not rewrite session 401s into password mismatch errors', async () => { + const { service, adapter } = makeService(); + adapter + .onPost('/auth/verify-password') + .reply(401, { error: 'unauthorized' }); + + await expect( + service.verifyPassword({ authHash: AUTH_HASH }) + ).rejects.toMatchObject({ + code: 'unauthorized', + status: 401, + }); + }); +}); + // --------------------------------------------------------------------------- // PR 7 — deriveChangePasswordKeys + changePassword // ---------------------------------------------------------------------------