OXYGENHUB

Coverage / src/cli/identity.rs 72.0%

175 of 243 lines run, 27 of 66 functions · run #29 on main · run missed

1
//! `spectra init` and `spectra identity`: the owner and the instance
2
//! (spec §8.3, §9.5). The owner proves possession of their identity by
3
//! signing with it; Spectra never holds the owner's secret key.
4
5
use super::{ui, Ctx};
6
use anyhow::{bail, Context, Result};
7
use spectra_core::identity::Identity;
8
use spectra_core::keys::KeyPair;
9
use spectra_core::ledger::Ledger;
10
use spectra_core::principals::{identity_payload, signed_event, Principals};
11
use std::path::{Path, PathBuf};
12
13
/// The owner's `OXY_HOME`: `--owner-home`, else `[owner] home` in
14
/// spectra.toml, else the default identity home.
15113×
pub fn owner_home(ctx: &Ctx, flag: Option<PathBuf>) -> PathBuf {
16113×
    flag.or_else(|| ctx.config_owner_home()).unwrap_or_else(spectra_core::keys::default_home)
17113×
}
18
1924×
pub async fn init(ctx: &Ctx, owner_home_flag: Option<PathBuf>, name: Option<String>) -> Result<i32> {
2024×
    let data = &ctx.data;
2124×
    let db = data.join("spectra.db");
2224×
    if db.exists() {
231×
        bail!("{} already holds a Spectra (spectra identity show)", data.display());
2423×
    }
2523×
    let home = owner_home(ctx, owner_home_flag);
2623×
    KeyPair::load_home(&home).with_context(|| {
271×
        format!("the owner signs with their own identity: none usable in {} (spectra identity create, or --owner-home)", home.display())
281×
    })?;
2922×
    std::fs::create_dir_all(data)?;
30
    #[cfg(unix)]
31
    {
32
        use std::os::unix::fs::PermissionsExt;
3322×
        std::fs::set_permissions(data, std::fs::Permissions::from_mode(0o700))?;
34
    }
3522×
    let name = name.unwrap_or_else(hostname);
3622×
    let instance = KeyPair::instance(data, &name)?;
37
    // You are setting up your own machine: your identity delegates to its
38
    // instance key, so its checkpoints are verifiably yours (spec §9.5).
3922×
    let owner = spectra_core::keys::delegate(&home, instance.key, None)?;
4022×
    let ledger = Ledger::open(&db, Some(instance.clone())).await?;
4122×
    let mut first = vec![
4222×
        signed_event(&owner, "human", "identity.owner", identity_payload(&owner.identity)),
4322×
        signed_event(&instance, "system", "identity.instance", identity_payload(&instance.identity)),
44
    ];
4522×
    first.extend(spectra_core::conversation::default_channel_events());
4622×
    ledger.append(first).await?;
4722×
    ledger.checkpoint().await?;
4822×
    let home_s = home.display().to_string();
4922×
    std::fs::write(
5022×
        data.join("spectra.toml"),
5122×
        format!("# Spectra's configuration (spec §22.1).\n\n[owner]\nkey = \"{}\"\nhome = {}\n", owner.key.to_hex(), toml_str(&home_s)),
520×
    )?;
5322×
    println!("{} {}  {}", ui::done("Initialised"), ui::subject("Spectra"), ui::path(data.display()));
5422×
    println!("{}", ui::kv("owner", format!("{}{}{}", ui::author(&owner.identity.name), ui::sep(), ui::hash(owner.key.short(12)))));
5522×
    println!("{}", ui::kv("instance", format!("{}{}{}", name, ui::sep(), ui::hash(instance.key.short(12)))));
5622×
    println!("{}", ui::kv("ledger", format!("{}{}", ui::path(db.display()), ui::dim(" (verified with spectra admin verify)"))));
5722×
    println!();
5822×
    println!(
59
        "  {} {} {}",
6022×
        ui::note(),
6122×
        ui::dim("then add agents with"),
6222×
        ui::id("spectra agent add NAME --kind KIND --project PROJECT --home DIR")
63
    );
6422×
    Ok(0)
6524×
}
66
672×
pub async fn show(ctx: &Ctx, json: bool) -> Result<i32> {
682×
    if let Some(cfg) = ctx.remote()? {
691×
        return show_remote(&cfg, json).await;
701×
    }
711×
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
721×
    let p = Principals::load(&ledger).await?;
731×
    let owner = p.owner().context("no owner is recorded")?;
741×
    let instance = p.instance();
751×
    let delegated = instance.is_some_and(|i| owner.delegates_to(&i.hash(), spectra_core::ids::now_ms()));
761×
    if json {
771×
        println!(
78
            "{}",
791×
            serde_json::json!({
801×
                "owner": { "name": owner.name, "key": owner.hash().to_hex() },
811×
                "instance": instance.map(|i| serde_json::json!({ "name": i.name, "key": i.hash().to_hex(), "delegated": delegated })),
82
            })
83
        );
841×
        return Ok(0);
850×
    }
860×
    println!("{}", ui::section("Owner"));
870×
    println!("{}", ui::kv("name", ui::author(format!("{} <{}>", owner.name, owner.contact))));
880×
    println!("{}", ui::kv("key", ui::hash(owner.hash().to_hex())));
890×
    println!("{}", ui::kv("delegates", owner.delegates.len()));
900×
    if let Some(i) = instance {
910×
        println!("{}", ui::section("Instance"));
920×
        println!("{}", ui::kv("name", &i.name));
930×
        println!("{}", ui::kv("key", ui::hash(i.hash().to_hex())));
940×
        if delegated {
950×
            println!("{}", ui::kv("status", ui::ok("delegated by the owner: checkpoints are verifiably yours")));
960×
        } else {
970×
            println!("{}", ui::kv("status", ui::warn("not delegated by the owner's current identity")));
980×
        }
990×
    }
1000×
    Ok(0)
1012×
}
102
103
/// Record the owner's current identity when it has moved on (a delegation
104
/// added or removed): `spectra identity refresh`.
1050×
pub async fn refresh(ctx: &Ctx, owner_home_flag: Option<PathBuf>) -> Result<i32> {
1060×
    let home = owner_home(ctx, owner_home_flag);
1070×
    let w = ctx.owner_writer(&home).await?;
1080×
    let rotated = w.refresh_event(&home).await?;
1090×
    let outcome = rotated.as_ref().map(|_| KeyPair::load_home(&home).map(|p| p.identity)).transpose()?;
1100×
    if let Some(e) = rotated {
1110×
        w.append(vec![e]).await?;
1120×
    }
1130×
    match outcome {
1140×
        Some(id) => println!(
115
            "{} {}{}{}",
1160×
            ui::done("Recorded"),
1170×
            ui::subject("the owner's current identity"),
1180×
            ui::sep(),
1190×
            ui::hash(id.hash().short(12))
120
        ),
1210×
        None => println!("{} {}", ui::done("Current"), ui::subject("the owner's identity has not moved")),
122
    }
1230×
    Ok(0)
1240×
}
125
126
/// If the owner's home names a successor of the recorded owner, the event
127
/// that records it, signed with it, with the identities between. Callers
128
/// append it in the same batch as whatever needs it.
12954×
pub async fn refresh_event(ledger: &Ledger, home: &Path) -> Result<Option<spectra_core::event::NewEvent>> {
13054×
    let p = Principals::load(ledger).await?;
13154×
    let recorded = p.owner().context("no owner is recorded (spectra init)")?.clone();
13254×
    rotation(&recorded, home)
13354×
}
134
135
/// The `identity.rotated` that takes `recorded` to the owner's current
136
/// identity in `home`, with the identities between, or none if it is
137
/// current.
13861×
pub fn rotation(recorded: &Identity, home: &Path) -> Result<Option<spectra_core::event::NewEvent>> {
13961×
    let pair = KeyPair::load_home(home)?;
14061×
    if pair.key == recorded.hash() {
14131×
        return Ok(None);
14230×
    }
14330×
    let mut chain = Vec::new();
14430×
    let mut at = pair.identity.clone();
14530×
    while let Some(prev) = at.previous {
14630×
        if prev == recorded.hash() {
14730×
            break;
1480×
        }
1490×
        let file = home.join("keys").join(format!("{}.identity", prev.to_hex()));
1500×
        at = Identity::decode(
1510×
            &std::fs::read(&file).with_context(|| format!("{} is not a successor of the recorded owner", pair.identity.name))?,
152
        )
1530×
        .map_err(|e| anyhow::anyhow!("{}: {e}", file.display()))?;
1540×
        chain.push(hex::encode(at.encode()));
1550×
        if chain.len() > 1000 {
1560×
            bail!("the owner's identity chain is too long to follow");
1570×
        }
158
    }
15930×
    if at.previous != Some(recorded.hash()) && pair.identity.previous != Some(recorded.hash()) {
1600×
        bail!("{} in {} is not a successor of the recorded owner {}", pair.identity.name, home.display(), recorded.hash().short(12));
16130×
    }
16230×
    let mut payload = identity_payload(&pair.identity);
16330×
    payload["chain"] = chain.into();
16430×
    Ok(Some(signed_event(&pair, "human", "identity.rotated", payload)))
16561×
}
166
167
/// The key pair of the owner identity the ledger already records, from the
168
/// owner's home: what a request to the daemon is signed with, since the
169
/// daemon may not know a newer one yet.
17014×
pub async fn recorded_owner(ledger: &Ledger, home: &Path) -> Result<KeyPair> {
17114×
    let p = Principals::load(ledger).await?;
17214×
    let recorded = p.owner().context("no owner is recorded (spectra init)")?.hash();
17314×
    let keys = home.join("keys");
17414×
    KeyPair::load(&keys.join(format!("{}.secret", recorded.to_hex())), &keys.join(format!("{}.identity", recorded.to_hex())))
17514×
        .with_context(|| format!("the recorded owner's key is not in {}", home.display()))
17614×
}
177
178
/// `identity show` for a Spectra on another machine: what it says of itself.
1791×
async fn show_remote(cfg: &super::connect::ClientCfg, json: bool) -> Result<i32> {
1801×
    let info = super::call(&spectra_mcp::client::Client::at(cfg.endpoint()?, None), "GET", "/api/v1/instance", None).await?;
1811×
    let owner_key = info["owner"].as_str().unwrap_or("").to_string();
1821×
    let instance_key = info["instance"]["key"].as_str().unwrap_or("").to_string();
1831×
    let home = spectra_core::keys::default_home();
1841×
    let me = KeyPair::load_home(&home).ok();
1851×
    let delegated = me.as_ref().is_some_and(|m| {
1861×
        spectra_core::identity::KeyId::from_hex(&instance_key).is_some_and(|k| m.identity.delegates_to(&k, spectra_core::ids::now_ms()))
1871×
    });
1881×
    if json {
1891×
        println!(
190
            "{}",
1911×
            serde_json::json!({
1921×
                "server": cfg.server,
1931×
                "owner": { "name": me.as_ref().map(|m| m.identity.name.clone()), "key": owner_key },
1941×
                "instance": { "name": info["name"], "key": instance_key, "delegated": delegated },
195
            })
196
        );
1971×
        return Ok(0);
1980×
    }
1990×
    println!("{}", ui::section("Server"));
2000×
    println!("{}", ui::kv("url", ui::path(&cfg.server)));
2010×
    println!("{}", ui::section("Owner"));
2020×
    if let Some(m) = &me {
2030×
        println!("{}", ui::kv("name", ui::author(format!("{} <{}>", m.identity.name, m.identity.contact))));
2040×
    }
2050×
    println!("{}", ui::kv("key", ui::hash(&owner_key)));
2060×
    println!("{}", ui::section("Instance"));
2070×
    println!("{}", ui::kv("name", info["name"].as_str().unwrap_or("")));
2080×
    println!("{}", ui::kv("key", ui::hash(&instance_key)));
2090×
    if delegated {
2100×
        println!("{}", ui::kv("status", ui::ok("delegated by you: its checkpoints and certificate are verifiably yours")));
2110×
    } else {
2120×
        println!("{}", ui::kv("status", ui::warn("not delegated by your current identity")));
2130×
    }
2140×
    Ok(0)
2151×
}
216
217
/// `spectra identity create`: the owner's own key pair (spec §8.0).
2183×
pub fn create(name: &str, contact: &str, home: Option<PathBuf>, as_json: bool) -> Result<i32> {
2193×
    let home = home.unwrap_or_else(spectra_core::keys::default_home);
2203×
    let pair = KeyPair::create_home(&home, name.trim(), contact.trim())?;
2212×
    if as_json {
2221×
        println!("{}", serde_json::json!({ "name": pair.identity.name, "key": pair.key.to_hex(), "home": home.display().to_string() }));
2231×
        return Ok(0);
2241×
    }
2251×
    println!("{} {}  {}", ui::done("Created"), ui::subject("your identity"), ui::path(home.display()));
2261×
    println!("{}", ui::kv("name", ui::author(&pair.identity.name)));
2271×
    println!("{}", ui::kv("key", ui::hash(pair.key.to_hex())));
2281×
    println!("  {} {}", ui::note(), ui::dim("keep keys/ private: anyone with it can act as you"));
2291×
    println!(
230
        "  {} {} {}",
2311×
        ui::note(),
2321×
        ui::dim("next: spectra init here, or on a server"),
2331×
        ui::id(format!("spectra init --owner {}", pair.key.to_hex()))
234
    );
2351×
    Ok(0)
2363×
}
237
238
/// `spectra init --owner KEYID` (spec §8.6): a server's instance key and
239
/// ledger, with the owner named but not yet recorded; the owner claims it
240
/// from their own machine, so their secret key never comes here.
2416×
pub async fn init_for(ctx: &Ctx, owner_key: &str, name: Option<String>) -> Result<i32> {
2426×
    let key = owner_key.trim().to_ascii_lowercase();
2436×
    if key.len() != 64 || hex::decode(&key).is_err() {
2441×
        bail!("{owner_key} is not a KeyId (64 hex digits; the owner's `spectra identity show` prints it)");
2455×
    }
2465×
    let data = &ctx.data;
2475×
    let db = data.join("spectra.db");
2485×
    if db.exists() {
2490×
        bail!("{} already holds a Spectra (spectra identity show)", data.display());
2505×
    }
2515×
    std::fs::create_dir_all(data)?;
252
    #[cfg(unix)]
253
    {
254
        use std::os::unix::fs::PermissionsExt;
2555×
        std::fs::set_permissions(data, std::fs::Permissions::from_mode(0o700))?;
256
    }
2575×
    let name = name.unwrap_or_else(hostname);
2585×
    let instance = KeyPair::instance(data, &name)?;
2595×
    let ledger = Ledger::open(&db, Some(instance.clone())).await?;
2605×
    let mut first = vec![signed_event(&instance, "system", "identity.instance", identity_payload(&instance.identity))];
2615×
    first.extend(spectra_core::conversation::default_channel_events());
2625×
    ledger.append(first).await?;
2635×
    ledger.checkpoint().await?;
2645×
    std::fs::write(
2655×
        data.join("spectra.toml"),
2665×
        format!(
267
            "# Spectra's configuration (spec §22.1).\n\n[owner]\nkey = \"{key}\"     # claimed from the owner's machine with `spectra connect`\n\n[server]\n# bind = \"[::]:7350\"   # serve other machines over TLS (spec §8.6)\n"
268
        ),
2690×
    )?;
2705×
    println!("{} {}  {}", ui::done("Initialised"), ui::subject("Spectra"), ui::path(data.display()));
2715×
    println!("{}", ui::kv("owner", format!("{}{}", ui::hash(&key[..12]), ui::dim(" (named; not yet claimed)"))));
2725×
    println!("{}", ui::kv("instance", format!("{}{}{}", name, ui::sep(), ui::hash(instance.key.short(12)))));
2735×
    println!();
2745×
    println!("  {} {}", ui::note(), ui::dim("serve it to other machines: set [server] bind in spectra.toml, then spectra serve"));
2755×
    println!("  {} {}", ui::note(), ui::dim("then, on the owner's machine:"));
2765×
    println!("    {}", ui::id("spectra connect https://HOST:7350"));
2775×
    Ok(0)
2786×
}
279
28021×
fn hostname() -> String {
28121×
    std::fs::read_to_string("/etc/hostname")
28221×
        .ok()
28321×
        .map(|h| h.trim().to_string())
28421×
        .filter(|h| !h.is_empty())
28521×
        .unwrap_or_else(|| "spectra".into())
28621×
}
287
28822×
fn toml_str(s: &str) -> String {
28922×
    serde_json::to_string(s).unwrap_or_default()
29022×
}