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)