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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/inbox-server",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
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;
|
||||
}
|
||||
86
packages/shade-inbox-server/src/blob-store.ts
Normal file
86
packages/shade-inbox-server/src/blob-store.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* BlobStore — server-side storage interface for the V4.9 encrypted-blob
|
||||
* primitive. A "slot" is a single AEAD-sealed blob keyed by a
|
||||
* deterministic 32-byte slotId derived client-side via HKDF from a
|
||||
* master key. The relay never sees plaintext, never holds private keys,
|
||||
* and never decrypts.
|
||||
*
|
||||
* Auth model (TOFU per slot, mirrors the inbox-owner pattern):
|
||||
* - First PUT to an empty slot stores the caller's Ed25519 signing
|
||||
* pubkey alongside the blob. Subsequent writes must produce a valid
|
||||
* signature verifiable by that pubkey.
|
||||
* - GET is unauthenticated — slotId is itself a 256-bit secret derived
|
||||
* from the master key, so knowing it implies you derived the master.
|
||||
* - DELETE clears the blob AND the owner pubkey, allowing future TOFU
|
||||
* re-claim by a fresh signing key derived from the same master (e.g.
|
||||
* after a rotation).
|
||||
*
|
||||
* CAS / etag semantics:
|
||||
* - Every successful PUT bumps a per-slot monotonic etag (returned to
|
||||
* the caller as a string).
|
||||
* - A stale `ifMatch` triggers `PreconditionFailedError` (HTTP 412).
|
||||
* - `ifMatch === undefined` against a populated slot triggers
|
||||
* `ConflictError` (HTTP 409) — clients must read-then-write.
|
||||
* - `ifMatch === '*'` against a populated slot is unconditional
|
||||
* overwrite (escape hatch). Against an empty slot it's still 412
|
||||
* per RFC 7232 (no entity to match).
|
||||
*/
|
||||
export interface BlobSlotRecord {
|
||||
/** Lower-hex 64-char slotId (32 bytes). */
|
||||
slotId: string;
|
||||
/** Raw AEAD ciphertext (bytes). The relay never decrypts. */
|
||||
blob: Uint8Array;
|
||||
/** Owner Ed25519 signing pubkey, established TOFU on the first PUT. */
|
||||
ownerPubkey: Uint8Array;
|
||||
/** Monotonic per-slot version. Used as the ETag on the wire. */
|
||||
etag: number;
|
||||
/** Wall-clock ms of the last successful write. */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** Returned to the route layer after a successful PUT. */
|
||||
export interface PutBlobResult {
|
||||
/** Whether the slot was created (true) or updated in place (false). */
|
||||
created: boolean;
|
||||
/** New etag after the write. */
|
||||
etag: number;
|
||||
/** Wall-clock ms of the write. */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface BlobStore {
|
||||
/** Read a slot, or null if it has never been written (or was deleted). */
|
||||
get(slotId: string): Promise<BlobSlotRecord | null>;
|
||||
|
||||
/**
|
||||
* Create or update a slot.
|
||||
*
|
||||
* Implementations MUST treat `(slotId, ownerPubkey)` atomically: the
|
||||
* route layer has already verified the signature, but the store is the
|
||||
* authority on whether the slot exists and what etag it has. Callers
|
||||
* pass the verified `ownerPubkey` (used on first-write to record the
|
||||
* owner; ignored on subsequent writes — the existing pubkey is the
|
||||
* source of truth for who's allowed to write).
|
||||
*
|
||||
* `expectedEtag` semantics (mirror the wire-level If-Match):
|
||||
* - `undefined` : create-only. Slot must be empty.
|
||||
* - `<number>` : compare-and-swap. Must equal the current etag.
|
||||
* - `'*'` : unconditional overwrite. Slot must already exist.
|
||||
*
|
||||
* On precondition mismatch the store throws `PreconditionFailedError`
|
||||
* (stale etag) or `ConflictError` (slot exists, no ifMatch).
|
||||
*/
|
||||
put(args: {
|
||||
slotId: string;
|
||||
blob: Uint8Array;
|
||||
ownerPubkey: Uint8Array;
|
||||
expectedEtag: number | '*' | undefined;
|
||||
now: number;
|
||||
}): Promise<PutBlobResult>;
|
||||
|
||||
/**
|
||||
* Delete a slot. Authentication has already been checked by the route
|
||||
* layer. Returns true if a row was removed (i.e. the slot existed).
|
||||
*/
|
||||
delete(slotId: string): Promise<boolean>;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Hono } from 'hono';
|
||||
import { Hono } from 'hono';
|
||||
import type { CryptoProvider } from '@shade/core';
|
||||
import { createInboxRoutes, type InboxRoutesOptions } from './routes.js';
|
||||
import { MemoryInboxStore } from './memory-store.js';
|
||||
import type { InboxStore } from './store.js';
|
||||
import { InboxServerEvents } from './events.js';
|
||||
import { createBlobRoutes, type BlobRoutesOptions } from './blob-routes.js';
|
||||
import { MemoryBlobStore } from './memory-blob-store.js';
|
||||
import type { BlobStore } from './blob-store.js';
|
||||
|
||||
export { createInboxRoutes } from './routes.js';
|
||||
export type { InboxRoutesOptions } from './routes.js';
|
||||
@@ -36,6 +39,10 @@ export { PresenceTracker } from './presence.js';
|
||||
export type { TrackedBridgeKind } from './presence.js';
|
||||
export { BridgeDeliveryLog } from './bridge-delivery-log.js';
|
||||
export type { BridgeDeliveryLogOptions } from './bridge-delivery-log.js';
|
||||
export { createBlobRoutes, DEFAULT_MAX_BLOB_BYTES } from './blob-routes.js';
|
||||
export type { BlobRoutesOptions } from './blob-routes.js';
|
||||
export { MemoryBlobStore } from './memory-blob-store.js';
|
||||
export type { BlobStore, BlobSlotRecord, PutBlobResult } from './blob-store.js';
|
||||
|
||||
/**
|
||||
* Create a standalone Shade Inbox Server.
|
||||
@@ -48,12 +55,21 @@ export type { BridgeDeliveryLogOptions } from './bridge-delivery-log.js';
|
||||
* const app = new Hono();
|
||||
* app.route('/', createInboxServer({ crypto }));
|
||||
*/
|
||||
export function createInboxServer(options: {
|
||||
crypto: CryptoProvider;
|
||||
store?: InboxStore;
|
||||
disableRateLimit?: boolean;
|
||||
events?: InboxServerEvents;
|
||||
} & Pick<InboxRoutesOptions, 'observability' | 'quota' | 'bridgeDeliveryLog'>): Hono {
|
||||
export function createInboxServer(
|
||||
options: {
|
||||
crypto: CryptoProvider;
|
||||
store?: InboxStore;
|
||||
disableRateLimit?: boolean;
|
||||
events?: InboxServerEvents;
|
||||
/**
|
||||
* V4.9 — when supplied, mounts the encrypted-blob primitive
|
||||
* (`/v1/blob/<slotId>`) on the same Hono app. Pass `null` to
|
||||
* explicitly opt out; omit to default to a `MemoryBlobStore`.
|
||||
*/
|
||||
blobStore?: BlobStore | null;
|
||||
blobOptions?: Pick<BlobRoutesOptions, 'maxBlobBytes'>;
|
||||
} & Pick<InboxRoutesOptions, 'observability' | 'quota' | 'bridgeDeliveryLog'>,
|
||||
): Hono {
|
||||
const store = options.store ?? new MemoryInboxStore();
|
||||
const routesOptions: InboxRoutesOptions = {};
|
||||
if (options.disableRateLimit !== undefined) routesOptions.disableRateLimit = options.disableRateLimit;
|
||||
@@ -61,5 +77,23 @@ export function createInboxServer(options: {
|
||||
if (options.observability !== undefined) routesOptions.observability = options.observability;
|
||||
if (options.quota !== undefined) routesOptions.quota = options.quota;
|
||||
if (options.bridgeDeliveryLog !== undefined) routesOptions.bridgeDeliveryLog = options.bridgeDeliveryLog;
|
||||
return createInboxRoutes(store, options.crypto, routesOptions);
|
||||
|
||||
const inboxApp = createInboxRoutes(store, options.crypto, routesOptions);
|
||||
|
||||
// Compose with the blob primitive unless explicitly disabled. The
|
||||
// blob routes share the same Hono app so a single port serves both.
|
||||
if (options.blobStore === null) return inboxApp;
|
||||
const blobStore = options.blobStore ?? new MemoryBlobStore();
|
||||
const blobRoutesOptions: BlobRoutesOptions = {};
|
||||
if (options.disableRateLimit !== undefined) blobRoutesOptions.disableRateLimit = options.disableRateLimit;
|
||||
if (options.observability !== undefined) blobRoutesOptions.observability = options.observability;
|
||||
if (options.blobOptions?.maxBlobBytes !== undefined) {
|
||||
blobRoutesOptions.maxBlobBytes = options.blobOptions.maxBlobBytes;
|
||||
}
|
||||
const blobApp = createBlobRoutes(blobStore, options.crypto, blobRoutesOptions);
|
||||
|
||||
const composed = new Hono();
|
||||
composed.route('/', inboxApp);
|
||||
composed.route('/', blobApp);
|
||||
return composed;
|
||||
}
|
||||
|
||||
85
packages/shade-inbox-server/src/memory-blob-store.ts
Normal file
85
packages/shade-inbox-server/src/memory-blob-store.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { ConflictError, PreconditionFailedError } from '@shade/core';
|
||||
import type { BlobStore, BlobSlotRecord, PutBlobResult } from './blob-store.js';
|
||||
|
||||
/**
|
||||
* In-memory BlobStore — used in tests and as the default fallback when
|
||||
* no SQLite/Postgres URL is configured. Rows are kept in a single Map.
|
||||
*
|
||||
* Etag is a strictly-monotonic per-process counter — guarantees a total
|
||||
* order across writes even when many land in the same millisecond. (We
|
||||
* could scope it per-slot, but a global counter keeps the implementation
|
||||
* trivial and the etag values still uniquely identify the write that
|
||||
* produced them, which is all CAS needs.)
|
||||
*/
|
||||
export class MemoryBlobStore implements BlobStore {
|
||||
private slots = new Map<string, BlobSlotRecord>();
|
||||
private nextEtag = 0;
|
||||
|
||||
async get(slotId: string): Promise<BlobSlotRecord | null> {
|
||||
const r = this.slots.get(slotId);
|
||||
if (!r) return null;
|
||||
return {
|
||||
slotId: r.slotId,
|
||||
blob: new Uint8Array(r.blob),
|
||||
ownerPubkey: new Uint8Array(r.ownerPubkey),
|
||||
etag: r.etag,
|
||||
updatedAt: r.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async put(args: {
|
||||
slotId: string;
|
||||
blob: Uint8Array;
|
||||
ownerPubkey: Uint8Array;
|
||||
expectedEtag: number | '*' | undefined;
|
||||
now: number;
|
||||
}): Promise<PutBlobResult> {
|
||||
const existing = this.slots.get(args.slotId);
|
||||
|
||||
if (!existing) {
|
||||
// Empty slot. `ifMatch: '*'` per RFC 7232 still fails — there is
|
||||
// no entity to match. A numeric etag also fails (we have nothing
|
||||
// to compare against).
|
||||
if (args.expectedEtag !== undefined) {
|
||||
throw new PreconditionFailedError(
|
||||
`Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`,
|
||||
);
|
||||
}
|
||||
this.nextEtag = Math.max(this.nextEtag + 1, args.now);
|
||||
const etag = this.nextEtag;
|
||||
this.slots.set(args.slotId, {
|
||||
slotId: args.slotId,
|
||||
blob: new Uint8Array(args.blob),
|
||||
ownerPubkey: new Uint8Array(args.ownerPubkey),
|
||||
etag,
|
||||
updatedAt: args.now,
|
||||
});
|
||||
return { created: true, etag, updatedAt: args.now };
|
||||
}
|
||||
|
||||
// Slot exists. Pubkey check is the route layer's job — by the time
|
||||
// we're here the signature has already been verified against
|
||||
// `existing.ownerPubkey`.
|
||||
if (args.expectedEtag === undefined) {
|
||||
throw new ConflictError(
|
||||
`Slot ${args.slotId} already exists; supply ifMatch to update`,
|
||||
);
|
||||
}
|
||||
if (args.expectedEtag !== '*' && args.expectedEtag !== existing.etag) {
|
||||
throw new PreconditionFailedError(
|
||||
`Slot ${args.slotId} etag mismatch: expected=${args.expectedEtag} actual=${existing.etag}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.nextEtag = Math.max(this.nextEtag + 1, args.now);
|
||||
const etag = this.nextEtag;
|
||||
existing.blob = new Uint8Array(args.blob);
|
||||
existing.etag = etag;
|
||||
existing.updatedAt = args.now;
|
||||
return { created: false, etag, updatedAt: args.now };
|
||||
}
|
||||
|
||||
async delete(slotId: string): Promise<boolean> {
|
||||
return this.slots.delete(slotId);
|
||||
}
|
||||
}
|
||||
295
packages/shade-inbox-server/tests/blob-routes.test.ts
Normal file
295
packages/shade-inbox-server/tests/blob-routes.test.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
import {
|
||||
createBlobRoutes,
|
||||
MemoryBlobStore,
|
||||
type BlobStore,
|
||||
} from '../src/index.js';
|
||||
import { signPayload } from '@shade/server';
|
||||
import { SubtleCryptoProvider, ed25519PublicKeyFromSeed } from '@shade/crypto-web';
|
||||
import { toBase64, fromBase64 } from '@shade/core';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
function randBytes(n: number): Uint8Array {
|
||||
const buf = new Uint8Array(n);
|
||||
globalThis.crypto.getRandomValues(buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
function hex(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += b.toString(16).padStart(2, '0');
|
||||
return s;
|
||||
}
|
||||
|
||||
describe('Shade Blob Routes (V4.9)', () => {
|
||||
let store: BlobStore;
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new MemoryBlobStore();
|
||||
app = createBlobRoutes(store, crypto, { disableRateLimit: true });
|
||||
});
|
||||
|
||||
async function makeOwner() {
|
||||
const seed = randBytes(32);
|
||||
const pubkey = ed25519PublicKeyFromSeed(seed);
|
||||
return { seed, pubkey };
|
||||
}
|
||||
|
||||
function makeSlotId(): string {
|
||||
return hex(randBytes(32));
|
||||
}
|
||||
|
||||
async function signedPut(args: {
|
||||
slotId: string;
|
||||
blob: Uint8Array;
|
||||
seed: Uint8Array;
|
||||
pubkey: Uint8Array;
|
||||
ifMatch?: string;
|
||||
}) {
|
||||
const payload: Record<string, unknown> = {
|
||||
ownerPubkey: toBase64(args.pubkey),
|
||||
blob: toBase64(args.blob),
|
||||
slotId: args.slotId,
|
||||
};
|
||||
if (args.ifMatch !== undefined) payload.ifMatch = args.ifMatch;
|
||||
const signed = await signPayload(crypto, args.seed, payload);
|
||||
const { slotId: _omit, ...wire } = signed as Record<string, unknown>;
|
||||
return app.request(`/v1/blob/${args.slotId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(wire),
|
||||
});
|
||||
}
|
||||
|
||||
async function signedDelete(args: {
|
||||
slotId: string;
|
||||
seed: Uint8Array;
|
||||
}) {
|
||||
const signed = await signPayload(crypto, args.seed, {
|
||||
slotId: args.slotId,
|
||||
});
|
||||
const { slotId: _omit, ...wire } = signed as Record<string, unknown>;
|
||||
return app.request(`/v1/blob/${args.slotId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(wire),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── GET ─────────────────────────────────────────────────────
|
||||
|
||||
test('GET on missing slot returns 404', async () => {
|
||||
const slotId = makeSlotId();
|
||||
const res = await app.request(`/v1/blob/${slotId}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('GET requires lowercase 64-hex slotId', async () => {
|
||||
const res = await app.request('/v1/blob/notahex');
|
||||
expect(res.status).toBe(400);
|
||||
const res2 = await app.request(`/v1/blob/${'A'.repeat(64)}`);
|
||||
expect(res2.status).toBe(400);
|
||||
});
|
||||
|
||||
// ─── PUT (TOFU) ──────────────────────────────────────────────
|
||||
|
||||
test('first PUT creates slot and returns etag', async () => {
|
||||
const slotId = makeSlotId();
|
||||
const owner = await makeOwner();
|
||||
const blob = randBytes(128);
|
||||
const res = await signedPut({ slotId, blob, ...owner });
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as { created: boolean; etag: string };
|
||||
expect(json.created).toBe(true);
|
||||
expect(typeof json.etag).toBe('string');
|
||||
|
||||
const got = await app.request(`/v1/blob/${slotId}`);
|
||||
expect(got.status).toBe(200);
|
||||
const back = (await got.json()) as { blob: string; etag: string };
|
||||
expect(fromBase64(back.blob)).toEqual(blob);
|
||||
expect(back.etag).toBe(json.etag);
|
||||
});
|
||||
|
||||
test('PUT without ifMatch on populated slot returns 409', async () => {
|
||||
const slotId = makeSlotId();
|
||||
const owner = await makeOwner();
|
||||
await signedPut({ slotId, blob: randBytes(64), ...owner });
|
||||
const res = await signedPut({ slotId, blob: randBytes(64), ...owner });
|
||||
expect(res.status).toBe(409);
|
||||
const json = (await res.json()) as { code: string };
|
||||
expect(json.code).toBe('SHADE_CONFLICT');
|
||||
});
|
||||
|
||||
test('PUT with stale ifMatch returns 412', async () => {
|
||||
const slotId = makeSlotId();
|
||||
const owner = await makeOwner();
|
||||
const r1 = await signedPut({ slotId, blob: randBytes(64), ...owner });
|
||||
const j1 = (await r1.json()) as { etag: string };
|
||||
// Use an etag we know does not match.
|
||||
const stale = String(Number(j1.etag) - 999);
|
||||
const res = await signedPut({
|
||||
slotId,
|
||||
blob: randBytes(64),
|
||||
...owner,
|
||||
ifMatch: stale,
|
||||
});
|
||||
expect(res.status).toBe(412);
|
||||
const json = (await res.json()) as { code: string };
|
||||
expect(json.code).toBe('SHADE_PRECONDITION_FAILED');
|
||||
});
|
||||
|
||||
test('PUT with matching ifMatch updates and bumps etag', async () => {
|
||||
const slotId = makeSlotId();
|
||||
const owner = await makeOwner();
|
||||
const r1 = await signedPut({ slotId, blob: randBytes(64), ...owner });
|
||||
const j1 = (await r1.json()) as { etag: string };
|
||||
const r2 = await signedPut({
|
||||
slotId,
|
||||
blob: randBytes(64),
|
||||
...owner,
|
||||
ifMatch: j1.etag,
|
||||
});
|
||||
expect(r2.status).toBe(200);
|
||||
const j2 = (await r2.json()) as { created: boolean; etag: string };
|
||||
expect(j2.created).toBe(false);
|
||||
expect(Number(j2.etag)).toBeGreaterThan(Number(j1.etag));
|
||||
});
|
||||
|
||||
test('PUT with ifMatch="*" unconditionally overwrites existing slot', async () => {
|
||||
const slotId = makeSlotId();
|
||||
const owner = await makeOwner();
|
||||
await signedPut({ slotId, blob: randBytes(64), ...owner });
|
||||
const res = await signedPut({
|
||||
slotId,
|
||||
blob: randBytes(64),
|
||||
...owner,
|
||||
ifMatch: '*',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('PUT with ifMatch="*" on empty slot returns 412', async () => {
|
||||
const slotId = makeSlotId();
|
||||
const owner = await makeOwner();
|
||||
const res = await signedPut({
|
||||
slotId,
|
||||
blob: randBytes(64),
|
||||
...owner,
|
||||
ifMatch: '*',
|
||||
});
|
||||
expect(res.status).toBe(412);
|
||||
});
|
||||
|
||||
test('PUT by a different owner key on existing slot is rejected', async () => {
|
||||
const slotId = makeSlotId();
|
||||
const ownerA = await makeOwner();
|
||||
await signedPut({ slotId, blob: randBytes(64), ...ownerA });
|
||||
|
||||
const ownerB = await makeOwner();
|
||||
const res = await signedPut({
|
||||
slotId,
|
||||
blob: randBytes(64),
|
||||
...ownerB,
|
||||
ifMatch: '*',
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('PUT with bad signature is rejected', async () => {
|
||||
const slotId = makeSlotId();
|
||||
const owner = await makeOwner();
|
||||
// Sign the payload, then mutate the blob bytes — signature no
|
||||
// longer matches the canonicalized body.
|
||||
const blob = randBytes(64);
|
||||
const payload = {
|
||||
ownerPubkey: toBase64(owner.pubkey),
|
||||
blob: toBase64(blob),
|
||||
slotId,
|
||||
};
|
||||
const signed = await signPayload(crypto, owner.seed, payload);
|
||||
(signed as any).blob = toBase64(randBytes(64));
|
||||
const { slotId: _omit, ...wire } = signed as Record<string, unknown>;
|
||||
const res = await app.request(`/v1/blob/${slotId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(wire),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('PUT rejects empty blob and oversized blob', async () => {
|
||||
const slotId = makeSlotId();
|
||||
const owner = await makeOwner();
|
||||
const empty = await signedPut({ slotId, blob: new Uint8Array(0), ...owner });
|
||||
expect(empty.status).toBe(400);
|
||||
const tooBig = await signedPut({
|
||||
slotId,
|
||||
blob: randBytes(70 * 1024),
|
||||
...owner,
|
||||
});
|
||||
expect(tooBig.status).toBe(400);
|
||||
});
|
||||
|
||||
// ─── DELETE ──────────────────────────────────────────────────
|
||||
|
||||
test('DELETE clears slot and lets a fresh key TOFU re-claim', async () => {
|
||||
const slotId = makeSlotId();
|
||||
const ownerA = await makeOwner();
|
||||
await signedPut({ slotId, blob: randBytes(64), ...ownerA });
|
||||
|
||||
const del = await signedDelete({ slotId, seed: ownerA.seed });
|
||||
expect(del.status).toBe(200);
|
||||
|
||||
// Slot is gone.
|
||||
const gone = await app.request(`/v1/blob/${slotId}`);
|
||||
expect(gone.status).toBe(404);
|
||||
|
||||
// A fresh owner can now claim it.
|
||||
const ownerB = await makeOwner();
|
||||
const claim = await signedPut({ slotId, blob: randBytes(64), ...ownerB });
|
||||
expect(claim.status).toBe(200);
|
||||
});
|
||||
|
||||
test('DELETE by a different key is rejected', async () => {
|
||||
const slotId = makeSlotId();
|
||||
const ownerA = await makeOwner();
|
||||
await signedPut({ slotId, blob: randBytes(64), ...ownerA });
|
||||
|
||||
const ownerB = await makeOwner();
|
||||
const res = await signedDelete({ slotId, seed: ownerB.seed });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('DELETE on missing slot returns 404', async () => {
|
||||
const slotId = makeSlotId();
|
||||
const owner = await makeOwner();
|
||||
const res = await signedDelete({ slotId, seed: owner.seed });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// ─── Cross-slot replay ───────────────────────────────────────
|
||||
|
||||
test('PUT signed for slot A is rejected against slot B', async () => {
|
||||
const slotA = makeSlotId();
|
||||
const slotB = makeSlotId();
|
||||
const owner = await makeOwner();
|
||||
const blob = randBytes(64);
|
||||
// Sign for slotA, send to slotB (URL).
|
||||
const payload = {
|
||||
ownerPubkey: toBase64(owner.pubkey),
|
||||
blob: toBase64(blob),
|
||||
slotId: slotA,
|
||||
};
|
||||
const signed = await signPayload(crypto, owner.seed, payload);
|
||||
const { slotId: _omit, ...wire } = signed as Record<string, unknown>;
|
||||
const res = await app.request(`/v1/blob/${slotB}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(wire),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user