1use std::collections::HashSet;
2use std::env;
3use std::fmt;
4use std::fs::{self, OpenOptions};
5use std::io::{self, Read};
6use std::path::{Path, PathBuf};
7
8use anyhow::{anyhow, bail, Context, Result};
9use argon2::{Algorithm, Argon2, Params, Version};
10use base32::Alphabet::Rfc4648;
11use base64::engine::general_purpose::STANDARD as BASE64;
12use base64::Engine as _;
13use chacha20poly1305::aead::{Aead, KeyInit, Payload};
14use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
15use chrono::{DateTime, Utc};
16use ciborium::{de::from_reader, ser::into_writer};
17use clap::{Args, Parser, Subcommand, ValueEnum};
18use directories::BaseDirs;
19use is_terminal::IsTerminal;
20use kem::{Decapsulate, Encapsulate};
21use ml_kem::{array::Array, Ciphertext, Encoded, EncodedSizeUser, KemCore, MlKem1024};
22use rand::{rngs::OsRng, RngCore};
23use rpassword::prompt_password;
24use serde::{Deserialize, Serialize};
25use serde_json::json;
26use tempfile::NamedTempFile;
27use totp_rs::{Algorithm as TotpAlgorithmLib, Secret as TotpSecret, TOTP};
28use typenum::Unsigned;
29use uuid::Uuid;
30use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
31
32const VAULT_VERSION: u32 = 1;
33const AAD_DEK: &[u8] = b"black-bag::sealed-dek";
34const AAD_DK: &[u8] = b"black-bag::sealed-dk";
35const AAD_PAYLOAD: &[u8] = b"black-bag::payload";
36const DEFAULT_TIME_COST: u32 = 3;
37const DEFAULT_LANES: u32 = 1;
38
39pub fn run() -> Result<()> {
40 let cli = Cli::parse();
41 match cli.command {
42 Command::Init(cmd) => init_vault(cmd),
43 Command::Add(cmd) => add_record(cmd),
44 Command::List(cmd) => list_records(cmd),
45 Command::Get(cmd) => get_record(cmd),
46 Command::Rotate(cmd) => rotate_vault(cmd),
47 Command::Doctor(cmd) => doctor(cmd),
48 Command::Recovery { command } => recovery(command),
49 Command::Totp { command } => totp(command),
50 Command::Selftest => self_test(),
51 }
52}
53
54#[derive(Parser)]
55#[command(name = "black-bag", version, about = "Ultra-secure zero-trace CLI vault", long_about = None)]
56struct Cli {
57 #[command(subcommand)]
58 command: Command,
59}
60
61#[derive(Subcommand)]
62enum Command {
63 Init(InitCommand),
65 Add(AddCommand),
67 List(ListCommand),
69 Get(GetCommand),
71 Rotate(RotateCommand),
73 Doctor(DoctorCommand),
75 Recovery {
77 #[command(subcommand)]
78 command: RecoveryCommand,
79 },
80 Totp {
82 #[command(subcommand)]
83 command: TotpCommand,
84 },
85 Selftest,
87}
88
89#[derive(Args)]
90struct InitCommand {
91 #[arg(long, default_value_t = 262_144)]
93 mem_kib: u32,
94}
95
96#[derive(Args)]
97struct ListCommand {
98 #[arg(long, value_enum)]
100 kind: Option<RecordKind>,
101 #[arg(long)]
103 tag: Option<String>,
104 #[arg(long)]
106 query: Option<String>,
107}
108
109#[derive(Args)]
110struct GetCommand {
111 id: Uuid,
113 #[arg(long)]
115 reveal: bool,
116}
117
118#[derive(Args, Default)]
119struct RotateCommand {
120 #[arg(long)]
122 mem_kib: Option<u32>,
123}
124
125#[derive(Args)]
126struct DoctorCommand {
127 #[arg(long)]
129 json: bool,
130}
131
132#[derive(Subcommand)]
133enum RecoveryCommand {
134 Split(RecoverySplitCommand),
136 Combine(RecoveryCombineCommand),
138}
139
140#[derive(Args)]
141struct RecoverySplitCommand {
142 #[arg(long, default_value_t = 3)]
144 threshold: u8,
145 #[arg(long, default_value_t = 5)]
147 shares: u8,
148}
149
150#[derive(Args)]
151struct RecoveryCombineCommand {
152 #[arg(long)]
154 threshold: u8,
155 #[arg(long)]
157 shares: String,
158}
159
160#[derive(Subcommand)]
161enum TotpCommand {
162 Code(TotpCodeCommand),
164}
165
166#[derive(Args)]
167struct TotpCodeCommand {
168 id: Uuid,
170 #[arg(long)]
172 time: Option<i64>,
173}
174
175#[derive(Args)]
176struct AddCommand {
177 #[command(subcommand)]
178 record: AddRecord,
179}
180
181#[derive(Subcommand)]
182enum AddRecord {
183 Login(AddLogin),
185 Contact(AddContact),
187 Id(AddIdentity),
189 Note(AddNote),
191 Bank(AddBank),
193 Wifi(AddWifi),
195 Api(AddApi),
197 Wallet(AddWallet),
199 Totp(AddTotp),
201 Ssh(AddSsh),
203 Pgp(AddPgp),
205 Recovery(AddRecovery),
207}
208
209#[derive(Args)]
210struct CommonRecordArgs {
211 #[arg(long)]
213 title: Option<String>,
214 #[arg(long, value_delimiter = ',')]
216 tags: Vec<String>,
217 #[arg(long)]
219 notes: Option<String>,
220}
221
222#[derive(Args)]
223struct AddLogin {
224 #[command(flatten)]
225 common: CommonRecordArgs,
226 #[arg(long)]
227 username: Option<String>,
228 #[arg(long)]
229 url: Option<String>,
230}
231
232#[derive(Args)]
233struct AddContact {
234 #[command(flatten)]
235 common: CommonRecordArgs,
236 #[arg(long, required = true)]
237 full_name: String,
238 #[arg(long, value_delimiter = ',')]
239 emails: Vec<String>,
240 #[arg(long, value_delimiter = ',')]
241 phones: Vec<String>,
242}
243
244#[derive(Args)]
245struct AddIdentity {
246 #[command(flatten)]
247 common: CommonRecordArgs,
248 #[arg(long)]
249 id_type: Option<String>,
250 #[arg(long)]
251 name_on_doc: Option<String>,
252 #[arg(long)]
253 number: Option<String>,
254 #[arg(long)]
255 issuing_country: Option<String>,
256 #[arg(long)]
257 expiry: Option<String>,
258}
259
260#[derive(Args)]
261struct AddNote {
262 #[command(flatten)]
263 common: CommonRecordArgs,
264}
265
266#[derive(Args)]
267struct AddBank {
268 #[command(flatten)]
269 common: CommonRecordArgs,
270 #[arg(long)]
271 institution: Option<String>,
272 #[arg(long)]
273 account_name: Option<String>,
274 #[arg(long)]
275 routing_number: Option<String>,
276}
277
278#[derive(Args)]
279struct AddWifi {
280 #[command(flatten)]
281 common: CommonRecordArgs,
282 #[arg(long)]
283 ssid: Option<String>,
284 #[arg(long)]
285 security: Option<String>,
286 #[arg(long)]
287 location: Option<String>,
288}
289
290#[derive(Args)]
291struct AddApi {
292 #[command(flatten)]
293 common: CommonRecordArgs,
294 #[arg(long)]
295 service: Option<String>,
296 #[arg(long)]
297 environment: Option<String>,
298 #[arg(long)]
299 access_key: Option<String>,
300 #[arg(long, value_delimiter = ',')]
301 scopes: Vec<String>,
302}
303
304#[derive(Args)]
305struct AddWallet {
306 #[command(flatten)]
307 common: CommonRecordArgs,
308 #[arg(long)]
309 asset: Option<String>,
310 #[arg(long)]
311 address: Option<String>,
312 #[arg(long)]
313 network: Option<String>,
314}
315
316#[derive(Args)]
317struct AddTotp {
318 #[command(flatten)]
319 common: CommonRecordArgs,
320 #[arg(long)]
322 issuer: Option<String>,
323 #[arg(long)]
325 account: Option<String>,
326 #[arg(long)]
328 secret: Option<String>,
329 #[arg(long, default_value_t = 6)]
331 digits: u8,
332 #[arg(long, default_value_t = 30)]
334 step: u64,
335 #[arg(long, default_value_t = 1)]
337 skew: u8,
338 #[arg(long, value_enum, default_value_t = TotpAlgorithm::Sha1)]
340 algorithm: TotpAlgorithm,
341}
342
343#[derive(Args)]
344struct AddSsh {
345 #[command(flatten)]
346 common: CommonRecordArgs,
347 #[arg(long)]
348 label: Option<String>,
349 #[arg(long)]
350 comment: Option<String>,
351}
352
353#[derive(Args)]
354struct AddPgp {
355 #[command(flatten)]
356 common: CommonRecordArgs,
357 #[arg(long)]
358 label: Option<String>,
359 #[arg(long)]
360 fingerprint: Option<String>,
361}
362
363#[derive(Args)]
364struct AddRecovery {
365 #[command(flatten)]
366 common: CommonRecordArgs,
367 #[arg(long)]
368 description: Option<String>,
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Hash)]
372#[serde(rename_all = "snake_case")]
373#[clap(rename_all = "snake_case")]
374enum RecordKind {
375 Login,
376 Contact,
377 Id,
378 Note,
379 Bank,
380 Wifi,
381 Api,
382 Wallet,
383 Totp,
384 Ssh,
385 Pgp,
386 Recovery,
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
390#[serde(rename_all = "lowercase")]
391enum TotpAlgorithm {
392 Sha1,
393 Sha256,
394 Sha512,
395}
396
397impl TotpAlgorithm {
398 fn to_lib(self) -> TotpAlgorithmLib {
399 match self {
400 TotpAlgorithm::Sha1 => TotpAlgorithmLib::SHA1,
401 TotpAlgorithm::Sha256 => TotpAlgorithmLib::SHA256,
402 TotpAlgorithm::Sha512 => TotpAlgorithmLib::SHA512,
403 }
404 }
405}
406
407impl fmt::Display for TotpAlgorithm {
408 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409 match self {
410 TotpAlgorithm::Sha1 => f.write_str("sha1"),
411 TotpAlgorithm::Sha256 => f.write_str("sha256"),
412 TotpAlgorithm::Sha512 => f.write_str("sha512"),
413 }
414 }
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
418struct Record {
419 id: Uuid,
420 created_at: DateTime<Utc>,
421 updated_at: DateTime<Utc>,
422 title: Option<String>,
423 tags: Vec<String>,
424 metadata_notes: Option<String>,
425 data: RecordData,
426}
427
428impl Record {
429 fn new(
430 data: RecordData,
431 title: Option<String>,
432 tags: Vec<String>,
433 notes: Option<String>,
434 ) -> Self {
435 let now = Utc::now();
436 Self {
437 id: Uuid::new_v4(),
438 created_at: now,
439 updated_at: now,
440 title,
441 tags,
442 metadata_notes: notes,
443 data,
444 }
445 }
446
447 fn kind(&self) -> RecordKind {
448 self.data.kind()
449 }
450
451 fn matches_tag(&self, tag: &str) -> bool {
452 let tag_lower = tag.to_ascii_lowercase();
453 self.tags
454 .iter()
455 .any(|t| t.to_ascii_lowercase().contains(&tag_lower))
456 }
457
458 fn matches_query(&self, needle: &str) -> bool {
459 let haystack = [
460 self.title.as_deref().unwrap_or_default(),
461 self.metadata_notes.as_deref().unwrap_or_default(),
462 &self.data.summary_text(),
463 ]
464 .join("\n")
465 .to_ascii_lowercase();
466 haystack.contains(&needle.to_ascii_lowercase())
467 }
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
471#[serde(tag = "kind", rename_all = "snake_case")]
472enum RecordData {
473 Login {
474 username: Option<String>,
475 url: Option<String>,
476 password: Sensitive,
477 },
478 Contact {
479 full_name: String,
480 emails: Vec<String>,
481 phones: Vec<String>,
482 },
483 Id {
484 id_type: Option<String>,
485 name_on_doc: Option<String>,
486 number: Option<String>,
487 issuing_country: Option<String>,
488 expiry: Option<String>,
489 secret: Option<Sensitive>,
490 },
491 Note {
492 body: Sensitive,
493 },
494 Bank {
495 institution: Option<String>,
496 account_name: Option<String>,
497 routing_number: Option<String>,
498 account_number: Sensitive,
499 },
500 Wifi {
501 ssid: Option<String>,
502 security: Option<String>,
503 location: Option<String>,
504 passphrase: Sensitive,
505 },
506 Api {
507 service: Option<String>,
508 environment: Option<String>,
509 access_key: Option<String>,
510 secret_key: Sensitive,
511 scopes: Vec<String>,
512 },
513 Wallet {
514 asset: Option<String>,
515 address: Option<String>,
516 network: Option<String>,
517 secret_key: Sensitive,
518 },
519 Totp {
520 issuer: Option<String>,
521 account: Option<String>,
522 secret: Sensitive,
523 digits: u8,
524 step: u64,
525 skew: u8,
526 algorithm: TotpAlgorithm,
527 },
528 Ssh {
529 label: Option<String>,
530 private_key: Sensitive,
531 comment: Option<String>,
532 },
533 Pgp {
534 label: Option<String>,
535 fingerprint: Option<String>,
536 armored_private_key: Sensitive,
537 },
538 Recovery {
539 description: Option<String>,
540 payload: Sensitive,
541 },
542}
543
544impl RecordData {
545 fn kind(&self) -> RecordKind {
546 match self {
547 RecordData::Login { .. } => RecordKind::Login,
548 RecordData::Contact { .. } => RecordKind::Contact,
549 RecordData::Id { .. } => RecordKind::Id,
550 RecordData::Note { .. } => RecordKind::Note,
551 RecordData::Bank { .. } => RecordKind::Bank,
552 RecordData::Wifi { .. } => RecordKind::Wifi,
553 RecordData::Api { .. } => RecordKind::Api,
554 RecordData::Wallet { .. } => RecordKind::Wallet,
555 RecordData::Totp { .. } => RecordKind::Totp,
556 RecordData::Ssh { .. } => RecordKind::Ssh,
557 RecordData::Pgp { .. } => RecordKind::Pgp,
558 RecordData::Recovery { .. } => RecordKind::Recovery,
559 }
560 }
561
562 fn summary_text(&self) -> String {
563 match self {
564 RecordData::Login { username, url, .. } => format!(
565 "user={} url={}",
566 username.as_deref().unwrap_or("-"),
567 url.as_deref().unwrap_or("-")
568 ),
569 RecordData::Contact {
570 full_name,
571 emails,
572 phones,
573 } => format!(
574 "{} | emails={} | phones={}",
575 full_name,
576 if emails.is_empty() {
577 "-".to_string()
578 } else {
579 emails.join(",")
580 },
581 if phones.is_empty() {
582 "-".to_string()
583 } else {
584 phones.join(",")
585 }
586 ),
587 RecordData::Id {
588 id_type,
589 number,
590 expiry,
591 ..
592 } => format!(
593 "type={} number={} expiry={}",
594 id_type.as_deref().unwrap_or("-"),
595 number.as_deref().unwrap_or("-"),
596 expiry.as_deref().unwrap_or("-")
597 ),
598 RecordData::Note { .. } => "secure note".to_string(),
599 RecordData::Bank {
600 institution,
601 account_name,
602 routing_number,
603 ..
604 } => format!(
605 "institution={} account={} routing={}",
606 institution.as_deref().unwrap_or("-"),
607 account_name.as_deref().unwrap_or("-"),
608 routing_number.as_deref().unwrap_or("-")
609 ),
610 RecordData::Wifi {
611 ssid,
612 security,
613 location,
614 ..
615 } => format!(
616 "ssid={} security={} location={}",
617 ssid.as_deref().unwrap_or("-"),
618 security.as_deref().unwrap_or("-"),
619 location.as_deref().unwrap_or("-")
620 ),
621 RecordData::Api {
622 service,
623 environment,
624 scopes,
625 ..
626 } => format!(
627 "service={} env={} scopes={}",
628 service.as_deref().unwrap_or("-"),
629 environment.as_deref().unwrap_or("-"),
630 if scopes.is_empty() {
631 "-".to_string()
632 } else {
633 scopes.join(",")
634 }
635 ),
636 RecordData::Wallet {
637 asset,
638 address,
639 network,
640 ..
641 } => format!(
642 "asset={} address={} network={}",
643 asset.as_deref().unwrap_or("-"),
644 address.as_deref().unwrap_or("-"),
645 network.as_deref().unwrap_or("-")
646 ),
647 RecordData::Totp {
648 issuer,
649 account,
650 digits,
651 step,
652 ..
653 } => format!(
654 "issuer={} account={} digits={} step={}",
655 issuer.as_deref().unwrap_or("-"),
656 account.as_deref().unwrap_or("-"),
657 digits,
658 step
659 ),
660 RecordData::Ssh { label, comment, .. } => format!(
661 "label={} comment={}",
662 label.as_deref().unwrap_or("-"),
663 comment.as_deref().unwrap_or("-")
664 ),
665 RecordData::Pgp {
666 label, fingerprint, ..
667 } => format!(
668 "label={} fingerprint={}",
669 label.as_deref().unwrap_or("-"),
670 fingerprint.as_deref().unwrap_or("-")
671 ),
672 RecordData::Recovery { description, .. } => {
673 format!("description={}", description.as_deref().unwrap_or("-"))
674 }
675 }
676 }
677}
678
679#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
680struct Sensitive {
681 #[serde(with = "serde_bytes")]
682 data: Vec<u8>,
683}
684
685impl Sensitive {
686 fn new_from_utf8(value: &[u8]) -> Self {
687 Self {
688 data: value.to_vec(),
689 }
690 }
691
692 fn from_string(value: &str) -> Self {
693 Self::new_from_utf8(value.as_bytes())
694 }
695
696 fn as_slice(&self) -> &[u8] {
697 &self.data
698 }
699
700 fn expose_utf8(&self) -> Result<String> {
701 Ok(String::from_utf8(self.data.clone())?)
702 }
703}
704
705impl Drop for Sensitive {
706 fn drop(&mut self) {
707 self.data.zeroize();
708 }
709}
710
711impl ZeroizeOnDrop for Sensitive {}
712
713#[derive(Serialize, Deserialize, Clone)]
714struct VaultFile {
715 version: u32,
716 header: VaultHeader,
717 payload: AeadBlob,
718}
719
720#[derive(Serialize, Deserialize, Clone)]
721struct VaultHeader {
722 created_at: DateTime<Utc>,
723 updated_at: DateTime<Utc>,
724 argon: ArgonState,
725 kem_public: Vec<u8>,
726 kem_ciphertext: Vec<u8>,
727 sealed_decapsulation: AeadBlob,
728 sealed_dek: AeadBlob,
729}
730
731#[derive(Serialize, Deserialize, Clone)]
732struct ArgonState {
733 mem_cost_kib: u32,
734 time_cost: u32,
735 lanes: u32,
736 salt: [u8; 32],
737}
738
739#[derive(Serialize, Deserialize, Clone)]
740struct AeadBlob {
741 nonce: [u8; 24],
742 ciphertext: Vec<u8>,
743}
744
745#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
746struct VaultPayload {
747 records: Vec<Record>,
748 record_counter: u64,
749}
750
751struct Vault {
752 path: PathBuf,
753 file: VaultFile,
754 payload: VaultPayload,
755 dek: Zeroizing<[u8; 32]>,
756}
757
758impl Vault {
759 fn init(path: &Path, passphrase: &Zeroizing<String>, mem_kib: u32) -> Result<()> {
760 if path.exists() {
761 bail!("vault already exists at {}", path.display());
762 }
763
764 if mem_kib < 32_768 {
765 bail!("mem-kib must be at least 32768 (32 MiB)");
766 }
767
768 let mut salt = [0u8; 32];
769 OsRng.fill_bytes(&mut salt);
770
771 let argon = ArgonState {
772 mem_cost_kib: mem_kib,
773 time_cost: DEFAULT_TIME_COST,
774 lanes: DEFAULT_LANES,
775 salt,
776 };
777
778 let kek = derive_kek(passphrase, &argon)?;
779
780 let (dk, ek) = <MlKem1024 as KemCore>::generate(&mut OsRng);
781 let (kem_ct, shared_key) = ek
782 .encapsulate(&mut OsRng)
783 .map_err(|e| anyhow!("ml-kem encapsulate failed: {e:?}"))?;
784
785 let mut dek_bytes = [0u8; 32];
786 OsRng.fill_bytes(&mut dek_bytes);
787 let dek = Zeroizing::new(dek_bytes);
788
789 let sealed_dek = encrypt_blob(shared_key.as_slice(), dek.as_slice(), AAD_DEK)?;
790
791 let dk_bytes = dk.as_bytes();
792 let sealed_decapsulation = encrypt_blob(kek.as_slice(), dk_bytes.as_slice(), AAD_DK)?;
793
794 let payload = VaultPayload {
795 records: Vec::new(),
796 record_counter: 0,
797 };
798 let payload_blob = encrypt_payload(dek.as_ref(), &payload)?;
799
800 let header = VaultHeader {
801 created_at: Utc::now(),
802 updated_at: Utc::now(),
803 argon,
804 kem_public: ek.as_bytes().to_vec(),
805 kem_ciphertext: kem_ct.to_vec(),
806 sealed_decapsulation,
807 sealed_dek,
808 };
809
810 let file = VaultFile {
811 version: VAULT_VERSION,
812 header,
813 payload: payload_blob,
814 };
815
816 write_vault(path, &file)
817 }
818
819 fn load(path: &Path, passphrase: &Zeroizing<String>) -> Result<Self> {
820 if !path.exists() {
821 bail!("vault not initialized at {}", path.display());
822 }
823 let mut file = OpenOptions::new()
824 .read(true)
825 .open(path)
826 .with_context(|| format!("failed to open vault at {}", path.display()))?;
827
828 let mut buf = Zeroizing::new(Vec::new());
829 file.read_to_end(&mut buf)?;
830 let vault_file: VaultFile = from_reader(buf.as_slice()).context("failed to parse vault")?;
831
832 if vault_file.version != VAULT_VERSION {
833 bail!("unsupported vault version {}", vault_file.version);
834 }
835
836 let kek = derive_kek(passphrase, &vault_file.header.argon)?;
837 let dk_bytes = Zeroizing::new(decrypt_blob(
838 kek.as_slice(),
839 &vault_file.header.sealed_decapsulation,
840 AAD_DK,
841 )?);
842 type DecapKey = <MlKem1024 as KemCore>::DecapsulationKey;
843 let dk_encoded = expect_encoded::<DecapKey>(dk_bytes.as_slice(), "decapsulation key")?;
844 let dk = DecapKey::from_bytes(&dk_encoded);
845
846 let kem_ct = expect_ciphertext(&vault_file.header.kem_ciphertext)?;
847 let shared = dk
848 .decapsulate(&kem_ct)
849 .map_err(|_| anyhow!("ml-kem decapsulation failed"))?;
850
851 let dek_bytes = Zeroizing::new(decrypt_blob(
852 shared.as_slice(),
853 &vault_file.header.sealed_dek,
854 AAD_DEK,
855 )?);
856 if dek_bytes.len() != 32 {
857 bail!("invalid dek length");
858 }
859 let mut dek_array = [0u8; 32];
860 dek_array.copy_from_slice(dek_bytes.as_slice());
861 let dek = Zeroizing::new(dek_array);
862
863 let payload: VaultPayload = decrypt_payload(dek.as_ref(), &vault_file.payload)?;
864
865 Ok(Self {
866 path: path.to_path_buf(),
867 file: vault_file,
868 payload,
869 dek,
870 })
871 }
872
873 fn save(&mut self, passphrase: &Zeroizing<String>) -> Result<()> {
874 self.file.header.updated_at = Utc::now();
875
876 let payload_blob = encrypt_payload(self.dek.as_ref(), &self.payload)?;
877 self.file.payload = payload_blob;
878
879 let kek = derive_kek(passphrase, &self.file.header.argon)?;
880
881 let dk_bytes = Zeroizing::new(decrypt_blob(
882 kek.as_slice(),
883 &self.file.header.sealed_decapsulation,
884 AAD_DK,
885 )?);
886 type DecapKey = <MlKem1024 as KemCore>::DecapsulationKey;
887 let dk_encoded = expect_encoded::<DecapKey>(dk_bytes.as_slice(), "decapsulation key")?;
888 let dk = DecapKey::from_bytes(&dk_encoded);
889 let (kem_ct, shared) = {
890 type EncKey = <MlKem1024 as KemCore>::EncapsulationKey;
891 let ek_encoded =
892 expect_encoded::<EncKey>(&self.file.header.kem_public, "encapsulation key")?;
893 let ek = EncKey::from_bytes(&ek_encoded);
894 ek.encapsulate(&mut OsRng)
895 .map_err(|e| anyhow!("ml-kem encapsulate failed: {e:?}"))?
896 };
897
898 let sealed_dek = encrypt_blob(shared.as_slice(), self.dek.as_ref(), AAD_DEK)?;
899 let sealed_decapsulation = encrypt_blob(kek.as_slice(), dk.as_bytes().as_slice(), AAD_DK)?;
900
901 self.file.header.kem_ciphertext = kem_ct.to_vec();
902 self.file.header.sealed_dek = sealed_dek;
903 self.file.header.sealed_decapsulation = sealed_decapsulation;
904
905 write_vault(&self.path, &self.file)
906 }
907
908 fn add_record(&mut self, record: Record) {
909 self.payload.record_counter = self.payload.record_counter.saturating_add(1);
910 self.payload.records.push(record);
911 }
912
913 fn list(
914 &self,
915 kind: Option<RecordKind>,
916 tag: Option<&str>,
917 query: Option<&str>,
918 ) -> Vec<&Record> {
919 self.payload
920 .records
921 .iter()
922 .filter(|rec| kind.map(|k| rec.kind() == k).unwrap_or(true))
923 .filter(|rec| tag.map(|t| rec.matches_tag(t)).unwrap_or(true))
924 .filter(|rec| query.map(|q| rec.matches_query(q)).unwrap_or(true))
925 .collect()
926 }
927
928 fn get(&mut self, id: Uuid) -> Option<&mut Record> {
929 self.payload.records.iter_mut().find(|rec| rec.id == id)
930 }
931
932 fn get_ref(&self, id: Uuid) -> Option<&Record> {
933 self.payload.records.iter().find(|rec| rec.id == id)
934 }
935
936 fn rotate(&mut self, passphrase: &Zeroizing<String>, mem_kib: Option<u32>) -> Result<()> {
937 if let Some(mem) = mem_kib {
938 if mem < 32_768 {
939 bail!("mem-kib must be at least 32768 (32 MiB)");
940 }
941 self.file.header.argon.mem_cost_kib = mem;
942 OsRng.fill_bytes(&mut self.file.header.argon.salt);
943 }
944
945 let (dk, ek) = <MlKem1024 as KemCore>::generate(&mut OsRng);
946 let (kem_ct, shared_key) = ek
947 .encapsulate(&mut OsRng)
948 .map_err(|e| anyhow!("ml-kem encapsulate failed: {e:?}"))?;
949
950 let kek = derive_kek(passphrase, &self.file.header.argon)?;
951 let sealed_dek = encrypt_blob(shared_key.as_slice(), self.dek.as_ref(), AAD_DEK)?;
952 let sealed_decapsulation = encrypt_blob(kek.as_slice(), dk.as_bytes().as_slice(), AAD_DK)?;
953
954 self.file.header.kem_public = ek.as_bytes().to_vec();
955 self.file.header.kem_ciphertext = kem_ct.to_vec();
956 self.file.header.sealed_dek = sealed_dek;
957 self.file.header.sealed_decapsulation = sealed_decapsulation;
958
959 Ok(())
960 }
961
962 fn stats(&self) -> VaultStats {
963 VaultStats {
964 created_at: self.file.header.created_at,
965 updated_at: self.file.header.updated_at,
966 record_count: self.payload.records.len(),
967 argon_mem_kib: self.file.header.argon.mem_cost_kib,
968 argon_time_cost: self.file.header.argon.time_cost,
969 argon_lanes: self.file.header.argon.lanes,
970 }
971 }
972}
973
974impl Drop for Vault {
975 fn drop(&mut self) {
976 self.dek.zeroize();
977 }
978}
979
980struct VaultStats {
981 created_at: DateTime<Utc>,
982 updated_at: DateTime<Utc>,
983 record_count: usize,
984 argon_mem_kib: u32,
985 argon_time_cost: u32,
986 argon_lanes: u32,
987}
988
989fn derive_kek(passphrase: &Zeroizing<String>, argon: &ArgonState) -> Result<Zeroizing<[u8; 32]>> {
990 let params = Params::new(argon.mem_cost_kib, argon.time_cost, argon.lanes, Some(32))
991 .map_err(|e| anyhow!("invalid Argon2 parameters: {e}"))?;
992 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
993 let mut output = Zeroizing::new([0u8; 32]);
994 argon2
995 .hash_password_into(passphrase.as_bytes(), &argon.salt, output.as_mut())
996 .map_err(|e| anyhow!("argon2 derivation failed: {e}"))?;
997 Ok(output)
998}
999
1000fn expect_encoded<T>(data: &[u8], label: &str) -> Result<Encoded<T>>
1001where
1002 T: EncodedSizeUser,
1003{
1004 let expected = <T as EncodedSizeUser>::EncodedSize::USIZE;
1005 Array::try_from_iter(data.iter().copied()).map_err(|_| {
1006 anyhow!(
1007 "invalid {label} length: expected {expected}, got {}",
1008 data.len()
1009 )
1010 })
1011}
1012
1013fn expect_ciphertext(data: &[u8]) -> Result<Ciphertext<MlKem1024>> {
1014 let expected = <MlKem1024 as KemCore>::CiphertextSize::USIZE;
1015 Array::try_from_iter(data.iter().copied()).map_err(|_| {
1016 anyhow!(
1017 "invalid ciphertext length: expected {expected}, got {}",
1018 data.len()
1019 )
1020 })
1021}
1022
1023fn encrypt_blob(key: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<AeadBlob> {
1024 let mut nonce = [0u8; 24];
1025 OsRng.fill_bytes(&mut nonce);
1026 let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
1027 let ciphertext = cipher
1028 .encrypt(
1029 XNonce::from_slice(&nonce),
1030 Payload {
1031 msg: plaintext,
1032 aad,
1033 },
1034 )
1035 .map_err(|_| anyhow!("encryption failed"))?;
1036 Ok(AeadBlob { nonce, ciphertext })
1037}
1038
1039fn decrypt_blob(key: &[u8], blob: &AeadBlob, aad: &[u8]) -> Result<Vec<u8>> {
1040 let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
1041 let plaintext = cipher
1042 .decrypt(
1043 XNonce::from_slice(&blob.nonce),
1044 Payload {
1045 msg: &blob.ciphertext,
1046 aad,
1047 },
1048 )
1049 .map_err(|_| anyhow!("decryption failed"))?;
1050 Ok(plaintext)
1051}
1052
1053fn encrypt_payload(dek: &[u8], payload: &VaultPayload) -> Result<AeadBlob> {
1054 let mut buf = Vec::new();
1055 into_writer(payload, &mut buf).context("failed to serialize payload")?;
1056 encrypt_blob(dek, &buf, AAD_PAYLOAD)
1057}
1058
1059fn decrypt_payload(dek: &[u8], blob: &AeadBlob) -> Result<VaultPayload> {
1060 let plaintext = decrypt_blob(dek, blob, AAD_PAYLOAD)?;
1061 let payload: VaultPayload =
1062 from_reader(plaintext.as_slice()).context("failed to parse payload")?;
1063 Ok(payload)
1064}
1065
1066fn vault_path() -> Result<PathBuf> {
1067 if let Ok(path) = env::var("BLACK_BAG_VAULT_PATH") {
1068 let pb = PathBuf::from(path);
1069 if let Some(parent) = pb.parent() {
1070 fs::create_dir_all(parent)
1071 .with_context(|| format!("failed to create {}", parent.display()))?;
1072 }
1073 return Ok(pb);
1074 }
1075 let base = BaseDirs::new().ok_or_else(|| anyhow!("unable to resolve base directory"))?;
1076 let dir = base.config_dir().join("black_bag");
1077 fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
1078 Ok(dir.join("vault.cbor"))
1079}
1080
1081fn prompt_passphrase(prompt: &str) -> Result<Zeroizing<String>> {
1082 let value = prompt_password(prompt)?;
1083 if value.trim().is_empty() {
1084 bail!("passphrase cannot be empty");
1085 }
1086 Ok(Zeroizing::new(value))
1087}
1088
1089fn init_vault(cmd: InitCommand) -> Result<()> {
1090 let path = vault_path()?;
1091 let pass1 = prompt_passphrase("Master passphrase: ")?;
1092 let pass2 = prompt_passphrase("Confirm passphrase: ")?;
1093 if pass1.as_str() != pass2.as_str() {
1094 bail!("passphrases do not match");
1095 }
1096 Vault::init(&path, &pass1, cmd.mem_kib)?;
1097 println!("Initialized vault at {}", path.display());
1098 Ok(())
1099}
1100
1101fn load_vault_with_prompt() -> Result<(Vault, Zeroizing<String>)> {
1102 let path = vault_path()?;
1103 let pass = prompt_passphrase("Master passphrase: ")?;
1104 let vault = Vault::load(&path, &pass)?;
1105 Ok((vault, pass))
1106}
1107
1108fn add_record(cmd: AddCommand) -> Result<()> {
1109 let (mut vault, pass) = load_vault_with_prompt()?;
1110
1111 let record = match cmd.record {
1112 AddRecord::Login(args) => {
1113 let CommonRecordArgs { title, tags, notes } = args.common;
1114 let password = prompt_hidden("Password: ")?;
1115 Record::new(
1116 RecordData::Login {
1117 username: args.username,
1118 url: args.url,
1119 password,
1120 },
1121 title,
1122 tags,
1123 notes,
1124 )
1125 }
1126 AddRecord::Contact(args) => {
1127 let CommonRecordArgs { title, tags, notes } = args.common;
1128 Record::new(
1129 RecordData::Contact {
1130 full_name: args.full_name,
1131 emails: args.emails,
1132 phones: args.phones,
1133 },
1134 title,
1135 tags,
1136 notes,
1137 )
1138 }
1139 AddRecord::Id(args) => {
1140 let CommonRecordArgs { title, tags, notes } = args.common;
1141 let secret = prompt_optional_hidden("Sensitive document secret (optional): ")?;
1142 Record::new(
1143 RecordData::Id {
1144 id_type: args.id_type,
1145 name_on_doc: args.name_on_doc,
1146 number: args.number,
1147 issuing_country: args.issuing_country,
1148 expiry: args.expiry,
1149 secret,
1150 },
1151 title,
1152 tags,
1153 notes,
1154 )
1155 }
1156 AddRecord::Note(args) => {
1157 let CommonRecordArgs { title, tags, notes } = args.common;
1158 let body = prompt_multiline("Secure note body (Ctrl-D to finish): ")?;
1159 Record::new(RecordData::Note { body }, title, tags, notes)
1160 }
1161 AddRecord::Bank(args) => {
1162 let CommonRecordArgs { title, tags, notes } = args.common;
1163 let account_number = prompt_hidden("Account number / secret: ")?;
1164 Record::new(
1165 RecordData::Bank {
1166 institution: args.institution,
1167 account_name: args.account_name,
1168 routing_number: args.routing_number,
1169 account_number,
1170 },
1171 title,
1172 tags,
1173 notes,
1174 )
1175 }
1176 AddRecord::Wifi(args) => {
1177 let CommonRecordArgs { title, tags, notes } = args.common;
1178 let passphrase = prompt_hidden("Wi-Fi passphrase: ")?;
1179 Record::new(
1180 RecordData::Wifi {
1181 ssid: args.ssid,
1182 security: args.security,
1183 location: args.location,
1184 passphrase,
1185 },
1186 title,
1187 tags,
1188 notes,
1189 )
1190 }
1191 AddRecord::Api(args) => {
1192 let CommonRecordArgs { title, tags, notes } = args.common;
1193 let secret = prompt_hidden("Secret key: ")?;
1194 Record::new(
1195 RecordData::Api {
1196 service: args.service,
1197 environment: args.environment,
1198 access_key: args.access_key,
1199 secret_key: secret,
1200 scopes: args.scopes,
1201 },
1202 title,
1203 tags,
1204 notes,
1205 )
1206 }
1207 AddRecord::Wallet(args) => {
1208 let CommonRecordArgs { title, tags, notes } = args.common;
1209 let secret = prompt_hidden("Wallet secret material: ")?;
1210 Record::new(
1211 RecordData::Wallet {
1212 asset: args.asset,
1213 address: args.address,
1214 network: args.network,
1215 secret_key: secret,
1216 },
1217 title,
1218 tags,
1219 notes,
1220 )
1221 }
1222 AddRecord::Totp(args) => {
1223 let AddTotp {
1224 common,
1225 issuer,
1226 account,
1227 secret,
1228 digits,
1229 step,
1230 skew,
1231 algorithm,
1232 } = args;
1233 if !(6..=8).contains(&digits) {
1234 bail!("digits must be between 6 and 8");
1235 }
1236 if step == 0 {
1237 bail!("step must be greater than zero");
1238 }
1239 let CommonRecordArgs { title, tags, notes } = common;
1240 let secret_bytes = match secret {
1241 Some(s) => parse_totp_secret(&s)?,
1242 None => {
1243 let input = prompt_hidden("Base32 secret: ")?;
1244 let value = Zeroizing::new(input.expose_utf8()?);
1245 parse_totp_secret(value.as_str())?
1246 }
1247 };
1248 let totp_secret = Sensitive { data: secret_bytes };
1249 build_totp_instance(
1250 &totp_secret,
1251 digits,
1252 step,
1253 skew,
1254 algorithm,
1255 &issuer,
1256 &account,
1257 )?;
1258 Record::new(
1259 RecordData::Totp {
1260 issuer,
1261 account,
1262 secret: totp_secret,
1263 digits,
1264 step,
1265 skew,
1266 algorithm,
1267 },
1268 title,
1269 tags,
1270 notes,
1271 )
1272 }
1273 AddRecord::Ssh(args) => {
1274 let CommonRecordArgs { title, tags, notes } = args.common;
1275 let private_key = prompt_multiline_hidden("Paste private key (Ctrl-D to finish): ")?;
1276 Record::new(
1277 RecordData::Ssh {
1278 label: args.label,
1279 private_key,
1280 comment: args.comment,
1281 },
1282 title,
1283 tags,
1284 notes,
1285 )
1286 }
1287 AddRecord::Pgp(args) => {
1288 let CommonRecordArgs { title, tags, notes } = args.common;
1289 let armored =
1290 prompt_multiline_hidden("Paste armored private key (Ctrl-D to finish): ")?;
1291 Record::new(
1292 RecordData::Pgp {
1293 label: args.label,
1294 fingerprint: args.fingerprint,
1295 armored_private_key: armored,
1296 },
1297 title,
1298 tags,
1299 notes,
1300 )
1301 }
1302 AddRecord::Recovery(args) => {
1303 let CommonRecordArgs { title, tags, notes } = args.common;
1304 let payload = prompt_multiline_hidden("Paste recovery payload (Ctrl-D to finish): ")?;
1305 Record::new(
1306 RecordData::Recovery {
1307 description: args.description,
1308 payload,
1309 },
1310 title,
1311 tags,
1312 notes,
1313 )
1314 }
1315 };
1316
1317 vault.add_record(record);
1318 vault.save(&pass)?;
1319 println!("Record added");
1320 Ok(())
1321}
1322fn list_records(cmd: ListCommand) -> Result<()> {
1323 let (vault, _) = load_vault_with_prompt()?;
1324 let list = vault.list(cmd.kind, cmd.tag.as_deref(), cmd.query.as_deref());
1325 if list.is_empty() {
1326 println!("No matching records");
1327 return Ok(());
1328 }
1329 for record in list {
1330 println!(
1331 "{} | {} | {} | tags=[{}] | {}",
1332 record.id,
1333 record.kind(),
1334 record.title.as_deref().unwrap_or("(untitled)"),
1335 if record.tags.is_empty() {
1336 String::new()
1337 } else {
1338 record.tags.join(",")
1339 },
1340 record.data.summary_text()
1341 );
1342 }
1343 Ok(())
1344}
1345
1346fn get_record(cmd: GetCommand) -> Result<()> {
1347 let (mut vault, _) = load_vault_with_prompt()?;
1348 if let Some(record) = vault.get(cmd.id) {
1349 println!("id: {}", record.id);
1350 println!("kind: {}", record.kind());
1351 if let Some(title) = &record.title {
1352 println!("title: {}", title);
1353 }
1354 if !record.tags.is_empty() {
1355 println!("tags: {}", record.tags.join(","));
1356 }
1357 if let Some(notes) = &record.metadata_notes {
1358 println!("notes: {}", notes);
1359 }
1360 if cmd.reveal {
1361 if !io::stdout().is_terminal() {
1362 bail!("--reveal requires an interactive TTY");
1363 }
1364 render_sensitive(record)?;
1365 } else {
1366 println!("(Sensitive fields hidden; re-run with --reveal on a TTY)");
1367 }
1368 } else {
1369 bail!("record {} not found", cmd.id);
1370 }
1371 Ok(())
1372}
1373
1374fn render_sensitive(record: &Record) -> Result<()> {
1375 match &record.data {
1376 RecordData::Login { password, .. } => {
1377 println!("password: {}", password.expose_utf8()?);
1378 }
1379 RecordData::Contact { .. } => {}
1380 RecordData::Id { secret, .. } => {
1381 if let Some(secret) = secret {
1382 println!("secret: {}", secret.expose_utf8()?);
1383 }
1384 }
1385 RecordData::Note { body } => {
1386 println!("note:\n{}", body.expose_utf8()?);
1387 }
1388 RecordData::Bank { account_number, .. } => {
1389 println!("account_number: {}", account_number.expose_utf8()?);
1390 }
1391 RecordData::Wifi { passphrase, .. } => {
1392 println!("passphrase: {}", passphrase.expose_utf8()?);
1393 }
1394 RecordData::Api { secret_key, .. } => {
1395 println!("secret_key: {}", secret_key.expose_utf8()?);
1396 }
1397 RecordData::Wallet { secret_key, .. } => {
1398 println!("secret_key: {}", secret_key.expose_utf8()?);
1399 }
1400 RecordData::Totp {
1401 issuer,
1402 account,
1403 secret,
1404 digits,
1405 step,
1406 skew,
1407 algorithm,
1408 } => {
1409 let base32 = base32::encode(Rfc4648 { padding: false }, secret.as_slice());
1410 println!("secret_base32: {}", base32);
1411 if let Some(issuer) = issuer {
1412 println!("issuer: {}", issuer);
1413 }
1414 if let Some(account) = account {
1415 println!("account: {}", account);
1416 }
1417 println!("digits: {}", digits);
1418 println!("step: {}s", step);
1419 println!("skew: {}", skew);
1420 println!("algorithm: {}", algorithm);
1421 if let Ok(totp) =
1422 build_totp_instance(secret, *digits, *step, *skew, *algorithm, issuer, account)
1423 {
1424 if let Ok(code) = totp.generate_current() {
1425 println!("current_code: {}", code);
1426 if let Ok(ttl) = totp.ttl() {
1427 println!("ttl: {}s", ttl);
1428 }
1429 }
1430 }
1431 }
1432 RecordData::Ssh { private_key, .. } => {
1433 println!("private_key:\n{}", private_key.expose_utf8()?);
1434 }
1435 RecordData::Pgp {
1436 armored_private_key,
1437 ..
1438 } => {
1439 println!("pgp_private_key:\n{}", armored_private_key.expose_utf8()?);
1440 }
1441 RecordData::Recovery { payload, .. } => {
1442 println!("recovery_payload:\n{}", payload.expose_utf8()?);
1443 }
1444 }
1445 Ok(())
1446}
1447
1448fn parse_totp_secret(input: &str) -> Result<Vec<u8>> {
1449 let cleaned: String = input
1450 .chars()
1451 .filter(|c| !c.is_whitespace() && *c != '-')
1452 .collect();
1453 if cleaned.is_empty() {
1454 bail!("secret cannot be empty");
1455 }
1456 let encoded = cleaned.to_uppercase();
1457 TotpSecret::Encoded(encoded)
1458 .to_bytes()
1459 .map_err(|_| anyhow!("invalid base32-encoded secret"))
1460}
1461
1462fn build_totp_instance(
1463 secret: &Sensitive,
1464 digits: u8,
1465 step: u64,
1466 skew: u8,
1467 algorithm: TotpAlgorithm,
1468 issuer: &Option<String>,
1469 account: &Option<String>,
1470) -> Result<TOTP> {
1471 let account_name = account.clone().unwrap_or_default();
1472 TOTP::new(
1473 algorithm.to_lib(),
1474 usize::from(digits),
1475 skew,
1476 step,
1477 secret.as_slice().to_vec(),
1478 issuer.clone(),
1479 account_name,
1480 )
1481 .map_err(|err| anyhow!("failed to construct TOTP: {err}"))
1482}
1483
1484fn split_secret(secret: &[u8], threshold: u8, share_count: u8) -> Result<Vec<Vec<u8>>> {
1485 if threshold == 0 {
1486 bail!("threshold must be at least 1");
1487 }
1488 if share_count < threshold {
1489 bail!("share count must be greater than or equal to threshold");
1490 }
1491 if share_count == 0 {
1492 bail!("share count must be at least 1");
1493 }
1494 let mut polys = Vec::with_capacity(secret.len());
1495 for &byte in secret {
1496 let mut poly = vec![byte];
1497 if threshold > 1 {
1498 let mut coeffs = vec![0u8; threshold.saturating_sub(1) as usize];
1499 OsRng.fill_bytes(&mut coeffs);
1500 poly.extend_from_slice(&coeffs);
1501 }
1502 polys.push(poly);
1503 }
1504
1505 let mut shares = Vec::with_capacity(share_count as usize);
1506 for x in 1..=share_count {
1507 let mut share = Vec::with_capacity(secret.len() + 1);
1508 share.push(x);
1509 for poly in &polys {
1510 share.push(eval_poly(poly, x));
1511 }
1512 shares.push(share);
1513 }
1514 Ok(shares)
1515}
1516
1517fn combine_secret(threshold: u8, shares: &[Vec<u8>]) -> Result<Vec<u8>> {
1518 if threshold == 0 {
1519 bail!("threshold must be at least 1");
1520 }
1521 if shares.len() < threshold as usize {
1522 bail!("not enough shares provided");
1523 }
1524 let first = shares
1525 .first()
1526 .ok_or_else(|| anyhow!("no shares provided"))?;
1527 if first.len() < 2 {
1528 bail!("share payload too short");
1529 }
1530 let share_len = first.len();
1531 for share in shares {
1532 if share.len() != share_len {
1533 bail!("shares have mismatched lengths");
1534 }
1535 }
1536
1537 let mut seen = HashSet::new();
1538 let mut usable = Vec::new();
1539 for share in shares {
1540 let id = share[0];
1541 if id == 0 {
1542 bail!("share identifier must be non-zero");
1543 }
1544 if seen.insert(id) {
1545 usable.push((id, share[1..].to_vec()));
1546 if usable.len() == threshold as usize {
1547 break;
1548 }
1549 }
1550 }
1551 if usable.len() < threshold as usize {
1552 bail!("not enough unique shares to reconstruct secret");
1553 }
1554
1555 let secret_len = share_len - 1;
1556 let mut secret = vec![0u8; secret_len];
1557 for idx in 0..secret_len {
1558 let points: Vec<(u8, u8)> = usable.iter().map(|(id, bytes)| (*id, bytes[idx])).collect();
1559 secret[idx] = interpolate_at_zero(&points)?;
1560 }
1561 Ok(secret)
1562}
1563
1564fn eval_poly(poly: &[u8], x: u8) -> u8 {
1565 let mut acc = 0u8;
1566 for &coeff in poly.iter().rev() {
1567 acc = gf_mul(acc, x) ^ coeff;
1568 }
1569 acc
1570}
1571
1572fn interpolate_at_zero(points: &[(u8, u8)]) -> Result<u8> {
1573 let mut result = 0u8;
1574 for (i, &(xi, yi)) in points.iter().enumerate() {
1575 let mut numerator = 1u8;
1576 let mut denominator = 1u8;
1577 for (j, &(xj, _)) in points.iter().enumerate() {
1578 if i == j {
1579 continue;
1580 }
1581 numerator = gf_mul(numerator, xj);
1582 let denom = xi ^ xj;
1583 if denom == 0 {
1584 bail!("duplicate share identifiers encountered");
1585 }
1586 denominator = gf_mul(denominator, denom);
1587 }
1588 let inv = gf_inv(denominator)?;
1589 let li = gf_mul(numerator, inv);
1590 result ^= gf_mul(yi, li);
1591 }
1592 Ok(result)
1593}
1594
1595fn gf_mul(mut a: u8, mut b: u8) -> u8 {
1596 let mut p = 0u8;
1597 while b != 0 {
1598 if b & 1 != 0 {
1599 p ^= a;
1600 }
1601 let hi_bit = a & 0x80;
1602 a <<= 1;
1603 if hi_bit != 0 {
1604 a ^= 0x1b;
1605 }
1606 b >>= 1;
1607 }
1608 p
1609}
1610
1611fn gf_pow(mut base: u8, mut exp: u16) -> u8 {
1612 let mut result = 1u8;
1613 while exp != 0 {
1614 if exp & 1 != 0 {
1615 result = gf_mul(result, base);
1616 }
1617 base = gf_mul(base, base);
1618 exp >>= 1;
1619 }
1620 result
1621}
1622
1623fn gf_inv(x: u8) -> Result<u8> {
1624 if x == 0 {
1625 bail!("encountered zero denominator during interpolation");
1626 }
1627 Ok(gf_pow(x, 254))
1628}
1629
1630fn rotate_vault(cmd: RotateCommand) -> Result<()> {
1631 let (mut vault, pass) = load_vault_with_prompt()?;
1632 vault.rotate(&pass, cmd.mem_kib)?;
1633 vault.save(&pass)?;
1634 println!("Rotation complete");
1635 Ok(())
1636}
1637
1638fn doctor(cmd: DoctorCommand) -> Result<()> {
1639 let (vault, _) = load_vault_with_prompt()?;
1640 let stats = vault.stats();
1641 if cmd.json {
1642 let payload = json!({
1643 "ready": true,
1644 "recordCount": stats.record_count,
1645 "argonMemKib": stats.argon_mem_kib,
1646 "argonTimeCost": stats.argon_time_cost,
1647 "argonLanes": stats.argon_lanes,
1648 "createdAt": stats.created_at.to_rfc3339(),
1649 "updatedAt": stats.updated_at.to_rfc3339(),
1650 });
1651 println!("{}", payload);
1652 } else {
1653 println!("status: ready");
1654 println!("records: {}", stats.record_count);
1655 println!("created: {}", stats.created_at);
1656 println!("updated: {}", stats.updated_at);
1657 println!(
1658 "argon2: mem={} KiB, time={}, lanes={}",
1659 stats.argon_mem_kib, stats.argon_time_cost, stats.argon_lanes
1660 );
1661 }
1662 Ok(())
1663}
1664
1665fn recovery(cmd: RecoveryCommand) -> Result<()> {
1666 match cmd {
1667 RecoveryCommand::Split(args) => {
1668 if args.threshold == 0 || args.threshold > args.shares {
1669 bail!("threshold must be between 1 and number of shares");
1670 }
1671 let secret = prompt_hidden("Secret to split: ")?;
1672 let shares = split_secret(secret.as_slice(), args.threshold, args.shares)?;
1673 for share in shares {
1674 let (id, data) = share
1675 .split_first()
1676 .ok_or_else(|| anyhow!("invalid share structure"))?;
1677 let encoded = BASE64.encode(data);
1678 println!("{}-{}", id, encoded);
1679 }
1680 Ok(())
1681 }
1682 RecoveryCommand::Combine(args) => {
1683 if args.threshold == 0 {
1684 bail!("threshold must be at least 1");
1685 }
1686 let mut shares = Vec::new();
1687 for part in args
1688 .shares
1689 .split(',')
1690 .map(str::trim)
1691 .filter(|s| !s.is_empty())
1692 {
1693 let (id, data) = part
1694 .split_once('-')
1695 .ok_or_else(|| anyhow!("invalid share format: {part}"))?;
1696 let identifier: u8 = id.parse().context("invalid share identifier")?;
1697 if identifier == 0 {
1698 bail!("share identifier must be between 1 and 255");
1699 }
1700 let mut decoded = BASE64.decode(data).context("invalid base64 in share")?;
1701 if decoded.is_empty() {
1702 bail!("share payload cannot be empty");
1703 }
1704 let mut share = Vec::with_capacity(decoded.len() + 1);
1705 share.push(identifier);
1706 share.append(&mut decoded);
1707 shares.push(share);
1708 }
1709 if shares.len() < args.threshold as usize {
1710 bail!(
1711 "insufficient shares provided (need at least {})",
1712 args.threshold
1713 );
1714 }
1715 let secret = combine_secret(args.threshold, &shares)?;
1716 println!("{}", String::from_utf8_lossy(&secret));
1717 Ok(())
1718 }
1719 }
1720}
1721
1722fn totp(cmd: TotpCommand) -> Result<()> {
1723 match cmd {
1724 TotpCommand::Code(args) => totp_code(args),
1725 }
1726}
1727
1728fn totp_code(args: TotpCodeCommand) -> Result<()> {
1729 let (vault, _) = load_vault_with_prompt()?;
1730 let record = vault
1731 .get_ref(args.id)
1732 .ok_or_else(|| anyhow!("record {} not found", args.id))?;
1733 let (issuer, account, secret, digits, step, skew, algorithm) = match &record.data {
1734 RecordData::Totp {
1735 issuer,
1736 account,
1737 secret,
1738 digits,
1739 step,
1740 skew,
1741 algorithm,
1742 } => (issuer, account, secret, *digits, *step, *skew, *algorithm),
1743 _ => bail!("record {} is not a TOTP secret", args.id),
1744 };
1745
1746 let totp = build_totp_instance(secret, digits, step, skew, algorithm, issuer, account)?;
1747 let code = if let Some(ts) = args.time {
1748 if ts < 0 {
1749 bail!("time must be non-negative");
1750 }
1751 totp.generate(ts as u64)
1752 } else {
1753 totp.generate_current()?
1754 };
1755 println!("code: {}", code);
1756 if args.time.is_none() {
1757 let ttl = totp.ttl()?;
1758 println!("ttl: {}s", ttl);
1759 }
1760 Ok(())
1761}
1762
1763fn self_test() -> Result<()> {
1764 let mut sample = [0u8; 32];
1765 OsRng.fill_bytes(&mut sample);
1766 let secret = Sensitive::new_from_utf8(&sample);
1767 let record = Record::new(
1768 RecordData::Note { body: secret },
1769 Some("Self-test".into()),
1770 vec!["selftest".into()],
1771 None,
1772 );
1773 let payload = VaultPayload {
1774 records: vec![record],
1775 record_counter: 1,
1776 };
1777
1778 let mut dek = [0u8; 32];
1779 OsRng.fill_bytes(&mut dek);
1780 let blob = encrypt_payload(&dek, &payload)?;
1781 let recovered = decrypt_payload(&dek, &blob)?;
1782 anyhow::ensure!(recovered.records.len() == 1, "self-test failed");
1783 println!("Self-test passed");
1784 Ok(())
1785}
1786
1787fn prompt_hidden(prompt: &str) -> Result<Sensitive> {
1788 let value = Zeroizing::new(prompt_password(prompt)?);
1789 Ok(Sensitive::from_string(value.as_str()))
1790}
1791
1792fn prompt_optional_hidden(prompt: &str) -> Result<Option<Sensitive>> {
1793 let value = Zeroizing::new(prompt_password(prompt)?);
1794 if value.trim().is_empty() {
1795 Ok(None)
1796 } else {
1797 Ok(Some(Sensitive::from_string(value.as_str())))
1798 }
1799}
1800
1801fn prompt_multiline(prompt: &str) -> Result<Sensitive> {
1802 eprintln!("{}", prompt);
1803 read_multiline(false)
1804}
1805
1806fn prompt_multiline_hidden(prompt: &str) -> Result<Sensitive> {
1807 eprintln!("{}", prompt);
1808 read_multiline(true)
1809}
1810
1811fn read_multiline(_hidden: bool) -> Result<Sensitive> {
1812 let mut buffer = Zeroizing::new(Vec::new());
1813 io::stdin().read_to_end(&mut buffer)?;
1814 while buffer.last().copied() == Some(b'\n') {
1815 buffer.pop();
1816 }
1817 Ok(Sensitive::new_from_utf8(&buffer))
1818}
1819
1820#[cfg(feature = "fuzzing")]
1821pub fn fuzz_try_payload(bytes: &[u8]) {
1822 let _ = ciborium::de::from_reader::<VaultPayload, _>(bytes);
1823}
1824
1825fn write_vault(path: &Path, file: &VaultFile) -> Result<()> {
1826 let parent = path.parent().ok_or_else(|| anyhow!("invalid vault path"))?;
1827 fs::create_dir_all(parent)?;
1828 let mut tmp = NamedTempFile::new_in(parent)?;
1829 into_writer(file, &mut tmp).context("failed to serialize vault")?;
1830 tmp.as_file_mut().sync_all()?;
1831 #[cfg(unix)]
1832 {
1833 use std::os::unix::fs::PermissionsExt;
1834 tmp.as_file_mut()
1835 .set_permissions(fs::Permissions::from_mode(0o600))?;
1836 }
1837 tmp.persist(path)?;
1838 Ok(())
1839}
1840
1841impl fmt::Display for RecordKind {
1842 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1843 let label = match self {
1844 RecordKind::Login => "login",
1845 RecordKind::Contact => "contact",
1846 RecordKind::Id => "id",
1847 RecordKind::Note => "note",
1848 RecordKind::Bank => "bank",
1849 RecordKind::Wifi => "wifi",
1850 RecordKind::Api => "api",
1851 RecordKind::Wallet => "wallet",
1852 RecordKind::Totp => "totp",
1853 RecordKind::Ssh => "ssh",
1854 RecordKind::Pgp => "pgp",
1855 RecordKind::Recovery => "recovery",
1856 };
1857 f.write_str(label)
1858 }
1859}
1860
1861#[cfg(test)]
1862mod tests {
1863 use super::*;
1864 use proptest::prelude::*;
1865 use proptest::proptest;
1866 use proptest::strategy::Strategy;
1867 use serial_test::serial;
1868 use std::env;
1869 use std::path::PathBuf;
1870 use tempfile::tempdir;
1871
1872 fn prepare(passphrase: &str) -> Result<(tempfile::TempDir, PathBuf, Zeroizing<String>)> {
1873 let dir = tempdir()?;
1874 let vault_path = dir.path().join("vault.cbor");
1875 env::set_var("BLACK_BAG_VAULT_PATH", &vault_path);
1876 let pass = Zeroizing::new(passphrase.to_string());
1877 Ok((dir, vault_path, pass))
1878 }
1879
1880 fn cleanup() {
1881 env::remove_var("BLACK_BAG_VAULT_PATH");
1882 }
1883
1884 fn arb_ascii_string(max: usize) -> impl Strategy<Value = String> {
1885 proptest::collection::vec(proptest::char::range('a', 'z'), 0..=max)
1886 .prop_map(|chars| chars.into_iter().collect())
1887 }
1888
1889 fn arb_note_record() -> impl Strategy<Value = Record> {
1890 (
1891 proptest::option::of(arb_ascii_string(12)),
1892 proptest::collection::vec(arb_ascii_string(8), 0..3),
1893 arb_ascii_string(48),
1894 )
1895 .prop_map(|(title, tags, body)| {
1896 Record::new(
1897 RecordData::Note {
1898 body: Sensitive::from_string(&body),
1899 },
1900 title,
1901 tags,
1902 None,
1903 )
1904 })
1905 }
1906
1907 proptest! {
1908 #[test]
1909 fn encrypt_blob_roundtrip_prop(
1910 key_bytes in proptest::array::uniform32(any::<u8>()),
1911 data in proptest::collection::vec(any::<u8>(), 0..256),
1912 aad in proptest::collection::vec(any::<u8>(), 0..32),
1913 ) {
1914 let blob = encrypt_blob(&key_bytes, &data, &aad).unwrap();
1915 let decrypted = decrypt_blob(&key_bytes, &blob, &aad).unwrap();
1916 prop_assert_eq!(decrypted, data);
1917 }
1918
1919 #[test]
1920 fn payload_roundtrip_prop(
1921 key_bytes in proptest::array::uniform32(any::<u8>()),
1922 records in proptest::collection::vec(arb_note_record(), 0..3),
1923 ) {
1924 let payload = VaultPayload {
1925 records: records.clone(),
1926 record_counter: records.len() as u64,
1927 };
1928 let blob = encrypt_payload(&key_bytes, &payload).unwrap();
1929 let decoded = decrypt_payload(&key_bytes, &blob).unwrap();
1930 prop_assert_eq!(decoded, payload);
1931 }
1932 }
1933
1934 #[test]
1935 #[serial]
1936 fn vault_round_trip_note() -> Result<()> {
1937 let (_tmp, vault_path, pass) = prepare("correct horse battery staple")?;
1938 Vault::init(&vault_path, &pass, 32_768)?;
1939
1940 let mut vault = Vault::load(&vault_path, &pass)?;
1941 let record = Record::new(
1942 RecordData::Note {
1943 body: Sensitive::from_string("mission ops"),
1944 },
1945 Some("Ops Note".into()),
1946 vec!["mission".into()],
1947 None,
1948 );
1949 let record_id = record.id;
1950 vault.add_record(record);
1951 vault.save(&pass)?;
1952
1953 drop(vault);
1954 let vault = Vault::load(&vault_path, &pass)?;
1955 let notes = vault.list(Some(RecordKind::Note), None, None);
1956 assert_eq!(notes.len(), 1);
1957 assert_eq!(notes[0].id, record_id);
1958 assert_eq!(notes[0].title.as_deref(), Some("Ops Note"));
1959 assert!(notes[0].matches_tag("mission"));
1960
1961 cleanup();
1962 Ok(())
1963 }
1964
1965 #[test]
1966 #[serial]
1967 fn totp_round_trip() -> Result<()> {
1968 let (_tmp, vault_path, pass) = prepare("totp-pass")?;
1969 Vault::init(&vault_path, &pass, 32_768)?;
1970
1971 let secret_bytes = parse_totp_secret("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")?;
1972 let mut vault = Vault::load(&vault_path, &pass)?;
1973 let record = Record::new(
1974 RecordData::Totp {
1975 issuer: Some("TestIssuer".into()),
1976 account: Some("test@example".into()),
1977 secret: Sensitive { data: secret_bytes },
1978 digits: 6,
1979 step: 30,
1980 skew: 1,
1981 algorithm: TotpAlgorithm::Sha1,
1982 },
1983 Some("TOTP".into()),
1984 vec![],
1985 None,
1986 );
1987 let record_id = record.id;
1988 vault.add_record(record);
1989 vault.save(&pass)?;
1990
1991 drop(vault);
1992 let vault = Vault::load(&vault_path, &pass)?;
1993 let record = vault
1994 .get_ref(record_id)
1995 .ok_or_else(|| anyhow!("TOTP record missing"))?;
1996 let code = match &record.data {
1997 RecordData::Totp {
1998 issuer,
1999 account,
2000 secret,
2001 digits,
2002 step,
2003 skew,
2004 algorithm,
2005 } => build_totp_instance(secret, *digits, *step, *skew, *algorithm, issuer, account)?
2006 .generate(59),
2007 _ => bail!("expected totp record"),
2008 };
2009 let expected = TOTP::new(
2010 TotpAlgorithmLib::SHA1,
2011 6,
2012 1,
2013 30,
2014 parse_totp_secret("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")?,
2015 Some("TestIssuer".into()),
2016 "test@example".into(),
2017 )
2018 .unwrap()
2019 .generate(59);
2020 assert_eq!(code, expected);
2021
2022 cleanup();
2023 Ok(())
2024 }
2025
2026 #[test]
2027 #[serial]
2028 fn vault_rotation_changes_wrapped_keys() -> Result<()> {
2029 let (_tmp, vault_path, pass) = prepare("rotate-all-the-things")?;
2030 Vault::init(&vault_path, &pass, 32_768)?;
2031
2032 let mut vault = Vault::load(&vault_path, &pass)?;
2033 let record = Record::new(
2034 RecordData::Api {
2035 service: Some("intel-api".into()),
2036 environment: Some("prod".into()),
2037 access_key: Some("AKIA-123".into()),
2038 secret_key: Sensitive::from_string("super-secret"),
2039 scopes: vec!["read".into(), "write".into()],
2040 },
2041 Some("API".into()),
2042 vec!["read".into()],
2043 None,
2044 );
2045 vault.add_record(record);
2046 let before = vault.file.header.sealed_dek.ciphertext.clone();
2047 vault.rotate(&pass, Some(65_536))?;
2048 vault.save(&pass)?;
2049 let after = vault.file.header.sealed_dek.ciphertext.clone();
2050 assert_ne!(before, after);
2051
2052 drop(vault);
2053 let vault = Vault::load(&vault_path, &pass)?;
2054 let apis = vault.list(Some(RecordKind::Api), Some("read"), None);
2055 assert_eq!(apis.len(), 1);
2056 assert!(apis[0].data.summary_text().contains("intel-api"));
2057
2058 cleanup();
2059 Ok(())
2060 }
2061
2062 #[test]
2063 fn recovery_split_combine_roundtrip() -> Result<()> {
2064 let secret = b"ultra-secret";
2065 let shares = split_secret(secret, 3, 5)?;
2066 let recovered = combine_secret(3, &shares)?;
2067 assert_eq!(recovered, secret);
2068 Ok(())
2069 }
2070}