secrets-vault 2.4.1

AES-256-GCM encrypted key-value vault with PBKDF2 key derivation. Store API keys and tokens securely instead of plaintext dotfiles.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
//! Windows master-key store: **DPAPI at rest, Windows Hello at the gate**.
//!
//! This is the Windows counterpart of `keychain.rs` (macOS Touch ID Keychain)
//! and exposes the SAME public interface, so `main.rs`, `lease.rs` and
//! `inbox.rs` are platform-blind. Two layers, matching the macOS split:
//!
//! 1. **At rest** — each account is a file under
//!    `%LOCALAPPDATA%\quantum-encoding\secrets\keyring\`, encrypted with
//!    `CryptProtectData` (DPAPI, **user scope**). The key is derived by the OS
//!    from the user's logon credential and never exists on disk, so the blob is
//!    worthless to another user account and worthless if the file is copied to
//!    another machine.
//! 2. **At the gate** — releasing a presence-protected account first requires a
//!    live `UserConsentVerifier` check (Windows Hello: PIN / fingerprint /
//!    face). This is the Touch ID analogue: the human, at the keyboard, now.
//!
//! ## What this ISN'T (read before trusting it like the macOS path)
//!
//! macOS binds the Keychain item to the binary's **code signature** via the
//! `keychain-access-groups` entitlement: a different program running as the
//! same user gets `errSecMissingEntitlement` and nothing else. **DPAPI user
//! scope has no equivalent.** Any process in this user's logon session can call
//! `CryptUnprotectData` on these blobs. The optional entropy below is NOT a
//! secret (it is derived from the account name) — it binds a ciphertext to its
//! account slot, it does not authenticate the caller.
//!
//! So on Windows the trust boundary is the **user account**, not the binary.
//! Windows Hello is what re-adds a human to the loop for the master key; it is
//! the only thing standing between a same-user process and the vault master.
//! Presence-gated accounts therefore get a real prompt on EVERY read (see
//! `strict` below). Non-gated accounts (`store_plain`, the lease keystore) are
//! protected only at the user boundary — the same class of protection the
//! portable `SECRETS_LEASE_KEYSTORE=file` mode gives, plus encryption at rest.
//!
//! ## DESIGN CONSTRAINT: DPAPI is unavailable over ssh public-key logon
//!
//! A network logon authenticated by an ssh **public key** carries no password
//! credential in the logon session, and DPAPI's user master key is unlocked
//! from exactly that credential. Both `CryptProtectData` and
//! `CryptUnprotectData` therefore FAIL for an ssh-key session on this box —
//! this is not a bug to route around, it is the mechanism working. (The same
//! constraint is observable in `gh`: its DPAPI-sealed token authenticates fine
//! in an interactive session and is rejected over ssh.)
//!
//! Consequences, by design:
//!   * The Windows master-key path serves the **interactive desktop user**.
//!   * An agent reaching this box **over ssh** must use `SECRETS_PASSPHRASE`,
//!     a TTY passphrase prompt, or a lease handed over by the interactive side.
//!   * Windows Hello would be doubly wrong over ssh anyway: there is nobody at
//!     that desktop to answer the prompt.
//!
//! Every failure here is a described error, never a panic and never a silent
//! empty read — `explain_dpapi_failure` turns the raw HRESULT into the sentence
//! above so the operator knows to switch credential paths rather than assuming
//! a corrupt vault.
//!
//! ## `strict` on Windows
//!
//! macOS `strict` selects `BiometryCurrentSet` (enrolled biometry ONLY — no
//! passcode fallback, self-invalidating when the fingerprint set changes).
//! `UserConsentVerifier` has **no biometry-only flavour**: it accepts any
//! enrolled Hello credential including the PIN, and exposes no way to refuse
//! the PIN or to notice enrollment changes. Rather than fake it, this arm
//! treats `strict` as advisory and is unconditionally strict in the other
//! sense: there is no reuse/grace window, so EVERY presence-gated read raises
//! a fresh prompt (macOS non-strict allows a burst of reads on one tap). The
//! one deliberate exception is `read_accounts`, which is a single prompt
//! covering the batch — the same "one tap opens inbox identity + master at
//! merge" behaviour its macOS twin has.

use std::path::PathBuf;

use zeroize::Zeroizing;

