111 lines
5.7 KiB
TypeScript
Executable File
111 lines
5.7 KiB
TypeScript
Executable File
import { lstat, readFile } from 'node:fs/promises';
|
|
import { request as httpsRequest } from 'node:https';
|
|
import { isIP } from 'node:net';
|
|
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,
|
|
...(isIP(url.hostname) ? {} : { 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);
|
|
});
|
|
};
|
|
}
|