release(v4.9.0): relay-side encrypted blob primitive + SDK Profile namespace
Ships the Prism FR (encrypted-profile-storage-v4.9.md) as a generic relay-side encrypted blob primitive: deterministically-located, AEAD-sealed blobs keyed by a 32-byte slotId derived client-side via HKDF from the user's master key. Unlocks credential-only bootstrap of new devices into existing E2EE state — no QR, no physical access. Server: BlobStore interface + Memory/Sqlite/Postgres impls, createBlobRoutes for GET/PUT/DELETE /v1/blob/:slotId with TOFU pubkey auth and If-Match CAS (409/412 semantics). Mounted on the same Hono app as the inbox; SHADE_BLOB_PG_URL / SHADE_BLOB_DB_PATH / SHADE_DISABLE_BLOB env-var plumbing in standalone. SDK: createProfileNamespace high-level wrapper (HKDF derivation, random-nonce AEAD seal, slotId-bound AAD) + low-level BlobClient. Cross-platform test vectors in test-vectors/blob-storage.json. New errors: ConflictError (409), PreconditionFailedError (412). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
87
CHANGELOG.md
87
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/),
|
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).
|
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:<slotIdHex>"`
|
||||||
|
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
|
## [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
|
Prism filed a "typing-into-a-chatty-shell" UX FR pointing at
|
||||||
|
|||||||
53
bun.lock
53
bun.lock
@@ -17,7 +17,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-cli": {
|
"packages/shade-cli": {
|
||||||
"name": "@shade/cli",
|
"name": "@shade/cli",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"bin": {
|
"bin": {
|
||||||
"shade": "src/cli.ts",
|
"shade": "src/cli.ts",
|
||||||
},
|
},
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-core": {
|
"packages/shade-core": {
|
||||||
"name": "@shade/core",
|
"name": "@shade/core",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/observability": "workspace:*",
|
"@shade/observability": "workspace:*",
|
||||||
},
|
},
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-crypto-web": {
|
"packages/shade-crypto-web": {
|
||||||
"name": "@shade/crypto-web",
|
"name": "@shade/crypto-web",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/curves": "^2.0.1",
|
"@noble/curves": "^2.0.1",
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-dashboard": {
|
"packages/shade-dashboard": {
|
||||||
"name": "@shade/dashboard",
|
"name": "@shade/dashboard",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/widgets": "workspace:*",
|
"@shade/widgets": "workspace:*",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-files": {
|
"packages/shade-files": {
|
||||||
"name": "@shade/files",
|
"name": "@shade/files",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
"@shade/crypto-web": "workspace:*",
|
"@shade/crypto-web": "workspace:*",
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-inbox": {
|
"packages/shade-inbox": {
|
||||||
"name": "@shade/inbox",
|
"name": "@shade/inbox",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
"@shade/proto": "workspace:*",
|
"@shade/proto": "workspace:*",
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-inbox-server": {
|
"packages/shade-inbox-server": {
|
||||||
"name": "@shade/inbox-server",
|
"name": "@shade/inbox-server",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
"@shade/observability": "workspace:*",
|
"@shade/observability": "workspace:*",
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-key-transparency": {
|
"packages/shade-key-transparency": {
|
||||||
"name": "@shade/key-transparency",
|
"name": "@shade/key-transparency",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
@@ -144,11 +144,11 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-keychain": {
|
"packages/shade-keychain": {
|
||||||
"name": "@shade/keychain",
|
"name": "@shade/keychain",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
},
|
},
|
||||||
"packages/shade-observability": {
|
"packages/shade-observability": {
|
||||||
"name": "@shade/observability",
|
"name": "@shade/observability",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
},
|
},
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-observer": {
|
"packages/shade-observer": {
|
||||||
"name": "@shade/observer",
|
"name": "@shade/observer",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
"@shade/server": "workspace:*",
|
"@shade/server": "workspace:*",
|
||||||
@@ -178,14 +178,14 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-proto": {
|
"packages/shade-proto": {
|
||||||
"name": "@shade/proto",
|
"name": "@shade/proto",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/shade-recovery": {
|
"packages/shade-recovery": {
|
||||||
"name": "@shade/recovery",
|
"name": "@shade/recovery",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
"@shade/crypto-web": "workspace:*",
|
"@shade/crypto-web": "workspace:*",
|
||||||
@@ -198,22 +198,25 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-sdk": {
|
"packages/shade-sdk": {
|
||||||
"name": "@shade/sdk",
|
"name": "@shade/sdk",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
"@shade/crypto-web": "workspace:*",
|
"@shade/crypto-web": "workspace:*",
|
||||||
"@shade/files": "workspace:*",
|
"@shade/files": "workspace:*",
|
||||||
|
"@shade/inbox": "workspace:*",
|
||||||
"@shade/key-transparency": "workspace:*",
|
"@shade/key-transparency": "workspace:*",
|
||||||
"@shade/observability": "workspace:*",
|
"@shade/observability": "workspace:*",
|
||||||
"@shade/observer": "workspace:*",
|
"@shade/observer": "workspace:*",
|
||||||
"@shade/proto": "workspace:*",
|
"@shade/proto": "workspace:*",
|
||||||
"@shade/server": "workspace:*",
|
"@shade/server": "workspace:*",
|
||||||
|
"@shade/storage-encrypted": "workspace:*",
|
||||||
"@shade/storage-sqlite": "workspace:*",
|
"@shade/storage-sqlite": "workspace:*",
|
||||||
"@shade/streams": "workspace:*",
|
"@shade/streams": "workspace:*",
|
||||||
"@shade/transfer": "workspace:*",
|
"@shade/transfer": "workspace:*",
|
||||||
"@shade/transport": "workspace:*",
|
"@shade/transport": "workspace:*",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@shade/inbox-server": "workspace:*",
|
||||||
"@shade/transport-webrtc": "workspace:*",
|
"@shade/transport-webrtc": "workspace:*",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -225,7 +228,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-server": {
|
"packages/shade-server": {
|
||||||
"name": "@shade/server",
|
"name": "@shade/server",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
"@shade/inbox-server": "workspace:*",
|
"@shade/inbox-server": "workspace:*",
|
||||||
@@ -245,7 +248,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-storage-encrypted": {
|
"packages/shade-storage-encrypted": {
|
||||||
"name": "@shade/storage-encrypted",
|
"name": "@shade/storage-encrypted",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
@@ -267,7 +270,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-storage-indexeddb": {
|
"packages/shade-storage-indexeddb": {
|
||||||
"name": "@shade/storage-indexeddb",
|
"name": "@shade/storage-indexeddb",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
@@ -279,7 +282,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-storage-postgres": {
|
"packages/shade-storage-postgres": {
|
||||||
"name": "@shade/storage-postgres",
|
"name": "@shade/storage-postgres",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
"@shade/inbox-server": "workspace:*",
|
"@shade/inbox-server": "workspace:*",
|
||||||
@@ -294,7 +297,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-storage-sqlite": {
|
"packages/shade-storage-sqlite": {
|
||||||
"name": "@shade/storage-sqlite",
|
"name": "@shade/storage-sqlite",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
"@shade/crypto-web": "workspace:*",
|
"@shade/crypto-web": "workspace:*",
|
||||||
@@ -304,7 +307,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-streams": {
|
"packages/shade-streams": {
|
||||||
"name": "@shade/streams",
|
"name": "@shade/streams",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
@@ -316,7 +319,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-transfer": {
|
"packages/shade-transfer": {
|
||||||
"name": "@shade/transfer",
|
"name": "@shade/transfer",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
"@shade/crypto-web": "workspace:*",
|
"@shade/crypto-web": "workspace:*",
|
||||||
@@ -333,7 +336,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-transport": {
|
"packages/shade-transport": {
|
||||||
"name": "@shade/transport",
|
"name": "@shade/transport",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
"@shade/crypto-web": "workspace:*",
|
"@shade/crypto-web": "workspace:*",
|
||||||
@@ -344,7 +347,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-transport-bridge": {
|
"packages/shade-transport-bridge": {
|
||||||
"name": "@shade/transport-bridge",
|
"name": "@shade/transport-bridge",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
"@shade/server": "workspace:*",
|
"@shade/server": "workspace:*",
|
||||||
@@ -366,7 +369,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-transport-webrtc": {
|
"packages/shade-transport-webrtc": {
|
||||||
"name": "@shade/transport-webrtc",
|
"name": "@shade/transport-webrtc",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
"@shade/streams": "workspace:*",
|
"@shade/streams": "workspace:*",
|
||||||
@@ -375,7 +378,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shade-widgets": {
|
"packages/shade-widgets": {
|
||||||
"name": "@shade/widgets",
|
"name": "@shade/widgets",
|
||||||
"version": "4.4.0",
|
"version": "4.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shade/recovery": "workspace:*",
|
"@shade/recovery": "workspace:*",
|
||||||
"@shade/sdk": "workspace:*",
|
"@shade/sdk": "workspace:*",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/cli",
|
"name": "@shade/cli",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/cli.ts",
|
"main": "src/cli.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/core",
|
"name": "@shade/core",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.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 ────────────────────────────
|
// ─── Error → HTTP Status Mapping ────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -180,7 +204,10 @@ export function errorToHttpStatus(error: unknown): number {
|
|||||||
return 400;
|
return 400;
|
||||||
case 'SHADE_REPLAY':
|
case 'SHADE_REPLAY':
|
||||||
case 'SHADE_DUPLICATE_MESSAGE':
|
case 'SHADE_DUPLICATE_MESSAGE':
|
||||||
|
case 'SHADE_CONFLICT':
|
||||||
return 409;
|
return 409;
|
||||||
|
case 'SHADE_PRECONDITION_FAILED':
|
||||||
|
return 412;
|
||||||
case 'SHADE_RATE_LIMIT':
|
case 'SHADE_RATE_LIMIT':
|
||||||
return 429;
|
return 429;
|
||||||
case 'SHADE_TIMEOUT':
|
case 'SHADE_TIMEOUT':
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/crypto-web",
|
"name": "@shade/crypto-web",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
18
packages/shade-crypto-web/src/ed25519-derive.ts
Normal file
18
packages/shade-crypto-web/src/ed25519-derive.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export { SubtleCryptoProvider } from './provider.js';
|
export { SubtleCryptoProvider } from './provider.js';
|
||||||
export { MemoryStorage } from './memory-storage.js';
|
export { MemoryStorage } from './memory-storage.js';
|
||||||
|
export { ed25519PublicKeyFromSeed } from './ed25519-derive.js';
|
||||||
|
|
||||||
// ─── Web Workers crypto (V3.8) ────────────────────────────
|
// ─── Web Workers crypto (V3.8) ────────────────────────────
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/dashboard",
|
"name": "@shade/dashboard",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/files",
|
"name": "@shade/files",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox-server",
|
"name": "@shade/inbox-server",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
268
packages/shade-inbox-server/src/blob-routes.ts
Normal file
268
packages/shade-inbox-server/src/blob-routes.ts
Normal file
@@ -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 ?? '<unknown>';
|
||||||
|
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, // "<etag>" | "*" | 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;
|
||||||
|
}
|
||||||
86
packages/shade-inbox-server/src/blob-store.ts
Normal file
86
packages/shade-inbox-server/src/blob-store.ts
Normal file
@@ -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<BlobSlotRecord | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* - `<number>` : 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<PutBlobResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<boolean>;
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import type { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { CryptoProvider } from '@shade/core';
|
import type { CryptoProvider } from '@shade/core';
|
||||||
import { createInboxRoutes, type InboxRoutesOptions } from './routes.js';
|
import { createInboxRoutes, type InboxRoutesOptions } from './routes.js';
|
||||||
import { MemoryInboxStore } from './memory-store.js';
|
import { MemoryInboxStore } from './memory-store.js';
|
||||||
import type { InboxStore } from './store.js';
|
import type { InboxStore } from './store.js';
|
||||||
import { InboxServerEvents } from './events.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 { createInboxRoutes } from './routes.js';
|
||||||
export type { InboxRoutesOptions } 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 type { TrackedBridgeKind } from './presence.js';
|
||||||
export { BridgeDeliveryLog } from './bridge-delivery-log.js';
|
export { BridgeDeliveryLog } from './bridge-delivery-log.js';
|
||||||
export type { BridgeDeliveryLogOptions } 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.
|
* Create a standalone Shade Inbox Server.
|
||||||
@@ -48,12 +55,21 @@ export type { BridgeDeliveryLogOptions } from './bridge-delivery-log.js';
|
|||||||
* const app = new Hono();
|
* const app = new Hono();
|
||||||
* app.route('/', createInboxServer({ crypto }));
|
* app.route('/', createInboxServer({ crypto }));
|
||||||
*/
|
*/
|
||||||
export function createInboxServer(options: {
|
export function createInboxServer(
|
||||||
|
options: {
|
||||||
crypto: CryptoProvider;
|
crypto: CryptoProvider;
|
||||||
store?: InboxStore;
|
store?: InboxStore;
|
||||||
disableRateLimit?: boolean;
|
disableRateLimit?: boolean;
|
||||||
events?: InboxServerEvents;
|
events?: InboxServerEvents;
|
||||||
} & Pick<InboxRoutesOptions, 'observability' | 'quota' | 'bridgeDeliveryLog'>): Hono {
|
/**
|
||||||
|
* V4.9 — when supplied, mounts the encrypted-blob primitive
|
||||||
|
* (`/v1/blob/<slotId>`) on the same Hono app. Pass `null` to
|
||||||
|
* explicitly opt out; omit to default to a `MemoryBlobStore`.
|
||||||
|
*/
|
||||||
|
blobStore?: BlobStore | null;
|
||||||
|
blobOptions?: Pick<BlobRoutesOptions, 'maxBlobBytes'>;
|
||||||
|
} & Pick<InboxRoutesOptions, 'observability' | 'quota' | 'bridgeDeliveryLog'>,
|
||||||
|
): Hono {
|
||||||
const store = options.store ?? new MemoryInboxStore();
|
const store = options.store ?? new MemoryInboxStore();
|
||||||
const routesOptions: InboxRoutesOptions = {};
|
const routesOptions: InboxRoutesOptions = {};
|
||||||
if (options.disableRateLimit !== undefined) routesOptions.disableRateLimit = options.disableRateLimit;
|
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.observability !== undefined) routesOptions.observability = options.observability;
|
||||||
if (options.quota !== undefined) routesOptions.quota = options.quota;
|
if (options.quota !== undefined) routesOptions.quota = options.quota;
|
||||||
if (options.bridgeDeliveryLog !== undefined) routesOptions.bridgeDeliveryLog = options.bridgeDeliveryLog;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
85
packages/shade-inbox-server/src/memory-blob-store.ts
Normal file
85
packages/shade-inbox-server/src/memory-blob-store.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { ConflictError, PreconditionFailedError } from '@shade/core';
|
||||||
|
import type { BlobStore, BlobSlotRecord, PutBlobResult } from './blob-store.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory BlobStore — used in tests and as the default fallback when
|
||||||
|
* no SQLite/Postgres URL is configured. Rows are kept in a single Map.
|
||||||
|
*
|
||||||
|
* Etag is a strictly-monotonic per-process counter — guarantees a total
|
||||||
|
* order across writes even when many land in the same millisecond. (We
|
||||||
|
* could scope it per-slot, but a global counter keeps the implementation
|
||||||
|
* trivial and the etag values still uniquely identify the write that
|
||||||
|
* produced them, which is all CAS needs.)
|
||||||
|
*/
|
||||||
|
export class MemoryBlobStore implements BlobStore {
|
||||||
|
private slots = new Map<string, BlobSlotRecord>();
|
||||||
|
private nextEtag = 0;
|
||||||
|
|
||||||
|
async get(slotId: string): Promise<BlobSlotRecord | null> {
|
||||||
|
const r = this.slots.get(slotId);
|
||||||
|
if (!r) return null;
|
||||||
|
return {
|
||||||
|
slotId: r.slotId,
|
||||||
|
blob: new Uint8Array(r.blob),
|
||||||
|
ownerPubkey: new Uint8Array(r.ownerPubkey),
|
||||||
|
etag: r.etag,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(args: {
|
||||||
|
slotId: string;
|
||||||
|
blob: Uint8Array;
|
||||||
|
ownerPubkey: Uint8Array;
|
||||||
|
expectedEtag: number | '*' | undefined;
|
||||||
|
now: number;
|
||||||
|
}): Promise<PutBlobResult> {
|
||||||
|
const existing = this.slots.get(args.slotId);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
// Empty slot. `ifMatch: '*'` per RFC 7232 still fails — there is
|
||||||
|
// no entity to match. A numeric etag also fails (we have nothing
|
||||||
|
// to compare against).
|
||||||
|
if (args.expectedEtag !== undefined) {
|
||||||
|
throw new PreconditionFailedError(
|
||||||
|
`Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.nextEtag = Math.max(this.nextEtag + 1, args.now);
|
||||||
|
const etag = this.nextEtag;
|
||||||
|
this.slots.set(args.slotId, {
|
||||||
|
slotId: args.slotId,
|
||||||
|
blob: new Uint8Array(args.blob),
|
||||||
|
ownerPubkey: new Uint8Array(args.ownerPubkey),
|
||||||
|
etag,
|
||||||
|
updatedAt: args.now,
|
||||||
|
});
|
||||||
|
return { created: true, etag, updatedAt: args.now };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slot exists. Pubkey check is the route layer's job — by the time
|
||||||
|
// we're here the signature has already been verified against
|
||||||
|
// `existing.ownerPubkey`.
|
||||||
|
if (args.expectedEtag === undefined) {
|
||||||
|
throw new ConflictError(
|
||||||
|
`Slot ${args.slotId} already exists; supply ifMatch to update`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (args.expectedEtag !== '*' && args.expectedEtag !== existing.etag) {
|
||||||
|
throw new PreconditionFailedError(
|
||||||
|
`Slot ${args.slotId} etag mismatch: expected=${args.expectedEtag} actual=${existing.etag}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nextEtag = Math.max(this.nextEtag + 1, args.now);
|
||||||
|
const etag = this.nextEtag;
|
||||||
|
existing.blob = new Uint8Array(args.blob);
|
||||||
|
existing.etag = etag;
|
||||||
|
existing.updatedAt = args.now;
|
||||||
|
return { created: false, etag, updatedAt: args.now };
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(slotId: string): Promise<boolean> {
|
||||||
|
return this.slots.delete(slotId);
|
||||||
|
}
|
||||||
|
}
|
||||||
295
packages/shade-inbox-server/tests/blob-routes.test.ts
Normal file
295
packages/shade-inbox-server/tests/blob-routes.test.ts
Normal file
@@ -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<string, unknown> = {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
const res = await app.request(`/v1/blob/${slotB}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(wire),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox",
|
"name": "@shade/inbox",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
208
packages/shade-inbox/src/blob-client.ts
Normal file
208
packages/shade-inbox/src/blob-client.ts
Normal file
@@ -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/<slotId>`). 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<BlobGetResult | null> {
|
||||||
|
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).
|
||||||
|
* - `<etag-string>`: 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<BlobPutResult> {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
validateSlotIdHex(args.slotIdHex);
|
||||||
|
const signed = await signPayload(this.options.crypto, args.signingPrivateKey, {
|
||||||
|
slotId: args.slotIdHex,
|
||||||
|
});
|
||||||
|
const { slotId: _omit, ...wireBody } = signed as Record<string, unknown>;
|
||||||
|
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<any> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -43,3 +43,11 @@ export type {
|
|||||||
} from './events.js';
|
} from './events.js';
|
||||||
|
|
||||||
export { computeMsgId } from './msg-id.js';
|
export { computeMsgId } from './msg-id.js';
|
||||||
|
|
||||||
|
// V4.9 — encrypted-blob primitive (`/v1/blob/<slotId>`).
|
||||||
|
export { BlobClient, slotIdToHex } from './blob-client.js';
|
||||||
|
export type {
|
||||||
|
BlobClientOptions,
|
||||||
|
BlobGetResult,
|
||||||
|
BlobPutResult,
|
||||||
|
} from './blob-client.js';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/key-transparency",
|
"name": "@shade/key-transparency",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/keychain",
|
"name": "@shade/keychain",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observability",
|
"name": "@shade/observability",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observer",
|
"name": "@shade/observer",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/proto",
|
"name": "@shade/proto",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/recovery",
|
"name": "@shade/recovery",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/sdk",
|
"name": "@shade/sdk",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -8,11 +8,13 @@
|
|||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
"@shade/crypto-web": "workspace:*",
|
"@shade/crypto-web": "workspace:*",
|
||||||
"@shade/files": "workspace:*",
|
"@shade/files": "workspace:*",
|
||||||
|
"@shade/inbox": "workspace:*",
|
||||||
"@shade/key-transparency": "workspace:*",
|
"@shade/key-transparency": "workspace:*",
|
||||||
"@shade/observability": "workspace:*",
|
"@shade/observability": "workspace:*",
|
||||||
"@shade/observer": "workspace:*",
|
"@shade/observer": "workspace:*",
|
||||||
"@shade/proto": "workspace:*",
|
"@shade/proto": "workspace:*",
|
||||||
"@shade/server": "workspace:*",
|
"@shade/server": "workspace:*",
|
||||||
|
"@shade/storage-encrypted": "workspace:*",
|
||||||
"@shade/storage-sqlite": "workspace:*",
|
"@shade/storage-sqlite": "workspace:*",
|
||||||
"@shade/streams": "workspace:*",
|
"@shade/streams": "workspace:*",
|
||||||
"@shade/transfer": "workspace:*",
|
"@shade/transfer": "workspace:*",
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@shade/inbox-server": "workspace:*",
|
||||||
"@shade/transport-webrtc": "workspace:*"
|
"@shade/transport-webrtc": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,24 @@ export {
|
|||||||
mainStreamIdForThumbnail,
|
mainStreamIdForThumbnail,
|
||||||
} from '@shade/streams';
|
} 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) ─────────────────────────────
|
// ─── Web Workers crypto (V3.8) ─────────────────────────────
|
||||||
export {
|
export {
|
||||||
createWorkerCryptoProvider,
|
createWorkerCryptoProvider,
|
||||||
|
|||||||
210
packages/shade-sdk/src/profile.ts
Normal file
210
packages/shade-sdk/src/profile.ts
Normal file
@@ -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: `"<app-id>-<purpose>-<schema-version>"`, 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).
|
||||||
|
* - `<etag-string>` : 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<ProfileGetResult | null>;
|
||||||
|
put(plaintext: Uint8Array | string, options?: ProfilePutOptions): Promise<ProfilePutResult>;
|
||||||
|
delete(): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<typeof BlobClient>[0] = {
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
crypto: options.crypto,
|
||||||
|
};
|
||||||
|
if (options.fetch) clientOptions.fetch = options.fetch;
|
||||||
|
const client = new BlobClient(clientOptions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
slotIdHex,
|
||||||
|
|
||||||
|
async get(): Promise<ProfileGetResult | null> {
|
||||||
|
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<ProfilePutResult> {
|
||||||
|
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<BlobClient['put']>[0] = {
|
||||||
|
slotIdHex,
|
||||||
|
blob: sealed,
|
||||||
|
signingPrivateKey: signingSeed,
|
||||||
|
ownerPubkey,
|
||||||
|
};
|
||||||
|
if (options?.ifMatch !== undefined) putArgs.ifMatch = options.ifMatch;
|
||||||
|
return client.put(putArgs);
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(): Promise<boolean> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
218
packages/shade-sdk/tests/profile.test.ts
Normal file
218
packages/shade-sdk/tests/profile.test.ts
Normal file
@@ -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<ServerHandle> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/server",
|
"name": "@shade/server",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { SubtleCryptoProvider } from '@shade/crypto-web';
|
|||||||
import {
|
import {
|
||||||
createInboxRoutes,
|
createInboxRoutes,
|
||||||
createBridgeRoutes,
|
createBridgeRoutes,
|
||||||
|
createBlobRoutes,
|
||||||
InboxServerEvents,
|
InboxServerEvents,
|
||||||
InboxPruneTask,
|
InboxPruneTask,
|
||||||
MemoryInboxStore,
|
MemoryInboxStore,
|
||||||
|
MemoryBlobStore,
|
||||||
type InboxStore,
|
type InboxStore,
|
||||||
|
type BlobStore,
|
||||||
} from '@shade/inbox-server';
|
} from '@shade/inbox-server';
|
||||||
import { createPrekeyRoutes } from './routes.js';
|
import { createPrekeyRoutes } from './routes.js';
|
||||||
import { createHealthRoutes } from './health.js';
|
import { createHealthRoutes } from './health.js';
|
||||||
@@ -71,6 +74,41 @@ async function createInboxStore(): Promise<InboxStore & { close?: () => void | P
|
|||||||
return new MemoryInboxStore();
|
return new MemoryInboxStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V4.9 — encrypted-blob primitive (`/v1/blob/<slotId>`).
|
||||||
|
*
|
||||||
|
* 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<BlobStore & { close?: () => void | Promise<void> }> {
|
||||||
|
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<KeyTransparencyService | undefined> {
|
async function maybeCreateKT(): Promise<KeyTransparencyService | undefined> {
|
||||||
const skPriv = process.env.SHADE_KT_SIGNING_PRIVATE_KEY;
|
const skPriv = process.env.SHADE_KT_SIGNING_PRIVATE_KEY;
|
||||||
const skPub = process.env.SHADE_KT_SIGNING_PUBLIC_KEY;
|
const skPub = process.env.SHADE_KT_SIGNING_PUBLIC_KEY;
|
||||||
@@ -204,6 +242,19 @@ app.route(
|
|||||||
);
|
);
|
||||||
app.route('/', bridgeRoutes.app);
|
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 ──────────────────────────
|
// ─── Optional: Observer + Dashboard ──────────────────────────
|
||||||
|
|
||||||
const observerToken = process.env.SHADE_OBSERVER_TOKEN;
|
const observerToken = process.env.SHADE_OBSERVER_TOKEN;
|
||||||
@@ -278,6 +329,9 @@ async function shutdown(signal: string) {
|
|||||||
if ('close' in inboxStore && typeof inboxStore.close === 'function') {
|
if ('close' in inboxStore && typeof inboxStore.close === 'function') {
|
||||||
await inboxStore.close();
|
await inboxStore.close();
|
||||||
}
|
}
|
||||||
|
if (blobStore && 'close' in blobStore && typeof blobStore.close === 'function') {
|
||||||
|
await blobStore.close();
|
||||||
|
}
|
||||||
logger.info('Shutdown complete');
|
logger.info('Shutdown complete');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-encrypted",
|
"name": "@shade/storage-encrypted",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export {
|
|||||||
deriveNonce,
|
deriveNonce,
|
||||||
buildAad,
|
buildAad,
|
||||||
hkdfDerive,
|
hkdfDerive,
|
||||||
|
deriveBlobSlotId,
|
||||||
|
deriveBlobKey,
|
||||||
|
deriveBlobSigningSeed,
|
||||||
} from './crypto/kdf.js';
|
} from './crypto/kdf.js';
|
||||||
export {
|
export {
|
||||||
AEAD_NONCE_LEN,
|
AEAD_NONCE_LEN,
|
||||||
|
|||||||
@@ -122,3 +122,42 @@ export function deriveNonce(rowKey: Uint8Array, table: string, pk: string): Uint
|
|||||||
export function buildAad(table: string, column: string, pk: string): Uint8Array {
|
export function buildAad(table: string, column: string, pk: string): Uint8Array {
|
||||||
return TEXT.encode(`shade-aad-v1|${table}|${column}|${pk}`);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export {
|
|||||||
deriveNonce,
|
deriveNonce,
|
||||||
buildAad,
|
buildAad,
|
||||||
hkdfDerive,
|
hkdfDerive,
|
||||||
|
deriveBlobSlotId,
|
||||||
|
deriveBlobKey,
|
||||||
|
deriveBlobSigningSeed,
|
||||||
} from './crypto/kdf.js';
|
} from './crypto/kdf.js';
|
||||||
export {
|
export {
|
||||||
AEAD_NONCE_LEN,
|
AEAD_NONCE_LEN,
|
||||||
|
|||||||
50
packages/shade-storage-encrypted/tests/blob-vectors.test.ts
Normal file
50
packages/shade-storage-encrypted/tests/blob-vectors.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-indexeddb",
|
"name": "@shade/storage-indexeddb",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-postgres",
|
"name": "@shade/storage-postgres",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -208,6 +208,30 @@ export async function ensureKTLogTables(sql: Sql): Promise<void> {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V4.9 — encrypted-blob primitive (`/v1/blob/<slotId>`). One row per
|
||||||
|
* slot, keyed on the 64-hex slotId. ETag is a sequence value so it's
|
||||||
|
* unique and monotonic across writers (matches the inbox `received_at`
|
||||||
|
* pattern). The blob column holds base64-encoded AEAD ciphertext —
|
||||||
|
* the relay never decrypts.
|
||||||
|
*/
|
||||||
|
export async function ensureBlobServerTables(sql: Sql): Promise<void> {
|
||||||
|
await sql`CREATE SEQUENCE IF NOT EXISTS shade_blob_seq`;
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS shade_blob_slots (
|
||||||
|
slot_id TEXT PRIMARY KEY,
|
||||||
|
owner_pubkey TEXT NOT NULL,
|
||||||
|
blob TEXT NOT NULL,
|
||||||
|
etag BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS shade_blob_updated_idx
|
||||||
|
ON shade_blob_slots(updated_at)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureInboxServerTables(sql: Sql): Promise<void> {
|
export async function ensureInboxServerTables(sql: Sql): Promise<void> {
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS shade_inbox_owners (
|
CREATE TABLE IF NOT EXISTS shade_inbox_owners (
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ export { PostgresStorage } from './postgres-storage.js';
|
|||||||
export { PostgresPrekeyStore } from './postgres-prekey-store.js';
|
export { PostgresPrekeyStore } from './postgres-prekey-store.js';
|
||||||
export { PostgresInboxStore } from './postgres-inbox-store.js';
|
export { PostgresInboxStore } from './postgres-inbox-store.js';
|
||||||
export { PostgresKTLogStore } from './postgres-kt-store.js';
|
export { PostgresKTLogStore } from './postgres-kt-store.js';
|
||||||
|
export { PostgresBlobStore } from './postgres-blob-store.js';
|
||||||
export {
|
export {
|
||||||
ensureClientTables,
|
ensureClientTables,
|
||||||
ensurePrekeyServerTables,
|
ensurePrekeyServerTables,
|
||||||
ensureInboxServerTables,
|
ensureInboxServerTables,
|
||||||
ensureKTLogTables,
|
ensureKTLogTables,
|
||||||
|
ensureBlobServerTables,
|
||||||
} from './ensure-tables.js';
|
} from './ensure-tables.js';
|
||||||
|
|||||||
140
packages/shade-storage-postgres/src/postgres-blob-store.ts
Normal file
140
packages/shade-storage-postgres/src/postgres-blob-store.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import postgres, { type Sql } from 'postgres';
|
||||||
|
import {
|
||||||
|
ConflictError,
|
||||||
|
PreconditionFailedError,
|
||||||
|
toBase64,
|
||||||
|
fromBase64,
|
||||||
|
} from '@shade/core';
|
||||||
|
import type { BlobStore, BlobSlotRecord, PutBlobResult } from '@shade/inbox-server';
|
||||||
|
import { ensureBlobServerTables } from './ensure-tables.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostgreSQL-backed BlobStore for the V4.9 encrypted-blob primitive.
|
||||||
|
*
|
||||||
|
* CAS is implemented at SQL level using a single UPDATE-with-WHERE
|
||||||
|
* (existing slots) or INSERT (empty slots), wrapped in a transaction so
|
||||||
|
* the read-then-write window can't race. ETag is generated server-side
|
||||||
|
* via `nextval('shade_blob_seq')` so the value is monotonic across
|
||||||
|
* processes — multi-instance deployments share a strict ordering.
|
||||||
|
*/
|
||||||
|
export class PostgresBlobStore implements BlobStore {
|
||||||
|
private constructor(
|
||||||
|
private readonly sql: Sql,
|
||||||
|
private readonly ownsConnection: boolean,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static async create(connectionString: string): Promise<PostgresBlobStore> {
|
||||||
|
const sql = postgres(connectionString);
|
||||||
|
const store = new PostgresBlobStore(sql, true);
|
||||||
|
await ensureBlobServerTables(sql);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fromClient(sql: Sql): Promise<PostgresBlobStore> {
|
||||||
|
const store = new PostgresBlobStore(sql, false);
|
||||||
|
await ensureBlobServerTables(sql);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.ownsConnection) await this.sql.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(slotId: string): Promise<BlobSlotRecord | null> {
|
||||||
|
const rows = await this.sql<
|
||||||
|
Array<{
|
||||||
|
slot_id: string;
|
||||||
|
owner_pubkey: string;
|
||||||
|
blob: string;
|
||||||
|
etag: string;
|
||||||
|
updated_at: string;
|
||||||
|
}>
|
||||||
|
>`
|
||||||
|
SELECT slot_id, owner_pubkey, blob, etag::text, updated_at::text
|
||||||
|
FROM shade_blob_slots WHERE slot_id = ${slotId}
|
||||||
|
`;
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
const r = rows[0]!;
|
||||||
|
return {
|
||||||
|
slotId: r.slot_id,
|
||||||
|
ownerPubkey: fromBase64(r.owner_pubkey),
|
||||||
|
blob: fromBase64(r.blob),
|
||||||
|
etag: parseInt(r.etag, 10),
|
||||||
|
updatedAt: parseInt(r.updated_at, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(args: {
|
||||||
|
slotId: string;
|
||||||
|
blob: Uint8Array;
|
||||||
|
ownerPubkey: Uint8Array;
|
||||||
|
expectedEtag: number | '*' | undefined;
|
||||||
|
now: number;
|
||||||
|
}): Promise<PutBlobResult> {
|
||||||
|
// Wrap in a serializable txn so the read-current → write window
|
||||||
|
// can't race with another writer.
|
||||||
|
return this.sql.begin(async (tx) => {
|
||||||
|
const existing = await tx<Array<{ etag: string }>>`
|
||||||
|
SELECT etag::text FROM shade_blob_slots
|
||||||
|
WHERE slot_id = ${args.slotId}
|
||||||
|
FOR UPDATE
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
if (args.expectedEtag !== undefined) {
|
||||||
|
throw new PreconditionFailedError(
|
||||||
|
`Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const inserted = await tx<Array<{ etag: string }>>`
|
||||||
|
INSERT INTO shade_blob_slots (slot_id, owner_pubkey, blob, etag, updated_at)
|
||||||
|
VALUES (
|
||||||
|
${args.slotId},
|
||||||
|
${toBase64(args.ownerPubkey)},
|
||||||
|
${toBase64(args.blob)},
|
||||||
|
nextval('shade_blob_seq'),
|
||||||
|
${args.now}
|
||||||
|
)
|
||||||
|
RETURNING etag::text
|
||||||
|
`;
|
||||||
|
return {
|
||||||
|
created: true,
|
||||||
|
etag: parseInt(inserted[0]!.etag, 10),
|
||||||
|
updatedAt: args.now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEtag = parseInt(existing[0]!.etag, 10);
|
||||||
|
if (args.expectedEtag === undefined) {
|
||||||
|
throw new ConflictError(
|
||||||
|
`Slot ${args.slotId} already exists; supply ifMatch to update`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (args.expectedEtag !== '*' && args.expectedEtag !== currentEtag) {
|
||||||
|
throw new PreconditionFailedError(
|
||||||
|
`Slot ${args.slotId} etag mismatch: expected=${args.expectedEtag} actual=${currentEtag}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const updated = await tx<Array<{ etag: string }>>`
|
||||||
|
UPDATE shade_blob_slots
|
||||||
|
SET blob = ${toBase64(args.blob)},
|
||||||
|
etag = nextval('shade_blob_seq'),
|
||||||
|
updated_at = ${args.now}
|
||||||
|
WHERE slot_id = ${args.slotId}
|
||||||
|
RETURNING etag::text
|
||||||
|
`;
|
||||||
|
return {
|
||||||
|
created: false,
|
||||||
|
etag: parseInt(updated[0]!.etag, 10),
|
||||||
|
updatedAt: args.now,
|
||||||
|
};
|
||||||
|
}) as Promise<PutBlobResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(slotId: string): Promise<boolean> {
|
||||||
|
const result = await this.sql`
|
||||||
|
DELETE FROM shade_blob_slots WHERE slot_id = ${slotId}
|
||||||
|
`;
|
||||||
|
return result.count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-sqlite",
|
"name": "@shade/storage-sqlite",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { SQLiteStorage } from './sqlite-storage.js';
|
export { SQLiteStorage } from './sqlite-storage.js';
|
||||||
export { SqlitePrekeyStore } from './sqlite-prekey-store.js';
|
export { SqlitePrekeyStore } from './sqlite-prekey-store.js';
|
||||||
export { SqliteInboxStore } from './sqlite-inbox-store.js';
|
export { SqliteInboxStore } from './sqlite-inbox-store.js';
|
||||||
|
export { SqliteBlobStore } from './sqlite-blob-store.js';
|
||||||
|
|||||||
156
packages/shade-storage-sqlite/src/sqlite-blob-store.ts
Normal file
156
packages/shade-storage-sqlite/src/sqlite-blob-store.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import {
|
||||||
|
ConflictError,
|
||||||
|
PreconditionFailedError,
|
||||||
|
toBase64,
|
||||||
|
fromBase64,
|
||||||
|
} from '@shade/core';
|
||||||
|
import type { BlobStore, BlobSlotRecord, PutBlobResult } from '@shade/inbox-server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite-backed BlobStore for the V4.9 encrypted-blob primitive.
|
||||||
|
*
|
||||||
|
* Single-table layout: each slot is a row keyed on the 64-hex slotId,
|
||||||
|
* with the AEAD ciphertext base64-encoded inline. The relay never
|
||||||
|
* decrypts the blob — it only enforces auth + CAS. ETag is a
|
||||||
|
* monotonic per-process counter clamped against `Date.now()` so the
|
||||||
|
* value is unique across writes and useful as a wall-clock hint.
|
||||||
|
*
|
||||||
|
* Docker usage: same volume as the inbox DB by convention; set
|
||||||
|
* `SHADE_BLOB_DB_PATH` (falls back to `/data/shade-blob.db`).
|
||||||
|
*/
|
||||||
|
export class SqliteBlobStore implements BlobStore {
|
||||||
|
private db: Database;
|
||||||
|
private stmts!: {
|
||||||
|
get: ReturnType<Database['prepare']>;
|
||||||
|
insert: ReturnType<Database['prepare']>;
|
||||||
|
update: ReturnType<Database['prepare']>;
|
||||||
|
delete: ReturnType<Database['prepare']>;
|
||||||
|
maxEtag: ReturnType<Database['prepare']>;
|
||||||
|
};
|
||||||
|
private seq = 0;
|
||||||
|
|
||||||
|
constructor(dbPath?: string) {
|
||||||
|
const path = dbPath ?? process.env.SHADE_BLOB_DB_PATH ?? '/data/shade-blob.db';
|
||||||
|
this.db = new Database(path, { create: true });
|
||||||
|
this.db.exec('PRAGMA journal_mode=WAL');
|
||||||
|
this.ensureTables();
|
||||||
|
this.prepareStatements();
|
||||||
|
this.bootstrapSeq();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureTables() {
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS shade_blob_slots (
|
||||||
|
slot_id TEXT PRIMARY KEY,
|
||||||
|
owner_pubkey TEXT NOT NULL,
|
||||||
|
blob TEXT NOT NULL,
|
||||||
|
etag INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shade_blob_updated
|
||||||
|
ON shade_blob_slots(updated_at);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareStatements() {
|
||||||
|
this.stmts = {
|
||||||
|
get: this.db.prepare(
|
||||||
|
'SELECT slot_id, owner_pubkey, blob, etag, updated_at FROM shade_blob_slots WHERE slot_id = ?',
|
||||||
|
),
|
||||||
|
insert: this.db.prepare(
|
||||||
|
'INSERT INTO shade_blob_slots (slot_id, owner_pubkey, blob, etag, updated_at) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
),
|
||||||
|
update: this.db.prepare(
|
||||||
|
'UPDATE shade_blob_slots SET blob = ?, etag = ?, updated_at = ? WHERE slot_id = ?',
|
||||||
|
),
|
||||||
|
delete: this.db.prepare('DELETE FROM shade_blob_slots WHERE slot_id = ?'),
|
||||||
|
maxEtag: this.db.prepare('SELECT MAX(etag) AS max FROM shade_blob_slots'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private bootstrapSeq() {
|
||||||
|
const row = this.stmts.maxEtag.get() as { max: number | null };
|
||||||
|
this.seq = Math.max(row?.max ?? 0, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(slotId: string): Promise<BlobSlotRecord | null> {
|
||||||
|
const row = this.stmts.get.get(slotId) as
|
||||||
|
| {
|
||||||
|
slot_id: string;
|
||||||
|
owner_pubkey: string;
|
||||||
|
blob: string;
|
||||||
|
etag: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
slotId: row.slot_id,
|
||||||
|
ownerPubkey: fromBase64(row.owner_pubkey),
|
||||||
|
blob: fromBase64(row.blob),
|
||||||
|
etag: row.etag,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(args: {
|
||||||
|
slotId: string;
|
||||||
|
blob: Uint8Array;
|
||||||
|
ownerPubkey: Uint8Array;
|
||||||
|
expectedEtag: number | '*' | undefined;
|
||||||
|
now: number;
|
||||||
|
}): Promise<PutBlobResult> {
|
||||||
|
// Read-then-write inside a write transaction so concurrent
|
||||||
|
// CAS attempts can't both observe the same etag.
|
||||||
|
const tx = this.db.transaction((): PutBlobResult => {
|
||||||
|
const existing = this.stmts.get.get(args.slotId) as
|
||||||
|
| { etag: number }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
if (args.expectedEtag !== undefined) {
|
||||||
|
throw new PreconditionFailedError(
|
||||||
|
`Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.seq = Math.max(this.seq + 1, args.now);
|
||||||
|
const etag = this.seq;
|
||||||
|
this.stmts.insert.run(
|
||||||
|
args.slotId,
|
||||||
|
toBase64(args.ownerPubkey),
|
||||||
|
toBase64(args.blob),
|
||||||
|
etag,
|
||||||
|
args.now,
|
||||||
|
);
|
||||||
|
return { created: true, etag, updatedAt: args.now };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.expectedEtag === undefined) {
|
||||||
|
throw new ConflictError(
|
||||||
|
`Slot ${args.slotId} already exists; supply ifMatch to update`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (args.expectedEtag !== '*' && args.expectedEtag !== existing.etag) {
|
||||||
|
throw new PreconditionFailedError(
|
||||||
|
`Slot ${args.slotId} etag mismatch: expected=${args.expectedEtag} actual=${existing.etag}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.seq = Math.max(this.seq + 1, args.now);
|
||||||
|
const etag = this.seq;
|
||||||
|
this.stmts.update.run(toBase64(args.blob), etag, args.now, args.slotId);
|
||||||
|
return { created: false, etag, updatedAt: args.now };
|
||||||
|
});
|
||||||
|
return tx();
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(slotId: string): Promise<boolean> {
|
||||||
|
const result = this.stmts.delete.run(slotId);
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/streams",
|
"name": "@shade/streams",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transfer",
|
"name": "@shade/transfer",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-bridge",
|
"name": "@shade/transport-bridge",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-webrtc",
|
"name": "@shade/transport-webrtc",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport",
|
"name": "@shade/transport",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/widgets",
|
"name": "@shade/widgets",
|
||||||
"version": "4.8.5",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
39
test-vectors/blob-storage.json
Normal file
39
test-vectors/blob-storage.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user