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:
85
packages/shade-inbox-server/src/memory-blob-store.ts
Normal file
85
packages/shade-inbox-server/src/memory-blob-store.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { ConflictError, PreconditionFailedError } from '@shade/core';
|
||||
import type { BlobStore, BlobSlotRecord, PutBlobResult } from './blob-store.js';
|
||||
|
||||
/**
|
||||
* In-memory BlobStore — used in tests and as the default fallback when
|
||||
* no SQLite/Postgres URL is configured. Rows are kept in a single Map.
|
||||
*
|
||||
* Etag is a strictly-monotonic per-process counter — guarantees a total
|
||||
* order across writes even when many land in the same millisecond. (We
|
||||
* could scope it per-slot, but a global counter keeps the implementation
|
||||
* trivial and the etag values still uniquely identify the write that
|
||||
* produced them, which is all CAS needs.)
|
||||
*/
|
||||
export class MemoryBlobStore implements BlobStore {
|
||||
private slots = new Map<string, BlobSlotRecord>();
|
||||
private nextEtag = 0;
|
||||
|
||||
async get(slotId: string): Promise<BlobSlotRecord | null> {
|
||||
const r = this.slots.get(slotId);
|
||||
if (!r) return null;
|
||||
return {
|
||||
slotId: r.slotId,
|
||||
blob: new Uint8Array(r.blob),
|
||||
ownerPubkey: new Uint8Array(r.ownerPubkey),
|
||||
etag: r.etag,
|
||||
updatedAt: r.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async put(args: {
|
||||
slotId: string;
|
||||
blob: Uint8Array;
|
||||
ownerPubkey: Uint8Array;
|
||||
expectedEtag: number | '*' | undefined;
|
||||
now: number;
|
||||
}): Promise<PutBlobResult> {
|
||||
const existing = this.slots.get(args.slotId);
|
||||
|
||||
if (!existing) {
|
||||
// Empty slot. `ifMatch: '*'` per RFC 7232 still fails — there is
|
||||
// no entity to match. A numeric etag also fails (we have nothing
|
||||
// to compare against).
|
||||
if (args.expectedEtag !== undefined) {
|
||||
throw new PreconditionFailedError(
|
||||
`Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`,
|
||||
);
|
||||
}
|
||||
this.nextEtag = Math.max(this.nextEtag + 1, args.now);
|
||||
const etag = this.nextEtag;
|
||||
this.slots.set(args.slotId, {
|
||||
slotId: args.slotId,
|
||||
blob: new Uint8Array(args.blob),
|
||||
ownerPubkey: new Uint8Array(args.ownerPubkey),
|
||||
etag,
|
||||
updatedAt: args.now,
|
||||
});
|
||||
return { created: true, etag, updatedAt: args.now };
|
||||
}
|
||||
|
||||
// Slot exists. Pubkey check is the route layer's job — by the time
|
||||
// we're here the signature has already been verified against
|
||||
// `existing.ownerPubkey`.
|
||||
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.nextEtag = Math.max(this.nextEtag + 1, args.now);
|
||||
const etag = this.nextEtag;
|
||||
existing.blob = new Uint8Array(args.blob);
|
||||
existing.etag = etag;
|
||||
existing.updatedAt = args.now;
|
||||
return { created: false, etag, updatedAt: args.now };
|
||||
}
|
||||
|
||||
async delete(slotId: string): Promise<boolean> {
|
||||
return this.slots.delete(slotId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user