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 RecordData::Id { secret, .. } => {
1607 if let Some(secret) = secret {
1608 println!("secret: {}", secret.expose_utf8()?);
1609 }
1610 }
1611 RecordData::Note { body } => {
1612 println!("note:\n{}", body.expose_utf8()?);
1613 }
1614 RecordData::Bank { account_number, .. } => {
1615 println!("account_number: {}", account_number.expose_utf8()?);
1616 }
1617 RecordData::Wifi { passphrase, .. } => {
1618 println!("passphrase: {}", passphrase.expose_utf8()?);
1619 }
1620 RecordData::Api { secret_key, .. } => {
1621 println!("secret_key: {}", secret_key.expose_utf8()?);
1622 }
1623 RecordData::Wallet { secret_key, .. } => {
1624 println!("secret_key: {}", secret_key.expose_utf8()?);
1625 }
1626 RecordData::Totp {
1627 issuer,
1628 account,
1629 secret,
1630 digits,
1631 step,
1632 skew,
1633 algorithm,
1634 } => {
1635 let base32 = base32::encode(Rfc4648 { padding: false }, secret.as_slice());
1636 println!("secret_base32: {}", base32);
1637 if let Some(issuer) = issuer {
1638 println!("issuer: {}", issuer);
1639 }
1640 if let Some(account) = account {
1641 println!("account: {}", account);
1642 }
1643 println!("digits: {}", digits);
1644 println!("step: {}s", step);
1645 println!("skew: {}", skew);
1646 println!("algorithm: {}", algorithm);
1647 if let Ok(totp) =
1648 build_totp_instance(secret, *digits, *step, *skew, *algorithm, issuer, account)
1649 {
1650 if let Ok(code) = totp.generate_current() {
1651 println!("current_code: {}", code);
1652 if let Ok(ttl) = totp.ttl() {
1653 println!("ttl: {}s", ttl);
1654 }
1655 }
1656 }
1657 }
1658 RecordData::Ssh { private_key, .. } => {
1659 println!("private_key:\n{}", private_key.expose_utf8()?);
1660 }
1661 RecordData::Pgp {
1662 armored_private_key,
1663 ..
1664 } => {
1665 println!("pgp_private_key:\n{}", armored_private_key.expose_utf8()?);
1666 }
1667 RecordData::Recovery { payload, .. } => {
1668 println!("recovery_payload:\n{}", payload.expose_utf8()?);
1669 }
1670 }
1671 Ok(())
1672}
1673
1674fn parse_totp_secret(input: &str) -> Result<Vec<u8>> {
1675 let cleaned: String = input
1676 .chars()
1677 .filter(|c| !c.is_whitespace() && *c != '-')
1678 .collect();
1679 if cleaned.is_empty() {
1680 bail!("secret cannot be empty");
1681 }
1682 let encoded = cleaned.to_uppercase();
1683 TotpSecret::Encoded(encoded)
1684 .to_bytes()
1685 .map_err(|_| anyhow!("invalid base32-encoded secret"))
1686}
1687
1688fn build_totp_instance(
1689 secret: &Sensitive,
1690 digits: u8,
1691 step: u64,
1692 skew: u8,
1693 algorithm: TotpAlgorithm,
1694 issuer: &Option<String>,
1695 account: &Option<String>,
1696) -> Result<TOTP> {
1697 let account_name = account.clone().unwrap_or_default();
1698 TOTP::new(
1699 algorithm.to_lib(),
1700 usize::from(digits),
1701 skew,
1702 step,
1703 secret.as_slice().to_vec(),
1704 issuer.clone(),
1705 account_name,
1706 )
1707 .map_err(|err| anyhow!("failed to construct TOTP: {err}"))
1708}
1709
1710fn split_secret(secret: &[u8], threshold: u8, share_count: u8) -> Result<Vec<Vec<u8>>> {
1711 if threshold == 0 {
1712 bail!("threshold must be at least 1");
1713 }
1714 if share_count == 0 {
1715 bail!("share count must be at least 1");
1716 }
1717 if share_count < threshold {
1718 bail!("share count must be greater than or equal to threshold");
1719 }
1720 let sharks = Sharks(threshold);
1721 let dealer = sharks.dealer(secret);
1722 let shares: Vec<Share> = dealer.take(share_count as usize).collect();
1723 if shares.len() != share_count as usize {
1724 bail!("failed to generate requested number of shares");
1725 }
1726 Ok(shares.iter().map(|s| Vec::from(s)).collect())
1728}
1729
1730fn combine_secret(threshold: u8, shares: &[Vec<u8>]) -> Result<Vec<u8>> {
1731 if threshold == 0 {
1732 bail!("threshold must be at least 1");
1733 }
1734 if shares.len() < threshold as usize {
1735 bail!("not enough shares provided");
1736 }
1737 let mut seen = HashSet::new();
1739 let mut usable: Vec<Share> = Vec::with_capacity(threshold as usize);
1740 for raw in shares {
1741 if raw.len() < 2 {
1742 bail!("share payload too short");
1743 }
1744 let id = raw[0];
1745 if id == 0 {
1746 bail!("share identifier must be non-zero");
1747 }
1748 if seen.insert(id) {
1749 let s = Share::try_from(raw.as_slice())
1750 .map_err(|_| anyhow!("invalid share bytes"))?;
1751 usable.push(s);
1752 if usable.len() == threshold as usize {
1753 break;
1754 }
1755 }
1756 }
1757 if usable.len() < threshold as usize {
1758 bail!("not enough unique shares to reconstruct secret");
1759 }
1760 let sharks = Sharks(threshold);
1761 sharks
1762 .recover(&usable)
1763 .map_err(|e| anyhow!("failed to reconstruct secret: {e}"))
1764}
1765
1766fn rotate_vault(cmd: RotateCommand) -> Result<()> {
1767 with_vault(VaultAccess::ReadWrite, move |vault, pass| {
1768 vault.rotate(pass, cmd.mem_kib)?;
1769 vault.save(pass)?;
1770 println!("Rotation complete");
1771 Ok(())
1772 })
1773}
1774
1775fn doctor(cmd: DoctorCommand) -> Result<()> {
1776 with_vault(VaultAccess::ReadOnly, move |vault, _| {
1777 let stats = vault.stats();
1778 #[cfg(all(unix, feature = "mlock"))]
1779 let (mlock_enabled, mlock_ok, mlock_error) = {
1780 let mut probe = [0u8; 32];
1781 match memlock::lock_region(probe.as_ptr(), probe.len()) {
1782 Ok(()) => {
1783 memlock::unlock_region(probe.as_ptr(), probe.len());
1784 (true, true, None)
1785 }
1786 Err(e) => (true, false, Some(e.to_string())),
1787 }
1788 };
1789 #[cfg(not(all(unix, feature = "mlock")))]
1790 let (mlock_enabled, mlock_ok, mlock_error) = (false, false, None::<String>);
1791 if cmd.json {
1792 let payload = json!({
1793 "ready": true,
1794 "recordCount": stats.record_count,
1795 "argonMemKib": stats.argon_mem_kib,
1796 "argonTimeCost": stats.argon_time_cost,
1797 "argonLanes": stats.argon_lanes,
1798 "createdAt": stats.created_at.to_rfc3339(),
1799 "updatedAt": stats.updated_at.to_rfc3339(),
1800 "mlock": {
1801 "enabled": mlock_enabled,
1802 "ok": mlock_ok,
1803 "error": mlock_error,
1804 },
1805 });
1806 println!("{}", payload);
1807 } else {
1808 println!("status: ready");
1809 println!("records: {}", stats.record_count);
1810 println!("created: {}", stats.created_at);
1811 println!("updated: {}", stats.updated_at);
1812 println!(
1813 "argon2: mem={} KiB, time={}, lanes={}",
1814 stats.argon_mem_kib, stats.argon_time_cost, stats.argon_lanes
1815 );
1816 if mlock_enabled {
1817 if mlock_ok {
1818 println!("mlock: enabled and working");
1819 } else if let Some(err) = mlock_error {
1820 println!("mlock: enabled but failed ({}). Check OS limits.", err);
1821 }
1822 } else {
1823 println!("mlock: not supported in this build/OS");
1824 }
1825 }
1826 Ok(())
1827 })
1828}
1829
1830fn recovery(cmd: RecoveryCommand) -> Result<()> {
1831 match cmd {
1832 RecoveryCommand::Split(args) => {
1833 if args.threshold == 0 || args.threshold > args.shares {
1834 bail!("threshold must be between 1 and number of shares");
1835 }
1836 let secret = prompt_hidden("Secret to split: ")?;
1837 let shares = split_secret(secret.as_slice(), args.threshold, args.shares)?;
1838 for share in shares {
1839 let (id, data) = share
1840 .split_first()
1841 .ok_or_else(|| anyhow!("invalid share structure"))?;
1842 let encoded = BASE64.encode(data);
1843 println!("{}-{}", id, encoded);
1844 }
1845 Ok(())
1846 }
1847 RecoveryCommand::Combine(args) => {
1848 if args.threshold == 0 {
1849 bail!("threshold must be at least 1");
1850 }
1851 let mut shares = Vec::new();
1852 for part in args
1853 .shares
1854 .split(',')
1855 .map(str::trim)
1856 .filter(|s| !s.is_empty())
1857 {
1858 let (id, data) = part
1859 .split_once('-')
1860 .ok_or_else(|| anyhow!("invalid share format: {part}"))?;
1861 let identifier: u8 = id.parse().context("invalid share identifier")?;
1862 if identifier == 0 {
1863 bail!("share identifier must be between 1 and 255");
1864 }
1865 let mut decoded = BASE64.decode(data).context("invalid base64 in share")?;
1866 if decoded.is_empty() {
1867 bail!("share payload cannot be empty");
1868 }
1869 let mut share = Vec::with_capacity(decoded.len() + 1);
1870 share.push(identifier);
1871 share.append(&mut decoded);
1872 shares.push(share);
1873 }
1874 if shares.len() < args.threshold as usize {
1875 bail!(
1876 "insufficient shares provided (need at least {})",
1877 args.threshold
1878 );
1879 }
1880 let secret = combine_secret(args.threshold, &shares)?;
1881 println!("{}", String::from_utf8_lossy(&secret));
1882 Ok(())
1883 }
1884 }
1885}
1886
1887fn totp(cmd: TotpCommand) -> Result<()> {
1888 match cmd {
1889 TotpCommand::Code(args) => totp_code(args),
1890 }
1891}
1892
1893fn totp_code(args: TotpCodeCommand) -> Result<()> {
1894 let TotpCodeCommand { id, time } = args;
1895 with_vault(VaultAccess::ReadOnly, move |vault, _| {
1896 let record = vault
1897 .get_ref(id)
1898 .ok_or_else(|| anyhow!("record {} not found", id))?;
1899 let (issuer, account, secret, digits, step, skew, algorithm) = match &record.data {
1900 RecordData::Totp {
1901 issuer,
1902 account,
1903 secret,
1904 digits,
1905 step,
1906 skew,
1907 algorithm,
1908 } => (issuer, account, secret, *digits, *step, *skew, *algorithm),
1909 _ => bail!("record {} is not a TOTP secret", id),
1910 };
1911
1912 let totp = build_totp_instance(secret, digits, step, skew, algorithm, issuer, account)?;
1913 let code = if let Some(ts) = time {
1914 if ts < 0 {
1915 bail!("time must be non-negative");
1916 }
1917 totp.generate(ts as u64)
1918 } else {
1919 totp.generate_current()?
1920 };
1921 println!("code: {}", code);
1922 if time.is_none() {
1923 let ttl = totp.ttl()?;
1924 println!("ttl: {}s", ttl);
1925 }
1926 Ok(())
1927 })
1928}
1929
1930fn backup(cmd: BackupCommand) -> Result<()> {
1931 match cmd {
1932 BackupCommand::Verify(args) => backup_verify(args),
1933 BackupCommand::Sign(args) => backup_sign(args),
1934 #[cfg(feature = "pq")]
1935 BackupCommand::Keygen(args) => backup_keygen(args),
1936 }
1937}
1938
1939fn show_version() -> Result<()> {
1940 let version = env!("CARGO_PKG_VERSION");
1941 let profile = option_env!("PROFILE").unwrap_or("unknown");
1942 let target = option_env!("TARGET").unwrap_or("unknown");
1943
1944 let mut features = Vec::new();
1945 if cfg!(feature = "mlock") {
1946 features.push("mlock");
1947 } else {
1948 features.push("no-mlock");
1949 }
1950 if cfg!(feature = "pq") {
1951 features.push("pq");
1952 } else {
1953 features.push("no-pq");
1954 }
1955 if cfg!(feature = "fuzzing") {
1956 features.push("fuzzing");
1957 }
1958 if cfg!(feature = "fhe") {
1959 features.push("fhe");
1960 }
1961
1962 println!("black-bag {version}");
1963 println!("profile: {profile}");
1964 println!("target: {target}");
1965 println!("features: {}", features.join(", "));
1966 Ok(())
1967}
1968
1969fn backup_verify(cmd: BackupVerifyCommand) -> Result<()> {
1970 let bytes = fs::read(&cmd.path)
1971 .with_context(|| format!("failed to read vault at {}", cmd.path.display()))?;
1972 let vault: VaultFile = from_reader(bytes.as_slice()).context("failed to parse vault")?;
1973 let expected = compute_public_integrity_tag(&bytes, &vault.header.kem_public);
1974 let actual = read_integrity_sidecar(&cmd.path)?;
1975 if actual.ct_eq(&expected).unwrap_u8() != 1 {
1976 bail!("integrity check failed: .int does not match vault payload");
1977 }
1978
1979 if let Some(pub_key) = cmd.pub_key.as_deref() {
1980 let sig = read_integrity_signature_sidecar(&cmd.path)?;
1981 verify_integrity_signature(&expected, pub_key, &sig)?;
1982 println!("Integrity and signature verified");
1983 } else {
1984 println!("Integrity verified");
1985 }
1986 Ok(())
1987}
1988
1989fn backup_sign(cmd: BackupSignCommand) -> Result<()> {
1990 let bytes = fs::read(&cmd.path)
1991 .with_context(|| format!("failed to read vault at {}", cmd.path.display()))?;
1992 let vault: VaultFile = from_reader(bytes.as_slice()).context("failed to parse vault")?;
1993 let tag = match read_integrity_sidecar(&cmd.path) {
1994 Ok(tag) => tag,
1995 Err(_) => {
1996 let computed = compute_public_integrity_tag(&bytes, &vault.header.kem_public);
1997 write_integrity_sidecar(&cmd.path, &computed)?;
1998 computed
1999 }
2000 };
2001
2002 let key_bytes = read_key_bytes(&cmd.key)?;
2003 let pub_out = cmd.pub_out.as_deref();
2004
2005 if try_sign_ed25519(&key_bytes, &tag, pub_out, &cmd.path)? {
2006 println!("Wrote Ed25519 signature sidecar");
2007 return Ok(());
2008 }
2009
2010 #[cfg(feature = "pq")]
2011 {
2012 if try_sign_mldsa(&key_bytes, &tag, pub_out, &cmd.path)? {
2013 println!("Wrote ML-DSA-87 signature sidecar");
2014 return Ok(());
2015 }
2016 }
2017
2018 bail!(
2019 "unsupported signing key size: expected 32/64 bytes (Ed25519) or ML-DSA-87 secret key length"
2020 )
2021}
2022
2023#[cfg(feature = "pq")]
2024fn backup_keygen(cmd: BackupKeygenCommand) -> Result<()> {
2025 let (pk, sk) = mldsa87::keypair();
2026 fs::write(&cmd.pub_out, BASE64.encode(pk.as_bytes()))
2027 .with_context(|| format!("failed to write {}", cmd.pub_out.display()))?;
2028 let mut secret_blob = Vec::with_capacity(pk.as_bytes().len() + sk.as_bytes().len());
2029 secret_blob.extend_from_slice(pk.as_bytes());
2030 secret_blob.extend_from_slice(sk.as_bytes());
2031 fs::write(&cmd.sk_out, BASE64.encode(secret_blob))
2032 .with_context(|| format!("failed to write {}", cmd.sk_out.display()))?;
2033 println!("Generated ML-DSA-87 keypair");
2034 Ok(())
2035}
2036
2037fn compute_public_integrity_tag(bytes: &[u8], kem_public: &[u8]) -> [u8; 32] {
2038 let key = blake3::hash(kem_public);
2039 let tag = blake3::keyed_hash(key.as_bytes(), bytes);
2040 let mut out = [0u8; 32];
2041 out.copy_from_slice(tag.as_bytes());
2042 out
2043}
2044
2045fn integrity_sidecar_path(path: &Path) -> PathBuf {
2046 let mut os = path.as_os_str().to_os_string();
2047 os.push(".int");
2048 PathBuf::from(os)
2049}
2050
2051fn integrity_signature_sidecar_path(path: &Path) -> PathBuf {
2052 let mut os = path.as_os_str().to_os_string();
2053 os.push(".int.sig");
2054 PathBuf::from(os)
2055}
2056
2057fn write_integrity_sidecar(path: &Path, tag: &[u8; 32]) -> Result<()> {
2058 let sidecar = integrity_sidecar_path(path);
2059 let mut file =
2060 File::create(&sidecar).with_context(|| format!("failed to write {}", sidecar.display()))?;
2061 writeln!(file, "{}", BASE64.encode(tag))
2062 .with_context(|| format!("failed to write {}", sidecar.display()))
2063}
2064
2065fn read_integrity_sidecar(path: &Path) -> Result<[u8; 32]> {
2066 let sidecar = integrity_sidecar_path(path);
2067 let mut contents = String::new();
2068 File::open(&sidecar)
2069 .with_context(|| format!("integrity sidecar missing at {}", sidecar.display()))?
2070 .read_to_string(&mut contents)
2071 .with_context(|| format!("failed to read {}", sidecar.display()))?;
2072 let trimmed = contents.trim();
2073 if trimmed.is_empty() {
2074 bail!("integrity sidecar at {} is empty", sidecar.display());
2075 }
2076 let decoded = if let Ok(bytes) = BASE64.decode(trimmed) {
2077 bytes
2078 } else if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
2079 hex::decode(trimmed).context("invalid hex in integrity sidecar")?
2080 } else {
2081 bail!(
2082 "integrity sidecar at {} is not valid base64/hex",
2083 sidecar.display()
2084 );
2085 };
2086 if decoded.len() != 32 {
2087 bail!("integrity sidecar length mismatch (expected 32 bytes)");
2088 }
2089 let mut out = [0u8; 32];
2090 out.copy_from_slice(&decoded);
2091 Ok(out)
2092}
2093
2094fn write_integrity_signature_sidecar(path: &Path, sig: &[u8]) -> Result<()> {
2095 let sidecar = integrity_signature_sidecar_path(path);
2096 let mut file =
2097 File::create(&sidecar).with_context(|| format!("failed to write {}", sidecar.display()))?;
2098 writeln!(file, "{}", BASE64.encode(sig))
2099 .with_context(|| format!("failed to write {}", sidecar.display()))
2100}
2101
2102fn read_integrity_signature_sidecar(path: &Path) -> Result<Vec<u8>> {
2103 let sidecar = integrity_signature_sidecar_path(path);
2104 let mut contents = String::new();
2105 File::open(&sidecar)
2106 .with_context(|| format!("signature sidecar missing at {}", sidecar.display()))?
2107 .read_to_string(&mut contents)
2108 .with_context(|| format!("failed to read {}", sidecar.display()))?;
2109 let trimmed = contents.trim();
2110 if trimmed.is_empty() {
2111 bail!("signature sidecar at {} is empty", sidecar.display());
2112 }
2113 BASE64
2114 .decode(trimmed)
2115 .context("signature sidecar is not valid base64")
2116}
2117
2118fn read_key_bytes(path: &Path) -> Result<Vec<u8>> {
2119 let raw = fs::read(path)
2120 .with_context(|| format!("failed to read key material from {}", path.display()))?;
2121 if let Ok(text) = std::str::from_utf8(&raw) {
2122 let trimmed = text.trim();
2123 if trimmed.is_empty() {
2124 bail!("key file {} is empty", path.display());
2125 }
2126 if let Ok(bytes) = BASE64.decode(trimmed) {
2127 return Ok(bytes);
2128 }
2129 if trimmed.len() % 2 == 0 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
2130 return hex::decode(trimmed).context("invalid hex encoding in key file");
2131 }
2132 }
2133 Ok(raw)
2134}
2135
2136fn verify_signature_with_key_bytes(tag: &[u8; 32], pk_bytes: &[u8], sig: &[u8]) -> Result<()> {
2137 if pk_bytes.len() == 32 {
2138 let vk = Ed25519VerifyingKey::from_bytes(
2139 pk_bytes
2140 .try_into()
2141 .map_err(|_| anyhow!("invalid Ed25519 public key length"))?,
2142 )
2143 .map_err(|e| anyhow!("invalid Ed25519 public key: {e}"))?;
2144 let sig_array: [u8; 64] = sig
2145 .try_into()
2146 .map_err(|_| anyhow!("invalid Ed25519 signature length"))?;
2147 let signature = Ed25519Signature::from_bytes(&sig_array);
2148 vk.verify(tag, &signature)
2149 .map_err(|_| anyhow!("Ed25519 signature verification failed"))?;
2150 return Ok(());
2151 }
2152
2153 #[cfg(feature = "pq")]
2154 {
2155 if pk_bytes.len() == mldsa87::public_key_bytes() {
2156 let pk = MlDsaPublicKey::from_bytes(pk_bytes)
2157 .map_err(|_| anyhow!("invalid ML-DSA-87 public key bytes"))?;
2158 let ds = MlDsaDetachedSignature::from_bytes(sig)
2159 .map_err(|_| anyhow!("invalid ML-DSA-87 signature bytes"))?;
2160 mldsa87::verify_detached_signature_ctx(&ds, tag, SIG_CTX, &pk)
2161 .map_err(|_| anyhow!("ML-DSA-87 signature verification failed"))?;
2162 return Ok(());
2163 }
2164 }
2165
2166 bail!("unsupported public key size for signature verification")
2167}
2168
2169fn try_sign_ed25519(
2170 key_bytes: &[u8],
2171 tag: &[u8; 32],
2172 pub_out: Option<&Path>,
2173 vault_path: &Path,
2174) -> Result<bool> {
2175 let signing_key = match key_bytes.len() {
2176 32 => {
2177 let array: [u8; 32] = key_bytes
2178 .try_into()
2179 .map_err(|_| anyhow!("invalid Ed25519 secret key length"))?;
2180 Ed25519SigningKey::from_bytes(&array)
2181 }
2182 64 => {
2183 let array: [u8; 64] = key_bytes
2184 .try_into()
2185 .map_err(|_| anyhow!("invalid Ed25519 keypair length"))?;
2186 Ed25519SigningKey::from_keypair_bytes(&array)
2187 .map_err(|_| anyhow!("invalid Ed25519 keypair bytes"))?
2188 }
2189 _ => return Ok(false),
2190 };
2191
2192 let signature: Ed25519Signature = signing_key.sign(tag);
2193 let sig_bytes = signature.to_bytes();
2194 write_integrity_signature_sidecar(vault_path, sig_bytes.as_ref())?;
2195 if let Some(out) = pub_out {
2196 let vk = signing_key.verifying_key();
2197 fs::write(out, BASE64.encode(vk.as_bytes()))
2198 .with_context(|| format!("failed to write {}", out.display()))?;
2199 }
2200 Ok(true)
2201}
2202
2203#[cfg(feature = "pq")]
2204fn try_sign_mldsa(
2205 key_bytes: &[u8],
2206 tag: &[u8; 32],
2207 pub_out: Option<&Path>,
2208 vault_path: &Path,
2209) -> Result<bool> {
2210 let sk_len = mldsa87::secret_key_bytes();
2211 let pk_len = mldsa87::public_key_bytes();
2212
2213 let (sk_material, pk_material) = if key_bytes.len() == sk_len {
2214 (key_bytes, None)
2215 } else if key_bytes.len() == sk_len + pk_len {
2216 (&key_bytes[pk_len..], Some(&key_bytes[..pk_len]))
2217 } else {
2218 return Ok(false);
2219 };
2220
2221 let sk = MlDsaSecretKey::from_bytes(sk_material)
2222 .map_err(|_| anyhow!("invalid ML-DSA-87 secret key bytes"))?;
2223 let signature = mldsa87::detached_sign_ctx(tag, SIG_CTX, &sk);
2224 write_integrity_signature_sidecar(vault_path, signature.as_bytes())?;
2225
2226 if let Some(out) = pub_out {
2227 let pk_slice = pk_material
2228 .ok_or_else(|| anyhow!("secret key does not embed an ML-DSA-87 public key"))?;
2229 fs::write(out, BASE64.encode(pk_slice))
2230 .with_context(|| format!("failed to write {}", out.display()))?;
2231 }
2232 Ok(true)
2233}
2234
2235fn verify_integrity_signature(tag: &[u8; 32], pub_key_path: &Path, sig: &[u8]) -> Result<()> {
2236 let pk_bytes = read_key_bytes(pub_key_path)?;
2237 verify_signature_with_key_bytes(tag, &pk_bytes, sig)
2238}
2239
2240fn self_test() -> Result<()> {
2241 let mut sample = [0u8; 32];
2242 OsRng.fill_bytes(&mut sample);
2243 let secret = Sensitive::new_from_utf8(&sample);
2244 let record = Record::new(
2245 RecordData::Note { body: secret },
2246 Some("Self-test".into()),
2247 vec!["selftest".into()],
2248 None,
2249 );
2250 let payload = VaultPayload {
2251 records: vec![record],
2252 record_counter: 1,
2253 };
2254
2255 let mut dek = [0u8; 32];
2256 OsRng.fill_bytes(&mut dek);
2257 let blob = encrypt_payload(&dek, &payload)?;
2258 let recovered = decrypt_payload(&dek, &blob)?;
2259 anyhow::ensure!(recovered.records.len() == 1, "self-test failed");
2260 println!("Self-test passed");
2261 Ok(())
2262}
2263
2264fn prompt_hidden(prompt: &str) -> Result<Sensitive> {
2265 let value = Zeroizing::new(prompt_password(prompt)?);
2266 Ok(Sensitive::from_string(value.as_str()))
2267}
2268
2269fn prompt_optional_hidden(prompt: &str) -> Result<Option<Sensitive>> {
2270 let value = Zeroizing::new(prompt_password(prompt)?);
2271 if value.trim().is_empty() {
2272 Ok(None)
2273 } else {
2274 Ok(Some(Sensitive::from_string(value.as_str())))
2275 }
2276}
2277
2278fn prompt_multiline(prompt: &str) -> Result<Sensitive> {
2279 eprintln!("{}", prompt);
2280 read_multiline(false)
2281}
2282
2283fn prompt_multiline_hidden(prompt: &str) -> Result<Sensitive> {
2284 eprintln!("{}", prompt);
2285 read_multiline(true)
2286}
2287
2288fn read_multiline(hidden: bool) -> Result<Sensitive> {
2289 let hide_output = hidden && io::stdin().is_terminal();
2290 #[cfg(unix)]
2291 let mut echo_guard: Option<EchoModeGuard> = None;
2292 if hide_output {
2293 #[cfg(unix)]
2294 {
2295 echo_guard = Some(EchoModeGuard::disable()?);
2296 }
2297 #[cfg(not(unix))]
2298 {
2299 bail!("hidden multiline prompts are not supported on this platform; pipe the input instead");
2300 }
2301 }
2302
2303 let mut buffer = Zeroizing::new(Vec::new());
2304 io::stdin().read_to_end(&mut buffer)?;
2305 #[cfg(unix)]
2306 if hide_output {
2307 let _ = echo_guard.take();
2308 eprintln!();
2309 }
2310 while buffer.last().copied() == Some(b'\n') {
2311 buffer.pop();
2312 }
2313 Ok(Sensitive::new_from_utf8(&buffer))
2314}
2315
2316#[cfg(unix)]
2317struct EchoModeGuard {
2318 fd: i32,
2319 original: libc::termios,
2320}
2321
2322#[cfg(unix)]
2323impl EchoModeGuard {
2324 fn disable() -> Result<Self> {
2325 let stdin = io::stdin();
2326 let fd = stdin.as_raw_fd();
2327 let mut term = MaybeUninit::<libc::termios>::uninit();
2328 if unsafe { libc::tcgetattr(fd, term.as_mut_ptr()) } != 0 {
2329 Result::<(), io::Error>::Err(io::Error::last_os_error())
2330 .context("failed to read terminal attributes")?;
2331 }
2332 let mut current = unsafe { term.assume_init() };
2333 let original = current;
2334 current.c_lflag &= !libc::ECHO;
2335 if unsafe { libc::tcsetattr(fd, libc::TCSANOW, ¤t) } != 0 {
2336 Result::<(), io::Error>::Err(io::Error::last_os_error())
2337 .context("failed to disable terminal echo")?;
2338 }
2339 Ok(Self { fd, original })
2340 }
2341}
2342
2343#[cfg(unix)]
2344impl Drop for EchoModeGuard {
2345 fn drop(&mut self) {
2346 let _ = unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &self.original) };
2347 }
2348}
2349
2350#[cfg(feature = "fuzzing")]
2351pub fn fuzz_try_payload(bytes: &[u8]) {
2352 let _ = ciborium::de::from_reader::<VaultPayload, _>(bytes);
2353}
2354
2355#[cfg(feature = "fuzzing")]
2356pub fn fuzz_verify_signature(bytes: &[u8]) {
2357 if bytes.len() < 34 {
2358 return;
2359 }
2360 let mut tag = [0u8; 32];
2361 tag.copy_from_slice(&bytes[..32]);
2362 let remainder = &bytes[32..];
2363 if remainder.len() < 2 {
2364 return;
2365 }
2366 let split = (remainder[0] as usize % (remainder.len() - 1)) + 1;
2367 let (pk_bytes, sig_bytes) = remainder.split_at(split);
2368 let _ = verify_signature_with_key_bytes(&tag, pk_bytes, sig_bytes);
2369}
2370
2371fn write_vault(path: &Path, file: &VaultFile) -> Result<()> {
2372 let parent = path.parent().ok_or_else(|| anyhow!("invalid vault path"))?;
2373 fs::create_dir_all(parent)?;
2374 let mut tmp = NamedTempFile::new_in(parent)?;
2375 into_writer(file, &mut tmp).context("failed to serialize vault")?;
2376 tmp.as_file_mut().sync_all()?;
2377 #[cfg(unix)]
2378 {
2379 use std::os::unix::fs::PermissionsExt;
2380 tmp.as_file_mut()
2381 .set_permissions(fs::Permissions::from_mode(0o600))?;
2382 }
2383 tmp.persist(path)?;
2384 Ok(())
2385}
2386
2387impl fmt::Display for RecordKind {
2388 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2389 let label = match self {
2390 RecordKind::Login => "login",
2391 RecordKind::Contact => "contact",
2392 RecordKind::Id => "id",
2393 RecordKind::Note => "note",
2394 RecordKind::Bank => "bank",
2395 RecordKind::Wifi => "wifi",
2396 RecordKind::Api => "api",
2397 RecordKind::Wallet => "wallet",
2398 RecordKind::Totp => "totp",
2399 RecordKind::Ssh => "ssh",
2400 RecordKind::Pgp => "pgp",
2401 RecordKind::Recovery => "recovery",
2402 };
2403 f.write_str(label)
2404 }
2405}
2406
2407#[cfg(test)]
2408mod tests {
2409 use super::*;
2410 use proptest::prelude::*;
2411 use proptest::proptest;
2412 use proptest::strategy::Strategy;
2413 use serial_test::serial;
2414 use std::env;
2415 use std::path::PathBuf;
2416 use tempfile::tempdir;
2417
2418 fn prepare(passphrase: &str) -> Result<(tempfile::TempDir, PathBuf, Zeroizing<String>)> {
2419 let dir = tempdir()?;
2420 let vault_path = dir.path().join("vault.cbor");
2421 env::set_var("BLACK_BAG_VAULT_PATH", &vault_path);
2422 let pass = Zeroizing::new(passphrase.to_string());
2423 Ok((dir, vault_path, pass))
2424 }
2425
2426 fn cleanup() {
2427 env::remove_var("BLACK_BAG_VAULT_PATH");
2428 }
2429
2430 fn arb_ascii_string(max: usize) -> impl Strategy<Value = String> {
2431 proptest::collection::vec(proptest::char::range('a', 'z'), 0..=max)
2432 .prop_map(|chars| chars.into_iter().collect())
2433 }
2434
2435 fn arb_note_record() -> impl Strategy<Value = Record> {
2436 (
2437 proptest::option::of(arb_ascii_string(12)),
2438 proptest::collection::vec(arb_ascii_string(8), 0..3),
2439 arb_ascii_string(48),
2440 )
2441 .prop_map(|(title, tags, body)| {
2442 Record::new(
2443 RecordData::Note {
2444 body: Sensitive::from_string(&body),
2445 },
2446 title,
2447 tags,
2448 None,
2449 )
2450 })
2451 }
2452
2453 proptest! {
2454 #[test]
2455 fn encrypt_blob_roundtrip_prop(
2456 key_bytes in proptest::array::uniform32(any::<u8>()),
2457 data in proptest::collection::vec(any::<u8>(), 0..256),
2458 aad in proptest::collection::vec(any::<u8>(), 0..32),
2459 ) {
2460 let blob = encrypt_blob(&key_bytes, &data, &aad).unwrap();
2461 let decrypted = decrypt_blob(&key_bytes, &blob, &aad).unwrap();
2462 prop_assert_eq!(decrypted, data);
2463 }
2464
2465 #[test]
2466 fn payload_roundtrip_prop(
2467 key_bytes in proptest::array::uniform32(any::<u8>()),
2468 records in proptest::collection::vec(arb_note_record(), 0..3),
2469 ) {
2470 let payload = VaultPayload {
2471 records: records.clone(),
2472 record_counter: records.len() as u64,
2473 };
2474 let blob = encrypt_payload(&key_bytes, &payload).unwrap();
2475 let decoded = decrypt_payload(&key_bytes, &blob).unwrap();
2476 prop_assert_eq!(decoded, payload);
2477 }
2478 }
2479
2480 #[test]
2481 #[serial]
2482 fn vault_round_trip_note() -> Result<()> {
2483 let (_tmp, vault_path, pass) = prepare("correct horse battery staple")?;
2484 Vault::init(&vault_path, &pass, 32_768)?;
2485
2486 let mut vault = Vault::load(&vault_path, &pass)?;
2487 let record = Record::new(
2488 RecordData::Note {
2489 body: Sensitive::from_string("mission ops"),
2490 },
2491 Some("Ops Note".into()),
2492 vec!["mission".into()],
2493 None,
2494 );
2495 let record_id = record.id;
2496 vault.add_record(record);
2497 vault.save(&pass)?;
2498
2499 drop(vault);
2500 let vault = Vault::load(&vault_path, &pass)?;
2501 let notes = vault.list(Some(RecordKind::Note), None, None);
2502 assert_eq!(notes.len(), 1);
2503 assert_eq!(notes[0].id, record_id);
2504 assert_eq!(notes[0].title.as_deref(), Some("Ops Note"));
2505 assert!(notes[0].matches_tag("mission"));
2506
2507 cleanup();
2508 Ok(())
2509 }
2510
2511 #[test]
2512 #[serial]
2513 fn totp_round_trip() -> Result<()> {
2514 let (_tmp, vault_path, pass) = prepare("totp-pass")?;
2515 Vault::init(&vault_path, &pass, 32_768)?;
2516
2517 let secret_bytes = parse_totp_secret("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")?;
2518 let mut vault = Vault::load(&vault_path, &pass)?;
2519 let record = Record::new(
2520 RecordData::Totp {
2521 issuer: Some("TestIssuer".into()),
2522 account: Some("test@example".into()),
2523 secret: Sensitive { data: secret_bytes },
2524 digits: 6,
2525 step: 30,
2526 skew: 1,
2527 algorithm: TotpAlgorithm::Sha1,
2528 },
2529 Some("TOTP".into()),
2530 vec![],
2531 None,
2532 );
2533 let record_id = record.id;
2534 vault.add_record(record);
2535 vault.save(&pass)?;
2536
2537 drop(vault);
2538 let vault = Vault::load(&vault_path, &pass)?;
2539 let record = vault
2540 .get_ref(record_id)
2541 .ok_or_else(|| anyhow!("TOTP record missing"))?;
2542 let code = match &record.data {
2543 RecordData::Totp {
2544 issuer,
2545 account,
2546 secret,
2547 digits,
2548 step,
2549 skew,
2550 algorithm,
2551 } => build_totp_instance(secret, *digits, *step, *skew, *algorithm, issuer, account)?
2552 .generate(59),
2553 _ => bail!("expected totp record"),
2554 };
2555 let expected = TOTP::new(
2556 TotpAlgorithmLib::SHA1,
2557 6,
2558 1,
2559 30,
2560 parse_totp_secret("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")?,
2561 Some("TestIssuer".into()),
2562 "test@example".into(),
2563 )
2564 .unwrap()
2565 .generate(59);
2566 assert_eq!(code, expected);
2567
2568 cleanup();
2569 Ok(())
2570 }
2571
2572 #[test]
2573 #[serial]
2574 fn vault_rotation_changes_wrapped_keys() -> Result<()> {
2575 let (_tmp, vault_path, pass) = prepare("rotate-all-the-things")?;
2576 Vault::init(&vault_path, &pass, 32_768)?;
2577
2578 let mut vault = Vault::load(&vault_path, &pass)?;
2579 let record = Record::new(
2580 RecordData::Api {
2581 service: Some("intel-api".into()),
2582 environment: Some("prod".into()),
2583 access_key: Some("AKIA-123".into()),
2584 secret_key: Sensitive::from_string("super-secret"),
2585 scopes: vec!["read".into(), "write".into()],
2586 },
2587 Some("API".into()),
2588 vec!["read".into()],
2589 None,
2590 );
2591 vault.add_record(record);
2592 let before = vault.file.header.sealed_dek.ciphertext.clone();
2593 vault.rotate(&pass, Some(65_536))?;
2594 vault.save(&pass)?;
2595 let after = vault.file.header.sealed_dek.ciphertext.clone();
2596 assert_ne!(before, after);
2597
2598 drop(vault);
2599 let vault = Vault::load(&vault_path, &pass)?;
2600 let apis = vault.list(Some(RecordKind::Api), Some("read"), None);
2601 assert_eq!(apis.len(), 1);
2602 assert!(apis[0].data.summary_text().contains("intel-api"));
2603
2604 cleanup();
2605 Ok(())
2606 }
2607
2608 #[test]
2609 fn recovery_split_combine_roundtrip() -> Result<()> {
2610 let secret = b"ultra-secret";
2611 let shares = split_secret(secret, 3, 5)?;
2612 let recovered = combine_secret(3, &shares)?;
2613 assert_eq!(recovered, secret);
2614 Ok(())
2615 }
2616
2617 #[test]
2618 fn split_secret_requires_threshold_shares() -> Result<()> {
2619 let secret = b"deg-guard";
2620 let shares = split_secret(secret, 3, 5)?;
2621 for i in 0..shares.len() {
2622 for j in (i + 1)..shares.len() {
2623 let subset = vec![shares[i].clone(), shares[j].clone()];
2624 let recovered = combine_secret(2, &subset)?;
2625 assert_ne!(recovered.as_slice(), secret);
2626 }
2627 }
2628 Ok(())
2629 }
2630
2631 #[test]
2632 #[serial]
2633 fn backup_sign_verify_ed25519() -> Result<()> {
2634 let (tmp, vault_path, pass) = prepare("backup-ed25519")?;
2635 Vault::init(&vault_path, &pass, 32_768)?;
2636
2637 let sk_path = tmp.path().join("ed25519.sk");
2638 let mut sk_bytes = [0u8; 32];
2639 OsRng.fill_bytes(&mut sk_bytes);
2640 fs::write(&sk_path, BASE64.encode(sk_bytes))?;
2641 let pub_path = tmp.path().join("ed25519.pub");
2642
2643 backup_sign(BackupSignCommand {
2644 path: vault_path.clone(),
2645 key: sk_path,
2646 pub_out: Some(pub_path.clone()),
2647 })?;
2648
2649 backup_verify(BackupVerifyCommand {
2650 path: vault_path.clone(),
2651 pub_key: Some(pub_path),
2652 })?;
2653
2654 cleanup();
2655 Ok(())
2656 }
2657
2658 #[cfg(feature = "pq")]
2659 #[test]
2660 #[serial]
2661 fn backup_sign_verify_mldsa87() -> Result<()> {
2662 let (tmp, vault_path, pass) = prepare("backup-mldsa87")?;
2663 Vault::init(&vault_path, &pass, 32_768)?;
2664
2665 let (pk, sk) = mldsa87::keypair();
2666 let sk_path = tmp.path().join("mldsa.sk");
2667 let pk_path = tmp.path().join("mldsa.pub");
2668 let mut secret_blob = Vec::with_capacity(pk.as_bytes().len() + sk.as_bytes().len());
2669 secret_blob.extend_from_slice(pk.as_bytes());
2670 secret_blob.extend_from_slice(sk.as_bytes());
2671 fs::write(&sk_path, BASE64.encode(&secret_blob))?;
2672 fs::write(&pk_path, BASE64.encode(pk.as_bytes()))?;
2673
2674 backup_sign(BackupSignCommand {
2675 path: vault_path.clone(),
2676 key: sk_path,
2677 pub_out: Some(pk_path.clone()),
2678 })?;
2679
2680 backup_verify(BackupVerifyCommand {
2681 path: vault_path.clone(),
2682 pub_key: Some(pk_path),
2683 })?;
2684
2685 cleanup();
2686 Ok(())
2687 }
2688}