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        RecordData::Id { secret, .. } => {
1607            if let Some(secret) = secret {
1608                println!("secret: {}", secret.expose_utf8()?);
1609            }
1610        }
1611        RecordData::Note { body } => {
1612            println!("note:\n{}", body.expose_utf8()?);
1613        }
1614        RecordData::Bank { account_number, .. } => {
1615            println!("account_number: {}", account_number.expose_utf8()?);
1616        }
1617        RecordData::Wifi { passphrase, .. } => {
1618            println!("passphrase: {}", passphrase.expose_utf8()?);
1619        }
1620        RecordData::Api { secret_key, .. } => {
1621            println!("secret_key: {}", secret_key.expose_utf8()?);
1622        }
1623        RecordData::Wallet { secret_key, .. } => {
1624            println!("secret_key: {}", secret_key.expose_utf8()?);
1625        }
1626        RecordData::Totp {
1627            issuer,
1628            account,
1629            secret,
1630            digits,
1631            step,
1632            skew,
1633            algorithm,
1634        } => {
1635            let base32 = base32::encode(Rfc4648 { padding: false }, secret.as_slice());
1636            println!("secret_base32: {}", base32);
1637            if let Some(issuer) = issuer {
1638                println!("issuer: {}", issuer);
1639            }
1640            if let Some(account) = account {
1641                println!("account: {}", account);
1642            }
1643            println!("digits: {}", digits);
1644            println!("step: {}s", step);
1645            println!("skew: {}", skew);
1646            println!("algorithm: {}", algorithm);
1647            if let Ok(totp) =
1648                build_totp_instance(secret, *digits, *step, *skew, *algorithm, issuer, account)
1649            {
1650                if let Ok(code) = totp.generate_current() {
1651                    println!("current_code: {}", code);
1652                    if let Ok(ttl) = totp.ttl() {
1653                        println!("ttl: {}s", ttl);
1654                    }
1655                }
1656            }
1657        }
1658        RecordData::Ssh { private_key, .. } => {
1659            println!("private_key:\n{}", private_key.expose_utf8()?);
1660        }
1661        RecordData::Pgp {
1662            armored_private_key,
1663            ..
1664        } => {
1665            println!("pgp_private_key:\n{}", armored_private_key.expose_utf8()?);
1666        }
1667        RecordData::Recovery { payload, .. } => {
1668            println!("recovery_payload:\n{}", payload.expose_utf8()?);
1669        }
1670    }
1671    Ok(())
1672}
1673
1674fn parse_totp_secret(input: &str) -> Result<Vec<u8>> {
1675    let cleaned: String = input
1676        .chars()
1677        .filter(|c| !c.is_whitespace() && *c != '-')
1678        .collect();
1679    if cleaned.is_empty() {
1680        bail!("secret cannot be empty");
1681    }
1682    let encoded = cleaned.to_uppercase();
1683    TotpSecret::Encoded(encoded)
1684        .to_bytes()
1685        .map_err(|_| anyhow!("invalid base32-encoded secret"))
1686}
1687
1688fn build_totp_instance(
1689    secret: &Sensitive,
1690    digits: u8,
1691    step: u64,
1692    skew: u8,
1693    algorithm: TotpAlgorithm,
1694    issuer: &Option<String>,
1695    account: &Option<String>,
1696) -> Result<TOTP> {
1697    let account_name = account.clone().unwrap_or_default();
1698    TOTP::new(
1699        algorithm.to_lib(),
1700        usize::from(digits),
1701        skew,
1702        step,
1703        secret.as_slice().to_vec(),
1704        issuer.clone(),
1705        account_name,
1706    )
1707    .map_err(|err| anyhow!("failed to construct TOTP: {err}"))
1708}
1709
1710fn split_secret(secret: &[u8], threshold: u8, share_count: u8) -> Result<Vec<Vec<u8>>> {
1711    if threshold == 0 {
1712        bail!("threshold must be at least 1");
1713    }
1714    if share_count == 0 {
1715        bail!("share count must be at least 1");
1716    }
1717    if share_count < threshold {
1718        bail!("share count must be greater than or equal to threshold");
1719    }
1720    let sharks = Sharks(threshold);
1721    let dealer = sharks.dealer(secret);
1722    let shares: Vec<Share> = dealer.take(share_count as usize).collect();
1723    if shares.len() != share_count as usize {
1724        bail!("failed to generate requested number of shares");
1725    }
1726    // Encode as [id || payload] per share, matching existing format.
1727    Ok(shares.iter().map(|s| Vec::from(s)).collect())
1728}
1729
1730fn combine_secret(threshold: u8, shares: &[Vec<u8>]) -> Result<Vec<u8>> {
1731    if threshold == 0 {
1732        bail!("threshold must be at least 1");
1733    }
1734    if shares.len() < threshold as usize {
1735        bail!("not enough shares provided");
1736    }
1737    // Deduplicate by id and take the first `threshold` usable shares
1738    let mut seen = HashSet::new();
1739    let mut usable: Vec<Share> = Vec::with_capacity(threshold as usize);
1740    for raw in shares {
1741        if raw.len() < 2 {
1742            bail!("share payload too short");
1743        }
1744        let id = raw[0];
1745        if id == 0 {
1746            bail!("share identifier must be non-zero");
1747        }
1748        if seen.insert(id) {
1749            let s = Share::try_from(raw.as_slice())
1750                .map_err(|_| anyhow!("invalid share bytes"))?;
1751            usable.push(s);
1752            if usable.len() == threshold as usize {
1753                break;
1754            }
1755        }
1756    }
1757    if usable.len() < threshold as usize {
1758        bail!("not enough unique shares to reconstruct secret");
1759    }
1760    let sharks = Sharks(threshold);
1761    sharks
1762        .recover(&usable)
1763        .map_err(|e| anyhow!("failed to reconstruct secret: {e}"))
1764}
1765
1766fn rotate_vault(cmd: RotateCommand) -> Result<()> {
1767    with_vault(VaultAccess::ReadWrite, move |vault, pass| {
1768        vault.rotate(pass, cmd.mem_kib)?;
1769        vault.save(pass)?;
1770        println!("Rotation complete");
1771        Ok(())
1772    })
1773}
1774
1775fn doctor(cmd: DoctorCommand) -> Result<()> {
1776    with_vault(VaultAccess::ReadOnly, move |vault, _| {
1777        let stats = vault.stats();
1778        #[cfg(all(unix, feature = "mlock"))]
1779        let (mlock_enabled, mlock_ok, mlock_error) = {
1780            let mut probe = [0u8; 32];
1781            match memlock::lock_region(probe.as_ptr(), probe.len()) {
1782                Ok(()) => {
1783                    memlock::unlock_region(probe.as_ptr(), probe.len());
1784                    (true, true, None)
1785                }
1786                Err(e) => (true, false, Some(e.to_string())),
1787            }
1788        };
1789        #[cfg(not(all(unix, feature = "mlock")))]
1790        let (mlock_enabled, mlock_ok, mlock_error) = (false, false, None::<String>);
1791        if cmd.json {
1792            let payload = json!({
1793                "ready": true,
1794                "recordCount": stats.record_count,
1795                "argonMemKib": stats.argon_mem_kib,
1796                "argonTimeCost": stats.argon_time_cost,
1797                "argonLanes": stats.argon_lanes,
1798                "createdAt": stats.created_at.to_rfc3339(),
1799                "updatedAt": stats.updated_at.to_rfc3339(),
1800                "mlock": {
1801                    "enabled": mlock_enabled,
1802                    "ok": mlock_ok,
1803                    "error": mlock_error,
1804                },
1805            });
1806            println!("{}", payload);
1807        } else {
1808            println!("status: ready");
1809            println!("records: {}", stats.record_count);
1810            println!("created: {}", stats.created_at);
1811            println!("updated: {}", stats.updated_at);
1812            println!(
1813                "argon2: mem={} KiB, time={}, lanes={}",
1814                stats.argon_mem_kib, stats.argon_time_cost, stats.argon_lanes
1815            );
1816            if mlock_enabled {
1817                if mlock_ok {
1818                    println!("mlock: enabled and working");
1819                } else if let Some(err) = mlock_error {
1820                    println!("mlock: enabled but failed ({}). Check OS limits.", err);
1821                }
1822            } else {
1823                println!("mlock: not supported in this build/OS");
1824            }
1825        }
1826        Ok(())
1827    })
1828}
1829
1830fn recovery(cmd: RecoveryCommand) -> Result<()> {
1831    match cmd {
1832        RecoveryCommand::Split(args) => {
1833            if args.threshold == 0 || args.threshold > args.shares {
1834                bail!("threshold must be between 1 and number of shares");
1835            }
1836            let secret = prompt_hidden("Secret to split: ")?;
1837            let shares = split_secret(secret.as_slice(), args.threshold, args.shares)?;
1838            for share in shares {
1839                let (id, data) = share
1840                    .split_first()
1841                    .ok_or_else(|| anyhow!("invalid share structure"))?;
1842                let encoded = BASE64.encode(data);
1843                println!("{}-{}", id, encoded);
1844            }
1845            Ok(())
1846        }
1847        RecoveryCommand::Combine(args) => {
1848            if args.threshold == 0 {
1849                bail!("threshold must be at least 1");
1850            }
1851            let mut shares = Vec::new();
1852            for part in args
1853                .shares
1854                .split(',')
1855                .map(str::trim)
1856                .filter(|s| !s.is_empty())
1857            {
1858                let (id, data) = part
1859                    .split_once('-')
1860                    .ok_or_else(|| anyhow!("invalid share format: {part}"))?;
1861                let identifier: u8 = id.parse().context("invalid share identifier")?;
1862                if identifier == 0 {
1863                    bail!("share identifier must be between 1 and 255");
1864                }
1865                let mut decoded = BASE64.decode(data).context("invalid base64 in share")?;
1866                if decoded.is_empty() {
1867                    bail!("share payload cannot be empty");
1868                }
1869                let mut share = Vec::with_capacity(decoded.len() + 1);
1870                share.push(identifier);
1871                share.append(&mut decoded);
1872                shares.push(share);
1873            }
1874            if shares.len() < args.threshold as usize {
1875                bail!(
1876                    "insufficient shares provided (need at least {})",
1877                    args.threshold
1878                );
1879            }
1880            let secret = combine_secret(args.threshold, &shares)?;
1881            println!("{}", String::from_utf8_lossy(&secret));
1882            Ok(())
1883        }
1884    }
1885}
1886
1887fn totp(cmd: TotpCommand) -> Result<()> {
1888    match cmd {
1889        TotpCommand::Code(args) => totp_code(args),
1890    }
1891}
1892
1893fn totp_code(args: TotpCodeCommand) -> Result<()> {
1894    let TotpCodeCommand { id, time } = args;
1895    with_vault(VaultAccess::ReadOnly, move |vault, _| {
1896        let record = vault
1897            .get_ref(id)
1898            .ok_or_else(|| anyhow!("record {} not found", id))?;
1899        let (issuer, account, secret, digits, step, skew, algorithm) = match &record.data {
1900            RecordData::Totp {
1901                issuer,
1902                account,
1903                secret,
1904                digits,
1905                step,
1906                skew,
1907                algorithm,
1908            } => (issuer, account, secret, *digits, *step, *skew, *algorithm),
1909            _ => bail!("record {} is not a TOTP secret", id),
1910        };
1911
1912        let totp = build_totp_instance(secret, digits, step, skew, algorithm, issuer, account)?;
1913        let code = if let Some(ts) = time {
1914            if ts < 0 {
1915                bail!("time must be non-negative");
1916            }
1917            totp.generate(ts as u64)
1918        } else {
1919            totp.generate_current()?
1920        };
1921        println!("code: {}", code);
1922        if time.is_none() {
1923            let ttl = totp.ttl()?;
1924            println!("ttl: {}s", ttl);
1925        }
1926        Ok(())
1927    })
1928}
1929
1930fn backup(cmd: BackupCommand) -> Result<()> {
1931    match cmd {
1932        BackupCommand::Verify(args) => backup_verify(args),
1933        BackupCommand::Sign(args) => backup_sign(args),
1934        #[cfg(feature = "pq")]
1935        BackupCommand::Keygen(args) => backup_keygen(args),
1936    }
1937}
1938
1939fn show_version() -> Result<()> {
1940    let version = env!("CARGO_PKG_VERSION");
1941    let profile = option_env!("PROFILE").unwrap_or("unknown");
1942    let target = option_env!("TARGET").unwrap_or("unknown");
1943
1944    let mut features = Vec::new();
1945    if cfg!(feature = "mlock") {
1946        features.push("mlock");
1947    } else {
1948        features.push("no-mlock");
1949    }
1950    if cfg!(feature = "pq") {
1951        features.push("pq");
1952    } else {
1953        features.push("no-pq");
1954    }
1955    if cfg!(feature = "fuzzing") {
1956        features.push("fuzzing");
1957    }
1958    if cfg!(feature = "fhe") {
1959        features.push("fhe");
1960    }
1961
1962    println!("black-bag {version}");
1963    println!("profile: {profile}");
1964    println!("target: {target}");
1965    println!("features: {}", features.join(", "));
1966    Ok(())
1967}
1968
1969fn backup_verify(cmd: BackupVerifyCommand) -> Result<()> {
1970    let bytes = fs::read(&cmd.path)
1971        .with_context(|| format!("failed to read vault at {}", cmd.path.display()))?;
1972    let vault: VaultFile = from_reader(bytes.as_slice()).context("failed to parse vault")?;
1973    let expected = compute_public_integrity_tag(&bytes, &vault.header.kem_public);
1974    let actual = read_integrity_sidecar(&cmd.path)?;
1975    if actual.ct_eq(&expected).unwrap_u8() != 1 {
1976        bail!("integrity check failed: .int does not match vault payload");
1977    }
1978
1979    if let Some(pub_key) = cmd.pub_key.as_deref() {
1980        let sig = read_integrity_signature_sidecar(&cmd.path)?;
1981        verify_integrity_signature(&expected, pub_key, &sig)?;
1982        println!("Integrity and signature verified");
1983    } else {
1984        println!("Integrity verified");
1985    }
1986    Ok(())
1987}
1988
1989fn backup_sign(cmd: BackupSignCommand) -> Result<()> {
1990    let bytes = fs::read(&cmd.path)
1991        .with_context(|| format!("failed to read vault at {}", cmd.path.display()))?;
1992    let vault: VaultFile = from_reader(bytes.as_slice()).context("failed to parse vault")?;
1993    let tag = match read_integrity_sidecar(&cmd.path) {
1994        Ok(tag) => tag,
1995        Err(_) => {
1996            let computed = compute_public_integrity_tag(&bytes, &vault.header.kem_public);
1997            write_integrity_sidecar(&cmd.path, &computed)?;
1998            computed
1999        }
2000    };
2001
2002    let key_bytes = read_key_bytes(&cmd.key)?;
2003    let pub_out = cmd.pub_out.as_deref();
2004
2005    if try_sign_ed25519(&key_bytes, &tag, pub_out, &cmd.path)? {
2006        println!("Wrote Ed25519 signature sidecar");
2007        return Ok(());
2008    }
2009
2010    #[cfg(feature = "pq")]
2011    {
2012        if try_sign_mldsa(&key_bytes, &tag, pub_out, &cmd.path)? {
2013            println!("Wrote ML-DSA-87 signature sidecar");
2014            return Ok(());
2015        }
2016    }
2017
2018    bail!(
2019        "unsupported signing key size: expected 32/64 bytes (Ed25519) or ML-DSA-87 secret key length"
2020    )
2021}
2022
2023#[cfg(feature = "pq")]
2024fn backup_keygen(cmd: BackupKeygenCommand) -> Result<()> {
2025    let (pk, sk) = mldsa87::keypair();
2026    fs::write(&cmd.pub_out, BASE64.encode(pk.as_bytes()))
2027        .with_context(|| format!("failed to write {}", cmd.pub_out.display()))?;
2028    let mut secret_blob = Vec::with_capacity(pk.as_bytes().len() + sk.as_bytes().len());
2029    secret_blob.extend_from_slice(pk.as_bytes());
2030    secret_blob.extend_from_slice(sk.as_bytes());
2031    fs::write(&cmd.sk_out, BASE64.encode(secret_blob))
2032        .with_context(|| format!("failed to write {}", cmd.sk_out.display()))?;
2033    println!("Generated ML-DSA-87 keypair");
2034    Ok(())
2035}
2036
2037fn compute_public_integrity_tag(bytes: &[u8], kem_public: &[u8]) -> [u8; 32] {
2038    let key = blake3::hash(kem_public);
2039    let tag = blake3::keyed_hash(key.as_bytes(), bytes);
2040    let mut out = [0u8; 32];
2041    out.copy_from_slice(tag.as_bytes());
2042    out
2043}
2044
2045fn integrity_sidecar_path(path: &Path) -> PathBuf {
2046    let mut os = path.as_os_str().to_os_string();
2047    os.push(".int");
2048    PathBuf::from(os)
2049}
2050
2051fn integrity_signature_sidecar_path(path: &Path) -> PathBuf {
2052    let mut os = path.as_os_str().to_os_string();
2053    os.push(".int.sig");
2054    PathBuf::from(os)
2055}
2056
2057fn write_integrity_sidecar(path: &Path, tag: &[u8; 32]) -> Result<()> {
2058    let sidecar = integrity_sidecar_path(path);
2059    let mut file =
2060        File::create(&sidecar).with_context(|| format!("failed to write {}", sidecar.display()))?;
2061    writeln!(file, "{}", BASE64.encode(tag))
2062        .with_context(|| format!("failed to write {}", sidecar.display()))
2063}
2064
2065fn read_integrity_sidecar(path: &Path) -> Result<[u8; 32]> {
2066    let sidecar = integrity_sidecar_path(path);
2067    let mut contents = String::new();
2068    File::open(&sidecar)
2069        .with_context(|| format!("integrity sidecar missing at {}", sidecar.display()))?
2070        .read_to_string(&mut contents)
2071        .with_context(|| format!("failed to read {}", sidecar.display()))?;
2072    let trimmed = contents.trim();
2073    if trimmed.is_empty() {
2074        bail!("integrity sidecar at {} is empty", sidecar.display());
2075    }
2076    let decoded = if let Ok(bytes) = BASE64.decode(trimmed) {
2077        bytes
2078    } else if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
2079        hex::decode(trimmed).context("invalid hex in integrity sidecar")?
2080    } else {
2081        bail!(
2082            "integrity sidecar at {} is not valid base64/hex",
2083            sidecar.display()
2084        );
2085    };
2086    if decoded.len() != 32 {
2087        bail!("integrity sidecar length mismatch (expected 32 bytes)");
2088    }
2089    let mut out = [0u8; 32];
2090    out.copy_from_slice(&decoded);
2091    Ok(out)
2092}
2093
2094fn write_integrity_signature_sidecar(path: &Path, sig: &[u8]) -> Result<()> {
2095    let sidecar = integrity_signature_sidecar_path(path);
2096    let mut file =
2097        File::create(&sidecar).with_context(|| format!("failed to write {}", sidecar.display()))?;
2098    writeln!(file, "{}", BASE64.encode(sig))
2099        .with_context(|| format!("failed to write {}", sidecar.display()))
2100}
2101
2102fn read_integrity_signature_sidecar(path: &Path) -> Result<Vec<u8>> {
2103    let sidecar = integrity_signature_sidecar_path(path);
2104    let mut contents = String::new();
2105    File::open(&sidecar)
2106        .with_context(|| format!("signature sidecar missing at {}", sidecar.display()))?
2107        .read_to_string(&mut contents)
2108        .with_context(|| format!("failed to read {}", sidecar.display()))?;
2109    let trimmed = contents.trim();
2110    if trimmed.is_empty() {
2111        bail!("signature sidecar at {} is empty", sidecar.display());
2112    }
2113    BASE64
2114        .decode(trimmed)
2115        .context("signature sidecar is not valid base64")
2116}
2117
2118fn read_key_bytes(path: &Path) -> Result<Vec<u8>> {
2119    let raw = fs::read(path)
2120        .with_context(|| format!("failed to read key material from {}", path.display()))?;
2121    if let Ok(text) = std::str::from_utf8(&raw) {
2122        let trimmed = text.trim();
2123        if trimmed.is_empty() {
2124            bail!("key file {} is empty", path.display());
2125        }
2126        if let Ok(bytes) = BASE64.decode(trimmed) {
2127            return Ok(bytes);
2128        }
2129        if trimmed.len() % 2 == 0 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
2130            return hex::decode(trimmed).context("invalid hex encoding in key file");
2131        }
2132    }
2133    Ok(raw)
2134}
2135
2136fn verify_signature_with_key_bytes(tag: &[u8; 32], pk_bytes: &[u8], sig: &[u8]) -> Result<()> {
2137    if pk_bytes.len() == 32 {
2138        let vk = Ed25519VerifyingKey::from_bytes(
2139            pk_bytes
2140                .try_into()
2141                .map_err(|_| anyhow!("invalid Ed25519 public key length"))?,
2142        )
2143        .map_err(|e| anyhow!("invalid Ed25519 public key: {e}"))?;
2144        let sig_array: [u8; 64] = sig
2145            .try_into()
2146            .map_err(|_| anyhow!("invalid Ed25519 signature length"))?;
2147        let signature = Ed25519Signature::from_bytes(&sig_array);
2148        vk.verify(tag, &signature)
2149            .map_err(|_| anyhow!("Ed25519 signature verification failed"))?;
2150        return Ok(());
2151    }
2152
2153    #[cfg(feature = "pq")]
2154    {
2155        if pk_bytes.len() == mldsa87::public_key_bytes() {
2156            let pk = MlDsaPublicKey::from_bytes(pk_bytes)
2157                .map_err(|_| anyhow!("invalid ML-DSA-87 public key bytes"))?;
2158            let ds = MlDsaDetachedSignature::from_bytes(sig)
2159                .map_err(|_| anyhow!("invalid ML-DSA-87 signature bytes"))?;
2160            mldsa87::verify_detached_signature_ctx(&ds, tag, SIG_CTX, &pk)
2161                .map_err(|_| anyhow!("ML-DSA-87 signature verification failed"))?;
2162            return Ok(());
2163        }
2164    }
2165
2166    bail!("unsupported public key size for signature verification")
2167}
2168
2169fn try_sign_ed25519(
2170    key_bytes: &[u8],
2171    tag: &[u8; 32],
2172    pub_out: Option<&Path>,
2173    vault_path: &Path,
2174) -> Result<bool> {
2175    let signing_key = match key_bytes.len() {
2176        32 => {
2177            let array: [u8; 32] = key_bytes
2178                .try_into()
2179                .map_err(|_| anyhow!("invalid Ed25519 secret key length"))?;
2180            Ed25519SigningKey::from_bytes(&array)
2181        }
2182        64 => {
2183            let array: [u8; 64] = key_bytes
2184                .try_into()
2185                .map_err(|_| anyhow!("invalid Ed25519 keypair length"))?;
2186            Ed25519SigningKey::from_keypair_bytes(&array)
2187                .map_err(|_| anyhow!("invalid Ed25519 keypair bytes"))?
2188        }
2189        _ => return Ok(false),
2190    };
2191
2192    let signature: Ed25519Signature = signing_key.sign(tag);
2193    let sig_bytes = signature.to_bytes();
2194    write_integrity_signature_sidecar(vault_path, sig_bytes.as_ref())?;
2195    if let Some(out) = pub_out {
2196        let vk = signing_key.verifying_key();
2197        fs::write(out, BASE64.encode(vk.as_bytes()))
2198            .with_context(|| format!("failed to write {}", out.display()))?;
2199    }
2200    Ok(true)
2201}
2202
2203#[cfg(feature = "pq")]
2204fn try_sign_mldsa(
2205    key_bytes: &[u8],
2206    tag: &[u8; 32],
2207    pub_out: Option<&Path>,
2208    vault_path: &Path,
2209) -> Result<bool> {
2210    let sk_len = mldsa87::secret_key_bytes();
2211    let pk_len = mldsa87::public_key_bytes();
2212
2213    let (sk_material, pk_material) = if key_bytes.len() == sk_len {
2214        (key_bytes, None)
2215    } else if key_bytes.len() == sk_len + pk_len {
2216        (&key_bytes[pk_len..], Some(&key_bytes[..pk_len]))
2217    } else {
2218        return Ok(false);
2219    };
2220
2221    let sk = MlDsaSecretKey::from_bytes(sk_material)
2222        .map_err(|_| anyhow!("invalid ML-DSA-87 secret key bytes"))?;
2223    let signature = mldsa87::detached_sign_ctx(tag, SIG_CTX, &sk);
2224    write_integrity_signature_sidecar(vault_path, signature.as_bytes())?;
2225
2226    if let Some(out) = pub_out {
2227        let pk_slice = pk_material
2228            .ok_or_else(|| anyhow!("secret key does not embed an ML-DSA-87 public key"))?;
2229        fs::write(out, BASE64.encode(pk_slice))
2230            .with_context(|| format!("failed to write {}", out.display()))?;
2231    }
2232    Ok(true)
2233}
2234
2235fn verify_integrity_signature(tag: &[u8; 32], pub_key_path: &Path, sig: &[u8]) -> Result<()> {
2236    let pk_bytes = read_key_bytes(pub_key_path)?;
2237    verify_signature_with_key_bytes(tag, &pk_bytes, sig)
2238}
2239
2240fn self_test() -> Result<()> {
2241    let mut sample = [0u8; 32];
2242    OsRng.fill_bytes(&mut sample);
2243    let secret = Sensitive::new_from_utf8(&sample);
2244    let record = Record::new(
2245        RecordData::Note { body: secret },
2246        Some("Self-test".into()),
2247        vec!["selftest".into()],
2248        None,
2249    );
2250    let payload = VaultPayload {
2251        records: vec![record],
2252        record_counter: 1,
2253    };
2254
2255    let mut dek = [0u8; 32];
2256    OsRng.fill_bytes(&mut dek);
2257    let blob = encrypt_payload(&dek, &payload)?;
2258    let recovered = decrypt_payload(&dek, &blob)?;
2259    anyhow::ensure!(recovered.records.len() == 1, "self-test failed");
2260    println!("Self-test passed");
2261    Ok(())
2262}
2263
2264fn prompt_hidden(prompt: &str) -> Result<Sensitive> {
2265    let value = Zeroizing::new(prompt_password(prompt)?);
2266    Ok(Sensitive::from_string(value.as_str()))
2267}
2268
2269fn prompt_optional_hidden(prompt: &str) -> Result<Option<Sensitive>> {
2270    let value = Zeroizing::new(prompt_password(prompt)?);
2271    if value.trim().is_empty() {
2272        Ok(None)
2273    } else {
2274        Ok(Some(Sensitive::from_string(value.as_str())))
2275    }
2276}
2277
2278fn prompt_multiline(prompt: &str) -> Result<Sensitive> {
2279    eprintln!("{}", prompt);
2280    read_multiline(false)
2281}
2282
2283fn prompt_multiline_hidden(prompt: &str) -> Result<Sensitive> {
2284    eprintln!("{}", prompt);
2285    read_multiline(true)
2286}
2287
2288fn read_multiline(hidden: bool) -> Result<Sensitive> {
2289    let hide_output = hidden && io::stdin().is_terminal();
2290    #[cfg(unix)]
2291    let mut echo_guard: Option<EchoModeGuard> = None;
2292    if hide_output {
2293        #[cfg(unix)]
2294        {
2295            echo_guard = Some(EchoModeGuard::disable()?);
2296        }
2297        #[cfg(not(unix))]
2298        {
2299            bail!("hidden multiline prompts are not supported on this platform; pipe the input instead");
2300        }
2301    }
2302
2303    let mut buffer = Zeroizing::new(Vec::new());
2304    io::stdin().read_to_end(&mut buffer)?;
2305    #[cfg(unix)]
2306    if hide_output {
2307        let _ = echo_guard.take();
2308        eprintln!();
2309    }
2310    while buffer.last().copied() == Some(b'\n') {
2311        buffer.pop();
2312    }
2313    Ok(Sensitive::new_from_utf8(&buffer))
2314}
2315
2316#[cfg(unix)]
2317struct EchoModeGuard {
2318    fd: i32,
2319    original: libc::termios,
2320}
2321
2322#[cfg(unix)]
2323impl EchoModeGuard {
2324    fn disable() -> Result<Self> {
2325        let stdin = io::stdin();
2326        let fd = stdin.as_raw_fd();
2327        let mut term = MaybeUninit::<libc::termios>::uninit();
2328        if unsafe { libc::tcgetattr(fd, term.as_mut_ptr()) } != 0 {
2329            Result::<(), io::Error>::Err(io::Error::last_os_error())
2330                .context("failed to read terminal attributes")?;
2331        }
2332        let mut current = unsafe { term.assume_init() };
2333        let original = current;
2334        current.c_lflag &= !libc::ECHO;
2335        if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &current) } != 0 {
2336            Result::<(), io::Error>::Err(io::Error::last_os_error())
2337                .context("failed to disable terminal echo")?;
2338        }
2339        Ok(Self { fd, original })
2340    }
2341}
2342
2343#[cfg(unix)]
2344impl Drop for EchoModeGuard {
2345    fn drop(&mut self) {
2346        let _ = unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &self.original) };
2347    }
2348}
2349
2350#[cfg(feature = "fuzzing")]
2351pub fn fuzz_try_payload(bytes: &[u8]) {
2352    let _ = ciborium::de::from_reader::<VaultPayload, _>(bytes);
2353}
2354
2355#[cfg(feature = "fuzzing")]
2356pub fn fuzz_verify_signature(bytes: &[u8]) {
2357    if bytes.len() < 34 {
2358        return;
2359    }
2360    let mut tag = [0u8; 32];
2361    tag.copy_from_slice(&bytes[..32]);
2362    let remainder = &bytes[32..];
2363    if remainder.len() < 2 {
2364        return;
2365    }
2366    let split = (remainder[0] as usize % (remainder.len() - 1)) + 1;
2367    let (pk_bytes, sig_bytes) = remainder.split_at(split);
2368    let _ = verify_signature_with_key_bytes(&tag, pk_bytes, sig_bytes);
2369}
2370
2371fn write_vault(path: &Path, file: &VaultFile) -> Result<()> {
2372    let parent = path.parent().ok_or_else(|| anyhow!("invalid vault path"))?;
2373    fs::create_dir_all(parent)?;
2374    let mut tmp = NamedTempFile::new_in(parent)?;
2375    into_writer(file, &mut tmp).context("failed to serialize vault")?;
2376    tmp.as_file_mut().sync_all()?;
2377    #[cfg(unix)]
2378    {
2379        use std::os::unix::fs::PermissionsExt;
2380        tmp.as_file_mut()
2381            .set_permissions(fs::Permissions::from_mode(0o600))?;
2382    }
2383    tmp.persist(path)?;
2384    Ok(())
2385}
2386
2387impl fmt::Display for RecordKind {
2388    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2389        let label = match self {
2390            RecordKind::Login => "login",
2391            RecordKind::Contact => "contact",
2392            RecordKind::Id => "id",
2393            RecordKind::Note => "note",
2394            RecordKind::Bank => "bank",
2395            RecordKind::Wifi => "wifi",
2396            RecordKind::Api => "api",
2397            RecordKind::Wallet => "wallet",
2398            RecordKind::Totp => "totp",
2399            RecordKind::Ssh => "ssh",
2400            RecordKind::Pgp => "pgp",
2401            RecordKind::Recovery => "recovery",
2402        };
2403        f.write_str(label)
2404    }
2405}
2406
2407#[cfg(test)]
2408mod tests {
2409    use super::*;
2410    use proptest::prelude::*;
2411    use proptest::proptest;
2412    use proptest::strategy::Strategy;
2413    use serial_test::serial;
2414    use std::env;
2415    use std::path::PathBuf;
2416    use tempfile::tempdir;
2417
2418    fn prepare(passphrase: &str) -> Result<(tempfile::TempDir, PathBuf, Zeroizing<String>)> {
2419        let dir = tempdir()?;
2420        let vault_path = dir.path().join("vault.cbor");
2421        env::set_var("BLACK_BAG_VAULT_PATH", &vault_path);
2422        let pass = Zeroizing::new(passphrase.to_string());
2423        Ok((dir, vault_path, pass))
2424    }
2425
2426    fn cleanup() {
2427        env::remove_var("BLACK_BAG_VAULT_PATH");
2428    }
2429
2430    fn arb_ascii_string(max: usize) -> impl Strategy<Value = String> {
2431        proptest::collection::vec(proptest::char::range('a', 'z'), 0..=max)
2432            .prop_map(|chars| chars.into_iter().collect())
2433    }
2434
2435    fn arb_note_record() -> impl Strategy<Value = Record> {
2436        (
2437            proptest::option::of(arb_ascii_string(12)),
2438            proptest::collection::vec(arb_ascii_string(8), 0..3),
2439            arb_ascii_string(48),
2440        )
2441            .prop_map(|(title, tags, body)| {
2442                Record::new(
2443                    RecordData::Note {
2444                        body: Sensitive::from_string(&body),
2445                    },
2446                    title,
2447                    tags,
2448                    None,
2449                )
2450            })
2451    }
2452
2453    proptest! {
2454        #[test]
2455        fn encrypt_blob_roundtrip_prop(
2456            key_bytes in proptest::array::uniform32(any::<u8>()),
2457            data in proptest::collection::vec(any::<u8>(), 0..256),
2458            aad in proptest::collection::vec(any::<u8>(), 0..32),
2459        ) {
2460            let blob = encrypt_blob(&key_bytes, &data, &aad).unwrap();
2461            let decrypted = decrypt_blob(&key_bytes, &blob, &aad).unwrap();
2462            prop_assert_eq!(decrypted, data);
2463        }
2464
2465        #[test]
2466        fn payload_roundtrip_prop(
2467            key_bytes in proptest::array::uniform32(any::<u8>()),
2468            records in proptest::collection::vec(arb_note_record(), 0..3),
2469        ) {
2470            let payload = VaultPayload {
2471                records: records.clone(),
2472                record_counter: records.len() as u64,
2473            };
2474            let blob = encrypt_payload(&key_bytes, &payload).unwrap();
2475            let decoded = decrypt_payload(&key_bytes, &blob).unwrap();
2476            prop_assert_eq!(decoded, payload);
2477        }
2478    }
2479
2480    #[test]
2481    #[serial]
2482    fn vault_round_trip_note() -> Result<()> {
2483        let (_tmp, vault_path, pass) = prepare("correct horse battery staple")?;
2484        Vault::init(&vault_path, &pass, 32_768)?;
2485
2486        let mut vault = Vault::load(&vault_path, &pass)?;
2487        let record = Record::new(
2488            RecordData::Note {
2489                body: Sensitive::from_string("mission ops"),
2490            },
2491            Some("Ops Note".into()),
2492            vec!["mission".into()],
2493            None,
2494        );
2495        let record_id = record.id;
2496        vault.add_record(record);
2497        vault.save(&pass)?;
2498
2499        drop(vault);
2500        let vault = Vault::load(&vault_path, &pass)?;
2501        let notes = vault.list(Some(RecordKind::Note), None, None);
2502        assert_eq!(notes.len(), 1);
2503        assert_eq!(notes[0].id, record_id);
2504        assert_eq!(notes[0].title.as_deref(), Some("Ops Note"));
2505        assert!(notes[0].matches_tag("mission"));
2506
2507        cleanup();
2508        Ok(())
2509    }
2510
2511    #[test]
2512    #[serial]
2513    fn totp_round_trip() -> Result<()> {
2514        let (_tmp, vault_path, pass) = prepare("totp-pass")?;
2515        Vault::init(&vault_path, &pass, 32_768)?;
2516
2517        let secret_bytes = parse_totp_secret("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")?;
2518        let mut vault = Vault::load(&vault_path, &pass)?;
2519        let record = Record::new(
2520            RecordData::Totp {
2521                issuer: Some("TestIssuer".into()),
2522                account: Some("test@example".into()),
2523                secret: Sensitive { data: secret_bytes },
2524                digits: 6,
2525                step: 30,
2526                skew: 1,
2527                algorithm: TotpAlgorithm::Sha1,
2528            },
2529            Some("TOTP".into()),
2530            vec![],
2531            None,
2532        );
2533        let record_id = record.id;
2534        vault.add_record(record);
2535        vault.save(&pass)?;
2536
2537        drop(vault);
2538        let vault = Vault::load(&vault_path, &pass)?;
2539        let record = vault
2540            .get_ref(record_id)
2541            .ok_or_else(|| anyhow!("TOTP record missing"))?;
2542        let code = match &record.data {
2543            RecordData::Totp {
2544                issuer,
2545                account,
2546                secret,
2547                digits,
2548                step,
2549                skew,
2550                algorithm,
2551            } => build_totp_instance(secret, *digits, *step, *skew, *algorithm, issuer, account)?
2552                .generate(59),
2553            _ => bail!("expected totp record"),
2554        };
2555        let expected = TOTP::new(
2556            TotpAlgorithmLib::SHA1,
2557            6,
2558            1,
2559            30,
2560            parse_totp_secret("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")?,
2561            Some("TestIssuer".into()),
2562            "test@example".into(),
2563        )
2564        .unwrap()
2565        .generate(59);
2566        assert_eq!(code, expected);
2567
2568        cleanup();
2569        Ok(())
2570    }
2571
2572    #[test]
2573    #[serial]
2574    fn vault_rotation_changes_wrapped_keys() -> Result<()> {
2575        let (_tmp, vault_path, pass) = prepare("rotate-all-the-things")?;
2576        Vault::init(&vault_path, &pass, 32_768)?;
2577
2578        let mut vault = Vault::load(&vault_path, &pass)?;
2579        let record = Record::new(
2580            RecordData::Api {
2581                service: Some("intel-api".into()),
2582                environment: Some("prod".into()),
2583                access_key: Some("AKIA-123".into()),
2584                secret_key: Sensitive::from_string("super-secret"),
2585                scopes: vec!["read".into(), "write".into()],
2586            },
2587            Some("API".into()),
2588            vec!["read".into()],
2589            None,
2590        );
2591        vault.add_record(record);
2592        let before = vault.file.header.sealed_dek.ciphertext.clone();
2593        vault.rotate(&pass, Some(65_536))?;
2594        vault.save(&pass)?;
2595        let after = vault.file.header.sealed_dek.ciphertext.clone();
2596        assert_ne!(before, after);
2597
2598        drop(vault);
2599        let vault = Vault::load(&vault_path, &pass)?;
2600        let apis = vault.list(Some(RecordKind::Api), Some("read"), None);
2601        assert_eq!(apis.len(), 1);
2602        assert!(apis[0].data.summary_text().contains("intel-api"));
2603
2604        cleanup();
2605        Ok(())
2606    }
2607
2608    #[test]
2609    fn recovery_split_combine_roundtrip() -> Result<()> {
2610        let secret = b"ultra-secret";
2611        let shares = split_secret(secret, 3, 5)?;
2612        let recovered = combine_secret(3, &shares)?;
2613        assert_eq!(recovered, secret);
2614        Ok(())
2615    }
2616
2617    #[test]
2618    fn split_secret_requires_threshold_shares() -> Result<()> {
2619        let secret = b"deg-guard";
2620        let shares = split_secret(secret, 3, 5)?;
2621        for i in 0..shares.len() {
2622            for j in (i + 1)..shares.len() {
2623                let subset = vec![shares[i].clone(), shares[j].clone()];
2624                let recovered = combine_secret(2, &subset)?;
2625                assert_ne!(recovered.as_slice(), secret);
2626            }
2627        }
2628        Ok(())
2629    }
2630
2631    #[test]
2632    #[serial]
2633    fn backup_sign_verify_ed25519() -> Result<()> {
2634        let (tmp, vault_path, pass) = prepare("backup-ed25519")?;
2635        Vault::init(&vault_path, &pass, 32_768)?;
2636
2637        let sk_path = tmp.path().join("ed25519.sk");
2638        let mut sk_bytes = [0u8; 32];
2639        OsRng.fill_bytes(&mut sk_bytes);
2640        fs::write(&sk_path, BASE64.encode(sk_bytes))?;
2641        let pub_path = tmp.path().join("ed25519.pub");
2642
2643        backup_sign(BackupSignCommand {
2644            path: vault_path.clone(),
2645            key: sk_path,
2646            pub_out: Some(pub_path.clone()),
2647        })?;
2648
2649        backup_verify(BackupVerifyCommand {
2650            path: vault_path.clone(),
2651            pub_key: Some(pub_path),
2652        })?;
2653
2654        cleanup();
2655        Ok(())
2656    }
2657
2658    #[cfg(feature = "pq")]
2659    #[test]
2660    #[serial]
2661    fn backup_sign_verify_mldsa87() -> Result<()> {
2662        let (tmp, vault_path, pass) = prepare("backup-mldsa87")?;
2663        Vault::init(&vault_path, &pass, 32_768)?;
2664
2665        let (pk, sk) = mldsa87::keypair();
2666        let sk_path = tmp.path().join("mldsa.sk");
2667        let pk_path = tmp.path().join("mldsa.pub");
2668        let mut secret_blob = Vec::with_capacity(pk.as_bytes().len() + sk.as_bytes().len());
2669        secret_blob.extend_from_slice(pk.as_bytes());
2670        secret_blob.extend_from_slice(sk.as_bytes());
2671        fs::write(&sk_path, BASE64.encode(&secret_blob))?;
2672        fs::write(&pk_path, BASE64.encode(pk.as_bytes()))?;
2673
2674        backup_sign(BackupSignCommand {
2675            path: vault_path.clone(),
2676            key: sk_path,
2677            pub_out: Some(pk_path.clone()),
2678        })?;
2679
2680        backup_verify(BackupVerifyCommand {
2681            path: vault_path.clone(),
2682            pub_key: Some(pk_path),
2683        })?;
2684
2685        cleanup();
2686        Ok(())
2687    }
2688}