1use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
4use std::path::{Path, PathBuf};
5use std::sync::OnceLock;
6
7#[must_use]
12pub fn current_agent_id() -> &'static str {
13 static CACHE: OnceLock<String> = OnceLock::new();
14 CACHE.get_or_init(|| {
15 std::env::var("LEAN_CTX_AGENT_ID")
16 .or_else(|_| std::env::var("LCTX_AGENT_ID"))
17 .unwrap_or_else(|_| "local".to_string())
18 })
19}
20
21static SESSION_ATTRIBUTION: OnceLock<String> = OnceLock::new();
24static SESSION_ATTRIBUTION_CONFLICT: std::sync::atomic::AtomicBool =
27 std::sync::atomic::AtomicBool::new(false);
28
29#[must_use]
46pub(crate) fn ledger_agent_id() -> String {
47 let explicit = current_agent_id();
48 if explicit != "local" {
49 return explicit.to_string();
50 }
51 if !SESSION_ATTRIBUTION_CONFLICT.load(std::sync::atomic::Ordering::Relaxed)
52 && let Some(session) = SESSION_ATTRIBUTION.get()
53 {
54 return session.clone();
55 }
56 host_from_env(|key| std::env::var(key).ok())
57 .unwrap_or("local")
58 .to_string()
59}
60
61pub fn set_session_attribution(client_name: &str, bus_agent_id: Option<&str>) {
66 let Some(value) = session_attribution_value(client_name, bus_agent_id) else {
67 return;
68 };
69 if let Err(value) = SESSION_ATTRIBUTION.set(value)
70 && SESSION_ATTRIBUTION.get() != Some(&value)
71 {
72 SESSION_ATTRIBUTION_CONFLICT.store(true, std::sync::atomic::Ordering::Relaxed);
73 }
74}
75
76fn session_attribution_value(client_name: &str, bus_agent_id: Option<&str>) -> Option<String> {
78 let client = attribution_component(client_name, 48);
79 let agent = bus_agent_id.and_then(|id| attribution_component(id, 72));
80 match (client, agent) {
81 (Some(client), Some(agent)) => Some(format!("{client}:{agent}")),
82 (Some(single), None) | (None, Some(single)) => Some(single),
83 (None, None) => None,
84 }
85}
86
87fn attribution_component(raw: &str, max: usize) -> Option<String> {
90 let folded: String = raw
91 .trim()
92 .chars()
93 .map(|c| {
94 let c = c.to_ascii_lowercase();
95 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
96 c
97 } else {
98 '-'
99 }
100 })
101 .take(max)
102 .collect();
103 let trimmed = folded.trim_matches('-');
104 (!trimmed.is_empty()).then(|| trimmed.to_string())
105}
106
107fn host_from_env(env: impl Fn(&str) -> Option<String>) -> Option<&'static str> {
110 let set = |key: &str| env(key).is_some_and(|v| !v.trim().is_empty());
111 if set("CLAUDECODE") {
112 Some("claude")
113 } else if set("CODEX_THREAD_ID") {
114 Some("codex")
115 } else if set("CURSOR_TASK_ID") {
116 Some("cursor")
117 } else {
118 None
119 }
120}
121
122#[must_use]
142pub fn delivery_agent_id() -> &'static str {
143 static CACHE: OnceLock<String> = OnceLock::new();
144 CACHE.get_or_init(|| {
145 resolve_delivery_agent_id(|key| std::env::var(key).ok(), std::process::id())
146 })
147}
148
149fn resolve_delivery_agent_id(env: impl Fn(&str) -> Option<String>, pid: u32) -> String {
152 let non_empty = |key: &str| {
153 env(key)
154 .map(|v| v.trim().to_string())
155 .filter(|v| !v.is_empty())
156 };
157 if let Some(id) = non_empty("LEAN_CTX_AGENT_ID").or_else(|| non_empty("LCTX_AGENT_ID")) {
158 return id;
159 }
160 if let Some(task) = non_empty("CURSOR_TASK_ID") {
161 return task;
162 }
163 if non_empty("CLAUDECODE").is_some() {
164 return format!("claude-{pid}");
165 }
166 if non_empty("CODEX_THREAD_ID").is_some() {
167 return format!("codex-{pid}");
168 }
169 format!("local-{pid}")
170}
171
172pub fn get_or_create_keypair(agent_id: &str) -> Result<SigningKey, String> {
173 let path = key_path(agent_id)?;
174 if path.exists() {
175 load_key(&path)
176 } else {
177 generate_and_save(agent_id)
178 }
179}
180
181pub(crate) fn get_public_key(agent_id: &str) -> Result<VerifyingKey, String> {
182 let key = get_or_create_keypair(agent_id)?;
183 Ok(key.verifying_key())
184}
185
186pub(crate) fn get_stored_signing_key(agent_id: &str) -> Result<SigningKey, String> {
188 load_key(&key_path(agent_id)?)
189}
190
191pub fn get_stored_public_key(agent_id: &str) -> Result<VerifyingKey, String> {
193 let bytes = read_key_file(&pub_key_path(agent_id)?, "trusted public key")?;
194 let bytes: [u8; 32] = bytes
195 .try_into()
196 .map_err(|_| "invalid public key file (expected 32 bytes)".to_string())?;
197 VerifyingKey::from_bytes(&bytes).map_err(|error| format!("invalid public key: {error}"))
198}
199
200pub(crate) fn load_public_key(agent_id: &str) -> Result<VerifyingKey, String> {
202 get_stored_public_key(agent_id)
203}
204pub(crate) fn sign_bytes(agent_id: &str, data: &[u8]) -> Result<Vec<u8>, String> {
205 let key = get_or_create_keypair(agent_id)?;
206 let sig = key.sign(data);
207 Ok(sig.to_bytes().to_vec())
208}
209
210#[must_use]
214pub fn sign_bytes_with(key: &SigningKey, data: &[u8]) -> Vec<u8> {
215 key.sign(data).to_bytes().to_vec()
216}
217
218pub fn verify_signature(public_key_bytes: &[u8], data: &[u8], signature_bytes: &[u8]) -> bool {
219 let pk_bytes: [u8; 32] = match public_key_bytes.try_into() {
220 Ok(b) => b,
221 Err(_) => return false,
222 };
223 let Ok(verifying_key) = VerifyingKey::from_bytes(&pk_bytes) else {
224 return false;
225 };
226 let sig_bytes: [u8; 64] = match signature_bytes.try_into() {
227 Ok(b) => b,
228 Err(_) => return false,
229 };
230 let signature = Signature::from_bytes(&sig_bytes);
231 verifying_key.verify(data, &signature).is_ok()
232}
233
234pub fn hex_encode(bytes: &[u8]) -> String {
235 use std::fmt::Write;
236 bytes.iter().fold(String::new(), |mut s, b| {
237 let _ = write!(s, "{b:02x}");
238 s
239 })
240}
241
242pub fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
244 if !s.len().is_multiple_of(2) {
245 return Err("odd-length hex string".to_string());
246 }
247 if !s.is_ascii() {
248 return Err("non-ASCII hex string".to_string());
249 }
250 (0..s.len())
251 .step_by(2)
252 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string()))
253 .collect()
254}
255
256fn key_path(agent_id: &str) -> Result<PathBuf, String> {
257 validate_agent_id(agent_id)?;
258 let base = crate::core::data_dir::lean_ctx_data_dir()?;
259 Ok(base.join("keys").join(format!("{agent_id}.key")))
260}
261
262fn pub_key_path(agent_id: &str) -> Result<PathBuf, String> {
263 validate_agent_id(agent_id)?;
264 let base = crate::core::data_dir::lean_ctx_data_dir()?;
265 Ok(base.join("keys").join(format!("{agent_id}.pub")))
266}
267
268fn validate_agent_id(agent_id: &str) -> Result<(), String> {
272 if agent_id.is_empty()
273 || agent_id.len() > 128
274 || !agent_id.is_ascii()
275 || !agent_id.as_bytes()[0].is_ascii_alphanumeric()
276 || !agent_id
277 .bytes()
278 .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
279 || agent_id.as_bytes().windows(2).any(|window| window == b"..")
280 {
281 return Err("invalid agent identity id".to_string());
282 }
283 Ok(())
284}
285
286fn generate_and_save(agent_id: &str) -> Result<SigningKey, String> {
287 let mut seed = [0u8; 32];
288 getrandom::fill(&mut seed).map_err(|e| format!("CSPRNG unavailable: {e}"))?;
289 let signing_key = SigningKey::from_bytes(&seed);
290
291 let key_file = key_path(agent_id)?;
292 let pub_file = pub_key_path(agent_id)?;
293
294 if let Some(parent) = key_file.parent() {
295 std::fs::create_dir_all(parent).map_err(|e| format!("mkdir keys: {e}"))?;
296 }
297
298 let signing_key = match create_key_file(&key_file, &signing_key.to_bytes()) {
302 Ok(()) => signing_key,
303 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => load_settled_key(&key_file)?,
304 Err(e) => return Err(format!("write key: {e}")),
305 };
306
307 let tmp = pub_file.with_extension(format!("pub.{}.tmp", std::process::id()));
310 std::fs::write(&tmp, signing_key.verifying_key().to_bytes())
311 .and_then(|()| std::fs::rename(&tmp, &pub_file))
312 .map_err(|e| {
313 let _ = std::fs::remove_file(&tmp);
314 format!("write pub: {e}")
315 })?;
316
317 Ok(signing_key)
318}
319
320fn create_key_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
322 use std::io::Write;
323 let mut options = std::fs::OpenOptions::new();
324 options.write(true).create_new(true);
325 #[cfg(unix)]
326 {
327 use std::os::unix::fs::OpenOptionsExt;
328 options.mode(0o600);
329 }
330 let mut file = options.open(path)?;
331 file.write_all(bytes)?;
332 file.sync_all()
333}
334
335fn load_settled_key(path: &Path) -> Result<SigningKey, String> {
338 let mut last = String::new();
339 for _ in 0..50 {
340 match load_key(path) {
341 Ok(key) => return Ok(key),
342 Err(e) => last = e,
343 }
344 std::thread::sleep(std::time::Duration::from_millis(5));
345 }
346 Err(last)
347}
348
349fn load_key(path: &Path) -> Result<SigningKey, String> {
350 let bytes = read_key_file(path, "key")?;
351 let arr: [u8; 32] = bytes
352 .try_into()
353 .map_err(|_| "invalid key file (expected 32 bytes)".to_string())?;
354 Ok(SigningKey::from_bytes(&arr))
355}
356
357fn read_key_file(path: &Path, label: &str) -> Result<Vec<u8>, String> {
362 let parent = path
363 .parent()
364 .ok_or_else(|| format!("{label} directory is unavailable"))?;
365
366 #[cfg(unix)]
367 {
368 use std::ffi::CString;
369 use std::io::Read;
370 use std::os::fd::{AsRawFd, FromRawFd};
371 use std::os::unix::ffi::OsStrExt;
372 use std::os::unix::fs::OpenOptionsExt;
373
374 let directory = std::fs::OpenOptions::new()
375 .read(true)
376 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
377 .open(parent)
378 .map_err(|error| format!("open {label} directory without symlinks: {error}"))?;
379 if !directory
380 .metadata()
381 .map_err(|error| format!("inspect {label} directory: {error}"))?
382 .is_dir()
383 {
384 return Err(format!("{label} directory must be a directory"));
385 }
386 let file_name = path
387 .file_name()
388 .ok_or_else(|| format!("{label} filename is unavailable"))?;
389 let file_name = CString::new(file_name.as_bytes())
390 .map_err(|_| format!("{label} filename contains NUL"))?;
391 let fd = unsafe {
394 libc::openat(
395 directory.as_raw_fd(),
396 file_name.as_ptr(),
397 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK,
398 )
399 };
400 if fd < 0 {
401 return Err(format!(
402 "open {label} without symlinks: {}",
403 std::io::Error::last_os_error()
404 ));
405 }
406 let file = unsafe { std::fs::File::from_raw_fd(fd) };
408 if !file
409 .metadata()
410 .map_err(|error| format!("inspect {label}: {error}"))?
411 .is_file()
412 {
413 return Err(format!("{label} must be a regular file"));
414 }
415 let mut bytes = Vec::new();
416 file.take(33)
419 .read_to_end(&mut bytes)
420 .map_err(|error| format!("read {label}: {error}"))?;
421 Ok(bytes)
422 }
423
424 #[cfg(windows)]
425 {
426 use std::io::Read;
427 use std::os::windows::ffi::OsStrExt;
428 use std::os::windows::fs::MetadataExt;
429 use std::os::windows::io::FromRawHandle;
430 use windows_sys::Wdk::Storage::FileSystem::{
431 FILE_NON_DIRECTORY_FILE, FILE_OPEN, FILE_OPEN_REPARSE_POINT,
432 FILE_SYNCHRONOUS_IO_NONALERT,
433 };
434 use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
435 use windows_sys::Win32::Storage::FileSystem::{
436 CreateFileW, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS,
437 FILE_FLAG_OPEN_REPARSE_POINT, FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES,
438 FILE_READ_DATA, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
439 SYNCHRONIZE,
440 };
441
442 const TRAVERSE_DIRECTORY_ACCESS: u32 =
443 FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | SYNCHRONIZE;
444 const READ_KEY_ACCESS: u32 = FILE_READ_DATA | FILE_READ_ATTRIBUTES | SYNCHRONIZE;
445
446 let wide_parent = parent
447 .as_os_str()
448 .encode_wide()
449 .chain(std::iter::once(0))
450 .collect::<Vec<_>>();
451 let directory_handle = unsafe {
454 CreateFileW(
455 wide_parent.as_ptr(),
456 TRAVERSE_DIRECTORY_ACCESS,
457 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
458 std::ptr::null(),
459 OPEN_EXISTING,
460 FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
461 std::ptr::null_mut(),
462 )
463 };
464 if directory_handle == INVALID_HANDLE_VALUE || directory_handle.is_null() {
465 let error = std::io::Error::last_os_error();
466 return Err(format!(
467 "open {label} directory without reparse points: {error}"
468 ));
469 }
470 let directory = unsafe { std::fs::File::from_raw_handle(directory_handle) };
472 let directory_metadata = directory
473 .metadata()
474 .map_err(|error| format!("inspect {label} directory: {error}"))?;
475 if directory_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
476 return Err(format!("{label} directory reparse point rejected"));
477 }
478 if !directory_metadata.is_dir() {
479 return Err(format!("{label} directory must be a directory"));
480 }
481
482 let file_name = path
483 .file_name()
484 .ok_or_else(|| format!("{label} filename is unavailable"))?
485 .encode_wide()
486 .collect::<Vec<_>>();
487 let file = leanctx_native_storage::windows_file::open_relative(
493 &directory,
494 &file_name,
495 READ_KEY_ACCESS,
496 FILE_OPEN,
497 FILE_NON_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT,
498 )
499 .map_err(|status| {
500 use leanctx_native_storage::windows_file::OpenError;
501 let reason = match status {
502 OpenError::Missing => "Missing (NotFound)",
503 OpenError::Reparse => "Reparse",
504 OpenError::Unsupported => "Unsupported",
505 OpenError::Failure | OpenError::Collision => "Failure",
506 };
507 format!("open {label} without reparse traversal: {reason}")
508 })?;
509 let metadata = file
510 .metadata()
511 .map_err(|error| format!("inspect {label}: {error}"))?;
512 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
513 return Err(format!("{label} reparse point rejected"));
514 }
515 if !metadata.is_file() {
516 return Err(format!("{label} must be a regular file"));
517 }
518 let mut bytes = Vec::new();
519 file.take(33)
522 .read_to_end(&mut bytes)
523 .map_err(|error| format!("read {label}: {error}"))?;
524 Ok(bytes)
525 }
526
527 #[cfg(not(any(unix, windows)))]
528 {
529 let _ = (parent, path);
530 Err(format!(
531 "secure {label} reads are unavailable on this platform; refusing a race-prone key lookup"
532 ))
533 }
534}
535
536#[cfg(all(test, any(unix, windows)))]
537mod tests {
538 use super::*;
539
540 fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
541 let owned: Vec<(String, String)> = pairs
542 .iter()
543 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
544 .collect();
545 move |key| owned.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
546 }
547
548 #[test]
549 fn session_attribution_combines_client_and_bus_id() {
550 assert_eq!(
551 session_attribution_value("claude-code", Some("mcp-1274-fbba0c02")).as_deref(),
552 Some("claude-code:mcp-1274-fbba0c02")
553 );
554 assert_eq!(
555 session_attribution_value("Codex MCP Client", None).as_deref(),
556 Some("codex-mcp-client")
557 );
558 assert_eq!(
559 session_attribution_value("", Some("mcp-9-x")).as_deref(),
560 Some("mcp-9-x")
561 );
562 assert_eq!(session_attribution_value(" ", None), None);
563 }
564
565 #[test]
566 fn session_attribution_is_bounded_and_ledger_safe() {
567 let long = "x".repeat(500);
568 let value = session_attribution_value(&format!("{long}\n:/"), Some(&long)).unwrap();
569 assert!(value.len() <= 48 + 1 + 72, "{}", value.len());
570 assert!(
571 value
572 .chars()
573 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':'))
574 );
575 }
576
577 #[test]
578 fn host_from_env_detects_agent_hosts() {
579 assert_eq!(
580 host_from_env(env_of(&[("CLAUDECODE", "1")])),
581 Some("claude")
582 );
583 assert_eq!(
584 host_from_env(env_of(&[("CODEX_THREAD_ID", "t-1")])),
585 Some("codex")
586 );
587 assert_eq!(
588 host_from_env(env_of(&[("CURSOR_TASK_ID", "task")])),
589 Some("cursor")
590 );
591 assert_eq!(host_from_env(env_of(&[("CLAUDECODE", " ")])), None);
592 assert_eq!(host_from_env(env_of(&[])), None);
593 }
594
595 #[test]
596 fn delivery_id_two_claude_processes_are_distinct() {
597 let env = env_of(&[("CLAUDECODE", "1")]);
599 let a = resolve_delivery_agent_id(&env, 100);
600 let b = resolve_delivery_agent_id(&env, 200);
601 assert_eq!(a, "claude-100");
602 assert_ne!(a, b, "two Claude Code clients must not share an identity");
603 assert_ne!(a, "1", "the constant CLAUDECODE value is not an identity");
604 }
605
606 #[test]
607 fn delivery_id_priority_matrix() {
608 let explicit = env_of(&[
609 ("LEAN_CTX_AGENT_ID", "ops-agent"),
610 ("CURSOR_TASK_ID", "t1"),
611 ("CLAUDECODE", "1"),
612 ]);
613 assert_eq!(resolve_delivery_agent_id(&explicit, 7), "ops-agent");
614 let legacy = env_of(&[("LCTX_AGENT_ID", "legacy"), ("CLAUDECODE", "1")]);
615 assert_eq!(resolve_delivery_agent_id(&legacy, 7), "legacy");
616 let cursor = env_of(&[("CURSOR_TASK_ID", "t1"), ("CLAUDECODE", "1")]);
617 assert_eq!(resolve_delivery_agent_id(&cursor, 7), "t1");
618 let codex = env_of(&[("CODEX_THREAD_ID", "th")]);
619 assert_eq!(resolve_delivery_agent_id(&codex, 7), "codex-7");
620 assert_eq!(resolve_delivery_agent_id(env_of(&[]), 7), "local-7");
621 }
622
623 #[test]
624 fn delivery_id_ignores_blank_values() {
625 let env = env_of(&[("LEAN_CTX_AGENT_ID", " "), ("CURSOR_TASK_ID", "")]);
626 assert_eq!(resolve_delivery_agent_id(env, 9), "local-9");
627 }
628
629 #[test]
630 fn delivery_id_is_stable_within_process() {
631 assert_eq!(delivery_agent_id(), delivery_agent_id());
632 }
633 #[test]
634 fn sign_and_verify_roundtrip() {
635 let mut seed = [0u8; 32];
636 getrandom::fill(&mut seed).unwrap();
637 let key = SigningKey::from_bytes(&seed);
638 let data = b"test payload";
639 let sig = key.sign(data);
640
641 let pub_bytes = key.verifying_key().to_bytes();
642 assert!(verify_signature(&pub_bytes, data, &sig.to_bytes()));
643 }
644
645 #[test]
646 fn verify_rejects_tampered_data() {
647 let mut seed = [0u8; 32];
648 getrandom::fill(&mut seed).unwrap();
649 let key = SigningKey::from_bytes(&seed);
650 let sig = key.sign(b"original");
651
652 let pub_bytes = key.verifying_key().to_bytes();
653 assert!(!verify_signature(&pub_bytes, b"tampered", &sig.to_bytes()));
654 }
655
656 #[test]
657 fn hex_roundtrip() {
658 let data = vec![0xde, 0xad, 0xbe, 0xef];
659 let encoded = hex_encode(&data);
660 assert_eq!(encoded, "deadbeef");
661 let decoded = hex_decode(&encoded).unwrap();
662 assert_eq!(decoded, data);
663 }
664
665 #[test]
666 fn hex_decode_accepts_all_bytes_and_rejects_malformed_text() {
667 let bytes: Vec<u8> = (0..=255).collect();
668 let encoded = hex_encode(&bytes);
669 assert_eq!(hex_decode(&encoded).unwrap(), bytes);
670 assert_eq!(hex_decode(&encoded.to_uppercase()).unwrap(), bytes);
671 assert_eq!(hex_decode("").unwrap(), Vec::<u8>::new());
672 for malformed in ["0", "gg", " 0", "\u{1f512}", "0\u{20ac}", "\u{e9}\u{e9}"] {
673 assert!(hex_decode(malformed).is_err(), "{malformed:?}");
674 }
675 assert_eq!(hex_decode("0").unwrap_err(), "odd-length hex string");
676 assert_eq!(hex_decode("0\u{20ac}").unwrap_err(), "non-ASCII hex string");
677 }
678
679 #[test]
680 fn stored_public_key_is_read_only_and_ids_are_path_safe() {
681 let _isolated = crate::core::data_dir::isolated_data_dir();
682 assert!(get_stored_public_key("missing").is_err());
683 assert!(get_stored_public_key("../outside").is_err());
684 assert!(get_stored_public_key("/tmp/outside").is_err());
685 assert!(get_or_create_keypair("agent/child").is_err());
686
687 let expected = get_or_create_keypair("root-agent").unwrap().verifying_key();
688 assert_eq!(get_stored_public_key("root-agent").unwrap(), expected);
689 }
690
691 #[cfg(any(unix, windows))]
692 #[test]
693 fn stored_public_key_rejects_symlinks() {
694 let _isolated = crate::core::data_dir::isolated_data_dir();
695 let keys_dir = crate::core::data_dir::lean_ctx_data_dir()
696 .unwrap()
697 .join("keys");
698 std::fs::create_dir_all(&keys_dir).unwrap();
699 let outside = tempfile::tempdir().unwrap();
700 let outside_key = outside.path().join("outside.pub");
701 std::fs::write(&outside_key, [7_u8; 32]).unwrap();
702 create_file_symlink(&outside_key, &keys_dir.join("linked.pub"));
703 assert!(get_stored_public_key("linked").is_err());
704 }
705
706 #[cfg(any(unix, windows))]
707 #[test]
708 fn stored_public_key_rejects_traversal_absolute_and_symlink_paths() {
709 let _isolated = crate::core::data_dir::isolated_data_dir();
710 assert!(get_stored_public_key("../outside").is_err());
711 assert!(get_stored_public_key("/tmp/outside").is_err());
712 assert!(get_or_create_keypair("../outside").is_err());
713
714 let data_dir = crate::core::data_dir::lean_ctx_data_dir().unwrap();
715 let keys_dir = data_dir.join("keys");
716 std::fs::create_dir_all(&keys_dir).unwrap();
717 let outside = tempfile::tempdir().unwrap();
718 let outside_key = outside.path().join("outside.pub");
719 std::fs::write(
720 &outside_key,
721 SigningKey::from_bytes(&[19_u8; 32])
722 .verifying_key()
723 .to_bytes(),
724 )
725 .unwrap();
726 create_file_symlink(&outside_key, &keys_dir.join("linked.pub"));
727 assert!(get_stored_public_key("linked").is_err());
728 create_file_symlink(&outside_key, &keys_dir.join("linked.key"));
729 assert!(get_or_create_keypair("linked").is_err());
730
731 std::fs::remove_file(keys_dir.join("linked.pub")).unwrap();
732 std::fs::remove_file(keys_dir.join("linked.key")).unwrap();
733 std::fs::create_dir(keys_dir.join("directory.pub")).unwrap();
734 assert!(get_stored_public_key("directory").is_err());
735 std::fs::remove_dir(keys_dir.join("directory.pub")).unwrap();
736 std::fs::create_dir(keys_dir.join("directory.key")).unwrap();
737 assert!(get_or_create_keypair("directory").is_err());
738 std::fs::remove_dir(keys_dir.join("directory.key")).unwrap();
739
740 std::fs::remove_dir(&keys_dir).unwrap();
741 create_directory_symlink(outside.path(), &keys_dir);
742 assert!(get_stored_public_key("outside").is_err());
743 }
744
745 #[test]
746 fn agent_id_is_one_bounded_canonical_filename_component() {
747 assert!(validate_agent_id("agent-01.alpha").is_ok());
748 assert!(validate_agent_id("../outside").is_err());
749 assert!(validate_agent_id("agent/child").is_err());
750 assert!(validate_agent_id("agent\\child").is_err());
751 assert!(validate_agent_id("agent:key").is_err());
752 assert!(validate_agent_id("agent..child").is_err());
753 assert!(validate_agent_id(&"a".repeat(129)).is_err());
754 }
755
756 #[test]
757 fn stored_key_reads_are_bounded_and_reject_directories() {
758 let root = tempfile::tempdir().unwrap();
759 let path = root.path().join("key");
760 std::fs::write(&path, [7_u8; 32]).unwrap();
761 assert!(load_key(&path).is_ok());
762 std::fs::write(&path, vec![7_u8; 1024 * 1024]).unwrap();
763 assert_eq!(read_key_file(&path, "key").unwrap().len(), 33);
764 assert!(load_key(&path).is_err());
765
766 let directory = root.path().join("directory.key");
767 std::fs::create_dir(&directory).unwrap();
768 assert!(load_key(&directory).is_err());
769 }
770
771 #[cfg(unix)]
772 #[test]
773 fn stored_key_reads_reject_fifos_without_a_writer() {
774 use std::os::unix::ffi::OsStrExt;
775 let root = tempfile::tempdir().unwrap();
776 let fifo = root.path().join("fifo");
777 let raw = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
778 assert_eq!(unsafe { libc::mkfifo(raw.as_ptr(), 0o600) }, 0);
780 assert!(load_key(&fifo).is_err());
781 assert!(load_key(&fifo.join("child")).is_err());
782 }
783
784 #[cfg(unix)]
785 fn create_file_symlink(target: &Path, link: &Path) {
786 std::os::unix::fs::symlink(target, link).unwrap();
787 }
788
789 #[cfg(windows)]
790 fn create_file_symlink(target: &Path, link: &Path) {
791 std::os::windows::fs::symlink_file(target, link).unwrap();
792 }
793
794 #[cfg(unix)]
795 fn create_directory_symlink(target: &Path, link: &Path) {
796 std::os::unix::fs::symlink(target, link).unwrap();
797 }
798
799 #[cfg(windows)]
800 fn create_directory_symlink(target: &Path, link: &Path) {
801 std::os::windows::fs::symlink_dir(target, link).unwrap();
802 }
803}