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
+109
View File
@@ -0,0 +1,109 @@
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<Buffer> {
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<MoneroFixtureTransport> {
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<Response>((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);
});
};
}
+7 -9
View File
@@ -11,8 +11,6 @@ const MAX_TRANSFERS = 4096;
const configSchema = z.object({
network: z.literal('mainnet'),
url: z.string().min(1).max(2048),
username: z.string().min(1).max(256),
password: z.string().min(1).max(1024),
}).strict();
const rpcEnvelopeSchema = z.object({
jsonrpc: z.literal('2.0'), id: z.string().uuid(), result: z.record(z.string(), z.unknown()).optional(),
@@ -137,18 +135,18 @@ export async function loadMoneroObserverConfig(env: NodeJS.ProcessEnv = process.
if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error();
const parsed = configSchema.parse(JSON.parse(await readFile(path, 'utf8')));
validateEndpoint(parsed.url);
if (/[\r\n\x00]/.test(parsed.username) || /[\r\n\x00]/.test(parsed.password)) throw new Error();
return parsed;
} catch {
throw new Error('Invalid or inaccessible Monero wallet-RPC config; credentials must be in a protected regular file');
throw new Error('Invalid or inaccessible protected Monero wallet-RPC configuration');
}
}
/** Restricted read-only surface. No generic RPC method, subaddress creation, transfer or sweep API exists. */
/** Restricted RPC surface. Authentication is delegated to an explicitly injected secure transport. */
function createWalletRpc(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport,
allowedMethods: readonly ('get_height' | 'get_address' | 'get_transfers' | 'validate_address' | 'create_address')[]) {
const endpoint = validateEndpoint(config.url);
const authorization = `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`;
// Removed Basic auth header
async function rpc(method: 'get_height' | 'get_address' | 'get_transfers' | 'validate_address' | 'create_address', params: Record<string, unknown> = {}) {
if (!allowedMethods.includes(method)) throw new Error('Monero wallet-RPC method is not allowed for this client');
const id = randomUUID(), controller = new AbortController();
@@ -159,7 +157,7 @@ function createWalletRpc(config: z.infer<typeof configSchema>, transport: Monero
let response: Response;
try {
response = await transport(endpoint.href, { method: 'POST', redirect: 'error', signal: controller.signal,
headers: { authorization, accept: 'application/json', 'content-type': 'application/json' },
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }) });
} catch { throw new Error('Monero wallet-RPC request failed'); }
if (!response.ok || response.redirected || !response.body ||
@@ -194,7 +192,7 @@ function createWalletRpc(config: z.infer<typeof configSchema>, transport: Monero
}
/** Read-only observer capability. It deliberately cannot create addresses or move funds. */
export function createMoneroWalletObserver(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport = fetch) {
export function createMoneroWalletObserver(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport) {
const rpc = createWalletRpc(config, transport, ['get_height', 'get_address', 'get_transfers']);
return {
async verifyMainnetWallet(): Promise<string> {
@@ -254,11 +252,11 @@ export async function moneroWalletFingerprint(primaryAddress: string): Promise<s
* from API and withdrawal capabilities. This
* client is intentionally not attached to startup, routes, UI, or automatic crediting.
*/
export function createMoneroDepositAddressRpc(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport = fetch) {
export function createMoneroDepositAddressRpc(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport) {
const rpc = createWalletRpc(config, transport, ['get_address', 'validate_address', 'create_address']);
return {
async verifyWallet(): Promise<string> {
const address = addressResultSchema.parse(await rpc('get_address', { account_index: 0 }));
const address = addressResultSchema.parse(await rpc('get_address', { account_index: 0, address_index: [0] }));
await validateXmrAddress(address.address);
validateAddressResultSchema.parse(await rpc('validate_address', { address: address.address }));
return moneroWalletFingerprint(address.address);
+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;
}