Started Walet RPC auth work without adding MD5 Digest support

This commit is contained in:
2026-10-21 17:33:33 -04:00
parent f00ea55dfd
commit 40f4aa6d4e
7 changed files with 467 additions and 22 deletions
+179
View File
@@ -0,0 +1,179 @@
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 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,
clientFingerprints: z.array(z.string().regex(/^[a-f0-9]{64}$/)).min(1).max(32),
}).strict();
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<typeof gatewayConfigSchema>;
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<MoneroGatewayConfig> {
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<Buffer> {
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): string {
const request = envelopeSchema.parse(value);
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;
}
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<Server> {
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 fingerprints = new Set(config.clientFingerprints);
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();
if (!peer.raw || !fingerprints.has(createHash('sha256').update(peer.raw).digest('hex'))) {
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'))); }
catch { response.writeHead(400).end(); return; }
let result: Awaited<ReturnType<typeof forwardToLocalWallet>>;
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<void>((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<void>(resolve => server.close(() => resolve()));
throw new Error('Monero RPC gateway did not bind to the configured loopback address');
}
return server;
}