import { Hono } from 'hono'; import type { CryptoProvider } from '@shade/core'; import { errorToHttpStatus, ShadeError, ValidationError, RateLimitError, UnauthorizedError, fromBase64, toBase64, constantTimeEqual, } from '@shade/core'; import { verifyPayload, validateAddress, RateLimiter, MemoryRateLimitStore, type RateLimitConfig, } from '@shade/server'; import { ATTR_ERROR_CODE, ATTR_HTTP_STATUS, ATTR_ROUTE, NOOP_HOOK, type ObservabilityHook, } from '@shade/observability'; import type { InboxStore } from './store.js'; import { InboxServerEvents, shortHash } from './events.js'; import { computeMsgId, isValidMsgId, constantTimeStringEqual } from './msg-id.js'; import { clampTtl, DEFAULT_INBOX_QUOTA, type InboxQuotaConfig } from './quota.js'; /** Max metadata-only body size (no ciphertext). 64 KB is enough headroom. */ const MAX_META_BODY_SIZE = 64 * 1024; /** * Per-route token-bucket presets. PUT is intentionally generous (senders * may burst) but bound on the recipient side (per-address quota in the * store). FETCH and DELETE are per-address. */ const INBOX_PUT_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 }; const INBOX_FETCH_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 }; const INBOX_DELETE_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 }; const INBOX_REGISTER_LIMIT: RateLimitConfig = { capacity: 5, refillPerSecond: 5 / 3600, }; export interface InboxRoutesOptions { /** Disable rate limiting (used in tests). */ disableRateLimit?: boolean; /** Optional event emitter. */ events?: InboxServerEvents; /** OTel observability hook. */ observability?: ObservabilityHook; /** Override quota policy. */ quota?: Partial; } export function createInboxRoutes( store: InboxStore, crypto: CryptoProvider, options: InboxRoutesOptions = {}, ): Hono { const app = new Hono(); const events = options.events; const observability = options.observability ?? NOOP_HOOK; const quota: InboxQuotaConfig = { ...DEFAULT_INBOX_QUOTA, ...(options.quota ?? {}) }; app.use('*', async (c, next) => { const route = c.req.routePath ?? c.req.path ?? ''; const span = observability.startSpan('shade.inbox.request', { [ATTR_ROUTE]: route, }); try { await next(); span.setAttribute(ATTR_HTTP_STATUS, c.res.status); span.setStatus(c.res.status >= 500 ? 'error' : 'ok'); } catch (err) { const code = err instanceof ShadeError ? err.code ?? 'SHADE_ERROR' : 'SHADE_INTERNAL'; span.setAttribute(ATTR_ERROR_CODE, code); span.recordException(err); span.setStatus('error', code); throw err; } finally { span.end(); } }); const rlStore = new MemoryRateLimitStore(); const putRL = new RateLimiter(rlStore, INBOX_PUT_LIMIT); const fetchRL = new RateLimiter(rlStore, INBOX_FETCH_LIMIT); const deleteRL = new RateLimiter(rlStore, INBOX_DELETE_LIMIT); const registerRL = new RateLimiter(rlStore, INBOX_REGISTER_LIMIT); const rateLimitEnabled = !options.disableRateLimit; const getClientIp = (c: any): string => c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? c.req.header('x-real-ip') ?? 'unknown'; app.get('/health', (c) => c.json({ status: 'ok', service: 'shade-inbox-server' })); app.onError((err, c) => { if (err instanceof RateLimitError) { events?.emit('inbox.rate_limited', { route: c.req.routePath ?? c.req.path, key: getClientIp(c), }); } if (err instanceof ShadeError) { const status = errorToHttpStatus(err); const body: any = err.toJSON(); if ((err as any).retryAfterSeconds) { c.header('Retry-After', String((err as any).retryAfterSeconds)); } return c.json(body, status as any); } console.error('[Shade] Unhandled inbox error:', err); return c.json({ error: 'Internal server error' }, 500); }); // ─── Register address (TOFU) ─────────────────────────────── // Recipient claims an address by uploading its signing key and a // signature over the canonical body. Subsequent PUT/GET/DELETE for the // address are authenticated against this key. Idempotent if the same key // re-registers; rejects if a different key tries to take an existing slot. app.post('/v1/inbox/register', async (c) => { if (rateLimitEnabled) await registerRL.consume(`inbox-register:${getClientIp(c)}`); const rawBody = await c.req.text(); if (rawBody.length > MAX_META_BODY_SIZE) { throw new ValidationError(`Request body too large (max ${MAX_META_BODY_SIZE} bytes)`); } const body = JSON.parse(rawBody); const { address, signingKey } = body; const addr = validateAddress(address); if (typeof signingKey !== 'string') { throw new ValidationError('Missing signingKey', 'signingKey'); } const key = b64ToBytes(signingKey); // Verify signature against the asserted key (TOFU). await verifyPayload(crypto, key, body); const existing = await store.getAddressOwner(addr); if (existing && !constantTimeEqual(existing, key)) { throw new UnauthorizedError(`Address already claimed by a different key`); } await store.saveAddressOwner(addr, key); if (events) { events.emit('inbox.address_registered', { address: addr, signingKeyHash: await shortHash(key), }); } return c.json({ ok: true }); }); // ─── Unregister (signed) ─────────────────────────────────── app.delete('/v1/inbox/register/:address', async (c) => { const address = validateAddress(c.req.param('address')); if (rateLimitEnabled) await registerRL.consume(`inbox-unregister:${address}`); const owner = await store.getAddressOwner(address); if (!owner) { return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404); } const body = await c.req.json(); await verifyPayload(crypto, owner, { ...body, address }); await store.deleteAddress(address); events?.emit('inbox.address_deleted', { address }); return c.json({ ok: true }); }); // ─── PUT a blob (signed by sender) ───────────────────────── // Body format: // { // senderSigningKey: b64, // msgId: hex(sha256(ciphertext)), // ciphertext: b64, // ttlSeconds?: number, // signedAt: number, // signature: b64, // over the canonical body sans signature // } // The recipient address is the path parameter. The sender authenticates // itself via `senderSigningKey` (TOFU per request — the *recipient* // determines whether to accept the sender, via the encrypted envelope). app.post('/v1/inbox/:address', async (c) => { if (rateLimitEnabled) await putRL.consume(`inbox-put:${getClientIp(c)}`); const address = validateAddress(c.req.param('address')); const rawBody = await c.req.text(); // Allow up to (maxBlobBytes * 4/3) for base64 + JSON overhead. const hardLimit = Math.ceil(quota.maxBlobBytes * 1.4) + MAX_META_BODY_SIZE; if (rawBody.length > hardLimit) { events?.emit('inbox.quota_rejected', { address, reason: 'body-too-large' }); throw new ValidationError(`Request body too large`); } const body = JSON.parse(rawBody); const { senderSigningKey, msgId, ciphertext, ttlSeconds } = body; if (typeof senderSigningKey !== 'string') { throw new ValidationError('Missing senderSigningKey', 'senderSigningKey'); } if (typeof ciphertext !== 'string') { throw new ValidationError('Missing ciphertext', 'ciphertext'); } if (!isValidMsgId(msgId)) { throw new ValidationError('msgId must be 64 lowercase hex chars', 'msgId'); } const senderKey = b64ToBytes(senderSigningKey); const ctBytes = b64ToBytes(ciphertext); if (ctBytes.length === 0) { throw new ValidationError('ciphertext is empty', 'ciphertext'); } if (ctBytes.length > quota.maxBlobBytes) { events?.emit('inbox.quota_rejected', { address, reason: 'body-too-large' }); throw new ValidationError( `ciphertext exceeds maxBlobBytes (${ctBytes.length} > ${quota.maxBlobBytes})`, ); } // Verify the claimed msgId matches the actual ciphertext digest. const recomputed = await computeMsgId(ctBytes); if (!constantTimeStringEqual(recomputed, msgId)) { throw new ValidationError('msgId does not match sha256(ciphertext)', 'msgId'); } // Verify sender signature. await verifyPayload(crypto, senderKey, body); // Recipient address must be registered (avoids DoS against unclaimed // slots — see THREAT-MODEL). const recipient = await store.getAddressOwner(address); if (!recipient) { return c.json({ error: 'Recipient not registered', code: 'SHADE_NOT_FOUND' }, 404); } const ttl = clampTtl(typeof ttlSeconds === 'number' ? ttlSeconds : undefined, quota); const expiresAt = Date.now() + ttl * 1000; // Per-address quota check before the write so the cap is enforced. const currentCount = await store.countBlobs(address, Date.now()); if (currentCount >= quota.maxBlobsPerAddress) { events?.emit('inbox.quota_rejected', { address, reason: 'address-quota' }); throw new ValidationError( `Recipient inbox is full (${currentCount} >= ${quota.maxBlobsPerAddress})`, ); } const result = await store.putBlob({ address, msgId, ciphertext: ctBytes, expiresAt, }); if (result.created) { events?.emit('inbox.blob_stored', { address, msgId, bytes: ctBytes.length, ttlSeconds: ttl, }); } else { events?.emit('inbox.blob_idempotent_replay', { address, msgId }); } return c.json({ ok: true, msgId, receivedAt: result.receivedAt, idempotent: !result.created, }); }); // ─── GET blobs (signed challenge by recipient) ───────────── // Auth model: recipient signs the canonical (address, sinceCursor, // signedAt) tuple. Server verifies against the address's registered // signing key. Cursor is opaque — clients pass back the highest // `receivedAt` seen so far. app.post('/v1/inbox/:address/fetch', async (c) => { const address = validateAddress(c.req.param('address')); if (rateLimitEnabled) await fetchRL.consume(`inbox-fetch:${address}`); const owner = await store.getAddressOwner(address); if (!owner) { return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404); } const rawBody = await c.req.text(); if (rawBody.length > MAX_META_BODY_SIZE) { throw new ValidationError(`Request body too large`); } const body = JSON.parse(rawBody); let { sinceCursor } = body; if (sinceCursor === undefined || sinceCursor === null) sinceCursor = 0; if (typeof sinceCursor !== 'number' || !Number.isFinite(sinceCursor) || sinceCursor < 0) { throw new ValidationError('sinceCursor must be a non-negative number', 'sinceCursor'); } // Bind the address to the signed payload to prevent cross-address replay. await verifyPayload(crypto, owner, { ...body, address }); const now = Date.now(); const rows = await store.fetchBlobs({ address, sinceCursor, now, limit: quota.fetchPageLimit, }); let bytes = 0; const blobs = rows.map((r) => { bytes += r.ciphertext.length; return { msgId: r.msgId, ciphertext: toBase64(r.ciphertext), receivedAt: r.receivedAt, expiresAt: r.expiresAt, }; }); const nextCursor = rows.length > 0 ? rows[rows.length - 1]!.receivedAt : sinceCursor; events?.emit('inbox.blob_fetched', { address, count: blobs.length, bytes }); return c.json({ blobs, cursor: nextCursor, hasMore: rows.length === quota.fetchPageLimit, }); }); // ─── DELETE a single blob (signed challenge by recipient) ── app.delete('/v1/inbox/:address/:msgId', async (c) => { const address = validateAddress(c.req.param('address')); if (rateLimitEnabled) await deleteRL.consume(`inbox-delete:${address}`); const msgId = c.req.param('msgId'); if (!isValidMsgId(msgId)) { throw new ValidationError('msgId must be 64 lowercase hex chars', 'msgId'); } const owner = await store.getAddressOwner(address); if (!owner) { return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404); } const body = await c.req.json(); await verifyPayload(crypto, owner, { ...body, address, msgId }); const removed = await store.deleteBlob(address, msgId); if (removed) { events?.emit('inbox.blob_acked', { address, msgId }); } return c.json({ ok: removed }); }); return app; } // ─── Base64 helpers ────────────────────────────────────────── function b64ToBytes(s: string): Uint8Array { return fromBase64(s); }