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,119 @@
import { describe, test, expect } from 'bun:test';
import {
ShadeError,
InvalidSignatureError,
DecryptionError,
NoSessionError,
MaxSkipExceededError,
DuplicateMessageError,
UntrustedIdentityError,
PreKeyNotFoundError,
ReplayError,
IdentityRotationError,
NetworkError,
StorageError,
ValidationError,
TimeoutError,
RateLimitError,
ConfigurationError,
UnauthorizedError,
errorToHttpStatus,
} from '../src/errors.js';
describe('Error hierarchy', () => {
test('all errors extend ShadeError', () => {
const errors = [
new InvalidSignatureError(),
new DecryptionError(),
new NoSessionError('addr'),
new MaxSkipExceededError(1001, 1000),
new DuplicateMessageError(),
new UntrustedIdentityError('addr'),
new PreKeyNotFoundError(1, 'signed'),
new ReplayError(),
new IdentityRotationError(),
new NetworkError('net'),
new StorageError('storage'),
new ValidationError('val'),
new TimeoutError(),
new RateLimitError(),
new ConfigurationError('cfg'),
new UnauthorizedError(),
];
for (const e of errors) {
expect(e).toBeInstanceOf(ShadeError);
expect(e).toBeInstanceOf(Error);
expect(e.code).toMatch(/^SHADE_/);
expect(e.name).not.toBe('ShadeError');
}
});
test('toJSON produces serializable form', () => {
const e = new InvalidSignatureError('bad sig');
const json = e.toJSON();
expect(json.name).toBe('InvalidSignatureError');
expect(json.code).toBe('SHADE_INVALID_SIGNATURE');
expect(json.message).toBe('bad sig');
const str = JSON.stringify(e);
const parsed = JSON.parse(str);
expect(parsed.code).toBe('SHADE_INVALID_SIGNATURE');
});
test('NetworkError carries status code', () => {
const e = new NetworkError('server error', 503);
expect(e.statusCode).toBe(503);
});
test('RateLimitError carries retry-after', () => {
const e = new RateLimitError('too many', 60);
expect(e.retryAfterSeconds).toBe(60);
});
test('ValidationError carries field name', () => {
const e = new ValidationError('must be positive', 'count');
expect(e.field).toBe('count');
});
});
describe('errorToHttpStatus', () => {
test('unknown error → 500', () => {
expect(errorToHttpStatus(new Error('plain'))).toBe(500);
expect(errorToHttpStatus('string error')).toBe(500);
expect(errorToHttpStatus(null)).toBe(500);
});
test('auth errors → 401/403', () => {
expect(errorToHttpStatus(new InvalidSignatureError())).toBe(401);
expect(errorToHttpStatus(new UnauthorizedError())).toBe(401);
expect(errorToHttpStatus(new UntrustedIdentityError('x'))).toBe(403);
});
test('not-found errors → 404', () => {
expect(errorToHttpStatus(new NoSessionError('x'))).toBe(404);
expect(errorToHttpStatus(new PreKeyNotFoundError(1, 'signed'))).toBe(404);
});
test('validation → 400', () => {
expect(errorToHttpStatus(new ValidationError('bad'))).toBe(400);
});
test('replay/duplicate → 409', () => {
expect(errorToHttpStatus(new ReplayError())).toBe(409);
expect(errorToHttpStatus(new DuplicateMessageError())).toBe(409);
});
test('rate limit → 429', () => {
expect(errorToHttpStatus(new RateLimitError())).toBe(429);
});
test('infra errors → 503', () => {
expect(errorToHttpStatus(new NetworkError('x'))).toBe(503);
expect(errorToHttpStatus(new StorageError('x'))).toBe(503);
expect(errorToHttpStatus(new ConfigurationError('x'))).toBe(503);
});
test('timeout → 504', () => {
expect(errorToHttpStatus(new TimeoutError())).toBe(504);
});
});

View File

