black_bag/
lib.rs

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