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:
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