diff --git a/README.md b/README.md index 6dd79b9..a98afd8 100644 --- a/README.md +++ b/README.md @@ -29,22 +29,27 @@ Sysnode is designed to make that information easier to read, easier to verify, a ## Data Source -The frontend reads live dashboard data from the Sysnode backend API. Anonymous public data can still be retargeted with `REACT_APP_API_BASE`, but the authenticated custody surface (`/auth`, `/vault`, `/gov`) is designed for same-origin production deployment: +The frontend reads live dashboard data from the Sysnode backend API. Production is designed for same-origin deployment: ```text https://sysnode.info/ -> SPA https://sysnode.info/auth/* -> backend https://sysnode.info/vault/* -> backend https://sysnode.info/gov/* -> backend +https://sysnode.info/mnStats -> backend +https://sysnode.info/mnCount -> backend +https://sysnode.info/govlist -> backend ``` -The backend aggregates data from a Syscoin Core node, Sentry Node RPC responses, market APIs, and supporting network datasets. For a fork or private deployment, override the API base URL at build time (no code change required): +The backend aggregates data from a Syscoin Core node, Sentry Node RPC responses, market APIs, and supporting network datasets. For a fork or private production deployment, keep the same-origin shape and point the reverse proxy at that deployment's backend. This keeps the CSP `connect-src` policy, host-only cookies, and CSRF model in lockstep. + +For local or non-production testing, you can override the API base URL while running the dev server: ```bash -REACT_APP_API_BASE=https://your-backend.example npm run build +REACT_APP_API_BASE=https://your-backend.example npm start ``` -The value is read at build time by both `src/lib/apiClient.js` (authenticated surface) and `src/lib/api.js` (anonymous surface — superblock timing, governance feed, masternode stats). Without it, development authenticated requests use `http://localhost:3001`; production authenticated requests use same-origin relative paths. Keeping the authenticated API under the SPA origin preserves host-only `Secure; SameSite=Lax` cookies and lets the SPA mirror the CSRF cookie into `X-CSRF-Token` without cross-site credentialed fetches. +The value is read at build time by both `src/lib/apiClient.js` (authenticated surface) and `src/lib/api.js` (anonymous surface — superblock timing, governance feed, masternode stats) outside production. Without it, development requests use `http://localhost:3001`; production requests use same-origin relative paths. Keeping the API under the SPA origin preserves host-only `Secure; SameSite=Lax` cookies and lets the SPA mirror the CSRF cookie into `X-CSRF-Token` without cross-site credentialed fetches. ## Getting Started diff --git a/package-lock.json b/package-lock.json index d1810dc..55e9237 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,8 @@ "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-router-dom": "^5.3.4", - "react-scripts": "5.0.1" + "react-scripts": "5.0.1", + "zxcvbn": "^4.4.2" }, "devDependencies": { "axios-mock-adapter": "^2.1.0" @@ -20847,6 +20848,12 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==", + "license": "MIT" } } } diff --git a/package.json b/package.json index 6e17c30..ad1d7e2 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-router-dom": "^5.3.4", - "react-scripts": "5.0.1" + "react-scripts": "5.0.1", + "zxcvbn": "^4.4.2" }, "scripts": { "start": "react-scripts start", diff --git a/server.js b/server.js index 2e1f79b..52c3c07 100644 --- a/server.js +++ b/server.js @@ -16,10 +16,18 @@ function uniqueSources(sources) { return [...new Set(sources.filter(Boolean))]; } +function explicitConnectSources(value) { + return splitCspSources(value).filter( + (source) => !['*', 'http:', 'https:', 'ws:', 'wss:'].includes(source) + ); +} + const connectSrc = uniqueSources([ "'self'", - 'https:', - ...splitCspSources(process.env.SYSNODE_CSP_CONNECT_SRC), + // Key-custody pages must not allow arbitrary HTTPS exfiltration. Production + // API traffic is same-origin by default; deployments that truly need another + // endpoint can add exact origins via SYSNODE_CSP_CONNECT_SRC. + ...explicitConnectSources(process.env.SYSNODE_CSP_CONNECT_SRC), ]); // HSTS is owned in code so any deployer (behind nginx, Caddy, a managed load diff --git a/src/App.css b/src/App.css index 331eecc..6583537 100644 --- a/src/App.css +++ b/src/App.css @@ -1844,6 +1844,56 @@ color: var(--muted); } +.password-meter { + display: grid; + gap: 6px; + margin-top: 2px; + font-size: 0.82rem; + color: var(--muted); +} + +.password-meter__topline { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.password-meter__topline strong { + color: var(--text); +} + +.password-meter__track { + height: 8px; + overflow: hidden; + border-radius: 999px; + background: rgba(20, 30, 43, 0.1); +} + +.password-meter__bar { + display: block; + height: 100%; + border-radius: inherit; + background: #c63024; + transition: width 140ms ease, background-color 140ms ease; +} + +.password-meter[data-score='1'] .password-meter__bar { + background: #d97826; +} + +.password-meter[data-score='2'] .password-meter__bar { + background: #caa21d; +} + +.password-meter[data-score='3'] .password-meter__bar, +.password-meter[data-score='4'] .password-meter__bar { + background: #238857; +} + +.password-meter__feedback { + margin: 0; +} + .auth-alert { background: rgba(229, 107, 85, 0.1); border: 1px solid rgba(229, 107, 85, 0.35); diff --git a/src/components/ChangePasswordCard.js b/src/components/ChangePasswordCard.js index 01d4f5f..a998912 100644 --- a/src/components/ChangePasswordCard.js +++ b/src/components/ChangePasswordCard.js @@ -1,6 +1,7 @@ import React, { useCallback, useState } from 'react'; import { authService as defaultAuthService } from '../lib/authService'; +import PasswordStrengthMeter from './PasswordStrengthMeter'; import { useAuth } from '../context/AuthContext'; import { useVault } from '../context/VaultContext'; import { @@ -109,6 +110,7 @@ export default function ChangePasswordCard({ const [localError, setLocalError] = useState(null); const [success, setSuccess] = useState(false); const [open, setOpen] = useState(defaultOpen); + const userEmail = user && user.email ? user.email : ''; const clearFeedback = useCallback(() => { setErrCode(null); @@ -130,7 +132,7 @@ export default function ChangePasswordCard({ setLocalError('Enter your current password.'); return; } - const passwordError = validateVaultPassword(newPassword); + const passwordError = validateVaultPassword(newPassword, [user.email]); if (passwordError) { setLocalError(passwordError.message); return; @@ -329,9 +331,15 @@ export default function ChangePasswordCard({ value={newPassword} onChange={(e) => setNewPassword(e.target.value)} minLength={MIN_VAULT_PASSWORD_LENGTH} + aria-describedby="cp-new-strength" required /> {VAULT_PASSWORD_HINT} +
diff --git a/src/components/ChangePasswordCard.test.js b/src/components/ChangePasswordCard.test.js index ff2e629..10fdcc6 100644 --- a/src/components/ChangePasswordCard.test.js +++ b/src/components/ChangePasswordCard.test.js @@ -193,18 +193,17 @@ describe('ChangePasswordCard', () => { target: { value: 'current-password-xyz' }, }); fireEvent.change(screen.getByLabelText(/^new password$/i), { - target: { value: 'short-password' }, + target: { value: 'Password1!' }, }); fireEvent.change(screen.getByLabelText(/confirm new password/i), { - target: { value: 'short-password' }, + target: { value: 'Password1!' }, }); await act(async () => { fireEvent.submit(screen.getByTestId('change-password-card')); }); const error = screen.getByTestId('change-password-local-error'); - expect(error).toHaveTextContent(/at least 8/i); - expect(error).toHaveTextContent(/3 of/i); + expect(error).toHaveTextContent(/common|another word/i); expect(cardAuthService.deriveChangePasswordKeys).not.toHaveBeenCalled(); expect(cardAuthService.changePassword).not.toHaveBeenCalled(); }); diff --git a/src/components/PasswordStrengthMeter.js b/src/components/PasswordStrengthMeter.js new file mode 100644 index 0000000..90ee6c7 --- /dev/null +++ b/src/components/PasswordStrengthMeter.js @@ -0,0 +1,62 @@ +import React, { useMemo } from 'react'; + +import { + estimateVaultPasswordStrength, + MIN_VAULT_PASSWORD_LENGTH, + MIN_VAULT_PASSWORD_SCORE, + passwordStrengthFeedback, + passwordStrengthLabel, + VAULT_PASSWORD_HINT, +} from '../lib/passwordPolicy'; + +export default function PasswordStrengthMeter({ + password, + userInputs, + id, + describedBy, +}) { + const result = useMemo( + () => estimateVaultPasswordStrength(password, userInputs), + [password, userInputs] + ); + const hasPassword = typeof password === 'string' && password.length > 0; + const lengthOk = hasPassword && password.length >= MIN_VAULT_PASSWORD_LENGTH; + const scoreOk = lengthOk && result.score >= MIN_VAULT_PASSWORD_SCORE; + const label = hasPassword ? passwordStrengthLabel(result.score) : 'Start typing'; + const feedback = !hasPassword + ? VAULT_PASSWORD_HINT + : !lengthOk + ? `Use at least ${MIN_VAULT_PASSWORD_LENGTH} characters.` + : scoreOk + ? 'Looks strong enough for vault encryption.' + : passwordStrengthFeedback(result); + + return ( +
+
+ Password strength + {label} +
+
+ +
+

{feedback}

+
+ ); +} diff --git a/src/lib/api.js b/src/lib/api.js index 8124863..dbb4472 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -14,15 +14,14 @@ import axios from 'axios'; // flips true. Codex PR20 round 7 P1. // // Priority (mirrors apiClient.js): -// 1. REACT_APP_API_BASE (build-time override for bespoke deployments) -// 2. Production builds → https://syscoin.dev (the default -// hosted backend, same host the authenticated client picks up) +// 1. Production builds → same-origin relative paths. The deployment +// reverse-proxies these anonymous endpoints next to the SPA. +// 2. Non-production REACT_APP_API_BASE override for local/bespoke testing. // 3. Development builds → http://localhost:3001 (backend dev server) const DEFAULT_BASE = - process.env.REACT_APP_API_BASE || (process.env.NODE_ENV === 'production' - ? 'https://syscoin.dev' - : 'http://localhost:3001'); + ? '' + : process.env.REACT_APP_API_BASE || 'http://localhost:3001'); const client = axios.create({ baseURL: DEFAULT_BASE, diff --git a/src/lib/apiClient.js b/src/lib/apiClient.js index e2775b5..c7e6987 100644 --- a/src/lib/apiClient.js +++ b/src/lib/apiClient.js @@ -21,18 +21,18 @@ import axios from 'axios'; // Default API base URL. // // Priority: -// 1. REACT_APP_API_BASE (build-time override for bespoke deployments) -// 2. Production builds → same-origin relative paths. Production must +// 1. Production builds → same-origin relative paths. Production must // reverse-proxy /auth, /vault, and /gov under the SPA origin so // host-only SameSite=Lax cookies and the readable csrf cookie work // without cross-site credentialed fetches. +// 2. Non-production REACT_APP_API_BASE override for local/bespoke testing. // 3. Development builds → http://localhost:3001 (backend dev server) export function resolveDefaultApiBase({ apiBase = process.env.REACT_APP_API_BASE, nodeEnv = process.env.NODE_ENV, } = {}) { - if (apiBase) return apiBase; - return nodeEnv === 'production' ? '' : 'http://localhost:3001'; + if (nodeEnv === 'production') return ''; + return apiBase || 'http://localhost:3001'; } const DEFAULT_BASE = resolveDefaultApiBase(); diff --git a/src/lib/apiClient.test.js b/src/lib/apiClient.test.js index 455e971..d3c076f 100644 --- a/src/lib/apiClient.test.js +++ b/src/lib/apiClient.test.js @@ -40,13 +40,13 @@ describe('readCsrfCookie', () => { }); describe('resolveDefaultApiBase', () => { - test('uses explicit REACT_APP_API_BASE override', () => { + test('keeps production authenticated calls same-origin even with an override', () => { expect( resolveDefaultApiBase({ apiBase: 'https://api.example.test', nodeEnv: 'production', }) - ).toBe('https://api.example.test'); + ).toBe(''); }); test('defaults production authenticated calls to same-origin relative paths', () => { @@ -60,6 +60,15 @@ describe('resolveDefaultApiBase', () => { 'http://localhost:3001' ); }); + + test('uses explicit REACT_APP_API_BASE override outside production', () => { + expect( + resolveDefaultApiBase({ + apiBase: 'https://api.example.test', + nodeEnv: 'development', + }) + ).toBe('https://api.example.test'); + }); }); describe('createApiClient — CSRF attachment', () => { diff --git a/src/lib/passwordPolicy.js b/src/lib/passwordPolicy.js index fb3ef21..1010ca7 100644 --- a/src/lib/passwordPolicy.js +++ b/src/lib/passwordPolicy.js @@ -1,28 +1,74 @@ +import zxcvbn from 'zxcvbn'; + export const MIN_VAULT_PASSWORD_LENGTH = 8; -export const MIN_VAULT_PASSWORD_CLASSES = 3; +export const MIN_VAULT_PASSWORD_SCORE = 3; export const VAULT_PASSWORD_HINT = - 'Use at least 8 characters with at least 3 of: lowercase, uppercase, number, symbol.'; - -function countCharacterClasses(password) { - let count = 0; - if (/[a-z]/.test(password)) count += 1; - if (/[A-Z]/.test(password)) count += 1; - if (/[0-9]/.test(password)) count += 1; - if (/[^A-Za-z0-9]/.test(password)) count += 1; - return count; + 'Use at least 8 characters. Longer passphrases are best; weak or common passwords are rejected.'; + +export const PASSWORD_STRENGTH_LABELS = [ + 'Very weak', + 'Weak', + 'Fair', + 'Strong', + 'Very strong', +]; + +function normalizeUserInputs(userInputs) { + const tokens = []; + for (const value of Array.isArray(userInputs) ? userInputs : []) { + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized) continue; + + tokens.push(normalized); + + const [localPart, domain] = normalized.split('@'); + if (localPart && domain) { + tokens.push(localPart, domain); + tokens.push(...domain.split('.')); + } + + tokens.push(...normalized.split(/[^a-z0-9]+/)); + } + + return [...new Set(tokens.filter((token) => token.length >= 3))]; } -export function validateVaultPassword(password) { +export function estimateVaultPasswordStrength(password, userInputs = []) { + return zxcvbn(String(password || ''), normalizeUserInputs(userInputs)); +} + +export function passwordStrengthLabel(score) { + const index = Math.max(0, Math.min(PASSWORD_STRENGTH_LABELS.length - 1, score)); + return PASSWORD_STRENGTH_LABELS[index]; +} + +export function passwordStrengthFeedback(result) { + if (!result || !result.feedback) return VAULT_PASSWORD_HINT; + const parts = [ + result.feedback.warning, + ...(result.feedback.suggestions || []), + ].filter(Boolean); + return parts.length > 0 ? parts.join(' ') : VAULT_PASSWORD_HINT; +} + +export function validateVaultPassword(password, userInputs = []) { if ( typeof password !== 'string' || - password.length < MIN_VAULT_PASSWORD_LENGTH || - countCharacterClasses(password) < MIN_VAULT_PASSWORD_CLASSES + password.length < MIN_VAULT_PASSWORD_LENGTH ) { return { code: 'password_too_short', message: VAULT_PASSWORD_HINT, }; } + const result = estimateVaultPasswordStrength(password, userInputs); + if (result.score < MIN_VAULT_PASSWORD_SCORE) { + return { + code: 'password_too_weak', + message: passwordStrengthFeedback(result), + score: result.score, + }; + } return null; } diff --git a/src/lib/passwordPolicy.test.js b/src/lib/passwordPolicy.test.js new file mode 100644 index 0000000..dd32870 --- /dev/null +++ b/src/lib/passwordPolicy.test.js @@ -0,0 +1,43 @@ +import { + estimateVaultPasswordStrength, + MIN_VAULT_PASSWORD_LENGTH, + validateVaultPassword, +} from './passwordPolicy'; + +test('rejects passwords below the minimum length', () => { + expect(validateVaultPassword('A7$qP9z')).toMatchObject({ + code: 'password_too_short', + }); +}); + +test('rejects common passwords even when they meet length and class rules', () => { + const result = validateVaultPassword('Password1!'); + expect(result).toMatchObject({ code: 'password_too_weak' }); + expect(result.message).toMatch(/common|another word/i); +}); + +test('accepts strong passphrases with spaces', () => { + expect(validateVaultPassword('correct horse battery 1')).toBeNull(); +}); + +test('accepts generated passwords once estimator score is strong enough', () => { + expect(validateVaultPassword('A7$qP9z!mK')).toBeNull(); +}); + +test('feeds user inputs into the strength estimator', () => { + const emailLocalPart = 'sentryoperator'; + const password = `${emailLocalPart}2026!`; + const withoutEmail = estimateVaultPasswordStrength(password); + const withEmail = estimateVaultPasswordStrength(password, [ + `${emailLocalPart}@example.com`, + ]); + + expect(password.length).toBeGreaterThanOrEqual(MIN_VAULT_PASSWORD_LENGTH); + expect(withEmail.guesses).toBeLessThan(withoutEmail.guesses); +}); + +test('rejects passwords built from the email local part', () => { + expect( + validateVaultPassword('sentryoperator2026!', ['sentryoperator@example.com']) + ).toMatchObject({ code: 'password_too_weak' }); +}); diff --git a/src/pages/Register.js b/src/pages/Register.js index a3656bb..9d8d327 100644 --- a/src/pages/Register.js +++ b/src/pages/Register.js @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { Link } from 'react-router-dom'; import PageMeta from '../components/PageMeta'; +import PasswordStrengthMeter from '../components/PasswordStrengthMeter'; import { useAuth } from '../context/AuthContext'; import { isValidEmailSyntax, normalizeEmail } from '../lib/crypto/normalize'; import { @@ -13,6 +14,7 @@ import { const ERROR_COPY = { invalid_email: 'That email address doesn\'t look right — please check and try again.', password_too_short: VAULT_PASSWORD_HINT, + password_too_weak: 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.', @@ -33,6 +35,7 @@ const ERROR_COPY = { const FIELDS_BY_CODE = { invalid_email: ['email'], password_too_short: ['password'], + password_too_weak: ['password'], password_mismatch: ['password', 'confirm'], invalid_body: ['email', 'password'], }; @@ -80,7 +83,7 @@ export default function Register() { }); return; } - const passwordError = validateVaultPassword(password); + const passwordError = validateVaultPassword(password, [normalized]); if (passwordError) { setError({ code: passwordError.code, @@ -116,6 +119,7 @@ export default function Register() { } const errorFields = error ? fieldsForCode(error.code) : []; + const normalizedEmail = normalizeEmail(email); if (submittedTo) { return ( @@ -221,11 +225,18 @@ export default function Register() { minLength={MIN_VAULT_PASSWORD_LENGTH} aria-invalid={errorFields.includes('password') || undefined} aria-describedby={ - errorFields.includes('password') ? 'register-alert' : undefined + errorFields.includes('password') + ? 'register-password-meter register-alert' + : 'register-password-meter' } required /> {VAULT_PASSWORD_HINT} +
diff --git a/src/pages/Register.test.js b/src/pages/Register.test.js index 48514ff..50660ec 100644 --- a/src/pages/Register.test.js +++ b/src/pages/Register.test.js @@ -43,7 +43,6 @@ test('validates password length and mismatch client-side', async () => { await userEvent.click(screen.getByRole('button', { name: /create account/i })); const weakAlert = await screen.findByRole('alert'); expect(weakAlert).toHaveTextContent(/at least 8/i); - expect(weakAlert).toHaveTextContent(/3 of/i); expect(service.register).not.toHaveBeenCalled(); const pw = screen.getByLabelText(/^password/i); @@ -57,6 +56,23 @@ test('validates password length and mismatch client-side', async () => { expect(service.register).not.toHaveBeenCalled(); }); +test('rejects common passwords and renders strength meter feedback', async () => { + const service = mockService(); + renderRegister(service); + + await userEvent.type(screen.getByLabelText(/^email/i), 'a@b.com'); + await userEvent.type(screen.getByLabelText(/^password/i), 'Password1!'); + await userEvent.type(screen.getByLabelText(/confirm password/i), 'Password1!'); + + expect(screen.getByText(/password strength/i)).toBeInTheDocument(); + expect(screen.getByText('Weak')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /create account/i })); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(/common|another word/i); + expect(service.register).not.toHaveBeenCalled(); +}); + test('flags the offending fields aria-invalid on client-side validation errors', async () => { // The alert is paired with an aria-describedby on the offending inputs // so screen readers (and sighted users via the matching red border)