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 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 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 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 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
195async 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 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 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 resp.exportable == Some(false) {
264 println!("Exportable: no — the private half is never released");
265 }
266 if let Some(label) = &resp.label {
267 println!("Label: {label}");
268 }
269 println!(
270 "Created At: {}",
271 crate::duration::format_local_datetime(resp.created_at)
272 );
273 println!(
274 "Updated At: {}",
275 crate::duration::format_local_datetime(resp.updated_at)
276 );
277 }
278 Ok(())
279}
280
281pub async fn cmd_key_set_exportability(
287 client: &VtaClient,
288 key_id: &str,
289 exportable: bool,
290) -> Result<(), Box<dyn std::error::Error>> {
291 let resp = client.set_key_exportability(key_id, exportable).await?;
292 if crate::render::is_json_output() {
293 crate::render::print_json(&resp)?;
294 return Ok(());
295 }
296 if resp.key.exportable == Some(false) {
297 println!("{} is no longer exportable.", resp.key.key_id);
298 println!(
299 "Its private half will not be released to any caller. It can still be used for \
300 signing and key agreement, so anything that asks the VTA to *use* it keeps working."
301 );
302 println!(
303 "Undoing this needs more authority than setting it did — super-admin, or a fresh \
304 step-up on your session."
305 );
306 } else {
307 println!("{} is exportable.", resp.key.key_id);
308 println!("Ordinary release rules apply: a caller entitled to the key can be given it.");
309 }
310 Ok(())
311}
312
313pub async fn cmd_key_revoke(
314 client: &VtaClient,
315 key_id: &str,
316) -> Result<(), Box<dyn std::error::Error>> {
317 let resp = client.invalidate_key(key_id).await?;
318 println!("Key revoked:");
319 println!(" Key ID: {}", resp.key_id);
320 println!(" Status: {}", resp.status);
321 println!(
322 " Updated At: {}",
323 crate::duration::format_local_datetime(resp.updated_at)
324 );
325 Ok(())
326}
327
328pub async fn cmd_key_rename(
329 client: &VtaClient,
330 key_id: &str,
331 new_key_id: &str,
332) -> Result<(), Box<dyn std::error::Error>> {
333 let resp = client.rename_key(key_id, new_key_id).await?;
334 println!("Key renamed:");
335 println!(" Key ID: {}", resp.key_id);
336 println!(
337 " Updated At: {}",
338 crate::duration::format_local_datetime(resp.updated_at)
339 );
340 Ok(())
341}
342
343pub async fn cmd_key_list(
344 client: &VtaClient,
345 offset: u64,
346 limit: u64,
347 status: Option<String>,
348 context_id: Option<String>,
349) -> Result<(), Box<dyn std::error::Error>> {
350 let resp = client
351 .list_keys(offset, limit, status.as_deref(), context_id.as_deref())
352 .await?;
353
354 if crate::render::is_json_output() {
355 crate::render::print_json(&resp)?;
356 return Ok(());
357 }
358
359 if resp.keys.is_empty() {
360 println!("No keys found.");
361 return Ok(());
362 }
363
364 let end = (offset + resp.keys.len() as u64).min(resp.total);
365
366 if is_full_display() {
367 print_full_list_title(
368 &format!("Keys (showing {}..{} of {}", offset + 1, end, resp.total),
369 resp.keys.len(),
370 );
371 for key in &resp.keys {
372 let label = key.label.as_deref().unwrap_or("—");
373 let created = key
374 .created_at
375 .with_timezone(&chrono::Local)
376 .format("%Y-%m-%d %H:%M:%S %:z")
377 .to_string();
378 let status = key.status.to_string();
379 let key_type = key.key_type.to_string();
380 print_full_entry(&[
381 ("Key ID", &key.key_id),
382 ("Label", label),
383 ("Type", &key_type),
384 ("Status", &status),
385 ("Derivation", &key.derivation_path),
386 ("Created", &created),
387 ]);
388 }
389 return Ok(());
390 }
391
392 let dim = Style::default().fg(Color::DarkGray);
393 let bold = Style::default()
394 .fg(Color::White)
395 .add_modifier(Modifier::BOLD);
396
397 let rows: Vec<Row> = resp
398 .keys
399 .iter()
400 .map(|key| {
401 let label = key.label.clone().unwrap_or_else(|| "\u{2014}".into());
402 let created = key
403 .created_at
404 .with_timezone(&chrono::Local)
405 .format("%Y-%m-%d")
406 .to_string();
407
408 let status_span = match key.status {
409 vta_sdk::keys::KeyStatus::Active => {
410 Span::styled(key.status.to_string(), Style::default().fg(Color::Green))
411 }
412 vta_sdk::keys::KeyStatus::Revoked => {
413 Span::styled(key.status.to_string(), Style::default().fg(Color::Red))
414 }
415 };
416
417 let id_line = Line::from(vec![
418 Span::styled("\u{25b8} ", Style::default().fg(Color::Cyan)),
419 Span::styled(key.key_id.clone(), bold),
420 ]);
421
422 let detail_line = Line::from(vec![
423 Span::raw(" "),
424 Span::styled(label, Style::default().fg(Color::Yellow)),
425 Span::styled(" \u{2502} ", dim),
426 Span::raw(key.key_type.to_string()),
427 Span::styled(" \u{2502} ", dim),
428 status_span,
429 Span::styled(" \u{2502} ", dim),
430 Span::styled(key.derivation_path.clone(), dim),
431 Span::styled(" \u{2502} ", dim),
432 Span::styled(created, dim),
433 ]);
434
435 Row::new(vec![Cell::from(Text::from(vec![id_line, detail_line]))])
436 .height(2)
437 .bottom_margin(1)
438 })
439 .collect();
440
441 let title = format!(" Keys ({}\u{2013}{} of {}) ", offset + 1, end, resp.total);
442
443 let table = Table::new(rows, [Constraint::Min(1)])
444 .block(Block::bordered().title(title).border_style(dim));
445
446 let height = (resp.keys.len() as u16 * 3).saturating_sub(1) + 2;
447 print_widget(table, height);
448
449 Ok(())
450}
451
452pub async fn cmd_seeds_list(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
453 let resp = client.list_seeds().await?;
454
455 if resp.seeds.is_empty() {
456 println!("No seed records found.");
457 println!(" (pre-rotation state: using external seed store as generation 0)");
458 println!(" Active seed ID: {}", resp.active_seed_id);
459 return Ok(());
460 }
461
462 println!("{} seed generation(s):\n", resp.seeds.len());
463 for seed in &resp.seeds {
464 println!(" Seed ID: {}", seed.id);
465 println!(" Status: {}", seed.status);
466 println!(
467 " Created: {}",
468 crate::duration::format_local_datetime(seed.created_at)
469 );
470 if let Some(retired_at) = seed.retired_at {
471 println!(
472 " Retired: {}",
473 crate::duration::format_local_datetime(retired_at)
474 );
475 }
476 println!();
477 }
478 println!("Active seed ID: {}", resp.active_seed_id);
479
480 Ok(())
481}
482
483pub async fn cmd_seeds_rotate(
484 client: &VtaClient,
485 mnemonic: Option<String>,
486) -> Result<(), Box<dyn std::error::Error>> {
487 let resp = client.rotate_seed(mnemonic).await?;
488
489 println!("Seed rotated successfully.");
490 println!(" Previous seed ID: {} (retired)", resp.previous_seed_id);
491 println!(" New active seed ID: {}", resp.new_seed_id);
492
493 Ok(())
494}
495
496pub async fn cmd_key_bundle(
497 client: &VtaClient,
498 context: &str,
499 recipient: crate::sealed_producer::SealedRecipient,
500) -> Result<(), Box<dyn std::error::Error>> {
501 let bundle = client.fetch_did_secrets_bundle(context).await?;
502 crate::sealed_producer::emit_did_secrets_bundle(bundle, &recipient, context, None).await
503}
504
505pub async fn cmd_key_secrets(
506 client: &VtaClient,
507 key_ids: Vec<String>,
508 context: Option<String>,
509) -> Result<(), Box<dyn std::error::Error>> {
510 let key_ids = if key_ids.is_empty() {
511 let ctx = context.as_deref().ok_or(
512 "provide key IDs as arguments, or use --context to export all active keys in a context",
513 )?;
514 let resp = client
515 .list_keys(0, 10000, Some("active"), Some(ctx))
516 .await?;
517 resp.keys.into_iter().map(|k| k.key_id).collect()
518 } else {
519 key_ids
520 };
521 if key_ids.is_empty() {
522 println!("No active keys found.");
523 return Ok(());
524 }
525 for (i, key_id) in key_ids.iter().enumerate() {
526 if i > 0 {
527 println!();
528 }
529 let resp = client.get_key_secret(key_id).await?;
530 println!("Key ID: {}", resp.key_id);
531 println!("Key Type: {}", resp.key_type);
532 println!("Public Key Multibase: {}", resp.public_key_multibase);
533 println!("Secret Key Multibase: {}", resp.private_key_multibase);
534 }
535 Ok(())
536}