import { lstat, readFile } from 'node:fs/promises'; import { constants } from 'node:fs'; import { open } from 'node:fs/promises'; import { X509Certificate } from 'node:crypto'; import type { PoolConfig } from 'pg'; export function custodyStorage(env: NodeJS.ProcessEnv = process.env): 'sqlite' | 'postgres' { const value = env.WIKIDECK_CUSTODY_STORAGE ?? 'sqlite'; if (value !== 'sqlite' && value !== 'postgres') throw new Error('Invalid custody storage backend'); return value; } async function secretFile(path: string | undefined): Promise { if (!path) throw new Error('Custody PostgreSQL secret file is required'); const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink() || info.size > 16384 || info.size === 0) throw new Error('Invalid PostgreSQL secret file'); if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error('PostgreSQL secret file permissions are too broad'); return (await readFile(path, 'utf8')).trim(); } function derLength(bytes: Buffer, offset: number): { length: number; next: number } { if (offset >= bytes.length) throw new Error('Invalid certificate DER length'); const first = bytes[offset]; if (first < 0x80) return { length: first, next: offset + 1 }; const count = first & 0x7f; if (count === 0 || count > 4 || offset + count >= bytes.length) throw new Error('Invalid certificate DER length'); let length = 0; for (let i = 1; i <= count; i++) length = length * 256 + bytes[offset + i]; return { length, next: offset + count + 1 }; } function derElement(bytes: Buffer, offset: number): { tag: number; start: number; end: number; next: number } { if (offset >= bytes.length) throw new Error('Invalid certificate DER'); const tag = bytes[offset], size = derLength(bytes, offset + 1), end = size.next + size.length; if (end > bytes.length) throw new Error('Invalid certificate DER'); return { tag, start: size.next, end, next: end }; } function validateCaCertificate(pem: Buffer): string { const certificate = new X509Certificate(pem); const now = Date.now(); if (!Number.isFinite(Date.parse(certificate.validFrom)) || !Number.isFinite(Date.parse(certificate.validTo)) || now < Date.parse(certificate.validFrom) || now > Date.parse(certificate.validTo)) { throw new Error('PostgreSQL CA certificate is not currently valid'); } if (certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) { throw new Error('PostgreSQL CA certificate must be a valid self-signed trust anchor'); } const key = certificate.publicKey; if (key.asymmetricKeyType !== 'rsa' || (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) { throw new Error('PostgreSQL CA certificate key must be RSA-2048 or stronger'); } const der = certificate.raw; const outer = derElement(der, 0); if (outer.tag !== 0x30) throw new Error('Invalid PostgreSQL CA certificate DER'); const tbs = derElement(der, outer.start); const signatureAlgorithm = derElement(der, tbs.next); if (signatureAlgorithm.tag !== 0x30) throw new Error('Invalid PostgreSQL CA signature algorithm'); const oid = derElement(der, signatureAlgorithm.start); if (oid.tag !== 0x06) throw new Error('Invalid PostgreSQL CA signature algorithm'); const signatureOid = der.subarray(oid.start, oid.end).toString('hex'); const weakSignatureOids = new Set(['2a864886f70d010104', '2b0e03021a', '2b0e03021b']); if (weakSignatureOids.has(signatureOid)) throw new Error('PostgreSQL CA certificate uses a weak signature algorithm'); return pem.toString('utf8'); } async function publicCaFile(path: string | undefined): Promise { if (!path) return undefined; const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); try { const info = await file.stat(); if (!info.isFile() || info.size < 1 || info.size > 64 * 1024) throw new Error('Invalid PostgreSQL CA file'); // A CA certificate is public trust material; mode 0644 is expected. It is not a database credential. const bytes = await file.readFile(); return validateCaCertificate(bytes); } finally { await file.close(); } } /** No URL logging, no TLS options in a connection string overriding verified transport. */ export async function postgresConfig(env: NodeJS.ProcessEnv = process.env): Promise { try { const url = new URL(await secretFile(env.WIKIDECK_CUSTODY_PG_URL_FILE)); 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'); if (url.port && (!/^\d+$/.test(url.port) || Number(url.port) < 1 || Number(url.port) > 65535)) throw new Error('Invalid PostgreSQL port'); const ca = await publicCaFile(env.WIKIDECK_CUSTODY_PG_CA_FILE); 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), // 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 explicit transport policy'); } }