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

269 lines
9.5 KiB
TypeScript
Raw Normal View History

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 ?? '<unknown>';
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, // "<etag>" | "*" | 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;
}