Skip to content

Commit 4f49c84

Browse files
committed
Security and performance hardening from rust-engineer audit
- Verify command checks agent.id matches requested domain - Zeroize private key bytes in decode/encode_private_key - Magic link: min code length 20, hint reduced to 2 chars, 1h max expiry - CORS restricted to agent.json endpoint only - Use exec() on Unix in sign command (no lingering process memory) - write_secure uses random temp file suffix (collision-safe) - agent_json save uses write_secure (0o600 perms) - Added Content-Security-Policy: default-src 'none' header - opt-level = 3 for better crypto throughput - Rate limiter HashMap pre-allocated - Deposit handler avoids unnecessary label clone - Background cleanup logs warnings on failure
1 parent 1f5f588 commit 4f49c84

8 files changed

Lines changed: 85 additions & 62 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ dirs = "6"
6464
zeroize = { version = "1.8.2", features = ["derive"] }
6565

6666
[profile.release]
67-
opt-level = "s"
67+
opt-level = 3
6868
lto = true
6969
codegen-units = 1
7070
panic = "abort"

src/agent_json.rs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,7 @@ impl AgentJson {
2929
pub fn save(&self, path: &Path) -> Result<()> {
3030
let json =
3131
serde_json::to_string_pretty(self).context("Failed to serialize agent.json")?;
32-
// Atomic write: tmp + rename to prevent corruption on crash
33-
let tmp_path = path.with_extension("tmp");
34-
std::fs::write(&tmp_path, &json)
35-
.with_context(|| format!("Failed to write {}", tmp_path.display()))?;
36-
std::fs::rename(&tmp_path, path)
37-
.with_context(|| format!("Failed to rename {} to {}", tmp_path.display(), path.display()))
32+
crate::config::write_secure(path, json.as_bytes())
3833
}
3934

4035
pub fn load(path: &Path) -> Result<Self> {

src/config.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
use anyhow::{Context, Result};
2+
use rand::RngCore;
23
use std::path::{Path, PathBuf};
34

45
pub fn write_secure(path: &Path, data: &[u8]) -> Result<()> {
56
use std::io::Write;
67
use std::os::unix::fs::OpenOptionsExt;
78

8-
// Atomic write: create .tmp with 0600 from the start, then rename.
9+
// Atomic write: create temp file with 0600 from the start, then rename.
910
// Using OpenOptionsExt::mode() sets permissions at creation time,
1011
// eliminating the TOCTOU window where the file could be world-readable.
11-
let tmp_path = path.with_extension("tmp");
12+
// Random suffix prevents collisions between concurrent writers.
13+
let mut suffix = [0u8; 8];
14+
rand::rngs::OsRng.fill_bytes(&mut suffix);
15+
let tmp_path = path.with_extension(format!("tmp.{}", hex::encode(suffix)));
1216
let mut file = std::fs::OpenOptions::new()
1317
.write(true)
1418
.create(true)

src/crypto/signing.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use base64::engine::general_purpose::STANDARD as BASE64;
33
use base64::Engine;
44
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
55
use rand::rngs::OsRng;
6+
use zeroize::Zeroizing;
67

78
pub fn generate_keypair() -> (SigningKey, VerifyingKey) {
89
let signing_key = SigningKey::generate(&mut OsRng);
@@ -37,14 +38,17 @@ pub fn decode_public_key(encoded: &str) -> Result<VerifyingKey> {
3738
}
3839

3940
pub fn encode_private_key(signing_key: &SigningKey) -> String {
40-
BASE64.encode(signing_key.to_bytes())
41+
let key_bytes = Zeroizing::new(signing_key.to_bytes());
42+
BASE64.encode(&*key_bytes)
4143
}
4244

4345
pub fn decode_private_key(encoded: &str) -> Result<SigningKey> {
44-
let bytes = BASE64.decode(encoded).context("Invalid base64 in private key")?;
45-
let key_bytes: [u8; 32] = bytes
46-
.try_into()
47-
.map_err(|_| anyhow::anyhow!("Private key must be 32 bytes"))?;
46+
let bytes = Zeroizing::new(BASE64.decode(encoded).context("Invalid base64 in private key")?);
47+
if bytes.len() != 32 {
48+
anyhow::bail!("Private key must be 32 bytes");
49+
}
50+
let mut key_bytes = Zeroizing::new([0u8; 32]);
51+
key_bytes.copy_from_slice(&bytes);
4852
Ok(SigningKey::from_bytes(&key_bytes))
4953
}
5054

src/magic_link.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,17 @@ fn hash_code(code: &str) -> String {
1111
}
1212

1313
pub fn host(code: &str, expires: &str) -> Result<()> {
14-
if code.len() < 8 {
15-
anyhow::bail!("Code must be at least 8 characters (got {}). Use a longer code to prevent brute-force.", code.len());
14+
if code.len() < 20 {
15+
anyhow::bail!("Code must be at least 20 characters (got {}). Use a longer code to prevent brute-force.", code.len());
1616
}
1717
let duration = parse_duration(expires)?;
18+
let max_secs: u64 = 3600; // 1 hour max for magic links
19+
let capped_secs = duration.as_secs().min(max_secs);
1820
let expires_at = chrono::Utc::now().timestamp()
19-
+ i64::try_from(duration.as_secs()).map_err(|_| anyhow::anyhow!("Duration too large"))?;
21+
+ i64::try_from(capped_secs).map_err(|_| anyhow::anyhow!("Duration too large"))?;
2022

2123
let code_hash = hash_code(code);
22-
let hint = &code[..4.min(code.len())];
24+
let hint = &code[..2.min(code.len())];
2325

2426
let conn = db::open()?;
2527
conn.execute(
@@ -97,7 +99,7 @@ mod tests {
9799

98100
fn insert_code(conn: &rusqlite::Connection, code: &str, expires_at: i64) {
99101
let code_hash = hash_code(code);
100-
let hint = &code[..4.min(code.len())];
102+
let hint = &code[..2.min(code.len())];
101103
conn.execute(
102104
"INSERT INTO magic_links (code_hash, hint, expires_at) VALUES (?1, ?2, ?3)",
103105
rusqlite::params![code_hash, hint, expires_at],

src/main.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,10 @@ async fn main() -> Result<()> {
121121
println!(" Public Key: {}", agent.public_key);
122122
println!(" Status: {}", agent.status);
123123
println!(" Created: {}", agent.created_at);
124-
if agent.status == "active" {
124+
if agent.id != domain {
125+
println!(" WARNING: agent.id '{}' does not match requested domain '{}'", agent.id, domain);
126+
println!(" Result: DOMAIN MISMATCH");
127+
} else if agent.status == "active" {
125128
println!(" Result: VALID");
126129
} else {
127130
println!(" Result: {}", agent.status.to_uppercase());

src/server.rs

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ pub async fn run_server(credentials: Credentials) -> Result<()> {
104104
db: std::sync::Mutex::new(db_conn),
105105
tls_active,
106106
behind_proxy,
107-
rate_limiter: std::sync::Mutex::new(HashMap::new()),
107+
rate_limiter: std::sync::Mutex::new(HashMap::with_capacity(256)),
108108
});
109109

110110
// Background task: clean expired magic links, old deposit nonces, and stale rate limiter entries
@@ -116,9 +116,13 @@ pub async fn run_server(credentials: Credentials) -> Result<()> {
116116
let _ = tokio::task::spawn_blocking(move || {
117117
if let Ok(conn) = db_ref.db.lock() {
118118
let now = chrono::Utc::now().timestamp();
119-
let _ = conn.execute("DELETE FROM magic_links WHERE expires_at <= ?1", [now]);
119+
if let Err(e) = conn.execute("DELETE FROM magic_links WHERE expires_at <= ?1", [now]) {
120+
tracing::warn!("Failed to clean expired magic links: {e}");
121+
}
120122
let cutoff = now - 7 * 86400;
121-
let _ = conn.execute("DELETE FROM used_deposits WHERE used_at < ?1", [cutoff]);
123+
if let Err(e) = conn.execute("DELETE FROM used_deposits WHERE used_at < ?1", [cutoff]) {
124+
tracing::warn!("Failed to clean old deposit nonces: {e}");
125+
}
122126
}
123127
// Evict stale rate limiter entries
124128
if let Ok(mut map) = db_ref.rate_limiter.lock() {
@@ -129,14 +133,20 @@ pub async fn run_server(credentials: Credentials) -> Result<()> {
129133
}
130134
});
131135

136+
// CORS only on the public agent.json endpoint; deposit and magic link
137+
// endpoints are called by servers, not browsers, and don't need CORS.
132138
let cors = CorsLayer::new()
133139
.allow_origin(Any)
134140
.allow_methods(Any)
135141
.allow_headers(Any);
136142

143+
let public_routes = Router::new()
144+
.route("/.well-known/agent.json", get(serve_agent_json))
145+
.layer(cors);
146+
137147
let app = Router::new()
138148
.route("/", get(root_redirect))
139-
.route("/.well-known/agent.json", get(serve_agent_json))
149+
.merge(public_routes)
140150
.route("/d/{token}", post(handle_deposit))
141151
.route("/m/{code}", get(handle_magic_link))
142152
.fallback(handle_404)
@@ -145,7 +155,6 @@ pub async fn run_server(credentials: Credentials) -> Result<()> {
145155
state.clone(),
146156
security_headers,
147157
))
148-
.layer(cors)
149158
.with_state(state);
150159

151160
let pid_path = config::pid_path()?;
@@ -245,40 +254,39 @@ async fn handle_deposit(
245254
// DB operations in spawn_blocking.
246255
// Access vault_key via the Arc<AppState> reference inside the closure
247256
// to avoid copying the key out of its Zeroizing wrapper.
248-
let label = payload.label.clone();
249257
let state_clone = state.clone();
250258
let body_clone = body;
251259
let deposit_result = tokio::task::spawn_blocking(move || {
252260
let conn = state_clone.db.lock()
253261
.map_err(|e| anyhow::anyhow!("DB mutex poisoned: {e}"))?;
254262
crate::deposit::claim_nonce_with_conn(&payload, &conn)?;
255263
crate::vault::vault_set_with_conn(&conn, &payload.label, &body_clone, state_clone.vault_key())?;
256-
crate::deposit::log_deposit(&conn, &payload.label, &source_ip, &user_agent)
264+
crate::deposit::log_deposit(&conn, &payload.label, &source_ip, &user_agent)?;
265+
Ok::<_, anyhow::Error>(payload.label)
257266
}).await;
258267

259268
match deposit_result {
260-
Ok(Ok(())) => {},
269+
Ok(Ok(label)) => {
270+
info!("Deposit received: '{label}'");
271+
let resp = DepositResponse { status: "deposited", label };
272+
match serde_json::to_string(&resp) {
273+
Ok(json) => (StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], json).into_response(),
274+
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
275+
}
276+
}
261277
Ok(Err(e)) => {
262278
let err_msg = format!("{e}");
263279
if err_msg.contains("replay") || err_msg.contains("Nonce already used") {
264280
return StatusCode::NOT_FOUND.into_response();
265281
}
266282
tracing::error!("Deposit failed: {}", e);
267-
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
283+
StatusCode::INTERNAL_SERVER_ERROR.into_response()
268284
}
269285
Err(e) => {
270286
tracing::error!("Deposit task panicked: {}", e);
271-
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
287+
StatusCode::INTERNAL_SERVER_ERROR.into_response()
272288
}
273289
}
274-
275-
info!("Deposit received: '{label}'");
276-
277-
let resp = DepositResponse { status: "deposited", label };
278-
match serde_json::to_string(&resp) {
279-
Ok(json) => (StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], json).into_response(),
280-
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
281-
}
282290
}
283291

284292
async fn handle_magic_link(
@@ -342,6 +350,10 @@ async fn security_headers(
342350
header::REFERRER_POLICY,
343351
HeaderValue::from_static("no-referrer"),
344352
);
353+
headers.insert(
354+
header::CONTENT_SECURITY_POLICY,
355+
HeaderValue::from_static("default-src 'none'"),
356+
);
345357
if state.tls_active {
346358
headers.insert(
347359
header::STRICT_TRANSPORT_SECURITY,

src/sign.rs

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use anyhow::{Context, Result};
1+
use anyhow::Result;
2+
#[cfg(not(unix))]
3+
use anyhow::Context;
24

35
use crate::config;
46
use crate::credentials::Credentials;
@@ -40,29 +42,30 @@ pub fn run(command: &[String], dry_run: bool) -> Result<()> {
4042
return Ok(());
4143
}
4244

43-
// Drop sensitive data before process::exit (which skips destructors)
44-
drop(signing_key);
45-
drop(creds);
46-
47-
let status = std::process::Command::new(&new_cmd[0])
48-
.args(&new_cmd[1..])
49-
.status()
50-
.with_context(|| format!("Failed to execute: {}", new_cmd[0]))?;
51-
52-
let exit_code = if let Some(code) = status.code() {
53-
code
54-
} else {
55-
#[cfg(unix)]
56-
{
57-
use std::os::unix::process::ExitStatusExt;
58-
128 + status.signal().unwrap_or(1)
59-
}
60-
#[cfg(not(unix))]
61-
{
62-
1
63-
}
64-
};
65-
std::process::exit(exit_code);
45+
// On Unix, exec replaces the process image entirely — no memory lingers,
46+
// so there is no need to manually drop/zeroize sensitive data.
47+
#[cfg(unix)]
48+
{
49+
use std::os::unix::process::CommandExt;
50+
let err = std::process::Command::new(&new_cmd[0])
51+
.args(&new_cmd[1..])
52+
.exec();
53+
// exec() only returns on error
54+
anyhow::bail!("Failed to exec {}: {}", new_cmd[0], err);
55+
}
56+
57+
#[cfg(not(unix))]
58+
{
59+
drop(signing_key);
60+
drop(creds);
61+
62+
let status = std::process::Command::new(&new_cmd[0])
63+
.args(&new_cmd[1..])
64+
.status()
65+
.with_context(|| format!("Failed to execute: {}", new_cmd[0]))?;
66+
67+
std::process::exit(status.code().unwrap_or(1));
68+
}
6669
}
6770

6871
// Pull the body from -d, --data, or --data-raw flags.

0 commit comments

Comments
 (0)