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; }