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

View File

@@ -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 (

View File

@@ -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';

View 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;
}
}