use windows::core::{IInspectable, Interface, GUID, HRESULT, HSTRING, PCWSTR};
use windows::Security::Credentials::UI::{
    UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability,
};
use windows::Win32::Foundation::{HWND, LocalFree, HLOCAL};
use windows::Win32::Security::Cryptography::{
    CryptProtectData, CryptUnprotectData, CRYPT_INTEGER_BLOB,
};
use windows::Win32::System::Console::GetConsoleWindow;
use windows_future::IAsyncOperation;

/// Namespace tag mixed into the DPAPI entropy — keeps these blobs from being
/// interchangeable with any other DPAPI data this user owns.
const APP_TAG: &str = "io.quantumencoding.secrets";
const ACCOUNT_MASTER: &str = "vault-master";
const ACCOUNT_INBOX: &str = "inbox-identity";

/// `CRYPTPROTECT_UI_FORBIDDEN` — never let DPAPI raise UI of its own. The only
/// prompt the user should ever see from this module is our Hello prompt.
const CRYPTPROTECT_UI_FORBIDDEN: u32 = 0x1;

/// On-disk envelope: magic + version + flag byte, then the DPAPI ciphertext.
const MAGIC: &[u8] = b"QSWK1\0";
/// This account may only be released after a live user-presence check.
const FLAG_PRESENCE: u8 = 0b0000_0001;

// ── Layout ──

/// Keyring root. Deliberately NOT under `SECRETS_DIR`: like the macOS Keychain,
/// the master key outlives any particular vault directory, and test runs that
/// point `SECRETS_DIR` at a throwaway path must not silently reuse — or
/// destroy — the real master key.
pub fn keyring_dir() -> PathBuf {
    let base = std::env::var_os("LOCALAPPDATA")
        .map(PathBuf::from)
        .or_else(|| dirs::data_local_dir())
        .unwrap_or_else(|| {
            dirs::home_dir()
                .unwrap_or_else(|| PathBuf::from("."))
                .join("AppData")
                .join("Local")
        });
    base.join("quantum-encoding").join("secrets").join("keyring")
}

/// Map an account name to a safe file name.
///
/// This is load-bearing, not cosmetic: the lease keystore's accounts are
/// `lease:<project>`, and a raw `:` in an NTFS path opens an **alternate data
/// stream** (`lease:myapp.dpapi` writes a stream on a file named `lease`)
/// rather than the file you meant — silently, and invisible to a directory
/// listing. Percent-encoding everything outside `[A-Za-z0-9._-]` keeps the
/// mapping injective and the path a real file.
fn safe_name(account: &str) -> String {
    let mut out = String::with_capacity(account.len() + 8);
    for b in account.as_bytes() {
        let c = *b as char;
        if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
            out.push(c);
        } else {
            out.push_str(&format!("%{b:02X}"));
        }
    }
    out
}

fn account_file(account: &str) -> PathBuf {
    keyring_dir().join(format!("{}.dpapi", safe_name(account)))
}

/// DPAPI optional entropy. Binds a ciphertext to (app, account, flags), so a
/// blob cannot be renamed over another account's slot — e.g. dropping the
/// unprotected `lease:x` blob over `vault-master.dpapi`, or clearing the
/// presence flag on an existing file — without decryption simply failing.
/// NOT a secret and not caller authentication (see the module docs).
fn entropy(account: &str, flags: u8) -> Vec<u8> {
    format!("{APP_TAG}|{account}|{flags}").into_bytes()
}

// ── DPAPI ──

fn blob(data: &[u8]) -> CRYPT_INTEGER_BLOB {
    CRYPT_INTEGER_BLOB {
        cbData: data.len() as u32,
        pbData: data.as_ptr() as *mut u8,
    }
}

/// Copy a DPAPI-returned blob into Rust memory and release the OS buffer.
/// # Safety
/// `out` must be a blob populated by a successful Crypt*Data call.
unsafe fn take_blob(out: &mut CRYPT_INTEGER_BLOB) -> Vec<u8> { unsafe {
    let v = if out.pbData.is_null() {
        Vec::new()
    } else {
        std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec()
    };
    if !out.pbData.is_null() {
        let _ = LocalFree(Some(HLOCAL(out.pbData as *mut core::ffi::c_void)));
        out.pbData = std::ptr::null_mut();
        out.cbData = 0;
    }
    v
}}

/// Turn a DPAPI HRESULT into a sentence that names the likely cause. The ssh
/// case is by far the most common real-world failure on this box, and the raw
/// error ("The parameter is incorrect" / "Key not valid for use in specified
/// state") tells the operator nothing actionable.
fn explain_dpapi_failure(op: &str, e: &windows::core::Error) -> String {
    format!(
        "{op} failed: {e}\n\
         Windows DPAPI unlocks its user key from the credential in your logon session.\n\
         If you reached this machine over ssh with a PUBLIC KEY, that session has no\n\
         such credential and DPAPI cannot work — this is expected, not a broken vault.\n\
         Use SECRETS_PASSPHRASE, an interactive passphrase prompt, or have the\n\
         interactive desktop user hand over a lease (`secrets lease create`)."
    )
}

fn protect(plain: &[u8], entropy: &[u8]) -> Result<Vec<u8>, String> {
    let din = blob(plain);
    let ent = blob(entropy);
    let mut out = CRYPT_INTEGER_BLOB::default();
    unsafe {
        CryptProtectData(
            &din,
            PCWSTR::null(),
            Some(&ent),
            None,
            None,
            CRYPTPROTECT_UI_FORBIDDEN,
            &mut out,
        )
        .map_err(|e| explain_dpapi_failure("CryptProtectData", &e))?;
        Ok(take_blob(&mut out))
    }
}

fn unprotect(cipher: &[u8], entropy: &[u8]) -> Result<Zeroizing<Vec<u8>>, String> {
    let din = blob(cipher);
    let ent = blob(entropy);
    let mut out = CRYPT_INTEGER_BLOB::default();
    unsafe {
        CryptUnprotectData(
            &din,
            None,
            Some(&ent),
            None,
            None,
            CRYPTPROTECT_UI_FORBIDDEN,
            &mut out,
        )
        .map_err(|e| explain_dpapi_failure("CryptUnprotectData", &e))?;
        Ok(Zeroizing::new(take_blob(&mut out)))
    }
}

// ── Windows Hello (user presence) ──

/// `IUserConsentVerifierInterop` — the COM flavour a console/Win32 process must
/// use. Plain `UserConsentVerifier::RequestVerificationAsync` is a UWP API with
/// no window to parent its dialog to; from a desktop process it needs an HWND.
/// Vtable laid out by hand: IUnknown (3 slots) + IInspectable (3) + the method.
const IID_USER_CONSENT_VERIFIER_INTEROP: GUID =
    GUID::from_u128(0x39E050C3_4E74_441A_8DC0_B81104DF949C);

#[repr(C)]
struct InteropVtbl {
    query_interface:
        unsafe extern "system" fn(*mut core::ffi::c_void, *const GUID, *mut *mut core::ffi::c_void) -> HRESULT,
    add_ref: unsafe extern "system" fn(*mut core::ffi::c_void) -> u32,
    release: unsafe extern "system" fn(*mut core::ffi::c_void) -> u32,
    get_iids:
        unsafe extern "system" fn(*mut core::ffi::c_void, *mut u32, *mut *mut GUID) -> HRESULT,
    get_runtime_class_name:
        unsafe extern "system" fn(*mut core::ffi::c_void, *mut *mut core::ffi::c_void) -> HRESULT,
    get_trust_level: unsafe extern "system" fn(*mut core::ffi::c_void, *mut i32) -> HRESULT,
    request_verification_for_window_async: unsafe extern "system" fn(
        *mut core::ffi::c_void,
        HWND,
        *mut core::ffi::c_void,
        *const GUID,
        *mut *mut core::ffi::c_void,
    ) -> HRESULT,
}

/// Is Windows Hello usable for this user right now? Reported verbatim so the
/// operator can tell "no hardware" from "you never enrolled" from "your admin
/// turned it off" — three very different fixes.
pub fn presence_availability() -> Result<(), String> {
    let a = UserConsentVerifier::CheckAvailabilityAsync()
        .and_then(|op| op.join())
        .map_err(|e| format!("could not query Windows Hello availability: {e}"))?;
    match a {
        UserConsentVerifierAvailability::Available => Ok(()),
        UserConsentVerifierAvailability::DeviceNotPresent => {
            Err("no Windows Hello device is present (no PIN, fingerprint or face enrolled)".into())
        }
        UserConsentVerifierAvailability::NotConfiguredForUser => {
            Err("Windows Hello is not configured for this user — enrol a PIN or biometric in \
                 Settings > Accounts > Sign-in options"
                .into())
        }
        UserConsentVerifierAvailability::DisabledByPolicy => {
            Err("Windows Hello is disabled by policy on this machine".into())
        }
        UserConsentVerifierAvailability::DeviceBusy => {
            Err("the Windows Hello device is busy — try again".into())
        }
        other => Err(format!("Windows Hello unavailable (availability code {})", other.0)),
    }
}

