Started Walet RPC auth work without adding MD5 Digest support
This commit is contained in:
Executable
+109
@@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Executable
+179
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user