mindfork 0.11.0

A terminal AI chat written in Rust: local models via llama.cpp or OpenAI, Anthropic, Gemini and Grok in the cloud, with persistent memory, notes, RAG and tools.
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
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
//! Machine-bound storage for secrets (cloud-provider API keys, the backup
//! password).
//!
//! A secret entered in settings is encrypted with **this machine's key** and put
//! into `settings.json` (see [`crate::shared::config::AppConfig::api_keys`]). The
//! config stays portable: on another machine the entry does not decrypt — the key
//! is entered again and added as **its own** entry; going back to the first
//! machine, its entry is still readable. See docs/research/api-key-storage.md.
//!
//! Encryption schemes (the entry's `scheme` field, a free-form string — an
//! unfamiliar scheme does not break config reading, the entry is simply "not
//! ours"):
//!
//! * **`dpapi`** (Windows) — the system `CryptProtectData`/`CryptUnprotectData`:
//!   a *user* master key managed by the OS. Decryption on another machine or by
//!   another user is impossible. `pOptionalEntropy` is an app constant
//!   ([`ENTROPY`]): not a secret, but it filters out generic "DPAPI dumper" tools.
//! * **`machine-key-v1`** (Linux) — the key is derived from `/etc/machine-id` via
//!   HKDF-SHA256 (the `sd_id128_get_machine_app_specific` pattern — systemd
//!   explicitly instructs against using the raw machine-id), encryption is
//!   ChaCha20-Poly1305 (AEAD, a random nonce prefixed to the ciphertext). The
//!   username goes into `info` → per-user binding, like DPAPI.
//!
//! **Threat model** (docs/research/api-key-storage.md §3): we protect the
//! **file** — a copy/move/backup of the config (outside its "own" machine it is a
//! useless ciphertext). It does not protect against malicious code running under
//! the same user on the same machine — it would call the same DPAPI / derive the
//! same key. This is fundamental for any scheme where "the app decrypts on its
//! own, with no user input" (Chrome and Git Credential Manager work the same
//! way). The previous path (an env variable) was no safer: any process of the
//! user can read it.

use serde::{Deserialize, Serialize};

/// The Windows DPAPI scheme (the value of the `scheme` field).
// Off Windows the scheme is unavailable (an entry with it is "not ours"), so the
// constant does not show up in code there: silence dead_code so the `-D warnings`
// gate stays green on both OSes.
#[cfg_attr(not(windows), allow(dead_code))]
pub const SCHEME_DPAPI: &str = "dpapi";
/// The Linux scheme: HKDF(machine-id) + ChaCha20-Poly1305.
pub const SCHEME_MACHINE_KEY_V1: &str = "machine-key-v1";

/// Reserved entry key for the **backup password** (spec §12.3), stored beside
/// the API keys in the same per-machine entry.
///
/// The hyphen makes a collision with a [`crate::shared::config::CloudProvider`]
/// key (`openai`/`gemini`/`claude`) impossible, so one map can hold both kinds of
/// secret. Reusing the entry rather than adding a second list keeps `put_key`/
/// [`stored_key`]/[`is_ours`] working unchanged — and renaming the `api_keys`
/// field on disk is exactly what the additive-only rule forbids (ADR 0006 F12),
/// so the field's *name* stays while its meaning is "this machine's secrets".
///
/// A dot would read as an i18n bundle key to `tools/cyrillic_scan.py`'s sibling
/// gate over `*.*` literals — a hyphen is just as unambiguous here.
pub const BACKUP_PASSWORD_KEY: &str = "backup-password";

/// One of the settings slots that can point at an **external**
/// OpenAI-compatible server. Each has a URL of its own, so each has a key of its
/// own: the common configuration is a cloud gateway for chat beside a local
/// `llama-server` for embeddings, and one shared key would send the gateway's
/// Bearer token to localhost. See docs/history/external-api-key.md §3, F1.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExternalSlot {
    /// `engine.external` — the assistant's chat server.
    Chat,
    /// `impersonation_engine.external` — the impersonation server (spec §11.8).
    Impersonation,
    /// `embed.external` — the embedding server (ADR 0002).
    Embed,
    /// `tts.external` — the speech server (ADR 0009).
    Tts,
}

