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
+126
View File
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import { createHash, randomBytes, randomUUID, X509Certificate } from 'node:crypto';
import { execFile as execFileCallback, spawn } from 'node:child_process';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { promisify } from 'node:util';
import { createMoneroRpcGateway } from '../server/market/monero-rpc-gateway.ts';
import { loadMoneroMtlsTransport } from '../server/market/monero-mtls-transport.ts';
const execFile = promisify(execFileCallback);
const walletRpcBinary = process.argv[2];
if (!walletRpcBinary || !/monero-wallet-rpc(?:\.exe)?$/i.test(walletRpcBinary)) {
throw new Error('Pass the verified monero-wallet-rpc binary path');
}
const binary = resolve(walletRpcBinary), temp = await mkdtemp(join(tmpdir(), 'wikideck-mtls-stagenet-'));
const walletPort = 18083, gatewayPort = 18085;
const rpcBase = `http://127.0.0.1:${walletPort}/json_rpc`;
const gatewayUrl = `https://127.0.0.1:${gatewayPort}/json_rpc`;
const walletDir = join(temp, 'wallets'), walletName = 'mtls-watch-only';
const walletPassword = randomBytes(32).toString('hex');
let walletRpc, gateway, passed = false;
const certs = Object.fromEntries(['ca.key','ca.crt','server.key','server.csr','server.crt','server.ext',
'client.key','client.csr','client.crt','client.ext','rogue.key','rogue.csr','rogue.crt','rogue.ext']
.map(name => [name, join(temp, name)]));
async function runOpenSsl(args) {
await execFile('openssl', args, { cwd: temp, windowsHide: true, timeout: 15_000 });
}
async function makeCerts() {
await runOpenSsl(['req','-x509','-newkey','rsa:2048','-nodes','-sha256','-days','1','-keyout',certs['ca.key'],
'-out',certs['ca.crt'],'-subj','/CN=Wikideck Disposable Test CA']);
await runOpenSsl(['req','-newkey','rsa:2048','-nodes','-sha256','-keyout',certs['server.key'],
'-out',certs['server.csr'],'-subj','/CN=127.0.0.1']);
await writeFile(certs['server.ext'], 'subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\nkeyUsage=digitalSignature,keyEncipherment\n');
await runOpenSsl(['x509','-req','-sha256','-days','1','-in',certs['server.csr'],'-CA',certs['ca.crt'],
'-CAkey',certs['ca.key'],'-CAcreateserial','-out',certs['server.crt'],'-extfile',certs['server.ext']]);
for (const name of ['client','rogue']) {
await runOpenSsl(['req','-newkey','rsa:2048','-nodes','-sha256','-keyout',certs[`${name}.key`],
'-out',certs[`${name}.csr`],'-subj',`/CN=wikideck-${name}`]);
await writeFile(certs[`${name}.ext`], 'extendedKeyUsage=clientAuth\nkeyUsage=digitalSignature,keyEncipherment\n');
await runOpenSsl(['x509','-req','-sha256','-days','1','-in',certs[`${name}.csr`],'-CA',certs['ca.crt'],
'-CAkey',certs['ca.key'],'-CAcreateserial','-out',certs[`${name}.crt`],'-extfile',certs[`${name}.ext`]]);
}
for (const file of ['ca.key','server.key','client.key','rogue.key']) await chmod(certs[file], 0o600);
return new X509Certificate(await readFile(certs['client.crt'])).fingerprint256.replaceAll(':','').toLowerCase();
}
async function rawRpc(method, params = {}) {
const id = randomUUID();
const response = await fetch(rpcBase, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(5000),
headers: { 'content-type':'application/json', accept:'application/json' },
body: JSON.stringify({ jsonrpc:'2.0', id, method, params }) });
assert.equal(response.status, 200);
const data = await response.json(); assert.equal(data.id,id);
if (data.error) { const error = new Error('wallet RPC method returned an error'); error.rpcCode = data.error.code; throw error; }
return data.result;
}
function startWallet(walletFile) {
const args = ['--stagenet','--offline','--rpc-bind-ip=127.0.0.1',`--rpc-bind-port=${walletPort}`,
'--disable-rpc-login',`--wallet-dir=${walletDir}`,'--non-interactive','--no-initial-sync',
'--max-concurrency=1','--log-level=0',`--log-file=${join(temp,'wallet-rpc.log')}`];
if (walletFile) { args.splice(args.indexOf(`--wallet-dir=${walletDir}`),1); args.push(`--wallet-file=${walletFile}`,`--password=${walletPassword}`); }
walletRpc = spawn(binary,args,{shell:false,windowsHide:true,stdio:'ignore',cwd:temp}); walletRpc.on('error',()=>{});
}
async function waitWallet() {
const deadline=Date.now()+15_000;
while(Date.now()<deadline) {
if(walletRpc.exitCode!==null) throw new Error(`Wallet RPC exited during startup (${walletRpc.exitCode})`);
try { await rawRpc('get_version'); return; } catch { await new Promise(r=>setTimeout(r,100)); }
}
throw new Error('Wallet RPC readiness timeout');
}
async function stopWallet() {
if(!walletRpc||walletRpc.exitCode!==null) return;
await rawRpc('store').catch(()=>{}); await rawRpc('stop_wallet').catch(()=>{});
await Promise.race([new Promise(r=>walletRpc.once('exit',r)),new Promise(r=>setTimeout(r,5000))]);
if(walletRpc.exitCode===null) walletRpc.kill('SIGKILL');
}
async function gatewayCall(transport,method,params={}) {
const id=randomUUID();
const response=await transport(gatewayUrl,{method:'POST',redirect:'error',headers:{'content-type':'application/json',accept:'application/json'},
body:JSON.stringify({jsonrpc:'2.0',id,method,params})});
const data=await response.json();
return {status:response.status,data};
}
try {
const fingerprint = await makeCerts();
startWallet(); await waitWallet();
await rawRpc('create_wallet',{filename:'source',password:walletPassword,language:'English'});
const primary=(await rawRpc('get_address',{account_index:0})).address;
const viewKey=(await rawRpc('query_key',{key_type:'view_key'})).key;
const watch=await rawRpc('generate_from_keys',{filename:walletName,address:primary,viewkey:viewKey,
password:walletPassword,restore_height:0,autosave_current:true});
assert.equal(watch.address,primary);
await assert.rejects(rawRpc('query_key',{key_type:'spend_key'}));
await stopWallet(); startWallet(join(walletDir,walletName)); await waitWallet();
gateway=await createMoneroRpcGateway({listenHost:'127.0.0.1',listenPort:gatewayPort,walletRpcUrl:rpcBase,
server:{certificateFile:certs['server.crt'],privateKeyFile:certs['server.key'],clientCaFile:certs['ca.crt']},
clientFingerprints:[fingerprint]});
const validTransport=await loadMoneroMtlsTransport({url:gatewayUrl,clientCertificateFile:certs['client.crt'],
clientPrivateKeyFile:certs['client.key'],serverCaFile:certs['ca.crt']});
const info=await gatewayCall(validTransport,'get_address',{account_index:0,address_index:[0]});
assert.equal(info.status,200); assert.equal(info.data.result.address,primary);
const sub=await gatewayCall(validTransport,'create_address',{account_index:0,label:`wikideck-deposit-v1:${randomUUID()}`,count:1});
assert.equal(sub.status,200); assert.equal(sub.data.result.address_index,1);
const forbiddenResponse=await validTransport(gatewayUrl,{method:'POST',redirect:'error',headers:{'content-type':'application/json'},
body:JSON.stringify({jsonrpc:'2.0',id:randomUUID(),method:'transfer',params:{destinations:[]}})});
assert.equal(forbiddenResponse.status,400,'gateway must reject transfer before Wallet RPC');
const rogueTransport=await loadMoneroMtlsTransport({url:gatewayUrl,clientCertificateFile:certs['rogue.crt'],
clientPrivateKeyFile:certs['rogue.key'],serverCaFile:certs['ca.crt']});
await assert.rejects(gatewayCall(rogueTransport,'get_address',{account_index:0,address_index:[0]}));
await assert.rejects(fetch(gatewayUrl,{method:'POST',headers:{'content-type':'application/json'},body:'{}',
signal:AbortSignal.timeout(3000)}),'gateway must reject clients without a certificate');
passed=true;
console.log(JSON.stringify({result:'passed',moneroWalletRpc:'0.18.5.4',network:'Stagenet',containerNetwork:'none',
rpcAuthentication:'mTLS',tlsMinimum:'1.3',approvedClientAccepted:true,untrustedClientRejected:true,
noCertificateRejected:true,forbiddenTransferRejected:true,allowedAddressCreation:true,watchOnly:true,
realFundsUsed:false,secretsOrAddressesLogged:false}));
} finally {
if(gateway) await new Promise(resolveClose=>gateway.close(()=>resolveClose()));
await stopWallet(); await rm(temp,{recursive:true,force:true}); if(!passed) process.exitCode=1;
}