Observer integration step

This commit is contained in:
2026-10-23 10:20:58 -04:00
parent bd3d36a7e1
commit 216a419c0b
7 changed files with 161 additions and 45 deletions
+31 -6
View File
@@ -10,19 +10,39 @@ 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 gatewayClientPolicySchema = z.object({
fingerprint: z.string().regex(/^[a-f0-9]{64}$/),
roles: z.array(z.enum(['observer', 'address-allocator'])).min(1).max(2),
}).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();
clientPolicies: z.array(gatewayClientPolicySchema).min(1).max(32),
}).strict().superRefine((config, context) => {
const fingerprints = config.clientPolicies.map(client => client.fingerprint);
if (new Set(fingerprints).size !== fingerprints.length) {
context.addIssue({ code: 'custom', message: 'Gateway client fingerprints must be unique', path: ['clientPolicies'] });
}
for (const client of config.clientPolicies) {
if (new Set(client.roles).size !== client.roles.length) {
context.addIssue({ code: 'custom', message: 'Gateway client roles must be unique', path: ['clientPolicies'] });
}
}
});
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<typeof gatewayConfigSchema>;
export type MoneroGatewayRole = 'observer' | 'address-allocator';
type GatewayMethod = 'get_height' | 'get_address' | 'validate_address' | 'create_address' | 'get_transfers' | 'incoming_transfers';
const METHODS_BY_ROLE: Record<MoneroGatewayRole, readonly GatewayMethod[]> = {
observer: ['get_height', 'get_address', 'get_transfers', 'incoming_transfers'],
'address-allocator': ['get_address', 'validate_address', 'create_address'],
};
function isPrivateBindAddress(value: string): boolean {
if (isIP(value) === 4) {
@@ -60,8 +80,12 @@ async function readPrivateFile(path: string): Promise<Buffer> {
return readFile(path);
}
export function validateMoneroRpcGatewayRequest(value: unknown): string {
export function validateMoneroRpcGatewayRequest(value: unknown, roles: readonly MoneroGatewayRole[]): string {
const request = envelopeSchema.parse(value);
const parsedRoles = z.array(z.enum(['observer', 'address-allocator'])).min(1).max(2).parse(roles);
if (new Set(parsedRoles).size !== parsedRoles.length) throw new Error('Monero RPC gateway client roles must be unique');
const allowed = new Set(parsedRoles.flatMap(role => METHODS_BY_ROLE[role]));
if (!allowed.has(request.method as GatewayMethod)) throw new Error('Wallet RPC method is not allowed for this client certificate');
const params = request.params ?? {};
switch (request.method) {
case 'get_height':
@@ -138,7 +162,7 @@ export async function createMoneroRpcGateway(configInput: unknown): Promise<Serv
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 clientPolicies = new Map(config.clientPolicies.map(policy => [policy.fingerprint, policy.roles]));
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) => {
@@ -148,7 +172,8 @@ export async function createMoneroRpcGateway(configInput: unknown): Promise<Serv
response.writeHead(404).end(); return;
}
const peer = (request.socket as TLSSocket).getPeerCertificate();
if (!peer.raw || !fingerprints.has(createHash('sha256').update(peer.raw).digest('hex'))) {
const roles = peer.raw ? clientPolicies.get(createHash('sha256').update(peer.raw).digest('hex')) : undefined;
if (!roles) {
response.writeHead(403).end(); return;
}
const chunks: Buffer[] = []; let size = 0;
@@ -158,7 +183,7 @@ export async function createMoneroRpcGateway(configInput: unknown): Promise<Serv
chunks.push(Buffer.from(chunk));
}
let body: string;
try { body = validateMoneroRpcGatewayRequest(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
try { body = validateMoneroRpcGatewayRequest(JSON.parse(Buffer.concat(chunks).toString('utf8')), roles); }
catch { response.writeHead(400).end(); return; }
let result: Awaited<ReturnType<typeof forwardToLocalWallet>>;
try { result = await forwardToLocalWallet(upstream, body); }