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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user