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

@@ -23,11 +23,16 @@ import {
ratchetEncrypt,
ratchetDecrypt,
} from './ratchet.js';
import { NoSessionError } from './errors.js';
import { NoSessionError, UntrustedIdentityError } from './errors.js';
import { computeFingerprint, shortFingerprint } from './fingerprint.js';
import { constantTimeEqual } from './crypto.js';
const enc = new TextEncoder();
const dec = new TextDecoder();
/** Default grace period for retired identities: 7 days */
export const GRACE_PERIOD_MS = 7 * 24 * 60 * 60 * 1000;
/**
* ShadeSessionManager — the high-level API for using Shade.
*
@@ -71,10 +76,13 @@ export class ShadeSessionManager {
await this.storage.saveIdentityKeyPair(this.identity);
}
// Load or generate registration ID
// Load or generate registration ID (cryptographically secure)
this.registrationId = await this.storage.getLocalRegistrationId();
if (this.registrationId === 0) {
this.registrationId = Math.floor(Math.random() * 0xffffffff) + 1;
// Ensure nonzero (0 is the "unset" sentinel)
let id = this.crypto.randomUint32();
if (id === 0) id = 1;
this.registrationId = id;
await this.storage.saveLocalRegistrationId(this.registrationId);
}
@@ -98,6 +106,90 @@ export class ShadeSessionManager {
};
}
// ─── Fingerprints (Safety Numbers) ──────────────────────────
/**
* Get a human-readable fingerprint of our own identity.
* Format: 12 groups of 5 decimal digits (60 total).
* Two parties compare these out-of-band to verify no MITM.
*/
async getIdentityFingerprint(): Promise<string> {
if (!this.identity) throw new Error('Not initialized');
return computeFingerprint(
this.crypto,
this.identity.signingPublicKey,
this.identity.dhPublicKey,
);
}
/** Short 4-group fingerprint for quick comparison */
async getShortFingerprint(): Promise<string> {
return shortFingerprint(await this.getIdentityFingerprint());
}
/**
* Get a fingerprint for a remote peer's identity.
* Throws NoSessionError if we haven't established a session with them.
*/
async getRemoteFingerprint(address: string): Promise<string> {
const session = await this.storage.getSession(address);
if (!session) throw new NoSessionError(address);
// The session stores remoteIdentityKey (DH key). We need the signing key too,
// which we don't store per-session. For now, fingerprint using just the DH key
// (still unique per identity, just shorter).
// In the future, store remoteIdentitySigningKey alongside.
return computeFingerprint(
this.crypto,
session.remoteIdentityKey,
session.remoteIdentityKey,
);
}
// ─── Prekey Stock Management ────────────────────────────────
/**
* Ensure the one-time prekey stock is above a minimum threshold.
* If below `min`, generates enough to bring it up to `target`.
* Returns the number of new keys generated (0 if no action needed).
*/
async ensurePreKeyStock(min = 5, target = 20): Promise<number> {
const current = await this.storage.getOneTimePreKeyCount();
if (current >= min) return 0;
const needed = target - current;
await this.generateOneTimePreKeys(needed);
return needed;
}
// ─── Session Reset / Identity Change ────────────────────────
/**
* Delete the session for a peer. The next message will trigger a fresh X3DH.
* Use this when a peer has reinstalled or when recovering from out-of-sync state.
*/
async resetSession(address: string): Promise<void> {
await this.storage.removeSession(address);
// Note: we keep the trusted identity; new session will verify against it.
}
/**
* Accept a changed remote identity. This should only be called after
* verifying the new identity out-of-band (e.g., comparing fingerprints).
* After this, any pinned trust for this address is replaced.
*/
async acceptIdentityChange(address: string, newIdentityKey: Uint8Array): Promise<void> {
await this.storage.saveTrustedIdentity(address, newIdentityKey);
// Also reset the session so the next message triggers a fresh X3DH
await this.storage.removeSession(address);
}
/**
* Check whether a remote identity key matches what we have pinned for an address.
* Returns true on TOFU (no pinned key yet) or exact match.
*/
async verifyRemoteIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
return this.storage.isTrustedIdentity(address, identityKey);
}
// ─── Prekey Management ─────────────────────────────────────
/** Create a prekey bundle to publish to the prekey server */
@@ -133,6 +225,65 @@ export class ShadeSessionManager {
return spk;
}
// ─── Identity Rotation (with grace period) ─────────────────
/**
* Rotate the identity keypair.
*
* Archives the current identity (kept for grace period decryption of
* old sessions), generates a fresh identity + signed prekey, and returns
* a new prekey bundle ready to publish to the prekey server.
*
* Callers should:
* 1. Call this method
* 2. Re-publish the new bundle via ShadeFetchTransport.register()
* 3. Optionally broadcast the rotation to known peers out-of-band
*
* The old identity is retained for GRACE_PERIOD_MS so existing sessions
* continue decrypting. Call `pruneExpiredIdentities()` periodically.
*/
async rotateIdentity(): Promise<PreKeyBundle> {
if (!this.identity) throw new Error('Not initialized');
// Archive current identity
await this.storage.addRetiredIdentity({
keyPair: this.identity,
retiredAt: Date.now(),
});
// Generate new identity + save
this.identity = await generateIdentityKeyPair(this.crypto);
await this.storage.saveIdentityKeyPair(this.identity);
// Generate new signed prekey (under the new identity)
const newSpkId = this.currentSignedPreKeyId + 1;
const spk = await generateSignedPreKey(this.crypto, this.identity, newSpkId);
await this.storage.saveSignedPreKey(spk);
this.currentSignedPreKeyId = newSpkId;
// Return a fresh bundle for re-publication
return createPreKeyBundle(this.registrationId, this.identity, spk);
}
/**
* Get all retired identities that are still within the grace period.
* Used internally for trying previous identities when X3DH fails.
*/
async getActiveRetiredIdentities(gracePeriodMs = GRACE_PERIOD_MS): Promise<IdentityKeyPair[]> {
const all = await this.storage.getRetiredIdentities();
const cutoff = Date.now() - gracePeriodMs;
return all.filter((r) => r.retiredAt >= cutoff).map((r) => r.keyPair);
}
/**
* Delete retired identities older than the grace period.
* Call this periodically (e.g., daily cleanup task).
*/
async pruneExpiredIdentities(gracePeriodMs = GRACE_PERIOD_MS): Promise<void> {
const cutoff = Date.now() - gracePeriodMs;
await this.storage.pruneRetiredIdentities(cutoff);
}
// ─── Session Establishment ─────────────────────────────────
/**