Skip to main content

vector_core/crypto/
guarded_key.rs

1//! Memory-hardened key storage: 128 indistinguishable arrays with decoy writes.
2//!
3//! The real 32-byte key is XOR-split into 4 shares (16 `usize` values on 64-bit),
4//! scattered across 128 static arrays of 4,096 entries each. 123 arrays are pure
5//! decoys — provably indistinguishable from the 5 real arrays.
6//!
7//! Defense layers:
8//! 1. **Secret splitting**: key XOR-split into 4 shares
9//! 2. **128 indistinguishable arrays**: 5 real + 123 decoy, all same size, all
10//!    OsRng-initialized, all modified identically during set()/clear()
11//! 3. **No heap allocations**: zero pointers, zero fingerprints, zero mlock
12//! 4. **Scattered positions**: seed, multipliers, and share data each placed at
13//!    (array, slot) positions derived from ASLR instance address. The slot
14//!    dimension is partitioned into LANE_COUNT disjoint lanes; each live key
15//!    owns one lane (chosen at set() to differ from other live keys), so two
16//!    keys can never share a slot — cross-key writes are collision-safe.
17//! 5. **Decoy writes**: during set(), ALL 128 arrays receive ~16 random writes,
18//!    making real writes indistinguishable via snapshot diffing
19//! 6. **Zero side-channel**: `AtomicUsize::load` during get() — zero writes
20//! 7. **Zero searchable constants**: all multipliers derived from ASLR addresses,
21//!    all modular arithmetic uses power-of-2 AND masks. The vault code compiles
22//!    to generic LDR + MUL + AND + EOR + LSR — indistinguishable from the
23//!    thousands of hash/cipher/RNG functions in the binary.
24//!
25//! Security tiers:
26//!   **Without source code** (info-stealers, forensic tools): immune.
27//!   Arrays are information-theoretically indistinguishable.
28//!
29//!   **With source code + memory dump**: attacker must brute-force the ASLR
30//!   instance address (~2.5M candidates on macOS, ~268M on Linux).
31//!   Computational hardening makes each attempt ~25 μs.
32//!   Estimated: ~1 min (macOS) to ~2 hrs (Linux), single-core.
33//!
34//!   The vault is the second layer — anti-debug protections (PT_DENY_ATTACH,
35//!   PR_SET_DUMPABLE, DACL) prevent obtaining the dump in the first place.
36
37use std::sync::atomic::{AtomicUsize, Ordering};
38use rand::RngCore;
39use zeroize::Zeroize;
40
41/// Entries per array — 4096 is common for crypto lookup tables (S-boxes, hash
42/// constants), maximizing false positives during memory scanning.
43const ARRAY_SIZE: usize = 4096;
44/// Total arrays: 3 config (seed, mul1, mul2) + 4 shares scattered across random arrays + decoys.
45/// Power-of-2: `% 128` compiles to `AND #0x7f` — hundreds of hits in any binary.
46/// Non-power-of-2 would use a unique magic-multiply constant (trivially searchable).
47const ARRAY_COUNT: usize = 128;
48/// Number of real XOR shares the key is split into.
49const NUM_SHARES: usize = 4;
50/// `usize` values per 32-byte share (4 on 64-bit, 8 on 32-bit).
51const USIZES_PER_SHARE: usize = 32 / std::mem::size_of::<usize>();
52/// Total entries used by one key's share data.
53const SHARE_ENTRIES: usize = NUM_SHARES * USIZES_PER_SHARE;
54/// Number of disjoint slot-lanes the slot dimension is partitioned into.
55/// Each live key occupies exactly one lane, so two keys in distinct lanes can
56/// never share an (array, slot) — eliminating cross-key write collisions without
57/// any coordination at read time. Power-of-2: `& (LANE_COUNT-1)` compiles to a
58/// plain AND mask (ubiquitous in any binary). 16 lanes ⇒ up to 16 concurrent keys
59/// (production uses 2). `LANE_WIDTH` = 256 slots ≫ the 19 positions a key needs.
60const LANE_COUNT: usize = 16;
61/// Slots per lane. `ARRAY_SIZE / LANE_COUNT`, power-of-2.
62const LANE_WIDTH: usize = ARRAY_SIZE / LANE_COUNT;
63
64/// 128 vault arrays — all identical, all OsRng-initialized, all modified during set().
65/// Memory: 128 × 4096 × size_of::<usize>() = 4 MB on 64-bit, 2 MB on 32-bit.
66static VAULTS: [[AtomicUsize; ARRAY_SIZE]; ARRAY_COUNT] = {
67    const ZERO: AtomicUsize = AtomicUsize::new(0);
68    const ROW: [AtomicUsize; ARRAY_SIZE] = [ZERO; ARRAY_SIZE];
69    [ROW; ARRAY_COUNT]
70};
71
72/// Initialize all 128 arrays with random values (once per process).
73/// Every entry across all arrays is filled — real and decoy arrays are identical.
74fn ensure_vaults() {
75    if VAULTS[0][0].load(Ordering::Relaxed) == 0 {
76
77        let mut rng = rand::rngs::OsRng;
78        for row in VAULTS.iter() {
79            for slot in row.iter() {
80                let mut val = rng.next_u64() as usize;
81                if val == 0 { val = 1; }
82                let _ = slot.compare_exchange(0, val, Ordering::SeqCst, Ordering::Relaxed);
83            }
84        }
85    }
86}
87
88/// A position within the vault: (array index, slot index).
89#[derive(Clone, Copy, PartialEq)]
90#[cfg_attr(test, derive(Debug))]
91struct VaultPos {
92    array: usize,
93    slot: usize,
94}
95
96/// Derive the mixing iteration count from ASLR addresses — ZERO constants in the binary.
97/// Range: 4096..8191. Changes every launch. `& 4095` compiles to `AND #0xFFF` (hundreds
98/// of hits in any binary). No fixed loop bound for RE engineers to search for.
99#[inline]
100fn mix_iterations(instance_addr: usize) -> usize {
101    let base = VAULTS.as_ptr() as usize;
102    let h = (instance_addr ^ base).wrapping_mul(instance_addr | 1);
103    4096 + ((h >> 7) & 4095)
104}
105
106/// Hardened hash mixing — runtime-determined iterations of shift-multiply-XOR.
107/// ZERO compile-time constants — both multipliers AND iteration count derived
108/// from ASLR addresses. Compiles to a tight loop of generic MUL + EOR + LSR.
109#[inline]
110fn addr_mix(mut h: u64, m1: u64, m2: u64, iterations: usize) -> u64 {
111    for _ in 0..iterations {
112        h ^= h >> 33;
113        h = h.wrapping_mul(m1);
114        h ^= h >> 29;
115        h = h.wrapping_mul(m2);
116        h ^= h >> 31;
117    }
118    h
119}
120
121/// Map a raw hash to a slot inside `lane`'s disjoint slot-range.
122/// `slot = lane*LANE_WIDTH + (raw % LANE_WIDTH)` — distinct lanes never overlap.
123/// Both ops are AND masks (power-of-2), so this stays a generic AND/OR/SHIFT
124/// sequence with no searchable constants.
125#[inline]
126fn lane_slot(raw: usize, lane: usize) -> usize {
127    (lane << LANE_WIDTH.trailing_zeros()) | (raw & (LANE_WIDTH - 1))
128}
129
130/// Derive the 3 configuration positions (seed, mul1, mul2) from the instance
131/// address. No dependency on stored share values — breaks the chicken-and-egg.
132/// Uses instance address + VAULTS base address (both ASLR'd, both change per launch).
133/// ZERO searchable constants — multipliers derived from the two ASLR addresses.
134/// `lane` confines the slot dimension to one of LANE_COUNT disjoint ranges, so two
135/// keys in different lanes can never collide. Guaranteed collision-free within a key
136/// (rehash on collision).
137fn config_positions(instance_addr: usize, lane: usize) -> (VaultPos, VaultPos, VaultPos) {
138    let base = VAULTS.as_ptr() as usize;
139    // Derive multipliers from ASLR addresses — forced odd for mixing quality.
140    // These change every launch. No constants in the binary.
141    let m1 = (instance_addr as u64) | 1;
142    let m2 = (base as u64) | 1;
143    let iters = mix_iterations(instance_addr);
144    let mut h = addr_mix((instance_addr as u64) ^ (base as u64).rotate_left(19), m1, m2, iters);
145
146    let mut positions = [VaultPos { array: 0, slot: 0 }; 3];
147    for i in 0..3 {
148        loop {
149            let candidate = VaultPos {
150                array: ((h >> 32) as usize) & (ARRAY_COUNT - 1),
151                slot: lane_slot(h as usize, lane),
152            };
153            if !positions[..i].contains(&candidate) {
154                positions[i] = candidate;
155                h = addr_mix(h, m1, m2, iters);
156                break;
157            }
158            h = addr_mix(h, m1, m2, iters);
159        }
160    }
161
162    (positions[0], positions[1], positions[2])
163}
164
165/// Derive share data positions. Uses the seed + multipliers read from the vault
166/// (which are at config-derived positions) + instance address.
167/// `lane` confines the slot dimension to the key's disjoint lane (same lane as its
168/// config positions). Returns SHARE_ENTRIES (array, slot) pairs — each guaranteed
169/// unique and non-overlapping with config positions (rehash on collision).
170fn share_positions(instance_addr: usize, lane: usize) -> [VaultPos; SHARE_ENTRIES] {
171    let (seed_pos, mul1_pos, mul2_pos) = config_positions(instance_addr, lane);
172    let seed = VAULTS[seed_pos.array][seed_pos.slot].load(Ordering::Relaxed) as u64;
173    let mul1 = VAULTS[mul1_pos.array][mul1_pos.slot].load(Ordering::Relaxed) as u64;
174    let mul2 = VAULTS[mul2_pos.array][mul2_pos.slot].load(Ordering::Relaxed) as u64;
175
176    let mut h = seed ^ (instance_addr as u64).rotate_left(23);
177    let mut positions = [VaultPos { array: 0, slot: 0 }; SHARE_ENTRIES];
178    let config = [seed_pos, mul1_pos, mul2_pos];
179
180    for i in 0..SHARE_ENTRIES {
181        loop {
182            h ^= h >> 17;
183            h = h.wrapping_mul(mul1 | 1);
184            h ^= h >> 13;
185            h = h.wrapping_mul(mul2 | 1);
186            h ^= h >> 16;
187            let candidate = VaultPos {
188                array: ((h >> 32) as usize) & (ARRAY_COUNT - 1),
189                slot: lane_slot(h as usize, lane),
190            };
191            if !config.contains(&candidate) && !positions[..i].contains(&candidate) {
192                positions[i] = candidate;
193                break;
194            }
195            // Collision: hash state already advanced, loop retries with new h
196        }
197    }
198
199    positions
200}
201
202/// Write ~16 random entries to EVERY array (real + decoy), excluding protected positions.
203/// Protected positions belong to other active GuardedKey instances — overwriting them would
204/// corrupt the other key's share/config data. Snapshot-diffing still shows identical change
205/// patterns across all 128 arrays (excluded slots are ≤19 out of 524,288 — invisible).
206fn write_decoys(protected: &[VaultPos]) {
207
208    let mut rng = rand::rngs::OsRng;
209    for (array_idx, row) in VAULTS.iter().enumerate() {
210        for _ in 0..SHARE_ENTRIES {
211            let mut slot = (rng.next_u64() as usize) & (ARRAY_SIZE - 1);
212            // Reroll if this hits another key's data (~0.004% chance, essentially never)
213            while protected.iter().any(|p| p.array == array_idx && p.slot == slot) {
214                slot = (rng.next_u64() as usize) & (ARRAY_SIZE - 1);
215            }
216            let mut val = rng.next_u64() as usize;
217            if val == 0 { val = 1; }
218            row[slot].store(val, Ordering::Release);
219        }
220    }
221}
222
223/// Memory-hardened key vault backed by 128 indistinguishable static arrays.
224pub struct GuardedKey {
225    /// Non-zero when a key is stored. Stores a random non-zero value (not 0/1)
226    /// so it looks like any other random data in `__DATA`.
227    active: AtomicUsize,
228}
229
230impl GuardedKey {
231    pub const fn empty() -> Self {
232        Self { active: AtomicUsize::new(0) }
233    }
234
235    #[inline]
236    fn instance_addr(&self) -> usize {
237        &self.active as *const _ as usize
238    }
239
240    /// The lane a marker maps to. Lanes partition the slot space; a key lives
241    /// entirely within one lane so it can never share a slot with a key in a
242    /// different lane. `& (LANE_COUNT-1)` is a plain AND mask.
243    #[inline]
244    fn lane_of(marker: usize) -> usize {
245        marker & (LANE_COUNT - 1)
246    }
247
248    /// This instance's current lane, read from its active marker (0 if empty).
249    #[inline]
250    fn lane(&self) -> usize {
251        Self::lane_of(self.active.load(Ordering::Acquire))
252    }
253
254    /// Pick a marker whose lane differs from every currently-live other key's lane,
255    /// so this key's positions stay disjoint from theirs. `rng` supplies the random
256    /// marker; we reroll until its lane is free. With LANE_COUNT lanes and far fewer
257    /// concurrent keys, this terminates in ~1 try. The marker itself remains a
258    /// full-range random non-zero usize — the lane is an emergent low-bit property,
259    /// not separately stored, so it adds no searchable fingerprint.
260    fn pick_marker(&self, others: &[&GuardedKey], rng: &mut rand::rngs::OsRng) -> usize {
261        let mut taken = [false; LANE_COUNT];
262        for &key in others {
263            if std::ptr::eq(key, self) || !key.has_key() { continue; }
264            taken[key.lane()] = true;
265        }
266        loop {
267            let mut marker = rng.next_u64() as usize;
268            if marker == 0 { marker = 1; }
269            if !taken[Self::lane_of(marker)] {
270                return marker;
271            }
272        }
273    }
274
275    /// Collect protected vault positions from other active GuardedKey instances.
276    /// Prevents write_decoys from corrupting another key's share/config data.
277    ///
278    /// `others` is a slice of references to other GuardedKey instances that share
279    /// the same VAULTS arrays. The caller is responsible for passing all other
280    /// active keys to ensure cross-key protection.
281    ///
282    /// Sized for up to LANE_COUNT live keys (the max that can coexist in distinct
283    /// lanes). Extra entries are simply not collected — lanes already guarantee
284    /// disjointness, so the decoy exclusion is a redundant safety net.
285    fn collect_other_protected(&self, others: &[&GuardedKey]) -> ([VaultPos; (3 + SHARE_ENTRIES) * LANE_COUNT], usize) {
286        let mut buf = [VaultPos { array: 0, slot: 0 }; (3 + SHARE_ENTRIES) * LANE_COUNT];
287        let mut n = 0;
288        for &key in others {
289            if std::ptr::eq(key, self) || !key.has_key() { continue; }
290            if n + 3 + SHARE_ENTRIES > buf.len() { break; }
291            let addr = key.instance_addr();
292            let lane = key.lane();
293            let (s, m1, m2) = config_positions(addr, lane);
294            buf[n] = s; n += 1;
295            buf[n] = m1; n += 1;
296            buf[n] = m2; n += 1;
297            for &pos in share_positions(addr, lane).iter() {
298                buf[n] = pos;
299                n += 1;
300            }
301        }
302        (buf, n)
303    }
304
305    /// Extract the secret key from a `Keys` struct, store it in the vault,
306    /// and zeroize the intermediate bytes. One-liner replacement for the
307    /// repeated extract -> set -> zeroize pattern across login paths.
308    ///
309    /// `others` is a slice of references to other active GuardedKey instances
310    /// for cross-key protection during decoy writes.
311    #[inline]
312    pub fn store_from_keys(&self, keys: &nostr_sdk::prelude::Keys, others: &[&GuardedKey]) {
313        let mut sk_bytes = keys.secret_key().secret_bytes();
314        self.set(sk_bytes, others);
315        sk_bytes.zeroize();
316    }
317
318    /// Store a key. XOR-split into 4 shares scattered across the 128 arrays,
319    /// with decoy writes to ALL arrays so real writes are indistinguishable.
320    ///
321    /// `others` is a slice of references to other active GuardedKey instances
322    /// for cross-key protection during decoy writes.
323    ///
324    /// INVARIANT: pass EVERY other live key. A key's lane is chosen by excluding only `others`'
325    /// lanes, so an omitted live key can collide and clobber it.
326    pub fn set(&self, mut key: [u8; 32], others: &[&GuardedKey]) {
327
328        let mut rng = rand::rngs::OsRng;
329        ensure_vaults();
330
331        // Choose this key's lane FIRST: a random marker whose lane is free among the
332        // live others. All of this key's positions live in that lane, so they are
333        // physically disjoint from every other-lane key's positions — no real write
334        // can ever land on another key's slot. The marker is committed to `active`
335        // only at the end; until then the old marker still answers get()/lane().
336        let marker = self.pick_marker(others, &mut rng);
337        let lane = Self::lane_of(marker);
338
339        // Overwriting a live key into a different lane? Scrub the old lane's share
340        // slots so the previous key never lingers (matches clear()'s scrub).
341        let old_marker = self.active.load(Ordering::Acquire);
342        if old_marker != 0 {
343            let old_lane = Self::lane_of(old_marker);
344            if old_lane != lane {
345                for pos in share_positions(self.instance_addr(), old_lane).iter() {
346                    let mut val = rng.next_u64() as usize;
347                    if val == 0 { val = 1; }
348                    VAULTS[pos.array][pos.slot].store(val, Ordering::Release);
349                }
350            }
351        }
352
353        // Protect other active key's positions from decoy writes
354        let (protected, pcount) = self.collect_other_protected(others);
355
356        // Write decoys FIRST — random noise across all 128 arrays.
357        // Excludes other keys' positions. Real writes below overwrite any decoy
358        // that landed on OUR slots, guaranteeing all keys' data survives intact.
359        write_decoys(&protected[..pcount]);
360
361        // Force multiplier entries odd (mixing quality).
362        // ~50% of all entries are already odd, so this isn't a fingerprint.
363        let (_, mul1_pos, mul2_pos) = config_positions(self.instance_addr(), lane);
364        let v = VAULTS[mul1_pos.array][mul1_pos.slot].load(Ordering::Relaxed);
365        VAULTS[mul1_pos.array][mul1_pos.slot].store(v | 1, Ordering::Relaxed);
366        let v = VAULTS[mul2_pos.array][mul2_pos.slot].load(Ordering::Relaxed);
367        VAULTS[mul2_pos.array][mul2_pos.slot].store(v | 1, Ordering::Relaxed);
368
369        // XOR-split the key into NUM_SHARES random shares
370        let mut shares = [[0u8; 32]; NUM_SHARES];
371        for share in shares.iter_mut().take(NUM_SHARES - 1) {
372            rng.fill_bytes(share);
373        }
374        shares[NUM_SHARES - 1] = key;
375        for i in 0..NUM_SHARES - 1 {
376            for j in 0..32 {
377                shares[NUM_SHARES - 1][j] ^= shares[i][j];
378            }
379        }
380        key.zeroize();
381
382        // Write share data to derived positions (after decoys, so real data survives)
383        let positions = share_positions(self.instance_addr(), lane);
384        for (share_idx, share) in shares.iter().enumerate() {
385            for u_idx in 0..USIZES_PER_SHARE {
386                let byte_off = u_idx * std::mem::size_of::<usize>();
387                let val = usize::from_ne_bytes(
388                    share[byte_off..byte_off + std::mem::size_of::<usize>()]
389                        .try_into().unwrap()
390                );
391                let pos = positions[share_idx * USIZES_PER_SHARE + u_idx];
392                VAULTS[pos.array][pos.slot].store(val, Ordering::Release);
393            }
394        }
395        for share in shares.iter_mut() { share.zeroize(); }
396
397        // Commit the lane-bearing marker. share_positions() in get() re-derives the
398        // same lane from this marker, so reads land on exactly these slots.
399        self.active.store(marker, Ordering::Release);
400    }
401
402    /// Recover the key. Zero writes — invisible to snapshot diffing.
403    pub fn get(&self) -> Option<[u8; 32]> {
404        let marker = self.active.load(Ordering::Acquire);
405        if marker == 0 {
406            return None;
407        }
408
409        // Lane comes from the marker, so reads land on exactly the slots set() wrote.
410        let positions = share_positions(self.instance_addr(), Self::lane_of(marker));
411        let mut key = [0u8; 32];
412
413        for share_idx in 0..NUM_SHARES {
414            let mut share = [0u8; 32];
415            for u_idx in 0..USIZES_PER_SHARE {
416                let pos = positions[share_idx * USIZES_PER_SHARE + u_idx];
417                let val = VAULTS[pos.array][pos.slot].load(Ordering::Acquire);
418                let byte_off = u_idx * std::mem::size_of::<usize>();
419                share[byte_off..byte_off + std::mem::size_of::<usize>()]
420                    .copy_from_slice(&val.to_ne_bytes());
421            }
422            for (a, b) in key.iter_mut().zip(share.iter()) {
423                *a ^= *b;
424            }
425        }
426
427        Some(key)
428    }
429
430    /// Clear the key. Overwrites shares with random values, writes decoys to all arrays.
431    ///
432    /// `others` is a slice of references to other active GuardedKey instances
433    /// for cross-key protection during decoy writes.
434    pub fn clear(&self, others: &[&GuardedKey]) {
435        // Set inactive FIRST — any concurrent get() will return None
436        let old_marker = self.active.swap(0, Ordering::SeqCst);
437        if old_marker != 0 {
438
439            let mut rng = rand::rngs::OsRng;
440            // Scrub the slots we actually wrote: lane from the now-cleared marker.
441            let positions = share_positions(self.instance_addr(), Self::lane_of(old_marker));
442            for pos in &positions {
443                let mut val = rng.next_u64() as usize;
444                if val == 0 { val = 1; }
445                VAULTS[pos.array][pos.slot].store(val, Ordering::Release);
446            }
447            let (protected, pcount) = self.collect_other_protected(others);
448            write_decoys(&protected[..pcount]);
449        }
450    }
451
452    pub fn has_key(&self) -> bool {
453        self.active.load(Ordering::Acquire) != 0
454    }
455
456    pub fn to_keys(&self) -> Option<nostr_sdk::prelude::Keys> {
457        let mut bytes = self.get()?;
458        let result = nostr_sdk::prelude::SecretKey::from_slice(&bytes);
459        bytes.zeroize();
460        Some(nostr_sdk::prelude::Keys::new(result.ok()?))
461    }
462}
463
464// ============================================================================
465// Tests
466// ============================================================================
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    // Test-local key statics (replaces crate::MY_SECRET_KEY / crate::ENCRYPTION_KEY)
473    static TEST_KEY_A: GuardedKey = GuardedKey::empty();
474    static TEST_KEY_B: GuardedKey = GuardedKey::empty();
475
476    // SERIALIZE on the crate-wide `DB_TEST_GUARD` — NOT a vault-local lock. The 128 `VAULTS` arrays are
477    // ONE process-global shared by EVERY GuardedKey, including the production `MY_SECRET_KEY` /
478    // `ENCRYPTION_KEY` that other tests write via `init_test_db` (which holds `DB_TEST_GUARD`). Those
479    // writers pass only their partner in `others`, so their decoys/lane don't know about TEST_KEY_A/B and
480    // would clobber them if run concurrently — the cause of the cross-key flake. Sharing the same guard
481    // makes all vault-touching vector-core tests mutually exclusive. (Production is unaffected: its two
482    // keys are always mutually `others`-aware, verified across every login/clear path. Poison-tolerant
483    // via `into_inner` so one test panic doesn't cascade poison across the suite.)
484
485    /// Fast reset: mark both test keys inactive without full clear overhead.
486    fn reset() {
487        TEST_KEY_A.active.store(0, Ordering::SeqCst);
488        TEST_KEY_B.active.store(0, Ordering::SeqCst);
489        ensure_vaults();
490    }
491
492    /// Helper: the "others" slice for TEST_KEY_A (protects TEST_KEY_B).
493    fn others_for_a() -> [&'static GuardedKey; 1] {
494        [&TEST_KEY_B]
495    }
496
497    /// Helper: the "others" slice for TEST_KEY_B (protects TEST_KEY_A).
498    fn others_for_b() -> [&'static GuardedKey; 1] {
499        [&TEST_KEY_A]
500    }
501
502    /// Generate a deterministic test key from a seed byte.
503    fn test_key(seed: u8) -> [u8; 32] {
504        let mut k = [0u8; 32];
505        for (i, b) in k.iter_mut().enumerate() {
506            *b = seed.wrapping_add(i as u8).wrapping_mul(37).wrapping_add(7);
507        }
508        k
509    }
510
511    // ================================================================
512    // Basic operations
513    // ================================================================
514
515    #[test]
516    fn set_get_roundtrip() {
517        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
518        reset();
519        let key = test_key(42);
520        TEST_KEY_A.set(key, &others_for_a());
521        assert_eq!(TEST_KEY_A.get(), Some(key));
522    }
523
524    #[test]
525    #[ignore = "vault stress: hundreds of guarded set/get rounds, serialized on the vault statics (~41s of a 52s suite). Run in CI by NAME (`-- --ignored guarded_key`), never --include-ignored: that also sweeps in the live-relay tests."]
526    fn set_get_1000_iterations() {
527        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
528        for i in 0..1000u16 {
529            reset();
530            let key = test_key((i ^ (i >> 3)) as u8);
531            TEST_KEY_A.set(key, &others_for_a());
532            assert_eq!(
533                TEST_KEY_A.get(), Some(key),
534                "Roundtrip failed at iteration {i}"
535            );
536        }
537    }
538
539    #[test]
540    fn empty_returns_none() {
541        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
542        reset();
543        assert_eq!(TEST_KEY_A.get(), None);
544        assert_eq!(TEST_KEY_B.get(), None);
545    }
546
547    #[test]
548    fn has_key_lifecycle() {
549        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
550        reset();
551        assert!(!TEST_KEY_A.has_key());
552        TEST_KEY_A.set(test_key(1), &others_for_a());
553        assert!(TEST_KEY_A.has_key());
554        TEST_KEY_A.clear(&others_for_a());
555        assert!(!TEST_KEY_A.has_key());
556    }
557
558    #[test]
559    fn clear_returns_none() {
560        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
561        reset();
562        TEST_KEY_A.set(test_key(99), &others_for_a());
563        assert!(TEST_KEY_A.get().is_some());
564        TEST_KEY_A.clear(&others_for_a());
565        assert_eq!(TEST_KEY_A.get(), None);
566    }
567
568    #[test]
569    fn set_overwrites_previous() {
570        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
571        reset();
572        let a = test_key(10);
573        let b = test_key(20);
574        TEST_KEY_A.set(a, &others_for_a());
575        assert_eq!(TEST_KEY_A.get(), Some(a));
576        TEST_KEY_A.set(b, &others_for_a());
577        assert_eq!(TEST_KEY_A.get(), Some(b));
578    }
579
580    #[test]
581    fn clear_idempotent() {
582        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
583        reset();
584        TEST_KEY_A.clear(&others_for_a());
585        TEST_KEY_A.clear(&others_for_a());
586        assert_eq!(TEST_KEY_A.get(), None);
587        TEST_KEY_A.set(test_key(5), &others_for_a());
588        TEST_KEY_A.clear(&others_for_a());
589        TEST_KEY_A.clear(&others_for_a());
590        assert_eq!(TEST_KEY_A.get(), None);
591    }
592
593    #[test]
594    fn encryption_key_basic() {
595        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
596        reset();
597        let key = test_key(0xEE);
598        TEST_KEY_B.set(key, &others_for_b());
599        assert_eq!(TEST_KEY_B.get(), Some(key));
600        TEST_KEY_B.clear(&others_for_b());
601        assert_eq!(TEST_KEY_B.get(), None);
602    }
603
604    // ================================================================
605    // Cross-key protection — 500 iterations each.
606    // Two keys live in disjoint lanes, so neither key's writes (decoys, shares,
607    // or multiplier-odd-forcing) can ever land on the other's slots.
608    // ================================================================
609
610    #[test]
611    #[ignore = "vault stress: hundreds of guarded set/get rounds, serialized on the vault statics (~41s of a 52s suite). Run in CI by NAME (`-- --ignored guarded_key`), never --include-ignored: that also sweeps in the live-relay tests."]
612    fn cross_key_set_then_set_500() {
613        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
614        let key_a = test_key(0xAA);
615        let key_b = test_key(0xBB);
616        for i in 0..500 {
617            reset();
618            TEST_KEY_A.set(key_a, &others_for_a());
619            TEST_KEY_B.set(key_b, &others_for_b());
620            assert_eq!(
621                TEST_KEY_A.get(), Some(key_a),
622                "TEST_KEY_A corrupted at iteration {i}"
623            );
624            assert_eq!(
625                TEST_KEY_B.get(), Some(key_b),
626                "TEST_KEY_B corrupted at iteration {i}"
627            );
628        }
629    }
630
631    #[test]
632    #[ignore = "vault stress: hundreds of guarded set/get rounds, serialized on the vault statics (~41s of a 52s suite). Run in CI by NAME (`-- --ignored guarded_key`), never --include-ignored: that also sweeps in the live-relay tests."]
633    fn cross_key_reverse_order_500() {
634        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
635        let key_a = test_key(0xCC);
636        let key_b = test_key(0xDD);
637        for i in 0..500 {
638            reset();
639            TEST_KEY_B.set(key_b, &others_for_b());
640            TEST_KEY_A.set(key_a, &others_for_a());
641            assert_eq!(
642                TEST_KEY_B.get(), Some(key_b),
643                "TEST_KEY_B corrupted at iteration {i}"
644            );
645            assert_eq!(
646                TEST_KEY_A.get(), Some(key_a),
647                "TEST_KEY_A corrupted at iteration {i}"
648            );
649        }
650    }
651
652    #[test]
653    #[ignore = "vault stress: hundreds of guarded set/get rounds, serialized on the vault statics (~41s of a 52s suite). Run in CI by NAME (`-- --ignored guarded_key`), never --include-ignored: that also sweeps in the live-relay tests."]
654    fn cross_key_clear_preserves_other_500() {
655        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
656        let key_a = test_key(0x11);
657        let key_b = test_key(0x22);
658        for i in 0..500 {
659            // Clear TEST_KEY_A, verify TEST_KEY_B survives
660            reset();
661            TEST_KEY_A.set(key_a, &others_for_a());
662            TEST_KEY_B.set(key_b, &others_for_b());
663            TEST_KEY_A.clear(&others_for_a());
664            assert_eq!(
665                TEST_KEY_B.get(), Some(key_b),
666                "KEY_B corrupted after KEY_A.clear() at iteration {i}"
667            );
668            // Clear TEST_KEY_B, verify TEST_KEY_A survives
669            reset();
670            TEST_KEY_A.set(key_a, &others_for_a());
671            TEST_KEY_B.set(key_b, &others_for_b());
672            TEST_KEY_B.clear(&others_for_b());
673            assert_eq!(
674                TEST_KEY_A.get(), Some(key_a),
675                "KEY_A corrupted after KEY_B.clear() at iteration {i}"
676            );
677        }
678    }
679
680    #[test]
681    #[ignore = "vault stress: hundreds of guarded set/get rounds, serialized on the vault statics (~41s of a 52s suite). Run in CI by NAME (`-- --ignored guarded_key`), never --include-ignored: that also sweeps in the live-relay tests."]
682    fn cross_key_alternating_500() {
683        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
684        for i in 0..500u16 {
685            reset();
686            let ka = test_key(i as u8);
687            let kb = test_key(!(i as u8));
688            TEST_KEY_A.set(ka, &others_for_a());
689            TEST_KEY_B.set(kb, &others_for_b());
690            assert_eq!(TEST_KEY_A.get(), Some(ka), "KEY_A wrong at iter {i}");
691            assert_eq!(TEST_KEY_B.get(), Some(kb), "KEY_B wrong at iter {i}");
692            TEST_KEY_A.clear(&others_for_a());
693            assert_eq!(TEST_KEY_B.get(), Some(kb), "KEY_B wrong after KEY_A clear at iter {i}");
694        }
695    }
696
697    /// Stress: alternating set order, different keys each round, 1000 iterations.
698    /// Two keys occupy disjoint lanes, so a real write from one can never clobber the
699    /// other's share slots regardless of decoy layout.
700    #[test]
701    #[ignore = "vault stress: hundreds of guarded set/get rounds, serialized on the vault statics (~41s of a 52s suite). Run in CI by NAME (`-- --ignored guarded_key`), never --include-ignored: that also sweeps in the live-relay tests."]
702    fn stress_both_keys_1000() {
703        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
704        for i in 0..1000u32 {
705            reset();
706            let ka = test_key((i & 0xFF) as u8);
707            let kb = test_key(!((i & 0xFF) as u8));
708            if i % 2 == 0 {
709                TEST_KEY_A.set(ka, &others_for_a());
710                TEST_KEY_B.set(kb, &others_for_b());
711            } else {
712                TEST_KEY_B.set(kb, &others_for_b());
713                TEST_KEY_A.set(ka, &others_for_a());
714            }
715            assert_eq!(TEST_KEY_A.get(), Some(ka), "KEY_A wrong at iter {i}");
716            assert_eq!(TEST_KEY_B.get(), Some(kb), "KEY_B wrong at iter {i}");
717        }
718    }
719
720    // ================================================================
721    // Position derivation
722    // ================================================================
723
724    #[test]
725    fn config_positions_all_unique() {
726        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
727        ensure_vaults();
728        for lane in 0..LANE_COUNT {
729            for addr in (0x1000..0x2000usize).step_by(8) {
730                let (a, b, c) = config_positions(addr, lane);
731                assert_ne!(a, b, "config collision a==b at addr {addr:#x} lane {lane}");
732                assert_ne!(a, c, "config collision a==c at addr {addr:#x} lane {lane}");
733                assert_ne!(b, c, "config collision b==c at addr {addr:#x} lane {lane}");
734            }
735        }
736    }
737
738    #[test]
739    fn share_positions_all_unique() {
740        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
741        ensure_vaults();
742        for lane in 0..LANE_COUNT {
743            for addr in (0x2000..0x2100usize).step_by(8) {
744                let positions = share_positions(addr, lane);
745                for i in 0..SHARE_ENTRIES {
746                    for j in (i + 1)..SHARE_ENTRIES {
747                        assert_ne!(
748                            positions[i], positions[j],
749                            "share collision [{i}]==[{j}] at addr {addr:#x} lane {lane}"
750                        );
751                    }
752                }
753            }
754        }
755    }
756
757    #[test]
758    fn share_positions_no_config_overlap() {
759        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
760        ensure_vaults();
761        for lane in 0..LANE_COUNT {
762            for addr in (0x3000..0x3100usize).step_by(8) {
763                let (s, m1, m2) = config_positions(addr, lane);
764                let config = [s, m1, m2];
765                let shares = share_positions(addr, lane);
766                for (i, pos) in shares.iter().enumerate() {
767                    assert!(
768                        !config.contains(pos),
769                        "share[{i}] collides with config at addr {addr:#x} lane {lane}"
770                    );
771                }
772            }
773        }
774    }
775
776    /// Distinct lanes are physically disjoint: no (array, slot) is shared between
777    /// two different lanes for the same address. This is the property that makes
778    /// cross-key writes collision-safe.
779    #[test]
780    fn distinct_lanes_are_disjoint() {
781        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
782        ensure_vaults();
783        for addr in (0x5000..0x5100usize).step_by(8) {
784            for la in 0..LANE_COUNT {
785                for lb in 0..LANE_COUNT {
786                    if la == lb { continue; }
787                    let (sa, m1a, m2a) = config_positions(addr, la);
788                    let a_all: Vec<VaultPos> = [sa, m1a, m2a]
789                        .into_iter()
790                        .chain(share_positions(addr, la))
791                        .collect();
792                    let (sb, m1b, m2b) = config_positions(addr, lb);
793                    let b_all: Vec<VaultPos> = [sb, m1b, m2b]
794                        .into_iter()
795                        .chain(share_positions(addr, lb))
796                        .collect();
797                    for p in &a_all {
798                        assert!(
799                            !b_all.contains(p),
800                            "lane {la} and lane {lb} share {p:?} at addr {addr:#x}"
801                        );
802                    }
803                }
804            }
805        }
806    }
807
808    #[test]
809    fn positions_deterministic() {
810        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
811        ensure_vaults();
812        let addr = TEST_KEY_A.instance_addr();
813        let cfg1 = config_positions(addr, 3);
814        let cfg2 = config_positions(addr, 3);
815        assert_eq!(cfg1, cfg2);
816        let sp1 = share_positions(addr, 3);
817        let sp2 = share_positions(addr, 3);
818        assert_eq!(sp1, sp2);
819    }
820
821    #[test]
822    fn all_positions_in_bounds() {
823        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
824        ensure_vaults();
825        for lane in 0..LANE_COUNT {
826            for addr in (0x4000..0x4200usize).step_by(8) {
827                let (a, b, c) = config_positions(addr, lane);
828                for p in [a, b, c] {
829                    assert!(p.array < ARRAY_COUNT);
830                    assert!(p.slot < ARRAY_SIZE);
831                }
832                for p in share_positions(addr, lane) {
833                    assert!(p.array < ARRAY_COUNT);
834                    assert!(p.slot < ARRAY_SIZE);
835                }
836            }
837        }
838    }
839
840    // ================================================================
841    // Internals
842    // ================================================================
843
844    #[test]
845    fn mix_iterations_in_range() {
846        for addr in 0..10000usize {
847            let n = mix_iterations(addr);
848            assert!((4096..=8191).contains(&n), "mix_iterations({addr}) = {n}");
849        }
850    }
851
852    #[test]
853    fn addr_mix_zero_iterations_is_identity() {
854        let h: u64 = 0xDEADBEEFCAFEBABE;
855        assert_eq!(addr_mix(h, 123, 456, 0), h);
856    }
857
858    #[test]
859    fn addr_mix_varies_output() {
860        let a = addr_mix(1, 3, 5, 10);
861        let b = addr_mix(2, 3, 5, 10);
862        let c = addr_mix(1, 7, 5, 10);
863        let d = addr_mix(1, 3, 11, 10);
864        assert_ne!(a, b);
865        assert_ne!(a, c);
866        assert_ne!(a, d);
867    }
868
869    #[test]
870    fn ensure_vaults_all_nonzero() {
871        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
872        ensure_vaults();
873        for (r, row) in VAULTS.iter().enumerate() {
874            for (s, slot) in row.iter().enumerate() {
875                assert_ne!(
876                    slot.load(Ordering::Relaxed), 0,
877                    "VAULTS[{r}][{s}] is zero after ensure_vaults"
878                );
879            }
880        }
881    }
882
883    #[test]
884    fn ensure_vaults_idempotent() {
885        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
886        ensure_vaults();
887        let samples: Vec<_> = (0..20)
888            .map(|i| {
889                let r = i * 13 % ARRAY_COUNT;
890                let s = i * 397 % ARRAY_SIZE;
891                (r, s, VAULTS[r][s].load(Ordering::Relaxed))
892            })
893            .collect();
894        ensure_vaults();
895        for (r, s, val) in &samples {
896            assert_eq!(
897                VAULTS[*r][*s].load(Ordering::Relaxed), *val,
898                "ensure_vaults changed VAULTS[{r}][{s}]"
899            );
900        }
901    }
902
903    /// Run write_decoys 500 times with protected positions — verify they are NEVER overwritten.
904    /// Without exclusion, P(at least one hit) per position ~ 86%. With 6 positions:
905    /// P(all survive unprotected) ~ 0.14^6 ~ 0.00075%, so a broken exclusion is caught with near-certainty.
906    #[test]
907    #[ignore = "vault stress: hundreds of guarded set/get rounds, serialized on the vault statics (~41s of a 52s suite). Run in CI by NAME (`-- --ignored guarded_key`), never --include-ignored: that also sweeps in the live-relay tests."]
908    fn write_decoys_respects_exclusions_500() {
909        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
910        ensure_vaults();
911        let protected = [
912            VaultPos { array: 0, slot: 100 },
913            VaultPos { array: 0, slot: 200 },
914            VaultPos { array: 50, slot: 2000 },
915            VaultPos { array: 50, slot: 3000 },
916            VaultPos { array: 100, slot: 500 },
917            VaultPos { array: 127, slot: 4095 },
918        ];
919        let before: Vec<usize> = protected.iter()
920            .map(|p| VAULTS[p.array][p.slot].load(Ordering::Relaxed))
921            .collect();
922        for _ in 0..500 {
923            write_decoys(&protected);
924        }
925        for (i, p) in protected.iter().enumerate() {
926            assert_eq!(
927                VAULTS[p.array][p.slot].load(Ordering::Relaxed),
928                before[i],
929                "Protected position ({}, {}) overwritten after 500 rounds",
930                p.array, p.slot
931            );
932        }
933    }
934
935    #[test]
936    fn write_decoys_empty_exclusion_works() {
937        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
938        ensure_vaults();
939        write_decoys(&[]);
940    }
941
942    // ================================================================
943    // Edge cases
944    // ================================================================
945
946    #[test]
947    fn zero_key_roundtrip() {
948        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
949        reset();
950        let key = [0u8; 32];
951        TEST_KEY_A.set(key, &others_for_a());
952        assert_eq!(TEST_KEY_A.get(), Some(key));
953    }
954
955    #[test]
956    fn max_key_roundtrip() {
957        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
958        reset();
959        let key = [0xFFu8; 32];
960        TEST_KEY_A.set(key, &others_for_a());
961        assert_eq!(TEST_KEY_A.get(), Some(key));
962    }
963
964    #[test]
965    fn to_keys_roundtrip() {
966        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
967        reset();
968        let mut sk_bytes = [0u8; 32];
969        sk_bytes[31] = 1; // scalar = 1, valid secp256k1 key
970        TEST_KEY_A.set(sk_bytes, &others_for_a());
971        let keys = TEST_KEY_A.to_keys();
972        assert!(keys.is_some(), "to_keys returned None for valid key");
973        assert_eq!(keys.unwrap().secret_key().secret_bytes(), sk_bytes);
974    }
975
976    #[test]
977    fn to_keys_none_when_empty() {
978        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
979        reset();
980        assert!(TEST_KEY_A.to_keys().is_none());
981    }
982
983    #[test]
984    fn store_from_keys_roundtrip() {
985        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
986        reset();
987        let keys = nostr_sdk::prelude::Keys::generate();
988        let expected = keys.secret_key().secret_bytes();
989        TEST_KEY_A.store_from_keys(&keys, &others_for_a());
990        assert_eq!(TEST_KEY_A.get(), Some(expected));
991    }
992
993    // ================================================================
994    // End-to-end encryption pipeline tests
995    //
996    // NOTE: These tests need crate::crypto integration (ChaCha20-Poly1305
997    // encrypt/decrypt pipeline). They are not included in this standalone
998    // module because they depend on:
999    //   - crate::crypto::internal_encrypt / internal_decrypt
1000    //   - crate::crypto::maybe_encrypt / maybe_decrypt
1001    //   - crate::crypto::looks_encrypted
1002    //   - crate::state::set_encryption_enabled
1003    //
1004    // The original tests (e2e_encrypt_decrypt_10k, e2e_cross_key_stress_10k,
1005    // e2e_maybe_encrypt_decrypt_10k, e2e_batch_encrypt_then_decrypt,
1006    // e2e_key_isolation, e2e_edge_case_content, e2e_login_sequence_stress_5k)
1007    // should be placed in the integrating crate that provides both the
1008    // GuardedKey vault and the encryption pipeline.
1009    // ================================================================
1010}