black_bagg/
lib.rs

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