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