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