Compare commits
2 commits
dcd86524d7
...
69faa43a72
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69faa43a72 | ||
|
|
b55b7c34f8 |
9 changed files with 262 additions and 23 deletions
|
|
@ -92,7 +92,7 @@ pub fn build_app(state: AppState) -> Router {
|
||||||
|
|
||||||
public_routes
|
public_routes
|
||||||
.merge(protected_routes)
|
.merge(protected_routes)
|
||||||
.with_state(state)
|
.with_state(state.clone())
|
||||||
.layer(
|
.layer(
|
||||||
ServiceBuilder::new()
|
ServiceBuilder::new()
|
||||||
// Resolve the client IP once and stash it for handlers/middleware
|
// Resolve the client IP once and stash it for handlers/middleware
|
||||||
|
|
@ -104,8 +104,9 @@ pub fn build_app(state: AppState) -> Router {
|
||||||
.layer(axum::middleware::from_fn(
|
.layer(axum::middleware::from_fn(
|
||||||
middleware::security_headers_middleware,
|
middleware::security_headers_middleware,
|
||||||
))
|
))
|
||||||
// General rate limiting (currently a stub)
|
// IP-based rate limiting (reads ClientIp from extensions).
|
||||||
.layer(axum::middleware::from_fn(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
middleware::general_rate_limit_middleware,
|
middleware::general_rate_limit_middleware,
|
||||||
))
|
))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,9 @@ pub struct AppState {
|
||||||
/// P0: persisted (hashed) refresh tokens, used for rotation, revocation,
|
/// P0: persisted (hashed) refresh tokens, used for rotation, revocation,
|
||||||
/// and surviving process restarts.
|
/// and surviving process restarts.
|
||||||
pub refresh_token_repo: Option<Arc<crate::models::refresh_token::RefreshTokenRepository>>,
|
pub refresh_token_repo: Option<Arc<crate::models::refresh_token::RefreshTokenRepository>>,
|
||||||
|
|
||||||
|
/// IP-based rate limiter (in-memory fixed-window).
|
||||||
|
pub rate_limiter: Arc<crate::security::RateLimiter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
|
@ -61,6 +64,13 @@ pub struct Config {
|
||||||
pub encryption: EncryptionConfig,
|
pub encryption: EncryptionConfig,
|
||||||
pub cors: CorsConfig,
|
pub cors: CorsConfig,
|
||||||
pub environment: Environment,
|
pub environment: Environment,
|
||||||
|
pub rate_limit: RateLimitConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct RateLimitConfig {
|
||||||
|
pub max_requests: u32,
|
||||||
|
pub window_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
|
@ -174,6 +184,14 @@ impl Config {
|
||||||
.collect(),
|
.collect(),
|
||||||
},
|
},
|
||||||
environment,
|
environment,
|
||||||
|
rate_limit: RateLimitConfig {
|
||||||
|
max_requests: std::env::var("RATE_LIMIT_MAX")
|
||||||
|
.unwrap_or_else(|_| "100".to_string())
|
||||||
|
.parse()?,
|
||||||
|
window_secs: std::env::var("RATE_LIMIT_WINDOW_SECS")
|
||||||
|
.unwrap_or_else(|_| "60".to_string())
|
||||||
|
.parse()?,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,12 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let interaction_service = Arc::new(services::InteractionService::new());
|
let interaction_service = Arc::new(services::InteractionService::new());
|
||||||
eprintln!("Interaction service initialized (Phase 2.8)");
|
eprintln!("Interaction service initialized (Phase 2.8)");
|
||||||
|
|
||||||
|
// IP-based rate limiter (in-memory fixed-window).
|
||||||
|
let rate_limiter = Arc::new(security::RateLimiter::new(
|
||||||
|
config.rate_limit.max_requests,
|
||||||
|
std::time::Duration::from_secs(config.rate_limit.window_secs),
|
||||||
|
));
|
||||||
|
|
||||||
// Create application state
|
// Create application state
|
||||||
let state = config::AppState {
|
let state = config::AppState {
|
||||||
db,
|
db,
|
||||||
|
|
@ -124,6 +130,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
interaction_service: Some(interaction_service),
|
interaction_service: Some(interaction_service),
|
||||||
token_version_cache,
|
token_version_cache,
|
||||||
refresh_token_repo: Some(refresh_token_repo),
|
refresh_token_repo: Some(refresh_token_repo),
|
||||||
|
rate_limiter,
|
||||||
};
|
};
|
||||||
|
|
||||||
eprintln!("Building router with security middleware...");
|
eprintln!("Building router with security middleware...");
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,42 @@
|
||||||
use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response};
|
use axum::{
|
||||||
|
extract::{Request, State},
|
||||||
|
http::StatusCode,
|
||||||
|
middleware::Next,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
|
||||||
/// Middleware for general rate limiting
|
use crate::{config::AppState, middleware::ClientIp};
|
||||||
/// NOTE: Currently a stub implementation. TODO: Implement IP-based rate limiting
|
|
||||||
|
/// IP-based rate limiting. Reads the resolved client IP from request extensions
|
||||||
|
/// (populated by `client_ip_middleware`). Returns 429 Too Many Requests if the
|
||||||
|
/// IP has exceeded the configured limit within the window.
|
||||||
pub async fn general_rate_limit_middleware(
|
pub async fn general_rate_limit_middleware(
|
||||||
|
State(state): State<AppState>,
|
||||||
req: Request,
|
req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, Response> {
|
||||||
// TODO: Implement proper rate limiting with IP-based tracking
|
let ip = req
|
||||||
// For now, just pass through
|
.extensions()
|
||||||
Ok(next.run(req).await)
|
.get::<ClientIp>()
|
||||||
|
.map(|c| c.as_str().to_string())
|
||||||
|
.unwrap_or_else(|| "0.0.0.0".to_string());
|
||||||
|
|
||||||
|
if !state.rate_limiter.check(&ip) {
|
||||||
|
let retry_after = state.rate_limiter.window_secs();
|
||||||
|
return Err((
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
[
|
||||||
|
("Retry-After", retry_after.to_string()),
|
||||||
|
("Content-Type", "application/json".to_string()),
|
||||||
|
],
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "rate limit exceeded",
|
||||||
|
"retry_after": retry_after,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Middleware for auth endpoint rate limiting
|
|
||||||
/// NOTE: Currently a stub implementation. TODO: Implement IP-based rate limiting
|
|
||||||
pub async fn auth_rate_limit_middleware(req: Request, next: Next) -> Result<Response, StatusCode> {
|
|
||||||
// TODO: Implement proper rate limiting with IP-based tracking
|
|
||||||
// For now, just pass through
|
|
||||||
Ok(next.run(req).await)
|
Ok(next.run(req).await)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
pub mod account_lockout;
|
pub mod account_lockout;
|
||||||
pub mod audit_logger;
|
pub mod audit_logger;
|
||||||
|
pub mod rate_limiter;
|
||||||
pub mod session_manager;
|
pub mod session_manager;
|
||||||
|
|
||||||
pub use account_lockout::AccountLockout;
|
pub use account_lockout::AccountLockout;
|
||||||
pub use audit_logger::AuditLogger;
|
pub use audit_logger::AuditLogger;
|
||||||
|
pub use rate_limiter::RateLimiter;
|
||||||
pub use session_manager::SessionManager;
|
pub use session_manager::SessionManager;
|
||||||
|
|
|
||||||
86
backend/src/security/rate_limiter.rs
Normal file
86
backend/src/security/rate_limiter.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// A fixed-window IP rate limiter. Thread-safe via a standard mutex (the
|
||||||
|
/// critical section is nanoseconds — increment + compare).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RateLimiter {
|
||||||
|
inner: Arc<Mutex<HashMap<String, WindowState>>>,
|
||||||
|
max_requests: u32,
|
||||||
|
window: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct WindowState {
|
||||||
|
count: u32,
|
||||||
|
started_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimiter {
|
||||||
|
pub fn new(max_requests: u32, window: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
max_requests,
|
||||||
|
window,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the given IP is allowed to make a request. Increments the
|
||||||
|
/// counter. Returns `true` if allowed, `false` if over the limit.
|
||||||
|
pub fn check(&self, ip: &str) -> bool {
|
||||||
|
let mut map = self.inner.lock().expect("rate limiter mutex poisoned");
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
let state = map.entry(ip.to_string()).or_insert(WindowState {
|
||||||
|
count: 0,
|
||||||
|
started_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset the window if it has elapsed.
|
||||||
|
if now.duration_since(state.started_at) >= self.window {
|
||||||
|
state.count = 0;
|
||||||
|
state.started_at = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.count += 1;
|
||||||
|
state.count <= self.max_requests
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The configured window duration (for the `Retry-After` response header).
|
||||||
|
pub fn window_secs(&self) -> u64 {
|
||||||
|
self.window.as_secs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_up_to_limit_then_denies() {
|
||||||
|
let limiter = RateLimiter::new(3, Duration::from_secs(60));
|
||||||
|
assert!(limiter.check("1.2.3.4"));
|
||||||
|
assert!(limiter.check("1.2.3.4"));
|
||||||
|
assert!(limiter.check("1.2.3.4"));
|
||||||
|
assert!(!limiter.check("1.2.3.4")); // 4th denied
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_ips_are_independent() {
|
||||||
|
let limiter = RateLimiter::new(2, Duration::from_secs(60));
|
||||||
|
assert!(limiter.check("1.1.1.1"));
|
||||||
|
assert!(limiter.check("1.1.1.1"));
|
||||||
|
assert!(!limiter.check("1.1.1.1")); // 1.1.1.1 exhausted
|
||||||
|
assert!(limiter.check("2.2.2.2")); // different IP still fine
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_reset_allows_again() {
|
||||||
|
let limiter = RateLimiter::new(1, Duration::from_millis(10));
|
||||||
|
assert!(limiter.check("9.9.9.9"));
|
||||||
|
assert!(!limiter.check("9.9.9.9")); // over limit
|
||||||
|
std::thread::sleep(Duration::from_millis(15));
|
||||||
|
assert!(limiter.check("9.9.9.9")); // window elapsed → allowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -142,6 +142,10 @@ pub async fn app_for_test() -> Option<(Router, String)> {
|
||||||
interaction_service: Some(interaction_service),
|
interaction_service: Some(interaction_service),
|
||||||
token_version_cache,
|
token_version_cache,
|
||||||
refresh_token_repo: Some(refresh_token_repo),
|
refresh_token_repo: Some(refresh_token_repo),
|
||||||
|
rate_limiter: std::sync::Arc::new(normogen_backend::security::RateLimiter::new(
|
||||||
|
1000,
|
||||||
|
std::time::Duration::from_secs(60),
|
||||||
|
)),
|
||||||
};
|
};
|
||||||
|
|
||||||
Some((build_app(state), db_name))
|
Some((build_app(state), db_name))
|
||||||
|
|
|
||||||
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> {
|
export async function wrapDek(dek: CryptoKey, kek: CryptoKey): Promise<CipherPayload> {
|
||||||
const rawDek = await crypto.subtle.exportKey('raw', dek);
|
const rawDek = await crypto.subtle.exportKey('raw', dek);
|
||||||
return encrypt(
|
// Base64-encode the raw bytes so they survive the encrypt/decrypt string cycle.
|
||||||
new TextDecoder().decode(new Uint8Array(rawDek)),
|
return encrypt(base64(new Uint8Array(rawDek)), kek);
|
||||||
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> {
|
export async function unwrapDek(payload: CipherPayload, kek: CryptoKey): Promise<CryptoKey> {
|
||||||
const rawDekB64 = await decrypt(payload, kek);
|
const rawDekB64 = await decrypt(payload, kek);
|
||||||
// The wrapped DEK was encrypted as a base64 string of the raw key bytes.
|
// 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',
|
'raw',
|
||||||
rawDek as BufferSource,
|
rawDek as BufferSource,
|
||||||
{ name: 'AES-GCM' },
|
{ 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'],
|
['encrypt', 'decrypt'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue