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