OXYGENHUB

Coverage / crates/spectra-core/src/keys.rs 76.6%

134 of 175 lines run, 39 of 82 functions · run #28 on main · run missed

1
//! Identity homes on disk (spec §8.0, §21.3): `keys/KEYID.secret` (0600),
2
//! `keys/KEYID.identity`, and `identity.toml` naming the current KeyId. The
3
//! owner's home is `$SPECTRA_HOME`, else `$XDG_CONFIG_HOME/spectra`, else
4
//! `~/.config/spectra`; each agent's is the `--home` it was added with.
5
//!
6
//! The instance key (spec §9.5) is kept in the data directory instead, at
7
//! `keys/instance.secret` and `keys/instance.identity`.
8
9
use crate::durable::{write_atomic, write_atomic_private};
10
use crate::identity::{Delegate, Identity, KeyId, SecretKey, Signature};
11
use anyhow::{bail, Context, Result};
12
use std::fs;
13
use std::path::{Path, PathBuf};
14
15
/// The owner's identity home.
16214×
pub fn default_home() -> PathBuf {
17214×
    if let Some(h) = std::env::var_os("SPECTRA_HOME").filter(|h| !h.is_empty()) {
18214×
        return PathBuf::from(h);
190×
    }
200×
    std::env::var_os("XDG_CONFIG_HOME")
210×
        .filter(|p| !p.is_empty())
220×
        .map(PathBuf::from)
230×
        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
240×
        .unwrap_or_else(|| PathBuf::from("."))
250×
        .join("spectra")
26214×
}
27
28
/// A key pair with its identity: the instance, an agent, or the owner.
29
#[derive(Clone, Debug)]
30
pub struct KeyPair {
31
    pub key: KeyId,
32
    pub identity: Identity,
33
    secret: SecretKey,
34
}
35
36
impl KeyPair {
37
    /// A fresh key pair named `name`, made now.
38138×
    pub fn generate(name: &str, contact: &str) -> KeyPair {
39138×
        let secret = SecretKey::generate();
40138×
        let identity = secret.identity(name, contact, crate::ids::now_ms());
41138×
        KeyPair { key: identity.hash(), identity, secret }
42138×
    }
43
44
    /// Sign `message`, which carries its own `spectra1 …` prefix.
45518×
    pub fn sign(&self, message: &[u8]) -> Signature {
46518×
        self.secret.sign(&self.key, message)
47518×
    }
48
49
    /// Make a key pair in a new identity home and name it current.
5063×
    pub fn create_home(home: &Path, name: &str, contact: &str) -> Result<KeyPair> {
5163×
        if current_key(home)?.is_some() {
522×
            bail!("{} already holds an identity", home.display());
5361×
        }
5461×
        let pair = KeyPair::generate(name, contact);
5561×
        fs::create_dir_all(home).with_context(|| format!("creating {}", home.display()))?;
56
        #[cfg(unix)]
57
        {
58
            use std::os::unix::fs::PermissionsExt;
5961×
            fs::set_permissions(home, fs::Permissions::from_mode(0o700))?;
60
        }
6161×
        pair.write(home)?;
6261×
        Ok(pair)
6363×
    }
64
65
    /// Write this key pair into `home` and name it current.
66129×
    fn write(&self, home: &Path) -> Result<()> {
67129×
        let keys = home.join("keys");
68129×
        write_atomic_private(&keys.join(format!("{}.secret", self.key.to_hex())), &self.secret.encode(&self.key))?;
69129×
        write_atomic(&keys.join(format!("{}.identity", self.key.to_hex())), &self.identity.encode())?;
70129×
        write_atomic(
71129×
            &home.join("identity.toml"),
72129×
            format!(
73129×
                "# The identity this home signs with (spec §8.0).\ncurrent = \"{}\"\nname = {}\n",
74129×
                self.key.to_hex(),
75129×
                toml_str(&self.identity.name)
76129×
            )
77129×
            .as_bytes(),
780×
        )?;
79129×
        Ok(())
80129×
    }
81
82
    /// The key pair a home signs with: `current` in `identity.toml`, else
83
    /// its only secret key.
84265×
    pub fn load_home(home: &Path) -> Result<KeyPair> {
85265×
        let keys = home.join("keys");
86265×
        let key = match current_key(home)? {
87264×
            Some(k) => k,
88
            None => {
891×
                let secrets: Vec<PathBuf> = fs::read_dir(&keys)
901×
                    .with_context(|| format!("{} holds no identity (spectra identity create)", home.display()))?
910×
                    .filter_map(|e| e.ok().map(|e| e.path()))
920×
                    .filter(|p| p.extension().is_some_and(|x| x == "secret"))
930×
                    .collect();
940×
                match secrets.as_slice() {
950×
                    [one] => KeyId::from_hex(one.file_stem().and_then(|s| s.to_str()).unwrap_or(""))
960×
                        .context("a secret key file is not named by its key")?,
970×
                    [] => bail!("{} holds no identity (spectra identity create)", home.display()),
980×
                    _ => bail!("{} holds several keys and its identity.toml names none", home.display()),
99
                }
100
            }
101
        };
102264×
        KeyPair::load_key(home, &key)
103265×
    }
104
105
    /// The key pair of the identity `key` in `home`.
106265×
    pub fn load_key(home: &Path, key: &KeyId) -> Result<KeyPair> {
107265×
        let keys = home.join("keys");
108265×
        KeyPair::load(&keys.join(format!("{}.secret", key.to_hex())), &keys.join(format!("{}.identity", key.to_hex())))
109265×
    }
110
111
    /// Read a secret and its identity, checking that they belong together.
112397×
    pub fn load(secret: &Path, identity: &Path) -> Result<KeyPair> {
113397×
        let (key, secret_key) = SecretKey::decode(&fs::read(secret).with_context(|| format!("reading {}", secret.display()))?)
114396×
            .map_err(|e| anyhow::anyhow!("{}: {e}", secret.display()))?;
115396×
        let identity = Identity::decode(&fs::read(identity).with_context(|| format!("reading {}", identity.display()))?)
116396×
            .map_err(|e| anyhow::anyhow!("{}: {e}", identity.display()))?;
117396×
        if identity.hash() != key {
1180×
            bail!("{} is not the identity of {}", identity.name, secret.display());
119396×
        }
120396×
        if !secret_key.matches(&identity) {
1210×
            bail!("the secret key in {} does not match its identity", secret.display());
122396×
        }
123396×
        Ok(KeyPair { key, identity, secret: secret_key })
124397×
    }
125
126
    /// The data directory's instance key, made on first use (spec §9.5).
127147×
    pub fn instance(data: &Path, name: &str) -> Result<KeyPair> {
128147×
        let keys = data.join("keys");
129147×
        let (secret, identity) = (keys.join("instance.secret"), keys.join("instance.identity"));
130147×
        if secret.exists() {
13197×
            return KeyPair::load(&secret, &identity);
13250×
        }
13350×
        fs::create_dir_all(&keys)?;
13450×
        let pair = KeyPair::generate(name, "");
13550×
        write_atomic_private(&secret, &pair.secret.encode(&pair.key))?;
13650×
        write_atomic(&identity, &pair.identity.encode())?;
13750×
        Ok(pair)
138147×
    }
139
140
    /// The successor of this identity with `delegates`, written into `home`
141
    /// and made current there.
14268×
    pub fn succeed(&self, home: &Path, delegates: Vec<Delegate>) -> Result<KeyPair> {
14368×
        let identity = self.identity.successor(delegates, self.identity.expires, crate::ids::now_ms());
14468×
        let next = KeyPair { key: identity.hash(), identity, secret: self.secret.clone() };
14568×
        next.write(home)?;
14668×
        Ok(next)
14768×
    }
148
}
149
150
/// Delegate to `key` from the current identity in `home` (replacing any
151
/// delegation to it): the owner's successor, made current.
15265×
pub fn delegate(home: &Path, key: KeyId, expires: Option<u64>) -> Result<KeyPair> {
15365×
    let cur = KeyPair::load_home(home)?;
15465×
    let mut d: Vec<Delegate> = cur.identity.delegates.iter().filter(|x| x.key != key).cloned().collect();
15565×
    d.push(Delegate { key, expires });
15665×
    cur.succeed(home, d)
15765×
}
158
159
/// Stop delegating to `key`: the owner's successor without it.
1603×
pub fn undelegate(home: &Path, key: KeyId) -> Result<KeyPair> {
1613×
    let cur = KeyPair::load_home(home)?;
1626×
    let d = cur.identity.delegates.iter().filter(|x| x.key != key).cloned().collect();
1633×
    cur.succeed(home, d)
1643×
}
165
166
/// The identity a home names as current.
16727×
pub fn home_identity(home: &Path) -> Result<Identity> {
16827×
    let key = current_key(home)?.with_context(|| format!("{} holds no identity (spectra identity create)", home.display()))?;
16927×
    let path = home.join("keys").join(format!("{}.identity", key.to_hex()));
17027×
    Identity::decode(&fs::read(&path).with_context(|| format!("reading {}", path.display()))?)
17127×
        .map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))
