Skip to main content

lean_ctx/core/
agent_identity.rs

1use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5/// Canonical resolver for the current agent identity. Reads `LEAN_CTX_AGENT_ID`
6/// (or legacy `LCTX_AGENT_ID`), falling back to `"local"`. Resolved once per
7/// process and cached, so all subsystems (heatmap, savings ledger, audit)
8/// attribute traces to the same identity.
9#[must_use]
10pub fn current_agent_id() -> &'static str {
11    static CACHE: OnceLock<String> = OnceLock::new();
12    CACHE.get_or_init(|| {
13        std::env::var("LEAN_CTX_AGENT_ID")
14            .or_else(|_| std::env::var("LCTX_AGENT_ID"))
15            .unwrap_or_else(|_| "local".to_string())
16    })
17}
18
19pub fn get_or_create_keypair(agent_id: &str) -> Result<SigningKey, String> {
20    let path = key_path(agent_id)?;
21    if path.exists() {
22        load_key(&path)
23    } else {
24        generate_and_save(agent_id)
25    }
26}
27
28pub fn get_public_key(agent_id: &str) -> Result<VerifyingKey, String> {
29    let key = get_or_create_keypair(agent_id)?;
30    Ok(key.verifying_key())
31}
32
33pub fn sign_bytes(agent_id: &str, data: &[u8]) -> Result<Vec<u8>, String> {
34    let key = get_or_create_keypair(agent_id)?;
35    let sig = key.sign(data);
36    Ok(sig.to_bytes().to_vec())
37}
38
39pub fn verify_signature(public_key_bytes: &[u8], data: &[u8], signature_bytes: &[u8]) -> bool {
40    let pk_bytes: [u8; 32] = match public_key_bytes.try_into() {
41        Ok(b) => b,
42        Err(_) => return false,
43    };
44    let Ok(verifying_key) = VerifyingKey::from_bytes(&pk_bytes) else {
45        return false;
46    };
47    let sig_bytes: [u8; 64] = match signature_bytes.try_into() {
48        Ok(b) => b,
49        Err(_) => return false,
50    };
51    let signature = Signature::from_bytes(&sig_bytes);
52    verifying_key.verify(data, &signature).is_ok()
53}
54
55pub fn hex_encode(bytes: &[u8]) -> String {
56    use std::fmt::Write;
57    bytes.iter().fold(String::new(), |mut s, b| {
58        let _ = write!(s, "{b:02x}");
59        s
60    })
61}
62
63pub fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
64    if !s.len().is_multiple_of(2) {
65        return Err("odd-length hex string".to_string());
66    }
67    (0..s.len())
68        .step_by(2)
69        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string()))
70        .collect()
71}
72
73fn key_path(agent_id: &str) -> Result<PathBuf, String> {
74    let base = crate::core::data_dir::lean_ctx_data_dir()?;
75    Ok(base.join("keys").join(format!("{agent_id}.key")))
76}
77
78fn pub_key_path(agent_id: &str) -> Result<PathBuf, String> {
79    let base = crate::core::data_dir::lean_ctx_data_dir()?;
80    Ok(base.join("keys").join(format!("{agent_id}.pub")))
81}
82
83fn generate_and_save(agent_id: &str) -> Result<SigningKey, String> {
84    let mut seed = [0u8; 32];
85    getrandom::fill(&mut seed).map_err(|e| format!("CSPRNG unavailable: {e}"))?;
86    let signing_key = SigningKey::from_bytes(&seed);
87
88    let key_file = key_path(agent_id)?;
89    let pub_file = pub_key_path(agent_id)?;
90
91    if let Some(parent) = key_file.parent() {
92        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir keys: {e}"))?;
93    }
94
95    std::fs::write(&key_file, signing_key.to_bytes()).map_err(|e| format!("write key: {e}"))?;
96
97    #[cfg(unix)]
98    {
99        use std::os::unix::fs::PermissionsExt;
100        let perms = std::fs::Permissions::from_mode(0o600);
101        let _ = std::fs::set_permissions(&key_file, perms);
102    }
103
104    let pub_bytes = signing_key.verifying_key().to_bytes();
105    std::fs::write(&pub_file, pub_bytes).map_err(|e| format!("write pub: {e}"))?;
106
107    Ok(signing_key)
108}
109
110fn load_key(path: &Path) -> Result<SigningKey, String> {
111    let bytes = std::fs::read(path).map_err(|e| format!("read key: {e}"))?;
112    let arr: [u8; 32] = bytes
113        .try_into()
114        .map_err(|_| "invalid key file (expected 32 bytes)".to_string())?;
115    Ok(SigningKey::from_bytes(&arr))
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn sign_and_verify_roundtrip() {
124        let mut seed = [0u8; 32];
125        getrandom::fill(&mut seed).unwrap();
126        let key = SigningKey::from_bytes(&seed);
127        let data = b"test payload";
128        let sig = key.sign(data);
129
130        let pub_bytes = key.verifying_key().to_bytes();
131        assert!(verify_signature(&pub_bytes, data, &sig.to_bytes()));
132    }
133
134    #[test]
135    fn verify_rejects_tampered_data() {
136        let mut seed = [0u8; 32];
137        getrandom::fill(&mut seed).unwrap();
138        let key = SigningKey::from_bytes(&seed);
139        let sig = key.sign(b"original");
140
141        let pub_bytes = key.verifying_key().to_bytes();
142        assert!(!verify_signature(&pub_bytes, b"tampered", &sig.to_bytes()));
143    }
144
145    #[test]
146    fn hex_roundtrip() {
147        let data = vec![0xde, 0xad, 0xbe, 0xef];
148        let encoded = hex_encode(&data);
149        assert_eq!(encoded, "deadbeef");
150        let decoded = hex_decode(&encoded).unwrap();
151        assert_eq!(decoded, data);
152    }
153}