Ships the Prism FR (encrypted-profile-storage-v4.9.md) as a generic relay-side encrypted blob primitive: deterministically-located, AEAD-sealed blobs keyed by a 32-byte slotId derived client-side via HKDF from the user's master key. Unlocks credential-only bootstrap of new devices into existing E2EE state — no QR, no physical access. Server: BlobStore interface + Memory/Sqlite/Postgres impls, createBlobRoutes for GET/PUT/DELETE /v1/blob/:slotId with TOFU pubkey auth and If-Match CAS (409/412 semantics). Mounted on the same Hono app as the inbox; SHADE_BLOB_PG_URL / SHADE_BLOB_DB_PATH / SHADE_DISABLE_BLOB env-var plumbing in standalone. SDK: createProfileNamespace high-level wrapper (HKDF derivation, random-nonce AEAD seal, slotId-bound AAD) + low-level BlobClient. Cross-platform test vectors in test-vectors/blob-storage.json. New errors: ConflictError (409), PreconditionFailedError (412). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
269 lines
9.5 KiB
TypeScript
269 lines
9.5 KiB
TypeScript
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;
|
|
}
|