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