//! Identity homes on disk (spec §8.0, §21.3): `keys/KEYID.secret` (0600), //! `keys/KEYID.identity`, and `identity.toml` naming the current KeyId. The //! owner's home is `$SPECTRA_HOME`, else `$XDG_CONFIG_HOME/spectra`, else //! `~/.config/spectra`; each agent's is the `--home` it was added with. //! //! The instance key (spec §9.5) is kept in the data directory instead, at //! `keys/instance.secret` and `keys/instance.identity`. use crate::durable::{write_atomic, write_atomic_private}; use crate::identity::{Delegate, Identity, KeyId, SecretKey, Signature}; use anyhow::{bail, Context, Result}; use std::fs; use std::path::{Path, PathBuf}; /// The owner's identity home. pub fn default_home() -> PathBuf { if let Some(h) = std::env::var_os("SPECTRA_HOME").filter(|h| !h.is_empty()) { return PathBuf::from(h); } std::env::var_os("XDG_CONFIG_HOME") .filter(|p| !p.is_empty()) .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) .unwrap_or_else(|| PathBuf::from(".")) .join("spectra") } /// A key pair with its identity: the instance, an agent, or the owner. #[derive(Clone, Debug)] pub struct KeyPair { pub key: KeyId, pub identity: Identity, secret: SecretKey, } impl KeyPair { /// A fresh key pair named `name`, made now. pub fn generate(name: &str, contact: &str) -> KeyPair { let secret = SecretKey::generate(); let identity = secret.identity(name, contact, crate::ids::now_ms()); KeyPair { key: identity.hash(), identity, secret } } /// Sign `message`, which carries its own `spectra1 …` prefix. pub fn sign(&self, message: &[u8]) -> Signature { self.secret.sign(&self.key, message) } /// Make a key pair in a new identity home and name it current. pub fn create_home(home: &Path, name: &str, contact: &str) -> Result { if current_key(home)?.is_some() { bail!("{} already holds an identity", home.display()); } let pair = KeyPair::generate(name, contact); fs::create_dir_all(home).with_context(|| format!("creating {}", home.display()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(home, fs::Permissions::from_mode(0o700))?; } pair.write(home)?; Ok(pair) } /// Write this key pair into `home` and name it current. fn write(&self, home: &Path) -> Result<()> { let keys = home.join("keys"); write_atomic_private(&keys.join(format!("{}.secret", self.key.to_hex())), &self.secret.encode(&self.key))?; write_atomic(&keys.join(format!("{}.identity", self.key.to_hex())), &self.identity.encode())?; write_atomic( &home.join("identity.toml"), format!( "# The identity this home signs with (spec §8.0).\ncurrent = \"{}\"\nname = {}\n", self.key.to_hex(), toml_str(&self.identity.name) ) .as_bytes(), )?; Ok(()) } /// The key pair a home signs with: `current` in `identity.toml`, else /// its only secret key. pub fn load_home(home: &Path) -> Result { let keys = home.join("keys"); let key = match current_key(home)? { Some(k) => k, None => { let secrets: Vec = fs::read_dir(&keys) .with_context(|| format!("{} holds no identity (spectra identity create)", home.display()))? .filter_map(|e| e.ok().map(|e| e.path())) .filter(|p| p.extension().is_some_and(|x| x == "secret")) .collect(); match secrets.as_slice() { [one] => KeyId::from_hex(one.file_stem().and_then(|s| s.to_str()).unwrap_or("")) .context("a secret key file is not named by its key")?, [] => bail!("{} holds no identity (spectra identity create)", home.display()), _ => bail!("{} holds several keys and its identity.toml names none", home.display()), } } }; KeyPair::load_key(home, &key) } /// The key pair of the identity `key` in `home`. pub fn load_key(home: &Path, key: &KeyId) -> Result { let keys = home.join("keys"); KeyPair::load(&keys.join(format!("{}.secret", key.to_hex())), &keys.join(format!("{}.identity", key.to_hex()))) } /// Read a secret and its identity, checking that they belong together. pub fn load(secret: &Path, identity: &Path) -> Result { let (key, secret_key) = SecretKey::decode(&fs::read(secret).with_context(|| format!("reading {}", secret.display()))?) .map_err(|e| anyhow::anyhow!("{}: {e}", secret.display()))?; let identity = Identity::decode(&fs::read(identity).with_context(|| format!("reading {}", identity.display()))?) .map_err(|e| anyhow::anyhow!("{}: {e}", identity.display()))?; if identity.hash() != key { bail!("{} is not the identity of {}", identity.name, secret.display()); } if !secret_key.matches(&identity) { bail!("the secret key in {} does not match its identity", secret.display()); } Ok(KeyPair { key, identity, secret: secret_key }) } /// The data directory's instance key, made on first use (spec §9.5). pub fn instance(data: &Path, name: &str) -> Result { let keys = data.join("keys"); let (secret, identity) = (keys.join("instance.secret"), keys.join("instance.identity")); if secret.exists() { return KeyPair::load(&secret, &identity); } fs::create_dir_all(&keys)?; let pair = KeyPair::generate(name, ""); write_atomic_private(&secret, &pair.secret.encode(&pair.key))?; write_atomic(&identity, &pair.identity.encode())?; Ok(pair) } /// The successor of this identity with `delegates`, written into `home` /// and made current there. pub fn succeed(&self, home: &Path, delegates: Vec) -> Result { let identity = self.identity.successor(delegates, self.identity.expires, crate::ids::now_ms()); let next = KeyPair { key: identity.hash(), identity, secret: self.secret.clone() }; next.write(home)?; Ok(next) } } /// Delegate to `key` from the current identity in `home` (replacing any /// delegation to it): the owner's successor, made current. pub fn delegate(home: &Path, key: KeyId, expires: Option) -> Result { let cur = KeyPair::load_home(home)?; let mut d: Vec = cur.identity.delegates.iter().filter(|x| x.key != key).cloned().collect(); d.push(Delegate { key, expires }); cur.succeed(home, d) } /// Stop delegating to `key`: the owner's successor without it. pub fn undelegate(home: &Path, key: KeyId) -> Result { let cur = KeyPair::load_home(home)?; let d = cur.identity.delegates.iter().filter(|x| x.key != key).cloned().collect(); cur.succeed(home, d) } /// The identity a home names as current. pub fn home_identity(home: &Path) -> Result { let key = current_key(home)?.with_context(|| format!("{} holds no identity (spectra identity create)", home.display()))?; let path = home.join("keys").join(format!("{}.identity", key.to_hex())); Identity::decode(&fs::read(&path).with_context(|| format!("reading {}", path.display()))?) .map_err(|e| anyhow::anyhow!("{}: {e}", path.display())) } /// An identity file of any home, by its KeyId. pub fn identity_in(home: &Path, key: &KeyId) -> Option { let path = home.join("keys").join(format!("{}.identity", key.to_hex())); Identity::decode(&fs::read(path).ok()?).ok() } fn current_key(home: &Path) -> Result> { let path = home.join("identity.toml"); let Ok(text) = fs::read_to_string(&path) else { return Ok(None) }; let table: toml::Table = text.parse().with_context(|| format!("reading {}", path.display()))?; match table.get("current").and_then(|v| v.as_str()) { Some(hex) => Ok(Some(KeyId::from_hex(hex).with_context(|| format!("{}: current is not a KeyId", path.display()))?)), None => Ok(None), } } fn toml_str(s: &str) -> String { toml::Value::String(s.to_string()).to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn a_home_made_here_reads_back_signs_and_delegates() { let dir = tempfile::tempdir().unwrap(); let made = KeyPair::create_home(dir.path(), "Andy McCall", "").unwrap(); let read = KeyPair::load_home(dir.path()).unwrap(); assert_eq!(made.key, read.key); assert_eq!(home_identity(dir.path()).unwrap().hash(), made.key); let msg = b"spectra1 test message of some length"; crate::identity::verify(&made.identity, &read.sign(msg), msg).unwrap(); assert!(KeyPair::create_home(dir.path(), "again", "").is_err()); let agent = KeyPair::generate("claude", ""); let next = delegate(dir.path(), agent.key, Some(u64::MAX)).unwrap(); assert_eq!(next.identity.previous, Some(made.key)); assert_eq!(KeyPair::load_home(dir.path()).unwrap().key, next.key, "the successor is current"); assert!(next.identity.delegates_to(&agent.key, 1)); assert!(KeyPair::load_key(dir.path(), &made.key).is_ok(), "the old identity is kept"); let after = undelegate(dir.path(), agent.key).unwrap(); assert!(!after.identity.delegates_to(&agent.key, 1)); } #[test] fn the_instance_key_is_made_once() { let dir = tempfile::tempdir().unwrap(); let a = KeyPair::instance(dir.path(), "laptop").unwrap(); let b = KeyPair::instance(dir.path(), "laptop").unwrap(); assert_eq!(a.key, b.key); } }