import { lstat, readFile } from 'node:fs/promises'; import { request as httpsRequest } from 'node:https'; import { z } from 'zod'; import type { MoneroFixtureTransport } from './monero-observer.ts'; const MAX_TLS_FILE_BYTES = 64 * 1024; const MAX_RPC_REQUEST_BYTES = 64 * 1024; const MAX_RPC_RESPONSE_BYTES = 1024 * 1024; const transportConfigSchema = z.object({ url: z.string().url().max(2048), clientCertificateFile: z.string().min(1).max(4096), clientPrivateKeyFile: z.string().min(1).max(4096), serverCaFile: z.string().min(1).max(4096), }).strict(); export async function loadMoneroMtlsTransportConfig(env: NodeJS.ProcessEnv = process.env) { const configPath = env.WIKIDECK_MONERO_MTLS_CONFIG_FILE; if (!configPath) throw new Error('Monero mTLS transport config file is required'); try { const info = await lstat(configPath); if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_TLS_FILE_BYTES) throw new Error(); if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error(); const parsed = transportConfigSchema.parse(JSON.parse(await readFile(configPath, 'utf8'))); const url = new URL(parsed.url); if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/json_rpc') throw new Error(); for (const path of [parsed.clientCertificateFile, parsed.clientPrivateKeyFile, parsed.serverCaFile]) { if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) throw new Error(); } return parsed; } catch { throw new Error('Invalid or inaccessible Monero mTLS configuration'); } } async function readProtectedPem(path: string): Promise { const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_TLS_FILE_BYTES) { throw new Error('Monero RPC TLS material must be a bounded regular file'); } if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) { throw new Error('Monero RPC TLS material permissions are too broad'); } return readFile(path); } /** * Create an HTTPS mutual-TLS transport for a private Wallet-RPC gateway. This replaces * Monero v0.18.5.4's legacy HTTP Digest authentication without implementing MD5 in the app. * The gateway is responsible for certificate authorization and a strict Wallet-RPC method * allowlist; this transport never adds Basic/Digest Authorization headers or follows redirects. */ export async function loadMoneroMtlsTransport(configInput: unknown): Promise { const config = transportConfigSchema.parse(configInput); const url = new URL(config.url); if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/json_rpc') { throw new Error('Monero mTLS gateway must be a bare HTTPS /json_rpc endpoint'); } const [cert, key, ca] = await Promise.all([ readProtectedPem(config.clientCertificateFile), readProtectedPem(config.clientPrivateKeyFile), readProtectedPem(config.serverCaFile), ]); return async (endpoint, init) => { const target = new URL(endpoint); if (target.href !== url.href || init.method !== 'POST' || init.redirect !== 'error' || typeof init.body !== 'string') { throw new Error('Monero mTLS request does not match the pinned RPC endpoint contract'); } const body = Buffer.from(init.body, 'utf8'); if (body.byteLength > MAX_RPC_REQUEST_BYTES) throw new Error('Monero RPC request exceeds size limit'); if (new Headers(init.headers).has('authorization')) throw new Error('HTTP Authorization headers are not supported; use mTLS'); return new Promise((resolve, reject) => { const request = httpsRequest({ protocol: 'https:', hostname: url.hostname, port: url.port || 443, path: url.pathname, method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', 'content-length': body.byteLength }, cert, key, ca, rejectUnauthorized: true, servername: url.hostname, minVersion: 'TLSv1.3', }, response => { if ((response.statusCode ?? 0) < 200 || (response.statusCode ?? 0) >= 300 || (response.headers.location !== undefined) || !/^application\/json(?:\s*;|$)/i.test(response.headers['content-type'] ?? '')) { response.destroy(); reject(new Error('Monero mTLS gateway response rejected')); return; } const chunks: Buffer[] = []; let size = 0; response.on('data', (chunk: Buffer) => { size += chunk.byteLength; if (size > MAX_RPC_RESPONSE_BYTES) { response.destroy(new Error('Monero mTLS gateway response exceeds size limit')); return; } chunks.push(chunk); }); response.on('error', () => reject(new Error('Monero mTLS gateway response failed'))); response.on('end', () => resolve(new Response(Buffer.concat(chunks), { status: response.statusCode, headers: { 'content-type': response.headers['content-type'] ?? 'application/json' }, }))); }); request.setTimeout(5000, () => request.destroy(new Error('Monero mTLS request deadline exceeded'))); const signal = init.signal; if (signal?.aborted) { request.destroy(new Error('Monero mTLS request aborted')); return; } signal?.addEventListener('abort', () => request.destroy(new Error('Monero mTLS request aborted')), { once: true }); request.on('error', () => reject(new Error('Monero mTLS request failed'))); request.end(body); }); }; }