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>
2026-04-10 17:45:34 +02:00
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
|
|
|
import { RateLimiter, MemoryRateLimitStore, createPrekeyServer, MemoryPrekeyStore, signPayload } from '../src/index.js';
|
|
|
|
|
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
|
|
|
|
import { generateIdentityKeyPair, RateLimitError } from '@shade/core';
|
|
|
|
|
|
|
|
|
|
const crypto = new SubtleCryptoProvider();
|
|
|
|
|
|
|
|
|
|
describe('RateLimiter', () => {
|
|
|
|
|
test('allows requests up to capacity', async () => {
|
|
|
|
|
const store = new MemoryRateLimitStore();
|
|
|
|
|
const rl = new RateLimiter(store, { capacity: 5, refillPerSecond: 1 });
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
|
|
|
await rl.consume('user:1');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('throws RateLimitError when exhausted', async () => {
|
|
|
|
|
const store = new MemoryRateLimitStore();
|
|
|
|
|
const rl = new RateLimiter(store, { capacity: 3, refillPerSecond: 0.1 });
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 3; i++) await rl.consume('user:1');
|
|
|
|
|
expect(rl.consume('user:1')).rejects.toThrow(RateLimitError);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('different keys have independent limits', async () => {
|
|
|
|
|
const store = new MemoryRateLimitStore();
|
|
|
|
|
const rl = new RateLimiter(store, { capacity: 2, refillPerSecond: 0.1 });
|
|
|
|
|
|
|
|
|
|
await rl.consume('user:1');
|
|
|
|
|
await rl.consume('user:1');
|
|
|
|
|
// user:1 exhausted
|
|
|
|
|
expect(rl.consume('user:1')).rejects.toThrow(RateLimitError);
|
|
|
|
|
|
|
|
|
|
// user:2 still has full capacity
|
|
|
|
|
await rl.consume('user:2');
|
|
|
|
|
await rl.consume('user:2');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('refills over time', async () => {
|
|
|
|
|
const store = new MemoryRateLimitStore();
|
|
|
|
|
const rl = new RateLimiter(store, { capacity: 2, refillPerSecond: 100 }); // fast refill for test
|
|
|
|
|
|
|
|
|
|
await rl.consume('user:1');
|
|
|
|
|
await rl.consume('user:1');
|
|
|
|
|
|
|
|
|
|
// Wait a bit for refill (100 tokens/sec → 2 tokens in 20ms)
|
|
|
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
|
|
|
|
|
|
|
|
// Should be refilled
|
|
|
|
|
await rl.consume('user:1');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('RateLimitError has retryAfterSeconds', async () => {
|
|
|
|
|
const store = new MemoryRateLimitStore();
|
|
|
|
|
const rl = new RateLimiter(store, { capacity: 1, refillPerSecond: 0.5 });
|
|
|
|
|
|
|
|
|
|
await rl.consume('user:1');
|
|
|
|
|
try {
|
|
|
|
|
await rl.consume('user:1');
|
|
|
|
|
expect.unreachable();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
expect(err).toBeInstanceOf(RateLimitError);
|
|
|
|
|
expect((err as RateLimitError).retryAfterSeconds).toBeGreaterThan(0);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Rate limiting integration with routes', () => {
|
|
|
|
|
test('register endpoint rate-limits per IP', async () => {
|
|
|
|
|
const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore() });
|
|
|
|
|
|
|
|
|
|
async function doRegister(addressSuffix: number) {
|
|
|
|
|
const identity = await generateIdentityKeyPair(crypto);
|
|
|
|
|
const body: any = {
|
|
|
|
|
address: `user${addressSuffix}`,
|
|
|
|
|
identitySigningKey: Buffer.from(identity.signingPublicKey).toString('base64'),
|
|
|
|
|
identityDHKey: Buffer.from(identity.dhPublicKey).toString('base64'),
|
|
|
|
|
signedPreKey: {
|
|
|
|
|
keyId: 1,
|
|
|
|
|
publicKey: Buffer.from(crypto.randomBytes(32)).toString('base64'),
|
|
|
|
|
signature: Buffer.from(crypto.randomBytes(64)).toString('base64'),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
const signed = await signPayload(crypto, identity.signingPrivateKey, body);
|
|
|
|
|
return app.request('/v1/keys/register', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '203.0.113.1' },
|
|
|
|
|
body: JSON.stringify(signed),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Register limit is 5/hour, so after 5 successful, the 6th should fail
|
|
|
|
|
const results: number[] = [];
|
|
|
|
|
for (let i = 0; i < 7; i++) {
|
|
|
|
|
const res = await doRegister(i);
|
|
|
|
|
results.push(res.status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// First 5 should succeed (200), rest should be rate-limited (429)
|
|
|
|
|
expect(results.filter((s) => s === 200).length).toBeGreaterThanOrEqual(5);
|
|
|
|
|
expect(results.filter((s) => s === 429).length).toBeGreaterThanOrEqual(1);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-08 00:55:57 +02:00
|
|
|
// V4.8.1 — `SHADE_DISABLE_RATE_LIMIT=1` in standalone.ts is plumbed
|
|
|
|
|
// through to `createPrekeyServer({ disableRateLimit })`. This test
|
|
|
|
|
// covers the "what happens when the flag is true" path; the env-var
|
|
|
|
|
// → option conversion in standalone.ts is a one-liner verified by
|
|
|
|
|
// inspection.
|
|
|
|
|
test('register endpoint allows >5/hour from a single IP when disableRateLimit is set', async () => {
|
|
|
|
|
const app = createPrekeyServer({
|
|
|
|
|
crypto,
|
|
|
|
|
store: new MemoryPrekeyStore(),
|
|
|
|
|
disableRateLimit: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function doRegister(addressSuffix: number) {
|
|
|
|
|
const identity = await generateIdentityKeyPair(crypto);
|
|
|
|
|
const body: any = {
|
|
|
|
|
address: `user${addressSuffix}`,
|
|
|
|
|
identitySigningKey: Buffer.from(identity.signingPublicKey).toString('base64'),
|
|
|
|
|
identityDHKey: Buffer.from(identity.dhPublicKey).toString('base64'),
|
|
|
|
|
signedPreKey: {
|
|
|
|
|
keyId: 1,
|
|
|
|
|
publicKey: Buffer.from(crypto.randomBytes(32)).toString('base64'),
|
|
|
|
|
signature: Buffer.from(crypto.randomBytes(64)).toString('base64'),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
const signed = await signPayload(crypto, identity.signingPrivateKey, body);
|
|
|
|
|
return app.request('/v1/keys/register', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '203.0.113.1' },
|
|
|
|
|
body: JSON.stringify(signed),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const results: number[] = [];
|
|
|
|
|
for (let i = 0; i < 12; i++) {
|
|
|
|
|
const res = await doRegister(i);
|
|
|
|
|
results.push(res.status);
|
|
|
|
|
}
|
|
|
|
|
// No 429 anywhere — the limit is OFF.
|
|
|
|
|
expect(results.filter((s) => s === 429).length).toBe(0);
|
|
|
|
|
expect(results.filter((s) => s === 200).length).toBe(12);
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-04-10 17:45:34 +02:00
|
|
|
test('rate limit returns Retry-After header', async () => {
|
|
|
|
|
const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore() });
|
|
|
|
|
|
|
|
|
|
// Burn through the delete limit (5/hour)
|
|
|
|
|
const identity = await generateIdentityKeyPair(crypto);
|
|
|
|
|
|
|
|
|
|
// First register
|
|
|
|
|
const regBody: any = {
|
|
|
|
|
address: 'ratelimit',
|
|
|
|
|
identitySigningKey: Buffer.from(identity.signingPublicKey).toString('base64'),
|
|
|
|
|
identityDHKey: Buffer.from(identity.dhPublicKey).toString('base64'),
|
|
|
|
|
signedPreKey: {
|
|
|
|
|
keyId: 1,
|
|
|
|
|
publicKey: Buffer.from(crypto.randomBytes(32)).toString('base64'),
|
|
|
|
|
signature: Buffer.from(crypto.randomBytes(64)).toString('base64'),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
const signedReg = await signPayload(crypto, identity.signingPrivateKey, regBody);
|
|
|
|
|
await app.request('/v1/keys/register', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '203.0.113.2' },
|
|
|
|
|
body: JSON.stringify(signedReg),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Burn through the delete limit
|
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
|
|
|
const delBody = await signPayload(crypto, identity.signingPrivateKey, { address: 'ratelimit' });
|
|
|
|
|
await app.request('/v1/keys/ratelimit', {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(delBody),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 6th should be rate-limited
|
|
|
|
|
const delBody = await signPayload(crypto, identity.signingPrivateKey, { address: 'ratelimit' });
|
|
|
|
|
const res = await app.request('/v1/keys/ratelimit', {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(delBody),
|
|
|
|
|
});
|
|
|
|
|
expect(res.status).toBe(429);
|
|
|
|
|
expect(res.headers.get('Retry-After')).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
});
|