diff --git a/docs/postgres-financial-storage.md b/docs/postgres-financial-storage.md index 9043864..088cdca 100755 --- a/docs/postgres-financial-storage.md +++ b/docs/postgres-financial-storage.md @@ -34,10 +34,24 @@ The following are configuration values, not credentials: | `WIKIDECK_CUSTODY_STORAGE=postgres` | Selects PostgreSQL; default `sqlite` is retained for existing sandbox users. | | `WIKIDECK_CUSTODY_PG_URL_FILE` | Absolute path to a private secret file containing a PostgreSQL URL. Never a Vite/browser variable. | | `WIKIDECK_CUSTODY_PG_CA_FILE` | Optional private file with trusted PEM CA certificate(s); otherwise use system trust. TLS hostname/certificate verification remains enabled. | +| `WIKIDECK_CUSTODY_PG_TRANSPORT` | Default `tls`: verified PostgreSQL TLS. Optional `infrastructure`: the driver uses plaintext and relies on an independently enforced encrypted/authenticated infrastructure path; see conditions below. | +| `WIKIDECK_CUSTODY_PG_INFRASTRUCTURE_ACK` | Required **only** in infrastructure mode, exactly `encrypted-authenticated-path-enforced`. An operator acknowledgement, NOT automatic verification of infrastructure security. | | `WIKIDECK_CUSTODY_PG_LOCAL_TEST=1` | **Disposable loopback tests only:** disables TLS solely for literal loopback/localhost. Never use for production. | URL query parameters/fragments are rejected, preventing `sslmode` options from overriding transport policy. Missing/malformed configuration fails with a generic error, not a URL/password dump. Secret files must be regular non-symlink bounded files; mount them read-only with restricted filesystem ACLs. Production network firewall, verified TLS, password/secret rotation and external database logs need an operator review. The automated fixture tests configuration policy, not an actual production certificate renewal/handshake drill. +### Kubernetes/infrastructure-managed transport + +Prefer the default verified PostgreSQL TLS, with certificate issuance/renewal provided by the database operator, managed service or cluster infrastructure. **Wikideck does not need a bespoke certificate lifecycle service.** The local Docker certificate generator is a sandbox convenience, not the Kubernetes production architecture. + +If an enforced service-mesh mTLS path or equivalent infrastructure authenticates the intended database peer and encrypts the complete inter-workload path, deployments may explicitly select `infrastructure` plus the exact acknowledgement above. This sets the PostgreSQL driver's `ssl` to false: application-to-proxy and proxy-to-database plaintext segments must be local/isolated within the reviewed boundary. A firewall, ordinary Kubernetes networking or NetworkPolicy alone does not satisfy that condition. Ensure sidecar interception cannot be bypassed, plaintext inter-pod access is denied, destination identities are restricted, and policies remain enforced after restarts/failover. Protect migration, audit and recovery clients too—not just the HTTP application. + +Infrastructure mode cannot be combined with a CA file or the loopback-test switch; a stray acknowledgement in TLS mode is rejected. URL TLS overrides remain forbidden and file-based password credentials remain required. No insecure “accept any certificate” TLS option is added. Wikideck cannot verify external mesh enforcement; the acknowledgement is not proof and does not authorize live funds. Cluster evidence/review remains a release gate. + +Validation for this transport increment: TypeScript/Vite build, **171 offline tests and 16 PostgreSQL integration tests passed**, zero failures/skips. Configuration tests cover secure defaults, exact opt-in, missing acknowledgement/password, invalid mode, conflicting CA/test settings and URL override rejection. No Kubernetes cluster or actual service-mesh enforcement was tested or certified. + +Existing Compose stacks remain configured for verified native PostgreSQL TLS. Their TLS-only database HBA and deployment smoke probe intentionally reject infrastructure/plaintext mode: do not change just the client flag on that stack. This option is for a separately configured infrastructure deployment, not automatic TLS fallback. No Kubernetes resources or running database/container settings were changed. Custom certificate renewal implementation is deferred to infrastructure; the local sandbox's documented short-lived certificates still need controlled operational replacement if retained. + The operator CLI is [scripts/custody-db.ts](../scripts/custody-db.ts), available through `npm run custody:db -- ACTION`. Credentials are provided only by the secret-file environment setting, never positional arguments or browser UI: 1. Provision a dedicated database, a migration/schema-owner identity, and a separate existing login role without superuser/create-role/create-db/replication/bypass-RLS privileges. Create passwords privately outside this tool; no credentials are generated or displayed by the CLI. diff --git a/docs/production-plan.md b/docs/production-plan.md index ad87708..c2a0068 100755 --- a/docs/production-plan.md +++ b/docs/production-plan.md @@ -126,6 +126,7 @@ Run throughout development; final release gates depend on P2–P5. - Complete wallet review: same-origin threat model, migration/lock/unlock and recovery across supported browsers, signing review/session invalidation and stale handles; decide isolated/hardware signing support. Close old plaintext copies as a user migration/risk procedure, not a secure-erasure claim. - Real host/edge verification: HTTPS/cert renewal, forwarded-header trust, firewall, admin access, secrets, egress, CPU/RAM/disk/WAL budgets, time sync, on-call alerts and monitoring of actual peer/tip and payment-worker progress. +- Deployment clarification: production target is behind a firewall in Kubernetes. Database certificate issuance/renewal belongs to the database/cluster infrastructure, not a bespoke application service. Verified PostgreSQL TLS remains the default; explicit [infrastructure-managed transport](postgres-financial-storage.md) is available only with acknowledged externally enforced encryption/peer authentication. A firewall/NetworkPolicy alone is insufficient evidence. Local Docker TLS provisioning remains sandbox-specific; no certificate-rotation feature is being activated. - Backups/restore with complete financial journals and off-host encrypted copies: measured RPO/RTO and successful cold restore/reconciliation; test losses of host, journal, wallet key and processor reachability. Recovery of customer keys and recovery of merchant funds are different responsibilities. - Repeat consensus/hashpower studies on intended miner/validator hardware. Quantify production attack and recovery costs; finalize difficulty/retarget/actual genesis after review. One powerful operator server is not multiple independent validators/miners or Monero-level security. - Pin release artifacts/dependencies/base images, SBOM, secret/dependency/container/native scans, fuzz/sanitizer runs, reproducible/provenance evidence and signed artifacts. Publish the selected project license and respect dependency/Wikipedia licensing. diff --git a/server/market/postgres-config.ts b/server/market/postgres-config.ts index 8ae2cb0..8758dd8 100755 --- a/server/market/postgres-config.ts +++ b/server/market/postgres-config.ts @@ -19,6 +19,15 @@ export async function postgresConfig(env: NodeJS.ProcessEnv = process.env): Prom if (!['postgres:', 'postgresql:'].includes(url.protocol) || !url.hostname || url.search || url.hash || !/^\/[a-zA-Z0-9_-]+$/.test(url.pathname)) throw new Error('Invalid PostgreSQL URL'); const insecure = env.WIKIDECK_CUSTODY_PG_LOCAL_TEST === '1'; + const transport = env.WIKIDECK_CUSTODY_PG_TRANSPORT ?? 'tls'; + if (transport !== 'tls' && transport !== 'infrastructure') throw new Error('Invalid PostgreSQL transport'); + const infrastructure = transport === 'infrastructure'; + const acknowledgement = env.WIKIDECK_CUSTODY_PG_INFRASTRUCTURE_ACK; + if (infrastructure) { + if (acknowledgement !== 'encrypted-authenticated-path-enforced' || insecure || env.WIKIDECK_CUSTODY_PG_CA_FILE) { + throw new Error('Infrastructure transport requires explicit acknowledgement without conflicting TLS/test settings'); + } + } else if (acknowledgement !== undefined) throw new Error('Infrastructure acknowledgement without infrastructure transport'); if (env.WIKIDECK_CUSTODY_PG_LOCAL_TEST !== undefined && !insecure) throw new Error('Invalid local-test switch'); if (insecure && !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) throw new Error('Non-TLS test connections must be loopback'); if (!insecure && (!url.username || !url.password)) throw new Error('PostgreSQL credentials required'); @@ -26,9 +35,11 @@ export async function postgresConfig(env: NodeJS.ProcessEnv = process.env): Prom const ca = env.WIKIDECK_CUSTODY_PG_CA_FILE ? await secretFile(env.WIKIDECK_CUSTODY_PG_CA_FILE) : undefined; return { host: url.hostname.replace(/^\[|\]$/g, ''), port: url.port ? Number(url.port) : 5432, database: url.pathname.slice(1), user: decodeURIComponent(url.username), password: decodeURIComponent(url.password), - ssl: insecure ? false : { rejectUnauthorized: true, ...(ca ? { ca } : {}) }, + // Infrastructure mode is explicit plaintext on the driver's hop. It does NOT configure or + // verify a service mesh, peer identity, network policy, or encryption outside this process. + ssl: insecure || infrastructure ? false : { rejectUnauthorized: true, ...(ca ? { ca } : {}) }, max: 4, connectionTimeoutMillis: 5000, idleTimeoutMillis: 10000, statement_timeout: 10000, lock_timeout: 3000, idle_in_transaction_session_timeout: 10000, application_name: 'wikideck-custody', options: '-c search_path=wk_custody,pg_catalog' }; - } catch { throw new Error('Invalid or inaccessible custody PostgreSQL configuration; check secret files and verified TLS settings'); } + } catch { throw new Error('Invalid or inaccessible custody PostgreSQL configuration; check secret files and explicit transport policy'); } } diff --git a/tests/postgres-transport.test.ts b/tests/postgres-transport.test.ts new file mode 100755 index 0000000..38c2c9b --- /dev/null +++ b/tests/postgres-transport.test.ts @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { postgresConfig } from '../server/market/postgres-config.ts'; + +test('PostgreSQL verified TLS remains the default and cannot be downgraded through URL options', async t => { + const dir = await mkdtemp(join(tmpdir(), 'wikideck-transport-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, 'connection'); + // Synthetic configuration fixture, never sent to a database. + await writeFile(file, 'postgresql://fixture:fixture-only@database.cluster.example/custody', { mode: 0o600 }); + const env = { WIKIDECK_CUSTODY_PG_URL_FILE: file }; + const config = await postgresConfig(env); + assert.equal((config.ssl as { rejectUnauthorized: boolean }).rejectUnauthorized, true); + assert.deepEqual(config.ssl, (await postgresConfig({ ...env, WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' })).ssl); + for (const mode of ['', 'off', 'disable', 'false', 'mesh', 'TLS']) { + await assert.rejects(postgresConfig({ ...env, WIKIDECK_CUSTODY_PG_TRANSPORT: mode }), /configuration/); + } + await writeFile(file, 'postgresql://fixture:fixture-only@database.cluster.example/custody?sslmode=disable'); + await assert.rejects(postgresConfig(env), /configuration/); +}); + +test('infrastructure-managed transport requires exact opt-in and rejects mixed TLS/test configuration', async t => { + const dir = await mkdtemp(join(tmpdir(), 'wikideck-infrastructure-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, 'connection'); + await writeFile(file, 'postgresql://fixture:fixture-only@database.cluster.example/custody', { mode: 0o600 }); + const env = { WIKIDECK_CUSTODY_PG_URL_FILE: file, WIKIDECK_CUSTODY_PG_TRANSPORT: 'infrastructure' }; + await assert.rejects(postgresConfig(env), /configuration/); + for (const ack of ['', 'true', 'firewall', 'encrypted-authenticated-path-enforced ']) { + await assert.rejects(postgresConfig({ ...env, WIKIDECK_CUSTODY_PG_INFRASTRUCTURE_ACK: ack }), /configuration/); + } + const approved = { ...env, WIKIDECK_CUSTODY_PG_INFRASTRUCTURE_ACK: 'encrypted-authenticated-path-enforced' }; + assert.equal((await postgresConfig(approved)).ssl, false); + for (const conflict of [{ WIKIDECK_CUSTODY_PG_CA_FILE: file }, { WIKIDECK_CUSTODY_PG_LOCAL_TEST: '1' }]) { + await assert.rejects(postgresConfig({ ...approved, ...conflict }), /configuration/); + } + await assert.rejects(postgresConfig({ ...approved, WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' }), /configuration/); + await writeFile(file, 'postgresql://fixture@database.cluster.example/custody'); + await assert.rejects(postgresConfig(approved), /configuration/); // Password auth remains mandatory. + await writeFile(file, 'postgresql://fixture:fixture-only@database.cluster.example/custody?sslmode=require'); + await assert.rejects(postgresConfig(approved), /configuration/); +});