1use 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#[derive(Debug, Subcommand)]
28pub enum BundleCommand {
29 Create(CreateArgs),
31 AddSlot(AddSlotArgs),
33 SetBackend(SetBackendArgs),
35 Deposit(DepositArgs),
37 Allow(AllowArgs),
39 Promote(PromoteArgs),
41 DepositKey(DepositKeyArgs),
43 Verify(VerifyArgs),
45 Show(ShowArgs),
47}
48
49#[derive(Debug, clap::Args)]
51pub struct CreateArgs {
52 bundle: PathBuf,
54
55 #[arg(long = "from", value_name = "FILE")]
57 from: Option<PathBuf>,
58
59 #[arg(long, value_name = "SPEC")]
61 slot: Vec<SlotArg>,
62
63 #[arg(long, value_name = "SPEC")]
65 backend: Vec<BackendArg>,
66
67 #[arg(long = "deposit-key", value_name = "OUT")]
69 deposit_key: Option<PathBuf>,
70}
71
72#[derive(Debug, clap::Args)]
74pub struct AddSlotArgs {
75 bundle: PathBuf,
77
78 #[arg(long, value_name = "SPEC")]
80 slot: SlotArg,
81
82 #[arg(long = "open", value_name = "METHOD", required = true)]
84 open: Vec<OpenArg>,
85}
86
87#[derive(Debug, clap::Args)]
89pub struct SetBackendArgs {
90 bundle: PathBuf,
92
93 #[arg(long, value_name = "SPEC")]
95 backend: BackendArg,
96
97 #[arg(long = "open", value_name = "METHOD", required = true)]
99 open: Vec<OpenArg>,
100}
101
102#[derive(Debug, clap::Args)]
104pub struct DepositArgs {
105 bundle: PathBuf,
107
108 #[arg(long, value_name = "SPEC")]
110 backend: BackendArg,
111
112 #[arg(short = 'r', long = "recipient", value_name = "FILE")]
114 recipient: PathBuf,
115
116 #[arg(short = 'i', long = "identity", value_name = "FILE")]
118 identity: PathBuf,
119
120 #[arg(long = "contributor-id", value_name = "ID")]
123 contributor_id: Option<String>,
124
125 #[arg(long)]
128 seq: Option<u64>,
129}
130
131#[derive(Debug, clap::Args)]
133pub struct AllowArgs {
134 bundle: PathBuf,
136
137 #[arg(long, value_name = "PUB")]
139 contributor: String,
140
141 #[arg(long = "contributor-id", value_name = "ID")]
143 contributor_id: Option<String>,
144
145 #[arg(long = "backend", value_name = "ID")]
147 backend: Vec<String>,
148
149 #[arg(long = "open", value_name = "METHOD", required = true)]
151 open: Vec<OpenArg>,
152}
153
154#[derive(Debug, clap::Args)]
156pub struct PromoteArgs {
157 bundle: PathBuf,
159
160 #[arg(long)]
162 dry_run: bool,
163
164 #[arg(long = "backend", value_name = "ID")]
166 backend: Vec<String>,
167
168 #[arg(long = "contributor", value_name = "ID")]
170 contributor: Vec<String>,
171
172 #[arg(long = "open", value_name = "METHOD", required = true)]
174 open: Vec<OpenArg>,
175}
176
177#[derive(Debug, clap::Args)]
179pub struct DepositKeyArgs {
180 bundle: PathBuf,
182
183 #[arg(long, value_name = "OUT")]
185 out: PathBuf,
186
187 #[arg(long = "open", value_name = "METHOD", required = true)]
189 open: Vec<OpenArg>,
190}
191
192#[derive(Debug, clap::Args)]
194pub struct VerifyArgs {
195 bundle: PathBuf,
197
198 #[arg(long = "open", value_name = "METHOD", required = true)]
200 open: Vec<OpenArg>,
201}
202
203#[derive(Debug, clap::Args)]
205pub struct ShowArgs {
206 bundle: PathBuf,
208
209 #[arg(long = "open", value_name = "METHOD")]
212 open: Vec<OpenArg>,
213}
214
215#[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#[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#[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
276pub 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, ®istry, 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, ®istry).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, ®istry, &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, ®istry).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, ®istry, &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, ®istry).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, ®istry, &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, ®istry).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, ®istry, &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, ®istry).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, ®istry).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 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
993fn 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 if bytes.last() == Some(&b'\n') {
1000 bytes.pop();
1001 }
1002 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
1109fn 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, ®istry).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}