1use std::collections::HashSet;
2use std::convert::TryInto;
3use std::env;
4use std::ffi::OsString;
5use std::fmt;
6use std::fs::{self, File, OpenOptions};
7use std::io::{self, Read, Write};
8use std::path::{Path, PathBuf};
9
10use anyhow::{anyhow, bail, Context, Result};
11use argon2::{Algorithm, Argon2, Params, Version};
12use base32::Alphabet::Rfc4648;
13use base64::engine::general_purpose::STANDARD as BASE64;
14use base64::Engine as _;
15use chacha20poly1305::aead::{Aead, KeyInit, Payload};
16use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
17use chrono::{DateTime, Utc};
18use ciborium::{de::from_reader, ser::into_writer};
19use clap::{Args, Parser, Subcommand, ValueEnum};
20use directories::BaseDirs;
21use ed25519_dalek::Signer;
22use ed25519_dalek::{
23 Signature as Ed25519Signature, SigningKey as Ed25519SigningKey, Verifier,
24 VerifyingKey as Ed25519VerifyingKey,
25};
26use fd_lock::{RwLock, RwLockReadGuard, RwLockWriteGuard};
27use is_terminal::IsTerminal;
28use kem::{Decapsulate, Encapsulate};
29use ml_kem::{array::Array, Ciphertext, Encoded, EncodedSizeUser, KemCore, MlKem1024};
30use rand::{rngs::OsRng, RngCore};
31use rpassword::prompt_password;
32use serde::{Deserialize, Serialize};
33use serde_json::json;
34use tempfile::NamedTempFile;
35use sharks::{Sharks, Share};
36use totp_rs::{Algorithm as TotpAlgorithmLib, Secret as TotpSecret, TOTP};
37use typenum::Unsigned;
38use uuid::Uuid;
39use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
40
41#[cfg(all(unix, feature = "mlock"))]
42mod memlock {
43 use libc::{mlock, munlock};
44 use std::io;
45
46 #[inline]
47 pub fn lock_region(ptr: *const u8, len: usize) -> io::Result<()> {
48 if len == 0 {
49 return Ok(());
50 }
51 let rc = unsafe { mlock(ptr as *const _, len) };
52 if rc == 0 { Ok(()) } else { Err(io::Error::last_os_error()) }
53 }
54
55 #[inline]
56 pub fn unlock_region(ptr: *const u8, len: usize) {
57 if len == 0 {
58 return;
59 }
60 let _ = unsafe { munlock(ptr as *const _, len) };
61 }
62
63 pub struct RegionGuard {
64 ptr: *const u8,
65 len: usize,
66 }
67
68 impl RegionGuard {
69 pub fn new(ptr: *const u8, len: usize) -> Option<Self> {
70 match lock_region(ptr, len) {
71 Ok(()) => Some(Self { ptr, len }),
72 Err(_) => None,
73 }
74 }
75 }
76
77 impl Drop for RegionGuard {
78 fn drop(&mut self) {
79 unlock_region(self.ptr, self.len);
80 }
81 }
82}
83
84#[cfg(unix)]
85use std::mem::MaybeUninit;
86#[cfg(unix)]
87use std::os::unix::io::AsRawFd;
88
89#[cfg(feature = "pq")]
90use pqcrypto_mldsa::mldsa87::{
91 self, DetachedSignature as MlDsaDetachedSignature, PublicKey as MlDsaPublicKey,
92 SecretKey as MlDsaSecretKey,
93};
94#[cfg(feature = "pq")]
95use pqcrypto_traits::sign::{DetachedSignature as _, PublicKey as _, SecretKey as _};
96
97use subtle::ConstantTimeEq;
98
99const VAULT_VERSION: u32 = 1;
100const AAD_DEK: &[u8] = b"black-bag::sealed-dek";
101const AAD_DK: &[u8] = b"black-bag::sealed-dk";
102const AAD_PAYLOAD: &[u8] = b"black-bag::payload";
103const DEFAULT_TIME_COST: u32 = 3;
104const DEFAULT_LANES: u32 = 1;
105
106#[cfg(feature = "pq")]
107const SIG_CTX: &[u8] = b"black-bag::backup-integrity::v1";
108
109pub fn run() -> Result<()> {
110 let cli = Cli::parse();
111 match cli.command {
112 Command::Init(cmd) => init_vault(cmd),
113 Command::Add(cmd) => add_record(cmd),
114 Command::List(cmd) => list_records(cmd),
115 Command::Get(cmd) => get_record(cmd),
116 Command::Rotate(cmd) => rotate_vault(cmd),
117 Command::Doctor(cmd) => doctor(cmd),
118 Command::Recovery { command } => recovery(command),
119 Command::Totp { command } => totp(command),
120 Command::Backup { command } => backup(command),
121 Command::Version => show_version(),
122 Command::Selftest => self_test(),
123 }
124}
125
126#[derive(Parser)]
127#[command(name = "black-bag", version, about = "Ultra-secure zero-trace CLI vault", long_about = None)]
128struct Cli {
129 #[command(subcommand)]
130 command: Command,
131}
132
133#[derive(Subcommand)]
134enum Command {
135 Init(InitCommand),
137 Add(AddCommand),
139 List(ListCommand),
141 Get(GetCommand),
143 Rotate(RotateCommand),
145 Doctor(DoctorCommand),
147 Recovery {
149 #[command(subcommand)]
150 command: RecoveryCommand,
151 },
152 Totp {
154 #[command(subcommand)]
155 command: TotpCommand,
156 },
157 Backup {
159 #[command(subcommand)]
160 command: BackupCommand,
161 },
162 Version,
164 Selftest,
166}
167
168#[derive(Args)]
169struct InitCommand {
170 #[arg(long, default_value_t = 262_144)]
172 mem_kib: u32,
173}
174
175#[derive(Args)]
176struct ListCommand {
177 #[arg(long, value_enum)]
179 kind: Option<RecordKind>,
180 #[arg(long)]
182 tag: Option<String>,
183 #[arg(long)]
185 query: Option<String>,
186}
187
188#[derive(Args)]
189struct GetCommand {
190 id: Uuid,
192 #[arg(long)]
194 reveal: bool,
195}
196
197#[derive(Args, Default)]
198struct RotateCommand {
199 #[arg(long)]
201 mem_kib: Option<u32>,
202}
203
204#[derive(Args)]
205struct DoctorCommand {
206 #[arg(long)]
208 json: bool,
209}
210
211#[derive(Subcommand)]
212enum RecoveryCommand {
213 Split(RecoverySplitCommand),
215 Combine(RecoveryCombineCommand),
217}
218
219#[derive(Args)]
220struct RecoverySplitCommand {
221 #[arg(long, default_value_t = 3)]
223 threshold: u8,
224 #[arg(long, default_value_t = 5)]
226 shares: u8,
227}
228
229#[derive(Args)]
230struct RecoveryCombineCommand {
231 #[arg(long)]
233 threshold: u8,
234 #[arg(long)]
236 shares: String,
237}
238
239#[derive(Subcommand)]
240enum TotpCommand {
241 Code(TotpCodeCommand),
243}
244
245#[derive(Args)]
246struct TotpCodeCommand {
247 id: Uuid,
249 #[arg(long)]
251 time: Option<i64>,
252}
253
254#[derive(Subcommand)]
255enum BackupCommand {
256 Verify(BackupVerifyCommand),
258 Sign(BackupSignCommand),
260 #[cfg(feature = "pq")]
262 Keygen(BackupKeygenCommand),
263}
264
265#[derive(Args)]
266struct BackupVerifyCommand {
267 #[arg(long)]
269 path: PathBuf,
270 #[arg(long)]
272 pub_key: Option<PathBuf>,
273}
274
275#[derive(Args)]
276struct BackupSignCommand {
277 #[arg(long)]
279 path: PathBuf,
280 #[arg(long)]
282 key: PathBuf,
283 #[arg(long)]
285 pub_out: Option<PathBuf>,
286}
287
288#[cfg(feature = "pq")]
289#[derive(Args)]
290struct BackupKeygenCommand {
291 #[arg(long)]
293 pub_out: PathBuf,
294 #[arg(long)]
296 sk_out: PathBuf,
297}
298
299#[derive(Args)]
300struct AddCommand {
301 #[command(subcommand)]
302 record: AddRecord,
303}
304
305#[derive(Subcommand)]
306enum AddRecord {
307 Login(AddLogin),
309 Contact(AddContact),
311 Id(AddIdentity),
313 Note(AddNote),
315 Bank(AddBank),
317 Wifi(AddWifi),
319 Api(AddApi),
321 Wallet(AddWallet),
323 Totp(AddTotp),
325 Ssh(AddSsh),
327 Pgp(AddPgp),
329 Recovery(AddRecovery),
331}
332
333#[derive(Args)]
334struct CommonRecordArgs {
335 #[arg(long)]
337 title: Option<String>,
338 #[arg(long, value_delimiter = ',')]
340 tags: Vec<String>,
341 #[arg(long)]
343 notes: Option<String>,
344}
345
346#[derive(Args)]
347struct AddLogin {
348 #[command(flatten)]
349 common: CommonRecordArgs,
350 #[arg(long)]
351 username: Option<String>,
352 #[arg(long)]
353 url: Option<String>,
354}
355
356#[derive(Args)]
357struct AddContact {
358 #[command(flatten)]
359 common: CommonRecordArgs,
360 #[arg(long, required = true)]
361 full_name: String,
362 #[arg(long, value_delimiter = ',')]
363 emails: Vec<String>,
364 #[arg(long, value_delimiter = ',')]
365 phones: Vec<String>,
366}
367
368#[derive(Args)]
369struct AddIdentity {
370 #[command(flatten)]
371 common: CommonRecordArgs,
372 #[arg(long)]
373 id_type: Option<String>,
374 #[arg(long)]
375 name_on_doc: Option<String>,
376 #[arg(long)]
377 number: Option<String>,
378 #[arg(long)]
379 issuing_country: Option<String>,
380 #[arg(long)]
381 expiry: Option<String>,
382}
383
384#[derive(Args)]
385struct AddNote {
386 #[command(flatten)]
387 common: CommonRecordArgs,
388}
389
390#[derive(Args)]
391struct AddBank {
392 #[command(flatten)]
393 common: CommonRecordArgs,
394 #[arg(long)]
395 institution: Option<String>,
396 #[arg(long)]
397 account_name: Option<String>,
398 #[arg(long)]
399 routing_number: Option<String>,
400}
401
402#[derive(Args)]
403struct AddWifi {
404 #[command(flatten)]
405 common: CommonRecordArgs,
406 #[arg(long)]
407 ssid: Option<String>,
408 #[arg(long)]
409 security: Option<String>,
410 #[arg(long)]
411 location: Option<String>,
412}
413
414#[derive(Args)]
415struct AddApi {
416 #[command(flatten)]
417 common: CommonRecordArgs,
418 #[arg(long)]
419 service: Option<String>,
420 #[arg(long)]
421 environment: Option<String>,
422 #[arg(long)]
423 access_key: Option<String>,
424 #[arg(long, value_delimiter = ',')]
425 scopes: Vec<String>,
426}
427
428#[derive(Args)]
429struct AddWallet {
430 #[command(flatten)]
431 common: CommonRecordArgs,
432 #[arg(long)]
433 asset: Option<String>,
434 #[arg(long)]
435 address: Option<String>,
436 #[arg(long)]
437 network: Option<String>,
438}
439
440#[derive(Args)]
441struct AddTotp {
442 #[command(flatten)]
443 common: CommonRecordArgs,
444 #[arg(long)]
446 issuer: Option<String>,
447 #[arg(long)]
449 account: Option<String>,
450 #[arg(long)]
452 secret: Option<String>,
453 #[arg(long, default_value_t = 6)]
455 digits: u8,
456 #[arg(long, default_value_t = 30)]
458 step: u64,
459 #[arg(long, default_value_t = 1)]
461 skew: u8,
462 #[arg(long, value_enum, default_value_t = TotpAlgorithm::Sha1)]
464 algorithm: TotpAlgorithm,
465}
466
467#[derive(Args)]
468struct AddSsh {
469 #[command(flatten)]
470 common: CommonRecordArgs,
471 #[arg(long)]
472 label: Option<String>,
473 #[arg(long)]
474 comment: Option<String>,
475}
476
477#[derive(Args)]
478struct AddPgp {
479 #[command(flatten)]
480 common: CommonRecordArgs,
481 #[arg(long)]
482 label: Option<String>,
483 #[arg(long)]
484 fingerprint: Option<String>,
485}
486
487#[derive(Args)]
488struct AddRecovery {
489 #[command(flatten)]
490 common: CommonRecordArgs,
491 #[arg(long)]
492 description: Option<String>,
493}
494
495#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Hash)]
496#[serde(rename_all = "snake_case")]
497#[clap(rename_all = "snake_case")]
498enum RecordKind {
499 Login,
500 Contact,
501 Id,
502 Note,
503 Bank,
504 Wifi,
505 Api,
506 Wallet,
507 Totp,
508 Ssh,
509 Pgp,
510 Recovery,
511}
512
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
514#[serde(rename_all = "lowercase")]
515enum TotpAlgorithm {
516 Sha1,
517 Sha256,
518 Sha512,
519}
520
521impl TotpAlgorithm {
522 fn to_lib(self) -> TotpAlgorithmLib {
523 match self {
524 TotpAlgorithm::Sha1 => TotpAlgorithmLib::SHA1,
525 TotpAlgorithm::Sha256 => TotpAlgorithmLib::SHA256,
526 TotpAlgorithm::Sha512 => TotpAlgorithmLib::SHA512,
527 }
528 }
529}
530
531impl fmt::Display for TotpAlgorithm {
532 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
533 match self {
534 TotpAlgorithm::Sha1 => f.write_str("sha1"),
535 TotpAlgorithm::Sha256 => f.write_str("sha256"),
536 TotpAlgorithm::Sha512 => f.write_str("sha512"),
537 }
538 }
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
542struct Record {
543 id: Uuid,
544 created_at: DateTime<Utc>,
545 updated_at: DateTime<Utc>,
546 title: Option<String>,
547 tags: Vec<String>,
548 metadata_notes: Option<String>,
549 data: RecordData,
550}
551
552impl Record {
553 fn new(
554 data: RecordData,
555 title: Option<String>,
556 tags: Vec<String>,
557 notes: Option<String>,
558 ) -> Self {
559 let now = Utc::now();
560 Self {
561 id: Uuid::new_v4(),
562 created_at: now,
563 updated_at: now,
564 title,
565 tags,
566 metadata_notes: notes,
567 data,
568 }
569 }
570
571 fn kind(&self) -> RecordKind {
572 self.data.kind()
573 }
574
575 fn matches_tag(&self, tag: &str) -> bool {
576 let tag_lower = tag.to_ascii_lowercase();
577 self.tags
578 .iter()
579 .any(|t| t.to_ascii_lowercase().contains(&tag_lower))
580 }
581
582 fn matches_query(&self, needle: &str) -> bool {
583 let haystack = [
584 self.title.as_deref().unwrap_or_default(),
585 self.metadata_notes.as_deref().unwrap_or_default(),
586 &self.data.summary_text(),
587 ]
588 .join("\n")
589 .to_ascii_lowercase();
590 haystack.contains(&needle.to_ascii_lowercase())
591 }
592}
593
594#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
595#[serde(tag = "kind", rename_all = "snake_case")]
596enum RecordData {
597 Login {
598 username: Option<String>,
599 url: Option<String>,
600 password: Sensitive,
601 },
602 Contact {
603 full_name: String,
604 emails: Vec<String>,
605 phones: Vec<String>,
606 },
607 Id {
608 id_type: Option<String>,
609 name_on_doc: Option<String>,
610 number: Option<String>,
611 issuing_country: Option<String>,
612 expiry: Option<String>,
613 secret: Option<Sensitive>,
614 },
615 Note {
616 body: Sensitive,
617 },
618 Bank {
619 institution: Option<String>,
620 account_name: Option<String>,
621 routing_number: Option<String>,
622 account_number: Sensitive,
623 },
624 Wifi {
625 ssid: Option<String>,
626 security: Option<String>,
627 location: Option<String>,
628 passphrase: Sensitive,
629 },
630 Api {
631 service: Option<String>,
632 environment: Option<String>,
633 access_key: Option<String>,
634 secret_key: Sensitive,
635 scopes: Vec<String>,
636 },
637 Wallet {
638 asset: Option<String>,
639 address: Option<String>,
640 network: Option<String>,
641 secret_key: Sensitive,
642 },
643 Totp {
644 issuer: Option<String>,
645 account: Option<String>,
646 secret: Sensitive,
647 digits: u8,
648 step: u64,
649 skew: u8,
650 algorithm: TotpAlgorithm,
651 },
652 Ssh {
653 label: Option<String>,
654 private_key: Sensitive,
655 comment: Option<String>,
656 },
657 Pgp {
658 label: Option<String>,
659 fingerprint: Option<String>,
660 armored_private_key: Sensitive,
661 },
662 Recovery {
663 description: Option<String>,
664 payload: Sensitive,
665 },
666}
667
668impl RecordData {
669 fn kind(&self) -> RecordKind {
670 match self {
671 RecordData::Login { .. } => RecordKind::Login,
672 RecordData::Contact { .. } => RecordKind::Contact,
673 RecordData::Id { .. } => RecordKind::Id,
674 RecordData::Note { .. } => RecordKind::Note,
675 RecordData::Bank { .. } => RecordKind::Bank,
676 RecordData::Wifi { .. } => RecordKind::Wifi,
677 RecordData::Api { .. } => RecordKind::Api,
678 RecordData::Wallet { .. } => RecordKind::Wallet,
679 RecordData::Totp { .. } => RecordKind::Totp,
680 RecordData::Ssh { .. } => RecordKind::Ssh,
681 RecordData::Pgp { .. } => RecordKind::Pgp,
682 RecordData::Recovery { .. } => RecordKind::Recovery,
683 }
684 }
685
686 fn summary_text(&self) -> String {
687 match self {
688 RecordData::Login { username, url, .. } => format!(
689 "user={} url={}",
690 username.as_deref().unwrap_or("-"),
691 url.as_deref().unwrap_or("-")
692 ),
693 RecordData::Contact {
694 full_name,
695 emails,
696 phones,
697 } => format!(
698 "{} | emails={} | phones={}",
699 full_name,
700 if emails.is_empty() {
701 "-".to_string()
702 } else {
703 emails.join(",")
704 },
705 if phones.is_empty() {
706 "-".to_string()
707 } else {
708 phones.join(",")
709 }
710 ),
711 RecordData::Id {
712 id_type,
713 number,
714 expiry,
715 ..
716 } => format!(
717 "type={} number={} expiry={}",
718 id_type.as_deref().unwrap_or("-"),
719 number.as_deref().unwrap_or("-"),
720 expiry.as_deref().unwrap_or("-")
721 ),
722 RecordData::Note { .. } => "secure note".to_string(),
723 RecordData::Bank {
724 institution,
725 account_name,
726 routing_number,
727 ..
728 } => format!(
729 "institution={} account={} routing={}",
730 institution.as_deref().unwrap_or("-"),
731 account_name.as_deref().unwrap_or("-"),
732 routing_number.as_deref().unwrap_or("-")
733 ),
734 RecordData::Wifi {
735 ssid,
736 security,
737 location,
738 ..
739 } => format!(
740 "ssid={} security={} location={}",
741 ssid.as_deref().unwrap_or("-"),
742 security.as_deref().unwrap_or("-"),
743 location.as_deref().unwrap_or("-")
744 ),
745 RecordData::Api {
746 service,
747 environment,
748 scopes,
749 ..
750 } => format!(
751 "service={} env={} scopes={}",
752 service.as_deref().unwrap_or("-"),
753 environment.as_deref().unwrap_or("-"),
754 if scopes.is_empty() {
755 "-".to_string()
756 } else {
757 scopes.join(",")
758 }
759 ),
760 RecordData::Wallet {
761 asset,
762 address,
763 network,
764 ..
765 } => format!(
766 "asset={} address={} network={}",
767 asset.as_deref().unwrap_or("-"),
768 address.as_deref().unwrap_or("-"),
769 network.as_deref().unwrap_or("-")
770 ),
771 RecordData::Totp {
772 issuer,
773 account,
774 digits,
775 step,
776 ..
777 } => format!(
778 "issuer={} account={} digits={} step={}",
779 issuer.as_deref().unwrap_or("-"),
780 account.as_deref().unwrap_or("-"),
781 digits,
782 step
783 ),
784 RecordData::Ssh { label, comment, .. } => format!(
785 "label={} comment={}",
786 label.as_deref().unwrap_or("-"),
787 comment.as_deref().unwrap_or("-")
788 ),
789 RecordData::Pgp {
790 label, fingerprint, ..
791 } => format!(
792 "label={} fingerprint={}",
793 label.as_deref().unwrap_or("-"),
794 fingerprint.as_deref().unwrap_or("-")
795 ),
796 RecordData::Recovery { description, .. } => {
797 format!("description={}", description.as_deref().unwrap_or("-"))
798 }
799 }
800 }
801}
802
803#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
804struct Sensitive {
805 #[serde(with = "serde_bytes")]
806 data: Vec<u8>,
807}
808
809impl Sensitive {
810 fn new_from_utf8(value: &[u8]) -> Self {
811 let mut v = value.to_vec();
812 #[cfg(all(unix, feature = "mlock"))]
813 {
814 let _ = memlock::lock_region(v.as_ptr(), v.len());
816 }
817 Self { data: v }
818 }
819
820 fn from_string(value: &str) -> Self {
821 Self::new_from_utf8(value.as_bytes())
822 }
823
824 fn as_slice(&self) -> &[u8] {
825 &self.data
826 }
827
828 fn expose_utf8(&self) -> Result<String> {
829 Ok(String::from_utf8(self.data.clone())?)
830 }
831}
832
833impl Drop for Sensitive {
834 fn drop(&mut self) {
835 self.data.zeroize();
836 #[cfg(all(unix, feature = "mlock"))]
837 {
838 memlock::unlock_region(self.data.as_ptr(), self.data.len());
839 }
840 }
841}
842
843impl ZeroizeOnDrop for Sensitive {}
844
845#[derive(Serialize, Deserialize, Clone)]
846struct VaultFile {
847 version: u32,
848 header: VaultHeader,
849 payload: AeadBlob,
850}
851
852#[derive(Debug, Clone, Copy, PartialEq, Eq)]
853enum VaultAccess {
854 ReadOnly,
855 ReadWrite,
856}
857
858impl VaultAccess {
859 fn requires_exclusive(self) -> bool {
860 matches!(self, VaultAccess::ReadWrite)
861 }
862}
863
864enum VaultLockGuard<'a> {
865 Shared(RwLockReadGuard<'a, File>),
866 Exclusive(RwLockWriteGuard<'a, File>),
867}
868
869#[derive(Serialize, Deserialize, Clone)]
870struct VaultHeader {
871 created_at: DateTime<Utc>,
872 updated_at: DateTime<Utc>,
873 argon: ArgonState,
874 kem_public: Vec<u8>,
875 kem_ciphertext: Vec<u8>,
876 sealed_decapsulation: AeadBlob,
877 sealed_dek: AeadBlob,
878}
879
880#[derive(Serialize, Deserialize, Clone)]
881struct ArgonState {
882 mem_cost_kib: u32,
883 time_cost: u32,
884 lanes: u32,
885 salt: [u8; 32],
886}
887
888#[derive(Serialize, Deserialize, Clone)]
889struct AeadBlob {
890 nonce: [u8; 24],
891 ciphertext: Vec<u8>,
892}
893
894#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
895struct VaultPayload {
896 records: Vec<Record>,
897 record_counter: u64,
898}
899
900struct Vault {
901 path: PathBuf,
902 file: VaultFile,
903 payload: VaultPayload,
904 dek: Box<Zeroizing<[u8; 32]>>,
905}
906
907impl Vault {
908 fn init(path: &Path, passphrase: &Zeroizing<String>, mem_kib: u32) -> Result<()> {
909 if path.exists() {
910 bail!("vault already exists at {}", path.display());
911 }
912
913 if mem_kib < 32_768 {
914 bail!("mem-kib must be at least 32768 (32 MiB)");
915 }
916
917 let mut salt = [0u8; 32];
918 OsRng.fill_bytes(&mut salt);
919
920 let argon = ArgonState {
921 mem_cost_kib: mem_kib,
922 time_cost: DEFAULT_TIME_COST,
923 lanes: DEFAULT_LANES,
924 salt,
925 };
926
927 let kek = derive_kek(passphrase, &argon)?;
928
929 let (dk, ek) = <MlKem1024 as KemCore>::generate(&mut OsRng);
930 let (kem_ct, shared_key) = ek
931 .encapsulate(&mut OsRng)
932 .map_err(|e| anyhow!("ml-kem encapsulate failed: {e:?}"))?;
933
934 let mut dek_bytes = [0u8; 32];
935 OsRng.fill_bytes(&mut dek_bytes);
936 let dek = Box::new(Zeroizing::new(dek_bytes));
937 #[cfg(all(unix, feature = "mlock"))]
938 {
939 let slice: &[u8] = &**dek;
940 let _ = memlock::lock_region(slice.as_ptr(), slice.len());
941 }
942
943 let sealed_dek = encrypt_blob(shared_key.as_slice(), &**dek, AAD_DEK)?;
944
945 let dk_bytes = dk.as_bytes();
946 let sealed_decapsulation = encrypt_blob(kek.as_slice(), dk_bytes.as_slice(), AAD_DK)?;
947
948 let payload = VaultPayload {
949 records: Vec::new(),
950 record_counter: 0,
951 };
952 let payload_blob = encrypt_payload(dek.as_ref().as_ref(), &payload)?;
953
954 let header = VaultHeader {
955 created_at: Utc::now(),
956 updated_at: Utc::now(),
957 argon,
958 kem_public: ek.as_bytes().to_vec(),
959 kem_ciphertext: kem_ct.to_vec(),
960 sealed_decapsulation,
961 sealed_dek,
962 };
963
964 let file = VaultFile {
965 version: VAULT_VERSION,
966 header,
967 payload: payload_blob,
968 };
969
970 write_vault(path, &file)
971 }
972
973 fn load(path: &Path, passphrase: &Zeroizing<String>) -> Result<Self> {
974 if !path.exists() {
975 bail!("vault not initialized at {}", path.display());
976 }
977 let mut file = OpenOptions::new()
978 .read(true)
979 .open(path)
980 .with_context(|| format!("failed to open vault at {}", path.display()))?;
981
982 let mut buf = Zeroizing::new(Vec::new());
983 file.read_to_end(&mut buf)?;
984 let vault_file: VaultFile = from_reader(buf.as_slice()).context("failed to parse vault")?;
985
986 if vault_file.version != VAULT_VERSION {
987 bail!("unsupported vault version {}", vault_file.version);
988 }
989
990 let kek = derive_kek(passphrase, &vault_file.header.argon)?;
991 let dk_bytes = Zeroizing::new(decrypt_blob(
992 kek.as_slice(),
993 &vault_file.header.sealed_decapsulation,
994 AAD_DK,
995 )?);
996 type DecapKey = <MlKem1024 as KemCore>::DecapsulationKey;
997 let dk_encoded = expect_encoded::<DecapKey>(dk_bytes.as_slice(), "decapsulation key")?;
998 let dk = DecapKey::from_bytes(&dk_encoded);
999
1000 let kem_ct = expect_ciphertext(&vault_file.header.kem_ciphertext)?;
1001 let shared = dk
1002 .decapsulate(&kem_ct)
1003 .map_err(|_| anyhow!("ml-kem decapsulation failed"))?;
1004
1005 let dek_bytes = Zeroizing::new(decrypt_blob(
1006 shared.as_slice(),
1007 &vault_file.header.sealed_dek,
1008 AAD_DEK,
1009 )?);
1010 if dek_bytes.len() != 32 {
1011 bail!("invalid dek length");
1012 }
1013 let mut dek_array = [0u8; 32];
1014 dek_array.copy_from_slice(dek_bytes.as_slice());
1015 let dek = Box::new(Zeroizing::new(dek_array));
1016 #[cfg(all(unix, feature = "mlock"))]
1017 {
1018 let slice: &[u8] = &**dek;
1019 let _ = memlock::lock_region(slice.as_ptr(), slice.len());
1020 }
1021
1022 let payload: VaultPayload = decrypt_payload(&**dek, &vault_file.payload)?;
1023
1024 Ok(Self {
1025 path: path.to_path_buf(),
1026 file: vault_file,
1027 payload,
1028 dek,
1029 })
1030 }
1031
1032 fn save(&mut self, passphrase: &Zeroizing<String>) -> Result<()> {
1033 self.file.header.updated_at = Utc::now();
1034
1035 let payload_blob = encrypt_payload(&**self.dek, &self.payload)?;
1036 self.file.payload = payload_blob;
1037
1038 let kek = derive_kek(passphrase, &self.file.header.argon)?;
1039
1040 let dk_bytes = Zeroizing::new(decrypt_blob(
1041 kek.as_slice(),
1042 &self.file.header.sealed_decapsulation,
1043 AAD_DK,
1044 )?);
1045 type DecapKey = <MlKem1024 as KemCore>::DecapsulationKey;
1046 let dk_encoded = expect_encoded::<DecapKey>(dk_bytes.as_slice(), "decapsulation key")?;
1047 let dk = DecapKey::from_bytes(&dk_encoded);
1048 let (kem_ct, shared) = {
1049 type EncKey = <MlKem1024 as KemCore>::EncapsulationKey;
1050 let ek_encoded =
1051 expect_encoded::<EncKey>(&self.file.header.kem_public, "encapsulation key")?;
1052 let ek = EncKey::from_bytes(&ek_encoded);
1053 ek.encapsulate(&mut OsRng)
1054 .map_err(|e| anyhow!("ml-kem encapsulate failed: {e:?}"))?
1055 };
1056
1057 let sealed_dek = encrypt_blob(shared.as_slice(), &**self.dek, AAD_DEK)?;
1058 let sealed_decapsulation = encrypt_blob(kek.as_slice(), dk.as_bytes().as_slice(), AAD_DK)?;
1059
1060 self.file.header.kem_ciphertext = kem_ct.to_vec();
1061 self.file.header.sealed_dek = sealed_dek;
1062 self.file.header.sealed_decapsulation = sealed_decapsulation;
1063
1064 write_vault(&self.path, &self.file)
1065 }
1066
1067 fn add_record(&mut self, record: Record) {
1068 self.payload.record_counter = self.payload.record_counter.saturating_add(1);
1069 self.payload.records.push(record);
1070 }
1071
1072 fn list(
1073 &self,
1074 kind: Option<RecordKind>,
1075 tag: Option<&str>,
1076 query: Option<&str>,
1077 ) -> Vec<&Record> {
1078 self.payload
1079 .records
1080 .iter()
1081 .filter(|rec| kind.map(|k| rec.kind() == k).unwrap_or(true))
1082 .filter(|rec| tag.map(|t| rec.matches_tag(t)).unwrap_or(true))
1083 .filter(|rec| query.map(|q| rec.matches_query(q)).unwrap_or(true))
1084 .collect()
1085 }
1086
1087 fn get_ref(&self, id: Uuid) -> Option<&Record> {
1088 self.payload.records.iter().find(|rec| rec.id == id)
1089 }
1090
1091 fn rotate(&mut self, passphrase: &Zeroizing<String>, mem_kib: Option<u32>) -> Result<()> {
1092 if let Some(mem) = mem_kib {
1093 if mem < 32_768 {
1094 bail!("mem-kib must be at least 32768 (32 MiB)");
1095 }
1096 self.file.header.argon.mem_cost_kib = mem;
1097 OsRng.fill_bytes(&mut self.file.header.argon.salt);
1098 }
1099
1100 let (dk, ek) = <MlKem1024 as KemCore>::generate(&mut OsRng);
1101 let (kem_ct, shared_key) = ek
1102 .encapsulate(&mut OsRng)
1103 .map_err(|e| anyhow!("ml-kem encapsulate failed: {e:?}"))?;
1104
1105 let kek = derive_kek(passphrase, &self.file.header.argon)?;
1106 let sealed_dek = encrypt_blob(shared_key.as_slice(), &**self.dek, AAD_DEK)?;
1107 let sealed_decapsulation = encrypt_blob(kek.as_slice(), dk.as_bytes().as_slice(), AAD_DK)?;
1108
1109 self.file.header.kem_public = ek.as_bytes().to_vec();
1110 self.file.header.kem_ciphertext = kem_ct.to_vec();
1111 self.file.header.sealed_dek = sealed_dek;
1112 self.file.header.sealed_decapsulation = sealed_decapsulation;
1113
1114 Ok(())
1115 }
1116
1117 fn stats(&self) -> VaultStats {
1118 VaultStats {
1119 created_at: self.file.header.created_at,
1120 updated_at: self.file.header.updated_at,
1121 record_count: self.payload.records.len(),
1122 argon_mem_kib: self.file.header.argon.mem_cost_kib,
1123 argon_time_cost: self.file.header.argon.time_cost,
1124 argon_lanes: self.file.header.argon.lanes,
1125 }
1126 }
1127}
1128
1129impl Drop for Vault {
1130 fn drop(&mut self) {
1131 self.dek.as_mut().zeroize();
1132 #[cfg(all(unix, feature = "mlock"))]
1133 {
1134 let slice: &[u8] = &**self.dek;
1135 memlock::unlock_region(slice.as_ptr(), slice.len());
1136 }
1137 }
1138}
1139
1140struct VaultStats {
1141 created_at: DateTime<Utc>,
1142 updated_at: DateTime<Utc>,
1143 record_count: usize,
1144 argon_mem_kib: u32,
1145 argon_time_cost: u32,
1146 argon_lanes: u32,
1147}
1148
1149fn derive_kek(passphrase: &Zeroizing<String>, argon: &ArgonState) -> Result<Zeroizing<[u8; 32]>> {
1150 let params = Params::new(argon.mem_cost_kib, argon.time_cost, argon.lanes, Some(32))
1151 .map_err(|e| anyhow!("invalid Argon2 parameters: {e}"))?;
1152 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
1153 let mut output = Zeroizing::new([0u8; 32]);
1154 argon2
1155 .hash_password_into(passphrase.as_bytes(), &argon.salt, output.as_mut())
1156 .map_err(|e| anyhow!("argon2 derivation failed: {e}"))?;
1157 Ok(output)
1158}
1159
1160fn expect_encoded<T>(data: &[u8], label: &str) -> Result<Encoded<T>>
1161where
1162 T: EncodedSizeUser,
1163{
1164 let expected = <T as EncodedSizeUser>::EncodedSize::USIZE;
1165 Array::try_from_iter(data.iter().copied()).map_err(|_| {
1166 anyhow!(
1167 "invalid {label} length: expected {expected}, got {}",
1168 data.len()
1169 )
1170 })
1171}
1172
1173fn expect_ciphertext(data: &[u8]) -> Result<Ciphertext<MlKem1024>> {
1174 let expected = <MlKem1024 as KemCore>::CiphertextSize::USIZE;
1175 Array::try_from_iter(data.iter().copied()).map_err(|_| {
1176 anyhow!(
1177 "invalid ciphertext length: expected {expected}, got {}",
1178 data.len()
1179 )
1180 })
1181}
1182
1183fn encrypt_blob(key: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<AeadBlob> {
1184 let mut nonce = [0u8; 24];
1185 OsRng.fill_bytes(&mut nonce);
1186 let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
1187 let ciphertext = cipher
1188 .encrypt(
1189 XNonce::from_slice(&nonce),
1190 Payload {
1191 msg: plaintext,
1192 aad,
1193 },
1194 )
1195 .map_err(|_| anyhow!("encryption failed"))?;
1196 Ok(AeadBlob { nonce, ciphertext })
1197}
1198
1199fn decrypt_blob(key: &[u8], blob: &AeadBlob, aad: &[u8]) -> Result<Vec<u8>> {
1200 let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
1201 let plaintext = cipher
1202 .decrypt(
1203 XNonce::from_slice(&blob.nonce),
1204 Payload {
1205 msg: &blob.ciphertext,
1206 aad,
1207 },
1208 )
1209 .map_err(|_| anyhow!("decryption failed"))?;
1210 Ok(plaintext)
1211}
1212
1213fn encrypt_payload(dek: &[u8], payload: &VaultPayload) -> Result<AeadBlob> {
1214 let mut buf = Vec::new();
1215 into_writer(payload, &mut buf).context("failed to serialize payload")?;
1216 encrypt_blob(dek, &buf, AAD_PAYLOAD)
1217}
1218
1219fn decrypt_payload(dek: &[u8], blob: &AeadBlob) -> Result<VaultPayload> {
1220 let plaintext = decrypt_blob(dek, blob, AAD_PAYLOAD)?;
1221 let payload: VaultPayload =
1222 from_reader(plaintext.as_slice()).context("failed to parse payload")?;
1223 Ok(payload)
1224}
1225
1226fn vault_path() -> Result<PathBuf> {
1227 if let Ok(path) = env::var("BLACK_BAG_VAULT_PATH") {
1228 let pb = PathBuf::from(path);
1229 if let Some(parent) = pb.parent() {
1230 fs::create_dir_all(parent)
1231 .with_context(|| format!("failed to create {}", parent.display()))?;
1232 }
1233 return Ok(pb);
1234 }
1235 let base = BaseDirs::new().ok_or_else(|| anyhow!("unable to resolve base directory"))?;
1236 let dir = base.config_dir().join("black_bag");
1237 fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
1238 Ok(dir.join("vault.cbor"))
1239}
1240
1241fn lock_file_for(path: &Path) -> Result<File> {
1242 let mut lock_name: OsString = path.as_os_str().to_os_string();
1243 lock_name.push(".lock");
1244 let lock_path = PathBuf::from(lock_name);
1245 if let Some(parent) = lock_path.parent() {
1246 fs::create_dir_all(parent)
1247 .with_context(|| format!("failed to create {}", parent.display()))?;
1248 }
1249 OpenOptions::new()
1250 .read(true)
1251 .write(true)
1252 .create(true)
1253 .truncate(true)
1254 .open(&lock_path)
1255 .with_context(|| format!("failed to open lock file {}", lock_path.display()))
1256}
1257
1258fn with_vault<F, R>(access: VaultAccess, f: F) -> Result<R>
1259where
1260 F: FnOnce(&mut Vault, &Zeroizing<String>) -> Result<R>,
1261{
1262 let pass = prompt_passphrase("Master passphrase: ")?;
1263 with_vault_with_pass(access, pass, f)
1264}
1265
1266fn with_vault_with_pass<F, R>(access: VaultAccess, pass: Zeroizing<String>, f: F) -> Result<R>
1267where
1268 F: FnOnce(&mut Vault, &Zeroizing<String>) -> Result<R>,
1269{
1270 let path = vault_path()?;
1271
1272 #[cfg(all(unix, feature = "mlock"))]
1273 let _pass_guard = {
1274 let bytes = pass.as_bytes();
1276 memlock::RegionGuard::new(bytes.as_ptr(), bytes.len())
1277 };
1278
1279 let lock_file = lock_file_for(&path)?;
1280 let mut lock = RwLock::new(lock_file);
1281 let guard = if access.requires_exclusive() {
1282 VaultLockGuard::Exclusive(lock.write()?)
1283 } else {
1284 VaultLockGuard::Shared(lock.read()?)
1285 };
1286
1287 let mut vault = Vault::load(&path, &pass)?;
1288 let result = f(&mut vault, &pass);
1289 drop(vault);
1290 match guard {
1291 VaultLockGuard::Shared(g) => drop(g),
1292 VaultLockGuard::Exclusive(g) => drop(g),
1293 }
1294 result
1295}
1296
1297fn prompt_passphrase(prompt: &str) -> Result<Zeroizing<String>> {
1298 let value = prompt_password(prompt)?;
1299 if value.trim().is_empty() {
1300 bail!("passphrase cannot be empty");
1301 }
1302 Ok(Zeroizing::new(value))
1303}
1304
1305fn init_vault(cmd: InitCommand) -> Result<()> {
1306 let path = vault_path()?;
1307 let pass1 = prompt_passphrase("Master passphrase: ")?;
1308 let pass2 = prompt_passphrase("Confirm passphrase: ")?;
1309 if pass1.as_str() != pass2.as_str() {
1310 bail!("passphrases do not match");
1311 }
1312 #[cfg(all(unix, feature = "mlock"))]
1313 let _guards = {
1314 let g1 = memlock::RegionGuard::new(pass1.as_bytes().as_ptr(), pass1.as_bytes().len());
1315 let g2 = memlock::RegionGuard::new(pass2.as_bytes().as_ptr(), pass2.as_bytes().len());
1316 (g1, g2)
1317 };
1318 let lock_file = lock_file_for(&path)?;
1319 let mut lock = RwLock::new(lock_file);
1320 let _guard = lock.write()?;
1321 Vault::init(&path, &pass1, cmd.mem_kib)?;
1322 println!("Initialized vault at {}", path.display());
1323 Ok(())
1324}
1325
1326fn add_record(cmd: AddCommand) -> Result<()> {
1327 let pass = prompt_passphrase("Master passphrase: ")?;
1328 let record = prepare_record(cmd.record)?;
1329 with_vault_with_pass(VaultAccess::ReadWrite, pass, move |vault, pass_ref| {
1330 vault.add_record(record);
1331 vault.save(pass_ref)?;
1332 println!("Record added");
1333 Ok(())
1334 })
1335}
1336
1337fn prepare_record(record: AddRecord) -> Result<Record> {
1338 match record {
1339 AddRecord::Login(args) => {
1340 let CommonRecordArgs { title, tags, notes } = args.common;
1341 let password = prompt_hidden("Password: ")?;
1342 Ok(Record::new(
1343 RecordData::Login {
1344 username: args.username,
1345 url: args.url,
1346 password,
1347 },
1348 title,
1349 tags,
1350 notes,
1351 ))
1352 }
1353 AddRecord::Contact(args) => {
1354 let CommonRecordArgs { title, tags, notes } = args.common;
1355 Ok(Record::new(
1356 RecordData::Contact {
1357 full_name: args.full_name,
1358 emails: args.emails,
1359 phones: args.phones,
1360 },
1361 title,
1362 tags,
1363 notes,
1364 ))
1365 }
1366 AddRecord::Id(args) => {
1367 let CommonRecordArgs { title, tags, notes } = args.common;
1368 let secret = prompt_optional_hidden("Sensitive document secret (optional): ")?;
1369 Ok(Record::new(
1370 RecordData::Id {
1371 id_type: args.id_type,
1372 name_on_doc: args.name_on_doc,
1373 number: args.number,
1374 issuing_country: args.issuing_country,
1375 expiry: args.expiry,
1376 secret,
1377 },
1378 title,
1379 tags,
1380 notes,
1381 ))
1382 }
1383 AddRecord::Note(args) => {
1384 let CommonRecordArgs { title, tags, notes } = args.common;
1385 let body = prompt_multiline("Secure note body (Ctrl-D to finish): ")?;
1386 Ok(Record::new(RecordData::Note { body }, title, tags, notes))
1387 }
1388 AddRecord::Bank(args) => {
1389 let CommonRecordArgs { title, tags, notes } = args.common;
1390 let account_number = prompt_hidden("Account number / secret: ")?;
1391 Ok(Record::new(
1392 RecordData::Bank {
1393 institution: args.institution,
1394 account_name: args.account_name,
1395 routing_number: args.routing_number,
1396 account_number,
1397 },
1398 title,
1399 tags,
1400 notes,
1401 ))
1402 }
1403 AddRecord::Wifi(args) => {
1404 let CommonRecordArgs { title, tags, notes } = args.common;
1405 let passphrase = prompt_hidden("Wi-Fi passphrase: ")?;
1406 Ok(Record::new(
1407 RecordData::Wifi {
1408 ssid: args.ssid,
1409 security: args.security,
1410 location: args.location,
1411 passphrase,
1412 },
1413 title,
1414 tags,
1415 notes,
1416 ))
1417 }
1418 AddRecord::Api(args) => {
1419 let CommonRecordArgs { title, tags, notes } = args.common;
1420 let secret = prompt_hidden("Secret key: ")?;
1421 Ok(Record::new(
1422 RecordData::Api {
1423 service: args.service,
1424 environment: args.environment,
1425 access_key: args.access_key,
1426 secret_key: secret,
1427 scopes: args.scopes,
1428 },
1429 title,
1430 tags,
1431 notes,
1432 ))
1433 }
1434 AddRecord::Wallet(args) => {
1435 let CommonRecordArgs { title, tags, notes } = args.common;
1436 let secret = prompt_hidden("Wallet secret material: ")?;
1437 Ok(Record::new(
1438 RecordData::Wallet {
1439 asset: args.asset,
1440 address: args.address,
1441 network: args.network,
1442 secret_key: secret,
1443 },
1444 title,
1445 tags,
1446 notes,
1447 ))
1448 }
1449 AddRecord::Totp(args) => {
1450 let AddTotp {
1451 common,
1452 issuer,
1453 account,
1454 secret,
1455 digits,
1456 step,
1457 skew,
1458 algorithm,
1459 } = args;
1460 if !(6..=8).contains(&digits) {
1461 bail!("digits must be between 6 and 8");
1462 }
1463 if step == 0 {
1464 bail!("step must be greater than zero");
1465 }
1466 let CommonRecordArgs { title, tags, notes } = common;
1467 let secret_bytes = match secret {
1468 Some(s) => parse_totp_secret(&s)?,
1469 None => {
1470 let input = prompt_hidden("Base32 secret: ")?;
1471 let value = Zeroizing::new(input.expose_utf8()?);
1472 parse_totp_secret(value.as_str())?
1473 }
1474 };
1475 let totp_secret = Sensitive { data: secret_bytes };
1476 build_totp_instance(
1477 &totp_secret,
1478 digits,
1479 step,
1480 skew,
1481 algorithm,
1482 &issuer,
1483 &account,
1484 )?;
1485 Ok(Record::new(
1486 RecordData::Totp {
1487 issuer,
1488 account,
1489 secret: totp_secret,
1490 digits,
1491 step,
1492 skew,
1493 algorithm,
1494 },
1495 title,
1496 tags,
1497 notes,
1498 ))
1499 }
1500 AddRecord::Ssh(args) => {
1501 let CommonRecordArgs { title, tags, notes } = args.common;
1502 let private_key = prompt_multiline_hidden("Paste private key (Ctrl-D to finish): ")?;
1503 Ok(Record::new(
1504 RecordData::Ssh {
1505 label: args.label,
1506 private_key,
1507 comment: args.comment,
1508 },
1509 title,
1510 tags,
1511 notes,
1512 ))
1513 }
1514 AddRecord::Pgp(args) => {
1515 let CommonRecordArgs { title, tags, notes } = args.common;
1516 let armored =
1517 prompt_multiline_hidden("Paste armored private key (Ctrl-D to finish): ")?;
1518 Ok(Record::new(
1519 RecordData::Pgp {
1520 label: args.label,
1521 fingerprint: args.fingerprint,
1522 armored_private_key: armored,
1523 },
1524 title,
1525 tags,
1526 notes,
1527 ))
1528 }
1529 AddRecord::Recovery(args) => {
1530 let CommonRecordArgs { title, tags, notes } = args.common;
1531 let payload = prompt_multiline_hidden("Paste recovery payload (Ctrl-D to finish): ")?;
1532 Ok(Record::new(
1533 RecordData::Recovery {
1534 description: args.description,
1535 payload,
1536 },
1537 title,
1538 tags,
1539 notes,
1540 ))
1541 }
1542 }
1543}
1544fn list_records(cmd: ListCommand) -> Result<()> {
1545 let ListCommand { kind, tag, query } = cmd;
1546 with_vault(VaultAccess::ReadOnly, move |vault, _| {
1547 let list = vault.list(kind, tag.as_deref(), query.as_deref());
1548 if list.is_empty() {
1549 println!("No matching records");
1550 return Ok(());
1551 }
1552 for record in list {
1553 println!(
1554 "{} | {} | {} | tags=[{}] | {}",
1555 record.id,
1556 record.kind(),
1557 record.title.as_deref().unwrap_or("(untitled)"),
1558 if record.tags.is_empty() {
1559 String::new()
1560 } else {
1561 record.tags.join(",")
1562 },
1563 record.data.summary_text()
1564 );
1565 }
1566 Ok(())
1567 })
1568}
1569
1570fn get_record(cmd: GetCommand) -> Result<()> {
1571 let GetCommand { id, reveal } = cmd;
1572 with_vault(VaultAccess::ReadOnly, move |vault, _| {
1573 if let Some(record) = vault.get_ref(id) {
1574 println!("id: {}", record.id);
1575 println!("kind: {}", record.kind());
1576 if let Some(title) = &record.title {
1577 println!("title: {}", title);
1578 }
1579 if !record.tags.is_empty() {
1580 println!("tags: {}", record.tags.join(","));
1581 }
1582 if let Some(notes) = &record.metadata_notes {
1583 println!("notes: {}", notes);
1584 }
1585 if reveal {
1586 if !io::stdout().is_terminal() {
1587 bail!("--reveal requires an interactive TTY");
1588 }
1589 render_sensitive(record)?;
1590 } else {
1591 println!("(Sensitive fields hidden; re-run with --reveal on a TTY)");
1592 }
1593 Ok(())
1594 } else {
1595 bail!("record {} not found", id);
1596 }
1597 })
1598}
1599
1600fn render_sensitive(record: &Record) -> Result<()> {
1601 match &record.data {
1602 RecordData::Login { password, .. } => {
1603 println!("password: {}", password.expose_utf8()?);
1604 }
1605 RecordData::Contact {
1606 full_name,
1607 emails,
1608 phones,
1609 } => {
1610 println!("full_name: {}", full_name);
1611 if emails.is_empty() {
1612 println!("emails: -");
1613 } else {
1614 println!("emails: {}", emails.join(","));
1615 }
1616 if phones.is_empty() {
1617 println!("phones: -");
1618 } else {
1619 println!("phones: {}", phones.join(","));
1620 }
1621 }
1622 RecordData::Id { secret, .. } => {
1623 if let Some(secret) = secret {
1624 println!("secret: {}", secret.expose_utf8()?);
1625 }
1626 }
1627 RecordData::Note { body } => {
1628 println!("note:\n{}", body.expose_utf8()?);
1629 }
1630 RecordData::Bank { account_number, .. } => {
1631 println!("account_number: {}", account_number.expose_utf8()?);
1632 }
1633 RecordData::Wifi { passphrase, .. } => {
1634 println!("passphrase: {}", passphrase.expose_utf8()?);
1635 }
1636 RecordData::Api { secret_key, .. } => {
1637 println!("secret_key: {}", secret_key.expose_utf8()?);
1638 }
1639 RecordData::Wallet { secret_key, .. } => {
1640 println!("secret_key: {}", secret_key.expose_utf8()?);
1641 }
1642 RecordData::Totp {
1643 issuer,
1644 account,
1645 secret,
1646 digits,
1647 step,
1648 skew,
1649 algorithm,
1650 } => {
1651 let base32 = base32::encode(Rfc4648 { padding: false }, secret.as_slice());
1652 println!("secret_base32: {}", base32);
1653 if let Some(issuer) = issuer {
1654 println!("issuer: {}", issuer);
1655 }
1656 if let Some(account) = account {
1657 println!("account: {}", account);
1658 }
1659 println!("digits: {}", digits);
1660 println!("step: {}s", step);
1661 println!("skew: {}", skew);
1662 println!("algorithm: {}", algorithm);
1663 if let Ok(totp) =
1664 build_totp_instance(secret, *digits, *step, *skew, *algorithm, issuer, account)
1665 {
1666 if let Ok(code) = totp.generate_current() {
1667 println!("current_code: {}", code);
1668 if let Ok(ttl) = totp.ttl() {
1669 println!("ttl: {}s", ttl);
1670 }
1671 }
1672 }
1673 }
1674 RecordData::Ssh { private_key, .. } => {
1675 println!("private_key:\n{}", private_key.expose_utf8()?);
1676 }
1677 RecordData::Pgp {
1678 armored_private_key,
1679 ..
1680 } => {
1681 println!("pgp_private_key:\n{}", armored_private_key.expose_utf8()?);
1682 }
1683 RecordData::Recovery { payload, .. } => {
1684 println!("recovery_payload:\n{}", payload.expose_utf8()?);
1685 }
1686 }
1687 Ok(())
1688}
1689
1690fn parse_totp_secret(input: &str) -> Result<Vec<u8>> {
1691 let cleaned: String = input
1692 .chars()
1693 .filter(|c| !c.is_whitespace() && *c != '-')
1694 .collect();
1695 if cleaned.is_empty() {
1696 bail!("secret cannot be empty");
1697 }
1698 let encoded = cleaned.to_uppercase();
1699 TotpSecret::Encoded(encoded)
1700 .to_bytes()
1701 .map_err(|_| anyhow!("invalid base32-encoded secret"))
1702}
1703
1704fn build_totp_instance(
1705 secret: &Sensitive,
1706 digits: u8,
1707 step: u64,
1708 skew: u8,
1709 algorithm: TotpAlgorithm,
1710 _issuer: &Option<String>,
1711 _account: &Option<String>,
1712) -> Result<TOTP> {
1713 TOTP::new(
1714 algorithm.to_lib(),
1715 usize::from(digits),
1716 skew,
1717 step,
1718 secret.as_slice().to_vec(),
1719 )
1720 .map_err(|err| anyhow!("failed to construct TOTP: {err}"))
1721}
1722
1723fn split_secret(secret: &[u8], threshold: u8, share_count: u8) -> Result<Vec<Vec<u8>>> {
1724 if threshold == 0 {
1725 bail!("threshold must be at least 1");
1726 }
1727 if share_count == 0 {
1728 bail!("share count must be at least 1");
1729 }
1730 if share_count < threshold {
1731 bail!("share count must be greater than or equal to threshold");
1732 }
1733 let sharks = Sharks(threshold);
1734 let dealer = sharks.dealer(secret);
1735 let shares: Vec<Share> = dealer.take(share_count as usize).collect();
1736 if shares.len() != share_count as usize {
1737 bail!("failed to generate requested number of shares");
1738 }
1739 Ok(shares.iter().map(|s| Vec::from(s)).collect())
1741}
1742
1743fn combine_secret(threshold: u8, shares: &[Vec<u8>]) -> Result<Vec<u8>> {
1744 if threshold == 0 {
1745 bail!("threshold must be at least 1");
1746 }
1747 if shares.len() < threshold as usize {
1748 bail!("not enough shares provided");
1749 }
1750 let mut seen = HashSet::new();
1752 let mut usable: Vec<Share> = Vec::with_capacity(threshold as usize);
1753 for raw in shares {
1754 if raw.len() < 2 {
1755 bail!("share payload too short");
1756 }
1757 let id = raw[0];
1758 if id == 0 {
1759 bail!("share identifier must be non-zero");
1760 }
1761 if seen.insert(id) {
1762 let s = Share::try_from(raw.as_slice())
1763 .map_err(|_| anyhow!("invalid share bytes"))?;
1764 usable.push(s);
1765 if usable.len() == threshold as usize {
1766 break;
1767 }
1768 }
1769 }
1770 if usable.len() < threshold as usize {
1771 bail!("not enough unique shares to reconstruct secret");
1772 }
1773 let sharks = Sharks(threshold);
1774 sharks
1775 .recover(&usable)
1776 .map_err(|e| anyhow!("failed to reconstruct secret: {e}"))
1777}
1778
1779fn rotate_vault(cmd: RotateCommand) -> Result<()> {
1780 with_vault(VaultAccess::ReadWrite, move |vault, pass| {
1781 vault.rotate(pass, cmd.mem_kib)?;
1782 vault.save(pass)?;
1783 println!("Rotation complete");
1784 Ok(())
1785 })
1786}
1787
1788fn doctor(cmd: DoctorCommand) -> Result<()> {
1789 with_vault(VaultAccess::ReadOnly, move |vault, _| {
1790 let stats = vault.stats();
1791 #[cfg(all(unix, feature = "mlock"))]
1792 let (mlock_enabled, mlock_ok, mlock_error) = {
1793 let mut probe = [0u8; 32];
1794 match memlock::lock_region(probe.as_ptr(), probe.len()) {
1795 Ok(()) => {
1796 memlock::unlock_region(probe.as_ptr(), probe.len());
1797 (true, true, None)
1798 }
1799 Err(e) => (true, false, Some(e.to_string())),
1800 }
1801 };
1802 #[cfg(not(all(unix, feature = "mlock")))]
1803 let (mlock_enabled, mlock_ok, mlock_error) = (false, false, None::<String>);
1804 if cmd.json {
1805 let payload = json!({
1806 "ready": true,
1807 "recordCount": stats.record_count,
1808 "argonMemKib": stats.argon_mem_kib,
1809 "argonTimeCost": stats.argon_time_cost,
1810 "argonLanes": stats.argon_lanes,
1811 "createdAt": stats.created_at.to_rfc3339(),
1812 "updatedAt": stats.updated_at.to_rfc3339(),
1813 "mlock": {
1814 "enabled": mlock_enabled,
1815 "ok": mlock_ok,
1816 "error": mlock_error,
1817 },
1818 });
1819 println!("{}", payload);
1820 } else {
1821 println!("status: ready");
1822 println!("records: {}", stats.record_count);
1823 println!("created: {}", stats.created_at);
1824 println!("updated: {}", stats.updated_at);
1825 println!(
1826 "argon2: mem={} KiB, time={}, lanes={}",
1827 stats.argon_mem_kib, stats.argon_time_cost, stats.argon_lanes
1828 );
1829 if mlock_enabled {
1830 if mlock_ok {
1831 println!("mlock: enabled and working");
1832 } else if let Some(err) = mlock_error {
1833 println!("mlock: enabled but failed ({}). Check OS limits.", err);
1834 }
1835 } else {
1836 println!("mlock: not supported in this build/OS");
1837 }
1838 }
1839 Ok(())
1840 })
1841}
1842
1843fn recovery(cmd: RecoveryCommand) -> Result<()> {
1844 match cmd {
1845 RecoveryCommand::Split(args) => {
1846 if args.threshold == 0 || args.threshold > args.shares {
1847 bail!("threshold must be between 1 and number of shares");
1848 }
1849 let secret = prompt_hidden("Secret to split: ")?;
1850 let shares = split_secret(secret.as_slice(), args.threshold, args.shares)?;
1851 for share in shares {
1852 let (id, data) = share
1853 .split_first()
1854 .ok_or_else(|| anyhow!("invalid share structure"))?;
1855 let encoded = BASE64.encode(data);
1856 println!("{}-{}", id, encoded);
1857 }
1858 Ok(())
1859 }
1860 RecoveryCommand::Combine(args) => {
1861 if args.threshold == 0 {
1862 bail!("threshold must be at least 1");
1863 }
1864 let mut shares = Vec::new();
1865 for part in args
1866 .shares
1867 .split(',')
1868 .map(str::trim)
1869 .filter(|s| !s.is_empty())
1870 {
1871 let (id, data) = part
1872 .split_once('-')
1873 .ok_or_else(|| anyhow!("invalid share format: {part}"))?;
1874 let identifier: u8 = id.parse().context("invalid share identifier")?;
1875 if identifier == 0 {
1876 bail!("share identifier must be between 1 and 255");
1877 }
1878 let mut decoded = BASE64.decode(data).context("invalid base64 in share")?;
1879 if decoded.is_empty() {
1880 bail!("share payload cannot be empty");
1881 }
1882 let mut share = Vec::with_capacity(decoded.len() + 1);
1883 share.push(identifier);
1884 share.append(&mut decoded);
1885 shares.push(share);
1886 }
1887 if shares.len() < args.threshold as usize {
1888 bail!(
1889 "insufficient shares provided (need at least {})",
1890 args.threshold
1891 );
1892 }
1893 let secret = combine_secret(args.threshold, &shares)?;
1894 println!("{}", String::from_utf8_lossy(&secret));
1895 Ok(())
1896 }
1897 }
1898}
1899
1900fn totp(cmd: TotpCommand) -> Result<()> {
1901 match cmd {
1902 TotpCommand::Code(args) => totp_code(args),
1903 }
1904}
1905
1906fn totp_code(args: TotpCodeCommand) -> Result<()> {
1907 let TotpCodeCommand { id, time } = args;
1908 with_vault(VaultAccess::ReadOnly, move |vault, _| {
1909 let record = vault
1910 .get_ref(id)
1911 .ok_or_else(|| anyhow!("record {} not found", id))?;
1912 let (issuer, account, secret, digits, step, skew, algorithm) = match &record.data {
1913 RecordData::Totp {
1914 issuer,
1915 account,
1916 secret,
1917 digits,
1918 step,
1919 skew,
1920 algorithm,
1921 } => (issuer, account, secret, *digits, *step, *skew, *algorithm),
1922 _ => bail!("record {} is not a TOTP secret", id),
1923 };
1924
1925 let totp = build_totp_instance(secret, digits, step, skew, algorithm, issuer, account)?;
1926 let code = if let Some(ts) = time {
1927 if ts < 0 {
1928 bail!("time must be non-negative");
1929 }
1930 totp.generate(ts as u64)
1931 } else {
1932 totp.generate_current()?
1933 };
1934 println!("code: {}", code);
1935 if time.is_none() {
1936 let ttl = totp.ttl()?;
1937 println!("ttl: {}s", ttl);
1938 }
1939 Ok(())
1940 })
1941}
1942
1943fn backup(cmd: BackupCommand) -> Result<()> {
1944 match cmd {
1945 BackupCommand::Verify(args) => backup_verify(args),
1946 BackupCommand::Sign(args) => backup_sign(args),
1947 #[cfg(feature = "pq")]
1948 BackupCommand::Keygen(args) => backup_keygen(args),
1949 }
1950}
1951
1952fn show_version() -> Result<()> {
1953 let version = env!("CARGO_PKG_VERSION");
1954 let profile = option_env!("PROFILE").unwrap_or("unknown");
1955 let target = option_env!("TARGET").unwrap_or("unknown");
1956
1957 let mut features = Vec::new();
1958 if cfg!(feature = "mlock") {
1959 features.push("mlock");
1960 } else {
1961 features.push("no-mlock");
1962 }
1963 if cfg!(feature = "pq") {
1964 features.push("pq");
1965 } else {
1966 features.push("no-pq");
1967 }
1968 if cfg!(feature = "fuzzing") {
1969 features.push("fuzzing");
1970 }
1971 if cfg!(feature = "fhe") {
1972 features.push("fhe");
1973 }
1974
1975 println!("black-bag {version}");
1976 println!("profile: {profile}");
1977 println!("target: {target}");
1978 println!("features: {}", features.join(", "));
1979 Ok(())
1980}
1981
1982fn backup_verify(cmd: BackupVerifyCommand) -> Result<()> {
1983 let bytes = fs::read(&cmd.path)
1984 .with_context(|| format!("failed to read vault at {}", cmd.path.display()))?;
1985 let vault: VaultFile = from_reader(bytes.as_slice()).context("failed to parse vault")?;
1986 let expected = compute_public_integrity_tag(&bytes, &vault.header.kem_public);
1987 let actual = read_integrity_sidecar(&cmd.path)?;
1988 if actual.ct_eq(&expected).unwrap_u8() != 1 {
1989 bail!("integrity check failed: .int does not match vault payload");
1990 }
1991
1992 if let Some(pub_key) = cmd.pub_key.as_deref() {
1993 let sig = read_integrity_signature_sidecar(&cmd.path)?;
1994 verify_integrity_signature(&expected, pub_key, &sig)?;
1995 println!("Integrity and signature verified");
1996 } else {
1997 println!("Integrity verified");
1998 }
1999 Ok(())
2000}
2001
2002fn backup_sign(cmd: BackupSignCommand) -> Result<()> {
2003 let bytes = fs::read(&cmd.path)
2004 .with_context(|| format!("failed to read vault at {}", cmd.path.display()))?;
2005 let vault: VaultFile = from_reader(bytes.as_slice()).context("failed to parse vault")?;
2006 let tag = match read_integrity_sidecar(&cmd.path) {
2007 Ok(tag) => tag,
2008 Err(_) => {
2009 let computed = compute_public_integrity_tag(&bytes, &vault.header.kem_public);
2010 write_integrity_sidecar(&cmd.path, &computed)?;
2011 computed
2012 }
2013 };
2014
2015 let key_bytes = read_key_bytes(&cmd.key)?;
2016 let pub_out = cmd.pub_out.as_deref();
2017
2018 if try_sign_ed25519(&key_bytes, &tag, pub_out, &cmd.path)? {
2019 println!("Wrote Ed25519 signature sidecar");
2020 return Ok(());
2021 }
2022
2023 #[cfg(feature = "pq")]
2024 {
2025 if try_sign_mldsa(&key_bytes, &tag, pub_out, &cmd.path)? {
2026 println!("Wrote ML-DSA-87 signature sidecar");
2027 return Ok(());
2028 }
2029 }
2030
2031 bail!(
2032 "unsupported signing key size: expected 32/64 bytes (Ed25519) or ML-DSA-87 secret key length"
2033 )
2034}
2035
2036#[cfg(feature = "pq")]
2037fn backup_keygen(cmd: BackupKeygenCommand) -> Result<()> {
2038 let (pk, sk) = mldsa87::keypair();
2039 fs::write(&cmd.pub_out, BASE64.encode(pk.as_bytes()))
2040 .with_context(|| format!("failed to write {}", cmd.pub_out.display()))?;
2041 let mut secret_blob = Vec::with_capacity(pk.as_bytes().len() + sk.as_bytes().len());
2042 secret_blob.extend_from_slice(pk.as_bytes());
2043 secret_blob.extend_from_slice(sk.as_bytes());
2044 fs::write(&cmd.sk_out, BASE64.encode(secret_blob))
2045 .with_context(|| format!("failed to write {}", cmd.sk_out.display()))?;
2046 println!("Generated ML-DSA-87 keypair");
2047 Ok(())
2048}
2049
2050fn compute_public_integrity_tag(bytes: &[u8], kem_public: &[u8]) -> [u8; 32] {
2051 let key = blake3::hash(kem_public);
2052 let tag = blake3::keyed_hash(key.as_bytes(), bytes);
2053 let mut out = [0u8; 32];
2054 out.copy_from_slice(tag.as_bytes());
2055 out
2056}
2057
2058fn integrity_sidecar_path(path: &Path) -> PathBuf {
2059 let mut os = path.as_os_str().to_os_string();
2060 os.push(".int");
2061 PathBuf::from(os)
2062}
2063
2064fn integrity_signature_sidecar_path(path: &Path) -> PathBuf {
2065 let mut os = path.as_os_str().to_os_string();
2066 os.push(".int.sig");
2067 PathBuf::from(os)
2068}
2069
2070fn write_integrity_sidecar(path: &Path, tag: &[u8; 32]) -> Result<()> {
2071 let sidecar = integrity_sidecar_path(path);
2072 let mut file =
2073 File::create(&sidecar).with_context(|| format!("failed to write {}", sidecar.display()))?;
2074 writeln!(file, "{}", BASE64.encode(tag))
2075 .with_context(|| format!("failed to write {}", sidecar.display()))
2076}
2077
2078fn read_integrity_sidecar(path: &Path) -> Result<[u8; 32]> {
2079 let sidecar = integrity_sidecar_path(path);
2080 let mut contents = String::new();
2081 File::open(&sidecar)
2082 .with_context(|| format!("integrity sidecar missing at {}", sidecar.display()))?
2083 .read_to_string(&mut contents)
2084 .with_context(|| format!("failed to read {}", sidecar.display()))?;
2085 let trimmed = contents.trim();
2086 if trimmed.is_empty() {
2087 bail!("integrity sidecar at {} is empty", sidecar.display());
2088 }
2089 let decoded = if let Ok(bytes) = BASE64.decode(trimmed) {
2090 bytes
2091 } else if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
2092 hex::decode(trimmed).context("invalid hex in integrity sidecar")?
2093 } else {
2094 bail!(
2095 "integrity sidecar at {} is not valid base64/hex",
2096 sidecar.display()
2097 );
2098 };
2099 if decoded.len() != 32 {
2100 bail!("integrity sidecar length mismatch (expected 32 bytes)");
2101 }
2102 let mut out = [0u8; 32];
2103 out.copy_from_slice(&decoded);
2104 Ok(out)
2105}
2106
2107fn write_integrity_signature_sidecar(path: &Path, sig: &[u8]) -> Result<()> {
2108 let sidecar = integrity_signature_sidecar_path(path);
2109 let mut file =
2110 File::create(&sidecar).with_context(|| format!("failed to write {}", sidecar.display()))?;
2111 writeln!(file, "{}", BASE64.encode(sig))
2112 .with_context(|| format!("failed to write {}", sidecar.display()))
2113}
2114
2115fn read_integrity_signature_sidecar(path: &Path) -> Result<Vec<u8>> {
2116 let sidecar = integrity_signature_sidecar_path(path);
2117 let mut contents = String::new();
2118 File::open(&sidecar)
2119 .with_context(|| format!("signature sidecar missing at {}", sidecar.display()))?
2120 .read_to_string(&mut contents)
2121 .with_context(|| format!("failed to read {}", sidecar.display()))?;
2122 let trimmed = contents.trim();
2123 if trimmed.is_empty() {
2124 bail!("signature sidecar at {} is empty", sidecar.display());
2125 }
2126 BASE64
2127 .decode(trimmed)
2128 .context("signature sidecar is not valid base64")
2129}
2130
2131fn read_key_bytes(path: &Path) -> Result<Vec<u8>> {
2132 let raw = fs::read(path)
2133 .with_context(|| format!("failed to read key material from {}", path.display()))?;
2134 if let Ok(text) = std::str::from_utf8(&raw) {
2135 let trimmed = text.trim();
2136 if trimmed.is_empty() {
2137 bail!("key file {} is empty", path.display());
2138 }
2139 if let Ok(bytes) = BASE64.decode(trimmed) {
2140 return Ok(bytes);
2141 }
2142 if trimmed.len() % 2 == 0 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
2143 return hex::decode(trimmed).context("invalid hex encoding in key file");
2144 }
2145 }
2146 Ok(raw)
2147}
2148
2149fn verify_signature_with_key_bytes(tag: &[u8; 32], pk_bytes: &[u8], sig: &[u8]) -> Result<()> {
2150 if pk_bytes.len() == 32 {
2151 let vk = Ed25519VerifyingKey::from_bytes(
2152 pk_bytes
2153 .try_into()
2154 .map_err(|_| anyhow!("invalid Ed25519 public key length"))?,
2155 )
2156 .map_err(|e| anyhow!("invalid Ed25519 public key: {e}"))?;
2157 let sig_array: [u8; 64] = sig
2158 .try_into()
2159 .map_err(|_| anyhow!("invalid Ed25519 signature length"))?;
2160 let signature = Ed25519Signature::from_bytes(&sig_array);
2161 vk.verify(tag, &signature)
2162 .map_err(|_| anyhow!("Ed25519 signature verification failed"))?;
2163 return Ok(());
2164 }
2165
2166 #[cfg(feature = "pq")]
2167 {
2168 if pk_bytes.len() == mldsa87::public_key_bytes() {
2169 let pk = MlDsaPublicKey::from_bytes(pk_bytes)
2170 .map_err(|_| anyhow!("invalid ML-DSA-87 public key bytes"))?;
2171 let ds = MlDsaDetachedSignature::from_bytes(sig)
2172 .map_err(|_| anyhow!("invalid ML-DSA-87 signature bytes"))?;
2173 mldsa87::verify_detached_signature_ctx(&ds, tag, SIG_CTX, &pk)
2174 .map_err(|_| anyhow!("ML-DSA-87 signature verification failed"))?;
2175 return Ok(());
2176 }
2177 }
2178
2179 bail!("unsupported public key size for signature verification")
2180}
2181
2182fn try_sign_ed25519(
2183 key_bytes: &[u8],
2184 tag: &[u8; 32],
2185 pub_out: Option<&Path>,
2186 vault_path: &Path,
2187) -> Result<bool> {
2188 let signing_key = match key_bytes.len() {
2189 32 => {
2190 let array: [u8; 32] = key_bytes
2191 .try_into()
2192 .map_err(|_| anyhow!("invalid Ed25519 secret key length"))?;
2193 Ed25519SigningKey::from_bytes(&array)
2194 }
2195 64 => {
2196 let array: [u8; 64] = key_bytes
2197 .try_into()
2198 .map_err(|_| anyhow!("invalid Ed25519 keypair length"))?;
2199 Ed25519SigningKey::from_keypair_bytes(&array)
2200 .map_err(|_| anyhow!("invalid Ed25519 keypair bytes"))?
2201 }
2202 _ => return Ok(false),
2203 };
2204
2205 let signature: Ed25519Signature = signing_key.sign(tag);
2206 let sig_bytes = signature.to_bytes();
2207 write_integrity_signature_sidecar(vault_path, sig_bytes.as_ref())?;
2208 if let Some(out) = pub_out {
2209 let vk = signing_key.verifying_key();
2210 fs::write(out, BASE64.encode(vk.as_bytes()))
2211 .with_context(|| format!("failed to write {}", out.display()))?;
2212 }
2213 Ok(true)
2214}
2215
2216#[cfg(feature = "pq")]
2217fn try_sign_mldsa(
2218 key_bytes: &[u8],
2219 tag: &[u8; 32],
2220 pub_out: Option<&Path>,
2221 vault_path: &Path,
2222) -> Result<bool> {
2223 let sk_len = mldsa87::secret_key_bytes();
2224 let pk_len = mldsa87::public_key_bytes();
2225
2226 let (sk_material, pk_material) = if key_bytes.len() == sk_len {
2227 (key_bytes, None)
2228 } else if key_bytes.len() == sk_len + pk_len {
2229 (&key_bytes[pk_len..], Some(&key_bytes[..pk_len]))
2230 } else {
2231 return Ok(false);
2232 };
2233
2234 let sk = MlDsaSecretKey::from_bytes(sk_material)
2235 .map_err(|_| anyhow!("invalid ML-DSA-87 secret key bytes"))?;
2236 let signature = mldsa87::detached_sign_ctx(tag, SIG_CTX, &sk);
2237 write_integrity_signature_sidecar(vault_path, signature.as_bytes())?;
2238
2239 if let Some(out) = pub_out {
2240 let pk_slice = pk_material
2241 .ok_or_else(|| anyhow!("secret key does not embed an ML-DSA-87 public key"))?;
2242 fs::write(out, BASE64.encode(pk_slice))
2243 .with_context(|| format!("failed to write {}", out.display()))?;
2244 }
2245 Ok(true)
2246}
2247
2248fn verify_integrity_signature(tag: &[u8; 32], pub_key_path: &Path, sig: &[u8]) -> Result<()> {
2249 let pk_bytes = read_key_bytes(pub_key_path)?;
2250 verify_signature_with_key_bytes(tag, &pk_bytes, sig)
2251}
2252
2253fn self_test() -> Result<()> {
2254 let mut sample = [0u8; 32];
2255 OsRng.fill_bytes(&mut sample);
2256 let secret = Sensitive::new_from_utf8(&sample);
2257 let record = Record::new(
2258 RecordData::Note { body: secret },
2259 Some("Self-test".into()),
2260 vec!["selftest".into()],
2261 None,
2262 );
2263 let payload = VaultPayload {
2264 records: vec![record],
2265 record_counter: 1,
2266 };
2267
2268 let mut dek = [0u8; 32];
2269 OsRng.fill_bytes(&mut dek);
2270 let blob = encrypt_payload(&dek, &payload)?;
2271 let recovered = decrypt_payload(&dek, &blob)?;
2272 anyhow::ensure!(recovered.records.len() == 1, "self-test failed");
2273 println!("Self-test passed");
2274 Ok(())
2275}
2276
2277fn prompt_hidden(prompt: &str) -> Result<Sensitive> {
2278 let value = Zeroizing::new(prompt_password(prompt)?);
2279 Ok(Sensitive::from_string(value.as_str()))
2280}
2281
2282fn prompt_optional_hidden(prompt: &str) -> Result<Option<Sensitive>> {
2283 let value = Zeroizing::new(prompt_password(prompt)?);
2284 if value.trim().is_empty() {
2285 Ok(None)
2286 } else {
2287 Ok(Some(Sensitive::from_string(value.as_str())))
2288 }
2289}
2290
2291fn prompt_multiline(prompt: &str) -> Result<Sensitive> {
2292 eprintln!("{}", prompt);
2293 read_multiline(false)
2294}
2295
2296fn prompt_multiline_hidden(prompt: &str) -> Result<Sensitive> {
2297 eprintln!("{}", prompt);
2298 read_multiline(true)
2299}
2300
2301fn read_multiline(hidden: bool) -> Result<Sensitive> {
2302 let hide_output = hidden && io::stdin().is_terminal();
2303 #[cfg(unix)]
2304 let mut echo_guard: Option<EchoModeGuard> = None;
2305 if hide_output {
2306 #[cfg(unix)]
2307 {
2308 echo_guard = Some(EchoModeGuard::disable()?);
2309 }
2310 #[cfg(not(unix))]
2311 {
2312 bail!("hidden multiline prompts are not supported on this platform; pipe the input instead");
2313 }
2314 }
2315
2316 let mut buffer = Zeroizing::new(Vec::new());
2317 io::stdin().read_to_end(&mut buffer)?;
2318 #[cfg(unix)]
2319 if hide_output {
2320 let _ = echo_guard.take();
2321 eprintln!();
2322 }
2323 while buffer.last().copied() == Some(b'\n') {
2324 buffer.pop();
2325 }
2326 Ok(Sensitive::new_from_utf8(&buffer))
2327}
2328
2329#[cfg(unix)]
2330struct EchoModeGuard {
2331 fd: i32,
2332 original: libc::termios,
2333}
2334
2335#[cfg(unix)]
2336impl EchoModeGuard {
2337 fn disable() -> Result<Self> {
2338 let stdin = io::stdin();
2339 let fd = stdin.as_raw_fd();
2340 let mut term = MaybeUninit::<libc::termios>::uninit();
2341 if unsafe { libc::tcgetattr(fd, term.as_mut_ptr()) } != 0 {
2342 Result::<(), io::Error>::Err(io::Error::last_os_error())
2343 .context("failed to read terminal attributes")?;
2344 }
2345 let mut current = unsafe { term.assume_init() };
2346 let original = current;
2347 current.c_lflag &= !libc::ECHO;
2348 if unsafe { libc::tcsetattr(fd, libc::TCSANOW, ¤t) } != 0 {
2349 Result::<(), io::Error>::Err(io::Error::last_os_error())
2350 .context("failed to disable terminal echo")?;
2351 }
2352 Ok(Self { fd, original })
2353 }
2354}
2355
2356#[cfg(unix)]
2357impl Drop for EchoModeGuard {
2358 fn drop(&mut self) {
2359 let _ = unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &self.original) };
2360 }
2361}
2362
2363#[cfg(feature = "fuzzing")]
2364pub fn fuzz_try_payload(bytes: &[u8]) {
2365 let _ = ciborium::de::from_reader::<VaultPayload, _>(bytes);
2366}
2367
2368#[cfg(feature = "fuzzing")]
2369pub fn fuzz_verify_signature(bytes: &[u8]) {
2370 if bytes.len() < 34 {
2371 return;
2372 }
2373 let mut tag = [0u8; 32];
2374 tag.copy_from_slice(&bytes[..32]);
2375 let remainder = &bytes[32..];
2376 if remainder.len() < 2 {
2377 return;
2378 }
2379 let split = (remainder[0] as usize % (remainder.len() - 1)) + 1;
2380 let (pk_bytes, sig_bytes) = remainder.split_at(split);
2381 let _ = verify_signature_with_key_bytes(&tag, pk_bytes, sig_bytes);
2382}
2383
2384fn write_vault(path: &Path, file: &VaultFile) -> Result<()> {
2385 let parent = path.parent().ok_or_else(|| anyhow!("invalid vault path"))?;
2386 fs::create_dir_all(parent)?;
2387 let mut tmp = NamedTempFile::new_in(parent)?;
2388 into_writer(file, &mut tmp).context("failed to serialize vault")?;
2389 tmp.as_file_mut().sync_all()?;
2390 #[cfg(unix)]
2391 {
2392 use std::os::unix::fs::PermissionsExt;
2393 tmp.as_file_mut()
2394 .set_permissions(fs::Permissions::from_mode(0o600))?;
2395 }
2396 tmp.persist(path)?;
2397 Ok(())
2398}
2399
2400impl fmt::Display for RecordKind {
2401 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2402 let label = match self {
2403 RecordKind::Login => "login",
2404 RecordKind::Contact => "contact",
2405 RecordKind::Id => "id",
2406 RecordKind::Note => "note",
2407 RecordKind::Bank => "bank",
2408 RecordKind::Wifi => "wifi",
2409 RecordKind::Api => "api",
2410 RecordKind::Wallet => "wallet",
2411 RecordKind::Totp => "totp",
2412 RecordKind::Ssh => "ssh",
2413 RecordKind::Pgp => "pgp",
2414 RecordKind::Recovery => "recovery",
2415 };
2416 f.write_str(label)
2417 }
2418}
2419
2420#[cfg(test)]
2421mod tests {
2422 use super::*;
2423 use proptest::prelude::*;
2424 use proptest::proptest;
2425 use proptest::strategy::Strategy;
2426 use serial_test::serial;
2427 use std::env;
2428 use std::path::PathBuf;
2429 use tempfile::tempdir;
2430
2431 fn prepare(passphrase: &str) -> Result<(tempfile::TempDir, PathBuf, Zeroizing<String>)> {
2432 let dir = tempdir()?;
2433 let vault_path = dir.path().join("vault.cbor");
2434 env::set_var("BLACK_BAG_VAULT_PATH", &vault_path);
2435 let pass = Zeroizing::new(passphrase.to_string());
2436 Ok((dir, vault_path, pass))
2437 }
2438
2439 fn cleanup() {
2440 env::remove_var("BLACK_BAG_VAULT_PATH");
2441 }
2442
2443 fn arb_ascii_string(max: usize) -> impl Strategy<Value = String> {
2444 proptest::collection::vec(proptest::char::range('a', 'z'), 0..=max)
2445 .prop_map(|chars| chars.into_iter().collect())
2446 }
2447
2448 fn arb_note_record() -> impl Strategy<Value = Record> {
2449 (
2450 proptest::option::of(arb_ascii_string(12)),
2451 proptest::collection::vec(arb_ascii_string(8), 0..3),
2452 arb_ascii_string(48),
2453 )
2454 .prop_map(|(title, tags, body)| {
2455 Record::new(
2456 RecordData::Note {
2457 body: Sensitive::from_string(&body),
2458 },
2459 title,
2460 tags,
2461 None,
2462 )
2463 })
2464 }
2465
2466 proptest! {
2467 #[test]
2468 fn encrypt_blob_roundtrip_prop(
2469 key_bytes in proptest::array::uniform32(any::<u8>()),
2470 data in proptest::collection::vec(any::<u8>(), 0..256),
2471 aad in proptest::collection::vec(any::<u8>(), 0..32),
2472 ) {
2473 let blob = encrypt_blob(&key_bytes, &data, &aad).unwrap();
2474 let decrypted = decrypt_blob(&key_bytes, &blob, &aad).unwrap();
2475 prop_assert_eq!(decrypted, data);
2476 }
2477
2478 #[test]
2479 fn payload_roundtrip_prop(
2480 key_bytes in proptest::array::uniform32(any::<u8>()),
2481 records in proptest::collection::vec(arb_note_record(), 0..3),
2482 ) {
2483 let payload = VaultPayload {
2484 records: records.clone(),
2485 record_counter: records.len() as u64,
2486 };
2487 let blob = encrypt_payload(&key_bytes, &payload).unwrap();
2488 let decoded = decrypt_payload(&key_bytes, &blob).unwrap();
2489 prop_assert_eq!(decoded, payload);
2490 }
2491 }
2492
2493 #[test]
2494 #[serial]
2495 fn vault_round_trip_note() -> Result<()> {
2496 let (_tmp, vault_path, pass) = prepare("correct horse battery staple")?;
2497 Vault::init(&vault_path, &pass, 32_768)?;
2498
2499 let mut vault = Vault::load(&vault_path, &pass)?;
2500 let record = Record::new(
2501 RecordData::Note {
2502 body: Sensitive::from_string("mission ops"),
2503 },
2504 Some("Ops Note".into()),
2505 vec!["mission".into()],
2506 None,
2507 );
2508 let record_id = record.id;
2509 vault.add_record(record);
2510 vault.save(&pass)?;
2511
2512 drop(vault);
2513 let vault = Vault::load(&vault_path, &pass)?;
2514 let notes = vault.list(Some(RecordKind::Note), None, None);
2515 assert_eq!(notes.len(), 1);
2516 assert_eq!(notes[0].id, record_id);
2517 assert_eq!(notes[0].title.as_deref(), Some("Ops Note"));
2518 assert!(notes[0].matches_tag("mission"));
2519
2520 cleanup();
2521 Ok(())
2522 }
2523
2524 #[test]
2525 #[serial]
2526 fn totp_round_trip() -> Result<()> {
2527 let (_tmp, vault_path, pass) = prepare("totp-pass")?;
2528 Vault::init(&vault_path, &pass, 32_768)?;
2529
2530 let secret_bytes = parse_totp_secret("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")?;
2531 let mut vault = Vault::load(&vault_path, &pass)?;
2532 let record = Record::new(
2533 RecordData::Totp {
2534 issuer: Some("TestIssuer".into()),
2535 account: Some("test@example".into()),
2536 secret: Sensitive { data: secret_bytes },
2537 digits: 6,
2538 step: 30,
2539 skew: 1,
2540 algorithm: TotpAlgorithm::Sha1,
2541 },
2542 Some("TOTP".into()),
2543 vec![],
2544 None,
2545 );
2546 let record_id = record.id;
2547 vault.add_record(record);
2548 vault.save(&pass)?;
2549
2550 drop(vault);
2551 let vault = Vault::load(&vault_path, &pass)?;
2552 let record = vault
2553 .get_ref(record_id)
2554 .ok_or_else(|| anyhow!("TOTP record missing"))?;
2555 let code = match &record.data {
2556 RecordData::Totp {
2557 issuer,
2558 account,
2559 secret,
2560 digits,
2561 step,
2562 skew,
2563 algorithm,
2564 } => build_totp_instance(secret, *digits, *step, *skew, *algorithm, issuer, account)?
2565 .generate(59),
2566 _ => bail!("expected totp record"),
2567 };
2568 let expected = TOTP::new(
2569 TotpAlgorithmLib::SHA1,
2570 6,
2571 1,
2572 30,
2573 parse_totp_secret("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")?,
2574 Some("TestIssuer".into()),
2575 "test@example".into(),
2576 )
2577 .unwrap()
2578 .generate(59);
2579 assert_eq!(code, expected);
2580
2581 cleanup();
2582 Ok(())
2583 }
2584
2585 #[test]
2586 #[serial]
2587 fn vault_rotation_changes_wrapped_keys() -> Result<()> {
2588 let (_tmp, vault_path, pass) = prepare("rotate-all-the-things")?;
2589 Vault::init(&vault_path, &pass, 32_768)?;
2590
2591 let mut vault = Vault::load(&vault_path, &pass)?;
2592 let record = Record::new(
2593 RecordData::Api {
2594 service: Some("intel-api".into()),
2595 environment: Some("prod".into()),
2596 access_key: Some("AKIA-123".into()),
2597 secret_key: Sensitive::from_string("super-secret"),
2598 scopes: vec!["read".into(), "write".into()],
2599 },
2600 Some("API".into()),
2601 vec!["read".into()],
2602 None,
2603 );
2604 vault.add_record(record);
2605 let before = vault.file.header.sealed_dek.ciphertext.clone();
2606 vault.rotate(&pass, Some(65_536))?;
2607 vault.save(&pass)?;
2608 let after = vault.file.header.sealed_dek.ciphertext.clone();
2609 assert_ne!(before, after);
2610
2611 drop(vault);
2612 let vault = Vault::load(&vault_path, &pass)?;
2613 let apis = vault.list(Some(RecordKind::Api), Some("read"), None);
2614 assert_eq!(apis.len(), 1);
2615 assert!(apis[0].data.summary_text().contains("intel-api"));
2616
2617 cleanup();
2618 Ok(())
2619 }
2620
2621 #[test]
2622 fn recovery_split_combine_roundtrip() -> Result<()> {
2623 let secret = b"ultra-secret";
2624 let shares = split_secret(secret, 3, 5)?;
2625 let recovered = combine_secret(3, &shares)?;
2626 assert_eq!(recovered, secret);
2627 Ok(())
2628 }
2629
2630 #[test]
2631 fn split_secret_requires_threshold_shares() -> Result<()> {
2632 let secret = b"deg-guard";
2633 let shares = split_secret(secret, 3, 5)?;
2634 for i in 0..shares.len() {
2635 for j in (i + 1)..shares.len() {
2636 let subset = vec![shares[i].clone(), shares[j].clone()];
2637 let recovered = combine_secret(2, &subset)?;
2638 assert_ne!(recovered.as_slice(), secret);
2639 }
2640 }
2641 Ok(())
2642 }
2643
2644 #[test]
2645 #[serial]
2646 fn backup_sign_verify_ed25519() -> Result<()> {
2647 let (tmp, vault_path, pass) = prepare("backup-ed25519")?;
2648 Vault::init(&vault_path, &pass, 32_768)?;
2649
2650 let sk_path = tmp.path().join("ed25519.sk");
2651 let mut sk_bytes = [0u8; 32];
2652 OsRng.fill_bytes(&mut sk_bytes);
2653 fs::write(&sk_path, BASE64.encode(sk_bytes))?;
2654 let pub_path = tmp.path().join("ed25519.pub");
2655
2656 backup_sign(BackupSignCommand {
2657 path: vault_path.clone(),
2658 key: sk_path,
2659 pub_out: Some(pub_path.clone()),
2660 })?;
2661
2662 backup_verify(BackupVerifyCommand {
2663 path: vault_path.clone(),
2664 pub_key: Some(pub_path),
2665 })?;
2666
2667 cleanup();
2668 Ok(())
2669 }
2670
2671 #[cfg(feature = "pq")]
2672 #[test]
2673 #[serial]
2674 fn backup_sign_verify_mldsa87() -> Result<()> {
2675 let (tmp, vault_path, pass) = prepare("backup-mldsa87")?;
2676 Vault::init(&vault_path, &pass, 32_768)?;
2677
2678 let (pk, sk) = mldsa87::keypair();
2679 let sk_path = tmp.path().join("mldsa.sk");
2680 let pk_path = tmp.path().join("mldsa.pub");
2681 let mut secret_blob = Vec::with_capacity(pk.as_bytes().len() + sk.as_bytes().len());
2682 secret_blob.extend_from_slice(pk.as_bytes());
2683 secret_blob.extend_from_slice(sk.as_bytes());
2684 fs::write(&sk_path, BASE64.encode(&secret_blob))?;
2685 fs::write(&pk_path, BASE64.encode(pk.as_bytes()))?;
2686
2687 backup_sign(BackupSignCommand {
2688 path: vault_path.clone(),
2689 key: sk_path,
2690 pub_out: Some(pk_path.clone()),
2691 })?;
2692
2693 backup_verify(BackupVerifyCommand {
2694 path: vault_path.clone(),
2695 pub_key: Some(pk_path),
2696 })?;
2697
2698 cleanup();
2699 Ok(())
2700 }
2701}