release(v4.9.0): relay-side encrypted blob primitive + SDK Profile namespace
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled

Ships the Prism FR (encrypted-profile-storage-v4.9.md) as a generic
relay-side encrypted blob primitive: deterministically-located,
AEAD-sealed blobs keyed by a 32-byte slotId derived client-side via
HKDF from the user's master key. Unlocks credential-only bootstrap
of new devices into existing E2EE state — no QR, no physical access.

Server: BlobStore interface + Memory/Sqlite/Postgres impls,
createBlobRoutes for GET/PUT/DELETE /v1/blob/:slotId with TOFU pubkey
auth and If-Match CAS (409/412 semantics). Mounted on the same Hono
app as the inbox; SHADE_BLOB_PG_URL / SHADE_BLOB_DB_PATH /
SHADE_DISABLE_BLOB env-var plumbing in standalone.

SDK: createProfileNamespace high-level wrapper (HKDF derivation,
random-nonce AEAD seal, slotId-bound AAD) + low-level BlobClient.
Cross-platform test vectors in test-vectors/blob-storage.json.

New errors: ConflictError (409), PreconditionFailedError (412).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 02:44:42 +02:00
parent 3c0db14904
commit 80c410f518
51 changed files with 2138 additions and 58 deletions

View File

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

View File

@@ -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:*",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/cli",
"version": "4.8.5",
"version": "4.9.0",
"type": "module",
"main": "src/cli.ts",
"bin": {

View File

@@ -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",

View File

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

View File

@@ -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",

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/dashboard",
"version": "4.8.5",
"version": "4.9.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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",

View File

@@ -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",

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

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

View File

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

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

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

View File

@@ -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",

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

View File

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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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:*"
}
}

View File

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

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

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

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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",

View File

@@ -31,6 +31,9 @@ export {
deriveNonce,
buildAad,
hkdfDerive,
deriveBlobSlotId,
deriveBlobKey,
deriveBlobSigningSeed,
} from './crypto/kdf.js';
export {
AEAD_NONCE_LEN,

View File

@@ -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);
}

View File

@@ -16,6 +16,9 @@ export {
deriveNonce,
buildAad,
hkdfDerive,
deriveBlobSlotId,
deriveBlobKey,
deriveBlobSigningSeed,
} from './crypto/kdf.js';
export {
AEAD_NONCE_LEN,

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

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/storage-postgres",
"version": "4.8.5",
"version": "4.9.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -208,6 +208,30 @@ export async function ensureKTLogTables(sql: Sql): Promise<void> {
`;
}
/**
* V4.9 — encrypted-blob primitive (`/v1/blob/<slotId>`). One row per
* slot, keyed on the 64-hex slotId. ETag is a sequence value so it's
* unique and monotonic across writers (matches the inbox `received_at`
* pattern). The blob column holds base64-encoded AEAD ciphertext —
* the relay never decrypts.
*/
export async function ensureBlobServerTables(sql: Sql): Promise<void> {
await sql`CREATE SEQUENCE IF NOT EXISTS shade_blob_seq`;
await sql`
CREATE TABLE IF NOT EXISTS shade_blob_slots (
slot_id TEXT PRIMARY KEY,
owner_pubkey TEXT NOT NULL,
blob TEXT NOT NULL,
etag BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)
`;
await sql`
CREATE INDEX IF NOT EXISTS shade_blob_updated_idx
ON shade_blob_slots(updated_at)
`;
}
export async function ensureInboxServerTables(sql: Sql): Promise<void> {
await sql`
CREATE TABLE IF NOT EXISTS shade_inbox_owners (

View File

@@ -2,9 +2,11 @@ export { PostgresStorage } from './postgres-storage.js';
export { PostgresPrekeyStore } from './postgres-prekey-store.js';
export { PostgresInboxStore } from './postgres-inbox-store.js';
export { PostgresKTLogStore } from './postgres-kt-store.js';
export { PostgresBlobStore } from './postgres-blob-store.js';
export {
ensureClientTables,
ensurePrekeyServerTables,
ensureInboxServerTables,
ensureKTLogTables,
ensureBlobServerTables,
} from './ensure-tables.js';

View File

@@ -0,0 +1,140 @@
import postgres, { type Sql } from 'postgres';
import {
ConflictError,
PreconditionFailedError,
toBase64,
fromBase64,
} from '@shade/core';
import type { BlobStore, BlobSlotRecord, PutBlobResult } from '@shade/inbox-server';
import { ensureBlobServerTables } from './ensure-tables.js';
/**
* PostgreSQL-backed BlobStore for the V4.9 encrypted-blob primitive.
*
* CAS is implemented at SQL level using a single UPDATE-with-WHERE
* (existing slots) or INSERT (empty slots), wrapped in a transaction so
* the read-then-write window can't race. ETag is generated server-side
* via `nextval('shade_blob_seq')` so the value is monotonic across
* processes — multi-instance deployments share a strict ordering.
*/
export class PostgresBlobStore implements BlobStore {
private constructor(
private readonly sql: Sql,
private readonly ownsConnection: boolean,
) {}
static async create(connectionString: string): Promise<PostgresBlobStore> {
const sql = postgres(connectionString);
const store = new PostgresBlobStore(sql, true);
await ensureBlobServerTables(sql);
return store;
}
static async fromClient(sql: Sql): Promise<PostgresBlobStore> {
const store = new PostgresBlobStore(sql, false);
await ensureBlobServerTables(sql);
return store;
}
async close(): Promise<void> {
if (this.ownsConnection) await this.sql.end();
}
async get(slotId: string): Promise<BlobSlotRecord | null> {
const rows = await this.sql<
Array<{
slot_id: string;
owner_pubkey: string;
blob: string;
etag: string;
updated_at: string;
}>
>`
SELECT slot_id, owner_pubkey, blob, etag::text, updated_at::text
FROM shade_blob_slots WHERE slot_id = ${slotId}
`;
if (rows.length === 0) return null;
const r = rows[0]!;
return {
slotId: r.slot_id,
ownerPubkey: fromBase64(r.owner_pubkey),
blob: fromBase64(r.blob),
etag: parseInt(r.etag, 10),
updatedAt: parseInt(r.updated_at, 10),
};
}
async put(args: {
slotId: string;
blob: Uint8Array;
ownerPubkey: Uint8Array;
expectedEtag: number | '*' | undefined;
now: number;
}): Promise<PutBlobResult> {
// Wrap in a serializable txn so the read-current → write window
// can't race with another writer.
return this.sql.begin(async (tx) => {
const existing = await tx<Array<{ etag: string }>>`
SELECT etag::text FROM shade_blob_slots
WHERE slot_id = ${args.slotId}
FOR UPDATE
`;
if (existing.length === 0) {
if (args.expectedEtag !== undefined) {
throw new PreconditionFailedError(
`Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`,
);
}
const inserted = await tx<Array<{ etag: string }>>`
INSERT INTO shade_blob_slots (slot_id, owner_pubkey, blob, etag, updated_at)
VALUES (
${args.slotId},
${toBase64(args.ownerPubkey)},
${toBase64(args.blob)},
nextval('shade_blob_seq'),
${args.now}
)
RETURNING etag::text
`;
return {
created: true,
etag: parseInt(inserted[0]!.etag, 10),
updatedAt: args.now,
};
}
const currentEtag = parseInt(existing[0]!.etag, 10);
if (args.expectedEtag === undefined) {
throw new ConflictError(
`Slot ${args.slotId} already exists; supply ifMatch to update`,
);
}
if (args.expectedEtag !== '*' && args.expectedEtag !== currentEtag) {
throw new PreconditionFailedError(
`Slot ${args.slotId} etag mismatch: expected=${args.expectedEtag} actual=${currentEtag}`,
);
}
const updated = await tx<Array<{ etag: string }>>`
UPDATE shade_blob_slots
SET blob = ${toBase64(args.blob)},
etag = nextval('shade_blob_seq'),
updated_at = ${args.now}
WHERE slot_id = ${args.slotId}
RETURNING etag::text
`;
return {
created: false,
etag: parseInt(updated[0]!.etag, 10),
updatedAt: args.now,
};
}) as Promise<PutBlobResult>;
}
async delete(slotId: string): Promise<boolean> {
const result = await this.sql`
DELETE FROM shade_blob_slots WHERE slot_id = ${slotId}
`;
return result.count > 0;
}
}

View File

@@ -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",

View File

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

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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View 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"
}
]
}