black_bag/
lib.rs

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    /// Initialize a new vault
147    Init(InitCommand),
148    /// Add a record to the vault
149    Add(AddCommand),
150    /// List records (masked summaries)
151    List(ListCommand),
152    /// Inspect a record by UUID
153    Get(GetCommand),
154    /// Rewrap the master key with fresh randomness
155    Rotate(RotateCommand),
156    /// Print health diagnostics
157    Doctor(DoctorCommand),
158    /// Manage Shamir recovery shares
159    Recovery {
160        #[command(subcommand)]
161        command: RecoveryCommand,
162    },
163    /// Work with stored TOTP secrets
164    Totp {
165        #[command(subcommand)]
166        command: TotpCommand,
167    },
168    /// Manage backup integrity sidecars and signatures
169    Backup {
170        #[command(subcommand)]
171        command: BackupCommand,
172    },
173    /// Show build and feature information
174    Version,
175    /// Run embedded self-tests
176    Selftest,
177}
178
179#[derive(Args)]
180struct InitCommand {
181    /// Argon2 memory cost in KiB (default: 262144 => 256 MiB)
182    #[arg(long, default_value_t = 262_144)]
183    mem_kib: u32,
184}
185
186#[derive(Args)]
187struct ListCommand {
188    /// Filter by record kind
189    #[arg(long, value_enum)]
190    kind: Option<RecordKind>,
191    /// Filter by tag (case-insensitive substring match)
192    #[arg(long)]
193    tag: Option<String>,
194    /// Full-text query over metadata fields
195    #[arg(long)]
196    query: Option<String>,
197}
198
199#[derive(Args)]
200struct GetCommand {
201    /// Record UUID to inspect
202    id: Uuid,
203    /// Reveal sensitive fields (requires TTY)
204    #[arg(long)]
205    reveal: bool,
206}
207
208#[derive(Args, Default)]
209struct RotateCommand {
210    /// Optionally override Argon2 memory cost in KiB during rotation
211    #[arg(long)]
212    mem_kib: Option<u32>,
213}
214
215#[derive(Args)]
216struct DoctorCommand {
217    /// Emit machine-readable JSON instead of human text
218    #[arg(long)]
219    json: bool,
220}
221
222#[derive(Subcommand)]
223enum RecoveryCommand {
224    /// Split a secret into Shamir shares
225    Split(RecoverySplitCommand),
226    /// Combine Shamir shares back into the original secret
227    Combine(RecoveryCombineCommand),
228}
229
230#[derive(Args)]
231struct RecoverySplitCommand {
232    /// Threshold required to reconstruct the secret
233    #[arg(long, default_value_t = 3)]
234    threshold: u8,
235    /// Total number of shares to produce
236    #[arg(long, default_value_t = 5)]
237    shares: u8,
238}
239
240#[derive(Args)]
241struct RecoveryCombineCommand {
242    /// Reconstruction threshold (usually matches value used during split)
243    #[arg(long)]
244    threshold: u8,
245    /// Comma-separated list of shares (e.g., 1-<base64>,2-<base64>)
246    #[arg(long)]
247    shares: String,
248}
249
250#[derive(Subcommand)]
251enum TotpCommand {
252    /// Generate a TOTP code for the specified record
253    Code(TotpCodeCommand),
254}
255
256#[derive(Args)]
257struct TotpCodeCommand {
258    /// Record UUID containing the TOTP secret
259    id: Uuid,
260    /// Unix timestamp (seconds) to use instead of now
261    #[arg(long)]
262    time: Option<i64>,
263}
264
265#[derive(Subcommand)]
266enum BackupCommand {
267    /// Verify integrity (and optional signature) for a vault backup
268    Verify(BackupVerifyCommand),
269    /// Sign the integrity tag with an Ed25519 or ML-DSA-87 key
270    Sign(BackupSignCommand),
271    /// Generate an ML-DSA-87 keypair (requires --features pq)
272    #[cfg(feature = "pq")]
273    Keygen(BackupKeygenCommand),
274}
275
276#[derive(Args)]
277struct BackupVerifyCommand {
278    /// Path to the vault ciphertext (e.g., vault.cbor)
279    #[arg(long)]
280    path: PathBuf,
281    /// Optional public key file (Ed25519 or ML-DSA-87)
282    #[arg(long)]
283    pub_key: Option<PathBuf>,
284}
285
286#[derive(Args)]
287struct BackupSignCommand {
288    /// Path to the vault ciphertext (e.g., vault.cbor)
289    #[arg(long)]
290    path: PathBuf,
291    /// Secret key file (Ed25519 or ML-DSA-87)
292    #[arg(long)]
293    key: PathBuf,
294    /// Emit derived public key (base64) to this path
295    #[arg(long)]
296    pub_out: Option<PathBuf>,
297}
298
299#[cfg(feature = "pq")]
300#[derive(Args)]
301struct BackupKeygenCommand {
302    /// Output path for the ML-DSA-87 public key (base64)
303    #[arg(long)]
304    pub_out: PathBuf,
305    /// Output path for the ML-DSA-87 secret key (base64)
306    #[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    /// Add a login/password record
319    Login(AddLogin),
320    /// Add a contact record
321    Contact(AddContact),
322    /// Add an identity document record
323    Id(AddIdentity),
324    /// Add a secure note
325    Note(AddNote),
326    /// Add a bank account record
327    Bank(AddBank),
328    /// Add a Wi-Fi profile record
329    Wifi(AddWifi),
330    /// Add an API credential record
331    Api(AddApi),
332    /// Add a cryptocurrency wallet record
333    Wallet(AddWallet),
334    /// Add a TOTP secret
335    Totp(AddTotp),
336    /// Add an SSH key record
337    Ssh(AddSsh),
338    /// Add a PGP key record
339    Pgp(AddPgp),
340    /// Add a recovery kit record
341    Recovery(AddRecovery),
342}
343
344#[derive(Args)]
345struct CommonRecordArgs {
346    /// Optional title for the record
347    #[arg(long)]
348    title: Option<String>,
349    /// Comma-separated list of tags
350    #[arg(long, value_delimiter = ',')]
351    tags: Vec<String>,
352    /// Optional free-form notes (stored alongside metadata)
353    #[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    /// Optional issuer string (display only)
456    #[arg(long)]
457    issuer: Option<String>,
458    /// Optional account/name label (display only)
459    #[arg(long)]
460    account: Option<String>,
461    /// Base32-encoded secret; omit to be prompted securely
462    #[arg(long)]
463    secret: Option<String>,
464    /// Number of digits (6-8)
465    #[arg(long, default_value_t = 6)]
466    digits: u8,
467    /// Seconds per step
468    #[arg(long, default_value_t = 30)]
469    step: u64,
470    /// Allowed skew (number of steps on each side)
471    #[arg(long, default_value_t = 1)]
472    skew: u8,
473    /// Hash algorithm
474    #[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            // Best-effort lock of the sensitive buffer for its lifetime.
826            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        // Lock the passphrase bytes for the duration of this operation.
1466        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    // Deduplicate by id and take the first `threshold` usable shares
2039    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, &current) } != 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}