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::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::Keys> {
457        let mut bytes = self.get()?;
458        let result = nostr_sdk::SecretKey::from_slice(&bytes);
459        bytes.zeroize();
460        Some(nostr_sdk::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    fn set_get_1000_iterations() {
526        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
527        for i in 0..1000u16 {
528            reset();
529            let key = test_key((i ^ (i >> 3)) as u8);
530            TEST_KEY_A.set(key, &others_for_a());
531            assert_eq!(
532                TEST_KEY_A.get(), Some(key),
533                "Roundtrip failed at iteration {i}"
534            );
535        }
536    }
537
538    #[test]
539    fn empty_returns_none() {
540        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
541        reset();
542        assert_eq!(TEST_KEY_A.get(), None);
543        assert_eq!(TEST_KEY_B.get(), None);
544    }
545
546    #[test]
547    fn has_key_lifecycle() {
548        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
549        reset();
550        assert!(!TEST_KEY_A.has_key());
551        TEST_KEY_A.set(test_key(1), &others_for_a());
552        assert!(TEST_KEY_A.has_key());
553        TEST_KEY_A.clear(&others_for_a());
554        assert!(!TEST_KEY_A.has_key());
555    }
556
557    #[test]
558    fn clear_returns_none() {
559        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
560        reset();
561        TEST_KEY_A.set(test_key(99), &others_for_a());
562        assert!(TEST_KEY_A.get().is_some());
563        TEST_KEY_A.clear(&others_for_a());
564        assert_eq!(TEST_KEY_A.get(), None);
565    }
566
567    #[test]
568    fn set_overwrites_previous() {
569        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
570        reset();
571        let a = test_key(10);
572        let b = test_key(20);
573        TEST_KEY_A.set(a, &others_for_a());
574        assert_eq!(TEST_KEY_A.get(), Some(a));
575        TEST_KEY_A.set(b, &others_for_a());
576        assert_eq!(TEST_KEY_A.get(), Some(b));
577    }
578
579    #[test]
580    fn clear_idempotent() {
581        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
582        reset();
583        TEST_KEY_A.clear(&others_for_a());
584        TEST_KEY_A.clear(&others_for_a());
585        assert_eq!(TEST_KEY_A.get(), None);
586        TEST_KEY_A.set(test_key(5), &others_for_a());
587        TEST_KEY_A.clear(&others_for_a());
588        TEST_KEY_A.clear(&others_for_a());
589        assert_eq!(TEST_KEY_A.get(), None);
590    }
591
592    #[test]
593    fn encryption_key_basic() {
594        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
595        reset();
596        let key = test_key(0xEE);
597        TEST_KEY_B.set(key, &others_for_b());
598        assert_eq!(TEST_KEY_B.get(), Some(key));
599        TEST_KEY_B.clear(&others_for_b());
600        assert_eq!(TEST_KEY_B.get(), None);
601    }
602
603    // ================================================================
604    // Cross-key protection — 500 iterations each.
605    // Two keys live in disjoint lanes, so neither key's writes (decoys, shares,
606    // or multiplier-odd-forcing) can ever land on the other's slots.
607    // ================================================================
608
609    #[test]
610    fn cross_key_set_then_set_500() {
611        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
612        let key_a = test_key(0xAA);
613        let key_b = test_key(0xBB);
614        for i in 0..500 {
615            reset();
616            TEST_KEY_A.set(key_a, &others_for_a());
617            TEST_KEY_B.set(key_b, &others_for_b());
618            assert_eq!(
619                TEST_KEY_A.get(), Some(key_a),
620                "TEST_KEY_A corrupted at iteration {i}"
621            );
622            assert_eq!(
623                TEST_KEY_B.get(), Some(key_b),
624                "TEST_KEY_B corrupted at iteration {i}"
625            );
626        }
627    }
628
629    #[test]
630    fn cross_key_reverse_order_500() {
631        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
632        let key_a = test_key(0xCC);
633        let key_b = test_key(0xDD);
634        for i in 0..500 {
635            reset();
636            TEST_KEY_B.set(key_b, &others_for_b());
637            TEST_KEY_A.set(key_a, &others_for_a());
638            assert_eq!(
639                TEST_KEY_B.get(), Some(key_b),
640                "TEST_KEY_B corrupted at iteration {i}"
641            );
642            assert_eq!(
643                TEST_KEY_A.get(), Some(key_a),
644                "TEST_KEY_A corrupted at iteration {i}"
645            );
646        }
647    }
648
649    #[test]
650    fn cross_key_clear_preserves_other_500() {
651        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
652        let key_a = test_key(0x11);
653        let key_b = test_key(0x22);
654        for i in 0..500 {
655            // Clear TEST_KEY_A, verify TEST_KEY_B survives
656            reset();
657            TEST_KEY_A.set(key_a, &others_for_a());
658            TEST_KEY_B.set(key_b, &others_for_b());
659            TEST_KEY_A.clear(&others_for_a());
660            assert_eq!(
661                TEST_KEY_B.get(), Some(key_b),
662                "KEY_B corrupted after KEY_A.clear() at iteration {i}"
663            );
664            // Clear TEST_KEY_B, verify TEST_KEY_A survives
665            reset();
666            TEST_KEY_A.set(key_a, &others_for_a());
667            TEST_KEY_B.set(key_b, &others_for_b());
668            TEST_KEY_B.clear(&others_for_b());
669            assert_eq!(
670                TEST_KEY_A.get(), Some(key_a),
671                "KEY_A corrupted after KEY_B.clear() at iteration {i}"
672            );
673        }
674    }
675
676    #[test]
677    fn cross_key_alternating_500() {
678        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
679        for i in 0..500u16 {
680            reset();
681            let ka = test_key(i as u8);
682            let kb = test_key(!(i as u8));
683            TEST_KEY_A.set(ka, &others_for_a());
684            TEST_KEY_B.set(kb, &others_for_b());
685            assert_eq!(TEST_KEY_A.get(), Some(ka), "KEY_A wrong at iter {i}");
686            assert_eq!(TEST_KEY_B.get(), Some(kb), "KEY_B wrong at iter {i}");
687            TEST_KEY_A.clear(&others_for_a());
688            assert_eq!(TEST_KEY_B.get(), Some(kb), "KEY_B wrong after KEY_A clear at iter {i}");
689        }
690    }
691
692    /// Stress: alternating set order, different keys each round, 1000 iterations.
693    /// Two keys occupy disjoint lanes, so a real write from one can never clobber the
694    /// other's share slots regardless of decoy layout.
695    #[test]
696    fn stress_both_keys_1000() {
697        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
698        for i in 0..1000u32 {
699            reset();
700            let ka = test_key((i & 0xFF) as u8);
701            let kb = test_key(!((i & 0xFF) as u8));
702            if i % 2 == 0 {
703                TEST_KEY_A.set(ka, &others_for_a());
704                TEST_KEY_B.set(kb, &others_for_b());
705            } else {
706                TEST_KEY_B.set(kb, &others_for_b());
707                TEST_KEY_A.set(ka, &others_for_a());
708            }
709            assert_eq!(TEST_KEY_A.get(), Some(ka), "KEY_A wrong at iter {i}");
710            assert_eq!(TEST_KEY_B.get(), Some(kb), "KEY_B wrong at iter {i}");
711        }
712    }
713
714    // ================================================================
715    // Position derivation
716    // ================================================================
717
718    #[test]
719    fn config_positions_all_unique() {
720        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
721        ensure_vaults();
722        for lane in 0..LANE_COUNT {
723            for addr in (0x1000..0x2000usize).step_by(8) {
724                let (a, b, c) = config_positions(addr, lane);
725                assert_ne!(a, b, "config collision a==b at addr {addr:#x} lane {lane}");
726                assert_ne!(a, c, "config collision a==c at addr {addr:#x} lane {lane}");
727                assert_ne!(b, c, "config collision b==c at addr {addr:#x} lane {lane}");
728            }
729        }
730    }
731
732    #[test]
733    fn share_positions_all_unique() {
734        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
735        ensure_vaults();
736        for lane in 0..LANE_COUNT {
737            for addr in (0x2000..0x2100usize).step_by(8) {
738                let positions = share_positions(addr, lane);
739                for i in 0..SHARE_ENTRIES {
740                    for j in (i + 1)..SHARE_ENTRIES {
741                        assert_ne!(
742                            positions[i], positions[j],
743                            "share collision [{i}]==[{j}] at addr {addr:#x} lane {lane}"
744                        );
745                    }
746                }
747            }
748        }
749    }
750
751    #[test]
752    fn share_positions_no_config_overlap() {
753        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
754        ensure_vaults();
755        for lane in 0..LANE_COUNT {
756            for addr in (0x3000..0x3100usize).step_by(8) {
757                let (s, m1, m2) = config_positions(addr, lane);
758                let config = [s, m1, m2];
759                let shares = share_positions(addr, lane);
760                for (i, pos) in shares.iter().enumerate() {
761                    assert!(
762                        !config.contains(pos),
763                        "share[{i}] collides with config at addr {addr:#x} lane {lane}"
764                    );
765                }
766            }
767        }
768    }
769
770    /// Distinct lanes are physically disjoint: no (array, slot) is shared between
771    /// two different lanes for the same address. This is the property that makes
772    /// cross-key writes collision-safe.
773    #[test]
774    fn distinct_lanes_are_disjoint() {
775        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
776        ensure_vaults();
777        for addr in (0x5000..0x5100usize).step_by(8) {
778            for la in 0..LANE_COUNT {
779                for lb in 0..LANE_COUNT {
780                    if la == lb { continue; }
781                    let (sa, m1a, m2a) = config_positions(addr, la);
782                    let a_all: Vec<VaultPos> = [sa, m1a, m2a]
783                        .into_iter()
784                        .chain(share_positions(addr, la))
785                        .collect();
786                    let (sb, m1b, m2b) = config_positions(addr, lb);
787                    let b_all: Vec<VaultPos> = [sb, m1b, m2b]
788                        .into_iter()
789                        .chain(share_positions(addr, lb))
790                        .collect();
791                    for p in &a_all {
792                        assert!(
793                            !b_all.contains(p),
794                            "lane {la} and lane {lb} share {p:?} at addr {addr:#x}"
795                        );
796                    }
797                }
798            }
799        }
800    }
801
802    #[test]
803    fn positions_deterministic() {
804        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
805        ensure_vaults();
806        let addr = TEST_KEY_A.instance_addr();
807        let cfg1 = config_positions(addr, 3);
808        let cfg2 = config_positions(addr, 3);
809        assert_eq!(cfg1, cfg2);
810        let sp1 = share_positions(addr, 3);
811        let sp2 = share_positions(addr, 3);
812        assert_eq!(sp1, sp2);
813    }
814
815    #[test]
816    fn all_positions_in_bounds() {
817        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
818        ensure_vaults();
819        for lane in 0..LANE_COUNT {
820            for addr in (0x4000..0x4200usize).step_by(8) {
821                let (a, b, c) = config_positions(addr, lane);
822                for p in [a, b, c] {
823                    assert!(p.array < ARRAY_COUNT);
824                    assert!(p.slot < ARRAY_SIZE);
825                }
826                for p in share_positions(addr, lane) {
827                    assert!(p.array < ARRAY_COUNT);
828                    assert!(p.slot < ARRAY_SIZE);
829                }
830            }
831        }
832    }
833
834    // ================================================================
835    // Internals
836    // ================================================================
837
838    #[test]
839    fn mix_iterations_in_range() {
840        for addr in 0..10000usize {
841            let n = mix_iterations(addr);
842            assert!((4096..=8191).contains(&n), "mix_iterations({addr}) = {n}");
843        }
844    }
845
846    #[test]
847    fn addr_mix_zero_iterations_is_identity() {
848        let h: u64 = 0xDEADBEEFCAFEBABE;
849        assert_eq!(addr_mix(h, 123, 456, 0), h);
850    }
851
852    #[test]
853    fn addr_mix_varies_output() {
854        let a = addr_mix(1, 3, 5, 10);
855        let b = addr_mix(2, 3, 5, 10);
856        let c = addr_mix(1, 7, 5, 10);
857        let d = addr_mix(1, 3, 11, 10);
858        assert_ne!(a, b);
859        assert_ne!(a, c);
860        assert_ne!(a, d);
861    }
862
863    #[test]
864    fn ensure_vaults_all_nonzero() {
865        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
866        ensure_vaults();
867        for (r, row) in VAULTS.iter().enumerate() {
868            for (s, slot) in row.iter().enumerate() {
869                assert_ne!(
870                    slot.load(Ordering::Relaxed), 0,
871                    "VAULTS[{r}][{s}] is zero after ensure_vaults"
872                );
873            }
874        }
875    }
876
877    #[test]
878    fn ensure_vaults_idempotent() {
879        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
880        ensure_vaults();
881        let samples: Vec<_> = (0..20)
882            .map(|i| {
883                let r = i * 13 % ARRAY_COUNT;
884                let s = i * 397 % ARRAY_SIZE;
885                (r, s, VAULTS[r][s].load(Ordering::Relaxed))
886            })
887            .collect();
888        ensure_vaults();
889        for (r, s, val) in &samples {
890            assert_eq!(
891                VAULTS[*r][*s].load(Ordering::Relaxed), *val,
892                "ensure_vaults changed VAULTS[{r}][{s}]"
893            );
894        }
895    }
896
897    /// Run write_decoys 500 times with protected positions — verify they are NEVER overwritten.
898    /// Without exclusion, P(at least one hit) per position ~ 86%. With 6 positions:
899    /// P(all survive unprotected) ~ 0.14^6 ~ 0.00075%, so a broken exclusion is caught with near-certainty.
900    #[test]
901    fn write_decoys_respects_exclusions_500() {
902        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
903        ensure_vaults();
904        let protected = [
905            VaultPos { array: 0, slot: 100 },
906            VaultPos { array: 0, slot: 200 },
907            VaultPos { array: 50, slot: 2000 },
908            VaultPos { array: 50, slot: 3000 },
909            VaultPos { array: 100, slot: 500 },
910            VaultPos { array: 127, slot: 4095 },
911        ];
912        let before: Vec<usize> = protected.iter()
913            .map(|p| VAULTS[p.array][p.slot].load(Ordering::Relaxed))
914            .collect();
915        for _ in 0..500 {
916            write_decoys(&protected);
917        }
918        for (i, p) in protected.iter().enumerate() {
919            assert_eq!(
920                VAULTS[p.array][p.slot].load(Ordering::Relaxed),
921                before[i],
922                "Protected position ({}, {}) overwritten after 500 rounds",
923                p.array, p.slot
924            );
925        }
926    }
927
928    #[test]
929    fn write_decoys_empty_exclusion_works() {
930        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
931        ensure_vaults();
932        write_decoys(&[]);
933    }
934
935    // ================================================================
936    // Edge cases
937    // ================================================================
938
939    #[test]
940    fn zero_key_roundtrip() {
941        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
942        reset();
943        let key = [0u8; 32];
944        TEST_KEY_A.set(key, &others_for_a());
945        assert_eq!(TEST_KEY_A.get(), Some(key));
946    }
947
948    #[test]
949    fn max_key_roundtrip() {
950        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
951        reset();
952        let key = [0xFFu8; 32];
953        TEST_KEY_A.set(key, &others_for_a());
954        assert_eq!(TEST_KEY_A.get(), Some(key));
955    }
956
957    #[test]
958    fn to_keys_roundtrip() {
959        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
960        reset();
961        let mut sk_bytes = [0u8; 32];
962        sk_bytes[31] = 1; // scalar = 1, valid secp256k1 key
963        TEST_KEY_A.set(sk_bytes, &others_for_a());
964        let keys = TEST_KEY_A.to_keys();
965        assert!(keys.is_some(), "to_keys returned None for valid key");
966        assert_eq!(keys.unwrap().secret_key().secret_bytes(), sk_bytes);
967    }
968
969    #[test]
970    fn to_keys_none_when_empty() {
971        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
972        reset();
973        assert!(TEST_KEY_A.to_keys().is_none());
974    }
975
976    #[test]
977    fn store_from_keys_roundtrip() {
978        let _l = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
979        reset();
980        let keys = nostr_sdk::Keys::generate();
981        let expected = keys.secret_key().secret_bytes();
982        TEST_KEY_A.store_from_keys(&keys, &others_for_a());
983        assert_eq!(TEST_KEY_A.get(), Some(expected));
984    }
985
986    // ================================================================
987    // End-to-end encryption pipeline tests
988    //
989    // NOTE: These tests need crate::crypto integration (ChaCha20-Poly1305
990    // encrypt/decrypt pipeline). They are not included in this standalone
991    // module because they depend on:
992    //   - crate::crypto::internal_encrypt / internal_decrypt
993    //   - crate::crypto::maybe_encrypt / maybe_decrypt
994    //   - crate::crypto::looks_encrypted
995    //   - crate::state::set_encryption_enabled
996    //
997    // The original tests (e2e_encrypt_decrypt_10k, e2e_cross_key_stress_10k,
998    // e2e_maybe_encrypt_decrypt_10k, e2e_batch_encrypt_then_decrypt,
999    // e2e_key_isolation, e2e_edge_case_content, e2e_login_sequence_stress_5k)
1000    // should be placed in the integrating crate that provides both the
1001    // GuardedKey vault and the encryption pipeline.
1002    // ================================================================
1003}