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