diff --git a/docs/custodial-market-design.md b/docs/custodial-market-design.md index 24609c3..07ea064 100755 --- a/docs/custodial-market-design.md +++ b/docs/custodial-market-design.md @@ -19,9 +19,7 @@ The operator has authorized proceeding with implementation instead of the previo **Financial storage update:** [ADR 003](postgres-financial-storage.md) selects PostgreSQL and records the integrated asynchronous custody backend, restricted runtime/schema roles, deferred accounting constraints, explicit SQLite freeze/import, and full dump/restore rehearsal. SQLite remains an opt-in-compatible local sandbox default, not an automatic fallback. Real custody and production operating acceptance remain blocked. -**Monero observation and deposit-address allocation groundwork:** [server/market/monero-observer.ts](../server/market/monero-observer.ts) keeps the existing observer client strictly read-only: mainnet wallet validation and bounded incoming scans, authenticated requests, strict HTTPS/literal-loopback-only HTTP, no redirects, bounded response/deadline, JSON-RPC ID validation and lossless integer handling. A separate, narrow `createMoneroDepositAddressRpc()` capability verifies a mainnet primary address with `get_address` plus `validate_address`, then derives a domain-separated fingerprint. It can inspect one indexed subaddress or request exactly one labeled subaddress; it exposes no transfer, relay, sweep or withdrawal operation. Actual testing against the verified Monero v0.18.5.4 Wallet RPC found that `get_wallet_info` returns JSON-RPC “Method not found” despite appearing in current docs; the implementation therefore does not depend on it. The allocator [server/market/monero-deposit-allocation-store.ts](../server/market/monero-deposit-allocation-store.ts) combines PostgreSQL v3 beneficiary/index reservations with this capability: it reserves first, serializes cooperating allocators per wallet/account, checks that the live wallet fingerprint matches the durable generation, checks the exact reserved index, and recovers a lost `create_address` response by looking up the deterministic allocation label before retrying creation. Actual v0.18.5.4 testing also confirmed that an unused address index returns RPC error `-15` (“address index is out of bound”), rather than an empty `addresses` array; the client recognizes only that exact error as “not created” and fails closed for other errors. Address registration is immutable and performed only after the wallet returns the expected index/address/label. The installed v0.18.5.4 interface does not provide a supported `get_wallet_info` query to attest the wallet type, so watch-only provisioning is an operator/deployment requirement, not a property this RPC client can prove. If another wallet client consumes indexes outside this allocator, the operation fails for reconciliation rather than reassigning an address; the wallet RPC must therefore be dedicated to the allocator. The configured wallet-generation UUID still requires controlled operator enrollment and backup. Neither client nor orchestration is wired to startup, routes, UI, deposit scans or financial posting. The registry remains inaccessible to both custody API and observer roles. Observation storage continues to record reorg/reappearance evidence only; no credit, debit, reversal or journal can be posted. Schema v1→v3 is explicit and additive. The test-target build succeeded; **185/185** offline tests and **35/35** disposable PostgreSQL tests passed, including rejection of wallet identity mismatch and simulated create-success/response-loss/recovery without duplicate address creation or journal posting. An actual wallet-RPC rehearsal on the authenticated official v0.18.5.4 archive passed in Docker `--network none`, `--stagenet`, `--offline`, loopback-only mode: it created a disposable watch-only wallet, allocated/labeled one subaddress, recovered by index after discarding the create response, and verified the same address/label after RPC restart. No daemon/network peers, funds, or production credentials were used; all generated wallet files were deleted with the temporary container. This remains test groundwork, not a public deposit allocator or live processor; mainnet payments remain disabled. - -Validation: TypeScript/Vite build and 144 offline tests passed in the serial native-RandomX test run; the initial parallel run encountered a peer-gossip timeout. Browser smoke verified a disposable wallet's signed test credit, withdrawal hold and cancellation restoring the exact balance, plus a 390px viewport without horizontal overflow. No real funds were used. Tests and remaining acceptance gates are below; this is not launch readiness. +**Monero observation and deposit-address allocation groundwork:** [server/market/monero-observer.ts](../server/market/monero-observer.ts) keeps the existing observer client strictly read-only: mainnet wallet validation and bounded incoming scans, authenticated requests, strict HTTPS/literal-loopback-only HTTP, no redirects, bounded response/deadline, JSON-RPC ID validation and lossless integer handling. A separate, narrow `createMoneroDepositAddressRpc()` capability verifies a mainnet primary address with `get_address` plus `validate_address`, then derives a domain-separated fingerprint. It can inspect one indexed subaddress or request exactly one labeled subaddress; it exposes no transfer, relay, sweep or withdrawal operation. Actual testing against the verified Monero v0.18.5.4 Wallet RPC found that `get_wallet_info` returns JSON-RPC “Method not found” despite appearing in current docs; the implementation therefore does not depend on it. The allocator [server/market/monero-deposit-allocation-store.ts](../server/market/monero-deposit-allocation-store.ts) combines PostgreSQL v3 beneficiary/index reservations with this capability: it reserves first, serializes cooperating allocators per wallet/account, checks that the live wallet fingerprint matches the durable generation, checks the exact reserved index, and recovers a lost `create_address` response by looking up the deterministic allocation label before retrying creation. Actual v0.18.5.4 testing also confirmed that an unused address index returns RPC error `-15` (“address index is out of bound”), rather than an empty `addresses` array; the client recognizes only that exact error as “not created” and fails closed for other errors. Address registration is immutable and performed only after the wallet returns the expected index/address/label. The installed v0.18.5.4 interface does not provide a supported `get_wallet_info` query to attest the wallet type, so watch-only provisioning is an operator/deployment requirement, not a property this RPC client can prove. If another wallet client consumes indexes outside this allocator, the operation fails for reconciliation rather than reassigning an address; the wallet RPC must therefore be dedicated to the allocator. The configured wallet-generation UUID still requires controlled operator enrollment and backup. Neither client nor orchestration is wired to startup, routes, UI, deposit scans or financial posting. The registry remains inaccessible to both custody API and observer roles. Observation storage continues to record reorg/reappearance evidence only; no credit, debit, reversal or journal can be posted. Schema v1→v3 is explicit and additive. The test-target build succeeded; **187/187** offline tests and **35/35** disposable PostgreSQL tests passed, including rejection of wallet identity mismatch and simulated create-success/response-loss/recovery without duplicate address creation or journal posting. The mTLS gateway method allowlist and config guard have unit coverage; an end-to-end certificate handshake remains open. **Security claim:** operator-custodied XMR, seller-authorized and host-attested NFT sale completion, with durable accounting and recoverable coordination. Not trustless, not an atomic Monero/NFT swap, not insured, and not an independently consensus-verified Monero payment. The operator can steal, censor, become insolvent or lose keys; segregation and review reduce but do not remove that risk. @@ -154,4 +152,4 @@ 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; sending Basic auth still returns 401. The current client therefore cannot authenticate to that configuration. Keep authenticated wallet RPC disabled until a reviewed compatible transport is implemented; do not disable authentication outside a strictly isolated test. +**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. diff --git a/docs/production-plan.md b/docs/production-plan.md index 03aae2d..66250c5 100755 --- a/docs/production-plan.md +++ b/docs/production-plan.md @@ -20,7 +20,7 @@ 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 Monero’s `--rpc-login` requires HTTP Digest while the current client sends Basic auth. Therefore authenticated RPC integration is **not yet compatible**; do not enable the client against a login-protected RPC. Watch-only wallet provisioning and isolated network placement must be enforced by deployment until a reviewed compatible auth/provisioning design is implemented. 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; **185/185** 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. No live RPC credentials, funds or mainnet wallet were used. Remaining deposit milestone: resolve authenticated RPC transport and watch-only provisioning, 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 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. **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. @@ -85,7 +85,7 @@ Acceptance: exact-amount property/boundary tests, versioned signature tests, zer Can run alongside P0/P1. Addresses SEC-01/SEC-06 rather than adding more fixed caps. -**Implemented M2 increments, 2026-10-20–21:** [canonical indexes and durable market observation](canonical-indexes.md). Canonical ownership/transaction SQL indexes now serve NFT, deck, custody availability and mock-market queries; per-block undo and anchored connect/disconnect events commit with ledger updates. Market checkpoint/reorg holds persist atomically and bounded catch-up blocks new mutations. Order/listing reconciliation advances in durable, stable-ID keyset batches of at most 32 records per table, resumes after restart, and re-fences when the chain tip/revision changes. The HTTP process now uses a single worker thread to select/evaluate bounded row batches, read canonical facts using its own read-only Ledger connection, and atomically persist changes with cursor progress. The offline suite has **182 passing tests**; focused market tests passed 27/27; the disposable PostgreSQL suite passed 31/31 in the previous run. Regressions verify a main-loop timer runs during worker backlog and that a second server process cannot start against the same data directory. A synthetic 10,000 confirmed-delivery run with 160 loopback health/market requests measured worker HTTP p50/p95/max of 285/322/324 ms versus 1,416/2,844/2,971 ms synchronously. This single local run is preliminary (no repeated confidence intervals or realistic request arrivals), not an SLO. Main-thread event-page acquisition/post-batch fencing, realistic concurrent HTTP load, multi-process fencing beyond the existing exclusive directory lock, target hardware/data, production financial storage/recovery, retained-DAG capacity and operator SLO decisions remain open. +**Implemented M2 increments, 2026-10-20–21:** [canonical indexes and durable market observation](canonical-indexes.md). Canonical ownership/transaction SQL indexes now serve NFT, deck, custody availability and mock-market queries; per-block undo and anchored connect/disconnect events commit with ledger updates. Market checkpoint/reorg holds persist atomically and bounded catch-up blocks new mutations. Order/listing reconciliation advances in durable, stable-ID keyset batches of at most 32 records per table, resumes after restart, and re-fences when the chain tip/revision changes. The HTTP process now uses a single worker thread to select/evaluate bounded row batches, read canonical facts using its own read-only Ledger connection, and atomically persist changes with cursor progress. The offline suite has **187 passing tests**; focused market tests passed 27/27; the disposable PostgreSQL suite passed 31/31 in the previous run. Regressions verify a main-loop timer runs during worker backlog and that a second server process cannot start against the same data directory. A synthetic 10,000 confirmed-delivery run with 160 loopback health/market requests measured worker HTTP p50/p95/max of 285/322/324 ms versus 1,416/2,844/2,971 ms synchronously. This single local run is preliminary (no repeated confidence intervals or realistic request arrivals), not an SLO. Main-thread event-page acquisition/post-batch fencing, realistic concurrent HTTP load, multi-process fencing beyond the existing exclusive directory lock, target hardware/data, production financial storage/recovery, retained-DAG capacity and operator SLO decisions remain open. **Next financial-storage increment:** [ADR 003 — PostgreSQL](postgres-financial-storage.md) selects and integrates the custody SQL backend with existing HTTP callers, exact constrained accounting, explicit migration/runtime roles, source-freezing SQLite import and dump/restore rehearsal. The adapter remains on the fictitious sandbox domain; it is not a production deposit/withdrawal worker. SQLite node-only backup deliberately refuses PostgreSQL-bound financial state. Production hardware/SLO tests, coordinated backup/PITR/failover, real payment schema/worker controls and independent review remain open, so this does not close M2/M4 or authorize funds. diff --git a/scripts/monero-mtls-stagenet-rehearsal.mjs b/scripts/monero-mtls-stagenet-rehearsal.mjs new file mode 100755 index 0000000..787ca75 --- /dev/null +++ b/scripts/monero-mtls-stagenet-rehearsal.mjs @@ -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()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; +} diff --git a/server/market/monero-mtls-transport.ts b/server/market/monero-mtls-transport.ts new file mode 100755 index 0000000..c83a041 --- /dev/null +++ b/server/market/monero-mtls-transport.ts @@ -0,0 +1,109 @@ +import { lstat, readFile } from 'node:fs/promises'; +import { request as httpsRequest } from 'node:https'; +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 { + 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 { + 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((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, + 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); + }); + }; +} diff --git a/server/market/monero-observer.ts b/server/market/monero-observer.ts index 0841182..116ba11 100755 --- a/server/market/monero-observer.ts +++ b/server/market/monero-observer.ts @@ -11,8 +11,6 @@ const MAX_TRANSFERS = 4096; const configSchema = z.object({ network: z.literal('mainnet'), url: z.string().min(1).max(2048), - username: z.string().min(1).max(256), - password: z.string().min(1).max(1024), }).strict(); const rpcEnvelopeSchema = z.object({ jsonrpc: z.literal('2.0'), id: z.string().uuid(), result: z.record(z.string(), z.unknown()).optional(), @@ -137,18 +135,18 @@ export async function loadMoneroObserverConfig(env: NodeJS.ProcessEnv = process. if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error(); const parsed = configSchema.parse(JSON.parse(await readFile(path, 'utf8'))); validateEndpoint(parsed.url); - if (/[\r\n\x00]/.test(parsed.username) || /[\r\n\x00]/.test(parsed.password)) throw new Error(); return parsed; } catch { - throw new Error('Invalid or inaccessible Monero wallet-RPC config; credentials must be in a protected regular file'); + throw new Error('Invalid or inaccessible protected Monero wallet-RPC configuration'); } } /** Restricted read-only surface. No generic RPC method, subaddress creation, transfer or sweep API exists. */ +/** Restricted RPC surface. Authentication is delegated to an explicitly injected secure transport. */ function createWalletRpc(config: z.infer, transport: MoneroFixtureTransport, allowedMethods: readonly ('get_height' | 'get_address' | 'get_transfers' | 'validate_address' | 'create_address')[]) { const endpoint = validateEndpoint(config.url); - const authorization = `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`; + // Removed Basic auth header async function rpc(method: 'get_height' | 'get_address' | 'get_transfers' | 'validate_address' | 'create_address', params: Record = {}) { if (!allowedMethods.includes(method)) throw new Error('Monero wallet-RPC method is not allowed for this client'); const id = randomUUID(), controller = new AbortController(); @@ -159,7 +157,7 @@ function createWalletRpc(config: z.infer, transport: Monero let response: Response; try { response = await transport(endpoint.href, { method: 'POST', redirect: 'error', signal: controller.signal, - headers: { authorization, accept: 'application/json', 'content-type': 'application/json' }, + headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id, method, params }) }); } catch { throw new Error('Monero wallet-RPC request failed'); } if (!response.ok || response.redirected || !response.body || @@ -194,7 +192,7 @@ function createWalletRpc(config: z.infer, transport: Monero } /** Read-only observer capability. It deliberately cannot create addresses or move funds. */ -export function createMoneroWalletObserver(config: z.infer, transport: MoneroFixtureTransport = fetch) { +export function createMoneroWalletObserver(config: z.infer, transport: MoneroFixtureTransport) { const rpc = createWalletRpc(config, transport, ['get_height', 'get_address', 'get_transfers']); return { async verifyMainnetWallet(): Promise { @@ -254,11 +252,11 @@ export async function moneroWalletFingerprint(primaryAddress: string): Promise, transport: MoneroFixtureTransport = fetch) { +export function createMoneroDepositAddressRpc(config: z.infer, transport: MoneroFixtureTransport) { const rpc = createWalletRpc(config, transport, ['get_address', 'validate_address', 'create_address']); return { async verifyWallet(): Promise { - const address = addressResultSchema.parse(await rpc('get_address', { account_index: 0 })); + const address = addressResultSchema.parse(await rpc('get_address', { account_index: 0, address_index: [0] })); await validateXmrAddress(address.address); validateAddressResultSchema.parse(await rpc('validate_address', { address: address.address })); return moneroWalletFingerprint(address.address); diff --git a/server/market/monero-rpc-gateway.ts b/server/market/monero-rpc-gateway.ts new file mode 100755 index 0000000..1389c15 --- /dev/null +++ b/server/market/monero-rpc-gateway.ts @@ -0,0 +1,179 @@ +import { createHash } from 'node:crypto'; +import { lstat, readFile } from 'node:fs/promises'; +import { createServer, type Server } from 'node:https'; +import type { AddressInfo } from 'node:net'; +import { isIP } from 'node:net'; +import type { TLSSocket } from 'node:tls'; +import { z } from 'zod'; + +const MAX_REQUEST_BYTES = 64 * 1024; +const MAX_RESPONSE_BYTES = 1024 * 1024; +const tlsFilesSchema = z.object({ certificateFile: z.string().min(1).max(4096), privateKeyFile: z.string().min(1).max(4096), + clientCaFile: z.string().min(1).max(4096) }).strict(); +const gatewayConfigSchema = z.object({ + listenHost: z.string().min(1).max(64), + listenPort: z.number().int().min(1024).max(65535), + walletRpcUrl: z.string().url().max(2048), + server: tlsFilesSchema, + clientFingerprints: z.array(z.string().regex(/^[a-f0-9]{64}$/)).min(1).max(32), +}).strict(); +const envelopeSchema = z.object({ jsonrpc: z.literal('2.0'), id: z.union([z.string().max(128), z.number().int().safe()]), + method: z.string().max(64), params: z.record(z.string(), z.unknown()).optional() }).strict(); +const uint32 = z.number().int().min(0).max(0xffff_ffff).safe(); +const height = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).safe(); + +export type MoneroGatewayConfig = z.infer; + +function isPrivateBindAddress(value: string): boolean { + if (isIP(value) === 4) { + const octets = value.split('.').map(Number); + return octets[0] === 127 || octets[0] === 10 || + (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || + (octets[0] === 192 && octets[1] === 168); + } + if (isIP(value) === 6) { + const normalized = value.toLowerCase(); + return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd'); + } + return false; +} + +export async function loadMoneroRpcGatewayConfig(path: string): Promise { + try { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > 16 * 1024) throw new Error(); + if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error(); + return gatewayConfigSchema.parse(JSON.parse(await readFile(path, 'utf8'))); + } catch { + throw new Error('Invalid or inaccessible protected Monero RPC gateway configuration'); + } +} + +async function readPrivateFile(path: string): Promise { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > 64 * 1024) { + throw new Error('Monero gateway TLS material must be a bounded regular file'); + } + if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) { + throw new Error('Monero gateway TLS material permissions are too broad'); + } + return readFile(path); +} + +export function validateMoneroRpcGatewayRequest(value: unknown): string { + const request = envelopeSchema.parse(value); + const params = request.params ?? {}; + switch (request.method) { + case 'get_height': + z.object({}).strict().parse(params); + break; + case 'get_address': { + z.object({ account_index: uint32, address_index: z.array(uint32).length(1) }).strict().parse(params); + break; + } + case 'validate_address': + z.object({ address: z.string().length(95) }).strict().parse(params); + break; + case 'create_address': { + const parsed = z.object({ account_index: uint32, label: z.string().min(1).max(128), count: z.literal(1) }).strict().parse(params); + if (/[\r\n\x00]/.test(parsed.label)) throw new Error('Invalid subaddress label'); + break; + } + case 'get_transfers': { + const parsed = z.object({ in: z.literal(true), filter_by_height: z.literal(true), + min_height: height, max_height: height }).strict().parse(params); + if (parsed.max_height < parsed.min_height || parsed.max_height - parsed.min_height > 100_000) { + throw new Error('Wallet transfer scan range is invalid or too large'); + } + break; + } + default: + throw new Error('Wallet RPC method is not allow-listed'); + } + const body = JSON.stringify(request); + if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) throw new Error('Wallet RPC request exceeds size limit'); + return body; +} + +async function forwardToLocalWallet(url: URL, body: string): Promise<{ status: number; contentType: string | undefined; body: Buffer }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); timeout.unref(); + try { + const response = await fetch(url, { method: 'POST', redirect: 'error', signal: controller.signal, + headers: { accept: 'application/json', 'content-type': 'application/json' }, body }); + if (response.status !== 200 || response.redirected || + !/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '') || !response.body) { + await response.body?.cancel(); throw new Error('Local Wallet RPC response rejected'); + } + const reader = response.body.getReader(), chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const part = await reader.read(); if (part.done) break; + size += part.value.byteLength; + if (size > MAX_RESPONSE_BYTES) throw new Error('Local Wallet RPC response exceeds size limit'); + chunks.push(part.value); + } + } finally { await reader.cancel(); reader.releaseLock(); } + return { status: response.status, contentType: response.headers.get('content-type') ?? undefined, + body: Buffer.concat(chunks) }; + } finally { clearTimeout(timeout); } +} + +/** + * Private mTLS authorization gateway for Monero v0.18.x Wallet RPC. Bind it to loopback; + * run wallet-rpc with --disable-rpc-login only behind this process and a host firewall. + * Only allow-listed, schema-bounded methods are forwarded to a literal loopback upstream. + */ +export async function createMoneroRpcGateway(configInput: unknown): Promise { + const config = gatewayConfigSchema.parse(configInput); + if (!isPrivateBindAddress(config.listenHost)) throw new Error('Monero RPC gateway must bind to an explicit private interface IP'); + let upstream: URL; + try { upstream = new URL(config.walletRpcUrl); } catch { throw new Error('Invalid local Wallet RPC URL'); } + if (upstream.protocol !== 'http:' || !['127.0.0.1', '[::1]'].includes(upstream.hostname) || + upstream.pathname !== '/json_rpc' || upstream.username || upstream.password || upstream.search || upstream.hash) { + throw new Error('Wallet RPC gateway upstream must be fixed loopback-only HTTP'); + } + const fingerprints = new Set(config.clientFingerprints); + const [cert, key, ca] = await Promise.all([readPrivateFile(config.server.certificateFile), + readPrivateFile(config.server.privateKeyFile), readPrivateFile(config.server.clientCaFile)]); + const server = createServer({ cert, key, ca, requestCert: true, rejectUnauthorized: true, minVersion: 'TLSv1.3' }, (request, response) => { + void (async () => { + if (request.method !== 'POST' || request.url !== '/json_rpc' || + !/^application\/json(?:\s*;|$)/i.test(request.headers['content-type'] ?? '')) { + response.writeHead(404).end(); return; + } + const peer = (request.socket as TLSSocket).getPeerCertificate(); + if (!peer.raw || !fingerprints.has(createHash('sha256').update(peer.raw).digest('hex'))) { + response.writeHead(403).end(); return; + } + const chunks: Buffer[] = []; let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > MAX_REQUEST_BYTES) { response.writeHead(413).end(); return; } + chunks.push(Buffer.from(chunk)); + } + let body: string; + try { body = validateMoneroRpcGatewayRequest(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } + catch { response.writeHead(400).end(); return; } + let result: Awaited>; + try { result = await forwardToLocalWallet(upstream, body); } + catch { response.writeHead(502).end(); return; } + response.writeHead(result.status, { 'content-type': result.contentType ?? 'application/json', + 'cache-control': 'no-store', 'content-length': result.body.byteLength }).end(result.body); + })().catch(() => { if (!response.headersSent) response.writeHead(500).end(); else response.destroy(); }); + }); + server.maxConnections = 32; + server.headersTimeout = 5000; + server.requestTimeout = 10_000; + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(config.listenPort, config.listenHost, () => { server.off('error', reject); resolve(); }); + }); + const address = server.address() as AddressInfo | null; + if (!address || address.address !== config.listenHost || address.port !== config.listenPort) { + await new Promise(resolve => server.close(() => resolve())); + throw new Error('Monero RPC gateway did not bind to the configured loopback address'); + } + return server; +} diff --git a/tests/monero-observer.test.ts b/tests/monero-observer.test.ts index 3f53a25..5317ba5 100755 --- a/tests/monero-observer.test.ts +++ b/tests/monero-observer.test.ts @@ -4,8 +4,11 @@ import { keccak } from 'hash-wasm'; import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts'; import { createMoneroDepositAddressRpc, createMoneroWalletObserver, loadMoneroObserverConfig, moneroWalletFingerprint, type MoneroFixtureTransport } from '../server/market/monero-observer.ts'; +import { loadMoneroMtlsTransportConfig } from '../server/market/monero-mtls-transport.ts'; +import { createMoneroRpcGateway, loadMoneroRpcGatewayConfig, + validateMoneroRpcGatewayRequest } from '../server/market/monero-rpc-gateway.ts'; -const config = { network: 'mainnet' as const, url: 'http://127.0.0.1:18083/json_rpc', username: 'fixture', password: 'not-a-live-secret' }; +const config = { network: 'mainnet' as const, url: 'http://127.0.0.1:18083/json_rpc' }; function response(id: string, result: Record, status = 200, type = 'application/json') { return new Response(JSON.stringify({ jsonrpc: '2.0', id, result }), { status, headers: { 'content-type': type } }); } @@ -55,7 +58,7 @@ test('read-only Monero observer preserves uint64 amount text and returns observa amountAtomicXmr: '9007199254740993', height: 91, confirmations: 10, timestamp: 1_800_000_000, unlockTime: '0', accountIndex: 0, addressIndex: 7, locked: false, doubleSpendSeen: false }); assert.deepEqual(calls.map(call => call.method).sort(), ['get_address', 'get_height', 'get_transfers']); - assert.ok(calls.every(call => call.auth === `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`)); + assert.ok(calls.every(call => call.auth === null), 'Wallet RPC credentials must never be sent as Basic/Digest headers'); assert.deepEqual(calls.find(call => call.method === 'get_transfers')?.params, { in: true, filter_by_height: true, min_height: 90, max_height: 100 }); assert.equal(Object.keys(observer).some(key => /transfer|sweep|send|credit/i.test(key)), false); @@ -84,7 +87,7 @@ test('deposit-address RPC client is narrowly allow-listed and creates only one v assert.deepEqual(await rpc.getSubaddress(0, 7), { address: subaddress, label: 'wikideck-deposit-v1:fixture' }); assert.deepEqual(await rpc.createSubaddress(0, 'wikideck-deposit-v1:fixture'), { address: subaddress, addressIndex: 7 }); assert.deepEqual(calls, [ - { method: 'get_address', params: { account_index: 0 } }, + { method: 'get_address', params: { account_index: 0, address_index: [0] } }, { method: 'validate_address', params: { address: ADDITIONAL_MARKET_FEE.address } }, { method: 'get_address', params: { account_index: 0, address_index: [7] } }, { method: 'create_address', params: { account_index: 0, label: 'wikideck-deposit-v1:fixture', count: 1 } }, @@ -125,11 +128,42 @@ test('official wallet not-yet-created address-index error is recoverable but unr await assert.rejects(createMoneroDepositAddressRpc(config, unrelated).getSubaddress(0, 1), /method failed \(-15\)/); }); +test('mTLS gateway allowlists only bounded observation and one-subaddress allocation methods', () => { + const request = (method: string, params: Record = {}) => + ({ jsonrpc: '2.0', id: 'fixture-id', method, params }); + assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_height'))); + assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_address', { account_index: 0, address_index: [0] }))); + assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('validate_address', { address: 'x'.repeat(95) }))); + assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('create_address', { account_index: 0, label: 'fixture', count: 1 }))); + assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_transfers', { + in: true, filter_by_height: true, min_height: 0, max_height: 100, + }))); + for (const method of ['transfer', 'transfer_split', 'sweep_all', 'relay_tx', 'get_tx_key', 'query_key', 'open_wallet', 'get_balance']) { + assert.throws(() => validateMoneroRpcGatewayRequest(request(method)), /not allow-listed/); + } + assert.throws(() => validateMoneroRpcGatewayRequest(request('get_address', { account_index: 0, address_index: [0], all: true }))); + assert.throws(() => validateMoneroRpcGatewayRequest(request('create_address', { account_index: 0, label: 'x', count: 2 }))); + assert.throws(() => validateMoneroRpcGatewayRequest(request('get_transfers', { + in: true, filter_by_height: true, min_height: 0, max_height: 100_001, + })), /range/); +}); + +test('production mTLS transport config is mandatory and requires a bare HTTPS RPC endpoint', async () => { + await assert.rejects(loadMoneroMtlsTransportConfig({}), /config file is required/); + await assert.rejects(loadMoneroRpcGatewayConfig(''), /protected Monero RPC gateway configuration/); + await assert.rejects(createMoneroRpcGateway({ listenHost: '203.0.113.10', listenPort: 18085, + walletRpcUrl: 'http://127.0.0.1:18083/json_rpc', server: { certificateFile: 'missing', privateKeyFile: 'missing', + clientCaFile: 'missing' }, clientFingerprints: ['a'.repeat(64)] }), /private interface IP/); + await assert.rejects(createMoneroRpcGateway({ listenHost: '127.0.0.1', listenPort: 18085, + walletRpcUrl: 'http://wallet.example/json_rpc', server: { certificateFile: 'missing', privateKeyFile: 'missing', + clientCaFile: 'missing' }, clientFingerprints: ['a'.repeat(64)] }), /loopback-only/); +}); + test('observer refuses non-loopback plaintext, URL credentials and malformed height ranges', async () => { - assert.throws(() => createMoneroWalletObserver({ ...config, url: 'http://wallet.example/json_rpc' }), /HTTPS/); - assert.throws(() => createMoneroWalletObserver({ ...config, url: 'http://localhost/json_rpc' }), /HTTPS/); - assert.throws(() => createMoneroWalletObserver({ ...config, url: 'https://user:pass@wallet.example/json_rpc' }), /bare/); const { transport } = fakeTransport(); const observer = createMoneroWalletObserver(config, transport); + assert.throws(() => createMoneroWalletObserver({ ...config, url: 'http://wallet.example/json_rpc' }, transport), /HTTPS/); + assert.throws(() => createMoneroWalletObserver({ ...config, url: 'http://localhost/json_rpc' }, transport), /HTTPS/); + assert.throws(() => createMoneroWalletObserver({ ...config, url: 'https://user:pass@wallet.example/json_rpc' }, transport), /bare/); await assert.rejects(observer.scanIncoming(-1), /height/); await assert.rejects(observer.scanIncoming(101), /ahead/); }); @@ -157,6 +191,7 @@ test('observer rejects unsafe amounts, duplicate observations, malformed transfe }); test('observer refuses redirects, wrong content type and oversized RPC response without exposing credentials', async () => { + const secretSentinel = 'fixture-secret-never-log'; const badResponses = [new Response('{}', { status: 302, headers: { 'content-type': 'application/json' } }), new Response('', { headers: { 'content-type': 'text/html' } }), new Response('x'.repeat(1024 * 1024 + 1), { headers: { 'content-type': 'application/json' } })]; @@ -168,7 +203,7 @@ test('observer refuses redirects, wrong content type and oversized RPC response return bad; }; await assert.rejects(createMoneroWalletObserver(config, transport).scanIncoming(90), error => - error instanceof Error && !error.message.includes(config.password)); + error instanceof Error && !error.message.includes(secretSentinel)); } });