Skip to main content

basil_core/
bundle_cli.rs

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