release(v4.9.0): relay-side encrypted blob primitive + SDK Profile namespace
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>
This commit is contained in:
268
packages/shade-inbox-server/src/blob-routes.ts
Normal file
268
packages/shade-inbox-server/src/blob-routes.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user