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/sdk",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -8,11 +8,13 @@
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/crypto-web": "workspace:*",
|
||||
"@shade/files": "workspace:*",
|
||||
"@shade/inbox": "workspace:*",
|
||||
"@shade/key-transparency": "workspace:*",
|
||||
"@shade/observability": "workspace:*",
|
||||
"@shade/observer": "workspace:*",
|
||||
"@shade/proto": "workspace:*",
|
||||
"@shade/server": "workspace:*",
|
||||
"@shade/storage-encrypted": "workspace:*",
|
||||
"@shade/storage-sqlite": "workspace:*",
|
||||
"@shade/streams": "workspace:*",
|
||||
"@shade/transfer": "workspace:*",
|
||||
@@ -27,6 +29,7 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shade/inbox-server": "workspace:*",
|
||||
"@shade/transport-webrtc": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,24 @@ export {
|
||||
mainStreamIdForThumbnail,
|
||||
} from '@shade/streams';
|
||||
|
||||
// ─── V4.9 — relay-side encrypted profile storage ──────────
|
||||
export {
|
||||
createProfileNamespace,
|
||||
profilePlaintextToString,
|
||||
deriveBlobSlotId,
|
||||
deriveBlobKey,
|
||||
deriveBlobSigningSeed,
|
||||
ed25519PublicKeyFromSeed,
|
||||
slotIdToHex,
|
||||
} from './profile.js';
|
||||
export type {
|
||||
ProfileNamespace,
|
||||
ProfileNamespaceOptions,
|
||||
ProfileGetResult,
|
||||
ProfilePutOptions,
|
||||
ProfilePutResult,
|
||||
} from './profile.js';
|
||||
|
||||
// ─── Web Workers crypto (V3.8) ─────────────────────────────
|
||||
export {
|
||||
createWorkerCryptoProvider,
|
||||
|
||||
210
packages/shade-sdk/src/profile.ts
Normal file
210
packages/shade-sdk/src/profile.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { CryptoProvider } from '@shade/core';
|
||||
import { ValidationError } from '@shade/core';
|
||||
import {
|
||||
deriveBlobSlotId,
|
||||
deriveBlobKey,
|
||||
deriveBlobSigningSeed,
|
||||
aeadSeal,
|
||||
aeadOpen,
|
||||
} from '@shade/storage-encrypted/crypto';
|
||||
import { ed25519PublicKeyFromSeed } from '@shade/crypto-web';
|
||||
import { BlobClient, slotIdToHex } from '@shade/inbox';
|
||||
|
||||
/**
|
||||
* V4.9 — relay-side encrypted profile storage.
|
||||
*
|
||||
* The `Profile` namespace lets a Shade-based app store a small,
|
||||
* AEAD-sealed JSON blob on the relay keyed by a deterministic slotId
|
||||
* derived from the user's master key. A brand new device that knows
|
||||
* only the credentials (password + PIN → masterKey via the existing
|
||||
* `@shade/storage-encrypted` KDF) can locate, decrypt, and update the
|
||||
* blob. The relay sees only opaque slotIds and AEAD-sealed bytes — it
|
||||
* never decrypts and cannot link slots to users.
|
||||
*
|
||||
* This is the *primitive* Prism uses for credential-driven device
|
||||
* linking (Phase 2 of the Prism device-linking plan): the blob holds
|
||||
* the list of paired hosts, the new device reads it, picks the first
|
||||
* online host, and starts a link-request handshake. But it's
|
||||
* deliberately app-shaped — any Shade app needing a credential-only
|
||||
* bootstrap into existing E2EE state can use it. Pass a different
|
||||
* `app` namespace string per use-case so two apps under the same
|
||||
* master never collide on the same slot.
|
||||
*
|
||||
* Usage:
|
||||
* const km = await KeyManager.unlock(...); // existing v4.5 flow
|
||||
* const profile = createProfileNamespace({
|
||||
* baseUrl: 'https://shade.example/',
|
||||
* crypto: new SubtleCryptoProvider(),
|
||||
* masterKey: km.masterKey,
|
||||
* app: 'prism-profile-v1',
|
||||
* });
|
||||
*
|
||||
* const current = await profile.get();
|
||||
* // -> { plaintext: Uint8Array, etag: string } | null
|
||||
*
|
||||
* await profile.put(JSON.stringify({ hosts: [...] }), {
|
||||
* ifMatch: current?.etag,
|
||||
* });
|
||||
*
|
||||
* await profile.delete(); // "forget everything"
|
||||
*/
|
||||
|
||||
export interface ProfileNamespaceOptions {
|
||||
/** Base URL of the Shade relay. */
|
||||
baseUrl: string;
|
||||
/** CryptoProvider — typically a fresh SubtleCryptoProvider instance. */
|
||||
crypto: CryptoProvider;
|
||||
/**
|
||||
* 32-byte master key, exactly the value you'd hand to
|
||||
* `@shade/storage-encrypted`'s row-codec — the existing v4.5 KDF
|
||||
* chain (passphrase + scrypt → masterKey, possibly upgraded with
|
||||
* argon2id over a PIN) lands you here. Profile storage uses HKDF
|
||||
* subderivations under separate `info` strings, so it can't leak
|
||||
* the storage encryption key or vice versa.
|
||||
*/
|
||||
masterKey: Uint8Array;
|
||||
/**
|
||||
* Per-app namespace string. Distinct apps under the same master key
|
||||
* MUST pass different values so they don't collide on the same slot.
|
||||
* Convention: `"<app-id>-<purpose>-<schema-version>"`, e.g.
|
||||
* `"prism-profile-v1"`.
|
||||
*/
|
||||
app: string;
|
||||
/** Optional fetch override (defaults to globalThis.fetch). */
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface ProfileGetResult {
|
||||
/** Decrypted plaintext bytes. The shape is up to the caller. */
|
||||
plaintext: Uint8Array;
|
||||
/** Pass back as `ifMatch` to do a CAS update. */
|
||||
etag: string;
|
||||
/** Wall-clock ms when the relay last accepted a write. */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ProfilePutOptions {
|
||||
/**
|
||||
* - `undefined` : create-only. Slot must be empty (else 409).
|
||||
* - `<etag-string>` : compare-and-swap. Must match current etag (else 412).
|
||||
* - `'*'` : unconditional overwrite. Slot must already exist (else 412).
|
||||
*/
|
||||
ifMatch?: string;
|
||||
}
|
||||
|
||||
export interface ProfilePutResult {
|
||||
/** True if this PUT created the slot, false if it updated an existing one. */
|
||||
created: boolean;
|
||||
/** New etag after the write. */
|
||||
etag: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ProfileNamespace {
|
||||
readonly slotIdHex: string;
|
||||
get(): Promise<ProfileGetResult | null>;
|
||||
put(plaintext: Uint8Array | string, options?: ProfilePutOptions): Promise<ProfilePutResult>;
|
||||
delete(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const TEXT = new TextEncoder();
|
||||
const TEXT_DECODER = new TextDecoder();
|
||||
|
||||
export function createProfileNamespace(
|
||||
options: ProfileNamespaceOptions,
|
||||
): ProfileNamespace {
|
||||
if (options.masterKey.length !== 32) {
|
||||
throw new ValidationError('masterKey must be 32 bytes');
|
||||
}
|
||||
if (options.app.length === 0) {
|
||||
throw new ValidationError('app namespace must be non-empty');
|
||||
}
|
||||
|
||||
const slotIdBytes = deriveBlobSlotId(options.masterKey, options.app);
|
||||
const slotIdHex = slotIdToHex(slotIdBytes);
|
||||
const blobKey = deriveBlobKey(options.masterKey, options.app);
|
||||
const signingSeed = deriveBlobSigningSeed(options.masterKey, options.app);
|
||||
const ownerPubkey = ed25519PublicKeyFromSeed(signingSeed);
|
||||
|
||||
// AAD binds the slotId into the AEAD seal: a relay returning the
|
||||
// wrong slot's blob (mistake or malice) fails to open. The slotId is
|
||||
// already part of the URL path, but binding it cryptographically
|
||||
// prevents any kind of cross-slot replay regardless of how the bytes
|
||||
// got to us.
|
||||
const aad = TEXT.encode(`shade-profile-aad-v1:${slotIdHex}`);
|
||||
|
||||
const clientOptions: ConstructorParameters<typeof BlobClient>[0] = {
|
||||
baseUrl: options.baseUrl,
|
||||
crypto: options.crypto,
|
||||
};
|
||||
if (options.fetch) clientOptions.fetch = options.fetch;
|
||||
const client = new BlobClient(clientOptions);
|
||||
|
||||
return {
|
||||
slotIdHex,
|
||||
|
||||
async get(): Promise<ProfileGetResult | null> {
|
||||
const result = await client.get(slotIdHex);
|
||||
if (!result) return null;
|
||||
// Deterministic 12-byte nonce from (slotId, etag): the relay
|
||||
// stores `nonce || ct||tag` as one blob, so the AEAD layer
|
||||
// pulls the nonce off the front. We don't pre-compute it —
|
||||
// aeadOpen handles the prefix automatically.
|
||||
const plaintext = await aeadOpen(blobKey, result.blob, aad);
|
||||
return {
|
||||
plaintext,
|
||||
etag: result.etag,
|
||||
updatedAt: result.updatedAt,
|
||||
};
|
||||
},
|
||||
|
||||
async put(
|
||||
plaintext: Uint8Array | string,
|
||||
options?: ProfilePutOptions,
|
||||
): Promise<ProfilePutResult> {
|
||||
const ptBytes =
|
||||
typeof plaintext === 'string' ? TEXT.encode(plaintext) : plaintext;
|
||||
// Random per-write 12-byte nonce. We don't reuse a deterministic
|
||||
// nonce because two consecutive writes of the same plaintext
|
||||
// (rare but possible — re-uploading after a transient error)
|
||||
// would otherwise reuse (key, nonce, plaintext), which is a
|
||||
// nonce-reuse condition for AES-GCM. A fresh random nonce per
|
||||
// PUT keeps each AEAD invocation unique.
|
||||
const nonce = clientOptions.crypto.randomBytes(12);
|
||||
const sealed = await aeadSeal(blobKey, nonce, ptBytes, aad);
|
||||
|
||||
const putArgs: Parameters<BlobClient['put']>[0] = {
|
||||
slotIdHex,
|
||||
blob: sealed,
|
||||
signingPrivateKey: signingSeed,
|
||||
ownerPubkey,
|
||||
};
|
||||
if (options?.ifMatch !== undefined) putArgs.ifMatch = options.ifMatch;
|
||||
return client.put(putArgs);
|
||||
},
|
||||
|
||||
async delete(): Promise<boolean> {
|
||||
return client.delete({
|
||||
slotIdHex,
|
||||
signingPrivateKey: signingSeed,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export the raw KDF helpers so apps that want to drive a custom
|
||||
// flow (skip the AEAD layer, use a different client, run interop
|
||||
// against a non-Shade relay) don't have to re-import from
|
||||
// `@shade/storage-encrypted/crypto`.
|
||||
export {
|
||||
deriveBlobSlotId,
|
||||
deriveBlobKey,
|
||||
deriveBlobSigningSeed,
|
||||
} from '@shade/storage-encrypted/crypto';
|
||||
export { ed25519PublicKeyFromSeed } from '@shade/crypto-web';
|
||||
export { slotIdToHex } from '@shade/inbox';
|
||||
|
||||
/** Decode a UTF-8 plaintext from a `ProfileGetResult`. */
|
||||
export function profilePlaintextToString(result: ProfileGetResult): string {
|
||||
return TEXT_DECODER.decode(result.plaintext);
|
||||
}
|
||||
218
packages/shade-sdk/tests/profile.test.ts
Normal file
218
packages/shade-sdk/tests/profile.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user