impl ExternalSlot {
    /// Every slot, for enumerating the presence list.
    pub const ALL: [ExternalSlot; 4] = [Self::Chat, Self::Impersonation, Self::Embed, Self::Tts];

    /// The slot's part of the storage name (see [`SecretKey::storage_name`]).
    fn key(self) -> &'static str {
        match self {
            Self::Chat => "chat",
            Self::Impersonation => "impersonation",
            Self::Embed => "embed",
            Self::Tts => "tts",
        }
    }
}

/// One of the keyed **web-search** providers (spec §9.3.1). Like
/// [`ExternalSlot`], a closed set addressed by slot: the providers are picked
/// from a fixed list rather than by URL, and each has its own account, so one
/// shared key would be meaningless. See
/// docs/research/web-search-keyed-providers.md §6.
///
/// One variant today. It stays an enum because the slot is what the stored name
/// (`search-tavily`) and the settings row are keyed by: a second provider is a
/// variant, not a reshape.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchSlot {
    /// Tavily (`api.tavily.com`) — `Authorization: Bearer`.
    Tavily,
}

impl SearchSlot {
    /// Every slot, for enumerating the presence list.
    pub const ALL: [SearchSlot; 1] = [Self::Tavily];

    /// The slot's part of the storage name (see [`SecretKey::storage_name`]).
    fn key(self) -> &'static str {
        match self {
            Self::Tavily => "tavily",
        }
    }

    /// The provider's name as the UI spells it: the settings row that holds this
    /// key and the tool's "which backend answered" line say the same word, from
    /// here (spec §9.3.1, §11.6).
    pub fn display_name(self) -> &'static str {
        match self {
            Self::Tavily => "Tavily",
        }
    }
}

/// Which secret a storage slot holds. One typed key instead of raw strings: the
/// side effects of storing differ per kind (a provider key re-raises the servers
/// that use it, an MCP one re-spawns that server, a backup password needs
/// nothing), and dispatching those by parsing a name is how they drift. Carried
/// by `AppCommand::SetSecret` and by the settings snapshot's presence list; the
/// storage itself only ever sees [`Self::storage_name`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SecretKey {
    /// A cloud provider's API key — shared by chat/impersonation/embeddings of
    /// that provider (ADR 0008 §3).
    Provider(crate::shared::config::CloudProvider),
    /// The Bearer key of one external OpenAI-compatible server (a proxy or a
    /// gateway — LiteLLM, OpenRouter, vLLM…). Addressed by **slot** rather than
    /// by provider, which is what ADR 0008 could not do and is why the external
    /// mode stayed env-only until now: an arbitrary URL cannot be pinned to a
    /// provider, but the sub-section the user is typing the URL into is a
    /// perfectly good address. See docs/history/external-api-key.md.
    External(ExternalSlot),
    /// The API key of one keyed web-search provider (spec §9.3.1). Its own
    /// variant rather than a [`Self::Provider`] one: these are not inference
    /// providers, they have their own accounts and their own billing, and a
    /// `web_search` key must never be reachable from the engine's key lookup.
    Search(SearchSlot),
    /// The backup password (spec §12.3).
    BackupPassword,
    /// The value of one environment variable handed to an MCP server
    /// (docs/history/mcp-server-editor.md §9, S1): the alternative to naming an
    /// OS variable in `env`, and the only one that does not require setting a
    /// variable outside the app.
    McpEnv { server: String, var: String },
}

impl SecretKey {
    /// The key this secret is stored under in the machine entry. Neither `mcp-`,
    /// `external-` nor `search-` can collide with a provider key
    /// (`openai`/`gemini`/`claude`/`grok`) or with [`BACKUP_PASSWORD_KEY`], and
    /// since a variable name is restricted to `[A-Za-z0-9_]` (only the server id
    /// may contain `-`) the composed MCP name is unambiguous from the right. The
    /// external and search slots are closed sets, so theirs cannot be ambiguous
    /// at all.
    pub fn storage_name(&self) -> String {
        match self {
            Self::Provider(p) => p.key().to_string(),
            Self::External(slot) => format!("external-{}", slot.key()),
            Self::Search(slot) => format!("search-{}", slot.key()),
            Self::BackupPassword => BACKUP_PASSWORD_KEY.to_string(),
            Self::McpEnv { server, var } => format!("mcp-{server}-{var}"),
        }
    }
}

