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