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 { request as httpsRequest } from 'node:https'; 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 Object.keys(certs)) await chmod(certs[file], 0o600); return new X509Certificate(await readFile(certs['client.crt'])).fingerprint256.replaceAll(':','').toLowerCase(); } async function rawRpc(method, params = {}) { console.log(`wallet-rpc: ${method}`); const id = randomUUID(); const response = await fetch(rpcBase, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000), 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; } console.log(`wallet-rpc: ${method} complete`); 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()+60_000; while(Date.now()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}; } async function rawGatewayStatus(method, params, cert, key, ca) { const body=Buffer.from(JSON.stringify({jsonrpc:'2.0',id:randomUUID(),method,params})); const [certPem,keyPem,caPem]=await Promise.all([readFile(cert),readFile(key),readFile(ca)]); return new Promise((resolveStatus,reject)=>{ const request=httpsRequest({hostname:'127.0.0.1',port:gatewayPort,path:'/json_rpc',method:'POST',cert:certPem,key:keyPem,ca:caPem, rejectUnauthorized:true,minVersion:'TLSv1.3',headers:{'content-type':'application/json','content-length':body.length}},response=>{ response.resume(); response.once('end',()=>resolveStatus(response.statusCode)); }); request.once('error',reject); request.end(body); }); } try { console.log('stage: generate ephemeral TLS certificates'); const fingerprint = await makeCerts(); console.log('stage: start offline Stagenet Wallet RPC'); startWallet(); await waitWallet(); console.log('stage: create watch-only disposable wallet'); 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(); console.log('stage: wallet is watch-only after restart'); 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']}, clientPolicies:[{fingerprint,roles:['observer','address-allocator']}]}); console.log('stage: mTLS gateway listening'); 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]}); console.log('stage: authorized mTLS address read'); 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}); console.log('stage: authorized mTLS subaddress creation'); assert.equal(sub.status,200); assert.equal(sub.data.result.address_index,1); const outputs=await gatewayCall(validTransport,'incoming_transfers',{transfer_type:'all',account_index:0,subaddr_indices:[1]}); assert.equal(outputs.status,200); assert.equal(outputs.data.error,undefined,'incoming_transfers must be a supported JSON-RPC method'); assert.deepEqual(outputs.data.result?.transfers ?? [],[], 'a fresh watch-only Stagenet address must return an empty owned-output list'); console.log('stage: incoming_transfers allowed; pinned RPC omits default empty result fields'); const forbiddenStatus=await rawGatewayStatus('transfer',{destinations:[]},certs['client.crt'],certs['client.key'],certs['ca.crt']); assert.equal(forbiddenStatus,400,'gateway must reject transfer before Wallet RPC'); console.log('stage: forbidden transfer blocked'); 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]})); console.log('stage: untrusted client certificate rejected'); await assert.rejects(fetch(gatewayUrl,{method:'POST',headers:{'content-type':'application/json'},body:'{}', signal:AbortSignal.timeout(3000)}),'gateway must reject clients without a certificate'); console.log('stage: missing client certificate rejected'); 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,allowedOutputRead: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; }