@@ -0,0 +1,164 @@
import { describe, test, expect } from 'bun:test';
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
import { ShadeSessionManager, computeFingerprint, shortFingerprint } from '../src/index.js';
const crypto = new SubtleCryptoProvider();
describe('Fingerprints (safety numbers)', () => {
test('computeFingerprint produces 12 groups of 5 digits', async () => {
const sig = crypto.randomBytes(32);
const dh = crypto.randomBytes(32);
const fp = await computeFingerprint(crypto, sig, dh);
const groups = fp.split(' ');
expect(groups.length).toBe(12);
for (const g of groups) {
expect(g).toMatch(/^\d{5}$/);
}
});
test('same input produces same fingerprint (deterministic)', async () => {
const sig = new Uint8Array(32).fill(0xab);
const dh = new Uint8Array(32).fill(0xcd);
const fp1 = await computeFingerprint(crypto, sig, dh);
const fp2 = await computeFingerprint(crypto, sig, dh);
expect(fp1).toBe(fp2);
});
test('different identities produce different fingerprints', async () => {
const sig1 = crypto.randomBytes(32);
const dh1 = crypto.randomBytes(32);
const sig2 = crypto.randomBytes(32);
const dh2 = crypto.randomBytes(32);
const fp1 = await computeFingerprint(crypto, sig1, dh1);
const fp2 = await computeFingerprint(crypto, sig2, dh2);
expect(fp1).not.toBe(fp2);
});
test('shortFingerprint is 4 groups', () => {
const full = '11111 22222 33333 44444 55555 66666 77777 88888 99999 11111 22222 33333';
expect(shortFingerprint(full)).toBe('11111 22222 33333 44444');
});
});
describe('ShadeSessionManager fingerprints', () => {
test('getIdentityFingerprint returns stable value', async () => {
const storage = new MemoryStorage();
const mgr = new ShadeSessionManager(crypto, storage);
await mgr.initialize();
const fp1 = await mgr.getIdentityFingerprint();
const fp2 = await mgr.getIdentityFingerprint();
expect(fp1).toBe(fp2);
expect(fp1.split(' ').length).toBe(12);
});
test('fingerprint persists across SessionManager instances', async () => {
const storage = new MemoryStorage();
const mgr1 = new ShadeSessionManager(crypto, storage);
await mgr1.initialize();
const fp1 = await mgr1.getIdentityFingerprint();
const mgr2 = new ShadeSessionManager(crypto, storage);
await mgr2.initialize();
const fp2 = await mgr2.getIdentityFingerprint();
expect(fp1).toBe(fp2);
});
test('different identities have different fingerprints', async () => {
const alice = new ShadeSessionManager(crypto, new MemoryStorage());
const bob = new ShadeSessionManager(crypto, new MemoryStorage());
await alice.initialize();
await bob.initialize();
const fpA = await alice.getIdentityFingerprint();
const fpB = await bob.getIdentityFingerprint();
expect(fpA).not.toBe(fpB);
});
test('getShortFingerprint returns 4 groups', async () => {
const mgr = new ShadeSessionManager(crypto, new MemoryStorage());
await mgr.initialize();
const short = await mgr.getShortFingerprint();
expect(short.split(' ').length).toBe(4);
});
});
describe('Prekey stock management', () => {
test('ensurePreKeyStock generates keys when below min', async () => {
const mgr = new ShadeSessionManager(crypto, new MemoryStorage());
await mgr.initialize();
// Start with 0 OTPKs
const generated = await mgr.ensurePreKeyStock(5, 10);
expect(generated).toBe(10);
});
test('ensurePreKeyStock does nothing when above min', async () => {
const mgr = new ShadeSessionManager(crypto, new MemoryStorage());
await mgr.initialize();
await mgr.generateOneTimePreKeys(10);
const generated = await mgr.ensurePreKeyStock(5, 10);
expect(generated).toBe(0);
});
test('ensurePreKeyStock tops up to target', async () => {
const mgr = new ShadeSessionManager(crypto, new MemoryStorage());
await mgr.initialize();
await mgr.generateOneTimePreKeys(3);
// Below min of 5, should generate enough to reach target of 20
const generated = await mgr.ensurePreKeyStock(5, 20);
expect(generated).toBe(17);
});
});
describe('Session reset + identity change', () => {
async function setupPair() {
const alice = new ShadeSessionManager(crypto, new MemoryStorage());
const bob = new ShadeSessionManager(crypto, new MemoryStorage());
await alice.initialize();
await bob.initialize();
const otpks = await bob.generateOneTimePreKeys(5);
const bundle = await bob.createPreKeyBundle();
bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey };
await alice.initSessionFromBundle('bob', bundle);
return { alice, bob };
}
test('resetSession removes the session', async () => {
const { alice } = await setupPair();
// Encrypt once to confirm session exists
await alice.encrypt('bob', 'hello');
await alice.resetSession('bob');
expect(alice.encrypt('bob', 'next')).rejects.toThrow('No session');
});
test('acceptIdentityChange updates pinned trust', async () => {
const { alice, bob } = await setupPair();
// Alice verifies Bob's current identity
const bobId = bob.getPublicIdentity();
expect(await alice.verifyRemoteIdentity('bob', bobId.dhKey)).toBe(true);
// Different key is rejected
const fakeKey = crypto.randomBytes(32);
expect(await alice.verifyRemoteIdentity('bob', fakeKey)).toBe(false);
// Accept the fake key as the new Bob
await alice.acceptIdentityChange('bob', fakeKey);
// Now the fake key is trusted, old one isn't
expect(await alice.verifyRemoteIdentity('bob', fakeKey)).toBe(true);
expect(await alice.verifyRemoteIdentity('bob', bobId.dhKey)).toBe(false);
// Session was also removed
expect(alice.encrypt('bob', 'test')).rejects.toThrow('No session');
});
});

