release(v4.9.0): relay-side encrypted blob primitive + SDK Profile namespace
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled

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:
2026-05-09 02:44:42 +02:00
parent 3c0db14904
commit 80c410f518
51 changed files with 2138 additions and 58 deletions

View File

@@ -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",

View 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;
}

View 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>;
}

View File

@@ -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;
}

View 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);
}
}

View 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);
});
});