OXYGENHUB
# da58bbbbc4d9 oxygen / src / cli / identity.rs
13.9 KiB · Rust · last changed CLI: modern output across all commands: rail-and-bullet log with -n/-v, relative times, authors, signature marks; glyphs on UTF-8 terminals with ASCII fallback 1 month ago source blame history raw
fce44ae9 Andy McCall ↶ 1
//! Identity, verification, and configuration commands.
2
3
use super::format::{short_id, signature_line, when};
4
use super::open;
135212de Andy McCall ↶ 5
use super::ui;
fce44ae9 Andy McCall ↶ 6
use anyhow::Result;
7
use oxy_core::hash::ChangeHash;
83e0677b Andy McCall ↶ 8
use oxy_core::identity::Identity;
28fc23d7 Andy McCall ↶ 9
use oxy_core::hash::KeyId;
10
use oxy_core::objects::Hlc;
11
use oxy_core::protocol::SignedPolicy;
12
use oxy_core::repo::{self, IdentityScope, PolicyUpdate, Repo, Rotation};
83e0677b Andy McCall ↶ 13
use std::path::{Path, PathBuf};
fce44ae9 Andy McCall ↶ 14
28fc23d7 Andy McCall ↶ 15
const DAY_MS: u64 = 24 * 60 * 60 * 1000;
16
17
fn expiry_from_days(days: u64) -> Option<u64> {
18
    (days > 0).then(|| Hlc::now().wall_ms + days * DAY_MS)
19
}
20
21
/// Resolve a KeyId prefix among the identities the repository knows.
22
fn resolve_key(repo: &Repo, prefix: &str) -> Result<KeyId> {
23
    if let Some(k) = KeyId::from_hex(prefix) {
24
        return Ok(k);
25
    }
26
    let mut candidates: Vec<KeyId> = Vec::new();
27
    if let Some((_, sp)) = repo.current_policy()? {
28
        candidates.extend(sp.policy.identities.iter().map(|i| i.hash()));
29
        candidates.extend(sp.policy.revoked.iter().copied());
30
    }
31
    if let Some(own) = repo.config.author_key {
32
        candidates.push(own);
33
        if let Ok(id) = repo.identity(&own) {
34
            candidates.extend(id.delegates.iter().copied());
35
        }
36
    }
37
    candidates.sort();
38
    candidates.dedup();
39
    let matches: Vec<_> = candidates.into_iter().filter(|k| k.starts_with_hex(prefix)).collect();
40
    match matches.len() {
41
        1 => Ok(matches[0]),
42
        0 => anyhow::bail!("no known key starts with {prefix:?}"),
43
        _ => anyhow::bail!("{prefix:?} is ambiguous"),
44
    }
45
}
46
47
/// Report a policy update: applied, or written out as a proposal that
48
/// other root holders sign with `oxy policy sign`.
49
fn report_update(u: &PolicyUpdate, out: Option<PathBuf>) -> Result<()> {
50
    if u.applied {
51
        println!("{} policy serial {} {}", ui::done("Applied"), u.policy.serial, ui::dim("(push to publish it)"));
52
        return Ok(());
53
    }
54
    let path = out.unwrap_or_else(|| PathBuf::from(format!("policy-{}.proposal", u.policy.serial)));
55
    std::fs::write(&path, u.signed().encode())?;
56
    println!(
57
        "{} policy serial {} needs {} of {} root signatures; has {}. Wrote {} for another root to run {}",
58
        ui::caution(),
59
        u.policy.serial,
60
        u.policy.root_threshold,
61
        u.policy.roots.len(),
62
        u.sigs.len(),
63
        ui::path(path.display()),
64
        ui::dim(format!("oxy policy sign {}", path.display()))
65
    );
66
    Ok(())
67
}
68
69
fn report_rotation(r: &Rotation, verb: &str) -> Result<()> {
70
    println!("{} identity {} <{}>", ui::done(verb), ui::author(&r.identity.name), r.identity.email);
71
    println!("{}", ui::kv("old key", ui::dim(r.old.to_hex())));
72
    println!("{}", ui::kv("new key", r.new.to_hex()));
73
    if !r.identity.delegates.is_empty() {
74
        println!("{}", ui::kv("delegates", r.identity.delegates.iter().map(|d| d.short(12)).collect::<Vec<_>>().join(" ")));
75
    }
76
    if let Some(e) = r.identity.expires {
77
        println!("{}", ui::kv("expires", when(e)));
78
    }
79
    match &r.policy {
80
        Some(u) => report_update(u, None)?,
81
        None => println!("  {} {}", ui::note(), ui::dim("no policy names the old key; export the new identity for policy holders")),
82
    }
83
    Ok(())
84
}
85
86
pub fn create(name: Option<String>, email: Option<String>, local: bool, expires: Option<u64>) -> Result<i32> {
fce44ae9 Andy McCall ↶ 87
    let mut repo = open()?;
88
    let name = name.unwrap_or_else(|| repo.config.author_name.clone());
89
    let email = email.unwrap_or_else(|| repo.config.author_email.clone());
90
    let scope = if local { IdentityScope::Repo } else { IdentityScope::User };
28fc23d7 Andy McCall ↶ 91
    let (key, _) = repo.create_identity_with(&name, &email, scope, expires.and_then(expiry_from_days))?;
94084087 Andy McCall ↶ 92
    println!("{} identity {} <{email}>", ui::done("Created"), ui::author(&name));
93
    println!("{}", ui::kv("key", key));
94
    println!("{}", ui::kv("secret", ui::path(repo::default_keys_dir().display())));
fce44ae9 Andy McCall ↶ 95
    match scope {
94084087 Andy McCall ↶ 96
        IdentityScope::User => println!("{}", ui::kv("scope", format!("default author in {}", ui::path(repo.user_config_path().display())))),
97
        IdentityScope::Repo => println!("{}", ui::kv("scope", "this repository's .oxy/config only")),
fce44ae9 Andy McCall ↶ 98
    }
99
    if !repo.config.sign_changes {
94084087 Andy McCall ↶ 100
        println!("  {} {}", ui::note(), ui::dim("changes will carry this author; run oxy config --user core.sign change to sign each commit"));
fce44ae9 Andy McCall ↶ 101
    }
102
    Ok(0)
103
}
104
105
pub fn show() -> Result<i32> {
106
    let repo = open()?;
107
    let Some(key) = repo.config.author_key else {
108
        println!("No identity configured; run `oxy identity create`.");
109
        return Ok(0);
110
    };
111
    let id = repo.identity(&key)?;
94084087 Andy McCall ↶ 112
    println!("{} {} <{}>", ui::section("identity"), ui::author(&id.name), id.email);
113
    println!("{}", ui::kv("key", key));
114
    println!("{}", ui::kv("created", format!("{} {}", when(id.created), ui::dim(format!("({})", ui::ago(id.created))))));
28fc23d7 Andy McCall ↶ 115
    if let Some(e) = id.expires {
116
        let expired = id.is_expired_at(Hlc::now().wall_ms);
117
        println!("{}", ui::kv("expires", if expired { ui::err(format!("{} EXPIRED", when(e))) } else { when(e) }));
118
    }
119
    if let Some(p) = id.previous {
120
        println!("{}", ui::kv("previous", ui::dim(p.short(12))));
121
    }
122
    if !id.delegates.is_empty() {
123
        println!("{}", ui::kv("delegates", id.delegates.iter().map(|d| d.short(12)).collect::<Vec<_>>().join(" ")));
124
    }
125
    if repo.is_revoked(&key)? {
126
        println!("{}", ui::kv("status", ui::err(format!("{} REVOKED", ui::glyph("✖", "x")))));
127
    }
94084087 Andy McCall ↶ 128
    println!("{}", ui::kv("secret", if repo.secret_key()?.is_some() { ui::ok(format!("{} available", ui::glyph("✔", "*"))) } else { ui::err(format!("{} not found in the key directory", ui::glyph("✖", "x"))) }));
129
    println!("{}", ui::kv("signing", if repo.config.sign_changes { "every change (core.sign = change)" } else { "at push via seals (core.sign = seal)" }));
fce44ae9 Andy McCall ↶ 130
    Ok(0)
131
}
132
133
pub fn verify(change: Option<&str>) -> Result<i32> {
134
    let repo = open()?;
135
    let hashes: Vec<ChangeHash> = match change {
136
        Some(c) => vec![repo.resolve_change(c)?],
137
        None => repo.log()?.into_iter().filter(|e| !e.is_working_copy).map(|e| e.hash).collect(),
138
    };
139
    let mut bad = 0;
140
    for h in hashes {
2863456a Andy McCall ↶ 141
        let Ok(c) = repo.store().get_change(&h) else {
142
            println!("{}  {}", ui::hash(format!("#{}", h.short(12))), ui::dim("not present (shallow)"));
143
            continue;
144
        };
fce44ae9 Andy McCall ↶ 145
        let line = signature_line(&repo, &h)?;
135212de Andy McCall ↶ 146
        let styled = if line.starts_with("INVALID") {
fce44ae9 Andy McCall ↶ 147
            bad += 1;
94084087 Andy McCall ↶ 148
            ui::err(format!("{} {line}", ui::glyph("✖", "x")))
135212de Andy McCall ↶ 149
        } else if line == "unsigned" {
94084087 Andy McCall ↶ 150
            ui::dim(format!("{} {line}", ui::glyph("·", "-")))
135212de Andy McCall ↶ 151
        } else {
94084087 Andy McCall ↶ 152
            ui::ok(format!("{} {line}", ui::glyph("✔", "v")))
135212de Andy McCall ↶ 153
        };
154
        println!("{} {}  {styled}", ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))));
