Skip to main content

basil_core/
bundle_cli.rs

1//! Top-level `basil bundle` sealed-bundle management.
2//!
3//! This is the pre-release replacement for the old `basil config bundle`
4//! scaffolding surface. The command parser uses structured repeatable
5//! `--slot TYPE[:field=value,...]` and `--backend id=NAME,type=TYPE,...`
6//! values so each source is self-contained and unambiguous.
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::ffi::OsString;
10use std::path::{Path, PathBuf};
11use std::str::FromStr;
12
13use crate::seal::{self, BackendCred, CredBundle, MethodRegistry, SlotSpec, UnlockMethod, format};
14use anyhow::{Context, Result, bail};
15use clap::Subcommand;
16use serde::Deserialize;
17use zero_secrets::{SecretArray, SecretString};
18use zeroize::Zeroizing;
19
20#[cfg(feature = "unlock-age-yubikey")]
21use crate::seal::AgeYubikeyMethod;
22#[cfg(feature = "unlock-bip39")]
23use crate::seal::Bip39Method;
24use crate::seal::PassphraseMethod;
25
26/// `bundle` subcommands.
27#[derive(Debug, Subcommand)]
28pub enum BundleCommand {
29    /// Create a new sealed bundle.
30    Create(CreateArgs),
31    /// Add one unlock slot to an existing bundle.
32    AddSlot(AddSlotArgs),
33    /// Set or replace one backend credential in the sealed payload.
34    SetBackend(SetBackendArgs),
35    /// Append one signed credential deposit without opening the bundle.
36    Deposit(DepositArgs),
37    /// Allow a contributor signing key to deposit selected backend ids.
38    Allow(AllowArgs),
39    /// Review or fold authorized deposits into the sealed payload.
40    Promote(PromoteArgs),
41    /// Export or create the bundle's public deposit recipient.
42    DepositKey(DepositKeyArgs),
43    /// Check that an unlock method opens a bundle without mutating it.
44    Verify(VerifyArgs),
45    /// Show non-secret bundle metadata.
46    Show(ShowArgs),
47}
48
49/// `bundle create` arguments.
50#[derive(Debug, clap::Args)]
51pub struct CreateArgs {
52    /// Where to write the new `0600` bundle.
53    bundle: PathBuf,
54
55    /// Load `[[slot]]` and `[[backend]]` tables from this `TOML` manifest.
56    #[arg(long = "from", value_name = "FILE")]
57    from: Option<PathBuf>,
58
59    /// Add an unlock slot: `TYPE[:field=value,...]`.
60    #[arg(long, value_name = "SPEC")]
61    slot: Vec<SlotArg>,
62
63    /// Seed one backend credential: `id=NAME,type=TYPE,<fields>`.
64    #[arg(long, value_name = "SPEC")]
65    backend: Vec<BackendArg>,
66
67    /// Write the public deposit recipient token to this file.
68    #[arg(long = "deposit-key", value_name = "OUT")]
69    deposit_key: Option<PathBuf>,
70}
71
72/// `bundle add-slot` arguments.
73#[derive(Debug, clap::Args)]
74pub struct AddSlotArgs {
75    /// Bundle file to update in place.
76    bundle: PathBuf,
77
78    /// New unlock slot: `TYPE[:field=value,...]`.
79    #[arg(long, value_name = "SPEC")]
80    slot: SlotArg,
81
82    /// Existing unlock method: `TYPE[:field=value,...]`.
83    #[arg(long = "open", value_name = "METHOD", required = true)]
84    open: Vec<OpenArg>,
85}
86
87/// `bundle set-backend` arguments.
88#[derive(Debug, clap::Args)]
89pub struct SetBackendArgs {
90    /// Bundle file to update in place.
91    bundle: PathBuf,
92
93    /// Backend credential: `id=NAME,type=TYPE,<fields>`.
94    #[arg(long, value_name = "SPEC")]
95    backend: BackendArg,
96
97    /// Existing unlock method: `TYPE[:field=value,...]`.
98    #[arg(long = "open", value_name = "METHOD", required = true)]
99    open: Vec<OpenArg>,
100}
101
102/// `bundle deposit` arguments.
103#[derive(Debug, clap::Args)]
104pub struct DepositArgs {
105    /// Bundle file to append to.
106    bundle: PathBuf,
107
108    /// Backend credential: `id=NAME,type=TYPE,<fields>`.
109    #[arg(long, value_name = "SPEC")]
110    backend: BackendArg,
111
112    /// File containing the public deposit recipient token.
113    #[arg(short = 'r', long = "recipient", value_name = "FILE")]
114    recipient: PathBuf,
115
116    /// `0600` file containing a raw 32-byte Ed25519 signing seed.
117    #[arg(short = 'i', long = "identity", value_name = "FILE")]
118    identity: PathBuf,
119
120    /// Contributor id recorded in the bundle allow-list. Defaults to the
121    /// signing public-key token.
122    #[arg(long = "contributor-id", value_name = "ID")]
123    contributor_id: Option<String>,
124
125    /// Explicit sequence number. Defaults to max existing sequence for this
126    /// contributor/backend plus one.
127    #[arg(long)]
128    seq: Option<u64>,
129}
130
131/// `bundle allow` arguments.
132#[derive(Debug, clap::Args)]
133pub struct AllowArgs {
134    /// Bundle file to update in place.
135    bundle: PathBuf,
136
137    /// Contributor Ed25519 public key token.
138    #[arg(long, value_name = "PUB")]
139    contributor: String,
140
141    /// Contributor id stored in the sealed allow-list. Defaults to `--contributor`.
142    #[arg(long = "contributor-id", value_name = "ID")]
143    contributor_id: Option<String>,
144
145    /// Backend id this contributor may deposit. Repeat for multiple ids.
146    #[arg(long = "backend", value_name = "ID")]
147    backend: Vec<String>,
148
149    /// Existing unlock method: `TYPE[:field=value,...]`.
150    #[arg(long = "open", value_name = "METHOD", required = true)]
151    open: Vec<OpenArg>,
152}
153
154/// `bundle promote` arguments.
155#[derive(Debug, clap::Args)]
156pub struct PromoteArgs {
157    /// Bundle file to review or update in place.
158    bundle: PathBuf,
159
160    /// Review without mutating the bundle.
161    #[arg(long)]
162    dry_run: bool,
163
164    /// Promote only these backend ids. Empty promotes every effective deposit.
165    #[arg(long = "backend", value_name = "ID")]
166    backend: Vec<String>,
167
168    /// Promote only these contributor ids. Empty promotes every effective deposit.
169    #[arg(long = "contributor", value_name = "ID")]
170    contributor: Vec<String>,
171
172    /// Existing unlock method: `TYPE[:field=value,...]`.
173    #[arg(long = "open", value_name = "METHOD", required = true)]
174    open: Vec<OpenArg>,
175}
176
177/// `bundle deposit-key` arguments.
178#[derive(Debug, clap::Args)]
179pub struct DepositKeyArgs {
180    /// Bundle file to inspect or update.
181    bundle: PathBuf,
182
183    /// Write the public deposit recipient token to this file.
184    #[arg(long, value_name = "OUT")]
185    out: PathBuf,
186
187    /// Existing unlock method.
188    #[arg(long = "open", value_name = "METHOD", required = true)]
189    open: Vec<OpenArg>,
190}
191
192/// `bundle verify` arguments.
193#[derive(Debug, clap::Args)]
194pub struct VerifyArgs {
195    /// Bundle file to check.
196    bundle: PathBuf,
197
198    /// Existing unlock method: `TYPE[:field=value,...]`.
199    #[arg(long = "open", value_name = "METHOD", required = true)]
200    open: Vec<OpenArg>,
201}
202
203/// `bundle show` arguments.
204#[derive(Debug, clap::Args)]
205pub struct ShowArgs {
206    /// Bundle file to inspect.
207    bundle: PathBuf,
208
209    /// Existing unlock method. When supplied, backend ids and credential kinds
210    /// are shown; secret values are never printed.
211    #[arg(long = "open", value_name = "METHOD")]
212    open: Vec<OpenArg>,
213}
214
215/// Structured `--slot` value.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct SlotArg {
218    kind: SlotKind,
219    fields: BTreeMap<String, String>,
220}
221
222impl FromStr for SlotArg {
223    type Err = String;
224
225    fn from_str(raw: &str) -> std::result::Result<Self, Self::Err> {
226        parse_slot_arg(raw).map_err(|e| e.to_string())
227    }
228}
229
230/// Structured `--open` value.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct OpenArg(SlotArg);
233
234impl FromStr for OpenArg {
235    type Err = String;
236
237    fn from_str(raw: &str) -> std::result::Result<Self, Self::Err> {
238        parse_slot_arg(raw).map(OpenArg).map_err(|e| e.to_string())
239    }
240}
241
242/// Structured `--backend` value.
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct BackendArg {
245    id: String,
246    kind: BackendKind,
247    fields: BTreeMap<String, String>,
248}
249
250impl FromStr for BackendArg {
251    type Err = String;
252
253    fn from_str(raw: &str) -> std::result::Result<Self, Self::Err> {
254        parse_backend_arg(raw).map_err(|e| e.to_string())
255    }
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259enum SlotKind {
260    AgeYubikey,
261    Bip39,
262    Passphrase,
263    Tpm,
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267enum BackendKind {
268    AwsKms,
269    DbKeystore,
270    GcpKms,
271    OnePassword,
272    OpenBao,
273    Vault,
274}
275
276/// Dispatch a `bundle` subcommand.
277pub fn run(cmd: BundleCommand) -> Result<()> {
278    match cmd {
279        BundleCommand::Create(args) => create(&args),
280        BundleCommand::AddSlot(args) => add_slot(&args),
281        BundleCommand::SetBackend(args) => set_backend(&args),
282        BundleCommand::Deposit(args) => deposit(&args),
283        BundleCommand::Allow(args) => allow(&args),
284        BundleCommand::Promote(args) => promote(&args),
285        BundleCommand::DepositKey(args) => deposit_key(&args),
286        BundleCommand::Verify(args) => verify(&args),
287        BundleCommand::Show(args) => show(&args),
288    }
289}
290
291fn create(args: &CreateArgs) -> Result<()> {
292    let source = create_source(args)?;
293    if source.slots.is_empty() {
294        bail!("create requires at least one --slot or [[slot]] manifest table");
295    }
296    let slot_methods = slot_methods(&source.slots, SlotUse::Create)?;
297    let specs = slot_methods.specs();
298    let mut creds = cred_bundle_from_backend_args(&source.backends)?;
299    if args.deposit_key.is_some() {
300        creds.ensure_deposit_identity();
301    }
302    let file = seal::seal(&creds, &specs).context("sealing new bundle")?;
303    let parsed = format::decode(&file).context("parsing sealed bundle after create")?;
304    write_0600(&args.bundle, &file)?;
305    seal::write_epoch_sidecar(&epoch_sidecar_path(&args.bundle), parsed.body.header.epoch)
306        .context("writing epoch sidecar")?;
307    if let Some(path) = &args.deposit_key {
308        let recipient = creds
309            .deposit_recipient()
310            .ok_or_else(|| anyhow::anyhow!("deposit identity was not generated"))?;
311        write_public_token(path, &seal::public_key_token(&recipient))?;
312    }
313    print_generated_phrases(&slot_methods.generated_phrases);
314    println!("wrote sealed bundle to {}", args.bundle.display());
315    Ok(())
316}
317
318fn add_slot(args: &AddSlotArgs) -> Result<()> {
319    let bytes = read_bundle(&args.bundle)?;
320    let parsed = format::decode(&bytes).context("parsing bundle")?;
321    let open_methods = open_methods(&args.open)?;
322    let registry = registry_from_methods(&open_methods.methods);
323    let new_slot = slot_methods(std::slice::from_ref(&args.slot), SlotUse::Create)?;
324    let specs = new_slot.specs();
325    let Some(spec) = specs.first() else {
326        bail!("add-slot requires one --slot");
327    };
328    let new_file = seal::add_slot(&parsed, &registry, spec).context("adding bundle slot")?;
329    write_0600(&args.bundle, &new_file)?;
330    seal::write_epoch_sidecar(&epoch_sidecar_path(&args.bundle), parsed.body.header.epoch)
331        .context("writing epoch sidecar")?;
332    print_generated_phrases(&new_slot.generated_phrases);
333    println!("added slot to {}", args.bundle.display());
334    Ok(())
335}
336
337fn set_backend(args: &SetBackendArgs) -> Result<()> {
338    let bytes = read_bundle(&args.bundle)?;
339    let parsed = format::decode(&bytes).context("parsing bundle")?;
340    let open_methods = open_methods(&args.open)?;
341    let registry = registry_from_methods(&open_methods.methods);
342    let mut creds =
343        seal::open_bundle(&parsed, &registry).context("opening bundle to update backend")?;
344    let (backend_id, cred) = backend_cred(&args.backend)?;
345    creds.set(backend_id.clone(), cred);
346    let new_file =
347        seal::reseal_payload(&parsed, &registry, &creds).context("re-sealing bundle payload")?;
348    let new_parsed = format::decode(&new_file).context("parsing updated sealed bundle")?;
349    drop(creds);
350    write_0600(&args.bundle, &new_file)?;
351    seal::write_epoch_sidecar(
352        &epoch_sidecar_path(&args.bundle),
353        new_parsed.body.header.epoch,
354    )
355    .context("writing epoch sidecar")?;
356    println!(
357        "updated backend `{}` in {}",
358        backend_id,
359        args.bundle.display()
360    );
361    Ok(())
362}
363
364fn deposit(args: &DepositArgs) -> Result<()> {
365    let bytes = read_bundle(&args.bundle)?;
366    let mut parsed = format::decode(&bytes).context("parsing bundle")?;
367    let recipient = read_public_key_token(&args.recipient)?;
368    let signing_seed = read_seed_0600(&args.identity)?;
369    let contributor_key_id = args
370        .contributor_id
371        .clone()
372        .unwrap_or_else(|| seal::contributor_public_token(&signing_seed));
373    let (backend_id, cred) = backend_cred(&args.backend)?;
374    let seq = args.seq.unwrap_or_else(|| {
375        next_deposit_seq(&parsed.body.deposits, &contributor_key_id, &backend_id)
376    });
377    let record = seal::create_signed_record(
378        &parsed.body.header,
379        backend_id.clone(),
380        contributor_key_id.clone(),
381        seq,
382        &recipient,
383        &signing_seed,
384        &cred,
385    )
386    .context("creating signed credential deposit")?;
387    parsed.body.deposits.push(record);
388    let header_aad = parsed.header_aad().to_vec();
389    let file = format::encode_with_deposits(
390        &parsed.body.header,
391        &header_aad,
392        parsed.body.slots,
393        parsed.body.payload,
394        parsed.body.deposits,
395    )
396    .context("encoding bundle with deposit")?;
397    write_0600(&args.bundle, &file)?;
398    println!("deposited backend `{backend_id}` as contributor `{contributor_key_id}` seq {seq}");
399    Ok(())
400}
401
402fn allow(args: &AllowArgs) -> Result<()> {
403    if args.backend.is_empty() {
404        bail!("allow requires at least one --backend ID");
405    }
406    let bytes = read_bundle(&args.bundle)?;
407    let parsed = format::decode(&bytes).context("parsing bundle")?;
408    let open_methods = open_methods(&args.open)?;
409    let registry = registry_from_methods(&open_methods.methods);
410    let mut creds =
411        seal::open_bundle(&parsed, &registry).context("opening bundle to allow contributor")?;
412    creds.ensure_deposit_identity();
413    let contributor_id = args
414        .contributor_id
415        .clone()
416        .unwrap_or_else(|| args.contributor.clone());
417    let public = seal::public_key_from_token(&args.contributor)
418        .context("validating contributor public key")?;
419    let public_key = seal::public_key_token(&public);
420    let allowed_backend_ids = args.backend.iter().cloned().collect::<BTreeSet<_>>();
421    creds.deposit.contributors.insert(
422        contributor_id.clone(),
423        seal::cred::DepositContributor {
424            public_key,
425            allowed_backend_ids,
426        },
427    );
428    let new_file =
429        seal::reseal_payload(&parsed, &registry, &creds).context("re-sealing allow-list")?;
430    write_0600(&args.bundle, &new_file)?;
431    println!("allowed contributor `{contributor_id}`");
432    Ok(())
433}
434
435fn promote(args: &PromoteArgs) -> Result<()> {
436    let bytes = read_bundle(&args.bundle)?;
437    let parsed = format::decode(&bytes).context("parsing bundle")?;
438    let open_methods = open_methods(&args.open)?;
439    let registry = registry_from_methods(&open_methods.methods);
440    let backend_filter = args.backend.iter().cloned().collect::<BTreeSet<_>>();
441    let contributor_filter = args.contributor.iter().cloned().collect::<BTreeSet<_>>();
442    if args.dry_run {
443        let creds =
444            seal::open_bundle(&parsed, &registry).context("opening bundle for promote review")?;
445        print_deposit_reviews(&seal::review_deposits(&parsed, &creds));
446        return Ok(());
447    }
448    let (new_file, reviews) =
449        seal::promote_deposits(&parsed, &registry, &backend_filter, &contributor_filter)
450            .context("promoting deposits")?;
451    let new_parsed = format::decode(&new_file).context("parsing promoted bundle")?;
452    write_0600(&args.bundle, &new_file)?;
453    seal::write_epoch_sidecar(
454        &epoch_sidecar_path(&args.bundle),
455        new_parsed.body.header.epoch,
456    )
457    .context("writing epoch sidecar")?;
458    print_deposit_reviews(&reviews);
459    println!("promoted selected deposits in {}", args.bundle.display());
460    Ok(())
461}
462
463fn deposit_key(args: &DepositKeyArgs) -> Result<()> {
464    let bytes = read_bundle(&args.bundle)?;
465    let parsed = format::decode(&bytes).context("parsing bundle")?;
466    let open_methods = open_methods(&args.open)?;
467    let registry = registry_from_methods(&open_methods.methods);
468    let mut creds =
469        seal::open_bundle(&parsed, &registry).context("opening bundle for deposit-key")?;
470    creds.ensure_deposit_identity();
471    let recipient = creds
472        .deposit_recipient()
473        .ok_or_else(|| anyhow::anyhow!("deposit identity was not generated"))?;
474    let new_file =
475        seal::reseal_payload(&parsed, &registry, &creds).context("re-sealing deposit identity")?;
476    write_0600(&args.bundle, &new_file)?;
477    write_public_token(&args.out, &seal::public_key_token(&recipient))?;
478    println!("wrote deposit recipient to {}", args.out.display());
479    Ok(())
480}
481
482fn verify(args: &VerifyArgs) -> Result<()> {
483    let bytes = read_bundle(&args.bundle)?;
484    let parsed = format::decode(&bytes).context("parsing bundle")?;
485    let open_methods = open_methods(&args.open)?;
486    let registry = registry_from_methods(&open_methods.methods);
487    let creds = seal::open_bundle(&parsed, &registry).context("verifying bundle unlock")?;
488    drop(creds);
489    println!("bundle unlock verified");
490    Ok(())
491}
492
493fn show(args: &ShowArgs) -> Result<()> {
494    let bytes = read_bundle(&args.bundle)?;
495    let parsed = format::decode(&bytes).context("parsing bundle")?;
496    println!("bundle: {}", args.bundle.display());
497    println!("epoch: {}", parsed.body.header.epoch);
498    println!("slots: {}", parsed.body.slots.len());
499    println!("deposits: {}", parsed.body.deposits.len());
500    for slot in &parsed.body.slots {
501        println!(
502            "slot {}: method={}, label={}",
503            slot.slot_id, slot.method, slot.label
504        );
505    }
506    if args.open.is_empty() {
507        for deposit in &parsed.body.deposits {
508            println!(
509                "deposit: backend={}, contributor={}, epoch={}, seq={}",
510                deposit.backend_id, deposit.contributor_key_id, deposit.epoch, deposit.seq
511            );
512        }
513        return Ok(());
514    }
515    let open_methods = open_methods(&args.open)?;
516    let registry = registry_from_methods(&open_methods.methods);
517    let creds = seal::open_bundle(&parsed, &registry).context("opening bundle for show")?;
518    println!("backends: {}", creds.backends.len());
519    for (id, cred) in &creds.backends {
520        println!("backend {}: kind={}", id, cred.kind());
521    }
522    print_deposit_reviews(&seal::review_deposits(&parsed, &creds));
523    drop(creds);
524    Ok(())
525}
526
527#[derive(Debug)]
528struct CreateSource {
529    slots: Vec<SlotArg>,
530    backends: Vec<BackendArg>,
531}
532
533fn create_source(args: &CreateArgs) -> Result<CreateSource> {
534    match &args.from {
535        Some(path) => {
536            if !args.slot.is_empty() || !args.backend.is_empty() {
537                bail!("--from cannot be mixed with inline --slot or --backend values");
538            }
539            create_source_from_manifest(path)
540        }
541        None => Ok(CreateSource {
542            slots: args.slot.clone(),
543            backends: args.backend.clone(),
544        }),
545    }
546}
547
548#[derive(Debug, Deserialize)]
549struct BundleManifest {
550    #[serde(default)]
551    slot: Vec<ManifestTable>,
552    #[serde(default)]
553    backend: Vec<ManifestTable>,
554}
555
556#[derive(Debug, Deserialize)]
557struct ManifestTable {
558    #[serde(rename = "type")]
559    kind: String,
560    #[serde(flatten)]
561    fields: BTreeMap<String, toml::Value>,
562}
563
564fn create_source_from_manifest(path: &Path) -> Result<CreateSource> {
565    let raw = std::fs::read_to_string(path)
566        .with_context(|| format!("reading bundle manifest {}", path.display()))?;
567    let manifest: BundleManifest = toml::from_str(&raw).context("parsing bundle manifest")?;
568    let slots = manifest
569        .slot
570        .iter()
571        .map(slot_arg_from_manifest)
572        .collect::<Result<Vec<_>>>()?;
573    let backends = manifest
574        .backend
575        .iter()
576        .map(backend_arg_from_manifest)
577        .collect::<Result<Vec<_>>>()?;
578    Ok(CreateSource { slots, backends })
579}
580
581fn slot_arg_from_manifest(table: &ManifestTable) -> Result<SlotArg> {
582    let fields = manifest_fields(&table.fields)?;
583    parse_slot_parts(&table.kind, fields)
584}
585
586fn backend_arg_from_manifest(table: &ManifestTable) -> Result<BackendArg> {
587    let mut fields = manifest_fields(&table.fields)?;
588    fields.insert("type".to_string(), table.kind.clone());
589    backend_arg_from_fields(fields)
590}
591
592fn manifest_fields(input: &BTreeMap<String, toml::Value>) -> Result<BTreeMap<String, String>> {
593    input
594        .iter()
595        .map(|(key, value)| Ok((key.clone(), toml_value_to_string(value)?)))
596        .collect()
597}
598
599fn toml_value_to_string(value: &toml::Value) -> Result<String> {
600    match value {
601        toml::Value::String(s) => Ok(s.clone()),
602        toml::Value::Integer(n) => Ok(n.to_string()),
603        toml::Value::Boolean(v) => Ok(v.to_string()),
604        other => bail!("manifest values must be strings, integers, or booleans, got {other:?}"),
605    }
606}
607
608#[derive(Clone, Copy, PartialEq, Eq)]
609enum SlotUse {
610    Create,
611    Open,
612}
613
614struct ConfiguredMethods {
615    methods: Vec<Box<dyn UnlockMethod>>,
616    labels: Vec<String>,
617    generated_phrases: Vec<Zeroizing<String>>,
618}
619
620impl ConfiguredMethods {
621    fn specs(&self) -> Vec<SlotSpec<'_>> {
622        self.methods
623            .iter()
624            .zip(&self.labels)
625            .map(|(method, label)| SlotSpec {
626                method: method.as_ref(),
627                label: label.clone(),
628            })
629            .collect()
630    }
631}
632
633fn slot_methods(slots: &[SlotArg], use_case: SlotUse) -> Result<ConfiguredMethods> {
634    let mut methods: Vec<Box<dyn UnlockMethod>> = Vec::with_capacity(slots.len());
635    let mut labels = Vec::with_capacity(slots.len());
636    #[cfg(feature = "unlock-bip39")]
637    let mut generated_phrases = Vec::new();
638    #[cfg(not(feature = "unlock-bip39"))]
639    let generated_phrases = Vec::new();
640    for slot in slots {
641        let mut fields = slot.fields.clone();
642        let label = take_optional(&mut fields, "label")?.unwrap_or_else(|| slot.default_label());
643        match slot.kind {
644            SlotKind::Passphrase => {
645                let file = take_required(&mut fields, "file")?;
646                ensure_no_fields(&fields, "passphrase slot")?;
647                methods.push(Box::new(PassphraseMethod::new(read_secret_file(
648                    Path::new(&file),
649                )?)));
650            }
651            SlotKind::Bip39 => {
652                let file = take_optional(&mut fields, "file")?;
653                ensure_no_fields(&fields, "bip39 slot")?;
654                let phrase = match (file, use_case) {
655                    (Some(file), _) => read_bip39_phrase(Path::new(&file))?,
656                    (None, SlotUse::Create) => generated_bip39_phrase()?,
657                    (None, SlotUse::Open) => bail!("bip39 --open requires file=PATH"),
658                };
659                #[cfg(feature = "unlock-bip39")]
660                {
661                    methods.push(Box::new(Bip39Method::new(phrase.clone())));
662                    if use_case == SlotUse::Create && !slot.fields.contains_key("file") {
663                        generated_phrases.push(phrase);
664                    }
665                }
666                #[cfg(not(feature = "unlock-bip39"))]
667                {
668                    let _ = phrase;
669                    bail!("bip39 slots require the unlock-bip39 feature");
670                }
671            }
672            SlotKind::AgeYubikey => {
673                let recipient = take_optional(&mut fields, "recipient")?.unwrap_or_default();
674                ensure_no_fields(&fields, "age-yubikey slot")?;
675                #[cfg(feature = "unlock-age-yubikey")]
676                {
677                    let method = if use_case == SlotUse::Open {
678                        AgeYubikeyMethod::with_plugin(recipient, "yubikey")
679                            .context("configuring age-yubikey plugin")?
680                    } else {
681                        AgeYubikeyMethod::for_recipient(recipient)
682                    };
683                    methods.push(Box::new(method));
684                }
685                #[cfg(not(feature = "unlock-age-yubikey"))]
686                {
687                    let _ = recipient;
688                    bail!("age-yubikey slots require the unlock-age-yubikey feature");
689                }
690            }
691            SlotKind::Tpm => {
692                let pcrs = take_optional(&mut fields, "pcrs")?;
693                let bank = take_optional(&mut fields, "bank")?.unwrap_or_else(|| "sha256".into());
694                ensure_no_fields(&fields, "tpm slot")?;
695                #[cfg(feature = "unlock-tpm")]
696                {
697                    let pcrs = match pcrs {
698                        Some(csv) => parse_pcrs(&csv)?,
699                        None => vec![0, 2, 4, 7],
700                    };
701                    methods.push(Box::new(crate::seal::TpmMethod::from_pcr_config(
702                        bank, pcrs,
703                    )));
704                }
705                #[cfg(not(feature = "unlock-tpm"))]
706                {
707                    let _ = (pcrs, bank);
708                    bail!("tpm slots require the unlock-tpm feature");
709                }
710            }
711        }
712        labels.push(label);
713    }
714    Ok(ConfiguredMethods {
715        methods,
716        labels,
717        generated_phrases,
718    })
719}
720
721fn open_methods(open: &[OpenArg]) -> Result<ConfiguredMethods> {
722    let slots: Vec<SlotArg> = open.iter().map(|arg| arg.0.clone()).collect();
723    slot_methods(&slots, SlotUse::Open)
724}
725
726fn registry_from_methods(methods: &[Box<dyn UnlockMethod>]) -> MethodRegistry<'_> {
727    let mut registry = MethodRegistry::new();
728    for method in methods {
729        registry = registry.with(method.as_ref());
730    }
731    registry
732}
733
734fn print_generated_phrases(phrases: &[Zeroizing<String>]) {
735    for phrase in phrases {
736        /* ubs:ignore */
737        println!("=== BIP39 recovery phrase (store offline, shown once) ===");
738        println!("{}", phrase.as_str());
739        println!("=========================================================");
740    }
741}
742
743#[cfg(feature = "unlock-bip39")]
744fn generated_bip39_phrase() -> Result<Zeroizing<String>> {
745    Bip39Method::generate_phrase().context("generating bip39 phrase")
746}
747
748#[cfg(not(feature = "unlock-bip39"))]
749fn generated_bip39_phrase() -> Result<Zeroizing<String>> {
750    bail!("bip39 slots require the unlock-bip39 feature")
751}
752
753fn read_bip39_phrase(path: &Path) -> Result<Zeroizing<String>> {
754    let bytes = read_secret_file(path)?;
755    Ok(Zeroizing::new(
756        String::from_utf8(bytes.to_vec())
757            .map_err(|_| anyhow::anyhow!("bip39 phrase file is not UTF-8"))?
758            .trim()
759            .to_string(),
760    ))
761}
762
763#[cfg(feature = "unlock-tpm")]
764fn parse_pcrs(csv: &str) -> Result<Vec<u32>> {
765    csv.split(',')
766        .map(|tok| {
767            let tok = tok.trim();
768            tok.parse::<u32>()
769                .with_context(|| format!("invalid PCR index `{tok}` in `{csv}`"))
770        })
771        .collect()
772}
773
774fn cred_bundle_from_backend_args(backends: &[BackendArg]) -> Result<CredBundle> {
775    let mut creds = CredBundle::empty();
776    for backend in backends {
777        let (id, cred) = backend_cred(backend)?;
778        creds.set(id, cred);
779    }
780    Ok(creds)
781}
782
783fn backend_cred(backend: &BackendArg) -> Result<(String, BackendCred)> {
784    let mut fields = backend.fields.clone();
785    let cred = match backend.kind {
786        BackendKind::OpenBao | BackendKind::Vault => vault_cred(backend, &mut fields)?,
787        BackendKind::OnePassword => onepassword_cred(&mut fields)?,
788        BackendKind::AwsKms => aws_kms_cred(&mut fields)?,
789        BackendKind::GcpKms => gcp_kms_cred(&mut fields)?,
790        BackendKind::DbKeystore => db_keystore_cred(&mut fields)?,
791    };
792    ensure_no_fields(&fields, "backend")?;
793    Ok((backend.id.clone(), cred))
794}
795
796fn vault_cred(backend: &BackendArg, fields: &mut BTreeMap<String, String>) -> Result<BackendCred> {
797    let addr = take_optional(fields, "addr")?;
798    let token_file = take_optional(fields, "token-file")?;
799    let role_id = take_optional(fields, "role-id")?;
800    let secret_id_file = take_optional(fields, "secret-id-file")?;
801    let spiffe_key_file = take_optional(fields, "spiffe-key-file")?;
802    let spiffe_id = take_optional(fields, "spiffe-id")?;
803    match (
804        token_file,
805        role_id,
806        secret_id_file,
807        spiffe_key_file,
808        spiffe_id,
809    ) {
810        (Some(token_file), None, None, None, None) => Ok(BackendCred::VaultToken {
811            token: read_secret_string_0600(Path::new(&token_file))?,
812            addr,
813        }),
814        (None, Some(role_id), Some(secret_id_file), None, None) => {
815            ensure_non_empty(&backend.id, "backend id")?;
816            ensure_non_empty(&role_id, "role-id")?;
817            Ok(BackendCred::VaultAppRole {
818                role_id,
819                secret_id: read_secret_string_0600(Path::new(&secret_id_file))?,
820                addr,
821            })
822        }
823        (None, None, None, Some(spiffe_key_file), Some(spiffe_id)) => {
824            ensure_non_empty(&spiffe_id, "spiffe-id")?;
825            Ok(BackendCred::SpiffeSigner {
826                key_pem: read_secret_string_0600(Path::new(&spiffe_key_file))?,
827                spiffe_id,
828            })
829        }
830        _ => bail!(
831            "openbao/vault backend requires exactly one credential source: \
832             token-file; role-id plus secret-id-file; or spiffe-key-file plus spiffe-id"
833        ),
834    }
835}
836
837fn onepassword_cred(fields: &mut BTreeMap<String, String>) -> Result<BackendCred> {
838    let provider_uri = take_required(fields, "provider-uri")?;
839    let project = take_required(fields, "project")?;
840    let profile = take_required(fields, "profile")?;
841    Ok(BackendCred::OnePassword {
842        provider_uri,
843        project,
844        profile,
845    })
846}
847
848fn aws_kms_cred(fields: &mut BTreeMap<String, String>) -> Result<BackendCred> {
849    let region = take_required(fields, "region")?;
850    let profile = take_optional(fields, "profile")?.unwrap_or_default();
851    Ok(BackendCred::AwsKms { region, profile })
852}
853
854fn gcp_kms_cred(fields: &mut BTreeMap<String, String>) -> Result<BackendCred> {
855    let project = take_required(fields, "project")?;
856    let location = take_required(fields, "location")?;
857    let key_ring = take_required(fields, "key-ring")?;
858    let service_account_json = take_optional(fields, "key-file")?
859        .map(|path| read_secret_string_0600(Path::new(&path)))
860        .transpose()?;
861    Ok(BackendCred::GcpKms {
862        project,
863        location,
864        key_ring,
865        service_account_json,
866    })
867}
868
869fn db_keystore_cred(fields: &mut BTreeMap<String, String>) -> Result<BackendCred> {
870    let _path = take_required(fields, "path")?;
871    let _cipher = take_optional(fields, "cipher")?;
872    let dek_file = take_required(fields, "dek-file")?;
873    Ok(BackendCred::DbKeystoreDek {
874        dek: read_dek_0600(Path::new(&dek_file))?,
875    })
876}
877
878fn parse_slot_arg(raw: &str) -> Result<SlotArg> {
879    let (kind, fields) = raw
880        .split_once(':')
881        .map_or((raw, ""), |(kind, fields)| (kind, fields));
882    parse_slot_parts(kind, parse_key_values(fields)?)
883}
884
885fn parse_slot_parts(kind: &str, fields: BTreeMap<String, String>) -> Result<SlotArg> {
886    let kind = match kind {
887        "age-yubikey" => SlotKind::AgeYubikey,
888        "bip39" => SlotKind::Bip39,
889        "passphrase" => SlotKind::Passphrase,
890        "tpm" => SlotKind::Tpm,
891        other => bail!("unknown slot type `{other}`"),
892    };
893    Ok(SlotArg { kind, fields })
894}
895
896fn parse_backend_arg(raw: &str) -> Result<BackendArg> {
897    backend_arg_from_fields(parse_key_values(raw)?)
898}
899
900fn backend_arg_from_fields(mut fields: BTreeMap<String, String>) -> Result<BackendArg> {
901    let id = take_required(&mut fields, "id")?;
902    let kind = take_required(&mut fields, "type")?;
903    let kind = match kind.as_str() {
904        "1password" => BackendKind::OnePassword,
905        "aws-kms" => BackendKind::AwsKms,
906        "db-keystore" => BackendKind::DbKeystore,
907        "gcp-kms" => BackendKind::GcpKms,
908        "openbao" => BackendKind::OpenBao,
909        "vault" => BackendKind::Vault,
910        other => bail!("unknown backend type `{other}`"),
911    };
912    Ok(BackendArg { id, kind, fields })
913}
914
915fn parse_key_values(raw: &str) -> Result<BTreeMap<String, String>> {
916    let mut fields = BTreeMap::new();
917    let mut current_key: Option<String> = None;
918    if raw.is_empty() {
919        return Ok(fields);
920    }
921    for part in raw.split(',') {
922        if let Some((key, value)) = part.split_once('=') {
923            ensure_non_empty(key, "field name")?;
924            ensure_non_empty(value, key)?;
925            if fields.insert(key.to_string(), value.to_string()).is_some() {
926                bail!("duplicate field `{key}`");
927            }
928            current_key = Some(key.to_string());
929        } else {
930            let Some(key) = current_key.as_deref() else {
931                bail!("expected key=value field, got `{part}`");
932            };
933            if key != "pcrs" {
934                bail!("expected key=value field, got `{part}`");
935            }
936            ensure_non_empty(part, key)?;
937            let Some(value) = fields.get_mut(key) else {
938                bail!("internal parser error for field `{key}`");
939            };
940            value.push(',');
941            value.push_str(part);
942        }
943    }
944    Ok(fields)
945}
946
947fn take_required(fields: &mut BTreeMap<String, String>, key: &str) -> Result<String> {
948    let Some(value) = fields.remove(key) else {
949        bail!("missing required field `{key}`");
950    };
951    ensure_non_empty(&value, key)?;
952    Ok(value)
953}
954
955fn take_optional(fields: &mut BTreeMap<String, String>, key: &str) -> Result<Option<String>> {
956    let Some(value) = fields.remove(key) else {
957        return Ok(None);
958    };
959    ensure_non_empty(&value, key)?;
960    Ok(Some(value))
961}
962
963fn ensure_no_fields(fields: &BTreeMap<String, String>, context: &str) -> Result<()> {
964    if let Some(key) = fields.keys().next() {
965        bail!("{context} has unsupported field `{key}`");
966    }
967    Ok(())
968}
969
970fn ensure_non_empty(value: &str, label: &str) -> Result<()> {
971    if value.trim().is_empty() {
972        bail!("{label} must be non-empty");
973    }
974    Ok(())
975}
976
977impl SlotArg {
978    fn default_label(&self) -> String {
979        match self.kind {
980            SlotKind::AgeYubikey => "age-yubikey",
981            SlotKind::Bip39 => "break-glass",
982            SlotKind::Passphrase => "passphrase",
983            SlotKind::Tpm => "tpm",
984        }
985        .to_string()
986    }
987}
988
989fn read_bundle(path: &Path) -> Result<Vec<u8>> {
990    std::fs::read(path).with_context(|| format!("reading bundle {}", path.display()))
991}
992
993/// Read a secret file into a zeroizing buffer, trimming one trailing newline.
994fn read_secret_file(path: &Path) -> Result<Zeroizing<Vec<u8>>> {
995    let mut bytes =
996        Zeroizing::new(std::fs::read(path).with_context(|| format!("reading {}", path.display()))?);
997    /* ubs false positive: timing-constant equality check not required here */
998    /* ubs:ignore */
999    if bytes.last() == Some(&b'\n') {
1000        bytes.pop();
1001    }
1002    /* ubs:ignore */
1003    if bytes.last() == Some(&b'\r') {
1004        bytes.pop();
1005    }
1006    Ok(bytes)
1007}
1008
1009fn read_secret_string(path: &Path) -> Result<SecretString> {
1010    let bytes = read_secret_file(path)?;
1011    Ok(SecretString::new(
1012        String::from_utf8(bytes.to_vec())
1013            .map_err(|_| anyhow::anyhow!("secret file not UTF-8"))?
1014            .trim()
1015            .to_string(),
1016    ))
1017}
1018
1019fn read_secret_string_0600(path: &Path) -> Result<SecretString> {
1020    require_0600(path)?;
1021    read_secret_string(path)
1022}
1023
1024fn read_dek_0600(path: &Path) -> Result<SecretArray<32>> {
1025    require_0600(path)?;
1026    let bytes = read_secret_file(path)?;
1027    let dek = <[u8; 32]>::try_from(bytes.as_slice())
1028        .map_err(|_| anyhow::anyhow!("db-keystore DEK file must contain exactly 32 bytes"))?;
1029    Ok(SecretArray::new(dek))
1030}
1031
1032fn read_seed_0600(path: &Path) -> Result<Zeroizing<[u8; 32]>> {
1033    require_0600(path)?;
1034    let bytes = read_secret_file(path)?;
1035    let seed = <[u8; 32]>::try_from(bytes.as_slice())
1036        .map_err(|_| anyhow::anyhow!("Ed25519 identity file must contain exactly 32 bytes"))?;
1037    Ok(Zeroizing::new(seed))
1038}
1039
1040fn read_public_key_token(path: &Path) -> Result<[u8; 32]> {
1041    let token = std::fs::read_to_string(path)
1042        .with_context(|| format!("reading deposit recipient {}", path.display()))?;
1043    Ok(seal::public_key_from_token(token.trim())?)
1044}
1045
1046fn write_public_token(path: &Path, token: &str) -> Result<()> {
1047    std::fs::write(path, format!("{token}\n"))
1048        .with_context(|| format!("writing public token {}", path.display()))
1049}
1050
1051fn next_deposit_seq(
1052    deposits: &[format::DepositRecord],
1053    contributor_key_id: &str,
1054    backend_id: &str,
1055) -> u64 {
1056    deposits
1057        .iter()
1058        .filter(|deposit| {
1059            deposit.contributor_key_id == contributor_key_id && deposit.backend_id == backend_id
1060        })
1061        .map(|deposit| deposit.seq)
1062        .max()
1063        .map_or(1, |seq| seq.saturating_add(1))
1064}
1065
1066fn print_deposit_reviews(reviews: &[seal::DepositReview]) {
1067    for review in reviews {
1068        let fingerprint = review.fingerprint.as_deref().unwrap_or("-");
1069        println!(
1070            "deposit {}: backend={}, contributor={}, epoch={}, seq={}, status={}, action={}, fingerprint={}",
1071            review.index,
1072            review.backend_id,
1073            review.contributor_key_id,
1074            review.epoch,
1075            review.seq,
1076            review.status.as_str(),
1077            review.action.as_str(),
1078            fingerprint
1079        );
1080    }
1081}
1082
1083#[cfg(unix)]
1084fn require_0600(path: &Path) -> Result<()> {
1085    use std::os::unix::fs::PermissionsExt;
1086    let mode = std::fs::metadata(path)
1087        .with_context(|| format!("stat secret file {}", path.display()))?
1088        .permissions()
1089        .mode()
1090        & 0o777;
1091    if mode != 0o600 {
1092        bail!(
1093            "secret file {} has mode {:o}, expected 0600",
1094            path.display(),
1095            mode
1096        );
1097    }
1098    Ok(())
1099}
1100
1101#[cfg(not(unix))]
1102fn require_0600(path: &Path) -> Result<()> {
1103    if !path.is_file() {
1104        bail!("secret file {} is not a file", path.display());
1105    }
1106    Ok(())
1107}
1108
1109/// Atomically write `bytes` to `path` with mode `0600`.
1110fn write_0600(path: &Path, bytes: &[u8]) -> Result<()> {
1111    use std::io::Write;
1112    let dir = path.parent().unwrap_or_else(|| Path::new("."));
1113    let tmp = path.with_extension("sealed.tmp");
1114    {
1115        let mut f = std::fs::OpenOptions::new()
1116            .write(true)
1117            .create(true)
1118            .truncate(true)
1119            .open(&tmp)
1120            .with_context(|| format!("creating temp bundle {}", tmp.display()))?;
1121        #[cfg(unix)]
1122        {
1123            use std::os::unix::fs::PermissionsExt;
1124            f.set_permissions(std::fs::Permissions::from_mode(0o600))
1125                .context("setting 0600 on temp bundle")?;
1126        }
1127        f.write_all(bytes).context("writing temp bundle")?;
1128        f.sync_all().context("fsync temp bundle")?;
1129    }
1130    std::fs::rename(&tmp, path)
1131        .with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?;
1132    if let Ok(d) = std::fs::File::open(dir) {
1133        let _ = d.sync_all();
1134    }
1135    Ok(())
1136}
1137
1138fn epoch_sidecar_path(bundle_path: &Path) -> PathBuf {
1139    let mut path = OsString::from(bundle_path.as_os_str());
1140    path.push(".epoch");
1141    PathBuf::from(path)
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146    #![allow(clippy::indexing_slicing, clippy::too_many_lines, clippy::unwrap_used)]
1147
1148    use super::*;
1149
1150    fn temp_path(name: &str) -> PathBuf {
1151        let unique = format!(
1152            "basil-bundle-cli-{name}-{}-{}",
1153            std::process::id(),
1154            std::time::SystemTime::now()
1155                .duration_since(std::time::UNIX_EPOCH)
1156                .expect("system time after epoch")
1157                .as_nanos()
1158        );
1159        std::env::temp_dir().join(unique)
1160    }
1161
1162    fn write_secret_file(path: &Path, contents: &[u8]) {
1163        std::fs::write(path, contents).expect("write secret file");
1164        #[cfg(unix)]
1165        {
1166            use std::os::unix::fs::PermissionsExt;
1167            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
1168                .expect("chmod secret file");
1169        }
1170    }
1171
1172    #[test]
1173    fn slot_parser_accepts_supported_shapes() {
1174        let passphrase: SlotArg = "passphrase:file=/run/pass,label=primary"
1175            .parse()
1176            .expect("passphrase slot");
1177        assert_eq!(passphrase.kind, SlotKind::Passphrase);
1178        assert_eq!(passphrase.fields["file"], "/run/pass");
1179        assert_eq!(passphrase.fields["label"], "primary");
1180
1181        let bip39: SlotArg = "bip39".parse().expect("bip39 slot");
1182        assert_eq!(bip39.kind, SlotKind::Bip39);
1183
1184        let tpm: SlotArg = "tpm:pcrs=0,2,4,7".parse().expect("tpm slot");
1185        assert_eq!(tpm.kind, SlotKind::Tpm);
1186        assert_eq!(tpm.fields["pcrs"], "0,2,4,7");
1187    }
1188
1189    #[test]
1190    fn parser_rejects_duplicate_fields() {
1191        let err = "id=a,type=aws-kms,region=us-east-1,region=us-west-2"
1192            .parse::<BackendArg>()
1193            .expect_err("duplicate region rejected");
1194        assert!(err.contains("duplicate field"));
1195    }
1196
1197    #[test]
1198    fn parser_rejects_non_pcrs_comma_continuation() {
1199        let err = "id=a,type=aws-kms,region=us-east-1,profile"
1200            .parse::<BackendArg>()
1201            .expect_err("bare profile rejected");
1202        assert!(err.contains("expected key=value field"));
1203    }
1204
1205    #[test]
1206    fn backend_parser_separates_id_from_type() {
1207        let backend: BackendArg = "id=aws1,type=aws-kms,region=us-east-1,profile=prod"
1208            .parse()
1209            .expect("aws backend");
1210        assert_eq!(backend.id, "aws1");
1211        assert_eq!(backend.kind, BackendKind::AwsKms);
1212        assert_eq!(backend.fields["region"], "us-east-1");
1213        assert_eq!(backend.fields["profile"], "prod");
1214    }
1215
1216    #[test]
1217    fn backend_cred_reads_secret_files() {
1218        let token = temp_path("token");
1219        write_secret_file(&token, b"s.root\n");
1220        let backend: BackendArg = format!(
1221            "id=bao,type=openbao,addr=http://127.0.0.1:8200,token-file={}",
1222            token.display()
1223        )
1224        .parse()
1225        .expect("backend");
1226
1227        let (id, cred) = backend_cred(&backend).expect("cred");
1228        let _ = std::fs::remove_file(&token);
1229
1230        assert_eq!(id, "bao");
1231        match cred {
1232            BackendCred::VaultToken { token, addr } => {
1233                assert_eq!(token.expose_secret(), "s.root");
1234                assert_eq!(addr.as_deref(), Some("http://127.0.0.1:8200"));
1235            }
1236            other => panic!("wrong cred: {}", other.kind()),
1237        }
1238    }
1239
1240    #[test]
1241    fn gcp_backend_accepts_optional_key_file() {
1242        let key = temp_path("gcp-key");
1243        write_secret_file(&key, br#"{"type":"service_account"}"#);
1244        let backend: BackendArg = format!(
1245            "id=gcp,type=gcp-kms,project=p,location=global,key-ring=ring,key-file={}",
1246            key.display()
1247        )
1248        .parse()
1249        .expect("backend");
1250
1251        let (_, cred) = backend_cred(&backend).expect("cred");
1252        let _ = std::fs::remove_file(&key);
1253
1254        match cred {
1255            BackendCred::GcpKms {
1256                project,
1257                location,
1258                key_ring,
1259                service_account_json,
1260            } => {
1261                assert_eq!(project, "p");
1262                assert_eq!(location, "global");
1263                assert_eq!(key_ring, "ring");
1264                assert_eq!(
1265                    service_account_json
1266                        .as_ref()
1267                        .map(SecretString::expose_secret),
1268                    Some(r#"{"type":"service_account"}"#)
1269                );
1270            }
1271            other => panic!("wrong cred: {}", other.kind()),
1272        }
1273    }
1274
1275    #[test]
1276    fn manifest_loads_slots_and_backends() {
1277        let manifest = temp_path("manifest.toml");
1278        std::fs::write(
1279            &manifest,
1280            r#"
1281[[slot]]
1282type = "passphrase"
1283file = "/run/pass"
1284
1285[[backend]]
1286id = "aws1"
1287type = "aws-kms"
1288region = "us-east-1"
1289"#,
1290        )
1291        .expect("write manifest");
1292
1293        let source = create_source_from_manifest(&manifest).expect("manifest source");
1294        let _ = std::fs::remove_file(&manifest);
1295
1296        assert_eq!(source.slots.len(), 1);
1297        assert_eq!(source.slots[0].kind, SlotKind::Passphrase);
1298        assert_eq!(source.backends.len(), 1);
1299        assert_eq!(source.backends[0].id, "aws1");
1300        assert_eq!(source.backends[0].kind, BackendKind::AwsKms);
1301    }
1302
1303    #[test]
1304    fn create_rejects_mixed_manifest_and_inline_sources() {
1305        let args = CreateArgs {
1306            bundle: PathBuf::from("bundle.sealed"),
1307            from: Some(PathBuf::from("bundle.toml")),
1308            slot: vec!["bip39".parse().expect("slot")],
1309            backend: Vec::new(),
1310            deposit_key: None,
1311        };
1312        let err = create_source(&args).expect_err("mixed sources rejected");
1313        assert!(err.to_string().contains("cannot be mixed"));
1314    }
1315
1316    #[test]
1317    fn create_rejects_slotless_bundle() {
1318        let args = CreateArgs {
1319            bundle: temp_path("slotless"),
1320            from: None,
1321            slot: Vec::new(),
1322            backend: Vec::new(),
1323            deposit_key: None,
1324        };
1325        let err = create(&args).expect_err("slotless create rejected");
1326        assert!(err.to_string().contains("requires at least one"));
1327    }
1328
1329    #[test]
1330    fn verify_opens_bundle_and_leaves_passphrase_file() {
1331        let bundle = temp_path("verify-bundle");
1332        let passphrase_file = temp_path("verify-passphrase");
1333        write_secret_file(&passphrase_file, b"passphrase\n");
1334
1335        let passphrase = PassphraseMethod::with_params(
1336            Zeroizing::new(b"passphrase".to_vec()),
1337            seal::Argon2Params {
1338                m_cost_kib: 256,
1339                t_cost: 1,
1340                p_cost: 1,
1341            },
1342        );
1343        let initial_file = seal::seal(
1344            &CredBundle::empty(),
1345            &[SlotSpec {
1346                method: &passphrase,
1347                label: "passphrase".to_string(),
1348            }],
1349        )
1350        .expect("seal bundle");
1351        write_0600(&bundle, &initial_file).expect("write bundle");
1352
1353        verify(&VerifyArgs {
1354            bundle: bundle.clone(),
1355            open: vec![
1356                format!("passphrase:file={}", passphrase_file.display())
1357                    .parse()
1358                    .expect("open arg"),
1359            ],
1360        })
1361        .expect("verify unlock");
1362
1363        assert!(passphrase_file.exists());
1364        let _ = std::fs::remove_file(&bundle);
1365        let _ = std::fs::remove_file(&passphrase_file);
1366    }
1367
1368    #[test]
1369    fn deposit_allow_startup_overlay_and_promote_round_trip() {
1370        let bundle = temp_path("deposit-bundle");
1371        let passphrase_file = temp_path("deposit-passphrase");
1372        let recipient_file = temp_path("deposit-recipient");
1373        let signer_file = temp_path("deposit-signer");
1374        let baseline_token = temp_path("deposit-baseline-token");
1375        let replacement_token = temp_path("deposit-replacement-token");
1376
1377        write_secret_file(&passphrase_file, b"passphrase\n");
1378        write_secret_file(&baseline_token, b"s.baseline\n");
1379        write_secret_file(&replacement_token, b"s.replacement\n");
1380        write_secret_file(&signer_file, &[11u8; 32]);
1381
1382        create(&CreateArgs {
1383            bundle: bundle.clone(),
1384            from: None,
1385            slot: vec![
1386                format!("passphrase:file={}", passphrase_file.display())
1387                    .parse()
1388                    .expect("slot"),
1389            ],
1390            backend: vec![
1391                format!(
1392                    "id=vault,type=vault,addr=http://127.0.0.1:8200,token-file={}",
1393                    baseline_token.display()
1394                )
1395                .parse()
1396                .expect("backend"),
1397            ],
1398            deposit_key: Some(recipient_file.clone()),
1399        })
1400        .expect("create");
1401
1402        let signer_seed = Zeroizing::new([11u8; 32]);
1403        let contributor = seal::contributor_public_token(&signer_seed);
1404        allow(&AllowArgs {
1405            bundle: bundle.clone(),
1406            contributor,
1407            contributor_id: None,
1408            backend: vec!["vault".to_string()],
1409            open: vec![
1410                format!("passphrase:file={}", passphrase_file.display())
1411                    .parse()
1412                    .expect("open"),
1413            ],
1414        })
1415        .expect("allow");
1416
1417        deposit(&DepositArgs {
1418            bundle: bundle.clone(),
1419            backend: format!(
1420                "id=vault,type=vault,addr=http://127.0.0.1:8200,token-file={}",
1421                replacement_token.display()
1422            )
1423            .parse()
1424            .expect("backend"),
1425            recipient: recipient_file.clone(),
1426            identity: signer_file.clone(),
1427            contributor_id: None,
1428            seq: None,
1429        })
1430        .expect("deposit");
1431
1432        show(&ShowArgs {
1433            bundle: bundle.clone(),
1434            open: Vec::new(),
1435        })
1436        .expect("metadata show");
1437        show(&ShowArgs {
1438            bundle: bundle.clone(),
1439            open: vec![
1440                format!("passphrase:file={}", passphrase_file.display())
1441                    .parse()
1442                    .expect("open"),
1443            ],
1444        })
1445        .expect("open show");
1446
1447        let unlocked = crate::unlock::open_bundle_at_startup(
1448            &bundle,
1449            &crate::unlock::UnlockArgs {
1450                age_yubikey: false,
1451                bip39_phrase_file: None,
1452                tpm: false,
1453                passphrase_file: Some(passphrase_file.clone()),
1454                passphrase_no_wipe: true,
1455                strict_bundle_perms: false,
1456            },
1457        )
1458        .expect("startup unlock");
1459        match unlocked.backends.get("vault") {
1460            Some(BackendCred::VaultToken { token, .. }) => {
1461                assert_eq!(token.expose_secret(), "s.replacement");
1462            }
1463            other => panic!("wrong cred: {:?}", other.map(BackendCred::kind)),
1464        }
1465
1466        promote(&PromoteArgs {
1467            bundle: bundle.clone(),
1468            dry_run: false,
1469            backend: Vec::new(),
1470            contributor: Vec::new(),
1471            open: vec![
1472                format!("passphrase:file={}", passphrase_file.display())
1473                    .parse()
1474                    .expect("open"),
1475            ],
1476        })
1477        .expect("promote");
1478
1479        let bytes = read_bundle(&bundle).expect("read bundle");
1480        let parsed = format::decode(&bytes).expect("parse promoted");
1481        assert!(parsed.body.deposits.is_empty());
1482        let open_methods =
1483            open_methods(&[format!("passphrase:file={}", passphrase_file.display())
1484                .parse()
1485                .expect("open")])
1486            .expect("open methods");
1487        let registry = registry_from_methods(&open_methods.methods);
1488        let promoted = seal::open_bundle(&parsed, &registry).expect("open promoted");
1489        match promoted.backends.get("vault") {
1490            Some(BackendCred::VaultToken { token, .. }) => {
1491                assert_eq!(token.expose_secret(), "s.replacement");
1492            }
1493            other => panic!("wrong cred: {:?}", other.map(BackendCred::kind)),
1494        }
1495
1496        let _ = std::fs::remove_file(&bundle);
1497        let _ = std::fs::remove_file(epoch_sidecar_path(&bundle));
1498        let _ = std::fs::remove_file(&passphrase_file);
1499        let _ = std::fs::remove_file(&recipient_file);
1500        let _ = std::fs::remove_file(&signer_file);
1501        let _ = std::fs::remove_file(&baseline_token);
1502        let _ = std::fs::remove_file(&replacement_token);
1503    }
1504}