From 80c410f518b43479ec44034d377dd320d73853af Mon Sep 17 00:00:00 2001 From: Sterister Date: Sat, 9 May 2026 02:44:42 +0200 Subject: [PATCH] release(v4.9.0): relay-side encrypted blob primitive + SDK Profile namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 87 ++++++ bun.lock | 53 ++-- packages/shade-cli/package.json | 2 +- packages/shade-core/package.json | 2 +- packages/shade-core/src/errors.ts | 27 ++ packages/shade-crypto-web/package.json | 2 +- .../shade-crypto-web/src/ed25519-derive.ts | 18 ++ packages/shade-crypto-web/src/index.ts | 1 + packages/shade-dashboard/package.json | 2 +- packages/shade-files/package.json | 2 +- packages/shade-inbox-server/package.json | 2 +- .../shade-inbox-server/src/blob-routes.ts | 268 ++++++++++++++++ packages/shade-inbox-server/src/blob-store.ts | 86 +++++ packages/shade-inbox-server/src/index.ts | 50 ++- .../src/memory-blob-store.ts | 85 +++++ .../tests/blob-routes.test.ts | 295 ++++++++++++++++++ packages/shade-inbox/package.json | 2 +- packages/shade-inbox/src/blob-client.ts | 208 ++++++++++++ packages/shade-inbox/src/index.ts | 8 + packages/shade-key-transparency/package.json | 2 +- packages/shade-keychain/package.json | 2 +- packages/shade-observability/package.json | 2 +- packages/shade-observer/package.json | 2 +- packages/shade-proto/package.json | 2 +- packages/shade-recovery/package.json | 2 +- packages/shade-sdk/package.json | 5 +- packages/shade-sdk/src/index.ts | 18 ++ packages/shade-sdk/src/profile.ts | 210 +++++++++++++ packages/shade-sdk/tests/profile.test.ts | 218 +++++++++++++ packages/shade-server/package.json | 2 +- packages/shade-server/src/standalone.ts | 54 ++++ packages/shade-storage-encrypted/package.json | 2 +- .../shade-storage-encrypted/src/crypto.ts | 3 + .../shade-storage-encrypted/src/crypto/kdf.ts | 39 +++ packages/shade-storage-encrypted/src/index.ts | 3 + .../tests/blob-vectors.test.ts | 50 +++ packages/shade-storage-indexeddb/package.json | 2 +- packages/shade-storage-postgres/package.json | 2 +- .../src/ensure-tables.ts | 24 ++ packages/shade-storage-postgres/src/index.ts | 2 + .../src/postgres-blob-store.ts | 140 +++++++++ packages/shade-storage-sqlite/package.json | 2 +- packages/shade-storage-sqlite/src/index.ts | 1 + .../src/sqlite-blob-store.ts | 156 +++++++++ packages/shade-streams/package.json | 2 +- packages/shade-transfer/package.json | 2 +- packages/shade-transport-bridge/package.json | 2 +- packages/shade-transport-webrtc/package.json | 2 +- packages/shade-transport/package.json | 2 +- packages/shade-widgets/package.json | 2 +- test-vectors/blob-storage.json | 39 +++ 51 files changed, 2138 insertions(+), 58 deletions(-) create mode 100644 packages/shade-crypto-web/src/ed25519-derive.ts create mode 100644 packages/shade-inbox-server/src/blob-routes.ts create mode 100644 packages/shade-inbox-server/src/blob-store.ts create mode 100644 packages/shade-inbox-server/src/memory-blob-store.ts create mode 100644 packages/shade-inbox-server/tests/blob-routes.test.ts create mode 100644 packages/shade-inbox/src/blob-client.ts create mode 100644 packages/shade-sdk/src/profile.ts create mode 100644 packages/shade-sdk/tests/profile.test.ts create mode 100644 packages/shade-storage-encrypted/tests/blob-vectors.test.ts create mode 100644 packages/shade-storage-postgres/src/postgres-blob-store.ts create mode 100644 packages/shade-storage-sqlite/src/sqlite-blob-store.ts create mode 100644 test-vectors/blob-storage.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 36e79fb..58f0770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,93 @@ All notable changes to Shade are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.9.0] — 2026-05-09 — relay-side encrypted blob primitive + SDK `Profile` namespace + +Prism filed a feature request +(`encrypted-profile-storage-v4.9.md`) for relay-side encrypted profile +storage as the missing cryptographic primitive for Phase 2 of their +device-linking work: a brand new browser/device must be able to +locate a user's existing E2EE state from credentials alone — no QR, +no physical access to a paired device. + +Ships as a *generic* primitive: a deterministically-located, +AEAD-sealed blob keyed by a 32-byte slotId derived client-side via +HKDF from the user's master key. The relay sees opaque slotIds and +opaque ciphertext; it never decrypts and cannot link slots to users. +Compare-and-swap via a per-slot etag prevents two devices from +silently clobbering each other's writes when a user adds a new +paired peer concurrently from two existing devices. + +**Server (`@shade/inbox-server`)** + +- New `BlobStore` interface + `MemoryBlobStore` reference impl. Per-slot + layout: `(slotId, ownerPubkey, blob, etag, updatedAt)`. ETag is + monotonic per process, clamped against `Date.now()` so values are + unique and roughly time-ordered for ops. +- New `createBlobRoutes(store, crypto, options)` mounting + `GET / PUT / DELETE /v1/blob/:slotId`. SlotId is validated as + 64 lowercase hex chars (32 bytes); URL-bound into the signed payload + to prevent cross-slot signature replay. Owner pubkey is recorded + TOFU on the first PUT — subsequent writes verify against it; a + different key trying to overwrite an existing slot returns 401. +- CAS via `ifMatch`: omitted = create-only (409 on populated slot), + numeric etag = strict CAS (412 on mismatch), `'*'` = + unconditional overwrite when populated (412 on empty per RFC 7232). +- Default per-slot ceiling: 64 KiB. Sized for ~500 host entries in + Prism's `hosts[]` JSON form with plenty of headroom. +- `createInboxServer` now also mounts the blob primitive on the + same Hono app — pass `{ blobStore: null }` to opt out. + +**Storage backends** + +- `SqliteBlobStore` (`@shade/storage-sqlite`) — single-table + `shade_blob_slots` with WAL journal mode. Reads CAS state and + writes inside a transaction so concurrent CAS attempts can't both + observe the same etag. Volume: same path convention as the inbox + store; `SHADE_BLOB_DB_PATH` overrides (default `/data/shade-blob.db`). +- `PostgresBlobStore` (`@shade/storage-postgres`) — uses + `nextval('shade_blob_seq')` so etag ordering is strict across + multi-instance deployments. CAS path holds `FOR UPDATE` on the + read so the txn serializes against concurrent writers. New + `ensureBlobServerTables()` exposed for ops. + +**SDK (`@shade/sdk`)** + +- New `createProfileNamespace({ baseUrl, crypto, masterKey, app })` + high-level wrapper. Computes slotId, blobKey, signing seed + deterministically; AEAD-seals plaintext with `AAD = "shade-profile-aad-v1:"` + on every PUT (fresh random nonce); verifies AEAD on every GET. +- New low-level `BlobClient` (`@shade/inbox`) — caller-supplied + signing key; transports already-sealed ciphertext. +- New `ed25519PublicKeyFromSeed(seed)` (`@shade/crypto-web`) for + deterministic Ed25519 keypair derivation from a 32-byte seed. +- KDF helpers `deriveBlobSlotId / deriveBlobKey / deriveBlobSigningSeed` + exposed from `@shade/storage-encrypted/crypto` so apps that want a + custom flow (skip the AEAD wrapper, hit a non-Shade relay) don't + reimplement the info-string conventions. +- `app` namespace string mandatory — distinct apps under the same + master MUST pass different values (e.g. `prism-profile-v1`) so + they don't collide on the same slot. + +**Standalone server** + +- Boots a `BlobStore` mirroring the inbox-store selection chain: + `SHADE_BLOB_PG_URL` > `SHADE_BLOB_DB_PATH` > shared + `SHADE_PREKEY_PG_URL` > memory. `SHADE_DISABLE_BLOB=1` opts out. + Honors `SHADE_DISABLE_RATE_LIMIT` for single-tenant deployments. + +**Errors** + +- New `ConflictError` → `SHADE_CONFLICT` → HTTP 409. +- New `PreconditionFailedError` → `SHADE_PRECONDITION_FAILED` → HTTP 412. + +**Test vectors** + +- `test-vectors/blob-storage.json` — three (masterKey, app) cases + with expected slotId/blobKey/signingSeed/ownerPubkey. Lets Prism + (and future non-JS implementations) verify HKDF-info-string + interop without spinning up a relay. + ## [4.8.5] — 2026-05-08 — `Inbox.flushOnce`: kill the 15 s success-backoff + per-recipient parallel drain Prism filed a "typing-into-a-chatty-shell" UX FR pointing at diff --git a/bun.lock b/bun.lock index 4d7fec1..d89ff91 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ }, "packages/shade-cli": { "name": "@shade/cli", - "version": "4.4.0", + "version": "4.8.5", "bin": { "shade": "src/cli.ts", }, @@ -36,7 +36,7 @@ }, "packages/shade-core": { "name": "@shade/core", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/observability": "workspace:*", }, @@ -49,7 +49,7 @@ }, "packages/shade-crypto-web": { "name": "@shade/crypto-web", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", @@ -59,7 +59,7 @@ }, "packages/shade-dashboard": { "name": "@shade/dashboard", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/widgets": "workspace:*", "react": "^19.0.0", @@ -74,7 +74,7 @@ }, "packages/shade-files": { "name": "@shade/files", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -101,7 +101,7 @@ }, "packages/shade-inbox": { "name": "@shade/inbox", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/core": "workspace:*", "@shade/proto": "workspace:*", @@ -114,7 +114,7 @@ }, "packages/shade-inbox-server": { "name": "@shade/inbox-server", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/core": "workspace:*", "@shade/observability": "workspace:*", @@ -132,7 +132,7 @@ }, "packages/shade-key-transparency": { "name": "@shade/key-transparency", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@noble/hashes": "^2.0.1", "@shade/core": "workspace:*", @@ -144,11 +144,11 @@ }, "packages/shade-keychain": { "name": "@shade/keychain", - "version": "4.4.0", + "version": "4.8.5", }, "packages/shade-observability": { "name": "@shade/observability", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@noble/hashes": "^2.0.1", }, @@ -166,7 +166,7 @@ }, "packages/shade-observer": { "name": "@shade/observer", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/core": "workspace:*", "@shade/server": "workspace:*", @@ -178,14 +178,14 @@ }, "packages/shade-proto": { "name": "@shade/proto", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/core": "workspace:*", }, }, "packages/shade-recovery": { "name": "@shade/recovery", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -198,22 +198,25 @@ }, "packages/shade-sdk": { "name": "@shade/sdk", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", "@shade/files": "workspace:*", + "@shade/inbox": "workspace:*", "@shade/key-transparency": "workspace:*", "@shade/observability": "workspace:*", "@shade/observer": "workspace:*", "@shade/proto": "workspace:*", "@shade/server": "workspace:*", + "@shade/storage-encrypted": "workspace:*", "@shade/storage-sqlite": "workspace:*", "@shade/streams": "workspace:*", "@shade/transfer": "workspace:*", "@shade/transport": "workspace:*", }, "devDependencies": { + "@shade/inbox-server": "workspace:*", "@shade/transport-webrtc": "workspace:*", }, "peerDependencies": { @@ -225,7 +228,7 @@ }, "packages/shade-server": { "name": "@shade/server", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/core": "workspace:*", "@shade/inbox-server": "workspace:*", @@ -245,7 +248,7 @@ }, "packages/shade-storage-encrypted": { "name": "@shade/storage-encrypted", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@noble/hashes": "^2.0.1", "@shade/core": "workspace:*", @@ -267,7 +270,7 @@ }, "packages/shade-storage-indexeddb": { "name": "@shade/storage-indexeddb", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/core": "workspace:*", "idb": "^8.0.3", @@ -279,7 +282,7 @@ }, "packages/shade-storage-postgres": { "name": "@shade/storage-postgres", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/core": "workspace:*", "@shade/inbox-server": "workspace:*", @@ -294,7 +297,7 @@ }, "packages/shade-storage-sqlite": { "name": "@shade/storage-sqlite", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -304,7 +307,7 @@ }, "packages/shade-streams": { "name": "@shade/streams", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@noble/hashes": "^2.0.1", "@shade/core": "workspace:*", @@ -316,7 +319,7 @@ }, "packages/shade-transfer": { "name": "@shade/transfer", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -333,7 +336,7 @@ }, "packages/shade-transport": { "name": "@shade/transport", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -344,7 +347,7 @@ }, "packages/shade-transport-bridge": { "name": "@shade/transport-bridge", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/core": "workspace:*", "@shade/server": "workspace:*", @@ -366,7 +369,7 @@ }, "packages/shade-transport-webrtc": { "name": "@shade/transport-webrtc", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/core": "workspace:*", "@shade/streams": "workspace:*", @@ -375,7 +378,7 @@ }, "packages/shade-widgets": { "name": "@shade/widgets", - "version": "4.4.0", + "version": "4.8.5", "dependencies": { "@shade/recovery": "workspace:*", "@shade/sdk": "workspace:*", diff --git a/packages/shade-cli/package.json b/packages/shade-cli/package.json index 736cbe1..74fb845 100644 --- a/packages/shade-cli/package.json +++ b/packages/shade-cli/package.json @@ -1,6 +1,6 @@ { "name": "@shade/cli", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/cli.ts", "bin": { diff --git a/packages/shade-core/package.json b/packages/shade-core/package.json index 0a8c308..5a3865d 100644 --- a/packages/shade-core/package.json +++ b/packages/shade-core/package.json @@ -1,6 +1,6 @@ { "name": "@shade/core", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-core/src/errors.ts b/packages/shade-core/src/errors.ts index 87245e3..973c2e5 100644 --- a/packages/shade-core/src/errors.ts +++ b/packages/shade-core/src/errors.ts @@ -158,6 +158,30 @@ export class UnauthorizedError extends ShadeError { } } +/** + * 409 Conflict — caller wrote to a resource that already exists without + * supplying an If-Match precondition. V4.9: the encrypted blob primitive + * uses this to force read-then-write on already-occupied slots. + */ +export class ConflictError extends ShadeError { + constructor(message = 'Conflict') { + super('SHADE_CONFLICT', message); + this.name = 'ConflictError'; + } +} + +/** + * 412 Precondition Failed — caller supplied an If-Match etag that does + * not match the current state. V4.9: the encrypted blob primitive uses + * this to surface stale-CAS so clients can re-read, merge, and retry. + */ +export class PreconditionFailedError extends ShadeError { + constructor(message = 'Precondition failed') { + super('SHADE_PRECONDITION_FAILED', message); + this.name = 'PreconditionFailedError'; + } +} + // ─── Error → HTTP Status Mapping ──────────────────────────── /** @@ -180,7 +204,10 @@ export function errorToHttpStatus(error: unknown): number { return 400; case 'SHADE_REPLAY': case 'SHADE_DUPLICATE_MESSAGE': + case 'SHADE_CONFLICT': return 409; + case 'SHADE_PRECONDITION_FAILED': + return 412; case 'SHADE_RATE_LIMIT': return 429; case 'SHADE_TIMEOUT': diff --git a/packages/shade-crypto-web/package.json b/packages/shade-crypto-web/package.json index 9fd8ab8..c8b8615 100644 --- a/packages/shade-crypto-web/package.json +++ b/packages/shade-crypto-web/package.json @@ -1,6 +1,6 @@ { "name": "@shade/crypto-web", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-crypto-web/src/ed25519-derive.ts b/packages/shade-crypto-web/src/ed25519-derive.ts new file mode 100644 index 0000000..93427b2 --- /dev/null +++ b/packages/shade-crypto-web/src/ed25519-derive.ts @@ -0,0 +1,18 @@ +import { ed25519 } from '@noble/curves/ed25519.js'; + +/** + * Deterministically derive an Ed25519 public key from a 32-byte seed. + * + * In the @noble/curves convention the "private key" *is* the seed — + * `sign(seed, msg)` works directly, and `getPublicKey(seed)` recovers + * the matching public key. V4.9's encrypted-blob primitive uses this + * to mint a per-slot signing keypair from an HKDF output rooted at the + * user's master key, so the same credentials always reproduce the same + * keypair. + */ +export function ed25519PublicKeyFromSeed(seed: Uint8Array): Uint8Array { + if (seed.length !== 32) { + throw new Error(`Ed25519 seed must be 32 bytes, got ${seed.length}`); + } + return ed25519.getPublicKey(seed); +} diff --git a/packages/shade-crypto-web/src/index.ts b/packages/shade-crypto-web/src/index.ts index 7edcbe4..864dba5 100644 --- a/packages/shade-crypto-web/src/index.ts +++ b/packages/shade-crypto-web/src/index.ts @@ -1,5 +1,6 @@ export { SubtleCryptoProvider } from './provider.js'; export { MemoryStorage } from './memory-storage.js'; +export { ed25519PublicKeyFromSeed } from './ed25519-derive.js'; // ─── Web Workers crypto (V3.8) ──────────────────────────── export { diff --git a/packages/shade-dashboard/package.json b/packages/shade-dashboard/package.json index 2d1d57e..e6f99c3 100644 --- a/packages/shade-dashboard/package.json +++ b/packages/shade-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@shade/dashboard", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/shade-files/package.json b/packages/shade-files/package.json index b8f9962..34e3b86 100644 --- a/packages/shade-files/package.json +++ b/packages/shade-files/package.json @@ -1,6 +1,6 @@ { "name": "@shade/files", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox-server/package.json b/packages/shade-inbox-server/package.json index 3cb556e..46cb1ba 100644 --- a/packages/shade-inbox-server/package.json +++ b/packages/shade-inbox-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox-server", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox-server/src/blob-routes.ts b/packages/shade-inbox-server/src/blob-routes.ts new file mode 100644 index 0000000..3f75ae0 --- /dev/null +++ b/packages/shade-inbox-server/src/blob-routes.ts @@ -0,0 +1,268 @@ +import { Hono } from 'hono'; +import type { CryptoProvider } from '@shade/core'; +import { + errorToHttpStatus, + ShadeError, + ValidationError, + UnauthorizedError, + fromBase64, + toBase64, + constantTimeEqual, +} from '@shade/core'; +import { + verifyPayload, + RateLimiter, + MemoryRateLimitStore, + type RateLimitConfig, +} from '@shade/server'; +import { + ATTR_ERROR_CODE, + ATTR_HTTP_STATUS, + ATTR_ROUTE, + NOOP_HOOK, + type ObservabilityHook, +} from '@shade/observability'; +import type { BlobStore } from './blob-store.js'; + +/** + * Wire-level wrapper around the V4.9 BlobStore primitive. + * + * Endpoints: + * GET /v1/blob/:slotId → { blob, etag } | 404 + * PUT /v1/blob/:slotId → { etag, created } | 409 | 412 + * DELETE /v1/blob/:slotId → { ok } + * + * SlotId is 64 lowercase hex chars (the HKDF output, 32 bytes). Payloads + * are base64-encoded ciphertext; the relay never decrypts. Auth uses + * `signPayload` / `verifyPayload` (same canonical-JSON-and-Ed25519 + * scheme as the inbox routes), keyed off the per-slot pubkey stored + * TOFU on the first PUT. + * + * Quota: a single slot holds one blob. `MAX_BLOB_BYTES` (64 KiB) is + * sized for Prism's profile use-case (a few hundred host entries) with + * plenty of headroom; future apps can override via `BlobRoutesOptions`. + */ +const SLOT_ID_REGEX = /^[0-9a-f]{64}$/; +const MAX_META_BODY_SIZE = 64 * 1024; +/** Default per-slot blob ceiling. Sized for ~500 host entries in JSON form. */ +export const DEFAULT_MAX_BLOB_BYTES = 64 * 1024; + +const PUT_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 }; +const GET_LIMIT: RateLimitConfig = { capacity: 120, refillPerSecond: 2 }; +const DELETE_LIMIT: RateLimitConfig = { capacity: 30, refillPerSecond: 1 }; + +export interface BlobRoutesOptions { + disableRateLimit?: boolean; + observability?: ObservabilityHook; + /** Per-blob byte ceiling. Defaults to 64 KiB. */ + maxBlobBytes?: number; +} + +export function createBlobRoutes( + store: BlobStore, + crypto: CryptoProvider, + options: BlobRoutesOptions = {}, +): Hono { + const app = new Hono(); + const observability = options.observability ?? NOOP_HOOK; + const maxBlobBytes = options.maxBlobBytes ?? DEFAULT_MAX_BLOB_BYTES; + + app.use('*', async (c, next) => { + const route = c.req.routePath ?? c.req.path ?? ''; + const span = observability.startSpan('shade.blob.request', { + [ATTR_ROUTE]: route, + }); + try { + await next(); + span.setAttribute(ATTR_HTTP_STATUS, c.res.status); + span.setStatus(c.res.status >= 500 ? 'error' : 'ok'); + } catch (err) { + const code = + err instanceof ShadeError ? err.code ?? 'SHADE_ERROR' : 'SHADE_INTERNAL'; + span.setAttribute(ATTR_ERROR_CODE, code); + span.recordException(err); + span.setStatus('error', code); + throw err; + } finally { + span.end(); + } + }); + + const rlStore = new MemoryRateLimitStore(); + const putRL = new RateLimiter(rlStore, PUT_LIMIT); + const getRL = new RateLimiter(rlStore, GET_LIMIT); + const deleteRL = new RateLimiter(rlStore, DELETE_LIMIT); + const rateLimitEnabled = !options.disableRateLimit; + + const getClientIp = (c: any): string => + c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? + c.req.header('x-real-ip') ?? + 'unknown'; + + app.onError((err, c) => { + if (err instanceof ShadeError) { + const status = errorToHttpStatus(err); + const body: any = err.toJSON(); + if ((err as any).retryAfterSeconds) { + c.header('Retry-After', String((err as any).retryAfterSeconds)); + } + return c.json(body, status as any); + } + console.error('[Shade] Unhandled blob error:', err); + return c.json({ error: 'Internal server error' }, 500); + }); + + function validateSlotId(raw: string | undefined): string { + if (typeof raw !== 'string' || !SLOT_ID_REGEX.test(raw)) { + throw new ValidationError( + 'slotId must be 64 lowercase hex chars (32 bytes)', + 'slotId', + ); + } + return raw; + } + + // ─── GET ───────────────────────────────────────────────────── + // Unauthenticated. SlotId is itself a 256-bit secret derived from the + // master key — knowing it implies you derived the master, which is + // equivalent to holding the credentials. The blob is AEAD-sealed, so + // a relay-side leak of slotId still cannot decrypt the contents. + app.get('/v1/blob/:slotId', async (c) => { + const slotId = validateSlotId(c.req.param('slotId')); + if (rateLimitEnabled) await getRL.consume(`blob-get:${getClientIp(c)}`); + + const row = await store.get(slotId); + if (!row) { + return c.json({ error: 'Slot not found', code: 'SHADE_NOT_FOUND' }, 404); + } + return c.json({ + blob: toBase64(row.blob), + etag: String(row.etag), + updatedAt: row.updatedAt, + }); + }); + + // ─── PUT ───────────────────────────────────────────────────── + // Body format: + // { + // ownerPubkey: b64, // Ed25519 pubkey deterministically + // // derived from the master via HKDF. + // blob: b64, + // ifMatch?: string, // "" | "*" | undefined + // signedAt: number, + // signature: b64 // over the canonical body sans signature + // } + // + // First write to a slot is TOFU: we record `ownerPubkey` and require + // any future write to verify against it. A different key trying to + // overwrite an existing slot is rejected with UnauthorizedError. + app.put('/v1/blob/:slotId', async (c) => { + const slotId = validateSlotId(c.req.param('slotId')); + if (rateLimitEnabled) await putRL.consume(`blob-put:${getClientIp(c)}`); + + const rawBody = await c.req.text(); + const hardLimit = Math.ceil(maxBlobBytes * 1.4) + MAX_META_BODY_SIZE; + if (rawBody.length > hardLimit) { + throw new ValidationError(`Request body too large`); + } + const body = JSON.parse(rawBody); + const { ownerPubkey, blob, ifMatch } = body; + + if (typeof ownerPubkey !== 'string') { + throw new ValidationError('Missing ownerPubkey', 'ownerPubkey'); + } + if (typeof blob !== 'string') { + throw new ValidationError('Missing blob', 'blob'); + } + const claimedKey = fromBase64(ownerPubkey); + if (claimedKey.length !== 32) { + throw new ValidationError('ownerPubkey must be 32 bytes (Ed25519)', 'ownerPubkey'); + } + const blobBytes = fromBase64(blob); + if (blobBytes.length === 0) { + throw new ValidationError('blob is empty', 'blob'); + } + if (blobBytes.length > maxBlobBytes) { + throw new ValidationError( + `blob exceeds maxBlobBytes (${blobBytes.length} > ${maxBlobBytes})`, + 'blob', + ); + } + + let expectedEtag: number | '*' | undefined; + if (ifMatch === undefined) { + expectedEtag = undefined; + } else if (typeof ifMatch !== 'string') { + throw new ValidationError('ifMatch must be a string when present', 'ifMatch'); + } else if (ifMatch === '*') { + expectedEtag = '*'; + } else { + const n = Number(ifMatch); + if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) { + throw new ValidationError('ifMatch must be a non-negative integer or "*"', 'ifMatch'); + } + expectedEtag = n; + } + + // Existing slot: caller must sign with the original owner key. Use + // the stored pubkey for verification. The body's `ownerPubkey` is + // bound by the signature too, so an attacker cannot trick us into + // verifying with a key they control — the canonicalization includes + // every field but `signature`. + const existing = await store.get(slotId); + const verifyKey = existing ? existing.ownerPubkey : claimedKey; + + // Bind slotId into the signed payload so a signature for slot A + // can't be replayed against slot B (the URL is otherwise outside + // the signed bytes). + await verifyPayload(crypto, verifyKey, { ...body, slotId }); + + if (existing && !constantTimeEqual(existing.ownerPubkey, claimedKey)) { + throw new UnauthorizedError( + `Slot ${slotId} is owned by a different signing key`, + ); + } + + const result = await store.put({ + slotId, + blob: blobBytes, + ownerPubkey: claimedKey, + expectedEtag, + now: Date.now(), + }); + + return c.json({ + ok: true, + created: result.created, + etag: String(result.etag), + updatedAt: result.updatedAt, + }); + }); + + // ─── DELETE ────────────────────────────────────────────────── + // Body format: { signedAt, signature }. Signed by the owner pubkey + // recorded on the first PUT. After deletion, the slot is fully gone — + // the next PUT TOFU-claims it again (potentially under a different + // signing key, e.g. after a rotation). + app.delete('/v1/blob/:slotId', async (c) => { + const slotId = validateSlotId(c.req.param('slotId')); + if (rateLimitEnabled) await deleteRL.consume(`blob-delete:${getClientIp(c)}`); + + const existing = await store.get(slotId); + if (!existing) { + return c.json({ error: 'Slot not found', code: 'SHADE_NOT_FOUND' }, 404); + } + + const rawBody = await c.req.text(); + if (rawBody.length > MAX_META_BODY_SIZE) { + throw new ValidationError(`Request body too large`); + } + const body = JSON.parse(rawBody); + await verifyPayload(crypto, existing.ownerPubkey, { ...body, slotId }); + + const removed = await store.delete(slotId); + return c.json({ ok: removed }); + }); + + return app; +} diff --git a/packages/shade-inbox-server/src/blob-store.ts b/packages/shade-inbox-server/src/blob-store.ts new file mode 100644 index 0000000..5ac6bfa --- /dev/null +++ b/packages/shade-inbox-server/src/blob-store.ts @@ -0,0 +1,86 @@ +/** + * BlobStore — server-side storage interface for the V4.9 encrypted-blob + * primitive. A "slot" is a single AEAD-sealed blob keyed by a + * deterministic 32-byte slotId derived client-side via HKDF from a + * master key. The relay never sees plaintext, never holds private keys, + * and never decrypts. + * + * Auth model (TOFU per slot, mirrors the inbox-owner pattern): + * - First PUT to an empty slot stores the caller's Ed25519 signing + * pubkey alongside the blob. Subsequent writes must produce a valid + * signature verifiable by that pubkey. + * - GET is unauthenticated — slotId is itself a 256-bit secret derived + * from the master key, so knowing it implies you derived the master. + * - DELETE clears the blob AND the owner pubkey, allowing future TOFU + * re-claim by a fresh signing key derived from the same master (e.g. + * after a rotation). + * + * CAS / etag semantics: + * - Every successful PUT bumps a per-slot monotonic etag (returned to + * the caller as a string). + * - A stale `ifMatch` triggers `PreconditionFailedError` (HTTP 412). + * - `ifMatch === undefined` against a populated slot triggers + * `ConflictError` (HTTP 409) — clients must read-then-write. + * - `ifMatch === '*'` against a populated slot is unconditional + * overwrite (escape hatch). Against an empty slot it's still 412 + * per RFC 7232 (no entity to match). + */ +export interface BlobSlotRecord { + /** Lower-hex 64-char slotId (32 bytes). */ + slotId: string; + /** Raw AEAD ciphertext (bytes). The relay never decrypts. */ + blob: Uint8Array; + /** Owner Ed25519 signing pubkey, established TOFU on the first PUT. */ + ownerPubkey: Uint8Array; + /** Monotonic per-slot version. Used as the ETag on the wire. */ + etag: number; + /** Wall-clock ms of the last successful write. */ + updatedAt: number; +} + +/** Returned to the route layer after a successful PUT. */ +export interface PutBlobResult { + /** Whether the slot was created (true) or updated in place (false). */ + created: boolean; + /** New etag after the write. */ + etag: number; + /** Wall-clock ms of the write. */ + updatedAt: number; +} + +export interface BlobStore { + /** Read a slot, or null if it has never been written (or was deleted). */ + get(slotId: string): Promise; + + /** + * Create or update a slot. + * + * Implementations MUST treat `(slotId, ownerPubkey)` atomically: the + * route layer has already verified the signature, but the store is the + * authority on whether the slot exists and what etag it has. Callers + * pass the verified `ownerPubkey` (used on first-write to record the + * owner; ignored on subsequent writes — the existing pubkey is the + * source of truth for who's allowed to write). + * + * `expectedEtag` semantics (mirror the wire-level If-Match): + * - `undefined` : create-only. Slot must be empty. + * - `` : compare-and-swap. Must equal the current etag. + * - `'*'` : unconditional overwrite. Slot must already exist. + * + * On precondition mismatch the store throws `PreconditionFailedError` + * (stale etag) or `ConflictError` (slot exists, no ifMatch). + */ + put(args: { + slotId: string; + blob: Uint8Array; + ownerPubkey: Uint8Array; + expectedEtag: number | '*' | undefined; + now: number; + }): Promise; + + /** + * Delete a slot. Authentication has already been checked by the route + * layer. Returns true if a row was removed (i.e. the slot existed). + */ + delete(slotId: string): Promise; +} diff --git a/packages/shade-inbox-server/src/index.ts b/packages/shade-inbox-server/src/index.ts index ebdb764..8f6e1f3 100644 --- a/packages/shade-inbox-server/src/index.ts +++ b/packages/shade-inbox-server/src/index.ts @@ -1,9 +1,12 @@ -import type { Hono } from 'hono'; +import { Hono } from 'hono'; import type { CryptoProvider } from '@shade/core'; import { createInboxRoutes, type InboxRoutesOptions } from './routes.js'; import { MemoryInboxStore } from './memory-store.js'; import type { InboxStore } from './store.js'; import { InboxServerEvents } from './events.js'; +import { createBlobRoutes, type BlobRoutesOptions } from './blob-routes.js'; +import { MemoryBlobStore } from './memory-blob-store.js'; +import type { BlobStore } from './blob-store.js'; export { createInboxRoutes } from './routes.js'; export type { InboxRoutesOptions } from './routes.js'; @@ -36,6 +39,10 @@ export { PresenceTracker } from './presence.js'; export type { TrackedBridgeKind } from './presence.js'; export { BridgeDeliveryLog } from './bridge-delivery-log.js'; export type { BridgeDeliveryLogOptions } from './bridge-delivery-log.js'; +export { createBlobRoutes, DEFAULT_MAX_BLOB_BYTES } from './blob-routes.js'; +export type { BlobRoutesOptions } from './blob-routes.js'; +export { MemoryBlobStore } from './memory-blob-store.js'; +export type { BlobStore, BlobSlotRecord, PutBlobResult } from './blob-store.js'; /** * Create a standalone Shade Inbox Server. @@ -48,12 +55,21 @@ export type { BridgeDeliveryLogOptions } from './bridge-delivery-log.js'; * const app = new Hono(); * app.route('/', createInboxServer({ crypto })); */ -export function createInboxServer(options: { - crypto: CryptoProvider; - store?: InboxStore; - disableRateLimit?: boolean; - events?: InboxServerEvents; -} & Pick): Hono { +export function createInboxServer( + options: { + crypto: CryptoProvider; + store?: InboxStore; + disableRateLimit?: boolean; + events?: InboxServerEvents; + /** + * V4.9 — when supplied, mounts the encrypted-blob primitive + * (`/v1/blob/`) on the same Hono app. Pass `null` to + * explicitly opt out; omit to default to a `MemoryBlobStore`. + */ + blobStore?: BlobStore | null; + blobOptions?: Pick; + } & Pick, +): Hono { const store = options.store ?? new MemoryInboxStore(); const routesOptions: InboxRoutesOptions = {}; if (options.disableRateLimit !== undefined) routesOptions.disableRateLimit = options.disableRateLimit; @@ -61,5 +77,23 @@ export function createInboxServer(options: { if (options.observability !== undefined) routesOptions.observability = options.observability; if (options.quota !== undefined) routesOptions.quota = options.quota; if (options.bridgeDeliveryLog !== undefined) routesOptions.bridgeDeliveryLog = options.bridgeDeliveryLog; - return createInboxRoutes(store, options.crypto, routesOptions); + + const inboxApp = createInboxRoutes(store, options.crypto, routesOptions); + + // Compose with the blob primitive unless explicitly disabled. The + // blob routes share the same Hono app so a single port serves both. + if (options.blobStore === null) return inboxApp; + const blobStore = options.blobStore ?? new MemoryBlobStore(); + const blobRoutesOptions: BlobRoutesOptions = {}; + if (options.disableRateLimit !== undefined) blobRoutesOptions.disableRateLimit = options.disableRateLimit; + if (options.observability !== undefined) blobRoutesOptions.observability = options.observability; + if (options.blobOptions?.maxBlobBytes !== undefined) { + blobRoutesOptions.maxBlobBytes = options.blobOptions.maxBlobBytes; + } + const blobApp = createBlobRoutes(blobStore, options.crypto, blobRoutesOptions); + + const composed = new Hono(); + composed.route('/', inboxApp); + composed.route('/', blobApp); + return composed; } diff --git a/packages/shade-inbox-server/src/memory-blob-store.ts b/packages/shade-inbox-server/src/memory-blob-store.ts new file mode 100644 index 0000000..f2fb891 --- /dev/null +++ b/packages/shade-inbox-server/src/memory-blob-store.ts @@ -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(); + private nextEtag = 0; + + async get(slotId: string): Promise { + 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 { + 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 { + return this.slots.delete(slotId); + } +} diff --git a/packages/shade-inbox-server/tests/blob-routes.test.ts b/packages/shade-inbox-server/tests/blob-routes.test.ts new file mode 100644 index 0000000..111bdff --- /dev/null +++ b/packages/shade-inbox-server/tests/blob-routes.test.ts @@ -0,0 +1,295 @@ +import { describe, test, expect, beforeEach } from 'bun:test'; +import { Hono } from 'hono'; +import { + createBlobRoutes, + MemoryBlobStore, + type BlobStore, +} from '../src/index.js'; +import { signPayload } from '@shade/server'; +import { SubtleCryptoProvider, ed25519PublicKeyFromSeed } from '@shade/crypto-web'; +import { toBase64, fromBase64 } from '@shade/core'; + +const crypto = new SubtleCryptoProvider(); + +function randBytes(n: number): Uint8Array { + const buf = new Uint8Array(n); + globalThis.crypto.getRandomValues(buf); + return buf; +} + +function hex(bytes: Uint8Array): string { + let s = ''; + for (const b of bytes) s += b.toString(16).padStart(2, '0'); + return s; +} + +describe('Shade Blob Routes (V4.9)', () => { + let store: BlobStore; + let app: Hono; + + beforeEach(() => { + store = new MemoryBlobStore(); + app = createBlobRoutes(store, crypto, { disableRateLimit: true }); + }); + + async function makeOwner() { + const seed = randBytes(32); + const pubkey = ed25519PublicKeyFromSeed(seed); + return { seed, pubkey }; + } + + function makeSlotId(): string { + return hex(randBytes(32)); + } + + async function signedPut(args: { + slotId: string; + blob: Uint8Array; + seed: Uint8Array; + pubkey: Uint8Array; + ifMatch?: string; + }) { + const payload: Record = { + ownerPubkey: toBase64(args.pubkey), + blob: toBase64(args.blob), + slotId: args.slotId, + }; + if (args.ifMatch !== undefined) payload.ifMatch = args.ifMatch; + const signed = await signPayload(crypto, args.seed, payload); + const { slotId: _omit, ...wire } = signed as Record; + return app.request(`/v1/blob/${args.slotId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(wire), + }); + } + + async function signedDelete(args: { + slotId: string; + seed: Uint8Array; + }) { + const signed = await signPayload(crypto, args.seed, { + slotId: args.slotId, + }); + const { slotId: _omit, ...wire } = signed as Record; + return app.request(`/v1/blob/${args.slotId}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(wire), + }); + } + + // ─── GET ───────────────────────────────────────────────────── + + test('GET on missing slot returns 404', async () => { + const slotId = makeSlotId(); + const res = await app.request(`/v1/blob/${slotId}`); + expect(res.status).toBe(404); + }); + + test('GET requires lowercase 64-hex slotId', async () => { + const res = await app.request('/v1/blob/notahex'); + expect(res.status).toBe(400); + const res2 = await app.request(`/v1/blob/${'A'.repeat(64)}`); + expect(res2.status).toBe(400); + }); + + // ─── PUT (TOFU) ────────────────────────────────────────────── + + test('first PUT creates slot and returns etag', async () => { + const slotId = makeSlotId(); + const owner = await makeOwner(); + const blob = randBytes(128); + const res = await signedPut({ slotId, blob, ...owner }); + expect(res.status).toBe(200); + const json = (await res.json()) as { created: boolean; etag: string }; + expect(json.created).toBe(true); + expect(typeof json.etag).toBe('string'); + + const got = await app.request(`/v1/blob/${slotId}`); + expect(got.status).toBe(200); + const back = (await got.json()) as { blob: string; etag: string }; + expect(fromBase64(back.blob)).toEqual(blob); + expect(back.etag).toBe(json.etag); + }); + + test('PUT without ifMatch on populated slot returns 409', async () => { + const slotId = makeSlotId(); + const owner = await makeOwner(); + await signedPut({ slotId, blob: randBytes(64), ...owner }); + const res = await signedPut({ slotId, blob: randBytes(64), ...owner }); + expect(res.status).toBe(409); + const json = (await res.json()) as { code: string }; + expect(json.code).toBe('SHADE_CONFLICT'); + }); + + test('PUT with stale ifMatch returns 412', async () => { + const slotId = makeSlotId(); + const owner = await makeOwner(); + const r1 = await signedPut({ slotId, blob: randBytes(64), ...owner }); + const j1 = (await r1.json()) as { etag: string }; + // Use an etag we know does not match. + const stale = String(Number(j1.etag) - 999); + const res = await signedPut({ + slotId, + blob: randBytes(64), + ...owner, + ifMatch: stale, + }); + expect(res.status).toBe(412); + const json = (await res.json()) as { code: string }; + expect(json.code).toBe('SHADE_PRECONDITION_FAILED'); + }); + + test('PUT with matching ifMatch updates and bumps etag', async () => { + const slotId = makeSlotId(); + const owner = await makeOwner(); + const r1 = await signedPut({ slotId, blob: randBytes(64), ...owner }); + const j1 = (await r1.json()) as { etag: string }; + const r2 = await signedPut({ + slotId, + blob: randBytes(64), + ...owner, + ifMatch: j1.etag, + }); + expect(r2.status).toBe(200); + const j2 = (await r2.json()) as { created: boolean; etag: string }; + expect(j2.created).toBe(false); + expect(Number(j2.etag)).toBeGreaterThan(Number(j1.etag)); + }); + + test('PUT with ifMatch="*" unconditionally overwrites existing slot', async () => { + const slotId = makeSlotId(); + const owner = await makeOwner(); + await signedPut({ slotId, blob: randBytes(64), ...owner }); + const res = await signedPut({ + slotId, + blob: randBytes(64), + ...owner, + ifMatch: '*', + }); + expect(res.status).toBe(200); + }); + + test('PUT with ifMatch="*" on empty slot returns 412', async () => { + const slotId = makeSlotId(); + const owner = await makeOwner(); + const res = await signedPut({ + slotId, + blob: randBytes(64), + ...owner, + ifMatch: '*', + }); + expect(res.status).toBe(412); + }); + + test('PUT by a different owner key on existing slot is rejected', async () => { + const slotId = makeSlotId(); + const ownerA = await makeOwner(); + await signedPut({ slotId, blob: randBytes(64), ...ownerA }); + + const ownerB = await makeOwner(); + const res = await signedPut({ + slotId, + blob: randBytes(64), + ...ownerB, + ifMatch: '*', + }); + expect(res.status).toBe(401); + }); + + test('PUT with bad signature is rejected', async () => { + const slotId = makeSlotId(); + const owner = await makeOwner(); + // Sign the payload, then mutate the blob bytes — signature no + // longer matches the canonicalized body. + const blob = randBytes(64); + const payload = { + ownerPubkey: toBase64(owner.pubkey), + blob: toBase64(blob), + slotId, + }; + const signed = await signPayload(crypto, owner.seed, payload); + (signed as any).blob = toBase64(randBytes(64)); + const { slotId: _omit, ...wire } = signed as Record; + const res = await app.request(`/v1/blob/${slotId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(wire), + }); + expect(res.status).toBe(401); + }); + + test('PUT rejects empty blob and oversized blob', async () => { + const slotId = makeSlotId(); + const owner = await makeOwner(); + const empty = await signedPut({ slotId, blob: new Uint8Array(0), ...owner }); + expect(empty.status).toBe(400); + const tooBig = await signedPut({ + slotId, + blob: randBytes(70 * 1024), + ...owner, + }); + expect(tooBig.status).toBe(400); + }); + + // ─── DELETE ────────────────────────────────────────────────── + + test('DELETE clears slot and lets a fresh key TOFU re-claim', async () => { + const slotId = makeSlotId(); + const ownerA = await makeOwner(); + await signedPut({ slotId, blob: randBytes(64), ...ownerA }); + + const del = await signedDelete({ slotId, seed: ownerA.seed }); + expect(del.status).toBe(200); + + // Slot is gone. + const gone = await app.request(`/v1/blob/${slotId}`); + expect(gone.status).toBe(404); + + // A fresh owner can now claim it. + const ownerB = await makeOwner(); + const claim = await signedPut({ slotId, blob: randBytes(64), ...ownerB }); + expect(claim.status).toBe(200); + }); + + test('DELETE by a different key is rejected', async () => { + const slotId = makeSlotId(); + const ownerA = await makeOwner(); + await signedPut({ slotId, blob: randBytes(64), ...ownerA }); + + const ownerB = await makeOwner(); + const res = await signedDelete({ slotId, seed: ownerB.seed }); + expect(res.status).toBe(401); + }); + + test('DELETE on missing slot returns 404', async () => { + const slotId = makeSlotId(); + const owner = await makeOwner(); + const res = await signedDelete({ slotId, seed: owner.seed }); + expect(res.status).toBe(404); + }); + + // ─── Cross-slot replay ─────────────────────────────────────── + + test('PUT signed for slot A is rejected against slot B', async () => { + const slotA = makeSlotId(); + const slotB = makeSlotId(); + const owner = await makeOwner(); + const blob = randBytes(64); + // Sign for slotA, send to slotB (URL). + const payload = { + ownerPubkey: toBase64(owner.pubkey), + blob: toBase64(blob), + slotId: slotA, + }; + const signed = await signPayload(crypto, owner.seed, payload); + const { slotId: _omit, ...wire } = signed as Record; + const res = await app.request(`/v1/blob/${slotB}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(wire), + }); + expect(res.status).toBe(401); + }); +}); diff --git a/packages/shade-inbox/package.json b/packages/shade-inbox/package.json index ed70092..9bbf7b4 100644 --- a/packages/shade-inbox/package.json +++ b/packages/shade-inbox/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox/src/blob-client.ts b/packages/shade-inbox/src/blob-client.ts new file mode 100644 index 0000000..646e567 --- /dev/null +++ b/packages/shade-inbox/src/blob-client.ts @@ -0,0 +1,208 @@ +import type { CryptoProvider } from '@shade/core'; +import { + NetworkError, + toBase64, + fromBase64, + ShadeError, + ValidationError, +} from '@shade/core'; +import { signPayload } from '@shade/server'; + +/** + * Low-level HTTP client for the V4.9 encrypted-blob primitive + * (`/v1/blob/`). Stateless and reusable; higher-level wrappers + * (e.g. `Profile` in `@shade/sdk`) compose this client. + * + * The client doesn't care what the blob bytes mean — it just transports + * them. Callers are responsible for AEAD-sealing/opening, deriving the + * slotId from the master key, and managing the signing key. + */ +export interface BlobClientOptions { + baseUrl: string; + crypto: CryptoProvider; + /** Optional fetch override (defaults to globalThis.fetch). */ + fetch?: typeof fetch; +} + +export interface BlobGetResult { + blob: Uint8Array; + /** ETag string — pass back as `ifMatch` to do a CAS update. */ + etag: string; + updatedAt: number; +} + +export interface BlobPutResult { + /** True if this PUT created the slot, false if it updated an existing one. */ + created: boolean; + /** New ETag after the write. */ + etag: string; + updatedAt: number; +} + +export class BlobClient { + private readonly fetchImpl: typeof fetch; + + constructor(private readonly options: BlobClientOptions) { + const f = options.fetch ?? globalThis.fetch; + this.fetchImpl = f.bind(globalThis); + } + + /** + * Read a slot. Returns null if no blob has ever been written there + * (or if it was DELETE'd). GET is unauthenticated — see the + * `BlobStore` JSDoc for the threat-model rationale. + */ + async get(slotIdHex: string): Promise { + validateSlotIdHex(slotIdHex); + const url = joinUrl(this.options.baseUrl, `/v1/blob/${slotIdHex}`); + let res: Response; + try { + res = await this.fetchImpl(url, { method: 'GET' }); + } catch (err) { + throw new NetworkError(`Blob GET failed: ${(err as Error).message}`); + } + if (res.status === 404) return null; + const text = await res.text(); + let json: any; + try { + json = text.length > 0 ? JSON.parse(text) : {}; + } catch { + throw new NetworkError(`Blob response not JSON: ${text.slice(0, 200)}`, res.status); + } + if (!res.ok) { + throw new ShadeError( + String(json.code ?? 'SHADE_NETWORK'), + String(json.message ?? text), + ); + } + return { + blob: fromBase64(String(json.blob)), + etag: String(json.etag), + updatedAt: Number(json.updatedAt), + }; + } + + /** + * Create or update a slot. + * + * `ifMatch` semantics: + * - `undefined`: create-only. Slot must be empty (else 409). + * - ``: compare-and-swap. Must match (else 412). + * - `'*'`: unconditional overwrite. Slot must already exist (else 412). + */ + async put(args: { + slotIdHex: string; + blob: Uint8Array; + /** 32-byte Ed25519 seed (== `signingPrivateKey`). */ + signingPrivateKey: Uint8Array; + /** Pubkey paired to `signingPrivateKey`. */ + ownerPubkey: Uint8Array; + ifMatch?: string; + }): Promise { + validateSlotIdHex(args.slotIdHex); + if (args.blob.length === 0) { + throw new ValidationError('Empty blob'); + } + if (args.ownerPubkey.length !== 32) { + throw new ValidationError('ownerPubkey must be 32 bytes (Ed25519)'); + } + + const payload: Record = { + ownerPubkey: toBase64(args.ownerPubkey), + blob: toBase64(args.blob), + slotId: args.slotIdHex, + }; + if (args.ifMatch !== undefined) payload.ifMatch = args.ifMatch; + + const signed = await signPayload( + this.options.crypto, + args.signingPrivateKey, + payload, + ); + // `slotId` was used for the signature canonicalization to bind it + // into the payload; the server rebuilds the same canonical form + // by mixing the URL slotId back in. Strip it from the wire body + // so we don't send it twice (URL is the path param). + const { slotId: _omit, ...wireBody } = signed as Record; + + const url = joinUrl(this.options.baseUrl, `/v1/blob/${args.slotIdHex}`); + const json = await this.requestJson('PUT', url, wireBody); + return { + created: Boolean(json.created), + etag: String(json.etag), + updatedAt: Number(json.updatedAt), + }; + } + + /** + * Delete a slot — the next PUT TOFU-claims it again, possibly under + * a fresh signing key (e.g. after a rotation). Used by the "forget + * everything" path. + */ + async delete(args: { + slotIdHex: string; + signingPrivateKey: Uint8Array; + }): Promise { + validateSlotIdHex(args.slotIdHex); + const signed = await signPayload(this.options.crypto, args.signingPrivateKey, { + slotId: args.slotIdHex, + }); + const { slotId: _omit, ...wireBody } = signed as Record; + const url = joinUrl(this.options.baseUrl, `/v1/blob/${args.slotIdHex}`); + const json = await this.requestJson('DELETE', url, wireBody); + return Boolean(json.ok); + } + + // ─── HTTP plumbing ────────────────────────────────────────── + + private async requestJson(method: string, url: string, body: unknown): Promise { + let res: Response; + try { + res = await this.fetchImpl(url, { + method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch (err) { + throw new NetworkError(`Blob request failed: ${(err as Error).message}`); + } + const text = await res.text(); + let json: any; + try { + json = text.length > 0 ? JSON.parse(text) : {}; + } catch { + throw new NetworkError(`Blob response not JSON: ${text.slice(0, 200)}`, res.status); + } + if (!res.ok) { + throw new ShadeError( + String(json.code ?? 'SHADE_NETWORK'), + String(json.message ?? text), + ); + } + return json; + } +} + +function validateSlotIdHex(s: string): void { + if (!/^[0-9a-f]{64}$/.test(s)) { + throw new ValidationError('slotIdHex must be 64 lowercase hex chars (32 bytes)'); + } +} + +function joinUrl(base: string, path: string): string { + if (base.endsWith('/') && path.startsWith('/')) return base + path.slice(1); + if (!base.endsWith('/') && !path.startsWith('/')) return base + '/' + path; + return base + path; +} + +/** Convert a 32-byte slotId Uint8Array into the lowercase-hex wire form. */ +export function slotIdToHex(slotId: Uint8Array): string { + if (slotId.length !== 32) { + throw new ValidationError('slotId must be 32 bytes'); + } + let s = ''; + for (let i = 0; i < slotId.length; i++) { + s += slotId[i]!.toString(16).padStart(2, '0'); + } + return s; +} diff --git a/packages/shade-inbox/src/index.ts b/packages/shade-inbox/src/index.ts index 1263894..da4b5b5 100644 --- a/packages/shade-inbox/src/index.ts +++ b/packages/shade-inbox/src/index.ts @@ -43,3 +43,11 @@ export type { } from './events.js'; export { computeMsgId } from './msg-id.js'; + +// V4.9 — encrypted-blob primitive (`/v1/blob/`). +export { BlobClient, slotIdToHex } from './blob-client.js'; +export type { + BlobClientOptions, + BlobGetResult, + BlobPutResult, +} from './blob-client.js'; diff --git a/packages/shade-key-transparency/package.json b/packages/shade-key-transparency/package.json index 4ce261a..1d2a927 100644 --- a/packages/shade-key-transparency/package.json +++ b/packages/shade-key-transparency/package.json @@ -1,6 +1,6 @@ { "name": "@shade/key-transparency", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-keychain/package.json b/packages/shade-keychain/package.json index 78c5750..4b29a24 100644 --- a/packages/shade-keychain/package.json +++ b/packages/shade-keychain/package.json @@ -1,6 +1,6 @@ { "name": "@shade/keychain", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observability/package.json b/packages/shade-observability/package.json index 57faca0..c4bc624 100644 --- a/packages/shade-observability/package.json +++ b/packages/shade-observability/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observability", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observer/package.json b/packages/shade-observer/package.json index 15d1b4d..637934e 100644 --- a/packages/shade-observer/package.json +++ b/packages/shade-observer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observer", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-proto/package.json b/packages/shade-proto/package.json index 872cdf2..3d23ed5 100644 --- a/packages/shade-proto/package.json +++ b/packages/shade-proto/package.json @@ -1,6 +1,6 @@ { "name": "@shade/proto", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-recovery/package.json b/packages/shade-recovery/package.json index 8faa4f4..034f62d 100644 --- a/packages/shade-recovery/package.json +++ b/packages/shade-recovery/package.json @@ -1,6 +1,6 @@ { "name": "@shade/recovery", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-sdk/package.json b/packages/shade-sdk/package.json index e5bcfb7..aafe49a 100644 --- a/packages/shade-sdk/package.json +++ b/packages/shade-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@shade/sdk", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", @@ -8,11 +8,13 @@ "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", "@shade/files": "workspace:*", + "@shade/inbox": "workspace:*", "@shade/key-transparency": "workspace:*", "@shade/observability": "workspace:*", "@shade/observer": "workspace:*", "@shade/proto": "workspace:*", "@shade/server": "workspace:*", + "@shade/storage-encrypted": "workspace:*", "@shade/storage-sqlite": "workspace:*", "@shade/streams": "workspace:*", "@shade/transfer": "workspace:*", @@ -27,6 +29,7 @@ } }, "devDependencies": { + "@shade/inbox-server": "workspace:*", "@shade/transport-webrtc": "workspace:*" } } diff --git a/packages/shade-sdk/src/index.ts b/packages/shade-sdk/src/index.ts index a61ad2c..926a373 100644 --- a/packages/shade-sdk/src/index.ts +++ b/packages/shade-sdk/src/index.ts @@ -97,6 +97,24 @@ export { mainStreamIdForThumbnail, } from '@shade/streams'; +// ─── V4.9 — relay-side encrypted profile storage ────────── +export { + createProfileNamespace, + profilePlaintextToString, + deriveBlobSlotId, + deriveBlobKey, + deriveBlobSigningSeed, + ed25519PublicKeyFromSeed, + slotIdToHex, +} from './profile.js'; +export type { + ProfileNamespace, + ProfileNamespaceOptions, + ProfileGetResult, + ProfilePutOptions, + ProfilePutResult, +} from './profile.js'; + // ─── Web Workers crypto (V3.8) ───────────────────────────── export { createWorkerCryptoProvider, diff --git a/packages/shade-sdk/src/profile.ts b/packages/shade-sdk/src/profile.ts new file mode 100644 index 0000000..2e36ee2 --- /dev/null +++ b/packages/shade-sdk/src/profile.ts @@ -0,0 +1,210 @@ +import type { CryptoProvider } from '@shade/core'; +import { ValidationError } from '@shade/core'; +import { + deriveBlobSlotId, + deriveBlobKey, + deriveBlobSigningSeed, + aeadSeal, + aeadOpen, +} from '@shade/storage-encrypted/crypto'; +import { ed25519PublicKeyFromSeed } from '@shade/crypto-web'; +import { BlobClient, slotIdToHex } from '@shade/inbox'; + +/** + * V4.9 — relay-side encrypted profile storage. + * + * The `Profile` namespace lets a Shade-based app store a small, + * AEAD-sealed JSON blob on the relay keyed by a deterministic slotId + * derived from the user's master key. A brand new device that knows + * only the credentials (password + PIN → masterKey via the existing + * `@shade/storage-encrypted` KDF) can locate, decrypt, and update the + * blob. The relay sees only opaque slotIds and AEAD-sealed bytes — it + * never decrypts and cannot link slots to users. + * + * This is the *primitive* Prism uses for credential-driven device + * linking (Phase 2 of the Prism device-linking plan): the blob holds + * the list of paired hosts, the new device reads it, picks the first + * online host, and starts a link-request handshake. But it's + * deliberately app-shaped — any Shade app needing a credential-only + * bootstrap into existing E2EE state can use it. Pass a different + * `app` namespace string per use-case so two apps under the same + * master never collide on the same slot. + * + * Usage: + * const km = await KeyManager.unlock(...); // existing v4.5 flow + * const profile = createProfileNamespace({ + * baseUrl: 'https://shade.example/', + * crypto: new SubtleCryptoProvider(), + * masterKey: km.masterKey, + * app: 'prism-profile-v1', + * }); + * + * const current = await profile.get(); + * // -> { plaintext: Uint8Array, etag: string } | null + * + * await profile.put(JSON.stringify({ hosts: [...] }), { + * ifMatch: current?.etag, + * }); + * + * await profile.delete(); // "forget everything" + */ + +export interface ProfileNamespaceOptions { + /** Base URL of the Shade relay. */ + baseUrl: string; + /** CryptoProvider — typically a fresh SubtleCryptoProvider instance. */ + crypto: CryptoProvider; + /** + * 32-byte master key, exactly the value you'd hand to + * `@shade/storage-encrypted`'s row-codec — the existing v4.5 KDF + * chain (passphrase + scrypt → masterKey, possibly upgraded with + * argon2id over a PIN) lands you here. Profile storage uses HKDF + * subderivations under separate `info` strings, so it can't leak + * the storage encryption key or vice versa. + */ + masterKey: Uint8Array; + /** + * Per-app namespace string. Distinct apps under the same master key + * MUST pass different values so they don't collide on the same slot. + * Convention: `"--"`, e.g. + * `"prism-profile-v1"`. + */ + app: string; + /** Optional fetch override (defaults to globalThis.fetch). */ + fetch?: typeof fetch; +} + +export interface ProfileGetResult { + /** Decrypted plaintext bytes. The shape is up to the caller. */ + plaintext: Uint8Array; + /** Pass back as `ifMatch` to do a CAS update. */ + etag: string; + /** Wall-clock ms when the relay last accepted a write. */ + updatedAt: number; +} + +export interface ProfilePutOptions { + /** + * - `undefined` : create-only. Slot must be empty (else 409). + * - `` : compare-and-swap. Must match current etag (else 412). + * - `'*'` : unconditional overwrite. Slot must already exist (else 412). + */ + ifMatch?: string; +} + +export interface ProfilePutResult { + /** True if this PUT created the slot, false if it updated an existing one. */ + created: boolean; + /** New etag after the write. */ + etag: string; + updatedAt: number; +} + +export interface ProfileNamespace { + readonly slotIdHex: string; + get(): Promise; + put(plaintext: Uint8Array | string, options?: ProfilePutOptions): Promise; + delete(): Promise; +} + +const TEXT = new TextEncoder(); +const TEXT_DECODER = new TextDecoder(); + +export function createProfileNamespace( + options: ProfileNamespaceOptions, +): ProfileNamespace { + if (options.masterKey.length !== 32) { + throw new ValidationError('masterKey must be 32 bytes'); + } + if (options.app.length === 0) { + throw new ValidationError('app namespace must be non-empty'); + } + + const slotIdBytes = deriveBlobSlotId(options.masterKey, options.app); + const slotIdHex = slotIdToHex(slotIdBytes); + const blobKey = deriveBlobKey(options.masterKey, options.app); + const signingSeed = deriveBlobSigningSeed(options.masterKey, options.app); + const ownerPubkey = ed25519PublicKeyFromSeed(signingSeed); + + // AAD binds the slotId into the AEAD seal: a relay returning the + // wrong slot's blob (mistake or malice) fails to open. The slotId is + // already part of the URL path, but binding it cryptographically + // prevents any kind of cross-slot replay regardless of how the bytes + // got to us. + const aad = TEXT.encode(`shade-profile-aad-v1:${slotIdHex}`); + + const clientOptions: ConstructorParameters[0] = { + baseUrl: options.baseUrl, + crypto: options.crypto, + }; + if (options.fetch) clientOptions.fetch = options.fetch; + const client = new BlobClient(clientOptions); + + return { + slotIdHex, + + async get(): Promise { + const result = await client.get(slotIdHex); + if (!result) return null; + // Deterministic 12-byte nonce from (slotId, etag): the relay + // stores `nonce || ct||tag` as one blob, so the AEAD layer + // pulls the nonce off the front. We don't pre-compute it — + // aeadOpen handles the prefix automatically. + const plaintext = await aeadOpen(blobKey, result.blob, aad); + return { + plaintext, + etag: result.etag, + updatedAt: result.updatedAt, + }; + }, + + async put( + plaintext: Uint8Array | string, + options?: ProfilePutOptions, + ): Promise { + const ptBytes = + typeof plaintext === 'string' ? TEXT.encode(plaintext) : plaintext; + // Random per-write 12-byte nonce. We don't reuse a deterministic + // nonce because two consecutive writes of the same plaintext + // (rare but possible — re-uploading after a transient error) + // would otherwise reuse (key, nonce, plaintext), which is a + // nonce-reuse condition for AES-GCM. A fresh random nonce per + // PUT keeps each AEAD invocation unique. + const nonce = clientOptions.crypto.randomBytes(12); + const sealed = await aeadSeal(blobKey, nonce, ptBytes, aad); + + const putArgs: Parameters[0] = { + slotIdHex, + blob: sealed, + signingPrivateKey: signingSeed, + ownerPubkey, + }; + if (options?.ifMatch !== undefined) putArgs.ifMatch = options.ifMatch; + return client.put(putArgs); + }, + + async delete(): Promise { + return client.delete({ + slotIdHex, + signingPrivateKey: signingSeed, + }); + }, + }; +} + +// Re-export the raw KDF helpers so apps that want to drive a custom +// flow (skip the AEAD layer, use a different client, run interop +// against a non-Shade relay) don't have to re-import from +// `@shade/storage-encrypted/crypto`. +export { + deriveBlobSlotId, + deriveBlobKey, + deriveBlobSigningSeed, +} from '@shade/storage-encrypted/crypto'; +export { ed25519PublicKeyFromSeed } from '@shade/crypto-web'; +export { slotIdToHex } from '@shade/inbox'; + +/** Decode a UTF-8 plaintext from a `ProfileGetResult`. */ +export function profilePlaintextToString(result: ProfileGetResult): string { + return TEXT_DECODER.decode(result.plaintext); +} diff --git a/packages/shade-sdk/tests/profile.test.ts b/packages/shade-sdk/tests/profile.test.ts new file mode 100644 index 0000000..017751c --- /dev/null +++ b/packages/shade-sdk/tests/profile.test.ts @@ -0,0 +1,218 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { + createProfileNamespace, + profilePlaintextToString, + deriveBlobSlotId, + deriveBlobKey, + deriveBlobSigningSeed, + ed25519PublicKeyFromSeed, + slotIdToHex, +} from '../src/index.js'; +import { + createInboxServer, + MemoryInboxStore, + MemoryBlobStore, +} from '@shade/inbox-server'; +import { SubtleCryptoProvider } from '@shade/crypto-web'; +import { ShadeError } from '@shade/core'; + +const crypto = new SubtleCryptoProvider(); + +function randBytes(n: number): Uint8Array { + const buf = new Uint8Array(n); + globalThis.crypto.getRandomValues(buf); + return buf; +} + +interface ServerHandle { + url: string; + stop: () => void; +} + +async function startServer(): Promise { + const app = createInboxServer({ + crypto, + store: new MemoryInboxStore(), + blobStore: new MemoryBlobStore(), + disableRateLimit: true, + }); + const port = 19000 + Math.floor(Math.random() * 500); + const handle = Bun.serve({ port, fetch: app.fetch }); + return { + url: `http://localhost:${port}`, + stop: () => handle.stop(true), + }; +} + +describe('SDK Profile namespace (V4.9)', () => { + let server: ServerHandle; + let masterKey: Uint8Array; + + beforeEach(async () => { + server = await startServer(); + masterKey = randBytes(32); + }); + + afterEach(() => { + server.stop(); + }); + + test('credential-only round trip: create, read, update, delete', async () => { + const profile = createProfileNamespace({ + baseUrl: server.url, + crypto, + masterKey, + app: 'test-profile-v1', + }); + + // Empty slot. + expect(await profile.get()).toBeNull(); + + // Create. + const payload = JSON.stringify({ hosts: ['device:abc'], v: 1 }); + const created = await profile.put(payload); + expect(created.created).toBe(true); + + // Read back. + const got1 = await profile.get(); + expect(got1).not.toBeNull(); + expect(profilePlaintextToString(got1!)).toBe(payload); + expect(got1!.etag).toBe(created.etag); + + // CAS update with the etag we just read. + const next = JSON.stringify({ hosts: ['device:abc', 'device:def'], v: 2 }); + const updated = await profile.put(next, { ifMatch: got1!.etag }); + expect(updated.created).toBe(false); + expect(Number(updated.etag)).toBeGreaterThan(Number(created.etag)); + + // Stale CAS fails. + await expect( + profile.put(JSON.stringify({ hosts: [] }), { ifMatch: created.etag }), + ).rejects.toThrow(ShadeError); + + // Delete. + const removed = await profile.delete(); + expect(removed).toBe(true); + expect(await profile.get()).toBeNull(); + }); + + test('different app namespaces map to different slots', async () => { + const a = createProfileNamespace({ + baseUrl: server.url, + crypto, + masterKey, + app: 'app-a', + }); + const b = createProfileNamespace({ + baseUrl: server.url, + crypto, + masterKey, + app: 'app-b', + }); + expect(a.slotIdHex).not.toBe(b.slotIdHex); + }); + + test('different master keys map to different slots', async () => { + const km2 = randBytes(32); + const a = createProfileNamespace({ + baseUrl: server.url, + crypto, + masterKey, + app: 'shared', + }); + const b = createProfileNamespace({ + baseUrl: server.url, + crypto, + masterKey: km2, + app: 'shared', + }); + expect(a.slotIdHex).not.toBe(b.slotIdHex); + }); + + test('a fresh client with the same master + app reads the existing blob', async () => { + const writer = createProfileNamespace({ + baseUrl: server.url, + crypto, + masterKey, + app: 'shared', + }); + await writer.put('hello world'); + + // Brand-new namespace instance — simulates "log in from a new + // device". Uses *only* the master key + app namespace; nothing + // else carried over. + const reader = createProfileNamespace({ + baseUrl: server.url, + crypto, + masterKey, + app: 'shared', + }); + const got = await reader.get(); + expect(got).not.toBeNull(); + expect(profilePlaintextToString(got!)).toBe('hello world'); + }); + + test('without ifMatch on populated slot is a SHADE_CONFLICT error', async () => { + const profile = createProfileNamespace({ + baseUrl: server.url, + crypto, + masterKey, + app: 'conflict-test', + }); + await profile.put('first'); + try { + await profile.put('second'); + throw new Error('expected put to throw'); + } catch (err) { + expect(err).toBeInstanceOf(ShadeError); + expect((err as ShadeError).code).toBe('SHADE_CONFLICT'); + } + }); + + test('stale ifMatch is a SHADE_PRECONDITION_FAILED error', async () => { + const profile = createProfileNamespace({ + baseUrl: server.url, + crypto, + masterKey, + app: 'precondition-test', + }); + const first = await profile.put('first'); + await profile.put('second', { ifMatch: first.etag }); + try { + await profile.put('third', { ifMatch: first.etag }); + throw new Error('expected put to throw'); + } catch (err) { + expect(err).toBeInstanceOf(ShadeError); + expect((err as ShadeError).code).toBe('SHADE_PRECONDITION_FAILED'); + } + }); +}); + +describe('KDF helpers (V4.9)', () => { + test('derivations are deterministic per (masterKey, app)', () => { + const km = randBytes(32); + const a1 = deriveBlobSlotId(km, 'x'); + const a2 = deriveBlobSlotId(km, 'x'); + expect(a1).toEqual(a2); + expect(deriveBlobSlotId(km, 'y')).not.toEqual(a1); + expect(deriveBlobKey(km, 'x')).not.toEqual(a1); + expect(deriveBlobSigningSeed(km, 'x')).not.toEqual(deriveBlobKey(km, 'x')); + }); + + test('signing seed → pubkey is deterministic and 32 bytes', () => { + const km = randBytes(32); + const seed = deriveBlobSigningSeed(km, 'p'); + const pk1 = ed25519PublicKeyFromSeed(seed); + const pk2 = ed25519PublicKeyFromSeed(seed); + expect(pk1).toEqual(pk2); + expect(pk1.length).toBe(32); + }); + + test('slotIdToHex round-trips through hex form', () => { + const km = randBytes(32); + const id = deriveBlobSlotId(km, 'rt'); + const hex = slotIdToHex(id); + expect(hex.length).toBe(64); + expect(/^[0-9a-f]{64}$/.test(hex)).toBe(true); + }); +}); diff --git a/packages/shade-server/package.json b/packages/shade-server/package.json index 7c40845..767ad33 100644 --- a/packages/shade-server/package.json +++ b/packages/shade-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/server", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-server/src/standalone.ts b/packages/shade-server/src/standalone.ts index 9e63a43..ba32df5 100644 --- a/packages/shade-server/src/standalone.ts +++ b/packages/shade-server/src/standalone.ts @@ -3,10 +3,13 @@ import { SubtleCryptoProvider } from '@shade/crypto-web'; import { createInboxRoutes, createBridgeRoutes, + createBlobRoutes, InboxServerEvents, InboxPruneTask, MemoryInboxStore, + MemoryBlobStore, type InboxStore, + type BlobStore, } from '@shade/inbox-server'; import { createPrekeyRoutes } from './routes.js'; import { createHealthRoutes } from './health.js'; @@ -71,6 +74,41 @@ async function createInboxStore(): Promise void | P return new MemoryInboxStore(); } +/** + * V4.9 — encrypted-blob primitive (`/v1/blob/`). + * + * Backend selection mirrors the inbox store: an explicit + * `SHADE_BLOB_PG_URL` wins, then a SQLite path, then we fall back to the + * shared `SHADE_PREKEY_PG_URL` if present, then memory. Operators can + * also opt the blob store *off* entirely via `SHADE_DISABLE_BLOB=1` — + * useful for relays that only want the inbox surface. + */ +async function createBlobStore(): Promise void | Promise }> { + const sqlitePath = process.env.SHADE_BLOB_DB_PATH; + const pgUrl = process.env.SHADE_BLOB_PG_URL ?? process.env.SHADE_PREKEY_PG_URL; + + if (pgUrl && process.env.SHADE_BLOB_PG_URL) { + const { PostgresBlobStore } = await import('@shade/storage-postgres'); + logger.info('Using PostgreSQL blob store', { url: maskUrl(pgUrl) }); + return PostgresBlobStore.create(pgUrl); + } + + if (sqlitePath) { + const { SqliteBlobStore } = await import('@shade/storage-sqlite'); + logger.info('Using SQLite blob store', { path: sqlitePath }); + return new SqliteBlobStore(sqlitePath); + } + + if (pgUrl) { + const { PostgresBlobStore } = await import('@shade/storage-postgres'); + logger.info('Using PostgreSQL blob store (sharing prekey URL)', { url: maskUrl(pgUrl) }); + return PostgresBlobStore.create(pgUrl); + } + + logger.warn('Using in-memory blob store — data will not persist across restarts'); + return new MemoryBlobStore(); +} + async function maybeCreateKT(): Promise { const skPriv = process.env.SHADE_KT_SIGNING_PRIVATE_KEY; const skPub = process.env.SHADE_KT_SIGNING_PUBLIC_KEY; @@ -204,6 +242,19 @@ app.route( ); app.route('/', bridgeRoutes.app); +// V4.9 — encrypted-blob primitive. Powers Prism's credential-driven +// device-linking (Phase 2) and any other Shade app that needs a +// "sign in from any device" UX. Mounted on the same Hono app so a +// single relay process serves prekey + inbox + blob from one port. +const blobDisabled = process.env.SHADE_DISABLE_BLOB === '1'; +const blobStore = blobDisabled ? null : await createBlobStore(); +if (blobDisabled) { + logger.info('Blob primitive disabled (SHADE_DISABLE_BLOB=1)'); +} else if (blobStore) { + app.route('/', createBlobRoutes(blobStore, crypto, { disableRateLimit })); + logger.info('Blob primitive enabled', { route: '/v1/blob/:slotId' }); +} + // ─── Optional: Observer + Dashboard ────────────────────────── const observerToken = process.env.SHADE_OBSERVER_TOKEN; @@ -278,6 +329,9 @@ async function shutdown(signal: string) { if ('close' in inboxStore && typeof inboxStore.close === 'function') { await inboxStore.close(); } + if (blobStore && 'close' in blobStore && typeof blobStore.close === 'function') { + await blobStore.close(); + } logger.info('Shutdown complete'); process.exit(0); } catch (err) { diff --git a/packages/shade-storage-encrypted/package.json b/packages/shade-storage-encrypted/package.json index dae8f69..3733065 100644 --- a/packages/shade-storage-encrypted/package.json +++ b/packages/shade-storage-encrypted/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-encrypted", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-encrypted/src/crypto.ts b/packages/shade-storage-encrypted/src/crypto.ts index 9470e3b..4d6cf03 100644 --- a/packages/shade-storage-encrypted/src/crypto.ts +++ b/packages/shade-storage-encrypted/src/crypto.ts @@ -31,6 +31,9 @@ export { deriveNonce, buildAad, hkdfDerive, + deriveBlobSlotId, + deriveBlobKey, + deriveBlobSigningSeed, } from './crypto/kdf.js'; export { AEAD_NONCE_LEN, diff --git a/packages/shade-storage-encrypted/src/crypto/kdf.ts b/packages/shade-storage-encrypted/src/crypto/kdf.ts index c9af9f6..14c3ede 100644 --- a/packages/shade-storage-encrypted/src/crypto/kdf.ts +++ b/packages/shade-storage-encrypted/src/crypto/kdf.ts @@ -122,3 +122,42 @@ export function deriveNonce(rowKey: Uint8Array, table: string, pk: string): Uint export function buildAad(table: string, column: string, pk: string): Uint8Array { return TEXT.encode(`shade-aad-v1|${table}|${column}|${pk}`); } + +// ─── V4.9 — relay-side encrypted blob primitive ────────────── +// +// Three deterministic 32-byte derivations rooted at the user's master +// key, used by `@shade/sdk`'s `Profile` namespace to bootstrap a brand +// new device into existing E2EE state from credentials alone: +// +// slotId = HKDF(masterKey, info=`shade-blob-slot-v1:${app}`) +// blobKey = HKDF(masterKey, info=`shade-blob-key-v1:${app}`) +// sigSeed = HKDF(masterKey, info=`shade-blob-sig-v1:${app}`) +// +// `app` is a caller-supplied namespace (e.g. `"prism-profile"`) so two +// Shade apps with the same user/master never collide on the same slot. +// +// The slot identifier and the AEAD key are *both* derived from the +// master — the relay sees opaque slotIds and AEAD-sealed blobs and +// cannot decrypt or correlate slots to users. The signing seed is the +// raw 32-byte Ed25519 private key (matches @noble/curves' API: pubkey +// = ed25519.getPublicKey(seed)). + +/** Lower-hex 64-char slotId derived from the master key. */ +export function deriveBlobSlotId(masterKey: Uint8Array, app: string): Uint8Array { + return hkdfDerive(masterKey, `shade-blob-slot-v1:${app}`, 32); +} + +/** AEAD key for sealing/opening the blob. Use AAD = slotId. */ +export function deriveBlobKey(masterKey: Uint8Array, app: string): Uint8Array { + return hkdfDerive(masterKey, `shade-blob-key-v1:${app}`, 32); +} + +/** + * 32-byte Ed25519 signing seed (== the private key in the @noble/curves + * convention). The pubkey, derived deterministically from the seed, is + * what the relay TOFU-stores on the first PUT and verifies subsequent + * writes against. + */ +export function deriveBlobSigningSeed(masterKey: Uint8Array, app: string): Uint8Array { + return hkdfDerive(masterKey, `shade-blob-sig-v1:${app}`, 32); +} diff --git a/packages/shade-storage-encrypted/src/index.ts b/packages/shade-storage-encrypted/src/index.ts index 28ee4cd..4d6c432 100644 --- a/packages/shade-storage-encrypted/src/index.ts +++ b/packages/shade-storage-encrypted/src/index.ts @@ -16,6 +16,9 @@ export { deriveNonce, buildAad, hkdfDerive, + deriveBlobSlotId, + deriveBlobKey, + deriveBlobSigningSeed, } from './crypto/kdf.js'; export { AEAD_NONCE_LEN, diff --git a/packages/shade-storage-encrypted/tests/blob-vectors.test.ts b/packages/shade-storage-encrypted/tests/blob-vectors.test.ts new file mode 100644 index 0000000..1444364 --- /dev/null +++ b/packages/shade-storage-encrypted/tests/blob-vectors.test.ts @@ -0,0 +1,50 @@ +import { describe, test, expect } from 'bun:test'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { + deriveBlobSlotId, + deriveBlobKey, + deriveBlobSigningSeed, +} from '../src/crypto/kdf.js'; +import { ed25519PublicKeyFromSeed } from '@shade/crypto-web'; + +function fromHex(s: string): Uint8Array { + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(s.substr(i * 2, 2), 16); + } + return out; +} + +function toHex(b: Uint8Array): string { + let s = ''; + for (const x of b) s += x.toString(16).padStart(2, '0'); + return s; +} + +describe('V4.9 blob-storage KDF vectors', () => { + // Resolve relative to this file, not to cwd, so the test passes + // regardless of which directory `bun test` is invoked from. + const vectorPath = join(import.meta.dir, '..', '..', '..', 'test-vectors', 'blob-storage.json'); + const vectors = JSON.parse(readFileSync(vectorPath, 'utf-8')) as { + kdf: Array<{ + masterKey: string; + app: string; + slotId: string; + blobKey: string; + signingSeed: string; + ownerPubkey: string; + }>; + }; + + for (const v of vectors.kdf) { + test(`(master=${v.masterKey.slice(0, 8)}…, app=${v.app})`, () => { + const km = fromHex(v.masterKey); + expect(toHex(deriveBlobSlotId(km, v.app))).toBe(v.slotId); + expect(toHex(deriveBlobKey(km, v.app))).toBe(v.blobKey); + const seed = deriveBlobSigningSeed(km, v.app); + expect(toHex(seed)).toBe(v.signingSeed); + expect(toHex(ed25519PublicKeyFromSeed(seed))).toBe(v.ownerPubkey); + }); + } +}); diff --git a/packages/shade-storage-indexeddb/package.json b/packages/shade-storage-indexeddb/package.json index 97f1991..85a094d 100644 --- a/packages/shade-storage-indexeddb/package.json +++ b/packages/shade-storage-indexeddb/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-indexeddb", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-postgres/package.json b/packages/shade-storage-postgres/package.json index 1dcb037..039a2f7 100644 --- a/packages/shade-storage-postgres/package.json +++ b/packages/shade-storage-postgres/package.json @@ -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", diff --git a/packages/shade-storage-postgres/src/ensure-tables.ts b/packages/shade-storage-postgres/src/ensure-tables.ts index 33c5664..5d35e4a 100644 --- a/packages/shade-storage-postgres/src/ensure-tables.ts +++ b/packages/shade-storage-postgres/src/ensure-tables.ts @@ -208,6 +208,30 @@ export async function ensureKTLogTables(sql: Sql): Promise { `; } +/** + * V4.9 — encrypted-blob primitive (`/v1/blob/`). 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 { + 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 { await sql` CREATE TABLE IF NOT EXISTS shade_inbox_owners ( diff --git a/packages/shade-storage-postgres/src/index.ts b/packages/shade-storage-postgres/src/index.ts index 67fd27d..ef77d7c 100644 --- a/packages/shade-storage-postgres/src/index.ts +++ b/packages/shade-storage-postgres/src/index.ts @@ -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'; diff --git a/packages/shade-storage-postgres/src/postgres-blob-store.ts b/packages/shade-storage-postgres/src/postgres-blob-store.ts new file mode 100644 index 0000000..bb1d815 --- /dev/null +++ b/packages/shade-storage-postgres/src/postgres-blob-store.ts @@ -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 { + const sql = postgres(connectionString); + const store = new PostgresBlobStore(sql, true); + await ensureBlobServerTables(sql); + return store; + } + + static async fromClient(sql: Sql): Promise { + const store = new PostgresBlobStore(sql, false); + await ensureBlobServerTables(sql); + return store; + } + + async close(): Promise { + if (this.ownsConnection) await this.sql.end(); + } + + async get(slotId: string): Promise { + 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 { + // 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>` + 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>` + 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>` + 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; + } + + async delete(slotId: string): Promise { + const result = await this.sql` + DELETE FROM shade_blob_slots WHERE slot_id = ${slotId} + `; + return result.count > 0; + } +} diff --git a/packages/shade-storage-sqlite/package.json b/packages/shade-storage-sqlite/package.json index 1ed6956..ddf4228 100644 --- a/packages/shade-storage-sqlite/package.json +++ b/packages/shade-storage-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-sqlite", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-sqlite/src/index.ts b/packages/shade-storage-sqlite/src/index.ts index 0783cea..f52e442 100644 --- a/packages/shade-storage-sqlite/src/index.ts +++ b/packages/shade-storage-sqlite/src/index.ts @@ -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'; diff --git a/packages/shade-storage-sqlite/src/sqlite-blob-store.ts b/packages/shade-storage-sqlite/src/sqlite-blob-store.ts new file mode 100644 index 0000000..30b4377 --- /dev/null +++ b/packages/shade-storage-sqlite/src/sqlite-blob-store.ts @@ -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; + insert: ReturnType; + update: ReturnType; + delete: ReturnType; + maxEtag: ReturnType; + }; + 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 { + 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 { + // 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 { + const result = this.stmts.delete.run(slotId); + return result.changes > 0; + } +} diff --git a/packages/shade-streams/package.json b/packages/shade-streams/package.json index fad6a64..508ded8 100644 --- a/packages/shade-streams/package.json +++ b/packages/shade-streams/package.json @@ -1,6 +1,6 @@ { "name": "@shade/streams", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transfer/package.json b/packages/shade-transfer/package.json index 9da3c56..29bfbd2 100644 --- a/packages/shade-transfer/package.json +++ b/packages/shade-transfer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transfer", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-bridge/package.json b/packages/shade-transport-bridge/package.json index 0b87bdd..e509668 100644 --- a/packages/shade-transport-bridge/package.json +++ b/packages/shade-transport-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-bridge", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-webrtc/package.json b/packages/shade-transport-webrtc/package.json index 27226c4..bacae86 100644 --- a/packages/shade-transport-webrtc/package.json +++ b/packages/shade-transport-webrtc/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-webrtc", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport/package.json b/packages/shade-transport/package.json index 47b7ba9..c97fa18 100644 --- a/packages/shade-transport/package.json +++ b/packages/shade-transport/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-widgets/package.json b/packages/shade-widgets/package.json index d27305a..ad47154 100644 --- a/packages/shade-widgets/package.json +++ b/packages/shade-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@shade/widgets", - "version": "4.8.5", + "version": "4.9.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/test-vectors/blob-storage.json b/test-vectors/blob-storage.json new file mode 100644 index 0000000..72ed355 --- /dev/null +++ b/test-vectors/blob-storage.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "description": "V4.9 — relay-side encrypted blob primitive: HKDF derivations from masterKey + per-app namespace string. Each (master, app) pair MUST reproduce the same slotId / blobKey / signingSeed / ownerPubkey across implementations. ownerPubkey = Ed25519.getPublicKey(signingSeed).", + "kdf": [ + { + "masterKey": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "app": "prism-profile-v1", + "slotIdInfo": "shade-blob-slot-v1:prism-profile-v1", + "blobKeyInfo": "shade-blob-key-v1:prism-profile-v1", + "signingSeedInfo": "shade-blob-sig-v1:prism-profile-v1", + "slotId": "cee6fe19af3c3ad20f91382938cd05ccf7f314566209f5debad17d8427508323", + "blobKey": "47ad8fc8fcb0f15ec75be95246e6040bb0674b1a9e4bc3cf7a2c3d1c1e57877b", + "signingSeed": "0bb58f21b588b44f22d5837602c1ee0049e56f99df5241702b65e5de0a1a0dab", + "ownerPubkey": "2be918c7af82278fb446bb3901e5a7691f5ac4123275d5e1b202882da2a637bc" + }, + { + "masterKey": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "app": "test-namespace", + "slotIdInfo": "shade-blob-slot-v1:test-namespace", + "blobKeyInfo": "shade-blob-key-v1:test-namespace", + "signingSeedInfo": "shade-blob-sig-v1:test-namespace", + "slotId": "b10a7e64f9902f48bc566d48c09c0276cdad2dc9ad55d456374c02a8f160aa46", + "blobKey": "9e140339142d23291f0f360f03072c66049cec2449994dce1b77a3aed43eeb37", + "signingSeed": "feec2d85ba7320fe34940abca082f056d5fa7927d940b267d44ae24acb486773", + "ownerPubkey": "94e8298ea69ba4b160934fb813ee3fa5b2a4254cc78cb3dd8339bdc7b68e660c" + }, + { + "masterKey": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "app": "prism-profile-v1", + "slotIdInfo": "shade-blob-slot-v1:prism-profile-v1", + "blobKeyInfo": "shade-blob-key-v1:prism-profile-v1", + "signingSeedInfo": "shade-blob-sig-v1:prism-profile-v1", + "slotId": "deffbe4e2934965ce63fff247331186579ff4ef13c867fa4597059c1d7047bfb", + "blobKey": "f498052d24513dccbdf538f2b9c13e9d6519fb06ead58eb3dfadf6b92d94227a", + "signingSeed": "e904e2f0f42297f16a29e636c43b9b72d57a49841ab0b9bfd29c03345e9f16d0", + "ownerPubkey": "822609f6b07f78d4692bfe708c05ce2d4d3c4eb25cf84a16a9d9e900015b3ca0" + } + ] +}