/// Plaintext of the `check` probe: encrypted alongside the keys; decrypting it
/// successfully identifies an entry as "ours" (we do not store an explicit
/// machine-id in the portable config).
///
/// The `mindfork-rs` prefix here and in [`ENTROPY`]/[`HKDF_INFO`] is frozen:
/// these v1 constants are inputs of the stored-key encryption, so "aligning"
/// them with the binary's rename to `mindfork` would orphan every stored key
/// (docs/research/binary-rename.md §3).
const CHECK_PLAINTEXT: &str = "mindfork-rs api-key check v1";

/// Additional DPAPI entropy / HKDF salt — an app constant (not a secret).
const ENTROPY: &[u8] = b"mindfork-rs/api-keys/v1";

/// The HKDF `info` string (the key domain; the username is appended to it).
const HKDF_INFO: &[u8] = b"mindfork-rs api-key v1 user=";

/// One entry of stored API keys — **for one machine**. Entries of other machines
/// sit alongside and are left untouched (they will "come alive" on their own
/// machines): the config is portable, the keys are per-machine.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ApiKeyEntry {
    /// Human-readable label (computer name + date) — for display/diagnostics
    /// only, takes no part in the logic.
    pub label: String,
    /// Encryption scheme: [`SCHEME_DPAPI`] | [`SCHEME_MACHINE_KEY_V1`]. A
    /// free-form string — an entry with an unfamiliar (future) scheme is read
    /// and saved as is.
    pub scheme: String,
    /// Ciphertext of [`CHECK_PLAINTEXT`] — the "is this entry ours" probe
    /// ([`is_ours`]).
    pub check: String,
    /// Ciphertexts of the keys by provider (`openai`/`gemini`/`claude`).
    pub keys: std::collections::BTreeMap<String, String>,
}

/// Whether a secret-encryption scheme is available on this machine (otherwise
/// stored keys are not supported — the env path remains). On Windows — always;
/// on Linux depends on machine-id being present.
// Consumers: tests (skip on systems without machine-id) and the stage-2 settings
// screen (the "API key" field is hidden/explained when storage is unavailable).
#[allow(dead_code)]
pub fn scheme_available() -> bool {
    local_scheme().is_some()
}

/// Identifies "our" entry — by decrypting the `check` probe (DPAPI returned
/// success / AEAD authentication matched). Entries of other machines and other
/// schemes — `false`.
pub fn is_ours(entry: &ApiKeyEntry) -> bool {
    decrypt(&entry.scheme, &entry.check).as_deref() == Some(CHECK_PLAINTEXT)
}

/// The decrypted provider key from "our" entry; `None` — there is no entry, it
/// belongs to another machine, or this provider's key is not set in it.
pub fn stored_key(entries: &[ApiKeyEntry], provider_key: &str) -> Option<String> {
    let entry = entries.iter().find(|e| is_ours(e))?;
    decrypt(&entry.scheme, entry.keys.get(provider_key)?)
}

/// Puts the provider's key into **this** machine's entry (creates it if absent).
/// An empty `key` removes the key; an emptied entry is removed entirely. Other
/// machines' entries are left untouched. `Err` — the scheme is unavailable on
/// this machine, or encryption failed.
pub fn put_key(
    entries: &mut Vec<ApiKeyEntry>,
    provider_key: &str,
    key: &str,
    label: impl FnOnce() -> String,
) -> Result<(), SecretError> {
    let idx = entries.iter().position(is_ours);
    if key.is_empty() {
        if let Some(i) = idx {
            entries[i].keys.remove(provider_key);
            if entries[i].keys.is_empty() {
                entries.remove(i);
            }
        }
        return Ok(());
    }
    let scheme = local_scheme().ok_or(SecretError::Unavailable)?;
    let cipher = encrypt(scheme, key)?;
    match idx {
        Some(i) => {
            entries[i].keys.insert(provider_key.into(), cipher);
        }
        None => entries.push(ApiKeyEntry {
            label: label(),
            check: encrypt(scheme, CHECK_PLAINTEXT)?,
            scheme: scheme.into(),
            keys: std::collections::BTreeMap::from([(provider_key.into(), cipher)]),
        }),
    }
    Ok(())
}

