Stagenet rehearsal passed
This commit is contained in:
@@ -152,4 +152,6 @@ Required failure outcomes: duplicate deposit credits are impossible; insufficien
|
||||
|
||||
Source review: 2026-10-20; documentation is capability evidence, not an audited implementation of this design. No wallet/RPC credentials or funds were accessed.
|
||||
|
||||
**Actual RPC integration blocker:** The v0.18.5.4 process rehearsal confirmed a login-protected Wallet RPC returns HTTP 401 with a Digest challenge; Basic auth also returns 401. The app does not implement legacy MD5 Digest. New groundwork replaces that idea with a local TLS 1.3 mTLS gateway and a client transport that emits no Authorization header. The gateway binds only to an explicitly configured private IP (never a wildcard/public interface), authenticates an allowlisted client certificate, permits only schema-bounded address/observation methods, and forwards to a fixed loopback Wallet RPC upstream. Wallet RPC would run without its own HTTP login only inside that protected boundary. This gateway is not deployed or wired to the application, and actual mTLS interoperability remains to be tested; do not enable wallet RPC access until the end-to-end certificate and isolation fixture passes.
|
||||
**Actual mTLS rehearsal result:** against the authenticated official v0.18.5.4 binary in Docker `--network none` on offline Stagenet, the approved ephemeral client certificate succeeded, a CA-trusted but non-allowlisted certificate and a client without a certificate were rejected, and a forbidden transfer method was rejected before Wallet RPC. This is an actual protocol handshake and allowlist test in one isolated container, not production network isolation or certificate lifecycle proof. Gateway/app wiring, deployment firewall policies, cert rotation, deposit observation/crediting and any payment capability remain unimplemented/disabled.
|
||||
|
||||
**Actual RPC integration blocker:** The v0.18.5.4 process rehearsal confirmed a login-protected Wallet RPC returns HTTP 401 with a Digest challenge; Basic auth also returns 401. The app does not implement legacy MD5 Digest. New groundwork replaces that idea with a local TLS 1.3 mTLS gateway and a client transport that emits no Authorization header. The gateway binds only to an explicitly configured private IP (never a wildcard/public interface), authenticates an allowlisted client certificate, permits only schema-bounded address/observation methods, and forwards to a fixed loopback Wallet RPC upstream. Wallet RPC would run without its own HTTP login only inside that protected boundary. This gateway is not deployed or wired to the application. Its isolated end-to-end certificate handshake has now passed against the official Wallet RPC; intended-host/network isolation, certificate provisioning/rotation, and production configuration remain unverified. Do not enable Wallet RPC outside the tested disposable boundary until those deployment controls pass review.
|
||||
|
||||
@@ -20,11 +20,11 @@ Status: active engineering plan; **not launch or payment authorization**. Update
|
||||
|
||||
**Current design deliverable:** [custodial market design](custodial-market-design.md). Read the workstreams below with this update: pooled customer liabilities replace per-order incoming invoices and external sale payouts in the proposed architecture. Earlier trustless-first research gates are historical, not a prerequisite to investigating custody. Exact ledger, withdrawal, fee, custody/legal and host-attested NFT consensus rules still require approval and evidence; the existing mock domain cannot authorize real balance debits.
|
||||
|
||||
**Monero observation and allocation groundwork:** the disabled-by-default observer remains read-only. The separate address RPC capability verifies a mainnet primary address using `get_address` and `validate_address` and derives a wallet fingerprint; it does not claim to attest wallet type. Actual testing found `get_wallet_info` is unavailable as JSON-RPC in v0.18.5.4, and `--rpc-login` requires legacy HTTP Digest/MD5. The app does not implement Digest or send Basic credentials; the new mTLS gateway/transport described below is not yet deployed or wired. Do not enable Wallet RPC until that end-to-end path passes its certificate and isolation tests. Watch-only wallet provisioning and network isolation must be enforced by deployment. The internal allocator checks the primary-address fingerprint against the durable generation before creating, reserves beneficiary/index first, serializes cooperating allocators, and recovers create-success/lost-response by checking a stable label at the reserved index. It recognizes only the observed “address index is out of bound” error as an unused slot and fails closed on other RPC errors. Actual RPC verification is limited to the offline disposable rehearsal below; no application startup/routes/UI/scans or financial posting are wired, and the registry remains inaccessible to custody API and observer roles. Operational assumptions still include a dedicated wallet RPC, controlled generation enrollment/backups, and wallet/database reconciliation. The registry cannot establish unique output identity. Missing scans remain review evidence, not automatic balance changes. Verification passed: Docker test-target build; **187/187** offline tests; **35/35** disposable PostgreSQL tests, including v1/v2 migrations, allocator concurrency, replay, immutable registration, role isolation, zero journal postings, wallet mismatch rejection, the official -15 index response, and simulated response-loss recovery. New gateway allowlist/config tests pass; no actual mTLS certificate handshake has yet been tested. No live RPC credentials, funds or mainnet wallet were used. Remaining deposit milestone: complete the mTLS gateway interoperability/isolation rehearsal, establish durable wallet-generation enrollment and recovery, pin per-output identity and canonical inclusion/lock/reorg semantics, then implement idempotent credit journals and audited reversal/hold handling before stagenet deposit-credit rehearsal or any real deposits.
|
||||
**Monero observation and allocation groundwork:** the disabled-by-default observer remains read-only. The separate address RPC capability verifies a mainnet primary address using `get_address` and `validate_address` and derives a wallet fingerprint; it does not claim to attest wallet type. Actual testing found `get_wallet_info` is unavailable as JSON-RPC in v0.18.5.4, and `--rpc-login` requires legacy HTTP Digest/MD5. The app does not implement Digest or send Basic credentials; the new mTLS gateway/transport is not deployed or wired. Its isolated certificate handshake, method allowlist and denial cases passed the rehearsal below; do not enable Wallet RPC until the intended deployment’s network isolation, certificate lifecycle and firewall controls are also verified. Do not enable Wallet RPC until that end-to-end path passes its certificate and isolation tests. Watch-only wallet provisioning and network isolation must be enforced by deployment. The internal allocator checks the primary-address fingerprint against the durable generation before creating, reserves beneficiary/index first, serializes cooperating allocators, and recovers create-success/lost-response by checking a stable label at the reserved index. It recognizes only the observed “address index is out of bound” error as an unused slot and fails closed on other RPC errors. Actual RPC verification is limited to the offline disposable rehearsal below; no application startup/routes/UI/scans or financial posting are wired, and the registry remains inaccessible to custody API and observer roles. Operational assumptions still include a dedicated wallet RPC, controlled generation enrollment/backups, and wallet/database reconciliation. The registry cannot establish unique output identity. Missing scans remain review evidence, not automatic balance changes. Verification passed: Docker test-target build; **187/187** offline tests; **35/35** disposable PostgreSQL tests, including v1/v2 migrations, allocator concurrency, replay, immutable registration, role isolation, zero journal postings, wallet mismatch rejection, the official -15 index response, and simulated response-loss recovery. New gateway allowlist/config tests pass; no actual mTLS certificate handshake has yet been tested. No live RPC credentials, funds or mainnet wallet were used. Remaining deposit milestone: complete the mTLS gateway interoperability/isolation rehearsal, establish durable wallet-generation enrollment and recovery, pin per-output identity and canonical inclusion/lock/reorg semantics, then implement idempotent credit journals and audited reversal/hold handling before stagenet deposit-credit rehearsal or any real deposits.
|
||||
|
||||
**Initial actual-wallet rehearsal blocker (2026-10-21, superseded below):** no Monero wallet binary or pinned image was present locally, so the RPC contract could not yet be exercised against a real process. This was resolved by obtaining and verifying the official v0.18.5.4 archive and running the disposable offline Stagenet rehearsal recorded below. The recorded evidence does not authorize mainnet access, deposits, credits, or payments.
|
||||
|
||||
**Actual Wallet RPC rehearsal completed (2026-10-21):** obtained Monero CLI v0.18.5.4 from the official downloads host. Verified the `binaryFate` GPG key fingerprint `81AC 591F E9C4 B65C 5806 AFC3 F0AF 4D46 2A0B DF92`, the GPG-signed canonical hashes file, and the Linux x64 archive SHA-256 `ba2928962c101b609434bf8c1b43ad59ebe96c5525977284ca599d8bf0f02d10`. Ran only the official `monero-wallet-rpc` executable in a disposable Docker container with `--network none --stagenet --offline`, loopback bind, and RPC login disabled only inside that network-isolated container. The rehearsal generated a temporary Stagenet wallet, derived a watch-only wallet from its view key (no key material or addresses logged), confirmed `validate_address` reports Stagenet, and verified watch-only behavior because `query_key(spend_key)` failed. It reserved the next index, observed v0.18.5.4 return error `-15` (“address index is out of bound”) for that unused index, created exactly one labeled subaddress, recovered by index after discarding the create response, stopped/restarted wallet RPC with the same wallet data, and verified the same address and label persisted. `get_wallet_info` returned Method not found. The release archive has no `monerod`, so there was no daemon, peer connection or chain sync. The run logic is captured by [scripts/monero-stagenet-rpc-rehearsal.mjs](../scripts/monero-stagenet-rpc-rehearsal.mjs). It does **not** test authenticated application transport (the fixture deliberately disables RPC login inside the isolated container), concurrent external wallet clients, hard power-loss durability, deposits, credits, withdrawals, mainnet, or live payment. Temporary wallet files and the verification/download directory are removed after preserving the signed hash/digest evidence; no production credentials or funds were used.
|
||||
**Actual Wallet RPC + mTLS gateway rehearsal completed (2026-10-21):** the test image obtains Monero CLI v0.18.5.4 only from the official downloads host, verifies the `binaryFate` fingerprint `81AC 591F E9C4 B65C 5806 AFC3 F0AF 4D46 2A0B DF92`, verifies the GPG-signed canonical hashes file and checks the Linux x64 archive digest `ba2928962c101b609434bf8c1b43ad59ebe96c5525977284ca599d8bf0f02d10` before extraction. Its rehearsal creates ephemeral CA/server/approved-client/untrusted-client certificates, then runs `monero-wallet-rpc` in Docker `--network none`, `--stagenet --offline`, loopback-only, with login disabled behind the tested TLS 1.3 mTLS gateway. The real wallet was verified watch-only by the `query_key(spend_key)` denial. The configured client cert successfully read the primary address and created one subaddress; the gateway rejected a transfer request (HTTP 400 before upstream), a CA-signed but unapproved client certificate, and a no-certificate request. Wallet files were stopped and deleted with the ephemeral container. No `monerod`, peer, network, live credential, mainnet or funds were involved. This proves protocol interoperability in that isolated single-container topology; it does **not** prove Kubernetes/apartment firewall isolation, certificate issuance/rotation, deployment configuration, hard power-loss durability, deposits/credits/withdrawals, or production readiness. The current app still does not start this gateway or observer; HTTP auth with Monero's own `--rpc-login` remains intentionally unused. Full offline suite after these changes: **187/187 passed**; disposable PostgreSQL suite: **35/35 passed**; test image build succeeded.
|
||||
|
||||
## 1A. MVP release boundary — proposed safe first production-hosted release
|
||||
|
||||
|
||||
Executable
+28
@@ -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"]
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -75,7 +76,7 @@ export async function loadMoneroMtlsTransport(configInput: unknown): Promise<Mon
|
||||
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',
|
||||
...(isIP(url.hostname) ? {} : { servername: url.hostname }), minVersion: 'TLSv1.3',
|
||||
}, response => {
|
||||
if ((response.statusCode ?? 0) < 200 || (response.statusCode ?? 0) >= 300 ||
|
||||
(response.headers.location !== undefined) ||
|
||||
|
||||
Reference in New Issue
Block a user