feat(hardening): M-Hard 1-5 — crypto, auth, rate limit, fingerprints, rotation

M-Hard 1: Cryptographic Hardening
- constantTimeEqual, zeroize, randomUint32 on CryptoProvider
- Fix Math.random() → crypto.randomUint32() for registrationId
- Zero message keys and chain keys after use in ratchet.ts
- Constant-time trust comparison in MemoryStorage + SQLiteStorage
- Timing variance test catches early-exit regressions

M-Hard 2: Self-Authenticated Prekey Server
- Ed25519 signature verification on all write routes
- signPayload/verifyPayload with canonical JSON, ±5 min replay window
- Address validation (NFKC, alphanumeric + :_-.)
- Global ShadeError → HTTP status mapping
- ShadeFetchTransport signs requests when signingPrivateKey provided
- Anonymous bundle fetches still allowed (read-only)

M-Hard 3: Rate Limiting + DoS Protection
- Token bucket rate limiter with pluggable store
- Per-route limits: register 5/h/IP, fetch 60/min/IP, replenish 10/min/id
- 64KB body size limit on POST
- Retry-After header on 429 responses

M-Hard 4: Auto-replenish + Fingerprints + Session Reset
- Safety numbers (12 groups × 5 digits, Signal-style)
- ensurePreKeyStock, resetSession, acceptIdentityChange
- verifyRemoteIdentity for out-of-band comparison

M-Hard 5: Identity Rotation with Grace Period
- rotateIdentity archives old identity, generates fresh signed prekey
- RetiredIdentity storage with addRetired/getRetired/pruneRetired
- 7-day default grace period for decrypting old sessions
- pruneExpiredIdentities for cleanup

M-Hard 8: Error Hierarchy
- New error types: Network, Storage, Validation, Timeout, RateLimit,
  Configuration, Unauthorized, Replay, IdentityRotation
- All errors have stable SHADE_* codes
- errorToHttpStatus for consistent HTTP mapping
- toJSON() for network serialization

188 tests passing, zero failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 17:45:34 +02:00
parent 7d214dc614
commit 96a8c210b2
25 changed files with 1835 additions and 257 deletions

View File

