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,149 @@
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);
});
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();
});
});

View File

@@ -1,6 +1,10 @@
import { describe, test, expect, beforeEach } from 'bun:test';
import { createPrekeyServer, MemoryPrekeyStore } from '../src/index.js';
import { createPrekeyServer, MemoryPrekeyStore, signPayload } from '../src/index.js';
import type { PrekeyStore } from '../src/index.js';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import { generateIdentityKeyPair } from '@shade/core';
const crypto = new SubtleCryptoProvider();
function b64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString('base64');
@@ -8,167 +12,164 @@ function b64(bytes: Uint8Array): string {
function randBytes(n: number): Uint8Array {
const buf = new Uint8Array(n);
crypto.getRandomValues(buf);
globalThis.crypto.getRandomValues(buf);
return buf;
}
async function makeIdentity() {
return generateIdentityKeyPair(crypto);
}
describe('Shade Prekey Server', () => {
let store: PrekeyStore;
let app: ReturnType<typeof createPrekeyServer>;
beforeEach(() => {
store = new MemoryPrekeyStore();
app = createPrekeyServer({ store });
app = createPrekeyServer({ crypto, store, disableRateLimit: true });
});
function req(method: string, path: string, body?: any) {
const init: RequestInit = { method, headers: { 'Content-Type': 'application/json' } };
if (body) init.body = JSON.stringify(body);
if (body !== undefined) init.body = JSON.stringify(body);
return app.request(path, init);
}
/** Helper: build a signed registration body for a given identity */
async function signedRegisterBody(identity: Awaited<ReturnType<typeof makeIdentity>>, address: string, withOTPKs = true) {
const body: any = {
address,
identitySigningKey: b64(identity.signingPublicKey),
identityDHKey: b64(identity.dhPublicKey),
signedPreKey: {
keyId: 1,
publicKey: b64(randBytes(32)),
signature: b64(randBytes(64)),
},
};
if (withOTPKs) {
body.oneTimePreKeys = [
{ keyId: 100, publicKey: b64(randBytes(32)) },
{ keyId: 101, publicKey: b64(randBytes(32)) },
];
}
return signPayload(crypto, identity.signingPrivateKey, body);
}
// ─── Registration ──────────────────────────────────────────
describe('POST /v1/keys/register', () => {
test('registers identity and signed prekey', async () => {
const res = await req('POST', '/v1/keys/register', {
address: 'alice',
identitySigningKey: b64(randBytes(32)),
identityDHKey: b64(randBytes(32)),
signedPreKey: {
keyId: 1,
publicKey: b64(randBytes(32)),
signature: b64(randBytes(64)),
},
});
test('accepts valid signed registration', async () => {
const alice = await makeIdentity();
const body = await signedRegisterBody(alice, 'alice');
const res = await req('POST', '/v1/keys/register', body);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
});
test('registers with one-time prekeys', async () => {
test('rejects unsigned registration', async () => {
const alice = await makeIdentity();
const res = await req('POST', '/v1/keys/register', {
address: 'alice',
identitySigningKey: b64(randBytes(32)),
identityDHKey: b64(randBytes(32)),
signedPreKey: {
keyId: 1,
publicKey: b64(randBytes(32)),
signature: b64(randBytes(64)),
},
oneTimePreKeys: [
{ keyId: 100, publicKey: b64(randBytes(32)) },
{ keyId: 101, publicKey: b64(randBytes(32)) },
{ keyId: 102, publicKey: b64(randBytes(32)) },
],
identitySigningKey: b64(alice.signingPublicKey),
identityDHKey: b64(alice.dhPublicKey),
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
});
expect(res.status).toBe(200);
// Verify count
const countRes = await req('GET', '/v1/keys/count/alice');
expect((await countRes.json()).count).toBe(3);
});
test('rejects missing fields', async () => {
const res = await req('POST', '/v1/keys/register', { address: 'alice' });
// Missing signature/signedAt → validation error
expect(res.status).toBe(400);
});
test('rejects registration with wrong signing key', async () => {
const alice = await makeIdentity();
const bob = await makeIdentity();
// Sign with bob's key but claim alice's public key
const body: any = {
address: 'alice',
identitySigningKey: b64(alice.signingPublicKey), // mismatch
identityDHKey: b64(alice.dhPublicKey),
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
};
const signed = await signPayload(crypto, bob.signingPrivateKey, body);
const res = await req('POST', '/v1/keys/register', signed);
expect(res.status).toBe(401);
});
test('rejects registration with stale signedAt', async () => {
const alice = await makeIdentity();
const body = await signedRegisterBody(alice, 'alice');
// Tamper with signedAt to be old
body.signedAt = Date.now() - 10 * 60 * 1000; // 10 minutes ago
const res = await req('POST', '/v1/keys/register', body);
expect(res.status).toBe(409); // ReplayError
});
test('rejects invalid address format', async () => {
const alice = await makeIdentity();
const body = await signedRegisterBody(alice, '../evil');
const res = await req('POST', '/v1/keys/register', body);
expect(res.status).toBe(400);
});
test('accepts registration with one-time prekeys', async () => {
const alice = await makeIdentity();
const body = await signedRegisterBody(alice, 'alice');
await req('POST', '/v1/keys/register', body);
const countRes = await req('GET', '/v1/keys/count/alice');
expect((await countRes.json()).count).toBe(2);
});
});
// ─── Fetch Bundle ──────────────────────────────────────────
// ─── Fetch Bundle (anonymous) ──────────────────────────────
describe('GET /v1/keys/bundle/:address', () => {
test('returns bundle with one-time prekey', async () => {
// Register first
const sigKey = b64(randBytes(32));
const dhKey = b64(randBytes(32));
const spkPub = b64(randBytes(32));
const spkSig = b64(randBytes(64));
const otpkPub = b64(randBytes(32));
await req('POST', '/v1/keys/register', {
address: 'bob',
identitySigningKey: sigKey,
identityDHKey: dhKey,
signedPreKey: { keyId: 1, publicKey: spkPub, signature: spkSig },
oneTimePreKeys: [{ keyId: 100, publicKey: otpkPub }],
});
test('returns bundle for registered address', async () => {
const bob = await makeIdentity();
const body = await signedRegisterBody(bob, 'bob');
await req('POST', '/v1/keys/register', body);
const res = await req('GET', '/v1/keys/bundle/bob');
expect(res.status).toBe(200);
const bundle = await res.json();
expect(bundle.identitySigningKey).toBe(sigKey);
expect(bundle.identityDHKey).toBe(dhKey);
expect(bundle.identitySigningKey).toBe(b64(bob.signingPublicKey));
expect(bundle.identityDHKey).toBe(b64(bob.dhPublicKey));
expect(bundle.signedPreKey.keyId).toBe(1);
expect(bundle.signedPreKey.publicKey).toBe(spkPub);
expect(bundle.signedPreKey.signature).toBe(spkSig);
expect(bundle.oneTimePreKey.keyId).toBe(100);
expect(bundle.oneTimePreKey.publicKey).toBe(otpkPub);
});
test('returns bundle without one-time prekey when depleted', async () => {
await req('POST', '/v1/keys/register', {
address: 'bob',
identitySigningKey: b64(randBytes(32)),
identityDHKey: b64(randBytes(32)),
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
});
test('consumes one-time prekey on each fetch (FIFO)', async () => {
const bob = await makeIdentity();
await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob'));
const res = await req('GET', '/v1/keys/bundle/bob');
expect(res.status).toBe(200);
const bundle = await res.json();
expect(bundle.oneTimePreKey).toBeUndefined();
});
test('consumes one-time prekeys on each fetch', async () => {
await req('POST', '/v1/keys/register', {
address: 'bob',
identitySigningKey: b64(randBytes(32)),
identityDHKey: b64(randBytes(32)),
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
oneTimePreKeys: [
{ keyId: 100, publicKey: b64(randBytes(32)) },
{ keyId: 101, publicKey: b64(randBytes(32)) },
],
});
// First fetch consumes key 100
const res1 = await req('GET', '/v1/keys/bundle/bob');
expect((await res1.json()).oneTimePreKey.keyId).toBe(100);
// Second fetch consumes key 101
const res2 = await req('GET', '/v1/keys/bundle/bob');
expect((await res2.json()).oneTimePreKey.keyId).toBe(101);
// Third fetch has none left
const res3 = await req('GET', '/v1/keys/bundle/bob');
expect((await res3.json()).oneTimePreKey).toBeUndefined();
// Count should be 0
const countRes = await req('GET', '/v1/keys/count/bob');
expect((await countRes.json()).count).toBe(0);
});
test('404 for unknown address', async () => {
const res = await req('GET', '/v1/keys/bundle/nobody');
expect(res.status).toBe(404);
});
test('rejects invalid address in URL', async () => {
const res = await req('GET', '/v1/keys/bundle/..evil');
expect(res.status).toBe(400);
});
});
// ─── Replenish ─────────────────────────────────────────────
// ─── Replenish (signed) ────────────────────────────────────
describe('POST /v1/keys/replenish', () => {
test('adds more one-time prekeys', async () => {
await req('POST', '/v1/keys/register', {
address: 'bob',
identitySigningKey: b64(randBytes(32)),
identityDHKey: b64(randBytes(32)),
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
oneTimePreKeys: [{ keyId: 100, publicKey: b64(randBytes(32)) }],
});
test('accepts signed replenishment from registered identity', async () => {
const bob = await makeIdentity();
await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob'));
const res = await req('POST', '/v1/keys/replenish', {
const replenishBody = await signPayload(crypto, bob.signingPrivateKey, {
address: 'bob',
oneTimePreKeys: [
{ keyId: 200, publicKey: b64(randBytes(32)) },
@@ -176,52 +177,83 @@ describe('Shade Prekey Server', () => {
],
});
const res = await req('POST', '/v1/keys/replenish', replenishBody);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.remaining).toBe(3); // 1 original + 2 new
expect(body.remaining).toBe(4); // 2 original + 2 new
});
test('rejects replenishment signed by wrong identity', async () => {
const bob = await makeIdentity();
const eve = await makeIdentity();
await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob'));
const evilBody = await signPayload(crypto, eve.signingPrivateKey, {
address: 'bob',
oneTimePreKeys: [{ keyId: 200, publicKey: b64(randBytes(32)) }],
});
const res = await req('POST', '/v1/keys/replenish', evilBody);
expect(res.status).toBe(401);
});
test('rejects replenishment for unknown address', async () => {
const bob = await makeIdentity();
const body = await signPayload(crypto, bob.signingPrivateKey, {
address: 'nobody',
oneTimePreKeys: [{ keyId: 200, publicKey: b64(randBytes(32)) }],
});
const res = await req('POST', '/v1/keys/replenish', body);
expect(res.status).toBe(404);
});
});
// ─── Delete ────────────────────────────────────────────────
// ─── Delete (signed) ───────────────────────────────────────
describe('DELETE /v1/keys/:address', () => {
test('removes all keys for an address', async () => {
await req('POST', '/v1/keys/register', {
address: 'bob',
identitySigningKey: b64(randBytes(32)),
identityDHKey: b64(randBytes(32)),
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
oneTimePreKeys: [{ keyId: 100, publicKey: b64(randBytes(32)) }],
});
test('accepts signed delete from registered identity', async () => {
const bob = await makeIdentity();
await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob'));
const delRes = await req('DELETE', '/v1/keys/bob');
expect(delRes.status).toBe(200);
const delBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob' });
const res = await req('DELETE', '/v1/keys/bob', delBody);
expect(res.status).toBe(200);
// Should be gone
const bundleRes = await req('GET', '/v1/keys/bundle/bob');
expect(bundleRes.status).toBe(404);
});
const countRes = await req('GET', '/v1/keys/count/bob');
expect((await countRes.json()).count).toBe(0);
test('rejects delete signed by wrong identity', async () => {
const bob = await makeIdentity();
const eve = await makeIdentity();
await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob'));
const evilBody = await signPayload(crypto, eve.signingPrivateKey, { address: 'bob' });
const res = await req('DELETE', '/v1/keys/bob', evilBody);
expect(res.status).toBe(401);
// Should still exist
const bundleRes = await req('GET', '/v1/keys/bundle/bob');
expect(bundleRes.status).toBe(200);
});
});
// ─── Multiple Addresses ────────────────────────────────────
// ─── Multi-address isolation ───────────────────────────────
describe('multi-address isolation', () => {
test('different addresses are independent', async () => {
for (const addr of ['alice', 'bob', 'charlie']) {
await req('POST', '/v1/keys/register', {
address: addr,
identitySigningKey: b64(randBytes(32)),
identityDHKey: b64(randBytes(32)),
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
oneTimePreKeys: [{ keyId: 1, publicKey: b64(randBytes(32)) }],
});
}
const alice = await makeIdentity();
const bob = await makeIdentity();
const charlie = await makeIdentity();
// Delete bob, others remain
await req('DELETE', '/v1/keys/bob');
await req('POST', '/v1/keys/register', await signedRegisterBody(alice, 'alice'));
await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob'));
await req('POST', '/v1/keys/register', await signedRegisterBody(charlie, 'charlie'));
// Delete bob with his own signature
const delBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob' });
await req('DELETE', '/v1/keys/bob', delBody);
expect((await req('GET', '/v1/keys/bundle/alice')).status).toBe(200);
expect((await req('GET', '/v1/keys/bundle/bob')).status).toBe(404);