fce44ae9 Andy McCall ↶ 155
    }
156
    Ok(if bad > 0 { 1 } else { 0 })
157
}
158
159
pub fn config(key: &str, value: &str, user: bool) -> Result<i32> {
160
    if user {
161
        let path = repo::default_user_config();
162
        repo::Config::set_user(&path, key, value)?;
94084087 Andy McCall ↶ 163
        println!("{} {key} = {value} {}", ui::done("Set"), ui::dim(format!("(in {})", path.display())));
fce44ae9 Andy McCall ↶ 164
    } else {
165
        let mut repo = open()?;
166
        repo.config.set(key, value)?;
167
        repo.save_config()?;
94084087 Andy McCall ↶ 168
        println!("{} {key} = {value} {}", ui::done("Set"), ui::dim("(in this repository)"));
fce44ae9 Andy McCall ↶ 169
    }
170
    Ok(0)
171
}
83e0677b Andy McCall ↶ 172
173
pub fn export(path: Option<PathBuf>) -> Result<i32> {
174
    let repo = open()?;
175
    let Some(key) = repo.config.author_key else {
176
        anyhow::bail!("no identity configured; run `oxy identity create`");
177
    };
178
    let id = repo.identity(&key)?;
179
    let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.identity", key.short(16))));
180
    std::fs::write(&path, id.encode())?;
94084087 Andy McCall ↶ 181
    println!("{} identity {} <{}> ({}) to {}", ui::done("Wrote"), ui::author(&id.name), id.email, key.short(12), ui::path(path.display()));
83e0677b Andy McCall ↶ 182
    Ok(0)
183
}
184
28fc23d7 Andy McCall ↶ 185
pub fn rotate() -> Result<i32> {
186
    let mut repo = open()?;
187
    let r = repo.rotate_identity()?;
188
    report_rotation(&r, "Rotated")?;
189
    Ok(0)
190
}
191
192
pub fn renew(expires: u64) -> Result<i32> {
193
    let mut repo = open()?;
194
    let r = repo.renew_identity(expiry_from_days(expires))?;
195
    report_rotation(&r, "Renewed")?;
196
    Ok(0)
197
}
198
199
pub fn revoke(key: Option<&str>, reason: &str) -> Result<i32> {
200
    let repo = open()?;
201
    let key = match key {
202
        Some(k) => resolve_key(&repo, k)?,
203
        None => repo.config.author_key.ok_or_else(|| anyhow::anyhow!("no identity configured"))?,
204
    };
205
    let update = repo.revoke_identity(&key, reason)?;
206
    println!("{} key {}{}", ui::done("Revoked"), ui::hash(key.short(12)), if reason.is_empty() { String::new() } else { format!(": {reason}") });
207
    match update {
208
        Some(u) => report_update(&u, None)?,
209
        None => println!("  {} {}", ui::note(), ui::dim("recorded locally; no policy named the key")),
210
    }
211
    if repo.config.author_key == Some(key) {
212
        println!("  {} {}", ui::caution(), ui::warn("this was your configured key; run oxy identity create or rotate"));
213
    }
214
    Ok(0)
215
}
216
217
pub fn delegate_add(identity_file: &Path) -> Result<i32> {
218
    let mut repo = open()?;
219
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
220
    let r = repo.delegate_add(id)?;
221
    report_rotation(&r, "Delegated")?;
222
    Ok(0)
223
}
224
225
pub fn delegate_remove(key: &str) -> Result<i32> {
226
    let mut repo = open()?;
227
    let k = resolve_key(&repo, key)?;
228
    let r = repo.delegate_remove(&k)?;
229
    report_rotation(&r, "Undelegated")?;
230
    Ok(0)
231
}
232
233
pub fn delegate_list() -> Result<i32> {
234
    let repo = open()?;
235
    let Some(key) = repo.config.author_key else {
236
        println!("No identity configured; run `oxy identity create`.");
237
        return Ok(0);
238
    };
239
    let id = repo.identity(&key)?;
240
    if id.delegates.is_empty() {
241
        println!("{}", ui::dim("No delegates."));
242
    }
243
    for d in &id.delegates {
244
        let who = repo.identity(d).map(|i| format!("{} <{}>", i.name, i.email)).unwrap_or_else(|_| "(unknown identity)".into());
245
        println!("  {} {}  {}", ui::ok(ui::glyph("🔑", "*")), ui::hash(d.short(12)), ui::author(who));
246
    }
247
    Ok(0)
248
}
249
250
pub fn policy_root_add(identity_file: &Path, out: Option<PathBuf>) -> Result<i32> {
251
    let repo = open()?;
252
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
253
    let key = id.hash();
254
    let u = repo.policy_root_add(id.clone())?;
255
    println!("{} root {} <{}> ({})", ui::done("Added"), ui::author(&id.name), id.email, key.short(12));
256
    report_update(&u, out)?;
257
    Ok(0)
258
}
259
260
pub fn policy_root_remove(key: &str, out: Option<PathBuf>) -> Result<i32> {
261
    let repo = open()?;
262
    let k = resolve_key(&repo, key)?;
263
    let u = repo.policy_root_remove(&k)?;
264
    println!("{} root {}", ui::done("Removed"), ui::hash(k.short(12)));
265
    report_update(&u, out)?;
266
    Ok(0)
267
}
268
269
pub fn policy_threshold(n: u32, out: Option<PathBuf>) -> Result<i32> {
270
    let repo = open()?;
271
    let u = repo.policy_set_threshold(n)?;
272
    println!("{} root threshold to {n}", ui::done("Set"));
273
    report_update(&u, out)?;
274
    Ok(0)
275
}
276
277
pub fn policy_sign(proposal: &Path, out: Option<PathBuf>) -> Result<i32> {
278
    let repo = open()?;
279
    let sp = SignedPolicy::decode(&std::fs::read(proposal)?)?;
280
    let u = repo.policy_sign(sp)?;
281
    println!("{} policy serial {}", ui::done("Signed"), u.policy.serial);
282
    report_update(&u, Some(out.unwrap_or_else(|| proposal.to_path_buf())))?;
283
    Ok(0)
284
}
285
83e0677b Andy McCall ↶ 286
pub fn policy_init() -> Result<i32> {
287
    let repo = open()?;
288
    let sp = repo.policy_init()?;
28fc23d7 Andy McCall ↶ 289
    println!("{} policy with root key {}", ui::done("Created"), sp.policy.roots[0].short(12));
290
    report_update(&sp, None)?;
83e0677b Andy McCall ↶ 291
    Ok(0)
292
}
293
294
pub fn policy_show() -> Result<i32> {
295
    let repo = open()?;
296
    let Some((r, sp)) = repo.current_policy()? else {
297
        println!("No policy: this repository is unverified. Run `oxy policy init` to create one.");
298
        return Ok(0);
299
    };
300
    let p = &sp.policy;
301
    let name = |k: &oxy_core::hash::KeyId| p.identity(k).map(|i| format!("{} <{}>", i.name, i.email)).unwrap_or_else(|| "(unknown identity)".into());
94084087 Andy McCall ↶ 302
    let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms);
