Skip to main content

rvm_cap/
verify.rs

1//! Three-layer proof verification (ADR-135).
2//!
3//! - **P1**: Capability existence + rights check (< 1 us, bitmap AND).
4//! - **P2**: Structural invariant validation (< 100 us, constant-time).
5//! - **P3**: Deep proof — derivation chain integrity (root reachability, epoch monotonicity).
6
7use crate::derivation::DerivationTree;
8use crate::error::ProofError;
9use crate::table::CapabilityTable;
10use rvm_types::CapRights;
11
12/// Nonce ring buffer size for replay prevention.
13///
14/// Increased from 64 to 4096 to prevent replay attacks that exploit
15/// the small ring buffer window (security finding: nonce ring too small).
16const NONCE_RING_SIZE: usize = 4096;
17
18/// Policy context for P2 validation.
19#[derive(Debug, Clone, Copy)]
20pub struct PolicyContext {
21    /// The expected owner partition ID.
22    pub expected_owner: u32,
23    /// Region lower bound (used for bounds checking).
24    pub region_base: u64,
25    /// Region upper bound.
26    pub region_limit: u64,
27    /// Lease expiry timestamp in nanoseconds.
28    pub lease_expiry_ns: u64,
29    /// Current timestamp in nanoseconds.
30    pub current_time_ns: u64,
31    /// Maximum delegation depth (typically 8).
32    pub max_delegation_depth: u8,
33    /// Nonce for replay prevention.
34    pub nonce: u64,
35}
36
37/// Three-layer proof verifier.
38///
39/// Encapsulates the epoch and nonce tracker needed for P1/P2/P3 verification.
40pub struct ProofVerifier<const N: usize> {
41    /// Reference epoch for stale-handle detection.
42    current_epoch: u32,
43    /// Nonce ring buffer for replay prevention.
44    nonce_ring: [u64; NONCE_RING_SIZE],
45    /// Hash-indexed nonce lookup: `nonce_hash[nonce % SIZE]` stores the
46    /// nonce value for O(1) replay detection instead of O(N) linear scan.
47    nonce_hash: [u64; NONCE_RING_SIZE],
48    /// Write position in the nonce ring.
49    nonce_write_pos: usize,
50    /// Monotonic watermark: any nonce below this value is rejected
51    /// outright, even if it has fallen off the ring buffer. This
52    /// prevents replaying very old nonces after ring eviction.
53    nonce_watermark: u64,
54    /// Whether nonce == 0 is allowed to bypass replay checks.
55    ///
56    /// Default is `false` (zero nonce is rejected). Set to `true` only
57    /// for boot-time or backwards-compatible contexts where a sentinel
58    /// nonce is acceptable.
59    allow_zero_nonce: bool,
60}
61
62impl<const N: usize> ProofVerifier<N> {
63    /// Creates a new proof verifier with the given epoch.
64    ///
65    /// By default, nonce == 0 is **rejected** (no zero-nonce bypass).
66    /// Use [`set_allow_zero_nonce`](Self::set_allow_zero_nonce) to enable
67    /// the sentinel behaviour for boot-time contexts.
68    #[must_use]
69    #[allow(clippy::large_stack_arrays)]
70    pub const fn new(epoch: u32) -> Self {
71        Self {
72            current_epoch: epoch,
73            nonce_ring: [0u64; NONCE_RING_SIZE],
74            nonce_hash: [0u64; NONCE_RING_SIZE],
75            nonce_write_pos: 0,
76            nonce_watermark: 0,
77            allow_zero_nonce: false,
78        }
79    }
80
81    /// Set whether nonce == 0 is allowed to bypass replay checks.
82    pub fn set_allow_zero_nonce(&mut self, allow: bool) {
83        self.allow_zero_nonce = allow;
84    }
85
86    /// Updates the current epoch.
87    pub fn set_epoch(&mut self, epoch: u32) {
88        self.current_epoch = epoch;
89    }
90
91    /// P1: Capability existence + rights check.
92    ///
93    /// Budget: < 1 us. No allocation. All checks execute regardless of
94    /// intermediate failures to prevent timing side-channel leakage.
95    /// The final error returned is deliberately the most generic
96    /// (`InvalidHandle`) to avoid leaking which check failed.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`ProofError::InvalidHandle`] if the handle is invalid.
101    /// Returns [`ProofError::StaleCapability`] if the epoch does not match.
102    /// Returns [`ProofError::InsufficientRights`] if the rights are insufficient.
103    #[inline]
104    pub fn verify_p1(
105        &self,
106        table: &CapabilityTable<N>,
107        cap_index: u32,
108        cap_generation: u32,
109        required_rights: CapRights,
110    ) -> Result<(), ProofError> {
111        // Run ALL checks unconditionally to prevent timing side channels.
112        // We accumulate a bitmask of failures rather than early-returning.
113        let mut fail_mask: u8 = 0;
114
115        let lookup_result = table.lookup(cap_index, cap_generation);
116
117        // Check 1: Handle validity.
118        let (epoch_match, rights_match) = if let Ok(slot) = &lookup_result {
119            // Check 2: Epoch match.
120            let e = slot.token.epoch() == self.current_epoch;
121            // Check 3: Rights subset.
122            let r = slot.token.has_rights(required_rights);
123            (e, r)
124        } else {
125            fail_mask |= 1;
126            // Still "compute" epoch and rights checks against dummy values
127            // to keep timing constant. The compiler should not elide these
128            // because fail_mask is read below.
129            (false, false)
130        };
131
132        if !epoch_match {
133            fail_mask |= 2;
134        }
135        if !rights_match {
136            fail_mask |= 4;
137        }
138
139        if fail_mask == 0 {
140            Ok(())
141        } else if fail_mask & 1 != 0 {
142            Err(ProofError::InvalidHandle)
143        } else if fail_mask & 2 != 0 {
144            Err(ProofError::StaleCapability)
145        } else {
146            Err(ProofError::InsufficientRights)
147        }
148    }
149
150    /// P2: Structural invariant validation (constant-time).
151    ///
152    /// Budget: < 100 us. All checks execute regardless of intermediate
153    /// failures to prevent timing side-channel leakage (ADR-135).
154    ///
155    /// Checks: ownership chain, region bounds, lease expiry,
156    /// delegation depth, nonce replay.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`ProofError::PolicyViolation`] if any structural check fails.
161    pub fn verify_p2(
162        &mut self,
163        table: &CapabilityTable<N>,
164        tree: &DerivationTree<N>,
165        cap_index: u32,
166        cap_generation: u32,
167        ctx: &PolicyContext,
168    ) -> Result<(), ProofError> {
169        let mut valid = true;
170
171        // 1. Ownership chain valid.
172        let owner_ok = table
173            .lookup(cap_index, cap_generation)
174            .is_ok_and(|slot| slot.owner.as_u32() == ctx.expected_owner);
175        valid &= owner_ok;
176
177        // 2. Region bounds legal.
178        valid &= ctx.region_base < ctx.region_limit;
179
180        // 3. Lease not expired.
181        valid &= ctx.current_time_ns <= ctx.lease_expiry_ns;
182
183        // 4. Delegation depth within limit.
184        let depth_ok = tree
185            .depth(cap_index)
186            .is_ok_and(|d| d <= ctx.max_delegation_depth);
187        valid &= depth_ok;
188
189        // 5. Nonce not replayed.
190        let nonce_ok = self.check_nonce(ctx.nonce);
191        valid &= nonce_ok;
192
193        if valid {
194            self.mark_nonce(ctx.nonce);
195            Ok(())
196        } else {
197            Err(ProofError::PolicyViolation)
198        }
199    }
200
201    /// P3: Deep proof — derivation chain integrity verification.
202    ///
203    /// Walks the derivation tree from the given capability back to its
204    /// root and verifies:
205    /// 1. Every ancestor is valid (not revoked).
206    /// 2. Depth decreases monotonically toward the root.
207    /// 3. Epoch values are non-decreasing from root to leaf.
208    /// 4. The chain terminates at a root node (depth 0).
209    /// 5. The chain length does not exceed `max_depth`.
210    ///
211    /// Budget: < 10 us for depth <= 8 (typical). Worst-case O(depth).
212    ///
213    /// # Errors
214    ///
215    /// Returns [`ProofError::DerivationChainBroken`] if the chain is
216    /// invalid, tampered, or does not reach a root.
217    pub fn verify_p3(
218        &self,
219        table: &CapabilityTable<N>,
220        tree: &DerivationTree<N>,
221        cap_index: u32,
222        cap_generation: u32,
223        max_depth: u8,
224    ) -> Result<(), ProofError> {
225        // Verify the capability itself is valid.
226        let _slot = table
227            .lookup(cap_index, cap_generation)
228            .map_err(|_| ProofError::DerivationChainBroken)?;
229
230        // Verify the derivation node exists and is valid.
231        let node = tree
232            .get(cap_index)
233            .ok_or(ProofError::DerivationChainBroken)?;
234        if !node.is_valid {
235            return Err(ProofError::DerivationChainBroken);
236        }
237
238        // If this IS a root, chain is trivially valid.
239        if node.depth == 0 {
240            return Ok(());
241        }
242
243        // Walk the derivation tree up to the root.
244        let mut current_depth = node.depth;
245        let mut current_epoch = node.epoch;
246        let mut steps = 0u8;
247
248        // Walk ancestors. The derivation tree uses first-child/next-sibling,
249        // so we need to find the parent. We do this by scanning for a node
250        // that has `cap_index` in its children chain.
251        let mut current_idx = cap_index;
252        loop {
253            steps += 1;
254            if steps > max_depth {
255                return Err(ProofError::DerivationChainBroken);
256            }
257
258            // Find the parent of current_idx.
259            let parent_idx = tree.find_parent(current_idx);
260            match parent_idx {
261                Some(pidx) => {
262                    let Some(parent) = tree.get(pidx) else {
263                        return Err(ProofError::DerivationChainBroken);
264                    };
265
266                    // Ancestor must be valid.
267                    if !parent.is_valid {
268                        return Err(ProofError::DerivationChainBroken);
269                    }
270                    // Depth must decrease.
271                    if parent.depth >= current_depth {
272                        return Err(ProofError::DerivationChainBroken);
273                    }
274                    // Epoch must be non-decreasing from root to leaf
275                    // (parent.epoch <= child.epoch).
276                    if parent.epoch > current_epoch {
277                        return Err(ProofError::DerivationChainBroken);
278                    }
279
280                    if parent.depth == 0 {
281                        // Reached the root — chain is valid.
282                        return Ok(());
283                    }
284
285                    current_depth = parent.depth;
286                    current_epoch = parent.epoch;
287                    current_idx = pidx;
288                }
289                None => {
290                    // No parent found but we're not at root — broken chain.
291                    return Err(ProofError::DerivationChainBroken);
292                }
293            }
294        }
295    }
296
297    /// Checks if a nonce has been used recently.
298    ///
299    /// Rejects nonces that are below the monotonic watermark (very old
300    /// nonces that have already fallen off the ring) as well as nonces
301    /// still present in the ring buffer.
302    ///
303    /// Nonce == 0 is rejected unless `allow_zero_nonce` is set. This
304    /// prevents callers from silently skipping replay protection by
305    /// passing a default/uninitialized nonce value.
306    fn check_nonce(&self, nonce: u64) -> bool {
307        if nonce == 0 {
308            return self.allow_zero_nonce;
309        }
310        // Watermark check: reject any nonce below the low-water mark.
311        if nonce <= self.nonce_watermark {
312            return false;
313        }
314        // O(1) hash-indexed lookup instead of linear scan.
315        let hash_slot = usize::try_from(nonce % NONCE_RING_SIZE as u64).unwrap_or(0);
316        if self.nonce_hash[hash_slot] == nonce {
317            return false;
318        }
319        true
320    }
321
322    /// Records a nonce as used and advances the watermark.
323    fn mark_nonce(&mut self, nonce: u64) {
324        if nonce == 0 {
325            return;
326        }
327        self.nonce_ring[self.nonce_write_pos] = nonce;
328        // Populate hash index for O(1) lookup.
329        let hash_slot = usize::try_from(nonce % NONCE_RING_SIZE as u64).unwrap_or(0);
330        self.nonce_hash[hash_slot] = nonce;
331        self.nonce_write_pos = (self.nonce_write_pos + 1) % NONCE_RING_SIZE;
332        // Advance watermark: the watermark tracks the minimum nonce
333        // that was evicted from the ring. When we wrap, the oldest
334        // entry is being overwritten, so we bump the watermark.
335        if self.nonce_write_pos == 0 {
336            // We just wrapped. Find the minimum value in the ring
337            // to set as the new watermark.
338            let mut min_val = u64::MAX;
339            for entry in &self.nonce_ring {
340                if *entry != 0 && *entry < min_val {
341                    min_val = *entry;
342                }
343            }
344            if min_val != u64::MAX && min_val > self.nonce_watermark {
345                self.nonce_watermark = min_val;
346            }
347        }
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use rvm_types::{CapToken, CapType, PartitionId};
355
356    fn setup() -> (CapabilityTable<64>, DerivationTree<64>, ProofVerifier<64>) {
357        let table = CapabilityTable::<64>::new();
358        let tree = DerivationTree::<64>::new();
359        let verifier = ProofVerifier::<64>::new(0);
360        (table, tree, verifier)
361    }
362
363    fn all_rights() -> CapRights {
364        CapRights::READ
365            .union(CapRights::WRITE)
366            .union(CapRights::EXECUTE)
367            .union(CapRights::GRANT)
368            .union(CapRights::REVOKE)
369    }
370
371    #[test]
372    fn test_p1_valid() {
373        let (mut table, _, verifier) = setup();
374        let owner = PartitionId::new(1);
375        let token = CapToken::new(100, CapType::Region, all_rights(), 0);
376        let (idx, gen) = table.insert_root(token, owner, 0).unwrap();
377        assert!(verifier
378            .verify_p1(&table, idx, gen, CapRights::READ)
379            .is_ok());
380    }
381
382    #[test]
383    fn test_p1_invalid_handle() {
384        let (table, _, verifier) = setup();
385        assert_eq!(
386            verifier.verify_p1(&table, 99, 0, CapRights::READ),
387            Err(ProofError::InvalidHandle)
388        );
389    }
390
391    #[test]
392    fn test_p1_stale_epoch() {
393        let (mut table, _, verifier) = setup();
394        let token = CapToken::new(100, CapType::Region, all_rights(), 5);
395        let (idx, gen) = table.insert_root(token, PartitionId::new(1), 0).unwrap();
396        assert_eq!(
397            verifier.verify_p1(&table, idx, gen, CapRights::READ),
398            Err(ProofError::StaleCapability)
399        );
400    }
401
402    #[test]
403    fn test_p1_insufficient_rights() {
404        let (mut table, _, verifier) = setup();
405        let token = CapToken::new(100, CapType::Region, CapRights::READ, 0);
406        let (idx, gen) = table.insert_root(token, PartitionId::new(1), 0).unwrap();
407        assert_eq!(
408            verifier.verify_p1(&table, idx, gen, CapRights::WRITE),
409            Err(ProofError::InsufficientRights)
410        );
411    }
412
413    #[test]
414    fn test_p2_all_pass() {
415        let (mut table, mut tree, mut verifier) = setup();
416        let token = CapToken::new(100, CapType::Region, all_rights(), 0);
417        let (idx, gen) = table.insert_root(token, PartitionId::new(1), 0).unwrap();
418        tree.add_root(idx, 0).unwrap();
419
420        let ctx = PolicyContext {
421            expected_owner: 1,
422            region_base: 0x1000,
423            region_limit: 0x2000,
424            lease_expiry_ns: 1_000_000_000,
425            current_time_ns: 500_000_000,
426            max_delegation_depth: 8,
427            nonce: 42,
428        };
429        assert!(verifier.verify_p2(&table, &tree, idx, gen, &ctx).is_ok());
430    }
431
432    #[test]
433    fn test_p2_nonce_replay() {
434        let (mut table, mut tree, mut verifier) = setup();
435        let token = CapToken::new(100, CapType::Region, all_rights(), 0);
436        let (idx, gen) = table.insert_root(token, PartitionId::new(1), 0).unwrap();
437        tree.add_root(idx, 0).unwrap();
438
439        let ctx = PolicyContext {
440            expected_owner: 1,
441            region_base: 0x1000,
442            region_limit: 0x2000,
443            lease_expiry_ns: 1_000_000_000,
444            current_time_ns: 500_000_000,
445            max_delegation_depth: 8,
446            nonce: 55,
447        };
448        assert!(verifier.verify_p2(&table, &tree, idx, gen, &ctx).is_ok());
449        assert_eq!(
450            verifier.verify_p2(&table, &tree, idx, gen, &ctx),
451            Err(ProofError::PolicyViolation)
452        );
453    }
454
455    #[test]
456    fn test_p3_root_passes() {
457        let (mut table, mut tree, verifier) = setup();
458        let token = CapToken::new(100, CapType::Region, all_rights(), 0);
459        let (idx, gen) = table.insert_root(token, PartitionId::new(1), 0).unwrap();
460        tree.add_root(idx, 0).unwrap();
461
462        assert!(verifier.verify_p3(&table, &tree, idx, gen, 8).is_ok());
463    }
464
465    #[test]
466    fn test_p3_one_level_derivation() {
467        let (mut table, mut tree, verifier) = setup();
468        let owner = PartitionId::new(1);
469
470        // Create root.
471        let root_token = CapToken::new(100, CapType::Region, all_rights(), 0);
472        let (root_idx, _root_gen) = table.insert_root(root_token, owner, 0).unwrap();
473        tree.add_root(root_idx, 0).unwrap();
474
475        // Derive a child.
476        let child_token = CapToken::new(200, CapType::Region, CapRights::READ, 0);
477        let (child_idx, child_gen) = table.insert_root(child_token, owner, 0).unwrap();
478        tree.add_child(root_idx, child_idx, 1, 1).unwrap();
479
480        // P3 should follow child → root and succeed.
481        assert!(verifier
482            .verify_p3(&table, &tree, child_idx, child_gen, 8)
483            .is_ok());
484    }
485
486    #[test]
487    fn test_p3_nonexistent_fails() {
488        let (table, tree, verifier) = setup();
489        assert_eq!(
490            verifier.verify_p3(&table, &tree, 99, 0, 8),
491            Err(ProofError::DerivationChainBroken),
492        );
493    }
494
495    #[test]
496    fn test_p3_revoked_ancestor_fails() {
497        let (mut table, mut tree, verifier) = setup();
498        let owner = PartitionId::new(1);
499
500        let root_token = CapToken::new(100, CapType::Region, all_rights(), 0);
501        let (root_idx, _) = table.insert_root(root_token, owner, 0).unwrap();
502        tree.add_root(root_idx, 0).unwrap();
503
504        let child_token = CapToken::new(200, CapType::Region, CapRights::READ, 0);
505        let (child_idx, child_gen) = table.insert_root(child_token, owner, 0).unwrap();
506        tree.add_child(root_idx, child_idx, 1, 1).unwrap();
507
508        // Revoke the root.
509        tree.revoke(root_idx).unwrap();
510
511        // P3 should fail because root is revoked.
512        assert_eq!(
513            verifier.verify_p3(&table, &tree, child_idx, child_gen, 8),
514            Err(ProofError::DerivationChainBroken),
515        );
516    }
517
518    #[test]
519    fn test_nonce_ring_4096_churn() {
520        // Verify that after filling the 4096-entry ring, old nonces are
521        // rejected by the monotonic watermark even after eviction.
522        let (mut table, mut tree, mut verifier) = setup();
523        let token = CapToken::new(100, CapType::Region, all_rights(), 0);
524        let (idx, gen) = table.insert_root(token, PartitionId::new(1), 0).unwrap();
525        tree.add_root(idx, 0).unwrap();
526
527        // Insert 4096 nonces (1..=4096).
528        for i in 1..=4096u64 {
529            let ctx = PolicyContext {
530                expected_owner: 1,
531                region_base: 0x1000,
532                region_limit: 0x2000,
533                lease_expiry_ns: 1_000_000_000,
534                current_time_ns: 500_000_000,
535                max_delegation_depth: 8,
536                nonce: i,
537            };
538            assert!(verifier.verify_p2(&table, &tree, idx, gen, &ctx).is_ok());
539        }
540
541        // Now insert one more to push nonce 1 out and trigger watermark.
542        let ctx_new = PolicyContext {
543            expected_owner: 1,
544            region_base: 0x1000,
545            region_limit: 0x2000,
546            lease_expiry_ns: 1_000_000_000,
547            current_time_ns: 500_000_000,
548            max_delegation_depth: 8,
549            nonce: 4097,
550        };
551        assert!(verifier
552            .verify_p2(&table, &tree, idx, gen, &ctx_new)
553            .is_ok());
554
555        // Nonce 1 should be rejected by the watermark even though it
556        // has been evicted from the ring.
557        let ctx_old = PolicyContext {
558            expected_owner: 1,
559            region_base: 0x1000,
560            region_limit: 0x2000,
561            lease_expiry_ns: 1_000_000_000,
562            current_time_ns: 500_000_000,
563            max_delegation_depth: 8,
564            nonce: 1,
565        };
566        assert_eq!(
567            verifier.verify_p2(&table, &tree, idx, gen, &ctx_old),
568            Err(ProofError::PolicyViolation)
569        );
570    }
571
572    #[test]
573    fn test_watermark_rejects_below_minimum() {
574        let mut verifier = ProofVerifier::<64>::new(0);
575        // Manually advance the watermark by filling the ring and wrapping.
576        // Use nonces 100..100+4096 to set a high watermark.
577        for i in 100..100 + 4096u64 {
578            verifier.mark_nonce(i);
579        }
580        // Nonce below the watermark should be rejected.
581        assert!(!verifier.check_nonce(1));
582        assert!(!verifier.check_nonce(99));
583    }
584}