diff --git a/.env.production b/.env.production
new file mode 100644
index 0000000..e78ce04
--- /dev/null
+++ b/.env.production
@@ -0,0 +1 @@
+INLINE_RUNTIME_CHUNK=false
diff --git a/public/index.html b/public/index.html
index fdb8ac8..9f1fbc5 100644
--- a/public/index.html
+++ b/public/index.html
@@ -23,8 +23,6 @@
name="twitter:description"
content="Track Syscoin Sentry Node count, locked supply, rewards, governance proposals, setup guidance, and market context in one clean dashboard."
/>
-
-
Sysnode | Syscoin Sentry Node Dashboard
diff --git a/server.js b/server.js
index fab37b3..e779b8b 100644
--- a/server.js
+++ b/server.js
@@ -2,22 +2,64 @@ const express = require('express');
const path = require('path');
const app = express();
+app.disable('x-powered-by');
+
+function splitCspSources(value) {
+ if (!value) return [];
+ return value
+ .split(/[\s,]+/)
+ .map((source) => source.trim())
+ .filter(Boolean);
+}
+
+function uniqueSources(sources) {
+ return [...new Set(sources.filter(Boolean))];
+}
+
+const connectSrc = uniqueSources([
+ "'self'",
+ 'https:',
+ ...splitCspSources(process.env.SYSNODE_CSP_CONNECT_SRC),
+]);
+
+const SECURITY_HEADERS = {
+ 'Content-Security-Policy': [
+ "default-src 'self'",
+ "base-uri 'none'",
+ "object-src 'none'",
+ "frame-ancestors 'none'",
+ "script-src 'self'",
+ `connect-src ${connectSrc.join(' ')}`,
+ "img-src 'self' data: https://coin-images.coingecko.com",
+ "font-src 'self'",
+ "style-src 'self' 'unsafe-inline'",
+ ].join('; '),
+ 'Referrer-Policy': 'no-referrer',
+ 'X-Content-Type-Options': 'nosniff',
+ 'X-Frame-Options': 'DENY',
+ 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
+};
+
+app.use((_req, res, next) => {
+ for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
+ res.setHeader(name, value);
+ }
+ next();
+});
// Serve the static files from the React app
app.use(express.static(path.join(__dirname, '/build')));
// Handles any requests that don't match the ones above
-app.get('*', (req,res) =>{
- res.sendFile(path.join(__dirname+'/build/index.html'));
+app.get('*', (_req, res) => {
+ res.sendFile(path.join(__dirname, '/build/index.html'));
});
-
-
-var server = app.listen(process.env.PORT || 3000, listen);
+const server = app.listen(process.env.PORT || 3000, listen);
// This call back just tells us that the server has started
function listen() {
- var host = server.address().address;
- var port = server.address().port;
- console.log('React app live at http://' + host + ':' + port);
+ const host = server.address().address;
+ const port = server.address().port;
+ console.log('React app live at http://' + host + ':' + port);
}
\ No newline at end of file
diff --git a/src/context/VaultContext.js b/src/context/VaultContext.js
index 6be7205..4f750f8 100644
--- a/src/context/VaultContext.js
+++ b/src/context/VaultContext.js
@@ -100,6 +100,15 @@ const EMPTY = 'empty';
const LOCKED = 'locked';
const UNLOCKED = 'unlocked';
const ERROR = 'error';
+const DEFAULT_IDLE_LOCK_MS = 15 * 60 * 1000;
+
+export function zeroizeDataKey(value) {
+ if (value instanceof Uint8Array) {
+ value.fill(0);
+ return true;
+ }
+ return false;
+}
function blankState() {
return {
@@ -125,6 +134,7 @@ function snapshotOf(s) {
export function VaultProvider({
children,
vaultService = defaultVaultService,
+ idleLockMs = DEFAULT_IDLE_LOCK_MS,
}) {
const { isAuthenticated, user } = useAuth();
const userId = user && user.id != null ? user.id : null;
@@ -217,11 +227,7 @@ export function VaultProvider({
}, []);
const wipeKeys = useCallback(() => {
- // Clearing dkRef.current doesn't zero the Uint8Array's backing
- // buffer — JS has no reliable way to do that, and the garbage
- // collector will eventually reclaim it. What we CAN guarantee is
- // that no code path inside VaultContext can reach the bytes once
- // the ref is null: every consumer reads them through the ref.
+ zeroizeDataKey(dkRef.current);
dkRef.current = null;
vaultKeyRef.current = null;
}, []);
@@ -411,6 +417,7 @@ export function VaultProvider({
sessionGenRef.current !== startingSession ||
myGen !== genRef.current
) {
+ zeroizeDataKey(decrypted.dk);
return { status: 'stale' };
}
dkRef.current = decrypted.dk;
@@ -523,6 +530,7 @@ export function VaultProvider({
let dk;
let vaultKey;
let ifMatch;
+ let wipeLocalDk = false;
if (isEmpty) {
if (!opts || typeof opts.password !== 'string' || !opts.password) {
@@ -548,9 +556,9 @@ export function VaultProvider({
ifMatch = '*';
} else {
// UNLOCKED. Re-use cached keys.
- dk = dkRef.current;
+ const cachedDk = dkRef.current;
vaultKey = vaultKeyRef.current;
- if (!dk || !vaultKey) {
+ if (!cachedDk || !vaultKey) {
// Invariant violation: status === UNLOCKED but refs are
// null. Only possible if reset() fired between our
// status read and here — treat as session loss.
@@ -558,10 +566,20 @@ export function VaultProvider({
e.code = 'vault_locked_out';
throw e;
}
+ // Copy the DK for this encrypt operation. lock()/reset() may
+ // zeroize dkRef.current while encryptEnvelope is mid-flight; sharing
+ // the same Uint8Array would let a lock corrupt the saved blob.
+ dk = new Uint8Array(cachedDk);
+ wipeLocalDk = true;
ifMatch = current.etag || undefined;
}
- const blob = await encryptEnvelope(newPayload, dk, vaultKey);
+ let blob;
+ try {
+ blob = await encryptEnvelope(newPayload, dk, vaultKey);
+ } finally {
+ if (wipeLocalDk) zeroizeDataKey(dk);
+ }
// Re-check session after the await train. If we logged out
// mid-encrypt we don't want to PUT under the new user's
@@ -670,6 +688,57 @@ export function VaultProvider({
commit({ status: LOCKED, data: null, error: null }, myGen);
}, [bumpGen, commit, wipeKeys]);
+ useEffect(() => {
+ if (state.status !== UNLOCKED) return undefined;
+ if (typeof window === 'undefined') return undefined;
+ if (!Number.isFinite(idleLockMs) || idleLockMs <= 0) return undefined;
+
+ let timer = null;
+ const lockIfUnlocked = () => {
+ if (stateRef.current.status === UNLOCKED) lock();
+ };
+ const armTimer = () => {
+ if (timer) window.clearTimeout(timer);
+ timer = window.setTimeout(lockIfUnlocked, idleLockMs);
+ };
+ const onVisibilityChange = () => {
+ if (
+ typeof document !== 'undefined' &&
+ document.visibilityState === 'hidden'
+ ) {
+ lockIfUnlocked();
+ return;
+ }
+ armTimer();
+ };
+
+ const events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'];
+ if (
+ typeof document !== 'undefined' &&
+ document.visibilityState === 'hidden'
+ ) {
+ lockIfUnlocked();
+ return undefined;
+ }
+ for (const eventName of events) {
+ window.addEventListener(eventName, armTimer, { passive: true });
+ }
+ if (typeof document !== 'undefined') {
+ document.addEventListener('visibilitychange', onVisibilityChange);
+ }
+ armTimer();
+
+ return () => {
+ if (timer) window.clearTimeout(timer);
+ for (const eventName of events) {
+ window.removeEventListener(eventName, armTimer);
+ }
+ if (typeof document !== 'undefined') {
+ document.removeEventListener('visibilitychange', onVisibilityChange);
+ }
+ };
+ }, [state.status, idleLockMs, lock]);
+
// -----------------------------------------------------------------------
// rewrapForPasswordChange(newMaster)
//
@@ -884,5 +953,6 @@ export function useVault() {
}
export const __testing = {
+ DEFAULT_IDLE_LOCK_MS,
STATUS: { IDLE, LOADING, EMPTY, LOCKED, UNLOCKED, ERROR },
};
diff --git a/src/context/VaultContext.test.js b/src/context/VaultContext.test.js
index 78b66bd..592bc09 100644
--- a/src/context/VaultContext.test.js
+++ b/src/context/VaultContext.test.js
@@ -9,7 +9,12 @@ import { render, act, waitFor } from '@testing-library/react';
const PBKDF2_TIMEOUT_MS = 30000;
import { AuthProvider } from './AuthContext';
-import { VaultProvider, useVault, __testing } from './VaultContext';
+import {
+ VaultProvider,
+ useVault,
+ zeroizeDataKey,
+ __testing,
+} from './VaultContext';
import {
encryptEnvelope,
decryptEnvelope,
@@ -76,10 +81,15 @@ function VaultObserver({ onValue }) {
return null;
}
-function renderWithProviders({ authService, vaultService, onVault }) {
+function renderWithProviders({
+ authService,
+ vaultService,
+ onVault,
+ idleLockMs,
+}) {
return render(
-
+
@@ -433,6 +443,115 @@ describe('VaultProvider — lock + logout', () => {
expect(last._hasVaultKeyForTest()).toBe(false);
});
+ test('idle timeout locks an unlocked vault and wipes keys', async () => {
+ const { master, blob } = await makeEncryptedBlobFor({});
+ const vaultService = {
+ load: jest
+ .fn()
+ .mockResolvedValue({ empty: false, blob, etag: 'E' }),
+ save: jest.fn(),
+ };
+ let last;
+ renderWithProviders({
+ authService: authedAuthService(),
+ vaultService,
+ idleLockMs: 10,
+ onVault: (v) => {
+ last = v;
+ },
+ });
+ await waitFor(() => expect(last.status).toBe(STATUS.LOCKED));
+ await act(async () => {
+ await last.unlockWithMaster(master);
+ });
+ await waitFor(() => expect(last.status).toBe(STATUS.UNLOCKED));
+
+ await waitFor(() => expect(last.status).toBe(STATUS.LOCKED));
+ expect(last.data).toBeNull();
+ expect(last._hasDataKeyForTest()).toBe(false);
+ expect(last._hasVaultKeyForTest()).toBe(false);
+ });
+
+ test('backgrounding the tab locks an unlocked vault immediately', async () => {
+ const { master, blob } = await makeEncryptedBlobFor({});
+ const vaultService = {
+ load: jest
+ .fn()
+ .mockResolvedValue({ empty: false, blob, etag: 'E' }),
+ save: jest.fn(),
+ };
+ let last;
+ const originalVisibility = document.visibilityState;
+ renderWithProviders({
+ authService: authedAuthService(),
+ vaultService,
+ idleLockMs: 60_000,
+ onVault: (v) => {
+ last = v;
+ },
+ });
+ try {
+ await waitFor(() => expect(last.status).toBe(STATUS.LOCKED));
+ await act(async () => {
+ await last.unlockWithMaster(master);
+ });
+ await waitFor(() => expect(last.status).toBe(STATUS.UNLOCKED));
+
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ value: 'hidden',
+ });
+ act(() => {
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ await waitFor(() => expect(last.status).toBe(STATUS.LOCKED));
+ } finally {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ value: originalVisibility,
+ });
+ }
+ });
+
+ test('unlocking while the tab is already hidden locks immediately', async () => {
+ const { master, blob } = await makeEncryptedBlobFor({});
+ const vaultService = {
+ load: jest
+ .fn()
+ .mockResolvedValue({ empty: false, blob, etag: 'E' }),
+ save: jest.fn(),
+ };
+ let last;
+ const originalVisibility = document.visibilityState;
+ renderWithProviders({
+ authService: authedAuthService(),
+ vaultService,
+ idleLockMs: 60_000,
+ onVault: (v) => {
+ last = v;
+ },
+ });
+ try {
+ await waitFor(() => expect(last.status).toBe(STATUS.LOCKED));
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ value: 'hidden',
+ });
+ await act(async () => {
+ await last.unlockWithMaster(master);
+ });
+ await waitFor(() => expect(last.status).toBe(STATUS.LOCKED));
+ expect(last.data).toBeNull();
+ expect(last._hasDataKeyForTest()).toBe(false);
+ expect(last._hasVaultKeyForTest()).toBe(false);
+ } finally {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ value: originalVisibility,
+ });
+ }
+ });
+
test('hard-resets and wipes state when auth transitions to anonymous', async () => {
const { master, blob } = await makeEncryptedBlobFor({});
const vaultService = {
@@ -477,6 +596,18 @@ describe('VaultProvider — lock + logout', () => {
});
});
+describe('VaultProvider — key zeroization', () => {
+ test('zeroizeDataKey overwrites Uint8Array contents in place', () => {
+ const bytes = new Uint8Array([1, 2, 3, 4]);
+ expect(zeroizeDataKey(bytes)).toBe(true);
+ expect(Array.from(bytes)).toEqual([0, 0, 0, 0]);
+ });
+
+ test('zeroizeDataKey ignores non-byte-array values', () => {
+ expect(zeroizeDataKey(null)).toBe(false);
+ });
+});
+
// ---------------------------------------------------------------------------
// Codex review round 1 — concurrency regressions (PR 3).
//
@@ -1220,6 +1351,10 @@ describe('VaultProvider — save() update (UNLOCKED → UNLOCKED)', () => {
expect(vaultService.save).toHaveBeenCalledTimes(1)
);
});
+ const savedBlob = vaultService.save.mock.calls[0][0].blob;
+ const vaultKey = await deriveVaultKey(master, SALT_A);
+ const savedEnvelope = await decryptEnvelope(savedBlob, vaultKey);
+ expect(savedEnvelope.data).toEqual({ k: 1 });
expect(last.isSaving).toBe(true);
await act(async () => {
diff --git a/src/index.css b/src/index.css
index fcdbcf8..92b7c42 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,5 +1,3 @@
-@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Space+Grotesk:wght@500;700&display=swap');
-
:root {
--bg: #f4f7fb;
--bg-soft: #ebf1f8;
@@ -46,7 +44,8 @@ body {
radial-gradient(circle at top right, rgba(20, 184, 166, 0.09), transparent 24%),
linear-gradient(180deg, #fbfdff 0%, #f5f8fc 48%, #edf3f9 100%);
color: var(--text);
- font: 16px/1.6 'IBM Plex Sans', 'Segoe UI', sans-serif;
+ font: 16px/1.6 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
+ sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -89,5 +88,6 @@ h4,
h5,
h6,
strong {
- font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
+ sans-serif;
}