OXYGENHUB
# c7161ec1a607 spectra / crates / spectra-core / src / keys.rs
9.7 KiB · Rust · last changed Spectra stands alone: its own identities, signatures and CBOR; projects of any kind of work (#26, #27) 1 day ago source blame history raw
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.
16
pub fn default_home() -> PathBuf {
17
    if let Some(h) = std::env::var_os("SPECTRA_HOME").filter(|h| !h.is_empty()) {
18
        return PathBuf::from(h);
19
    }
20
    std::env::var_os("XDG_CONFIG_HOME")
21
        .filter(|p| !p.is_empty())
22
        .map(PathBuf::from)
23
        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
24
        .unwrap_or_else(|| PathBuf::from("."))
25
        .join("spectra")
26
}
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.
38
    pub fn generate(name: &str, contact: &str) -> KeyPair {
39
        let secret = SecretKey::generate();
40
        let identity = secret.identity(name, contact, crate::ids::now_ms());
41
        KeyPair { key: identity.hash(), identity, secret }
42
    }
43
44
    /// Sign `message`, which carries its own `spectra1 …` prefix.
45
    pub fn sign(&self, message: &[u8]) -> Signature {
46
        self.secret.sign(&self.key, message)
47
    }
48
49
    /// Make a key pair in a new identity home and name it current.
50
    pub fn create_home(home: &Path, name: &str, contact: &str) -> Result<KeyPair> {
51
        if current_key(home)?.is_some() {
52
            bail!("{} already holds an identity", home.display());
53
        }
54
        let pair = KeyPair::generate(name, contact);
55
        fs::create_dir_all(home).with_context(|| format!("creating {}", home.display()))?;
56
        #[cfg(unix)]
57
        {
58
            use std::os::unix::fs::PermissionsExt;
59
            fs::set_permissions(home, fs::Permissions::from_mode(0o700))?;
60
        }
61
        pair.write(home)?;
62
        Ok(pair)
63
    }
64
65
    /// Write this key pair into `home` and name it current.
66
    fn write(&self, home: &Path) -> Result<()> {
67
        let keys = home.join("keys");
68
        write_atomic_private(&keys.join(format!("{}.secret", self.key.to_hex())), &self.secret.encode(&self.key))?;
69
        write_atomic(&keys.join(format!("{}.identity", self.key.to_hex())), &self.identity.encode())?;
70
        write_atomic(
71
            &home.join("identity.toml"),
72
            format!(
73
                "# The identity this home signs with (spec §8.0).\ncurrent = \"{}\"\nname = {}\n",
74
                self.key.to_hex(),
75
                toml_str(&self.identity.name)
76
            )
77
            .as_bytes(),
78
        )?;
79
        Ok(())
80
    }
81
82
    /// The key pair a home signs with: `current` in `identity.toml`, else
83
    /// its only secret key.
84
    pub fn load_home(home: &Path) -> Result<KeyPair> {
85
        let keys = home.join("keys");
86
        let key = match current_key(home)? {
87
            Some(k) => k,
88
            None => {
89
                let secrets: Vec<PathBuf> = fs::read_dir(&keys)
90
                    .with_context(|| format!("{} holds no identity (spectra identity create)", home.display()))?
91
                    .filter_map(|e| e.ok().map(|e| e.path()))
92
                    .filter(|p| p.extension().is_some_and(|x| x == "secret"))
93
                    .collect();
94
                match secrets.as_slice() {
95
                    [one] => KeyId::from_hex(one.file_stem().and_then(|s| s.to_str()).unwrap_or(""))
96
                        .context("a secret key file is not named by its key")?,
97
                    [] => bail!("{} holds no identity (spectra identity create)", home.display()),
98
                    _ => bail!("{} holds several keys and its identity.toml names none", home.display()),
99
                }
100
            }
101
        };
102
        KeyPair::load_key(home, &key)
103
    }
104
105
    /// The key pair of the identity `key` in `home`.
106
    pub fn load_key(home: &Path, key: &KeyId) -> Result<KeyPair> {
107
        let keys = home.join("keys");
108
        KeyPair::load(&keys.join(format!("{}.secret", key.to_hex())), &keys.join(format!("{}.identity", key.to_hex())))
109
    }
110
111
    /// Read a secret and its identity, checking that they belong together.
112
    pub fn load(secret: &Path, identity: &Path) -> Result<KeyPair> {
113
        let (key, secret_key) = SecretKey::decode(&fs::read(secret).with_context(|| format!("reading {}", secret.display()))?)
114
            .map_err(|e| anyhow::anyhow!("{}: {e}", secret.display()))?;
115
        let identity = Identity::decode(&fs::read(identity).with_context(|| format!("reading {}", identity.display()))?)
116
            .map_err(|e| anyhow::anyhow!("{}: {e}", identity.display()))?;
117
        if identity.hash() != key {
118
            bail!("{} is not the identity of {}", identity.name, secret.display());
119
        }
120
        if !secret_key.matches(&identity) {
121
            bail!("the secret key in {} does not match its identity", secret.display());
122
        }
123
        Ok(KeyPair { key, identity, secret: secret_key })
124
    }
125
126
    /// The data directory's instance key, made on first use (spec §9.5).
127
    pub fn instance(data: &Path, name: &str) -> Result<KeyPair> {
128
        let keys = data.join("keys");
129
        let (secret, identity) = (keys.join("instance.secret"), keys.join("instance.identity"));
130
        if secret.exists() {
131
            return KeyPair::load(&secret, &identity);
132
        }
133
        fs::create_dir_all(&keys)?;
134
        let pair = KeyPair::generate(name, "");
135
        write_atomic_private(&secret, &pair.secret.encode(&pair.key))?;
136
        write_atomic(&identity, &pair.identity.encode())?;
137
        Ok(pair)
138
    }
139
140
    /// The successor of this identity with `delegates`, written into `home`
141
    /// and made current there.
142
    pub fn succeed(&self, home: &Path, delegates: Vec<Delegate>) -> Result<KeyPair> {
143
        let identity = self.identity.successor(delegates, self.identity.expires, crate::ids::now_ms());
144
        let next = KeyPair { key: identity.hash(), identity, secret: self.secret.clone() };
145
        next.write(home)?;
146
        Ok(next)
147
    }
148
}
149
150
/// Delegate to `key` from the current identity in `home` (replacing any
151
/// delegation to it): the owner's successor, made current.
152
pub fn delegate(home: &Path, key: KeyId, expires: Option<u64>) -> Result<KeyPair> {
153
    let cur = KeyPair::load_home(home)?;
154
    let mut d: Vec<Delegate> = cur.identity.delegates.iter().filter(|x| x.key != key).cloned().collect();
155
    d.push(Delegate { key, expires });
156
    cur.succeed(home, d)
157
}
158
159
/// Stop delegating to `key`: the owner's successor without it.
160
pub fn undelegate(home: &Path, key: KeyId) -> Result<KeyPair> {
161
    let cur = KeyPair::load_home(home)?;
162
    let d = cur.identity.delegates.iter().filter(|x| x.key != key).cloned().collect();
163
    cur.succeed(home, d)
164
}
165
166
/// The identity a home names as current.
167
pub fn home_identity(home: &Path) -> Result<Identity> {
168
    let key = current_key(home)?.with_context(|| format!("{} holds no identity (spectra identity create)", home.display()))?;
169
    let path = home.join("keys").join(format!("{}.identity", key.to_hex()));
170
    Identity::decode(&fs::read(&path).with_context(|| format!("reading {}", path.display()))?)
171
        .map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))
