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
26 changes: 14 additions & 12 deletions src/components/ChangePasswordCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import React, { useCallback, useState } from 'react';
import { authService as defaultAuthService } from '../lib/authService';
import { useAuth } from '../context/AuthContext';
import { useVault } from '../context/VaultContext';
import {
MIN_VAULT_PASSWORD_LENGTH,
validateVaultPassword,
VAULT_PASSWORD_HINT,
} from '../lib/passwordPolicy';

// ChangePasswordCard
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -53,8 +58,7 @@ import { useVault } from '../context/VaultContext';

const ERROR_COPY = {
invalid_credentials: 'Your current password is incorrect.',
invalid_body:
'We couldn\'t apply that change. Double-check your new password meets the length requirement.',
invalid_body: `We couldn't apply that change. ${VAULT_PASSWORD_HINT}`,
precondition_failed:
'Your vault was updated in another tab or device. Reload this page and try again.',
vault_rewrap_required:
Expand All @@ -80,8 +84,6 @@ const ERROR_COPY = {
'Your account is missing vault configuration. Please sign out and sign back in.',
};

const MIN_PASSWORD_LENGTH = 12;

function errorCopy(code) {
return ERROR_COPY[code] || 'Password change failed. Please try again.';
}
Expand Down Expand Up @@ -128,10 +130,9 @@ export default function ChangePasswordCard({
setLocalError('Enter your current password.');
return;
}
if (newPassword.length < MIN_PASSWORD_LENGTH) {
setLocalError(
`Your new password must be at least ${MIN_PASSWORD_LENGTH} characters.`
);
const passwordError = validateVaultPassword(newPassword);
if (passwordError) {
setLocalError(passwordError.message);
return;
}
if (newPassword === oldPassword) {
Expand Down Expand Up @@ -276,8 +277,8 @@ export default function ChangePasswordCard({
Change password
</h2>
<p className="auth-card__hint">
Your new password re-encrypts your voting vault locally.
Other signed-in devices will be signed out.
Your new password re-encrypts your voting vault locally. Use a long
passphrase; other signed-in devices will be signed out.
</p>
</div>
<button
Expand Down Expand Up @@ -327,9 +328,10 @@ export default function ChangePasswordCard({
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={MIN_PASSWORD_LENGTH}
minLength={MIN_VAULT_PASSWORD_LENGTH}
required
/>
<span className="auth-hint">{VAULT_PASSWORD_HINT}</span>
</div>

<div className="auth-field">
Expand All @@ -343,7 +345,7 @@ export default function ChangePasswordCard({
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
minLength={MIN_PASSWORD_LENGTH}
minLength={MIN_VAULT_PASSWORD_LENGTH}
required
/>
</div>
Expand Down
29 changes: 29 additions & 0 deletions src/components/ChangePasswordCard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,35 @@ describe('ChangePasswordCard', () => {
expect(loadingVault.rewrapForPasswordChange).not.toHaveBeenCalled();
});

test('rejects weak new passwords before derivation', async () => {
const authService = makeAuthService();
const cardAuthService = makeCardAuthService();

renderCard({ authService, cardAuthService });
await waitFor(() =>
expect(screen.getByTestId('auth-probe')).toHaveTextContent('authenticated')
);

fireEvent.change(screen.getByLabelText(/current password/i), {
target: { value: 'current-password-xyz' },
});
fireEvent.change(screen.getByLabelText(/^new password$/i), {
target: { value: 'short-password' },
});
fireEvent.change(screen.getByLabelText(/confirm new password/i), {
target: { value: 'short-password' },
});
await act(async () => {
fireEvent.submit(screen.getByTestId('change-password-card'));
});

expect(screen.getByTestId('change-password-local-error')).toHaveTextContent(
/at least 16/i
);
expect(cardAuthService.deriveChangePasswordKeys).not.toHaveBeenCalled();
expect(cardAuthService.changePassword).not.toHaveBeenCalled();
});

test('non-auth failures (e.g. invalid_credentials) still render inline error and stay signed in', async () => {
// Companion case — proves the unauthorized handling is targeted
// and didn't silently gate ALL failures through handleAuthLost.
Expand Down
19 changes: 17 additions & 2 deletions src/components/VaultImportModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ 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';

Expand Down Expand Up @@ -275,6 +280,13 @@ export default function VaultImportModal({ open, onClose }) {
setSaving(true);
setError(null);
try {
if (requiresPassword) {
const passwordError = validateVaultPassword(password);
if (passwordError) {
setError(passwordError.code);
return;
}
}
const nowMs = Date.now();
const newKeys = buildKeysFromValidRows(rows, nowMs);
const basePayload = vault.data || { version: 1, keys: [] };
Expand Down Expand Up @@ -468,11 +480,12 @@ export default function VaultImportModal({ open, onClose }) {
className="auth-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={MIN_VAULT_PASSWORD_LENGTH}
data-testid="vault-import-password"
/>
<span className="auth-hint">
We need your account password to derive the vault key.
It stays on this device — the server never sees it.
{VAULT_PASSWORD_HINT} It stays on this device — the server never
sees it.
</span>
</div>
) : null}
Expand All @@ -485,6 +498,8 @@ export default function VaultImportModal({ open, onClose }) {
>
{error === 'password_required'
? 'Please enter your password.'
: error === 'password_too_short'
? VAULT_PASSWORD_HINT
: error === 'vault_stale'
? 'Your vault changed in another tab. Close this dialog and try again.'
: error === 'network_error'
Expand Down
23 changes: 21 additions & 2 deletions src/components/VaultImportModal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,21 +334,40 @@ describe('VaultImportModal — save flow (EMPTY, first write)', () => {
);
});

test('rejects short first-write passwords before vault.save', async () => {
const vault = emptyVault();
mount({ vault });
pasteInto(screen.getByTestId('vault-import-paste'), VALID_WIF_1);
await userEvent.type(
screen.getByTestId('vault-import-password'),
'too-short'
);
await waitFor(() =>
expect(screen.getByTestId('vault-import-save')).not.toBeDisabled()
);

await userEvent.click(screen.getByTestId('vault-import-save'));
expect(await screen.findByTestId('vault-import-error')).toHaveTextContent(
/at least 16/i
);
expect(vault.save).not.toHaveBeenCalled();
});

test('forwards {password, email} 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`);
await userEvent.type(
screen.getByTestId('vault-import-password'),
'my-secret'
'my-secret-passphrase'
);
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));
expect(vault.save.mock.calls[0][1]).toEqual({
password: 'my-secret',
password: 'my-secret-passphrase',
email: 'user@example.com',
});
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
Expand Down
7 changes: 7 additions & 0 deletions src/context/VaultContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
generateDataKey,
rewrapEnvelope,
} from '../lib/crypto/envelope';
import { validateVaultPassword } from '../lib/passwordPolicy';

// ---------------------------------------------------------------------------
// VaultContext
Expand Down Expand Up @@ -538,6 +539,12 @@ 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;
throw e;
}
const email = opts.email || userEmailRef.current;
if (!email) {
const e = new Error('email_required');
Expand Down
39 changes: 36 additions & 3 deletions src/context/VaultContext.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 'hunter22a';
const password = 'correct horse battery';
const email = 'new@example.com';
const saltV = SALT_A;
const data = { keys: [{ label: 'mn1', wif: 'KxFoo...' }] };
Expand Down Expand Up @@ -1035,6 +1035,33 @@ describe('VaultProvider — save() first write (EMPTY → UNLOCKED)', () => {
expect(last._hasDataKeyForTest()).toBe(false);
});

test('first write rejects weak passwords before encrypting', 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));

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);
});

test('first write rejects when user has no saltV on their identity', async () => {
const vaultService = {
load: jest.fn().mockResolvedValue({ empty: true }),
Expand Down Expand Up @@ -1063,7 +1090,10 @@ describe('VaultProvider — save() first write (EMPTY → UNLOCKED)', () => {

await expect(
act(async () => {
await last.save({ keys: [] }, { password: 'pw', email: 'x@y.com' });
await last.save(
{ keys: [] },
{ password: 'correct horse battery', email: 'x@y.com' }
);
})
).rejects.toMatchObject({ code: 'missing_salt_v' });
expect(vaultService.save).not.toHaveBeenCalled();
Expand All @@ -1090,7 +1120,10 @@ describe('VaultProvider — save() first write (EMPTY → UNLOCKED)', () => {
let caught;
await act(async () => {
try {
await last.save({ k: 1 }, { password: 'pw', email: 'user@example.com' });
await last.save(
{ k: 1 },
{ password: 'correct horse battery', email: 'user@example.com' }
);
} catch (e) {
caught = e;
}
Expand Down
17 changes: 17 additions & 0 deletions src/lib/passwordPolicy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const MIN_VAULT_PASSWORD_LENGTH = 16;

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.';

export function validateVaultPassword(password) {
if (
typeof password !== 'string' ||
password.length < MIN_VAULT_PASSWORD_LENGTH
) {
return {
code: 'password_too_short',
message: VAULT_PASSWORD_HINT,
};
}
return null;
}
2 changes: 1 addition & 1 deletion src/pages/Login.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { isValidEmailSyntax, normalizeEmail } from '../lib/crypto/normalize';

const ERROR_COPY = {
invalid_email: 'That email address doesn\'t look right — please check and try again.',
password_too_short: 'Passwords are at least 8 characters.',
password_too_short: 'Enter your account password.',
invalid_credentials:
"We couldn't sign you in with that email and password. Double-check for typos and try again.",
email_not_verified:
Expand Down
Loading
Loading