diff --git a/src/components/ChangePasswordCard.js b/src/components/ChangePasswordCard.js
index b3453fc..01d4f5f 100644
--- a/src/components/ChangePasswordCard.js
+++ b/src/components/ChangePasswordCard.js
@@ -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
// -----------------------------------------------------------------------
@@ -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:
@@ -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.';
}
@@ -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) {
@@ -276,8 +277,8 @@ export default function ChangePasswordCard({
Change password
- 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.
setNewPassword(e.target.value)}
- minLength={MIN_PASSWORD_LENGTH}
+ minLength={MIN_VAULT_PASSWORD_LENGTH}
required
/>
+ {VAULT_PASSWORD_HINT}
@@ -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
/>
diff --git a/src/components/ChangePasswordCard.test.js b/src/components/ChangePasswordCard.test.js
index ed88b81..9bc4a78 100644
--- a/src/components/ChangePasswordCard.test.js
+++ b/src/components/ChangePasswordCard.test.js
@@ -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.
diff --git a/src/components/VaultImportModal.js b/src/components/VaultImportModal.js
index 06bcfb5..886634b 100644
--- a/src/components/VaultImportModal.js
+++ b/src/components/VaultImportModal.js
@@ -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';
@@ -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: [] };
@@ -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"
/>
- 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.
) : null}
@@ -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'
diff --git a/src/components/VaultImportModal.test.js b/src/components/VaultImportModal.test.js
index fa5bdc1..598e984 100644
--- a/src/components/VaultImportModal.test.js
+++ b/src/components/VaultImportModal.test.js
@@ -334,13 +334,32 @@ 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()
@@ -348,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',
+ password: 'my-secret-passphrase',
email: 'user@example.com',
});
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
diff --git a/src/context/VaultContext.js b/src/context/VaultContext.js
index 4f750f8..93ae4a1 100644
--- a/src/context/VaultContext.js
+++ b/src/context/VaultContext.js
@@ -17,6 +17,7 @@ import {
generateDataKey,
rewrapEnvelope,
} from '../lib/crypto/envelope';
+import { validateVaultPassword } from '../lib/passwordPolicy';
// ---------------------------------------------------------------------------
// VaultContext
@@ -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');
diff --git a/src/context/VaultContext.test.js b/src/context/VaultContext.test.js
index 592bc09..8831584 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 = 'hunter22a';
+ const password = 'correct horse battery';
const email = 'new@example.com';
const saltV = SALT_A;
const data = { keys: [{ label: 'mn1', wif: 'KxFoo...' }] };
@@ -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 }),
@@ -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();
@@ -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;
}
diff --git a/src/lib/passwordPolicy.js b/src/lib/passwordPolicy.js
new file mode 100644
index 0000000..6407e33
--- /dev/null
+++ b/src/lib/passwordPolicy.js
@@ -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;
+}
diff --git a/src/pages/Login.js b/src/pages/Login.js
index ad3272f..8059007 100644
--- a/src/pages/Login.js
+++ b/src/pages/Login.js
@@ -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:
diff --git a/src/pages/Register.js b/src/pages/Register.js
index 9c04090..8218894 100644
--- a/src/pages/Register.js
+++ b/src/pages/Register.js
@@ -4,16 +4,22 @@ import { Link } from 'react-router-dom';
import PageMeta from '../components/PageMeta';
import { useAuth } from '../context/AuthContext';
import { isValidEmailSyntax, normalizeEmail } from '../lib/crypto/normalize';
+import {
+ MIN_VAULT_PASSWORD_LENGTH,
+ validateVaultPassword,
+ VAULT_PASSWORD_HINT,
+} from '../lib/passwordPolicy';
const ERROR_COPY = {
invalid_email: 'That email address doesn\'t look right — please check and try again.',
- password_too_short: 'Password must be at least 8 characters.',
+ password_too_short: VAULT_PASSWORD_HINT,
password_mismatch: 'The passwords you entered don\'t match.',
network_error:
'We couldn\'t reach the sysnode server. Check your connection and try again.',
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 8 characters.',
+ invalid_body:
+ 'Please enter a valid email and a password of at least 16 characters.',
// 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
@@ -52,13 +58,6 @@ function inputClass(fields, name) {
return fields.includes(name) ? 'auth-input auth-input--error' : 'auth-input';
}
-// We intentionally enforce only a length floor on the client. The server
-// never sees the password directly — it only receives the PBKDF2+HKDF
-// output — so enforcing arbitrary character-class rules would add friction
-// without buying anything. Users who want stronger passwords are welcome
-// to use longer ones; the KDF work factor cushions the rest.
-const MIN_PASSWORD_LEN = 8;
-
export default function Register() {
const { register } = useAuth();
@@ -81,10 +80,11 @@ export default function Register() {
});
return;
}
- if (password.length < MIN_PASSWORD_LEN) {
+ const passwordError = validateVaultPassword(password);
+ if (passwordError) {
setError({
- code: 'password_too_short',
- message: errorToCopy('password_too_short'),
+ code: passwordError.code,
+ message: passwordError.message,
});
return;
}
@@ -174,8 +174,8 @@ export default function Register() {
Create your account
Your password derives a key in your browser — Sysnode never sees
- or stores it. Choose something you'll remember, because a lost
- password means a lost voting vault.
+ or stores it. Choose a long passphrase you'll remember, because a
+ lost password means a lost voting vault.
@@ -218,13 +218,14 @@ export default function Register() {
onChange={function onPasswordChange(e) {
setPassword(e.target.value);
}}
+ minLength={MIN_VAULT_PASSWORD_LENGTH}
aria-invalid={errorFields.includes('password') || undefined}
aria-describedby={
errorFields.includes('password') ? 'register-alert' : undefined
}
required
/>
- At least 8 characters.
+ {VAULT_PASSWORD_HINT}
@@ -240,6 +241,7 @@ export default function Register() {
onChange={function onConfirmChange(e) {
setConfirm(e.target.value);
}}
+ minLength={MIN_VAULT_PASSWORD_LENGTH}
aria-invalid={errorFields.includes('confirm') || undefined}
aria-describedby={
errorFields.includes('confirm') ? 'register-alert' : undefined
diff --git a/src/pages/Register.test.js b/src/pages/Register.test.js
index 75e352b..a8e4bdf 100644
--- a/src/pages/Register.test.js
+++ b/src/pages/Register.test.js
@@ -41,15 +41,15 @@ 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 8/i);
+ expect(await screen.findByRole('alert')).toHaveTextContent(/at least 16/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, 'hunter22a');
- await userEvent.type(cf, 'hunter22b');
+ await userEvent.type(pw, 'correct horse battery staple');
+ await userEvent.type(cf, 'correct horse battery mismatch');
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 +69,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, 'hunter22a');
- await userEvent.type(cf, 'hunter22b');
+ await userEvent.type(pw, 'correct horse battery staple');
+ await userEvent.type(cf, 'correct horse battery mismatch');
await userEvent.click(screen.getByRole('button', { name: /create account/i }));
await screen.findByRole('alert');
@@ -96,8 +96,14 @@ test('renders the WebCrypto-unavailable copy with an actionable fix', async () =
renderRegister(service);
await userEvent.type(screen.getByLabelText(/^email/i), 'a@b.com');
- await userEvent.type(screen.getByLabelText(/^password/i), 'hunter22a');
- await userEvent.type(screen.getByLabelText(/confirm password/i), 'hunter22a');
+ await userEvent.type(
+ screen.getByLabelText(/^password/i),
+ 'correct horse battery staple'
+ );
+ await userEvent.type(
+ screen.getByLabelText(/confirm password/i),
+ 'correct horse battery staple'
+ );
await userEvent.click(screen.getByRole('button', { name: /create account/i }));
const alert = await screen.findByRole('alert');
@@ -120,8 +126,14 @@ test('surfaces an unknown error\'s own message rather than the generic fallback'
renderRegister(service);
await userEvent.type(screen.getByLabelText(/^email/i), 'a@b.com');
- await userEvent.type(screen.getByLabelText(/^password/i), 'hunter22a');
- await userEvent.type(screen.getByLabelText(/confirm password/i), 'hunter22a');
+ await userEvent.type(
+ screen.getByLabelText(/^password/i),
+ 'correct horse battery staple'
+ );
+ await userEvent.type(
+ screen.getByLabelText(/confirm password/i),
+ 'correct horse battery staple'
+ );
await userEvent.click(screen.getByRole('button', { name: /create account/i }));
const alert = await screen.findByRole('alert');
@@ -134,12 +146,21 @@ test('shows the "check your inbox" screen on success', async () => {
renderRegister(service);
await userEvent.type(screen.getByLabelText(/^email/i), 'a@b.com');
- await userEvent.type(screen.getByLabelText(/^password/i), 'hunter22a');
- await userEvent.type(screen.getByLabelText(/confirm password/i), 'hunter22a');
+ await userEvent.type(
+ screen.getByLabelText(/^password/i),
+ 'correct horse battery staple'
+ );
+ await userEvent.type(
+ screen.getByLabelText(/confirm password/i),
+ 'correct horse battery staple'
+ );
await userEvent.click(screen.getByRole('button', { name: /create account/i }));
await waitFor(() =>
expect(screen.getByText(/check your inbox/i)).toBeInTheDocument()
);
- expect(service.register).toHaveBeenCalledWith('a@b.com', 'hunter22a');
+ expect(service.register).toHaveBeenCalledWith(
+ 'a@b.com',
+ 'correct horse battery staple'
+ );
});