Skip to main content

holographic_memory/core/
mask.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Client-side keyed obfuscation of sparse hypervectors.
5//!
6//! A [`VectorMask`] is a secret permutation of the coordinate space, derived
7//! from a passphrase with Argon2id. Applying it before vectors leave the client
8//! lets a remote store index and rank them without learning which coordinates
9//! were originally active.
10//!
11//! This is obfuscation, **not encryption**, and it is not homomorphic
12//! encryption. It is deterministic and preserves every pairwise similarity by
13//! construction, so a store holding masked vectors still learns the full
14//! similarity structure of the collection, equality of vectors, and coordinate
15//! activation frequencies. Each known (plain, masked) pair reveals part of the
16//! permutation. Use AES-GCM storage encryption for confidentiality at rest.
17//!
18//! Set algebra (`bind`, bundling, `similarity`) commutes with the mask. The
19//! cyclic `permute` used for ordered binding does not, so sequence operations
20//! must be composed before masking.
21
22use anyhow::{anyhow, ensure, Result};
23use sha2::{Digest, Sha256};
24use zeroize::Zeroize;
25
26use super::entangled::EntangledHVec;
27
28const DOMAIN: &[u8] = b"hms-vector-mask-v1";
29
30/// Largest coordinate space a mask may be derived for (bounds the allocation).
31pub const MAX_MASK_DIMENSIONS: usize = 1 << 20;
32
33/// Minimum salt length accepted by [`VectorMask::derive`].
34pub const MIN_SALT_LEN: usize = 16;
35
36/// A secret permutation of `[0, dim)`.
37pub struct VectorMask {
38    forward: Vec<u32>,
39}
40
41impl VectorMask {
42    /// Derive the mask for `dim` coordinates from a passphrase and salt.
43    ///
44    /// The same `(passphrase, salt, dim)` always yields the same mask, so every
45    /// client of one collection must share the salt. The salt is not secret.
46    pub fn derive(passphrase: &[u8], salt: &[u8], dim: usize) -> Result<Self> {
47        ensure!(!passphrase.is_empty(), "mask passphrase must not be empty");
48        ensure!(
49            salt.len() >= MIN_SALT_LEN,
50            "mask salt must be at least {MIN_SALT_LEN} bytes"
51        );
52        ensure!(
53            (1..=MAX_MASK_DIMENSIONS).contains(&dim),
54            "mask dimensions must be in 1..={MAX_MASK_DIMENSIONS}"
55        );
56
57        let mut key = [0u8; 32];
58        argon2::Argon2::default()
59            .hash_password_into(passphrase, salt, &mut key)
60            .map_err(|e| anyhow!("Argon2 key derivation failed: {e}"))?;
61
62        // Rank every coordinate by a keyed PRF; the sort order is the permutation.
63        let mut prefix = Sha256::new();
64        prefix.update(DOMAIN);
65        prefix.update(key.as_slice());
66        prefix.update((dim as u64).to_le_bytes());
67        key.zeroize();
68
69        let mut ranked: Vec<([u8; 32], u32)> = (0..dim as u32)
70            .map(|idx| {
71                let mut h = prefix.clone();
72                h.update(idx.to_le_bytes());
73                (h.finalize().into(), idx)
74            })
75            .collect();
76        ranked.sort_unstable();
77
78        let mut forward = vec![0u32; dim];
79        for (rank, (_, idx)) in ranked.iter().enumerate() {
80            forward[*idx as usize] = rank as u32;
81        }
82        for (tag, _) in ranked.iter_mut() {
83            tag.zeroize();
84        }
85        Ok(Self { forward })
86    }
87
88    pub fn dimensions(&self) -> usize {
89        self.forward.len()
90    }
91
92    /// Mask a vector. Fails if its dimension differs from the mask's.
93    pub fn apply(&self, vector: &EntangledHVec) -> Result<EntangledHVec> {
94        self.map(vector, &self.forward)
95    }
96
97    /// Recover the original vector from a masked one.
98    pub fn invert(&self, masked: &EntangledHVec) -> Result<EntangledHVec> {
99        let mut inverse = vec![0u32; self.forward.len()];
100        for (idx, &target) in self.forward.iter().enumerate() {
101            inverse[target as usize] = idx as u32;
102        }
103        let result = self.map(masked, &inverse);
104        inverse.zeroize();
105        result
106    }
107
108    fn map(&self, vector: &EntangledHVec, table: &[u32]) -> Result<EntangledHVec> {
109        ensure!(
110            vector.dim == table.len(),
111            "vector dimension {} does not match mask dimension {}",
112            vector.dim,
113            table.len()
114        );
115        let mut indices = Vec::with_capacity(vector.indices.len());
116        for &idx in &vector.indices {
117            let mapped = table
118                .get(idx as usize)
119                .ok_or_else(|| anyhow!("vector index {idx} is out of range"))?;
120            indices.push(*mapped);
121        }
122        indices.sort_unstable();
123        Ok(EntangledHVec::from_indices(indices, vector.dim))
124    }
125}
126
127impl Drop for VectorMask {
128    fn drop(&mut self) {
129        self.forward.zeroize();
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    const DIM: usize = 16384;
138    const SALT: &[u8] = b"0123456789abcdef";
139
140    fn mask(passphrase: &str) -> VectorMask {
141        VectorMask::derive(passphrase.as_bytes(), SALT, DIM).unwrap()
142    }
143
144    #[test]
145    fn forward_table_is_a_permutation() {
146        let m = mask("correct horse");
147        let mut seen = m.forward.clone();
148        seen.sort_unstable();
149        assert!(seen.iter().enumerate().all(|(i, &v)| v == i as u32));
150    }
151
152    #[test]
153    fn similarity_and_binding_are_preserved_exactly() {
154        let m = mask("correct horse");
155        let a = EntangledHVec::new_deterministic(DIM, 1);
156        let b = EntangledHVec::new_deterministic(DIM, 2);
157        // Overlapping pair so the preserved similarity is non-trivial.
158        let c = EntangledHVec::bundle(&[a.clone(), b.clone()]);
159        let (ma, mb, mc) = (
160            m.apply(&a).unwrap(),
161            m.apply(&b).unwrap(),
162            m.apply(&c).unwrap(),
163        );
164
165        assert!(a.similarity(&c) > 0.1);
166        assert_eq!(a.similarity(&c), ma.similarity(&mc));
167        assert_eq!(a.similarity(&b), ma.similarity(&mb));
168        assert_eq!(ma.indices.len(), a.indices.len());
169        assert_eq!(m.apply(&a.bind(&b)).unwrap().indices, ma.bind(&mb).indices);
170    }
171
172    #[test]
173    fn round_trips_and_hides_original_coordinates() {
174        let m = mask("correct horse");
175        let a = EntangledHVec::new_deterministic(DIM, 7);
176        let masked = m.apply(&a).unwrap();
177        assert_eq!(m.invert(&masked).unwrap().indices, a.indices);
178        assert!(a.similarity(&masked) < 0.1);
179    }
180
181    #[test]
182    fn derivation_is_deterministic_and_key_and_salt_dependent() {
183        let a = EntangledHVec::new_deterministic(DIM, 7);
184        let base = mask("correct horse").apply(&a).unwrap();
185        assert_eq!(
186            mask("correct horse").apply(&a).unwrap().indices,
187            base.indices
188        );
189        assert_ne!(mask("wrong horse").apply(&a).unwrap().indices, base.indices);
190        let other_salt = VectorMask::derive(b"correct horse", b"fedcba9876543210", DIM).unwrap();
191        assert_ne!(other_salt.apply(&a).unwrap().indices, base.indices);
192    }
193
194    #[test]
195    fn rejects_invalid_parameters() {
196        assert!(VectorMask::derive(b"", SALT, DIM).is_err());
197        assert!(VectorMask::derive(b"k", &SALT[..MIN_SALT_LEN - 1], DIM).is_err());
198        assert!(VectorMask::derive(b"k", SALT, 0).is_err());
199        assert!(VectorMask::derive(b"k", SALT, MAX_MASK_DIMENSIONS + 1).is_err());
200        assert!(VectorMask::derive(b"k", SALT, MAX_MASK_DIMENSIONS).is_ok());
201
202        let wrong_dim = EntangledHVec::new_deterministic(DIM / 2, 1);
203        assert!(mask("k").apply(&wrong_dim).is_err());
204    }
205}