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/),
|
||||
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
|
||||
|
||||
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": {
|
||||
"name": "@shade/cli",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"bin": {
|
||||
"shade": "src/cli.ts",
|
||||
},
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"packages/shade-core": {
|
||||
"name": "@shade/core",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/observability": "workspace:*",
|
||||
},
|
||||
@@ -49,7 +49,7 @@
|
||||
},
|
||||
"packages/shade-crypto-web": {
|
||||
"name": "@shade/crypto-web",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
"packages/shade-dashboard": {
|
||||
"name": "@shade/dashboard",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/widgets": "workspace:*",
|
||||
"react": "^19.0.0",
|
||||
@@ -74,7 +74,7 @@
|
||||
},
|
||||
"packages/shade-files": {
|
||||
"name": "@shade/files",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/crypto-web": "workspace:*",
|
||||
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"packages/shade-inbox": {
|
||||
"name": "@shade/inbox",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/proto": "workspace:*",
|
||||
@@ -114,7 +114,7 @@
|
||||
},
|
||||
"packages/shade-inbox-server": {
|
||||
"name": "@shade/inbox-server",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/observability": "workspace:*",
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"packages/shade-key-transparency": {
|
||||
"name": "@shade/key-transparency",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@shade/core": "workspace:*",
|
||||
@@ -144,11 +144,11 @@
|
||||
},
|
||||
"packages/shade-keychain": {
|
||||
"name": "@shade/keychain",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
},
|
||||
"packages/shade-observability": {
|
||||
"name": "@shade/observability",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
},
|
||||
@@ -166,7 +166,7 @@
|
||||
},
|
||||
"packages/shade-observer": {
|
||||
"name": "@shade/observer",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/server": "workspace:*",
|
||||
@@ -178,14 +178,14 @@
|
||||
},
|
||||
"packages/shade-proto": {
|
||||
"name": "@shade/proto",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/shade-recovery": {
|
||||
"name": "@shade/recovery",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/crypto-web": "workspace:*",
|
||||
@@ -198,22 +198,25 @@
|
||||
},
|
||||
"packages/shade-sdk": {
|
||||
"name": "@shade/sdk",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/crypto-web": "workspace:*",
|
||||
"@shade/files": "workspace:*",
|
||||
"@shade/inbox": "workspace:*",
|
||||
"@shade/key-transparency": "workspace:*",
|
||||
"@shade/observability": "workspace:*",
|
||||
"@shade/observer": "workspace:*",
|
||||
"@shade/proto": "workspace:*",
|
||||
"@shade/server": "workspace:*",
|
||||
"@shade/storage-encrypted": "workspace:*",
|
||||
"@shade/storage-sqlite": "workspace:*",
|
||||
"@shade/streams": "workspace:*",
|
||||
"@shade/transfer": "workspace:*",
|
||||
"@shade/transport": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shade/inbox-server": "workspace:*",
|
||||
"@shade/transport-webrtc": "workspace:*",
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -225,7 +228,7 @@
|
||||
},
|
||||
"packages/shade-server": {
|
||||
"name": "@shade/server",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/inbox-server": "workspace:*",
|
||||
@@ -245,7 +248,7 @@
|
||||
},
|
||||
"packages/shade-storage-encrypted": {
|
||||
"name": "@shade/storage-encrypted",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@shade/core": "workspace:*",
|
||||
@@ -267,7 +270,7 @@
|
||||
},
|
||||
"packages/shade-storage-indexeddb": {
|
||||
"name": "@shade/storage-indexeddb",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"idb": "^8.0.3",
|
||||
@@ -279,7 +282,7 @@
|
||||
},
|
||||
"packages/shade-storage-postgres": {
|
||||
"name": "@shade/storage-postgres",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/inbox-server": "workspace:*",
|
||||
@@ -294,7 +297,7 @@
|
||||
},
|
||||
"packages/shade-storage-sqlite": {
|
||||
"name": "@shade/storage-sqlite",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/crypto-web": "workspace:*",
|
||||
@@ -304,7 +307,7 @@
|
||||
},
|
||||
"packages/shade-streams": {
|
||||
"name": "@shade/streams",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@shade/core": "workspace:*",
|
||||
@@ -316,7 +319,7 @@
|
||||
},
|
||||
"packages/shade-transfer": {
|
||||
"name": "@shade/transfer",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/crypto-web": "workspace:*",
|
||||
@@ -333,7 +336,7 @@
|
||||
},
|
||||
"packages/shade-transport": {
|
||||
"name": "@shade/transport",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/crypto-web": "workspace:*",
|
||||
@@ -344,7 +347,7 @@
|
||||
},
|
||||
"packages/shade-transport-bridge": {
|
||||
"name": "@shade/transport-bridge",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/server": "workspace:*",
|
||||
@@ -366,7 +369,7 @@
|
||||
},
|
||||
"packages/shade-transport-webrtc": {
|
||||
"name": "@shade/transport-webrtc",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/streams": "workspace:*",
|
||||
@@ -375,7 +378,7 @@
|
||||
},
|
||||
"packages/shade-widgets": {
|
||||
"name": "@shade/widgets",
|
||||
"version": "4.4.0",
|
||||
"version": "4.8.5",
|
||||
"dependencies": {
|
||||
"@shade/recovery": "workspace:*",
|
||||
"@shade/sdk": "workspace:*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/cli",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/cli.ts",
|
||||
"bin": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/core",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -158,6 +158,30 @@ export class UnauthorizedError extends ShadeError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 409 Conflict — caller wrote to a resource that already exists without
|
||||
* supplying an If-Match precondition. V4.9: the encrypted blob primitive
|
||||
* uses this to force read-then-write on already-occupied slots.
|
||||
*/
|
||||
export class ConflictError extends ShadeError {
|
||||
constructor(message = 'Conflict') {
|
||||
super('SHADE_CONFLICT', message);
|
||||
this.name = 'ConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 412 Precondition Failed — caller supplied an If-Match etag that does
|
||||
* not match the current state. V4.9: the encrypted blob primitive uses
|
||||
* this to surface stale-CAS so clients can re-read, merge, and retry.
|
||||
*/
|
||||
export class PreconditionFailedError extends ShadeError {
|
||||
constructor(message = 'Precondition failed') {
|
||||
super('SHADE_PRECONDITION_FAILED', message);
|
||||
this.name = 'PreconditionFailedError';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Error → HTTP Status Mapping ────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -180,7 +204,10 @@ export function errorToHttpStatus(error: unknown): number {
|
||||
return 400;
|
||||
case 'SHADE_REPLAY':
|
||||
case 'SHADE_DUPLICATE_MESSAGE':
|
||||
case 'SHADE_CONFLICT':
|
||||
return 409;
|
||||
case 'SHADE_PRECONDITION_FAILED':
|
||||
return 412;
|
||||
case 'SHADE_RATE_LIMIT':
|
||||
return 429;
|
||||
case 'SHADE_TIMEOUT':
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/crypto-web",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
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 { MemoryStorage } from './memory-storage.js';
|
||||
export { ed25519PublicKeyFromSeed } from './ed25519-derive.js';
|
||||
|
||||
// ─── Web Workers crypto (V3.8) ────────────────────────────
|
||||
export {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/dashboard",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/files",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/inbox-server",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
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 { createInboxRoutes, type InboxRoutesOptions } from './routes.js';
|
||||
import { MemoryInboxStore } from './memory-store.js';
|
||||
import type { InboxStore } from './store.js';
|
||||
import { InboxServerEvents } from './events.js';
|
||||
import { createBlobRoutes, type BlobRoutesOptions } from './blob-routes.js';
|
||||
import { MemoryBlobStore } from './memory-blob-store.js';
|
||||
import type { BlobStore } from './blob-store.js';
|
||||
|
||||
export { createInboxRoutes } from './routes.js';
|
||||
export type { InboxRoutesOptions } from './routes.js';
|
||||
@@ -36,6 +39,10 @@ export { PresenceTracker } from './presence.js';
|
||||
export type { TrackedBridgeKind } from './presence.js';
|
||||
export { BridgeDeliveryLog } from './bridge-delivery-log.js';
|
||||
export type { BridgeDeliveryLogOptions } from './bridge-delivery-log.js';
|
||||
export { createBlobRoutes, DEFAULT_MAX_BLOB_BYTES } from './blob-routes.js';
|
||||
export type { BlobRoutesOptions } from './blob-routes.js';
|
||||
export { MemoryBlobStore } from './memory-blob-store.js';
|
||||
export type { BlobStore, BlobSlotRecord, PutBlobResult } from './blob-store.js';
|
||||
|
||||
/**
|
||||
* Create a standalone Shade Inbox Server.
|
||||
@@ -48,12 +55,21 @@ export type { BridgeDeliveryLogOptions } from './bridge-delivery-log.js';
|
||||
* const app = new Hono();
|
||||
* app.route('/', createInboxServer({ crypto }));
|
||||
*/
|
||||
export function createInboxServer(options: {
|
||||
export function createInboxServer(
|
||||
options: {
|
||||
crypto: CryptoProvider;
|
||||
store?: InboxStore;
|
||||
disableRateLimit?: boolean;
|
||||
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 routesOptions: InboxRoutesOptions = {};
|
||||
if (options.disableRateLimit !== undefined) routesOptions.disableRateLimit = options.disableRateLimit;
|
||||
@@ -61,5 +77,23 @@ export function createInboxServer(options: {
|
||||
if (options.observability !== undefined) routesOptions.observability = options.observability;
|
||||
if (options.quota !== undefined) routesOptions.quota = options.quota;
|
||||
if (options.bridgeDeliveryLog !== undefined) routesOptions.bridgeDeliveryLog = options.bridgeDeliveryLog;
|
||||
return createInboxRoutes(store, options.crypto, routesOptions);
|
||||
|
||||
const inboxApp = createInboxRoutes(store, options.crypto, routesOptions);
|
||||
|
||||
// Compose with the blob primitive unless explicitly disabled. The
|
||||
// blob routes share the same Hono app so a single port serves both.
|
||||
if (options.blobStore === null) return inboxApp;
|
||||
const blobStore = options.blobStore ?? new MemoryBlobStore();
|
||||
const blobRoutesOptions: BlobRoutesOptions = {};
|
||||
if (options.disableRateLimit !== undefined) blobRoutesOptions.disableRateLimit = options.disableRateLimit;
|
||||
if (options.observability !== undefined) blobRoutesOptions.observability = options.observability;
|
||||
if (options.blobOptions?.maxBlobBytes !== undefined) {
|
||||
blobRoutesOptions.maxBlobBytes = options.blobOptions.maxBlobBytes;
|
||||
}
|
||||
const blobApp = createBlobRoutes(blobStore, options.crypto, blobRoutesOptions);
|
||||
|
||||
const composed = new Hono();
|
||||
composed.route('/', inboxApp);
|
||||
composed.route('/', blobApp);
|
||||
return composed;
|
||||
}
|
||||
|
||||
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",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "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';
|
||||
|
||||
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",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/keychain",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/observability",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/observer",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/proto",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/recovery",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/sdk",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -8,11 +8,13 @@
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/crypto-web": "workspace:*",
|
||||
"@shade/files": "workspace:*",
|
||||
"@shade/inbox": "workspace:*",
|
||||
"@shade/key-transparency": "workspace:*",
|
||||
"@shade/observability": "workspace:*",
|
||||
"@shade/observer": "workspace:*",
|
||||
"@shade/proto": "workspace:*",
|
||||
"@shade/server": "workspace:*",
|
||||
"@shade/storage-encrypted": "workspace:*",
|
||||
"@shade/storage-sqlite": "workspace:*",
|
||||
"@shade/streams": "workspace:*",
|
||||
"@shade/transfer": "workspace:*",
|
||||
@@ -27,6 +29,7 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shade/inbox-server": "workspace:*",
|
||||
"@shade/transport-webrtc": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,24 @@ export {
|
||||
mainStreamIdForThumbnail,
|
||||
} from '@shade/streams';
|
||||
|
||||
// ─── V4.9 — relay-side encrypted profile storage ──────────
|
||||
export {
|
||||
createProfileNamespace,
|
||||
profilePlaintextToString,
|
||||
deriveBlobSlotId,
|
||||
deriveBlobKey,
|
||||
deriveBlobSigningSeed,
|
||||
ed25519PublicKeyFromSeed,
|
||||
slotIdToHex,
|
||||
} from './profile.js';
|
||||
export type {
|
||||
ProfileNamespace,
|
||||
ProfileNamespaceOptions,
|
||||
ProfileGetResult,
|
||||
ProfilePutOptions,
|
||||
ProfilePutResult,
|
||||
} from './profile.js';
|
||||
|
||||
// ─── Web Workers crypto (V3.8) ─────────────────────────────
|
||||
export {
|
||||
createWorkerCryptoProvider,
|
||||
|
||||
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",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -3,10 +3,13 @@ import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import {
|
||||
createInboxRoutes,
|
||||
createBridgeRoutes,
|
||||
createBlobRoutes,
|
||||
InboxServerEvents,
|
||||
InboxPruneTask,
|
||||
MemoryInboxStore,
|
||||
MemoryBlobStore,
|
||||
type InboxStore,
|
||||
type BlobStore,
|
||||
} from '@shade/inbox-server';
|
||||
import { createPrekeyRoutes } from './routes.js';
|
||||
import { createHealthRoutes } from './health.js';
|
||||
@@ -71,6 +74,41 @@ async function createInboxStore(): Promise<InboxStore & { close?: () => void | P
|
||||
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> {
|
||||
const skPriv = process.env.SHADE_KT_SIGNING_PRIVATE_KEY;
|
||||
const skPub = process.env.SHADE_KT_SIGNING_PUBLIC_KEY;
|
||||
@@ -204,6 +242,19 @@ app.route(
|
||||
);
|
||||
app.route('/', bridgeRoutes.app);
|
||||
|
||||
// V4.9 — encrypted-blob primitive. Powers Prism's credential-driven
|
||||
// device-linking (Phase 2) and any other Shade app that needs a
|
||||
// "sign in from any device" UX. Mounted on the same Hono app so a
|
||||
// single relay process serves prekey + inbox + blob from one port.
|
||||
const blobDisabled = process.env.SHADE_DISABLE_BLOB === '1';
|
||||
const blobStore = blobDisabled ? null : await createBlobStore();
|
||||
if (blobDisabled) {
|
||||
logger.info('Blob primitive disabled (SHADE_DISABLE_BLOB=1)');
|
||||
} else if (blobStore) {
|
||||
app.route('/', createBlobRoutes(blobStore, crypto, { disableRateLimit }));
|
||||
logger.info('Blob primitive enabled', { route: '/v1/blob/:slotId' });
|
||||
}
|
||||
|
||||
// ─── Optional: Observer + Dashboard ──────────────────────────
|
||||
|
||||
const observerToken = process.env.SHADE_OBSERVER_TOKEN;
|
||||
@@ -278,6 +329,9 @@ async function shutdown(signal: string) {
|
||||
if ('close' in inboxStore && typeof inboxStore.close === 'function') {
|
||||
await inboxStore.close();
|
||||
}
|
||||
if (blobStore && 'close' in blobStore && typeof blobStore.close === 'function') {
|
||||
await blobStore.close();
|
||||
}
|
||||
logger.info('Shutdown complete');
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/storage-encrypted",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -31,6 +31,9 @@ export {
|
||||
deriveNonce,
|
||||
buildAad,
|
||||
hkdfDerive,
|
||||
deriveBlobSlotId,
|
||||
deriveBlobKey,
|
||||
deriveBlobSigningSeed,
|
||||
} from './crypto/kdf.js';
|
||||
export {
|
||||
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 {
|
||||
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,
|
||||
buildAad,
|
||||
hkdfDerive,
|
||||
deriveBlobSlotId,
|
||||
deriveBlobKey,
|
||||
deriveBlobSigningSeed,
|
||||
} from './crypto/kdf.js';
|
||||
export {
|
||||
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",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/storage-postgres",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -208,6 +208,30 @@ export async function ensureKTLogTables(sql: Sql): Promise<void> {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* V4.9 — encrypted-blob primitive (`/v1/blob/<slotId>`). One row per
|
||||
* slot, keyed on the 64-hex slotId. ETag is a sequence value so it's
|
||||
* unique and monotonic across writers (matches the inbox `received_at`
|
||||
* pattern). The blob column holds base64-encoded AEAD ciphertext —
|
||||
* the relay never decrypts.
|
||||
*/
|
||||
export async function ensureBlobServerTables(sql: Sql): Promise<void> {
|
||||
await sql`CREATE SEQUENCE IF NOT EXISTS shade_blob_seq`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS shade_blob_slots (
|
||||
slot_id TEXT PRIMARY KEY,
|
||||
owner_pubkey TEXT NOT NULL,
|
||||
blob TEXT NOT NULL,
|
||||
etag BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS shade_blob_updated_idx
|
||||
ON shade_blob_slots(updated_at)
|
||||
`;
|
||||
}
|
||||
|
||||
export async function ensureInboxServerTables(sql: Sql): Promise<void> {
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS shade_inbox_owners (
|
||||
|
||||
@@ -2,9 +2,11 @@ export { PostgresStorage } from './postgres-storage.js';
|
||||
export { PostgresPrekeyStore } from './postgres-prekey-store.js';
|
||||
export { PostgresInboxStore } from './postgres-inbox-store.js';
|
||||
export { PostgresKTLogStore } from './postgres-kt-store.js';
|
||||
export { PostgresBlobStore } from './postgres-blob-store.js';
|
||||
export {
|
||||
ensureClientTables,
|
||||
ensurePrekeyServerTables,
|
||||
ensureInboxServerTables,
|
||||
ensureKTLogTables,
|
||||
ensureBlobServerTables,
|
||||
} from './ensure-tables.js';
|
||||
|
||||
140
packages/shade-storage-postgres/src/postgres-blob-store.ts
Normal file
140
packages/shade-storage-postgres/src/postgres-blob-store.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import postgres, { type Sql } from 'postgres';
|
||||
import {
|
||||
ConflictError,
|
||||
PreconditionFailedError,
|
||||
toBase64,
|
||||
fromBase64,
|
||||
} from '@shade/core';
|
||||
import type { BlobStore, BlobSlotRecord, PutBlobResult } from '@shade/inbox-server';
|
||||
import { ensureBlobServerTables } from './ensure-tables.js';
|
||||
|
||||
/**
|
||||
* PostgreSQL-backed BlobStore for the V4.9 encrypted-blob primitive.
|
||||
*
|
||||
* CAS is implemented at SQL level using a single UPDATE-with-WHERE
|
||||
* (existing slots) or INSERT (empty slots), wrapped in a transaction so
|
||||
* the read-then-write window can't race. ETag is generated server-side
|
||||
* via `nextval('shade_blob_seq')` so the value is monotonic across
|
||||
* processes — multi-instance deployments share a strict ordering.
|
||||
*/
|
||||
export class PostgresBlobStore implements BlobStore {
|
||||
private constructor(
|
||||
private readonly sql: Sql,
|
||||
private readonly ownsConnection: boolean,
|
||||
) {}
|
||||
|
||||
static async create(connectionString: string): Promise<PostgresBlobStore> {
|
||||
const sql = postgres(connectionString);
|
||||
const store = new PostgresBlobStore(sql, true);
|
||||
await ensureBlobServerTables(sql);
|
||||
return store;
|
||||
}
|
||||
|
||||
static async fromClient(sql: Sql): Promise<PostgresBlobStore> {
|
||||
const store = new PostgresBlobStore(sql, false);
|
||||
await ensureBlobServerTables(sql);
|
||||
return store;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.ownsConnection) await this.sql.end();
|
||||
}
|
||||
|
||||
async get(slotId: string): Promise<BlobSlotRecord | null> {
|
||||
const rows = await this.sql<
|
||||
Array<{
|
||||
slot_id: string;
|
||||
owner_pubkey: string;
|
||||
blob: string;
|
||||
etag: string;
|
||||
updated_at: string;
|
||||
}>
|
||||
>`
|
||||
SELECT slot_id, owner_pubkey, blob, etag::text, updated_at::text
|
||||
FROM shade_blob_slots WHERE slot_id = ${slotId}
|
||||
`;
|
||||
if (rows.length === 0) return null;
|
||||
const r = rows[0]!;
|
||||
return {
|
||||
slotId: r.slot_id,
|
||||
ownerPubkey: fromBase64(r.owner_pubkey),
|
||||
blob: fromBase64(r.blob),
|
||||
etag: parseInt(r.etag, 10),
|
||||
updatedAt: parseInt(r.updated_at, 10),
|
||||
};
|
||||
}
|
||||
|
||||
async put(args: {
|
||||
slotId: string;
|
||||
blob: Uint8Array;
|
||||
ownerPubkey: Uint8Array;
|
||||
expectedEtag: number | '*' | undefined;
|
||||
now: number;
|
||||
}): Promise<PutBlobResult> {
|
||||
// Wrap in a serializable txn so the read-current → write window
|
||||
// can't race with another writer.
|
||||
return this.sql.begin(async (tx) => {
|
||||
const existing = await tx<Array<{ etag: string }>>`
|
||||
SELECT etag::text FROM shade_blob_slots
|
||||
WHERE slot_id = ${args.slotId}
|
||||
FOR UPDATE
|
||||
`;
|
||||
|
||||
if (existing.length === 0) {
|
||||
if (args.expectedEtag !== undefined) {
|
||||
throw new PreconditionFailedError(
|
||||
`Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`,
|
||||
);
|
||||
}
|
||||
const inserted = await tx<Array<{ etag: string }>>`
|
||||
INSERT INTO shade_blob_slots (slot_id, owner_pubkey, blob, etag, updated_at)
|
||||
VALUES (
|
||||
${args.slotId},
|
||||
${toBase64(args.ownerPubkey)},
|
||||
${toBase64(args.blob)},
|
||||
nextval('shade_blob_seq'),
|
||||
${args.now}
|
||||
)
|
||||
RETURNING etag::text
|
||||
`;
|
||||
return {
|
||||
created: true,
|
||||
etag: parseInt(inserted[0]!.etag, 10),
|
||||
updatedAt: args.now,
|
||||
};
|
||||
}
|
||||
|
||||
const currentEtag = parseInt(existing[0]!.etag, 10);
|
||||
if (args.expectedEtag === undefined) {
|
||||
throw new ConflictError(
|
||||
`Slot ${args.slotId} already exists; supply ifMatch to update`,
|
||||
);
|
||||
}
|
||||
if (args.expectedEtag !== '*' && args.expectedEtag !== currentEtag) {
|
||||
throw new PreconditionFailedError(
|
||||
`Slot ${args.slotId} etag mismatch: expected=${args.expectedEtag} actual=${currentEtag}`,
|
||||
);
|
||||
}
|
||||
const updated = await tx<Array<{ etag: string }>>`
|
||||
UPDATE shade_blob_slots
|
||||
SET blob = ${toBase64(args.blob)},
|
||||
etag = nextval('shade_blob_seq'),
|
||||
updated_at = ${args.now}
|
||||
WHERE slot_id = ${args.slotId}
|
||||
RETURNING etag::text
|
||||
`;
|
||||
return {
|
||||
created: false,
|
||||
etag: parseInt(updated[0]!.etag, 10),
|
||||
updatedAt: args.now,
|
||||
};
|
||||
}) as Promise<PutBlobResult>;
|
||||
}
|
||||
|
||||
async delete(slotId: string): Promise<boolean> {
|
||||
const result = await this.sql`
|
||||
DELETE FROM shade_blob_slots WHERE slot_id = ${slotId}
|
||||
`;
|
||||
return result.count > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/storage-sqlite",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { SQLiteStorage } from './sqlite-storage.js';
|
||||
export { SqlitePrekeyStore } from './sqlite-prekey-store.js';
|
||||
export { SqliteInboxStore } from './sqlite-inbox-store.js';
|
||||
export { SqliteBlobStore } from './sqlite-blob-store.js';
|
||||
|
||||
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",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/transfer",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/transport-bridge",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/transport-webrtc",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/transport",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/widgets",
|
||||
"version": "4.8.5",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
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