Skip to main content

metamorphic_log/
coniks.rs

1//! Layer-3c: **CONIKS-style index privacy** — a per-namespace directory whose
2//! lookups produce verifiable *presence* and *absence* proofs without revealing
3//! which identities the directory holds.
4//!
5//! ## How it fits together
6//!
7//! Three pieces combine here:
8//!
9//! 1. A swappable VRF ([`crate::vrf`]) maps an identity to a private, verifiable
10//!    256-bit tree **index**. Because the index is the VRF output, an observer
11//!    learns nothing about the identity, and the directory cannot move an
12//!    identity elsewhere without a fresh VRF proof.
13//! 2. A SHA3-512 **commitment** ([`crate::commitment`]) binds that index to the
14//!    identity's value (e.g. a key-history head). The commitment — the
15//!    post-quantum, binding half — is what actually sits in the tree.
16//! 3. A **sparse Merkle prefix tree** (depth 256, SHA3-512 nodes) accumulates
17//!    every commitment into a single root. An authentication path from a leaf to
18//!    the root proves *presence*; an authentication path to the (default) empty
19//!    leaf at an index proves *absence*.
20//!
21//! Everything is **per-namespace**: the namespace label is threaded through the
22//! VRF input and every tree/commitment hash, so proofs from one namespace never
23//! verify against another. The VRF construction is bound in via its
24//! [`suite_id`](crate::vrf::Vrf::suite_id), so a proof is tied to the exact VRF
25//! it was produced under.
26//!
27//! ## Independent verification (#316)
28//!
29//! The [`verify_lookup`] and [`verify_absence`] free functions take only the
30//! public inputs a relying party already has — the namespace, the VRF public
31//! key, the directory root, the queried identity, and the proof — and recompute
32//! everything from scratch. They do **not** need the directory. A proof also
33//! serializes to canonical bytes ([`LookupProof::to_bytes`] /
34//! [`AbsenceProof::to_bytes`]) and parses back, so it can be transmitted and
35//! verified by any independent implementation.
36//!
37//! ## Hashing posture
38//!
39//! This prefix tree uses **SHA3-512** (post-quantum) for its nodes — it is a
40//! distinct structure from the RFC 6962 append-only log ([`crate::merkle`]),
41//! which stays on ecosystem SHA-256 for witness compatibility. A CONIKS root is
42//! opaque bytes to Layer 1, so it can be embedded as a Layer-0 leaf and
43//! witnessed without either layer's hashing affecting the other.
44
45use std::collections::BTreeMap;
46
47use metamorphic_crypto::hash::sha3_512_with_context;
48
49use crate::commitment::{COMMITMENT_LEN, COMMITMENT_OPENING_LEN, Commitment, Opening};
50use crate::error::{Error, Result};
51use crate::vrf::{Vrf, VrfProof, VrfPublicKey, VrfSecretKey};
52
53/// Tree depth: a 256-bit index gives one leaf position per possible VRF output
54/// prefix.
55const TREE_DEPTH: usize = 256;
56/// Length of a tree node / leaf hash, in bytes (SHA3-512).
57const NODE_LEN: usize = 64;
58/// Length of the VRF-derived index, in bytes.
59const INDEX_LEN: usize = 32;
60
61/// A validated CONIKS namespace — the per-tenant domain separator.
62///
63/// A namespace is a single non-empty segment of printable ASCII excluding `/`
64/// (so it slots cleanly into the `<namespace>/<record-type>/v<N>` context-label
65/// grammar shared with [`crate::leaf::ContextLabel`]). Distinct namespaces get
66/// independent VRF inputs and tree/commitment hashes, so their directories and
67/// proofs are fully isolated.
68#[derive(Debug, Clone, PartialEq, Eq, Hash)]
69pub struct Namespace(String);
70
71impl Namespace {
72    /// Parse and validate a namespace segment.
73    ///
74    /// # Errors
75    /// Returns [`Error::MalformedNamespace`] if it is empty or contains a byte
76    /// outside printable ASCII, or a `/`.
77    pub fn parse(namespace: &str) -> Result<Self> {
78        if namespace.is_empty() || !namespace.bytes().all(|b| b.is_ascii_graphic() && b != b'/') {
79            return Err(Error::MalformedNamespace(format!(
80                "namespace must be non-empty printable ASCII without '/': {namespace:?}"
81            )));
82        }
83        Ok(Self(namespace.to_string()))
84    }
85
86    /// The namespace string.
87    #[must_use]
88    pub fn as_str(&self) -> &str {
89        &self.0
90    }
91
92    /// The per-namespace commitment context label, `<ns>/coniks-commitment/v1`.
93    #[must_use]
94    pub fn commitment_label(&self) -> String {
95        format!("{}/coniks-commitment/v1", self.0)
96    }
97
98    fn leaf_label(&self) -> String {
99        format!("{}/coniks-leaf/v1", self.0)
100    }
101
102    fn node_label(&self) -> String {
103        format!("{}/coniks-node/v1", self.0)
104    }
105
106    fn empty_label(&self) -> String {
107        format!("{}/coniks-empty/v1", self.0)
108    }
109
110    /// Build the VRF input (`alpha`) for an identity, namespace-scoped so the
111    /// derived index differs across namespaces: `u32_be(len(ns)) || ns ||
112    /// identity`.
113    #[must_use]
114    pub fn vrf_input(&self, identity: &[u8]) -> Vec<u8> {
115        let mut input = Vec::with_capacity(4 + self.0.len() + identity.len());
116        input.extend_from_slice(&(self.0.len() as u32).to_be_bytes());
117        input.extend_from_slice(self.0.as_bytes());
118        input.extend_from_slice(identity);
119        input
120    }
121}
122
123/// Return bit `i` of a 256-bit index, most-significant-bit-first (bit 0 is the
124/// MSB of byte 0 and selects the first branch from the root).
125fn index_bit(index: &[u8; INDEX_LEN], i: usize) -> u8 {
126    (index[i / 8] >> (7 - (i % 8))) & 1
127}
128
129/// Shared hashing for a namespace's prefix tree: node/leaf hashing plus the
130/// precomputed empty-subtree defaults. Built identically by the directory
131/// (to construct proofs) and by the verifier (to check them), so there is a
132/// single source of the byte discipline.
133struct TreeHasher {
134    leaf_label: String,
135    node_label: String,
136    suite_id: u8,
137    /// `empty[d]` is the hash of a wholly empty subtree rooted at depth `d`;
138    /// `empty[TREE_DEPTH]` is the empty-leaf hash.
139    empty: Vec<[u8; NODE_LEN]>,
140}
141
142impl TreeHasher {
143    fn new(namespace: &Namespace, suite_id: u8) -> Self {
144        let empty_leaf: [u8; NODE_LEN] = sha3_512_with_context(&namespace.empty_label(), &[]);
145        let node_label = namespace.node_label();
146
147        // empty[TREE_DEPTH] = empty leaf; empty[d] = node(empty[d+1], empty[d+1]).
148        let mut empty = vec![[0u8; NODE_LEN]; TREE_DEPTH + 1];
149        empty[TREE_DEPTH] = empty_leaf;
150        for d in (0..TREE_DEPTH).rev() {
151            empty[d] = node_hash(&node_label, &empty[d + 1], &empty[d + 1]);
152        }
153
154        Self {
155            leaf_label: namespace.leaf_label(),
156            node_label,
157            suite_id,
158            empty,
159        }
160    }
161
162    /// Leaf hash, binding the VRF suite, the index, and the commitment:
163    /// `SHA3-512_with_context(leaf_label, suite_id(1) || index(32) ||
164    /// commitment(64))`.
165    fn leaf_hash(&self, index: &[u8; INDEX_LEN], commitment: &Commitment) -> [u8; NODE_LEN] {
166        let mut buf = [0u8; 1 + INDEX_LEN + COMMITMENT_LEN];
167        buf[0] = self.suite_id;
168        buf[1..1 + INDEX_LEN].copy_from_slice(index);
169        buf[1 + INDEX_LEN..].copy_from_slice(commitment.as_bytes());
170        sha3_512_with_context(&self.leaf_label, &buf)
171    }
172
173    fn empty_leaf(&self) -> [u8; NODE_LEN] {
174        self.empty[TREE_DEPTH]
175    }
176
177    /// Hash of the subtree at `depth` containing exactly `leaves` (each a
178    /// `(index, commitment)`), using empty-subtree shortcuts.
179    fn subtree(&self, depth: usize, leaves: &[(&[u8; INDEX_LEN], &Commitment)]) -> [u8; NODE_LEN] {
180        if leaves.is_empty() {
181            return self.empty[depth];
182        }
183        if depth == TREE_DEPTH {
184            // Indices are unique, so a leaf depth holds exactly one entry.
185            let (index, commitment) = leaves[0];
186            return self.leaf_hash(index, commitment);
187        }
188        let (left, right): (Vec<_>, Vec<_>) = leaves
189            .iter()
190            .partition(|(index, _)| index_bit(index, depth) == 0);
191        let l = self.subtree(depth + 1, &left);
192        let r = self.subtree(depth + 1, &right);
193        node_hash(&self.node_label, &l, &r)
194    }
195
196    /// Collect the authentication-path siblings for `target` from depth 0 to
197    /// `TREE_DEPTH - 1`. A sibling equal to the empty default is recorded as
198    /// `None` (the verifier recomputes it), keeping proofs compact.
199    fn collect_path(
200        &self,
201        depth: usize,
202        target: &[u8; INDEX_LEN],
203        leaves: &[(&[u8; INDEX_LEN], &Commitment)],
204        out: &mut Vec<Option<[u8; NODE_LEN]>>,
205    ) {
206        if depth == TREE_DEPTH {
207            return;
208        }
209        let (left, right): (Vec<_>, Vec<_>) = leaves
210            .iter()
211            .copied()
212            .partition(|(index, _)| index_bit(index, depth) == 0);
213        let (on_path, sibling) = if index_bit(target, depth) == 0 {
214            (left, right)
215        } else {
216            (right, left)
217        };
218        let sibling_hash = self.subtree(depth + 1, &sibling);
219        out.push(if sibling_hash == self.empty[depth + 1] {
220            None
221        } else {
222            Some(sibling_hash)
223        });
224        self.collect_path(depth + 1, target, &on_path, out);
225    }
226
227    /// Recompute the directory root from a leaf node and its authentication
228    /// path, folding empty defaults in for absent siblings.
229    fn recompute_root(
230        &self,
231        target: &[u8; INDEX_LEN],
232        leaf_node: [u8; NODE_LEN],
233        siblings: &[Option<[u8; NODE_LEN]>],
234    ) -> [u8; NODE_LEN] {
235        let mut current = leaf_node;
236        for depth in (0..TREE_DEPTH).rev() {
237            let sibling = siblings[depth].unwrap_or(self.empty[depth + 1]);
238            current = if index_bit(target, depth) == 0 {
239                node_hash(&self.node_label, &current, &sibling)
240            } else {
241                node_hash(&self.node_label, &sibling, &current)
242            };
243        }
244        current
245    }
246}
247
248/// Interior node hash: `SHA3-512_with_context(node_label, left(64) ||
249/// right(64))`.
250fn node_hash(node_label: &str, left: &[u8; NODE_LEN], right: &[u8; NODE_LEN]) -> [u8; NODE_LEN] {
251    let mut buf = [0u8; 2 * NODE_LEN];
252    buf[..NODE_LEN].copy_from_slice(left);
253    buf[NODE_LEN..].copy_from_slice(right);
254    sha3_512_with_context(node_label, &buf)
255}
256
257/// An authentication path: one optional sibling per tree level (0 = nearest the
258/// leaf). `None` denotes an empty-default sibling the verifier recomputes.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct AuthPath {
261    siblings: Vec<Option<[u8; NODE_LEN]>>,
262}
263
264impl AuthPath {
265    /// Canonical serialization: a 32-byte big-endian-bit bitmap (bit `d` set iff
266    /// level `d` has a non-empty sibling), followed by the present sibling
267    /// hashes in level order.
268    fn to_bytes(&self) -> Vec<u8> {
269        let mut bitmap = [0u8; TREE_DEPTH / 8];
270        let mut hashes = Vec::new();
271        for (d, sibling) in self.siblings.iter().enumerate() {
272            if let Some(h) = sibling {
273                bitmap[d / 8] |= 1 << (7 - (d % 8));
274                hashes.extend_from_slice(h);
275            }
276        }
277        let mut out = Vec::with_capacity(bitmap.len() + hashes.len());
278        out.extend_from_slice(&bitmap);
279        out.extend_from_slice(&hashes);
280        out
281    }
282
283    /// Parse a canonical authentication path, returning it and the number of
284    /// input bytes consumed.
285    fn parse(bytes: &[u8]) -> Result<(Self, usize)> {
286        let bitmap_len = TREE_DEPTH / 8;
287        if bytes.len() < bitmap_len {
288            return Err(Error::MalformedConiksProof(
289                "authentication path shorter than its bitmap".into(),
290            ));
291        }
292        let bitmap = &bytes[..bitmap_len];
293        let present: usize = bitmap.iter().map(|b| b.count_ones() as usize).sum();
294        let needed = bitmap_len + present * NODE_LEN;
295        if bytes.len() < needed {
296            return Err(Error::MalformedConiksProof(format!(
297                "authentication path: bitmap implies {present} sibling hashes ({needed} bytes), \
298                 only {} available",
299                bytes.len()
300            )));
301        }
302
303        let mut siblings = Vec::with_capacity(TREE_DEPTH);
304        let mut offset = bitmap_len;
305        for d in 0..TREE_DEPTH {
306            let set = (bitmap[d / 8] >> (7 - (d % 8))) & 1 == 1;
307            if set {
308                let mut h = [0u8; NODE_LEN];
309                h.copy_from_slice(&bytes[offset..offset + NODE_LEN]);
310                offset += NODE_LEN;
311                siblings.push(Some(h));
312            } else {
313                siblings.push(None);
314            }
315        }
316        Ok((Self { siblings }, needed))
317    }
318}
319
320/// Append `u32_be(len(bytes)) || bytes` to `out`.
321fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
322    out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
323    out.extend_from_slice(bytes);
324}
325
326/// Read a `u32_be`-length-prefixed field at `offset`, returning the field bytes
327/// and the new offset.
328fn read_lp<'a>(bytes: &'a [u8], offset: usize, what: &str) -> Result<(&'a [u8], usize)> {
329    if bytes.len() < offset + 4 {
330        return Err(Error::MalformedConiksProof(format!(
331            "{what}: missing 4-byte length prefix"
332        )));
333    }
334    let len = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize;
335    let start = offset + 4;
336    if bytes.len() < start + len {
337        return Err(Error::MalformedConiksProof(format!(
338            "{what}: length prefix {len} overruns available bytes"
339        )));
340    }
341    Ok((&bytes[start..start + len], start + len))
342}
343
344const TAG_ABSENCE: u8 = 0x00;
345const TAG_PRESENCE: u8 = 0x01;
346
347/// A **presence** proof: the queried identity is in the directory, mapped (via
348/// the VRF) to a tree index whose committed value is revealed and whose
349/// authentication path recomputes the directory root.
350#[derive(Debug, Clone, PartialEq, Eq)]
351pub struct LookupProof {
352    vrf_proof: VrfProof,
353    value: Vec<u8>,
354    opening: Opening,
355    auth_path: AuthPath,
356}
357
358impl LookupProof {
359    /// The revealed value bound at the identity's index. Only trustworthy once
360    /// the proof has been checked with [`verify_lookup`].
361    #[must_use]
362    pub fn value(&self) -> &[u8] {
363        &self.value
364    }
365
366    /// Canonical serialization:
367    /// `0x01 || lp(vrf_proof) || lp(value) || opening(32) || auth_path`.
368    #[must_use]
369    pub fn to_bytes(&self) -> Vec<u8> {
370        let mut out = vec![TAG_PRESENCE];
371        push_lp(&mut out, self.vrf_proof.as_bytes());
372        push_lp(&mut out, &self.value);
373        out.extend_from_slice(self.opening.as_bytes());
374        out.extend_from_slice(&self.auth_path.to_bytes());
375        out
376    }
377
378    /// Parse a canonical presence proof.
379    ///
380    /// # Errors
381    /// Returns [`Error::MalformedConiksProof`] if the tag, length prefixes, or
382    /// trailing authentication path are inconsistent.
383    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
384        let Some((&tag, rest)) = bytes.split_first() else {
385            return Err(Error::MalformedConiksProof("empty proof".into()));
386        };
387        if tag != TAG_PRESENCE {
388            return Err(Error::MalformedConiksProof(format!(
389                "expected presence tag 0x01, got {tag:#04x}"
390            )));
391        }
392        let (vrf_proof, off) = read_lp(rest, 0, "vrf proof")?;
393        let (value, off) = read_lp(rest, off, "value")?;
394        if rest.len() < off + COMMITMENT_OPENING_LEN {
395            return Err(Error::MalformedConiksProof("missing opening".into()));
396        }
397        let mut opening = [0u8; COMMITMENT_OPENING_LEN];
398        opening.copy_from_slice(&rest[off..off + COMMITMENT_OPENING_LEN]);
399        let (auth_path, consumed) = AuthPath::parse(&rest[off + COMMITMENT_OPENING_LEN..])?;
400        if off + COMMITMENT_OPENING_LEN + consumed != rest.len() {
401            return Err(Error::MalformedConiksProof(
402                "trailing bytes after authentication path".into(),
403            ));
404        }
405        Ok(Self {
406            vrf_proof: VrfProof::from_bytes(vrf_proof.to_vec()),
407            value: value.to_vec(),
408            opening: Opening::from_bytes(opening),
409            auth_path,
410        })
411    }
412}
413
414/// An **absence** proof: the queried identity is *not* in the directory; its
415/// VRF-derived index holds the empty leaf, and the authentication path to that
416/// empty leaf recomputes the directory root.
417#[derive(Debug, Clone, PartialEq, Eq)]
418pub struct AbsenceProof {
419    vrf_proof: VrfProof,
420    auth_path: AuthPath,
421}
422
423impl AbsenceProof {
424    /// Canonical serialization: `0x00 || lp(vrf_proof) || auth_path`.
425    #[must_use]
426    pub fn to_bytes(&self) -> Vec<u8> {
427        let mut out = vec![TAG_ABSENCE];
428        push_lp(&mut out, self.vrf_proof.as_bytes());
429        out.extend_from_slice(&self.auth_path.to_bytes());
430        out
431    }
432
433    /// Parse a canonical absence proof.
434    ///
435    /// # Errors
436    /// Returns [`Error::MalformedConiksProof`] if the tag, length prefix, or
437    /// trailing authentication path are inconsistent.
438    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
439        let Some((&tag, rest)) = bytes.split_first() else {
440            return Err(Error::MalformedConiksProof("empty proof".into()));
441        };
442        if tag != TAG_ABSENCE {
443            return Err(Error::MalformedConiksProof(format!(
444                "expected absence tag 0x00, got {tag:#04x}"
445            )));
446        }
447        let (vrf_proof, off) = read_lp(rest, 0, "vrf proof")?;
448        let (auth_path, consumed) = AuthPath::parse(&rest[off..])?;
449        if off + consumed != rest.len() {
450            return Err(Error::MalformedConiksProof(
451                "trailing bytes after authentication path".into(),
452            ));
453        }
454        Ok(Self {
455            vrf_proof: VrfProof::from_bytes(vrf_proof.to_vec()),
456            auth_path,
457        })
458    }
459}
460
461/// The outcome of a directory lookup: either a presence or an absence proof.
462#[derive(Debug, Clone, PartialEq, Eq)]
463pub enum LookupResult {
464    /// The identity is present; carries a [`LookupProof`].
465    Present(LookupProof),
466    /// The identity is absent; carries an [`AbsenceProof`].
467    Absent(AbsenceProof),
468}
469
470struct DirectoryEntry {
471    value: Vec<u8>,
472    opening: Opening,
473}
474
475/// A per-namespace CONIKS directory: maps identities to committed values at
476/// VRF-derived indices and produces presence/absence proofs against its root.
477///
478/// This is the prover/operator side. Relying parties verify with the free
479/// [`verify_lookup`] / [`verify_absence`] functions and never need this type.
480pub struct ConiksDirectory {
481    namespace: Namespace,
482    vrf: Box<dyn Vrf>,
483    vrf_secret: VrfSecretKey,
484    vrf_public: VrfPublicKey,
485    hasher: TreeHasher,
486    entries: BTreeMap<Vec<u8>, DirectoryEntry>,
487    leaves: BTreeMap<[u8; INDEX_LEN], Commitment>,
488}
489
490impl ConiksDirectory {
491    /// Create an empty directory for `namespace` using `vrf`, generating a fresh
492    /// VRF keypair from the OS CSPRNG.
493    #[must_use]
494    pub fn new(namespace: Namespace, vrf: Box<dyn Vrf>) -> Self {
495        let (vrf_secret, vrf_public) = vrf.generate_keypair();
496        Self::with_secret_key(namespace, vrf, vrf_secret, vrf_public)
497    }
498
499    /// Create an empty directory from an existing VRF secret key (e.g. a
500    /// persisted per-namespace key).
501    ///
502    /// # Errors
503    /// Returns [`Error::Vrf`] if the public key cannot be derived from the
504    /// secret key.
505    pub fn from_secret_key(
506        namespace: Namespace,
507        vrf: Box<dyn Vrf>,
508        vrf_secret: VrfSecretKey,
509    ) -> Result<Self> {
510        let vrf_public = vrf.derive_public_key(&vrf_secret)?;
511        Ok(Self::with_secret_key(
512            namespace, vrf, vrf_secret, vrf_public,
513        ))
514    }
515
516    fn with_secret_key(
517        namespace: Namespace,
518        vrf: Box<dyn Vrf>,
519        vrf_secret: VrfSecretKey,
520        vrf_public: VrfPublicKey,
521    ) -> Self {
522        let hasher = TreeHasher::new(&namespace, vrf.suite_id());
523        Self {
524            namespace,
525            vrf,
526            vrf_secret,
527            vrf_public,
528            hasher,
529            entries: BTreeMap::new(),
530            leaves: BTreeMap::new(),
531        }
532    }
533
534    /// The namespace this directory serves.
535    #[must_use]
536    pub fn namespace(&self) -> &Namespace {
537        &self.namespace
538    }
539
540    /// The VRF public key relying parties use to verify proofs.
541    #[must_use]
542    pub fn vrf_public_key(&self) -> &VrfPublicKey {
543        &self.vrf_public
544    }
545
546    /// Insert (or replace) `identity`'s `value`, committing to it under a fresh
547    /// random opening and placing the commitment at the identity's VRF-derived
548    /// index.
549    ///
550    /// # Errors
551    /// Returns [`Error::Vrf`] if proving the identity's index fails.
552    pub fn insert(&mut self, identity: &[u8], value: &[u8]) -> Result<()> {
553        let alpha = self.namespace.vrf_input(identity);
554        let proof = self.vrf.prove(&self.vrf_secret, &alpha)?;
555        let index = self.vrf.proof_to_output(&proof)?.index();
556
557        let (commitment, opening) =
558            crate::commitment::commit(&self.namespace.commitment_label(), value);
559
560        self.leaves.insert(index, commitment);
561        self.entries.insert(
562            identity.to_vec(),
563            DirectoryEntry {
564                value: value.to_vec(),
565                opening,
566            },
567        );
568        Ok(())
569    }
570
571    /// The current directory root (the SHA3-512 prefix-tree root over all
572    /// commitments).
573    #[must_use]
574    pub fn root(&self) -> [u8; NODE_LEN] {
575        let leaves: Vec<_> = self.leaves.iter().collect();
576        self.hasher.subtree(0, &leaves)
577    }
578
579    /// Look up `identity`, returning a presence or absence proof against the
580    /// current root.
581    ///
582    /// # Errors
583    /// Returns [`Error::Vrf`] if proving the identity's index fails.
584    pub fn lookup(&self, identity: &[u8]) -> Result<LookupResult> {
585        let alpha = self.namespace.vrf_input(identity);
586        let vrf_proof = self.vrf.prove(&self.vrf_secret, &alpha)?;
587        let index = self.vrf.proof_to_output(&vrf_proof)?.index();
588
589        let all: Vec<_> = self.leaves.iter().collect();
590        let mut siblings = Vec::with_capacity(TREE_DEPTH);
591        self.hasher.collect_path(0, &index, &all, &mut siblings);
592        let auth_path = AuthPath { siblings };
593
594        match self.entries.get(identity) {
595            Some(entry) => Ok(LookupResult::Present(LookupProof {
596                vrf_proof,
597                value: entry.value.clone(),
598                opening: entry.opening.clone(),
599                auth_path,
600            })),
601            None => Ok(LookupResult::Absent(AbsenceProof {
602                vrf_proof,
603                auth_path,
604            })),
605        }
606    }
607}
608
609/// Recover and validate the VRF-derived index for `identity` under a proof.
610fn verified_index(
611    vrf: &dyn Vrf,
612    namespace: &Namespace,
613    vrf_public: &VrfPublicKey,
614    identity: &[u8],
615    vrf_proof: &VrfProof,
616) -> Result<[u8; INDEX_LEN]> {
617    let alpha = namespace.vrf_input(identity);
618    match vrf.verify(vrf_public, &alpha, vrf_proof)? {
619        Some(output) => Ok(output.index()),
620        None => Err(Error::VrfProofInvalid),
621    }
622}
623
624/// Independently verify a **presence** proof, returning the proven value.
625///
626/// Recomputes everything from public inputs: the VRF proof binds `identity` to a
627/// private index, the revealed `(value, opening)` reproduce the leaf commitment,
628/// and the authentication path must recompute `root`.
629///
630/// # Errors
631/// - [`Error::VrfProofInvalid`] if the VRF proof does not verify.
632/// - [`Error::ConiksRootMismatch`] if the authentication path does not
633///   recompute `root` (a wrong value, opening, or tampered path all surface
634///   here).
635/// - [`Error::Vrf`] for a structurally invalid VRF proof.
636pub fn verify_lookup(
637    vrf: &dyn Vrf,
638    namespace: &Namespace,
639    vrf_public: &VrfPublicKey,
640    root: &[u8; NODE_LEN],
641    identity: &[u8],
642    proof: &LookupProof,
643) -> Result<Vec<u8>> {
644    let index = verified_index(vrf, namespace, vrf_public, identity, &proof.vrf_proof)?;
645
646    let commitment = crate::commitment::commit_with_opening(
647        &namespace.commitment_label(),
648        &proof.value,
649        &proof.opening,
650    );
651    let hasher = TreeHasher::new(namespace, vrf.suite_id());
652    let leaf = hasher.leaf_hash(&index, &commitment);
653    let recomputed = hasher.recompute_root(&index, leaf, &proof.auth_path.siblings);
654
655    if &recomputed == root {
656        Ok(proof.value.clone())
657    } else {
658        Err(Error::ConiksRootMismatch)
659    }
660}
661
662/// Independently verify an **absence** proof.
663///
664/// Recomputes the VRF-derived index for `identity` and checks that the
665/// authentication path to the *empty* leaf at that index recomputes `root` —
666/// proving no value is committed there.
667///
668/// # Errors
669/// - [`Error::VrfProofInvalid`] if the VRF proof does not verify.
670/// - [`Error::ConiksRootMismatch`] if the authentication path does not
671///   recompute `root`.
672/// - [`Error::Vrf`] for a structurally invalid VRF proof.
673pub fn verify_absence(
674    vrf: &dyn Vrf,
675    namespace: &Namespace,
676    vrf_public: &VrfPublicKey,
677    root: &[u8; NODE_LEN],
678    identity: &[u8],
679    proof: &AbsenceProof,
680) -> Result<()> {
681    let index = verified_index(vrf, namespace, vrf_public, identity, &proof.vrf_proof)?;
682
683    let hasher = TreeHasher::new(namespace, vrf.suite_id());
684    let recomputed = hasher.recompute_root(&index, hasher.empty_leaf(), &proof.auth_path.siblings);
685
686    if &recomputed == root {
687        Ok(())
688    } else {
689        Err(Error::ConiksRootMismatch)
690    }
691}
692
693#[cfg(all(test, not(target_arch = "wasm32")))]
694mod tests {
695    use super::*;
696    use crate::vrf::Ecvrf;
697
698    fn dir() -> ConiksDirectory {
699        ConiksDirectory::new(Namespace::parse("acme").unwrap(), Box::new(Ecvrf))
700    }
701
702    #[test]
703    fn namespace_validation() {
704        assert!(Namespace::parse("acme").is_ok());
705        assert!(Namespace::parse("mosslet").is_ok());
706        assert!(Namespace::parse("").is_err());
707        assert!(Namespace::parse("has/slash").is_err());
708        assert!(Namespace::parse("has space").is_err());
709    }
710
711    #[test]
712    fn present_lookup_verifies() {
713        let mut d = dir();
714        d.insert(b"alice", b"alice-value").unwrap();
715        d.insert(b"bob", b"bob-value").unwrap();
716        let root = d.root();
717
718        let LookupResult::Present(proof) = d.lookup(b"alice").unwrap() else {
719            panic!("alice should be present");
720        };
721        let value = verify_lookup(
722            &Ecvrf,
723            d.namespace(),
724            d.vrf_public_key(),
725            &root,
726            b"alice",
727            &proof,
728        )
729        .unwrap();
730        assert_eq!(value, b"alice-value");
731    }
732
733    #[test]
734    fn absent_lookup_verifies() {
735        let mut d = dir();
736        d.insert(b"alice", b"v").unwrap();
737        let root = d.root();
738
739        let LookupResult::Absent(proof) = d.lookup(b"carol").unwrap() else {
740            panic!("carol should be absent");
741        };
742        verify_absence(
743            &Ecvrf,
744            d.namespace(),
745            d.vrf_public_key(),
746            &root,
747            b"carol",
748            &proof,
749        )
750        .unwrap();
751    }
752
753    #[test]
754    fn empty_directory_absence_verifies() {
755        let d = dir();
756        let root = d.root();
757        let LookupResult::Absent(proof) = d.lookup(b"nobody").unwrap() else {
758            panic!("absent");
759        };
760        verify_absence(
761            &Ecvrf,
762            d.namespace(),
763            d.vrf_public_key(),
764            &root,
765            b"nobody",
766            &proof,
767        )
768        .unwrap();
769    }
770
771    #[test]
772    fn tampered_value_is_rejected() {
773        let mut d = dir();
774        d.insert(b"alice", b"real").unwrap();
775        let root = d.root();
776        let LookupResult::Present(mut proof) = d.lookup(b"alice").unwrap() else {
777            panic!("present");
778        };
779        proof.value = b"forged".to_vec();
780        assert_eq!(
781            verify_lookup(
782                &Ecvrf,
783                d.namespace(),
784                d.vrf_public_key(),
785                &root,
786                b"alice",
787                &proof
788            ),
789            Err(Error::ConiksRootMismatch)
790        );
791    }
792
793    #[test]
794    fn absence_proof_for_present_identity_is_rejected() {
795        // A directory cannot prove absence of an identity it actually holds: the
796        // empty-leaf root recomputation will not match the real root.
797        let mut d = dir();
798        d.insert(b"alice", b"v").unwrap();
799        let root = d.root();
800        let LookupResult::Present(present) = d.lookup(b"alice").unwrap() else {
801            panic!("present");
802        };
803        // Forge an absence proof reusing the (valid) VRF proof + path.
804        let forged = AbsenceProof {
805            vrf_proof: VrfProof::from_bytes(present.to_bytes()[5..5].to_vec()),
806            auth_path: present.auth_path.clone(),
807        };
808        // Structurally the forged vrf proof is empty -> Vrf error, but even a
809        // valid VRF proof would fail the root check; assert it does not pass.
810        assert!(
811            verify_absence(
812                &Ecvrf,
813                d.namespace(),
814                d.vrf_public_key(),
815                &root,
816                b"alice",
817                &forged
818            )
819            .is_err()
820        );
821    }
822
823    #[test]
824    fn cross_namespace_proof_is_rejected() {
825        let mut d = dir();
826        d.insert(b"alice", b"v").unwrap();
827        let root = d.root();
828        let LookupResult::Present(proof) = d.lookup(b"alice").unwrap() else {
829            panic!("present");
830        };
831        // Verifying under a different namespace must fail: the VRF input is
832        // namespace-scoped, so the proof does not verify.
833        let other = Namespace::parse("evil").unwrap();
834        assert!(
835            verify_lookup(&Ecvrf, &other, d.vrf_public_key(), &root, b"alice", &proof).is_err()
836        );
837    }
838
839    #[test]
840    fn wrong_root_is_rejected() {
841        let mut d = dir();
842        d.insert(b"alice", b"v").unwrap();
843        let LookupResult::Present(proof) = d.lookup(b"alice").unwrap() else {
844            panic!("present");
845        };
846        let bad_root = [0u8; NODE_LEN];
847        assert_eq!(
848            verify_lookup(
849                &Ecvrf,
850                d.namespace(),
851                d.vrf_public_key(),
852                &bad_root,
853                b"alice",
854                &proof
855            ),
856            Err(Error::ConiksRootMismatch)
857        );
858    }
859
860    #[test]
861    fn proofs_round_trip_through_bytes() {
862        let mut d = dir();
863        d.insert(b"alice", b"alice-value").unwrap();
864        let root = d.root();
865
866        let LookupResult::Present(present) = d.lookup(b"alice").unwrap() else {
867            panic!("present");
868        };
869        let reparsed = LookupProof::from_bytes(&present.to_bytes()).unwrap();
870        assert_eq!(reparsed, present);
871        assert_eq!(
872            verify_lookup(
873                &Ecvrf,
874                d.namespace(),
875                d.vrf_public_key(),
876                &root,
877                b"alice",
878                &reparsed
879            )
880            .unwrap(),
881            b"alice-value"
882        );
883
884        let LookupResult::Absent(absent) = d.lookup(b"carol").unwrap() else {
885            panic!("absent");
886        };
887        let reparsed_absent = AbsenceProof::from_bytes(&absent.to_bytes()).unwrap();
888        assert_eq!(reparsed_absent, absent);
889        verify_absence(
890            &Ecvrf,
891            d.namespace(),
892            d.vrf_public_key(),
893            &root,
894            b"carol",
895            &reparsed_absent,
896        )
897        .unwrap();
898    }
899
900    #[test]
901    fn malformed_proof_bytes_are_rejected() {
902        assert!(LookupProof::from_bytes(&[]).is_err());
903        assert!(LookupProof::from_bytes(&[TAG_ABSENCE]).is_err()); // wrong tag
904        assert!(AbsenceProof::from_bytes(&[TAG_PRESENCE]).is_err()); // wrong tag
905        assert!(AbsenceProof::from_bytes(&[TAG_ABSENCE, 0, 0]).is_err()); // truncated lp
906    }
907
908    #[test]
909    fn index_is_namespace_scoped() {
910        // The same identity gets different indices under different namespaces.
911        let ns_a = Namespace::parse("a").unwrap();
912        let ns_b = Namespace::parse("b").unwrap();
913        assert_ne!(ns_a.vrf_input(b"alice"), ns_b.vrf_input(b"alice"));
914    }
915
916    use proptest::prelude::*;
917
918    proptest! {
919        // Each lookup over a sparse depth-256 tree is intentionally O(leaves *
920        // depth) (singleton branches descend to the full index length), so cap
921        // the case count to keep CI fast while still exercising many random
922        // directory shapes.
923        #![proptest_config(ProptestConfig::with_cases(24))]
924
925        #[test]
926        fn random_directory_presence_and_absence(
927            present_ids in proptest::collection::vec(any::<u64>(), 1..9),
928            absent_id: u64,
929        ) {
930            prop_assume!(!present_ids.contains(&absent_id));
931            let mut d = dir();
932            for id in &present_ids {
933                d.insert(&id.to_be_bytes(), format!("v{id}").as_bytes()).unwrap();
934            }
935            let root = d.root();
936
937            // Every inserted identity verifies as present with its value.
938            for id in &present_ids {
939                let key = id.to_be_bytes();
940                let LookupResult::Present(proof) = d.lookup(&key).unwrap() else {
941                    prop_assert!(false, "expected present");
942                    unreachable!();
943                };
944                let value = verify_lookup(
945                    &Ecvrf, d.namespace(), d.vrf_public_key(), &root, &key, &proof,
946                ).unwrap();
947                prop_assert_eq!(value, format!("v{id}").into_bytes());
948            }
949
950            // A non-inserted identity verifies as absent.
951            let key = absent_id.to_be_bytes();
952            let LookupResult::Absent(proof) = d.lookup(&key).unwrap() else {
953                prop_assert!(false, "expected absent");
954                unreachable!();
955            };
956            verify_absence(
957                &Ecvrf, d.namespace(), d.vrf_public_key(), &root, &key, &proof,
958            ).unwrap();
959        }
960    }
961}