Stagenet rehearsal passed

This commit is contained in:
2026-10-21 18:38:25 -04:00
parent 40f4aa6d4e
commit e3b2261da3
5 changed files with 63 additions and 9 deletions
+28
View File
@@ -0,0 +1,28 @@
FROM node:24-trixie-slim
WORKDIR /app
RUN apt-get update -qq \
&& apt-get upgrade -y -qq \
&& apt-get install -y -qq --no-install-recommends ca-certificates curl gnupg openssl bzip2 \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN set -eu; \
mkdir -m 700 -p /tmp/monero-verify /opt/monero; \
cd /tmp/monero-verify; \
curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
'https://downloads.getmonero.org/cli/monero-linux-x64-v0.18.5.4.tar.bz2' -o monero-linux-x64-v0.18.5.4.tar.bz2; \
curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
'https://www.getmonero.org/downloads/hashes.txt' -o hashes.txt; \
curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
'https://raw.githubusercontent.com/monero-project/monero/master/utils/gpg_keys/binaryfate.asc' -o binaryfate.asc; \
actual_fingerprint="$(gpg --batch --show-keys --with-colons binaryfate.asc | awk -F: '$1 == "fpr" {print $10; exit}')"; \
test "$actual_fingerprint" = '81AC591FE9C4B65C5806AFC3F0AF4D462A0BDF92'; \
export GNUPGHOME=/tmp/monero-verify/gnupg; mkdir -m 700 -p "$GNUPGHOME"; \
gpg --batch --import binaryfate.asc >/dev/null 2>&1; \
gpg --batch --output hashes.verified.txt --decrypt hashes.txt >/dev/null 2>&1; \
grep -Fx 'ba2928962c101b609434bf8c1b43ad59ebe96c5525977284ca599d8bf0f02d10 monero-linux-x64-v0.18.5.4.tar.bz2' hashes.verified.txt >/dev/null; \
printf '%s %s\n' 'ba2928962c101b609434bf8c1b43ad59ebe96c5525977284ca599d8bf0f02d10' 'monero-linux-x64-v0.18.5.4.tar.bz2' | sha256sum --check --status; \
tar -xjf monero-linux-x64-v0.18.5.4.tar.bz2 -C /opt/monero --strip-components=1; \
rm -rf /tmp/monero-verify
CMD ["node", "--experimental-strip-types", "scripts/monero-mtls-stagenet-rehearsal.mjs", "/opt/monero/monero-wallet-rpc"]
+28 -5
View File
@@ -5,6 +5,7 @@ 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';
@@ -42,18 +43,20 @@ async function makeCerts() {
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);
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(5000),
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) {
@@ -84,10 +87,24 @@ async function gatewayCall(transport,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;
@@ -96,25 +113,31 @@ try {
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']},
clientFingerprints:[fingerprint]});
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 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 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,