OXYGENHUB
# 01320eefd505 spectra / src / cli / identity.rs
12.7 KiB · Rust · last changed The command line from another machine: every command through the API (#23, completes #21) 1 day ago source blame history raw
09752034 Andy McCall ↶ 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 oxy_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 oxy's own default.
15
pub fn owner_home(ctx: &Ctx, flag: Option<PathBuf>) -> PathBuf {
16
    flag.or_else(|| ctx.config_owner_home()).unwrap_or_else(oxy_core::repo::default_home)
17
}
18
19
pub async fn init(ctx: &Ctx, owner_home_flag: Option<PathBuf>, name: Option<String>) -> Result<i32> {
20
    let data = &ctx.data;
21
    let db = data.join("spectra.db");
22
    if db.exists() {
23
        bail!("{} already holds a Spectra (spectra identity show)", data.display());
24
    }
25
    let home = owner_home(ctx, owner_home_flag);
26
    let owner = KeyPair::load_home(&home).with_context(|| {
27
        format!("the owner signs with their own identity: none usable in {} (oxy identity create, or --owner-home)", home.display())
28
    })?;
29
    std::fs::create_dir_all(data)?;
30
    #[cfg(unix)]
31
    {
32
        use std::os::unix::fs::PermissionsExt;
33
        std::fs::set_permissions(data, std::fs::Permissions::from_mode(0o700))?;
34
    }
35
    let name = name.unwrap_or_else(hostname);
36
    let instance = KeyPair::instance(data, &name)?;
37
    let ledger = Ledger::open(&db, Some(instance.clone())).await?;
5eb57578 Andy McCall ↶ 38
    let mut first = vec![
39
        signed_event(&owner, "human", "identity.owner", identity_payload(&owner.identity)),
40
        signed_event(&instance, "system", "identity.instance", identity_payload(&instance.identity)),
41
    ];
42
    first.extend(spectra_core::conversation::default_channel_events());
43
    ledger.append(first).await?;
09752034 Andy McCall ↶ 44
    ledger.checkpoint().await?;
45
    let home_s = home.display().to_string();
46
    std::fs::write(
47
        data.join("spectra.toml"),
48
        format!("# Spectra's configuration (spec §22.1).\n\n[owner]\nkey = \"{}\"\nhome = {}\n", owner.key.to_hex(), toml_str(&home_s)),
49
    )?;
5d695d86 Andy McCall ↶ 50
    println!("{} {}  {}", ui::done("Initialised"), ui::subject("Spectra"), ui::path(data.display()));
51
    println!("{}", ui::kv("owner", format!("{}{}{}", ui::author(&owner.identity.name), ui::sep(), ui::hash(owner.key.short(12)))));
52
    println!("{}", ui::kv("instance", format!("{}{}{}", name, ui::sep(), ui::hash(instance.key.short(12)))));
53
    println!("{}", ui::kv("ledger", format!("{}{}", ui::path(db.display()), ui::dim(" (verified with spectra admin verify)"))));
09752034 Andy McCall ↶ 54
    println!();
5d695d86 Andy McCall ↶ 55
    println!("  {} {}", ui::note(), ui::dim("so that checkpoints are verifiably from your machine, delegate to the instance key:"));
09752034 Andy McCall ↶ 56
    println!(
5d695d86 Andy McCall ↶ 57
        "    {}",
09752034 Andy McCall ↶ 58
        ui::id(format!("oxy identity delegate add {} --scope changes", data.join("keys").join("instance.identity").display()))
59
    );
5d695d86 Andy McCall ↶ 60
    println!(
61
        "  {} {} {}",
62
        ui::note(),
63
        ui::dim("then add agents with"),
64
        ui::id("spectra agent add NAME --kind KIND --project PROJECT --home DIR")
65
    );
09752034 Andy McCall ↶ 66
    Ok(0)
67
}
68
69
pub async fn show(ctx: &Ctx, json: bool) -> Result<i32> {
67bfa06a Andy McCall ↶ 70
    if let Some(cfg) = ctx.remote()? {
71
        return show_remote(&cfg, json).await;
72
    }
09752034 Andy McCall ↶ 73
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
74
    let p = Principals::load(&ledger).await?;
75
    let owner = p.owner().context("no owner is recorded")?;
76
    let instance = p.instance();
77
    let delegated =
78
        instance.is_some_and(|i| owner.delegates_to(&i.hash(), spectra_core::principals::DELEGATION_SCOPE, spectra_core::ids::now_ms()));
79
    if json {
80
        println!(
81
            "{}",
82
            serde_json::json!({
83
                "owner": { "name": owner.name, "key": owner.hash().to_hex() },
84
                "instance": instance.map(|i| serde_json::json!({ "name": i.name, "key": i.hash().to_hex(), "delegated": delegated })),
85
            })
86
        );
87
        return Ok(0);
88
    }
5d695d86 Andy McCall ↶ 89
    println!("{}", ui::section("Owner"));
90
    println!("{}", ui::kv("name", ui::author(format!("{} <{}>", owner.name, owner.email))));
91
    println!("{}", ui::kv("key", ui::hash(owner.hash().to_hex())));
92
    println!("{}", ui::kv("delegates", owner.delegates.len()));
09752034 Andy McCall ↶ 93
    if let Some(i) = instance {
5d695d86 Andy McCall ↶ 94
        println!("{}", ui::section("Instance"));
95
        println!("{}", ui::kv("name", &i.name));
96
        println!("{}", ui::kv("key", ui::hash(i.hash().to_hex())));
97
        if delegated {
98
            println!("{}", ui::kv("status", ui::ok("delegated by the owner: checkpoints are verifiably yours")));
99
        } else {
100
            println!("{}", ui::kv("status", ui::warn("not delegated by the owner yet")));
101
            println!(
102
                "  {} {}",
103
                ui::note(),
104
                ui::id(format!("oxy identity delegate add {} --scope changes", ctx.data.join("keys").join("instance.identity").display()))
105
            );
106
        }
09752034 Andy McCall ↶ 107
    }
108
    Ok(0)
109
}
110
111
/// Record the owner's current identity when it has moved on (a delegation
112
/// added or removed): `spectra identity refresh`.
113
pub async fn refresh(ctx: &Ctx, owner_home_flag: Option<PathBuf>) -> Result<i32> {
571572f5 Andy McCall ↶ 114
    let home = owner_home(ctx, owner_home_flag);
115
    let w = ctx.owner_writer(&home).await?;
67bfa06a Andy McCall ↶ 116
    let rotated = w.refresh_event(&home).await?;
571572f5 Andy McCall ↶ 117
    let outcome = rotated.as_ref().map(|_| KeyPair::load_home(&home).map(|p| p.identity)).transpose()?;
118
    if let Some(e) = rotated {
119
        w.append(vec![e]).await?;
120
    }
09752034 Andy McCall ↶ 121
    match outcome {
5d695d86 Andy McCall ↶ 122
        Some(id) => println!(
123
            "{} {}{}{}",
124
            ui::done("Recorded"),
125
            ui::subject("the owner's current identity"),
126
            ui::sep(),
127
            ui::hash(id.hash().short(12))
128
        ),
129
        None => println!("{} {}", ui::done("Current"), ui::subject("the owner's identity has not moved")),
09752034 Andy McCall ↶ 130
    }
131
    Ok(0)
132
}
133
571572f5 Andy McCall ↶ 134
/// If the owner's home names a successor of the recorded owner, the event
135
/// that records it, signed with it, with the identities between. Callers
136
/// append it in the same batch as whatever needs it.
137
pub async fn refresh_event(ledger: &Ledger, home: &Path) -> Result<Option<spectra_core::event::NewEvent>> {
09752034 Andy McCall ↶ 138
    let p = Principals::load(ledger).await?;
139
    let recorded = p.owner().context("no owner is recorded (spectra init)")?.clone();
eca5af92 Andy McCall ↶ 140
    rotation(&recorded, home)
141
}
142
143
/// The `identity.rotated` that takes `recorded` to the owner's current
144
/// identity in `home`, with the identities between, or none if it is
145
/// current.
146
pub fn rotation(recorded: &Identity, home: &Path) -> Result<Option<spectra_core::event::NewEvent>> {
09752034 Andy McCall ↶ 147
    let pair = KeyPair::load_home(home)?;
148
    if pair.key == recorded.hash() {
149
        return Ok(None);
150
    }
151
    let mut chain = Vec::new();
152
    let mut at = pair.identity.clone();
153
    while let Some(prev) = at.previous {
154
        if prev == recorded.hash() {
155
            break;
156
        }
157
        let file = home.join("keys").join(format!("{}.identity", prev.to_hex()));
158
        at = Identity::decode(
159
            &std::fs::read(&file).with_context(|| format!("{} is not a successor of the recorded owner", pair.identity.name))?,
160
        )
161
        .map_err(|e| anyhow::anyhow!("{}: {e}", file.display()))?;
162
        chain.push(hex::encode(at.encode()));
163
        if chain.len() > 1000 {
164
            bail!("the owner's identity chain is too long to follow");
165
        }
166
    }
167
    if at.previous != Some(recorded.hash()) && pair.identity.previous != Some(recorded.hash()) {
168
        bail!("{} in {} is not a successor of the recorded owner {}", pair.identity.name, home.display(), recorded.hash().short(12));
169
    }
170
    let mut payload = identity_payload(&pair.identity);
171
    payload["chain"] = chain.into();
571572f5 Andy McCall ↶ 172
    Ok(Some(signed_event(&pair, "human", "identity.rotated", payload)))
173
}
174
175
/// The key pair of the owner identity the ledger already records, from the
176
/// owner's home: what a request to the daemon is signed with, since the
177
/// daemon may not know a newer one yet.
178
pub async fn recorded_owner(ledger: &Ledger, home: &Path) -> Result<KeyPair> {
179
    let p = Principals::load(ledger).await?;
180
    let recorded = p.owner().context("no owner is recorded (spectra init)")?.hash();
181
    let keys = home.join("keys");
182
    KeyPair::load(&keys.join(format!("{}.secret", recorded.to_hex())), &keys.join(format!("{}.identity", recorded.to_hex())))
183
        .with_context(|| format!("the recorded owner's key is not in {}", home.display()))
67bfa06a Andy McCall ↶ 184
}
185
186
/// `identity show` for a Spectra on another machine: what it says of itself.
187
async fn show_remote(cfg: &super::connect::ClientCfg, json: bool) -> Result<i32> {
188
    let info = super::call(&spectra_mcp::client::Client::at(cfg.endpoint()?, None), "GET", "/api/v1/instance", None).await?;
189
    let owner_key = info["owner"].as_str().unwrap_or("").to_string();
190
    let instance_key = info["instance"]["key"].as_str().unwrap_or("").to_string();
191
    let home = oxy_core::repo::default_home();
192
    let me = KeyPair::load_home(&home).ok();
193
    let delegated = me.as_ref().is_some_and(|m| {
194
        oxy_core::hash::ObjectRef::from_hex(&instance_key)
195
            .is_some_and(|k| m.identity.delegates_to(&k, spectra_core::principals::DELEGATION_SCOPE, spectra_core::ids::now_ms()))
196
    });
197
    if json {
198
        println!(
199
            "{}",
200
            serde_json::json!({
201
                "server": cfg.server,
202
                "owner": { "name": me.as_ref().map(|m| m.identity.name.clone()), "key": owner_key },
203
                "instance": { "name": info["name"], "key": instance_key, "delegated": delegated },
204
            })
205
        );
206
        return Ok(0);
207
    }
208
    println!("{}", ui::section("Server"));
209
    println!("{}", ui::kv("url", ui::path(&cfg.server)));
210
    println!("{}", ui::section("Owner"));
211
    if let Some(m) = &me {
212
        println!("{}", ui::kv("name", ui::author(format!("{} <{}>", m.identity.name, m.identity.email))));
213
    }
214
    println!("{}", ui::kv("key", ui::hash(&owner_key)));
215
    println!("{}", ui::section("Instance"));
216
    println!("{}", ui::kv("name", info["name"].as_str().unwrap_or("")));
217
    println!("{}", ui::kv("key", ui::hash(&instance_key)));
218
    if delegated {
219
        println!("{}", ui::kv("status", ui::ok("delegated by you: its checkpoints and certificate are verifiably yours")));
220
    } else {
221
        println!("{}", ui::kv("status", ui::warn("not delegated by your current identity")));
222
    }
223
    Ok(0)
09752034 Andy McCall ↶ 224
}
225
77df591d Andy McCall ↶ 226
/// `spectra init --owner KEYID` (spec §8.6): a server's instance key and
227
/// ledger, with the owner named but not yet recorded; the owner claims it
228
/// from their own machine, so their secret key never comes here.
229
pub async fn init_for(ctx: &Ctx, owner_key: &str, name: Option<String>) -> Result<i32> {
230
    let key = owner_key.trim().to_ascii_lowercase();
231
    if key.len() != 64 || hex::decode(&key).is_err() {
232
        bail!("{owner_key} is not a KeyId (64 hex digits; the owner's `oxy identity show` prints it)");
233
    }
234
    let data = &ctx.data;
235
    let db = data.join("spectra.db");
236
    if db.exists() {
237
        bail!("{} already holds a Spectra (spectra identity show)", data.display());
238
    }
239
    std::fs::create_dir_all(data)?;
240
    #[cfg(unix)]
241
    {
242
        use std::os::unix::fs::PermissionsExt;
243
        std::fs::set_permissions(data, std::fs::Permissions::from_mode(0o700))?;
244
    }
245
    let name = name.unwrap_or_else(hostname);
246
    let instance = KeyPair::instance(data, &name)?;
247
    let ledger = Ledger::open(&db, Some(instance.clone())).await?;
248
    let mut first = vec![signed_event(&instance, "system", "identity.instance", identity_payload(&instance.identity))];
249
    first.extend(spectra_core::conversation::default_channel_events());
250
    ledger.append(first).await?;
251
    ledger.checkpoint().await?;
252
    std::fs::write(
253
        data.join("spectra.toml"),
254
        format!(
255
            "# 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"
256
        ),
257
    )?;
258
    println!("{} {}  {}", ui::done("Initialised"), ui::subject("Spectra"), ui::path(data.display()));
259
    println!("{}", ui::kv("owner", format!("{}{}", ui::hash(&key[..12]), ui::dim(" (named; not yet claimed)"))));
260
    println!("{}", ui::kv("instance", format!("{}{}{}", name, ui::sep(), ui::hash(instance.key.short(12)))));
261
    println!();
262
    println!("  {} {}", ui::note(), ui::dim("serve it to other machines: set [server] bind in spectra.toml, then spectra serve"));
263
    println!("  {} {}", ui::note(), ui::dim("then, on the owner's machine:"));
264
    println!("    {}", ui::id("spectra connect https://HOST:7350"));
265
    Ok(0)
266
}
267
09752034 Andy McCall ↶ 268
fn hostname() -> String {
269
    std::fs::read_to_string("/etc/hostname")
270
        .ok()
271
        .map(|h| h.trim().to_string())
272
        .filter(|h| !h.is_empty())
273
        .unwrap_or_else(|| "spectra".into())
274
}
275
276
fn toml_str(s: &str) -> String {
277
    serde_json::to_string(s).unwrap_or_default()
278
}