@@ -0,0 +1,133 @@
import type { CryptoProvider } from '@shade/core';
import { fromBase64, UnauthorizedError, ValidationError, ReplayError } from '@shade/core';
/**
* Self-authenticated prekey server.
*
* Each write request must include a signature over a canonical
* representation of the body (excluding the signature field itself)
* using the identity's Ed25519 signing key.
*
* On register: the signing key is extracted from the body itself
* (TOFU — first registration establishes the identity).
*
* On replenish/delete: the signing key is looked up from the stored
* identity record for the address.
*/
/** Maximum age of a signed request, in milliseconds */
const MAX_SIGNATURE_AGE_MS = 5 * 60 * 1000; // 5 minutes
/** Maximum clock skew tolerated (future timestamps) */
const MAX_FUTURE_SKEW_MS = 1 * 60 * 1000; // 1 minute
interface SignedPayload {
signedAt: number;
signature: string; // base64
[key: string]: unknown;
}
/**
* Canonicalize a payload for signing by omitting the `signature` field
* and using deterministic JSON key ordering.
*
* Both signer and verifier must produce identical bytes.
*/
export function canonicalizePayload(payload: SignedPayload): Uint8Array {
const { signature, ...rest } = payload;
// Sort keys to be deterministic
const sorted = Object.keys(rest)
.sort()
.reduce<Record<string, unknown>>((acc, key) => {
acc[key] = (rest as Record<string, unknown>)[key];
return acc;
}, {});
return new TextEncoder().encode(JSON.stringify(sorted));
}
/**
* Sign a payload with an Ed25519 signing private key.
* Returns the payload with `signature` and `signedAt` set.
*/
export async function signPayload<T extends Record<string, unknown>>(
crypto: CryptoProvider,
signingPrivateKey: Uint8Array,
payload: T,
): Promise<T & { signedAt: number; signature: string }> {
const signedAt = Date.now();
const bytes = canonicalizePayload({ ...payload, signedAt, signature: '' });
const sig = await crypto.sign(signingPrivateKey, bytes);
return {
...payload,
signedAt,
signature: Buffer.from(sig).toString('base64'),
};
}
/**
* Verify a signed payload against a given signing public key.
*
* Throws:
* - ValidationError if signedAt or signature fields are missing/malformed
* - ReplayError if signedAt is outside the allowed window
* - UnauthorizedError if the signature does not verify
*/
export async function verifyPayload(
crypto: CryptoProvider,
signingPublicKey: Uint8Array,
payload: unknown,
): Promise<void> {
if (!payload || typeof payload !== 'object') {
throw new ValidationError('Payload must be an object');
}
const p = payload as SignedPayload;
if (typeof p.signedAt !== 'number') {
throw new ValidationError('Missing or invalid signedAt', 'signedAt');
}
if (typeof p.signature !== 'string') {
throw new ValidationError('Missing or invalid signature', 'signature');
}
const now = Date.now();
const age = now - p.signedAt;
if (age > MAX_SIGNATURE_AGE_MS) {
throw new ReplayError(`Signature too old: ${age}ms`);
}
if (age < -MAX_FUTURE_SKEW_MS) {
throw new ReplayError(`Signature in the future: ${-age}ms ahead`);
}
let sigBytes: Uint8Array;
try {
sigBytes = fromBase64(p.signature);
} catch {
throw new ValidationError('Signature is not valid base64', 'signature');
}
const canonical = canonicalizePayload(p);
const valid = await crypto.verify(signingPublicKey, canonical, sigBytes);
if (!valid) {
throw new UnauthorizedError('Invalid signature');
}
}
// ─── Address validation ─────────────────────────────────────
const ADDRESS_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9:_\-.]{0,255}$/;
/**
* Validate a peer address string.
* Allows alphanumeric, :, _, -, . with max 256 chars and must start with alphanumeric.
* Rejects path traversal, unicode tricks, and empty strings.
*/
export function validateAddress(address: unknown): string {
if (typeof address !== 'string') {
throw new ValidationError('Address must be a string', 'address');
}
const normalized = address.normalize('NFKC');
if (!ADDRESS_REGEX.test(normalized)) {
throw new ValidationError('Invalid address format', 'address');
}
return normalized;
}

View File