/// Raise a Windows Hello prompt carrying `reason`, and return Ok(()) ONLY on a
/// verified human response. Every other outcome — cancel, retries exhausted,
/// device vanished — is an error, so callers fail closed.
///
/// Prefers the interop route with the console window handle. If there is no
/// console window (e.g. launched detached), it falls back to the plain WinRT
/// call rather than giving up; that path works on current Windows builds when a
/// foreground window can be inferred, and if it cannot, it errors — it never
/// silently skips the check.
fn require_presence(reason: &str) -> Result<(), String> {
    presence_availability()?;

    let msg = HSTRING::from(reason);
    let hwnd = unsafe { GetConsoleWindow() };

    let result = if hwnd.is_invalid() {
        UserConsentVerifier::RequestVerificationAsync(&msg)
            .and_then(|op| op.join())
            .map_err(|e| format!("Windows Hello prompt failed: {e}"))?
    } else {
        request_verification_for_window(hwnd, &msg)?
    };

    match result {
        UserConsentVerificationResult::Verified => Ok(()),
        UserConsentVerificationResult::Canceled => {
            Err("Windows Hello: cancelled — access denied".into())
        }
        UserConsentVerificationResult::RetriesExhausted => {
            Err("Windows Hello: too many failed attempts — access denied".into())
        }
        UserConsentVerificationResult::DeviceNotPresent => {
            Err("Windows Hello: no verification device present".into())
        }
        UserConsentVerificationResult::NotConfiguredForUser => {
            Err("Windows Hello: not configured for this user".into())
        }
        UserConsentVerificationResult::DisabledByPolicy => {
            Err("Windows Hello: disabled by policy".into())
        }
        UserConsentVerificationResult::DeviceBusy => Err("Windows Hello: device busy".into()),
        other => Err(format!("Windows Hello: unverified (result code {})", other.0)),
    }
}

fn request_verification_for_window(
    hwnd: HWND,
    msg: &HSTRING,
) -> Result<UserConsentVerificationResult, String> {
    let factory: IInspectable = windows::core::factory::<UserConsentVerifier, IInspectable>()
        .map_err(|e| format!("Windows Hello factory unavailable: {e}"))?;

    let mut interop: *mut core::ffi::c_void = std::ptr::null_mut();
    unsafe {
        let hr = (Interface::vtable(&factory).base.QueryInterface)(
            factory.as_raw(),
            &IID_USER_CONSENT_VERIFIER_INTEROP,
            &mut interop,
        );
        if hr.is_err() || interop.is_null() {
            return Err(format!("IUserConsentVerifierInterop unavailable: {hr:?}"));
        }
    }

    // From here on `interop` holds a reference we must release on every path.
    let mut raw: *mut core::ffi::c_void = std::ptr::null_mut();
    unsafe {
        let vtbl = *(interop as *mut *mut InteropVtbl);
        let hr = ((*vtbl).request_verification_for_window_async)(
            interop,
            hwnd,
            std::mem::transmute_copy(msg),
            &IAsyncOperation::<UserConsentVerificationResult>::IID,
            &mut raw,
        );
        if hr.is_err() || raw.is_null() {
            ((*vtbl).release)(interop);
            return Err(format!("Windows Hello prompt could not be raised: {hr:?}"));
        }
        let op: IAsyncOperation<UserConsentVerificationResult> = std::mem::transmute(raw);
        let out = op.join().map_err(|e| format!("Windows Hello prompt failed: {e}"));
        ((*vtbl).release)(interop);
        out
    }
}

// ── Account storage ──

fn ensure_dir() -> Result<(), String> {
    let d = keyring_dir();
    std::fs::create_dir_all(&d).map_err(|e| format!("creating {}: {e}", d.display()))?;
    crate::winacl::restrict_to_owner_warn(&d);
    Ok(())
}

