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