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
// ---------------------------------------------------------------------------