fn write_account(account: &str, secret: &str, presence: bool) -> Result<(), String> {
    ensure_dir()?;
    let flags = if presence { FLAG_PRESENCE } else { 0 };
    let cipher = protect(secret.as_bytes(), &entropy(account, flags))?;

    let mut body = Vec::with_capacity(MAGIC.len() + 1 + cipher.len());
    body.extend_from_slice(MAGIC);
    body.push(flags);
    body.extend_from_slice(&cipher);

    let path = account_file(account);
    // Write-replace via a temp file so a crash can't leave a half-written
    // master key where a whole one used to be.
    let tmp = path.with_extension("dpapi.tmp");
    std::fs::write(&tmp, &body).map_err(|e| format!("writing {}: {e}", tmp.display()))?;
    std::fs::rename(&tmp, &path).map_err(|e| format!("replacing {}: {e}", path.display()))?;
    crate::winacl::restrict_to_owner_warn(&path);
    Ok(())
}

/// Parsed envelope: the flags and the still-encrypted body.
fn load_envelope(account: &str) -> Result<Option<(u8, Vec<u8>)>, String> {
    let path = account_file(account);
    let raw = match std::fs::read(&path) {
        Ok(r) => r,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => return Err(format!("reading {}: {e}", path.display())),
    };
    if raw.len() < MAGIC.len() + 1 || &raw[..MAGIC.len()] != MAGIC {
        return Err(format!("{} is not a secrets keyring file", path.display()));
    }
    Ok(Some((raw[MAGIC.len()], raw[MAGIC.len() + 1..].to_vec())))
}

/// Read one account. `allow_presence_gated` is the caller's declaration that it
/// is prepared to raise a prompt; a presence-gated blob reached through a
/// tap-free path (`read_plain`) is REFUSED rather than quietly released.
/// `consent_already_given` lets a batch share one prompt.
fn read_account(
    account: &str,
    allow_presence_gated: bool,
    reason: Option<&str>,
    consent_already_given: bool,
) -> Result<Option<String>, String> {
    let Some((flags, cipher)) = load_envelope(account)? else {
        return Ok(None);
    };
    let gated = flags & FLAG_PRESENCE != 0;
    if gated {
        if !allow_presence_gated {
            return Err(format!(
                "'{account}' is presence-protected and cannot be read through a tap-free path"
            ));
        }
        if !consent_already_given {
            require_presence(reason.unwrap_or("Release a secret from your vault"))?;
        }
    }
    let plain = unprotect(&cipher, &entropy(account, flags))?;
    String::from_utf8(plain.to_vec())
        .map(Some)
        .map_err(|_| format!("'{account}' does not hold valid UTF-8"))
}

fn delete_account(account: &str) -> Result<(), String> {
    match std::fs::remove_file(account_file(account)) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(format!("deleting {account}: {e}")),
    }
}

// ── Public interface (mirrors keychain.rs exactly) ──

/// Store the vault master passphrase behind DPAPI + Hello. Storing needs no
/// prompt (same as macOS: only reading is gated).
pub fn store(passphrase: &str, strict: bool) -> Result<(), String> {
    // `strict` cannot be honoured as biometry-only here (see module docs); the
    // caller has already told the user what it means. Reads are unconditionally
    // prompt-every-time, which is the stricter half of the macOS behaviour.
    let _ = strict;
    presence_availability().map_err(|e| {
        format!("{e}\nWithout Windows Hello the master key would sit behind DPAPI alone, which \
                 any process running as you could open. Refusing to store it ungated — use \
                 `secrets unlock --no-presence` if you accept that trade.")
    })?;
    write_account(ACCOUNT_MASTER, passphrase, true)
}

/// Store the master key with DPAPI only and NO presence gate. Split out so the
/// weaker mode is something the operator asks for by name and can be seen in
/// the evidence, never a silent fallback when Hello is missing.
pub fn store_no_presence(passphrase: &str) -> Result<(), String> {
    write_account(ACCOUNT_MASTER, passphrase, false)
}

