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:
@@ -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 ─────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user