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