/// Read the master passphrase, raising a Hello prompt. Ok(None) = not unlocked
/// (no prompt raised — an absent key must not cost the user a gesture).
pub fn read(prompt: &str, strict: bool) -> Result<Option<String>, String> {
    let _ = strict;
    read_account(ACCOUNT_MASTER, true, Some(prompt), false)
}

pub fn delete() -> Result<(), String> {
    delete_account(ACCOUNT_MASTER)
}

/// Store the write-only inbox's age identity — presence-gated like the master,
/// opened only at `inbox merge`.
pub fn store_inbox_identity(secret: &str) -> Result<(), String> {
    write_account(ACCOUNT_INBOX, secret, true)
}

/// Whether the inbox identity exists — WITHOUT raising a prompt. Existence is
/// a file-presence question here; the ciphertext is never touched.
pub fn inbox_identity_exists() -> bool {
    account_file(ACCOUNT_INBOX).exists()
}

/// Read several accounts under ONE Hello prompt, so `inbox merge` opens the
/// identity + the master with a single gesture (the macOS shared-LAContext
/// behaviour). Accounts that don't exist yield `None` and never trigger a
/// prompt; the prompt is raised once, only if at least one requested account is
/// present AND presence-gated.
pub fn read_accounts(accounts: &[&str], strict: bool) -> Result<Vec<Option<String>>, String> {
    let _ = strict;

    let mut need_consent = false;
    for account in accounts {
        if let Some((flags, _)) = load_envelope(account)? {
            if flags & FLAG_PRESENCE != 0 {
                need_consent = true;
                break;
            }
        }
    }
    if need_consent {
        require_presence(&format!(
            "Open {} from your secrets vault",
            accounts.join(" + ")
        ))?;
    }

    let mut out = Vec::with_capacity(accounts.len());
    for account in accounts {
        out.push(read_account(account, true, None, true)?);
    }
    Ok(out)
}

// ── Plain (no-presence) accounts: the lease keystore ──
//
// macOS gets its boundary here from the code-signing entitlement — tap-free but
// unreachable by any other binary. Windows has no such boundary (module docs),
// so these are DPAPI-at-rest only: safe against file theft and other user
// accounts, NOT against another process running as this user. That is a real
// step down from macOS and a real step UP from `SECRETS_LEASE_KEYSTORE=file`,
// which stores the same key as plaintext next to the lease.

pub fn store_plain(account: &str, secret: &str) -> Result<(), String> {
    write_account(account, secret, false)
}

pub fn read_plain(account: &str) -> Result<Option<String>, String> {
    read_account(account, false, None, false)
}

pub fn delete_plain(account: &str) -> Result<(), String> {
    delete_account(account)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn safe_name_escapes_the_ads_colon() {
        // `lease:myapp` must not become an NTFS alternate data stream.
        assert_eq!(safe_name("lease:myapp"), "lease%3Amyapp");
        assert_eq!(safe_name("vault-master"), "vault-master");
        assert_eq!(safe_name("inbox-identity"), "inbox-identity");
        // Injective: the escape character itself is escaped.
        assert_ne!(safe_name("a%3Ab"), safe_name("a:b"));
        assert!(!account_file("lease:myapp").to_string_lossy().contains("lease:myapp"));
    }

    #[test]
    fn entropy_separates_accounts_and_flags() {
        assert_ne!(entropy("vault-master", 1), entropy("inbox-identity", 1));
        // Clearing the presence flag on a stored blob must not decrypt.
        assert_ne!(entropy("vault-master", 1), entropy("vault-master", 0));
    }

    #[test]
    fn dpapi_round_trip() {
        // Proves the DPAPI wiring end to end in-process. Skips (rather than
        // fails) where DPAPI has no credential to work with — e.g. an ssh
        // public-key logon, exactly the constraint in the module docs.
        let ent = entropy("test-account", 0);
        match protect(b"round-trip-value", &ent) {
            Ok(cipher) => {
                assert_ne!(&cipher[..], b"round-trip-value", "ciphertext must not be plaintext");
                let plain = unprotect(&cipher, &ent).expect("unprotect own ciphertext");
                assert_eq!(&plain[..], b"round-trip-value");
                // Wrong entropy (i.e. another account's slot) must not open it.
                assert!(unprotect(&cipher, &entropy("other-account", 0)).is_err());
            }
            Err(e) => eprintln!("skipping: DPAPI unavailable in this logon session ({e})"),
        }
    }
}