From 8b8ff057b157be5c20d0dbd9b4b34a6f4ccf9f01 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sat, 25 Apr 2026 18:36:25 -0700 Subject: [PATCH] fix(auth): reject personal-info passwords Add an inline password reveal control across secret-entry forms and cap password strength when account identifiers are used, so personal-info variants cannot pass by adding extra entropy-looking characters. Made-with: Cursor --- src/App.css | 47 +++++++++++++++++++++++++ src/components/ChangePasswordCard.js | 10 +++--- src/components/DeleteAccountCard.js | 4 +-- src/components/GovernanceOpsHero.js | 4 +-- src/components/PasswordInput.js | 52 ++++++++++++++++++++++++++++ src/components/PasswordInput.test.js | 23 ++++++++++++ src/components/TwoFactorCard.js | 4 +-- src/components/VaultImportModal.js | 4 +-- src/components/VaultStatusCard.js | 4 +-- src/lib/passwordPolicy.js | 41 +++++++++++++++++++++- src/lib/passwordPolicy.test.js | 27 +++++++++++++++ src/pages/Login.js | 4 +-- src/pages/Login.test.js | 18 +++++----- src/pages/Register.js | 7 ++-- 14 files changed, 217 insertions(+), 32 deletions(-) create mode 100644 src/components/PasswordInput.js create mode 100644 src/components/PasswordInput.test.js diff --git a/src/App.css b/src/App.css index 6583537..ded98d1 100644 --- a/src/App.css +++ b/src/App.css @@ -1804,6 +1804,53 @@ box-shadow: 0 0 0 3px var(--accent-soft); } +.password-input { + position: relative; + display: flex; + align-items: center; +} + +.password-input .auth-input { + width: 100%; + padding-right: 52px; +} + +.password-input__toggle { + position: absolute; + right: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border: 0; + border-radius: 12px; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.password-input__toggle:hover, +.password-input__toggle:focus-visible { + background: rgba(30, 120, 255, 0.1); + color: var(--text); +} + +.password-input__toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.password-input__toggle svg { + display: block; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + /* Error variant — paired with aria-invalid on the input and the #
-alert container described by aria-describedby. We keep the red border visible even on :focus so the user isn't left to wonder diff --git a/src/components/ChangePasswordCard.js b/src/components/ChangePasswordCard.js index a998912..a121211 100644 --- a/src/components/ChangePasswordCard.js +++ b/src/components/ChangePasswordCard.js @@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react'; import { authService as defaultAuthService } from '../lib/authService'; import PasswordStrengthMeter from './PasswordStrengthMeter'; +import PasswordInput from './PasswordInput'; import { useAuth } from '../context/AuthContext'; import { useVault } from '../context/VaultContext'; import { @@ -308,10 +309,9 @@ export default function ChangePasswordCard({ - setOldPassword(e.target.value)} @@ -323,10 +323,9 @@ export default function ChangePasswordCard({ - setNewPassword(e.target.value)} @@ -346,10 +345,9 @@ export default function ChangePasswordCard({ - setConfirmPassword(e.target.value)} diff --git a/src/components/DeleteAccountCard.js b/src/components/DeleteAccountCard.js index 96e4658..f8d04df 100644 --- a/src/components/DeleteAccountCard.js +++ b/src/components/DeleteAccountCard.js @@ -4,6 +4,7 @@ import { useHistory } from 'react-router-dom'; import { authService as defaultAuthService } from '../lib/authService'; import { deriveLoginKeys } from '../lib/crypto/kdf'; import { useAuth } from '../context/AuthContext'; +import PasswordInput from './PasswordInput'; // DeleteAccountCard // ----------------------------------------------------------------------- @@ -219,10 +220,9 @@ export default function DeleteAccountCard({ - setPassword(e.target.value)} diff --git a/src/components/GovernanceOpsHero.js b/src/components/GovernanceOpsHero.js index a9bf113..b139b99 100644 --- a/src/components/GovernanceOpsHero.js +++ b/src/components/GovernanceOpsHero.js @@ -5,6 +5,7 @@ import { computeOpsStats } from '../lib/governanceOps'; import { formatNumber } from '../lib/formatters'; import { useAuth } from '../context/AuthContext'; import { useVault } from '../context/VaultContext'; +import PasswordInput from './PasswordInput'; // Error copy for the inline unlock form. Kept in sync with the same // map in components/VaultStatusCard.js (the /account source of truth @@ -403,10 +404,9 @@ function VaultLockedHero() { - + + + + ); +}); + +export default PasswordInput; diff --git a/src/components/PasswordInput.test.js b/src/components/PasswordInput.test.js new file mode 100644 index 0000000..ce1c531 --- /dev/null +++ b/src/components/PasswordInput.test.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import PasswordInput from './PasswordInput'; + +test('keeps password hidden by default and reveals it on explicit toggle', async () => { + render( + <> + + {}} /> + + ); + + const input = screen.getByLabelText('Password'); + expect(input).toHaveAttribute('type', 'password'); + + await userEvent.click(screen.getByRole('button', { name: /show password/i })); + expect(input).toHaveAttribute('type', 'text'); + + await userEvent.click(screen.getByRole('button', { name: /hide password/i })); + expect(input).toHaveAttribute('type', 'password'); +}); diff --git a/src/components/TwoFactorCard.js b/src/components/TwoFactorCard.js index 366abcf..800b7ce 100644 --- a/src/components/TwoFactorCard.js +++ b/src/components/TwoFactorCard.js @@ -3,6 +3,7 @@ import { toString as qrToString } from 'qrcode'; import { authService as defaultAuthService } from '../lib/authService'; import { useAuth } from '../context/AuthContext'; +import PasswordInput from './PasswordInput'; const ERROR_COPY = { invalid_totp_code: "That authenticator code didn't work. Check the current code and try again.", @@ -295,10 +296,9 @@ export default function TwoFactorCard({ - setCurrentPassword(e.target.value)} diff --git a/src/components/VaultImportModal.js b/src/components/VaultImportModal.js index 299d460..885014e 100644 --- a/src/components/VaultImportModal.js +++ b/src/components/VaultImportModal.js @@ -17,6 +17,7 @@ import { import { useAuth } from '../context/AuthContext'; import { useVault } from '../context/VaultContext'; import { authService as defaultAuthService } from '../lib/authService'; +import PasswordInput from './PasswordInput'; // VaultImportModal // ----------------------------------------------------------------------- @@ -521,10 +522,9 @@ export default function VaultImportModal({ - Password - token.length >= 3))]; } +function personalInfoTokens(userInputs) { + const tokens = []; + for (const value of Array.isArray(userInputs) ? userInputs : []) { + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized) continue; + + const [localPart] = normalized.split('@'); + if (localPart) { + tokens.push(localPart); + tokens.push(...localPart.split(/[^a-z0-9]+/)); + } + } + + return [...new Set(tokens.filter((token) => token.length >= 4))]; +} + +function findPersonalInfoToken(password, userInputs) { + const normalizedPassword = String(password || '').toLowerCase(); + if (!normalizedPassword) return null; + return ( + personalInfoTokens(userInputs).find((token) => + normalizedPassword.includes(token) + ) || null + ); +} + export function estimateVaultPasswordStrength(password, userInputs = []) { - return zxcvbn(String(password || ''), normalizeUserInputs(userInputs)); + const result = zxcvbn(String(password || ''), normalizeUserInputs(userInputs)); + const personalInfoToken = findPersonalInfoToken(password, userInputs); + if (!personalInfoToken) return result; + return { + ...result, + score: Math.min(result.score, 1), + personalInfoToken, + feedback: { + warning: PERSONAL_INFO_PASSWORD_HINT, + suggestions: [], + }, + }; } export function passwordStrengthLabel(score) { diff --git a/src/lib/passwordPolicy.test.js b/src/lib/passwordPolicy.test.js index dd32870..2854c3d 100644 --- a/src/lib/passwordPolicy.test.js +++ b/src/lib/passwordPolicy.test.js @@ -41,3 +41,30 @@ test('rejects passwords built from the email local part', () => { validateVaultPassword('sentryoperator2026!', ['sentryoperator@example.com']) ).toMatchObject({ code: 'password_too_weak' }); }); + +test('caps strength for email-like personal-info variants even when long', () => { + const userInputs = ['doejohn@gmail.com']; + const result = estimateVaultPasswordStrength( + 'doejohn@gmail.co123456789', + userInputs + ); + + expect(result.score).toBeLessThan(3); + expect(result.feedback.warning).toMatch(/email address|account identifier/i); + expect( + validateVaultPassword('doejohn@gmail.co123456789', userInputs) + ).toMatchObject({ + code: 'password_too_weak', + }); +}); + +test('keeps personal-info passwords weak after adding extra numbers', () => { + const userInputs = ['doejohn@gmail.com']; + + expect(validateVaultPassword('doejohn123!', userInputs)).toMatchObject({ + code: 'password_too_weak', + }); + expect(validateVaultPassword('doejohn123456789!', userInputs)).toMatchObject({ + code: 'password_too_weak', + }); +}); diff --git a/src/pages/Login.js b/src/pages/Login.js index b89c306..5cac8c3 100644 --- a/src/pages/Login.js +++ b/src/pages/Login.js @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { Link, useHistory, useLocation } from 'react-router-dom'; import PageMeta from '../components/PageMeta'; +import PasswordInput from '../components/PasswordInput'; import { useAuth } from '../context/AuthContext'; import { useVault } from '../context/VaultContext'; import { isValidEmailSyntax, normalizeEmail } from '../lib/crypto/normalize'; @@ -342,10 +343,9 @@ export default function Login() { - expect(service.me).toHaveBeenCalled()); await userEvent.type(screen.getByLabelText(/email/i), 'not-an-email'); - await userEvent.type(screen.getByLabelText(/password/i), 'hunter22a'); + await userEvent.type(screen.getByLabelText('Password'), 'hunter22a'); await userEvent.click(screen.getByRole('button', { name: /sign in/i })); expect(await screen.findByRole('alert')).toHaveTextContent( @@ -86,7 +86,7 @@ test('renders a friendly message when the server returns invalid_credentials', a await waitFor(() => expect(service.me).toHaveBeenCalled()); await userEvent.type(screen.getByLabelText(/email/i), 'a@b.com'); - await userEvent.type(screen.getByLabelText(/password/i), 'hunter22a'); + await userEvent.type(screen.getByLabelText('Password'), 'hunter22a'); await userEvent.click(screen.getByRole('button', { name: /sign in/i })); expect(await screen.findByRole('alert')).toHaveTextContent( @@ -112,7 +112,7 @@ test('flags both email and password aria-invalid on invalid_credentials (anti-en await waitFor(() => expect(service.me).toHaveBeenCalled()); const email = screen.getByLabelText(/email/i); - const pw = screen.getByLabelText(/password/i); + const pw = screen.getByLabelText('Password'); await userEvent.type(email, 'a@b.com'); await userEvent.type(pw, 'hunter22a'); await userEvent.click(screen.getByRole('button', { name: /sign in/i })); @@ -140,7 +140,7 @@ test('renders the WebCrypto-unavailable copy with an actionable fix', async () = await waitFor(() => expect(service.me).toHaveBeenCalled()); await userEvent.type(screen.getByLabelText(/email/i), 'a@b.com'); - await userEvent.type(screen.getByLabelText(/password/i), 'hunter22a'); + await userEvent.type(screen.getByLabelText('Password'), 'hunter22a'); await userEvent.click(screen.getByRole('button', { name: /sign in/i })); const alert = await screen.findByRole('alert'); @@ -169,7 +169,7 @@ test('sends the user to /account on successful login', async () => { await waitFor(() => expect(service.me).toHaveBeenCalledTimes(1)); await userEvent.type(screen.getByLabelText(/email/i), 'a@b.com'); - await userEvent.type(screen.getByLabelText(/password/i), 'hunter22a'); + await userEvent.type(screen.getByLabelText('Password'), 'hunter22a'); await act(async () => { await userEvent.click(screen.getByRole('button', { name: /sign in/i })); }); @@ -204,7 +204,7 @@ test('completes a pending TOTP login before navigating', async () => { await waitFor(() => expect(service.me).toHaveBeenCalledTimes(1)); await userEvent.type(screen.getByLabelText(/email/i), 'a@b.com'); - await userEvent.type(screen.getByLabelText(/password/i), 'hunter22a'); + await userEvent.type(screen.getByLabelText('Password'), 'hunter22a'); await userEvent.click(screen.getByRole('button', { name: /sign in/i })); expect(await screen.findByText(/two-factor check/i)).toBeInTheDocument(); @@ -232,7 +232,7 @@ test('clears MFA errors when returning to password sign in', async () => { await waitFor(() => expect(service.me).toHaveBeenCalled()); await userEvent.type(screen.getByLabelText(/email/i), 'a@b.com'); - await userEvent.type(screen.getByLabelText(/password/i), 'hunter22a'); + await userEvent.type(screen.getByLabelText('Password'), 'hunter22a'); await userEvent.click(screen.getByRole('button', { name: /sign in/i })); expect(await screen.findByText(/two-factor check/i)).toBeInTheDocument(); @@ -269,7 +269,7 @@ test('fires vault.unlockWithMaster with the login-returned master (fire-and-forg await waitFor(() => expect(service.me).toHaveBeenCalledTimes(1)); await userEvent.type(screen.getByLabelText(/email/i), 'a@b.com'); - await userEvent.type(screen.getByLabelText(/password/i), 'hunter22a'); + await userEvent.type(screen.getByLabelText('Password'), 'hunter22a'); await act(async () => { await userEvent.click(screen.getByRole('button', { name: /sign in/i })); }); @@ -312,7 +312,7 @@ test('a vault auto-unlock failure does not prevent navigation or surface as a lo await waitFor(() => expect(service.me).toHaveBeenCalledTimes(1)); await userEvent.type(screen.getByLabelText(/email/i), 'a@b.com'); - await userEvent.type(screen.getByLabelText(/password/i), 'hunter22a'); + await userEvent.type(screen.getByLabelText('Password'), 'hunter22a'); await act(async () => { await userEvent.click(screen.getByRole('button', { name: /sign in/i })); }); diff --git a/src/pages/Register.js b/src/pages/Register.js index 9d8d327..e018c3e 100644 --- a/src/pages/Register.js +++ b/src/pages/Register.js @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import PageMeta from '../components/PageMeta'; import PasswordStrengthMeter from '../components/PasswordStrengthMeter'; +import PasswordInput from '../components/PasswordInput'; import { useAuth } from '../context/AuthContext'; import { isValidEmailSyntax, normalizeEmail } from '../lib/crypto/normalize'; import { @@ -213,10 +214,9 @@ export default function Register() { - Confirm password -