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>
296 lines
9.7 KiB
TypeScript
296 lines
9.7 KiB
TypeScript
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);
|
|
});
|
|
});
|