feat: rate limiting + E2E crypto lifecycle test

Rate limiting (closes the last security gap #3):
- New RateLimiter: in-memory fixed-window IP-based limiter (std::sync::Mutex
  HashMap, no new deps). Configurable via RATE_LIMIT_MAX (default 100) +
  RATE_LIMIT_WINDOW_SECS (default 60) env vars.
- general_rate_limit_middleware now reads ClientIp from request extensions and
  rejects with 429 + Retry-After header when over the limit. Wired via
  from_fn_with_state in app.rs. Lived on AppState as Arc<RateLimiter>.
- Deleted the dead auth_rate_limit_middleware (never wired).
- 3 unit tests (allows up to N, independent IPs, window reset).

E2E crypto lifecycle test:
- Full zero-knowledge round-trip against jsdom's real Web Crypto: setup →
  encrypt → verify ciphertext → unlock with password → decrypt → recover via
  phrase → rewrap under new password → decrypt. Plus wrong-password/wrong-phrase
  failures and cross-user key isolation.
- Fixed wrapDek/unwrapDek: base64-encode raw DEK bytes (was using TextDecoder
  which produced non-base64), and make unwrapped DEK extractable (needed for
  rewrapDek to export).

Verified: backend 24 tests 0 warnings; frontend 24 tests, build clean.
This commit is contained in:
goose 2026-07-03 21:57:39 -03:00
parent dcd86524d7
commit b55b7c34f8
9 changed files with 262 additions and 23 deletions

View file

@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import {
setupEncryption,
unlockWithPassword,
unlockWithRecovery,
rewrapDek,
encryptJson,
decryptJson,
setEncKey,
clearEncKey,
hasEncKey,
} from './index';
describe('zero-knowledge crypto lifecycle', () => {
const password = 'my-secret-password';
const recoveryPhrase = 'my-recovery-phrase';
const sampleData = { name: 'Aspirin', dosage: '100mg', frequency: 'daily' };
it('full lifecycle: setup → encrypt → unlock → decrypt → recover → rewrap', async () => {
// 1. Setup (register): generate DEK, wrap under password + recovery.
const setup = await setupEncryption(password, recoveryPhrase);
expect(setup.dek).toBeDefined();
expect(setup.passwordWrappedDek.data).not.toBe('');
expect(setup.recoveryWrappedDek).toBeDefined();
expect(setup.recoveryKekHash).toBeDefined();
// 2. Encrypt data with the DEK.
const encrypted = await encryptJson(sampleData, setup.dek);
// Verify it's ciphertext, not plaintext.
expect(encrypted.data).not.toContain('Aspirin');
expect(encrypted.iv).not.toBe('');
// 3. Decrypt with the same DEK — round-trip.
const decrypted = await decryptJson<typeof sampleData>(encrypted, setup.dek);
expect(decrypted.name).toBe('Aspirin');
expect(decrypted.dosage).toBe('100mg');
// 4. Simulate page reload: DEK is lost.
clearEncKey();
expect(hasEncKey()).toBe(false);
// 5. Unlock with password (the unlock screen flow).
const unlockResult = await unlockWithPassword(password, setup.passwordWrappedDek);
setEncKey(unlockResult.dek);
expect(hasEncKey()).toBe(true);
// 6. The unlocked DEK can decrypt the same data.
const decryptedAfterUnlock = await decryptJson<typeof sampleData>(
encrypted,
unlockResult.dek,
);
expect(decryptedAfterUnlock.name).toBe('Aspirin');
// 7. Recover via recovery phrase (forgot password flow).
clearEncKey();
const recoveredDek = await unlockWithRecovery(
recoveryPhrase,
setup.recoveryWrappedDek!,
);
const decryptedAfterRecovery = await decryptJson<typeof sampleData>(
encrypted,
recoveredDek,
);
expect(decryptedAfterRecovery.name).toBe('Aspirin');
// 8. Rewrap under a new password (post-recovery).
const newPassword = 'my-new-password';
const rewrapped = await rewrapDek(recoveredDek, newPassword);
const unlockWithNew = await unlockWithPassword(newPassword, rewrapped);
const decryptedAfterRewrap = await decryptJson<typeof sampleData>(
encrypted,
unlockWithNew.dek,
);
expect(decryptedAfterRewrap.name).toBe('Aspirin');
clearEncKey();
});
it('wrong password fails to unlock', async () => {
const setup = await setupEncryption(password);
await expect(
unlockWithPassword('wrong-password', setup.passwordWrappedDek),
).rejects.toThrow();
});
it('wrong recovery phrase fails to unlock', async () => {
const setup = await setupEncryption(password, recoveryPhrase);
await expect(
unlockWithRecovery('wrong-phrase', setup.recoveryWrappedDek!),
).rejects.toThrow();
});
it('data encrypted under one key cannot be decrypted by another', async () => {
const setupA = await setupEncryption('user-a-password');
const setupB = await setupEncryption('user-b-password');
const encrypted = await encryptJson(sampleData, setupA.dek);
// User B's key cannot decrypt User A's data.
await expect(decryptJson(encrypted, setupB.dek)).rejects.toThrow();
});
});