/// Computer name for the entry's label (diagnostics: whose entry this is). For
/// display only — takes no part in identifying the entry (see [`is_ours`]).
/// Fallback — `?`.
pub fn machine_label() -> String {
    std::env::var("COMPUTERNAME")
        .ok()
        .or_else(|| std::env::var("HOSTNAME").ok())
        .or_else(|| std::fs::read_to_string("/etc/hostname").ok())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "?".into())
}

/// Error working with stored secrets.
#[derive(Debug, thiserror::Error)]
pub enum SecretError {
    /// No scheme is available on this machine (Linux without machine-id) —
    /// storing keys is not supported, the env path remains.
    #[error("storing keys is not supported on this machine (no machine-id)")]
    Unavailable,
    /// Platform encryption failure (DPAPI/AEAD).
    #[error("failed to encrypt the secret")]
    Encrypt,
}

/// This machine's encryption scheme (`None` — unavailable).
fn local_scheme() -> Option<&'static str> {
    #[cfg(windows)]
    {
        Some(SCHEME_DPAPI)
    }
    #[cfg(not(windows))]
    {
        machine_ikm().map(|_| SCHEME_MACHINE_KEY_V1)
    }
}

/// Encrypts a secret with the given scheme → a hex string for JSON.
fn encrypt(scheme: &str, plaintext: &str) -> Result<String, SecretError> {
    let bytes = match scheme {
        #[cfg(windows)]
        SCHEME_DPAPI => dpapi::protect(plaintext.as_bytes()).ok_or(SecretError::Encrypt)?,
        SCHEME_MACHINE_KEY_V1 => {
            let key = machine_key().ok_or(SecretError::Unavailable)?;
            encrypt_with_key(&key, plaintext.as_bytes()).ok_or(SecretError::Encrypt)?
        }
        _ => return Err(SecretError::Unavailable),
    };
    Ok(hex_encode(&bytes))
}

/// Decrypts a hex string with the given scheme. `None` — a foreign machine, an
/// unfamiliar scheme, or data corruption (all cases equivalent: "cannot be
/// read").
fn decrypt(scheme: &str, hex: &str) -> Option<String> {
    let bytes = hex_decode(hex)?;
    let plain = match scheme {
        #[cfg(windows)]
        SCHEME_DPAPI => dpapi::unprotect(&bytes)?,
        SCHEME_MACHINE_KEY_V1 => decrypt_with_key(&machine_key()?, &bytes)?,
        _ => return None,
    };
    String::from_utf8(plain).ok()
}

// ── The `machine-key-v1` scheme: HKDF(machine-id) + ChaCha20-Poly1305 ──────────

/// ChaCha20-Poly1305 nonce length (ciphertext prefix).
const NONCE_LEN: usize = 12;

/// Derives a 32-byte key from the input keying material (machine-id) and the
/// username. A pure function — testable on any OS by injecting `ikm`.
fn derive_key(ikm: &[u8], user: &str) -> [u8; 32] {
    let mut info = HKDF_INFO.to_vec();
    info.extend_from_slice(user.as_bytes());
    let mut okm = [0u8; 32];
    // `expand` into 32 bytes (= the SHA-256 output size) cannot exceed the HKDF limit.
    hkdf::Hkdf::<sha2::Sha256>::new(Some(ENTROPY), ikm)
        .expand(&info, &mut okm)
        .expect("HKDF: 32 bytes is always a valid length");
    okm
}

/// Encrypts `plaintext`: the result = `nonce || ciphertext+tag`. A pure function.
fn encrypt_with_key(key: &[u8; 32], plaintext: &[u8]) -> Option<Vec<u8>> {
    use chacha20poly1305::aead::{Aead, OsRng};
    use chacha20poly1305::{AeadCore, ChaCha20Poly1305, KeyInit};

    let cipher = ChaCha20Poly1305::new(key.into());
    let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
    let mut out = nonce.to_vec();
    out.extend_from_slice(&cipher.encrypt(&nonce, plaintext).ok()?);
    Some(out)
}

/// Decrypts `nonce || ciphertext+tag`. `None` — a foreign key (AEAD did not
/// authenticate), corruption, or too-short input. A pure function.
fn decrypt_with_key(key: &[u8; 32], data: &[u8]) -> Option<Vec<u8>> {
    use chacha20poly1305::aead::Aead;
    use chacha20poly1305::{ChaCha20Poly1305, KeyInit};

    if data.len() <= NONCE_LEN {
        return None;
    }
    let (nonce, ct) = data.split_at(NONCE_LEN);
    ChaCha20Poly1305::new(key.into())
        .decrypt(nonce.into(), ct)
        .ok()
}

