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:
parent
dcd86524d7
commit
b55b7c34f8
9 changed files with 262 additions and 23 deletions
101
web/normogen-web/src/crypto/crypto.e2e.test.ts
Normal file
101
web/normogen-web/src/crypto/crypto.e2e.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -83,16 +83,14 @@ export async function generateDek(): Promise<CryptoKey> {
|
|||
);
|
||||
}
|
||||
|
||||
/** Export a DEK to raw bytes, encrypt (wrap) it under a KEK, return base64. */
|
||||
/** Export a DEK to raw bytes, base64-encode, then encrypt (wrap) under a KEK. */
|
||||
export async function wrapDek(dek: CryptoKey, kek: CryptoKey): Promise<CipherPayload> {
|
||||
const rawDek = await crypto.subtle.exportKey('raw', dek);
|
||||
return encrypt(
|
||||
new TextDecoder().decode(new Uint8Array(rawDek)),
|
||||
kek,
|
||||
);
|
||||
// Base64-encode the raw bytes so they survive the encrypt/decrypt string cycle.
|
||||
return encrypt(base64(new Uint8Array(rawDek)), kek);
|
||||
}
|
||||
|
||||
/** Decrypt (unwrap) a wrapped DEK and import as a non-extractable AES-GCM key. */
|
||||
/** Decrypt (unwrap) a wrapped DEK and import as an AES-GCM key. */
|
||||
export async function unwrapDek(payload: CipherPayload, kek: CryptoKey): Promise<CryptoKey> {
|
||||
const rawDekB64 = await decrypt(payload, kek);
|
||||
// The wrapped DEK was encrypted as a base64 string of the raw key bytes.
|
||||
|
|
@ -101,7 +99,7 @@ export async function unwrapDek(payload: CipherPayload, kek: CryptoKey): Promise
|
|||
'raw',
|
||||
rawDek as BufferSource,
|
||||
{ name: 'AES-GCM' },
|
||||
false, // non-extractable in the session (can't be re-exported)
|
||||
true, // extractable so rewrapDek can export it for re-wrapping
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue