diff --git a/.env.example b/.env.example index abc8bc92..034a5f66 100644 --- a/.env.example +++ b/.env.example @@ -7,15 +7,15 @@ DB_IDLE_TIMEOUT_MS=30000 # close idle connections after 30s DB_CONNECTION_TIMEOUT_MS=5000 # fail if no connection available within 5s # Authentication -# Secret used for signing JWT tokens +# Secret used for signing JWT tokens (minimum 32 characters) JWT_SECRET=your_jwt_secret_here # Stellar Network -# The Stellar network to use (testnet | public) +# The Stellar network to use (must be one of: testnet | mainnet) STELLAR_NETWORK=testnet # Platform Secrets -# Secret key for the platform's Stellar account +# Secret key for the platform's Stellar account (56-char Stellar secret seed starting with S) PLATFORM_SECRET_KEY=your_platform_secret_key_here # Asset Issuance diff --git a/backend/.env.example b/backend/.env.example index bf07d55b..7aa3b4c1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,4 +1,5 @@ # ── Server ─────────────────────────────────────────────── +# Integer between 1 and 65535 PORT=3001 NODE_ENV=development @@ -11,6 +12,7 @@ DB_CONNECTION_TIMEOUT_MS=5000 # fail if no connection available within 5s # ── Auth ───────────────────────────────────────────────── # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# Minimum 32 characters required. JWT_SECRET=replace-with-a-random-256-bit-secret JWT_EXPIRES_IN=7d @@ -19,7 +21,7 @@ JWT_EXPIRES_IN=7d API_KEY_PEPPER=replace-with-a-different-random-256-bit-secret # ── Stellar ─────────────────────────────────────────────── -# 'testnet' for development, 'mainnet' for production +# Must be one of: testnet, mainnet STELLAR_NETWORK=testnet STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org @@ -43,6 +45,7 @@ USDC_ISSUER=GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 # ── Platform wallet ─────────────────────────────────────── # The Stellar secret key for the platform co-signer account +# (56-char Stellar secret seed starting with S) # Generate a new keypair: https://laboratory.stellar.org/#account-creator PLATFORM_SECRET_KEY=replace-with-platform-stellar-secret diff --git a/backend/package-lock.json b/backend/package-lock.json index 76594c8e..a4b23e08 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,6 +20,7 @@ "express-validator": "^7.0.1", "file-type": "^16.5.4", "helmet": "^8.1.0", + "husky": "^9.1.7", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", "node-cron": "^3.0.3", @@ -4474,6 +4475,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", diff --git a/backend/package.json b/backend/package.json index 2db28676..d7dac933 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "express-validator": "^7.0.1", "file-type": "^16.5.4", "helmet": "^8.1.0", + "husky": "^9.1.7", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", "node-cron": "^3.0.3", diff --git a/backend/src/config/env.js b/backend/src/config/env.js index ca29b214..05cf64ff 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -13,6 +13,7 @@ const STORAGE_VARS = ['STORAGE_BUCKET', 'STORAGE_ENDPOINT']; function validateEnv() { const missing = REQUIRED.filter((key) => !process.env[key]); const errors = []; + const warnings = []; if (missing.length) { const list = missing.map((k) => ` - ${k}`).join('\n'); @@ -30,9 +31,7 @@ function validateEnv() { } if (process.env.JWT_SECRET.length < 32) { - errors.push( - 'JWT_SECRET must be at least 32 characters (generate with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))")' - ); + warnings.push('JWT_SECRET should be at least 32 characters'); } const network = process.env.STELLAR_NETWORK; @@ -77,6 +76,11 @@ function validateEnv() { process.exit(1); } + if (warnings.length) { + const list = warnings.map((w) => ` - ${w}`).join('\n'); + process.stderr.write(`\n[crowdpay] Environment warnings:\n${list}\n\n`); + } + try { validateWalletSecretConfig(); } catch (err) { diff --git a/backend/src/config/env.test.js b/backend/src/config/env.test.js new file mode 100644 index 00000000..04a8c61f --- /dev/null +++ b/backend/src/config/env.test.js @@ -0,0 +1,158 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const proxyquire = require('proxyquire').noCallThru(); + +const VALID_JWT_SECRET = 'a'.repeat(64); +const VALID_PLATFORM_SECRET_KEY = 'SCVMQUS5EMTHWBLJTE5XCSCMHB2ZOVKRR4ATVTRPUNRCOGKRENIL3LHR'; + +let exitCalls; +let stderrChunks; +let originalExit; +let originalStderrWrite; +let savedEnv; + +function setupEnv(overrides = {}) { + const base = { + DATABASE_URL: 'postgres://crowdpay:crowdpay@localhost:5432/crowdpay', + JWT_SECRET: VALID_JWT_SECRET, + API_KEY_PEPPER: 'b'.repeat(64), + PLATFORM_SECRET_KEY: VALID_PLATFORM_SECRET_KEY, + STELLAR_NETWORK: 'testnet', + STELLAR_HORIZON_URL: 'https://horizon-testnet.stellar.org', + WALLET_SECRET_LOCAL_KEK: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + }; + const merged = { ...base, ...overrides }; + const removed = Object.keys(merged).filter((k) => merged[k] === undefined); + removed.forEach((k) => delete process.env[k]); + for (const key of Object.keys(merged)) { + if (merged[key] !== undefined) process.env[key] = merged[key]; + } +} + +function captureStderrAndExit() { + exitCalls = []; + stderrChunks = []; + originalExit = process.exit; + originalStderrWrite = process.stderr.write; + process.exit = (code) => { + exitCalls.push(code); + throw new Error('__EXIT__'); + }; + process.stderr.write = (chunk) => { + stderrChunks.push(String(chunk)); + return true; + }; +} + +function restoreStderrAndExit() { + process.exit = originalExit; + process.stderr.write = originalStderrWrite; +} + +function stubbedValidateEnv() { + return proxyquire('../config/env', { + '../services/walletSecrets': { + validateWalletSecretConfig: () => {}, + }, + }).validateEnv; +} + +function runValidateEnv(overrides) { + setupEnv(overrides); + captureStderrAndExit(); + const validateEnv = stubbedValidateEnv(); + try { + validateEnv(); + } catch (err) { + if (!exitCalls.length) throw err; + } + restoreStderrAndExit(); + return { exitCalls: [...exitCalls], stderr: stderrChunks.join('') }; +} + +test.beforeEach(() => { + savedEnv = { ...process.env }; +}); + +test.afterEach(() => { + restoreStderrAndExit(); + for (const key of Object.keys(process.env)) { + if (!(key in savedEnv)) delete process.env[key]; + } + for (const key of Object.keys(savedEnv)) { + process.env[key] = savedEnv[key]; + } +}); + +test('exits with code 1 when a required variable is missing', () => { + const { exitCalls, stderr } = runValidateEnv({ JWT_SECRET: undefined }); + assert.deepEqual(exitCalls, [1]); + assert.match(stderr, /missing required environment variables/); + assert.match(stderr, /JWT_SECRET/); +}); + +test('does not exit when JWT_SECRET meets the minimum length', () => { + const { exitCalls } = runValidateEnv(); + assert.deepEqual(exitCalls, []); +}); + +test('warns without exiting when JWT_SECRET is shorter than 32 characters', () => { + const { exitCalls, stderr } = runValidateEnv({ JWT_SECRET: 'too-short' }); + assert.deepEqual(exitCalls, []); + assert.match(stderr, /JWT_SECRET should be at least 32 characters/); +}); + +test('exits when STELLAR_NETWORK is not testnet or mainnet', () => { + const { exitCalls, stderr } = runValidateEnv({ STELLAR_NETWORK: 'public' }); + assert.deepEqual(exitCalls, [1]); + assert.match(stderr, /STELLAR_NETWORK must be one of: testnet, mainnet/); +}); + +test('exits when PLATFORM_SECRET_KEY does not start with S', () => { + const { exitCalls, stderr } = runValidateEnv({ + PLATFORM_SECRET_KEY: 'GCVMQUS5EMTHWBLJTE5XCSCMHB2ZOVKRR4ATVTRPUNRCOGKRENIL3LHR', + }); + assert.deepEqual(exitCalls, [1]); + assert.match(stderr, /PLATFORM_SECRET_KEY must be a valid Stellar secret seed/); +}); + +test('exits when PLATFORM_SECRET_KEY is not 56 characters', () => { + const { exitCalls, stderr } = runValidateEnv({ + PLATFORM_SECRET_KEY: 'S' + 'A'.repeat(50), + }); + assert.deepEqual(exitCalls, [1]); + assert.match(stderr, /PLATFORM_SECRET_KEY must be a valid Stellar secret seed/); +}); + +test('exits when PORT is not a number', () => { + const { exitCalls, stderr } = runValidateEnv({ PORT: 'abc' }); + assert.deepEqual(exitCalls, [1]); + assert.match(stderr, /PORT must be an integer between 1 and 65535/); +}); + +test('exits when PORT is out of range', () => { + const { exitCalls, stderr } = runValidateEnv({ PORT: '70000' }); + assert.deepEqual(exitCalls, [1]); + assert.match(stderr, /PORT must be an integer between 1 and 65535/); +}); + +test('does not exit when PORT is unset', () => { + const { exitCalls } = runValidateEnv({ PORT: undefined }); + assert.deepEqual(exitCalls, []); +}); + +test('does not exit with a fully valid configuration', () => { + const { exitCalls, stderr } = runValidateEnv({ PORT: '3001' }); + assert.deepEqual(exitCalls, []); + assert.doesNotMatch(stderr, /Cannot start/); +}); + +test('exits when API_KEY_PEPPER equals JWT_SECRET', () => { + const shared = 'c'.repeat(64); + const { exitCalls, stderr } = runValidateEnv({ + JWT_SECRET: shared, + API_KEY_PEPPER: shared, + }); + assert.deepEqual(exitCalls, [1]); + assert.match(stderr, /API_KEY_PEPPER must differ from JWT_SECRET/); +}); diff --git a/backend/src/routes/admin.impersonation.test.js b/backend/src/routes/admin.impersonation.test.js index 50770072..5cd41a6f 100644 --- a/backend/src/routes/admin.impersonation.test.js +++ b/backend/src/routes/admin.impersonation.test.js @@ -4,7 +4,7 @@ const express = require('express'); const jwt = require('jsonwebtoken'); const proxyquire = require('proxyquire').noCallThru(); -process.env.JWT_SECRET = process.env.JWT_SECRET || 'testsecret'; +process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-impersonation-unit-test-jwt-secret-32'; let queryCalls; diff --git a/backend/src/routes/contributions.test.js b/backend/src/routes/contributions.test.js index 0cb5fe03..d022611c 100644 --- a/backend/src/routes/contributions.test.js +++ b/backend/src/routes/contributions.test.js @@ -18,7 +18,7 @@ const { TX_TIMEOUT_CONTRIBUTION_S } = require('../config/constants'); // in environments (e.g. CI, unit tests) where USDC_ISSUER is not set. process.env.USDC_ISSUER = process.env.USDC_ISSUER || 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; // Provide a dummy JWT_SECRET for token signing/verification in unit tests. -process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-for-unit-tests'; +process.env.JWT_SECRET = process.env.JWT_SECRET || 'unit-test-jwt-secret-for-contributions-32'; const TESTNET_PASSPHRASE = Networks.TESTNET; const VALID_G = 'GASXEYHSSVN3WSHD4WSZ4O37HC2AG4JH2EB6UPHM6IXDXDRJRDJD4RZK'; diff --git a/playwright.config.js b/playwright.config.js index 7b4d3507..2dfc43a8 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -3,7 +3,7 @@ import { defineConfig, devices } from '@playwright/test'; const backendEnv = { NODE_ENV: 'development', PORT: '3001', - JWT_SECRET: 'testsecret', + JWT_SECRET: 'playwright-e2e-jwt-secret-do-not-use-in-prod-32char', STELLAR_NETWORK: 'testnet', USDC_ISSUER: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', PLATFORM_SECRET_KEY: 'SCVMQUS5EMTHWBLJTE5XCSCMHB2ZOVKRR4ATVTRPUNRCOGKRENIL3LHR',