/// This machine's key for the `machine-key-v1` scheme (`None` — no machine-id).
fn machine_key() -> Option<[u8; 32]> {
    Some(derive_key(&machine_ikm()?, &current_user()))
}

/// Key input material: the OS instance identifier. Primary source —
/// `/etc/machine-id` (systemd), fallback — `/var/lib/dbus/machine-id`. `None` —
/// a non-systemd system without either (the scheme is unavailable, the env path
/// remains). Not used on Windows (DPAPI is used there).
fn machine_ikm() -> Option<Vec<u8>> {
    for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
        if let Ok(s) = std::fs::read_to_string(path) {
            let s = s.trim();
            if !s.is_empty() {
                return Some(s.as_bytes().to_vec());
            }
        }
    }
    None
}

/// The current username (the HKDF `info` component → per-user binding). An
/// empty name is acceptable — the binding is then to the machine only.
fn current_user() -> String {
    std::env::var("USER")
        .or_else(|_| std::env::var("USERNAME"))
        .unwrap_or_default()
}

// ── The `dpapi` scheme (Windows) ────────────────────────────────────────────────

#[cfg(windows)]
mod dpapi {
    use super::ENTROPY;
    use windows_sys::Win32::Foundation::LocalFree;
    use windows_sys::Win32::Security::Cryptography::{
        CRYPT_INTEGER_BLOB, CryptProtectData, CryptUnprotectData,
    };

    /// Encrypts data with DPAPI (a user key managed by the OS). `None` — a
    /// winapi failure.
    pub(super) fn protect(data: &[u8]) -> Option<Vec<u8>> {
        crypt(data, true)
    }

    /// Decrypts DPAPI data. `None` — a different machine/user, corruption, or
    /// different entropy (all cases equivalent: "cannot be read").
    pub(super) fn unprotect(data: &[u8]) -> Option<Vec<u8>> {
        crypt(data, false)
    }

    /// Shared wrapper: both DPAPI calls have the same shape (blob in → blob
    /// out, the OS allocates the output buffer and it must be freed with
    /// `LocalFree`).
    fn crypt(data: &[u8], protect: bool) -> Option<Vec<u8>> {
        let input = blob(data);
        let entropy = blob(ENTROPY);
        let mut out = CRYPT_INTEGER_BLOB {
            cbData: 0,
            pbData: std::ptr::null_mut(),
        };
        // SAFETY: `input`/`entropy` point to live slices for the whole call;
        // the other pointers are null (we do not use description/reserved/prompt);
        // `out` is filled by the OS, we free it with `LocalFree` exactly once below.
        let ok = unsafe {
            if protect {
                CryptProtectData(
                    &input,
                    std::ptr::null(),
                    &entropy,
                    std::ptr::null(),
                    std::ptr::null(),
                    0,
                    &mut out,
                )
            } else {
                CryptUnprotectData(
                    &input,
                    std::ptr::null_mut(),
                    &entropy,
                    std::ptr::null(),
                    std::ptr::null(),
                    0,
                    &mut out,
                )
            }
        };
        if ok == 0 || out.pbData.is_null() {
            return None;
        }
        // SAFETY: on success the OS guarantees a valid buffer of length `cbData`.
        let bytes = unsafe { std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec() };
        // SAFETY: `pbData` was allocated by the OS specifically for `LocalFree`; not used afterward.
        unsafe { LocalFree(out.pbData as *mut core::ffi::c_void) };
        Some(bytes)
    }

    /// Wraps a slice into a DPAPI blob (a "length + pointer" structure).
    fn blob(data: &[u8]) -> CRYPT_INTEGER_BLOB {
        CRYPT_INTEGER_BLOB {
            cbData: data.len() as u32,
            pbData: data.as_ptr() as *mut u8,
        }
    }
}

// ── hex codec (ciphertext in JSON) ───────────────────────────────────────────────
// A hand-rolled implementation instead of the base64 crate — a precedent is
// `features::sandbox_setup::hex_lower`; the string-size difference is negligible
// for a config, no dependency needed.

