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>
219 lines
6.1 KiB
TypeScript
219 lines
6.1 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import {
|
|
createProfileNamespace,
|
|
profilePlaintextToString,
|
|
deriveBlobSlotId,
|
|
deriveBlobKey,
|
|
deriveBlobSigningSeed,
|
|
ed25519PublicKeyFromSeed,
|
|
slotIdToHex,
|
|
} from '../src/index.js';
|
|
import {
|
|
createInboxServer,
|
|
MemoryInboxStore,
|
|
MemoryBlobStore,
|
|
} from '@shade/inbox-server';
|
|
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
|
import { ShadeError } from '@shade/core';
|
|
|
|
const crypto = new SubtleCryptoProvider();
|
|
|
|
function randBytes(n: number): Uint8Array {
|
|
const buf = new Uint8Array(n);
|
|
globalThis.crypto.getRandomValues(buf);
|
|
return buf;
|
|
}
|
|
|
|
interface ServerHandle {
|
|
url: string;
|
|
stop: () => void;
|
|
}
|
|
|
|
async function startServer(): Promise<ServerHandle> {
|
|
const app = createInboxServer({
|
|
crypto,
|
|
store: new MemoryInboxStore(),
|
|
blobStore: new MemoryBlobStore(),
|
|
disableRateLimit: true,
|
|
});
|
|
const port = 19000 + Math.floor(Math.random() * 500);
|
|
const handle = Bun.serve({ port, fetch: app.fetch });
|
|
return {
|
|
url: `http://localhost:${port}`,
|
|
stop: () => handle.stop(true),
|
|
};
|
|
}
|
|
|
|
describe('SDK Profile namespace (V4.9)', () => {
|
|
let server: ServerHandle;
|
|
let masterKey: Uint8Array;
|
|
|
|
beforeEach(async () => {
|
|
server = await startServer();
|
|
masterKey = randBytes(32);
|
|
});
|
|
|
|
afterEach(() => {
|
|
server.stop();
|
|
});
|
|
|
|
test('credential-only round trip: create, read, update, delete', async () => {
|
|
const profile = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'test-profile-v1',
|
|
});
|
|
|
|
// Empty slot.
|
|
expect(await profile.get()).toBeNull();
|
|
|
|
// Create.
|
|
const payload = JSON.stringify({ hosts: ['device:abc'], v: 1 });
|
|
const created = await profile.put(payload);
|
|
expect(created.created).toBe(true);
|
|
|
|
// Read back.
|
|
const got1 = await profile.get();
|
|
expect(got1).not.toBeNull();
|
|
expect(profilePlaintextToString(got1!)).toBe(payload);
|
|
expect(got1!.etag).toBe(created.etag);
|
|
|
|
// CAS update with the etag we just read.
|
|
const next = JSON.stringify({ hosts: ['device:abc', 'device:def'], v: 2 });
|
|
const updated = await profile.put(next, { ifMatch: got1!.etag });
|
|
expect(updated.created).toBe(false);
|
|
expect(Number(updated.etag)).toBeGreaterThan(Number(created.etag));
|
|
|
|
// Stale CAS fails.
|
|
await expect(
|
|
profile.put(JSON.stringify({ hosts: [] }), { ifMatch: created.etag }),
|
|
).rejects.toThrow(ShadeError);
|
|
|
|
// Delete.
|
|
const removed = await profile.delete();
|
|
expect(removed).toBe(true);
|
|
expect(await profile.get()).toBeNull();
|
|
});
|
|
|
|
test('different app namespaces map to different slots', async () => {
|
|
const a = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'app-a',
|
|
});
|
|
const b = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'app-b',
|
|
});
|
|
expect(a.slotIdHex).not.toBe(b.slotIdHex);
|
|
});
|
|
|
|
test('different master keys map to different slots', async () => {
|
|
const km2 = randBytes(32);
|
|
const a = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'shared',
|
|
});
|
|
const b = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey: km2,
|
|
app: 'shared',
|
|
});
|
|
expect(a.slotIdHex).not.toBe(b.slotIdHex);
|
|
});
|
|
|
|
test('a fresh client with the same master + app reads the existing blob', async () => {
|
|
const writer = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'shared',
|
|
});
|
|
await writer.put('hello world');
|
|
|
|
// Brand-new namespace instance — simulates "log in from a new
|
|
// device". Uses *only* the master key + app namespace; nothing
|
|
// else carried over.
|
|
const reader = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'shared',
|
|
});
|
|
const got = await reader.get();
|
|
expect(got).not.toBeNull();
|
|
expect(profilePlaintextToString(got!)).toBe('hello world');
|
|
});
|
|
|
|
test('without ifMatch on populated slot is a SHADE_CONFLICT error', async () => {
|
|
const profile = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'conflict-test',
|
|
});
|
|
await profile.put('first');
|
|
try {
|
|
await profile.put('second');
|
|
throw new Error('expected put to throw');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(ShadeError);
|
|
expect((err as ShadeError).code).toBe('SHADE_CONFLICT');
|
|
}
|
|
});
|
|
|
|
test('stale ifMatch is a SHADE_PRECONDITION_FAILED error', async () => {
|
|
const profile = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'precondition-test',
|
|
});
|
|
const first = await profile.put('first');
|
|
await profile.put('second', { ifMatch: first.etag });
|
|
try {
|
|
await profile.put('third', { ifMatch: first.etag });
|
|
throw new Error('expected put to throw');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(ShadeError);
|
|
expect((err as ShadeError).code).toBe('SHADE_PRECONDITION_FAILED');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('KDF helpers (V4.9)', () => {
|
|
test('derivations are deterministic per (masterKey, app)', () => {
|
|
const km = randBytes(32);
|
|
const a1 = deriveBlobSlotId(km, 'x');
|
|
const a2 = deriveBlobSlotId(km, 'x');
|
|
expect(a1).toEqual(a2);
|
|
expect(deriveBlobSlotId(km, 'y')).not.toEqual(a1);
|
|
expect(deriveBlobKey(km, 'x')).not.toEqual(a1);
|
|
expect(deriveBlobSigningSeed(km, 'x')).not.toEqual(deriveBlobKey(km, 'x'));
|
|
});
|
|
|
|
test('signing seed → pubkey is deterministic and 32 bytes', () => {
|
|
const km = randBytes(32);
|
|
const seed = deriveBlobSigningSeed(km, 'p');
|
|
const pk1 = ed25519PublicKeyFromSeed(seed);
|
|
const pk2 = ed25519PublicKeyFromSeed(seed);
|
|
expect(pk1).toEqual(pk2);
|
|
expect(pk1.length).toBe(32);
|
|
});
|
|
|
|
test('slotIdToHex round-trips through hex form', () => {
|
|
const km = randBytes(32);
|
|
const id = deriveBlobSlotId(km, 'rt');
|
|
const hex = slotIdToHex(id);
|
|
expect(hex.length).toBe(64);
|
|
expect(/^[0-9a-f]{64}$/.test(hex)).toBe(true);
|
|
});
|
|
});
|