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,3 +1,4 @@
|
||||
export { SQLiteStorage } from './sqlite-storage.js';
|
||||
export { SqlitePrekeyStore } from './sqlite-prekey-store.js';
|
||||
export { SqliteInboxStore } from './sqlite-inbox-store.js';
|
||||
export { SqliteBlobStore } from './sqlite-blob-store.js';
|
||||
|
||||
156
packages/shade-storage-sqlite/src/sqlite-blob-store.ts
Normal file
156
packages/shade-storage-sqlite/src/sqlite-blob-store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user