SPB Git

Repository Public

server.ts 6.2 KB 81 lines typescript

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
Files main
Menu

Theme