import { createHash } from 'node:crypto'; import { lstat, readFile } from 'node:fs/promises'; import { createServer, type Server } from 'node:https'; import type { AddressInfo } from 'node:net'; import { isIP } from 'node:net'; import type { TLSSocket } from 'node:tls'; import { z } from 'zod'; const MAX_REQUEST_BYTES = 64 * 1024; const MAX_RESPONSE_BYTES = 1024 * 1024; const tlsFilesSchema = z.object({ certificateFile: z.string().min(1).max(4096), privateKeyFile: z.string().min(1).max(4096), clientCaFile: z.string().min(1).max(4096) }).strict(); const gatewayClientPolicySchema = z.object({ fingerprint: z.string().regex(/^[a-f0-9]{64}$/), roles: z.array(z.enum(['observer', 'address-allocator'])).min(1).max(2), }).strict(); const gatewayConfigSchema = z.object({ listenHost: z.string().min(1).max(64), listenPort: z.number().int().min(1024).max(65535), walletRpcUrl: z.string().url().max(2048), server: tlsFilesSchema, clientPolicies: z.array(gatewayClientPolicySchema).min(1).max(32), }).strict().superRefine((config, context) => { const fingerprints = config.clientPolicies.map(client => client.fingerprint); if (new Set(fingerprints).size !== fingerprints.length) { context.addIssue({ code: 'custom', message: 'Gateway client fingerprints must be unique', path: ['clientPolicies'] }); } for (const client of config.clientPolicies) { if (new Set(client.roles).size !== client.roles.length) { context.addIssue({ code: 'custom', message: 'Gateway client roles must be unique', path: ['clientPolicies'] }); } } }); const envelopeSchema = z.object({ jsonrpc: z.literal('2.0'), id: z.union([z.string().max(128), z.number().int().safe()]), method: z.string().max(64), params: z.record(z.string(), z.unknown()).optional() }).strict(); const uint32 = z.number().int().min(0).max(0xffff_ffff).safe(); const height = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).safe(); export type MoneroGatewayConfig = z.infer; export type MoneroGatewayRole = 'observer' | 'address-allocator'; type GatewayMethod = 'get_height' | 'get_address' | 'validate_address' | 'create_address' | 'get_transfers' | 'incoming_transfers'; const METHODS_BY_ROLE: Record = { observer: ['get_height', 'get_address', 'get_transfers', 'incoming_transfers'], 'address-allocator': ['get_address', 'validate_address', 'create_address'], }; function isPrivateBindAddress(value: string): boolean { if (isIP(value) === 4) { const octets = value.split('.').map(Number); return octets[0] === 127 || octets[0] === 10 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168); } if (isIP(value) === 6) { const normalized = value.toLowerCase(); return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd'); } return false; } export async function loadMoneroRpcGatewayConfig(path: string): Promise { try { const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > 16 * 1024) throw new Error(); if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error(); return gatewayConfigSchema.parse(JSON.parse(await readFile(path, 'utf8'))); } catch { throw new Error('Invalid or inaccessible protected Monero RPC gateway configuration'); } } async function readPrivateFile(path: string): Promise { const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > 64 * 1024) { throw new Error('Monero gateway TLS material must be a bounded regular file'); } if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) { throw new Error('Monero gateway TLS material permissions are too broad'); } return readFile(path); } export function validateMoneroRpcGatewayRequest(value: unknown, roles: readonly MoneroGatewayRole[]): string { const request = envelopeSchema.parse(value); const parsedRoles = z.array(z.enum(['observer', 'address-allocator'])).min(1).max(2).parse(roles); if (new Set(parsedRoles).size !== parsedRoles.length) throw new Error('Monero RPC gateway client roles must be unique'); const allowed = new Set(parsedRoles.flatMap(role => METHODS_BY_ROLE[role])); if (!allowed.has(request.method as GatewayMethod)) throw new Error('Wallet RPC method is not allowed for this client certificate'); const params = request.params ?? {}; switch (request.method) { case 'get_height': z.object({}).strict().parse(params); break; case 'get_address': { z.object({ account_index: uint32, address_index: z.array(uint32).length(1) }).strict().parse(params); break; } case 'validate_address': z.object({ address: z.string().length(95) }).strict().parse(params); break; case 'create_address': { const parsed = z.object({ account_index: uint32, label: z.string().min(1).max(128), count: z.literal(1) }).strict().parse(params); if (/[\r\n\x00]/.test(parsed.label)) throw new Error('Invalid subaddress label'); break; } case 'get_transfers': { const parsed = z.object({ in: z.literal(true), filter_by_height: z.literal(true), min_height: height, max_height: height }).strict().parse(params); if (parsed.max_height < parsed.min_height || parsed.max_height - parsed.min_height > 100_000) { throw new Error('Wallet transfer scan range is invalid or too large'); } break; } case 'incoming_transfers': z.object({ transfer_type: z.literal('all'), account_index: uint32, subaddr_indices: z.array(uint32).min(1).max(64) }).strict().parse(params); break; default: throw new Error('Wallet RPC method is not allow-listed'); } const body = JSON.stringify(request); if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) throw new Error('Wallet RPC request exceeds size limit'); return body; } async function forwardToLocalWallet(url: URL, body: string): Promise<{ status: number; contentType: string | undefined; body: Buffer }> { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); timeout.unref(); try { const response = await fetch(url, { method: 'POST', redirect: 'error', signal: controller.signal, headers: { accept: 'application/json', 'content-type': 'application/json' }, body }); if (response.status !== 200 || response.redirected || !/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '') || !response.body) { await response.body?.cancel(); throw new Error('Local Wallet RPC response rejected'); } const reader = response.body.getReader(), chunks: Uint8Array[] = []; let size = 0; try { for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.byteLength; if (size > MAX_RESPONSE_BYTES) throw new Error('Local Wallet RPC response exceeds size limit'); chunks.push(part.value); } } finally { await reader.cancel(); reader.releaseLock(); } return { status: response.status, contentType: response.headers.get('content-type') ?? undefined, body: Buffer.concat(chunks) }; } finally { clearTimeout(timeout); } } /** * Private mTLS authorization gateway for Monero v0.18.x Wallet RPC. Bind it to loopback; * run wallet-rpc with --disable-rpc-login only behind this process and a host firewall. * Only allow-listed, schema-bounded methods are forwarded to a literal loopback upstream. */ export async function createMoneroRpcGateway(configInput: unknown): Promise { const config = gatewayConfigSchema.parse(configInput); if (!isPrivateBindAddress(config.listenHost)) throw new Error('Monero RPC gateway must bind to an explicit private interface IP'); let upstream: URL; try { upstream = new URL(config.walletRpcUrl); } catch { throw new Error('Invalid local Wallet RPC URL'); } if (upstream.protocol !== 'http:' || !['127.0.0.1', '[::1]'].includes(upstream.hostname) || upstream.pathname !== '/json_rpc' || upstream.username || upstream.password || upstream.search || upstream.hash) { throw new Error('Wallet RPC gateway upstream must be fixed loopback-only HTTP'); } const clientPolicies = new Map(config.clientPolicies.map(policy => [policy.fingerprint, policy.roles])); const [cert, key, ca] = await Promise.all([readPrivateFile(config.server.certificateFile), readPrivateFile(config.server.privateKeyFile), readPrivateFile(config.server.clientCaFile)]); const server = createServer({ cert, key, ca, requestCert: true, rejectUnauthorized: true, minVersion: 'TLSv1.3' }, (request, response) => { void (async () => { if (request.method !== 'POST' || request.url !== '/json_rpc' || !/^application\/json(?:\s*;|$)/i.test(request.headers['content-type'] ?? '')) { response.writeHead(404).end(); return; } const peer = (request.socket as TLSSocket).getPeerCertificate(); const roles = peer.raw ? clientPolicies.get(createHash('sha256').update(peer.raw).digest('hex')) : undefined; if (!roles) { response.writeHead(403).end(); return; } const chunks: Buffer[] = []; let size = 0; for await (const chunk of request) { size += chunk.length; if (size > MAX_REQUEST_BYTES) { response.writeHead(413).end(); return; } chunks.push(Buffer.from(chunk)); } let body: string; try { body = validateMoneroRpcGatewayRequest(JSON.parse(Buffer.concat(chunks).toString('utf8')), roles); } catch { response.writeHead(400).end(); return; } let result: Awaited>; try { result = await forwardToLocalWallet(upstream, body); } catch { response.writeHead(502).end(); return; } response.writeHead(result.status, { 'content-type': result.contentType ?? 'application/json', 'cache-control': 'no-store', 'content-length': result.body.byteLength }).end(result.body); })().catch(() => { if (!response.headersSent) response.writeHead(500).end(); else response.destroy(); }); }); server.maxConnections = 32; server.headersTimeout = 5000; server.requestTimeout = 10_000; await new Promise((resolve, reject) => { server.once('error', reject); server.listen(config.listenPort, config.listenHost, () => { server.off('error', reject); resolve(); }); }); const address = server.address() as AddressInfo | null; if (!address || address.address !== config.listenHost || address.port !== config.listenPort) { await new Promise(resolve => server.close(() => resolve())); throw new Error('Monero RPC gateway did not bind to the configured loopback address'); } return server; }