Skip to main content

vta_cli_common/commands/
keys.rs

1use ratatui::{
2    layout::Constraint,
3    style::{Color, Modifier, Style},
4    text::{Line, Span, Text},
5    widgets::{Block, Cell, Row, Table},
6};
7use vta_sdk::prelude::*;
8
9use crate::render::{is_full_display, print_full_entry, print_full_list_title, print_widget};
10
11#[allow(clippy::too_many_arguments)]
12/// Render a key type with the quantum-resistance axis it answers.
13///
14/// Says which axis, always. "post-quantum" alone would invite the reading that
15/// the *identity* is post-quantum, and an identity that signs with ML-DSA while
16/// agreeing keys with X25519 is not — its recorded traffic is still exposed to
17/// harvest-now-decrypt-later. An operator reading a key row should come away
18/// knowing one fact about one key, not a conclusion about a system.
19///
20/// See [`vta_sdk::keys::QuantumPosture`] for why the two axes are never
21/// collapsed.
22fn key_type_with_posture(key_type: &KeyType) -> String {
23    let rendered = serde_json::to_value(key_type)
24        .ok()
25        .and_then(|v| v.as_str().map(str::to_string))
26        .unwrap_or_else(|| format!("{key_type:?}"));
27    format!("{rendered} ({})", key_type.posture().label())
28}
29
30pub async fn cmd_key_create(
31    client: &VtaClient,
32    key_type: &str,
33    derivation_path: Option<String>,
34    mnemonic: Option<String>,
35    label: Option<String>,
36    context_id: Option<String>,
37    internal: bool,
38    yes: bool,
39) -> Result<(), Box<dyn std::error::Error>> {
40    let key_type = match key_type {
41        "ed25519" => KeyType::Ed25519,
42        "x25519" => KeyType::X25519,
43        "p256" => KeyType::P256,
44        // Post-quantum signing (FIPS 204). The VTA has been able to derive
45        // these since the BIP-32 work landed; this match was the only thing
46        // standing between an operator and a PQC key.
47        "mldsa44" => KeyType::MlDsa44,
48        "mldsa65" => KeyType::MlDsa65,
49        other => {
50            return Err(format!(
51                "unknown key type '{other}', expected ed25519, x25519, p256, mldsa44 or mldsa65"
52            )
53            .into());
54        }
55    };
56    // An internal key is the one thing this CLI can create that cannot be
57    // undone by any later command, restored from the mnemonic, or recovered
58    // from a backup. Say so plainly, and require the operator to type the
59    // consequence rather than mash `y` — a habitual yes/no prompt is not
60    // proportionate to a permanent, silent loss of signing ability.
61    if internal {
62        eprintln!();
63        eprintln!("  ⚠  You are about to create a NON-RECOVERABLE internal key.");
64        eprintln!();
65        eprintln!("     • Its material is generated from the system CSPRNG and is NOT");
66        eprintln!("       derived from your BIP-39 seed. Your 24-word mnemonic will NOT");
67        eprintln!("       recover it.");
68        eprintln!("     • It is excluded from `pnm backup export`. A restored VTA will");
69        eprintln!("       NOT have it.");
70        eprintln!("     • It can never be exported — not by you, not by an admin, not");
71        eprintln!("       under internal authority. The VTA will only sign with it.");
72        eprintln!("     • If this VTA's storage is lost, every signature this key was");
73        eprintln!("       the sole authority for becomes unproducible, permanently.");
74        eprintln!();
75        eprintln!("     It CANNOT be used to sign did:webvh log entries, precisely");
76        eprintln!("     because losing it would freeze that DID forever. It CAN be a");
77        eprintln!("     signing verificationMethod inside a DID document.");
78        eprintln!();
79
80        if !yes {
81            eprint!("     Type 'i understand this key cannot be recovered' to continue: ");
82            use std::io::{BufRead, Write};
83            std::io::stderr().flush().ok();
84            let mut line = String::new();
85            std::io::stdin().lock().read_line(&mut line)?;
86            if !line
87                .trim()
88                .eq_ignore_ascii_case("i understand this key cannot be recovered")
89            {
90                return Err("aborted: confirmation phrase not matched".into());
91            }
92        }
93    }
94
95    let mut req = CreateKeyRequest::new(key_type);
96    if internal {
97        req.internal = Some(true);
98    }
99    if let Some(p) = derivation_path {
100        req = req.derivation_path(p);
101    }
102    if let Some(m) = mnemonic {
103        req = req.mnemonic(m);
104    }
105    if let Some(l) = label {
106        req = req.label(l);
107    }
108    if let Some(c) = context_id {
109        req = req.context(c);
110    }
111    let resp = client.create_key(req).await?;
112    println!("Key created:");
113    println!("  Key ID:          {}", resp.key_id);
114    println!(
115        "  Key Type:        {}",
116        key_type_with_posture(&resp.key_type)
117    );
118    println!("  Derivation Path: {}", resp.derivation_path);
119    println!("  Public Key:      {}", resp.public_key);
120    println!("  Status:          {}", resp.status);
121    if resp.origin == vta_sdk::keys::KeyOrigin::Internal {
122        println!();
123        println!("  ⚠  This is an internal key. It cannot be exported, is excluded from");
124        println!("     backups, and cannot be recovered from your mnemonic. If this VTA's");
125        println!("     storage is lost, this key is gone.");
126    }
127    if let Some(label) = &resp.label {
128        println!("  Label:           {label}");
129    }
130    println!(
131        "  Created At:      {}",
132        crate::duration::format_local_datetime(resp.created_at)
133    );
134    Ok(())
135}
136
137pub async fn cmd_key_import(
138    client: &VtaClient,
139    key_type: &str,
140    private_key: Option<String>,
141    private_key_file: Option<std::path::PathBuf>,
142    label: Option<String>,
143    context_id: Option<String>,
144) -> Result<(), Box<dyn std::error::Error>> {
145    let key_type = match key_type {
146        "ed25519" => KeyType::Ed25519,
147        "x25519" => KeyType::X25519,
148        "p256" => KeyType::P256,
149        // Deliberately NOT offered here, though `keys create` accepts them.
150        // The VTA's import path validates a supplied private key against its
151        // algorithm and refuses any type it cannot check, ML-DSA included —
152        // offering it here would take the operator's key material and fail at
153        // the far end. Named explicitly so the refusal reads as "not supported
154        // yet" rather than "no such algorithm", which is what the generic
155        // message would imply.
156        "mldsa44" | "mldsa65" => {
157            return Err(format!(
158                "importing a post-quantum key ('{key_type}') is not supported yet — the VTA \
159                 validates imported key material per algorithm and has no checker for ML-DSA. \
160                 Use `keys create --type {key_type}` to have the VTA derive one instead."
161            )
162            .into());
163        }
164        other => {
165            return Err(
166                format!("unknown key type '{other}', expected ed25519, x25519, or p256").into(),
167            );
168        }
169    };
170
171    // Read private key bytes
172    let private_key_multibase = if let Some(key_str) = private_key {
173        key_str
174    } else if let Some(path) = private_key_file {
175        let bytes = std::fs::read(&path)
176            .map_err(|e| format!("failed to read key file '{}': {e}", path.display()))?;
177        // If file is text (multibase), use as-is; otherwise encode as multibase
178        match String::from_utf8(bytes.clone()) {
179            Ok(s) if s.starts_with('z') || s.starts_with('f') || s.starts_with('u') => {
180                s.trim().to_string()
181            }
182            _ => multibase::encode(multibase::Base::Base58Btc, &bytes),
183        }
184    } else {
185        return Err("either --private-key or --private-key-file is required".into());
186    };
187
188    // Fetch the server's ephemeral wrapping pubkey and seal the private
189    // key via sealed-transfer. The REST `POST /keys/import` handler no
190    // longer accepts `private_key_multibase` (the previous fallback) —
191    // posting raw key material over a TLS-only channel was rejected by
192    // the April 2026 security review (patch #9). If the wrapping-key
193    // fetch fails, surface the error to the operator with the cause
194    // intact rather than silently downgrading to a request the server
195    // would reject as `unknown field`.
196    let wrapping_key = client.get_wrapping_key().await.map_err(|e| {
197        format!(
198            "failed to fetch ephemeral wrapping key from {}/keys/import/wrapping-key: {e} \
199             — the VTA must support sealed-transfer key import (vta-sdk ≥ 0.8); \
200             raw `private_key_multibase` over REST is no longer accepted",
201            client.endpoint_label()
202        )
203    })?;
204    let sealed = seal_private_key(&wrapping_key.x, &key_type, &private_key_multibase).await?;
205
206    let req = ImportKeyRequest {
207        key_type,
208        private_key_sealed: Some(sealed),
209        private_key_jwe: None,
210        private_key_multibase: None,
211        label,
212        context_id,
213    };
214    let resp = client.import_key(req).await?;
215
216    println!("Key imported successfully:");
217    println!("  Key ID:     {}", resp.key_id);
218    println!("  Key Type:   {}", key_type_with_posture(&resp.key_type));
219    println!("  Public Key: {}", resp.public_key);
220    println!("  Status:     {}", resp.status);
221    println!("  Origin:     imported");
222    if let Some(label) = &resp.label {
223        println!("  Label:      {label}");
224    }
225    println!(
226        "  Created At: {}",
227        crate::duration::format_local_datetime(resp.created_at)
228    );
229    eprintln!();
230    eprintln!(
231        "\x1b[1;33mWarning: securely delete the source key material \u{2014} the VTA now holds this secret.\x1b[0m"
232    );
233
234    Ok(())
235}
236
237/// Seal a multibase-encoded private key to the VTA's wrapping pubkey using
238/// HPKE via `vta_sdk::sealed_transfer`. Returns an armored bundle suitable
239/// for the `private_key_sealed` field of `ImportKeyRequest`.
240async fn seal_private_key(
241    vta_pub_b64: &str,
242    key_type: &KeyType,
243    private_key_multibase: &str,
244) -> Result<String, Box<dyn std::error::Error>> {
245    use base64::Engine;
246    use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
247    use vta_sdk::sealed_transfer::{
248        AssertionProof, InMemoryNonceStore, ProducerAssertion, RawPrivateKey, SealedPayloadV1,
249        armor, generate_ed25519_keypair, seal_payload,
250    };
251
252    // JWK `x` is base64url-no-pad; the server encodes with URL_SAFE_NO_PAD
253    // in wrapping.rs.
254    let vta_pub_bytes: [u8; 32] = B64URL
255        .decode(vta_pub_b64)?
256        .try_into()
257        .map_err(|_| "wrapping public key must be 32 bytes")?;
258
259    let (_, key_bytes) = multibase::decode(private_key_multibase)?;
260
261    let payload = SealedPayloadV1::RawPrivateKey(RawPrivateKey {
262        key_type: key_type.to_string(),
263        key_bytes_b64: B64URL.encode(&key_bytes),
264    });
265
266    // Producer identity is irrelevant here — the server trusts the request
267    // because it's authenticated at the request layer, and the sealed bundle
268    // is protected by HPKE bound to the server's wrapping pubkey. The
269    // PinnedOnly assertion is just a placeholder for wire-format uniformity.
270    let (_seed, producer_ed_pub) = generate_ed25519_keypair();
271    let producer = ProducerAssertion {
272        producer_did: affinidi_crypto::did_key::ed25519_pub_to_did_key(&producer_ed_pub),
273        proof: AssertionProof::PinnedOnly,
274    };
275
276    let bundle_id: [u8; 16] = rand::random();
277
278    let nonce_store = InMemoryNonceStore::new();
279    let bundle = seal_payload(&vta_pub_bytes, bundle_id, producer, &payload, &nonce_store).await?;
280    Ok(armor::encode(&bundle))
281}
282
283pub async fn cmd_key_get(
284    client: &VtaClient,
285    key_id: &str,
286    secret: bool,
287) -> Result<(), Box<dyn std::error::Error>> {
288    if secret {
289        let resp = client.get_key_secret(key_id).await?;
290        println!("Key ID:               {}", resp.key_id);
291        println!(
292            "Key Type:             {}",
293            key_type_with_posture(&resp.key_type)
294        );
295        println!("Public Key Multibase: {}", resp.public_key_multibase);
296        println!("Secret Key Multibase: {}", resp.private_key_multibase);
297    } else {
298        let resp = client.get_key(key_id).await?;
299        println!("Key ID:          {}", resp.key_id);
300        println!("Key Type:        {}", key_type_with_posture(&resp.key_type));
301        println!("Derivation Path: {}", resp.derivation_path);
302        println!("Public Key:      {}", resp.public_key);
303        println!("Status:          {}", resp.status);
304        // Printed only when it has been decided. A line reading "Exportable:
305        // yes" on every key would turn "nobody has considered this" into
306        // "somebody allowed it" — the same conflation the record avoids by
307        // storing `None` rather than `Some(true)`.
308        if resp.exportable == Some(false) {
309            println!("Exportable:      no — the private half is never released");
310        }
311        if let Some(label) = &resp.label {
312            println!("Label:           {label}");
313        }
314        println!(
315            "Created At:      {}",
316            crate::duration::format_local_datetime(resp.created_at)
317        );
318        println!(
319            "Updated At:      {}",
320            crate::duration::format_local_datetime(resp.updated_at)
321        );
322    }
323    Ok(())
324}
325
326/// Mark a key as releasable, or refuse every future export of it.
327///
328/// `exportable` is required rather than defaulted and there is no toggle: the
329/// state is absolute, so re-running a command whose output you did not see
330/// lands where you asked rather than undoing it.
331pub async fn cmd_key_set_exportability(
332    client: &VtaClient,
333    key_id: &str,
334    exportable: bool,
335) -> Result<(), Box<dyn std::error::Error>> {
336    let resp = client.set_key_exportability(key_id, exportable).await?;
337    if crate::render::is_json_output() {
338        crate::render::print_json(&resp)?;
339        return Ok(());
340    }
341    if resp.key.exportable == Some(false) {
342        println!("{} is no longer exportable.", resp.key.key_id);
343        println!(
344            "Its private half will not be released to any caller. It can still be used for \
345             signing and key agreement, so anything that asks the VTA to *use* it keeps working."
346        );
347        println!(
348            "Undoing this needs more authority than setting it did — super-admin, or a fresh \
349             step-up on your session."
350        );
351    } else {
352        println!("{} is exportable.", resp.key.key_id);
353        println!("Ordinary release rules apply: a caller entitled to the key can be given it.");
354    }
355    Ok(())
356}
357
358pub async fn cmd_key_revoke(
359    client: &VtaClient,
360    key_id: &str,
361) -> Result<(), Box<dyn std::error::Error>> {
362    let resp = client.invalidate_key(key_id).await?;
363    println!("Key revoked:");
364    println!("  Key ID:     {}", resp.key_id);
365    println!("  Status:     {}", resp.status);
366    println!(
367        "  Updated At: {}",
368        crate::duration::format_local_datetime(resp.updated_at)
369    );
370    Ok(())
371}
372
373pub async fn cmd_key_rename(
374    client: &VtaClient,
375    key_id: &str,
376    new_key_id: &str,
377) -> Result<(), Box<dyn std::error::Error>> {
378    let resp = client.rename_key(key_id, new_key_id).await?;
379    println!("Key renamed:");
380    println!("  Key ID:     {}", resp.key_id);
381    println!(
382        "  Updated At: {}",
383        crate::duration::format_local_datetime(resp.updated_at)
384    );
385    Ok(())
386}
387
388pub async fn cmd_key_list(
389    client: &VtaClient,
390    offset: u64,
391    limit: u64,
392    status: Option<String>,
393    context_id: Option<String>,
394) -> Result<(), Box<dyn std::error::Error>> {
395    let resp = client
396        .list_keys(offset, limit, status.as_deref(), context_id.as_deref())
397        .await?;
398
399    if crate::render::is_json_output() {
400        crate::render::print_json(&resp)?;
401        return Ok(());
402    }
403
404    if resp.keys.is_empty() {
405        println!("No keys found.");
406        return Ok(());
407    }
408
409    let end = (offset + resp.keys.len() as u64).min(resp.total);
410
411    if is_full_display() {
412        print_full_list_title(
413            &format!("Keys (showing {}..{} of {}", offset + 1, end, resp.total),
414            resp.keys.len(),
415        );
416        for key in &resp.keys {
417            let label = key.label.as_deref().unwrap_or("—");
418            let created = key
419                .created_at
420                .with_timezone(&chrono::Local)
421                .format("%Y-%m-%d %H:%M:%S %:z")
422                .to_string();
423            let status = key.status.to_string();
424            let key_type = key.key_type.to_string();
425            print_full_entry(&[
426                ("Key ID", &key.key_id),
427                ("Label", label),
428                ("Type", &key_type),
429                ("Status", &status),
430                ("Derivation", &key.derivation_path),
431                ("Created", &created),
432            ]);
433        }
434        return Ok(());
435    }
436
437    let dim = Style::default().fg(Color::DarkGray);
438    let bold = Style::default()
439        .fg(Color::White)
440        .add_modifier(Modifier::BOLD);
441
442    let rows: Vec<Row> = resp
443        .keys
444        .iter()
445        .map(|key| {
446            let label = key.label.clone().unwrap_or_else(|| "\u{2014}".into());
447            let created = key
448                .created_at
449                .with_timezone(&chrono::Local)
450                .format("%Y-%m-%d")
451                .to_string();
452
453            let status_span = match key.status {
454                vta_sdk::keys::KeyStatus::Active => {
455                    Span::styled(key.status.to_string(), Style::default().fg(Color::Green))
456                }
457                vta_sdk::keys::KeyStatus::Revoked => {
458                    Span::styled(key.status.to_string(), Style::default().fg(Color::Red))
459                }
460            };
461
462            let id_line = Line::from(vec![
463                Span::styled("\u{25b8} ", Style::default().fg(Color::Cyan)),
464                Span::styled(key.key_id.clone(), bold),
465            ]);
466
467            let detail_line = Line::from(vec![
468                Span::raw("  "),
469                Span::styled(label, Style::default().fg(Color::Yellow)),
470                Span::styled("  \u{2502}  ", dim),
471                Span::raw(key.key_type.to_string()),
472                Span::styled("  \u{2502}  ", dim),
473                status_span,
474                Span::styled("  \u{2502}  ", dim),
475                Span::styled(key.derivation_path.clone(), dim),
476                Span::styled("  \u{2502}  ", dim),
477                Span::styled(created, dim),
478            ]);
479
480            Row::new(vec![Cell::from(Text::from(vec![id_line, detail_line]))])
481                .height(2)
482                .bottom_margin(1)
483        })
484        .collect();
485
486    let title = format!(" Keys ({}\u{2013}{} of {}) ", offset + 1, end, resp.total);
487
488    let table = Table::new(rows, [Constraint::Min(1)])
489        .block(Block::bordered().title(title).border_style(dim));
490
491    let height = (resp.keys.len() as u16 * 3).saturating_sub(1) + 2;
492    print_widget(table, height);
493
494    Ok(())
495}
496
497pub async fn cmd_seeds_list(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
498    let resp = client.list_seeds().await?;
499
500    if resp.seeds.is_empty() {
501        println!("No seed records found.");
502        println!("  (pre-rotation state: using external seed store as generation 0)");
503        println!("  Active seed ID: {}", resp.active_seed_id);
504        return Ok(());
505    }
506
507    println!("{} seed generation(s):\n", resp.seeds.len());
508    for seed in &resp.seeds {
509        println!("  Seed ID:     {}", seed.id);
510        println!("  Status:      {}", seed.status);
511        println!(
512            "  Created:     {}",
513            crate::duration::format_local_datetime(seed.created_at)
514        );
515        if let Some(retired_at) = seed.retired_at {
516            println!(
517                "  Retired:     {}",
518                crate::duration::format_local_datetime(retired_at)
519            );
520        }
521        println!();
522    }
523    println!("Active seed ID: {}", resp.active_seed_id);
524
525    Ok(())
526}
527
528pub async fn cmd_seeds_rotate(
529    client: &VtaClient,
530    mnemonic: Option<String>,
531) -> Result<(), Box<dyn std::error::Error>> {
532    let resp = client.rotate_seed(mnemonic).await?;
533
534    println!("Seed rotated successfully.");
535    println!("  Previous seed ID: {} (retired)", resp.previous_seed_id);
536    println!("  New active seed ID: {}", resp.new_seed_id);
537
538    Ok(())
539}
540
541pub async fn cmd_key_bundle(
542    client: &VtaClient,
543    context: &str,
544    recipient: crate::sealed_producer::SealedRecipient,
545) -> Result<(), Box<dyn std::error::Error>> {
546    let bundle = client.fetch_did_secrets_bundle(context).await?;
547    crate::sealed_producer::emit_did_secrets_bundle(bundle, &recipient, context, None).await
548}
549
550pub async fn cmd_key_secrets(
551    client: &VtaClient,
552    key_ids: Vec<String>,
553    context: Option<String>,
554) -> Result<(), Box<dyn std::error::Error>> {
555    let key_ids = if key_ids.is_empty() {
556        let ctx = context.as_deref().ok_or(
557            "provide key IDs as arguments, or use --context to export all active keys in a context",
558        )?;
559        let resp = client
560            .list_keys(0, 10000, Some("active"), Some(ctx))
561            .await?;
562        resp.keys.into_iter().map(|k| k.key_id).collect()
563    } else {
564        key_ids
565    };
566    if key_ids.is_empty() {
567        println!("No active keys found.");
568        return Ok(());
569    }
570    for (i, key_id) in key_ids.iter().enumerate() {
571        if i > 0 {
572            println!();
573        }
574        let resp = client.get_key_secret(key_id).await?;
575        println!("Key ID:               {}", resp.key_id);
576        println!(
577            "Key Type:             {}",
578            key_type_with_posture(&resp.key_type)
579        );
580        println!("Public Key Multibase: {}", resp.public_key_multibase);
581        println!("Secret Key Multibase: {}", resp.private_key_multibase);
582    }
583    Ok(())
584}