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/storage-postgres",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -208,6 +208,30 @@ export async function ensureKTLogTables(sql: Sql): Promise<void> {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* V4.9 — encrypted-blob primitive (`/v1/blob/<slotId>`). One row per
|
||||
* slot, keyed on the 64-hex slotId. ETag is a sequence value so it's
|
||||
* unique and monotonic across writers (matches the inbox `received_at`
|
||||
* pattern). The blob column holds base64-encoded AEAD ciphertext —
|
||||
* the relay never decrypts.
|
||||
*/
|
||||
export async function ensureBlobServerTables(sql: Sql): Promise<void> {
|
||||
await sql`CREATE SEQUENCE IF NOT EXISTS shade_blob_seq`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS shade_blob_slots (
|
||||
slot_id TEXT PRIMARY KEY,
|
||||
owner_pubkey TEXT NOT NULL,
|
||||
blob TEXT NOT NULL,
|
||||
etag BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS shade_blob_updated_idx
|
||||
ON shade_blob_slots(updated_at)
|
||||
`;
|
||||
}
|
||||
|
||||
export async function ensureInboxServerTables(sql: Sql): Promise<void> {
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS shade_inbox_owners (
|
||||
|
||||
@@ -2,9 +2,11 @@ export { PostgresStorage } from './postgres-storage.js';
|
||||
export { PostgresPrekeyStore } from './postgres-prekey-store.js';
|
||||
export { PostgresInboxStore } from './postgres-inbox-store.js';
|
||||
export { PostgresKTLogStore } from './postgres-kt-store.js';
|
||||
export { PostgresBlobStore } from './postgres-blob-store.js';
|
||||
export {
|
||||
ensureClientTables,
|
||||
ensurePrekeyServerTables,
|
||||
ensureInboxServerTables,
|
||||
ensureKTLogTables,
|
||||
ensureBlobServerTables,
|
||||
} from './ensure-tables.js';
|
||||
|
||||
140
packages/shade-storage-postgres/src/postgres-blob-store.ts
Normal file
140
packages/shade-storage-postgres/src/postgres-blob-store.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import postgres, { type Sql } from 'postgres';
|
||||
import {
|
||||
ConflictError,
|
||||
PreconditionFailedError,
|
||||
toBase64,
|
||||
fromBase64,
|
||||
} from '@shade/core';
|
||||
import type { BlobStore, BlobSlotRecord, PutBlobResult } from '@shade/inbox-server';
|
||||
import { ensureBlobServerTables } from './ensure-tables.js';
|
||||
|
||||
/**
|
||||
* PostgreSQL-backed BlobStore for the V4.9 encrypted-blob primitive.
|
||||
*
|
||||
* CAS is implemented at SQL level using a single UPDATE-with-WHERE
|
||||
* (existing slots) or INSERT (empty slots), wrapped in a transaction so
|
||||
* the read-then-write window can't race. ETag is generated server-side
|
||||
* via `nextval('shade_blob_seq')` so the value is monotonic across
|
||||
* processes — multi-instance deployments share a strict ordering.
|
||||
*/
|
||||
export class PostgresBlobStore implements BlobStore {
|
||||
private constructor(
|
||||
private readonly sql: Sql,
|
||||
private readonly ownsConnection: boolean,
|
||||
) {}
|
||||
|
||||
static async create(connectionString: string): Promise<PostgresBlobStore> {
|
||||
const sql = postgres(connectionString);
|
||||
const store = new PostgresBlobStore(sql, true);
|
||||
await ensureBlobServerTables(sql);
|
||||
return store;
|
||||
}
|
||||
|
||||
static async fromClient(sql: Sql): Promise<PostgresBlobStore> {
|
||||
const store = new PostgresBlobStore(sql, false);
|
||||
await ensureBlobServerTables(sql);
|
||||
return store;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.ownsConnection) await this.sql.end();
|
||||
}
|
||||
|
||||
async get(slotId: string): Promise<BlobSlotRecord | null> {
|
||||
const rows = await this.sql<
|
||||
Array<{
|
||||
slot_id: string;
|
||||
owner_pubkey: string;
|
||||
blob: string;
|
||||
etag: string;
|
||||
updated_at: string;
|
||||
}>
|
||||
>`
|
||||
SELECT slot_id, owner_pubkey, blob, etag::text, updated_at::text
|
||||
FROM shade_blob_slots WHERE slot_id = ${slotId}
|
||||
`;
|
||||
if (rows.length === 0) return null;
|
||||
const r = rows[0]!;
|
||||
return {
|
||||
slotId: r.slot_id,
|
||||
ownerPubkey: fromBase64(r.owner_pubkey),
|
||||
blob: fromBase64(r.blob),
|
||||
etag: parseInt(r.etag, 10),
|
||||
updatedAt: parseInt(r.updated_at, 10),
|
||||
};
|
||||
}
|
||||
|
||||
async put(args: {
|
||||
slotId: string;
|
||||
blob: Uint8Array;
|
||||
ownerPubkey: Uint8Array;
|
||||
expectedEtag: number | '*' | undefined;
|
||||
now: number;
|
||||
}): Promise<PutBlobResult> {
|
||||
// Wrap in a serializable txn so the read-current → write window
|
||||
// can't race with another writer.
|
||||
return this.sql.begin(async (tx) => {
|
||||
const existing = await tx<Array<{ etag: string }>>`
|
||||
SELECT etag::text FROM shade_blob_slots
|
||||
WHERE slot_id = ${args.slotId}
|
||||
FOR UPDATE
|
||||
`;
|
||||
|
||||
if (existing.length === 0) {
|
||||
if (args.expectedEtag !== undefined) {
|
||||
throw new PreconditionFailedError(
|
||||
`Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`,
|
||||
);
|
||||
}
|
||||
const inserted = await tx<Array<{ etag: string }>>`
|
||||
INSERT INTO shade_blob_slots (slot_id, owner_pubkey, blob, etag, updated_at)
|
||||
VALUES (
|
||||
${args.slotId},
|
||||
${toBase64(args.ownerPubkey)},
|
||||
${toBase64(args.blob)},
|
||||
nextval('shade_blob_seq'),
|
||||
${args.now}
|
||||
)
|
||||
RETURNING etag::text
|
||||
`;
|
||||
return {
|
||||
created: true,
|
||||
etag: parseInt(inserted[0]!.etag, 10),
|
||||
updatedAt: args.now,
|
||||
};
|
||||
}
|
||||
|
||||
const currentEtag = parseInt(existing[0]!.etag, 10);
|
||||
if (args.expectedEtag === undefined) {
|
||||
throw new ConflictError(
|
||||
`Slot ${args.slotId} already exists; supply ifMatch to update`,
|
||||
);
|
||||
}
|
||||
if (args.expectedEtag !== '*' && args.expectedEtag !== currentEtag) {
|
||||
throw new PreconditionFailedError(
|
||||
`Slot ${args.slotId} etag mismatch: expected=${args.expectedEtag} actual=${currentEtag}`,
|
||||
);
|
||||
}
|
||||
const updated = await tx<Array<{ etag: string }>>`
|
||||
UPDATE shade_blob_slots
|
||||
SET blob = ${toBase64(args.blob)},
|
||||
etag = nextval('shade_blob_seq'),
|
||||
updated_at = ${args.now}
|
||||
WHERE slot_id = ${args.slotId}
|
||||
RETURNING etag::text
|
||||
`;
|
||||
return {
|
||||
created: false,
|
||||
etag: parseInt(updated[0]!.etag, 10),
|
||||
updatedAt: args.now,
|
||||
};
|
||||
}) as Promise<PutBlobResult>;
|
||||
}
|
||||
|
||||
async delete(slotId: string): Promise<boolean> {
|
||||
const result = await this.sql`
|
||||
DELETE FROM shade_blob_slots WHERE slot_id = ${slotId}
|
||||
`;
|
||||
return result.count > 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user