black_bagg/
lib.rs

1use std::collections::HashSet;
2use std::env;
3use std::fmt;
4use std::fs::{self, OpenOptions};
5use std::io::{self, Read};
6use std::path::{Path, PathBuf};
7
8use anyhow::{anyhow, bail, Context, Result};
9use argon2::{Algorithm, Argon2, Params, Version};
10use base32::Alphabet::Rfc4648;
11use base64::engine::general_purpose::STANDARD as BASE64;
12use base64::Engine as _;
13use chacha20poly1305::aead::{Aead, KeyInit, Payload};
14use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
15use chrono::{DateTime, Utc};
16use ciborium::{de::from_reader, ser::into_writer};
17use clap::{Args, Parser, Subcommand, ValueEnum};
18use directories::BaseDirs;
19use is_terminal::IsTerminal;
20use kem::{Decapsulate, Encapsulate};
21use ml_kem::{array::Array, Ciphertext, Encoded, EncodedSizeUser, KemCore, MlKem1024};
22use rand::{rngs::OsRng, RngCore};
23use rpassword::prompt_password;
24use serde::{Deserialize, Serialize};
25use serde_json::json;
26use tempfile::NamedTempFile;
27use totp_rs::{Algorithm as TotpAlgorithmLib, Secret as TotpSecret, TOTP};
28use typenum::Unsigned;
29use uuid::Uuid;
30use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
31
32const VAULT_VERSION: u32 = 1;
33const AAD_DEK: &[u8] = b"black-bag::sealed-dek";
34const AAD_DK: &[u8] = b"black-bag::sealed-dk";
35const AAD_PAYLOAD: &[u8] = b"black-bag::payload";
36const DEFAULT_TIME_COST: u32 = 3;
37const DEFAULT_LANES: u32 = 1;
38
39pub fn run() -> Result<()> {
40    let cli = Cli::parse();
41    match cli.command {
42        Command::Init(cmd) => init_vault(cmd),
43        Command::Add(cmd) => add_record(cmd),
44        Command::List(cmd) => list_records(cmd),
45        Command::Get(cmd) => get_record(cmd),
46        Command::Rotate(cmd) => rotate_vault(cmd),
47        Command::Doctor(cmd) => doctor(cmd),
48        Command::Recovery { command } => recovery(command),
49        Command::Totp { command } => totp(command),
50        Command::Selftest => self_test(),
51    }
52}
53
54#[derive(Parser)]
55#[command(name = "black-bag", version, about = "Ultra-secure zero-trace CLI vault", long_about = None)]
56struct Cli {
57    #[command(subcommand)]
58    command: Command,
59}
60
61#[derive(Subcommand)]
62enum Command {
63    /// Initialize a new vault
64    Init(InitCommand),
65    /// Add a record to the vault
66    Add(AddCommand),
67    /// List records (masked summaries)
68    List(ListCommand),
69    /// Inspect a record by UUID
70    Get(GetCommand),
71    /// Rewrap the master key with fresh randomness
72    Rotate(RotateCommand),
73    /// Print health diagnostics
74    Doctor(DoctorCommand),
75    /// Manage Shamir recovery shares
76    Recovery {
77        #[command(subcommand)]
78        command: RecoveryCommand,
79    },
80    /// Work with stored TOTP secrets
81    Totp {
82        #[command(subcommand)]
83        command: TotpCommand,
84    },
85    /// Run embedded self-tests
86    Selftest,
87}
88
89#[derive(Args)]
90struct InitCommand {
91    /// Argon2 memory cost in KiB (default: 262144 => 256 MiB)
92    #[arg(long, default_value_t = 262_144)]
93    mem_kib: u32,
94}
95
96#[derive(Args)]
97struct ListCommand {
98    /// Filter by record kind
99    #[arg(long, value_enum)]
100    kind: Option<RecordKind>,
101    /// Filter by tag (case-insensitive substring match)
102    #[arg(long)]
103    tag: Option<String>,
104    /// Full-text query over metadata fields
105    #[arg(long)]
106    query: Option<String>,
107}
108
109#[derive(Args)]
110struct GetCommand {
111    /// Record UUID to inspect
112    id: Uuid,
113    /// Reveal sensitive fields (requires TTY)
114    #[arg(long)]
115    reveal: bool,
116}
117
118#[derive(Args, Default)]
119struct RotateCommand {
120    /// Optionally override Argon2 memory cost in KiB during rotation
121    #[arg(long)]
122    mem_kib: Option<u32>,
123}
124
125#[derive(Args)]
126struct DoctorCommand {
127    /// Emit machine-readable JSON instead of human text
128    #[arg(long)]
129    json: bool,
130}
131
132#[derive(Subcommand)]
133enum RecoveryCommand {
134    /// Split a secret into Shamir shares
135    Split(RecoverySplitCommand),
136    /// Combine Shamir shares back into the original secret
137    Combine(RecoveryCombineCommand),
138}
139
140#[derive(Args)]
141struct RecoverySplitCommand {
142    /// Threshold required to reconstruct the secret
143    #[arg(long, default_value_t = 3)]
144    threshold: u8,
145    /// Total number of shares to produce
146    #[arg(long, default_value_t = 5)]
147    shares: u8,
148}
149
150#[derive(Args)]
151struct RecoveryCombineCommand {
152    /// Reconstruction threshold (usually matches value used during split)
153    #[arg(long)]
154    threshold: u8,
155    /// Comma-separated list of shares (e.g., 1-<base64>,2-<base64>)
156    #[arg(long)]
157    shares: String,
158}
159
160#[derive(Subcommand)]
161enum TotpCommand {
162    /// Generate a TOTP code for the specified record
163    Code(TotpCodeCommand),
164}
165
166#[derive(Args)]
167struct TotpCodeCommand {
168    /// Record UUID containing the TOTP secret
169    id: Uuid,
170    /// Unix timestamp (seconds) to use instead of now
171    #[arg(long)]
172    time: Option<i64>,
173}
174
175#[derive(Args)]
176struct AddCommand {
177    #[command(subcommand)]
178    record: AddRecord,
179}
180
181#[derive(Subcommand)]
182enum AddRecord {
183    /// Add a login/password record
184    Login(AddLogin),
185    /// Add a contact record
186    Contact(AddContact),
187    /// Add an identity document record
188    Id(AddIdentity),
189    /// Add a secure note
190    Note(AddNote),
191    /// Add a bank account record
192    Bank(AddBank),
193    /// Add a Wi-Fi profile record
194    Wifi(AddWifi),
195    /// Add an API credential record
196    Api(AddApi),
197    /// Add a cryptocurrency wallet record
198    Wallet(AddWallet),
199    /// Add a TOTP secret
200    Totp(AddTotp),
201    /// Add an SSH key record
202    Ssh(AddSsh),
203    /// Add a PGP key record
204    Pgp(AddPgp),
205    /// Add a recovery kit record
206    Recovery(AddRecovery),
207}
208
209#[derive(Args)]
210struct CommonRecordArgs {
211    /// Optional title for the record
212    #[arg(long)]
213    title: Option<String>,
214    /// Comma-separated list of tags
215    #[arg(long, value_delimiter = ',')]
216    tags: Vec<String>,
217    /// Optional free-form notes (stored alongside metadata)
218    #[arg(long)]
219    notes: Option<String>,
220}
221
222#[derive(Args)]
223struct AddLogin {
224    #[command(flatten)]
225    common: CommonRecordArgs,
226    #[arg(long)]
227    username: Option<String>,
228    #[arg(long)]
229    url: Option<String>,
230}
231
232#[derive(Args)]
233struct AddContact {
234    #[command(flatten)]
235    common: CommonRecordArgs,
236    #[arg(long, required = true)]
237    full_name: String,
238    #[arg(long, value_delimiter = ',')]
239    emails: Vec<String>,
240    #[arg(long, value_delimiter = ',')]
241    phones: Vec<String>,
242}
243
244#[derive(Args)]
245struct AddIdentity {
246    #[command(flatten)]
247    common: CommonRecordArgs,
248    #[arg(long)]
249    id_type: Option<String>,
250    #[arg(long)]
251    name_on_doc: Option<String>,
252    #[arg(long)]
253    number: Option<String>,
254    #[arg(long)]
255    issuing_country: Option<String>,
256    #[arg(long)]
257    expiry: Option<String>,
258}
259
260#[derive(Args)]
261struct AddNote {
262    #[command(flatten)]
263    common: CommonRecordArgs,
264}
265
266#[derive(Args)]
267struct AddBank {
268    #[command(flatten)]
269    common: CommonRecordArgs,
270    #[arg(long)]
271    institution: Option<String>,
272    #[arg(long)]
273    account_name: Option<String>,
274    #[arg(long)]
275    routing_number: Option<String>,
276}
277
278#[derive(Args)]
279struct AddWifi {
280    #[command(flatten)]
281    common: CommonRecordArgs,
282    #[arg(long)]
283    ssid: Option<String>,
284    #[arg(long)]
285    security: Option<String>,
286    #[arg(long)]
287    location: Option<String>,
288}
289
290#[derive(Args)]
291struct AddApi {
292    #[command(flatten)]
293    common: CommonRecordArgs,
294    #[arg(long)]
295    service: Option<String>,
296    #[arg(long)]
297    environment: Option<String>,
298    #[arg(long)]
299    access_key: Option<String>,
300    #[arg(long, value_delimiter = ',')]
301    scopes: Vec<String>,
302}
303
304#[derive(Args)]
305struct AddWallet {
306    #[command(flatten)]
307    common: CommonRecordArgs,
308    #[arg(long)]
309    asset: Option<String>,
310    #[arg(long)]
311    address: Option<String>,
312    #[arg(long)]
313    network: Option<String>,
314}
315
316#[derive(Args)]
317struct AddTotp {
318    #[command(flatten)]
319    common: CommonRecordArgs,
320    /// Optional issuer string (display only)
321    #[arg(long)]
322    issuer: Option<String>,
323    /// Optional account/name label (display only)
324    #[arg(long)]
325    account: Option<String>,
326    /// Base32-encoded secret; omit to be prompted securely
327    #[arg(long)]
328    secret: Option<String>,
329    /// Number of digits (6-8)
330    #[arg(long, default_value_t = 6)]
331    digits: u8,
332    /// Seconds per step
333    #[arg(long, default_value_t = 30)]
334    step: u64,
335    /// Allowed skew (number of steps on each side)
336    #[arg(long, default_value_t = 1)]
337    skew: u8,
338    /// Hash algorithm
339    #[arg(long, value_enum, default_value_t = TotpAlgorithm::Sha1)]
340    algorithm: TotpAlgorithm,
341}
342
343#[derive(Args)]
344struct AddSsh {
345    #[command(flatten)]
346    common: CommonRecordArgs,
347    #[arg(long)]
348    label: Option<String>,
349    #[arg(long)]
350    comment: Option<String>,
351}
352
353#[derive(Args)]
354struct AddPgp {
355    #[command(flatten)]
356    common: CommonRecordArgs,
357    #[arg(long)]
358    label: Option<String>,
359    #[arg(long)]
360    fingerprint: Option<String>,
361}
362
363#[derive(Args)]
364struct AddRecovery {
365    #[command(flatten)]
366    common: CommonRecordArgs,
367    #[arg(long)]
368    description: Option<String>,
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Hash)]
372#[serde(rename_all = "snake_case")]
373#[clap(rename_all = "snake_case")]
374enum RecordKind {
375    Login,
376    Contact,
377    Id,
378    Note,
379    Bank,
380    Wifi,
381    Api,
382    Wallet,
383    Totp,
384    Ssh,
385    Pgp,
386    Recovery,
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
390#[serde(rename_all = "lowercase")]
391enum TotpAlgorithm {
392    Sha1,
393    Sha256,
394    Sha512,
395}
396
397impl TotpAlgorithm {
398    fn to_lib(self) -> TotpAlgorithmLib {
399        match self {
400            TotpAlgorithm::Sha1 => TotpAlgorithmLib::SHA1,
401            TotpAlgorithm::Sha256 => TotpAlgorithmLib::SHA256,
402            TotpAlgorithm::Sha512 => TotpAlgorithmLib::SHA512,
403        }
404    }
405}
406
407impl fmt::Display for TotpAlgorithm {
408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409        match self {
410            TotpAlgorithm::Sha1 => f.write_str("sha1"),
411            TotpAlgorithm::Sha256 => f.write_str("sha256"),
412            TotpAlgorithm::Sha512 => f.write_str("sha512"),
413        }
414    }
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
418struct Record {
419    id: Uuid,
420    created_at: DateTime<Utc>,
421    updated_at: DateTime<Utc>,
422    title: Option<String>,
423    tags: Vec<String>,
424    metadata_notes: Option<String>,
425    data: RecordData,
426}
427
428impl Record {
429    fn new(
430        data: RecordData,
431        title: Option<String>,
432        tags: Vec<String>,
433        notes: Option<String>,
434    ) -> Self {
435        let now = Utc::now();
436        Self {
437            id: Uuid::new_v4(),
438            created_at: now,
439            updated_at: now,
440            title,
441            tags,
442            metadata_notes: notes,
443            data,
444        }
445    }
446
447    fn kind(&self) -> RecordKind {
448        self.data.kind()
449    }
450
451    fn matches_tag(&self, tag: &str) -> bool {
452        let tag_lower = tag.to_ascii_lowercase();
453        self.tags
454            .iter()
455            .any(|t| t.to_ascii_lowercase().contains(&tag_lower))
456    }
457
458    fn matches_query(&self, needle: &str) -> bool {
459        let haystack = [
460            self.title.as_deref().unwrap_or_default(),
461            self.metadata_notes.as_deref().unwrap_or_default(),
462            &self.data.summary_text(),
463        ]
464        .join("\n")
465        .to_ascii_lowercase();
466        haystack.contains(&needle.to_ascii_lowercase())
467    }
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
471#[serde(tag = "kind", rename_all = "snake_case")]
472enum RecordData {
473    Login {
474        username: Option<String>,
475        url: Option<String>,
476        password: Sensitive,
477    },
478    Contact {
479        full_name: String,
480        emails: Vec<String>,
481        phones: Vec<String>,
482    },
483    Id {
484        id_type: Option<String>,
485        name_on_doc: Option<String>,
486        number: Option<String>,
487        issuing_country: Option<String>,
488        expiry: Option<String>,
489        secret: Option<Sensitive>,
490    },
491    Note {
492        body: Sensitive,
493    },
494    Bank {
495        institution: Option<String>,
496        account_name: Option<String>,
497        routing_number: Option<String>,
498        account_number: Sensitive,
499    },
500    Wifi {
501        ssid: Option<String>,
502        security: Option<String>,
503        location: Option<String>,
504        passphrase: Sensitive,
505    },
506    Api {
507        service: Option<String>,
508        environment: Option<String>,
509        access_key: Option<String>,
510        secret_key: Sensitive,
511        scopes: Vec<String>,
512    },
513    Wallet {
514        asset: Option<String>,
515        address: Option<String>,
516        network: Option<String>,
517        secret_key: Sensitive,
518    },
519    Totp {
520        issuer: Option<String>,
521        account: Option<String>,
522        secret: Sensitive,
523        digits: u8,
524        step: u64,
525        skew: u8,
526        algorithm: TotpAlgorithm,
527    },
528    Ssh {
529        label: Option<String>,
530        private_key: Sensitive,
531        comment: Option<String>,
532    },
533    Pgp {
534        label: Option<String>,
535        fingerprint: Option<String>,
536        armored_private_key: Sensitive,
537    },
538    Recovery {
539        description: Option<String>,
540        payload: Sensitive,
541    },
542}
543
544impl RecordData {
545    fn kind(&self) -> RecordKind {
546        match self {
547            RecordData::Login { .. } => RecordKind::Login,
548            RecordData::Contact { .. } => RecordKind::Contact,
549            RecordData::Id { .. } => RecordKind::Id,
550            RecordData::Note { .. } => RecordKind::Note,
551            RecordData::Bank { .. } => RecordKind::Bank,
552            RecordData::Wifi { .. } => RecordKind::Wifi,
553            RecordData::Api { .. } => RecordKind::Api,
554            RecordData::Wallet { .. } => RecordKind::Wallet,
555            RecordData::Totp { .. } => RecordKind::Totp,
556            RecordData::Ssh { .. } => RecordKind::Ssh,
557            RecordData::Pgp { .. } => RecordKind::Pgp,
558            RecordData::Recovery { .. } => RecordKind::Recovery,
559        }
560    }
561
562    fn summary_text(&self) -> String {
563        match self {
564            RecordData::Login { username, url, .. } => format!(
565                "user={} url={}",
566                username.as_deref().unwrap_or("-"),
567                url.as_deref().unwrap_or("-")
568            ),
569            RecordData::Contact {
570                full_name,
571                emails,
572                phones,
573            } => format!(
574                "{} | emails={} | phones={}",
575                full_name,
576                if emails.is_empty() {
577                    "-".to_string()
578                } else {
579                    emails.join(",")
580                },
581                if phones.is_empty() {
582                    "-".to_string()
583                } else {
584                    phones.join(",")
585                }
586            ),
587            RecordData::Id {
588                id_type,
589                number,
590                expiry,
591                ..
592            } => format!(
593                "type={} number={} expiry={}",
594                id_type.as_deref().unwrap_or("-"),
595                number.as_deref().unwrap_or("-"),
596                expiry.as_deref().unwrap_or("-")
597            ),
598            RecordData::Note { .. } => "secure note".to_string(),
599            RecordData::Bank {
600                institution,
601                account_name,
602                routing_number,
603                ..
604            } => format!(
605                "institution={} account={} routing={}",
606                institution.as_deref().unwrap_or("-"),
607                account_name.as_deref().unwrap_or("-"),
608                routing_number.as_deref().unwrap_or("-")
609            ),
610            RecordData::Wifi {
611                ssid,
612                security,
613                location,
614                ..
615            } => format!(
616                "ssid={} security={} location={}",
617                ssid.as_deref().unwrap_or("-"),
618                security.as_deref().unwrap_or("-"),
619                location.as_deref().unwrap_or("-")
620            ),
621            RecordData::Api {
622                service,
623                environment,
624                scopes,
625                ..
626            } => format!(
627                "service={} env={} scopes={}",
628                service.as_deref().unwrap_or("-"),
629                environment.as_deref().unwrap_or("-"),
630                if scopes.is_empty() {
631                    "-".to_string()
632                } else {
633                    scopes.join(",")
634                }
635            ),
636            RecordData::Wallet {
637                asset,
638                address,
639                network,
640                ..
641            } => format!(
642                "asset={} address={} network={}",
643                asset.as_deref().unwrap_or("-"),
644                address.as_deref().unwrap_or("-"),
645                network.as_deref().unwrap_or("-")
646            ),
647            RecordData::Totp {
648                issuer,
649                account,
650                digits,
651                step,
652                ..
653            } => format!(
654                "issuer={} account={} digits={} step={}",
655                issuer.as_deref().unwrap_or("-"),
656                account.as_deref().unwrap_or("-"),
657                digits,
658                step
659            ),
660            RecordData::Ssh { label, comment, .. } => format!(
661                "label={} comment={}",
662                label.as_deref().unwrap_or("-"),
663                comment.as_deref().unwrap_or("-")
664            ),
665            RecordData::Pgp {
666                label, fingerprint, ..
667            } => format!(
668                "label={} fingerprint={}",
669                label.as_deref().unwrap_or("-"),
670                fingerprint.as_deref().unwrap_or("-")
671            ),
672            RecordData::Recovery { description, .. } => {
673                format!("description={}", description.as_deref().unwrap_or("-"))
674            }
675        }
676    }
677}
678
679#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
680struct Sensitive {
681    #[serde(with = "serde_bytes")]
682    data: Vec<u8>,
683}
684
685impl Sensitive {
686    fn new_from_utf8(value: &[u8]) -> Self {
687        Self {
688            data: value.to_vec(),
689        }
690    }
691
692    fn from_string(value: &str) -> Self {
693        Self::new_from_utf8(value.as_bytes())
694    }
695
696    fn as_slice(&self) -> &[u8] {
697        &self.data
698    }
699
700    fn expose_utf8(&self) -> Result<String> {
701        Ok(String::from_utf8(self.data.clone())?)
702    }
703}
704
705impl Drop for Sensitive {
706    fn drop(&mut self) {
707        self.data.zeroize();
708    }
709}
710
711impl ZeroizeOnDrop for Sensitive {}
712
713#[derive(Serialize, Deserialize, Clone)]
714struct VaultFile {
715    version: u32,
716    header: VaultHeader,
717    payload: AeadBlob,
718}
719
720#[derive(Serialize, Deserialize, Clone)]
721struct VaultHeader {
722    created_at: DateTime<Utc>,
723    updated_at: DateTime<Utc>,
724    argon: ArgonState,
725    kem_public: Vec<u8>,
726    kem_ciphertext: Vec<u8>,
727    sealed_decapsulation: AeadBlob,
728    sealed_dek: AeadBlob,
729}
730
731#[derive(Serialize, Deserialize, Clone)]
732struct ArgonState {
733    mem_cost_kib: u32,
734    time_cost: u32,
735    lanes: u32,
736    salt: [u8; 32],
737}
738
739#[derive(Serialize, Deserialize, Clone)]
740struct AeadBlob {
741    nonce: [u8; 24],
742    ciphertext: Vec<u8>,
743}
744
745#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
746struct VaultPayload {
747    records: Vec<Record>,
748    record_counter: u64,
749}
750
751struct Vault {
752    path: PathBuf,
753    file: VaultFile,
754    payload: VaultPayload,
755    dek: Zeroizing<[u8; 32]>,
756}
757
758impl Vault {
759    fn init(path: &Path, passphrase: &Zeroizing<String>, mem_kib: u32) -> Result<()> {
760        if path.exists() {
761            bail!("vault already exists at {}", path.display());
762        }
763
764        if mem_kib < 32_768 {
765            bail!("mem-kib must be at least 32768 (32 MiB)");
766        }
767
768        let mut salt = [0u8; 32];
769        OsRng.fill_bytes(&mut salt);
770
771        let argon = ArgonState {
772            mem_cost_kib: mem_kib,
773            time_cost: DEFAULT_TIME_COST,
774            lanes: DEFAULT_LANES,
775            salt,
776        };
777
778        let kek = derive_kek(passphrase, &argon)?;
779
780        let (dk, ek) = <MlKem1024 as KemCore>::generate(&mut OsRng);
781        let (kem_ct, shared_key) = ek
782            .encapsulate(&mut OsRng)
783            .map_err(|e| anyhow!("ml-kem encapsulate failed: {e:?}"))?;
784
785        let mut dek_bytes = [0u8; 32];
786        OsRng.fill_bytes(&mut dek_bytes);
787        let dek = Zeroizing::new(dek_bytes);
788
789        let sealed_dek = encrypt_blob(shared_key.as_slice(), dek.as_slice(), AAD_DEK)?;
790
791        let dk_bytes = dk.as_bytes();
792        let sealed_decapsulation = encrypt_blob(kek.as_slice(), dk_bytes.as_slice(), AAD_DK)?;
793
794        let payload = VaultPayload {
795            records: Vec::new(),
796            record_counter: 0,
797        };
798        let payload_blob = encrypt_payload(dek.as_ref(), &payload)?;
799
800        let header = VaultHeader {
801            created_at: Utc::now(),
802            updated_at: Utc::now(),
803            argon,
804            kem_public: ek.as_bytes().to_vec(),
805            kem_ciphertext: kem_ct.to_vec(),
806            sealed_decapsulation,
807            sealed_dek,
808        };
809
810        let file = VaultFile {
811            version: VAULT_VERSION,
812            header,
813            payload: payload_blob,
814        };
815
816        write_vault(path, &file)
817    }
818
819    fn load(path: &Path, passphrase: &Zeroizing<String>) -> Result<Self> {
820        if !path.exists() {
821            bail!("vault not initialized at {}", path.display());
822        }
823        let mut file = OpenOptions::new()
824            .read(true)
825            .open(path)
826            .with_context(|| format!("failed to open vault at {}", path.display()))?;
827
828        let mut buf = Zeroizing::new(Vec::new());
829        file.read_to_end(&mut buf)?;
830        let vault_file: VaultFile = from_reader(buf.as_slice()).context("failed to parse vault")?;
831
832        if vault_file.version != VAULT_VERSION {
833            bail!("unsupported vault version {}", vault_file.version);
834        }
835
836        let kek = derive_kek(passphrase, &vault_file.header.argon)?;
837        let dk_bytes = Zeroizing::new(decrypt_blob(
838            kek.as_slice(),
839            &vault_file.header.sealed_decapsulation,
840            AAD_DK,
841        )?);
842        type DecapKey = <MlKem1024 as KemCore>::DecapsulationKey;
843        let dk_encoded = expect_encoded::<DecapKey>(dk_bytes.as_slice(), "decapsulation key")?;
844        let dk = DecapKey::from_bytes(&dk_encoded);
845
846        let kem_ct = expect_ciphertext(&vault_file.header.kem_ciphertext)?;
847        let shared = dk
848            .decapsulate(&kem_ct)
849            .map_err(|_| anyhow!("ml-kem decapsulation failed"))?;
850
851        let dek_bytes = Zeroizing::new(decrypt_blob(
852            shared.as_slice(),
853            &vault_file.header.sealed_dek,
854            AAD_DEK,
855        )?);
856        if dek_bytes.len() != 32 {
857            bail!("invalid dek length");
858        }
859        let mut dek_array = [0u8; 32];
860        dek_array.copy_from_slice(dek_bytes.as_slice());
861        let dek = Zeroizing::new(dek_array);
862
863        let payload: VaultPayload = decrypt_payload(dek.as_ref(), &vault_file.payload)?;
864
865        Ok(Self {
866            path: path.to_path_buf(),
867            file: vault_file,
868            payload,
869            dek,
870        })
871    }
872
873    fn save(&mut self, passphrase: &Zeroizing<String>) -> Result<()> {
874        self.file.header.updated_at = Utc::now();
875
876        let payload_blob = encrypt_payload(self.dek.as_ref(), &self.payload)?;
877        self.file.payload = payload_blob;
878
879        let kek = derive_kek(passphrase, &self.file.header.argon)?;
880
881        let dk_bytes = Zeroizing::new(decrypt_blob(
882            kek.as_slice(),
883            &self.file.header.sealed_decapsulation,
884            AAD_DK,
885        )?);
886        type DecapKey = <MlKem1024 as KemCore>::DecapsulationKey;
887        let dk_encoded = expect_encoded::<DecapKey>(dk_bytes.as_slice(), "decapsulation key")?;
888        let dk = DecapKey::from_bytes(&dk_encoded);
889        let (kem_ct, shared) = {
890            type EncKey = <MlKem1024 as KemCore>::EncapsulationKey;
891            let ek_encoded =
892                expect_encoded::<EncKey>(&self.file.header.kem_public, "encapsulation key")?;
893            let ek = EncKey::from_bytes(&ek_encoded);
894            ek.encapsulate(&mut OsRng)
895                .map_err(|e| anyhow!("ml-kem encapsulate failed: {e:?}"))?
896        };
897
898        let sealed_dek = encrypt_blob(shared.as_slice(), self.dek.as_ref(), AAD_DEK)?;
899        let sealed_decapsulation = encrypt_blob(kek.as_slice(), dk.as_bytes().as_slice(), AAD_DK)?;
900
901        self.file.header.kem_ciphertext = kem_ct.to_vec();
902        self.file.header.sealed_dek = sealed_dek;
903        self.file.header.sealed_decapsulation = sealed_decapsulation;
904
905        write_vault(&self.path, &self.file)
906    }
907
908    fn add_record(&mut self, record: Record) {
909        self.payload.record_counter = self.payload.record_counter.saturating_add(1);
910        self.payload.records.push(record);
911    }
912
913    fn list(
914        &self,
915        kind: Option<RecordKind>,
916        tag: Option<&str>,
917        query: Option<&str>,
918    ) -> Vec<&Record> {
919        self.payload
920            .records
921            .iter()
922            .filter(|rec| kind.map(|k| rec.kind() == k).unwrap_or(true))
923            .filter(|rec| tag.map(|t| rec.matches_tag(t)).unwrap_or(true))
924            .filter(|rec| query.map(|q| rec.matches_query(q)).unwrap_or(true))
925            .collect()
926    }
927
928    fn get(&mut self, id: Uuid) -> Option<&mut Record> {
929        self.payload.records.iter_mut().find(|rec| rec.id == id)
930    }
931
932    fn get_ref(&self, id: Uuid) -> Option<&Record> {
933        self.payload.records.iter().find(|rec| rec.id == id)
934    }
935
936    fn rotate(&mut self, passphrase: &Zeroizing<String>, mem_kib: Option<u32>) -> Result<()> {
937        if let Some(mem) = mem_kib {
938            if mem < 32_768 {
939                bail!("mem-kib must be at least 32768 (32 MiB)");
940            }
941            self.file.header.argon.mem_cost_kib = mem;
942            OsRng.fill_bytes(&mut self.file.header.argon.salt);
943        }
944
945        let (dk, ek) = <MlKem1024 as KemCore>::generate(&mut OsRng);
946        let (kem_ct, shared_key) = ek
947            .encapsulate(&mut OsRng)
948            .map_err(|e| anyhow!("ml-kem encapsulate failed: {e:?}"))?;
949
950        let kek = derive_kek(passphrase, &self.file.header.argon)?;
951        let sealed_dek = encrypt_blob(shared_key.as_slice(), self.dek.as_ref(), AAD_DEK)?;
952        let sealed_decapsulation = encrypt_blob(kek.as_slice(), dk.as_bytes().as_slice(), AAD_DK)?;
953
954        self.file.header.kem_public = ek.as_bytes().to_vec();
955        self.file.header.kem_ciphertext = kem_ct.to_vec();
956        self.file.header.sealed_dek = sealed_dek;
957        self.file.header.sealed_decapsulation = sealed_decapsulation;
958
959        Ok(())
960    }
961
962    fn stats(&self) -> VaultStats {
963        VaultStats {
964            created_at: self.file.header.created_at,
965            updated_at: self.file.header.updated_at,
966            record_count: self.payload.records.len(),
967            argon_mem_kib: self.file.header.argon.mem_cost_kib,
968            argon_time_cost: self.file.header.argon.time_cost,
969            argon_lanes: self.file.header.argon.lanes,
970        }
971    }
972}
973
974impl Drop for Vault {
975    fn drop(&mut self) {
976        self.dek.zeroize();
977    }
978}
979
980struct VaultStats {
981    created_at: DateTime<Utc>,
982    updated_at: DateTime<Utc>,
983    record_count: usize,
984    argon_mem_kib: u32,
985    argon_time_cost: u32,
986    argon_lanes: u32,
987}
988
989fn derive_kek(passphrase: &Zeroizing<String>, argon: &ArgonState) -> Result<Zeroizing<[u8; 32]>> {
990    let params = Params::new(argon.mem_cost_kib, argon.time_cost, argon.lanes, Some(32))
991        .map_err(|e| anyhow!("invalid Argon2 parameters: {e}"))?;
992    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
993    let mut output = Zeroizing::new([0u8; 32]);
994    argon2
995        .hash_password_into(passphrase.as_bytes(), &argon.salt, output.as_mut())
996        .map_err(|e| anyhow!("argon2 derivation failed: {e}"))?;
997    Ok(output)
998}
999
1000fn expect_encoded<T>(data: &[u8], label: &str) -> Result<Encoded<T>>
1001where
1002    T: EncodedSizeUser,
1003{
1004    let expected = <T as EncodedSizeUser>::EncodedSize::USIZE;
1005    Array::try_from_iter(data.iter().copied()).map_err(|_| {
1006        anyhow!(
1007            "invalid {label} length: expected {expected}, got {}",
1008            data.len()
1009        )
1010    })
1011}
1012
1013fn expect_ciphertext(data: &[u8]) -> Result<Ciphertext<MlKem1024>> {
1014    let expected = <MlKem1024 as KemCore>::CiphertextSize::USIZE;
1015    Array::try_from_iter(data.iter().copied()).map_err(|_| {
1016        anyhow!(
1017            "invalid ciphertext length: expected {expected}, got {}",
1018            data.len()
1019        )
1020    })
1021}
1022
1023fn encrypt_blob(key: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<AeadBlob> {
1024    let mut nonce = [0u8; 24];
1025    OsRng.fill_bytes(&mut nonce);
1026    let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
1027    let ciphertext = cipher
1028        .encrypt(
1029            XNonce::from_slice(&nonce),
1030            Payload {
1031                msg: plaintext,
1032                aad,
1033            },
1034        )
1035        .map_err(|_| anyhow!("encryption failed"))?;
1036    Ok(AeadBlob { nonce, ciphertext })
1037}
1038
1039fn decrypt_blob(key: &[u8], blob: &AeadBlob, aad: &[u8]) -> Result<Vec<u8>> {
1040    let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
1041    let plaintext = cipher
1042        .decrypt(
1043            XNonce::from_slice(&blob.nonce),
1044            Payload {
1045                msg: &blob.ciphertext,
1046                aad,
1047            },
1048        )
1049        .map_err(|_| anyhow!("decryption failed"))?;
1050    Ok(plaintext)
1051}
1052
1053fn encrypt_payload(dek: &[u8], payload: &VaultPayload) -> Result<AeadBlob> {
1054    let mut buf = Vec::new();
1055    into_writer(payload, &mut buf).context("failed to serialize payload")?;
1056    encrypt_blob(dek, &buf, AAD_PAYLOAD)
1057}
1058
1059fn decrypt_payload(dek: &[u8], blob: &AeadBlob) -> Result<VaultPayload> {
1060    let plaintext = decrypt_blob(dek, blob, AAD_PAYLOAD)?;
1061    let payload: VaultPayload =
1062        from_reader(plaintext.as_slice()).context("failed to parse payload")?;
1063    Ok(payload)
1064}
1065
1066fn vault_path() -> Result<PathBuf> {
1067    if let Ok(path) = env::var("BLACK_BAG_VAULT_PATH") {
1068        let pb = PathBuf::from(path);
1069        if let Some(parent) = pb.parent() {
1070            fs::create_dir_all(parent)
1071                .with_context(|| format!("failed to create {}", parent.display()))?;
1072        }
1073        return Ok(pb);
1074    }
1075    let base = BaseDirs::new().ok_or_else(|| anyhow!("unable to resolve base directory"))?;
1076    let dir = base.config_dir().join("black_bag");
1077    fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
1078    Ok(dir.join("vault.cbor"))
1079}
1080
1081fn prompt_passphrase(prompt: &str) -> Result<Zeroizing<String>> {
1082    let value = prompt_password(prompt)?;
1083    if value.trim().is_empty() {
1084        bail!("passphrase cannot be empty");
1085    }
1086    Ok(Zeroizing::new(value))
1087}
1088
1089fn init_vault(cmd: InitCommand) -> Result<()> {
1090    let path = vault_path()?;
1091    let pass1 = prompt_passphrase("Master passphrase: ")?;
1092    let pass2 = prompt_passphrase("Confirm passphrase: ")?;
1093    if pass1.as_str() != pass2.as_str() {
1094        bail!("passphrases do not match");
1095    }
1096    Vault::init(&path, &pass1, cmd.mem_kib)?;
1097    println!("Initialized vault at {}", path.display());
1098    Ok(())
1099}
1100
1101fn load_vault_with_prompt() -> Result<(Vault, Zeroizing<String>)> {
1102    let path = vault_path()?;
1103    let pass = prompt_passphrase("Master passphrase: ")?;
1104    let vault = Vault::load(&path, &pass)?;
1105    Ok((vault, pass))
1106}
1107
1108fn add_record(cmd: AddCommand) -> Result<()> {
1109    let (mut vault, pass) = load_vault_with_prompt()?;
1110
1111    let record = match cmd.record {
1112        AddRecord::Login(args) => {
1113            let CommonRecordArgs { title, tags, notes } = args.common;
1114            let password = prompt_hidden("Password: ")?;
1115            Record::new(
1116                RecordData::Login {
1117                    username: args.username,
1118                    url: args.url,
1119                    password,
1120                },
1121                title,
1122                tags,
1123                notes,
1124            )
1125        }
1126        AddRecord::Contact(args) => {
1127            let CommonRecordArgs { title, tags, notes } = args.common;
1128            Record::new(
1129                RecordData::Contact {
1130                    full_name: args.full_name,
1131                    emails: args.emails,
1132                    phones: args.phones,
1133                },
1134                title,
1135                tags,
1136                notes,
1137            )
1138        }
1139        AddRecord::Id(args) => {
1140            let CommonRecordArgs { title, tags, notes } = args.common;
1141            let secret = prompt_optional_hidden("Sensitive document secret (optional): ")?;
1142            Record::new(
1143                RecordData::Id {
1144                    id_type: args.id_type,
1145                    name_on_doc: args.name_on_doc,
1146                    number: args.number,
1147                    issuing_country: args.issuing_country,
1148                    expiry: args.expiry,
1149                    secret,
1150                },
1151                title,
1152                tags,
1153                notes,
1154            )
1155        }
1156        AddRecord::Note(args) => {
1157            let CommonRecordArgs { title, tags, notes } = args.common;
1158            let body = prompt_multiline("Secure note body (Ctrl-D to finish): ")?;
1159            Record::new(RecordData::Note { body }, title, tags, notes)
1160        }
1161        AddRecord::Bank(args) => {
1162            let CommonRecordArgs { title, tags, notes } = args.common;
1163            let account_number = prompt_hidden("Account number / secret: ")?;
1164            Record::new(
1165                RecordData::Bank {
1166                    institution: args.institution,
1167                    account_name: args.account_name,
1168                    routing_number: args.routing_number,
1169                    account_number,
1170                },
1171                title,
1172                tags,
1173                notes,
1174            )
1175        }
1176        AddRecord::Wifi(args) => {
1177            let CommonRecordArgs { title, tags, notes } = args.common;
1178            let passphrase = prompt_hidden("Wi-Fi passphrase: ")?;
1179            Record::new(
1180                RecordData::Wifi {
1181                    ssid: args.ssid,
1182                    security: args.security,
1183                    location: args.location,
1184                    passphrase,
1185                },
1186                title,
1187                tags,
1188                notes,
1189            )
1190        }
1191        AddRecord::Api(args) => {
1192            let CommonRecordArgs { title, tags, notes } = args.common;
1193            let secret = prompt_hidden("Secret key: ")?;
1194            Record::new(
1195                RecordData::Api {
1196                    service: args.service,
1197                    environment: args.environment,
1198                    access_key: args.access_key,
1199                    secret_key: secret,
1200                    scopes: args.scopes,
1201                },
1202                title,
1203                tags,
1204                notes,
1205            )
1206        }
1207        AddRecord::Wallet(args) => {
1208            let CommonRecordArgs { title, tags, notes } = args.common;
1209            let secret = prompt_hidden("Wallet secret material: ")?;
1210            Record::new(
1211                RecordData::Wallet {
1212                    asset: args.asset,
1213                    address: args.address,
1214                    network: args.network,
1215                    secret_key: secret,
1216                },
1217                title,
1218                tags,
1219                notes,
1220            )
1221        }
1222        AddRecord::Totp(args) => {
1223            let AddTotp {
1224                common,
1225                issuer,
1226                account,
1227                secret,
1228                digits,
1229                step,
1230                skew,
1231                algorithm,
1232            } = args;
1233            if !(6..=8).contains(&digits) {
1234                bail!("digits must be between 6 and 8");
1235            }
1236            if step == 0 {
1237                bail!("step must be greater than zero");
1238            }
1239            let CommonRecordArgs { title, tags, notes } = common;
1240            let secret_bytes = match secret {
1241                Some(s) => parse_totp_secret(&s)?,
1242                None => {
1243                    let input = prompt_hidden("Base32 secret: ")?;
1244                    let value = Zeroizing::new(input.expose_utf8()?);
1245                    parse_totp_secret(value.as_str())?
1246                }
1247            };
1248            let totp_secret = Sensitive { data: secret_bytes };
1249            build_totp_instance(
1250                &totp_secret,
1251                digits,
1252                step,
1253                skew,
1254                algorithm,
1255                &issuer,
1256                &account,
1257            )?;
1258            Record::new(
1259                RecordData::Totp {
1260                    issuer,
1261                    account,
1262                    secret: totp_secret,
1263                    digits,
1264                    step,
1265                    skew,
1266                    algorithm,
1267                },
1268                title,
1269                tags,
1270                notes,
1271            )
1272        }
1273        AddRecord::Ssh(args) => {
1274            let CommonRecordArgs { title, tags, notes } = args.common;
1275            let private_key = prompt_multiline_hidden("Paste private key (Ctrl-D to finish): ")?;
1276            Record::new(
1277                RecordData::Ssh {
1278                    label: args.label,
1279                    private_key,
1280                    comment: args.comment,
1281                },
1282                title,
1283                tags,
1284                notes,
1285            )
1286        }
1287        AddRecord::Pgp(args) => {
1288            let CommonRecordArgs { title, tags, notes } = args.common;
1289            let armored =
1290                prompt_multiline_hidden("Paste armored private key (Ctrl-D to finish): ")?;
1291            Record::new(
1292                RecordData::Pgp {
1293                    label: args.label,
1294                    fingerprint: args.fingerprint,
1295                    armored_private_key: armored,
1296                },
1297                title,
1298                tags,
1299                notes,
1300            )
1301        }
1302        AddRecord::Recovery(args) => {
1303            let CommonRecordArgs { title, tags, notes } = args.common;
1304            let payload = prompt_multiline_hidden("Paste recovery payload (Ctrl-D to finish): ")?;
1305            Record::new(
1306                RecordData::Recovery {
1307                    description: args.description,
1308                    payload,
1309                },
1310                title,
1311                tags,
1312                notes,
1313            )
1314        }
1315    };
1316
1317    vault.add_record(record);
1318    vault.save(&pass)?;
1319    println!("Record added");
1320    Ok(())
1321}
1322fn list_records(cmd: ListCommand) -> Result<()> {
1323    let (vault, _) = load_vault_with_prompt()?;
1324    let list = vault.list(cmd.kind, cmd.tag.as_deref(), cmd.query.as_deref());
1325    if list.is_empty() {
1326        println!("No matching records");
1327        return Ok(());
1328    }
1329    for record in list {
1330        println!(
1331            "{} | {} | {} | tags=[{}] | {}",
1332            record.id,
1333            record.kind(),
1334            record.title.as_deref().unwrap_or("(untitled)"),
1335            if record.tags.is_empty() {
1336                String::new()
1337            } else {
1338                record.tags.join(",")
1339            },
1340            record.data.summary_text()
1341        );
1342    }
1343    Ok(())
1344}
1345
1346fn get_record(cmd: GetCommand) -> Result<()> {
1347    let (mut vault, _) = load_vault_with_prompt()?;
1348    if let Some(record) = vault.get(cmd.id) {
1349        println!("id: {}", record.id);
1350        println!("kind: {}", record.kind());
1351        if let Some(title) = &record.title {
1352            println!("title: {}", title);
1353        }
1354        if !record.tags.is_empty() {
1355            println!("tags: {}", record.tags.join(","));
1356        }
1357        if let Some(notes) = &record.metadata_notes {
1358            println!("notes: {}", notes);
1359        }
1360        if cmd.reveal {
1361            if !io::stdout().is_terminal() {
1362                bail!("--reveal requires an interactive TTY");
1363            }
1364            render_sensitive(record)?;
1365        } else {
1366            println!("(Sensitive fields hidden; re-run with --reveal on a TTY)");
1367        }
1368    } else {
1369        bail!("record {} not found", cmd.id);
1370    }
1371    Ok(())
1372}
1373
1374fn render_sensitive(record: &Record) -> Result<()> {
1375    match &record.data {
1376        RecordData::Login { password, .. } => {
1377            println!("password: {}", password.expose_utf8()?);
1378        }
1379        RecordData::Contact { .. } => {}
1380        RecordData::Id { secret, .. } => {
1381            if let Some(secret) = secret {
1382                println!("secret: {}", secret.expose_utf8()?);
1383            }
1384        }
1385        RecordData::Note { body } => {
1386            println!("note:\n{}", body.expose_utf8()?);
1387        }
1388        RecordData::Bank { account_number, .. } => {
1389            println!("account_number: {}", account_number.expose_utf8()?);
1390        }
1391        RecordData::Wifi { passphrase, .. } => {
1392            println!("passphrase: {}", passphrase.expose_utf8()?);
1393        }
1394        RecordData::Api { secret_key, .. } => {
1395            println!("secret_key: {}", secret_key.expose_utf8()?);
1396        }
1397        RecordData::Wallet { secret_key, .. } => {
1398            println!("secret_key: {}", secret_key.expose_utf8()?);
1399        }
1400        RecordData::Totp {
1401            issuer,
1402            account,
1403            secret,
1404            digits,
1405            step,
1406            skew,
1407            algorithm,
1408        } => {
1409            let base32 = base32::encode(Rfc4648 { padding: false }, secret.as_slice());
1410            println!("secret_base32: {}", base32);
1411            if let Some(issuer) = issuer {
1412                println!("issuer: {}", issuer);
1413            }
1414            if let Some(account) = account {
1415                println!("account: {}", account);
1416            }
1417            println!("digits: {}", digits);
1418            println!("step: {}s", step);
1419            println!("skew: {}", skew);
1420            println!("algorithm: {}", algorithm);
1421            if let Ok(totp) =
1422                build_totp_instance(secret, *digits, *step, *skew, *algorithm, issuer, account)
1423            {
1424                if let Ok(code) = totp.generate_current() {
1425                    println!("current_code: {}", code);
1426                    if let Ok(ttl) = totp.ttl() {
1427                        println!("ttl: {}s", ttl);
1428                    }
1429                }
1430            }
1431        }
1432        RecordData::Ssh { private_key, .. } => {
1433            println!("private_key:\n{}", private_key.expose_utf8()?);
1434        }
1435        RecordData::Pgp {
1436            armored_private_key,
1437            ..
1438        } => {
1439            println!("pgp_private_key:\n{}", armored_private_key.expose_utf8()?);
1440        }
1441        RecordData::Recovery { payload, .. } => {
1442            println!("recovery_payload:\n{}", payload.expose_utf8()?);
1443        }
1444    }
1445    Ok(())
1446}
1447
1448fn parse_totp_secret(input: &str) -> Result<Vec<u8>> {
1449    let cleaned: String = input
1450        .chars()
1451        .filter(|c| !c.is_whitespace() && *c != '-')
1452        .collect();
1453    if cleaned.is_empty() {
1454        bail!("secret cannot be empty");
1455    }
1456    let encoded = cleaned.to_uppercase();
1457    TotpSecret::Encoded(encoded)
1458        .to_bytes()
1459        .map_err(|_| anyhow!("invalid base32-encoded secret"))
1460}
1461
1462fn build_totp_instance(
1463    secret: &Sensitive,
1464    digits: u8,
1465    step: u64,
1466    skew: u8,
1467    algorithm: TotpAlgorithm,
1468    issuer: &Option<String>,
1469    account: &Option<String>,
1470) -> Result<TOTP> {
1471    let account_name = account.clone().unwrap_or_default();
1472    TOTP::new(
1473        algorithm.to_lib(),
1474        usize::from(digits),
1475        skew,
1476        step,
1477        secret.as_slice().to_vec(),
1478        issuer.clone(),
1479        account_name,
1480    )
1481    .map_err(|err| anyhow!("failed to construct TOTP: {err}"))
1482}
1483
1484fn split_secret(secret: &[u8], threshold: u8, share_count: u8) -> Result<Vec<Vec<u8>>> {
1485    if threshold == 0 {
1486        bail!("threshold must be at least 1");
1487    }
1488    if share_count < threshold {
1489        bail!("share count must be greater than or equal to threshold");
1490    }
1491    if share_count == 0 {
1492        bail!("share count must be at least 1");
1493    }
1494    let mut polys = Vec::with_capacity(secret.len());
1495    for &byte in secret {
1496        let mut poly = vec![byte];
1497        if threshold > 1 {
1498            let mut coeffs = vec![0u8; threshold.saturating_sub(1) as usize];
1499            OsRng.fill_bytes(&mut coeffs);
1500            poly.extend_from_slice(&coeffs);
1501        }
1502        polys.push(poly);
1503    }
1504
1505    let mut shares = Vec::with_capacity(share_count as usize);
1506    for x in 1..=share_count {
1507        let mut share = Vec::with_capacity(secret.len() + 1);
1508        share.push(x);
1509        for poly in &polys {
1510            share.push(eval_poly(poly, x));
1511        }
1512        shares.push(share);
1513    }
1514    Ok(shares)
1515}
1516
1517fn combine_secret(threshold: u8, shares: &[Vec<u8>]) -> Result<Vec<u8>> {
1518    if threshold == 0 {
1519        bail!("threshold must be at least 1");
1520    }
1521    if shares.len() < threshold as usize {
1522        bail!("not enough shares provided");
1523    }
1524    let first = shares
1525        .first()
1526        .ok_or_else(|| anyhow!("no shares provided"))?;
1527    if first.len() < 2 {
1528        bail!("share payload too short");
1529    }
1530    let share_len = first.len();
1531    for share in shares {
1532        if share.len() != share_len {
1533            bail!("shares have mismatched lengths");
1534        }
1535    }
1536
1537    let mut seen = HashSet::new();
1538    let mut usable = Vec::new();
1539    for share in shares {
1540        let id = share[0];
1541        if id == 0 {
1542            bail!("share identifier must be non-zero");
1543        }
1544        if seen.insert(id) {
1545            usable.push((id, share[1..].to_vec()));
1546            if usable.len() == threshold as usize {
1547                break;
1548            }
1549        }
1550    }
1551    if usable.len() < threshold as usize {
1552        bail!("not enough unique shares to reconstruct secret");
1553    }
1554
1555    let secret_len = share_len - 1;
1556    let mut secret = vec![0u8; secret_len];
1557    for idx in 0..secret_len {
1558        let points: Vec<(u8, u8)> = usable.iter().map(|(id, bytes)| (*id, bytes[idx])).collect();
1559        secret[idx] = interpolate_at_zero(&points)?;
1560    }
1561    Ok(secret)
1562}
1563
1564fn eval_poly(poly: &[u8], x: u8) -> u8 {
1565    let mut acc = 0u8;
1566    for &coeff in poly.iter().rev() {
1567        acc = gf_mul(acc, x) ^ coeff;
1568    }
1569    acc
1570}
1571
1572fn interpolate_at_zero(points: &[(u8, u8)]) -> Result<u8> {
1573    let mut result = 0u8;
1574    for (i, &(xi, yi)) in points.iter().enumerate() {
1575        let mut numerator = 1u8;
1576        let mut denominator = 1u8;
1577        for (j, &(xj, _)) in points.iter().enumerate() {
1578            if i == j {
1579                continue;
1580            }
1581            numerator = gf_mul(numerator, xj);
1582            let denom = xi ^ xj;
1583            if denom == 0 {
1584                bail!("duplicate share identifiers encountered");
1585            }
1586            denominator = gf_mul(denominator, denom);
1587        }
1588        let inv = gf_inv(denominator)?;
1589        let li = gf_mul(numerator, inv);
1590        result ^= gf_mul(yi, li);
1591    }
1592    Ok(result)
1593}
1594
1595fn gf_mul(mut a: u8, mut b: u8) -> u8 {
1596    let mut p = 0u8;
1597    while b != 0 {
1598        if b & 1 != 0 {
1599            p ^= a;
1600        }
1601        let hi_bit = a & 0x80;
1602        a <<= 1;
1603        if hi_bit != 0 {
1604            a ^= 0x1b;
1605        }
1606        b >>= 1;
1607    }
1608    p
1609}
1610
1611fn gf_pow(mut base: u8, mut exp: u16) -> u8 {
1612    let mut result = 1u8;
1613    while exp != 0 {
1614        if exp & 1 != 0 {
1615            result = gf_mul(result, base);
1616        }
1617        base = gf_mul(base, base);
1618        exp >>= 1;
1619    }
1620    result
1621}
1622
1623fn gf_inv(x: u8) -> Result<u8> {
1624    if x == 0 {
1625        bail!("encountered zero denominator during interpolation");
1626    }
1627    Ok(gf_pow(x, 254))
1628}
1629
1630fn rotate_vault(cmd: RotateCommand) -> Result<()> {
1631    let (mut vault, pass) = load_vault_with_prompt()?;
1632    vault.rotate(&pass, cmd.mem_kib)?;
1633    vault.save(&pass)?;
1634    println!("Rotation complete");
1635    Ok(())
1636}
1637
1638fn doctor(cmd: DoctorCommand) -> Result<()> {
1639    let (vault, _) = load_vault_with_prompt()?;
1640    let stats = vault.stats();
1641    if cmd.json {
1642        let payload = json!({
1643            "ready": true,
1644            "recordCount": stats.record_count,
1645            "argonMemKib": stats.argon_mem_kib,
1646            "argonTimeCost": stats.argon_time_cost,
1647            "argonLanes": stats.argon_lanes,
1648            "createdAt": stats.created_at.to_rfc3339(),
1649            "updatedAt": stats.updated_at.to_rfc3339(),
1650        });
1651        println!("{}", payload);
1652    } else {
1653        println!("status: ready");
1654        println!("records: {}", stats.record_count);
1655        println!("created: {}", stats.created_at);
1656        println!("updated: {}", stats.updated_at);
1657        println!(
1658            "argon2: mem={} KiB, time={}, lanes={}",
1659            stats.argon_mem_kib, stats.argon_time_cost, stats.argon_lanes
1660        );
1661    }
1662    Ok(())
1663}
1664
1665fn recovery(cmd: RecoveryCommand) -> Result<()> {
1666    match cmd {
1667        RecoveryCommand::Split(args) => {
1668            if args.threshold == 0 || args.threshold > args.shares {
1669                bail!("threshold must be between 1 and number of shares");
1670            }
1671            let secret = prompt_hidden("Secret to split: ")?;
1672            let shares = split_secret(secret.as_slice(), args.threshold, args.shares)?;
1673            for share in shares {
1674                let (id, data) = share
1675                    .split_first()
1676                    .ok_or_else(|| anyhow!("invalid share structure"))?;
1677                let encoded = BASE64.encode(data);
1678                println!("{}-{}", id, encoded);
1679            }
1680            Ok(())
1681        }
1682        RecoveryCommand::Combine(args) => {
1683            if args.threshold == 0 {
1684                bail!("threshold must be at least 1");
1685            }
1686            let mut shares = Vec::new();
1687            for part in args
1688                .shares
1689                .split(',')
1690                .map(str::trim)
1691                .filter(|s| !s.is_empty())
1692            {
1693                let (id, data) = part
1694                    .split_once('-')
1695                    .ok_or_else(|| anyhow!("invalid share format: {part}"))?;
1696                let identifier: u8 = id.parse().context("invalid share identifier")?;
1697                if identifier == 0 {
1698                    bail!("share identifier must be between 1 and 255");
1699                }
1700                let mut decoded = BASE64.decode(data).context("invalid base64 in share")?;
1701                if decoded.is_empty() {
1702                    bail!("share payload cannot be empty");
1703                }
1704                let mut share = Vec::with_capacity(decoded.len() + 1);
1705                share.push(identifier);
1706                share.append(&mut decoded);
1707                shares.push(share);
1708            }
1709            if shares.len() < args.threshold as usize {
1710                bail!(
1711                    "insufficient shares provided (need at least {})",
1712                    args.threshold
1713                );
1714            }
1715            let secret = combine_secret(args.threshold, &shares)?;
1716            println!("{}", String::from_utf8_lossy(&secret));
1717            Ok(())
1718        }
1719    }
1720}
1721
1722fn totp(cmd: TotpCommand) -> Result<()> {
1723    match cmd {
1724        TotpCommand::Code(args) => totp_code(args),
1725    }
1726}
1727
1728fn totp_code(args: TotpCodeCommand) -> Result<()> {
1729    let (vault, _) = load_vault_with_prompt()?;
1730    let record = vault
1731        .get_ref(args.id)
1732        .ok_or_else(|| anyhow!("record {} not found", args.id))?;
1733    let (issuer, account, secret, digits, step, skew, algorithm) = match &record.data {
1734        RecordData::Totp {
1735            issuer,
1736            account,
1737            secret,
1738            digits,
1739            step,
1740            skew,
1741            algorithm,
1742        } => (issuer, account, secret, *digits, *step, *skew, *algorithm),
1743        _ => bail!("record {} is not a TOTP secret", args.id),
1744    };
1745
1746    let totp = build_totp_instance(secret, digits, step, skew, algorithm, issuer, account)?;
1747    let code = if let Some(ts) = args.time {
1748        if ts < 0 {
1749            bail!("time must be non-negative");
1750        }
1751        totp.generate(ts as u64)
1752    } else {
1753        totp.generate_current()?
1754    };
1755    println!("code: {}", code);
1756    if args.time.is_none() {
1757        let ttl = totp.ttl()?;
1758        println!("ttl: {}s", ttl);
1759    }
1760    Ok(())
1761}
1762
1763fn self_test() -> Result<()> {
1764    let mut sample = [0u8; 32];
1765    OsRng.fill_bytes(&mut sample);
1766    let secret = Sensitive::new_from_utf8(&sample);
1767    let record = Record::new(
1768        RecordData::Note { body: secret },
1769        Some("Self-test".into()),
1770        vec!["selftest".into()],
1771        None,
1772    );
1773    let payload = VaultPayload {
1774        records: vec![record],
1775        record_counter: 1,
1776    };
1777
1778    let mut dek = [0u8; 32];
1779    OsRng.fill_bytes(&mut dek);
1780    let blob = encrypt_payload(&dek, &payload)?;
1781    let recovered = decrypt_payload(&dek, &blob)?;
1782    anyhow::ensure!(recovered.records.len() == 1, "self-test failed");
1783    println!("Self-test passed");
1784    Ok(())
1785}
1786
1787fn prompt_hidden(prompt: &str) -> Result<Sensitive> {
1788    let value = Zeroizing::new(prompt_password(prompt)?);
1789    Ok(Sensitive::from_string(value.as_str()))
1790}
1791
1792fn prompt_optional_hidden(prompt: &str) -> Result<Option<Sensitive>> {
1793    let value = Zeroizing::new(prompt_password(prompt)?);
1794    if value.trim().is_empty() {
1795        Ok(None)
1796    } else {
1797        Ok(Some(Sensitive::from_string(value.as_str())))
1798    }
1799}
1800
1801fn prompt_multiline(prompt: &str) -> Result<Sensitive> {
1802    eprintln!("{}", prompt);
1803    read_multiline(false)
1804}
1805
1806fn prompt_multiline_hidden(prompt: &str) -> Result<Sensitive> {
1807    eprintln!("{}", prompt);
1808    read_multiline(true)
1809}
1810
1811fn read_multiline(_hidden: bool) -> Result<Sensitive> {
1812    let mut buffer = Zeroizing::new(Vec::new());
1813    io::stdin().read_to_end(&mut buffer)?;
1814    while buffer.last().copied() == Some(b'\n') {
1815        buffer.pop();
1816    }
1817    Ok(Sensitive::new_from_utf8(&buffer))
1818}
1819
1820#[cfg(feature = "fuzzing")]
1821pub fn fuzz_try_payload(bytes: &[u8]) {
1822    let _ = ciborium::de::from_reader::<VaultPayload, _>(bytes);
1823}
1824
1825fn write_vault(path: &Path, file: &VaultFile) -> Result<()> {
1826    let parent = path.parent().ok_or_else(|| anyhow!("invalid vault path"))?;
1827    fs::create_dir_all(parent)?;
1828    let mut tmp = NamedTempFile::new_in(parent)?;
1829    into_writer(file, &mut tmp).context("failed to serialize vault")?;
1830    tmp.as_file_mut().sync_all()?;
1831    #[cfg(unix)]
1832    {
1833        use std::os::unix::fs::PermissionsExt;
1834        tmp.as_file_mut()
1835            .set_permissions(fs::Permissions::from_mode(0o600))?;
1836    }
1837    tmp.persist(path)?;
1838    Ok(())
1839}
1840
1841impl fmt::Display for RecordKind {
1842    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1843        let label = match self {
1844            RecordKind::Login => "login",
1845            RecordKind::Contact => "contact",
1846            RecordKind::Id => "id",
1847            RecordKind::Note => "note",
1848            RecordKind::Bank => "bank",
1849            RecordKind::Wifi => "wifi",
1850            RecordKind::Api => "api",
1851            RecordKind::Wallet => "wallet",
1852            RecordKind::Totp => "totp",
1853            RecordKind::Ssh => "ssh",
1854            RecordKind::Pgp => "pgp",
1855            RecordKind::Recovery => "recovery",
1856        };
1857        f.write_str(label)
1858    }
1859}
1860
1861#[cfg(test)]
1862mod tests {
1863    use super::*;
1864    use proptest::prelude::*;
1865    use proptest::proptest;
1866    use proptest::strategy::Strategy;
1867    use serial_test::serial;
1868    use std::env;
1869    use std::path::PathBuf;
1870    use tempfile::tempdir;
1871
1872    fn prepare(passphrase: &str) -> Result<(tempfile::TempDir, PathBuf, Zeroizing<String>)> {
1873        let dir = tempdir()?;
1874        let vault_path = dir.path().join("vault.cbor");
1875        env::set_var("BLACK_BAG_VAULT_PATH", &vault_path);
1876        let pass = Zeroizing::new(passphrase.to_string());
1877        Ok((dir, vault_path, pass))
1878    }
1879
1880    fn cleanup() {
1881        env::remove_var("BLACK_BAG_VAULT_PATH");
1882    }
1883
1884    fn arb_ascii_string(max: usize) -> impl Strategy<Value = String> {
1885        proptest::collection::vec(proptest::char::range('a', 'z'), 0..=max)
1886            .prop_map(|chars| chars.into_iter().collect())
1887    }
1888
1889    fn arb_note_record() -> impl Strategy<Value = Record> {
1890        (
1891            proptest::option::of(arb_ascii_string(12)),
1892            proptest::collection::vec(arb_ascii_string(8), 0..3),
1893            arb_ascii_string(48),
1894        )
1895            .prop_map(|(title, tags, body)| {
1896                Record::new(
1897                    RecordData::Note {
1898                        body: Sensitive::from_string(&body),
1899                    },
1900                    title,
1901                    tags,
1902                    None,
1903                )
1904            })
1905    }
1906
1907    proptest! {
1908        #[test]
1909        fn encrypt_blob_roundtrip_prop(
1910            key_bytes in proptest::array::uniform32(any::<u8>()),
1911            data in proptest::collection::vec(any::<u8>(), 0..256),
1912            aad in proptest::collection::vec(any::<u8>(), 0..32),
1913        ) {
1914            let blob = encrypt_blob(&key_bytes, &data, &aad).unwrap();
1915            let decrypted = decrypt_blob(&key_bytes, &blob, &aad).unwrap();
1916            prop_assert_eq!(decrypted, data);
1917        }
1918
1919        #[test]
1920        fn payload_roundtrip_prop(
1921            key_bytes in proptest::array::uniform32(any::<u8>()),
1922            records in proptest::collection::vec(arb_note_record(), 0..3),
1923        ) {
1924            let payload = VaultPayload {
1925                records: records.clone(),
1926                record_counter: records.len() as u64,
1927            };
1928            let blob = encrypt_payload(&key_bytes, &payload).unwrap();
1929            let decoded = decrypt_payload(&key_bytes, &blob).unwrap();
1930            prop_assert_eq!(decoded, payload);
1931        }
1932    }
1933
1934    #[test]
1935    #[serial]
1936    fn vault_round_trip_note() -> Result<()> {
1937        let (_tmp, vault_path, pass) = prepare("correct horse battery staple")?;
1938        Vault::init(&vault_path, &pass, 32_768)?;
1939
1940        let mut vault = Vault::load(&vault_path, &pass)?;
1941        let record = Record::new(
1942            RecordData::Note {
1943                body: Sensitive::from_string("mission ops"),
1944            },
1945            Some("Ops Note".into()),
1946            vec!["mission".into()],
1947            None,
1948        );
1949        let record_id = record.id;
1950        vault.add_record(record);
1951        vault.save(&pass)?;
1952
1953        drop(vault);
1954        let vault = Vault::load(&vault_path, &pass)?;
1955        let notes = vault.list(Some(RecordKind::Note), None, None);
1956        assert_eq!(notes.len(), 1);
1957        assert_eq!(notes[0].id, record_id);
1958        assert_eq!(notes[0].title.as_deref(), Some("Ops Note"));
1959        assert!(notes[0].matches_tag("mission"));
1960
1961        cleanup();
1962        Ok(())
1963    }
1964
1965    #[test]
1966    #[serial]
1967    fn totp_round_trip() -> Result<()> {
1968        let (_tmp, vault_path, pass) = prepare("totp-pass")?;
1969        Vault::init(&vault_path, &pass, 32_768)?;
1970
1971        let secret_bytes = parse_totp_secret("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")?;
1972        let mut vault = Vault::load(&vault_path, &pass)?;
1973        let record = Record::new(
1974            RecordData::Totp {
1975                issuer: Some("TestIssuer".into()),
1976                account: Some("test@example".into()),
1977                secret: Sensitive { data: secret_bytes },
1978                digits: 6,
1979                step: 30,
1980                skew: 1,
1981                algorithm: TotpAlgorithm::Sha1,
1982            },
1983            Some("TOTP".into()),
1984            vec![],
1985            None,
1986        );
1987        let record_id = record.id;
1988        vault.add_record(record);
1989        vault.save(&pass)?;
1990
1991        drop(vault);
1992        let vault = Vault::load(&vault_path, &pass)?;
1993        let record = vault
1994            .get_ref(record_id)
1995            .ok_or_else(|| anyhow!("TOTP record missing"))?;
1996        let code = match &record.data {
1997            RecordData::Totp {
1998                issuer,
1999                account,
2000                secret,
2001                digits,
2002                step,
2003                skew,
2004                algorithm,
2005            } => build_totp_instance(secret, *digits, *step, *skew, *algorithm, issuer, account)?
2006                .generate(59),
2007            _ => bail!("expected totp record"),
2008        };
2009        let expected = TOTP::new(
2010            TotpAlgorithmLib::SHA1,
2011            6,
2012            1,
2013            30,
2014            parse_totp_secret("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")?,
2015            Some("TestIssuer".into()),
2016            "test@example".into(),
2017        )
2018        .unwrap()
2019        .generate(59);
2020        assert_eq!(code, expected);
2021
2022        cleanup();
2023        Ok(())
2024    }
2025
2026    #[test]
2027    #[serial]
2028    fn vault_rotation_changes_wrapped_keys() -> Result<()> {
2029        let (_tmp, vault_path, pass) = prepare("rotate-all-the-things")?;
2030        Vault::init(&vault_path, &pass, 32_768)?;
2031
2032        let mut vault = Vault::load(&vault_path, &pass)?;
2033        let record = Record::new(
2034            RecordData::Api {
2035                service: Some("intel-api".into()),
2036                environment: Some("prod".into()),
2037                access_key: Some("AKIA-123".into()),
2038                secret_key: Sensitive::from_string("super-secret"),
2039                scopes: vec!["read".into(), "write".into()],
2040            },
2041            Some("API".into()),
2042            vec!["read".into()],
2043            None,
2044        );
2045        vault.add_record(record);
2046        let before = vault.file.header.sealed_dek.ciphertext.clone();
2047        vault.rotate(&pass, Some(65_536))?;
2048        vault.save(&pass)?;
2049        let after = vault.file.header.sealed_dek.ciphertext.clone();
2050        assert_ne!(before, after);
2051
2052        drop(vault);
2053        let vault = Vault::load(&vault_path, &pass)?;
2054        let apis = vault.list(Some(RecordKind::Api), Some("read"), None);
2055        assert_eq!(apis.len(), 1);
2056        assert!(apis[0].data.summary_text().contains("intel-api"));
2057
2058        cleanup();
2059        Ok(())
2060    }
2061
2062    #[test]
2063    fn recovery_split_combine_roundtrip() -> Result<()> {
2064        let secret = b"ultra-secret";
2065        let shares = split_secret(secret, 3, 5)?;
2066        let recovered = combine_secret(3, &shares)?;
2067        assert_eq!(recovered, secret);
2068        Ok(())
2069    }
2070}