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_kyber::kyber1024;
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};
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 );
78 apply_process_hardening();
79 let _ = app_config.unsafe_clipboard;
81 let _ = app_config.duress;
82 match cli.command {
83 Command::Init(cmd) => init_vault(cmd, &app_config),
84 Command::Add(cmd) => add_record(cmd, &app_config),
85 Command::List(cmd) => list_records(cmd, &app_config),
86 Command::Get(cmd) => get_record(cmd, &app_config),
87 Command::Rotate(cmd) => rotate_vault(cmd, &app_config),
88 Command::Doctor(cmd) => doctor(cmd, &app_config),
89 Command::Export { command } => export(command, &app_config),
90 Command::Recovery { command } => recovery(command, &app_config),
91 Command::Totp { command } => totp(command, &app_config),
92 Command::Backup { command } => backup(command, &app_config),
93 Command::Passwd(cmd) => passwd(cmd, &app_config),
94 #[cfg(feature = "tui")]
95 Command::Tui => Ok(tui::app::run(&app_config)?),
96 Command::Migrate => migrate_vault(&app_config),
97 Command::Selftest => self_test(),
98 }
99}
100
101fn apply_process_hardening() {
102 #[cfg(unix)]
103 unsafe {
104 let lim = libc::rlimit { rlim_cur: 0, rlim_max: 0 };
106 let _ = libc::setrlimit(libc::RLIMIT_CORE, &lim);
107 #[cfg(target_os = "linux")]
109 {
110 let _ = libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0);
111 if let Ok(mut f) = std::fs::File::open("/proc/self/status") {
113 use std::io::Read as _;
114 let mut s = String::new();
115 let _ = f.read_to_string(&mut s);
116 if let Some(line) = s.lines().find(|l| l.starts_with("TracerPid:")) {
117 let val = line.split(':').nth(1).unwrap_or("0").trim();
118 if val != "0" && std::env::var_os("BLACK_BAG_ALLOW_DEBUG").is_none() {
119 eprintln!("debugger detected; refusing to run (set BLACK_BAG_ALLOW_DEBUG=1 to override)");
120 std::process::exit(1);
121 }
122 }
123 }
124 }
125 #[cfg(target_os = "macos")]
127 {
128 const PT_DENY_ATTACH: libc::c_int = 31;
129 let _ = libc::ptrace(PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0);
130 }
131 }
132}
133
134fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
135 if a.len() != b.len() {
136 return false;
137 }
138 let mut diff: u8 = 0;
139 for (x, y) in a.iter().zip(b.iter()) {
140 diff |= x ^ y;
141 }
142 diff == 0
143}
144
145fn migrate_vault(config: &AppConfig) -> Result<()> {
146 let (mut vault, pass) = load_vault_with_prompt(config)?;
147 if vault.file.version == VAULT_VERSION {
148 println!("Already at latest version {}", VAULT_VERSION);
149 return Ok(());
150 }
151 vault.file.version = VAULT_VERSION;
152 let kek = derive_kek(&pass, &vault.file.header.argon)?;
154 vault.file.header.header_mac = Some(compute_header_mac(&vault.file.header, kek.as_slice()));
155 vault.save(&pass, config)?;
156 println!("Migration complete to version {}", VAULT_VERSION);
157 Ok(())
158}
159
160#[derive(Parser)]
161#[command(name = "black-bag", version, about = "Ultra-secure zero-trace CLI vault", long_about = None)]
162struct Cli {
163 #[arg(long, global = true, default_value_t = false)]
165 unsafe_stdout: bool,
166 #[arg(long, global = true, default_value_t = false)]
168 require_mlock: bool,
169 #[arg(long, value_enum, global = true)]
171 emit: Option<EmitMode>,
172 #[arg(long, value_enum, global = true)]
174 agent: Option<config::AgentMode>,
175 #[arg(long, global = true, default_value_t = false)]
177 unsafe_clipboard: bool,
178 #[arg(long, global = true, default_value_t = false)]
180 duress: bool,
181 #[command(subcommand)]
182 command: Command,
183}
184
185#[derive(Subcommand)]
186enum Command {
187 Init(InitCommand),
189 Add(AddCommand),
191 List(ListCommand),
193 Get(GetCommand),
195 Rotate(RotateCommand),
197 Doctor(DoctorCommand),
199 Recovery {
201 #[command(subcommand)]
202 command: RecoveryCommand,
203 },
204 Totp {
206 #[command(subcommand)]
207 command: TotpCommand,
208 },
209 Export {
211 #[command(subcommand)]
212 command: ExportCommand,
213 },
214 Backup {
216 #[command(subcommand)]
217 command: BackupCommand,
218 },
219 Passwd(PasswordCommand),
221 #[cfg(feature = "tui")]
223 Tui,
224 Migrate,
226 Selftest,
228}
229
230#[derive(Args)]
231struct InitCommand {
232 #[arg(long, default_value_t = 262_144)]
234 mem_kib: u32,
235 #[arg(long)]
237 argon_lanes: Option<String>,
238}
239
240#[derive(Args)]
241struct ListCommand {
242 #[arg(long, value_enum)]
244 kind: Option<RecordKind>,
245 #[arg(long)]
247 tag: Option<String>,
248 #[arg(long)]
250 query: Option<String>,
251 #[arg(long, default_value_t = false)]
253 fuzzy: bool,
254}
255
256#[derive(Args)]
257struct GetCommand {
258 id: Uuid,
260 #[arg(long)]
262 reveal: bool,
263 #[arg(long, default_value_t = false)]
265 clipboard: bool,
266}
267
268#[derive(Args, Default)]
269struct RotateCommand {
270 #[arg(long)]
272 mem_kib: Option<u32>,
273}
274
275#[derive(Args, Default)]
276struct PasswordCommand {
277 #[arg(long)]
279 mem_kib: Option<u32>,
280 #[arg(long)]
282 argon_lanes: Option<String>,
283 #[arg(long, default_value_t = false)]
285 rekey_dek: bool,
286}
287
288#[derive(Args)]
289struct DoctorCommand {
290 #[arg(long)]
292 json: bool,
293}
294
295#[derive(Subcommand)]
296enum ExportCommand {
297 Csv(ExportCsvCommand),
299}
300
301#[derive(Args)]
302struct ExportCsvCommand {
303 #[arg(long, value_enum)]
305 kind: Option<RecordKind>,
306 #[arg(long, value_delimiter = ',')]
308 fields: Vec<String>,
309 #[arg(long, default_value_t = false)]
311 include_secrets: bool,
312}
313
314#[derive(Subcommand)]
315enum RecoveryCommand {
316 Split(RecoverySplitCommand),
318 Combine(RecoveryCombineCommand),
320}
321
322#[derive(Args)]
323struct RecoverySplitCommand {
324 #[arg(long, default_value_t = 3)]
326 threshold: u8,
327 #[arg(long, default_value_t = 5)]
329 shares: u8,
330 #[arg(long, default_value_t = false)]
332 duress: bool,
333}
334
335#[derive(Args)]
336struct RecoveryCombineCommand {
337 #[arg(long)]
339 threshold: u8,
340 #[arg(long)]
342 shares: String,
343 #[arg(long)]
345 set_id: Option<String>,
346 #[arg(long, default_value_t = false)]
348 raw: bool,
349 #[arg(long, default_value_t = false)]
351 duress: bool,
352}
353
354#[derive(Subcommand)]
355enum TotpCommand {
356 Code(TotpCodeCommand),
358}
359
360#[derive(Args)]
361struct TotpCodeCommand {
362 id: Uuid,
364 #[arg(long)]
366 time: Option<i64>,
367}
368
369#[derive(Args)]
370struct AddCommand {
371 #[command(subcommand)]
372 record: AddRecord,
373}
374
375#[derive(Subcommand)]
376enum AddRecord {
377 Login(AddLogin),
379 Contact(AddContact),
381 Id(AddIdentity),
383 Note(AddNote),
385 Bank(AddBank),
387 Wifi(AddWifi),
389 Api(AddApi),
391 Wallet(AddWallet),
393 Totp(AddTotp),
395 Ssh(AddSsh),
397 Pgp(AddPgp),
399 Recovery(AddRecovery),
401}
402
403#[derive(Args)]
404struct CommonRecordArgs {
405 #[arg(long)]
407 title: Option<String>,
408 #[arg(long, value_delimiter = ',')]
410 tags: Vec<String>,
411 #[arg(long)]
413 notes: Option<String>,
414}
415
416#[derive(Args)]
417struct AddLogin {
418 #[command(flatten)]
419 common: CommonRecordArgs,
420 #[arg(long)]
421 username: Option<String>,
422 #[arg(long)]
423 url: Option<String>,
424}
425
426#[derive(Args)]
427struct AddContact {
428 #[command(flatten)]
429 common: CommonRecordArgs,
430 #[arg(long, required = true)]
431 full_name: String,
432 #[arg(long, value_delimiter = ',')]
433 emails: Vec<String>,
434 #[arg(long, value_delimiter = ',')]
435 phones: Vec<String>,
436}
437
438#[derive(Args)]
439struct AddIdentity {
440 #[command(flatten)]
441 common: CommonRecordArgs,
442 #[arg(long)]
443 id_type: Option<String>,
444 #[arg(long)]
445 name_on_doc: Option<String>,
446 #[arg(long)]
447 number: Option<String>,
448 #[arg(long)]
449 issuing_country: Option<String>,
450 #[arg(long)]
451 expiry: Option<String>,
452}
453
454#[derive(Args)]
455struct AddNote {
456 #[command(flatten)]
457 common: CommonRecordArgs,
458}
459
460#[derive(Args)]
461struct AddBank {
462 #[command(flatten)]
463 common: CommonRecordArgs,
464 #[arg(long)]
465 institution: Option<String>,
466 #[arg(long)]
467 account_name: Option<String>,
468 #[arg(long)]
469 routing_number: Option<String>,
470}
471
472#[derive(Args)]
473struct AddWifi {
474 #[command(flatten)]
475 common: CommonRecordArgs,
476 #[arg(long)]
477 ssid: Option<String>,
478 #[arg(long)]
479 security: Option<String>,
480 #[arg(long)]
481 location: Option<String>,
482}
483
484#[derive(Args)]
485struct AddApi {
486 #[command(flatten)]
487 common: CommonRecordArgs,
488 #[arg(long)]
489 service: Option<String>,
490 #[arg(long)]
491 environment: Option<String>,
492 #[arg(long)]
493 access_key: Option<String>,
494 #[arg(long, value_delimiter = ',')]
495 scopes: Vec<String>,
496}
497
498#[derive(Args)]
499struct AddWallet {
500 #[command(flatten)]
501 common: CommonRecordArgs,
502 #[arg(long)]
503 asset: Option<String>,
504 #[arg(long)]
505 address: Option<String>,
506 #[arg(long)]
507 network: Option<String>,
508}
509
510#[derive(Args)]
511struct AddTotp {
512 #[command(flatten)]
513 common: CommonRecordArgs,
514 #[arg(long)]
516 issuer: Option<String>,
517 #[arg(long)]
519 account: Option<String>,
520 #[arg(long, value_name = "PATH")]
522 secret_file: Option<std::path::PathBuf>,
523 #[arg(long, default_value_t = false)]
525 secret_stdin: bool,
526 #[arg(long, default_value_t = false)]
528 otpauth_stdin: bool,
529 #[arg(long, default_value_t = false)]
531 qr: bool,
532 #[arg(long, default_value_t = false)]
534 confirm_qr: bool,
535 #[arg(long, default_value_t = 6)]
537 digits: u8,
538 #[arg(long, default_value_t = 30)]
540 step: u64,
541 #[arg(long, default_value_t = 1)]
543 skew: u8,
544 #[arg(long, value_enum, default_value_t = TotpAlgorithm::Sha1)]
546 algorithm: TotpAlgorithm,
547}
548#[derive(Subcommand)]
549enum BackupCommand {
550 Verify {
552 #[arg(long)]
553 path: std::path::PathBuf,
554 },
555}
556
557#[derive(Args)]
558struct AddSsh {
559 #[command(flatten)]
560 common: CommonRecordArgs,
561 #[arg(long)]
562 label: Option<String>,
563 #[arg(long)]
564 comment: Option<String>,
565}
566
567#[derive(Args)]
568struct AddPgp {
569 #[command(flatten)]
570 common: CommonRecordArgs,
571 #[arg(long)]
572 label: Option<String>,
573 #[arg(long)]
574 fingerprint: Option<String>,
575}
576
577#[derive(Args)]
578struct AddRecovery {
579 #[command(flatten)]
580 common: CommonRecordArgs,
581 #[arg(long)]
582 description: Option<String>,
583}
584
585#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Hash)]
586#[serde(rename_all = "snake_case")]
587#[clap(rename_all = "snake_case")]
588enum RecordKind {
589 Login,
590 Contact,
591 Id,
592 Note,
593 Bank,
594 Wifi,
595 Api,
596 Wallet,
597 Totp,
598 Ssh,
599 Pgp,
600 Recovery,
601}
602
603#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
604#[serde(rename_all = "lowercase")]
605enum TotpAlgorithm {
606 Sha1,
607 Sha256,
608 Sha512,
609}
610
611impl TotpAlgorithm {
612 fn to_lib(self) -> TotpAlgorithmLib {
613 match self {
614 TotpAlgorithm::Sha1 => TotpAlgorithmLib::SHA1,
615 TotpAlgorithm::Sha256 => TotpAlgorithmLib::SHA256,
616 TotpAlgorithm::Sha512 => TotpAlgorithmLib::SHA512,
617 }
618 }
619}
620
621impl fmt::Display for TotpAlgorithm {
622 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
623 match self {
624 TotpAlgorithm::Sha1 => f.write_str("sha1"),
625 TotpAlgorithm::Sha256 => f.write_str("sha256"),
626 TotpAlgorithm::Sha512 => f.write_str("sha512"),
627 }
628 }
629}
630
631#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
632struct Record {
633 id: Uuid,
634 created_at: DateTime<Utc>,
635 updated_at: DateTime<Utc>,
636 title: Option<String>,
637 tags: Vec<String>,
638 metadata_notes: Option<String>,
639 data: RecordData,
640 #[serde(default)]
641 sealed_rdek: Option<AeadBlob>,
642}
643
644impl Record {
645 fn new(
646 data: RecordData,
647 title: Option<String>,
648 tags: Vec<String>,
649 notes: Option<String>,
650 ) -> Self {
651 let now = Utc::now();
652 Self {
653 id: Uuid::new_v4(),
654 created_at: now,
655 updated_at: now,
656 title,
657 tags,
658 metadata_notes: notes,
659 data,
660 sealed_rdek: None,
661 }
662 }
663
664 fn kind(&self) -> RecordKind {
665 self.data.kind()
666 }
667
668 fn matches_tag(&self, tag: &str) -> bool {
669 let tag_lower = tag.to_ascii_lowercase();
670 self.tags
671 .iter()
672 .any(|t| t.to_ascii_lowercase().contains(&tag_lower))
673 }
674
675 fn matches_query(&self, needle: &str) -> bool {
676 let haystack = [
677 self.title.as_deref().unwrap_or_default(),
678 self.metadata_notes.as_deref().unwrap_or_default(),
679 &self.data.summary_text(),
680 ]
681 .join("\n")
682 .to_ascii_lowercase();
683 haystack.contains(&needle.to_ascii_lowercase())
684 }
685}
686
687#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
688#[serde(tag = "kind", rename_all = "snake_case")]
689enum RecordData {
690 Login {
691 username: Option<String>,
692 url: Option<String>,
693 password: Sensitive,
694 },
695 Contact {
696 full_name: String,
697 emails: Vec<String>,
698 phones: Vec<String>,
699 },
700 Id {
701 id_type: Option<String>,
702 name_on_doc: Option<String>,
703 number: Option<String>,
704 issuing_country: Option<String>,
705 expiry: Option<String>,
706 secret: Option<Sensitive>,
707 },
708 Note {
709 body: Sensitive,
710 },
711 Bank {
712 institution: Option<String>,
713 account_name: Option<String>,
714 routing_number: Option<String>,
715 account_number: Sensitive,
716 },
717 Wifi {
718 ssid: Option<String>,
719 security: Option<String>,
720 location: Option<String>,
721 passphrase: Sensitive,
722 },
723 Api {
724 service: Option<String>,
725 environment: Option<String>,
726 access_key: Option<String>,
727 secret_key: Sensitive,
728 scopes: Vec<String>,
729 },
730 Wallet {
731 asset: Option<String>,
732 address: Option<String>,
733 network: Option<String>,
734 secret_key: Sensitive,
735 },
736 Totp {
737 issuer: Option<String>,
738 account: Option<String>,
739 secret: Sensitive,
740 digits: u8,
741 step: u64,
742 skew: u8,
743 algorithm: TotpAlgorithm,
744 },
745 Ssh {
746 label: Option<String>,
747 private_key: Sensitive,
748 comment: Option<String>,
749 },
750 Pgp {
751 label: Option<String>,
752 fingerprint: Option<String>,
753 armored_private_key: Sensitive,
754 },
755 Recovery {
756 description: Option<String>,
757 payload: Sensitive,
758 },
759}
760
761impl RecordData {
762 fn kind(&self) -> RecordKind {
763 match self {
764 RecordData::Login { .. } => RecordKind::Login,
765 RecordData::Contact { .. } => RecordKind::Contact,
766 RecordData::Id { .. } => RecordKind::Id,
767 RecordData::Note { .. } => RecordKind::Note,
768 RecordData::Bank { .. } => RecordKind::Bank,
769 RecordData::Wifi { .. } => RecordKind::Wifi,
770 RecordData::Api { .. } => RecordKind::Api,
771 RecordData::Wallet { .. } => RecordKind::Wallet,
772 RecordData::Totp { .. } => RecordKind::Totp,
773 RecordData::Ssh { .. } => RecordKind::Ssh,
774 RecordData::Pgp { .. } => RecordKind::Pgp,
775 RecordData::Recovery { .. } => RecordKind::Recovery,
776 }
777 }
778
779 fn summary_text(&self) -> String {
780 match self {
781 RecordData::Login { username, url, .. } => format!(
782 "user={} url={}",
783 username.as_deref().unwrap_or("-"),
784 url.as_deref().unwrap_or("-")
785 ),
786 RecordData::Contact {
787 full_name,
788 emails,
789 phones,
790 } => format!(
791 "{} | emails={} | phones={}",
792 full_name,
793 if emails.is_empty() {
794 "-".to_string()
795 } else {
796 emails.join(",")
797 },
798 if phones.is_empty() {
799 "-".to_string()
800 } else {
801 phones.join(",")
802 }
803 ),
804 RecordData::Id {
805 id_type,
806 number,
807 expiry,
808 ..
809 } => format!(
810 "type={} number={} expiry={}",
811 id_type.as_deref().unwrap_or("-"),
812 number.as_deref().unwrap_or("-"),
813 expiry.as_deref().unwrap_or("-")
814 ),
815 RecordData::Note { .. } => "secure note".to_string(),
816 RecordData::Bank {
817 institution,
818 account_name,
819 routing_number,
820 ..
821 } => format!(
822 "institution={} account={} routing={}",
823 institution.as_deref().unwrap_or("-"),
824 account_name.as_deref().unwrap_or("-"),
825 routing_number.as_deref().unwrap_or("-")
826 ),
827 RecordData::Wifi {
828 ssid,
829 security,
830 location,
831 ..
832 } => format!(
833 "ssid={} security={} location={}",
834 ssid.as_deref().unwrap_or("-"),
835 security.as_deref().unwrap_or("-"),
836 location.as_deref().unwrap_or("-")
837 ),
838 RecordData::Api {
839 service,
840 environment,
841 scopes,
842 ..
843 } => format!(
844 "service={} env={} scopes={}",
845 service.as_deref().unwrap_or("-"),
846 environment.as_deref().unwrap_or("-"),
847 if scopes.is_empty() {
848 "-".to_string()
849 } else {
850 scopes.join(",")
851 }
852 ),
853 RecordData::Wallet {
854 asset,
855 address,
856 network,
857 ..
858 } => format!(
859 "asset={} address={} network={}",
860 asset.as_deref().unwrap_or("-"),
861 address.as_deref().unwrap_or("-"),
862 network.as_deref().unwrap_or("-")
863 ),
864 RecordData::Totp {
865 issuer,
866 account,
867 digits,
868 step,
869 ..
870 } => format!(
871 "issuer={} account={} digits={} step={}",
872 issuer.as_deref().unwrap_or("-"),
873 account.as_deref().unwrap_or("-"),
874 digits,
875 step
876 ),
877 RecordData::Ssh { label, comment, .. } => format!(
878 "label={} comment={}",
879 label.as_deref().unwrap_or("-"),
880 comment.as_deref().unwrap_or("-")
881 ),
882 RecordData::Pgp {
883 label, fingerprint, ..
884 } => format!(
885 "label={} fingerprint={}",
886 label.as_deref().unwrap_or("-"),
887 fingerprint.as_deref().unwrap_or("-")
888 ),
889 RecordData::Recovery { description, .. } => {
890 format!("description={}", description.as_deref().unwrap_or("-"))
891 }
892 }
893 }
894}
895
896#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
897struct Sensitive {
898 #[serde(with = "serde_bytes")]
899 data: Vec<u8>,
900}
901
902impl Sensitive {
903 fn new_from_utf8(value: &[u8]) -> Self {
904 Self {
905 data: value.to_vec(),
906 }
907 }
908
909 fn from_string(value: &str) -> Self {
910 Self::new_from_utf8(value.as_bytes())
911 }
912
913 fn as_slice(&self) -> &[u8] {
914 &self.data
915 }
916
917 fn expose_utf8(&self) -> Result<String> {
918 Ok(String::from_utf8(self.data.clone())?)
919 }
920}
921
922fn cap_sensitive(name: &str, s: &Sensitive, max: usize) -> Result<()> {
923 if s.as_slice().len() > max {
924 bail!("{name} too large (>{} bytes)", max);
925 }
926 Ok(())
927}
928
929impl Drop for Sensitive {
930 fn drop(&mut self) {
931 self.data.zeroize();
932 }
933}
934
935impl ZeroizeOnDrop for Sensitive {}
936
937#[derive(Serialize, Deserialize, Clone)]
938struct VaultFile {
939 version: u32,
940 header: VaultHeader,
941 payload: AeadBlob,
942}
943
944#[derive(Serialize, Deserialize, Clone)]
945struct VaultHeader {
946 created_at: DateTime<Utc>,
947 updated_at: DateTime<Utc>,
948 #[serde(default)]
949 epoch: u64,
950 argon: ArgonState,
951 kem_public: Vec<u8>,
952 kem_ciphertext: Vec<u8>,
953 sealed_decapsulation: AeadBlob,
954 sealed_dek: AeadBlob,
955 #[serde(default)]
958 header_mac: Option<[u8; 32]>,
959}
960
961#[derive(Serialize, Deserialize, Clone)]
962struct ArgonState {
963 mem_cost_kib: u32,
964 time_cost: u32,
965 lanes: u32,
966 salt: [u8; 32],
967}
968
969#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
970struct AeadBlob {
971 nonce: [u8; 24],
972 ciphertext: Vec<u8>,
973}
974
975#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
976struct VaultPayload {
977 records: Vec<Record>,
978 record_counter: u64,
979}
980
981struct Vault {
982 path: PathBuf,
983 file: VaultFile,
984 payload: VaultPayload,
985 dek: Zeroizing<[u8; 32]>,
986 #[cfg(feature = "mlock")]
987 _dek_lock: Option<crate::memory::MlockGuard>,
988}
989
990impl Vault {
991 fn find_index(&self, id: &Uuid) -> Option<usize> {
992 let mut found = None;
993 for (idx, record) in self.payload.records.iter().enumerate() {
994 if constant_time_uuid_eq(&record.id, id) && found.is_none() {
995 found = Some(idx);
996 }
997 }
998 found
999 }
1000
1001 fn init(
1002 path: &Path,
1003 passphrase: &Zeroizing<String>,
1004 mem_kib: u32,
1005 config: &AppConfig,
1006 lanes_choice: Option<&str>,
1007 ) -> Result<()> {
1008 if path.exists() {
1009 bail!("vault already exists at {}", path.display());
1010 }
1011
1012 if mem_kib < 32_768 {
1013 bail!("mem-kib must be at least 32768 (32 MiB)");
1014 }
1015
1016 let mut salt = [0u8; 32];
1017 OsRng.fill_bytes(&mut salt);
1018
1019 let argon_lanes = match lanes_choice {
1020 Some(s) if s.eq_ignore_ascii_case("auto") => {
1021 let cpus = num_cpus::get().min(8) as u32;
1022 cpus.max(DEFAULT_LANES)
1023 }
1024 Some(s) => s.parse::<u32>().unwrap_or(DEFAULT_LANES).max(DEFAULT_LANES),
1025 None => {
1026 let cpus = num_cpus::get().min(8) as u32;
1027 cpus.max(DEFAULT_LANES)
1028 }
1029 };
1030
1031 let argon = ArgonState {
1032 mem_cost_kib: mem_kib,
1033 time_cost: DEFAULT_TIME_COST,
1034 lanes: argon_lanes,
1035 salt,
1036 };
1037
1038 let kek = derive_kek(passphrase, &argon)?;
1039
1040 let (ek, dk) = kyber1024::keypair();
1041 let (shared_key, kem_ct) = kyber1024::encapsulate(&ek);
1042
1043 let mut dek = Zeroizing::new([0u8; 32]);
1044 OsRng.fill_bytes(&mut *dek);
1045
1046 let sealed_dek = encrypt_blob(shared_key.as_bytes(), dek.as_slice(), AAD_DEK)?;
1047
1048 let dk_bytes = dk.as_bytes();
1049 let sealed_decapsulation = encrypt_blob(kek.as_slice(), dk_bytes, AAD_DK)?;
1050
1051 let payload = VaultPayload {
1052 records: Vec::new(),
1053 record_counter: 0,
1054 };
1055 let payload_blob = encrypt_payload_with_config(dek.as_ref(), &payload, config)?;
1056
1057 let mut header = VaultHeader {
1058 created_at: Utc::now(),
1059 updated_at: Utc::now(),
1060 epoch: 1,
1061 argon,
1062 kem_public: ek.as_bytes().to_vec(),
1063 kem_ciphertext: kem_ct.as_bytes().to_vec(),
1064 sealed_decapsulation,
1065 sealed_dek,
1066 header_mac: None,
1067 };
1068
1069 header.header_mac = Some(compute_header_mac(&header, kek.as_slice()));
1071
1072 let file = VaultFile {
1073 version: VAULT_VERSION,
1074 header,
1075 payload: payload_blob,
1076 };
1077
1078 write_vault(path, &file)
1079 }
1080
1081 fn load(path: &Path, passphrase: &Zeroizing<String>, config: &AppConfig) -> Result<Self> {
1082 if !path.exists() {
1083 bail!("vault not initialized");
1084 }
1085
1086 let buf = Zeroizing::new(read_vault_bytes(path)?);
1087 let vault_file: VaultFile = match from_reader(buf.as_slice()) {
1088 Ok(file) => file,
1089 Err(err) => return Err(unlock_failure(err)),
1090 };
1091
1092 if vault_file.version != VAULT_VERSION && vault_file.version != 1 {
1093 return Err(unlock_failure(anyhow!(
1094 "unsupported vault version {}",
1095 vault_file.version
1096 )));
1097 }
1098
1099 let result = (|| -> Result<Self> {
1100 let kek = derive_kek(passphrase, &vault_file.header.argon)?;
1101 #[cfg(feature = "mlock")]
1102 let _kek_lock = crate::memory::try_lock_slice(kek.as_slice(), config.require_mlock)?;
1103
1104 if let Some(stored) = &vault_file.header.header_mac {
1106 let computed = compute_header_mac(&vault_file.header, kek.as_slice());
1107 if !constant_time_eq(stored, &computed) {
1108 bail!("header integrity check failed");
1109 }
1110 }
1111
1112 let dk_bytes = Zeroizing::new(decrypt_blob(
1113 kek.as_slice(),
1114 &vault_file.header.sealed_decapsulation,
1115 AAD_DK,
1116 )?);
1117 #[cfg(feature = "mlock")]
1118 let _dk_bytes_lock =
1119 crate::memory::try_lock_slice(dk_bytes.as_slice(), config.require_mlock)?;
1120 let dk = expect_sk(dk_bytes.as_slice())?;
1121 let kem_ct = expect_ct(&vault_file.header.kem_ciphertext)?;
1122 let shared = kyber1024::decapsulate(&kem_ct, &dk);
1123 #[cfg(feature = "mlock")]
1124 let _shared_lock =
1125 crate::memory::try_lock_slice(shared.as_bytes(), config.require_mlock)?;
1126
1127 let dek_bytes = Zeroizing::new(decrypt_blob(
1128 shared.as_bytes(),
1129 &vault_file.header.sealed_dek,
1130 AAD_DEK,
1131 )?);
1132 #[cfg(feature = "mlock")]
1133 let _sealed_dek_lock =
1134 crate::memory::try_lock_slice(dek_bytes.as_slice(), config.require_mlock)?;
1135 if dek_bytes.len() != 32 {
1136 bail!("invalid dek length");
1137 }
1138 let mut dek = Zeroizing::new([0u8; 32]);
1139 (&mut *dek).copy_from_slice(dek_bytes.as_slice());
1140 #[cfg(feature = "mlock")]
1141 let dek_guard = crate::memory::try_lock_slice(dek.as_ref(), config.require_mlock)?;
1142
1143 let payload: VaultPayload =
1144 decrypt_payload_with_config(dek.as_ref(), &vault_file.payload, config)?;
1145 let mut payload = payload;
1147 unwrap_payload_after_load(&mut payload, dek.as_ref())?;
1148 if let Ok(seen) = read_epoch_sidecar(path) {
1150 if vault_file.header.epoch < seen {
1151 if std::env::var_os("BLACK_BAG_STRICT_ROLLBACK").is_some() {
1152 bail!("vault appears rolled back");
1153 } else {
1154 eprintln!("warning: vault epoch behind last seen; possible rollback");
1155 }
1156 }
1157 }
1158
1159 #[cfg(feature = "agent-keychain")]
1161 {
1162 if let Some(stored) = maybe_agent_get_epoch(config, path) {
1163 if vault_file.header.epoch < stored {
1164 if std::env::var_os("BLACK_BAG_ALLOW_ROLLBACK").is_none() {
1165 bail!("vault appears rolled back (keychain)");
1166 }
1167 }
1168 }
1169 maybe_agent_store_epoch(config, path, vault_file.header.epoch);
1170 }
1171
1172 Ok(Self {
1173 path: path.to_path_buf(),
1174 file: vault_file,
1175 payload,
1176 dek,
1177 #[cfg(feature = "mlock")]
1178 _dek_lock: dek_guard,
1179 })
1180 })();
1181
1182 result.map_err(unlock_failure)
1183 }
1184
1185 fn save(&mut self, passphrase: &Zeroizing<String>, config: &AppConfig) -> Result<()> {
1186 self.file.header.updated_at = Utc::now();
1187 self.file.header.epoch = self.file.header.epoch.saturating_add(1);
1188
1189 let storage_payload = wrap_payload_for_storage(&self.payload, self.dek.as_ref())?;
1191 let payload_blob =
1192 encrypt_payload_with_config(self.dek.as_ref(), &storage_payload, config)?;
1193 self.file.payload = payload_blob;
1194
1195 let kek = derive_kek(passphrase, &self.file.header.argon)?;
1196 #[cfg(feature = "mlock")]
1197 let _kek_lock = crate::memory::try_lock_slice(kek.as_slice(), config.require_mlock)?;
1198
1199 let dk_bytes = Zeroizing::new(decrypt_blob(
1200 kek.as_slice(),
1201 &self.file.header.sealed_decapsulation,
1202 AAD_DK,
1203 )?);
1204 #[cfg(feature = "mlock")]
1205 let _dk_lock = crate::memory::try_lock_slice(dk_bytes.as_slice(), config.require_mlock)?;
1206 let dk = expect_sk(dk_bytes.as_slice())?;
1207 let ek = expect_pk(&self.file.header.kem_public)?;
1208 let (shared, kem_ct) = kyber1024::encapsulate(&ek);
1209 #[cfg(feature = "mlock")]
1210 let _shared_lock = crate::memory::try_lock_slice(shared.as_bytes(), config.require_mlock)?;
1211
1212 let sealed_dek = encrypt_blob(shared.as_bytes(), self.dek.as_ref(), AAD_DEK)?;
1213 let sealed_decapsulation = encrypt_blob(kek.as_slice(), dk.as_bytes(), AAD_DK)?;
1214
1215 self.file.header.kem_ciphertext = kem_ct.as_bytes().to_vec();
1216 self.file.header.sealed_dek = sealed_dek;
1217 self.file.header.sealed_decapsulation = sealed_decapsulation;
1218
1219 self.file.header.header_mac = Some(compute_header_mac(&self.file.header, kek.as_slice()));
1221
1222 write_vault(&self.path, &self.file)
1223 }
1224
1225 fn add_record(&mut self, record: Record) {
1226 self.payload.record_counter = self.payload.record_counter.saturating_add(1);
1227 self.payload.records.push(record);
1228 }
1229
1230 fn list(
1231 &self,
1232 kind: Option<RecordKind>,
1233 tag: Option<&str>,
1234 query: Option<&str>,
1235 fuzzy: bool,
1236 ) -> Vec<&Record> {
1237 #[allow(clippy::redundant_closure)]
1238 let matcher = if fuzzy {
1239 Some(fuzzy_matcher::skim::SkimMatcherV2::default())
1240 } else {
1241 None
1242 };
1243 self.payload
1244 .records
1245 .iter()
1246 .filter_map(|rec| {
1247 if let Some(k) = kind {
1248 if rec.kind() != k {
1249 return None;
1250 }
1251 }
1252 if let Some(t) = tag {
1253 if !rec.matches_tag(t) {
1254 return None;
1255 }
1256 }
1257 if let Some(q) = query {
1258 let passed = if fuzzy {
1259 use fuzzy_matcher::FuzzyMatcher;
1260 let matcher = matcher.as_ref().unwrap();
1261 let hay = [
1262 rec.title.as_deref().unwrap_or_default(),
1263 rec.metadata_notes.as_deref().unwrap_or_default(),
1264 &rec.data.summary_text(),
1265 ]
1266 .join("\n");
1267 matcher.fuzzy_match(&hay, q).is_some()
1268 } else {
1269 rec.matches_query(q)
1270 };
1271 if !passed {
1272 return None;
1273 }
1274 }
1275 Some(rec)
1276 })
1277 .collect()
1278 }
1279
1280 fn get(&mut self, id: Uuid) -> Option<&mut Record> {
1281 self.find_index(&id)
1282 .and_then(move |idx| self.payload.records.get_mut(idx))
1283 }
1284
1285 fn get_ref(&self, id: Uuid) -> Option<&Record> {
1286 self.find_index(&id)
1287 .and_then(|idx| self.payload.records.get(idx))
1288 }
1289
1290 fn rotate(
1291 &mut self,
1292 passphrase: &Zeroizing<String>,
1293 mem_kib: Option<u32>,
1294 config: &AppConfig,
1295 ) -> Result<()> {
1296 if let Some(mem) = mem_kib {
1297 if mem < 32_768 {
1298 bail!("mem-kib must be at least 32768 (32 MiB)");
1299 }
1300 self.file.header.argon.mem_cost_kib = mem;
1301 OsRng.fill_bytes(&mut self.file.header.argon.salt);
1302 }
1303
1304 let (ek, dk) = kyber1024::keypair();
1305 let (shared_key, kem_ct) = kyber1024::encapsulate(&ek);
1306
1307 let kek = derive_kek(passphrase, &self.file.header.argon)?;
1308 #[cfg(feature = "mlock")]
1309 let _kek_lock = crate::memory::try_lock_slice(kek.as_slice(), config.require_mlock)?;
1310 #[cfg(feature = "mlock")]
1311 let _shared_lock =
1312 crate::memory::try_lock_slice(shared_key.as_bytes(), config.require_mlock)?;
1313
1314 let sealed_dek = encrypt_blob(shared_key.as_bytes(), self.dek.as_ref(), AAD_DEK)?;
1315 let sealed_decapsulation = encrypt_blob(kek.as_slice(), dk.as_bytes(), AAD_DK)?;
1316
1317 self.file.header.kem_public = ek.as_bytes().to_vec();
1318 self.file.header.kem_ciphertext = kem_ct.as_bytes().to_vec();
1319 self.file.header.sealed_dek = sealed_dek;
1320 self.file.header.sealed_decapsulation = sealed_decapsulation;
1321
1322 let kek = derive_kek(passphrase, &self.file.header.argon)?;
1324 self.file.header.header_mac = Some(compute_header_mac(&self.file.header, kek.as_slice()));
1325
1326 Ok(())
1327 }
1328
1329 fn stats(&self) -> VaultStats {
1330 VaultStats {
1331 created_at: self.file.header.created_at,
1332 updated_at: self.file.header.updated_at,
1333 record_count: self.payload.records.len(),
1334 argon_mem_kib: self.file.header.argon.mem_cost_kib,
1335 argon_time_cost: self.file.header.argon.time_cost,
1336 argon_lanes: self.file.header.argon.lanes,
1337 }
1338 }
1339}
1340
1341impl Drop for Vault {
1342 fn drop(&mut self) {
1343 self.dek.zeroize();
1344 }
1345}
1346
1347struct VaultStats {
1348 created_at: DateTime<Utc>,
1349 updated_at: DateTime<Utc>,
1350 record_count: usize,
1351 argon_mem_kib: u32,
1352 argon_time_cost: u32,
1353 argon_lanes: u32,
1354}
1355
1356fn derive_kek(passphrase: &Zeroizing<String>, argon: &ArgonState) -> Result<Zeroizing<[u8; 32]>> {
1357 let params = Params::new(argon.mem_cost_kib, argon.time_cost, argon.lanes, Some(32))
1358 .map_err(|e| anyhow!("invalid Argon2 parameters: {e}"))?;
1359 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
1360 let mut output = Zeroizing::new([0u8; 32]);
1361 argon2
1362 .hash_password_into(passphrase.as_bytes(), &argon.salt, output.as_mut())
1363 .map_err(|e| anyhow!("argon2 derivation failed: {e}"))?;
1364 Ok(output)
1365}
1366
1367fn expect_pk(data: &[u8]) -> Result<kyber1024::PublicKey> {
1369 kyber1024::PublicKey::from_bytes(data).map_err(|_| anyhow!("invalid public key length").into())
1370}
1371
1372fn expect_sk(data: &[u8]) -> Result<kyber1024::SecretKey> {
1373 kyber1024::SecretKey::from_bytes(data).map_err(|_| anyhow!("invalid secret key length").into())
1374}
1375
1376fn expect_ct(data: &[u8]) -> Result<kyber1024::Ciphertext> {
1377 kyber1024::Ciphertext::from_bytes(data).map_err(|_| anyhow!("invalid ciphertext length").into())
1378}
1379
1380fn compute_header_mac(header: &VaultHeader, kek: &[u8]) -> [u8; 32] {
1381 use blake3::derive_key;
1382 let mut buf = Vec::new();
1384 buf.extend_from_slice(header.created_at.to_rfc3339().as_bytes());
1385 buf.extend_from_slice(header.updated_at.to_rfc3339().as_bytes());
1386 buf.extend_from_slice(&header.argon.mem_cost_kib.to_be_bytes());
1387 buf.extend_from_slice(&header.argon.time_cost.to_be_bytes());
1388 buf.extend_from_slice(&header.argon.lanes.to_be_bytes());
1389 buf.extend_from_slice(&header.argon.salt);
1390 buf.extend_from_slice(&header.epoch.to_be_bytes());
1391 buf.extend_from_slice(&header.kem_public);
1392 buf.extend_from_slice(&header.kem_ciphertext);
1393 buf.extend_from_slice(&header.sealed_decapsulation.nonce);
1395 buf.extend_from_slice(&header.sealed_decapsulation.ciphertext);
1396 buf.extend_from_slice(&header.sealed_dek.nonce);
1397 buf.extend_from_slice(&header.sealed_dek.ciphertext);
1398 let key = derive_key("black-bag header mac", kek);
1399 let mut hasher = blake3::Hasher::new_keyed(&key);
1400 hasher.update(&buf);
1401 *hasher.finalize().as_bytes()
1402}
1403
1404fn encrypt_blob(key: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<AeadBlob> {
1405 let mut nonce = [0u8; 24];
1406 OsRng.fill_bytes(&mut nonce);
1407 let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
1408 let ciphertext = cipher
1409 .encrypt(
1410 XNonce::from_slice(&nonce),
1411 Payload {
1412 msg: plaintext,
1413 aad,
1414 },
1415 )
1416 .map_err(|_| anyhow!("encryption failed"))?;
1417 Ok(AeadBlob { nonce, ciphertext })
1418}
1419
1420#[cfg(test)]
1421fn encrypt_blob_with_nonce(key: &[u8], plaintext: &[u8], aad: &[u8], nonce: [u8; 24]) -> Result<AeadBlob> {
1422 let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
1423 let ciphertext = cipher
1424 .encrypt(
1425 XNonce::from_slice(&nonce),
1426 Payload { msg: plaintext, aad },
1427 )
1428 .map_err(|_| anyhow!("encryption failed"))?;
1429 Ok(AeadBlob { nonce, ciphertext })
1430}
1431
1432fn decrypt_blob(key: &[u8], blob: &AeadBlob, aad: &[u8]) -> Result<Vec<u8>> {
1433 let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
1434 let plaintext = cipher
1435 .decrypt(
1436 XNonce::from_slice(&blob.nonce),
1437 Payload {
1438 msg: &blob.ciphertext,
1439 aad,
1440 },
1441 )
1442 .map_err(|_| anyhow!("decryption failed"))?;
1443 Ok(plaintext)
1444}
1445
1446fn encrypt_payload_with_config(
1447 dek: &[u8],
1448 payload: &VaultPayload,
1449 config: &AppConfig,
1450) -> Result<AeadBlob> {
1451 let mut buf = Zeroizing::new(Vec::new());
1452 into_writer(payload, &mut *buf).context("failed to serialize payload")?;
1453 let block = std::env::var("BLACK_BAG_PAD_BLOCK")
1454 .ok()
1455 .and_then(|v| v.parse::<usize>().ok())
1456 .unwrap_or(64 * 1024);
1457 let padded = apply_padding(buf.as_slice(), block)?;
1458 #[cfg(feature = "mlock")]
1459 let _guard = crate::memory::try_lock_slice(padded.as_slice(), config.require_mlock)?;
1460 encrypt_blob(dek, padded.as_slice(), AAD_PAYLOAD)
1461}
1462
1463fn decrypt_payload_with_config(
1464 dek: &[u8],
1465 blob: &AeadBlob,
1466 config: &AppConfig,
1467) -> Result<VaultPayload> {
1468 let plaintext = Zeroizing::new(decrypt_blob(dek, blob, AAD_PAYLOAD)?);
1469 if plaintext.len() > MAX_PAYLOAD_PLAINTEXT_BYTES {
1470 bail!("payload too large");
1471 }
1472 #[cfg(feature = "mlock")]
1473 let _guard = crate::memory::try_lock_slice(plaintext.as_slice(), config.require_mlock)?;
1474 let inner = strip_padding(plaintext.as_slice())?;
1475 let payload: VaultPayload = from_reader(inner).context("failed to parse payload")?;
1476 enforce_payload_limits(&payload)?;
1477 Ok(payload)
1478}
1479
1480fn apply_padding(data: &[u8], block: usize) -> Result<Zeroizing<Vec<u8>>> {
1481 if block == 0 {
1482 return Ok(Zeroizing::new(data.to_vec()));
1483 }
1484 let header_len = PAD_MAGIC.len() + 8;
1485 let total = header_len + data.len();
1486 let mut out = Zeroizing::new(Vec::with_capacity(total + block));
1487 out.extend_from_slice(PAD_MAGIC);
1488 out.extend_from_slice(&(data.len() as u64).to_le_bytes());
1489 out.extend_from_slice(data);
1490 let padded_len = ((out.len() + block - 1) / block) * block;
1491 let pad_needed = padded_len - out.len();
1492 if pad_needed > 0 {
1493 let mut pad = vec![0u8; pad_needed];
1494 OsRng.fill_bytes(&mut pad);
1495 out.extend_from_slice(&pad);
1496 } else {
1497 let mut pad = vec![0u8; block];
1498 OsRng.fill_bytes(&mut pad);
1499 out.extend_from_slice(&pad);
1500 }
1501 Ok(out)
1502}
1503
1504fn strip_padding<'a>(plaintext: &'a [u8]) -> Result<&'a [u8]> {
1505 if plaintext.len() >= PAD_MAGIC.len() + 8 && &plaintext[..PAD_MAGIC.len()] == PAD_MAGIC {
1506 let mut len_bytes = [0u8; 8];
1507 len_bytes.copy_from_slice(&plaintext[PAD_MAGIC.len()..PAD_MAGIC.len() + 8]);
1508 let cbor_len = u64::from_le_bytes(len_bytes) as usize;
1509 let start = PAD_MAGIC.len() + 8;
1510 if start + cbor_len > plaintext.len() {
1511 bail!("invalid padded payload length");
1512 }
1513 Ok(&plaintext[start..start + cbor_len])
1514 } else {
1515 Ok(plaintext)
1516 }
1517}
1518
1519fn wrap_payload_for_storage(src: &VaultPayload, dek: &[u8]) -> Result<VaultPayload> {
1520 let mut out = src.clone();
1521 for rec in out.records.iter_mut() {
1522 process_record_wrap(rec, dek)?;
1523 }
1524 Ok(out)
1525}
1526
1527fn unwrap_payload_after_load(payload: &mut VaultPayload, dek: &[u8]) -> Result<()> {
1528 for rec in payload.records.iter_mut() {
1529 process_record_unwrap(rec, dek)?;
1530 }
1531 Ok(())
1532}
1533
1534fn process_record_wrap(rec: &mut Record, dek: &[u8]) -> Result<()> {
1535 let rdek = if let Some(sealed) = &rec.sealed_rdek {
1537 let bytes = decrypt_blob(dek, sealed, AAD_RDEK)?;
1538 Zeroizing::new(bytes)
1539 } else {
1540 let mut r = [0u8; 32];
1541 OsRng.fill_bytes(&mut r);
1542 let r_vec = Zeroizing::new(r.to_vec());
1543 rec.sealed_rdek = Some(encrypt_blob(dek, r_vec.as_slice(), AAD_RDEK)?);
1544 r_vec
1545 };
1546 wrap_record_secrets(&mut rec.data, rdek.as_slice())
1547}
1548
1549fn process_record_unwrap(rec: &mut Record, dek: &[u8]) -> Result<()> {
1550 if let Some(sealed) = &rec.sealed_rdek {
1551 let rdek = Zeroizing::new(decrypt_blob(dek, sealed, AAD_RDEK)?);
1552 unwrap_record_secrets(&mut rec.data, rdek.as_slice())?;
1553 }
1554 Ok(())
1555}
1556
1557fn wrap_record_secrets(data: &mut RecordData, rdek: &[u8]) -> Result<()> {
1558 match data {
1559 RecordData::Login { password, .. } => wrap_sensitive(password, rdek)?,
1560 RecordData::Contact { .. } => {}
1561 RecordData::Id { secret, .. } => {
1562 if let Some(s) = secret {
1563 wrap_sensitive(s, rdek)?;
1564 }
1565 }
1566 RecordData::Note { body } => wrap_sensitive(body, rdek)?,
1567 RecordData::Bank { account_number, .. } => wrap_sensitive(account_number, rdek)?,
1568 RecordData::Wifi { passphrase, .. } => wrap_sensitive(passphrase, rdek)?,
1569 RecordData::Api { secret_key, .. } => wrap_sensitive(secret_key, rdek)?,
1570 RecordData::Wallet { secret_key, .. } => wrap_sensitive(secret_key, rdek)?,
1571 RecordData::Totp { secret, .. } => wrap_sensitive(secret, rdek)?,
1572 RecordData::Ssh { private_key, .. } => wrap_sensitive(private_key, rdek)?,
1573 RecordData::Pgp {
1574 armored_private_key,
1575 ..
1576 } => wrap_sensitive(armored_private_key, rdek)?,
1577 RecordData::Recovery { payload, .. } => wrap_sensitive(payload, rdek)?,
1578 }
1579 Ok(())
1580}
1581
1582fn unwrap_record_secrets(data: &mut RecordData, rdek: &[u8]) -> Result<()> {
1583 match data {
1584 RecordData::Login { password, .. } => unwrap_sensitive(password, rdek)?,
1585 RecordData::Contact { .. } => {}
1586 RecordData::Id { secret, .. } => {
1587 if let Some(s) = secret {
1588 unwrap_sensitive(s, rdek)?;
1589 }
1590 }
1591 RecordData::Note { body } => unwrap_sensitive(body, rdek)?,
1592 RecordData::Bank { account_number, .. } => unwrap_sensitive(account_number, rdek)?,
1593 RecordData::Wifi { passphrase, .. } => unwrap_sensitive(passphrase, rdek)?,
1594 RecordData::Api { secret_key, .. } => unwrap_sensitive(secret_key, rdek)?,
1595 RecordData::Wallet { secret_key, .. } => unwrap_sensitive(secret_key, rdek)?,
1596 RecordData::Totp { secret, .. } => unwrap_sensitive(secret, rdek)?,
1597 RecordData::Ssh { private_key, .. } => unwrap_sensitive(private_key, rdek)?,
1598 RecordData::Pgp {
1599 armored_private_key,
1600 ..
1601 } => unwrap_sensitive(armored_private_key, rdek)?,
1602 RecordData::Recovery { payload, .. } => unwrap_sensitive(payload, rdek)?,
1603 }
1604 Ok(())
1605}
1606
1607fn enforce_payload_limits(payload: &VaultPayload) -> Result<()> {
1608 if payload.records.len() > MAX_RECORDS {
1609 bail!("too many records");
1610 }
1611 for rec in &payload.records {
1612 if rec.tags.len() > MAX_TAGS_PER_RECORD {
1613 bail!("too many tags");
1614 }
1615 for t in &rec.tags {
1616 if t.len() > MAX_TAG_LEN { bail!("tag too long"); }
1617 }
1618 if let Some(t) = &rec.title { if t.len() > MAX_TITLE_LEN { bail!("title too long"); } }
1619 if let Some(n) = &rec.metadata_notes { if n.len() > MAX_NOTE_BYTES { bail!("notes too large"); } }
1620 match &rec.data {
1621 RecordData::Login{ username, url, password } => {
1622 if let Some(u)=username { if u.len()>2048 { bail!("username too long"); } }
1623 if let Some(u)=url { if u.len()>4096 { bail!("url too long"); } }
1624 if password.as_slice().len() > MAX_FIELD_BYTES { bail!("password too large"); }
1625 }
1626 RecordData::Contact{ full_name, emails, phones } => {
1627 if full_name.len()>1024 { bail!("full name too long"); }
1628 if emails.len()>256 || phones.len()>256 { bail!("too many contact entries"); }
1629 }
1630 RecordData::Id{ secret, .. } => {
1631 if let Some(s)=secret { if s.as_slice().len() > MAX_FIELD_BYTES { bail!("id secret too large"); } }
1632 }
1633 RecordData::Note{ body } => { if body.as_slice().len() > MAX_NOTE_BYTES { bail!("note too large"); } }
1634 RecordData::Bank{ account_number, .. } => { if account_number.as_slice().len() > MAX_FIELD_BYTES { bail!("account number too large"); } }
1635 RecordData::Wifi{ passphrase, .. } => { if passphrase.as_slice().len() > MAX_FIELD_BYTES { bail!("wifi passphrase too large"); } }
1636 RecordData::Api{ secret_key, .. } => { if secret_key.as_slice().len() > MAX_FIELD_BYTES { bail!("api secret too large"); } }
1637 RecordData::Wallet{ secret_key, .. } => { if secret_key.as_slice().len() > MAX_FIELD_BYTES { bail!("wallet secret too large"); } }
1638 RecordData::Totp{ secret, .. } => { if secret.as_slice().len() > MAX_FIELD_BYTES { bail!("totp secret too large"); } }
1639 RecordData::Ssh{ private_key, .. } => { if private_key.as_slice().len() > MAX_NOTE_BYTES { bail!("ssh key too large"); } }
1640 RecordData::Pgp{ armored_private_key, .. } => { if armored_private_key.as_slice().len() > MAX_NOTE_BYTES { bail!("pgp key too large"); } }
1641 RecordData::Recovery{ payload, .. } => { if payload.as_slice().len() > MAX_NOTE_BYTES { bail!("recovery payload too large"); } }
1642 }
1643 }
1644 Ok(())
1645}
1646
1647fn wrap_sensitive(s: &mut Sensitive, rdek: &[u8]) -> Result<()> {
1648 if s.data.len() >= 4 && &s.data[..4] == SECRET_MARKER {
1650 return Ok(());
1651 }
1652 let blob = encrypt_blob(rdek, s.as_slice(), AAD_SECRET)?;
1653 let mut out = Vec::with_capacity(4 + 24 + blob.ciphertext.len());
1654 out.extend_from_slice(SECRET_MARKER);
1655 out.extend_from_slice(&blob.nonce);
1656 out.extend_from_slice(&blob.ciphertext);
1657 s.data = out;
1658 Ok(())
1659}
1660
1661fn unwrap_sensitive(s: &mut Sensitive, rdek: &[u8]) -> Result<()> {
1662 if s.data.len() >= 4 && &s.data[..4] == SECRET_MARKER {
1663 if s.data.len() < 4 + 24 {
1664 bail!("wrapped secret too short");
1665 }
1666 let mut nonce = [0u8; 24];
1667 nonce.copy_from_slice(&s.data[4..28]);
1668 let ct = s.data[28..].to_vec();
1669 let blob = AeadBlob {
1670 nonce,
1671 ciphertext: ct,
1672 };
1673 let pt = decrypt_blob(rdek, &blob, AAD_SECRET)?;
1674 s.data = pt;
1675 }
1676 Ok(())
1677}
1678
1679fn encrypt_payload(dek: &[u8], payload: &VaultPayload) -> Result<AeadBlob> {
1680 encrypt_payload_with_config(dek, payload, &AppConfig::default())
1681}
1682
1683fn decrypt_payload(dek: &[u8], blob: &AeadBlob) -> Result<VaultPayload> {
1684 decrypt_payload_with_config(dek, blob, &AppConfig::default())
1685}
1686
1687fn vault_path_with_cfg(config: &AppConfig) -> Result<PathBuf> {
1688 if let Ok(path) = env::var(if config.duress {
1689 "BLACK_BAG_VAULT_DURESS_PATH"
1690 } else {
1691 "BLACK_BAG_VAULT_PATH"
1692 }) {
1693 let pb = PathBuf::from(path);
1694 if let Some(parent) = pb.parent() {
1695 fs::create_dir_all(parent).context("failed to create vault directory")?;
1696 }
1697 return Ok(pb);
1698 }
1699 let base = BaseDirs::new().ok_or_else(|| anyhow!("unable to resolve base directory"))?;
1700 let dir = base.config_dir().join("black_bag");
1701 fs::create_dir_all(&dir).context("failed to create vault directory")?;
1702 let path = dir.join("vault.cbor");
1703 if config.duress {
1704 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("vault");
1705 Ok(path.with_file_name(format!("{}.duress.cbor", stem)))
1706 } else {
1707 Ok(path)
1708 }
1709}
1710
1711fn prompt_passphrase(prompt: &str, config: &AppConfig) -> Result<Zeroizing<String>> {
1712 match prompt_password(prompt) {
1713 Ok(value) => {
1714 if value.trim().is_empty() {
1715 bail!("passphrase cannot be empty");
1716 }
1717 Ok(Zeroizing::new(value))
1718 }
1719 Err(_) if config.unsafe_stdout => {
1720 let mut stderr = io::stderr();
1721 stderr.write_all(prompt.as_bytes())?;
1722 stderr.write_all(b"\n")?;
1723 stderr.flush()?;
1724 let mut line = String::new();
1725 io::stdin().read_line(&mut line)?;
1726 let line = line.trim_end_matches(&['\r', '\n'][..]).to_string();
1727 if line.trim().is_empty() {
1728 bail!("passphrase cannot be empty");
1729 }
1730 Ok(Zeroizing::new(line))
1731 }
1732 Err(err) => Err(err.into()),
1733 }
1734}
1735
1736fn ensure_passphrase_strength(passphrase: &str) -> Result<()> {
1737 use unicode_normalization::UnicodeNormalization;
1738 let norm: String = passphrase.nfkc().collect();
1739 if norm.chars().count() < 14 {
1740 bail!("passphrase must be at least 14 characters");
1741 }
1742 const COMMON: &[&str] = &[
1744 "password","123456","123456789","qwerty","letmein","iloveyou","admin","welcome","monkey","abc123","1q2w3e4r","dragon","sunshine","princess","football","password1","zaq12wsx","qwertyuiop","passw0rd","baseball"
1745 ];
1746 let lower = norm.to_ascii_lowercase();
1747 if COMMON.iter().any(|w| lower.contains(w)) {
1748 bail!("passphrase too common");
1749 }
1750 let unique: std::collections::HashSet<char> = norm.chars().collect();
1751 if unique.len() < 6 {
1752 bail!("passphrase has too few unique characters");
1753 }
1754 let estimate = zxcvbn(&norm, &[])
1755 .map_err(|err| anyhow!("failed to evaluate passphrase strength: {err}"))?;
1756 if estimate.score() < 3 {
1757 bail!(
1758 "passphrase too weak (score {} of 4). Use a longer, less predictable passphrase.",
1759 estimate.score()
1760 );
1761 }
1762 Ok(())
1763}
1764
1765fn init_vault(cmd: InitCommand, config: &AppConfig) -> Result<()> {
1766 let path = vault_path_with_cfg(config)?;
1767 let pass1 = prompt_passphrase("Master passphrase: ", config)?;
1768 ensure_passphrase_strength(pass1.as_str())?;
1769 let pass2 = prompt_passphrase("Confirm passphrase: ", config)?;
1770 if pass1.as_str() != pass2.as_str() {
1771 bail!("passphrases do not match");
1772 }
1773 Vault::init(
1774 &path,
1775 &pass1,
1776 cmd.mem_kib,
1777 config,
1778 cmd.argon_lanes.as_deref(),
1779 )?;
1780 maybe_agent_store(config, &path, pass1.as_str());
1781 println!("Initialized vault");
1782 Ok(())
1783}
1784
1785struct LockedPassphrase {
1786 inner: Zeroizing<String>,
1787 #[cfg(feature = "mlock")]
1788 _guard: Option<crate::memory::MlockGuard>,
1789}
1790
1791impl LockedPassphrase {
1792 fn new(value: Zeroizing<String>, config: &AppConfig) -> Result<Self> {
1793 #[cfg(feature = "mlock")]
1794 let guard = crate::memory::try_lock_slice(value.as_bytes(), config.require_mlock)?;
1795 Ok(Self {
1796 inner: value,
1797 #[cfg(feature = "mlock")]
1798 _guard: guard,
1799 })
1800 }
1801}
1802
1803impl Deref for LockedPassphrase {
1804 type Target = Zeroizing<String>;
1805 fn deref(&self) -> &Self::Target {
1806 &self.inner
1807 }
1808}
1809
1810impl DerefMut for LockedPassphrase {
1811 fn deref_mut(&mut self) -> &mut Self::Target {
1812 &mut self.inner
1813 }
1814}
1815
1816fn load_vault_with_prompt(config: &AppConfig) -> Result<(Vault, LockedPassphrase)> {
1817 let path = vault_path_with_cfg(config)?;
1818 if let Some(agent_pass) = maybe_agent_retrieve(config, &path) {
1820 if let Ok(locked) = LockedPassphrase::new(agent_pass, config) {
1821 if let Ok(vault) = Vault::load(&path, &locked, config) {
1822 return Ok((vault, locked));
1823 }
1824 }
1825 eprintln!("warning: cached passphrase invalid; falling back to prompt");
1826 }
1827 let pass = prompt_passphrase("Master passphrase: ", config)?;
1828 let locked = LockedPassphrase::new(pass, config)?;
1829 let vault = Vault::load(&path, &locked, config)?;
1830 Ok((vault, locked))
1831}
1832
1833fn add_record(cmd: AddCommand, config: &AppConfig) -> Result<()> {
1834 let (mut vault, pass) = load_vault_with_prompt(config)?;
1835
1836 let record = match cmd.record {
1837 AddRecord::Login(args) => {
1838 let CommonRecordArgs { title, tags, notes } = args.common;
1839 let password = prompt_hidden("Password: ")?;
1840 Record::new(
1841 RecordData::Login {
1842 username: args.username,
1843 url: args.url,
1844 password,
1845 },
1846 title,
1847 tags,
1848 notes,
1849 )
1850 }
1851 AddRecord::Contact(args) => {
1852 let CommonRecordArgs { title, tags, notes } = args.common;
1853 Record::new(
1854 RecordData::Contact {
1855 full_name: args.full_name,
1856 emails: args.emails,
1857 phones: args.phones,
1858 },
1859 title,
1860 tags,
1861 notes,
1862 )
1863 }
1864 AddRecord::Id(args) => {
1865 let CommonRecordArgs { title, tags, notes } = args.common;
1866 let secret = prompt_optional_hidden("Sensitive document secret (optional): ")?;
1867 Record::new(
1868 RecordData::Id {
1869 id_type: args.id_type,
1870 name_on_doc: args.name_on_doc,
1871 number: args.number,
1872 issuing_country: args.issuing_country,
1873 expiry: args.expiry,
1874 secret,
1875 },
1876 title,
1877 tags,
1878 notes,
1879 )
1880 }
1881 AddRecord::Note(args) => {
1882 let CommonRecordArgs { title, tags, notes } = args.common;
1883 let body = prompt_multiline("Secure note body (Ctrl-D to finish): ")?;
1884 Record::new(RecordData::Note { body }, title, tags, notes)
1885 }
1886 AddRecord::Bank(args) => {
1887 let CommonRecordArgs { title, tags, notes } = args.common;
1888 let account_number = prompt_hidden("Account number / secret: ")?;
1889 cap_sensitive("account number", &account_number, MAX_FIELD_BYTES)?;
1890 Record::new(
1891 RecordData::Bank {
1892 institution: args.institution,
1893 account_name: args.account_name,
1894 routing_number: args.routing_number,
1895 account_number,
1896 },
1897 title,
1898 tags,
1899 notes,
1900 )
1901 }
1902 AddRecord::Wifi(args) => {
1903 let CommonRecordArgs { title, tags, notes } = args.common;
1904 let passphrase = prompt_hidden("Wi-Fi passphrase: ")?;
1905 cap_sensitive("wifi passphrase", &passphrase, MAX_FIELD_BYTES)?;
1906 Record::new(
1907 RecordData::Wifi {
1908 ssid: args.ssid,
1909 security: args.security,
1910 location: args.location,
1911 passphrase,
1912 },
1913 title,
1914 tags,
1915 notes,
1916 )
1917 }
1918 AddRecord::Api(args) => {
1919 let CommonRecordArgs { title, tags, notes } = args.common;
1920 let secret = prompt_hidden("Secret key: ")?;
1921 cap_sensitive("api secret", &secret, MAX_FIELD_BYTES)?;
1922 Record::new(
1923 RecordData::Api {
1924 service: args.service,
1925 environment: args.environment,
1926 access_key: args.access_key,
1927 secret_key: secret,
1928 scopes: args.scopes,
1929 },
1930 title,
1931 tags,
1932 notes,
1933 )
1934 }
1935 AddRecord::Wallet(args) => {
1936 let CommonRecordArgs { title, tags, notes } = args.common;
1937 let secret = prompt_hidden("Wallet secret material: ")?;
1938 cap_sensitive("wallet secret", &secret, MAX_FIELD_BYTES)?;
1939 Record::new(
1940 RecordData::Wallet {
1941 asset: args.asset,
1942 address: args.address,
1943 network: args.network,
1944 secret_key: secret,
1945 },
1946 title,
1947 tags,
1948 notes,
1949 )
1950 }
1951 AddRecord::Totp(args) => {
1952 let AddTotp {
1953 common,
1954 issuer,
1955 account,
1956 secret_file,
1957 secret_stdin,
1958 otpauth_stdin,
1959 qr,
1960 confirm_qr,
1961 digits,
1962 step,
1963 skew,
1964 algorithm,
1965 } = args;
1966 const TOTP_MIN_DIGITS: u8 = 6;
1967 const TOTP_MAX_DIGITS: u8 = 8;
1968 if !(TOTP_MIN_DIGITS..=TOTP_MAX_DIGITS).contains(&digits) {
1969 bail!("digits must be between 6 and 8");
1970 }
1971 if step == 0 {
1972 bail!("step must be greater than zero");
1973 }
1974 let CommonRecordArgs { title, tags, notes } = common;
1975 let (secret_bytes, issuer, account, digits, step, algorithm) = if otpauth_stdin {
1976 use std::io::Read;
1977 let mut s = String::new();
1978 std::io::stdin().read_to_string(&mut s)?;
1979 let parsed = parse_otpauth_uri(&s)?;
1980 (
1981 parsed.secret,
1982 parsed.issuer.or(issuer),
1983 parsed.account.or(account),
1984 parsed.digits,
1985 parsed.step,
1986 parsed.algorithm,
1987 )
1988 } else {
1989 let sb = read_totp_secret_arg(secret_file.as_ref(), secret_stdin)?;
1990 (sb, issuer, account, digits, step, algorithm)
1991 };
1992 let totp_secret = Sensitive { data: secret_bytes };
1993 build_totp_instance(
1994 &totp_secret,
1995 digits,
1996 step,
1997 skew,
1998 algorithm,
1999 &issuer,
2000 &account,
2001 )?;
2002 let record = Record::new(
2003 RecordData::Totp {
2004 issuer: issuer.clone(),
2005 account: account.clone(),
2006 secret: totp_secret,
2007 digits,
2008 step,
2009 skew,
2010 algorithm,
2011 },
2012 title,
2013 tags,
2014 notes,
2015 );
2016 if qr {
2017 if !confirm_qr {
2018 bail!("printing QR requires --confirm-qr acknowledgment");
2019 }
2020 if let (Some(uri_issuer), Some(uri_account)) = (issuer, account) {
2021 let base32 = base32::encode(
2022 base32::Alphabet::Rfc4648 { padding: false },
2023 record_secret_slice(&record),
2024 );
2025 let uri = build_otpauth_uri(
2026 &uri_issuer,
2027 &uri_account,
2028 &base32,
2029 digits,
2030 step,
2031 algorithm,
2032 );
2033 output::print_qr_to_tty(&uri, config)?;
2034 }
2035 }
2036 record
2037 }
2038 AddRecord::Ssh(args) => {
2039 let CommonRecordArgs { title, tags, notes } = args.common;
2040 let private_key = prompt_multiline_paste(
2041 "Paste private key (Ctrl-D to finish): (input will be visible)",
2042 )?;
2043 cap_sensitive("ssh private key", &private_key, MAX_NOTE_BYTES)?;
2044 Record::new(
2045 RecordData::Ssh {
2046 label: args.label,
2047 private_key,
2048 comment: args.comment,
2049 },
2050 title,
2051 tags,
2052 notes,
2053 )
2054 }
2055 AddRecord::Pgp(args) => {
2056 let CommonRecordArgs { title, tags, notes } = args.common;
2057 let armored = prompt_multiline_paste(
2058 "Paste armored private key (Ctrl-D to finish): (input will be visible)",
2059 )?;
2060 cap_sensitive("pgp private key", &armored, MAX_NOTE_BYTES)?;
2061 Record::new(
2062 RecordData::Pgp {
2063 label: args.label,
2064 fingerprint: args.fingerprint,
2065 armored_private_key: armored,
2066 },
2067 title,
2068 tags,
2069 notes,
2070 )
2071 }
2072 AddRecord::Recovery(args) => {
2073 let CommonRecordArgs { title, tags, notes } = args.common;
2074 let payload = prompt_multiline_paste(
2075 "Paste recovery payload (Ctrl-D to finish): (input will be visible)",
2076 )?;
2077 cap_sensitive("recovery payload", &payload, MAX_NOTE_BYTES)?;
2078 Record::new(
2079 RecordData::Recovery {
2080 description: args.description,
2081 payload,
2082 },
2083 title,
2084 tags,
2085 notes,
2086 )
2087 }
2088 };
2089
2090 vault.add_record(record);
2091 vault.save(&pass, config)?;
2092 println!("Record added");
2093 Ok(())
2094}
2095fn list_records(cmd: ListCommand, config: &AppConfig) -> Result<()> {
2096 let (vault, _) = load_vault_with_prompt(config)?;
2097 let list = vault.list(
2098 cmd.kind,
2099 cmd.tag.as_deref(),
2100 cmd.query.as_deref(),
2101 cmd.fuzzy,
2102 );
2103 if list.is_empty() {
2104 println!("No matching records");
2105 return Ok(());
2106 }
2107 for record in list {
2108 println!(
2109 "{} | {} | {} | tags=[{}] | {}",
2110 record.id,
2111 record.kind(),
2112 record.title.as_deref().unwrap_or("(untitled)"),
2113 if record.tags.is_empty() {
2114 String::new()
2115 } else {
2116 record.tags.join(",")
2117 },
2118 record.data.summary_text()
2119 );
2120 }
2121 Ok(())
2122}
2123
2124fn get_record(cmd: GetCommand, config: &AppConfig) -> Result<()> {
2125 let (mut vault, _) = load_vault_with_prompt(config)?;
2126 match vault.get(cmd.id) {
2127 Some(record) => {
2128 println!("id: {}", record.id);
2129 println!("kind: {}", record.kind());
2130 if let Some(title) = &record.title {
2131 println!("title: {}", title);
2132 }
2133 if !record.tags.is_empty() {
2134 println!("tags: {}", record.tags.join(","));
2135 }
2136 if let Some(notes) = &record.metadata_notes {
2137 println!("notes: {}", notes);
2138 }
2139 if cmd.clipboard {
2140 #[cfg(feature = "clipboard")]
2141 {
2142 if !config.unsafe_clipboard {
2143 bail!("--clipboard requires --unsafe-clipboard");
2144 }
2145 copy_primary_secret_to_clipboard(record, config)?;
2146 eprintln!("Copied to clipboard; it will be cleared shortly.");
2147 }
2148 #[cfg(not(feature = "clipboard"))]
2149 {
2150 bail!("clipboard support disabled at build time");
2151 }
2152 } else if cmd.reveal {
2153 ensure_interactive_or_override(config, "--reveal")?;
2154 render_sensitive(record, config)?;
2155 } else {
2156 println!("(Sensitive fields hidden; re-run with --reveal on a TTY)");
2157 }
2158 Ok(())
2159 }
2160 None => bail!("operation failed"),
2161 }
2162}
2163
2164#[cfg(feature = "clipboard")]
2165fn copy_primary_secret_to_clipboard(record: &Record, _config: &AppConfig) -> Result<()> {
2166 use arboard::Clipboard;
2167 let text = match &record.data {
2168 RecordData::Login { password, .. } => password.expose_utf8()?,
2169 RecordData::Bank { account_number, .. } => account_number.expose_utf8()?,
2170 RecordData::Wifi { passphrase, .. } => passphrase.expose_utf8()?,
2171 RecordData::Api { secret_key, .. } => secret_key.expose_utf8()?,
2172 RecordData::Wallet { secret_key, .. } => secret_key.expose_utf8()?,
2173 RecordData::Totp { secret, .. } => base32::encode(
2174 base32::Alphabet::Rfc4648 { padding: false },
2175 secret.as_slice(),
2176 ),
2177 RecordData::Ssh { private_key, .. } => private_key.expose_utf8()?,
2178 RecordData::Pgp {
2179 armored_private_key,
2180 ..
2181 } => armored_private_key.expose_utf8()?,
2182 RecordData::Recovery { payload, .. } => payload.expose_utf8()?,
2183 RecordData::Contact { .. } | RecordData::Id { .. } | RecordData::Note { .. } => {
2184 bail!("no primary secret available for this record kind");
2185 }
2186 };
2187 let mut cb = Clipboard::new().map_err(|e| anyhow!("clipboard unavailable: {e}"))?;
2188 cb.set_text(text.clone())
2189 .map_err(|e| anyhow!("failed to set clipboard: {e}"))?;
2190 std::thread::spawn(move || {
2191 std::thread::sleep(std::time::Duration::from_secs(20));
2192 if let Ok(mut c) = Clipboard::new() {
2193 let _ = c.set_text(String::new());
2194 }
2195 });
2196 Ok(())
2197}
2198
2199fn parse_totp_secret(input: &str) -> Result<Vec<u8>> {
2200 let cleaned: String = input
2201 .chars()
2202 .filter(|c| !c.is_whitespace() && *c != '-')
2203 .collect();
2204 if cleaned.is_empty() {
2205 bail!("secret cannot be empty");
2206 }
2207 let encoded = cleaned.to_uppercase();
2208 Ok(TotpSecret::Encoded(encoded)
2209 .to_bytes()
2210 .map_err(|_| anyhow!("invalid base32-encoded secret"))?)
2211}
2212
2213struct ParsedOtpAuth {
2214 secret: Vec<u8>,
2215 digits: u8,
2216 step: u64,
2217 algorithm: TotpAlgorithm,
2218 issuer: Option<String>,
2219 account: Option<String>,
2220}
2221
2222fn parse_otpauth_uri(uri: &str) -> Result<ParsedOtpAuth> {
2223 let url = url::Url::parse(uri).map_err(|_| anyhow!("invalid otpauth uri"))?;
2224 if url.scheme() != "otpauth" {
2225 bail!("not an otpauth uri");
2226 }
2227 if url.host_str() != Some("totp") {
2228 bail!("only totp is supported");
2229 }
2230 let label = url.path().trim_start_matches('/');
2231 let account = if label.is_empty() {
2232 None
2233 } else {
2234 Some(label.to_string())
2235 };
2236 let mut secret_opt = None;
2237 let mut issuer = None;
2238 let mut digits = 6u8;
2239 let mut step = 30u64;
2240 let mut algorithm = TotpAlgorithm::Sha1;
2241 for (k, v) in url.query_pairs() {
2242 match k.as_ref() {
2243 "secret" => {
2244 secret_opt = Some(parse_totp_secret(v.as_ref())?);
2245 }
2246 "issuer" => issuer = Some(v.to_string()),
2247 "digits" => {
2248 digits = v.parse::<u8>().unwrap_or(6);
2249 }
2250 "period" => {
2251 step = v.parse::<u64>().unwrap_or(30);
2252 }
2253 "algorithm" => {
2254 algorithm = match v.as_ref().to_ascii_uppercase().as_str() {
2255 "SHA256" => TotpAlgorithm::Sha256,
2256 "SHA512" => TotpAlgorithm::Sha512,
2257 _ => TotpAlgorithm::Sha1,
2258 };
2259 }
2260 _ => {}
2261 }
2262 }
2263 let secret = secret_opt.ok_or_else(|| anyhow!("otpauth missing secret"))?;
2264 Ok(ParsedOtpAuth {
2265 secret,
2266 digits,
2267 step,
2268 algorithm,
2269 issuer,
2270 account,
2271 })
2272}
2273
2274fn build_otpauth_uri(
2275 issuer: &str,
2276 account: &str,
2277 secret_base32: &str,
2278 digits: u8,
2279 step: u64,
2280 algorithm: TotpAlgorithm,
2281) -> String {
2282 let algo = match algorithm {
2283 TotpAlgorithm::Sha1 => "SHA1",
2284 TotpAlgorithm::Sha256 => "SHA256",
2285 TotpAlgorithm::Sha512 => "SHA512",
2286 };
2287 format!(
2288 "otpauth://totp/{}?secret={}&issuer={}&digits={}&period={}&algorithm={}",
2289 account,
2290 secret_base32,
2291 urlencoding::encode(issuer),
2292 digits,
2293 step,
2294 algo
2295 )
2296}
2297
2298fn record_secret_slice(record: &Record) -> &[u8] {
2299 match &record.data {
2300 RecordData::Totp { secret, .. } => secret.as_slice(),
2301 _ => &[],
2302 }
2303}
2304
2305fn read_totp_secret_arg(
2306 secret_file: Option<&std::path::PathBuf>,
2307 secret_stdin: bool,
2308) -> Result<Vec<u8>> {
2309 if let Some(path) = secret_file {
2310 let s = std::fs::read_to_string(path)?;
2311 return parse_totp_secret(s.trim());
2312 }
2313 if secret_stdin {
2314 let mut s = String::new();
2315 io::stdin().read_to_string(&mut s)?;
2316 return parse_totp_secret(s.trim());
2317 }
2318 let input = prompt_hidden("Base32 secret: ")?;
2319 let value = Zeroizing::new(input.expose_utf8()?);
2320 parse_totp_secret(value.as_str())
2321}
2322
2323fn build_totp_instance(
2324 secret: &Sensitive,
2325 digits: u8,
2326 step: u64,
2327 skew: u8,
2328 algorithm: TotpAlgorithm,
2329 _issuer: &Option<String>,
2330 _account: &Option<String>,
2331) -> Result<TOTP> {
2332 let secret_bytes = Zeroizing::new(secret.as_slice().to_vec());
2333 let totp_secret = (*secret_bytes).clone();
2334 Ok(TOTP::new(
2335 algorithm.to_lib(),
2336 usize::from(digits),
2337 skew,
2338 step,
2339 totp_secret,
2340 )
2341 .map_err(|err| anyhow!("failed to construct TOTP: {err}"))?)
2342}
2343
2344fn rotate_vault(cmd: RotateCommand, config: &AppConfig) -> Result<()> {
2345 let (mut vault, pass) = load_vault_with_prompt(config)?;
2346 vault.rotate(&pass, cmd.mem_kib, config)?;
2347 vault.save(&pass, config)?;
2348 println!("Rotation complete");
2349 Ok(())
2350}
2351
2352fn passwd(cmd: PasswordCommand, config: &AppConfig) -> Result<()> {
2353 let (mut vault, old_pass) = load_vault_with_prompt(config)?;
2354
2355 if let Some(mem) = cmd.mem_kib {
2356 if mem < 32_768 {
2357 bail!("mem-kib must be at least 32768 (32 MiB)");
2358 }
2359 vault.file.header.argon.mem_cost_kib = mem;
2360 OsRng.fill_bytes(&mut vault.file.header.argon.salt);
2361 }
2362 if let Some(lanes) = cmd.argon_lanes.as_deref() {
2363 let lanes = if lanes.eq_ignore_ascii_case("auto") {
2364 (num_cpus::get().min(8) as u32).max(DEFAULT_LANES)
2365 } else {
2366 lanes.parse::<u32>().unwrap_or(DEFAULT_LANES).max(DEFAULT_LANES)
2367 };
2368 vault.file.header.argon.lanes = lanes;
2369 }
2370
2371 let new1 = prompt_passphrase("New passphrase: ", config)?;
2372 ensure_passphrase_strength(new1.as_str())?;
2373 let new2 = prompt_passphrase("Confirm new passphrase: ", config)?;
2374 if new1.as_str() != new2.as_str() {
2375 bail!("passphrases do not match");
2376 }
2377
2378 let old_kek = derive_kek(&old_pass, &vault.file.header.argon)?;
2380 let new_kek = derive_kek(&new1, &vault.file.header.argon)?;
2381
2382 let dk_bytes = Zeroizing::new(decrypt_blob(
2384 old_kek.as_slice(),
2385 &vault.file.header.sealed_decapsulation,
2386 AAD_DK,
2387 )?);
2388 let sealed_decapsulation = encrypt_blob(new_kek.as_slice(), dk_bytes.as_slice(), AAD_DK)?;
2389 vault.file.header.sealed_decapsulation = sealed_decapsulation;
2390
2391 if cmd.rekey_dek {
2393 let mut new_dek = Zeroizing::new([0u8; 32]);
2394 OsRng.fill_bytes(&mut *new_dek);
2395 vault.dek = new_dek;
2396 #[cfg(feature = "mlock")]
2397 let _ = crate::memory::try_lock_slice(vault.dek.as_ref(), config.require_mlock)?;
2398 }
2399
2400 vault.file.header.header_mac = Some(compute_header_mac(&vault.file.header, new_kek.as_slice()));
2402 vault.save(&new1, config)?;
2403 let path = vault_path_with_cfg(config)?;
2404 maybe_agent_store(config, &path, new1.as_str());
2405 println!("Master passphrase updated");
2406 Ok(())
2407}
2408
2409#[cfg(feature = "agent-keychain")]
2410fn agent_label_for_path(path: &Path) -> String {
2411 use blake3::Hasher;
2412 let s = path.to_string_lossy();
2413 let mut h = Hasher::new();
2414 h.update(s.as_bytes());
2415 hex::encode(h.finalize().as_bytes())
2416}
2417
2418#[cfg(feature = "agent-keychain")]
2419fn maybe_agent_retrieve(config: &AppConfig, path: &Path) -> Option<Zeroizing<String>> {
2420 use crate::agent::Agent;
2421 if matches!(config.agent, config::AgentMode::Keychain) {
2422 let label = agent_label_for_path(path);
2423 let agent = crate::agent::KeychainAgent;
2424 match agent.retrieve_passphrase(&label) {
2425 Ok(Some(s)) => return Some(Zeroizing::new(s)),
2426 _ => {}
2427 }
2428 }
2429 None
2430}
2431
2432#[cfg(not(feature = "agent-keychain"))]
2433fn maybe_agent_retrieve(config: &AppConfig, _path: &Path) -> Option<Zeroizing<String>> {
2434 let _ = config.agent; None
2436}
2437
2438#[cfg(feature = "agent-keychain")]
2439fn maybe_agent_store(config: &AppConfig, path: &Path, pass: &str) {
2440 use crate::agent::Agent;
2441 if matches!(config.agent, config::AgentMode::Keychain) {
2442 let _ = crate::agent::KeychainAgent.store_passphrase(&agent_label_for_path(path), pass);
2443 }
2444}
2445
2446#[cfg(not(feature = "agent-keychain"))]
2447fn maybe_agent_store(config: &AppConfig, _path: &Path, _pass: &str) {
2448 let _ = config.agent; }
2450
2451#[cfg(feature = "agent-keychain")]
2452fn maybe_agent_get_epoch(config: &AppConfig, path: &Path) -> Option<u64> {
2453 use crate::agent::Agent;
2454 if matches!(config.agent, config::AgentMode::Keychain) {
2455 let agent = crate::agent::KeychainAgent;
2456 let label = format!("epoch:{}", agent_label_for_path(path));
2457 if let Ok(Some(s)) = agent.retrieve_passphrase(&label) {
2458 if let Ok(v) = s.parse::<u64>() { return Some(v); }
2459 }
2460 }
2461 None
2462}
2463
2464#[cfg(feature = "agent-keychain")]
2465fn maybe_agent_store_epoch(config: &AppConfig, path: &Path, epoch: u64) {
2466 use crate::agent::Agent;
2467 if matches!(config.agent, config::AgentMode::Keychain) {
2468 let agent = crate::agent::KeychainAgent;
2469 let label = format!("epoch:{}", agent_label_for_path(path));
2470 let _ = agent.store_passphrase(&label, &epoch.to_string());
2471 }
2472}
2473
2474fn doctor(cmd: DoctorCommand, config: &AppConfig) -> Result<()> {
2475 let (vault, _) = load_vault_with_prompt(config)?;
2476 let stats = vault.stats();
2477 let header_mac_verified = vault.file.header.header_mac.is_some();
2479 if cmd.json {
2480 let payload = json!({
2481 "ready": true,
2482 "recordCount": stats.record_count,
2483 "argonMemKib": stats.argon_mem_kib,
2484 "argonTimeCost": stats.argon_time_cost,
2485 "argonLanes": stats.argon_lanes,
2486 "createdAt": stats.created_at.to_rfc3339(),
2487 "updatedAt": stats.updated_at.to_rfc3339(),
2488 "headerMacVerified": header_mac_verified,
2489 });
2490 println!("{}", payload);
2491 } else {
2492 println!("status: ready");
2493 println!("records: {}", stats.record_count);
2494 println!("created: {}", stats.created_at);
2495 println!("updated: {}", stats.updated_at);
2496 println!(
2497 "argon2: mem={} KiB, time={}, lanes={}",
2498 stats.argon_mem_kib, stats.argon_time_cost, stats.argon_lanes
2499 );
2500 println!(
2501 "header-mac: {}",
2502 if header_mac_verified { "verified" } else { "absent (legacy)" }
2503 );
2504 }
2505 Ok(())
2506}
2507
2508fn export(cmd: ExportCommand, config: &AppConfig) -> Result<()> {
2509 match cmd {
2510 ExportCommand::Csv(args) => export_csv(args, config),
2511 }
2512}
2513
2514fn export_csv(args: ExportCsvCommand, config: &AppConfig) -> Result<()> {
2515 if !config.unsafe_stdout {
2516 bail!("export requires --unsafe-stdout");
2517 }
2518 let (vault, _) = load_vault_with_prompt(config)?;
2519 let mut wtr = csv::Writer::from_writer(std::io::stdout());
2520 let fields = if args.fields.is_empty() {
2521 vec![
2522 "id".to_string(),
2523 "kind".to_string(),
2524 "title".to_string(),
2525 "summary".to_string(),
2526 ]
2527 } else {
2528 args.fields.clone()
2529 };
2530 let sensitive: std::collections::HashSet<&str> = [
2531 "password",
2532 "secret_key",
2533 "totp_secret",
2534 "private_key",
2535 "pgp_private_key",
2536 "recovery_payload",
2537 "account_number",
2538 "passphrase",
2539 ]
2540 .into_iter()
2541 .collect();
2542 if !args.include_secrets && fields.iter().any(|f| sensitive.contains(f.as_str())) {
2543 bail!("exporting secret fields requires --include-secrets");
2544 }
2545 wtr.write_record(&fields)?;
2546 let items = vault.list(args.kind, None, None, false);
2547 for r in items {
2548 let mut row = Vec::with_capacity(fields.len());
2549 for f in &fields {
2550 let val = match f.as_str() {
2551 "id" => r.id.to_string(),
2552 "kind" => format!("{}", r.kind()),
2553 "title" => r.title.clone().unwrap_or_default(),
2554 "tags" => {
2555 if r.tags.is_empty() {
2556 String::new()
2557 } else {
2558 r.tags.join(",")
2559 }
2560 }
2561 "summary" => r.data.summary_text(),
2562 "username" => match &r.data {
2564 RecordData::Login { username, .. } => username.clone().unwrap_or_default(),
2565 _ => String::new(),
2566 },
2567 "url" => match &r.data {
2568 RecordData::Login { url, .. } => url.clone().unwrap_or_default(),
2569 _ => String::new(),
2570 },
2571 "password" => match &r.data {
2572 RecordData::Login { password, .. } => {
2573 password.expose_utf8().unwrap_or_default()
2574 }
2575 _ => String::new(),
2576 },
2577 "secret_key" => match &r.data {
2578 RecordData::Api { secret_key, .. } => {
2579 secret_key.expose_utf8().unwrap_or_default()
2580 }
2581 RecordData::Wallet { secret_key, .. } => {
2582 secret_key.expose_utf8().unwrap_or_default()
2583 }
2584 _ => String::new(),
2585 },
2586 "totp_secret" => match &r.data {
2587 RecordData::Totp { secret, .. } => base32::encode(
2588 base32::Alphabet::Rfc4648 { padding: false },
2589 secret.as_slice(),
2590 ),
2591 _ => String::new(),
2592 },
2593 _ => String::new(),
2594 };
2595 row.push(val);
2596 }
2597 wtr.write_record(&row)?;
2598 }
2599 wtr.flush()?;
2600 Ok(())
2601}
2602
2603fn recovery(cmd: RecoveryCommand, _config: &AppConfig) -> Result<()> {
2604 match cmd {
2605 RecoveryCommand::Split(args) => {
2606 if args.threshold == 0 || args.threshold > args.shares {
2607 bail!("threshold must be between 1 and number of shares");
2608 }
2609 let secret = prompt_hidden("Secret to split: ")?;
2610 let split = if args.duress {
2611 shamir::split_secret_with_purpose(
2612 secret.as_slice(),
2613 args.threshold,
2614 args.shares,
2615 shamir::SharePurpose::Duress,
2616 )?
2617 } else {
2618 split_secret(secret.as_slice(), args.threshold, args.shares)?
2619 };
2620 for share in &split.shares {
2621 let (id, data) = share
2622 .split_first()
2623 .ok_or_else(|| anyhow!("invalid share structure"))?;
2624 let encoded = BASE64.encode(data);
2625 println!("{}-{}", id, encoded);
2626 }
2627 println!("# share-set-id {}", split.set_id_base32);
2628 println!("# record this share-set ID separately; combine will verify it");
2629 Ok(())
2630 }
2631 RecoveryCommand::Combine(args) => {
2632 std::thread::sleep(std::time::Duration::from_millis(200));
2634 if args.threshold == 0 {
2635 bail!("threshold must be at least 1");
2636 }
2637 let mut shares = Vec::new();
2638 for part in args
2639 .shares
2640 .split(',')
2641 .map(str::trim)
2642 .filter(|s| !s.is_empty())
2643 {
2644 let (id, data) = part
2645 .split_once('-')
2646 .ok_or_else(|| anyhow!("invalid share format: {part}"))?;
2647 let identifier: u8 = id.parse().context("invalid share identifier")?;
2648 if identifier == 0 {
2649 bail!("share identifier must be between 1 and 255");
2650 }
2651 let mut decoded = BASE64.decode(data).context("invalid base64 in share")?;
2652 if decoded.is_empty() {
2653 bail!("share payload cannot be empty");
2654 }
2655 let mut share = Vec::with_capacity(decoded.len() + 1);
2656 share.push(identifier);
2657 share.append(&mut decoded);
2658 shares.push(share);
2659 }
2660 if shares.len() < args.threshold as usize {
2661 bail!(
2662 "insufficient shares provided (need at least {})",
2663 args.threshold
2664 );
2665 }
2666 let secret = if args.duress {
2667 shamir::combine_secret_with_purpose(
2668 args.threshold,
2669 &shares,
2670 args.set_id.as_deref(),
2671 shamir::SharePurpose::Duress,
2672 )?
2673 } else {
2674 combine_secret(args.threshold, &shares, args.set_id.as_deref())?
2675 };
2676 if args.raw {
2677 ensure_interactive_or_override(_config, "recovery combine --raw")?;
2678 write_bytes_tty(&secret, _config)?;
2679 } else {
2680 let b64 = BASE64.encode(&secret);
2681 write_line_tty(&b64, _config)?;
2682 }
2683 Ok(())
2684 }
2685 }
2686}
2687
2688fn totp(cmd: TotpCommand, config: &AppConfig) -> Result<()> {
2689 match cmd {
2690 TotpCommand::Code(args) => totp_code(args, config),
2691 }
2692}
2693
2694fn backup(cmd: BackupCommand, _config: &AppConfig) -> Result<()> {
2695 match cmd {
2696 BackupCommand::Verify { path } => backup_verify(&path),
2697 }
2698}
2699
2700fn backup_verify(path: &Path) -> Result<()> {
2701 let bytes = std::fs::read(path)?;
2702 let file: VaultFile = match from_reader(bytes.as_slice()) {
2703 Ok(v) => v,
2704 Err(_) => bail!("failed to parse vault"),
2705 };
2706 let expected = read_integrity_sidecar(path)?;
2707 let actual = compute_public_integrity_tag(&bytes, &file.header.kem_public);
2708 if expected == actual {
2709 eprintln!("OK: integrity tag matches");
2710 Ok(())
2711 } else {
2712 bail!("integrity tag mismatch")
2713 }
2714}
2715
2716fn totp_code(args: TotpCodeCommand, config: &AppConfig) -> Result<()> {
2717 ensure_interactive_or_override(config, "totp code")?;
2718 let (vault, _) = load_vault_with_prompt(config)?;
2719 let record = vault
2720 .get_ref(args.id)
2721 .ok_or_else(|| anyhow!("operation failed"))?;
2722 let (issuer, account, secret, digits, step, skew, algorithm) = match &record.data {
2723 RecordData::Totp {
2724 issuer,
2725 account,
2726 secret,
2727 digits,
2728 step,
2729 skew,
2730 algorithm,
2731 } => (issuer, account, secret, *digits, *step, *skew, *algorithm),
2732 _ => bail!("operation failed"),
2733 };
2734
2735 let totp = build_totp_instance(secret, digits, step, skew, algorithm, issuer, account)?;
2736 let code = if let Some(ts) = args.time {
2737 if ts < 0 {
2738 bail!("time must be non-negative");
2739 }
2740 totp.generate(ts as u64)
2741 } else {
2742 totp.generate_current()?
2743 };
2744 let ttl = if args.time.is_none() {
2745 Some(totp.ttl()?)
2746 } else {
2747 None
2748 };
2749 emit_totp_code(&code, ttl, config)
2750}
2751
2752fn self_test() -> Result<()> {
2753 let mut sample = [0u8; 32];
2754 OsRng.fill_bytes(&mut sample);
2755 let secret = Sensitive::new_from_utf8(&sample);
2756 let record = Record::new(
2757 RecordData::Note { body: secret },
2758 Some("Self-test".into()),
2759 vec!["selftest".into()],
2760 None,
2761 );
2762 let payload = VaultPayload {
2763 records: vec![record],
2764 record_counter: 1,
2765 };
2766
2767 let mut dek = [0u8; 32];
2768 OsRng.fill_bytes(&mut dek);
2769 let blob = encrypt_payload(&dek, &payload)?;
2770 let recovered = decrypt_payload(&dek, &blob)?;
2771 if recovered.records.len() != 1 {
2772 bail!("self-test failed");
2773 }
2774 println!("Self-test passed");
2775 Ok(())
2776}
2777
2778fn prompt_hidden(prompt: &str) -> Result<Sensitive> {
2779 let value = Zeroizing::new(prompt_password(prompt)?);
2780 Ok(Sensitive::from_string(value.as_str()))
2781}
2782
2783fn prompt_optional_hidden(prompt: &str) -> Result<Option<Sensitive>> {
2784 let value = Zeroizing::new(prompt_password(prompt)?);
2785 if value.trim().is_empty() {
2786 Ok(None)
2787 } else {
2788 Ok(Some(Sensitive::from_string(value.as_str())))
2789 }
2790}
2791
2792fn prompt_multiline(prompt: &str) -> Result<Sensitive> {
2793 eprintln!("{}", prompt);
2794 read_multiline(false)
2795}
2796
2797fn prompt_multiline_paste(prompt: &str) -> Result<Sensitive> {
2798 eprintln!("{}", prompt);
2799 read_multiline(true)
2800}
2801
2802fn read_multiline(_hidden: bool) -> Result<Sensitive> {
2803 let mut buffer = Zeroizing::new(Vec::new());
2804 io::stdin().read_to_end(&mut buffer)?;
2805 while buffer.last().copied() == Some(b'\n') {
2806 buffer.pop();
2807 }
2808 Ok(Sensitive::new_from_utf8(&buffer))
2809}
2810
2811fn lock_handle(path: &Path) -> Result<RwLock<File>> {
2812 let lock_path = path.with_extension("lock");
2813 let file = OpenOptions::new()
2814 .create(true)
2815 .read(true)
2816 .write(true)
2817 .open(&lock_path)
2818 .context("failed to open lock file")?;
2819 Ok(RwLock::new(file))
2820}
2821
2822fn read_vault_bytes(path: &Path) -> Result<Vec<u8>> {
2823 let lock = lock_handle(path)?;
2824 let _guard = lock.read()?;
2825 let meta = fs::metadata(path).context("failed to stat vault")?;
2826 if meta.len() > MAX_VAULT_FILE_BYTES {
2827 bail!("vault too large");
2828 }
2829 #[cfg(all(unix, target_os = "linux"))]
2830 use std::os::unix::fs::OpenOptionsExt;
2831 #[cfg(all(unix, target_os = "linux"))]
2832 let mut file = match OpenOptions::new()
2833 .read(true)
2834 .custom_flags(libc::O_NOATIME)
2835 .open(path)
2836 {
2837 Ok(f) => f,
2838 Err(_) => File::open(path).context("failed to open vault")?,
2839 };
2840 #[cfg(not(all(unix, target_os = "linux")))]
2841 let mut file = File::open(path).context("failed to open vault")?;
2842 let mut buf = Vec::new();
2843 file.read_to_end(&mut buf)?;
2844 Ok(buf)
2845}
2846
2847#[cfg(feature = "fuzzing")]
2848pub fn fuzz_try_payload(bytes: &[u8]) {
2849 let _ = ciborium::de::from_reader::<VaultPayload, _>(bytes);
2850}
2851
2852#[cfg(feature = "fuzzing")]
2853pub fn fuzz_try_header(bytes: &[u8]) {
2854 let _ = ciborium::de::from_reader::<VaultFile, _>(bytes);
2855}
2856
2857fn write_vault(path: &Path, file: &VaultFile) -> Result<()> {
2858 let parent = path.parent().ok_or_else(|| anyhow!("invalid vault path"))?;
2859 fs::create_dir_all(parent)?;
2860 #[cfg(unix)]
2861 {
2862 use std::os::unix::fs::MetadataExt;
2863 let meta = fs::metadata(parent)?;
2864 let mode = meta.mode() & 0o777;
2865 if (mode & 0o022) != 0 {
2866 bail!("insecure vault directory permissions");
2867 }
2868 if meta.uid() != unsafe { libc::getuid() } {
2869 bail!("vault directory not owned by current user");
2870 }
2871 }
2872 #[cfg(windows)]
2873 {
2874 ensure_secure_dir_permissions(parent)?;
2875 }
2876 let mut lock = lock_handle(path)?;
2877 let _guard = lock.write()?;
2878
2879 let mut buf = Vec::new();
2881 into_writer(file, &mut buf).context("failed to serialize vault")?;
2882
2883 let mut tmp = NamedTempFile::new_in(parent)?;
2885 use std::io::Write as _;
2886 tmp.as_file_mut().write_all(&buf)?;
2887 tmp.as_file_mut().sync_all()?;
2888 #[cfg(unix)]
2889 {
2890 use std::os::unix::fs::PermissionsExt;
2891 tmp.as_file_mut()
2892 .set_permissions(fs::Permissions::from_mode(0o600))?;
2893 }
2894 tmp.persist(path)?;
2895 sync_dir(parent)?;
2896
2897 let tag = compute_public_integrity_tag(&buf, &file.header.kem_public);
2899 let _ = write_integrity_sidecar(path, tag);
2900 let _ = write_epoch_sidecar(path, file.header.epoch);
2901 Ok(())
2902}
2903
2904#[cfg(windows)]
2905fn ensure_secure_dir_permissions(dir: &Path) -> Result<()> {
2906 use std::ffi::OsStr;
2907 use std::os::windows::ffi::OsStrExt;
2908 use windows_sys::Win32::Foundation::{CloseHandle, BOOL, HANDLE, PSID};
2909 use windows_sys::Win32::Security::Authorization::{
2910 GetAce, GetAclInformation, ACL, ACL_INFORMATION_CLASS, ACL_REVISION_INFORMATION,
2911 ACL_REVISION_INFORMATION as ACLRI, AceRevisionInformation, ACCESS_ALLOWED_ACE,
2912 ACCESS_ALLOWED_ACE_TYPE,
2913 };
2914 use windows_sys::Win32::Security::{
2915 CreateWellKnownSid, EqualSid, GetLengthSid, IsValidSid, WinAuthenticatedUserSid,
2916 WinWorldSid, OWNER_SECURITY_INFORMATION, DACL_SECURITY_INFORMATION,
2917 };
2918 use windows_sys::Win32::Security::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
2919
2920 unsafe {
2921 let mut wide: Vec<u16> = OsStr::new(dir.as_os_str())
2922 .encode_wide()
2923 .chain(std::iter::once(0))
2924 .collect();
2925 let mut owner_sid: PSID = std::ptr::null_mut();
2926 let mut dacl_ptr: *mut ACL = std::ptr::null_mut();
2927 let mut sd: *mut core::ffi::c_void = std::ptr::null_mut();
2928 let rc = GetNamedSecurityInfoW(
2929 wide.as_mut_ptr(),
2930 SE_FILE_OBJECT,
2931 OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
2932 &mut owner_sid,
2933 std::ptr::null_mut(),
2934 &mut dacl_ptr,
2935 std::ptr::null_mut(),
2936 &mut sd,
2937 );
2938 if rc != 0 {
2939 bail!("failed to query directory ACL");
2940 }
2941 if !dacl_ptr.is_null() {
2943 let mut world_sid_buf = [0u8; 68];
2945 let mut world_len = world_sid_buf.len() as u32;
2946 let ok1 = CreateWellKnownSid(
2947 WinWorldSid as i32,
2948 std::ptr::null_mut(),
2949 world_sid_buf.as_mut_ptr() as PSID,
2950 &mut world_len,
2951 );
2952 let mut auth_sid_buf = [0u8; 68];
2953 let mut auth_len = auth_sid_buf.len() as u32;
2954 let ok2 = CreateWellKnownSid(
2955 WinAuthenticatedUserSid as i32,
2956 std::ptr::null_mut(),
2957 auth_sid_buf.as_mut_ptr() as PSID,
2958 &mut auth_len,
2959 );
2960 if ok1 == 0 || ok2 == 0 {
2961 bail!("failed to construct well-known SIDs");
2962 }
2963 let mut index: u32 = 0;
2966 loop {
2967 let mut ace_ptr: *mut core::ffi::c_void = std::ptr::null_mut();
2968 let ok: BOOL = GetAce(dacl_ptr, index as u32, &mut ace_ptr);
2969 if ok == 0 {
2970 break;
2971 }
2972 let header = ace_ptr as *const u8;
2973 let ace_type = *header;
2975 if ace_type == ACCESS_ALLOWED_ACE_TYPE as u8 {
2976 let ace = ace_ptr as *const ACCESS_ALLOWED_ACE;
2977 let sid_ptr = (&(*ace)).SidStart.as_ptr() as PSID;
2979 if EqualSid(sid_ptr, world_sid_buf.as_mut_ptr() as PSID) != 0
2980 || EqualSid(sid_ptr, auth_sid_buf.as_mut_ptr() as PSID) != 0
2981 {
2982 bail!("insecure vault directory permissions");
2983 }
2984 }
2985 index += 1;
2986 }
2987 }
2988 if !sd.is_null() {
2990 let _ = windows_sys::Win32::Foundation::LocalFree(sd as isize);
2991 }
2992 }
2993 Ok(())
2994}
2995
2996fn sync_dir(dir: &Path) -> Result<()> {
2997 #[cfg(unix)]
2998 {
2999 use std::os::unix::fs::OpenOptionsExt;
3000 let file = OpenOptions::new()
3001 .read(true)
3002 .custom_flags(libc::O_DIRECTORY)
3003 .open(dir)?;
3004 file.sync_all()?;
3005 }
3006 #[cfg(not(unix))]
3007 {
3008 let _ = dir;
3010 }
3011 Ok(())
3012}
3013
3014fn integrity_sidecar_path(path: &Path) -> PathBuf {
3015 let mut p = path.to_path_buf();
3016 p.set_extension("int");
3017 p
3018}
3019
3020fn compute_public_integrity_tag(vault_bytes: &[u8], kem_public_key: &[u8]) -> [u8; 32] {
3021 use blake3::derive_key;
3022 let key = derive_key("black-bag public integrity", kem_public_key);
3023 let mut hasher = blake3::Hasher::new_keyed(&key);
3024 hasher.update(vault_bytes);
3025 *hasher.finalize().as_bytes()
3026}
3027
3028fn write_integrity_sidecar(path: &Path, tag: [u8; 32]) -> Result<()> {
3029 use std::io::Write;
3030 let sidecar = integrity_sidecar_path(path);
3031 let mut f = std::fs::File::create(&sidecar)?;
3032 writeln!(f, "{}", hex::encode(tag))?;
3033 Ok(())
3034}
3035
3036fn read_integrity_sidecar(path: &Path) -> Result<[u8; 32]> {
3037 use std::io::Read;
3038 let sidecar = integrity_sidecar_path(path);
3039 let mut s = String::new();
3040 std::fs::File::open(&sidecar)?.read_to_string(&mut s)?;
3041 let bytes = hex::decode(s.trim())?;
3042 if bytes.len() != 32 {
3043 bail!("integrity tag wrong length");
3044 }
3045 let mut out = [0u8; 32];
3046 out.copy_from_slice(&bytes);
3047 Ok(out)
3048}
3049
3050fn epoch_sidecar_path(path: &Path) -> PathBuf {
3051 let mut p = path.to_path_buf();
3052 p.set_extension("epoch");
3053 p
3054}
3055
3056fn write_epoch_sidecar(path: &Path, epoch: u64) -> Result<()> {
3057 use std::io::Write;
3058 let sidecar = epoch_sidecar_path(path);
3059 let mut f = std::fs::File::create(&sidecar)?;
3060 writeln!(f, "{}", epoch)?;
3061 Ok(())
3062}
3063
3064fn read_epoch_sidecar(path: &Path) -> Result<u64> {
3065 use std::io::Read;
3066 let sidecar = epoch_sidecar_path(path);
3067 let mut s = String::new();
3068 std::fs::File::open(&sidecar)?.read_to_string(&mut s)?;
3069 s.trim().parse::<u64>().map_err(|_| anyhow!("invalid epoch sidecar").into())
3070}
3071
3072fn unlock_failure<E: Into<anyhow::Error>>(err: E) -> Error {
3073 let any = err.into();
3074 if std::env::var_os("BLACK_BAG_DEBUG").is_some() {
3075 Error::Anyhow(any.context("failed to unlock vault"))
3076 } else {
3077 Error::Anyhow(anyhow!("failed to unlock vault"))
3079 }
3080}
3081
3082fn constant_time_uuid_eq(a: &Uuid, b: &Uuid) -> bool {
3083 if a.as_bytes().len() != b.as_bytes().len() {
3084 return false;
3085 }
3086 let mut diff = 0u8;
3087 for (x, y) in a.as_bytes().iter().zip(b.as_bytes()) {
3088 diff |= x ^ y;
3089 }
3090 diff == 0
3091}
3092
3093impl fmt::Display for RecordKind {
3094 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3095 let label = match self {
3096 RecordKind::Login => "login",
3097 RecordKind::Contact => "contact",
3098 RecordKind::Id => "id",
3099 RecordKind::Note => "note",
3100 RecordKind::Bank => "bank",
3101 RecordKind::Wifi => "wifi",
3102 RecordKind::Api => "api",
3103 RecordKind::Wallet => "wallet",
3104 RecordKind::Totp => "totp",
3105 RecordKind::Ssh => "ssh",
3106 RecordKind::Pgp => "pgp",
3107 RecordKind::Recovery => "recovery",
3108 };
3109 f.write_str(label)
3110 }
3111}
3112
3113#[cfg(test)]
3114mod tests {
3115 use super::*;
3116 use proptest::prelude::*;
3117 use proptest::proptest;
3118 use proptest::strategy::Strategy;
3119 use serial_test::serial;
3120 use std::env;
3121 use std::path::PathBuf;
3122 use tempfile::tempdir;
3123
3124 fn prepare(passphrase: &str) -> Result<(tempfile::TempDir, PathBuf, Zeroizing<String>)> {
3125 let dir = tempdir()?;
3126 let vault_path = dir.path().join("vault.cbor");
3127 env::set_var("BLACK_BAG_VAULT_PATH", &vault_path);
3128 let pass = Zeroizing::new(passphrase.to_string());
3129 Ok((dir, vault_path, pass))
3130 }
3131
3132 #[test]
3133 fn kyber_sizes_match_fips203() -> Result<()> {
3134 const PK: usize = 1568;
3136 const SK: usize = 3168;
3137 const CT: usize = 1568;
3138 const SS: usize = 32;
3139 let (pk, sk) = kyber1024::keypair();
3140 assert_eq!(pk.as_bytes().len(), PK);
3141 assert_eq!(sk.as_bytes().len(), SK);
3142 let (ss, ct) = kyber1024::encapsulate(&pk);
3143 assert_eq!(ct.as_bytes().len(), CT);
3144 assert_eq!(ss.as_bytes().len(), SS);
3145 Ok(())
3146 }
3147
3148 fn cleanup() {
3149 env::remove_var("BLACK_BAG_VAULT_PATH");
3150 }
3151
3152 fn arb_ascii_string(max: usize) -> impl Strategy<Value = String> {
3153 proptest::collection::vec(proptest::char::range('a', 'z'), 0..=max)
3154 .prop_map(|chars| chars.into_iter().collect())
3155 }
3156
3157 fn arb_note_record() -> impl Strategy<Value = Record> {
3158 (
3159 proptest::option::of(arb_ascii_string(12)),
3160 proptest::collection::vec(arb_ascii_string(8), 0..3),
3161 arb_ascii_string(48),
3162 )
3163 .prop_map(|(title, tags, body)| {
3164 Record::new(
3165 RecordData::Note {
3166 body: Sensitive::from_string(&body),
3167 },
3168 title,
3169 tags,
3170 None,
3171 )
3172 })
3173 }
3174
3175 proptest! {
3176 #[test]
3177 fn encrypt_blob_roundtrip_prop(
3178 key_bytes in proptest::array::uniform32(any::<u8>()),
3179 data in proptest::collection::vec(any::<u8>(), 0..256),
3180 aad in proptest::collection::vec(any::<u8>(), 0..32),
3181 ) {
3182 let blob = encrypt_blob(&key_bytes, &data, &aad).unwrap();
3183 let decrypted = decrypt_blob(&key_bytes, &blob, &aad).unwrap();
3184 prop_assert_eq!(decrypted, data);
3185 }
3186
3187 #[test]
3188 fn payload_roundtrip_prop(
3189 key_bytes in proptest::array::uniform32(any::<u8>()),
3190 records in proptest::collection::vec(arb_note_record(), 0..3),
3191 ) {
3192 let payload = VaultPayload {
3193 records: records.clone(),
3194 record_counter: records.len() as u64,
3195 };
3196 let blob = encrypt_payload(&key_bytes, &payload).unwrap();
3197 let decoded = decrypt_payload(&key_bytes, &blob).unwrap();
3198 prop_assert_eq!(decoded, payload);
3199 }
3200 }
3201
3202 #[test]
3203 #[serial]
3204 fn vault_round_trip_note() -> Result<()> {
3205 let (_tmp, vault_path, pass) = prepare("CorrectHorseBatteryStaple!7")?;
3206 let config = AppConfig::default();
3207 Vault::init(&vault_path, &pass, 32_768, &config, None)?;
3208
3209 let mut vault = Vault::load(&vault_path, &pass, &config)?;
3210 let record = Record::new(
3211 RecordData::Note {
3212 body: Sensitive::from_string("mission ops"),
3213 },
3214 Some("Ops Note".into()),
3215 vec!["mission".into()],
3216 None,
3217 );
3218 let record_id = record.id;
3219 vault.add_record(record);
3220 vault.save(&pass, &config)?;
3221
3222 drop(vault);
3223 let vault = Vault::load(&vault_path, &pass, &config)?;
3224 let notes = vault.list(Some(RecordKind::Note), None, None, false);
3225 assert_eq!(notes.len(), 1);
3226 assert_eq!(notes[0].id, record_id);
3227 assert_eq!(notes[0].title.as_deref(), Some("Ops Note"));
3228 assert!(notes[0].matches_tag("mission"));
3229
3230 cleanup();
3231 Ok(())
3232 }
3233
3234 #[test]
3235 #[serial]
3236 fn totp_round_trip() -> Result<()> {
3237 let (_tmp, vault_path, pass) = prepare("TotpPassphrase!7")?;
3238 let config = AppConfig::default();
3239 Vault::init(&vault_path, &pass, 32_768, &config, None)?;
3240
3241 let secret_bytes = parse_totp_secret("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")?;
3242 let mut vault = Vault::load(&vault_path, &pass, &config)?;
3243 let record = Record::new(
3244 RecordData::Totp {
3245 issuer: Some("TestIssuer".into()),
3246 account: Some("test@example".into()),
3247 secret: Sensitive { data: secret_bytes },
3248 digits: 6,
3249 step: 30,
3250 skew: 1,
3251 algorithm: TotpAlgorithm::Sha1,
3252 },
3253 Some("TOTP".into()),
3254 vec![],
3255 None,
3256 );
3257 let record_id = record.id;
3258 vault.add_record(record);
3259 vault.save(&pass, &config)?;
3260
3261 drop(vault);
3262 let vault = Vault::load(&vault_path, &pass, &config)?;
3263 let record = vault
3264 .get_ref(record_id)
3265 .ok_or_else(|| anyhow!("TOTP record missing"))?;
3266 let code = match &record.data {
3267 RecordData::Totp {
3268 issuer,
3269 account,
3270 secret,
3271 digits,
3272 step,
3273 skew,
3274 algorithm,
3275 } => build_totp_instance(secret, *digits, *step, *skew, *algorithm, issuer, account)?
3276 .generate(59),
3277 _ => bail!("expected totp record"),
3278 };
3279 let expected = TOTP::new(
3280 TotpAlgorithmLib::SHA1,
3281 6,
3282 1,
3283 30,
3284 parse_totp_secret("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")?,
3285 )
3286 .unwrap()
3287 .generate(59);
3288 assert_eq!(code, expected);
3289
3290 cleanup();
3291 Ok(())
3292 }
3293
3294 #[test]
3295 #[serial]
3296 fn vault_rotation_changes_wrapped_keys() -> Result<()> {
3297 let (_tmp, vault_path, pass) = prepare("RotateAllTheThings!7")?;
3298 let config = AppConfig::default();
3299 Vault::init(&vault_path, &pass, 32_768, &config, None)?;
3300
3301 let mut vault = Vault::load(&vault_path, &pass, &config)?;
3302 let record = Record::new(
3303 RecordData::Api {
3304 service: Some("intel-api".into()),
3305 environment: Some("prod".into()),
3306 access_key: Some("AKIA-123".into()),
3307 secret_key: Sensitive::from_string("super-secret"),
3308 scopes: vec!["read".into(), "write".into()],
3309 },
3310 Some("API".into()),
3311 vec!["read".into()],
3312 None,
3313 );
3314 vault.add_record(record);
3315 let before = vault.file.header.sealed_dek.ciphertext.clone();
3316 vault.rotate(&pass, Some(65_536), &config)?;
3317 vault.save(&pass, &config)?;
3318 let after = vault.file.header.sealed_dek.ciphertext.clone();
3319 assert_ne!(before, after);
3320
3321 drop(vault);
3322 let vault = Vault::load(&vault_path, &pass, &config)?;
3323 let apis = vault.list(Some(RecordKind::Api), Some("read"), None, false);
3324 assert_eq!(apis.len(), 1);
3325 assert!(apis[0].data.summary_text().contains("intel-api"));
3326
3327 cleanup();
3328 Ok(())
3329 }
3330
3331 #[test]
3332 fn recovery_split_combine_roundtrip() -> Result<()> {
3333 let secret = b"ultra-secret";
3334 let split = split_secret(secret, 3, 5)?;
3335 let recovered = combine_secret(3, &split.shares, Some(&split.set_id_base32))?;
3336 assert_eq!(recovered, secret);
3337 Ok(())
3338 }
3339
3340 #[test]
3341 #[serial]
3342 fn header_mac_tamper_detected() -> Result<()> {
3343 let (_tmp, vault_path, pass) = prepare("HeaderTamper!7")?;
3344 let config = AppConfig::default();
3345 Vault::init(&vault_path, &pass, 32_768, &config, None)?;
3346
3347 let bytes = std::fs::read(&vault_path)?;
3349 let mut file: VaultFile = ciborium::de::from_reader(bytes.as_slice())
3350 .map_err(|e| anyhow!("cbor decode: {e}"))?;
3351 file.header.epoch = file.header.epoch.saturating_add(1);
3352 let mut out = Vec::new();
3353 ciborium::ser::into_writer(&file, &mut out).map_err(|e| anyhow!("cbor encode: {e}"))?;
3354 std::fs::write(&vault_path, &out)?;
3355
3356 env::set_var("BLACK_BAG_DEBUG", "1");
3358 let _ = match Vault::load(&vault_path, &pass, &config) {
3359 Ok(_) => panic!("expected header integrity failure"),
3360 Err(e) => e,
3361 };
3362 cleanup();
3363 Ok(())
3364 }
3365
3366 #[test]
3367 fn aead_wrong_aad_fails() -> Result<()> {
3368 let key = [7u8; 32];
3369 let blob = encrypt_blob(&key, b"hello", b"AAD1")?;
3370 let err = decrypt_blob(&key, &blob, b"AAD2").unwrap_err().to_string();
3371 assert!(err.to_ascii_lowercase().contains("decryption failed"));
3372 Ok(())
3373 }
3374
3375 #[test]
3376 fn aead_bitflip_fails() -> Result<()> {
3377 let key = [9u8; 32];
3378 let mut blob = encrypt_blob(&key, b"world", b"AAD").unwrap();
3379 if !blob.ciphertext.is_empty() {
3380 blob.ciphertext[0] ^= 0x01;
3381 }
3382 let err = decrypt_blob(&key, &blob, b"AAD").unwrap_err().to_string();
3383 assert!(err.to_ascii_lowercase().contains("decryption failed"));
3384 Ok(())
3385 }
3386
3387 #[test]
3388 fn encrypt_with_nonce_matches_reference() -> Result<()> {
3389 let key = [3u8; 32];
3390 let nonce = [5u8; 24];
3391 let ours = encrypt_blob_with_nonce(&key, b"kat", b"abc", nonce)?;
3392 let cipher = XChaCha20Poly1305::new(Key::from_slice(&key));
3393 let ct = cipher
3394 .encrypt(XNonce::from_slice(&nonce), Payload { msg: b"kat", aad: b"abc" })
3395 .map_err(|_| anyhow!("encryption failed"))?;
3396 assert_eq!(ours.ciphertext, ct);
3397 assert_eq!(ours.nonce, nonce);
3398 Ok(())
3399 }
3400}