Repository Public
1import { createHash, createHmac, timingSafeEqual } from 'node:crypto';2import { createServer, type IncomingMessage, type Server } from 'node:http';3import { Signer, SignerError } from './signer.ts';45const MAX_BODY = 1024 * 1024; // legacy UTXO chains attach previous transactions67export function authHeader(secret: string, method: string, path: string, body: string, ts = Math.floor(Date.now() / 1000), nonce: string = crypto.randomUUID()): string {8 const mac = createHmac('sha256', secret).update([method, path, ts, nonce, createHash('sha256').update(body).digest('hex')].join('\n')).digest('hex');9 return `t=${ts},n=${nonce},s=${mac}`;10}1112function readBody(req: IncomingMessage): Promise<string> {13 return new Promise((resolve, reject) => {14 let n = 0; const chunks: Buffer[] = [];15 req.on('data', (c: Buffer) => { n += c.length; if (n > MAX_BODY) { reject(new SignerError('too_large', 'body too large')); req.destroy(); } else chunks.push(c); });16 req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));17 req.on('error', reject);18 });19}2021/** Narrow HTTP surface: status, enroll, sign, halt. Every request is HMAC-authenticated with replay protection. */22export function createSignerServer(signer: Signer, secret: string, allowedIps: string[] = []): Server {23 if (secret.length < 32) throw new Error('signer shared secret too short');24 const nonces = new Map<string, number>();25 const check = (req: IncomingMessage, body: string) => {26 const m = /^t=(\d+),n=([0-9a-f-]{36}),s=([0-9a-f]{64})$/.exec(String(req.headers['x-spw-auth'] ?? ''));27 if (!m) throw new SignerError('unauthorized', 'missing auth');28 const ts = Number(m[1]), now = Math.floor(Date.now() / 1000);29 if (Math.abs(now - ts) > 60) throw new SignerError('unauthorized', 'stale request');30 const want = authHeader(secret, req.method ?? '', req.url ?? '', body, ts, m[2]!).split('s=')[1]!;31 if (!timingSafeEqual(Buffer.from(want, 'hex'), Buffer.from(m[3]!, 'hex'))) throw new SignerError('unauthorized', 'bad mac');32 for (const [k, t] of nonces) if (t < now - 120) nonces.delete(k);33 if (nonces.has(m[2]!)) throw new SignerError('unauthorized', 'replayed request');34 nonces.set(m[2]!, ts);35 };36 let queue: Promise<unknown> = Promise.resolve(); // strictly serialize state-changing work37 return createServer((req, res) => {38 const send = (code: number, obj: unknown) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store', connection: 'close' }); res.end(JSON.stringify(obj)); };39 if (req.method === 'GET' && req.url === '/healthz') return send(200, { ok: true }); // liveness only, no information40 const run = async () => {41 const ip = (req.socket.remoteAddress ?? '').replace(/^::ffff:/, '');42 if (allowedIps.length && !allowedIps.includes(ip)) throw new SignerError('unauthorized', 'source not allowed');43 const body = req.method === 'POST' ? await readBody(req) : '';44 check(req, body);45 const json = body ? JSON.parse(body) : {};46 const route = `${req.method} ${req.url}`;47 if (route === 'GET /status') return signer.status();48 if (route === 'POST /enroll') { await signer.enroll(json); return { ok: true }; }49 if (route === 'POST /sign') return await signer.sign(json.intent, json.assertion, json.aux && typeof json.aux === 'object' ? json.aux : undefined);50 // second leg of an approved bridge: only the receiveMessage that matches the stored approval (signer.ts)51 if (route === 'POST /bridge/mint') return await signer.signBridgeMint(json);52 if (route === 'POST /halt') { signer.halt(); return { ok: true, halted: true }; }53 // agent vaults: mandate approval needs the owner's passkey; orders need only to fit the mandate; revoking is always allowed54 if (route === 'POST /vault/address') return { address: signer.vaultAddress(Number(json.index)) };55 if (route === 'POST /mandate/approve') return await signer.approveMandate(json.mandate, json.assertion);56 if (route === 'POST /mandate/revoke') { signer.revokeMandate(String(json.mandateId)); return { ok: true }; }57 if (route === 'POST /mandate/sign') return await signer.signDelegated(String(json.mandateId), json.intent, json.aux && typeof json.aux === 'object' ? json.aux : undefined);58 if (route === 'POST /mandate/return') return await signer.signReturn(String(json.mandateId), json.intent);59 if (route === 'POST /vault/return') return await signer.signVaultReturn(Number(json.index), json.intent);60 // live derivatives: a dedicated trading key per venue and network; the authorization needs the owner's passkey, orders and61 // leverage changes need it too (MANUAL_LIVE), cancels do not, revoking is always allowed (venue.ts)62 if (route === 'POST /venue/key') return signer.venueKey(json.venue, json.network);63 if (route === 'POST /venue/authorize') return await signer.venueAuthorize(json.authorization, json.assertion);64 if (route === 'POST /venue/sign') return await signer.venueSign(json);65 if (route === 'POST /venue/revoke') return signer.venueRevoke(String(json.authorizationId));66 // collateral: a withdrawal from the venue to the wallet's own address, signed by the main key after a passkey approval (venue-collateral.ts)67 if (route === 'POST /venue/withdraw') return await signer.venueWithdraw(json.withdrawal, json.assertion);68 // derivative mandates: approval needs the owner's passkey; signing needs none (every limit is checked by the signer); revoking is always allowed69 if (route === 'POST /venue/mandate/approve') return await signer.venueMandateApprove(json.mandate, json.assertion);70 if (route === 'POST /venue/mandate/sign') return await signer.venueMandateSign(json);71 if (route === 'POST /venue/mandate/revoke') return signer.venueMandateRevoke(String(json.mandateId));72 throw new SignerError('not_found', 'no such route');73 };74 queue = queue.then(run).then((r) => send(200, r), (e: unknown) => {75 const code = e instanceof SignerError ? e.code : 'internal';76 const http = code === 'unauthorized' ? 401 : code === 'not_found' ? 404 : code === 'internal' ? 500 : 422;77 send(http, { error: code, message: e instanceof SignerError ? e.message : 'internal error' });78 });79 });80}81