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/inbox",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
208
packages/shade-inbox/src/blob-client.ts
Normal file
208
packages/shade-inbox/src/blob-client.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user