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

@ -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'],
);
}