import { Hono } from 'hono'; import type { CryptoProvider } from '@shade/core'; import { errorToHttpStatus, ShadeError, ValidationError, UnauthorizedError, fromBase64, toBase64, constantTimeEqual, } from '@shade/core'; import { verifyPayload, 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 { BlobStore } from './blob-store.js'; /** * Wire-level wrapper around the V4.9 BlobStore primitive. * * Endpoints: * GET /v1/blob/:slotId → { blob, etag } | 404 * PUT /v1/blob/:slotId → { etag, created } | 409 | 412 * DELETE /v1/blob/:slotId → { ok } * * SlotId is 64 lowercase hex chars (the HKDF output, 32 bytes). Payloads * are base64-encoded ciphertext; the relay never decrypts. Auth uses * `signPayload` / `verifyPayload` (same canonical-JSON-and-Ed25519 * scheme as the inbox routes), keyed off the per-slot pubkey stored * TOFU on the first PUT. * * Quota: a single slot holds one blob. `MAX_BLOB_BYTES` (64 KiB) is * sized for Prism's profile use-case (a few hundred host entries) with * plenty of headroom; future apps can override via `BlobRoutesOptions`. */ const SLOT_ID_REGEX = /^[0-9a-f]{64}$/; const MAX_META_BODY_SIZE = 64 * 1024; /** Default per-slot blob ceiling. Sized for ~500 host entries in JSON form. */ export const DEFAULT_MAX_BLOB_BYTES = 64 * 1024; const PUT_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 }; const GET_LIMIT: RateLimitConfig = { capacity: 120, refillPerSecond: 2 }; const DELETE_LIMIT: RateLimitConfig = { capacity: 30, refillPerSecond: 1 }; export interface BlobRoutesOptions { disableRateLimit?: boolean; observability?: ObservabilityHook; /** Per-blob byte ceiling. Defaults to 64 KiB. */ maxBlobBytes?: number; } export function createBlobRoutes( store: BlobStore, crypto: CryptoProvider, options: BlobRoutesOptions = {}, ): Hono { const app = new Hono(); const observability = options.observability ?? NOOP_HOOK; const maxBlobBytes = options.maxBlobBytes ?? DEFAULT_MAX_BLOB_BYTES; app.use('*', async (c, next) => { const route = c.req.routePath ?? c.req.path ?? ''; const span = observability.startSpan('shade.blob.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, PUT_LIMIT); const getRL = new RateLimiter(rlStore, GET_LIMIT); const deleteRL = new RateLimiter(rlStore, DELETE_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.onError((err, 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 blob error:', err); return c.json({ error: 'Internal server error' }, 500); }); function validateSlotId(raw: string | undefined): string { if (typeof raw !== 'string' || !SLOT_ID_REGEX.test(raw)) { throw new ValidationError( 'slotId must be 64 lowercase hex chars (32 bytes)', 'slotId', ); } return raw; } // ─── GET ───────────────────────────────────────────────────── // Unauthenticated. SlotId is itself a 256-bit secret derived from the // master key — knowing it implies you derived the master, which is // equivalent to holding the credentials. The blob is AEAD-sealed, so // a relay-side leak of slotId still cannot decrypt the contents. app.get('/v1/blob/:slotId', async (c) => { const slotId = validateSlotId(c.req.param('slotId')); if (rateLimitEnabled) await getRL.consume(`blob-get:${getClientIp(c)}`); const row = await store.get(slotId); if (!row) { return c.json({ error: 'Slot not found', code: 'SHADE_NOT_FOUND' }, 404); } return c.json({ blob: toBase64(row.blob), etag: String(row.etag), updatedAt: row.updatedAt, }); }); // ─── PUT ───────────────────────────────────────────────────── // Body format: // { // ownerPubkey: b64, // Ed25519 pubkey deterministically // // derived from the master via HKDF. // blob: b64, // ifMatch?: string, // "" | "*" | undefined // signedAt: number, // signature: b64 // over the canonical body sans signature // } // // First write to a slot is TOFU: we record `ownerPubkey` and require // any future write to verify against it. A different key trying to // overwrite an existing slot is rejected with UnauthorizedError. app.put('/v1/blob/:slotId', async (c) => { const slotId = validateSlotId(c.req.param('slotId')); if (rateLimitEnabled) await putRL.consume(`blob-put:${getClientIp(c)}`); const rawBody = await c.req.text(); const hardLimit = Math.ceil(maxBlobBytes * 1.4) + MAX_META_BODY_SIZE; if (rawBody.length > hardLimit) { throw new ValidationError(`Request body too large`); } const body = JSON.parse(rawBody); const { ownerPubkey, blob, ifMatch } = body; if (typeof ownerPubkey !== 'string') { throw new ValidationError('Missing ownerPubkey', 'ownerPubkey'); } if (typeof blob !== 'string') { throw new ValidationError('Missing blob', 'blob'); } const claimedKey = fromBase64(ownerPubkey); if (claimedKey.length !== 32) { throw new ValidationError('ownerPubkey must be 32 bytes (Ed25519)', 'ownerPubkey'); } const blobBytes = fromBase64(blob); if (blobBytes.length === 0) { throw new ValidationError('blob is empty', 'blob'); } if (blobBytes.length > maxBlobBytes) { throw new ValidationError( `blob exceeds maxBlobBytes (${blobBytes.length} > ${maxBlobBytes})`, 'blob', ); } let expectedEtag: number | '*' | undefined; if (ifMatch === undefined) { expectedEtag = undefined; } else if (typeof ifMatch !== 'string') { throw new ValidationError('ifMatch must be a string when present', 'ifMatch'); } else if (ifMatch === '*') { expectedEtag = '*'; } else { const n = Number(ifMatch); if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) { throw new ValidationError('ifMatch must be a non-negative integer or "*"', 'ifMatch'); } expectedEtag = n; } // Existing slot: caller must sign with the original owner key. Use // the stored pubkey for verification. The body's `ownerPubkey` is // bound by the signature too, so an attacker cannot trick us into // verifying with a key they control — the canonicalization includes // every field but `signature`. const existing = await store.get(slotId); const verifyKey = existing ? existing.ownerPubkey : claimedKey; // Bind slotId into the signed payload so a signature for slot A // can't be replayed against slot B (the URL is otherwise outside // the signed bytes). await verifyPayload(crypto, verifyKey, { ...body, slotId }); if (existing && !constantTimeEqual(existing.ownerPubkey, claimedKey)) { throw new UnauthorizedError( `Slot ${slotId} is owned by a different signing key`, ); } const result = await store.put({ slotId, blob: blobBytes, ownerPubkey: claimedKey, expectedEtag, now: Date.now(), }); return c.json({ ok: true, created: result.created, etag: String(result.etag), updatedAt: result.updatedAt, }); }); // ─── DELETE ────────────────────────────────────────────────── // Body format: { signedAt, signature }. Signed by the owner pubkey // recorded on the first PUT. After deletion, the slot is fully gone — // the next PUT TOFU-claims it again (potentially under a different // signing key, e.g. after a rotation). app.delete('/v1/blob/:slotId', async (c) => { const slotId = validateSlotId(c.req.param('slotId')); if (rateLimitEnabled) await deleteRL.consume(`blob-delete:${getClientIp(c)}`); const existing = await store.get(slotId); if (!existing) { return c.json({ error: 'Slot not found', 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); await verifyPayload(crypto, existing.ownerPubkey, { ...body, slotId }); const removed = await store.delete(slotId); return c.json({ ok: removed }); }); return app; }