//! `spectra init` and `spectra identity`: the owner and the instance //! (spec §8.3, §9.5). The owner proves possession of their identity by //! signing with it; Spectra never holds the owner's secret key. use super::{ui, Ctx}; use anyhow::{bail, Context, Result}; use oxy_core::identity::Identity; use spectra_core::keys::KeyPair; use spectra_core::ledger::Ledger; use spectra_core::principals::{identity_payload, signed_event, Principals}; use std::path::{Path, PathBuf}; /// The owner's `OXY_HOME`: `--owner-home`, else `[owner] home` in /// spectra.toml, else oxy's own default. pub fn owner_home(ctx: &Ctx, flag: Option) -> PathBuf { flag.or_else(|| ctx.config_owner_home()).unwrap_or_else(oxy_core::repo::default_home) } pub async fn init(ctx: &Ctx, owner_home_flag: Option, name: Option) -> Result { let data = &ctx.data; let db = data.join("spectra.db"); if db.exists() { bail!("{} already holds a Spectra (spectra identity show)", data.display()); } let home = owner_home(ctx, owner_home_flag); let owner = KeyPair::load_home(&home).with_context(|| { format!("the owner signs with their own identity: none usable in {} (oxy identity create, or --owner-home)", home.display()) })?; std::fs::create_dir_all(data)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(data, std::fs::Permissions::from_mode(0o700))?; } let name = name.unwrap_or_else(hostname); let instance = KeyPair::instance(data, &name)?; let ledger = Ledger::open(&db, Some(instance.clone())).await?; let mut first = vec![ signed_event(&owner, "human", "identity.owner", identity_payload(&owner.identity)), signed_event(&instance, "system", "identity.instance", identity_payload(&instance.identity)), ]; first.extend(spectra_core::conversation::default_channel_events()); ledger.append(first).await?; ledger.checkpoint().await?; let home_s = home.display().to_string(); std::fs::write( data.join("spectra.toml"), format!("# Spectra's configuration (spec §22.1).\n\n[owner]\nkey = \"{}\"\nhome = {}\n", owner.key.to_hex(), toml_str(&home_s)), )?; println!("{} {} {}", ui::done("Initialised"), ui::subject("Spectra"), ui::path(data.display())); println!("{}", ui::kv("owner", format!("{}{}{}", ui::author(&owner.identity.name), ui::sep(), ui::hash(owner.key.short(12))))); println!("{}", ui::kv("instance", format!("{}{}{}", name, ui::sep(), ui::hash(instance.key.short(12))))); println!("{}", ui::kv("ledger", format!("{}{}", ui::path(db.display()), ui::dim(" (verified with spectra admin verify)")))); println!(); println!(" {} {}", ui::note(), ui::dim("so that checkpoints are verifiably from your machine, delegate to the instance key:")); println!( " {}", ui::id(format!("oxy identity delegate add {} --scope changes", data.join("keys").join("instance.identity").display())) ); println!( " {} {} {}", ui::note(), ui::dim("then add agents with"), ui::id("spectra agent add NAME --kind KIND --project PROJECT --home DIR") ); Ok(0) } pub async fn show(ctx: &Ctx, json: bool) -> Result { let ledger = Ledger::open_read_only(&ctx.db()?).await?; let p = Principals::load(&ledger).await?; let owner = p.owner().context("no owner is recorded")?; let instance = p.instance(); let delegated = instance.is_some_and(|i| owner.delegates_to(&i.hash(), spectra_core::principals::DELEGATION_SCOPE, spectra_core::ids::now_ms())); if json { println!( "{}", serde_json::json!({ "owner": { "name": owner.name, "key": owner.hash().to_hex() }, "instance": instance.map(|i| serde_json::json!({ "name": i.name, "key": i.hash().to_hex(), "delegated": delegated })), }) ); return Ok(0); } println!("{}", ui::section("Owner")); println!("{}", ui::kv("name", ui::author(format!("{} <{}>", owner.name, owner.email)))); println!("{}", ui::kv("key", ui::hash(owner.hash().to_hex()))); println!("{}", ui::kv("delegates", owner.delegates.len())); if let Some(i) = instance { println!("{}", ui::section("Instance")); println!("{}", ui::kv("name", &i.name)); println!("{}", ui::kv("key", ui::hash(i.hash().to_hex()))); if delegated { println!("{}", ui::kv("status", ui::ok("delegated by the owner: checkpoints are verifiably yours"))); } else { println!("{}", ui::kv("status", ui::warn("not delegated by the owner yet"))); println!( " {} {}", ui::note(), ui::id(format!("oxy identity delegate add {} --scope changes", ctx.data.join("keys").join("instance.identity").display())) ); } } Ok(0) } /// Record the owner's current identity when it has moved on (a delegation /// added or removed): `spectra identity refresh`. pub async fn refresh(ctx: &Ctx, owner_home_flag: Option) -> Result { let home = owner_home(ctx, owner_home_flag); let w = ctx.owner_writer(&home).await?; let rotated = refresh_event(w.ledger(), &home).await?; let outcome = rotated.as_ref().map(|_| KeyPair::load_home(&home).map(|p| p.identity)).transpose()?; if let Some(e) = rotated { w.append(vec![e]).await?; } match outcome { Some(id) => println!( "{} {}{}{}", ui::done("Recorded"), ui::subject("the owner's current identity"), ui::sep(), ui::hash(id.hash().short(12)) ), None => println!("{} {}", ui::done("Current"), ui::subject("the owner's identity has not moved")), } Ok(0) } /// If the owner's home names a successor of the recorded owner, the event /// that records it, signed with it, with the identities between. Callers /// append it in the same batch as whatever needs it. pub async fn refresh_event(ledger: &Ledger, home: &Path) -> Result> { let p = Principals::load(ledger).await?; let recorded = p.owner().context("no owner is recorded (spectra init)")?.clone(); let pair = KeyPair::load_home(home)?; if pair.key == recorded.hash() { return Ok(None); } let mut chain = Vec::new(); let mut at = pair.identity.clone(); while let Some(prev) = at.previous { if prev == recorded.hash() { break; } let file = home.join("keys").join(format!("{}.identity", prev.to_hex())); at = Identity::decode( &std::fs::read(&file).with_context(|| format!("{} is not a successor of the recorded owner", pair.identity.name))?, ) .map_err(|e| anyhow::anyhow!("{}: {e}", file.display()))?; chain.push(hex::encode(at.encode())); if chain.len() > 1000 { bail!("the owner's identity chain is too long to follow"); } } if at.previous != Some(recorded.hash()) && pair.identity.previous != Some(recorded.hash()) { bail!("{} in {} is not a successor of the recorded owner {}", pair.identity.name, home.display(), recorded.hash().short(12)); } let mut payload = identity_payload(&pair.identity); payload["chain"] = chain.into(); Ok(Some(signed_event(&pair, "human", "identity.rotated", payload))) } /// The key pair of the owner identity the ledger already records, from the /// owner's home: what a request to the daemon is signed with, since the /// daemon may not know a newer one yet. pub async fn recorded_owner(ledger: &Ledger, home: &Path) -> Result { let p = Principals::load(ledger).await?; let recorded = p.owner().context("no owner is recorded (spectra init)")?.hash(); let keys = home.join("keys"); KeyPair::load(&keys.join(format!("{}.secret", recorded.to_hex())), &keys.join(format!("{}.identity", recorded.to_hex()))) .with_context(|| format!("the recorded owner's key is not in {}", home.display())) } fn hostname() -> String { std::fs::read_to_string("/etc/hostname") .ok() .map(|h| h.trim().to_string()) .filter(|h| !h.is_empty()) .unwrap_or_else(|| "spectra".into()) } fn toml_str(s: &str) -> String { serde_json::to_string(s).unwrap_or_default() }