@@ -1,4 +1,5 @@
import { Hono } from 'hono';
import type { Hono } from 'hono';
import type { CryptoProvider } from '@shade/core';
import { createPrekeyRoutes } from './routes.js';
import { MemoryPrekeyStore } from './memory-store.js';
import type { PrekeyStore } from './store.js';
@@ -6,23 +7,30 @@ import type { PrekeyStore } from './store.js';
export { createPrekeyRoutes } from './routes.js';
export { MemoryPrekeyStore } from './memory-store.js';
export type { PrekeyStore } from './store.js';
export { verifyPayload, signPayload, canonicalizePayload, validateAddress } from './auth.js';
/**
* Create a standalone Shade Prekey Server.
*
* Can be used standalone (Docker) or embedded in another Hono app.
* Requires a CryptoProvider for signature verification on write routes.
*
* Standalone:
* const server = createPrekeyServer();
* const crypto = new SubtleCryptoProvider();
* const server = createPrekeyServer({ crypto });
* export default { port: 3900, fetch: server.fetch };
*
* Embedded:
* Embedded in another Hono app:
* const app = new Hono();
* app.route('/shade', createPrekeyServer());
* app.route('/shade', createPrekeyServer({ crypto }));
*/
export function createPrekeyServer(options?: {
export function createPrekeyServer(options: {
crypto: CryptoProvider;
store?: PrekeyStore;
disableRateLimit?: boolean;
}): Hono {
const store = options?.store ?? new MemoryPrekeyStore();
return createPrekeyRoutes(store);
const store = options.store ?? new MemoryPrekeyStore();
return createPrekeyRoutes(store, options.crypto, { disableRateLimit: options.disableRateLimit });
}
export { RateLimiter, MemoryRateLimitStore } from './rate-limit.js';
export type { RateLimitStore, RateLimitConfig } from './rate-limit.js';

View File

@@ -0,0 +1,103 @@
import { RateLimitError } from '@shade/core';
/**
* Simple token-bucket rate limiter with pluggable storage.
*
* Default storage is in-memory (Map). For distributed deployments,
* swap in a Redis-backed RateLimitStore implementation.
*/
export interface RateLimitStore {
/** Get the current token count and last refill time for a key */
get(key: string): Promise<{ tokens: number; lastRefill: number } | null>;
/** Store the current token count and last refill time */
set(key: string, tokens: number, lastRefill: number): Promise<void>;
}
export class MemoryRateLimitStore implements RateLimitStore {
private entries = new Map<string, { tokens: number; lastRefill: number }>();
async get(key: string) {
return this.entries.get(key) ?? null;
}
async set(key: string, tokens: number, lastRefill: number) {
this.entries.set(key, { tokens, lastRefill });
}
/** Periodic cleanup: drop entries older than `maxAge` ms */
cleanup(maxAge: number) {
const now = Date.now();
for (const [key, entry] of this.entries) {
if (now - entry.lastRefill > maxAge) {
this.entries.delete(key);
}
}
}
}
export interface RateLimitConfig {
/** Maximum tokens in the bucket (burst capacity) */
capacity: number;
/** Tokens added per second */
refillPerSecond: number;
}
export class RateLimiter {
constructor(
private readonly store: RateLimitStore,
private readonly config: RateLimitConfig,
) {}
/**
* Attempt to consume one token for the given key.
* Throws RateLimitError if no tokens available.
*/
async consume(key: string, tokens = 1): Promise<void> {
const now = Date.now();
const entry = await this.store.get(key);
let currentTokens: number;
if (!entry) {
currentTokens = this.config.capacity;
} else {
const elapsed = (now - entry.lastRefill) / 1000;
const refilled = elapsed * this.config.refillPerSecond;
currentTokens = Math.min(this.config.capacity, entry.tokens + refilled);
}
if (currentTokens < tokens) {
const needed = tokens - currentTokens;
const retryAfter = Math.ceil(needed / this.config.refillPerSecond);
throw new RateLimitError(`Rate limit exceeded for ${key}`, retryAfter);
}
await this.store.set(key, currentTokens - tokens, now);
}
}
// ─── Preset configurations ──────────────────────────────────
/** Register: 5 per hour (prevent identity flooding) */
export const REGISTER_LIMIT: RateLimitConfig = {
capacity: 5,
refillPerSecond: 5 / 3600, // 5 per hour
};
/** Fetch bundle: 60 per minute (anonymous, high limit) */
export const FETCH_LIMIT: RateLimitConfig = {
capacity: 60,
refillPerSecond: 1,
};
/** Replenish: 10 per minute per identity */
export const REPLENISH_LIMIT: RateLimitConfig = {
capacity: 10,
refillPerSecond: 10 / 60,
};
/** Delete: 5 per hour per identity */
export const DELETE_LIMIT: RateLimitConfig = {
capacity: 5,
refillPerSecond: 5 / 3600,
};

View File

@@ -1,35 +1,93 @@
import { Hono } from 'hono';
import type { CryptoProvider } from '@shade/core';
import { fromBase64, errorToHttpStatus, ShadeError, ValidationError } from '@shade/core';
import type { PrekeyStore } from './store.js';
import { verifyPayload, validateAddress } from './auth.js';
import { RateLimiter, MemoryRateLimitStore, REGISTER_LIMIT, FETCH_LIMIT, REPLENISH_LIMIT, DELETE_LIMIT } from './rate-limit.js';
/** Max POST body size in bytes (64KB) */
const MAX_BODY_SIZE = 64 * 1024;
/**
* Create the Shade Prekey Server Hono app.
*
* Routes:
* POST /v1/keys/register — Register identity + upload prekey bundle
* GET /v1/keys/bundle/:address — Fetch a prekey bundle (consumes one OTP key)
* POST /v1/keys/replenish — Upload additional one-time prekeys
* GET /v1/keys/count/:address — Get remaining one-time prekey count
* DELETE /v1/keys/:address — Unregister (delete all keys)
* POST /v1/keys/register — Register identity + upload prekey bundle (SIGNED)
* GET /v1/keys/bundle/:address — Fetch a prekey bundle (anonymous)
* POST /v1/keys/replenish — Upload additional one-time prekeys (SIGNED)
* GET /v1/keys/count/:address — Get remaining one-time prekey count (anonymous)
* DELETE /v1/keys/:address — Unregister (SIGNED)
*
* Write routes require a valid Ed25519 signature. See auth.ts.
*/
export function createPrekeyRoutes(store: PrekeyStore): Hono {
export interface PrekeyRoutesOptions {
/** Disable rate limiting (for tests). Default: enabled. */
disableRateLimit?: boolean;
}
export function createPrekeyRoutes(
store: PrekeyStore,
crypto: CryptoProvider,
options: PrekeyRoutesOptions = {},
): Hono {
const app = new Hono();
// Rate limiters (one per route, per IP or per identity)
const rlStore = new MemoryRateLimitStore();
const registerRL = new RateLimiter(rlStore, REGISTER_LIMIT);
const fetchRL = new RateLimiter(rlStore, FETCH_LIMIT);
const replenishRL = new RateLimiter(rlStore, REPLENISH_LIMIT);
const deleteRL = new RateLimiter(rlStore, DELETE_LIMIT);
const rateLimitEnabled = !options.disableRateLimit;
// Helper: extract client IP from request headers
const getClientIp = (c: any): string => {
return (
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ??
c.req.header('x-real-ip') ??
'unknown'
);
};
// Global error handler — maps ShadeError to HTTP status
app.onError((err, c) => {
if (err instanceof ShadeError) {
const status = errorToHttpStatus(err);
const body: any = err.toJSON();
if ((err as any).retryAfterSeconds) {
c.header('Retry-After', String((err as any).retryAfterSeconds));
}
return c.json(body, status as any);
}
console.error('[Shade] Unhandled error:', err);
return c.json({ error: 'Internal server error' }, 500);
});
// ─── Register ──────────────────────────────────────────────
app.post('/v1/keys/register', async (c) => {
const body = await c.req.json();
if (rateLimitEnabled) await registerRL.consume(`register:${getClientIp(c)}`);
const rawBody = await c.req.text();
if (rawBody.length > MAX_BODY_SIZE) {
throw new ValidationError(`Request body too large (max ${MAX_BODY_SIZE} bytes)`);
}
const body = JSON.parse(rawBody);
const { address, identitySigningKey, identityDHKey, signedPreKey, oneTimePreKeys } = body;
if (!address || !identitySigningKey || !identityDHKey || !signedPreKey) {
return c.json({ error: 'Missing required fields' }, 400);
const addr = validateAddress(address);
if (!identitySigningKey || !identityDHKey || !signedPreKey) {
throw new ValidationError('Missing required fields');
}
// Decode base64 keys
const signingKey = b64ToBytes(identitySigningKey);
const dhKey = b64ToBytes(identityDHKey);
await store.saveIdentity(address, signingKey, dhKey);
// Verify signature against the identity's own signing key (TOFU)
await verifyPayload(crypto, signingKey, body);
await store.saveIdentity(addr, signingKey, dhKey);
await store.saveSignedPreKey(
address,
addr,
signedPreKey.keyId,
b64ToBytes(signedPreKey.publicKey),
b64ToBytes(signedPreKey.signature),
@@ -40,27 +98,27 @@ export function createPrekeyRoutes(store: PrekeyStore): Hono {
keyId: k.keyId,
publicKey: b64ToBytes(k.publicKey),
}));
await store.saveOneTimePreKeys(address, keys);
await store.saveOneTimePreKeys(addr, keys);
}
return c.json({ ok: true });
});
// ─── Fetch Bundle ──────────────────────────────────────────
// ─── Fetch Bundle (anonymous) ──────────────────────────────
app.get('/v1/keys/bundle/:address', async (c) => {
const address = c.req.param('address');
if (rateLimitEnabled) await fetchRL.consume(`fetch:${getClientIp(c)}`);
const address = validateAddress(c.req.param('address'));
const identity = await store.getIdentity(address);
if (!identity) {
return c.json({ error: 'Address not found' }, 404);
return c.json({ error: 'Address not found', code: 'SHADE_NOT_FOUND' }, 404);
}
const signedPreKey = await store.getSignedPreKey(address);
if (!signedPreKey) {
return c.json({ error: 'No signed prekey' }, 404);
return c.json({ error: 'No signed prekey', code: 'SHADE_NOT_FOUND' }, 404);
}
// Consume one one-time prekey (if available)
const oneTimePreKey = await store.consumeOneTimePreKey(address);
const bundle: any = {
@@ -83,35 +141,59 @@ export function createPrekeyRoutes(store: PrekeyStore): Hono {
return c.json(bundle);
});
// ─── Replenish One-Time Prekeys ────────────────────────────
// ─── Replenish One-Time Prekeys (signed) ───────────────────
app.post('/v1/keys/replenish', async (c) => {
const body = await c.req.json();
const rawBody = await c.req.text();
if (rawBody.length > MAX_BODY_SIZE) {
throw new ValidationError(`Request body too large (max ${MAX_BODY_SIZE} bytes)`);
}
const body = JSON.parse(rawBody);
const { address, oneTimePreKeys } = body;
if (!address || !oneTimePreKeys || !Array.isArray(oneTimePreKeys)) {
return c.json({ error: 'Missing address or oneTimePreKeys' }, 400);
const addr = validateAddress(address);
if (rateLimitEnabled) await replenishRL.consume(`replenish:${addr}`);
if (!oneTimePreKeys || !Array.isArray(oneTimePreKeys)) {
throw new ValidationError('Missing oneTimePreKeys');
}
// Look up the stored identity and verify the signature against it
const identity = await store.getIdentity(addr);
if (!identity) {
return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404);
}
await verifyPayload(crypto, identity.identitySigningKey, body);
const keys = oneTimePreKeys.map((k: any) => ({
keyId: k.keyId,
publicKey: b64ToBytes(k.publicKey),
}));
await store.saveOneTimePreKeys(address, keys);
await store.saveOneTimePreKeys(addr, keys);
const count = await store.getOneTimePreKeyCount(address);
const count = await store.getOneTimePreKeyCount(addr);
return c.json({ ok: true, remaining: count });
});
// ─── Get Count ─────────────────────────────────────────────
// ─── Get Count (anonymous) ─────────────────────────────────
app.get('/v1/keys/count/:address', async (c) => {
const address = c.req.param('address');
const address = validateAddress(c.req.param('address'));
const count = await store.getOneTimePreKeyCount(address);
return c.json({ count });
});
// ─── Delete ────────────────────────────────────────────────
// ─── Delete (signed) ───────────────────────────────────────
app.delete('/v1/keys/:address', async (c) => {
const address = c.req.param('address');
const address = validateAddress(c.req.param('address'));
if (rateLimitEnabled) await deleteRL.consume(`delete:${address}`);
const body = await c.req.json();
// Look up the stored identity and verify the signature
const identity = await store.getIdentity(address);
if (!identity) {
return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404);
}
// Include address in the signed payload
await verifyPayload(crypto, identity.identitySigningKey, { ...body, address });
await store.deleteAll(address);
return c.json({ ok: true });
});

View File

@@ -1,3 +1,4 @@
import { SubtleCryptoProvider } from '@shade/crypto-web';
import { createPrekeyRoutes } from './routes.js';
import type { PrekeyStore } from './store.js';
@@ -13,8 +14,9 @@ async function createStore(): Promise<PrekeyStore> {
return new MemoryPrekeyStore();
}
const crypto = new SubtleCryptoProvider();
const store = await createStore();
const server = createPrekeyRoutes(store);
const server = createPrekeyRoutes(store, crypto);
const port = Number(process.env.PORT ?? 3900);
export default { port, fetch: server.fetch };