Started Walet RPC auth work without adding MD5 Digest support

This commit is contained in:
2026-10-21 17:33:33 -04:00
parent f00ea55dfd
commit 40f4aa6d4e
7 changed files with 467 additions and 22 deletions
+42 -7
View File
@@ -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<string, unknown>, 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<string, unknown> = {}) =>
({ 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('<html/>', { 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));
}
});