Skip to main content

oxirs_did/revocation/
revocation_list.rs

1//! RevocationList2020 — W3C Credential Status revocation bitmap
2//!
3//! Implements the W3C Revocation List 2020 specification:
4//! <https://w3c-ccg.github.io/vc-status-rl-2020/>
5//!
6//! Key features:
7//! - `RevocationList2020`: fixed-size bitset (default 16 384 entries)
8//! - `RevocationEntry`: index + reason code
9//! - `RevocationRegistry2020`: in-memory O(1) check with bloom filter for
10//!   fast non-membership proof
11//! - `RevocationStatus` enum: Valid, Revoked(reason), Unknown
12
13use crate::{DidError, DidResult};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use std::collections::HashMap;
17
18// ── RevocationStatus ─────────────────────────────────────────────────────────
19
20/// Result of querying whether a credential has been revoked
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub enum RevocationStatus {
23    /// Credential is currently valid (not revoked)
24    Valid,
25    /// Credential has been revoked — reason string gives human-readable context
26    Revoked { reason: String },
27    /// Credential ID is not tracked by this registry — cannot determine status
28    Unknown,
29}
30
31impl RevocationStatus {
32    /// Returns `true` if the credential is revoked
33    pub fn is_revoked(&self) -> bool {
34        matches!(self, RevocationStatus::Revoked { .. })
35    }
36
37    /// Returns `true` if the credential is valid
38    pub fn is_valid(&self) -> bool {
39        matches!(self, RevocationStatus::Valid)
40    }
41
42    /// Returns `true` if the credential is unknown
43    pub fn is_unknown(&self) -> bool {
44        matches!(self, RevocationStatus::Unknown)
45    }
46}
47
48// ── RevocationEntry ──────────────────────────────────────────────────────────
49
50/// A single revocation record stored in the list
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52pub struct RevocationEntry {
53    /// Bit-list index assigned to this credential
54    pub index: usize,
55    /// Credential URI / identifier
56    pub credential_id: String,
57    /// Optional reason code (e.g. "keyCompromise", "superseded")
58    pub reason: String,
59    /// ISO-8601 timestamp at revocation time
60    pub revoked_at: String,
61}
62
63impl RevocationEntry {
64    /// Create with an explicit reason
65    pub fn new(index: usize, credential_id: &str, reason: &str, revoked_at: &str) -> Self {
66        Self {
67            index,
68            credential_id: credential_id.to_string(),
69            reason: reason.to_string(),
70            revoked_at: revoked_at.to_string(),
71        }
72    }
73}
74
75// ── Bloom filter (simple, deterministic) ────────────────────────────────────
76
77/// Minimal bloom filter for fast non-membership proofs.
78///
79/// Uses k=3 independent SHA-256-derived hash functions.
80/// The filter is sized to give < 1 % false-positive rate for up to 10 000 items.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct BloomFilter {
83    /// Bitset stored as bytes
84    bits: Vec<u8>,
85    /// Number of hash functions
86    k: usize,
87    /// Number of bits in the filter (= bits.len() * 8)
88    m: usize,
89    /// Count of items inserted
90    count: usize,
91}
92
93impl BloomFilter {
94    /// Create a new bloom filter with `m` bits and `k` hash functions.
95    /// Sensible defaults: `m = 131_072` (16 KiB), `k = 3`.
96    pub fn new(m: usize, k: usize) -> Self {
97        let byte_count = (m + 7) / 8;
98        Self {
99            bits: vec![0u8; byte_count],
100            k,
101            m,
102            count: 0,
103        }
104    }
105
106    /// Create with defaults optimised for up to 10 000 items with ≈1 % FP rate
107    pub fn with_defaults() -> Self {
108        Self::new(131_072, 3)
109    }
110
111    /// Insert an item
112    pub fn insert(&mut self, item: &str) {
113        for i in 0..self.k {
114            let bit = self.hash(item, i);
115            let byte_idx = bit / 8;
116            let bit_idx = bit % 8;
117            self.bits[byte_idx] |= 1 << bit_idx;
118        }
119        self.count += 1;
120    }
121
122    /// Query: returns `false` if the item is **definitely not** in the set.
123    /// Returns `true` if the item is **possibly** in the set (may be a FP).
124    pub fn might_contain(&self, item: &str) -> bool {
125        for i in 0..self.k {
126            let bit = self.hash(item, i);
127            let byte_idx = bit / 8;
128            let bit_idx = bit % 8;
129            if self.bits[byte_idx] & (1 << bit_idx) == 0 {
130                return false;
131            }
132        }
133        true
134    }
135
136    /// Number of items inserted
137    pub fn count(&self) -> usize {
138        self.count
139    }
140
141    /// Estimated false-positive rate given current fill
142    pub fn false_positive_rate(&self) -> f64 {
143        // FP ≈ (1 - e^{-k·n/m})^k
144        let exponent = -(self.k as f64) * (self.count as f64) / (self.m as f64);
145        (1.0 - exponent.exp()).powi(self.k as i32)
146    }
147
148    // Deterministic hash function: SHA-256(seed || item) mod m
149    fn hash(&self, item: &str, seed: usize) -> usize {
150        let mut hasher = Sha256::new();
151        hasher.update(seed.to_be_bytes());
152        hasher.update(item.as_bytes());
153        let digest = hasher.finalize();
154        // Use first 8 bytes as u64
155        let value = u64::from_be_bytes(digest[..8].try_into().unwrap_or([0u8; 8]));
156        (value as usize) % self.m
157    }
158}
159
160// ── RevocationList2020 ───────────────────────────────────────────────────────
161
162/// Bitset-based revocation list (W3C Revocation List 2020).
163///
164/// Unlike `StatusList2021` this structure is not GZIP-compressed in memory;
165/// it exposes raw bit operations.  It can be serialised to a VC-style JSON
166/// credential via `to_credential()`.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct RevocationList2020 {
169    /// Unique identifier for this list (URL)
170    pub id: String,
171    /// Issuer DID
172    pub issuer: String,
173    /// Raw bitset (1 bit per credential)
174    bits: Vec<u8>,
175    /// Total capacity in bit positions
176    capacity: usize,
177}
178
179impl RevocationList2020 {
180    /// Create a new list with `capacity` bit positions.
181    ///
182    /// Minimum capacity is `16_384` to prevent correlation attacks.
183    pub fn new(id: &str, issuer: &str, capacity: usize) -> DidResult<Self> {
184        const MIN_CAPACITY: usize = 16_384;
185        if capacity < MIN_CAPACITY {
186            return Err(DidError::InvalidKey(format!(
187                "RevocationList2020 capacity must be at least {MIN_CAPACITY}, got {capacity}"
188            )));
189        }
190        let byte_count = (capacity + 7) / 8;
191        Ok(Self {
192            id: id.to_string(),
193            issuer: issuer.to_string(),
194            bits: vec![0u8; byte_count],
195            capacity,
196        })
197    }
198
199    /// Check whether the bit at `index` is set (credential revoked)
200    pub fn is_revoked(&self, index: usize) -> DidResult<bool> {
201        self.check_bounds(index)?;
202        let byte = self.bits[index / 8];
203        Ok(byte & (1 << (index % 8)) != 0)
204    }
205
206    /// Set or clear the bit at `index`
207    pub fn set_status(&mut self, index: usize, revoked: bool) -> DidResult<()> {
208        self.check_bounds(index)?;
209        if revoked {
210            self.bits[index / 8] |= 1 << (index % 8);
211        } else {
212            self.bits[index / 8] &= !(1 << (index % 8));
213        }
214        Ok(())
215    }
216
217    /// Total bit capacity
218    pub fn capacity(&self) -> usize {
219        self.capacity
220    }
221
222    /// Count of revoked credentials
223    pub fn revoked_count(&self) -> usize {
224        self.bits.iter().map(|b| b.count_ones() as usize).sum()
225    }
226
227    /// Indices of all revoked credentials
228    pub fn revoked_indices(&self) -> Vec<usize> {
229        let mut result = Vec::new();
230        for (byte_idx, byte) in self.bits.iter().enumerate() {
231            for bit_idx in 0..8 {
232                let global = byte_idx * 8 + bit_idx;
233                if global >= self.capacity {
234                    break;
235                }
236                if byte & (1 << bit_idx) != 0 {
237                    result.push(global);
238                }
239            }
240        }
241        result
242    }
243
244    /// Serialise to a VC-style JSON credential (not signed)
245    pub fn to_credential(&self) -> DidResult<serde_json::Value> {
246        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
247        let encoded = URL_SAFE_NO_PAD.encode(&self.bits);
248        Ok(serde_json::json!({
249            "@context": [
250                "https://www.w3.org/2018/credentials/v1",
251                "https://w3id.org/vc-revocation-list-2020/v1"
252            ],
253            "id": self.id,
254            "type": ["VerifiableCredential", "RevocationList2020Credential"],
255            "issuer": self.issuer,
256            "credentialSubject": {
257                "id": format!("{}#list", self.id),
258                "type": "RevocationList2020",
259                "encodedList": encoded
260            }
261        }))
262    }
263
264    fn check_bounds(&self, index: usize) -> DidResult<()> {
265        if index >= self.capacity {
266            Err(DidError::InvalidKey(format!(
267                "index {index} out of range (capacity {})",
268                self.capacity
269            )))
270        } else {
271            Ok(())
272        }
273    }
274}
275
276// ── RevocationRegistry2020 ───────────────────────────────────────────────────
277
278/// High-level registry combining `RevocationList2020` with a bloom filter
279/// for O(1) non-membership proofs and a credential → index map.
280#[derive(Debug, Clone)]
281pub struct RevocationRegistry2020 {
282    list: RevocationList2020,
283    bloom: BloomFilter,
284    /// credential_id → (bit index, RevocationEntry if revoked)
285    credential_map: HashMap<String, usize>,
286    /// revoked entries, keyed by bit index
287    entries: HashMap<usize, RevocationEntry>,
288    /// Next free index
289    next_index: usize,
290}
291
292impl RevocationRegistry2020 {
293    /// Create a new registry
294    pub fn new(id: &str, issuer: &str, capacity: usize) -> DidResult<Self> {
295        Ok(Self {
296            list: RevocationList2020::new(id, issuer, capacity)?,
297            bloom: BloomFilter::with_defaults(),
298            credential_map: HashMap::new(),
299            entries: HashMap::new(),
300            next_index: 0,
301        })
302    }
303
304    /// Register a credential ID and assign it a bit index.
305    /// Returns the assigned index.
306    pub fn register(&mut self, credential_id: &str) -> DidResult<usize> {
307        if self.credential_map.contains_key(credential_id) {
308            return Err(DidError::InvalidKey(format!(
309                "Credential already registered: {credential_id}"
310            )));
311        }
312        if self.next_index >= self.list.capacity() {
313            return Err(DidError::InvalidKey("Revocation list is full".to_string()));
314        }
315        let index = self.next_index;
316        self.next_index += 1;
317        self.credential_map.insert(credential_id.to_string(), index);
318        Ok(index)
319    }
320
321    /// Check status using bloom filter for fast non-membership, then exact check.
322    pub fn check_status(&self, credential_id: &str) -> RevocationStatus {
323        // Unknown if not registered
324        let Some(&index) = self.credential_map.get(credential_id) else {
325            return RevocationStatus::Unknown;
326        };
327
328        // Bloom filter quick negative check
329        if !self.bloom.might_contain(credential_id) {
330            return RevocationStatus::Valid;
331        }
332
333        // Exact check via bitset
334        match self.list.is_revoked(index) {
335            Ok(true) => {
336                let reason = self
337                    .entries
338                    .get(&index)
339                    .map_or("unspecified", |e| e.reason.as_str())
340                    .to_string();
341                RevocationStatus::Revoked { reason }
342            }
343            _ => RevocationStatus::Valid,
344        }
345    }
346
347    /// Revoke a credential
348    pub fn revoke(&mut self, credential_id: &str, reason: &str) -> DidResult<()> {
349        let index = self.resolve_index(credential_id)?;
350        self.list.set_status(index, true)?;
351        self.bloom.insert(credential_id);
352        let ts = chrono::Utc::now().to_rfc3339();
353        self.entries.insert(
354            index,
355            RevocationEntry::new(index, credential_id, reason, &ts),
356        );
357        Ok(())
358    }
359
360    /// Reinstate a revoked credential
361    pub fn reinstate(&mut self, credential_id: &str) -> DidResult<()> {
362        let index = self.resolve_index(credential_id)?;
363        self.list.set_status(index, false)?;
364        self.entries.remove(&index);
365        // Note: the bloom filter cannot un-set a bit; subsequent checks fall
366        // through to the exact bitset and return Valid correctly.
367        Ok(())
368    }
369
370    /// Count of revoked credentials
371    pub fn revoked_count(&self) -> usize {
372        self.list.revoked_count()
373    }
374
375    /// Number of registered credentials
376    pub fn registered_count(&self) -> usize {
377        self.credential_map.len()
378    }
379
380    /// Access the underlying revocation list (for serialisation/export)
381    pub fn list(&self) -> &RevocationList2020 {
382        &self.list
383    }
384
385    /// Reference to the bloom filter
386    pub fn bloom(&self) -> &BloomFilter {
387        &self.bloom
388    }
389
390    /// Get all revocation entries
391    pub fn entries(&self) -> impl Iterator<Item = &RevocationEntry> {
392        self.entries.values()
393    }
394
395    fn resolve_index(&self, credential_id: &str) -> DidResult<usize> {
396        self.credential_map
397            .get(credential_id)
398            .copied()
399            .ok_or_else(|| {
400                DidError::InvalidKey(format!("Credential not registered: {credential_id}"))
401            })
402    }
403}
404
405// ── tests ─────────────────────────────────────────────────────────────────────
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    const ID: &str = "https://example.com/status/rl2020";
412    const ISSUER: &str = "did:key:z6Mk";
413
414    fn make_registry(cap: usize) -> RevocationRegistry2020 {
415        RevocationRegistry2020::new(ID, ISSUER, cap).unwrap()
416    }
417
418    // ── RevocationStatus ──────────────────────────────────────────────────────
419
420    #[test]
421    fn test_status_valid_is_not_revoked() {
422        assert!(!RevocationStatus::Valid.is_revoked());
423        assert!(RevocationStatus::Valid.is_valid());
424        assert!(!RevocationStatus::Valid.is_unknown());
425    }
426
427    #[test]
428    fn test_status_revoked_is_revoked() {
429        let s = RevocationStatus::Revoked {
430            reason: "test".to_string(),
431        };
432        assert!(s.is_revoked());
433        assert!(!s.is_valid());
434    }
435
436    #[test]
437    fn test_status_unknown() {
438        assert!(RevocationStatus::Unknown.is_unknown());
439    }
440
441    // ── BloomFilter ───────────────────────────────────────────────────────────
442
443    #[test]
444    fn test_bloom_insert_and_query() {
445        let mut bf = BloomFilter::with_defaults();
446        bf.insert("urn:uuid:cred-1");
447        assert!(bf.might_contain("urn:uuid:cred-1"));
448    }
449
450    #[test]
451    fn test_bloom_non_member_definite_negative() {
452        let bf = BloomFilter::with_defaults();
453        // Empty filter must return false for any item
454        assert!(!bf.might_contain("urn:uuid:never-inserted"));
455    }
456
457    #[test]
458    fn test_bloom_count() {
459        let mut bf = BloomFilter::with_defaults();
460        bf.insert("a");
461        bf.insert("b");
462        bf.insert("c");
463        assert_eq!(bf.count(), 3);
464    }
465
466    #[test]
467    fn test_bloom_false_positive_rate_low_for_empty() {
468        let bf = BloomFilter::with_defaults();
469        assert!(bf.false_positive_rate() < 0.01);
470    }
471
472    #[test]
473    fn test_bloom_custom_params() {
474        let mut bf = BloomFilter::new(1024, 2);
475        bf.insert("item");
476        assert!(bf.might_contain("item"));
477        assert!(!bf.might_contain("other-item-definitely-not-here-xyz"));
478    }
479
480    // ── RevocationList2020 ────────────────────────────────────────────────────
481
482    #[test]
483    fn test_list_new_min_capacity_error() {
484        assert!(RevocationList2020::new(ID, ISSUER, 1024).is_err());
485    }
486
487    #[test]
488    fn test_list_set_and_check() {
489        let mut list = RevocationList2020::new(ID, ISSUER, 16_384).unwrap();
490        assert!(!list.is_revoked(42).unwrap());
491        list.set_status(42, true).unwrap();
492        assert!(list.is_revoked(42).unwrap());
493    }
494
495    #[test]
496    fn test_list_set_false_clears_bit() {
497        let mut list = RevocationList2020::new(ID, ISSUER, 16_384).unwrap();
498        list.set_status(10, true).unwrap();
499        list.set_status(10, false).unwrap();
500        assert!(!list.is_revoked(10).unwrap());
501    }
502
503    #[test]
504    fn test_list_out_of_bounds_error() {
505        let list = RevocationList2020::new(ID, ISSUER, 16_384).unwrap();
506        assert!(list.is_revoked(16_384).is_err());
507        assert!(list.is_revoked(99_999).is_err());
508    }
509
510    #[test]
511    fn test_list_revoked_count() {
512        let mut list = RevocationList2020::new(ID, ISSUER, 16_384).unwrap();
513        list.set_status(1, true).unwrap();
514        list.set_status(5, true).unwrap();
515        list.set_status(1000, true).unwrap();
516        assert_eq!(list.revoked_count(), 3);
517    }
518
519    #[test]
520    fn test_list_revoked_indices() {
521        let mut list = RevocationList2020::new(ID, ISSUER, 16_384).unwrap();
522        list.set_status(3, true).unwrap();
523        list.set_status(7, true).unwrap();
524        list.set_status(200, true).unwrap();
525        assert_eq!(list.revoked_indices(), vec![3, 7, 200]);
526    }
527
528    #[test]
529    fn test_list_to_credential_json() {
530        let mut list = RevocationList2020::new(ID, ISSUER, 16_384).unwrap();
531        list.set_status(0, true).unwrap();
532        let cred = list.to_credential().unwrap();
533        assert_eq!(cred["type"][1], "RevocationList2020Credential");
534        assert_eq!(cred["issuer"], ISSUER);
535        assert_eq!(cred["credentialSubject"]["type"], "RevocationList2020");
536        assert!(cred["credentialSubject"]["encodedList"].is_string());
537    }
538
539    #[test]
540    fn test_list_capacity() {
541        let list = RevocationList2020::new(ID, ISSUER, 32_768).unwrap();
542        assert_eq!(list.capacity(), 32_768);
543    }
544
545    // ── RevocationRegistry2020 ────────────────────────────────────────────────
546
547    #[test]
548    fn test_registry_register_and_valid() {
549        let mut reg = make_registry(16_384);
550        reg.register("urn:uuid:a").unwrap();
551        assert_eq!(reg.check_status("urn:uuid:a"), RevocationStatus::Valid);
552    }
553
554    #[test]
555    fn test_registry_unknown_credential() {
556        let reg = make_registry(16_384);
557        assert_eq!(
558            reg.check_status("urn:uuid:never-registered"),
559            RevocationStatus::Unknown
560        );
561    }
562
563    #[test]
564    fn test_registry_revoke_and_check() {
565        let mut reg = make_registry(16_384);
566        reg.register("urn:uuid:cred-1").unwrap();
567        reg.revoke("urn:uuid:cred-1", "keyCompromise").unwrap();
568        match reg.check_status("urn:uuid:cred-1") {
569            RevocationStatus::Revoked { reason } => assert_eq!(reason, "keyCompromise"),
570            other => panic!("Expected Revoked, got {other:?}"),
571        }
572    }
573
574    #[test]
575    fn test_registry_reinstate() {
576        let mut reg = make_registry(16_384);
577        reg.register("urn:uuid:cred-2").unwrap();
578        reg.revoke("urn:uuid:cred-2", "superseded").unwrap();
579        reg.reinstate("urn:uuid:cred-2").unwrap();
580        assert_eq!(reg.check_status("urn:uuid:cred-2"), RevocationStatus::Valid);
581    }
582
583    #[test]
584    fn test_registry_revoked_count() {
585        let mut reg = make_registry(16_384);
586        for i in 0..5 {
587            reg.register(&format!("urn:uuid:{i}")).unwrap();
588        }
589        reg.revoke("urn:uuid:0", "a").unwrap();
590        reg.revoke("urn:uuid:2", "b").unwrap();
591        assert_eq!(reg.revoked_count(), 2);
592    }
593
594    #[test]
595    fn test_registry_registered_count() {
596        let mut reg = make_registry(16_384);
597        reg.register("urn:uuid:x").unwrap();
598        reg.register("urn:uuid:y").unwrap();
599        assert_eq!(reg.registered_count(), 2);
600    }
601
602    #[test]
603    fn test_registry_double_register_error() {
604        let mut reg = make_registry(16_384);
605        reg.register("urn:uuid:dup").unwrap();
606        assert!(reg.register("urn:uuid:dup").is_err());
607    }
608
609    #[test]
610    fn test_registry_revoke_unregistered_error() {
611        let mut reg = make_registry(16_384);
612        assert!(reg.revoke("urn:uuid:ghost", "reason").is_err());
613    }
614
615    #[test]
616    fn test_registry_reinstate_unregistered_error() {
617        let mut reg = make_registry(16_384);
618        assert!(reg.reinstate("urn:uuid:ghost").is_err());
619    }
620
621    #[test]
622    fn test_registry_entries_after_revoke() {
623        let mut reg = make_registry(16_384);
624        reg.register("urn:uuid:e1").unwrap();
625        reg.revoke("urn:uuid:e1", "expired").unwrap();
626        let entries: Vec<_> = reg.entries().collect();
627        assert_eq!(entries.len(), 1);
628        assert_eq!(entries[0].credential_id, "urn:uuid:e1");
629        assert_eq!(entries[0].reason, "expired");
630    }
631
632    #[test]
633    fn test_registry_entries_cleared_after_reinstate() {
634        let mut reg = make_registry(16_384);
635        reg.register("urn:uuid:e2").unwrap();
636        reg.revoke("urn:uuid:e2", "admin").unwrap();
637        reg.reinstate("urn:uuid:e2").unwrap();
638        let entries: Vec<_> = reg.entries().collect();
639        assert_eq!(entries.len(), 0);
640    }
641
642    #[test]
643    fn test_registry_multiple_credentials() {
644        let mut reg = make_registry(16_384);
645        for i in 0..10 {
646            reg.register(&format!("urn:uuid:multi-{i}")).unwrap();
647        }
648        reg.revoke("urn:uuid:multi-3", "r3").unwrap();
649        reg.revoke("urn:uuid:multi-7", "r7").unwrap();
650
651        assert!(reg.check_status("urn:uuid:multi-3").is_revoked());
652        assert!(reg.check_status("urn:uuid:multi-7").is_revoked());
653        assert!(reg.check_status("urn:uuid:multi-5").is_valid());
654        assert_eq!(reg.revoked_count(), 2);
655    }
656
657    // ── RevocationEntry ───────────────────────────────────────────────────────
658
659    #[test]
660    fn test_revocation_entry_fields() {
661        let entry =
662            RevocationEntry::new(42, "urn:uuid:test", "keyCompromise", "2026-01-01T00:00:00Z");
663        assert_eq!(entry.index, 42);
664        assert_eq!(entry.credential_id, "urn:uuid:test");
665        assert_eq!(entry.reason, "keyCompromise");
666        assert_eq!(entry.revoked_at, "2026-01-01T00:00:00Z");
667    }
668}