17227×
}
173
174
/// An identity file of any home, by its KeyId.
1750×
pub fn identity_in(home: &Path, key: &KeyId) -> Option<Identity> {
1760×
    let path = home.join("keys").join(format!("{}.identity", key.to_hex()));
1770×
    Identity::decode(&fs::read(path).ok()?).ok()
1780×
}
179
180355×
fn current_key(home: &Path) -> Result<Option<KeyId>> {
181355×
    let path = home.join("identity.toml");
182355×
    let Ok(text) = fs::read_to_string(&path) else { return Ok(None) };
183293×
    let table: toml::Table = text.parse().with_context(|| format!("reading {}", path.display()))?;
184293×
    match table.get("current").and_then(|v| v.as_str()) {
185293×
        Some(hex) => Ok(Some(KeyId::from_hex(hex).with_context(|| format!("{}: current is not a KeyId", path.display()))?)),
1860×
        None => Ok(None),
187
    }
188355×
}
189
190129×
fn toml_str(s: &str) -> String {
191129×
    toml::Value::String(s.to_string()).to_string()
192129×
}
193
194
#[cfg(test)]
195
mod tests {
196
    use super::*;
197
198
    #[test]
1991×
    fn a_home_made_here_reads_back_signs_and_delegates() {
2001×
        let dir = tempfile::tempdir().unwrap();
2011×
        let made = KeyPair::create_home(dir.path(), "Andy McCall", "").unwrap();
2021×
        let read = KeyPair::load_home(dir.path()).unwrap();
2031×
        assert_eq!(made.key, read.key);
2041×
        assert_eq!(home_identity(dir.path()).unwrap().hash(), made.key);
2051×
        let msg = b"spectra1 test message of some length";
2061×
        crate::identity::verify(&made.identity, &read.sign(msg), msg).unwrap();
2071×
        assert!(KeyPair::create_home(dir.path(), "again", "").is_err());
208
2091×
        let agent = KeyPair::generate("claude", "");
2101×
        let next = delegate(dir.path(), agent.key, Some(u64::MAX)).unwrap();
2111×
        assert_eq!(next.identity.previous, Some(made.key));
2121×
        assert_eq!(KeyPair::load_home(dir.path()).unwrap().key, next.key, "the successor is current");
2131×
        assert!(next.identity.delegates_to(&agent.key, 1));
2141×
        assert!(KeyPair::load_key(dir.path(), &made.key).is_ok(), "the old identity is kept");
2151×
        let after = undelegate(dir.path(), agent.key).unwrap();
2161×
        assert!(!after.identity.delegates_to(&agent.key, 1));
2171×
    }
218
219
    #[test]
2201×
    fn the_instance_key_is_made_once() {
2211×
        let dir = tempfile::tempdir().unwrap();
2221×
        let a = KeyPair::instance(dir.path(), "laptop").unwrap();
2231×
        let b = KeyPair::instance(dir.path(), "laptop").unwrap();
2241×
        assert_eq!(a.key, b.key);
2251×
    }
226
}