1use 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#[derive(Debug, Subcommand)]
33pub enum BundleCommand {
34 Create(CreateArgs),
36 AddSlot(AddSlotArgs),
38 SetBackend(SetBackendArgs),
40 Deposit(DepositArgs),
42 Allow(AllowArgs),
44 Promote(PromoteArgs),
46 DepositKey(DepositKeyArgs),
48 Verify(VerifyArgs),
50 Show(ShowArgs),
52}
53
54#[derive(Debug, clap::Args)]
56pub struct CreateArgs {
57 bundle: PathBuf,
59
60 #[arg(long = "from", value_name = "FILE")]
62 from: Option<PathBuf>,
63
64 #[arg(long, value_name = "SPEC")]
66 slot: Vec<SlotArg>,
67
68 #[arg(long, value_name = "SPEC")]
70 backend: Vec<BackendArg>,
71
72 #[arg(long = "deposit-key", value_name = "OUT")]
74 deposit_key: Option<PathBuf>,
75}
76
77#[derive(Debug, clap::Args)]
79pub struct AddSlotArgs {
80 bundle: PathBuf,
82
83 #[arg(long, value_name = "SPEC")]
85 slot: SlotArg,
86
87 #[arg(long = "open", value_name = "METHOD", required = true)]
89 open: Vec<OpenArg>,
90}
91
92#[derive(Debug, clap::Args)]
94pub struct SetBackendArgs {
95 bundle: PathBuf,
97
98 #[arg(long, value_name = "SPEC")]
100 backend: BackendArg,
101
102 #[arg(long = "open", value_name = "METHOD", required = true)]
104 open: Vec<OpenArg>,
105}
106
107#[derive(Debug, clap::Args)]
109pub struct DepositArgs {
110 bundle: PathBuf,
112
113 #[arg(long, value_name = "SPEC")]
115 backend: BackendArg,
116
117 #[arg(short = 'r', long = "recipient", value_name = "FILE")]
119 recipient: PathBuf,
120
121 #[arg(short = 'i', long = "identity", value_name = "FILE")]
123 identity: PathBuf,
124
125 #[arg(long = "contributor-id", value_name = "ID")]
128 contributor_id: Option<String>,
129
130 #[arg(long)]
133 seq: Option<u64>,
134}
135
136#[derive(Debug, clap::Args)]
138pub struct AllowArgs {
139 bundle: PathBuf,
141
142 #[arg(long, value_name = "PUB")]
144 contributor: String,
145
146 #[arg(long = "contributor-id", value_name = "ID")]
148 contributor_id: Option<String>,
149
150 #[arg(long = "backend", value_name = "ID")]
152 backend: Vec<String>,
153
154 #[arg(long = "open", value_name = "METHOD", required = true)]
156 open: Vec<OpenArg>,
157}
158
159#[derive(Debug, clap::Args)]
161pub struct PromoteArgs {
162 bundle: PathBuf,
164
165 #[arg(long)]
167 dry_run: bool,
168
169 #[arg(long = "backend", value_name = "ID")]
171 backend: Vec<String>,
172
173 #[arg(long = "contributor", value_name = "ID")]
175 contributor: Vec<String>,
176
177 #[arg(long = "open", value_name = "METHOD", required = true)]
179 open: Vec<OpenArg>,
180}
181
182#[derive(Debug, clap::Args)]
184pub struct DepositKeyArgs {
185 bundle: PathBuf,
187
188 #[arg(long, value_name = "OUT")]
190 out: PathBuf,
191
192 #[arg(long = "open", value_name = "METHOD", required = true)]
194 open: Vec<OpenArg>,
195}
196
197#[derive(Debug, clap::Args)]
199pub struct VerifyArgs {
200 bundle: PathBuf,
202
203 #[arg(long = "open", value_name = "METHOD", required = true)]
205 open: Vec<OpenArg>,
206}
207
208#[derive(Debug, clap::Args)]
210pub struct ShowArgs {
211 bundle: PathBuf,
213
214 #[arg(long = "open", value_name = "METHOD")]
217 open: Vec<OpenArg>,
218}
219
220#[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#[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#[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
281pub 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, ®istry, 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, ®istry).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, ®istry, &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, ®istry).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, ®istry, &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, ®istry).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, ®istry, &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, ®istry).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, ®istry, &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, ®istry).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, ®istry).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 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
845fn 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
1021fn 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 if bytes.last() == Some(&b'\n') {
1028 bytes.pop();
1029 }
1030 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
1137fn 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 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!(err.contains("unsupported scheme"), "unexpected error: {err}");
1290 }
1291
1292 #[test]
1293 fn vault_backend_rejects_schemeless_addr_at_parse_time() {
1294 let token = temp_path("schemeless-token");
1295 write_secret_file(&token, b"s.root\n");
1296 let backend: BackendArg = format!(
1297 "id=bao,type=openbao,addr=127.0.0.1:8200,token-file={}",
1298 token.display()
1299 )
1300 .parse()
1301 .expect("backend arg parses");
1302
1303 let err = backend_cred(&backend).expect_err("schemeless addr rejected");
1304 let _ = std::fs::remove_file(&token);
1305 assert!(
1306 err.to_string().contains("not a valid URL"),
1307 "unexpected error: {err}"
1308 );
1309 }
1310
1311 #[test]
1312 fn gcp_backend_accepts_optional_key_file() {
1313 let key = temp_path("gcp-key");
1314 write_secret_file(&key, br#"{"type":"service_account"}"#);
1315 let backend: BackendArg = format!(
1316 "id=gcp,type=gcp-kms,project=p,location=global,key-ring=ring,key-file={}",
1317 key.display()
1318 )
1319 .parse()
1320 .expect("backend");
1321
1322 let (_, cred) = backend_cred(&backend).expect("cred");
1323 let _ = std::fs::remove_file(&key);
1324
1325 match cred {
1326 BackendCred::GcpKms {
1327 project,
1328 location,
1329 key_ring,
1330 service_account_json,
1331 } => {
1332 assert_eq!(project, "p");
1333 assert_eq!(location, "global");
1334 assert_eq!(key_ring, "ring");
1335 assert_eq!(
1336 service_account_json
1337 .as_ref()
1338 .map(SecretString::expose_secret),
1339 Some(r#"{"type":"service_account"}"#)
1340 );
1341 }
1342 other => panic!("wrong cred: {}", other.kind()),
1343 }
1344 }
1345
1346 #[test]
1347 fn manifest_loads_slots_and_backends() {
1348 let manifest = temp_path("manifest.toml");
1349 std::fs::write(
1350 &manifest,
1351 r#"
1352[[slot]]
1353type = "passphrase"
1354file = "/run/pass"
1355
1356[[backend]]
1357id = "aws1"
1358type = "aws-kms"
1359region = "us-east-1"
1360"#,
1361 )
1362 .expect("write manifest");
1363
1364 let source = create_source_from_manifest(&manifest).expect("manifest source");
1365 let _ = std::fs::remove_file(&manifest);
1366
1367 assert_eq!(source.slots.len(), 1);
1368 assert_eq!(source.slots[0].kind, SlotKind::Passphrase);
1369 assert_eq!(source.backends.len(), 1);
1370 assert_eq!(source.backends[0].id, "aws1");
1371 assert_eq!(source.backends[0].kind, BackendKind::AwsKms);
1372 }
1373
1374 #[test]
1375 fn create_rejects_mixed_manifest_and_inline_sources() {
1376 let args = CreateArgs {
1377 bundle: PathBuf::from("bundle.sealed"),
1378 from: Some(PathBuf::from("bundle.toml")),
1379 slot: vec!["bip39".parse().expect("slot")],
1380 backend: Vec::new(),
1381 deposit_key: None,
1382 };
1383 let err = create_source(&args).expect_err("mixed sources rejected");
1384 assert!(err.to_string().contains("cannot be mixed"));
1385 }
1386
1387 #[test]
1388 fn create_rejects_slotless_bundle() {
1389 let args = CreateArgs {
1390 bundle: temp_path("slotless"),
1391 from: None,
1392 slot: Vec::new(),
1393 backend: Vec::new(),
1394 deposit_key: None,
1395 };
1396 let err = create(&args).expect_err("slotless create rejected");
1397 assert!(err.to_string().contains("requires at least one"));
1398 }
1399
1400 #[test]
1401 fn verify_opens_bundle_and_leaves_passphrase_file() {
1402 let bundle = temp_path("verify-bundle");
1403 let passphrase_file = temp_path("verify-passphrase");
1404 write_secret_file(&passphrase_file, b"passphrase\n");
1405
1406 let passphrase = PassphraseMethod::with_params(
1407 Zeroizing::new(b"passphrase".to_vec()),
1408 seal::Argon2Params {
1409 m_cost_kib: 256,
1410 t_cost: 1,
1411 p_cost: 1,
1412 },
1413 );
1414 let initial_file = seal::seal(
1415 &CredBundle::empty(),
1416 &[SlotSpec {
1417 method: &passphrase,
1418 label: "passphrase".to_string(),
1419 }],
1420 )
1421 .expect("seal bundle");
1422 write_0600(&bundle, &initial_file).expect("write bundle");
1423
1424 verify(&VerifyArgs {
1425 bundle: bundle.clone(),
1426 open: vec![
1427 format!("passphrase:file={}", passphrase_file.display())
1428 .parse()
1429 .expect("open arg"),
1430 ],
1431 })
1432 .expect("verify unlock");
1433
1434 assert!(passphrase_file.exists());
1435 let _ = std::fs::remove_file(&bundle);
1436 let _ = std::fs::remove_file(&passphrase_file);
1437 }
1438
1439 #[test]
1440 fn deposit_allow_startup_overlay_and_promote_round_trip() {
1441 let bundle = temp_path("deposit-bundle");
1442 let passphrase_file = temp_path("deposit-passphrase");
1443 let recipient_file = temp_path("deposit-recipient");
1444 let signer_file = temp_path("deposit-signer");
1445 let baseline_token = temp_path("deposit-baseline-token");
1446 let replacement_token = temp_path("deposit-replacement-token");
1447
1448 write_secret_file(&passphrase_file, b"passphrase\n");
1449 write_secret_file(&baseline_token, b"s.baseline\n");
1450 write_secret_file(&replacement_token, b"s.replacement\n");
1451 write_secret_file(&signer_file, &[11u8; 32]);
1452
1453 create(&CreateArgs {
1454 bundle: bundle.clone(),
1455 from: None,
1456 slot: vec![
1457 format!("passphrase:file={}", passphrase_file.display())
1458 .parse()
1459 .expect("slot"),
1460 ],
1461 backend: vec![
1462 format!(
1463 "id=vault,type=vault,addr=http://127.0.0.1:8200,token-file={}",
1464 baseline_token.display()
1465 )
1466 .parse()
1467 .expect("backend"),
1468 ],
1469 deposit_key: Some(recipient_file.clone()),
1470 })
1471 .expect("create");
1472
1473 let signer_seed = Zeroizing::new([11u8; 32]);
1474 let contributor = seal::contributor_public_token(&signer_seed);
1475 allow(&AllowArgs {
1476 bundle: bundle.clone(),
1477 contributor,
1478 contributor_id: None,
1479 backend: vec!["vault".to_string()],
1480 open: vec![
1481 format!("passphrase:file={}", passphrase_file.display())
1482 .parse()
1483 .expect("open"),
1484 ],
1485 })
1486 .expect("allow");
1487
1488 deposit(&DepositArgs {
1489 bundle: bundle.clone(),
1490 backend: format!(
1491 "id=vault,type=vault,addr=http://127.0.0.1:8200,token-file={}",
1492 replacement_token.display()
1493 )
1494 .parse()
1495 .expect("backend"),
1496 recipient: recipient_file.clone(),
1497 identity: signer_file.clone(),
1498 contributor_id: None,
1499 seq: None,
1500 })
1501 .expect("deposit");
1502
1503 show(&ShowArgs {
1504 bundle: bundle.clone(),
1505 open: Vec::new(),
1506 })
1507 .expect("metadata show");
1508 show(&ShowArgs {
1509 bundle: bundle.clone(),
1510 open: vec![
1511 format!("passphrase:file={}", passphrase_file.display())
1512 .parse()
1513 .expect("open"),
1514 ],
1515 })
1516 .expect("open show");
1517
1518 let unlocked = crate::unlock::open_bundle_at_startup(
1519 &bundle,
1520 &crate::unlock::UnlockArgs {
1521 age_yubikey: false,
1522 bip39_phrase_file: None,
1523 tpm: false,
1524 passphrase_file: Some(passphrase_file.clone()),
1525 passphrase_no_wipe: true,
1526 strict_bundle_perms: false,
1527 },
1528 )
1529 .expect("startup unlock");
1530 match unlocked.backends.get("vault") {
1531 Some(BackendCred::VaultToken { token, .. }) => {
1532 assert_eq!(token.expose_secret(), "s.replacement");
1533 }
1534 other => panic!("wrong cred: {:?}", other.map(BackendCred::kind)),
1535 }
1536
1537 promote(&PromoteArgs {
1538 bundle: bundle.clone(),
1539 dry_run: false,
1540 backend: Vec::new(),
1541 contributor: Vec::new(),
1542 open: vec![
1543 format!("passphrase:file={}", passphrase_file.display())
1544 .parse()
1545 .expect("open"),
1546 ],
1547 })
1548 .expect("promote");
1549
1550 let bytes = read_bundle(&bundle).expect("read bundle");
1551 let parsed = format::decode(&bytes).expect("parse promoted");
1552 assert!(parsed.body.deposits.is_empty());
1553 let open_methods =
1554 open_methods(&[format!("passphrase:file={}", passphrase_file.display())
1555 .parse()
1556 .expect("open")])
1557 .expect("open methods");
1558 let registry = registry_from_methods(&open_methods.methods);
1559 let promoted = seal::open_bundle(&parsed, ®istry).expect("open promoted");
1560 match promoted.backends.get("vault") {
1561 Some(BackendCred::VaultToken { token, .. }) => {
1562 assert_eq!(token.expose_secret(), "s.replacement");
1563 }
1564 other => panic!("wrong cred: {:?}", other.map(BackendCred::kind)),
1565 }
1566
1567 let _ = std::fs::remove_file(&bundle);
1568 let _ = std::fs::remove_file(epoch_sidecar_path(&bundle));
1569 let _ = std::fs::remove_file(&passphrase_file);
1570 let _ = std::fs::remove_file(&recipient_file);
1571 let _ = std::fs::remove_file(&signer_file);
1572 let _ = std::fs::remove_file(&baseline_token);
1573 let _ = std::fs::remove_file(&replacement_token);
1574 }
1575}