black_bag/
lib.rs

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