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

@@ -0,0 +1,156 @@
import { Database } from 'bun:sqlite';
import {
ConflictError,
PreconditionFailedError,
toBase64,
fromBase64,
} from '@shade/core';
import type { BlobStore, BlobSlotRecord, PutBlobResult } from '@shade/inbox-server';
/**
* SQLite-backed BlobStore for the V4.9 encrypted-blob primitive.
*
* Single-table layout: each slot is a row keyed on the 64-hex slotId,
* with the AEAD ciphertext base64-encoded inline. The relay never
* decrypts the blob — it only enforces auth + CAS. ETag is a
* monotonic per-process counter clamped against `Date.now()` so the
* value is unique across writes and useful as a wall-clock hint.
*
* Docker usage: same volume as the inbox DB by convention; set
* `SHADE_BLOB_DB_PATH` (falls back to `/data/shade-blob.db`).
*/
export class SqliteBlobStore implements BlobStore {
private db: Database;
private stmts!: {
get: ReturnType<Database['prepare']>;
insert: ReturnType<Database['prepare']>;
update: ReturnType<Database['prepare']>;
delete: ReturnType<Database['prepare']>;
maxEtag: ReturnType<Database['prepare']>;
};
private seq = 0;
constructor(dbPath?: string) {
const path = dbPath ?? process.env.SHADE_BLOB_DB_PATH ?? '/data/shade-blob.db';
this.db = new Database(path, { create: true });
this.db.exec('PRAGMA journal_mode=WAL');
this.ensureTables();
this.prepareStatements();
this.bootstrapSeq();
}
private ensureTables() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS shade_blob_slots (
slot_id TEXT PRIMARY KEY,
owner_pubkey TEXT NOT NULL,
blob TEXT NOT NULL,
etag INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_shade_blob_updated
ON shade_blob_slots(updated_at);
`);
}
private prepareStatements() {
this.stmts = {
get: this.db.prepare(
'SELECT slot_id, owner_pubkey, blob, etag, updated_at FROM shade_blob_slots WHERE slot_id = ?',
),
insert: this.db.prepare(
'INSERT INTO shade_blob_slots (slot_id, owner_pubkey, blob, etag, updated_at) VALUES (?, ?, ?, ?, ?)',
),
update: this.db.prepare(
'UPDATE shade_blob_slots SET blob = ?, etag = ?, updated_at = ? WHERE slot_id = ?',
),
delete: this.db.prepare('DELETE FROM shade_blob_slots WHERE slot_id = ?'),
maxEtag: this.db.prepare('SELECT MAX(etag) AS max FROM shade_blob_slots'),
};
}
private bootstrapSeq() {
const row = this.stmts.maxEtag.get() as { max: number | null };
this.seq = Math.max(row?.max ?? 0, Date.now());
}
close() {
this.db.close();
}
async get(slotId: string): Promise<BlobSlotRecord | null> {
const row = this.stmts.get.get(slotId) as
| {
slot_id: string;
owner_pubkey: string;
blob: string;
etag: number;
updated_at: number;
}
| undefined;
if (!row) return null;
return {
slotId: row.slot_id,
ownerPubkey: fromBase64(row.owner_pubkey),
blob: fromBase64(row.blob),
etag: row.etag,
updatedAt: row.updated_at,
};
}
async put(args: {
slotId: string;
blob: Uint8Array;
ownerPubkey: Uint8Array;
expectedEtag: number | '*' | undefined;
now: number;
}): Promise<PutBlobResult> {
// Read-then-write inside a write transaction so concurrent
// CAS attempts can't both observe the same etag.
const tx = this.db.transaction((): PutBlobResult => {
const existing = this.stmts.get.get(args.slotId) as
| { etag: number }
| undefined;
if (!existing) {
if (args.expectedEtag !== undefined) {
throw new PreconditionFailedError(
`Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`,
);
}
this.seq = Math.max(this.seq + 1, args.now);
const etag = this.seq;
this.stmts.insert.run(
args.slotId,
toBase64(args.ownerPubkey),
toBase64(args.blob),
etag,
args.now,
);
return { created: true, etag, updatedAt: args.now };
}
if (args.expectedEtag === undefined) {
throw new ConflictError(
`Slot ${args.slotId} already exists; supply ifMatch to update`,
);
}
if (args.expectedEtag !== '*' && args.expectedEtag !== existing.etag) {
throw new PreconditionFailedError(
`Slot ${args.slotId} etag mismatch: expected=${args.expectedEtag} actual=${existing.etag}`,
);
}
this.seq = Math.max(this.seq + 1, args.now);
const etag = this.seq;
this.stmts.update.run(toBase64(args.blob), etag, args.now, args.slotId);
return { created: false, etag, updatedAt: args.now };
});
return tx();
}
async delete(slotId: string): Promise<boolean> {
const result = this.stmts.delete.run(slotId);
return result.changes > 0;
}
}