View File

@@ -0,0 +1,131 @@
import { describe, test, expect } from 'bun:test';
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
import { ShadeSessionManager, GRACE_PERIOD_MS } from '../src/index.js';
const crypto = new SubtleCryptoProvider();
describe('Identity rotation', () => {
test('rotateIdentity generates new identity and archives old', async () => {
const storage = new MemoryStorage();
const mgr = new ShadeSessionManager(crypto, storage);
await mgr.initialize();
const oldFp = await mgr.getIdentityFingerprint();
const oldPub = mgr.getPublicIdentity();
await mgr.rotateIdentity();
const newFp = await mgr.getIdentityFingerprint();
const newPub = mgr.getPublicIdentity();
// New identity should differ
expect(newFp).not.toBe(oldFp);
expect(newPub.signingKey).not.toEqual(oldPub.signingKey);
expect(newPub.dhKey).not.toEqual(oldPub.dhKey);
// Old identity is archived
const retired = await storage.getRetiredIdentities();
expect(retired.length).toBe(1);
expect(retired[0].keyPair.signingPublicKey).toEqual(oldPub.signingKey);
});
test('rotation returns a new prekey bundle', async () => {
const mgr = new ShadeSessionManager(crypto, new MemoryStorage());
await mgr.initialize();
const bundle = await mgr.rotateIdentity();
expect(bundle.identitySigningKey.length).toBe(32);
expect(bundle.identityDHKey.length).toBe(32);
expect(bundle.signedPreKey.keyId).toBe(2); // advanced
expect(bundle.signedPreKey.signature.length).toBe(64);
});
test('getActiveRetiredIdentities returns entries within grace period', async () => {
const mgr = new ShadeSessionManager(crypto, new MemoryStorage());
await mgr.initialize();
await mgr.rotateIdentity();
await mgr.rotateIdentity();
const active = await mgr.getActiveRetiredIdentities();
expect(active.length).toBe(2);
});
test('getActiveRetiredIdentities filters out expired entries', async () => {
const storage = new MemoryStorage();
const mgr = new ShadeSessionManager(crypto, storage);
await mgr.initialize();
// Manually add a very old retired identity
await storage.addRetiredIdentity({
keyPair: (await mgr.getPublicIdentity()) as any, // placeholder
retiredAt: Date.now() - (GRACE_PERIOD_MS + 1000),
});
// And a fresh one
await mgr.rotateIdentity();
const active = await mgr.getActiveRetiredIdentities();
expect(active.length).toBe(1);
});
test('pruneExpiredIdentities removes old entries', async () => {
const storage = new MemoryStorage();
const mgr = new ShadeSessionManager(crypto, storage);
await mgr.initialize();
// Rotate twice
await mgr.rotateIdentity();
await mgr.rotateIdentity();
expect((await storage.getRetiredIdentities()).length).toBe(2);
// Default grace period: nothing is expired yet
await mgr.pruneExpiredIdentities();
expect((await storage.getRetiredIdentities()).length).toBe(2);
// Force prune with 0 grace → everything goes
await mgr.pruneExpiredIdentities(0);
expect((await storage.getRetiredIdentities()).length).toBe(0);
});
test('rotation persists across manager restart', async () => {
const storage = new MemoryStorage();
const mgr1 = new ShadeSessionManager(crypto, storage);
await mgr1.initialize();
await mgr1.rotateIdentity();
const fp1 = await mgr1.getIdentityFingerprint();
const mgr2 = new ShadeSessionManager(crypto, storage);
await mgr2.initialize();
const fp2 = await mgr2.getIdentityFingerprint();
expect(fp1).toBe(fp2);
});
test('existing sessions survive identity rotation', async () => {
// Set up Alice-Bob conversation
const alice = new ShadeSessionManager(crypto, new MemoryStorage());
const bob = new ShadeSessionManager(crypto, new MemoryStorage());
await alice.initialize();
await bob.initialize();
const otpks = await bob.generateOneTimePreKeys(5);
const bundle = await bob.createPreKeyBundle();
bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey };
await alice.initSessionFromBundle('bob', bundle);
// Exchange messages to establish bidirectional session
const env1 = await alice.encrypt('bob', 'hello');
expect(await bob.decrypt('alice', env1)).toBe('hello');
const env2 = await bob.encrypt('alice', 'hi');
expect(await alice.decrypt('bob', env2)).toBe('hi');
// Alice rotates her identity — but her session with Bob should still work
// because the Double Ratchet uses ephemeral DH keys, not identity keys
await alice.rotateIdentity();
const env3 = await alice.encrypt('bob', 'after rotation');
expect(await bob.decrypt('alice', env3)).toBe('after rotation');
});
});