fn hex_encode(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push(char::from_digit((b >> 4) as u32, 16).unwrap_or('0'));
        s.push(char::from_digit((b & 0x0f) as u32, 16).unwrap_or('0'));
    }
    s
}

fn hex_decode(s: &str) -> Option<Vec<u8>> {
    if !s.len().is_multiple_of(2) {
        return None;
    }
    let b = s.as_bytes();
    (0..b.len() / 2)
        .map(|i| {
            let hi = (b[i * 2] as char).to_digit(16)?;
            let lo = (b[i * 2 + 1] as char).to_digit(16)?;
            Some(((hi << 4) | lo) as u8)
        })
        .collect()
}

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

    /// Every kind of secret has to occupy its own slot in the one per-machine
    /// `keys` map: a collision would make two unrelated fields overwrite each
    /// other's value. The external names are also pinned literally — they are on
    /// disk now, so renaming one silently orphans a stored key.
    #[test]
    fn storage_names_are_distinct_across_kinds() {
        use crate::shared::config::CloudProvider;
        let mut names: Vec<String> = CloudProvider::ALL
            .into_iter()
            .map(SecretKey::Provider)
            .chain(ExternalSlot::ALL.into_iter().map(SecretKey::External))
            .chain(SearchSlot::ALL.into_iter().map(SecretKey::Search))
            .chain([
                SecretKey::BackupPassword,
                SecretKey::McpEnv {
                    server: "chat".into(), // a server named after an external slot
                    var: "TOKEN".into(),
                },
                SecretKey::McpEnv {
                    server: "tavily".into(), // ...and one named after a search slot
                    var: "TOKEN".into(),
                },
            ])
            .map(|k| k.storage_name())
            .collect();
        let total = names.len();
        names.sort();
        names.dedup();
        assert_eq!(names.len(), total, "storage names collide: {names:?}");
        assert_eq!(
            SecretKey::External(ExternalSlot::Chat).storage_name(),
            "external-chat"
        );
        assert_eq!(
            SecretKey::External(ExternalSlot::Impersonation).storage_name(),
            "external-impersonation"
        );
        assert_eq!(
            SecretKey::External(ExternalSlot::Embed).storage_name(),
            "external-embed"
        );
        assert_eq!(
            SecretKey::External(ExternalSlot::Tts).storage_name(),
            "external-tts"
        );
    }

    #[test]
    fn hex_round_trip_and_rejects_malformed() {
        let data = vec![0u8, 1, 15, 16, 200, 255];
        assert_eq!(hex_decode(&hex_encode(&data)).unwrap(), data);
        assert_eq!(hex_encode(&[0xab, 0x0f]), "ab0f");
        assert!(hex_decode("abc").is_none()); // odd length
        assert!(hex_decode("zz").is_none()); // not hex
    }

    #[test]
    fn aead_round_trip_with_derived_key() {
        let key = derive_key(b"machine-id-abc", "user1");
        let enc = encrypt_with_key(&key, b"sk-secret-value").unwrap();
        assert_ne!(&enc[NONCE_LEN..], b"sk-secret-value"); // not plaintext
        assert_eq!(decrypt_with_key(&key, &enc).unwrap(), b"sk-secret-value");
    }

    #[test]
    fn aead_rejects_foreign_key_and_tampering() {
        let mine = derive_key(b"machine-A", "user1");
        let theirs = derive_key(b"machine-B", "user1");
        let other_user = derive_key(b"machine-A", "user2");
        let enc = encrypt_with_key(&mine, b"secret").unwrap();
        // A different machine and a different user on the same machine cannot read it.
        assert!(decrypt_with_key(&theirs, &enc).is_none());
        assert!(decrypt_with_key(&other_user, &enc).is_none());
        // Ciphertext corruption is caught by AEAD authentication.
        let mut bad = enc.clone();
        *bad.last_mut().unwrap() ^= 0xff;
        assert!(decrypt_with_key(&mine, &bad).is_none());
        // Too-short input (not even a nonce) — not a panic, but `None`.
        assert!(decrypt_with_key(&mine, &[0u8; NONCE_LEN]).is_none());
    }

    #[test]
    fn nonce_is_random_so_ciphertexts_differ() {
        let key = derive_key(b"machine-id", "u");
        let a = encrypt_with_key(&key, b"same").unwrap();
        let b = encrypt_with_key(&key, b"same").unwrap();
        assert_ne!(
            a, b,
            "identical plaintext must not produce identical ciphertext"
        );
    }

    #[test]
    fn derive_key_is_deterministic_and_domain_separated() {
        assert_eq!(derive_key(b"m", "u"), derive_key(b"m", "u"));
        assert_ne!(derive_key(b"m", "u"), derive_key(b"m", "v"));
        assert_ne!(derive_key(b"m", "u"), derive_key(b"n", "u"));
    }

    /// HKDF-SHA256 is a specification (RFC 5869), not an implementation detail,
    /// and the key derived here decrypts every API key already stored on this
    /// machine. So the vector is pinned: an upgrade of `hkdf` or `sha2` that
    /// moved it would lock the user out of their own keys, and the round-trip
    /// tests above — which derive and use the key in the same process — would
    /// all still pass.
    #[test]
    fn derive_key_matches_a_pinned_vector() {
        assert_eq!(
            hex_encode(&derive_key(b"machine-id-abc", "user1")),
            "f2b76bdb73f60eed5e07d306d47e73cff8643c35720f89146167ea37e855f1e4"
        );
    }

    /// Full round trip over a config entry — on this machine's platform scheme
    /// (Windows: DPAPI; Linux: machine-id if present — otherwise the test is
    /// skipped).
    #[test]
    fn entry_round_trip_on_local_scheme() {
        if !scheme_available() {
            return; // non-systemd Linux without machine-id: key storage is not supported
        }
        let mut entries: Vec<ApiKeyEntry> = vec![];
        put_key(&mut entries, "openai", "sk-test-123", || "test".into()).unwrap();
        assert_eq!(entries.len(), 1);
        assert!(is_ours(&entries[0]));
        // The secret is not stored in plaintext.
        let json = serde_json::to_string(&entries).unwrap();
        assert!(
            !json.contains("sk-test-123"),
            "plaintext leaked into serialization: {json}"
        );
        assert_eq!(
            stored_key(&entries, "openai").as_deref(),
            Some("sk-test-123")
        );
        assert_eq!(stored_key(&entries, "claude"), None);
        // A second provider goes into the same entry.
        put_key(&mut entries, "claude", "sk-ant-9", || "test".into()).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(stored_key(&entries, "claude").as_deref(), Some("sk-ant-9"));
        // An empty key removes the provider; an emptied entry disappears.
        put_key(&mut entries, "openai", "", || "test".into()).unwrap();
        assert_eq!(stored_key(&entries, "openai"), None);
        assert_eq!(entries.len(), 1);
        put_key(&mut entries, "claude", "", || "test".into()).unwrap();
        assert!(entries.is_empty());
    }

    /// An entry from a foreign machine (undecryptable) and an entry with an
    /// unfamiliar scheme are not recognized as ours, do not yield keys, and are
    /// **left untouched** when editing.
    #[test]
    fn foreign_entries_are_ignored_and_preserved() {
        if !scheme_available() {
            return;
        }
        let foreign = ApiKeyEntry {
            label: "other-pc".into(),
            scheme: SCHEME_MACHINE_KEY_V1.into(),
            check: hex_encode(&[7u8; 40]), // a foreign ciphertext
            keys: std::collections::BTreeMap::from([("openai".into(), hex_encode(&[9u8; 40]))]),
        };
        let future = ApiKeyEntry {
            label: "future-pc".into(),
            scheme: "keychain-v9".into(), // a scheme we do not know
            check: "00".into(),
            keys: std::collections::BTreeMap::from([("openai".into(), "00".into())]),
        };
        let mut entries = vec![foreign.clone(), future.clone()];
        assert!(!is_ours(&entries[0]) && !is_ours(&entries[1]));
        assert_eq!(stored_key(&entries, "openai"), None);

        put_key(&mut entries, "openai", "sk-mine", || "mine".into()).unwrap();
        assert_eq!(
            entries.len(),
            3,
            "our entry is added, foreign ones are not replaced"
        );
        assert_eq!(entries[0], foreign, "the foreign entry is untouched");
        assert_eq!(
            entries[1], future,
            "the entry with an unfamiliar scheme is untouched"
        );
        assert_eq!(stored_key(&entries, "openai").as_deref(), Some("sk-mine"));
    }
}