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
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:
21
packages/shade-inbox-server/package.json
Normal file
21
packages/shade-inbox-server/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@shade/inbox-server",
|
||||
"version": "4.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/observability": "workspace:*",
|
||||
"@shade/server": "workspace:*",
|
||||
"hono": "^4.12.12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@shade/crypto-web": "workspace:*",
|
||||
"@shade/storage-postgres": "workspace:*",
|
||||
"@shade/storage-sqlite": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shade/crypto-web": "workspace:*"
|
||||
}
|
||||
}
|
||||
461
packages/shade-inbox-server/src/bridge.ts
Normal file
461
packages/shade-inbox-server/src/bridge.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Bridge routes — V3.7.
|
||||
*
|
||||
* Three transports, one delivery semantic. Each one streams the same
|
||||
* not-yet-acked inbox blobs for an authenticated address:
|
||||
*
|
||||
* GET /v1/bridge/stream — SSE feed, one envelope per `event: envelope`
|
||||
* GET /v1/bridge/poll — long-poll, returns at most one batch then closes
|
||||
* GET /v1/bridge/ws — WebSocket, JSON frame per envelope
|
||||
*
|
||||
* Auth: signed query string (`address`, `kind`, `since`, `signedAt`,
|
||||
* `signature`). The signature is verified against the address's owner key
|
||||
* registered via `/v1/inbox/register`. The `kind` field is bound into the
|
||||
* canonical signed payload to prevent cross-endpoint replay.
|
||||
*
|
||||
* Cursor semantics: `since` is the highest `receivedAt` the client already
|
||||
* processed. The server returns blobs strictly greater than that cursor and
|
||||
* advances the client's cursor by emitting a fresh `id:` (SSE) or by
|
||||
* including the highest seen `receivedAt` in the JSON response (poll/ws).
|
||||
*
|
||||
* The implementations subscribe to {@link InboxServerEvents} so that newly
|
||||
* stored blobs land on connected clients without polling the store. The
|
||||
* fallback path (no events configured) relies on a small in-process polling
|
||||
* timer with a configurable interval.
|
||||
*/
|
||||
|
||||
import { Hono, type Context } from 'hono';
|
||||
import { streamSSE } from 'hono/streaming';
|
||||
import { createBunWebSocket } from 'hono/bun';
|
||||
import type { CryptoProvider } from '@shade/core';
|
||||
import {
|
||||
errorToHttpStatus,
|
||||
ShadeError,
|
||||
ValidationError,
|
||||
UnauthorizedError,
|
||||
toBase64,
|
||||
} from '@shade/core';
|
||||
import { verifyPayload, validateAddress } from '@shade/server';
|
||||
import type { InboxStore } from './store.js';
|
||||
import type { InboxServerEvents } from './events.js';
|
||||
|
||||
export type BridgeKind = 'stream' | 'poll' | 'ws';
|
||||
|
||||
export interface BridgeRoutesOptions {
|
||||
store: InboxStore;
|
||||
crypto: CryptoProvider;
|
||||
/** Optional events emitter — enables push-style delivery. */
|
||||
events?: InboxServerEvents;
|
||||
/** Maximum blobs returned per fetch page. Default 50. */
|
||||
pageLimit?: number;
|
||||
/** Default long-poll hold (ms). Default 25_000 (under typical proxy cutoffs). */
|
||||
longPollTimeoutMs?: number;
|
||||
/** Maximum long-poll hold (ms). Hard cap. Default 55_000. */
|
||||
longPollMaxTimeoutMs?: number;
|
||||
/** SSE heartbeat interval (ms). Default 15_000. */
|
||||
heartbeatIntervalMs?: number;
|
||||
/**
|
||||
* Fallback poll interval (ms) used when no `events` emitter is wired in.
|
||||
* The bridge will re-check the store at this cadence to detect new blobs.
|
||||
* Default 1_000.
|
||||
*/
|
||||
fallbackPollIntervalMs?: number;
|
||||
}
|
||||
|
||||
interface VerifiedBridgeRequest {
|
||||
address: string;
|
||||
kind: BridgeKind;
|
||||
since: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the bridge Hono router and a paired Bun-WebSocket handler.
|
||||
*
|
||||
* The HTTP routes (`/v1/bridge/stream`, `/v1/bridge/poll`) work on every
|
||||
* Hono runtime. The `/v1/bridge/ws` route requires `hono/adapter/bun` to be
|
||||
* available — we lazy-require it so that non-Bun deployments aren't
|
||||
* forced to ship the import.
|
||||
*/
|
||||
export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
||||
app: Hono;
|
||||
/** Pass to `Bun.serve({ websocket })`. Undefined if Bun adapter is missing. */
|
||||
websocket: unknown;
|
||||
} {
|
||||
const app = new Hono();
|
||||
const pageLimit = opts.pageLimit ?? 50;
|
||||
const heartbeatIntervalMs = opts.heartbeatIntervalMs ?? 15_000;
|
||||
const longPollDefault = opts.longPollTimeoutMs ?? 25_000;
|
||||
const longPollMax = opts.longPollMaxTimeoutMs ?? 55_000;
|
||||
const fallbackPollIntervalMs = opts.fallbackPollIntervalMs ?? 1_000;
|
||||
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof ShadeError) {
|
||||
const status = errorToHttpStatus(err);
|
||||
return c.json(err.toJSON(), status as any);
|
||||
}
|
||||
console.error('[Shade] Unhandled bridge error:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
});
|
||||
|
||||
// ─── SSE ──────────────────────────────────────────────────────
|
||||
app.get('/v1/bridge/stream', async (c) => {
|
||||
const verified = await verifyBridgeAuth(c, opts, 'stream');
|
||||
return streamSSE(c, async (stream) => {
|
||||
const address = verified.address;
|
||||
let cursor = verified.since;
|
||||
const writer = makeBlobWriter(opts.store, pageLimit);
|
||||
|
||||
// Initial backlog drain.
|
||||
const flushed = await flushTo(writer, address, cursor, async (blob) => {
|
||||
await stream.writeSSE({
|
||||
id: String(blob.receivedAt),
|
||||
event: 'envelope',
|
||||
data: JSON.stringify(serializeBlob(blob)),
|
||||
});
|
||||
});
|
||||
cursor = Math.max(cursor, flushed);
|
||||
|
||||
// Hook up event-driven push if available, else fall back to a poll
|
||||
// timer that does the same scan.
|
||||
let cleanupSubscription: (() => void) | null = null;
|
||||
let signalled = false;
|
||||
let pendingFlushPromise: Promise<void> = Promise.resolve();
|
||||
|
||||
const triggerFlush = (): void => {
|
||||
signalled = true;
|
||||
// Serialize fan-in so concurrent triggers don't double-fetch.
|
||||
pendingFlushPromise = pendingFlushPromise.then(async () => {
|
||||
while (signalled) {
|
||||
signalled = false;
|
||||
const drained = await flushTo(writer, address, cursor, async (blob) => {
|
||||
await stream.writeSSE({
|
||||
id: String(blob.receivedAt),
|
||||
event: 'envelope',
|
||||
data: JSON.stringify(serializeBlob(blob)),
|
||||
});
|
||||
});
|
||||
if (drained > cursor) cursor = drained;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (opts.events) {
|
||||
cleanupSubscription = opts.events.on((e) => {
|
||||
if (e.name === 'inbox.blob_stored' && e.data.address === address) {
|
||||
triggerFlush();
|
||||
}
|
||||
});
|
||||
}
|
||||
const fallbackTimer = setInterval(() => triggerFlush(), fallbackPollIntervalMs);
|
||||
const heartbeat = setInterval(() => {
|
||||
// Comment lines are valid SSE keepalives.
|
||||
stream.write(`: ping ${Date.now()}\n\n`).catch(() => {});
|
||||
}, heartbeatIntervalMs);
|
||||
|
||||
// Wait for the request to abort (client disconnect).
|
||||
await new Promise<void>((resolve) => {
|
||||
const sig = c.req.raw.signal;
|
||||
if (sig.aborted) return resolve();
|
||||
sig.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
|
||||
cleanupSubscription?.();
|
||||
clearInterval(fallbackTimer);
|
||||
clearInterval(heartbeat);
|
||||
await pendingFlushPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Long-poll ────────────────────────────────────────────────
|
||||
app.get('/v1/bridge/poll', async (c) => {
|
||||
const verified = await verifyBridgeAuth(c, opts, 'poll');
|
||||
const requestedTimeout = Number(c.req.query('timeoutMs') ?? longPollDefault);
|
||||
const timeoutMs = Math.min(
|
||||
Math.max(0, Number.isFinite(requestedTimeout) ? requestedTimeout : longPollDefault),
|
||||
longPollMax,
|
||||
);
|
||||
|
||||
// Try immediate fetch first.
|
||||
let blobs = await opts.store.fetchBlobs({
|
||||
address: verified.address,
|
||||
sinceCursor: verified.since,
|
||||
now: Date.now(),
|
||||
limit: pageLimit,
|
||||
});
|
||||
if (blobs.length > 0) {
|
||||
return c.json(buildPollResponse(blobs, verified.since));
|
||||
}
|
||||
|
||||
// Otherwise, wait for either a new event or the timeout.
|
||||
blobs = await waitForBlobs({
|
||||
events: opts.events ?? null,
|
||||
store: opts.store,
|
||||
address: verified.address,
|
||||
since: verified.since,
|
||||
timeoutMs,
|
||||
pageLimit,
|
||||
fallbackPollIntervalMs,
|
||||
abortSignal: c.req.raw.signal,
|
||||
});
|
||||
return c.json(buildPollResponse(blobs, verified.since));
|
||||
});
|
||||
|
||||
// ─── WebSocket ────────────────────────────────────────────────
|
||||
// Hono's Bun adapter resolves `getBunServer` from the request's `env`
|
||||
// (the second argument of Bun.serve's fetch). On non-Bun runtimes the
|
||||
// upgrade simply fails at runtime; the SSE/long-poll routes still work.
|
||||
const { upgradeWebSocket, websocket } = createBunWebSocket();
|
||||
|
||||
app.get(
|
||||
'/v1/bridge/ws',
|
||||
upgradeWebSocket(async (c: Context) => {
|
||||
let verified: VerifiedBridgeRequest | null = null;
|
||||
let upgradeError: Error | null = null;
|
||||
try {
|
||||
verified = await verifyBridgeAuth(c, opts, 'ws');
|
||||
} catch (err) {
|
||||
upgradeError = err as Error;
|
||||
}
|
||||
if (!verified) {
|
||||
// Hono's API doesn't let us reject the upgrade with a status code
|
||||
// before opening the socket; close immediately on open with a 4xxx
|
||||
// policy code so the client can fall back to a different bridge.
|
||||
return {
|
||||
onOpen(_evt: unknown, ws: { close: (code?: number, reason?: string) => void }) {
|
||||
const status =
|
||||
upgradeError instanceof ShadeError ? errorToHttpStatus(upgradeError) : 500;
|
||||
ws.close(4000 + (status % 1000), upgradeError?.message ?? 'unauthorized');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const address = verified.address;
|
||||
let cursor = verified.since;
|
||||
const writer = makeBlobWriter(opts.store, pageLimit);
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let fallbackTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let pendingFlushPromise: Promise<void> = Promise.resolve();
|
||||
let signalled = false;
|
||||
let connected = true;
|
||||
|
||||
return {
|
||||
onOpen(_evt: unknown, ws: {
|
||||
send: (data: string) => void;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
}) {
|
||||
const triggerFlush = (): void => {
|
||||
signalled = true;
|
||||
pendingFlushPromise = pendingFlushPromise.then(async () => {
|
||||
while (signalled && connected) {
|
||||
signalled = false;
|
||||
const drained = await flushTo(writer, address, cursor, async (blob) => {
|
||||
ws.send(JSON.stringify(serializeBlob(blob)));
|
||||
});
|
||||
if (drained > cursor) cursor = drained;
|
||||
}
|
||||
});
|
||||
};
|
||||
if (opts.events) {
|
||||
unsubscribe = opts.events.on((e) => {
|
||||
if (e.name === 'inbox.blob_stored' && e.data.address === address) {
|
||||
triggerFlush();
|
||||
}
|
||||
});
|
||||
}
|
||||
fallbackTimer = setInterval(() => triggerFlush(), fallbackPollIntervalMs);
|
||||
triggerFlush();
|
||||
},
|
||||
onClose() {
|
||||
connected = false;
|
||||
unsubscribe?.();
|
||||
if (fallbackTimer) clearInterval(fallbackTimer);
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { app, websocket };
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────
|
||||
|
||||
async function verifyBridgeAuth(
|
||||
c: Context,
|
||||
opts: BridgeRoutesOptions,
|
||||
expectedKind: BridgeKind,
|
||||
): Promise<VerifiedBridgeRequest> {
|
||||
const url = new URL(c.req.url);
|
||||
const qs = url.searchParams;
|
||||
const address = validateAddress(qs.get('address'));
|
||||
const kind = qs.get('kind');
|
||||
if (kind !== expectedKind) {
|
||||
throw new ValidationError(`bridge kind mismatch: expected ${expectedKind}`, 'kind');
|
||||
}
|
||||
const sinceStr = qs.get('since');
|
||||
const signedAtStr = qs.get('signedAt');
|
||||
const signature = qs.get('signature');
|
||||
if (sinceStr === null) throw new ValidationError('missing since', 'since');
|
||||
if (signedAtStr === null) throw new ValidationError('missing signedAt', 'signedAt');
|
||||
if (!signature) throw new ValidationError('missing signature', 'signature');
|
||||
const since = Number(sinceStr);
|
||||
const signedAt = Number(signedAtStr);
|
||||
if (!Number.isFinite(since) || since < 0) {
|
||||
throw new ValidationError('since must be a non-negative number', 'since');
|
||||
}
|
||||
if (!Number.isFinite(signedAt)) {
|
||||
throw new ValidationError('signedAt must be a number', 'signedAt');
|
||||
}
|
||||
|
||||
const owner = await opts.store.getAddressOwner(address);
|
||||
if (!owner) {
|
||||
throw new UnauthorizedError(`address ${address} is not registered`);
|
||||
}
|
||||
|
||||
await verifyPayload(opts.crypto, owner, {
|
||||
address,
|
||||
kind,
|
||||
since,
|
||||
signedAt,
|
||||
signature,
|
||||
});
|
||||
return { address, kind: kind as BridgeKind, since };
|
||||
}
|
||||
|
||||
interface BlobRow {
|
||||
msgId: string;
|
||||
ciphertext: Uint8Array;
|
||||
receivedAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface BlobWriter {
|
||||
fetchPage(address: string, cursor: number): Promise<BlobRow[]>;
|
||||
}
|
||||
|
||||
function makeBlobWriter(store: InboxStore, pageLimit: number): BlobWriter {
|
||||
return {
|
||||
async fetchPage(address, cursor) {
|
||||
return store.fetchBlobs({
|
||||
address,
|
||||
sinceCursor: cursor,
|
||||
now: Date.now(),
|
||||
limit: pageLimit,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushTo(
|
||||
writer: BlobWriter,
|
||||
address: string,
|
||||
startCursor: number,
|
||||
emit: (blob: BlobRow) => Promise<void>,
|
||||
): Promise<number> {
|
||||
let cursor = startCursor;
|
||||
// Drain page-by-page so a backlog larger than `pageLimit` still flushes.
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const page = await writer.fetchPage(address, cursor);
|
||||
if (page.length === 0) break;
|
||||
for (const row of page) {
|
||||
await emit(row);
|
||||
if (row.receivedAt > cursor) cursor = row.receivedAt;
|
||||
}
|
||||
if (page.length === 0) break;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function serializeBlob(blob: BlobRow): {
|
||||
msgId: string;
|
||||
ciphertext: string;
|
||||
receivedAt: number;
|
||||
expiresAt: number;
|
||||
} {
|
||||
return {
|
||||
msgId: blob.msgId,
|
||||
ciphertext: toBase64(blob.ciphertext),
|
||||
receivedAt: blob.receivedAt,
|
||||
expiresAt: blob.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPollResponse(blobs: BlobRow[], sinceFallback: number): {
|
||||
blobs: ReturnType<typeof serializeBlob>[];
|
||||
cursor: number;
|
||||
hasMore: boolean;
|
||||
} {
|
||||
const out = blobs.map(serializeBlob);
|
||||
const cursor = blobs.length > 0 ? blobs[blobs.length - 1]!.receivedAt : sinceFallback;
|
||||
return { blobs: out, cursor, hasMore: false };
|
||||
}
|
||||
|
||||
interface WaitForBlobsArgs {
|
||||
events: InboxServerEvents | null;
|
||||
store: InboxStore;
|
||||
address: string;
|
||||
since: number;
|
||||
timeoutMs: number;
|
||||
pageLimit: number;
|
||||
fallbackPollIntervalMs: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
async function waitForBlobs(args: WaitForBlobsArgs): Promise<BlobRow[]> {
|
||||
if (args.timeoutMs === 0) return [];
|
||||
|
||||
return new Promise<BlobRow[]>((resolve) => {
|
||||
let resolved = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let fallback: ReturnType<typeof setInterval> | null = null;
|
||||
let abortHandler: (() => void) | null = null;
|
||||
|
||||
const finish = (blobs: BlobRow[]) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (fallback) clearInterval(fallback);
|
||||
if (unsubscribe) unsubscribe();
|
||||
if (abortHandler && args.abortSignal) {
|
||||
args.abortSignal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
resolve(blobs);
|
||||
};
|
||||
|
||||
const tryFetch = async (): Promise<void> => {
|
||||
try {
|
||||
const rows = await args.store.fetchBlobs({
|
||||
address: args.address,
|
||||
sinceCursor: args.since,
|
||||
now: Date.now(),
|
||||
limit: args.pageLimit,
|
||||
});
|
||||
if (rows.length > 0) finish(rows);
|
||||
} catch {
|
||||
// swallow — let timeout handle it
|
||||
}
|
||||
};
|
||||
|
||||
if (args.events) {
|
||||
unsubscribe = args.events.on((e) => {
|
||||
if (e.name === 'inbox.blob_stored' && e.data.address === args.address) {
|
||||
void tryFetch();
|
||||
}
|
||||
});
|
||||
}
|
||||
fallback = setInterval(tryFetch, args.fallbackPollIntervalMs);
|
||||
|
||||
timer = setTimeout(() => finish([]), args.timeoutMs);
|
||||
|
||||
if (args.abortSignal) {
|
||||
if (args.abortSignal.aborted) {
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
abortHandler = () => finish([]);
|
||||
args.abortSignal.addEventListener('abort', abortHandler, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
69
packages/shade-inbox-server/src/cleanup.ts
Normal file
69
packages/shade-inbox-server/src/cleanup.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { InboxStore } from './store.js';
|
||||
import type { InboxServerEvents } from './events.js';
|
||||
|
||||
/**
|
||||
* Periodic prune task — drops every blob whose `expires_at <= now`.
|
||||
*
|
||||
* Configurable via env vars:
|
||||
* SHADE_INBOX_PRUNE_INTERVAL_MINUTES (default 5)
|
||||
*/
|
||||
export class InboxPruneTask {
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private running = false;
|
||||
private readonly intervalMs: number;
|
||||
|
||||
constructor(
|
||||
private readonly store: InboxStore,
|
||||
options: {
|
||||
intervalMinutes?: number;
|
||||
events?: InboxServerEvents;
|
||||
logger?: { info: (msg: string, meta?: unknown) => void; error: (msg: string, meta?: unknown) => void };
|
||||
} = {},
|
||||
) {
|
||||
const minutes = options.intervalMinutes
|
||||
?? Number(process.env.SHADE_INBOX_PRUNE_INTERVAL_MINUTES ?? 5);
|
||||
this.intervalMs = minutes * 60 * 1000;
|
||||
this.events = options.events;
|
||||
this.logger = options.logger ?? null;
|
||||
}
|
||||
|
||||
private readonly events: InboxServerEvents | undefined;
|
||||
private readonly logger: {
|
||||
info: (msg: string, meta?: unknown) => void;
|
||||
error: (msg: string, meta?: unknown) => void;
|
||||
} | null;
|
||||
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.runOnce().catch((err) =>
|
||||
this.logger?.error('Initial inbox prune failed', { error: String(err) }),
|
||||
);
|
||||
this.timer = setInterval(() => {
|
||||
this.runOnce().catch((err) =>
|
||||
this.logger?.error('Inbox prune failed', { error: String(err) }),
|
||||
);
|
||||
}, this.intervalMs);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async runOnce(): Promise<number> {
|
||||
const removed = await this.store.purgeExpired(Date.now());
|
||||
if (removed > 0) {
|
||||
this.logger?.info('Inbox prune removed expired blobs', { count: removed });
|
||||
this.events?.emit('inbox.expired_purged', { count: removed });
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
}
|
||||
90
packages/shade-inbox-server/src/events.ts
Normal file
90
packages/shade-inbox-server/src/events.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Inbox server event emitter.
|
||||
*
|
||||
* Mirrors `PrekeyServerEvents`. Emits structural facts only — no plaintext,
|
||||
* no signatures, no key material. Used by the observer dashboard and
|
||||
* operator metrics.
|
||||
*/
|
||||
|
||||
export interface InboxServerEventBase {
|
||||
seq: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface InboxServerEventMap {
|
||||
'inbox.address_registered': { address: string; signingKeyHash: string };
|
||||
'inbox.address_deleted': { address: string };
|
||||
'inbox.blob_stored': { address: string; msgId: string; bytes: number; ttlSeconds: number };
|
||||
'inbox.blob_idempotent_replay': { address: string; msgId: string };
|
||||
'inbox.blob_fetched': { address: string; count: number; bytes: number };
|
||||
'inbox.blob_acked': { address: string; msgId: string };
|
||||
'inbox.expired_purged': { count: number };
|
||||
'inbox.rate_limited': { route: string; key: string };
|
||||
'inbox.quota_rejected': { address: string; reason: 'address-quota' | 'sender-quota' | 'body-too-large' };
|
||||
}
|
||||
|
||||
export type InboxServerEventName = keyof InboxServerEventMap;
|
||||
|
||||
export type InboxServerEvent = {
|
||||
[K in InboxServerEventName]: InboxServerEventBase & { name: K; data: InboxServerEventMap[K] };
|
||||
}[InboxServerEventName];
|
||||
|
||||
export type InboxServerEventListener = (event: InboxServerEvent) => void;
|
||||
|
||||
export class InboxServerEvents {
|
||||
private listeners = new Set<InboxServerEventListener>();
|
||||
private nextSeq = 1;
|
||||
private buffer: InboxServerEvent[] = [];
|
||||
private readonly maxBuffer: number;
|
||||
|
||||
constructor(options: { bufferSize?: number } = {}) {
|
||||
this.maxBuffer = options.bufferSize ?? 1000;
|
||||
}
|
||||
|
||||
on(listener: InboxServerEventListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
off(listener: InboxServerEventListener): void {
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
emit<K extends InboxServerEventName>(name: K, data: InboxServerEventMap[K]): void {
|
||||
const event = {
|
||||
seq: this.nextSeq++,
|
||||
timestamp: Date.now(),
|
||||
name,
|
||||
data,
|
||||
} as InboxServerEvent;
|
||||
|
||||
this.buffer.push(event);
|
||||
if (this.buffer.length > this.maxBuffer) this.buffer.shift();
|
||||
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (err) {
|
||||
console.error('[Shade] Inbox event listener threw:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getBufferedSince(since: number): InboxServerEvent[] {
|
||||
return this.buffer.filter((e) => e.seq > since);
|
||||
}
|
||||
|
||||
getRecent(n: number): InboxServerEvent[] {
|
||||
return this.buffer.slice(-n);
|
||||
}
|
||||
|
||||
get currentSeq(): number {
|
||||
return this.nextSeq - 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function shortHash(key: Uint8Array): Promise<string> {
|
||||
const buf = await globalThis.crypto.subtle.digest('SHA-256', key as unknown as ArrayBuffer);
|
||||
const arr = new Uint8Array(buf).slice(0, 8);
|
||||
return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
60
packages/shade-inbox-server/src/index.ts
Normal file
60
packages/shade-inbox-server/src/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { 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';
|
||||
|
||||
export { createInboxRoutes } from './routes.js';
|
||||
export type { InboxRoutesOptions } from './routes.js';
|
||||
export { MemoryInboxStore } from './memory-store.js';
|
||||
export type { InboxStore } from './store.js';
|
||||
export {
|
||||
InboxServerEvents,
|
||||
shortHash as inboxShortHash,
|
||||
} from './events.js';
|
||||
export type {
|
||||
InboxServerEvent,
|
||||
InboxServerEventName,
|
||||
InboxServerEventMap,
|
||||
InboxServerEventListener,
|
||||
} from './events.js';
|
||||
export { InboxPruneTask } from './cleanup.js';
|
||||
export {
|
||||
computeMsgId,
|
||||
isValidMsgId,
|
||||
constantTimeStringEqual,
|
||||
} from './msg-id.js';
|
||||
export {
|
||||
DEFAULT_INBOX_QUOTA,
|
||||
clampTtl,
|
||||
} from './quota.js';
|
||||
export type { InboxQuotaConfig } from './quota.js';
|
||||
export { createBridgeRoutes } from './bridge.js';
|
||||
export type { BridgeRoutesOptions, BridgeKind } from './bridge.js';
|
||||
|
||||
/**
|
||||
* Create a standalone Shade Inbox Server.
|
||||
*
|
||||
* const crypto = new SubtleCryptoProvider();
|
||||
* const inbox = createInboxServer({ crypto });
|
||||
* export default { port: 3901, fetch: inbox.fetch };
|
||||
*
|
||||
* Or compose into an existing Hono app:
|
||||
* const app = new Hono();
|
||||
* app.route('/', createInboxServer({ crypto }));
|
||||
*/
|
||||
export function createInboxServer(options: {
|
||||
crypto: CryptoProvider;
|
||||
store?: InboxStore;
|
||||
disableRateLimit?: boolean;
|
||||
events?: InboxServerEvents;
|
||||
} & Pick<InboxRoutesOptions, 'observability' | 'quota'>): Hono {
|
||||
const store = options.store ?? new MemoryInboxStore();
|
||||
const routesOptions: InboxRoutesOptions = {};
|
||||
if (options.disableRateLimit !== undefined) routesOptions.disableRateLimit = options.disableRateLimit;
|
||||
if (options.events !== undefined) routesOptions.events = options.events;
|
||||
if (options.observability !== undefined) routesOptions.observability = options.observability;
|
||||
if (options.quota !== undefined) routesOptions.quota = options.quota;
|
||||
return createInboxRoutes(store, options.crypto, routesOptions);
|
||||
}
|
||||
105
packages/shade-inbox-server/src/memory-store.ts
Normal file
105
packages/shade-inbox-server/src/memory-store.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { InboxStore } from './store.js';
|
||||
|
||||
interface BlobRow {
|
||||
msgId: string;
|
||||
ciphertext: Uint8Array;
|
||||
receivedAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory InboxStore — used in tests and as the default fallback when
|
||||
* neither SHADE_INBOX_DB_PATH nor SHADE_INBOX_PG_URL is set.
|
||||
*
|
||||
* Blobs are organized in per-address insertion-ordered arrays so cursor
|
||||
* pagination is just a `>` filter on `receivedAt`.
|
||||
*/
|
||||
export class MemoryInboxStore implements InboxStore {
|
||||
private owners = new Map<string, Uint8Array>();
|
||||
private blobs = new Map<string, BlobRow[]>();
|
||||
private nextReceivedAt = 0;
|
||||
|
||||
async saveAddressOwner(address: string, signingKey: Uint8Array): Promise<void> {
|
||||
this.owners.set(address, new Uint8Array(signingKey));
|
||||
}
|
||||
|
||||
async getAddressOwner(address: string): Promise<Uint8Array | null> {
|
||||
const k = this.owners.get(address);
|
||||
return k ? new Uint8Array(k) : null;
|
||||
}
|
||||
|
||||
async putBlob(args: {
|
||||
address: string;
|
||||
msgId: string;
|
||||
ciphertext: Uint8Array;
|
||||
expiresAt: number;
|
||||
}): Promise<{ created: boolean; receivedAt: number }> {
|
||||
const list = this.blobs.get(args.address) ?? [];
|
||||
const existing = list.find((r) => r.msgId === args.msgId);
|
||||
if (existing) return { created: false, receivedAt: existing.receivedAt };
|
||||
// Monotonic `receivedAt` so cursor compare is total-order even when
|
||||
// multiple blobs land in the same millisecond.
|
||||
const receivedAt = Math.max(this.nextReceivedAt + 1, Date.now());
|
||||
this.nextReceivedAt = receivedAt;
|
||||
list.push({
|
||||
msgId: args.msgId,
|
||||
ciphertext: new Uint8Array(args.ciphertext),
|
||||
receivedAt,
|
||||
expiresAt: args.expiresAt,
|
||||
});
|
||||
this.blobs.set(args.address, list);
|
||||
return { created: true, receivedAt };
|
||||
}
|
||||
|
||||
async fetchBlobs(args: {
|
||||
address: string;
|
||||
sinceCursor: number;
|
||||
now: number;
|
||||
limit: number;
|
||||
}): Promise<Array<{ msgId: string; ciphertext: Uint8Array; receivedAt: number; expiresAt: number }>> {
|
||||
const list = this.blobs.get(args.address) ?? [];
|
||||
return list
|
||||
.filter((r) => r.receivedAt > args.sinceCursor && r.expiresAt > args.now)
|
||||
.sort((a, b) => a.receivedAt - b.receivedAt)
|
||||
.slice(0, args.limit)
|
||||
.map((r) => ({
|
||||
msgId: r.msgId,
|
||||
ciphertext: new Uint8Array(r.ciphertext),
|
||||
receivedAt: r.receivedAt,
|
||||
expiresAt: r.expiresAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async deleteBlob(address: string, msgId: string): Promise<boolean> {
|
||||
const list = this.blobs.get(address);
|
||||
if (!list) return false;
|
||||
const idx = list.findIndex((r) => r.msgId === msgId);
|
||||
if (idx === -1) return false;
|
||||
list.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
async countBlobs(address: string, now: number): Promise<number> {
|
||||
const list = this.blobs.get(address) ?? [];
|
||||
return list.filter((r) => r.expiresAt > now).length;
|
||||
}
|
||||
|
||||
async purgeExpired(now: number): Promise<number> {
|
||||
let removed = 0;
|
||||
for (const [addr, list] of this.blobs) {
|
||||
const filtered = list.filter((r) => r.expiresAt > now);
|
||||
removed += list.length - filtered.length;
|
||||
if (filtered.length === 0) {
|
||||
this.blobs.delete(addr);
|
||||
} else {
|
||||
this.blobs.set(addr, filtered);
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
async deleteAddress(address: string): Promise<void> {
|
||||
this.owners.delete(address);
|
||||
this.blobs.delete(address);
|
||||
}
|
||||
}
|
||||
37
packages/shade-inbox-server/src/msg-id.ts
Normal file
37
packages/shade-inbox-server/src/msg-id.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* msgId derivation: deterministic SHA-256 of the ciphertext blob.
|
||||
*
|
||||
* `msgId = lowercase-hex( sha256(ciphertext) )`
|
||||
*
|
||||
* Both client and server compute it independently and check that the
|
||||
* client's claimed msgId equals the recomputed hash — that gives us
|
||||
* idempotency on PUT (same ciphertext → same row) and replay-protection
|
||||
* for free (a tampered re-upload changes the hash and lands in a new
|
||||
* slot the recipient simply ignores when decrypt fails).
|
||||
*/
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
/** Constant-time string equality on the hex form, both lowercased first. */
|
||||
export function constantTimeStringEqual(a: string, b: string): boolean {
|
||||
const aa = a.toLowerCase();
|
||||
const bb = b.toLowerCase();
|
||||
if (aa.length !== bb.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < aa.length; i++) {
|
||||
diff |= aa.charCodeAt(i) ^ bb.charCodeAt(i);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/** Validate hex form: 64 lowercase hex chars (32-byte digest). */
|
||||
export function isValidMsgId(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[0-9a-f]{64}$/.test(s);
|
||||
}
|
||||
47
packages/shade-inbox-server/src/quota.ts
Normal file
47
packages/shade-inbox-server/src/quota.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ValidationError } from '@shade/core';
|
||||
|
||||
/**
|
||||
* Inbox quota policy. The relay limits per-address and per-sender storage
|
||||
* so a single ondsinnet sender can't exhaust capacity.
|
||||
*/
|
||||
export interface InboxQuotaConfig {
|
||||
/** Hard cap on the body size of a single PUT (bytes). Default: 1 MiB. */
|
||||
maxBlobBytes: number;
|
||||
/**
|
||||
* Hard cap on the number of non-expired blobs queued for a single
|
||||
* recipient address. PUTs past this cap return SHADE_VALIDATION.
|
||||
* Default: 1000.
|
||||
*/
|
||||
maxBlobsPerAddress: number;
|
||||
/** Default TTL when sender omits ttlSeconds. Default: 7 days. */
|
||||
defaultTtlSeconds: number;
|
||||
/** Maximum TTL the relay will accept. Default: 30 days. */
|
||||
maxTtlSeconds: number;
|
||||
/** Minimum TTL. Default: 60 seconds. */
|
||||
minTtlSeconds: number;
|
||||
/** Maximum number of blobs returned in one GET. Default: 100. */
|
||||
fetchPageLimit: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_INBOX_QUOTA: InboxQuotaConfig = {
|
||||
maxBlobBytes: 1 * 1024 * 1024,
|
||||
maxBlobsPerAddress: 1000,
|
||||
defaultTtlSeconds: 7 * 24 * 60 * 60,
|
||||
maxTtlSeconds: 30 * 24 * 60 * 60,
|
||||
minTtlSeconds: 60,
|
||||
fetchPageLimit: 100,
|
||||
};
|
||||
|
||||
export function clampTtl(ttl: number | undefined, q: InboxQuotaConfig): number {
|
||||
const v = ttl ?? q.defaultTtlSeconds;
|
||||
if (!Number.isFinite(v) || v <= 0) {
|
||||
throw new ValidationError('ttlSeconds must be positive', 'ttlSeconds');
|
||||
}
|
||||
if (v < q.minTtlSeconds) {
|
||||
throw new ValidationError(`ttlSeconds < min (${q.minTtlSeconds})`, 'ttlSeconds');
|
||||
}
|
||||
if (v > q.maxTtlSeconds) {
|
||||
throw new ValidationError(`ttlSeconds > max (${q.maxTtlSeconds})`, 'ttlSeconds');
|
||||
}
|
||||
return Math.floor(v);
|
||||
}
|
||||
372
packages/shade-inbox-server/src/routes.ts
Normal file
372
packages/shade-inbox-server/src/routes.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { CryptoProvider } from '@shade/core';
|
||||
import {
|
||||
errorToHttpStatus,
|
||||
ShadeError,
|
||||
ValidationError,
|
||||
RateLimitError,
|
||||
UnauthorizedError,
|
||||
fromBase64,
|
||||
toBase64,
|
||||
constantTimeEqual,
|
||||
} from '@shade/core';
|
||||
import {
|
||||
verifyPayload,
|
||||
validateAddress,
|
||||
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 { InboxStore } from './store.js';
|
||||
import { InboxServerEvents, shortHash } from './events.js';
|
||||
import { computeMsgId, isValidMsgId, constantTimeStringEqual } from './msg-id.js';
|
||||
import { clampTtl, DEFAULT_INBOX_QUOTA, type InboxQuotaConfig } from './quota.js';
|
||||
|
||||
/** Max metadata-only body size (no ciphertext). 64 KB is enough headroom. */
|
||||
const MAX_META_BODY_SIZE = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Per-route token-bucket presets. PUT is intentionally generous (senders
|
||||
* may burst) but bound on the recipient side (per-address quota in the
|
||||
* store). FETCH and DELETE are per-address.
|
||||
*/
|
||||
const INBOX_PUT_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 };
|
||||
const INBOX_FETCH_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 };
|
||||
const INBOX_DELETE_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 };
|
||||
const INBOX_REGISTER_LIMIT: RateLimitConfig = {
|
||||
capacity: 5,
|
||||
refillPerSecond: 5 / 3600,
|
||||
};
|
||||
|
||||
export interface InboxRoutesOptions {
|
||||
/** Disable rate limiting (used in tests). */
|
||||
disableRateLimit?: boolean;
|
||||
/** Optional event emitter. */
|
||||
events?: InboxServerEvents;
|
||||
/** OTel observability hook. */
|
||||
observability?: ObservabilityHook;
|
||||
/** Override quota policy. */
|
||||
quota?: Partial<InboxQuotaConfig>;
|
||||
}
|
||||
|
||||
export function createInboxRoutes(
|
||||
store: InboxStore,
|
||||
crypto: CryptoProvider,
|
||||
options: InboxRoutesOptions = {},
|
||||
): Hono {
|
||||
const app = new Hono();
|
||||
const events = options.events;
|
||||
const observability = options.observability ?? NOOP_HOOK;
|
||||
const quota: InboxQuotaConfig = { ...DEFAULT_INBOX_QUOTA, ...(options.quota ?? {}) };
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
const route = c.req.routePath ?? c.req.path ?? '<unknown>';
|
||||
const span = observability.startSpan('shade.inbox.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, INBOX_PUT_LIMIT);
|
||||
const fetchRL = new RateLimiter(rlStore, INBOX_FETCH_LIMIT);
|
||||
const deleteRL = new RateLimiter(rlStore, INBOX_DELETE_LIMIT);
|
||||
const registerRL = new RateLimiter(rlStore, INBOX_REGISTER_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.get('/health', (c) => c.json({ status: 'ok', service: 'shade-inbox-server' }));
|
||||
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof RateLimitError) {
|
||||
events?.emit('inbox.rate_limited', {
|
||||
route: c.req.routePath ?? c.req.path,
|
||||
key: getClientIp(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 inbox error:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
});
|
||||
|
||||
// ─── Register address (TOFU) ───────────────────────────────
|
||||
// Recipient claims an address by uploading its signing key and a
|
||||
// signature over the canonical body. Subsequent PUT/GET/DELETE for the
|
||||
// address are authenticated against this key. Idempotent if the same key
|
||||
// re-registers; rejects if a different key tries to take an existing slot.
|
||||
app.post('/v1/inbox/register', async (c) => {
|
||||
if (rateLimitEnabled) await registerRL.consume(`inbox-register:${getClientIp(c)}`);
|
||||
|
||||
const rawBody = await c.req.text();
|
||||
if (rawBody.length > MAX_META_BODY_SIZE) {
|
||||
throw new ValidationError(`Request body too large (max ${MAX_META_BODY_SIZE} bytes)`);
|
||||
}
|
||||
const body = JSON.parse(rawBody);
|
||||
const { address, signingKey } = body;
|
||||
const addr = validateAddress(address);
|
||||
if (typeof signingKey !== 'string') {
|
||||
throw new ValidationError('Missing signingKey', 'signingKey');
|
||||
}
|
||||
const key = b64ToBytes(signingKey);
|
||||
|
||||
// Verify signature against the asserted key (TOFU).
|
||||
await verifyPayload(crypto, key, body);
|
||||
|
||||
const existing = await store.getAddressOwner(addr);
|
||||
if (existing && !constantTimeEqual(existing, key)) {
|
||||
throw new UnauthorizedError(`Address already claimed by a different key`);
|
||||
}
|
||||
await store.saveAddressOwner(addr, key);
|
||||
|
||||
if (events) {
|
||||
events.emit('inbox.address_registered', {
|
||||
address: addr,
|
||||
signingKeyHash: await shortHash(key),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Unregister (signed) ───────────────────────────────────
|
||||
app.delete('/v1/inbox/register/:address', async (c) => {
|
||||
const address = validateAddress(c.req.param('address'));
|
||||
if (rateLimitEnabled) await registerRL.consume(`inbox-unregister:${address}`);
|
||||
|
||||
const owner = await store.getAddressOwner(address);
|
||||
if (!owner) {
|
||||
return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
await verifyPayload(crypto, owner, { ...body, address });
|
||||
|
||||
await store.deleteAddress(address);
|
||||
events?.emit('inbox.address_deleted', { address });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── PUT a blob (signed by sender) ─────────────────────────
|
||||
// Body format:
|
||||
// {
|
||||
// senderSigningKey: b64,
|
||||
// msgId: hex(sha256(ciphertext)),
|
||||
// ciphertext: b64,
|
||||
// ttlSeconds?: number,
|
||||
// signedAt: number,
|
||||
// signature: b64, // over the canonical body sans signature
|
||||
// }
|
||||
// The recipient address is the path parameter. The sender authenticates
|
||||
// itself via `senderSigningKey` (TOFU per request — the *recipient*
|
||||
// determines whether to accept the sender, via the encrypted envelope).
|
||||
app.post('/v1/inbox/:address', async (c) => {
|
||||
if (rateLimitEnabled) await putRL.consume(`inbox-put:${getClientIp(c)}`);
|
||||
|
||||
const address = validateAddress(c.req.param('address'));
|
||||
|
||||
const rawBody = await c.req.text();
|
||||
// Allow up to (maxBlobBytes * 4/3) for base64 + JSON overhead.
|
||||
const hardLimit = Math.ceil(quota.maxBlobBytes * 1.4) + MAX_META_BODY_SIZE;
|
||||
if (rawBody.length > hardLimit) {
|
||||
events?.emit('inbox.quota_rejected', { address, reason: 'body-too-large' });
|
||||
throw new ValidationError(`Request body too large`);
|
||||
}
|
||||
|
||||
const body = JSON.parse(rawBody);
|
||||
const { senderSigningKey, msgId, ciphertext, ttlSeconds } = body;
|
||||
|
||||
if (typeof senderSigningKey !== 'string') {
|
||||
throw new ValidationError('Missing senderSigningKey', 'senderSigningKey');
|
||||
}
|
||||
if (typeof ciphertext !== 'string') {
|
||||
throw new ValidationError('Missing ciphertext', 'ciphertext');
|
||||
}
|
||||
if (!isValidMsgId(msgId)) {
|
||||
throw new ValidationError('msgId must be 64 lowercase hex chars', 'msgId');
|
||||
}
|
||||
|
||||
const senderKey = b64ToBytes(senderSigningKey);
|
||||
const ctBytes = b64ToBytes(ciphertext);
|
||||
|
||||
if (ctBytes.length === 0) {
|
||||
throw new ValidationError('ciphertext is empty', 'ciphertext');
|
||||
}
|
||||
if (ctBytes.length > quota.maxBlobBytes) {
|
||||
events?.emit('inbox.quota_rejected', { address, reason: 'body-too-large' });
|
||||
throw new ValidationError(
|
||||
`ciphertext exceeds maxBlobBytes (${ctBytes.length} > ${quota.maxBlobBytes})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the claimed msgId matches the actual ciphertext digest.
|
||||
const recomputed = await computeMsgId(ctBytes);
|
||||
if (!constantTimeStringEqual(recomputed, msgId)) {
|
||||
throw new ValidationError('msgId does not match sha256(ciphertext)', 'msgId');
|
||||
}
|
||||
|
||||
// Verify sender signature.
|
||||
await verifyPayload(crypto, senderKey, body);
|
||||
|
||||
// Recipient address must be registered (avoids DoS against unclaimed
|
||||
// slots — see THREAT-MODEL).
|
||||
const recipient = await store.getAddressOwner(address);
|
||||
if (!recipient) {
|
||||
return c.json({ error: 'Recipient not registered', code: 'SHADE_NOT_FOUND' }, 404);
|
||||
}
|
||||
|
||||
const ttl = clampTtl(typeof ttlSeconds === 'number' ? ttlSeconds : undefined, quota);
|
||||
const expiresAt = Date.now() + ttl * 1000;
|
||||
|
||||
// Per-address quota check before the write so the cap is enforced.
|
||||
const currentCount = await store.countBlobs(address, Date.now());
|
||||
if (currentCount >= quota.maxBlobsPerAddress) {
|
||||
events?.emit('inbox.quota_rejected', { address, reason: 'address-quota' });
|
||||
throw new ValidationError(
|
||||
`Recipient inbox is full (${currentCount} >= ${quota.maxBlobsPerAddress})`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await store.putBlob({
|
||||
address,
|
||||
msgId,
|
||||
ciphertext: ctBytes,
|
||||
expiresAt,
|
||||
});
|
||||
if (result.created) {
|
||||
events?.emit('inbox.blob_stored', {
|
||||
address,
|
||||
msgId,
|
||||
bytes: ctBytes.length,
|
||||
ttlSeconds: ttl,
|
||||
});
|
||||
} else {
|
||||
events?.emit('inbox.blob_idempotent_replay', { address, msgId });
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
msgId,
|
||||
receivedAt: result.receivedAt,
|
||||
idempotent: !result.created,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET blobs (signed challenge by recipient) ─────────────
|
||||
// Auth model: recipient signs the canonical (address, sinceCursor,
|
||||
// signedAt) tuple. Server verifies against the address's registered
|
||||
// signing key. Cursor is opaque — clients pass back the highest
|
||||
// `receivedAt` seen so far.
|
||||
app.post('/v1/inbox/:address/fetch', async (c) => {
|
||||
const address = validateAddress(c.req.param('address'));
|
||||
if (rateLimitEnabled) await fetchRL.consume(`inbox-fetch:${address}`);
|
||||
|
||||
const owner = await store.getAddressOwner(address);
|
||||
if (!owner) {
|
||||
return c.json({ error: 'Address not registered', 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);
|
||||
let { sinceCursor } = body;
|
||||
if (sinceCursor === undefined || sinceCursor === null) sinceCursor = 0;
|
||||
if (typeof sinceCursor !== 'number' || !Number.isFinite(sinceCursor) || sinceCursor < 0) {
|
||||
throw new ValidationError('sinceCursor must be a non-negative number', 'sinceCursor');
|
||||
}
|
||||
|
||||
// Bind the address to the signed payload to prevent cross-address replay.
|
||||
await verifyPayload(crypto, owner, { ...body, address });
|
||||
|
||||
const now = Date.now();
|
||||
const rows = await store.fetchBlobs({
|
||||
address,
|
||||
sinceCursor,
|
||||
now,
|
||||
limit: quota.fetchPageLimit,
|
||||
});
|
||||
|
||||
let bytes = 0;
|
||||
const blobs = rows.map((r) => {
|
||||
bytes += r.ciphertext.length;
|
||||
return {
|
||||
msgId: r.msgId,
|
||||
ciphertext: toBase64(r.ciphertext),
|
||||
receivedAt: r.receivedAt,
|
||||
expiresAt: r.expiresAt,
|
||||
};
|
||||
});
|
||||
const nextCursor = rows.length > 0 ? rows[rows.length - 1]!.receivedAt : sinceCursor;
|
||||
|
||||
events?.emit('inbox.blob_fetched', { address, count: blobs.length, bytes });
|
||||
|
||||
return c.json({
|
||||
blobs,
|
||||
cursor: nextCursor,
|
||||
hasMore: rows.length === quota.fetchPageLimit,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE a single blob (signed challenge by recipient) ──
|
||||
app.delete('/v1/inbox/:address/:msgId', async (c) => {
|
||||
const address = validateAddress(c.req.param('address'));
|
||||
if (rateLimitEnabled) await deleteRL.consume(`inbox-delete:${address}`);
|
||||
|
||||
const msgId = c.req.param('msgId');
|
||||
if (!isValidMsgId(msgId)) {
|
||||
throw new ValidationError('msgId must be 64 lowercase hex chars', 'msgId');
|
||||
}
|
||||
|
||||
const owner = await store.getAddressOwner(address);
|
||||
if (!owner) {
|
||||
return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404);
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
await verifyPayload(crypto, owner, { ...body, address, msgId });
|
||||
|
||||
const removed = await store.deleteBlob(address, msgId);
|
||||
if (removed) {
|
||||
events?.emit('inbox.blob_acked', { address, msgId });
|
||||
}
|
||||
return c.json({ ok: removed });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Base64 helpers ──────────────────────────────────────────
|
||||
|
||||
function b64ToBytes(s: string): Uint8Array {
|
||||
return fromBase64(s);
|
||||
}
|
||||
87
packages/shade-inbox-server/src/store.ts
Normal file
87
packages/shade-inbox-server/src/store.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* InboxStore — server-side storage interface for the async store-and-forward
|
||||
* relay (V3.6).
|
||||
*
|
||||
* The relay stores ciphertext blobs only. It never sees plaintext, never
|
||||
* holds private keys, and never decrypts anything. The address-owner table
|
||||
* binds an address to a recipient signing key (Ed25519) so GET/DELETE
|
||||
* authentication can verify that the caller actually owns the address.
|
||||
*
|
||||
* Per-blob row layout (mandated by V3.6 spec):
|
||||
* address || msgId || ciphertext-bytes || expires_at
|
||||
*
|
||||
* `received_at` is also stored per blob to support cursor-based GET. The
|
||||
* cursor is opaque to the caller — it is just the highest `received_at`
|
||||
* the client has seen, encoded as a string.
|
||||
*/
|
||||
export interface InboxStore {
|
||||
// ─── Address ownership (TOFU on first register) ───────────
|
||||
|
||||
/**
|
||||
* Register or update the signing key that owns `address`. First call
|
||||
* "claims" the address (TOFU). Subsequent calls with the same key are
|
||||
* no-ops. A different key trying to claim the same address is rejected
|
||||
* by the route layer (the store itself just upserts).
|
||||
*/
|
||||
saveAddressOwner(address: string, signingKey: Uint8Array): Promise<void>;
|
||||
|
||||
/** Look up the owner signing key for `address`, or null if unregistered. */
|
||||
getAddressOwner(address: string): Promise<Uint8Array | null>;
|
||||
|
||||
// ─── Blob CRUD ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Store an inbox blob.
|
||||
*
|
||||
* **Idempotent**: if a row already exists for `(address, msgId)` the
|
||||
* implementation MUST return `{ created: false }` and leave the existing
|
||||
* row untouched. A fresh insert returns `{ created: true, receivedAt }`.
|
||||
*/
|
||||
putBlob(args: {
|
||||
address: string;
|
||||
msgId: string;
|
||||
ciphertext: Uint8Array;
|
||||
expiresAt: number;
|
||||
}): Promise<{ created: boolean; receivedAt: number }>;
|
||||
|
||||
/**
|
||||
* Fetch all non-expired blobs for `address` whose `received_at > sinceCursor`,
|
||||
* ordered ascending by `received_at`. The caller passes 0 (or `null`-equiv
|
||||
* "") to fetch from the beginning.
|
||||
*
|
||||
* Implementations must filter out expired rows so a slow consumer never
|
||||
* sees a payload past TTL. Pruning of expired rows happens out-of-band.
|
||||
*/
|
||||
fetchBlobs(args: {
|
||||
address: string;
|
||||
sinceCursor: number;
|
||||
now: number;
|
||||
limit: number;
|
||||
}): Promise<Array<{ msgId: string; ciphertext: Uint8Array; receivedAt: number; expiresAt: number }>>;
|
||||
|
||||
/**
|
||||
* Delete a single blob by `(address, msgId)`. Returns true if a row was
|
||||
* removed. Used by clients to ack a fetched message for early prune.
|
||||
*/
|
||||
deleteBlob(address: string, msgId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Count non-expired blobs for `address`. Used by quota enforcement and
|
||||
* the inbox-fanout fingerprint gate.
|
||||
*/
|
||||
countBlobs(address: string, now: number): Promise<number>;
|
||||
|
||||
// ─── Maintenance ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Purge every blob whose `expires_at <= now`. Returns count removed.
|
||||
* Called periodically by a cron task.
|
||||
*/
|
||||
purgeExpired(now: number): Promise<number>;
|
||||
|
||||
/**
|
||||
* Drop the address owner record and any remaining blobs for `address`.
|
||||
* Used by the unregister route.
|
||||
*/
|
||||
deleteAddress(address: string): Promise<void>;
|
||||
}
|
||||
260
packages/shade-inbox-server/tests/lifecycle.test.ts
Normal file
260
packages/shade-inbox-server/tests/lifecycle.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
createInboxServer,
|
||||
MemoryInboxStore,
|
||||
computeMsgId,
|
||||
InboxPruneTask,
|
||||
} from '../src/index.js';
|
||||
import { signPayload } from '@shade/server';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { generateIdentityKeyPair, toBase64, fromBase64 } from '@shade/core';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
describe('Inbox lifecycle', () => {
|
||||
test('100 messages delivered without online overlap', async () => {
|
||||
const store = new MemoryInboxStore();
|
||||
const app = createInboxServer({ crypto, store, disableRateLimit: true });
|
||||
|
||||
const bob = await makeIdentity();
|
||||
const alice = await makeIdentity();
|
||||
|
||||
// Bob registers, then goes "offline".
|
||||
const reg = await signPayload(crypto, bob.signingPrivateKey, {
|
||||
address: 'bob',
|
||||
signingKey: toBase64(bob.signingPublicKey),
|
||||
});
|
||||
await app.request('/v1/inbox/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reg),
|
||||
});
|
||||
|
||||
// Alice puts 100 unique blobs while Bob is offline.
|
||||
const sentMsgIds = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const ct = randBytes(64 + (i % 8));
|
||||
const msgId = await computeMsgId(ct);
|
||||
sentMsgIds.add(msgId);
|
||||
const body = await signPayload(crypto, alice.signingPrivateKey, {
|
||||
senderSigningKey: toBase64(alice.signingPublicKey),
|
||||
msgId,
|
||||
ciphertext: toBase64(ct),
|
||||
});
|
||||
const r = await app.request(`/v1/inbox/bob`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
}
|
||||
|
||||
// Bob comes online and pulls everything in pages.
|
||||
const seen = new Set<string>();
|
||||
let cursor = 0;
|
||||
let safety = 0;
|
||||
while (safety++ < 50) {
|
||||
const fetchBody = await signPayload(crypto, bob.signingPrivateKey, {
|
||||
address: 'bob',
|
||||
sinceCursor: cursor,
|
||||
});
|
||||
const r = await app.request(`/v1/inbox/bob/fetch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fetchBody),
|
||||
});
|
||||
const j: any = await r.json();
|
||||
for (const b of j.blobs) seen.add(b.msgId);
|
||||
cursor = j.cursor;
|
||||
if (!j.hasMore) break;
|
||||
}
|
||||
expect(seen.size).toBe(100);
|
||||
for (const msgId of sentMsgIds) expect(seen.has(msgId)).toBe(true);
|
||||
});
|
||||
|
||||
test('persistence across "restart" — same store, fresh app object', async () => {
|
||||
const store = new MemoryInboxStore();
|
||||
|
||||
const bob = await makeIdentity();
|
||||
const alice = await makeIdentity();
|
||||
|
||||
// Stage 1: register + put 5 blobs.
|
||||
{
|
||||
const app = createInboxServer({ crypto, store, disableRateLimit: true });
|
||||
const reg = await signPayload(crypto, bob.signingPrivateKey, {
|
||||
address: 'bob',
|
||||
signingKey: toBase64(bob.signingPublicKey),
|
||||
});
|
||||
await app.request('/v1/inbox/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reg),
|
||||
});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const ct = randBytes(48 + i);
|
||||
const msgId = await computeMsgId(ct);
|
||||
const body = await signPayload(crypto, alice.signingPrivateKey, {
|
||||
senderSigningKey: toBase64(alice.signingPublicKey),
|
||||
msgId,
|
||||
ciphertext: toBase64(ct),
|
||||
});
|
||||
const r = await app.request(`/v1/inbox/bob`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 2: simulate a restart by building a brand-new Hono app on top
|
||||
// of the same persistent store, then verify fetches still see the data.
|
||||
{
|
||||
const app = createInboxServer({ crypto, store, disableRateLimit: true });
|
||||
const fetchBody = await signPayload(crypto, bob.signingPrivateKey, {
|
||||
address: 'bob',
|
||||
sinceCursor: 0,
|
||||
});
|
||||
const r = await app.request('/v1/inbox/bob/fetch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fetchBody),
|
||||
});
|
||||
const j: any = await r.json();
|
||||
expect(j.blobs.length).toBe(5);
|
||||
}
|
||||
});
|
||||
|
||||
test('prune removes expired blobs but keeps live ones', async () => {
|
||||
const store = new MemoryInboxStore();
|
||||
const app = createInboxServer({ crypto, store, disableRateLimit: true });
|
||||
const bob = await makeIdentity();
|
||||
const alice = await makeIdentity();
|
||||
|
||||
const reg = await signPayload(crypto, bob.signingPrivateKey, {
|
||||
address: 'bob',
|
||||
signingKey: toBase64(bob.signingPublicKey),
|
||||
});
|
||||
await app.request('/v1/inbox/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reg),
|
||||
});
|
||||
|
||||
// One blob with min TTL, one with default TTL (well in future).
|
||||
const shortCt = randBytes(64);
|
||||
const shortMsgId = await computeMsgId(shortCt);
|
||||
const shortBody = await signPayload(crypto, alice.signingPrivateKey, {
|
||||
senderSigningKey: toBase64(alice.signingPublicKey),
|
||||
msgId: shortMsgId,
|
||||
ciphertext: toBase64(shortCt),
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
await app.request('/v1/inbox/bob', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(shortBody),
|
||||
});
|
||||
|
||||
const longCt = randBytes(64);
|
||||
const longMsgId = await computeMsgId(longCt);
|
||||
const longBody = await signPayload(crypto, alice.signingPrivateKey, {
|
||||
senderSigningKey: toBase64(alice.signingPublicKey),
|
||||
msgId: longMsgId,
|
||||
ciphertext: toBase64(longCt),
|
||||
});
|
||||
await app.request('/v1/inbox/bob', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(longBody),
|
||||
});
|
||||
|
||||
// Force-expire the short blob by mutating expires_at.
|
||||
const list: any = (store as any).blobs.get('bob');
|
||||
list[0].expiresAt = Date.now() - 1000;
|
||||
|
||||
const prune = new InboxPruneTask(store, { intervalMinutes: 60 });
|
||||
const removed = await prune.runOnce();
|
||||
expect(removed).toBe(1);
|
||||
|
||||
const fetchBody = await signPayload(crypto, bob.signingPrivateKey, {
|
||||
address: 'bob',
|
||||
sinceCursor: 0,
|
||||
});
|
||||
const r = await app.request('/v1/inbox/bob/fetch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fetchBody),
|
||||
});
|
||||
const j: any = await r.json();
|
||||
expect(j.blobs.length).toBe(1);
|
||||
expect(j.blobs[0].msgId).toBe(longMsgId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tamper resistance', () => {
|
||||
test('bit-flip on stored ciphertext is reported as decode/decrypt failure on the client', async () => {
|
||||
const store = new MemoryInboxStore();
|
||||
const app = createInboxServer({ crypto, store, disableRateLimit: true });
|
||||
|
||||
const bob = await makeIdentity();
|
||||
const alice = await makeIdentity();
|
||||
|
||||
const reg = await signPayload(crypto, bob.signingPrivateKey, {
|
||||
address: 'bob',
|
||||
signingKey: toBase64(bob.signingPublicKey),
|
||||
});
|
||||
await app.request('/v1/inbox/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reg),
|
||||
});
|
||||
const ct = randBytes(64);
|
||||
const msgId = await computeMsgId(ct);
|
||||
const body = await signPayload(crypto, alice.signingPrivateKey, {
|
||||
senderSigningKey: toBase64(alice.signingPublicKey),
|
||||
msgId,
|
||||
ciphertext: toBase64(ct),
|
||||
});
|
||||
const putRes = await app.request('/v1/inbox/bob', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
expect(putRes.status).toBe(200);
|
||||
|
||||
// Tamper directly in the store.
|
||||
const blobs: any = (store as any).blobs.get('bob');
|
||||
blobs[0].ciphertext[5] ^= 0x01;
|
||||
|
||||
// Fetch returns the tampered blob — server is oblivious to integrity.
|
||||
const fetchBody = await signPayload(crypto, bob.signingPrivateKey, {
|
||||
address: 'bob',
|
||||
sinceCursor: 0,
|
||||
});
|
||||
const r = await app.request('/v1/inbox/bob/fetch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fetchBody),
|
||||
});
|
||||
const j: any = await r.json();
|
||||
expect(j.blobs.length).toBe(1);
|
||||
|
||||
// Recipient recomputes msgId; tampered ciphertext now hashes to a
|
||||
// value different from the stored msgId — that's the client-side
|
||||
// canary the V3.6 spec requires.
|
||||
const tampered = fromBase64(j.blobs[0].ciphertext);
|
||||
const recomputed = await computeMsgId(tampered);
|
||||
expect(recomputed).not.toBe(msgId);
|
||||
});
|
||||
});
|
||||
383
packages/shade-inbox-server/tests/routes.test.ts
Normal file
383
packages/shade-inbox-server/tests/routes.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
import {
|
||||
createInboxServer,
|
||||
MemoryInboxStore,
|
||||
computeMsgId,
|
||||
type InboxStore,
|
||||
} from '../src/index.js';
|
||||
import { signPayload } from '@shade/server';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { generateIdentityKeyPair, toBase64 } from '@shade/core';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
describe('Shade Inbox Server', () => {
|
||||
let store: InboxStore;
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new MemoryInboxStore();
|
||||
app = createInboxServer({ crypto, store, disableRateLimit: true });
|
||||
});
|
||||
|
||||
function req(method: string, path: string, body?: any) {
|
||||
const init: RequestInit = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
return app.request(path, init);
|
||||
}
|
||||
|
||||
async function registerBob(address = 'bob') {
|
||||
const bob = await makeIdentity();
|
||||
const body = await signPayload(crypto, bob.signingPrivateKey, {
|
||||
address,
|
||||
signingKey: toBase64(bob.signingPublicKey),
|
||||
});
|
||||
const res = await req('POST', '/v1/inbox/register', body);
|
||||
expect(res.status).toBe(200);
|
||||
return bob;
|
||||
}
|
||||
|
||||
async function putMsg(args: {
|
||||
sender: Awaited<ReturnType<typeof makeIdentity>>;
|
||||
recipient: string;
|
||||
ciphertext: Uint8Array;
|
||||
ttlSeconds?: number;
|
||||
}) {
|
||||
const msgId = await computeMsgId(args.ciphertext);
|
||||
const body: Record<string, unknown> = {
|
||||
senderSigningKey: toBase64(args.sender.signingPublicKey),
|
||||
msgId,
|
||||
ciphertext: toBase64(args.ciphertext),
|
||||
};
|
||||
if (args.ttlSeconds !== undefined) body.ttlSeconds = args.ttlSeconds;
|
||||
const signed = await signPayload(crypto, args.sender.signingPrivateKey, body);
|
||||
const res = await req('POST', `/v1/inbox/${args.recipient}`, signed);
|
||||
return { res, msgId };
|
||||
}
|
||||
|
||||
// ─── Health ─────────────────────────────────────────────────
|
||||
|
||||
test('health endpoint responds', async () => {
|
||||
const res = await req('GET', '/health');
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.service).toBe('shade-inbox-server');
|
||||
});
|
||||
|
||||
// ─── Registration (TOFU) ────────────────────────────────────
|
||||
|
||||
describe('POST /v1/inbox/register', () => {
|
||||
test('accepts valid registration', async () => {
|
||||
await registerBob();
|
||||
});
|
||||
|
||||
test('idempotent re-register with same key', async () => {
|
||||
const bob = await registerBob('bob');
|
||||
const body = await signPayload(crypto, bob.signingPrivateKey, {
|
||||
address: 'bob',
|
||||
signingKey: toBase64(bob.signingPublicKey),
|
||||
});
|
||||
const res = await req('POST', '/v1/inbox/register', body);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('rejects different key claiming same address', async () => {
|
||||
await registerBob('bob');
|
||||
const eve = await makeIdentity();
|
||||
const body = await signPayload(crypto, eve.signingPrivateKey, {
|
||||
address: 'bob',
|
||||
signingKey: toBase64(eve.signingPublicKey),
|
||||
});
|
||||
const res = await req('POST', '/v1/inbox/register', body);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects unsigned body', async () => {
|
||||
const bob = await makeIdentity();
|
||||
const res = await req('POST', '/v1/inbox/register', {
|
||||
address: 'bob',
|
||||
signingKey: toBase64(bob.signingPublicKey),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects bad signature', async () => {
|
||||
const bob = await makeIdentity();
|
||||
const res = await req('POST', '/v1/inbox/register', {
|
||||
address: 'bob',
|
||||
signingKey: toBase64(bob.signingPublicKey),
|
||||
signedAt: Date.now(),
|
||||
signature: toBase64(randBytes(64)),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PUT blob ───────────────────────────────────────────────
|
||||
|
||||
describe('POST /v1/inbox/:address (PUT blob)', () => {
|
||||
test('stores a signed blob from sender', async () => {
|
||||
await registerBob();
|
||||
const alice = await makeIdentity();
|
||||
const ct = randBytes(128);
|
||||
const { res, msgId } = await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct });
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.msgId).toBe(msgId);
|
||||
expect(json.idempotent).toBe(false);
|
||||
});
|
||||
|
||||
test('idempotent on duplicate ciphertext', async () => {
|
||||
await registerBob();
|
||||
const alice = await makeIdentity();
|
||||
const ct = randBytes(64);
|
||||
const first = await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct });
|
||||
expect(first.res.status).toBe(200);
|
||||
const second = await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct });
|
||||
expect(second.res.status).toBe(200);
|
||||
const j2 = await second.res.json();
|
||||
expect(j2.idempotent).toBe(true);
|
||||
expect(j2.msgId).toBe(first.msgId);
|
||||
});
|
||||
|
||||
test('rejects mismatched msgId', async () => {
|
||||
await registerBob();
|
||||
const alice = await makeIdentity();
|
||||
const ct = randBytes(64);
|
||||
const wrongId = '0'.repeat(64);
|
||||
const body = await signPayload(crypto, alice.signingPrivateKey, {
|
||||
senderSigningKey: toBase64(alice.signingPublicKey),
|
||||
msgId: wrongId,
|
||||
ciphertext: toBase64(ct),
|
||||
});
|
||||
const res = await req('POST', '/v1/inbox/bob', body);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects PUT to unregistered address', async () => {
|
||||
const alice = await makeIdentity();
|
||||
const ct = randBytes(64);
|
||||
const { res } = await putMsg({ sender: alice, recipient: 'nobody', ciphertext: ct });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('rejects bad sender signature', async () => {
|
||||
await registerBob();
|
||||
const alice = await makeIdentity();
|
||||
const eve = await makeIdentity();
|
||||
const ct = randBytes(64);
|
||||
const msgId = await computeMsgId(ct);
|
||||
// Sign with Eve, claim Alice's key.
|
||||
const body = await signPayload(crypto, eve.signingPrivateKey, {
|
||||
senderSigningKey: toBase64(alice.signingPublicKey),
|
||||
msgId,
|
||||
ciphertext: toBase64(ct),
|
||||
});
|
||||
const res = await req('POST', '/v1/inbox/bob', body);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects ciphertext > maxBlobBytes', async () => {
|
||||
const small = createInboxServer({
|
||||
crypto,
|
||||
store: new MemoryInboxStore(),
|
||||
disableRateLimit: true,
|
||||
quota: { maxBlobBytes: 256 },
|
||||
});
|
||||
// Register bob in this fresh app.
|
||||
const bob = await makeIdentity();
|
||||
const reg = await signPayload(crypto, bob.signingPrivateKey, {
|
||||
address: 'bob',
|
||||
signingKey: toBase64(bob.signingPublicKey),
|
||||
});
|
||||
await small.request('/v1/inbox/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reg),
|
||||
});
|
||||
const alice = await makeIdentity();
|
||||
const ct = randBytes(257);
|
||||
const msgId = await computeMsgId(ct);
|
||||
const body = await signPayload(crypto, alice.signingPrivateKey, {
|
||||
senderSigningKey: toBase64(alice.signingPublicKey),
|
||||
msgId,
|
||||
ciphertext: toBase64(ct),
|
||||
});
|
||||
const res = await small.request('/v1/inbox/bob', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects stale signature (replay window)', async () => {
|
||||
await registerBob();
|
||||
const alice = await makeIdentity();
|
||||
const ct = randBytes(64);
|
||||
const msgId = await computeMsgId(ct);
|
||||
// Hand-craft: sign normally, then mutate signedAt to 10 minutes ago.
|
||||
const signed = await signPayload(crypto, alice.signingPrivateKey, {
|
||||
senderSigningKey: toBase64(alice.signingPublicKey),
|
||||
msgId,
|
||||
ciphertext: toBase64(ct),
|
||||
});
|
||||
(signed as any).signedAt = Date.now() - 10 * 60 * 1000;
|
||||
const res = await req('POST', '/v1/inbox/bob', signed);
|
||||
// signedAt mutated → signature invalid → 401, OR replay → 409.
|
||||
expect([401, 409]).toContain(res.status);
|
||||
});
|
||||
|
||||
test('enforces per-address quota', async () => {
|
||||
const small = createInboxServer({
|
||||
crypto,
|
||||
store: new MemoryInboxStore(),
|
||||
disableRateLimit: true,
|
||||
quota: { maxBlobsPerAddress: 2 },
|
||||
});
|
||||
const bob = await makeIdentity();
|
||||
const reg = await signPayload(crypto, bob.signingPrivateKey, {
|
||||
address: 'bob',
|
||||
signingKey: toBase64(bob.signingPublicKey),
|
||||
});
|
||||
await small.request('/v1/inbox/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reg),
|
||||
});
|
||||
|
||||
const alice = await makeIdentity();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const ct = randBytes(32 + i);
|
||||
const msgId = await computeMsgId(ct);
|
||||
const body = await signPayload(crypto, alice.signingPrivateKey, {
|
||||
senderSigningKey: toBase64(alice.signingPublicKey),
|
||||
msgId,
|
||||
ciphertext: toBase64(ct),
|
||||
});
|
||||
const r = await small.request('/v1/inbox/bob', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
}
|
||||
// Third should be quota-rejected.
|
||||
const ct = randBytes(99);
|
||||
const msgId = await computeMsgId(ct);
|
||||
const body = await signPayload(crypto, alice.signingPrivateKey, {
|
||||
senderSigningKey: toBase64(alice.signingPublicKey),
|
||||
msgId,
|
||||
ciphertext: toBase64(ct),
|
||||
});
|
||||
const r = await small.request('/v1/inbox/bob', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
expect(r.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FETCH ──────────────────────────────────────────────────
|
||||
|
||||
describe('POST /v1/inbox/:address/fetch', () => {
|
||||
test('returns blobs after registration', async () => {
|
||||
const bob = await registerBob();
|
||||
const alice = await makeIdentity();
|
||||
const ct1 = randBytes(64);
|
||||
const ct2 = randBytes(80);
|
||||
await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct1 });
|
||||
await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct2 });
|
||||
|
||||
const fetchBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: 0 });
|
||||
const res = await req('POST', '/v1/inbox/bob/fetch', fetchBody);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.blobs.length).toBe(2);
|
||||
expect(typeof json.cursor).toBe('number');
|
||||
});
|
||||
|
||||
test('cursor pagination skips already-seen blobs', async () => {
|
||||
const bob = await registerBob();
|
||||
const alice = await makeIdentity();
|
||||
await putMsg({ sender: alice, recipient: 'bob', ciphertext: randBytes(20) });
|
||||
const firstFetch = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: 0 });
|
||||
const r1 = await req('POST', '/v1/inbox/bob/fetch', firstFetch);
|
||||
const j1 = await r1.json();
|
||||
const cursor = j1.cursor;
|
||||
expect(j1.blobs.length).toBe(1);
|
||||
// Add a second blob.
|
||||
await putMsg({ sender: alice, recipient: 'bob', ciphertext: randBytes(30) });
|
||||
const secondFetch = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: cursor });
|
||||
const r2 = await req('POST', '/v1/inbox/bob/fetch', secondFetch);
|
||||
const j2 = await r2.json();
|
||||
expect(j2.blobs.length).toBe(1);
|
||||
});
|
||||
|
||||
test('rejects fetch from a different signing key', async () => {
|
||||
await registerBob();
|
||||
const eve = await makeIdentity();
|
||||
const fetchBody = await signPayload(crypto, eve.signingPrivateKey, { address: 'bob', sinceCursor: 0 });
|
||||
const res = await req('POST', '/v1/inbox/bob/fetch', fetchBody);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects fetch on unregistered address', async () => {
|
||||
const eve = await makeIdentity();
|
||||
const fetchBody = await signPayload(crypto, eve.signingPrivateKey, { address: 'nobody', sinceCursor: 0 });
|
||||
const res = await req('POST', '/v1/inbox/nobody/fetch', fetchBody);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE / ack ───────────────────────────────────────────
|
||||
|
||||
describe('DELETE /v1/inbox/:address/:msgId', () => {
|
||||
test('removes a blob after ack', async () => {
|
||||
const bob = await registerBob();
|
||||
const alice = await makeIdentity();
|
||||
const ct = randBytes(64);
|
||||
const { msgId } = await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct });
|
||||
|
||||
const ackBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', msgId });
|
||||
const res = await req('DELETE', `/v1/inbox/bob/${msgId}`, ackBody);
|
||||
expect(res.status).toBe(200);
|
||||
const j = await res.json();
|
||||
expect(j.ok).toBe(true);
|
||||
|
||||
// Subsequent fetch should return zero.
|
||||
const fetchBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: 0 });
|
||||
const r2 = await req('POST', '/v1/inbox/bob/fetch', fetchBody);
|
||||
const j2 = await r2.json();
|
||||
expect(j2.blobs.length).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects ack from a different signing key', async () => {
|
||||
const bob = await registerBob();
|
||||
const alice = await makeIdentity();
|
||||
const eve = await makeIdentity();
|
||||
const ct = randBytes(64);
|
||||
const { msgId } = await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct });
|
||||
const ackBody = await signPayload(crypto, eve.signingPrivateKey, { address: 'bob', msgId });
|
||||
const res = await req('DELETE', `/v1/inbox/bob/${msgId}`, ackBody);
|
||||
expect(res.status).toBe(401);
|
||||
// and the blob must still be there
|
||||
const fetchBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: 0 });
|
||||
const r2 = await req('POST', '/v1/inbox/bob/fetch', fetchBody);
|
||||
const j2 = await r2.json();
|
||||
expect(j2.blobs.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
8
packages/shade-inbox-server/tsconfig.json
Normal file
8
packages/shade-inbox-server/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user