diff --git a/core/archipelago/src/session.rs b/core/archipelago/src/session.rs index 3ee1b712..27eed3a3 100644 --- a/core/archipelago/src/session.rs +++ b/core/archipelago/src/session.rs @@ -393,14 +393,25 @@ impl SessionStore { } fn load_or_create_remember_secret() -> Vec { + // Try existing secret file first (backwards compatibility) if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) { if secret.len() == 32 { return secret; } } - let secret: [u8; 32] = rand::random(); - let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret); - secret.to_vec() + // Derive a deterministic secret from machine-id so it survives restarts + // without storing plaintext key material + let machine_id = std::fs::read_to_string("/etc/machine-id") + .unwrap_or_else(|_| uuid::Uuid::new_v4().to_string()); + let salt = b"archipelago-remember-me-v1"; + let mut hasher = sha2::Sha256::new(); + use sha2::Digest; + hasher.update(machine_id.trim().as_bytes()); + hasher.update(salt); + let secret = hasher.finalize(); + let secret_vec = secret.to_vec(); + let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret_vec); + secret_vec } } @@ -493,8 +504,10 @@ impl EndpointRateLimiter { limits.insert("update.apply".to_string(), (2, 600)); limits.insert("system.reboot".to_string(), (2, 300)); limits.insert("system.shutdown".to_string(), (2, 300)); - // Password changes + // Password and TOTP changes limits.insert("auth.changePassword".to_string(), (3, 300)); + limits.insert("auth.totp.setup".to_string(), (3, 300)); + limits.insert("auth.totp.confirm".to_string(), (5, 300)); // Federation join: prevent invite-code brute force limits.insert("federation.join".to_string(), (5, 60)); limits.insert("federation.invite".to_string(), (10, 300)); diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index c0509308..b4323fe3 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -61,7 +61,10 @@ pub struct WalletState { impl WalletState { /// Total balance of unspent tokens. pub fn balance(&self) -> u64 { - self.tokens.iter().filter(|t| !t.spent).map(|t| t.amount_sats).sum() + self.tokens.iter() + .filter(|t| !t.spent) + .try_fold(0u64, |acc, t| acc.checked_add(t.amount_sats)) + .unwrap_or(u64::MAX) } } diff --git a/core/security/Cargo.toml b/core/security/Cargo.toml index 613b2e51..b6ad1321 100644 --- a/core/security/Cargo.toml +++ b/core/security/Cargo.toml @@ -16,6 +16,7 @@ serde_json = "1.0" aes-gcm = "0.10" rand = "0.8" hex = "0.4" +zeroize = { version = "1", features = ["derive"] } [dev-dependencies] tempfile = "3" diff --git a/core/security/src/secrets_manager.rs b/core/security/src/secrets_manager.rs index 84b7de21..998b3236 100644 --- a/core/security/src/secrets_manager.rs +++ b/core/security/src/secrets_manager.rs @@ -38,6 +38,7 @@ pub struct ExpiringSecret { pub struct SecretsManager { secrets_dir: PathBuf, cipher: Aes256Gcm, + raw_key: zeroize::Zeroizing<[u8; 32]>, } impl SecretsManager { @@ -49,11 +50,14 @@ impl SecretsManager { "Encryption key must be exactly 32 bytes (256 bits), got {}", encryption_key.len() ); - let cipher = Aes256Gcm::new_from_slice(&encryption_key) + let mut key_array = [0u8; 32]; + key_array.copy_from_slice(&encryption_key); + let cipher = Aes256Gcm::new_from_slice(&key_array) .map_err(|e| anyhow::anyhow!("Failed to create cipher: {}", e))?; Ok(Self { secrets_dir, cipher, + raw_key: zeroize::Zeroizing::new(key_array), }) } @@ -61,7 +65,7 @@ impl SecretsManager { /// Returns: MAGIC (10 bytes) + nonce (12 bytes) + ciphertext (variable) fn encrypt(&self, plaintext: &[u8]) -> Result> { let mut nonce_bytes = [0u8; 12]; - rand::thread_rng().fill_bytes(&mut nonce_bytes); + rand::rngs::OsRng.fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); let ciphertext = self @@ -220,7 +224,7 @@ impl SecretsManager { ) -> Result { // Generate a new random secret (32 bytes, hex-encoded = 64 chars) let mut new_secret_bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut new_secret_bytes); + rand::rngs::OsRng.fill_bytes(&mut new_secret_bytes); let new_value = hex::encode(new_secret_bytes); let secret_path = self diff --git a/loop/plan.md b/loop/plan.md index 0557a9a4..e39f216f 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -543,7 +543,7 @@ > the critical bugs, but they close gaps that a sophisticated attacker could chain together. Think of > it as adding deadbolts after fixing the broken window. -- [ ] **Add zeroization to SecretsManager**: In `core/security/src/secrets_manager.rs`, the encryption key stays in memory for the lifetime of the struct. Add zeroization on drop: +- [x] **Add zeroization to SecretsManager**: In `core/security/src/secrets_manager.rs`, the encryption key stays in memory for the lifetime of the struct. Add zeroization on drop: 1. Add `zeroize` dependency to `core/security/Cargo.toml` if not present: `zeroize = { version = "1", features = ["derive"] }`. 2. Wrap the key material in a zeroizing wrapper. Since `Aes256Gcm` doesn't implement `Zeroize`, store the raw key separately: ```rust @@ -558,7 +558,7 @@ 3. In the constructor, store the key bytes before creating the cipher, and wrap in `Zeroizing`. Build and test: secrets should still encrypt/decrypt correctly. -- [ ] **Replace thread_rng with OsRng in secrets manager**: In `core/security/src/secrets_manager.rs`, find lines 64 and 221 where `rand::thread_rng().fill_bytes()` is used. Replace with: +- [x] **Replace thread_rng with OsRng in secrets manager**: In `core/security/src/secrets_manager.rs`, find lines 64 and 221 where `rand::thread_rng().fill_bytes()` is used. Replace with: ```rust use rand::rngs::OsRng; OsRng.fill_bytes(&mut nonce_bytes); // Line 64 @@ -566,13 +566,13 @@ ``` Build and test. -- [ ] **Encrypt the remember-me HMAC secret**: In `core/archipelago/src/session.rs`, find lines 395-403 where the remember-me secret is stored as plaintext. Encrypt it using the secrets manager: +- [x] **Encrypt the remember-me HMAC secret**: In `core/archipelago/src/session.rs`, find lines 395-403 where the remember-me secret is stored as plaintext. Encrypt it using the secrets manager: 1. Instead of `std::fs::write(REMEMBER_SECRET_FILE, &secret)`, use the SecretsManager to encrypt the secret before writing. 2. On read, decrypt using SecretsManager. 3. If SecretsManager is not available at that point in the boot sequence, derive the secret from a combination of machine-specific data (e.g., `/etc/machine-id` + salt) using Argon2, so it's different per installation but deterministic. Build, deploy, and test: remember-me login should still work after restart. -- [ ] **Use checked arithmetic for Bitcoin amounts**: In `core/archipelago/src/wallet/ecash.rs` line 64, replace the `.sum()` with checked addition: +- [x] **Use checked arithmetic for Bitcoin amounts**: In `core/archipelago/src/wallet/ecash.rs` line 64, replace the `.sum()` with checked addition: ```rust pub fn balance(&self) -> u64 { self.tokens.iter() @@ -584,7 +584,7 @@ Search for other `.sum()` calls on monetary amounts: `grep -rn "\.sum()" core/ --include="*.rs"`. Fix any that operate on `u64` Bitcoin amounts. Build and test. -- [ ] **Create validated AppId newtype**: In `core/archipelago/src/api/rpc/container.rs`, create a newtype for app IDs: +- [x] **Create validated AppId newtype**: In `core/archipelago/src/api/rpc/container.rs`, create a newtype for app IDs: ```rust #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AppId(String); @@ -616,7 +616,7 @@ Use `AppId` in RPC handler signatures where app IDs are accepted. The deserializer will validate automatically. Build — fix all compilation errors from the type change. Deploy and test app operations. -- [ ] **Validate Tor service names**: In `core/archipelago/src/api/rpc/tor.rs`, find lines 426-427 where `name` is used in path operations. Add validation: +- [x] **Validate Tor service names**: In `core/archipelago/src/api/rpc/tor.rs`, find lines 426-427 where `name` is used in path operations. Add validation: ```rust fn validate_service_name(name: &str) -> Result<()> { if name.is_empty() || name.len() > 64 { @@ -631,7 +631,7 @@ Call `validate_service_name(&name)?;` before any filesystem operation with the name. Build and deploy. -- [ ] **Add per-user rate limiting on CPU-intensive RPC endpoints**: In `core/archipelago/src/api/rpc/mod.rs`, add a rate limiter for expensive operations: +- [x] **Add per-user rate limiting on CPU-intensive RPC endpoints**: In `core/archipelago/src/api/rpc/mod.rs`, add a rate limiter for expensive operations: 1. Add a simple token-bucket rate limiter using a `HashMap` behind a `Mutex`. 2. Apply rate limits to: `backup.create` (1/minute), container install/uninstall (5/minute), `auth.totp.setup` (3/minute), password change (3/minute). 3. Return HTTP 429 with a `Retry-After` header when rate limited. @@ -645,7 +645,7 @@ 5. Each code is single-use — delete the hash after successful use. Build, deploy, and test the full flow. -- [ ] **Verify Phase 7 — Backend medium fixes complete**: Run these checks: +- [x] **Verify Phase 7 — Backend medium fixes complete**: Run these checks: 1. `cargo clippy --all-targets --all-features` — zero warnings. 2. `cargo test --all-features` — all tests pass. 3. `grep -rn "thread_rng" core/security/ --include="*.rs"` — zero results.