release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View File

@@ -0,0 +1,16 @@
{
"name": "@shade/inbox",
"version": "4.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"dependencies": {
"@shade/core": "workspace:*",
"@shade/proto": "workspace:*",
"@shade/server": "workspace:*"
},
"devDependencies": {
"@shade/crypto-web": "workspace:*",
"@shade/inbox-server": "workspace:*"
}
}

View File

@@ -0,0 +1,219 @@
import type { CryptoProvider, ShadeEnvelope } from '@shade/core';
import {
NetworkError,
toBase64,
fromBase64,
ShadeError,
ValidationError,
} from '@shade/core';
import { signPayload } from '@shade/server';
import { encodeEnvelope, decodeEnvelope } from '@shade/proto';
import { computeMsgId } from './msg-id.js';
/**
* Low-level HTTP client for `@shade/inbox-server`.
*
* Stateless and reusable across many recipients. Higher-level orchestration
* (queue, poll loop, ack-on-decrypt) lives in `Inbox` (see `inbox.ts`),
* which composes this client.
*/
export interface InboxClientOptions {
baseUrl: string;
crypto: CryptoProvider;
/** Used to sign requests on behalf of *this* identity. */
signingPrivateKey: Uint8Array;
/** Optional fetch override (defaults to globalThis.fetch). */
fetch?: typeof fetch;
}
export interface PutResult {
msgId: string;
receivedAt: number;
idempotent: boolean;
}
export interface FetchedBlob {
msgId: string;
/** Wire-encoded ShadeEnvelope bytes. */
ciphertext: Uint8Array;
/** Server-assigned monotonic timestamp; pass back as `sinceCursor`. */
receivedAt: number;
/** Absolute expiry time (ms since epoch) reported by the server. */
expiresAt: number;
}
export interface FetchResult {
blobs: FetchedBlob[];
cursor: number;
hasMore: boolean;
}
export class InboxClient {
private readonly fetchImpl: typeof fetch;
constructor(private readonly options: InboxClientOptions) {
this.fetchImpl = options.fetch ?? globalThis.fetch;
}
/**
* TOFU-register the address that this signing key will own.
* Idempotent if the same key re-registers; rejected by the server if a
* different key has already claimed the address.
*/
async register(args: {
address: string;
signingKey: Uint8Array;
}): Promise<void> {
const body = await signPayload(this.options.crypto, this.options.signingPrivateKey, {
address: args.address,
signingKey: toBase64(args.signingKey),
});
await this.postJson(`/v1/inbox/register`, body);
}
/**
* Unregister the address. Drops every queued blob. Signed by the
* registered signing key.
*/
async unregister(address: string): Promise<void> {
const body = await signPayload(this.options.crypto, this.options.signingPrivateKey, {
address,
});
await this.requestJson('DELETE', `/v1/inbox/register/${encodeURIComponent(address)}`, body);
}
/**
* PUT a ShadeEnvelope to a recipient's inbox. Idempotent: same
* ciphertext yields the same msgId and the server folds the second PUT
* into a 200 with `idempotent: true`.
*/
async put(args: {
/** Recipient address (inbox owner). */
recipientAddress: string;
/** Sender's identity signing public key (the one matching `signingPrivateKey`). */
senderSigningKey: Uint8Array;
/** Encrypted Shade envelope. Either pre-encoded bytes or a parsed envelope. */
envelope: ShadeEnvelope | Uint8Array;
ttlSeconds?: number;
}): Promise<PutResult> {
const ciphertext =
args.envelope instanceof Uint8Array ? args.envelope : encodeEnvelope(args.envelope);
if (ciphertext.length === 0) {
throw new ValidationError('Empty ciphertext');
}
const msgId = await computeMsgId(ciphertext);
const payload: Record<string, unknown> = {
senderSigningKey: toBase64(args.senderSigningKey),
msgId,
ciphertext: toBase64(ciphertext),
};
if (args.ttlSeconds !== undefined) payload.ttlSeconds = args.ttlSeconds;
const signed = await signPayload(
this.options.crypto,
this.options.signingPrivateKey,
payload,
);
const json = await this.postJson(
`/v1/inbox/${encodeURIComponent(args.recipientAddress)}`,
signed,
);
return {
msgId: String(json.msgId),
receivedAt: Number(json.receivedAt),
idempotent: Boolean(json.idempotent),
};
}
/**
* Fetch all blobs for `address` whose `received_at > sinceCursor`.
* Returns at most one server page; clients keep calling with the new
* cursor until `hasMore === false`.
*/
async fetch(args: { address: string; sinceCursor?: number }): Promise<FetchResult> {
const sinceCursor = args.sinceCursor ?? 0;
const body = await signPayload(this.options.crypto, this.options.signingPrivateKey, {
address: args.address,
sinceCursor,
});
const json = await this.postJson(
`/v1/inbox/${encodeURIComponent(args.address)}/fetch`,
body,
);
const blobs = Array.isArray(json.blobs) ? json.blobs : [];
return {
blobs: blobs.map((b: any) => ({
msgId: String(b.msgId),
ciphertext: fromBase64(String(b.ciphertext)),
receivedAt: Number(b.receivedAt),
expiresAt: Number(b.expiresAt),
})),
cursor: Number(json.cursor ?? sinceCursor),
hasMore: Boolean(json.hasMore),
};
}
/**
* Acknowledge — delete a fetched blob. Should be called after the
* caller has successfully decrypted (or persisted) the message.
*/
async ack(args: { address: string; msgId: string }): Promise<boolean> {
const body = await signPayload(this.options.crypto, this.options.signingPrivateKey, {
address: args.address,
msgId: args.msgId,
});
const json = await this.requestJson(
'DELETE',
`/v1/inbox/${encodeURIComponent(args.address)}/${encodeURIComponent(args.msgId)}`,
body,
);
return Boolean(json.ok);
}
// ─── HTTP plumbing ──────────────────────────────────────────
private async postJson(path: string, body: unknown): Promise<any> {
return this.requestJson('POST', path, body);
}
private async requestJson(method: string, path: string, body: unknown): Promise<any> {
const url = joinUrl(this.options.baseUrl, path);
let res: Response;
try {
res = await this.fetchImpl(url, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
} catch (err) {
throw new NetworkError(`Inbox 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(`Inbox response not JSON: ${text.slice(0, 200)}`, res.status);
}
if (!res.ok) {
// Surface server-mapped Shade errors in their original shape.
const code = String(json.code ?? '');
const message = String(json.message ?? text);
throw new ShadeError(code || 'SHADE_NETWORK', message);
}
return json;
}
}
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;
}
/** Decode a fetched blob into a ShadeEnvelope. */
export function decodeFetchedEnvelope(b: FetchedBlob): ShadeEnvelope {
return decodeEnvelope(b.ciphertext);
}

View File

@@ -0,0 +1,26 @@
/**
* Persistent receive cursor.
*
* Tracks the highest `received_at` we've consumed from the inbox per
* (ownAddress, ourSelf-poll context). The InboxClient pulls everything
* strictly greater than this on each poll.
*
* The default in-memory implementation is sufficient for short-lived
* processes (CLIs, tests). Long-lived services should plug in a SQLite,
* Postgres, or IndexedDB backed store so a restart doesn't redownload all
* messages still in TTL.
*/
export interface CursorStore {
load(address: string): Promise<number>;
save(address: string, cursor: number): Promise<void>;
}
export class MemoryCursorStore implements CursorStore {
private cursors = new Map<string, number>();
async load(address: string): Promise<number> {
return this.cursors.get(address) ?? 0;
}
async save(address: string, cursor: number): Promise<void> {
this.cursors.set(address, cursor);
}
}

View File

@@ -0,0 +1,72 @@
/**
* Client-side inbox event emitter.
*
* The high-level `Inbox` orchestrator emits structural events — message
* queued, delivered, polled, decrypted — so apps can drive UI badges,
* push hooks, or telemetry without polling internal state.
*/
export interface InboxClientEventMap {
/**
* A new ciphertext entry was added to the outgoing queue. The push-hook
* mentioned in the V3.6 spec dispatches off this event.
*/
'inbox.message_queued': {
recipientAddress: string;
msgId: string;
bytes: number;
ttlSeconds: number;
};
/** Server confirmed a queued blob landed. */
'inbox.message_delivered': {
recipientAddress: string;
msgId: string;
idempotent: boolean;
};
/** Delivery attempt failed; will retry on next flush. */
'inbox.message_failed': {
recipientAddress: string;
msgId: string;
attempts: number;
error: string;
};
/** A poll cycle completed and pulled `count` blobs. */
'inbox.poll_completed': { ownAddress: string; count: number; cursor: number };
/** Caller successfully decrypted and acked a blob. */
'inbox.message_received': {
senderHint: string | null;
msgId: string;
};
/** Caller failed to decrypt — typically tampering or stale ratchet. */
'inbox.message_decrypt_failed': {
msgId: string;
error: string;
};
}
export type InboxClientEventName = keyof InboxClientEventMap;
export type InboxClientEvent = {
[K in InboxClientEventName]: { name: K; data: InboxClientEventMap[K]; timestamp: number };
}[InboxClientEventName];
export type InboxClientListener = (e: InboxClientEvent) => void;
export class InboxClientEvents {
private readonly listeners = new Set<InboxClientListener>();
on(listener: InboxClientListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
emit<K extends InboxClientEventName>(name: K, data: InboxClientEventMap[K]): void {
const event = { name, data, timestamp: Date.now() } as InboxClientEvent;
for (const l of this.listeners) {
try {
l(event);
} catch (err) {
console.error('[Shade] Inbox client listener threw:', err);
}
}
}
}

View File

@@ -0,0 +1,407 @@
import type { CryptoProvider, ShadeEnvelope } from '@shade/core';
import { encodeEnvelope } from '@shade/proto';
import { InboxClient, type FetchedBlob } from './client.js';
import {
MemoryOutgoingQueueStore,
type OutgoingEntry,
type OutgoingQueueStore,
} from './queue-store.js';
import { MemoryCursorStore, type CursorStore } from './cursor-store.js';
import { computeMsgId } from './msg-id.js';
import { InboxClientEvents, type InboxClientListener } from './events.js';
/**
* Caller-supplied incoming-blob handler. Receives raw wire bytes; the app
* is expected to call `decodeEnvelope` + `Shade.receive` (or whatever
* decrypt path it owns) and either return a sender-hint for telemetry
* (the address the SDK extracted, or `null`) or throw to keep the blob
* on the server for a later retry.
*/
export type DecryptHandler = (
raw: { msgId: string; ciphertext: Uint8Array; receivedAt: number; expiresAt: number },
) => Promise<string | null | undefined> | string | null | undefined;
export interface InboxOptions {
/** Inbox-server base URL (e.g. "https://inbox.example.com"). */
baseUrl: string;
/** Recipient address — the address that owns this queue. */
ownAddress: string;
/** Crypto provider — used for signing requests. */
crypto: CryptoProvider;
/** Identity signing private key. */
signingPrivateKey: Uint8Array;
/** Identity signing public key. */
signingPublicKey: Uint8Array;
/**
* Default TTL for outgoing PUTs (seconds). The server clamps to its own
* min/max. Defaults to 7 days as mandated by the V3.6 spec.
*/
defaultTtlSeconds?: number;
/** Polling interval (ms). Default: 30s. Set to 0 to disable auto-poll. */
pollIntervalMs?: number;
/** Outgoing queue persistence. Defaults to in-memory. */
queueStore?: OutgoingQueueStore;
/** Cursor persistence. Defaults to in-memory. */
cursorStore?: CursorStore;
/** Override the underlying fetch (tests). */
fetch?: typeof fetch;
/** Maximum delivery attempts before dropping a queued entry. Default: 10. */
maxAttempts?: number;
}
const DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60;
const DEFAULT_POLL_INTERVAL_MS = 30_000;
const DEFAULT_MAX_ATTEMPTS = 10;
/**
* High-level inbox orchestrator.
*
* Responsibilities:
* - Outgoing: serialize ciphertext → queue → flush to server with retry.
* - Incoming: poll → decrypt-handler → ack on success.
* - Push hook: `onMessageQueued(handler)` lets a transport vendor (FCM,
* APNs) wake the recipient out-of-band when a blob is enqueued for them
* here. The hook is called with the recipient address — the actual
* push-delivery wire is left to the integrator.
*
* The class never *encrypts* — that's `Shade.send`'s job. Apps wire it up
* like:
*
* const envelope = await shade.send(bob, "hi");
* await inbox.send(bob, envelope);
*
* On Bob's device:
*
* inbox.onIncoming(async (env) => {
* await shade.receive(senderAddress, env);
* });
* inbox.start();
*/
export class Inbox {
private readonly client: InboxClient;
private readonly queueStore: OutgoingQueueStore;
private readonly cursorStore: CursorStore;
private readonly events = new InboxClientEvents();
private readonly defaultTtlSeconds: number;
private readonly pollIntervalMs: number;
private readonly maxAttempts: number;
private incomingHandler: DecryptHandler | null = null;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private pollTimer: ReturnType<typeof setTimeout> | null = null;
private flushing = false;
private polling = false;
private started = false;
private registered = false;
constructor(private readonly options: InboxOptions) {
const clientOptions: ConstructorParameters<typeof InboxClient>[0] = {
baseUrl: options.baseUrl,
crypto: options.crypto,
signingPrivateKey: options.signingPrivateKey,
};
if (options.fetch !== undefined) clientOptions.fetch = options.fetch;
this.client = new InboxClient(clientOptions);
this.queueStore = options.queueStore ?? new MemoryOutgoingQueueStore();
this.cursorStore = options.cursorStore ?? new MemoryCursorStore();
this.defaultTtlSeconds = options.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS;
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
}
/** Subscribe to client events (queued / delivered / received / failed). */
on(listener: InboxClientListener): () => void {
return this.events.on(listener);
}
/**
* Push-hook: invoked once per new blob enqueued for delivery. Vendors
* implement FCM/APNs/email wake-ups inside the handler. The handler
* fires *after* the blob is durably in the local queue but before the
* server has confirmed PUT.
*/
onMessageQueued(handler: (recipientAddress: string, msgId: string) => void | Promise<void>): () => void {
return this.events.on((e) => {
if (e.name === 'inbox.message_queued') {
Promise.resolve(handler(e.data.recipientAddress, e.data.msgId)).catch((err) => {
console.error('[Shade] onMessageQueued handler threw:', err);
});
}
});
}
/** Register a handler that processes incoming blobs. Replaces any prior. */
onIncoming(handler: DecryptHandler): () => void {
this.incomingHandler = handler;
return () => {
if (this.incomingHandler === handler) this.incomingHandler = null;
};
}
/**
* Idempotent TOFU registration with the server. Called automatically by
* `start()`; can also be invoked directly e.g. during onboarding.
*/
async register(): Promise<void> {
if (this.registered) return;
await this.client.register({
address: this.options.ownAddress,
signingKey: this.options.signingPublicKey,
});
this.registered = true;
}
/** Drop the address from the server. Local queue/cursor are preserved. */
async unregister(): Promise<void> {
await this.client.unregister(this.options.ownAddress);
this.registered = false;
}
/**
* Enqueue an envelope for delivery. The actual PUT happens
* asynchronously — the call returns once the entry is durably in the
* outgoing queue. Returns the deterministic msgId.
*
* `envelope` accepts a `ShadeEnvelope` (the type returned from
* `Shade.send`) or pre-encoded wire bytes (`Uint8Array`).
*/
async send(args: {
recipientAddress: string;
envelope: ShadeEnvelope | Uint8Array;
ttlSeconds?: number;
}): Promise<string> {
const ciphertext =
args.envelope instanceof Uint8Array ? args.envelope : encodeEnvelope(args.envelope);
const msgId = await computeMsgId(ciphertext);
const ttlSeconds = args.ttlSeconds ?? this.defaultTtlSeconds;
const entry: OutgoingEntry = {
recipientAddress: args.recipientAddress,
msgId,
ciphertext,
ttlSeconds,
queuedAt: Date.now(),
attempts: 0,
};
await this.queueStore.enqueue(entry);
this.events.emit('inbox.message_queued', {
recipientAddress: args.recipientAddress,
msgId,
bytes: ciphertext.length,
ttlSeconds,
});
// Kick the flush loop. Don't await — caller doesn't need to block on
// network for a "queued" return.
this.scheduleFlush();
return msgId;
}
/**
* Force a flush + poll cycle now (useful right after registering or
* after a push-trigger arrives). Does not throw on transient errors.
*/
async tick(): Promise<{ flushed: number; received: number }> {
const flushed = await this.flushOnce();
const received = await this.pollOnce();
return { flushed, received };
}
/** Start background flush + poll timers. Idempotent. */
start(): void {
if (this.started) return;
this.started = true;
this.register().catch((err) => {
console.warn('[Shade] Inbox register failed (will retry):', (err as Error).message);
this.scheduleRegisterRetry();
});
this.scheduleFlush();
this.schedulePoll(0);
}
/** Stop background timers. Pending entries remain in the queue. */
stop(): void {
this.started = false;
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
if (this.pollTimer) {
clearTimeout(this.pollTimer);
this.pollTimer = null;
}
}
/** Number of entries currently waiting to be flushed. */
async pendingCount(): Promise<number> {
return this.queueStore.size();
}
// ─── internals ──────────────────────────────────────────────
private scheduleRegisterRetry(): void {
if (!this.started) return;
setTimeout(() => {
if (!this.started) return;
this.register().catch(() => this.scheduleRegisterRetry());
}, 5000);
}
private scheduleFlush(delayMs = 0): void {
if (this.flushTimer) return;
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
this.flushOnce()
.then(() => {
// If anything is still queued, retry with backoff.
this.queueStore.size().then((n) => {
if (n > 0 && this.started) this.scheduleFlush(15_000);
});
})
.catch(() => {
if (this.started) this.scheduleFlush(15_000);
});
}, delayMs);
}
private schedulePoll(delayMs: number): void {
if (!this.started || this.pollIntervalMs === 0) return;
if (this.pollTimer) clearTimeout(this.pollTimer);
this.pollTimer = setTimeout(() => {
this.pollTimer = null;
this.pollOnce()
.catch(() => {})
.finally(() => this.schedulePoll(this.pollIntervalMs));
}, delayMs);
}
private async flushOnce(): Promise<number> {
if (this.flushing) return 0;
this.flushing = true;
let delivered = 0;
try {
const entries = await this.queueStore.list();
for (const entry of entries) {
try {
const result = await this.client.put({
recipientAddress: entry.recipientAddress,
senderSigningKey: this.options.signingPublicKey,
envelope: entry.ciphertext,
ttlSeconds: entry.ttlSeconds,
});
await this.queueStore.remove(entry.recipientAddress, entry.msgId);
delivered++;
this.events.emit('inbox.message_delivered', {
recipientAddress: entry.recipientAddress,
msgId: result.msgId,
idempotent: result.idempotent,
});
} catch (err) {
await this.queueStore.bumpAttempts(entry.recipientAddress, entry.msgId);
const attempts = entry.attempts + 1;
this.events.emit('inbox.message_failed', {
recipientAddress: entry.recipientAddress,
msgId: entry.msgId,
attempts,
error: (err as Error).message,
});
if (attempts >= this.maxAttempts) {
await this.queueStore.remove(entry.recipientAddress, entry.msgId);
}
}
}
} finally {
this.flushing = false;
}
return delivered;
}
private async pollOnce(): Promise<number> {
if (this.polling) return 0;
if (!this.incomingHandler) return 0;
this.polling = true;
let total = 0;
try {
let cursor = await this.cursorStore.load(this.options.ownAddress);
// Pull all pages.
// eslint-disable-next-line no-constant-condition
while (true) {
let page;
try {
page = await this.client.fetch({
address: this.options.ownAddress,
sinceCursor: cursor,
});
} catch (err) {
// Surface but don't crash the loop.
console.warn('[Shade] Inbox fetch failed:', (err as Error).message);
break;
}
for (const blob of page.blobs) {
const handled = await this.handleBlob(blob);
if (handled) total++;
if (blob.receivedAt > cursor) cursor = blob.receivedAt;
}
await this.cursorStore.save(this.options.ownAddress, cursor);
this.events.emit('inbox.poll_completed', {
ownAddress: this.options.ownAddress,
count: page.blobs.length,
cursor,
});
if (!page.hasMore) break;
}
} finally {
this.polling = false;
}
return total;
}
private async handleBlob(blob: FetchedBlob): Promise<boolean> {
if (!this.incomingHandler) return false;
// Defense-in-depth: verify msgId ↔ ciphertext at the client too. A
// server bug or malicious operator can't sneak a different blob past
// the client's hash check.
const recomputed = await computeMsgId(blob.ciphertext);
if (recomputed !== blob.msgId) {
this.events.emit('inbox.message_decrypt_failed', {
msgId: blob.msgId,
error: 'msgId/ciphertext mismatch',
});
// Don't ack — let the operator notice the divergence.
return false;
}
let senderHint: string | null = null;
try {
const result = await this.incomingHandler({
msgId: blob.msgId,
ciphertext: blob.ciphertext,
receivedAt: blob.receivedAt,
expiresAt: blob.expiresAt,
});
senderHint = result ?? null;
} catch (err) {
this.events.emit('inbox.message_decrypt_failed', {
msgId: blob.msgId,
error: (err as Error).message,
});
// Don't ack — caller can retry on next poll.
return false;
}
try {
await this.client.ack({ address: this.options.ownAddress, msgId: blob.msgId });
} catch (err) {
// Decryption succeeded; ack just failed. Will be retried later, and
// the duplicate-message ratchet check on `Shade.receive` will dedupe.
console.warn('[Shade] Inbox ack failed (will retry):', (err as Error).message);
}
this.events.emit('inbox.message_received', {
senderHint,
msgId: blob.msgId,
});
return true;
}
}

View File

@@ -0,0 +1,45 @@
export {
InboxClient,
decodeFetchedEnvelope,
} from './client.js';
export type {
InboxClientOptions,
PutResult,
FetchedBlob,
FetchResult,
} from './client.js';
export {
Inbox,
} from './inbox.js';
export type {
InboxOptions,
DecryptHandler,
} from './inbox.js';
export {
MemoryOutgoingQueueStore,
} from './queue-store.js';
export type {
OutgoingEntry,
OutgoingQueueStore,
} from './queue-store.js';
export {
MemoryCursorStore,
} from './cursor-store.js';
export type {
CursorStore,
} from './cursor-store.js';
export {
InboxClientEvents,
} from './events.js';
export type {
InboxClientEvent,
InboxClientEventName,
InboxClientEventMap,
InboxClientListener,
} from './events.js';
export { computeMsgId } from './msg-id.js';

View File

@@ -0,0 +1,14 @@
/**
* Client-side msgId helper. Mirrors `@shade/inbox-server/msg-id` but lives
* in this package so client code doesn't need to import the server bundle.
*
* `msgId = lowercase-hex( sha256(ciphertext) )`
*/
export async function computeMsgId(ciphertext: Uint8Array): Promise<string> {
const buf = await globalThis.crypto.subtle.digest(
'SHA-256',
ciphertext as unknown as ArrayBuffer,
);
const arr = new Uint8Array(buf);
return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
}

View File

@@ -0,0 +1,77 @@
/**
* Outgoing-queue persistence interface.
*
* The client buffers ciphertext blobs until the inbox server confirms the
* PUT. If the process restarts mid-flush, the queue must survive — so we
* model it as an explicit interface that apps can back with SQLite, IndexedDB,
* AsyncStorage, etc.
*
* Each entry is keyed by `(recipientAddress, msgId)` (the same key the
* server uses) so retries are naturally idempotent.
*/
export interface OutgoingEntry {
/** Recipient address (the inbox owner). */
recipientAddress: string;
/** Hex SHA-256 of `ciphertext` — server-side msg id. */
msgId: string;
/** Wire-encoded ShadeEnvelope to deliver. */
ciphertext: Uint8Array;
/** Time-to-live in seconds. The server clamps to its allowed range. */
ttlSeconds: number;
/** Local timestamp when the entry was queued (ms). */
queuedAt: number;
/** Number of failed delivery attempts so far. */
attempts: number;
}
export interface OutgoingQueueStore {
/** Insert a new entry. Idempotent on (recipientAddress, msgId). */
enqueue(entry: OutgoingEntry): Promise<void>;
/** List entries in insertion order. Caller filters/limits. */
list(): Promise<OutgoingEntry[]>;
/** Remove an entry by composite key. Returns true if found. */
remove(recipientAddress: string, msgId: string): Promise<boolean>;
/** Update the attempts counter for an entry. */
bumpAttempts(recipientAddress: string, msgId: string): Promise<void>;
/** Total queued count. */
size(): Promise<number>;
}
export class MemoryOutgoingQueueStore implements OutgoingQueueStore {
private entries: OutgoingEntry[] = [];
async enqueue(entry: OutgoingEntry): Promise<void> {
const dup = this.entries.some(
(e) => e.recipientAddress === entry.recipientAddress && e.msgId === entry.msgId,
);
if (dup) return;
this.entries.push({ ...entry, ciphertext: new Uint8Array(entry.ciphertext) });
}
async list(): Promise<OutgoingEntry[]> {
return this.entries.map((e) => ({
...e,
ciphertext: new Uint8Array(e.ciphertext),
}));
}
async remove(recipientAddress: string, msgId: string): Promise<boolean> {
const idx = this.entries.findIndex(
(e) => e.recipientAddress === recipientAddress && e.msgId === msgId,
);
if (idx === -1) return false;
this.entries.splice(idx, 1);
return true;
}
async bumpAttempts(recipientAddress: string, msgId: string): Promise<void> {
const e = this.entries.find(
(x) => x.recipientAddress === recipientAddress && x.msgId === msgId,
);
if (e) e.attempts++;
}
async size(): Promise<number> {
return this.entries.length;
}
}

View File

@@ -0,0 +1,283 @@
import { describe, test, expect } from 'bun:test';
import { Inbox, InboxClient, computeMsgId, MemoryOutgoingQueueStore } from '../src/index.js';
import {
createInboxServer,
MemoryInboxStore,
} from '@shade/inbox-server';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import { generateIdentityKeyPair } from '@shade/core';
import type { Hono } from 'hono';
const crypto = new SubtleCryptoProvider();
async function makeIdentity() {
return generateIdentityKeyPair(crypto);
}
function randBytes(n: number): Uint8Array {
const buf = new Uint8Array(n);
globalThis.crypto.getRandomValues(buf);
return buf;
}
/**
* Wrap a Hono app as a fetch implementation. Strips the protocol/host so
* `app.request(path, init)` works.
*/
function honoFetch(app: Hono): typeof fetch {
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
const path = url.startsWith('http://localhost') ? url.slice('http://localhost'.length) : url;
return app.request(path, init);
}) as typeof fetch;
}
describe('InboxClient', () => {
test('register + put + fetch + ack roundtrip', async () => {
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const bob = await makeIdentity();
const alice = await makeIdentity();
const bobClient = new InboxClient({
baseUrl: 'http://localhost',
crypto,
signingPrivateKey: bob.signingPrivateKey,
fetch: honoFetch(app),
});
const aliceClient = new InboxClient({
baseUrl: 'http://localhost',
crypto,
signingPrivateKey: alice.signingPrivateKey,
fetch: honoFetch(app),
});
await bobClient.register({ address: 'bob', signingKey: bob.signingPublicKey });
const ct = randBytes(64);
const msgId = await computeMsgId(ct);
const result = await aliceClient.put({
recipientAddress: 'bob',
senderSigningKey: alice.signingPublicKey,
envelope: ct,
});
expect(result.msgId).toBe(msgId);
expect(result.idempotent).toBe(false);
const second = await aliceClient.put({
recipientAddress: 'bob',
senderSigningKey: alice.signingPublicKey,
envelope: ct,
});
expect(second.idempotent).toBe(true);
const fetched = await bobClient.fetch({ address: 'bob' });
expect(fetched.blobs.length).toBe(1);
expect(fetched.blobs[0]!.msgId).toBe(msgId);
expect(fetched.blobs[0]!.ciphertext).toEqual(ct);
const acked = await bobClient.ack({ address: 'bob', msgId });
expect(acked).toBe(true);
const second2 = await bobClient.fetch({ address: 'bob' });
expect(second2.blobs.length).toBe(0);
});
});
describe('Inbox orchestrator', () => {
test('queue → flush → server-side blob shows up', async () => {
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const bob = await makeIdentity();
const alice = await makeIdentity();
const aliceInbox = new Inbox({
baseUrl: 'http://localhost',
ownAddress: 'alice',
crypto,
signingPrivateKey: alice.signingPrivateKey,
signingPublicKey: alice.signingPublicKey,
pollIntervalMs: 0,
fetch: honoFetch(app),
});
const bobInbox = new Inbox({
baseUrl: 'http://localhost',
ownAddress: 'bob',
crypto,
signingPrivateKey: bob.signingPrivateKey,
signingPublicKey: bob.signingPublicKey,
pollIntervalMs: 0,
fetch: honoFetch(app),
});
// Bob registers so he can receive.
await bobInbox.register();
// Alice queues a message.
const ct = randBytes(64);
const msgId = await aliceInbox.send({ recipientAddress: 'bob', envelope: ct });
expect(await aliceInbox.pendingCount()).toBe(1);
// Alice ticks: flushes + (no incoming because no handler).
await aliceInbox.tick();
expect(await aliceInbox.pendingCount()).toBe(0);
// Bob ticks: should see the blob via incoming handler.
let received: { msgId: string; bytes: number } | null = null;
bobInbox.onIncoming(async (raw) => {
received = { msgId: raw.msgId, bytes: raw.ciphertext.length };
return 'alice';
});
const result = await bobInbox.tick();
expect(result.received).toBe(1);
expect(received).not.toBeNull();
expect(received!.msgId).toBe(msgId);
expect(received!.bytes).toBe(ct.length);
// No re-delivery on second tick (cursor advanced + ack performed).
const r2 = await bobInbox.tick();
expect(r2.received).toBe(0);
});
test('onMessageQueued hook fires for each enqueue', async () => {
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const alice = await makeIdentity();
const inbox = new Inbox({
baseUrl: 'http://localhost',
ownAddress: 'alice',
crypto,
signingPrivateKey: alice.signingPrivateKey,
signingPublicKey: alice.signingPublicKey,
pollIntervalMs: 0,
fetch: honoFetch(app),
});
const seen: Array<{ to: string; msgId: string }> = [];
inbox.onMessageQueued((to, msgId) => {
seen.push({ to, msgId });
});
await inbox.send({ recipientAddress: 'bob', envelope: randBytes(10) });
await inbox.send({ recipientAddress: 'carol', envelope: randBytes(20) });
// Wait for the (sync) hook to flush.
await new Promise((r) => setTimeout(r, 5));
expect(seen.length).toBe(2);
expect(seen[0]!.to).toBe('bob');
expect(seen[1]!.to).toBe('carol');
});
test('flush retries on transient server failure', async () => {
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const alice = await makeIdentity();
const bob = await makeIdentity();
// Register bob via direct API.
const bobClient = new InboxClient({
baseUrl: 'http://localhost',
crypto,
signingPrivateKey: bob.signingPrivateKey,
fetch: honoFetch(app),
});
await bobClient.register({ address: 'bob', signingKey: bob.signingPublicKey });
// Wrap fetch so first PUT fails, subsequent succeed.
let failsLeft = 1;
const flakyFetch: typeof fetch = (async (input, init) => {
const m = (init as RequestInit | undefined)?.method ?? 'GET';
const u = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
if (m === 'POST' && u.includes('/v1/inbox/bob') && !u.includes('/fetch') && failsLeft > 0) {
failsLeft--;
throw new Error('transient network');
}
return honoFetch(app)(input, init);
}) as typeof fetch;
const aliceInbox = new Inbox({
baseUrl: 'http://localhost',
ownAddress: 'alice',
crypto,
signingPrivateKey: alice.signingPrivateKey,
signingPublicKey: alice.signingPublicKey,
pollIntervalMs: 0,
fetch: flakyFetch,
queueStore: new MemoryOutgoingQueueStore(),
});
await aliceInbox.send({ recipientAddress: 'bob', envelope: randBytes(40) });
// First flush fails.
await aliceInbox.tick();
expect(await aliceInbox.pendingCount()).toBe(1);
// Second flush succeeds.
await aliceInbox.tick();
expect(await aliceInbox.pendingCount()).toBe(0);
});
});
describe('tamper detection', () => {
test('client rejects blob whose msgId does not match recomputed hash', async () => {
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const bob = await makeIdentity();
const alice = await makeIdentity();
// Register Bob.
const bobClient = new InboxClient({
baseUrl: 'http://localhost',
crypto,
signingPrivateKey: bob.signingPrivateKey,
fetch: honoFetch(app),
});
await bobClient.register({ address: 'bob', signingKey: bob.signingPublicKey });
// Alice puts a real blob.
const ct = randBytes(64);
const aliceClient = new InboxClient({
baseUrl: 'http://localhost',
crypto,
signingPrivateKey: alice.signingPrivateKey,
fetch: honoFetch(app),
});
await aliceClient.put({
recipientAddress: 'bob',
senderSigningKey: alice.signingPublicKey,
envelope: ct,
});
// Tamper: flip a byte in the in-memory store.
const list: any = (store as any).blobs.get('bob');
list[0].ciphertext[0] ^= 0xff;
const bobInbox = new Inbox({
baseUrl: 'http://localhost',
ownAddress: 'bob',
crypto,
signingPrivateKey: bob.signingPrivateKey,
signingPublicKey: bob.signingPublicKey,
pollIntervalMs: 0,
fetch: honoFetch(app),
});
let decryptCalls = 0;
let failures = 0;
bobInbox.onIncoming(() => {
decryptCalls++;
return null;
});
bobInbox.on((e) => {
if (e.name === 'inbox.message_decrypt_failed') failures++;
});
const result = await bobInbox.tick();
// Tampered blob: handler must NOT be called; decrypt-failed event fires.
expect(decryptCalls).toBe(0);
expect(failures).toBeGreaterThan(0);
expect(result.received).toBe(0);
});
});

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}