Files
Shade/packages/shade-inbox-server/src/routes.ts

429 lines
16 KiB
TypeScript
Raw Normal View History

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';
import type { BridgeDeliveryLog } from './bridge-delivery-log.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<InboxQuotaConfig>;
/**
* V4.8.4 shared bridge delivery log. When provided (and the same
* instance is wired into `createBridgeRoutes`), the inbox-fetch route
* filters out blobs already pushed via bridge within the log's grace
* window. Without this, a recipient that runs both a bridge
* subscription and inbox-poll receives the same envelope twice.
* Optional leaving it unset preserves the pre-V4.8.4 behavior of
* always returning every blob the cursor matches.
*/
bridgeDeliveryLog?: BridgeDeliveryLog;
}
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 ?? '<unknown>';
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);
options.bridgeDeliveryLog?.forgetAddress(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})`,
);
}
// V4.8: capture sender fingerprint at PUT time. The sender's
// signing key was just verified for this request, so the fingerprint
// is bound to the same authentication path that authorized the
// store. Surfaced on bridge push + inbox-fetch responses to
// bootstrap unknown-sender first-contact (X3DH pair handshake).
const senderFp = await shortHash(senderKey);
const result = await store.putBlob({
address,
msgId,
ciphertext: ctBytes,
expiresAt,
senderFp,
});
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 rawRows = await store.fetchBlobs({
address,
sinceCursor,
now,
limit: quota.fetchPageLimit,
});
// V4.8.4 — drop blobs the bridge has already pushed to this address
// within the grace window. This is the cross-channel dedup gate that
// makes "one inbox.send ⇒ one observable delivery" hold even when
// the recipient runs both a bridge subscription and inbox-poll. The
// cursor still advances over the whole `rawRows` window so the
// client doesn't get stuck behind suppressed blobs — pollOnce uses
// `nextCursor` (max receivedAt seen by the server, suppressed or
// not) for the next fetch.
const rows = options.bridgeDeliveryLog
? options.bridgeDeliveryLog.filterRecent(address, rawRows, now)
: rawRows;
let bytes = 0;
const blobs = rows.map((r) => {
bytes += r.ciphertext.length;
const out: {
msgId: string;
ciphertext: string;
receivedAt: number;
expiresAt: number;
from?: string;
} = {
msgId: r.msgId,
ciphertext: toBase64(r.ciphertext),
receivedAt: r.receivedAt,
expiresAt: r.expiresAt,
};
// V4.8: surface sender fingerprint when present. Empty for blobs
// persisted by a pre-4.8 relay that didn't track sender provenance.
if (r.senderFp) out.from = r.senderFp;
return out;
});
// Advance the cursor past the FULL rawRows window — including blobs
// we suppressed because the bridge already pushed them. If we
// anchored the cursor on `rows` only, suppressed blobs in the
// middle of the window would block all subsequent fetches forever
// (re-fetched on every poll, re-suppressed, no progress). The
// bridge-delivery contract is "the bridge frame is the canonical
// delivery"; if the recipient missed processing it, they fall back
// to ack-via-DELETE or the blob ages out at TTL — same as a
// recipient that crashes mid-handler in the no-bridge case.
const cursorAnchor = rawRows.length > 0 ? rawRows[rawRows.length - 1]!.receivedAt : sinceCursor;
const nextCursor = cursorAnchor;
events?.emit('inbox.blob_fetched', { address, count: blobs.length, bytes });
return c.json({
blobs,
cursor: nextCursor,
hasMore: rawRows.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 });
}
// Drop any bridge-delivery mark — keeps the log bounded under
// sustained traffic (otherwise long-lived addresses accumulate
// entries even after the underlying blob is gone).
options.bridgeDeliveryLog?.forget(address, msgId);
return c.json({ ok: removed });
});
return app;
}
// ─── Base64 helpers ──────────────────────────────────────────
function b64ToBytes(s: string): Uint8Array {
return fromBase64(s);
}