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

@@ -1,10 +1,12 @@
import { Database } from 'bun:sqlite';
import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState } from '@shade/core';
import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, RetiredIdentity } from '@shade/core';
import {
toBase64, fromBase64,
constantTimeEqual,
serializeSessionState, deserializeSessionState,
serializeSignedPreKey, deserializeSignedPreKey,
serializeOneTimePreKey, deserializeOneTimePreKey,
serializeIdentityKeyPair, deserializeIdentityKeyPair,
} from '@shade/core';
/**
@@ -37,6 +39,9 @@ export class SQLiteStorage implements StorageProvider {
removeSession: ReturnType<Database['prepare']>;
getTrust: ReturnType<Database['prepare']>;
saveTrust: ReturnType<Database['prepare']>;
addRetired: ReturnType<Database['prepare']>;
listRetired: ReturnType<Database['prepare']>;
pruneRetired: ReturnType<Database['prepare']>;
};
constructor(dbPath?: string) {
@@ -76,6 +81,12 @@ export class SQLiteStorage implements StorageProvider {
address TEXT PRIMARY KEY,
identity_key TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS retired_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data_json TEXT NOT NULL,
retired_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_retired_at ON retired_identities(retired_at);
`);
}
@@ -97,6 +108,9 @@ export class SQLiteStorage implements StorageProvider {
removeSession: this.db.prepare('DELETE FROM sessions WHERE address = ?'),
getTrust: this.db.prepare('SELECT identity_key FROM trusted_identities WHERE address = ?'),
saveTrust: this.db.prepare('INSERT OR REPLACE INTO trusted_identities (address, identity_key) VALUES (?, ?)'),
addRetired: this.db.prepare('INSERT INTO retired_identities (data_json, retired_at) VALUES (?, ?)'),
listRetired: this.db.prepare('SELECT data_json, retired_at FROM retired_identities ORDER BY retired_at DESC'),
pruneRetired: this.db.prepare('DELETE FROM retired_identities WHERE retired_at < ?'),
};
}
@@ -193,10 +207,32 @@ export class SQLiteStorage implements StorageProvider {
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
const row = this.stmts.getTrust.get(address) as any;
if (!row) return true; // TOFU
return row.identity_key === toBase64(identityKey);
const storedBytes = fromBase64(row.identity_key);
return constantTimeEqual(storedBytes, identityKey);
}
async saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise<void> {
this.stmts.saveTrust.run(address, toBase64(identityKey));
}
// ─── Identity History ─────────────────────────────────────
async addRetiredIdentity(identity: RetiredIdentity): Promise<void> {
this.stmts.addRetired.run(
serializeIdentityKeyPair(identity.keyPair),
identity.retiredAt,
);
}
async getRetiredIdentities(): Promise<RetiredIdentity[]> {
const rows = this.stmts.listRetired.all() as any[];
return rows.map((r) => ({
keyPair: deserializeIdentityKeyPair(r.data_json),
retiredAt: r.retired_at,
}));
}
async pruneRetiredIdentities(olderThan: number): Promise<void> {
this.stmts.pruneRetired.run(olderThan);
}
}