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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user