1use std::env;
2use std::fmt;
3use std::fs::{self, File, OpenOptions};
4use std::io::{self, Read, Write};
5use std::ops::{Deref, DerefMut};
6use std::path::{Path, PathBuf};
7
8use crate::error::{Error, Result};
9use anyhow::{anyhow, Context};
10use argon2::{Algorithm, Argon2, Params, Version};
11use base64::engine::general_purpose::STANDARD as BASE64;
12use base64::Engine as _;
13use chacha20poly1305::aead::{Aead, KeyInit, Payload};
14use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
15use chrono::{DateTime, Utc};
16use ciborium::{de::from_reader, ser::into_writer};
17use clap::{Args, Parser, Subcommand, ValueEnum};
18use directories::BaseDirs;
19use fd_lock::RwLock;
20#[cfg(feature = "pq")]
21use pqcrypto_mlkem::mlkem1024;
22#[cfg(feature = "pq")]
23use pqcrypto_traits::kem::{Ciphertext as _, PublicKey as _, SecretKey as _, SharedSecret as _};
24use rand::{rngs::OsRng, RngCore};
25use rpassword::prompt_password;
26use serde::{Deserialize, Serialize};
27use serde_json::json;
28use tempfile::NamedTempFile;
29use totp_rs::{Algorithm as TotpAlgorithmLib, Secret as TotpSecret, TOTP};
30use uuid::Uuid;
31use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
32use zxcvbn::zxcvbn;
33
34#[cfg(feature = "agent-keychain")]
35mod agent;
36mod config;
37pub mod error;
38mod memory;
39mod output;
40mod shamir;
41#[cfg(feature = "tui")]
42mod tui;
43
44use config::{AppConfig, EmitMode, FormatMode, PolicyMode};
45use output::write_bytes_tty;
46use output::{emit_totp_code, ensure_interactive_or_override, render_sensitive, write_line_tty};
47use shamir::{combine_secret, split_secret};
48
49const VAULT_VERSION: u32 = 2;
50const AAD_DEK: &[u8] = b"black-bag::sealed-dek";
51const AAD_DK: &[u8] = b"black-bag::sealed-dk";
52const AAD_PAYLOAD: &[u8] = b"black-bag::payload";
53const AAD_RDEK: &[u8] = b"black-bag::record-dek";
54const AAD_SECRET: &[u8] = b"black-bag::record-secret";
55const SECRET_MARKER: &[u8; 4] = b"BBE1";
56const PAD_MAGIC: &[u8; 8] = b"BBPAD1\0\0";
57const DEFAULT_TIME_COST: u32 = 10;
58const DEFAULT_LANES: u32 = 4;
59const MAX_FIELD_BYTES: usize = 8 * 1024; const MAX_NOTE_BYTES: usize = 256 * 1024; const MAX_VAULT_FILE_BYTES: u64 = 64 * 1024 * 1024; const MAX_PAYLOAD_PLAINTEXT_BYTES: usize = 32 * 1024 * 1024; const MAX_RECORDS: usize = 100_000;
64const MAX_TAGS_PER_RECORD: usize = 64;
65const MAX_TAG_LEN: usize = 128;
66const MAX_TITLE_LEN: usize = 256;
67
68pub fn run() -> Result<()> {
69 let cli = Cli::parse();
70 let app_config = AppConfig::from_sources(
71 cli.unsafe_stdout,
72 cli.require_mlock,
73 cli.emit,
74 cli.agent,
75 cli.unsafe_clipboard,
76 cli.duress,
77 Some(cli.format),
78 cli.quiet,
79 cli.schema_version,
80 cli.policy,
81 cli.machine,
82 );
83 apply_process_hardening();
84 let _ = app_config.unsafe_clipboard;
86 let _ = app_config.duress;
87 match cli.command {
88 Command::Init(cmd) => init_vault(cmd, &app_config),
89 Command::Add(cmd) => add_record(cmd, &app_config),
90 Command::List(cmd) => list_records(cmd, &app_config),
91 Command::Get(cmd) => get_record(cmd, &app_config),
92 Command::Rotate(cmd) => rotate_vault(cmd, &app_config),
93 Command::Doctor(cmd) => doctor(cmd, &app_config),
94 Command::Export { command } => export(command, &app_config),
95 Command::Record { command } => record(command, &app_config),
96 Command::Scan { command } => scan(command, &app_config),
97 Command::Recovery { command } => recovery(command, &app_config),
98 Command::Totp { command } => totp(command, &app_config),
99 Command::Backup { command } => backup(command, &app_config),
100 Command::Passwd(cmd) => passwd(cmd, &app_config),
101 #[cfg(feature = "tui")]
102 Command::Tui => Ok(tui::app::run(&app_config)?),
103 Command::Migrate(cmd) => migrate_vault_with(&cmd, &app_config),
104 Command::Selftest => self_test(),
105 Command::Completions(cmd) => completions(cmd),
106 Command::HelpMan => help_man(),
107 Command::Version => version_info(),
108 }
109}
110
111fn apply_process_hardening() {
112 #[cfg(unix)]
113 unsafe {
114 let lim = libc::rlimit { rlim_cur: 0, rlim_max: 0 };
116 let _ = libc::setrlimit(libc::RLIMIT_CORE, &lim);
117 #[cfg(target_os = "linux")]
119 {
120 let _ = libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0);
121 if let Ok(mut f) = std::fs::File::open("/proc/self/status") {
123 use std::io::Read as _;
124 let mut s = String::new();
125 let _ = f.read_to_string(&mut s);
126 if let Some(line) = s.lines().find(|l| l.starts_with("TracerPid:")) {
127 let val = line.split(':').nth(1).unwrap_or("0").trim();
128 if val != "0" && std::env::var_os("BLACK_BAG_ALLOW_DEBUG").is_none() {
129 eprintln!("debugger detected; refusing to run (set BLACK_BAG_ALLOW_DEBUG=1 to override)");
130 std::process::exit(1);
131 }
132 }
133 }
134 }
135 #[cfg(target_os = "macos")]
137 {
138 const PT_DENY_ATTACH: libc::c_int = 31;
139 let _ = libc::ptrace(PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0);
140 }
141 }
142}
143
144fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
145 if a.len() != b.len() {
146 return false;
147 }
148 let mut diff: u8 = 0;
149 for (x, y) in a.iter().zip(b.iter()) {
150 diff |= x ^ y;
151 }
152 diff == 0
153}
154
155#[derive(Args, Default)]
156struct MigrateCommand {
157 #[arg(long)]
159 pq: Option<String>,
160 #[arg(long)]
162 aead: Option<String>,
163 #[arg(long, default_value_t = false)]
165 dry_run: bool,
166}
167
168fn migrate_vault_with(cmd: &MigrateCommand, config: &AppConfig) -> Result<()> {
169 let (mut vault, pass) = load_vault_with_prompt(config)?;
170 if vault.file.version == VAULT_VERSION {
171 }
173 if let Some(pq) = &cmd.pq {
175 let pq_norm = pq.to_ascii_lowercase();
176 if pq_norm != "kyber1024" && pq_norm != "ml-kem-1024" && pq_norm != "next" {
177 bail!("unsupported pq target: {pq}");
178 }
179 if pq_norm == "next" {
180 eprintln!("warning: pq=next reserved for future migration; no-op");
181 }
182 if pq_norm != "kyber1024" && pq_norm != "ml-kem-1024" {
183 eprintln!("warning: pq target not currently implemented; no-op");
184 }
185 }
186 if let Some(aead) = &cmd.aead {
187 let aead_norm = aead.to_ascii_lowercase();
188 if aead_norm != "xchacha20poly1305" && aead_norm != "aes256gcm" {
189 bail!("unsupported aead target: {aead}");
190 }
191 if aead_norm == "aes256gcm" {
192 eprintln!("warning: aes256gcm migration not implemented yet; no-op");
193 }
194 }
195
196 if cmd.dry_run {
197 println!("Dry-run: would migrate vault to version {}", VAULT_VERSION);
198 if let Some(pq) = &cmd.pq { println!("PQ target: {} (validated/forward-compatible)", pq); }
199 if let Some(aead) = &cmd.aead { println!("AEAD target: {} (validated/forward-compatible)", aead); }
200 println!("Action: recompute header MAC and write .int/.epoch sidecars");
201 return Ok(());
202 }
203 vault.file.version = VAULT_VERSION;
205 let kek = derive_kek(&pass, &vault.file.header.argon)?;
206 vault.file.header.header_mac = Some(compute_header_mac(&vault.file.header, kek.as_slice()));
207 vault.save(&pass, config)?;
208 println!("Migration complete to version {}", VAULT_VERSION);
209 Ok(())
210}
211
212#[derive(Parser)]
213#[command(name = "black-bag", version, about = "Ultra-secure zero-trace CLI vault", long_about = None)]
214struct Cli {
215 #[arg(long, global = true, default_value_t = false)]
217 unsafe_stdout: bool,
218 #[arg(long, global = true, default_value_t = false)]
220 require_mlock: bool,
221 #[arg(long, value_enum, global = true)]
223 emit: Option<EmitMode>,
224 #[arg(long, value_enum, global = true)]
226 agent: Option<config::AgentMode>,
227 #[arg(long, global = true, default_value_t = false)]
229 unsafe_clipboard: bool,
230 #[arg(long, global = true, default_value_t = false)]
232 duress: bool,
233 #[arg(long, value_enum, global = true, default_value_t = config::FormatMode::Text)]
235 format: config::FormatMode,
236 #[arg(long, global = true, default_value_t = false, alias = "no-banner")]
238 quiet: bool,
239 #[arg(long, global = true)]
241 schema_version: Option<u32>,
242 #[arg(long, value_enum, global = true)]
244 policy: Option<config::PolicyMode>,
245 #[arg(long, global = true, default_value_t = false)]
247 machine: bool,
248 #[command(subcommand)]
249 command: Command,
250}
251
252#[derive(Subcommand)]
253enum Command {
254 Init(InitCommand),
256 Add(AddCommand),
258 List(ListCommand),
260 Get(GetCommand),
262 Rotate(RotateCommand),
264 Doctor(DoctorCommand),
266 Recovery {
268 #[command(subcommand)]
269 command: RecoveryCommand,
270 },
271 Totp {
273 #[command(subcommand)]
274 command: TotpCommand,
275 },
276 Export {
278 #[command(subcommand)]
279 command: ExportCommand,
280 },
281 Record {
283 #[command(subcommand)]
284 command: RecordCommand,
285 },
286 Backup {
288 #[command(subcommand)]
289 command: BackupCommand,
290 },
291 Scan {
293 #[command(subcommand)]
294 command: ScanCommand,
295 },
296 Passwd(PasswordCommand),
298 #[cfg(feature = "tui")]
300 Tui,
301 Migrate(MigrateCommand),
303 Selftest,
305 Completions(CompletionsCommand),
307 HelpMan,
309 Version,
311}
312
313#[derive(Args)]
314struct CompletionsCommand {
315 #[arg(value_enum)]
317 shell: clap_complete::Shell,
318}
319
320#[derive(Args)]
321struct InitCommand {
322 #[arg(long, default_value_t = 262_144)]
324 mem_kib: u32,
325 #[arg(long)]
327 argon_lanes: Option<String>,
328 #[arg(long, default_value_t = false)]
330 dry_run: bool,
331}
332
333#[derive(Args)]
334struct ListCommand {
335 #[arg(long, value_enum)]
337 kind: Option<RecordKind>,
338 #[arg(long)]
340 tag: Option<String>,
341 #[arg(long)]
343 query: Option<String>,
344 #[arg(long, default_value_t = false)]
346 fuzzy: bool,
347}
348
349#[derive(Args)]
350struct GetCommand {
351 id: Uuid,
353 #[arg(long)]
355 reveal: bool,
356 #[arg(long, default_value_t = false)]
358 clipboard: bool,
359 #[arg(long)]
361 clipboard_ttl: Option<u64>,
362 #[arg(long, default_value_t = false)]
364 otpauth: bool,
365 #[arg(long, default_value_t = false)]
367 qr: bool,
368 #[arg(long, default_value_t = false)]
370 confirm_qr: bool,
371}
372
373#[derive(Args, Default)]
374struct RotateCommand {
375 #[arg(long)]
377 mem_kib: Option<u32>,
378}
379
380#[derive(Args, Default)]
381struct PasswordCommand {
382 #[arg(long)]
384 mem_kib: Option<u32>,
385 #[arg(long)]
387 argon_lanes: Option<String>,
388 #[arg(long, default_value_t = false)]
390 rekey_dek: bool,
391}
392
393#[derive(Args)]
394struct DoctorCommand {
395 #[arg(long)]
397 json: bool,
398}
399
400#[derive(Subcommand)]
401enum ExportCommand {
402 Csv(ExportCsvCommand),
404}
405
406#[derive(Subcommand)]
407enum RecordCommand {
408 Edit(RecordEditCommand),
410 Delete(RecordDeleteCommand),
412}
413
414#[derive(Args, Default)]
415struct RecordEditCommand {
416 id: Uuid,
418 #[arg(long)]
420 title: Option<String>,
421 #[arg(long, value_delimiter = ',')]
423 add_tag: Vec<String>,
424 #[arg(long, value_delimiter = ',')]
426 rm_tag: Vec<String>,
427 #[arg(long)]
429 notes: Option<String>,
430}
431
432#[derive(Args, Default)]
433struct RecordDeleteCommand {
434 id: Uuid,
436 #[arg(long, default_value_t = false)]
438 force: bool,
439}
440
441#[derive(Subcommand)]
442enum ScanCommand {
443 Passwords(ScanPasswordsCommand),
445}
446
447#[derive(Args, Default)]
448struct ScanPasswordsCommand {
449 #[arg(long, default_value_t = true)]
451 duplicates: bool,
452 #[arg(long, default_value_t = true)]
454 weak: bool,
455}
456
457#[derive(Args)]
458struct ExportCsvCommand {
459 #[arg(long, value_enum)]
461 kind: Option<RecordKind>,
462 #[arg(long, value_delimiter = ',')]
464 fields: Vec<String>,
465 #[arg(long, default_value_t = false)]
467 include_secrets: bool,
468 #[arg(long, default_value_t = false)]
470 schema: bool,
471}
472
473#[derive(Subcommand)]
474enum RecoveryCommand {
475 Split(RecoverySplitCommand),
477 Combine(RecoveryCombineCommand),
479 Verify(RecoveryVerifyCommand),
481 Doctor(RecoveryDoctorCommand),
483}
484
485#[derive(Args)]
486struct RecoverySplitCommand {
487 #[arg(long, default_value_t = 3)]
489 threshold: u8,
490 #[arg(long, default_value_t = 5)]
492 shares: u8,
493 #[arg(long, default_value_t = false)]
495 duress: bool,
496 #[arg(long, default_value_t = false)]
498 qr: bool,
499 #[arg(long, default_value_t = false)]
501 confirm_qr: bool,
502 #[arg(long, default_value_t = false)]
504 with_checksum: bool,
505}
506
507#[derive(Args)]
508struct RecoveryCombineCommand {
509 #[arg(long)]
511 threshold: u8,
512 #[arg(long)]
514 shares: String,
515 #[arg(long)]
517 set_id: Option<String>,
518 #[arg(long, default_value_t = false)]
520 raw: bool,
521 #[arg(long, default_value_t = false)]
523 duress: bool,
524}
525
526#[derive(Args)]
527struct RecoveryVerifyCommand {
528 #[arg(long)]
530 threshold: u8,
531 #[arg(long)]
533 shares: String,
534 #[arg(long)]
536 set_id: Option<String>,
537 #[arg(long, default_value_t = false)]
539 duress: bool,
540}
541
542#[derive(Args)]
543struct RecoveryDoctorCommand {
544 #[arg(long)]
546 threshold: u8,
547 #[arg(long)]
549 shares: String,
550 #[arg(long)]
552 set_id: Option<String>,
553 #[arg(long, default_value_t = false)]
555 duress: bool,
556}
557
558#[derive(Subcommand)]
559enum TotpCommand {
560 Code(TotpCodeCommand),
562 Doctor(TotpDoctorCommand),
564}
565
566#[derive(Args)]
567struct TotpCodeCommand {
568 id: Uuid,
570 #[arg(long)]
572 time: Option<i64>,
573}
574
575#[derive(Args)]
576struct TotpDoctorCommand {
577 id: Uuid,
579 #[arg(long)]
581 ref_time: Option<i64>,
582}
583
584#[derive(Args)]
585struct AddCommand {
586 #[command(subcommand)]
587 record: AddRecord,
588}
589
590#[derive(Subcommand)]
591enum AddRecord {
592 Login(AddLogin),
594 Contact(AddContact),
596 Id(AddIdentity),
598 Note(AddNote),
600 Bank(AddBank),
602 Wifi(AddWifi),
604 Api(AddApi),
606 Wallet(AddWallet),
608 Totp(AddTotp),
610 Ssh(AddSsh),
612 Pgp(AddPgp),
614 Recovery(AddRecovery),
616}
617
618#[derive(Args)]
619struct CommonRecordArgs {
620 #[arg(long)]
622 title: Option<String>,
623 #[arg(long, value_delimiter = ',')]
625 tags: Vec<String>,
626 #[arg(long)]
628 notes: Option<String>,
629}
630
631#[derive(Args)]
632struct AddLogin {
633 #[command(flatten)]
634 common: CommonRecordArgs,
635 #[arg(long)]
636 username: Option<String>,
637 #[arg(long)]
638 url: Option<String>,
639}
640
641#[derive(Args)]
642struct AddContact {
643 #[command(flatten)]
644 common: CommonRecordArgs,
645 #[arg(long, required = true)]
646 full_name: String,
647 #[arg(long, value_delimiter = ',')]
648 emails: Vec<String>,
649 #[arg(long, value_delimiter = ',')]
650 phones: Vec<String>,
651}
652
653#[derive(Args)]
654struct AddIdentity {
655 #[command(flatten)]
656 common: CommonRecordArgs,
657 #[arg(long)]
658 id_type: Option<String>,
659 #[arg(long)]
660 name_on_doc: Option<String>,
661 #[arg(long)]
662 number: Option<String>,
663 #[arg(long)]
664 issuing_country: Option<String>,
665 #[arg(long)]
666 expiry: Option<String>,
667}
668
669#[derive(Args)]
670struct AddNote {
671 #[command(flatten)]
672 common: CommonRecordArgs,
673}
674
675#[derive(Args)]
676struct AddBank {
677 #[command(flatten)]
678 common: CommonRecordArgs,
679 #[arg(long)]
680 institution: Option<String>,
681 #[arg(long)]
682 account_name: Option<String>,
683 #[arg(long)]
684 routing_number: Option<String>,
685}
686
687#[derive(Args)]
688struct AddWifi {
689 #[command(flatten)]
690 common: CommonRecordArgs,
691 #[arg(long)]
692 ssid: Option<String>,
693 #[arg(long)]
694 security: Option<String>,
695 #[arg(long)]
696 location: Option<String>,
697}
698
699#[derive(Args)]
700struct AddApi {
701 #[command(flatten)]
702 common: CommonRecordArgs,
703 #[arg(long)]
704 service: Option<String>,
705 #[arg(long)]
706 environment: Option<String>,
707 #[arg(long)]
708 access_key: Option<String>,
709 #[arg(long, value_delimiter = ',')]
710 scopes: Vec<String>,
711}
712
713#[derive(Args)]
714struct AddWallet {
715 #[command(flatten)]
716 common: CommonRecordArgs,
717 #[arg(long)]
718 asset: Option<String>,
719 #[arg(long)]
720 address: Option<String>,
721 #[arg(long)]
722 network: Option<String>,
723}
724
725#[derive(Args)]
726struct AddTotp {
727 #[command(flatten)]
728 common: CommonRecordArgs,
729 #[arg(long)]
731 issuer: Option<String>,
732 #[arg(long)]
734 account: Option<String>,
735 #[arg(long, value_name = "PATH")]
737 secret_file: Option<std::path::PathBuf>,
738 #[arg(long, default_value_t = false)]
740 secret_stdin: bool,
741 #[arg(long, default_value_t = false)]
743 otpauth_stdin: bool,
744 #[arg(long, default_value_t = false)]
746 qr: bool,
747 #[arg(long, default_value_t = false)]
749 confirm_qr: bool,
750 #[arg(long, default_value_t = 6)]
752 digits: u8,
753 #[arg(long, default_value_t = 30)]
755 step: u64,
756 #[arg(long, default_value_t = 1)]
758 skew: u8,
759 #[arg(long, value_enum, default_value_t = TotpAlgorithm::Sha1)]
761 algorithm: TotpAlgorithm,
762}
763#[derive(Subcommand)]
764enum BackupCommand {
765 Verify {
767 #[arg(long)]
768 path: std::path::PathBuf,
769 #[arg(long)]
771 pub_key: Option<std::path::PathBuf>,
772 },
773 Sign {
775 #[arg(long)]
776 path: std::path::PathBuf,
777 #[arg(long)]
779 key: std::path::PathBuf,
780 #[arg(long)]
782 pub_out: Option<std::path::PathBuf>,
783 },
784}
785
786#[derive(Args)]
787struct AddSsh {
788 #[command(flatten)]
789 common: CommonRecordArgs,
790 #[arg(long)]
791 label: Option<String>,
792 #[arg(long)]
793 comment: Option<String>,
794}
795
796#[derive(Args)]
797struct AddPgp {
798 #[command(flatten)]
799 common: CommonRecordArgs,
800 #[arg(long)]
801 label: Option<String>,
802 #[arg(long)]
803 fingerprint: Option<String>,
804}
805
806#[derive(Args)]
807struct AddRecovery {
808 #[command(flatten)]
809 common: CommonRecordArgs,
810 #[arg(long)]
811 description: Option<String>,
812}
813
814#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Hash)]
815#[serde(rename_all = "snake_case")]
816#[clap(rename_all = "snake_case")]
817enum RecordKind {
818 Login,
819 Contact,
820 Id,
821 Note,
822 Bank,
823 Wifi,
824 Api,
825 Wallet,
826 Totp,
827 Ssh,
828 Pgp,
829 Recovery,
830}
831
832#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
833#[serde(rename_all = "lowercase")]
834enum TotpAlgorithm {
835 Sha1,
836 Sha256,
837 Sha512,
838}
839
840impl TotpAlgorithm {
841 fn to_lib(self) -> TotpAlgorithmLib {
842 match self {
843 TotpAlgorithm::Sha1 => TotpAlgorithmLib::SHA1,
844 TotpAlgorithm::Sha256 => TotpAlgorithmLib::SHA256,
845 TotpAlgorithm::Sha512 => TotpAlgorithmLib::SHA512,
846 }
847 }
848}
849
850impl fmt::Display for TotpAlgorithm {
851 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
852 match self {
853 TotpAlgorithm::Sha1 => f.write_str("sha1"),
854 TotpAlgorithm::Sha256 => f.write_str("sha256"),
855 TotpAlgorithm::Sha512 => f.write_str("sha512"),
856 }
857 }
858}
859
860#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
861struct Record {
862 id: Uuid,
863 created_at: DateTime<Utc>,
864 updated_at: DateTime<Utc>,
865 title: Option<String>,
866 tags: Vec<String>,
867 metadata_notes: Option<String>,
868 data: RecordData,
869 #[serde(default)]
870 sealed_rdek: Option<AeadBlob>,
871}
872
873impl Record {
874 fn new(
875 data: RecordData,
876 title: Option<String>,
877 tags: Vec<String>,
878 notes: Option<String>,
879 ) -> Self {
880 let now = Utc::now();
881 Self {
882 id: Uuid::new_v4(),
883 created_at: now,
884 updated_at: now,
885 title,
886 tags,
887 metadata_notes: notes,
888 data,
889 sealed_rdek: None,
890 }
891 }
892
893 fn kind(&self) -> RecordKind {
894 self.data.kind()
895 }
896
897 fn matches_tag(&self, tag: &str) -> bool {
898 let tag_lower = tag.to_ascii_lowercase();
899 self.tags
900 .iter()
901 .any(|t| t.to_ascii_lowercase().contains(&tag_lower))
902 }
903
904 fn matches_query(&self, needle: &str) -> bool {
905 let haystack = [
906 self.title.as_deref().unwrap_or_default(),
907 self.metadata_notes.as_deref().unwrap_or_default(),
908 &self.data.summary_text(),
909 ]
910 .join("\n")
911 .to_ascii_lowercase();
912 haystack.contains(&needle.to_ascii_lowercase())
913 }
914}
915
916#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
917#[serde(tag = "kind", rename_all = "snake_case")]
918enum RecordData {
919 Login {
920 username: Option<String>,
921 url: Option<String>,
922 password: Sensitive,
923 },
924 Contact {
925 full_name: String,
926 emails: Vec<String>,
927 phones: Vec<String>,
928 },
929 Id {
930 id_type: Option<String>,
931 name_on_doc: Option<String>,
932 number: Option<String>,
933 issuing_country: Option<String>,
934 expiry: Option<String>,
935 secret: Option<Sensitive>,
936 },
937 Note {
938 body: Sensitive,
939 },
940 Bank {
941 institution: Option<String>,
942 account_name: Option<String>,
943 routing_number: Option<String>,
944 account_number: Sensitive,
945 },
946 Wifi {
947 ssid: Option<String>,
948 security: Option<String>,
949 location: Option<String>,
950 passphrase: Sensitive,
951 },
952 Api {
953 service: Option<String>,
954 environment: Option<String>,
955 access_key: Option<String>,
956 secret_key: Sensitive,
957 scopes: Vec<String>,
958 },
959 Wallet {
960 asset: Option<String>,
961 address: Option<String>,
962 network: Option<String>,
963 secret_key: Sensitive,
964 },
965 Totp {
966 issuer: Option<String>,
967 account: Option<String>,
968 secret: Sensitive,
969 digits: u8,
970 step: u64,
971 skew: u8,
972 algorithm: TotpAlgorithm,
973 },
974 Ssh {
975 label: Option<String>,
976 private_key: Sensitive,
977 comment: Option<String>,
978 },
979 Pgp {
980 label: Option<String>,
981 fingerprint: Option<String>,
982 armored_private_key: Sensitive,
983 },
984 Recovery {
985 description: Option<String>,
986 payload: Sensitive,
987 },
988}
989
990impl RecordData {
991 fn kind(&self) -> RecordKind {
992 match self {
993 RecordData::Login { .. } => RecordKind::Login,
994 RecordData::Contact { .. } => RecordKind::Contact,
995 RecordData::Id { .. } => RecordKind::Id,
996 RecordData::Note { .. } => RecordKind::Note,
997 RecordData::Bank { .. } => RecordKind::Bank,
998 RecordData::Wifi { .. } => RecordKind::Wifi,
999 RecordData::Api { .. } => RecordKind::Api,
1000 RecordData::Wallet { .. } => RecordKind::Wallet,
1001 RecordData::Totp { .. } => RecordKind::Totp,
1002 RecordData::Ssh { .. } => RecordKind::Ssh,
1003 RecordData::Pgp { .. } => RecordKind::Pgp,
1004 RecordData::Recovery { .. } => RecordKind::Recovery,
1005 }
1006 }
1007
1008 fn summary_text(&self) -> String {
1009 match self {
1010 RecordData::Login { username, url, .. } => format!(
1011 "user={} url={}",
1012 username.as_deref().unwrap_or("-"),
1013 url.as_deref().unwrap_or("-")
1014 ),
1015 RecordData::Contact {
1016 full_name,
1017 emails,
1018 phones,
1019 } => format!(
1020 "{} | emails={} | phones={}",
1021 full_name,
1022 if emails.is_empty() {
1023 "-".to_string()
1024 } else {
1025 emails.join(",")
1026 },
1027 if phones.is_empty() {
1028 "-".to_string()
1029 } else {
1030 phones.join(",")
1031 }
1032 ),
1033 RecordData::Id {
1034 id_type,
1035 number,
1036 expiry,
1037 ..
1038 } => format!(
1039 "type={} number={} expiry={}",
1040 id_type.as_deref().unwrap_or("-"),
1041 number.as_deref().unwrap_or("-"),
1042 expiry.as_deref().unwrap_or("-")
1043 ),
1044 RecordData::Note { .. } => "secure note".to_string(),
1045 RecordData::Bank {
1046 institution,
1047 account_name,
1048 routing_number,
1049 ..
1050 } => format!(
1051 "institution={} account={} routing={}",
1052 institution.as_deref().unwrap_or("-"),
1053 account_name.as_deref().unwrap_or("-"),
1054 routing_number.as_deref().unwrap_or("-")
1055 ),
1056 RecordData::Wifi {
1057 ssid,
1058 security,
1059 location,
1060 ..
1061 } => format!(
1062 "ssid={} security={} location={}",
1063 ssid.as_deref().unwrap_or("-"),
1064 security.as_deref().unwrap_or("-"),
1065 location.as_deref().unwrap_or("-")
1066 ),
1067 RecordData::Api {
1068 service,
1069 environment,
1070 scopes,
1071 ..
1072 } => format!(
1073 "service={} env={} scopes={}",
1074 service.as_deref().unwrap_or("-"),
1075 environment.as_deref().unwrap_or("-"),
1076 if scopes.is_empty() {
1077 "-".to_string()
1078 } else {
1079 scopes.join(",")
1080 }
1081 ),
1082 RecordData::Wallet {
1083 asset,
1084 address,
1085 network,
1086 ..
1087 } => format!(
1088 "asset={} address={} network={}",
1089 asset.as_deref().unwrap_or("-"),
1090 address.as_deref().unwrap_or("-"),
1091 network.as_deref().unwrap_or("-")
1092 ),
1093 RecordData::Totp {
1094 issuer,
1095 account,
1096 digits,
1097 step,
1098 ..
1099 } => format!(
1100 "issuer={} account={} digits={} step={}",
1101 issuer.as_deref().unwrap_or("-"),
1102 account.as_deref().unwrap_or("-"),
1103 digits,
1104 step
1105 ),
1106 RecordData::Ssh { label, comment, .. } => format!(
1107 "label={} comment={}",
1108 label.as_deref().unwrap_or("-"),
1109 comment.as_deref().unwrap_or("-")
1110 ),
1111 RecordData::Pgp {
1112 label, fingerprint, ..
1113 } => format!(
1114 "label={} fingerprint={}",
1115 label.as_deref().unwrap_or("-"),
1116 fingerprint.as_deref().unwrap_or("-")
1117 ),
1118 RecordData::Recovery { description, .. } => {
1119 format!("description={}", description.as_deref().unwrap_or("-"))
1120 }
1121 }
1122 }
1123}
1124
1125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1126struct Sensitive {
1127 #[serde(with = "serde_bytes")]
1128 data: Vec<u8>,
1129}
1130
1131impl Sensitive {
1132 fn new_from_utf8(value: &[u8]) -> Self {
1133 Self {
1134 data: value.to_vec(),
1135 }
1136 }
1137
1138 fn from_string(value: &str) -> Self {
1139 Self::new_from_utf8(value.as_bytes())
1140 }
1141
1142 fn as_slice(&self) -> &[u8] {
1143 &self.data
1144 }
1145
1146 fn expose_utf8(&self) -> Result<String> {
1147 Ok(String::from_utf8(self.data.clone())?)
1148 }
1149}
1150
1151fn cap_sensitive(name: &str, s: &Sensitive, max: usize) -> Result<()> {
1152 if s.as_slice().len() > max {
1153 bail!("{name} too large (>{} bytes)", max);
1154 }
1155 Ok(())
1156}
1157
1158impl Drop for Sensitive {
1159 fn drop(&mut self) {
1160 self.data.zeroize();
1161 }
1162}
1163
1164impl ZeroizeOnDrop for Sensitive {}
1165
1166#[derive(Serialize, Deserialize, Clone)]
1167struct VaultFile {
1168 version: u32,
1169 header: VaultHeader,
1170 payload: AeadBlob,
1171}
1172
1173#[derive(Serialize, Deserialize, Clone)]
1174struct VaultHeader {
1175 created_at: DateTime<Utc>,
1176 updated_at: DateTime<Utc>,
1177 #[serde(default)]
1178 epoch: u64,
1179 argon: ArgonState,
1180 kem_public: Vec<u8>,
1181 kem_ciphertext: Vec<u8>,
1182 sealed_decapsulation: AeadBlob,
1183 sealed_dek: AeadBlob,
1184 #[serde(default)]
1187 header_mac: Option<[u8; 32]>,
1188}
1189
1190#[derive(Serialize, Deserialize, Clone)]
1191struct ArgonState {
1192 mem_cost_kib: u32,
1193 time_cost: u32,
1194 lanes: u32,
1195 salt: [u8; 32],
1196}
1197
1198#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
1199struct AeadBlob {
1200 nonce: [u8; 24],
1201 ciphertext: Vec<u8>,
1202}
1203
1204#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
1205struct VaultPayload {
1206 records: Vec<Record>,
1207 record_counter: u64,
1208}
1209
1210struct Vault {
1211 path: PathBuf,
1212 file: VaultFile,
1213 payload: VaultPayload,
1214 dek: Zeroizing<[u8; 32]>,
1215 #[cfg(feature = "mlock")]
1216 _dek_lock: Option<crate::memory::MlockGuard>,
1217}
1218
1219impl Vault {
1220 fn find_index(&self, id: &Uuid) -> Option<usize> {
1221 let mut found = None;
1222 for (idx, record) in self.payload.records.iter().enumerate() {
1223 if constant_time_uuid_eq(&record.id, id) && found.is_none() {
1224 found = Some(idx);
1225 }
1226 }
1227 found
1228 }
1229
1230 fn init(
1231 path: &Path,
1232 passphrase: &Zeroizing<String>,
1233 mem_kib: u32,
1234 config: &AppConfig,
1235 lanes_choice: Option<&str>,
1236 ) -> Result<()> {
1237 if path.exists() {
1238 bail!("vault already exists at {}", path.display());
1239 }
1240
1241 if mem_kib < 32_768 {
1242 bail!("mem-kib must be at least 32768 (32 MiB)");
1243 }
1244
1245 let mut salt = [0u8; 32];
1246 OsRng.fill_bytes(&mut salt);
1247
1248 let argon_lanes = match lanes_choice {
1249 Some(s) if s.eq_ignore_ascii_case("auto") => {
1250 let cpus = num_cpus::get().min(8) as u32;
1251 cpus.max(DEFAULT_LANES)
1252 }
1253 Some(s) => s.parse::<u32>().unwrap_or(DEFAULT_LANES).max(DEFAULT_LANES),
1254 None => {
1255 let cpus = num_cpus::get().min(8) as u32;
1256 cpus.max(DEFAULT_LANES)
1257 }
1258 };
1259
1260 let argon = ArgonState {
1261 mem_cost_kib: mem_kib,
1262 time_cost: DEFAULT_TIME_COST,
1263 lanes: argon_lanes,
1264 salt,
1265 };
1266
1267 let kek = derive_kek(passphrase, &argon)?;
1268
1269 let (ek, dk) = mlkem1024::keypair();
1270 let (shared_key, kem_ct) = mlkem1024::encapsulate(&ek);
1271
1272 let mut dek = Zeroizing::new([0u8; 32]);
1273 OsRng.fill_bytes(&mut *dek);
1274
1275 let sealed_dek = encrypt_blob(shared_key.as_bytes(), dek.as_slice(), AAD_DEK)?;
1276
1277 let dk_bytes = dk.as_bytes();
1278 let sealed_decapsulation = encrypt_blob(kek.as_slice(), dk_bytes, AAD_DK)?;
1279
1280 let payload = VaultPayload {
1281 records: Vec::new(),
1282 record_counter: 0,
1283 };
1284 let payload_blob = encrypt_payload_with_config(dek.as_ref(), &payload, config)?;
1285
1286 let mut header = VaultHeader {
1287 created_at: Utc::now(),
1288 updated_at: Utc::now(),
1289 epoch: 1,
1290 argon,
1291 kem_public: ek.as_bytes().to_vec(),
1292 kem_ciphertext: kem_ct.as_bytes().to_vec(),
1293 sealed_decapsulation,
1294 sealed_dek,
1295 header_mac: None,
1296 };
1297
1298 header.header_mac = Some(compute_header_mac(&header, kek.as_slice()));
1300
1301 let file = VaultFile {
1302 version: VAULT_VERSION,
1303 header,
1304 payload: payload_blob,
1305 };
1306
1307 write_vault(path, &file)
1308 }
1309
1310 fn load(path: &Path, passphrase: &Zeroizing<String>, config: &AppConfig) -> Result<Self> {
1311 if !path.exists() {
1312 bail!("vault not initialized");
1313 }
1314
1315 let buf = Zeroizing::new(read_vault_bytes(path)?);
1316 let vault_file: VaultFile = match from_reader(buf.as_slice()) {
1317 Ok(file) => file,
1318 Err(err) => return Err(unlock_failure(err)),
1319 };
1320
1321 if vault_file.version != VAULT_VERSION && vault_file.version != 1 {
1322 return Err(unlock_failure(anyhow!(
1323 "unsupported vault version {}",
1324 vault_file.version
1325 )));
1326 }
1327
1328 let result = (|| -> Result<Self> {
1329 let kek = derive_kek(passphrase, &vault_file.header.argon)?;
1330 #[cfg(feature = "mlock")]
1331 let _kek_lock = crate::memory::try_lock_slice(kek.as_slice(), config.require_mlock)?;
1332
1333 if let Some(stored) = &vault_file.header.header_mac {
1335 let computed = compute_header_mac(&vault_file.header, kek.as_slice());
1336 if !constant_time_eq(stored, &computed) {
1337 bail!("header_mac_mismatch");
1338 }
1339 }
1340
1341 let dk_bytes = Zeroizing::new(decrypt_blob(
1342 kek.as_slice(),
1343 &vault_file.header.sealed_decapsulation,
1344 AAD_DK,
1345 )?);
1346 #[cfg(feature = "mlock")]
1347 let _dk_bytes_lock =
1348 crate::memory::try_lock_slice(dk_bytes.as_slice(), config.require_mlock)?;
1349 let dk = expect_sk(dk_bytes.as_slice())?;
1350 let kem_ct = expect_ct(&vault_file.header.kem_ciphertext)?;
1351 let shared = mlkem1024::decapsulate(&kem_ct, &dk);
1352 #[cfg(feature = "mlock")]
1353 let _shared_lock =
1354 crate::memory::try_lock_slice(shared.as_bytes(), config.require_mlock)?;
1355
1356 let dek_bytes = Zeroizing::new(decrypt_blob(
1357 shared.as_bytes(),
1358 &vault_file.header.sealed_dek,
1359 AAD_DEK,
1360 )?);
1361 #[cfg(feature = "mlock")]
1362 let _sealed_dek_lock =
1363 crate::memory::try_lock_slice(dek_bytes.as_slice(), config.require_mlock)?;
1364 if dek_bytes.len() != 32 {
1365 bail!("invalid dek length");
1366 }
1367 let mut dek = Zeroizing::new([0u8; 32]);
1368 (&mut *dek).copy_from_slice(dek_bytes.as_slice());
1369 #[cfg(feature = "mlock")]
1370 let dek_guard = crate::memory::try_lock_slice(dek.as_ref(), config.require_mlock)?;
1371
1372 let payload: VaultPayload =
1373 decrypt_payload_with_config(dek.as_ref(), &vault_file.payload, config)?;
1374 let mut payload = payload;
1376 unwrap_payload_after_load(&mut payload, dek.as_ref())?;
1377 if let Ok(seen) = read_epoch_sidecar(path) {
1379 if vault_file.header.epoch < seen {
1380 if std::env::var_os("BLACK_BAG_STRICT_ROLLBACK").is_some() {
1381 bail!("vault appears rolled back");
1382 } else {
1383 eprintln!("warning: vault epoch behind last seen; possible rollback");
1384 }
1385 }
1386 }
1387
1388 #[cfg(feature = "agent-keychain")]
1390 {
1391 if let Some(stored) = maybe_agent_get_epoch(config, path) {
1392 if vault_file.header.epoch < stored {
1393 if std::env::var_os("BLACK_BAG_ALLOW_ROLLBACK").is_none() {
1394 bail!("vault appears rolled back (keychain)");
1395 }
1396 }
1397 }
1398 maybe_agent_store_epoch(config, path, vault_file.header.epoch);
1399 }
1400
1401 Ok(Self {
1402 path: path.to_path_buf(),
1403 file: vault_file,
1404 payload,
1405 dek,
1406 #[cfg(feature = "mlock")]
1407 _dek_lock: dek_guard,
1408 })
1409 })();
1410
1411 result.map_err(unlock_failure)
1412 }
1413
1414 fn save(&mut self, passphrase: &Zeroizing<String>, config: &AppConfig) -> Result<()> {
1415 self.file.header.updated_at = Utc::now();
1416 self.file.header.epoch = self.file.header.epoch.saturating_add(1);
1417
1418 let storage_payload = wrap_payload_for_storage(&self.payload, self.dek.as_ref())?;
1420 let payload_blob =
1421 encrypt_payload_with_config(self.dek.as_ref(), &storage_payload, config)?;
1422 self.file.payload = payload_blob;
1423
1424 let kek = derive_kek(passphrase, &self.file.header.argon)?;
1425 #[cfg(feature = "mlock")]
1426 let _kek_lock = crate::memory::try_lock_slice(kek.as_slice(), config.require_mlock)?;
1427
1428 let dk_bytes = Zeroizing::new(decrypt_blob(
1429 kek.as_slice(),
1430 &self.file.header.sealed_decapsulation,
1431 AAD_DK,
1432 )?);
1433 #[cfg(feature = "mlock")]
1434 let _dk_lock = crate::memory::try_lock_slice(dk_bytes.as_slice(), config.require_mlock)?;
1435 let dk = expect_sk(dk_bytes.as_slice())?;
1436 let ek = expect_pk(&self.file.header.kem_public)?;
1437 let (shared, kem_ct) = mlkem1024::encapsulate(&ek);
1438 #[cfg(feature = "mlock")]
1439 let _shared_lock = crate::memory::try_lock_slice(shared.as_bytes(), config.require_mlock)?;
1440
1441 let sealed_dek = encrypt_blob(shared.as_bytes(), self.dek.as_ref(), AAD_DEK)?;
1442 let sealed_decapsulation = encrypt_blob(kek.as_slice(), dk.as_bytes(), AAD_DK)?;
1443
1444 self.file.header.kem_ciphertext = kem_ct.as_bytes().to_vec();
1445 self.file.header.sealed_dek = sealed_dek;
1446 self.file.header.sealed_decapsulation = sealed_decapsulation;
1447
1448 self.file.header.header_mac = Some(compute_header_mac(&self.file.header, kek.as_slice()));
1450
1451 write_vault(&self.path, &self.file)
1452 }
1453
1454 fn add_record(&mut self, record: Record) {
1455 self.payload.record_counter = self.payload.record_counter.saturating_add(1);
1456 self.payload.records.push(record);
1457 }
1458
1459 fn list(
1460 &self,
1461 kind: Option<RecordKind>,
1462 tag: Option<&str>,
1463 query: Option<&str>,
1464 fuzzy: bool,
1465 ) -> Vec<&Record> {
1466 #[allow(clippy::redundant_closure)]
1467 let matcher = if fuzzy {
1468 Some(fuzzy_matcher::skim::SkimMatcherV2::default())
1469 } else {
1470 None
1471 };
1472 self.payload
1473 .records
1474 .iter()
1475 .filter_map(|rec| {
1476 if let Some(k) = kind {
1477 if rec.kind() != k {
1478 return None;
1479 }
1480 }
1481 if let Some(t) = tag {
1482 if !rec.matches_tag(t) {
1483 return None;
1484 }
1485 }
1486 if let Some(q) = query {
1487 let passed = if fuzzy {
1488 use fuzzy_matcher::FuzzyMatcher;
1489 let matcher = matcher.as_ref().unwrap();
1490 let hay = [
1491 rec.title.as_deref().unwrap_or_default(),
1492 rec.metadata_notes.as_deref().unwrap_or_default(),
1493 &rec.data.summary_text(),
1494 ]
1495 .join("\n");
1496 matcher.fuzzy_match(&hay, q).is_some()
1497 } else {
1498 rec.matches_query(q)
1499 };
1500 if !passed {
1501 return None;
1502 }
1503 }
1504 Some(rec)
1505 })
1506 .collect()
1507 }
1508
1509 fn get(&mut self, id: Uuid) -> Option<&mut Record> {
1510 self.find_index(&id)
1511 .and_then(move |idx| self.payload.records.get_mut(idx))
1512 }
1513
1514 fn get_ref(&self, id: Uuid) -> Option<&Record> {
1515 self.find_index(&id)
1516 .and_then(|idx| self.payload.records.get(idx))
1517 }
1518
1519 fn rotate(
1520 &mut self,
1521 passphrase: &Zeroizing<String>,
1522 mem_kib: Option<u32>,
1523 config: &AppConfig,
1524 ) -> Result<()> {
1525 if let Some(mem) = mem_kib {
1526 if mem < 32_768 {
1527 bail!("mem-kib must be at least 32768 (32 MiB)");
1528 }
1529 self.file.header.argon.mem_cost_kib = mem;
1530 OsRng.fill_bytes(&mut self.file.header.argon.salt);
1531 }
1532
1533 let (ek, dk) = mlkem1024::keypair();
1534 let (shared_key, kem_ct) = mlkem1024::encapsulate(&ek);
1535
1536 let kek = derive_kek(passphrase, &self.file.header.argon)?;
1537 #[cfg(feature = "mlock")]
1538 let _kek_lock = crate::memory::try_lock_slice(kek.as_slice(), config.require_mlock)?;
1539 #[cfg(feature = "mlock")]
1540 let _shared_lock =
1541 crate::memory::try_lock_slice(shared_key.as_bytes(), config.require_mlock)?;
1542
1543 let sealed_dek = encrypt_blob(shared_key.as_bytes(), self.dek.as_ref(), AAD_DEK)?;
1544 let sealed_decapsulation = encrypt_blob(kek.as_slice(), dk.as_bytes(), AAD_DK)?;
1545
1546 self.file.header.kem_public = ek.as_bytes().to_vec();
1547 self.file.header.kem_ciphertext = kem_ct.as_bytes().to_vec();
1548 self.file.header.sealed_dek = sealed_dek;
1549 self.file.header.sealed_decapsulation = sealed_decapsulation;
1550
1551 self.file.header.header_mac = Some(compute_header_mac(&self.file.header, kek.as_slice()));
1553
1554 Ok(())
1555 }
1556
1557 fn stats(&self) -> VaultStats {
1558 VaultStats {
1559 created_at: self.file.header.created_at,
1560 updated_at: self.file.header.updated_at,
1561 record_count: self.payload.records.len(),
1562 argon_mem_kib: self.file.header.argon.mem_cost_kib,
1563 argon_time_cost: self.file.header.argon.time_cost,
1564 argon_lanes: self.file.header.argon.lanes,
1565 }
1566 }
1567}
1568
1569impl Drop for Vault {
1570 fn drop(&mut self) {
1571 self.dek.zeroize();
1572 }
1573}
1574
1575struct VaultStats {
1576 created_at: DateTime<Utc>,
1577 updated_at: DateTime<Utc>,
1578 record_count: usize,
1579 argon_mem_kib: u32,
1580 argon_time_cost: u32,
1581 argon_lanes: u32,
1582}
1583
1584fn derive_kek(passphrase: &Zeroizing<String>, argon: &ArgonState) -> Result<Zeroizing<[u8; 32]>> {
1585 let params = Params::new(argon.mem_cost_kib, argon.time_cost, argon.lanes, Some(32))
1586 .map_err(|e| anyhow!("invalid Argon2 parameters: {e}"))?;
1587 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
1588 let mut output = Zeroizing::new([0u8; 32]);
1589 argon2
1590 .hash_password_into(passphrase.as_bytes(), &argon.salt, output.as_mut())
1591 .map_err(|e| anyhow!("argon2 derivation failed: {e}"))?;
1592 Ok(output)
1593}
1594
1595fn expect_pk(data: &[u8]) -> Result<mlkem1024::PublicKey> {
1597 mlkem1024::PublicKey::from_bytes(data).map_err(|_| anyhow!("invalid public key length").into())
1598}
1599
1600fn expect_sk(data: &[u8]) -> Result<mlkem1024::SecretKey> {
1601 mlkem1024::SecretKey::from_bytes(data).map_err(|_| anyhow!("invalid secret key length").into())
1602}
1603
1604fn expect_ct(data: &[u8]) -> Result<mlkem1024::Ciphertext> {
1605 mlkem1024::Ciphertext::from_bytes(data).map_err(|_| anyhow!("invalid ciphertext length").into())
1606}
1607
1608fn compute_header_mac(header: &VaultHeader, kek: &[u8]) -> [u8; 32] {
1609 use blake3::derive_key;
1610 let mut buf = Vec::new();
1612 buf.extend_from_slice(header.created_at.to_rfc3339().as_bytes());
1613 buf.extend_from_slice(header.updated_at.to_rfc3339().as_bytes());
1614 buf.extend_from_slice(&header.argon.mem_cost_kib.to_be_bytes());
1615 buf.extend_from_slice(&header.argon.time_cost.to_be_bytes());
1616 buf.extend_from_slice(&header.argon.lanes.to_be_bytes());
1617 buf.extend_from_slice(&header.argon.salt);
1618 buf.extend_from_slice(&header.epoch.to_be_bytes());
1619 buf.extend_from_slice(&header.kem_public);
1620 buf.extend_from_slice(&header.kem_ciphertext);
1621 buf.extend_from_slice(&header.sealed_decapsulation.nonce);
1623 buf.extend_from_slice(&header.sealed_decapsulation.ciphertext);
1624 buf.extend_from_slice(&header.sealed_dek.nonce);
1625 buf.extend_from_slice(&header.sealed_dek.ciphertext);
1626 let key = derive_key("black-bag header mac", kek);
1627 let mut hasher = blake3::Hasher::new_keyed(&key);
1628 hasher.update(&buf);
1629 *hasher.finalize().as_bytes()
1630}
1631
1632fn encrypt_blob(key: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<AeadBlob> {
1633 let mut nonce = [0u8; 24];
1634 OsRng.fill_bytes(&mut nonce);
1635 let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
1636 let ciphertext = cipher
1637 .encrypt(
1638 XNonce::from_slice(&nonce),
1639 Payload {
1640 msg: plaintext,
1641 aad,
1642 },
1643 )
1644 .map_err(|_| anyhow!("encryption failed"))?;
1645 Ok(AeadBlob { nonce, ciphertext })
1646}
1647
1648#[cfg(test)]
1649fn encrypt_blob_with_nonce(key: &[u8], plaintext: &[u8], aad: &[u8], nonce: [u8; 24]) -> Result<AeadBlob> {
1650 let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
1651 let ciphertext = cipher
1652 .encrypt(
1653 XNonce::from_slice(&nonce),
1654 Payload { msg: plaintext, aad },
1655 )
1656 .map_err(|_| anyhow!("encryption failed"))?;
1657 Ok(AeadBlob { nonce, ciphertext })
1658}
1659
1660fn decrypt_blob(key: &[u8], blob: &AeadBlob, aad: &[u8]) -> Result<Vec<u8>> {
1661 let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
1662 let plaintext = cipher
1663 .decrypt(
1664 XNonce::from_slice(&blob.nonce),
1665 Payload {
1666 msg: &blob.ciphertext,
1667 aad,
1668 },
1669 )
1670 .map_err(|_| anyhow!("aead_auth_fail"))?;
1671 Ok(plaintext)
1672}
1673
1674fn encrypt_payload_with_config(
1675 dek: &[u8],
1676 payload: &VaultPayload,
1677 config: &AppConfig,
1678) -> Result<AeadBlob> {
1679 let mut buf = Zeroizing::new(Vec::new());
1680 into_writer(payload, &mut *buf).context("failed to serialize payload")?;
1681 let block = std::env::var("BLACK_BAG_PAD_BLOCK")
1682 .ok()
1683 .and_then(|v| v.parse::<usize>().ok())
1684 .unwrap_or(64 * 1024);
1685 let padded = apply_padding(buf.as_slice(), block)?;
1686 #[cfg(feature = "mlock")]
1687 let _guard = crate::memory::try_lock_slice(padded.as_slice(), config.require_mlock)?;
1688 encrypt_blob(dek, padded.as_slice(), AAD_PAYLOAD)
1689}
1690
1691fn decrypt_payload_with_config(
1692 dek: &[u8],
1693 blob: &AeadBlob,
1694 config: &AppConfig,
1695) -> Result<VaultPayload> {
1696 let plaintext = Zeroizing::new(decrypt_blob(dek, blob, AAD_PAYLOAD)?);
1697 if plaintext.len() > MAX_PAYLOAD_PLAINTEXT_BYTES {
1698 bail!("payload too large");
1699 }
1700 #[cfg(feature = "mlock")]
1701 let _guard = crate::memory::try_lock_slice(plaintext.as_slice(), config.require_mlock)?;
1702 let inner = strip_padding(plaintext.as_slice())?;
1703 let payload: VaultPayload = from_reader(inner).context("failed to parse payload")?;
1704 enforce_payload_limits(&payload)?;
1705 Ok(payload)
1706}
1707
1708fn apply_padding(data: &[u8], block: usize) -> Result<Zeroizing<Vec<u8>>> {
1709 if block == 0 {
1710 return Ok(Zeroizing::new(data.to_vec()));
1711 }
1712 let header_len = PAD_MAGIC.len() + 8;
1713 let total = header_len + data.len();
1714 let mut out = Zeroizing::new(Vec::with_capacity(total + block));
1715 out.extend_from_slice(PAD_MAGIC);
1716 out.extend_from_slice(&(data.len() as u64).to_le_bytes());
1717 out.extend_from_slice(data);
1718 let padded_len = ((out.len() + block - 1) / block) * block;
1719 let pad_needed = padded_len - out.len();
1720 if pad_needed > 0 {
1721 let mut pad = vec![0u8; pad_needed];
1722 OsRng.fill_bytes(&mut pad);
1723 out.extend_from_slice(&pad);
1724 } else {
1725 let mut pad = vec![0u8; block];
1726 OsRng.fill_bytes(&mut pad);
1727 out.extend_from_slice(&pad);
1728 }
1729 Ok(out)
1730}
1731
1732fn strip_padding<'a>(plaintext: &'a [u8]) -> Result<&'a [u8]> {
1733 if plaintext.len() >= PAD_MAGIC.len() + 8 && &plaintext[..PAD_MAGIC.len()] == PAD_MAGIC {
1734 let mut len_bytes = [0u8; 8];
1735 len_bytes.copy_from_slice(&plaintext[PAD_MAGIC.len()..PAD_MAGIC.len() + 8]);
1736 let cbor_len = u64::from_le_bytes(len_bytes) as usize;
1737 let start = PAD_MAGIC.len() + 8;
1738 if start + cbor_len > plaintext.len() {
1739 bail!("invalid padded payload length");
1740 }
1741 Ok(&plaintext[start..start + cbor_len])
1742 } else {
1743 Ok(plaintext)
1744 }
1745}
1746
1747fn wrap_payload_for_storage(src: &VaultPayload, dek: &[u8]) -> Result<VaultPayload> {
1748 let mut out = src.clone();
1749 for rec in out.records.iter_mut() {
1750 process_record_wrap(rec, dek)?;
1751 }
1752 Ok(out)
1753}
1754
1755fn unwrap_payload_after_load(payload: &mut VaultPayload, dek: &[u8]) -> Result<()> {
1756 for rec in payload.records.iter_mut() {
1757 process_record_unwrap(rec, dek)?;
1758 }
1759 Ok(())
1760}
1761
1762fn process_record_wrap(rec: &mut Record, dek: &[u8]) -> Result<()> {
1763 let rdek = if let Some(sealed) = &rec.sealed_rdek {
1765 let bytes = decrypt_blob(dek, sealed, AAD_RDEK)?;
1766 Zeroizing::new(bytes)
1767 } else {
1768 let mut r = [0u8; 32];
1769 OsRng.fill_bytes(&mut r);
1770 let r_vec = Zeroizing::new(r.to_vec());
1771 rec.sealed_rdek = Some(encrypt_blob(dek, r_vec.as_slice(), AAD_RDEK)?);
1772 r_vec
1773 };
1774 wrap_record_secrets(&mut rec.data, rdek.as_slice())
1775}
1776
1777fn process_record_unwrap(rec: &mut Record, dek: &[u8]) -> Result<()> {
1778 if let Some(sealed) = &rec.sealed_rdek {
1779 let rdek = Zeroizing::new(decrypt_blob(dek, sealed, AAD_RDEK)?);
1780 unwrap_record_secrets(&mut rec.data, rdek.as_slice())?;
1781 }
1782 Ok(())
1783}
1784
1785fn wrap_record_secrets(data: &mut RecordData, rdek: &[u8]) -> Result<()> {
1786 match data {
1787 RecordData::Login { password, .. } => wrap_sensitive(password, rdek)?,
1788 RecordData::Contact { .. } => {}
1789 RecordData::Id { secret, .. } => {
1790 if let Some(s) = secret {
1791 wrap_sensitive(s, rdek)?;
1792 }
1793 }
1794 RecordData::Note { body } => wrap_sensitive(body, rdek)?,
1795 RecordData::Bank { account_number, .. } => wrap_sensitive(account_number, rdek)?,
1796 RecordData::Wifi { passphrase, .. } => wrap_sensitive(passphrase, rdek)?,
1797 RecordData::Api { secret_key, .. } => wrap_sensitive(secret_key, rdek)?,
1798 RecordData::Wallet { secret_key, .. } => wrap_sensitive(secret_key, rdek)?,
1799 RecordData::Totp { secret, .. } => wrap_sensitive(secret, rdek)?,
1800 RecordData::Ssh { private_key, .. } => wrap_sensitive(private_key, rdek)?,
1801 RecordData::Pgp {
1802 armored_private_key,
1803 ..
1804 } => wrap_sensitive(armored_private_key, rdek)?,
1805 RecordData::Recovery { payload, .. } => wrap_sensitive(payload, rdek)?,
1806 }
1807 Ok(())
1808}
1809
1810fn unwrap_record_secrets(data: &mut RecordData, rdek: &[u8]) -> Result<()> {
1811 match data {
1812 RecordData::Login { password, .. } => unwrap_sensitive(password, rdek)?,
1813 RecordData::Contact { .. } => {}
1814 RecordData::Id { secret, .. } => {
1815 if let Some(s) = secret {
1816 unwrap_sensitive(s, rdek)?;
1817 }
1818 }
1819 RecordData::Note { body } => unwrap_sensitive(body, rdek)?,
1820 RecordData::Bank { account_number, .. } => unwrap_sensitive(account_number, rdek)?,
1821 RecordData::Wifi { passphrase, .. } => unwrap_sensitive(passphrase, rdek)?,
1822 RecordData::Api { secret_key, .. } => unwrap_sensitive(secret_key, rdek)?,
1823 RecordData::Wallet { secret_key, .. } => unwrap_sensitive(secret_key, rdek)?,
1824 RecordData::Totp { secret, .. } => unwrap_sensitive(secret, rdek)?,
1825 RecordData::Ssh { private_key, .. } => unwrap_sensitive(private_key, rdek)?,
1826 RecordData::Pgp {
1827 armored_private_key,
1828 ..
1829 } => unwrap_sensitive(armored_private_key, rdek)?,
1830 RecordData::Recovery { payload, .. } => unwrap_sensitive(payload, rdek)?,
1831 }
1832 Ok(())
1833}
1834
1835fn enforce_payload_limits(payload: &VaultPayload) -> Result<()> {
1836 if payload.records.len() > MAX_RECORDS {
1837 bail!("too many records");
1838 }
1839 for rec in &payload.records {
1840 if rec.tags.len() > MAX_TAGS_PER_RECORD {
1841 bail!("too many tags");
1842 }
1843 for t in &rec.tags {
1844 if t.len() > MAX_TAG_LEN { bail!("tag too long"); }
1845 }
1846 if let Some(t) = &rec.title { if t.len() > MAX_TITLE_LEN { bail!("title too long"); } }
1847 if let Some(n) = &rec.metadata_notes { if n.len() > MAX_NOTE_BYTES { bail!("notes too large"); } }
1848 match &rec.data {
1849 RecordData::Login{ username, url, password } => {
1850 if let Some(u)=username { if u.len()>2048 { bail!("username too long"); } }
1851 if let Some(u)=url { if u.len()>4096 { bail!("url too long"); } }
1852 if password.as_slice().len() > MAX_FIELD_BYTES { bail!("password too large"); }
1853 }
1854 RecordData::Contact{ full_name, emails, phones } => {
1855 if full_name.len()>1024 { bail!("full name too long"); }
1856 if emails.len()>256 || phones.len()>256 { bail!("too many contact entries"); }
1857 }
1858 RecordData::Id{ secret, .. } => {
1859 if let Some(s)=secret { if s.as_slice().len() > MAX_FIELD_BYTES { bail!("id secret too large"); } }
1860 }
1861 RecordData::Note{ body } => { if body.as_slice().len() > MAX_NOTE_BYTES { bail!("note too large"); } }
1862 RecordData::Bank{ account_number, .. } => { if account_number.as_slice().len() > MAX_FIELD_BYTES { bail!("account number too large"); } }
1863 RecordData::Wifi{ passphrase, .. } => { if passphrase.as_slice().len() > MAX_FIELD_BYTES { bail!("wifi passphrase too large"); } }
1864 RecordData::Api{ secret_key, .. } => { if secret_key.as_slice().len() > MAX_FIELD_BYTES { bail!("api secret too large"); } }
1865 RecordData::Wallet{ secret_key, .. } => { if secret_key.as_slice().len() > MAX_FIELD_BYTES { bail!("wallet secret too large"); } }
1866 RecordData::Totp{ secret, .. } => { if secret.as_slice().len() > MAX_FIELD_BYTES { bail!("totp secret too large"); } }
1867 RecordData::Ssh{ private_key, .. } => { if private_key.as_slice().len() > MAX_NOTE_BYTES { bail!("ssh key too large"); } }
1868 RecordData::Pgp{ armored_private_key, .. } => { if armored_private_key.as_slice().len() > MAX_NOTE_BYTES { bail!("pgp key too large"); } }
1869 RecordData::Recovery{ payload, .. } => { if payload.as_slice().len() > MAX_NOTE_BYTES { bail!("recovery payload too large"); } }
1870 }
1871 }
1872 Ok(())
1873}
1874
1875fn wrap_sensitive(s: &mut Sensitive, rdek: &[u8]) -> Result<()> {
1876 if s.data.len() >= 4 && &s.data[..4] == SECRET_MARKER {
1878 return Ok(());
1879 }
1880 let blob = encrypt_blob(rdek, s.as_slice(), AAD_SECRET)?;
1881 let mut out = Vec::with_capacity(4 + 24 + blob.ciphertext.len());
1882 out.extend_from_slice(SECRET_MARKER);
1883 out.extend_from_slice(&blob.nonce);
1884 out.extend_from_slice(&blob.ciphertext);
1885 s.data = out;
1886 Ok(())
1887}
1888
1889fn unwrap_sensitive(s: &mut Sensitive, rdek: &[u8]) -> Result<()> {
1890 if s.data.len() >= 4 && &s.data[..4] == SECRET_MARKER {
1891 if s.data.len() < 4 + 24 {
1892 bail!("wrapped secret too short");
1893 }
1894 let mut nonce = [0u8; 24];
1895 nonce.copy_from_slice(&s.data[4..28]);
1896 let ct = s.data[28..].to_vec();
1897 let blob = AeadBlob {
1898 nonce,
1899 ciphertext: ct,
1900 };
1901 let pt = decrypt_blob(rdek, &blob, AAD_SECRET)?;
1902 s.data = pt;
1903 }
1904 Ok(())
1905}
1906
1907fn encrypt_payload(dek: &[u8], payload: &VaultPayload) -> Result<AeadBlob> {
1908 encrypt_payload_with_config(dek, payload, &AppConfig::default())
1909}
1910
1911fn decrypt_payload(dek: &[u8], blob: &AeadBlob) -> Result<VaultPayload> {
1912 decrypt_payload_with_config(dek, blob, &AppConfig::default())
1913}
1914
1915fn vault_path_with_cfg(config: &AppConfig) -> Result<PathBuf> {
1916 if let Ok(path) = env::var(if config.duress {
1917 "BLACK_BAG_VAULT_DURESS_PATH"
1918 } else {
1919 "BLACK_BAG_VAULT_PATH"
1920 }) {
1921 let pb = PathBuf::from(path);
1922 if let Some(parent) = pb.parent() {
1923 fs::create_dir_all(parent).context("failed to create vault directory")?;
1924 }
1925 return Ok(pb);
1926 }
1927 let base = BaseDirs::new().ok_or_else(|| anyhow!("unable to resolve base directory"))?;
1928 let dir = base.config_dir().join("black_bag");
1929 fs::create_dir_all(&dir).context("failed to create vault directory")?;
1930 let path = dir.join("vault.cbor");
1931 if config.duress {
1932 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("vault");
1933 Ok(path.with_file_name(format!("{}.duress.cbor", stem)))
1934 } else {
1935 Ok(path)
1936 }
1937}
1938
1939fn prompt_passphrase(prompt: &str, config: &AppConfig) -> Result<Zeroizing<String>> {
1940 {
1942 use std::io::Write as _;
1943 let _ = io::stderr().write_all(prompt.as_bytes());
1944 let _ = io::stderr().flush();
1945 }
1946 match prompt_password("") {
1947 Ok(value) => {
1948 if value.trim().is_empty() {
1949 bail!("passphrase cannot be empty");
1950 }
1951 Ok(Zeroizing::new(value))
1952 }
1953 Err(_) if config.unsafe_stdout => {
1954 let mut stderr = io::stderr();
1955 stderr.write_all(prompt.as_bytes())?;
1956 stderr.write_all(b"\n")?;
1957 stderr.flush()?;
1958 let mut line = String::new();
1959 io::stdin().read_line(&mut line)?;
1960 let line = line.trim_end_matches(&['\r', '\n'][..]).to_string();
1961 if line.trim().is_empty() {
1962 bail!("passphrase cannot be empty");
1963 }
1964 Ok(Zeroizing::new(line))
1965 }
1966 Err(err) => Err(err.into()),
1967 }
1968}
1969
1970fn ensure_passphrase_strength(passphrase: &str, policy: PolicyMode, quiet: bool) -> Result<()> {
1971 use unicode_normalization::UnicodeNormalization;
1972 let norm: String = passphrase.nfkc().collect();
1973 let (min_len, min_score) = match policy {
1974 PolicyMode::Moderate => (14usize, 3u8),
1975 PolicyMode::Strict => (18usize, 4u8),
1976 };
1977 let length = norm.chars().count();
1978 if length < min_len {
1979 bail!(
1980 "passphrase_policy_violation: requires >= {min_len} characters (got {length})"
1981 );
1982 }
1983 const COMMON: &[&str] = &[
1985 "password","123456","123456789","qwerty","letmein","iloveyou","admin","welcome","monkey","abc123","1q2w3e4r","dragon","sunshine","princess","football","password1","zaq12wsx","qwertyuiop","passw0rd","baseball"
1986 ];
1987 let lower = norm.to_ascii_lowercase();
1988 if COMMON.iter().any(|w| lower.contains(w)) {
1989 bail!("passphrase_policy_violation: too common");
1990 }
1991 let unique: std::collections::HashSet<char> = norm.chars().collect();
1992 if unique.len() < 6 {
1993 bail!("passphrase_policy_violation: too few unique characters");
1994 }
1995 let estimate = zxcvbn(&norm, &[])
1996 .map_err(|err| anyhow!("failed to evaluate passphrase strength: {err}"))?;
1997 let score = estimate.score();
1998 if score < min_score {
1999 let guesses_log10 = estimate.guesses_log10();
2000 let entropy_bits = (guesses_log10 * 3.321_928_094_887_362).round();
2001 if !quiet {
2002 if let Some(fb) = estimate.feedback() {
2003 if let Some(w) = fb.warning() {
2004 eprintln!("warning: {}", w);
2005 }
2006 for s in fb.suggestions() {
2007 eprintln!("suggestion: {}", s);
2008 }
2009 }
2010 eprintln!(
2011 "policy {} requires score >= {} (got {}), ~{} bits entropy.",
2012 match policy { PolicyMode::Moderate => "moderate", PolicyMode::Strict => "strict" },
2013 min_score,
2014 score,
2015 entropy_bits as i64,
2016 );
2017 }
2018 bail!("passphrase_policy_violation: insufficient strength (score {score})");
2019 }
2020 Ok(())
2021}
2022
2023fn init_vault(cmd: InitCommand, config: &AppConfig) -> Result<()> {
2024 let path = vault_path_with_cfg(config)?;
2025 if cmd.dry_run {
2026 let lanes = if let Some(l) = &cmd.argon_lanes { l.clone() } else { "auto".into() };
2027 println!("Dry-run: would initialize vault at {}", path.display());
2028 println!("Argon2 mem={} KiB, lanes={}, time={}", cmd.mem_kib, lanes, DEFAULT_TIME_COST);
2029 println!("Files: {} (.int + .epoch sidecars)", path.display());
2030 return Ok(());
2031 }
2032 let pass1 = prompt_passphrase("Master passphrase: ", config)?;
2033 ensure_passphrase_strength(pass1.as_str(), config.policy, config.quiet)?;
2034 let pass2 = prompt_passphrase("Confirm passphrase: ", config)?;
2035 if pass1.as_str() != pass2.as_str() { bail!("passphrases do not match"); }
2036 Vault::init(
2037 &path,
2038 &pass1,
2039 cmd.mem_kib,
2040 config,
2041 cmd.argon_lanes.as_deref(),
2042 )?;
2043 maybe_agent_store(config, &path, pass1.as_str());
2044 println!("Initialized vault");
2045 Ok(())
2046}
2047
2048struct LockedPassphrase {
2049 inner: Zeroizing<String>,
2050 #[cfg(feature = "mlock")]
2051 _guard: Option<crate::memory::MlockGuard>,
2052}
2053
2054impl LockedPassphrase {
2055 fn new(value: Zeroizing<String>, config: &AppConfig) -> Result<Self> {
2056 #[cfg(feature = "mlock")]
2057 let guard = crate::memory::try_lock_slice(value.as_bytes(), config.require_mlock)?;
2058 Ok(Self {
2059 inner: value,
2060 #[cfg(feature = "mlock")]
2061 _guard: guard,
2062 })
2063 }
2064}
2065
2066impl Deref for LockedPassphrase {
2067 type Target = Zeroizing<String>;
2068 fn deref(&self) -> &Self::Target {
2069 &self.inner
2070 }
2071}
2072
2073impl DerefMut for LockedPassphrase {
2074 fn deref_mut(&mut self) -> &mut Self::Target {
2075 &mut self.inner
2076 }
2077}
2078
2079fn load_vault_with_prompt(config: &AppConfig) -> Result<(Vault, LockedPassphrase)> {
2080 let path = vault_path_with_cfg(config)?;
2081 if let Some(agent_pass) = maybe_agent_retrieve(config, &path) {
2083 if let Ok(locked) = LockedPassphrase::new(agent_pass, config) {
2084 if let Ok(vault) = Vault::load(&path, &locked, config) {
2085 return Ok((vault, locked));
2086 }
2087 }
2088 eprintln!("warning: cached passphrase invalid; falling back to prompt");
2089 }
2090 let pass = prompt_passphrase("Master passphrase: ", config)?;
2091 let locked = LockedPassphrase::new(pass, config)?;
2092 let vault = Vault::load(&path, &locked, config)?;
2093 Ok((vault, locked))
2094}
2095
2096fn add_record(cmd: AddCommand, config: &AppConfig) -> Result<()> {
2097 let (mut vault, pass) = load_vault_with_prompt(config)?;
2098
2099 let record = match cmd.record {
2100 AddRecord::Login(args) => {
2101 let CommonRecordArgs { title, tags, notes } = args.common;
2102 let password = prompt_hidden("Password: ")?;
2103 Record::new(
2104 RecordData::Login {
2105 username: args.username,
2106 url: args.url,
2107 password,
2108 },
2109 title,
2110 tags,
2111 notes,
2112 )
2113 }
2114 AddRecord::Contact(args) => {
2115 let CommonRecordArgs { title, tags, notes } = args.common;
2116 Record::new(
2117 RecordData::Contact {
2118 full_name: args.full_name,
2119 emails: args.emails,
2120 phones: args.phones,
2121 },
2122 title,
2123 tags,
2124 notes,
2125 )
2126 }
2127 AddRecord::Id(args) => {
2128 let CommonRecordArgs { title, tags, notes } = args.common;
2129 let secret = prompt_optional_hidden("Sensitive document secret (optional): ")?;
2130 Record::new(
2131 RecordData::Id {
2132 id_type: args.id_type,
2133 name_on_doc: args.name_on_doc,
2134 number: args.number,
2135 issuing_country: args.issuing_country,
2136 expiry: args.expiry,
2137 secret,
2138 },
2139 title,
2140 tags,
2141 notes,
2142 )
2143 }
2144 AddRecord::Note(args) => {
2145 let CommonRecordArgs { title, tags, notes } = args.common;
2146 let body = prompt_multiline("Secure note body (Ctrl-D to finish): ")?;
2147 Record::new(RecordData::Note { body }, title, tags, notes)
2148 }
2149 AddRecord::Bank(args) => {
2150 let CommonRecordArgs { title, tags, notes } = args.common;
2151 let account_number = prompt_hidden("Account number / secret: ")?;
2152 cap_sensitive("account number", &account_number, MAX_FIELD_BYTES)?;
2153 Record::new(
2154 RecordData::Bank {
2155 institution: args.institution,
2156 account_name: args.account_name,
2157 routing_number: args.routing_number,
2158 account_number,
2159 },
2160 title,
2161 tags,
2162 notes,
2163 )
2164 }
2165 AddRecord::Wifi(args) => {
2166 let CommonRecordArgs { title, tags, notes } = args.common;
2167 let passphrase = prompt_hidden("Wi-Fi passphrase: ")?;
2168 cap_sensitive("wifi passphrase", &passphrase, MAX_FIELD_BYTES)?;
2169 Record::new(
2170 RecordData::Wifi {
2171 ssid: args.ssid,
2172 security: args.security,
2173 location: args.location,
2174 passphrase,
2175 },
2176 title,
2177 tags,
2178 notes,
2179 )
2180 }
2181 AddRecord::Api(args) => {
2182 let CommonRecordArgs { title, tags, notes } = args.common;
2183 let secret = prompt_hidden("Secret key: ")?;
2184 cap_sensitive("api secret", &secret, MAX_FIELD_BYTES)?;
2185 Record::new(
2186 RecordData::Api {
2187 service: args.service,
2188 environment: args.environment,
2189 access_key: args.access_key,
2190 secret_key: secret,
2191 scopes: args.scopes,
2192 },
2193 title,
2194 tags,
2195 notes,
2196 )
2197 }
2198 AddRecord::Wallet(args) => {
2199 let CommonRecordArgs { title, tags, notes } = args.common;
2200 let secret = prompt_hidden("Wallet secret material: ")?;
2201 cap_sensitive("wallet secret", &secret, MAX_FIELD_BYTES)?;
2202 Record::new(
2203 RecordData::Wallet {
2204 asset: args.asset,
2205 address: args.address,
2206 network: args.network,
2207 secret_key: secret,
2208 },
2209 title,
2210 tags,
2211 notes,
2212 )
2213 }
2214 AddRecord::Totp(args) => {
2215 let AddTotp {
2216 common,
2217 issuer,
2218 account,
2219 secret_file,
2220 secret_stdin,
2221 otpauth_stdin,
2222 qr,
2223 confirm_qr,
2224 digits,
2225 step,
2226 skew,
2227 algorithm,
2228 } = args;
2229 const TOTP_MIN_DIGITS: u8 = 6;
2230 const TOTP_MAX_DIGITS: u8 = 8;
2231 if !(TOTP_MIN_DIGITS..=TOTP_MAX_DIGITS).contains(&digits) {
2232 bail!("digits must be between 6 and 8");
2233 }
2234 if step == 0 {
2235 bail!("step must be greater than zero");
2236 }
2237 let CommonRecordArgs { title, tags, notes } = common;
2238 let (secret_bytes, issuer, account, digits, step, algorithm) = if otpauth_stdin {
2239 use std::io::Read;
2240 let mut s = String::new();
2241 std::io::stdin().read_to_string(&mut s)?;
2242 let parsed = parse_otpauth_uri(&s)?;
2243 (
2244 parsed.secret,
2245 parsed.issuer.or(issuer),
2246 parsed.account.or(account),
2247 parsed.digits,
2248 parsed.step,
2249 parsed.algorithm,
2250 )
2251 } else {
2252 let sb = read_totp_secret_arg(secret_file.as_ref(), secret_stdin)?;
2253 (sb, issuer, account, digits, step, algorithm)
2254 };
2255 let totp_secret = Sensitive { data: secret_bytes };
2256 build_totp_instance(
2257 &totp_secret,
2258 digits,
2259 step,
2260 skew,
2261 algorithm,
2262 &issuer,
2263 &account,
2264 )?;
2265 let record = Record::new(
2266 RecordData::Totp {
2267 issuer: issuer.clone(),
2268 account: account.clone(),
2269 secret: totp_secret,
2270 digits,
2271 step,
2272 skew,
2273 algorithm,
2274 },
2275 title,
2276 tags,
2277 notes,
2278 );
2279 if qr {
2280 if !confirm_qr {
2281 bail!("printing QR requires --confirm-qr acknowledgment");
2282 }
2283 if let (Some(uri_issuer), Some(uri_account)) = (issuer, account) {
2284 let base32 = base32::encode(
2285 base32::Alphabet::Rfc4648 { padding: false },
2286 record_secret_slice(&record),
2287 );
2288 let uri = build_otpauth_uri(
2289 &uri_issuer,
2290 &uri_account,
2291 &base32,
2292 digits,
2293 step,
2294 algorithm,
2295 );
2296 output::print_qr_to_tty(&uri, config)?;
2297 }
2298 }
2299 record
2300 }
2301 AddRecord::Ssh(args) => {
2302 let CommonRecordArgs { title, tags, notes } = args.common;
2303 let private_key = prompt_multiline_paste(
2304 "Paste private key (Ctrl-D to finish): (input will be visible)",
2305 )?;
2306 cap_sensitive("ssh private key", &private_key, MAX_NOTE_BYTES)?;
2307 Record::new(
2308 RecordData::Ssh {
2309 label: args.label,
2310 private_key,
2311 comment: args.comment,
2312 },
2313 title,
2314 tags,
2315 notes,
2316 )
2317 }
2318 AddRecord::Pgp(args) => {
2319 let CommonRecordArgs { title, tags, notes } = args.common;
2320 let armored = prompt_multiline_paste(
2321 "Paste armored private key (Ctrl-D to finish): (input will be visible)",
2322 )?;
2323 cap_sensitive("pgp private key", &armored, MAX_NOTE_BYTES)?;
2324 Record::new(
2325 RecordData::Pgp {
2326 label: args.label,
2327 fingerprint: args.fingerprint,
2328 armored_private_key: armored,
2329 },
2330 title,
2331 tags,
2332 notes,
2333 )
2334 }
2335 AddRecord::Recovery(args) => {
2336 let CommonRecordArgs { title, tags, notes } = args.common;
2337 let payload = prompt_multiline_paste(
2338 "Paste recovery payload (Ctrl-D to finish): (input will be visible)",
2339 )?;
2340 cap_sensitive("recovery payload", &payload, MAX_NOTE_BYTES)?;
2341 Record::new(
2342 RecordData::Recovery {
2343 description: args.description,
2344 payload,
2345 },
2346 title,
2347 tags,
2348 notes,
2349 )
2350 }
2351 };
2352
2353 vault.add_record(record);
2354 vault.save(&pass, config)?;
2355 println!("Record added");
2356 Ok(())
2357}
2358fn list_records(cmd: ListCommand, config: &AppConfig) -> Result<()> {
2359 let (vault, _) = load_vault_with_prompt(config)?;
2360 let list = vault.list(
2361 cmd.kind,
2362 cmd.tag.as_deref(),
2363 cmd.query.as_deref(),
2364 cmd.fuzzy,
2365 );
2366 match config.format {
2367 FormatMode::Text => {
2368 if list.is_empty() {
2369 println!("No matching records");
2370 return Ok(());
2371 }
2372 for record in list {
2373 println!(
2374 "{} | {} | {} | tags=[{}] | {}",
2375 record.id,
2376 record.kind(),
2377 record.title.as_deref().unwrap_or("(untitled)"),
2378 if record.tags.is_empty() {
2379 String::new()
2380 } else {
2381 record.tags.join(",")
2382 },
2383 record.data.summary_text()
2384 );
2385 }
2386 Ok(())
2387 }
2388 FormatMode::Json => {
2389 let items: Vec<serde_json::Value> = list
2390 .iter()
2391 .map(|r| {
2392 serde_json::json!({
2393 "id": r.id,
2394 "kind": format!("{}", r.kind()),
2395 "title": r.title,
2396 "tags": r.tags,
2397 "summary": r.data.summary_text(),
2398 })
2399 })
2400 .collect();
2401 let payload = serde_json::json!({
2402 "schema": config.schema_version.to_string(),
2403 "items": items,
2404 });
2405 println!("{}", payload);
2406 Ok(())
2407 }
2408 FormatMode::Ndjson => {
2409 for r in list {
2410 let line = serde_json::json!({
2411 "schema": config.schema_version.to_string(),
2412 "id": r.id,
2413 "kind": format!("{}", r.kind()),
2414 "title": r.title,
2415 "tags": r.tags,
2416 "summary": r.data.summary_text(),
2417 });
2418 println!("{}", line);
2419 }
2420 Ok(())
2421 }
2422 }
2423}
2424
2425fn get_record(cmd: GetCommand, config: &AppConfig) -> Result<()> {
2426 let (mut vault, _) = load_vault_with_prompt(config)?;
2427 match vault.get(cmd.id) {
2428 Some(record) => {
2429 println!("id: {}", record.id);
2430 println!("kind: {}", record.kind());
2431 if let Some(title) = &record.title {
2432 println!("title: {}", title);
2433 }
2434 if !record.tags.is_empty() {
2435 println!("tags: {}", record.tags.join(","));
2436 }
2437 if let Some(notes) = &record.metadata_notes {
2438 println!("notes: {}", notes);
2439 }
2440 if cmd.clipboard {
2441 #[cfg(feature = "clipboard")]
2442 {
2443 if !config.unsafe_clipboard {
2444 bail!("--clipboard requires --unsafe-clipboard");
2445 }
2446 let ttl = cmd.clipboard_ttl.unwrap_or(20);
2447 copy_primary_secret_to_clipboard(record, config, ttl)?;
2448 eprintln!(
2449 "Copied to clipboard; it will be cleared in {}s. Clipboard is a trap.",
2450 ttl
2451 );
2452 }
2453 #[cfg(not(feature = "clipboard"))]
2454 {
2455 bail!("clipboard support disabled at build time");
2456 }
2457 } else if cmd.otpauth {
2458 match &record.data {
2460 RecordData::Totp { issuer, account, secret, digits, step, algorithm, .. } => {
2461 let secret_b32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, secret.as_slice());
2462 let uri = build_otpauth_uri(
2463 issuer.as_deref().unwrap_or(""),
2464 account.as_deref().unwrap_or(""),
2465 &secret_b32,
2466 *digits, *step, *algorithm,
2467 );
2468 match config.emit {
2469 EmitMode::Tty => {
2470 write_line_tty(&uri, config)?;
2471 if cmd.qr {
2472 if !cmd.confirm_qr { bail!("printing QR requires --confirm-qr acknowledgment"); }
2473 output::print_qr_to_tty(&uri, config)?;
2474 }
2475 }
2476 EmitMode::Json | EmitMode::Stdout => {
2477 if !config.unsafe_stdout { bail!("--otpauth with non-TTY emit requires --unsafe-stdout"); }
2478 let payload = json!({
2479 "schema": config.schema_version.to_string(),
2480 "otpauth": uri,
2481 });
2482 println!("{}", payload);
2483 }
2484 }
2485 return Ok(())
2486 }
2487 _ => bail!("otpauth only supported for TOTP records"),
2488 }
2489 } else if cmd.reveal {
2490 ensure_interactive_or_override(config, "--reveal")?;
2491 render_sensitive(record, config)?;
2492 } else {
2493 println!("(Sensitive fields hidden; re-run with --reveal on a TTY)");
2494 }
2495 Ok(())
2496 }
2497 None => bail!("operation failed"),
2498 }
2499}
2500
2501#[cfg(feature = "clipboard")]
2502fn copy_primary_secret_to_clipboard(record: &Record, _config: &AppConfig, ttl_secs: u64) -> Result<()> {
2503 use arboard::Clipboard;
2504 let text = match &record.data {
2505 RecordData::Login { password, .. } => password.expose_utf8()?,
2506 RecordData::Bank { account_number, .. } => account_number.expose_utf8()?,
2507 RecordData::Wifi { passphrase, .. } => passphrase.expose_utf8()?,
2508 RecordData::Api { secret_key, .. } => secret_key.expose_utf8()?,
2509 RecordData::Wallet { secret_key, .. } => secret_key.expose_utf8()?,
2510 RecordData::Totp { secret, .. } => base32::encode(
2511 base32::Alphabet::Rfc4648 { padding: false },
2512 secret.as_slice(),
2513 ),
2514 RecordData::Ssh { private_key, .. } => private_key.expose_utf8()?,
2515 RecordData::Pgp {
2516 armored_private_key,
2517 ..
2518 } => armored_private_key.expose_utf8()?,
2519 RecordData::Recovery { payload, .. } => payload.expose_utf8()?,
2520 RecordData::Contact { .. } | RecordData::Id { .. } | RecordData::Note { .. } => {
2521 bail!("no primary secret available for this record kind");
2522 }
2523 };
2524 let mut cb = Clipboard::new().map_err(|e| anyhow!("clipboard unavailable: {e}"))?;
2525 cb.set_text(text.clone())
2526 .map_err(|e| anyhow!("failed to set clipboard: {e}"))?;
2527 std::thread::spawn(move || {
2528 std::thread::sleep(std::time::Duration::from_secs(ttl_secs));
2529 if let Ok(mut c) = Clipboard::new() {
2530 let _ = c.set_text(String::new());
2531 }
2532 });
2533 Ok(())
2534}
2535
2536fn parse_totp_secret(input: &str) -> Result<Vec<u8>> {
2537 let cleaned: String = input
2538 .chars()
2539 .filter(|c| !c.is_whitespace() && *c != '-')
2540 .collect();
2541 if cleaned.is_empty() {
2542 bail!("secret cannot be empty");
2543 }
2544 let encoded = cleaned.to_uppercase();
2545 Ok(TotpSecret::Encoded(encoded)
2546 .to_bytes()
2547 .map_err(|_| anyhow!("invalid base32-encoded secret"))?)
2548}
2549
2550struct ParsedOtpAuth {
2551 secret: Vec<u8>,
2552 digits: u8,
2553 step: u64,
2554 algorithm: TotpAlgorithm,
2555 issuer: Option<String>,
2556 account: Option<String>,
2557}
2558
2559fn parse_otpauth_uri(uri: &str) -> Result<ParsedOtpAuth> {
2560 let url = url::Url::parse(uri).map_err(|_| anyhow!("invalid otpauth uri"))?;
2561 if url.scheme() != "otpauth" {
2562 bail!("not an otpauth uri");
2563 }
2564 if url.host_str() != Some("totp") {
2565 bail!("only totp is supported");
2566 }
2567 let label = url.path().trim_start_matches('/');
2568 let account = if label.is_empty() {
2569 None
2570 } else {
2571 Some(label.to_string())
2572 };
2573 let mut secret_opt = None;
2574 let mut issuer = None;
2575 let mut digits = 6u8;
2576 let mut step = 30u64;
2577 let mut algorithm = TotpAlgorithm::Sha1;
2578 for (k, v) in url.query_pairs() {
2579 match k.as_ref() {
2580 "secret" => {
2581 secret_opt = Some(parse_totp_secret(v.as_ref())?);
2582 }
2583 "issuer" => issuer = Some(v.to_string()),
2584 "digits" => {
2585 digits = v.parse::<u8>().unwrap_or(6);
2586 }
2587 "period" => {
2588 step = v.parse::<u64>().unwrap_or(30);
2589 }
2590 "algorithm" => {
2591 algorithm = match v.as_ref().to_ascii_uppercase().as_str() {
2592 "SHA256" => TotpAlgorithm::Sha256,
2593 "SHA512" => TotpAlgorithm::Sha512,
2594 _ => TotpAlgorithm::Sha1,
2595 };
2596 }
2597 _ => {}
2598 }
2599 }
2600 let secret = secret_opt.ok_or_else(|| anyhow!("otpauth missing secret"))?;
2601 Ok(ParsedOtpAuth {
2602 secret,
2603 digits,
2604 step,
2605 algorithm,
2606 issuer,
2607 account,
2608 })
2609}
2610
2611fn build_otpauth_uri(
2612 issuer: &str,
2613 account: &str,
2614 secret_base32: &str,
2615 digits: u8,
2616 step: u64,
2617 algorithm: TotpAlgorithm,
2618) -> String {
2619 let algo = match algorithm {
2620 TotpAlgorithm::Sha1 => "SHA1",
2621 TotpAlgorithm::Sha256 => "SHA256",
2622 TotpAlgorithm::Sha512 => "SHA512",
2623 };
2624 format!(
2625 "otpauth://totp/{}?secret={}&issuer={}&digits={}&period={}&algorithm={}",
2626 account,
2627 secret_base32,
2628 urlencoding::encode(issuer),
2629 digits,
2630 step,
2631 algo
2632 )
2633}
2634
2635fn record_secret_slice(record: &Record) -> &[u8] {
2636 match &record.data {
2637 RecordData::Totp { secret, .. } => secret.as_slice(),
2638 _ => &[],
2639 }
2640}
2641
2642fn read_totp_secret_arg(
2643 secret_file: Option<&std::path::PathBuf>,
2644 secret_stdin: bool,
2645) -> Result<Vec<u8>> {
2646 if let Some(path) = secret_file {
2647 let s = std::fs::read_to_string(path)?;
2648 return parse_totp_secret(s.trim());
2649 }
2650 if secret_stdin {
2651 let mut s = String::new();
2652 io::stdin().read_to_string(&mut s)?;
2653 return parse_totp_secret(s.trim());
2654 }
2655 let input = prompt_hidden("Base32 secret: ")?;
2656 let value = Zeroizing::new(input.expose_utf8()?);
2657 parse_totp_secret(value.as_str())
2658}
2659
2660fn build_totp_instance(
2661 secret: &Sensitive,
2662 digits: u8,
2663 step: u64,
2664 skew: u8,
2665 algorithm: TotpAlgorithm,
2666 _issuer: &Option<String>,
2667 _account: &Option<String>,
2668) -> Result<TOTP> {
2669 let secret_bytes = Zeroizing::new(secret.as_slice().to_vec());
2670 let totp_secret = (*secret_bytes).clone();
2671 Ok(TOTP::new(
2672 algorithm.to_lib(),
2673 usize::from(digits),
2674 skew,
2675 step,
2676 totp_secret,
2677 )
2678 .map_err(|err| anyhow!("failed to construct TOTP: {err}"))?)
2679}
2680
2681fn rotate_vault(cmd: RotateCommand, config: &AppConfig) -> Result<()> {
2682 let (mut vault, pass) = load_vault_with_prompt(config)?;
2683 vault.rotate(&pass, cmd.mem_kib, config)?;
2684 vault.save(&pass, config)?;
2685 println!("Rotation complete");
2686 Ok(())
2687}
2688
2689fn passwd(cmd: PasswordCommand, config: &AppConfig) -> Result<()> {
2690 let (mut vault, old_pass) = load_vault_with_prompt(config)?;
2691
2692 if let Some(mem) = cmd.mem_kib {
2693 if mem < 32_768 {
2694 bail!("mem-kib must be at least 32768 (32 MiB)");
2695 }
2696 vault.file.header.argon.mem_cost_kib = mem;
2697 OsRng.fill_bytes(&mut vault.file.header.argon.salt);
2698 }
2699 if let Some(lanes) = cmd.argon_lanes.as_deref() {
2700 let lanes = if lanes.eq_ignore_ascii_case("auto") {
2701 (num_cpus::get().min(8) as u32).max(DEFAULT_LANES)
2702 } else {
2703 lanes.parse::<u32>().unwrap_or(DEFAULT_LANES).max(DEFAULT_LANES)
2704 };
2705 vault.file.header.argon.lanes = lanes;
2706 }
2707
2708 let new1 = prompt_passphrase("New passphrase: ", config)?;
2709 ensure_passphrase_strength(new1.as_str(), config.policy, config.quiet)?;
2710 let new2 = prompt_passphrase("Confirm new passphrase: ", config)?;
2711 if new1.as_str() != new2.as_str() {
2712 bail!("passphrases do not match");
2713 }
2714
2715 let old_kek = derive_kek(&old_pass, &vault.file.header.argon)?;
2717 let new_kek = derive_kek(&new1, &vault.file.header.argon)?;
2718
2719 let dk_bytes = Zeroizing::new(decrypt_blob(
2721 old_kek.as_slice(),
2722 &vault.file.header.sealed_decapsulation,
2723 AAD_DK,
2724 )?);
2725 let sealed_decapsulation = encrypt_blob(new_kek.as_slice(), dk_bytes.as_slice(), AAD_DK)?;
2726 vault.file.header.sealed_decapsulation = sealed_decapsulation;
2727
2728 if cmd.rekey_dek {
2730 let mut new_dek = Zeroizing::new([0u8; 32]);
2731 OsRng.fill_bytes(&mut *new_dek);
2732 vault.dek = new_dek;
2733 #[cfg(feature = "mlock")]
2734 let _ = crate::memory::try_lock_slice(vault.dek.as_ref(), config.require_mlock)?;
2735 }
2736
2737 vault.file.header.header_mac = Some(compute_header_mac(&vault.file.header, new_kek.as_slice()));
2739 vault.save(&new1, config)?;
2740 let path = vault_path_with_cfg(config)?;
2741 maybe_agent_store(config, &path, new1.as_str());
2742 println!("Master passphrase updated");
2743 Ok(())
2744}
2745
2746#[cfg(feature = "agent-keychain")]
2747fn agent_label_for_path(path: &Path) -> String {
2748 use blake3::Hasher;
2749 let s = path.to_string_lossy();
2750 let mut h = Hasher::new();
2751 h.update(s.as_bytes());
2752 hex::encode(h.finalize().as_bytes())
2753}
2754
2755#[cfg(feature = "agent-keychain")]
2756fn maybe_agent_retrieve(config: &AppConfig, path: &Path) -> Option<Zeroizing<String>> {
2757 use crate::agent::Agent;
2758 if matches!(config.agent, config::AgentMode::Keychain) {
2759 let label = agent_label_for_path(path);
2760 let agent = crate::agent::KeychainAgent;
2761 match agent.retrieve_passphrase(&label) {
2762 Ok(Some(s)) => return Some(Zeroizing::new(s)),
2763 _ => {}
2764 }
2765 }
2766 None
2767}
2768
2769#[cfg(not(feature = "agent-keychain"))]
2770fn maybe_agent_retrieve(config: &AppConfig, _path: &Path) -> Option<Zeroizing<String>> {
2771 let _ = config.agent; None
2773}
2774
2775#[cfg(feature = "agent-keychain")]
2776fn maybe_agent_store(config: &AppConfig, path: &Path, pass: &str) {
2777 use crate::agent::Agent;
2778 if matches!(config.agent, config::AgentMode::Keychain) {
2779 let _ = crate::agent::KeychainAgent.store_passphrase(&agent_label_for_path(path), pass);
2780 }
2781}
2782
2783#[cfg(not(feature = "agent-keychain"))]
2784fn maybe_agent_store(config: &AppConfig, _path: &Path, _pass: &str) {
2785 let _ = config.agent; }
2787
2788#[cfg(feature = "agent-keychain")]
2789fn maybe_agent_get_epoch(config: &AppConfig, path: &Path) -> Option<u64> {
2790 use crate::agent::Agent;
2791 if matches!(config.agent, config::AgentMode::Keychain) {
2792 let agent = crate::agent::KeychainAgent;
2793 let label = format!("epoch:{}", agent_label_for_path(path));
2794 if let Ok(Some(s)) = agent.retrieve_passphrase(&label) {
2795 if let Ok(v) = s.parse::<u64>() { return Some(v); }
2796 }
2797 }
2798 None
2799}
2800
2801#[cfg(feature = "agent-keychain")]
2802fn maybe_agent_store_epoch(config: &AppConfig, path: &Path, epoch: u64) {
2803 use crate::agent::Agent;
2804 if matches!(config.agent, config::AgentMode::Keychain) {
2805 let agent = crate::agent::KeychainAgent;
2806 let label = format!("epoch:{}", agent_label_for_path(path));
2807 let _ = agent.store_passphrase(&label, &epoch.to_string());
2808 }
2809}
2810
2811fn doctor(cmd: DoctorCommand, config: &AppConfig) -> Result<()> {
2812 let path = vault_path_with_cfg(config)?;
2813 let (vault, _) = load_vault_with_prompt(config)?;
2814 let stats = vault.stats();
2815 let header_mac_verified = vault.file.header.header_mac.is_some();
2816
2817 let mut critical = Vec::new();
2818 let mut warnings = Vec::new();
2819
2820 if let Some(parent) = path.parent() {
2821 #[cfg(unix)]
2822 {
2823 use std::os::unix::fs::MetadataExt;
2824 if let Ok(meta) = fs::metadata(parent) {
2825 let mode = meta.mode() & 0o777;
2826 if (mode & 0o022) != 0 {
2827 critical.push("vault directory permissions are too broad");
2828 }
2829 if meta.uid() != unsafe { libc::getuid() } {
2830 critical.push("vault directory not owned by current user");
2831 }
2832 }
2833 }
2834 #[cfg(windows)]
2835 {
2836 if let Err(_) = ensure_secure_dir_permissions(parent) {
2837 warnings.push("vault directory ACLs may be too permissive");
2838 }
2839 }
2840 }
2841
2842 let int_path = integrity_sidecar_path(&path);
2843 if !int_path.exists() {
2844 warnings.push("integrity sidecar (.int) missing; run backup verify or save vault again");
2845 }
2846
2847 if let Ok(meta) = fs::metadata(&path) {
2848 if meta.len() > MAX_VAULT_FILE_BYTES {
2849 critical.push("vault file size exceeds sanity threshold");
2850 }
2851 }
2852
2853 #[cfg(feature = "mlock")]
2854 {
2855 let probe = [0u8; 32];
2856 match crate::memory::try_lock_slice(&probe, false) {
2857 Ok(_) => {}
2858 Err(_) => warnings.push("mlock unavailable; enable caps/limits or omit --require-mlock"),
2859 }
2860 }
2861
2862 let mut swap = "unknown".to_string();
2863 #[cfg(target_os = "linux")]
2864 {
2865 if let Ok(s) = std::fs::read_to_string("/proc/meminfo") {
2866 if let Some(line) = s.lines().find(|l| l.starts_with("SwapTotal:")) {
2867 swap = line.trim().to_string();
2868 }
2869 }
2870 }
2871 #[cfg(target_os = "macos")]
2872 {
2873 if let Ok(out) = std::process::Command::new("/usr/sbin/sysctl").args(["vm.swapusage"]).output() {
2874 if out.status.success() {
2875 swap = String::from_utf8_lossy(&out.stdout).trim().to_string();
2876 }
2877 }
2878 }
2879
2880 let mut rng_ok = true;
2881 let mut buf = [0u8; 16];
2882 if let Err(_) = OsRng.try_fill_bytes(&mut buf) { rng_ok = false; critical.push("OS RNG unavailable"); }
2883
2884 let ready = critical.is_empty();
2885
2886 if cmd.json || matches!(config.format, FormatMode::Json | FormatMode::Ndjson) {
2887 let payload = json!({
2888 "schema": config.schema_version.to_string(),
2889 "ready": ready,
2890 "recordCount": stats.record_count,
2891 "argonMemKib": stats.argon_mem_kib,
2892 "argonTimeCost": stats.argon_time_cost,
2893 "argonLanes": stats.argon_lanes,
2894 "createdAt": stats.created_at.to_rfc3339(),
2895 "updatedAt": stats.updated_at.to_rfc3339(),
2896 "headerMacVerified": header_mac_verified,
2897 "swap": swap,
2898 "rng": if rng_ok { "ok" } else { "fail" },
2899 "warnings": warnings,
2900 "critical": critical,
2901 });
2902 println!("{}", payload);
2903 } else {
2904 println!("status: {}", if ready { "ready" } else { "issues" });
2905 println!("records: {}", stats.record_count);
2906 println!("created: {}", stats.created_at);
2907 println!("updated: {}", stats.updated_at);
2908 println!(
2909 "argon2: mem={} KiB, time={}, lanes={}",
2910 stats.argon_mem_kib, stats.argon_time_cost, stats.argon_lanes
2911 );
2912 println!(
2913 "header-mac: {}",
2914 if header_mac_verified { "verified" } else { "absent (legacy)" }
2915 );
2916 println!("swap: {}", swap);
2917 if !warnings.is_empty() { eprintln!("warnings: {}", warnings.join("; ")); }
2918 if !critical.is_empty() { eprintln!("critical: {}", critical.join("; ")); }
2919 }
2920
2921 if ready { Ok(()) } else { bail!("doctor found critical issues") }
2922}
2923
2924fn completions(cmd: CompletionsCommand) -> Result<()> {
2925 use clap::CommandFactory;
2926 let mut command = Cli::command();
2927 let bin = command.get_name().to_string();
2928 clap_complete::generate(cmd.shell, &mut command, bin, &mut std::io::stdout());
2929 Ok(())
2930}
2931
2932fn help_man() -> Result<()> {
2933 use clap::CommandFactory;
2934 let command = Cli::command();
2935 let man = clap_mangen::Man::new(command);
2936 let mut out: Vec<u8> = Vec::new();
2937 man.render(&mut out)?;
2938 print!("{}", String::from_utf8_lossy(&out));
2939 Ok(())
2940}
2941
2942fn version_info() -> Result<()> {
2943 println!("black-bag {}", env!("CARGO_PKG_VERSION"));
2944 println!("schema: 1");
2945 println!(
2946 "pqc: {}",
2947 if cfg!(feature = "pq") { "on (ml-kem-1024)" } else { "off" }
2948 );
2949 println!("aead: xchacha20poly1305");
2950 println!("argon2id: time=10 lanes>=4 (auto up to 8)");
2951 println!("features:{}{}{}{}",
2952 if cfg!(feature = "mlock") { " mlock" } else { "" },
2953 if cfg!(feature = "tui") { " tui" } else { "" },
2954 if cfg!(feature = "agent-keychain") { " agent-keychain" } else { "" },
2955 if cfg!(feature = "clipboard") { " clipboard" } else { "" },
2956 );
2957 Ok(())
2958}
2959
2960fn export(cmd: ExportCommand, config: &AppConfig) -> Result<()> {
2961 match cmd {
2962 ExportCommand::Csv(args) => export_csv(args, config),
2963 }
2964}
2965
2966fn record(cmd: RecordCommand, config: &AppConfig) -> Result<()> {
2967 match cmd {
2968 RecordCommand::Edit(args) => record_edit(args, config),
2969 RecordCommand::Delete(args) => record_delete(args, config),
2970 }
2971}
2972
2973fn scan(cmd: ScanCommand, config: &AppConfig) -> Result<()> {
2974 match cmd {
2975 ScanCommand::Passwords(args) => scan_passwords(args, config),
2976 }
2977}
2978
2979fn scan_passwords(args: ScanPasswordsCommand, config: &AppConfig) -> Result<()> {
2980 let (vault, _pass) = load_vault_with_prompt(config)?;
2981 use std::collections::HashMap;
2982 let mut dup_map: HashMap<u64, Vec<Uuid>> = HashMap::new();
2983 let mut weak: Vec<(Uuid, u8)> = Vec::new();
2984 for r in vault.list(None, None, None, true) {
2985 if let RecordData::Login { password, .. } = &r.data {
2986 let s = match password.expose_utf8() { Ok(v) => v, Err(_) => continue };
2987 if args.weak {
2988 if let Ok(z) = zxcvbn(&s, &[]) {
2989 if z.score() < 3 { weak.push((r.id, z.score())); }
2990 }
2991 }
2992 if args.duplicates {
2993 let mut h = blake3::Hasher::new();
2994 h.update(s.as_bytes());
2995 let mut key = [0u8;8];
2996 key.copy_from_slice(&h.finalize().as_bytes()[..8]);
2997 let k = u64::from_le_bytes(key);
2998 dup_map.entry(k).or_default().push(r.id);
2999 }
3000 }
3001 }
3002 if args.duplicates { dup_map.retain(|_, v| v.len() > 1); }
3003
3004 if matches!(config.format, FormatMode::Json | FormatMode::Ndjson) {
3005 let dups: Vec<_> = dup_map.values().map(|ids| {
3006 json!({ "count": ids.len(), "ids": ids })
3007 }).collect();
3008 let payload = json!({
3009 "schema": config.schema_version.to_string(),
3010 "duplicates": dups,
3011 "weak": weak.iter().map(|(id,score)| json!({"id": id, "score": score})).collect::<Vec<_>>()
3012 });
3013 println!("{}", payload);
3014 } else {
3015 if args.duplicates {
3016 if dup_map.is_empty() { println!("duplicates: none"); } else {
3017 println!("duplicates:");
3018 for ids in dup_map.values() {
3019 println!("- {} records share a password: {}", ids.len(), ids.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(","));
3020 }
3021 }
3022 }
3023 if args.weak {
3024 if weak.is_empty() { println!("weak: none"); } else {
3025 println!("weak (score<3): {} records", weak.len());
3026 for (id, score) in weak { println!("- {} score={}", id, score); }
3027 }
3028 }
3029 }
3030 Ok(())
3031}
3032
3033fn export_csv(args: ExportCsvCommand, config: &AppConfig) -> Result<()> {
3034 if !config.unsafe_stdout {
3035 bail!("export requires --unsafe-stdout");
3036 }
3037 let (vault, _) = load_vault_with_prompt(config)?;
3038 let mut wtr = csv::Writer::from_writer(std::io::stdout());
3039 let fields = if args.fields.is_empty() {
3040 match args.kind {
3041 Some(RecordKind::Contact) => vec!["id","kind","title","full_name","emails","phones","notes"],
3042 Some(RecordKind::Id) => vec!["id","kind","title","id_type","name_on_doc","number","issuing_country","expiry","notes"],
3043 Some(RecordKind::Wifi) => vec!["id","kind","title","ssid","security","location","summary"],
3044 Some(RecordKind::Api) => vec!["id","kind","title","service","environment","access_key","scopes","summary"],
3045 Some(RecordKind::Ssh) => vec!["id","kind","title","label","comment","summary"],
3046 Some(RecordKind::Pgp) => vec!["id","kind","title","label","fingerprint","summary"],
3047 _ => vec!["id","kind","title","tags","summary"],
3048 }.into_iter().map(|s| s.to_string()).collect()
3049 } else {
3050 args.fields.clone()
3051 };
3052 let sensitive: std::collections::HashSet<&str> = [
3053 "password",
3054 "secret_key",
3055 "totp_secret",
3056 "private_key",
3057 "pgp_private_key",
3058 "recovery_payload",
3059 "account_number",
3060 "passphrase",
3061 ]
3062 .into_iter()
3063 .collect();
3064 if !args.include_secrets && fields.iter().any(|f| sensitive.contains(f.as_str())) {
3065 bail!("exporting secret fields requires --include-secrets");
3066 }
3067 wtr.write_record(&fields)?;
3068 if args.schema {
3069 wtr.flush()?;
3070 return Ok(());
3071 }
3072 let items = vault.list(args.kind, None, None, false);
3073 for r in items {
3074 let mut row = Vec::with_capacity(fields.len());
3075 for f in &fields {
3076 let val = match f.as_str() {
3077 "id" => r.id.to_string(),
3078 "kind" => format!("{}", r.kind()),
3079 "title" => r.title.clone().unwrap_or_default(),
3080 "notes" => r.metadata_notes.clone().unwrap_or_default(),
3081 "tags" => {
3082 if r.tags.is_empty() {
3083 String::new()
3084 } else {
3085 r.tags.join(",")
3086 }
3087 }
3088 "summary" => r.data.summary_text(),
3089 "username" => match &r.data {
3091 RecordData::Login { username, .. } => username.clone().unwrap_or_default(),
3092 _ => String::new(),
3093 },
3094 "full_name" => match &r.data { RecordData::Contact { full_name, .. } => full_name.clone(), _ => String::new() },
3095 "emails" => match &r.data { RecordData::Contact { emails, .. } => if emails.is_empty() { String::new() } else { emails.join(",") }, _ => String::new() },
3096 "phones" => match &r.data { RecordData::Contact { phones, .. } => if phones.is_empty() { String::new() } else { phones.join(",") }, _ => String::new() },
3097 "id_type" => match &r.data { RecordData::Id { id_type, .. } => id_type.clone().unwrap_or_default(), _ => String::new() },
3098 "name_on_doc" => match &r.data { RecordData::Id { name_on_doc, .. } => name_on_doc.clone().unwrap_or_default(), _ => String::new() },
3099 "number" => match &r.data { RecordData::Id { number, .. } => number.clone().unwrap_or_default(), _ => String::new() },
3100 "issuing_country" => match &r.data { RecordData::Id { issuing_country, .. } => issuing_country.clone().unwrap_or_default(), _ => String::new() },
3101 "expiry" => match &r.data { RecordData::Id { expiry, .. } => expiry.clone().unwrap_or_default(), _ => String::new() },
3102 "service" => match &r.data { RecordData::Api { service, .. } => service.clone().unwrap_or_default(), _ => String::new() },
3103 "environment" => match &r.data { RecordData::Api { environment, .. } => environment.clone().unwrap_or_default(), _ => String::new() },
3104 "access_key" => match &r.data { RecordData::Api { access_key, .. } => access_key.clone().unwrap_or_default(), _ => String::new() },
3105 "scopes" => match &r.data { RecordData::Api { scopes, .. } => if scopes.is_empty() { String::new() } else { scopes.join(",") }, _ => String::new() },
3106 "ssid" => match &r.data { RecordData::Wifi { ssid, .. } => ssid.clone().unwrap_or_default(), _ => String::new() },
3107 "security" => match &r.data { RecordData::Wifi { security, .. } => security.clone().unwrap_or_default(), _ => String::new() },
3108 "location" => match &r.data { RecordData::Wifi { location, .. } => location.clone().unwrap_or_default(), _ => String::new() },
3109 "label" => match &r.data { RecordData::Ssh { label, .. } | RecordData::Pgp { label, .. } => label.clone().unwrap_or_default(), _ => String::new() },
3110 "comment" => match &r.data { RecordData::Ssh { comment, .. } => comment.clone().unwrap_or_default(), _ => String::new() },
3111 "fingerprint" => match &r.data { RecordData::Pgp { fingerprint, .. } => fingerprint.clone().unwrap_or_default(), _ => String::new() },
3112 "url" => match &r.data {
3113 RecordData::Login { url, .. } => url.clone().unwrap_or_default(),
3114 _ => String::new(),
3115 },
3116 "password" => match &r.data {
3117 RecordData::Login { password, .. } => {
3118 password.expose_utf8().unwrap_or_default()
3119 }
3120 _ => String::new(),
3121 },
3122 "secret_key" => match &r.data {
3123 RecordData::Api { secret_key, .. } => {
3124 secret_key.expose_utf8().unwrap_or_default()
3125 }
3126 RecordData::Wallet { secret_key, .. } => {
3127 secret_key.expose_utf8().unwrap_or_default()
3128 }
3129 _ => String::new(),
3130 },
3131 "totp_secret" => match &r.data {
3132 RecordData::Totp { secret, .. } => base32::encode(
3133 base32::Alphabet::Rfc4648 { padding: false },
3134 secret.as_slice(),
3135 ),
3136 _ => String::new(),
3137 },
3138 _ => String::new(),
3139 };
3140 row.push(val);
3141 }
3142 wtr.write_record(&row)?;
3143 }
3144 wtr.flush()?;
3145 Ok(())
3146}
3147
3148fn record_edit(cmd: RecordEditCommand, config: &AppConfig) -> Result<()> {
3149 let (mut vault, pass) = load_vault_with_prompt(config)?;
3150 let rec = vault.get(cmd.id).ok_or_else(|| anyhow!("record not found"))?;
3151 if let Some(t) = cmd.title {
3152 if t.is_empty() { rec.title = None; } else {
3153 if t.len() > MAX_TITLE_LEN { bail!("title too long"); }
3154 rec.title = Some(t);
3155 }
3156 }
3157 if let Some(n) = cmd.notes {
3158 if n.is_empty() { rec.metadata_notes = None; } else {
3159 if n.len() > MAX_NOTE_BYTES { bail!("notes too long"); }
3160 rec.metadata_notes = Some(n);
3161 }
3162 }
3163 if !cmd.add_tag.is_empty() {
3164 for tag in cmd.add_tag {
3165 let tag = tag.trim().to_string();
3166 if tag.is_empty() { continue; }
3167 if tag.len() > MAX_TAG_LEN { bail!("tag too long"); }
3168 if rec.tags.len() >= MAX_TAGS_PER_RECORD { bail!("too many tags"); }
3169 if !rec.tags.iter().any(|t| t.eq_ignore_ascii_case(&tag)) {
3170 rec.tags.push(tag);
3171 }
3172 }
3173 }
3174 if !cmd.rm_tag.is_empty() {
3175 rec.tags.retain(|t| !cmd.rm_tag.iter().any(|r| t.eq_ignore_ascii_case(r)));
3176 }
3177 rec.updated_at = Utc::now();
3178 vault.save(&pass, config)?;
3179 println!("Record updated");
3180 Ok(())
3181}
3182
3183fn record_delete(cmd: RecordDeleteCommand, config: &AppConfig) -> Result<()> {
3184 let (mut vault, pass) = load_vault_with_prompt(config)?;
3185 if !cmd.force {
3186 ensure_interactive_or_override(config, "record delete")?;
3187 eprintln!("Type DELETE to confirm deletion of {}:", cmd.id);
3188 let mut line = String::new();
3189 io::stdin().read_line(&mut line)?;
3190 if line.trim() != "DELETE" { bail!("deletion aborted"); }
3191 }
3192 if let Some(idx) = vault.find_index(&cmd.id) {
3193 vault.payload.records.remove(idx);
3194 vault.save(&pass, config)?;
3195 println!("Record deleted");
3196 Ok(())
3197 } else {
3198 bail!("record not found")
3199 }
3200}
3201
3202fn recovery(cmd: RecoveryCommand, _config: &AppConfig) -> Result<()> {
3203 match cmd {
3204 RecoveryCommand::Split(args) => {
3205 if args.threshold == 0 || args.threshold > args.shares {
3206 bail!("threshold must be between 1 and number of shares");
3207 }
3208 let secret = prompt_hidden("Secret to split: ")?;
3209 let split = if args.duress {
3210 shamir::split_secret_with_purpose(
3211 secret.as_slice(),
3212 args.threshold,
3213 args.shares,
3214 shamir::SharePurpose::Duress,
3215 )?
3216 } else {
3217 split_secret(secret.as_slice(), args.threshold, args.shares)?
3218 };
3219 for share in &split.shares {
3220 let (id, data) = share
3221 .split_first()
3222 .ok_or_else(|| anyhow!("invalid share structure"))?;
3223 let encoded = BASE64.encode(data);
3224 let token = format!("{}-{}", id, encoded);
3225 if args.with_checksum {
3226 let chk = short_crockford_checksum(token.as_bytes());
3227 println!("{} # chk: {}", token, chk);
3228 } else {
3229 println!("{}", token);
3230 }
3231 if args.qr {
3232 if !args.confirm_qr { bail!("printing QR requires --confirm-qr acknowledgment"); }
3233 output::print_qr_to_tty(&token, _config)?;
3234 }
3235 }
3236 println!("# share-set-id {}", split.set_id_base32);
3237 println!("# record this share-set ID separately; combine will verify it");
3238 Ok(())
3239 }
3240 RecoveryCommand::Combine(args) => {
3241 std::thread::sleep(std::time::Duration::from_millis(200));
3243 if args.threshold == 0 {
3244 bail!("threshold must be at least 1");
3245 }
3246 let mut shares = Vec::new();
3247 for part in args
3248 .shares
3249 .split(',')
3250 .map(str::trim)
3251 .filter(|s| !s.is_empty())
3252 {
3253 let token = part
3255 .split_whitespace()
3256 .next()
3257 .unwrap_or("")
3258 .split('#')
3259 .next()
3260 .unwrap_or("");
3261 let (id, data) = token
3262 .split_once('-')
3263 .ok_or_else(|| anyhow!("invalid share format: {part}"))?;
3264 let identifier: u8 = id.parse().context("invalid share identifier")?;
3265 if identifier == 0 {
3266 bail!("share identifier must be between 1 and 255");
3267 }
3268 let mut decoded = BASE64.decode(data).context("invalid base64 in share")?;
3269 if decoded.is_empty() {
3270 bail!("share payload cannot be empty");
3271 }
3272 let mut share = Vec::with_capacity(decoded.len() + 1);
3273 share.push(identifier);
3274 share.append(&mut decoded);
3275 shares.push(share);
3276 }
3277 if shares.len() < args.threshold as usize {
3278 bail!(
3279 "insufficient shares provided (need at least {})",
3280 args.threshold
3281 );
3282 }
3283 let secret = if args.duress {
3284 shamir::combine_secret_with_purpose(
3285 args.threshold,
3286 &shares,
3287 args.set_id.as_deref(),
3288 shamir::SharePurpose::Duress,
3289 )?
3290 } else {
3291 combine_secret(args.threshold, &shares, args.set_id.as_deref())?
3292 };
3293 if args.raw {
3294 ensure_interactive_or_override(_config, "recovery combine --raw")?;
3295 write_bytes_tty(&secret, _config)?;
3296 } else {
3297 let b64 = BASE64.encode(&secret);
3298 write_line_tty(&b64, _config)?;
3299 }
3300 Ok(())
3301 }
3302 RecoveryCommand::Verify(args) => {
3303 std::thread::sleep(std::time::Duration::from_millis(100));
3304 if args.threshold == 0 { bail!("threshold must be at least 1"); }
3305 let shares = parse_shares_input(&args.shares)?;
3306 let purpose = if args.duress { shamir::SharePurpose::Duress } else { shamir::SharePurpose::Normal };
3307 let status = shamir::audit_shares(args.threshold, &shares, args.set_id.as_deref(), purpose)?;
3308 let out = match status {
3309 shamir::AuditStatus::Ok | shamir::AuditStatus::LegacyOnly => "OK",
3310 shamir::AuditStatus::Mismatch => "mismatch",
3311 shamir::AuditStatus::Tamper | shamir::AuditStatus::Mixed => "tamper",
3312 };
3313 println!("{}", out);
3314 Ok(())
3315 }
3316 RecoveryCommand::Doctor(args) => {
3317 let shares = parse_shares_input(&args.shares)?;
3318 let purpose = if args.duress { shamir::SharePurpose::Duress } else { shamir::SharePurpose::Normal };
3319 let status = shamir::audit_shares(args.threshold, &shares, args.set_id.as_deref(), purpose)?;
3320 match status {
3321 shamir::AuditStatus::LegacyOnly => {
3322 eprintln!("Detected legacy shares (unauthenticated). Action: re-split to authenticated shares.");
3323 }
3324 shamir::AuditStatus::Mixed => {
3325 eprintln!("Detected mix of legacy and authenticated shares. Action: re-split entire set; do not combine.");
3326 }
3327 shamir::AuditStatus::Mismatch => {
3328 eprintln!("Share-set ID mismatch. Action: ensure all shares belong to the same set; re-issue as needed.");
3329 }
3330 shamir::AuditStatus::Tamper => {
3331 eprintln!("Tamper or corruption detected. Action: check transcription/scans; re-issue affected shares.");
3332 }
3333 shamir::AuditStatus::Ok => {
3334 eprintln!("OK: authenticated share-set; ready to combine.");
3335 }
3336 }
3337 Ok(())
3338 }
3339 }
3340}
3341
3342fn parse_shares_input(input: &str) -> Result<Vec<Vec<u8>>> {
3343 let mut shares = Vec::new();
3344 for part in input.split(',').map(str::trim).filter(|s| !s.is_empty()) {
3345 let token = part
3346 .split_whitespace()
3347 .next()
3348 .unwrap_or("")
3349 .split('#')
3350 .next()
3351 .unwrap_or("");
3352 let (id, data) = token
3353 .split_once('-')
3354 .ok_or_else(|| anyhow!("invalid share format: {part}"))?;
3355 let identifier: u8 = id.parse().context("invalid share identifier")?;
3356 if identifier == 0 { bail!("share identifier must be between 1 and 255"); }
3357 let mut decoded = BASE64.decode(data).context("invalid base64 in share")?;
3358 if decoded.is_empty() { bail!("share payload cannot be empty"); }
3359 let mut share = Vec::with_capacity(decoded.len() + 1);
3360 share.push(identifier);
3361 share.append(&mut decoded);
3362 shares.push(share);
3363 }
3364 Ok(shares)
3365}
3366
3367fn short_crockford_checksum(data: &[u8]) -> String {
3368 use blake3::Hasher;
3369 let mut h = Hasher::new();
3370 h.update(data);
3371 let digest = h.finalize();
3372 let mut bytes = [0u8; 5];
3374 bytes.copy_from_slice(&digest.as_bytes()[..5]);
3375 base32::encode(base32::Alphabet::Crockford, &bytes)
3376}
3377
3378fn totp(cmd: TotpCommand, config: &AppConfig) -> Result<()> {
3379 match cmd {
3380 TotpCommand::Code(args) => totp_code(args, config),
3381 TotpCommand::Doctor(args) => totp_doctor(args, config),
3382 }
3383}
3384
3385fn totp_doctor(args: TotpDoctorCommand, config: &AppConfig) -> Result<()> {
3386 let (mut vault, _pass) = load_vault_with_prompt(config)?;
3387 let rec = vault.get(args.id).ok_or_else(|| anyhow!("record not found"))?;
3388 let (issuer, account, secret, digits, step, skew, algorithm) = match &rec.data {
3389 RecordData::Totp { issuer, account, secret, digits, step, skew, algorithm } =>
3390 (issuer.clone(), account.clone(), secret.clone(), *digits, *step, *skew, *algorithm),
3391 _ => bail!("record is not TOTP"),
3392 };
3393 let now = Utc::now().timestamp();
3394 let phase = (now.rem_euclid(step as i64)) as u64;
3395 let ttl = step.saturating_sub(phase);
3396 let base32_secret = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, secret.as_slice());
3397 let uri = build_otpauth_uri(
3398 issuer.as_deref().unwrap_or(""),
3399 account.as_deref().unwrap_or(""),
3400 &base32_secret, digits, step, algorithm,
3401 );
3402 let drift = args.ref_time.map(|t| now.saturating_sub(t)).unwrap_or(0);
3403 if matches!(config.emit, EmitMode::Json | EmitMode::Stdout) {
3404 if !config.unsafe_stdout { bail!("totp doctor json requires --unsafe-stdout"); }
3405 let payload = json!({
3406 "schema": config.schema_version.to_string(),
3407 "now": now,
3408 "phase": phase,
3409 "ttl": ttl,
3410 "digits": digits,
3411 "step": step,
3412 "skew": skew,
3413 "algorithm": format!("{:?}", algorithm),
3414 "otpauth": uri,
3415 "drift": drift,
3416 "suggestion": if ttl < 2 { "time near boundary; if persistent, check NTP" } else { "ok" },
3417 });
3418 println!("{}", payload);
3419 } else {
3420 println!("now: {}", now);
3421 println!("phase: {} of {}", phase, step);
3422 println!("ttl: {}", ttl);
3423 println!("digits: {} step: {} skew: {} algo: {:?}", digits, step, skew, algorithm);
3424 println!("otpauth: {}", uri);
3425 if ttl < 2 { eprintln!("warning: time near boundary; if persistent, check NTP"); }
3426 if args.ref_time.is_some() { println!("drift: {}", drift); }
3427 }
3428 Ok(())
3429}
3430
3431fn backup(cmd: BackupCommand, _config: &AppConfig) -> Result<()> {
3432 match cmd {
3433 BackupCommand::Verify { path, pub_key } => backup_verify(&path, pub_key.as_deref()),
3434 BackupCommand::Sign { path, key, pub_out } => backup_sign(&path, &key, pub_out.as_deref()),
3435 }
3436}
3437
3438fn backup_verify(path: &Path, pub_key_path: Option<&Path>) -> Result<()> {
3439 let bytes = std::fs::read(path)?;
3440 let file: VaultFile = match from_reader(bytes.as_slice()) {
3441 Ok(v) => v,
3442 Err(_) => bail!("failed to parse vault"),
3443 };
3444 let expected = read_integrity_sidecar(path)?;
3445 let actual = compute_public_integrity_tag(&bytes, &file.header.kem_public);
3446 eprintln!("note: this checks bit-rot, not authenticity; keep vault + .int paired");
3447 if expected != actual {
3448 bail!("integrity tag mismatch")
3449 }
3450 if let Some(pk) = pub_key_path {
3451 let sig = read_integrity_signature_sidecar(path)?;
3452 verify_integrity_signature(&expected, pk, &sig)?;
3453 eprintln!("OK: signature verified");
3454 } else {
3455 eprintln!("OK: integrity tag matches");
3456 }
3457 Ok(())
3458}
3459
3460fn backup_sign(path: &Path, key_path: &Path, pub_out: Option<&Path>) -> Result<()> {
3461 use ed25519_dalek::{SigningKey, VerifyingKey, Signer};
3462 let bytes = std::fs::read(path)?;
3463 let file: VaultFile = match from_reader(bytes.as_slice()) {
3464 Ok(v) => v,
3465 Err(_) => bail!("failed to parse vault"),
3466 };
3467 let tag = match read_integrity_sidecar(path) {
3468 Ok(t) => t,
3469 Err(_) => {
3470 let t = compute_public_integrity_tag(&bytes, &file.header.kem_public);
3472 let _ = write_integrity_sidecar(path, t);
3473 t
3474 }
3475 };
3476 let sk_bytes = read_key_bytes(key_path)?;
3477 let sk = if sk_bytes.len() == 32 {
3478 SigningKey::from_bytes(sk_bytes.as_slice().try_into().map_err(|_| anyhow!("invalid secret key length"))?)
3479 } else if sk_bytes.len() == 64 {
3480 SigningKey::from_keypair_bytes(sk_bytes.as_slice().try_into().map_err(|_| anyhow!("invalid secret key length"))?)
3481 .map_err(|_| anyhow!("invalid keypair bytes"))?
3482 } else {
3483 bail!("unsupported key size: expected 32 or 64 bytes")
3484 };
3485 let sig = sk.sign(&tag);
3486 write_integrity_signature_sidecar(path, sig.to_bytes())?;
3487 let vk: VerifyingKey = sk.verifying_key();
3488 if let Some(out) = pub_out {
3489 std::fs::write(out, hex::encode(vk.to_bytes()))?;
3490 }
3491 eprintln!("OK: wrote signature sidecar");
3492 Ok(())
3493}
3494
3495fn read_key_bytes(path: &Path) -> Result<Vec<u8>> {
3496 let s = std::fs::read_to_string(path)?;
3497 let t = s.trim();
3498 let hex_ok = t.chars().all(|c| c.is_ascii_hexdigit());
3499 if hex_ok && (t.len() == 64 || t.len() == 128) {
3500 return Ok(hex::decode(t)?);
3501 }
3502 if let Ok(b) = BASE64.decode(t) { return Ok(b); }
3504 bail!("unrecognized key encoding (hex or base64)")
3505}
3506
3507fn integrity_signature_sidecar_path(path: &Path) -> PathBuf {
3508 let mut p = integrity_sidecar_path(path);
3509 p.set_extension("int.sig");
3510 p
3511}
3512
3513fn write_integrity_signature_sidecar(path: &Path, sig: [u8; 64]) -> Result<()> {
3514 use std::io::Write;
3515 let sidecar = integrity_signature_sidecar_path(path);
3516 let mut f = std::fs::File::create(&sidecar)?;
3517 writeln!(f, "{}", base64::engine::general_purpose::STANDARD.encode(sig))?;
3518 Ok(())
3519}
3520
3521fn read_integrity_signature_sidecar(path: &Path) -> Result<[u8; 64]> {
3522 use std::io::Read;
3523 let sidecar = integrity_signature_sidecar_path(path);
3524 let mut s = String::new();
3525 let mut f = std::fs::File::open(&sidecar)
3526 .map_err(|_| anyhow!("signature sidecar missing; run backup sign --key <sk>"))?;
3527 f.read_to_string(&mut s)
3528 .map_err(|_| anyhow!("invalid signature sidecar"))?;
3529 let bytes = base64::engine::general_purpose::STANDARD
3530 .decode(s.trim())
3531 .map_err(|_| anyhow!("invalid signature sidecar"))?;
3532 if bytes.len() != 64 { bail!("invalid signature length"); }
3533 let mut out = [0u8; 64];
3534 out.copy_from_slice(&bytes);
3535 Ok(out)
3536}
3537
3538fn verify_integrity_signature(tag: &[u8; 32], pub_key: &Path, sig: &[u8; 64]) -> Result<()> {
3539 use ed25519_dalek::{Signature, VerifyingKey, Verifier};
3540 let pk_bytes = read_key_bytes(pub_key)?;
3541 if pk_bytes.len() != 32 { bail!("invalid public key length; expected 32 bytes"); }
3542 let vk = VerifyingKey::from_bytes(pk_bytes.as_slice().try_into().unwrap())
3543 .map_err(|_| anyhow!("invalid public key"))?;
3544 let signature = Signature::from_bytes(sig);
3545 vk.verify(tag, &signature).map_err(|_| anyhow!("integrity signature verification failed").into())
3546}
3547
3548fn totp_code(args: TotpCodeCommand, config: &AppConfig) -> Result<()> {
3549 ensure_interactive_or_override(config, "totp code")?;
3550 let (vault, _) = load_vault_with_prompt(config)?;
3551 let record = vault
3552 .get_ref(args.id)
3553 .ok_or_else(|| anyhow!("operation failed"))?;
3554 let (issuer, account, secret, digits, step, skew, algorithm) = match &record.data {
3555 RecordData::Totp {
3556 issuer,
3557 account,
3558 secret,
3559 digits,
3560 step,
3561 skew,
3562 algorithm,
3563 } => (issuer, account, secret, *digits, *step, *skew, *algorithm),
3564 _ => bail!("operation failed"),
3565 };
3566
3567 let totp = build_totp_instance(secret, digits, step, skew, algorithm, issuer, account)?;
3568 let code = if let Some(ts) = args.time {
3569 if ts < 0 {
3570 bail!("time must be non-negative");
3571 }
3572 totp.generate(ts as u64)
3573 } else {
3574 totp.generate_current()?
3575 };
3576 let ttl = if args.time.is_none() {
3577 Some(totp.ttl()?)
3578 } else {
3579 None
3580 };
3581 emit_totp_code(&code, ttl, config)
3582}
3583
3584fn self_test() -> Result<()> {
3585 let mut sample = [0u8; 32];
3586 OsRng.fill_bytes(&mut sample);
3587 let secret = Sensitive::new_from_utf8(&sample);
3588 let record = Record::new(
3589 RecordData::Note { body: secret },
3590 Some("Self-test".into()),
3591 vec!["selftest".into()],
3592 None,
3593 );
3594 let payload = VaultPayload {
3595 records: vec![record],
3596 record_counter: 1,
3597 };
3598
3599 let mut dek = [0u8; 32];
3600 OsRng.fill_bytes(&mut dek);
3601 let blob = encrypt_payload(&dek, &payload)?;
3602 let recovered = decrypt_payload(&dek, &blob)?;
3603 if recovered.records.len() != 1 {
3604 bail!("self-test failed");
3605 }
3606 println!("Self-test passed");
3607 Ok(())
3608}
3609
3610fn prompt_hidden(prompt: &str) -> Result<Sensitive> {
3611 let value = Zeroizing::new(prompt_password(prompt)?);
3612 Ok(Sensitive::from_string(value.as_str()))
3613}
3614
3615fn prompt_optional_hidden(prompt: &str) -> Result<Option<Sensitive>> {
3616 let value = Zeroizing::new(prompt_password(prompt)?);
3617 if value.trim().is_empty() {
3618 Ok(None)
3619 } else {
3620 Ok(Some(Sensitive::from_string(value.as_str())))
3621 }
3622}
3623
3624fn prompt_multiline(prompt: &str) -> Result<Sensitive> {
3625 eprintln!("{}", prompt);
3626 read_multiline(false)
3627}
3628
3629fn prompt_multiline_paste(prompt: &str) -> Result<Sensitive> {
3630 eprintln!("{}", prompt);
3631 read_multiline(true)
3632}
3633
3634fn read_multiline(_hidden: bool) -> Result<Sensitive> {
3635 let mut buffer = Zeroizing::new(Vec::new());
3636 io::stdin().read_to_end(&mut buffer)?;
3637 while buffer.last().copied() == Some(b'\n') {
3638 buffer.pop();
3639 }
3640 Ok(Sensitive::new_from_utf8(&buffer))
3641}
3642
3643fn lock_handle(path: &Path) -> Result<RwLock<File>> {
3644 let lock_path = path.with_extension("lock");
3645 let file = OpenOptions::new()
3646 .create(true)
3647 .read(true)
3648 .write(true)
3649 .open(&lock_path)
3650 .context("failed to open lock file")?;
3651 Ok(RwLock::new(file))
3652}
3653
3654fn read_vault_bytes(path: &Path) -> Result<Vec<u8>> {
3655 let lock = lock_handle(path)?;
3656 let _guard = lock.read()?;
3657 let meta = fs::metadata(path).context("failed to stat vault")?;
3658 if meta.len() > MAX_VAULT_FILE_BYTES {
3659 bail!("vault too large");
3660 }
3661 #[cfg(all(unix, target_os = "linux"))]
3662 use std::os::unix::fs::OpenOptionsExt;
3663 #[cfg(all(unix, target_os = "linux"))]
3664 let mut file = match OpenOptions::new()
3665 .read(true)
3666 .custom_flags(libc::O_NOATIME)
3667 .open(path)
3668 {
3669 Ok(f) => f,
3670 Err(_) => File::open(path).context("failed to open vault")?,
3671 };
3672 #[cfg(not(all(unix, target_os = "linux")))]
3673 let mut file = File::open(path).context("failed to open vault")?;
3674 let mut buf = Vec::new();
3675 file.read_to_end(&mut buf)?;
3676 Ok(buf)
3677}
3678
3679#[cfg(feature = "fuzzing")]
3680pub fn fuzz_try_payload(bytes: &[u8]) {
3681 let _ = ciborium::de::from_reader::<VaultPayload, _>(bytes);
3682}
3683
3684#[cfg(feature = "fuzzing")]
3685pub fn fuzz_try_header(bytes: &[u8]) {
3686 let _ = ciborium::de::from_reader::<VaultFile, _>(bytes);
3687}
3688
3689#[cfg(feature = "fuzzing")]
3690pub fn fuzz_try_otpauth(bytes: &[u8]) {
3691 use std::borrow::Cow;
3692 let s = String::from_utf8_lossy(bytes);
3693 let input: Cow<str> = match &s {
3694 Cow::Borrowed(b) => Cow::Borrowed(*b),
3695 Cow::Owned(o) => Cow::Owned(o.replace('\0', "")),
3696 };
3697 let _ = parse_otpauth_uri(&input);
3698}
3699
3700#[cfg(feature = "fuzzing")]
3701pub fn fuzz_try_parse_shares(bytes: &[u8]) {
3702 let s = String::from_utf8_lossy(bytes).to_string();
3703 let _ = parse_shares_input(&s);
3704}
3705
3706fn write_vault(path: &Path, file: &VaultFile) -> Result<()> {
3707 let parent = path.parent().ok_or_else(|| anyhow!("invalid vault path"))?;
3708 fs::create_dir_all(parent)?;
3709 #[cfg(unix)]
3710 {
3711 use std::os::unix::fs::MetadataExt;
3712 let meta = fs::metadata(parent)?;
3713 let mode = meta.mode() & 0o777;
3714 if (mode & 0o022) != 0 {
3715 bail!("insecure vault directory permissions");
3716 }
3717 if meta.uid() != unsafe { libc::getuid() } {
3718 bail!("vault directory not owned by current user");
3719 }
3720 }
3721 #[cfg(windows)]
3722 {
3723 ensure_secure_dir_permissions(parent)?;
3724 }
3725 let mut lock = lock_handle(path)?;
3726 let _guard = lock.write()?;
3727
3728 let mut buf = Vec::new();
3730 into_writer(file, &mut buf).context("failed to serialize vault")?;
3731
3732 let mut tmp = NamedTempFile::new_in(parent)?;
3734 use std::io::Write as _;
3735 tmp.as_file_mut().write_all(&buf)?;
3736 tmp.as_file_mut().sync_all()?;
3737 #[cfg(unix)]
3738 {
3739 use std::os::unix::fs::PermissionsExt;
3740 tmp.as_file_mut()
3741 .set_permissions(fs::Permissions::from_mode(0o600))?;
3742 }
3743 tmp.persist(path)?;
3744 sync_dir(parent)?;
3745
3746 let tag = compute_public_integrity_tag(&buf, &file.header.kem_public);
3748 let _ = write_integrity_sidecar(path, tag);
3749 let _ = write_epoch_sidecar(path, file.header.epoch);
3750 Ok(())
3751}
3752
3753#[cfg(windows)]
3754fn ensure_secure_dir_permissions(dir: &Path) -> Result<()> {
3755 use std::ffi::OsStr;
3756 use std::os::windows::ffi::OsStrExt;
3757 use windows_sys::Win32::Foundation::{CloseHandle, BOOL, HANDLE, PSID};
3758 use windows_sys::Win32::Security::Authorization::{
3759 GetAce, GetAclInformation, ACL, ACL_INFORMATION_CLASS, ACL_REVISION_INFORMATION,
3760 ACL_REVISION_INFORMATION as ACLRI, AceRevisionInformation, ACCESS_ALLOWED_ACE,
3761 ACCESS_ALLOWED_ACE_TYPE,
3762 };
3763 use windows_sys::Win32::Security::{
3764 CreateWellKnownSid, EqualSid, GetLengthSid, IsValidSid, WinAuthenticatedUserSid,
3765 WinWorldSid, OWNER_SECURITY_INFORMATION, DACL_SECURITY_INFORMATION,
3766 };
3767 use windows_sys::Win32::Security::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
3768
3769 unsafe {
3770 let mut wide: Vec<u16> = OsStr::new(dir.as_os_str())
3771 .encode_wide()
3772 .chain(std::iter::once(0))
3773 .collect();
3774 let mut owner_sid: PSID = std::ptr::null_mut();
3775 let mut dacl_ptr: *mut ACL = std::ptr::null_mut();
3776 let mut sd: *mut core::ffi::c_void = std::ptr::null_mut();
3777 let rc = GetNamedSecurityInfoW(
3778 wide.as_mut_ptr(),
3779 SE_FILE_OBJECT,
3780 OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
3781 &mut owner_sid,
3782 std::ptr::null_mut(),
3783 &mut dacl_ptr,
3784 std::ptr::null_mut(),
3785 &mut sd,
3786 );
3787 if rc != 0 {
3788 bail!("failed to query directory ACL");
3789 }
3790 if !dacl_ptr.is_null() {
3792 let mut world_sid_buf = [0u8; 68];
3794 let mut world_len = world_sid_buf.len() as u32;
3795 let ok1 = CreateWellKnownSid(
3796 WinWorldSid as i32,
3797 std::ptr::null_mut(),
3798 world_sid_buf.as_mut_ptr() as PSID,
3799 &mut world_len,
3800 );
3801 let mut auth_sid_buf = [0u8; 68];
3802 let mut auth_len = auth_sid_buf.len() as u32;
3803 let ok2 = CreateWellKnownSid(
3804 WinAuthenticatedUserSid as i32,
3805 std::ptr::null_mut(),
3806 auth_sid_buf.as_mut_ptr() as PSID,
3807 &mut auth_len,
3808 );
3809 if ok1 == 0 || ok2 == 0 {
3810 bail!("failed to construct well-known SIDs");
3811 }
3812 let mut index: u32 = 0;
3815 loop {
3816 let mut ace_ptr: *mut core::ffi::c_void = std::ptr::null_mut();
3817 let ok: BOOL = GetAce(dacl_ptr, index as u32, &mut ace_ptr);
3818 if ok == 0 {
3819 break;
3820 }
3821 let header = ace_ptr as *const u8;
3822 let ace_type = *header;
3824 if ace_type == ACCESS_ALLOWED_ACE_TYPE as u8 {
3825 let ace = ace_ptr as *const ACCESS_ALLOWED_ACE;
3826 let sid_ptr = (&(*ace)).SidStart.as_ptr() as PSID;
3828 if EqualSid(sid_ptr, world_sid_buf.as_mut_ptr() as PSID) != 0
3829 || EqualSid(sid_ptr, auth_sid_buf.as_mut_ptr() as PSID) != 0
3830 {
3831 bail!("insecure vault directory permissions");
3832 }
3833 }
3834 index += 1;
3835 }
3836 }
3837 if !sd.is_null() {
3839 let _ = windows_sys::Win32::Foundation::LocalFree(sd as isize);
3840 }
3841 }
3842 Ok(())
3843}
3844
3845fn sync_dir(dir: &Path) -> Result<()> {
3846 #[cfg(unix)]
3847 {
3848 use std::os::unix::fs::OpenOptionsExt;
3849 let file = OpenOptions::new()
3850 .read(true)
3851 .custom_flags(libc::O_DIRECTORY)
3852 .open(dir)?;
3853 file.sync_all()?;
3854 }
3855 #[cfg(not(unix))]
3856 {
3857 let _ = dir;
3859 }
3860 Ok(())
3861}
3862
3863fn integrity_sidecar_path(path: &Path) -> PathBuf {
3864 let mut p = path.to_path_buf();
3865 p.set_extension("int");
3866 p
3867}
3868
3869fn compute_public_integrity_tag(vault_bytes: &[u8], kem_public_key: &[u8]) -> [u8; 32] {
3870 use blake3::derive_key;
3871 let key = derive_key("black-bag public integrity", kem_public_key);
3872 let mut hasher = blake3::Hasher::new_keyed(&key);
3873 hasher.update(vault_bytes);
3874 *hasher.finalize().as_bytes()
3875}
3876
3877fn write_integrity_sidecar(path: &Path, tag: [u8; 32]) -> Result<()> {
3878 use std::io::Write;
3879 let sidecar = integrity_sidecar_path(path);
3880 let mut f = std::fs::File::create(&sidecar)?;
3881 writeln!(f, "{}", hex::encode(tag))?;
3882 Ok(())
3883}
3884
3885fn read_integrity_sidecar(path: &Path) -> Result<[u8; 32]> {
3886 use std::io::Read;
3887 let sidecar = integrity_sidecar_path(path);
3888 let mut s = String::new();
3889 std::fs::File::open(&sidecar)?.read_to_string(&mut s)?;
3890 let bytes = hex::decode(s.trim())?;
3891 if bytes.len() != 32 {
3892 bail!("integrity tag wrong length");
3893 }
3894 let mut out = [0u8; 32];
3895 out.copy_from_slice(&bytes);
3896 Ok(out)
3897}
3898
3899fn epoch_sidecar_path(path: &Path) -> PathBuf {
3900 let mut p = path.to_path_buf();
3901 p.set_extension("epoch");
3902 p
3903}
3904
3905fn write_epoch_sidecar(path: &Path, epoch: u64) -> Result<()> {
3906 use std::io::Write;
3907 let sidecar = epoch_sidecar_path(path);
3908 let mut f = std::fs::File::create(&sidecar)?;
3909 writeln!(f, "{}", epoch)?;
3910 Ok(())
3911}
3912
3913fn read_epoch_sidecar(path: &Path) -> Result<u64> {
3914 use std::io::Read;
3915 let sidecar = epoch_sidecar_path(path);
3916 let mut s = String::new();
3917 std::fs::File::open(&sidecar)?.read_to_string(&mut s)?;
3918 s.trim().parse::<u64>().map_err(|_| anyhow!("invalid epoch sidecar").into())
3919}
3920
3921fn unlock_failure<E: Into<anyhow::Error>>(err: E) -> Error {
3922 let any = err.into();
3923 if std::env::var_os("BLACK_BAG_DEBUG").is_some() {
3924 Error::Anyhow(any)
3926 } else {
3927 Error::Anyhow(anyhow!("failed to unlock vault"))
3929 }
3930}
3931
3932fn constant_time_uuid_eq(a: &Uuid, b: &Uuid) -> bool {
3933 if a.as_bytes().len() != b.as_bytes().len() {
3934 return false;
3935 }
3936 let mut diff = 0u8;
3937 for (x, y) in a.as_bytes().iter().zip(b.as_bytes()) {
3938 diff |= x ^ y;
3939 }
3940 diff == 0
3941}
3942
3943impl fmt::Display for RecordKind {
3944 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3945 let label = match self {
3946 RecordKind::Login => "login",
3947 RecordKind::Contact => "contact",
3948 RecordKind::Id => "id",
3949 RecordKind::Note => "note",
3950 RecordKind::Bank => "bank",
3951 RecordKind::Wifi => "wifi",
3952 RecordKind::Api => "api",
3953 RecordKind::Wallet => "wallet",
3954 RecordKind::Totp => "totp",
3955 RecordKind::Ssh => "ssh",
3956 RecordKind::Pgp => "pgp",
3957 RecordKind::Recovery => "recovery",
3958 };
3959 f.write_str(label)
3960 }
3961}
3962
3963#[cfg(test)]
3964mod tests {
3965 use super::*;
3966 use proptest::prelude::*;
3967 use proptest::proptest;
3968 use proptest::strategy::Strategy;
3969 use serial_test::serial;
3970 use std::env;
3971 use std::path::PathBuf;
3972 use tempfile::tempdir;
3973
3974 fn prepare(passphrase: &str) -> Result<(tempfile::TempDir, PathBuf, Zeroizing<String>)> {
3975 let dir = tempdir()?;
3976 let vault_path = dir.path().join("vault.cbor");
3977 env::set_var("BLACK_BAG_VAULT_PATH", &vault_path);
3978 let pass = Zeroizing::new(passphrase.to_string());
3979 Ok((dir, vault_path, pass))
3980 }
3981
3982 #[test]
3983 fn mlkem_sizes_match_fips203() -> Result<()> {
3984 const PK: usize = 1568;
3986 const SK: usize = 3168;
3987 const CT: usize = 1568;
3988 const SS: usize = 32;
3989 let (pk, sk) = mlkem1024::keypair();
3990 assert_eq!(pk.as_bytes().len(), PK);
3991 assert_eq!(sk.as_bytes().len(), SK);
3992 let (ss, ct) = mlkem1024::encapsulate(&pk);
3993 assert_eq!(ct.as_bytes().len(), CT);
3994 assert_eq!(ss.as_bytes().len(), SS);
3995 Ok(())
3996 }
3997
3998 fn cleanup() {
3999 env::remove_var("BLACK_BAG_VAULT_PATH");
4000 }
4001
4002 fn arb_ascii_string(max: usize) -> impl Strategy<Value = String> {
4003 proptest::collection::vec(proptest::char::range('a', 'z'), 0..=max)
4004 .prop_map(|chars| chars.into_iter().collect())
4005 }
4006
4007 fn arb_note_record() -> impl Strategy<Value = Record> {
4008 (
4009 proptest::option::of(arb_ascii_string(12)),
4010 proptest::collection::vec(arb_ascii_string(8), 0..3),
4011 arb_ascii_string(48),
4012 )
4013 .prop_map(|(title, tags, body)| {
4014 Record::new(
4015 RecordData::Note {
4016 body: Sensitive::from_string(&body),
4017 },
4018 title,
4019 tags,
4020 None,
4021 )
4022 })
4023 }
4024
4025 proptest! {
4026 #[test]
4027 fn encrypt_blob_roundtrip_prop(
4028 key_bytes in proptest::array::uniform32(any::<u8>()),
4029 data in proptest::collection::vec(any::<u8>(), 0..256),
4030 aad in proptest::collection::vec(any::<u8>(), 0..32),
4031 ) {
4032 let blob = encrypt_blob(&key_bytes, &data, &aad).unwrap();
4033 let decrypted = decrypt_blob(&key_bytes, &blob, &aad).unwrap();
4034 prop_assert_eq!(decrypted, data);
4035 }
4036
4037 #[test]
4038 fn payload_roundtrip_prop(
4039 key_bytes in proptest::array::uniform32(any::<u8>()),
4040 records in proptest::collection::vec(arb_note_record(), 0..3),
4041 ) {
4042 let payload = VaultPayload {
4043 records: records.clone(),
4044 record_counter: records.len() as u64,
4045 };
4046 let blob = encrypt_payload(&key_bytes, &payload).unwrap();
4047 let decoded = decrypt_payload(&key_bytes, &blob).unwrap();
4048 prop_assert_eq!(decoded, payload);
4049 }
4050 }
4051
4052 #[test]
4053 #[serial]
4054 fn vault_round_trip_note() -> Result<()> {
4055 let (_tmp, vault_path, pass) = prepare("CorrectHorseBatteryStaple!7")?;
4056 let config = AppConfig::default();
4057 Vault::init(&vault_path, &pass, 32_768, &config, None)?;
4058
4059 let mut vault = Vault::load(&vault_path, &pass, &config)?;
4060 let record = Record::new(
4061 RecordData::Note {
4062 body: Sensitive::from_string("mission ops"),
4063 },
4064 Some("Ops Note".into()),
4065 vec!["mission".into()],
4066 None,
4067 );
4068 let record_id = record.id;
4069 vault.add_record(record);
4070 vault.save(&pass, &config)?;
4071
4072 drop(vault);
4073 let vault = Vault::load(&vault_path, &pass, &config)?;
4074 let notes = vault.list(Some(RecordKind::Note), None, None, false);
4075 assert_eq!(notes.len(), 1);
4076 assert_eq!(notes[0].id, record_id);
4077 assert_eq!(notes[0].title.as_deref(), Some("Ops Note"));
4078 assert!(notes[0].matches_tag("mission"));
4079
4080 cleanup();
4081 Ok(())
4082 }
4083
4084 #[test]
4085 #[serial]
4086 fn totp_round_trip() -> Result<()> {
4087 let (_tmp, vault_path, pass) = prepare("TotpPassphrase!7")?;
4088 let config = AppConfig::default();
4089 Vault::init(&vault_path, &pass, 32_768, &config, None)?;
4090
4091 let secret_bytes = parse_totp_secret("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")?;
4092 let mut vault = Vault::load(&vault_path, &pass, &config)?;
4093 let record = Record::new(
4094 RecordData::Totp {
4095 issuer: Some("TestIssuer".into()),
4096 account: Some("test@example".into()),
4097 secret: Sensitive { data: secret_bytes },
4098 digits: 6,
4099 step: 30,
4100 skew: 1,
4101 algorithm: TotpAlgorithm::Sha1,
4102 },
4103 Some("TOTP".into()),
4104 vec![],
4105 None,
4106 );
4107 let record_id = record.id;
4108 vault.add_record(record);
4109 vault.save(&pass, &config)?;
4110
4111 drop(vault);
4112 let vault = Vault::load(&vault_path, &pass, &config)?;
4113 let record = vault
4114 .get_ref(record_id)
4115 .ok_or_else(|| anyhow!("TOTP record missing"))?;
4116 let code = match &record.data {
4117 RecordData::Totp {
4118 issuer,
4119 account,
4120 secret,
4121 digits,
4122 step,
4123 skew,
4124 algorithm,
4125 } => build_totp_instance(secret, *digits, *step, *skew, *algorithm, issuer, account)?
4126 .generate(59),
4127 _ => bail!("expected totp record"),
4128 };
4129 let expected = TOTP::new(
4130 TotpAlgorithmLib::SHA1,
4131 6,
4132 1,
4133 30,
4134 parse_totp_secret("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")?,
4135 )
4136 .unwrap()
4137 .generate(59);
4138 assert_eq!(code, expected);
4139
4140 cleanup();
4141 Ok(())
4142 }
4143
4144 #[test]
4145 #[serial]
4146 fn vault_rotation_changes_wrapped_keys() -> Result<()> {
4147 let (_tmp, vault_path, pass) = prepare("RotateAllTheThings!7")?;
4148 let config = AppConfig::default();
4149 Vault::init(&vault_path, &pass, 32_768, &config, None)?;
4150
4151 let mut vault = Vault::load(&vault_path, &pass, &config)?;
4152 let record = Record::new(
4153 RecordData::Api {
4154 service: Some("intel-api".into()),
4155 environment: Some("prod".into()),
4156 access_key: Some("AKIA-123".into()),
4157 secret_key: Sensitive::from_string("super-secret"),
4158 scopes: vec!["read".into(), "write".into()],
4159 },
4160 Some("API".into()),
4161 vec!["read".into()],
4162 None,
4163 );
4164 vault.add_record(record);
4165 let before = vault.file.header.sealed_dek.ciphertext.clone();
4166 vault.rotate(&pass, Some(65_536), &config)?;
4167 vault.save(&pass, &config)?;
4168 let after = vault.file.header.sealed_dek.ciphertext.clone();
4169 assert_ne!(before, after);
4170
4171 drop(vault);
4172 let vault = Vault::load(&vault_path, &pass, &config)?;
4173 let apis = vault.list(Some(RecordKind::Api), Some("read"), None, false);
4174 assert_eq!(apis.len(), 1);
4175 assert!(apis[0].data.summary_text().contains("intel-api"));
4176
4177 cleanup();
4178 Ok(())
4179 }
4180
4181 #[test]
4182 fn recovery_split_combine_roundtrip() -> Result<()> {
4183 let secret = b"ultra-secret";
4184 let split = split_secret(secret, 3, 5)?;
4185 let recovered = combine_secret(3, &split.shares, Some(&split.set_id_base32))?;
4186 assert_eq!(recovered, secret);
4187 Ok(())
4188 }
4189
4190 #[test]
4191 #[serial]
4192 fn header_mac_tamper_detected() -> Result<()> {
4193 let (_tmp, vault_path, pass) = prepare("HeaderTamper!7")?;
4194 let config = AppConfig::default();
4195 Vault::init(&vault_path, &pass, 32_768, &config, None)?;
4196
4197 let bytes = std::fs::read(&vault_path)?;
4199 let mut file: VaultFile = ciborium::de::from_reader(bytes.as_slice())
4200 .map_err(|e| anyhow!("cbor decode: {e}"))?;
4201 file.header.epoch = file.header.epoch.saturating_add(1);
4202 let mut out = Vec::new();
4203 ciborium::ser::into_writer(&file, &mut out).map_err(|e| anyhow!("cbor encode: {e}"))?;
4204 std::fs::write(&vault_path, &out)?;
4205
4206 env::set_var("BLACK_BAG_DEBUG", "1");
4208 let err = match Vault::load(&vault_path, &pass, &config) {
4209 Ok(_) => panic!("expected header_mac_mismatch"),
4210 Err(e) => e.to_string(),
4211 };
4212 assert!(err.contains("header_mac_mismatch"));
4213 cleanup();
4214 Ok(())
4215 }
4216
4217 #[test]
4218 #[serial]
4219 fn header_mac_field_corruptions() -> Result<()> {
4220 use ciborium::{de::from_reader as de, ser::into_writer as ser};
4221 let (_tmp, vault_path, pass) = prepare("HeaderFieldTamper!7")?;
4222 let config = AppConfig::default();
4223 Vault::init(&vault_path, &pass, 32_768, &config, None)?;
4224
4225 let bytes = std::fs::read(&vault_path)?;
4226 let original: VaultFile = de(bytes.as_slice()).map_err(|e| anyhow!("cbor decode: {e}"))?;
4227 let mut cases: Vec<Box<dyn Fn(&mut VaultFile)>> = Vec::new();
4228 cases.push(Box::new(|h| h.header.created_at = h.header.created_at - chrono::TimeDelta::seconds(1)));
4229 cases.push(Box::new(|h| h.header.updated_at = h.header.updated_at + chrono::TimeDelta::seconds(1)));
4230 cases.push(Box::new(|h| h.header.argon.mem_cost_kib = h.header.argon.mem_cost_kib.saturating_add(1)));
4231 cases.push(Box::new(|h| h.header.argon.time_cost = h.header.argon.time_cost.saturating_add(1)));
4232 cases.push(Box::new(|h| h.header.argon.lanes = h.header.argon.lanes.saturating_add(1)));
4233 cases.push(Box::new(|h| h.header.argon.salt[0] ^= 0x01));
4234 cases.push(Box::new(|h| h.header.epoch = h.header.epoch.saturating_add(1)));
4235 cases.push(Box::new(|h| if !h.header.kem_public.is_empty() { h.header.kem_public[0] ^= 0x01 }));
4236 cases.push(Box::new(|h| if !h.header.kem_ciphertext.is_empty() { h.header.kem_ciphertext[0] ^= 0x01 }));
4237 cases.push(Box::new(|h| h.header.sealed_decapsulation.nonce[0] ^= 0x01));
4238 cases.push(Box::new(|h| if !h.header.sealed_decapsulation.ciphertext.is_empty() { h.header.sealed_decapsulation.ciphertext[0] ^= 0x01 }));
4239 cases.push(Box::new(|h| h.header.sealed_dek.nonce[0] ^= 0x01));
4240 cases.push(Box::new(|h| if !h.header.sealed_dek.ciphertext.is_empty() { h.header.sealed_dek.ciphertext[0] ^= 0x01 }));
4241
4242 std::env::set_var("BLACK_BAG_DEBUG", "1");
4243 for mutate in cases.into_iter() {
4244 let mut tampered = original.clone();
4245 mutate(&mut tampered);
4246 let mut out = Vec::new();
4247 ser(&tampered, &mut out).map_err(|e| anyhow!("cbor encode: {e}"))?;
4248 std::fs::write(&vault_path, &out)?;
4249 let err = match Vault::load(&vault_path, &pass, &config) {
4250 Ok(_) => panic!("expected header_mac_mismatch"),
4251 Err(e) => e.to_string(),
4252 };
4253 assert!(err.contains("header_mac_mismatch"));
4254 }
4255
4256 cleanup();
4257 Ok(())
4258 }
4259
4260 #[test]
4261 #[serial]
4262 fn payload_corruption_yields_aead_error() -> Result<()> {
4263 use ciborium::{de::from_reader as de, ser::into_writer as ser};
4264 let (_tmp, vault_path, pass) = prepare("PayloadTamper!7")?;
4265 let config = AppConfig::default();
4266 Vault::init(&vault_path, &pass, 32_768, &config, None)?;
4267 let mut vault = Vault::load(&vault_path, &pass, &config)?;
4269 vault.add_record(Record::new(RecordData::Note { body: Sensitive::from_string("x") }, None, vec![], None));
4270 vault.save(&pass, &config)?;
4271 drop(vault);
4272
4273 let bytes = std::fs::read(&vault_path)?;
4274 let mut file: VaultFile = de(bytes.as_slice()).map_err(|e| anyhow!("cbor decode: {e}"))?;
4275 if !file.payload.ciphertext.is_empty() {
4276 file.payload.ciphertext[0] ^= 0x01;
4277 }
4278 let mut out = Vec::new();
4279 ser(&file, &mut out).map_err(|e| anyhow!("cbor encode: {e}"))?;
4280 std::fs::write(&vault_path, &out)?;
4281 std::env::set_var("BLACK_BAG_DEBUG", "1");
4282 let err = match Vault::load(&vault_path, &pass, &config) {
4283 Ok(_) => panic!("expected aead_auth_fail"),
4284 Err(e) => e.to_string(),
4285 };
4286 assert!(err.contains("aead_auth_fail"));
4287 cleanup();
4288 Ok(())
4289 }
4290
4291 #[test]
4292 fn strict_policy_rejects_weak_on_init_check() {
4293 let err = ensure_passphrase_strength("shortpass", PolicyMode::Strict, true)
4295 .unwrap_err()
4296 .to_string();
4297 assert!(err.contains("passphrase_policy_violation"));
4298 }
4299
4300 #[test]
4301 fn moderate_policy_accepts_reasonable_passphrase() -> Result<()> {
4302 ensure_passphrase_strength(
4303 "TrulyLongPassphrase#2025!alphaPHI",
4304 PolicyMode::Moderate,
4305 true,
4306 )?;
4307 Ok(())
4308 }
4309
4310 #[test]
4311 fn doctor_fails_on_bad_dir_perms() -> Result<()> {
4312 use std::os::unix::fs::PermissionsExt;
4313 let tmp = tempfile::tempdir()?;
4314 let dir = tmp.path();
4315 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o777))?;
4317 let vault_path = dir.join("badperms.cbor");
4318 std::env::set_var("BLACK_BAG_VAULT_PATH", &vault_path);
4319 let config = AppConfig::default();
4320 let err = doctor(DoctorCommand { json: false }, &config).unwrap_err().to_string();
4322 std::env::remove_var("BLACK_BAG_VAULT_PATH");
4323 assert!(err.to_ascii_lowercase().contains("doctor"));
4324 Ok(())
4325 }
4326
4327 use proptest::prelude::*;
4328 proptest! {
4329 #[test]
4330 fn header_mutations_fail_unlock(mem_cost in 32768u32..131072u32, t in 1u32..3u32, lanes in 4u32..9u32) {
4331 let (_tmp, vault_path, pass) = prepare("PropHeader!7").unwrap();
4332 let mut config = AppConfig::default();
4333 Vault::init(&vault_path, &pass, mem_cost, &config, Some(&lanes.to_string())).unwrap();
4334 let bytes = std::fs::read(&vault_path).unwrap();
4335 let mut file: VaultFile = from_reader(bytes.as_slice()).unwrap();
4336 match t % 5 {
4337 0 => file.header.argon.mem_cost_kib = file.header.argon.mem_cost_kib.saturating_add(1),
4338 1 => file.header.argon.time_cost = file.header.argon.time_cost.saturating_add(1),
4339 2 => file.header.argon.lanes = file.header.argon.lanes.saturating_add(1),
4340 3 => if !file.header.kem_public.is_empty() { file.header.kem_public[0] ^= 0x01 },
4341 _ => file.header.epoch = file.header.epoch.saturating_add(1),
4342 }
4343 let mut out = Vec::new();
4344 ciborium::ser::into_writer(&file, &mut out).unwrap();
4345 std::fs::write(&vault_path, &out).unwrap();
4346 std::env::set_var("BLACK_BAG_DEBUG", "1");
4347 let err = match Vault::load(&vault_path, &pass, &config) {
4348 Ok(_) => panic!("expected failure"),
4349 Err(e) => e.to_string(),
4350 };
4351 assert!(err.contains("header_mac_mismatch") || err.contains("aead_auth_fail"));
4352 std::env::remove_var("BLACK_BAG_DEBUG");
4353 }
4354 }
4355
4356 #[test]
4357 fn aead_wrong_aad_fails() -> Result<()> {
4358 let key = [7u8; 32];
4359 let blob = encrypt_blob(&key, b"hello", b"AAD1")?;
4360 let err = decrypt_blob(&key, &blob, b"AAD2").unwrap_err().to_string();
4361 assert!(err.contains("aead_auth_fail"));
4362 Ok(())
4363 }
4364
4365 #[test]
4366 fn aead_bitflip_fails() -> Result<()> {
4367 let key = [9u8; 32];
4368 let mut blob = encrypt_blob(&key, b"world", b"AAD").unwrap();
4369 if !blob.ciphertext.is_empty() {
4370 blob.ciphertext[0] ^= 0x01;
4371 }
4372 let err = decrypt_blob(&key, &blob, b"AAD").unwrap_err().to_string();
4373 assert!(err.contains("aead_auth_fail"));
4374 Ok(())
4375 }
4376
4377 #[test]
4378 fn encrypt_with_nonce_matches_reference() -> Result<()> {
4379 let key = [3u8; 32];
4380 let nonce = [5u8; 24];
4381 let ours = encrypt_blob_with_nonce(&key, b"kat", b"abc", nonce)?;
4382 let cipher = XChaCha20Poly1305::new(Key::from_slice(&key));
4383 let ct = cipher
4384 .encrypt(XNonce::from_slice(&nonce), Payload { msg: b"kat", aad: b"abc" })
4385 .map_err(|_| anyhow!("encryption failed"))?;
4386 assert_eq!(ours.ciphertext, ct);
4387 assert_eq!(ours.nonce, nonce);
4388 Ok(())
4389 }
4390
4391 #[test]
4392 fn print_header_mac_kat() -> Result<()> {
4393 use chrono::TimeZone;
4394 let header = VaultHeader {
4395 created_at: Utc.with_ymd_and_hms(2023, 1, 2, 3, 4, 5).unwrap(),
4396 updated_at: Utc.with_ymd_and_hms(2023, 1, 2, 3, 4, 6).unwrap(),
4397 epoch: 42,
4398 argon: ArgonState {
4399 mem_cost_kib: 32_768,
4400 time_cost: 10,
4401 lanes: 4,
4402 salt: {
4403 let mut s = [0u8;32];
4404 for (i,b) in s.iter_mut().enumerate() { *b = i as u8; }
4405 s
4406 },
4407 },
4408 kem_public: vec![1,2,3,4],
4409 kem_ciphertext: vec![5,6,7],
4410 sealed_decapsulation: AeadBlob { nonce: [9u8;24], ciphertext: vec![10,11] },
4411 sealed_dek: AeadBlob { nonce: [12u8;24], ciphertext: vec![13,14,15] },
4412 header_mac: None,
4413 };
4414 let kek = [0xAAu8; 32];
4415 let mac = compute_header_mac(&header, &kek);
4416 assert_eq!(hex::encode(mac), "09fc2ba4d7078309d97386c469c0c0f395e57da6fea9e9ba23e3b692e6ea7a94");
4417 Ok(())
4418 }
4419
4420 #[test]
4421 fn backup_sign_and_verify_happy_and_failures() -> Result<()> {
4422 use ed25519_dalek::SigningKey;
4423 use rand::rngs::OsRng;
4424 use rand::RngCore as _;
4425 let (_tmp, vault_path, pass) = prepare("BackupSign!7")?;
4427 let config = AppConfig::default();
4428 Vault::init(&vault_path, &pass, 32_768, &config, None)?;
4429 let bytes = std::fs::read(&vault_path)?;
4431 let file: VaultFile = match from_reader(bytes.as_slice()) {
4432 Ok(v) => v,
4433 Err(_) => bail!("failed to parse vault"),
4434 };
4435 let tag = compute_public_integrity_tag(&bytes, &file.header.kem_public);
4436 write_integrity_sidecar(&vault_path, tag)?;
4437 let mut seed = [0u8; 32];
4439 OsRng.fill_bytes(&mut seed);
4440 let sk = SigningKey::from_bytes(&seed);
4441 let sk_bytes = sk.to_keypair_bytes();
4442 let sk_path = vault_path.with_extension("sk");
4443 std::fs::write(&sk_path, hex::encode(sk_bytes))?;
4444 let pk_hex = hex::encode(sk.verifying_key().to_bytes());
4445 let pk_path = vault_path.with_extension("pk");
4446 std::fs::write(&pk_path, &pk_hex)?;
4447 backup_sign(&vault_path, &sk_path, None)?;
4449 backup_verify(&vault_path, Some(&pk_path))?;
4451 let bad_sig_path = integrity_signature_sidecar_path(&vault_path);
4453 std::fs::write(&bad_sig_path, base64::engine::general_purpose::STANDARD.encode([0u8;64]))?;
4454 let err = backup_verify(&vault_path, Some(&pk_path)).unwrap_err().to_string();
4455 assert!(err.contains("signature"));
4456 std::fs::remove_file(&bad_sig_path)?;
4458 let err = read_integrity_signature_sidecar(&vault_path).unwrap_err().to_string();
4459 assert!(err.contains("signature sidecar missing"));
4460 Ok(())
4461 }
4462}