172
}
173
174
/// An identity file of any home, by its KeyId.
175
pub fn identity_in(home: &Path, key: &KeyId) -> Option<Identity> {
176
    let path = home.join("keys").join(format!("{}.identity", key.to_hex()));
177
    Identity::decode(&fs::read(path).ok()?).ok()
178
}
179
180
fn current_key(home: &Path) -> Result<Option<KeyId>> {
181
    let path = home.join("identity.toml");
182
    let Ok(text) = fs::read_to_string(&path) else { return Ok(None) };
183
    let table: toml::Table = text.parse().with_context(|| format!("reading {}", path.display()))?;
184
    match table.get("current").and_then(|v| v.as_str()) {
185
        Some(hex) => Ok(Some(KeyId::from_hex(hex).with_context(|| format!("{}: current is not a KeyId", path.display()))?)),
186
        None => Ok(None),
187
    }
188
}
189
190
fn toml_str(s: &str) -> String {
191
    toml::Value::String(s.to_string()).to_string()
192
}
193
194
#[cfg(test)]
195
mod tests {
196
    use super::*;
197
198
    #[test]
199
    fn a_home_made_here_reads_back_signs_and_delegates() {
200
        let dir = tempfile::tempdir().unwrap();
201
        let made = KeyPair::create_home(dir.path(), "Andy McCall", "").unwrap();
202
        let read = KeyPair::load_home(dir.path()).unwrap();
203
        assert_eq!(made.key, read.key);
204
        assert_eq!(home_identity(dir.path()).unwrap().hash(), made.key);
205
        let msg = b"spectra1 test message of some length";
206
        crate::identity::verify(&made.identity, &read.sign(msg), msg).unwrap();
207
        assert!(KeyPair::create_home(dir.path(), "again", "").is_err());
208
209
        let agent = KeyPair::generate("claude", "");
210
        let next = delegate(dir.path(), agent.key, Some(u64::MAX)).unwrap();
211
        assert_eq!(next.identity.previous, Some(made.key));
212
        assert_eq!(KeyPair::load_home(dir.path()).unwrap().key, next.key, "the successor is current");
213
        assert!(next.identity.delegates_to(&agent.key, 1));
214
        assert!(KeyPair::load_key(dir.path(), &made.key).is_ok(), "the old identity is kept");
215
        let after = undelegate(dir.path(), agent.key).unwrap();
216
        assert!(!after.identity.delegates_to(&agent.key, 1));
217
    }
218
219
    #[test]
220
    fn the_instance_key_is_made_once() {
221
        let dir = tempfile::tempdir().unwrap();
222
        let a = KeyPair::instance(dir.path(), "laptop").unwrap();
223
        let b = KeyPair::instance(dir.path(), "laptop").unwrap();
224
        assert_eq!(a.key, b.key);
225
    }
226
}