303
    println!("{} {}{}serial {}{}expires {}{}", ui::section("policy"), ui::hash(format!("#{}", r.short(12))), ui::sep(), p.serial, ui::sep(), when(p.expires), if expired { ui::err(format!("  {} EXPIRED", ui::glyph("✖", "x"))) } else { String::new() });
304
    println!("{}", ui::section(format!("roots {}", ui::dim(format!("(threshold {})", p.root_threshold)))));
83e0677b Andy McCall ↶ 305
    for k in &p.roots {
94084087 Andy McCall ↶ 306
        println!("  {} {}  {}", ui::ok(ui::glyph("🔑", "*")), ui::hash(k.short(12)), ui::author(name(k)));
83e0677b Andy McCall ↶ 307
    }
94084087 Andy McCall ↶ 308
    println!("{}", ui::section("rules"));
83e0677b Andy McCall ↶ 309
    for rule in &p.rules {
94084087 Andy McCall ↶ 310
        println!("  {} {:12} {}", ui::ok(ui::glyph("▸", ">")), ui::bookmark(&rule.pattern), ui::dim(format!("threshold {}, require {}, conflicts {}", rule.threshold, rule.require.name(), if rule.allow_conflicts { "allowed" } else { "refused" })));
83e0677b Andy McCall ↶ 311
        for k in &rule.keys {
94084087 Andy McCall ↶ 312
            println!("      {}  {}", ui::hash(k.short(12)), ui::author(name(k)));
83e0677b Andy McCall ↶ 313
        }
314
    }
