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/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:*"
}
}

View File

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

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

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