Skip to main content

cyphr_cli/commands/
key.rs

1//! Key management commands.
2
3use coz::base64ct::Encoding;
4use cyphr_storage::{Genesis, export_commits, load_principal_from_commits};
5use indexmap::IndexMap;
6use serde_json::Value;
7
8use super::common::{
9    current_timestamp, extract_genesis_from_commits, generate_key, load_key_from_keystore,
10    parse_principal_genesis, parse_store,
11};
12use crate::keystore::{JsonKeyStore, KeyStore};
13use crate::{Cli, KeyCommands, OutputFormat};
14
15/// Run a key subcommand.
16pub fn run(cli: &Cli, command: &KeyCommands) -> crate::Result<()> {
17    match command {
18        KeyCommands::Generate { algo, tag } => generate(cli, algo, tag.as_deref()),
19        KeyCommands::Add {
20            identity,
21            key,
22            signer,
23        } => add(cli, identity, key.as_deref(), signer, &cli.authority),
24        KeyCommands::Revoke {
25            identity,
26            key,
27            signer,
28        } => revoke(cli, identity, key, signer, &cli.authority),
29        KeyCommands::List { identity } => list(cli, identity.as_deref()),
30    }
31}
32
33/// Generate a new keypair and store it in the keystore.
34fn generate(cli: &Cli, algo: &str, tag: Option<&str>) -> crate::Result<()> {
35    let mut keystore = JsonKeyStore::open(&cli.keystore)?;
36
37    let (tmb, stored_key, _key) = generate_key(algo, tag)?;
38    keystore.store(&tmb, stored_key)?;
39    keystore.save()?;
40
41    match cli.output {
42        OutputFormat::Json => {
43            let output = serde_json::json!({
44                "tmb": tmb,
45                "alg": algo,
46                "tag": tag,
47            });
48            println!("{}", serde_json::to_string_pretty(&output)?);
49        },
50        OutputFormat::Table => {
51            println!("Generated {algo} key");
52            println!("  tmb: {tmb}");
53            if let Some(t) = tag {
54                println!("  tag: {t}");
55            }
56            println!("  stored: {}", cli.keystore.display());
57        },
58    }
59
60    Ok(())
61}
62
63/// Add a key to an identity.
64fn add(
65    cli: &Cli,
66    identity: &str,
67    key_tmb: Option<&str>,
68    signer_tmb: &str,
69    authority: &str,
70) -> crate::Result<()> {
71    let mut keystore = JsonKeyStore::open(&cli.keystore)?;
72    let store = parse_store(&cli.store)?;
73    let pr = parse_principal_genesis(identity)?;
74
75    // Load current principal state
76    let commits = store.get_commits(&pr).unwrap_or_default();
77
78    // Detect implicit genesis: if identity (PR) is in keystore, it's an implicit genesis identity
79    let is_implicit_genesis = keystore.get(identity).is_ok();
80
81    let mut principal = if commits.is_empty() {
82        // Genesis state - reconstruct from keystore
83        let genesis_key = load_key_from_keystore(&keystore, identity)?;
84        cyphr::Principal::implicit(genesis_key)?
85    } else if is_implicit_genesis {
86        // Implicit genesis with commits: use keystore key as genesis
87        let genesis_key = load_key_from_keystore(&keystore, identity)?;
88        let genesis = Genesis::Implicit(genesis_key);
89        load_principal_from_commits(genesis, &commits)?
90    } else {
91        // Explicit genesis: extract from commits
92        let genesis = extract_genesis_from_commits(&commits, None)?;
93        load_principal_from_commits(genesis, &commits)?
94    };
95
96    // Get or generate the new key
97    let (new_key_tmb, new_key) = match key_tmb {
98        Some(tmb) => {
99            let key = load_key_from_keystore(&keystore, tmb)?;
100            (tmb.to_string(), key)
101        },
102        None => {
103            // Generate new key (use same algorithm as signer)
104            let signer_stored = keystore.get(signer_tmb)?;
105            let (tmb, stored, key) = generate_key(&signer_stored.alg, None)?;
106            keystore.store(&tmb, stored)?;
107            keystore.save()?;
108            (tmb, key)
109        },
110    };
111
112    // Get signer key for signing
113    let signer_stored = keystore.get(signer_tmb)?;
114
115    // Build pay Value for key/create (without commit — finalize_with_commit injects it)
116    let now = current_timestamp();
117    let pre = principal.pr_tagged()?;
118
119    let mut pay_map: IndexMap<String, Value> = IndexMap::new();
120    pay_map.insert("alg".to_string(), Value::String(signer_stored.alg.clone()));
121    pay_map.insert("id".to_string(), Value::String(new_key_tmb.clone()));
122    pay_map.insert("now".to_string(), Value::Number(now.into()));
123    pay_map.insert("pre".to_string(), Value::String(pre));
124    pay_map.insert("tmb".to_string(), Value::String(signer_tmb.to_string()));
125    pay_map.insert(
126        "typ".to_string(),
127        Value::String(format!(
128            "{}/{}",
129            authority,
130            cyphr::parsed_coz::typ::KEY_CREATE
131        )),
132    );
133
134    let pay_value: Value = serde_json::to_value(&pay_map)?;
135
136    // Use CommitScope to atomically compute CS, inject commit field, sign, and finalize.
137    let pay_vec = serde_json::to_vec(&pay_value)?;
138    let (sig_bytes, cad) = coz::sign_json(
139        &pay_vec,
140        &signer_stored.alg,
141        &signer_stored.prv_key,
142        &signer_stored.pub_key,
143    )
144    .ok_or_else(|| crate::Error::Signing("sign_json failed".into()))?;
145    let czd = coz::czd_for_alg(&cad, &sig_bytes, &signer_stored.alg)
146        .ok_or_else(|| crate::Error::Signing("czd_for_alg failed".into()))?;
147    let mut scope = principal.begin_commit();
148    scope.verify_and_apply(&pay_vec, &sig_bytes, czd, Some(new_key.clone()))?;
149    let tmb = coz::Thumbprint::from_bytes(
150        coz::base64ct::Base64UrlUnpadded::decode_vec(signer_tmb)
151            .map_err(|e| crate::Error::Signing(format!("invalid tmb base64: {}", e)))?,
152    );
153    scope.finalize_with_arrow(
154        &signer_stored.alg,
155        &signer_stored.prv_key,
156        &signer_stored.pub_key,
157        &tmb,
158        now,
159        authority,
160    )?;
161
162    // Store updated state
163    let new_commits = export_commits(&principal)?;
164    // Only append new commits (the ones after current)
165    for commit in new_commits.iter().skip(commits.len()) {
166        store.append_commit(&pr, commit)?;
167    }
168
169    match cli.output {
170        OutputFormat::Json => {
171            let output = serde_json::json!({
172                "identity": identity,
173                "added_key": new_key_tmb,
174                "signed_by": signer_tmb,
175            });
176            println!("{}", serde_json::to_string_pretty(&output)?);
177        },
178        OutputFormat::Table => {
179            println!("Added key to identity");
180            println!("  identity: {identity}");
181            println!("  key: {new_key_tmb}");
182            println!("  signed by: {signer_tmb}");
183        },
184    }
185
186    Ok(())
187}
188
189/// Revoke a key from an identity.
190fn revoke(
191    cli: &Cli,
192    identity: &str,
193    key_tmb: &str,
194    signer_tmb: &str,
195    authority: &str,
196) -> crate::Result<()> {
197    let keystore = JsonKeyStore::open(&cli.keystore)?;
198    let store = parse_store(&cli.store)?;
199    let pr = parse_principal_genesis(identity)?;
200
201    // Load current principal state
202    let commits = store.get_commits(&pr).unwrap_or_default();
203
204    // Detect implicit genesis: if identity (PR) is in keystore, it's an implicit genesis identity
205    let is_implicit_genesis = keystore.get(identity).is_ok();
206
207    let mut principal = if commits.is_empty() {
208        // Genesis state - reconstruct from keystore
209        let genesis_key = load_key_from_keystore(&keystore, identity)?;
210        cyphr::Principal::implicit(genesis_key)?
211    } else if is_implicit_genesis {
212        // Implicit genesis with commits: use keystore key as genesis
213        let genesis_key = load_key_from_keystore(&keystore, identity)?;
214        let genesis = Genesis::Implicit(genesis_key);
215        load_principal_from_commits(genesis, &commits)?
216    } else {
217        // Explicit genesis: extract from commits
218        let genesis = extract_genesis_from_commits(&commits, None)?;
219        load_principal_from_commits(genesis, &commits)?
220    };
221
222    // Get signer key for signing
223    let signer_stored = keystore.get(signer_tmb)?;
224
225    // Self-revoke is the only revoke type the protocol supports (SPEC §3.2).
226    // The key being revoked must be the signer itself.
227    if key_tmb != signer_tmb {
228        return Err(crate::Error::InvalidArgument(
229            "key revoke only supports self-revoke: --key must equal --signer".into(),
230        ));
231    }
232
233    // Build pay Value for key/revoke. `id` MUST be absent for self-revoke;
234    // its presence is rejected at parse time.
235    let now = current_timestamp();
236    let pre = principal.pr_tagged()?;
237
238    let mut pay_map: IndexMap<String, Value> = IndexMap::new();
239    pay_map.insert("alg".to_string(), Value::String(signer_stored.alg.clone()));
240    pay_map.insert("now".to_string(), Value::Number(now.into()));
241    pay_map.insert("pre".to_string(), Value::String(pre));
242    pay_map.insert("rvk".to_string(), Value::Number(now.into()));
243    pay_map.insert("tmb".to_string(), Value::String(signer_tmb.to_string()));
244    pay_map.insert(
245        "typ".to_string(),
246        Value::String(format!(
247            "{}/{}",
248            authority,
249            cyphr::parsed_coz::typ::KEY_REVOKE
250        )),
251    );
252
253    let pay_value: Value = serde_json::to_value(&pay_map)?;
254
255    // Use CommitScope to atomically compute CS, inject commit field, sign, and finalize.
256    let pay_vec = serde_json::to_vec(&pay_value)?;
257    let (sig_bytes, cad) = coz::sign_json(
258        &pay_vec,
259        &signer_stored.alg,
260        &signer_stored.prv_key,
261        &signer_stored.pub_key,
262    )
263    .ok_or_else(|| crate::Error::Signing("sign_json failed".into()))?;
264    let czd = coz::czd_for_alg(&cad, &sig_bytes, &signer_stored.alg)
265        .ok_or_else(|| crate::Error::Signing("czd_for_alg failed".into()))?;
266    let mut scope = principal.begin_commit();
267    scope.verify_and_apply(&pay_vec, &sig_bytes, czd, None)?;
268    let tmb = coz::Thumbprint::from_bytes(
269        coz::base64ct::Base64UrlUnpadded::decode_vec(signer_tmb)
270            .map_err(|e| crate::Error::Signing(format!("invalid tmb base64: {}", e)))?,
271    );
272    scope.finalize_with_arrow(
273        &signer_stored.alg,
274        &signer_stored.prv_key,
275        &signer_stored.pub_key,
276        &tmb,
277        now,
278        authority,
279    )?;
280
281    // Store updated state
282    let new_commits = export_commits(&principal)?;
283    for commit in new_commits.iter().skip(commits.len()) {
284        store.append_commit(&pr, commit)?;
285    }
286
287    match cli.output {
288        OutputFormat::Json => {
289            let output = serde_json::json!({
290                "identity": identity,
291                "revoked_key": key_tmb,
292                "signed_by": signer_tmb,
293            });
294            println!("{}", serde_json::to_string_pretty(&output)?);
295        },
296        OutputFormat::Table => {
297            println!("Revoked key from identity");
298            println!("  identity: {identity}");
299            println!("  key: {key_tmb}");
300            println!("  signed by: {signer_tmb}");
301        },
302    }
303
304    Ok(())
305}
306
307/// List keys - from keystore if identity is None, from identity if provided.
308fn list(cli: &Cli, identity: Option<&str>) -> crate::Result<()> {
309    match identity {
310        None => list_keystore(cli),
311        Some(pr) => list_identity(cli, pr),
312    }
313}
314
315/// List all keys in the keystore.
316fn list_keystore(cli: &Cli) -> crate::Result<()> {
317    let keystore = JsonKeyStore::open(&cli.keystore)?;
318    let thumbprints = keystore.list();
319
320    if thumbprints.is_empty() {
321        match cli.output {
322            OutputFormat::Json => println!("[]"),
323            OutputFormat::Table => println!("No keys in keystore"),
324        }
325        return Ok(());
326    }
327
328    match cli.output {
329        OutputFormat::Json => {
330            let mut keys = Vec::new();
331            for tmb in &thumbprints {
332                let key = keystore.get(tmb)?;
333                keys.push(serde_json::json!({
334                    "tmb": tmb,
335                    "alg": key.alg,
336                    "tag": key.tag,
337                }));
338            }
339            println!("{}", serde_json::to_string_pretty(&keys)?);
340        },
341        OutputFormat::Table => {
342            println!("Keys in keystore:");
343            for tmb in thumbprints {
344                let key = keystore.get(tmb)?;
345                let tag_str = key.tag.as_deref().unwrap_or("-");
346                println!("  {} ({}) [{}]", tmb, key.alg, tag_str);
347            }
348        },
349    }
350
351    Ok(())
352}
353
354/// List keys for an identity.
355fn list_identity(cli: &Cli, identity: &str) -> crate::Result<()> {
356    let keystore = JsonKeyStore::open(&cli.keystore)?;
357    let store = parse_store(&cli.store)?;
358    let pr = parse_principal_genesis(identity)?;
359
360    let commits = store.get_commits(&pr).unwrap_or_default();
361
362    // Detect implicit genesis: if identity (PR) is in keystore, it's an implicit genesis identity
363    let is_implicit_genesis = keystore.get(identity).is_ok();
364
365    let principal = if commits.is_empty() {
366        let genesis_key = load_key_from_keystore(&keystore, identity)?;
367        cyphr::Principal::implicit(genesis_key)?
368    } else if is_implicit_genesis {
369        let genesis_key = load_key_from_keystore(&keystore, identity)?;
370        let genesis = Genesis::Implicit(genesis_key);
371        load_principal_from_commits(genesis, &commits)?
372    } else {
373        let genesis = extract_genesis_from_commits(&commits, None)?;
374        load_principal_from_commits(genesis, &commits)?
375    };
376
377    let active: Vec<_> = principal.active_keys().collect();
378
379    match cli.output {
380        OutputFormat::Json => {
381            let keys: Vec<_> = active
382                .iter()
383                .map(|k| {
384                    serde_json::json!({
385                        "tmb": k.tmb.to_b64(),
386                        "alg": k.alg,
387                        "tag": k.tag,
388                    })
389                })
390                .collect();
391            let output = serde_json::json!({
392                "identity": identity,
393                "active_keys": keys,
394            });
395            println!("{}", serde_json::to_string_pretty(&output)?);
396        },
397        OutputFormat::Table => {
398            println!("Active keys for {identity}:");
399            for key in active {
400                let tag_str = key.tag.as_deref().unwrap_or("-");
401                println!("  {} ({}) [{}]", key.tmb.to_b64(), key.alg, tag_str);
402            }
403        },
404    }
405
406    Ok(())
407}