315
    if !p.revoked.is_empty() {
94084087 Andy McCall ↶ 316
        println!("{}", ui::section("revoked"));
83e0677b Andy McCall ↶ 317
        for k in &p.revoked {
94084087 Andy McCall ↶ 318
            println!("  {} {}  {}", ui::err(ui::glyph("✖", "x")), ui::hash(k.short(12)), ui::author(name(k)));
83e0677b Andy McCall ↶ 319
        }
320
    }
321
    Ok(0)
322
}
323
28fc23d7 Andy McCall ↶ 324
pub fn policy_grant(identity_file: &Path, pattern: Option<&str>, out: Option<PathBuf>) -> Result<i32> {
83e0677b Andy McCall ↶ 325
    let repo = open()?;
326
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
327
    let key = id.hash();
28fc23d7 Andy McCall ↶ 328
    let u = repo.policy_grant(id.clone(), pattern)?;
329
    println!("{} {} <{}> ({}) on {}", ui::done("Granted"), ui::author(&id.name), id.email, key.short(12), ui::bookmark(pattern.unwrap_or("*")));
330
    report_update(&u, out)?;
83e0677b Andy McCall ↶ 331
    Ok(0)
332
}
333
28fc23d7 Andy McCall ↶ 334
pub fn policy_revoke(key: &str, out: Option<PathBuf>) -> Result<i32> {
83e0677b Andy McCall ↶ 335
    let repo = open()?;
28fc23d7 Andy McCall ↶ 336
    if repo.current_policy()?.is_none() {
83e0677b Andy McCall ↶ 337
        anyhow::bail!("this repository has no policy");
28fc23d7 Andy McCall ↶ 338
    }
339
    let full = resolve_key(&repo, key)?;
340
    let u = repo.policy_revoke(&full)?;
341
    println!("{} {}", ui::done("Revoked"), ui::hash(full.short(12)));
342
    report_update(&u, out)?;
83e0677b Andy McCall ↶ 343
    Ok(0)
344
}