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/inbox",
"version": "4.8.5",
"version": "4.9.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -0,0 +1,208 @@
import type { CryptoProvider } from '@shade/core';
import {
NetworkError,
toBase64,
fromBase64,
ShadeError,
ValidationError,
} from '@shade/core';
import { signPayload } from '@shade/server';
/**
* Low-level HTTP client for the V4.9 encrypted-blob primitive
* (`/v1/blob/<slotId>`). Stateless and reusable; higher-level wrappers
* (e.g. `Profile` in `@shade/sdk`) compose this client.
*
* The client doesn't care what the blob bytes mean — it just transports
* them. Callers are responsible for AEAD-sealing/opening, deriving the
* slotId from the master key, and managing the signing key.
*/
export interface BlobClientOptions {
baseUrl: string;
crypto: CryptoProvider;
/** Optional fetch override (defaults to globalThis.fetch). */
fetch?: typeof fetch;
}
export interface BlobGetResult {
blob: Uint8Array;
/** ETag string — pass back as `ifMatch` to do a CAS update. */
etag: string;
updatedAt: number;
}
export interface BlobPutResult {
/** 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 class BlobClient {
private readonly fetchImpl: typeof fetch;
constructor(private readonly options: BlobClientOptions) {
const f = options.fetch ?? globalThis.fetch;
this.fetchImpl = f.bind(globalThis);
}
/**
* Read a slot. Returns null if no blob has ever been written there
* (or if it was DELETE'd). GET is unauthenticated — see the
* `BlobStore` JSDoc for the threat-model rationale.
*/
async get(slotIdHex: string): Promise<BlobGetResult | null> {
validateSlotIdHex(slotIdHex);
const url = joinUrl(this.options.baseUrl, `/v1/blob/${slotIdHex}`);
let res: Response;
try {
res = await this.fetchImpl(url, { method: 'GET' });
} catch (err) {
throw new NetworkError(`Blob GET failed: ${(err as Error).message}`);
}
if (res.status === 404) return null;
const text = await res.text();
let json: any;
try {
json = text.length > 0 ? JSON.parse(text) : {};
} catch {
throw new NetworkError(`Blob response not JSON: ${text.slice(0, 200)}`, res.status);
}
if (!res.ok) {
throw new ShadeError(
String(json.code ?? 'SHADE_NETWORK'),
String(json.message ?? text),
);
}
return {
blob: fromBase64(String(json.blob)),
etag: String(json.etag),
updatedAt: Number(json.updatedAt),
};
}
/**
* Create or update a slot.
*
* `ifMatch` semantics:
* - `undefined`: create-only. Slot must be empty (else 409).
* - `<etag-string>`: compare-and-swap. Must match (else 412).
* - `'*'`: unconditional overwrite. Slot must already exist (else 412).
*/
async put(args: {
slotIdHex: string;
blob: Uint8Array;
/** 32-byte Ed25519 seed (== `signingPrivateKey`). */
signingPrivateKey: Uint8Array;
/** Pubkey paired to `signingPrivateKey`. */
ownerPubkey: Uint8Array;
ifMatch?: string;
}): Promise<BlobPutResult> {
validateSlotIdHex(args.slotIdHex);
if (args.blob.length === 0) {
throw new ValidationError('Empty blob');
}
if (args.ownerPubkey.length !== 32) {
throw new ValidationError('ownerPubkey must be 32 bytes (Ed25519)');
}
const payload: Record<string, unknown> = {
ownerPubkey: toBase64(args.ownerPubkey),
blob: toBase64(args.blob),
slotId: args.slotIdHex,
};
if (args.ifMatch !== undefined) payload.ifMatch = args.ifMatch;
const signed = await signPayload(
this.options.crypto,
args.signingPrivateKey,
payload,
);
// `slotId` was used for the signature canonicalization to bind it
// into the payload; the server rebuilds the same canonical form
// by mixing the URL slotId back in. Strip it from the wire body
// so we don't send it twice (URL is the path param).
const { slotId: _omit, ...wireBody } = signed as Record<string, unknown>;
const url = joinUrl(this.options.baseUrl, `/v1/blob/${args.slotIdHex}`);
const json = await this.requestJson('PUT', url, wireBody);
return {
created: Boolean(json.created),
etag: String(json.etag),
updatedAt: Number(json.updatedAt),
};
}
/**
* Delete a slot — the next PUT TOFU-claims it again, possibly under
* a fresh signing key (e.g. after a rotation). Used by the "forget
* everything" path.
*/
async delete(args: {
slotIdHex: string;
signingPrivateKey: Uint8Array;
}): Promise<boolean> {
validateSlotIdHex(args.slotIdHex);
const signed = await signPayload(this.options.crypto, args.signingPrivateKey, {
slotId: args.slotIdHex,
});
const { slotId: _omit, ...wireBody } = signed as Record<string, unknown>;
const url = joinUrl(this.options.baseUrl, `/v1/blob/${args.slotIdHex}`);
const json = await this.requestJson('DELETE', url, wireBody);
return Boolean(json.ok);
}
// ─── HTTP plumbing ──────────────────────────────────────────
private async requestJson(method: string, url: string, body: unknown): Promise<any> {
let res: Response;
try {
res = await this.fetchImpl(url, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
} catch (err) {
throw new NetworkError(`Blob request failed: ${(err as Error).message}`);
}
const text = await res.text();
let json: any;
try {
json = text.length > 0 ? JSON.parse(text) : {};
} catch {
throw new NetworkError(`Blob response not JSON: ${text.slice(0, 200)}`, res.status);
}
if (!res.ok) {
throw new ShadeError(
String(json.code ?? 'SHADE_NETWORK'),
String(json.message ?? text),
);
}
return json;
}
}
function validateSlotIdHex(s: string): void {
if (!/^[0-9a-f]{64}$/.test(s)) {
throw new ValidationError('slotIdHex must be 64 lowercase hex chars (32 bytes)');
}
}
function joinUrl(base: string, path: string): string {
if (base.endsWith('/') && path.startsWith('/')) return base + path.slice(1);
if (!base.endsWith('/') && !path.startsWith('/')) return base + '/' + path;
return base + path;
}
/** Convert a 32-byte slotId Uint8Array into the lowercase-hex wire form. */
export function slotIdToHex(slotId: Uint8Array): string {
if (slotId.length !== 32) {
throw new ValidationError('slotId must be 32 bytes');
}
let s = '';
for (let i = 0; i < slotId.length; i++) {
s += slotId[i]!.toString(16).padStart(2, '0');
}
return s;
}

View File

@@ -43,3 +43,11 @@ export type {
} from './events.js';
export { computeMsgId } from './msg-id.js';
// V4.9 — encrypted-blob primitive (`/v1/blob/<slotId>`).
export { BlobClient, slotIdToHex } from './blob-client.js';
export type {
BlobClientOptions,
BlobGetResult,
BlobPutResult,
} from './blob-client.js';