wsc 0.8.3

WebAssembly Signature Component - WASM signing and verification toolkit
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
//! Rekor inclusion proof cache for availability resilience.
//!
//! Caches verified Rekor inclusion proofs so that verification can
//! succeed during transient Rekor outages. The cache is an optimization
//! only — it never weakens security guarantees (DD-2).
//!
//! # Security Properties
//!
//! - Cache entries are keyed by module hash + Rekor UUID
//! - Only successfully verified proofs are cached
//! - TTL-based expiry prevents stale proofs
//! - Cache poisoning is prevented because we store the full
//!   `RekorEntry` and re-validate structure on cache hit
//! - Fail-closed: if both cache miss and Rekor unavailable,
//!   verification fails
//!
//! # Example
//!
//! ```ignore
//! use wsc::keyless::proof_cache::{ProofCache, MemoryProofCache};
//! use std::time::Duration;
//!
//! let cache = MemoryProofCache::new(Duration::from_secs(86400)); // 24h TTL
//! ```

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

use super::rekor::RekorEntry;

/// Key for cached proof entries.
#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheKey {
    /// SHA-256 hex digest of the signed artifact
    pub artifact_hash: String,
    /// Rekor entry UUID
    pub rekor_uuid: String,
}

impl CacheKey {
    /// Create a cache key from raw artifact bytes and Rekor UUID.
    pub fn new(artifact_bytes: &[u8], rekor_uuid: &str) -> Self {
        let hash = Sha256::digest(artifact_bytes);
        Self {
            artifact_hash: hex::encode(hash),
            rekor_uuid: rekor_uuid.to_string(),
        }
    }

    /// Create a cache key from a pre-computed hash and Rekor UUID.
    pub fn from_hash(artifact_hash: &str, rekor_uuid: &str) -> Self {
        Self {
            artifact_hash: artifact_hash.to_string(),
            rekor_uuid: rekor_uuid.to_string(),
        }
    }
}

/// A cached Rekor proof entry with expiry metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedProof {
    /// The verified Rekor entry
    pub entry: RekorEntry,
    /// When this entry was cached (seconds since UNIX epoch)
    pub cached_at_epoch: u64,
    /// TTL in seconds
    pub ttl_secs: u64,
}

impl CachedProof {
    /// Whether this cached entry has expired based on current time.
    pub fn is_expired_at(&self, now_epoch: u64) -> bool {
        now_epoch > self.cached_at_epoch + self.ttl_secs
    }
}

/// Trait for pluggable proof cache backends.
pub trait ProofCacheBackend: Send + Sync {
    /// Look up a cached proof by key. Returns None if not found or expired.
    fn get(&self, key: &CacheKey) -> Option<CachedProof>;

    /// Store a verified proof in the cache.
    fn insert(&self, key: CacheKey, proof: CachedProof);

    /// Remove a specific entry.
    fn invalidate(&self, key: &CacheKey);

    /// Remove all expired entries.
    fn evict_expired(&self);

    /// Number of entries currently in cache.
    fn len(&self) -> usize;

    /// Whether the cache is empty.
    fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

/// Default maximum cache entries (SC-25 / SC-34).
const DEFAULT_MAX_CACHE_ENTRIES: usize = 10_000;

/// In-memory proof cache with TTL-based expiry and bounded size.
///
/// Thread-safe via `Mutex`. Suitable for CLI tools and short-lived
/// processes. For long-running services, consider a file-based or
/// distributed cache backend.
///
/// # Security (SC-25 / SC-34)
///
/// The cache enforces a maximum entry count to prevent unbounded memory
/// growth from cache flooding attacks (H-36 / AS-34). When the limit is
/// reached, expired entries are evicted first; if still over limit, the
/// oldest entries are removed.
pub struct MemoryProofCache {
    entries: Mutex<HashMap<CacheKey, CacheEntry>>,
    ttl: Duration,
    max_entries: usize,
}

/// Internal cache entry with instant-based expiry for in-memory use.
struct CacheEntry {
    proof: CachedProof,
    inserted_at: Instant,
    expires_at: Instant,
}

impl MemoryProofCache {
    /// Create a new in-memory cache with the given TTL and default size limit.
    pub fn new(ttl: Duration) -> Self {
        Self {
            entries: Mutex::new(HashMap::new()),
            ttl,
            max_entries: DEFAULT_MAX_CACHE_ENTRIES,
        }
    }

    /// Create a cache with custom TTL and maximum entry count.
    pub fn with_max_entries(ttl: Duration, max_entries: usize) -> Self {
        Self {
            entries: Mutex::new(HashMap::new()),
            ttl,
            max_entries,
        }
    }

    /// Create a cache with a 24-hour TTL (recommended default).
    pub fn default_ttl() -> Self {
        Self::new(Duration::from_secs(24 * 60 * 60))
    }

    /// Get the configured TTL.
    pub fn ttl(&self) -> Duration {
        self.ttl
    }

    /// Get the maximum entry count.
    pub fn max_entries(&self) -> usize {
        self.max_entries
    }
}

impl ProofCacheBackend for MemoryProofCache {
    fn get(&self, key: &CacheKey) -> Option<CachedProof> {
        let entries = self.entries.lock().ok()?;
        let entry = entries.get(key)?;
        if Instant::now() >= entry.expires_at {
            return None; // Expired
        }
        Some(entry.proof.clone())
    }

    fn insert(&self, key: CacheKey, proof: CachedProof) {
        if let Ok(mut entries) = self.entries.lock() {
            let now = Instant::now();

            // SC-34: Enforce maximum entry count to prevent cache flooding (H-36)
            if entries.len() >= self.max_entries {
                // First try evicting expired entries
                entries.retain(|_, entry| now < entry.expires_at);

                // If still over limit, evict oldest entries
                if entries.len() >= self.max_entries {
                    let evict_count = entries.len() - self.max_entries + 1;
                    let mut by_age: Vec<(CacheKey, Instant)> = entries
                        .iter()
                        .map(|(k, v)| (k.clone(), v.inserted_at))
                        .collect();
                    by_age.sort_by_key(|(_, inserted)| *inserted);
                    for (old_key, _) in by_age.into_iter().take(evict_count) {
                        entries.remove(&old_key);
                    }
                    log::debug!(
                        "Cache at capacity ({}): evicted {} oldest entries",
                        self.max_entries,
                        evict_count
                    );
                }
            }

            entries.insert(
                key,
                CacheEntry {
                    proof,
                    inserted_at: now,
                    expires_at: now + self.ttl,
                },
            );
        }
    }

    fn invalidate(&self, key: &CacheKey) {
        if let Ok(mut entries) = self.entries.lock() {
            entries.remove(key);
        }
    }

    fn evict_expired(&self) {
        if let Ok(mut entries) = self.entries.lock() {
            let now = Instant::now();
            entries.retain(|_, entry| now < entry.expires_at);
        }
    }

    fn len(&self) -> usize {
        self.entries.lock().map(|e| e.len()).unwrap_or(0)
    }
}

/// Convenience: create a cached proof from a verified RekorEntry.
pub fn cache_verified_proof(entry: &RekorEntry, ttl: Duration) -> CachedProof {
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();

    CachedProof {
        entry: entry.clone(),
        cached_at_epoch: now,
        ttl_secs: ttl.as_secs(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample_rekor_entry() -> RekorEntry {
        RekorEntry {
            uuid: "test-uuid-1234".to_string(),
            log_index: 42,
            body: "base64body==".to_string(),
            log_id: "log-id-abc".to_string(),
            inclusion_proof: vec![1, 2, 3],
            signed_entry_timestamp: "base64set==".to_string(),
            integrated_time: "2026-03-18T00:00:00Z".to_string(),
        }
    }

    #[test]
    fn test_cache_key_from_bytes() {
        let key = CacheKey::new(b"hello world", "uuid-1234");
        assert!(!key.artifact_hash.is_empty());
        assert_eq!(key.rekor_uuid, "uuid-1234");
        // SHA-256 of "hello world"
        assert_eq!(
            key.artifact_hash,
            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
        );
    }

    #[test]
    fn test_cache_key_from_hash() {
        let key = CacheKey::from_hash("abc123", "uuid-5678");
        assert_eq!(key.artifact_hash, "abc123");
        assert_eq!(key.rekor_uuid, "uuid-5678");
    }

    #[test]
    fn test_cache_key_equality() {
        let k1 = CacheKey::from_hash("abc", "uuid");
        let k2 = CacheKey::from_hash("abc", "uuid");
        let k3 = CacheKey::from_hash("def", "uuid");
        assert_eq!(k1, k2);
        assert_ne!(k1, k3);
    }

    #[test]
    fn test_cached_proof_expiry() {
        let proof = CachedProof {
            entry: sample_rekor_entry(),
            cached_at_epoch: 1000,
            ttl_secs: 100,
        };

        assert!(!proof.is_expired_at(1050)); // 50s in, not expired
        assert!(!proof.is_expired_at(1100)); // exactly at TTL
        assert!(proof.is_expired_at(1101)); // 1s past TTL
    }

    #[test]
    fn test_memory_cache_insert_get() {
        let cache = MemoryProofCache::new(Duration::from_secs(3600));
        let key = CacheKey::from_hash("abc", "uuid-1");
        let proof = cache_verified_proof(&sample_rekor_entry(), Duration::from_secs(3600));

        assert!(cache.is_empty());
        cache.insert(key.clone(), proof);
        assert_eq!(cache.len(), 1);

        let retrieved = cache.get(&key);
        assert!(retrieved.is_some());
        let retrieved = retrieved.unwrap();
        assert_eq!(retrieved.entry.uuid, "test-uuid-1234");
        assert_eq!(retrieved.entry.log_index, 42);
    }

    #[test]
    fn test_memory_cache_miss() {
        let cache = MemoryProofCache::new(Duration::from_secs(3600));
        let key = CacheKey::from_hash("nonexistent", "uuid");
        assert!(cache.get(&key).is_none());
    }

    #[test]
    fn test_memory_cache_invalidate() {
        let cache = MemoryProofCache::new(Duration::from_secs(3600));
        let key = CacheKey::from_hash("abc", "uuid-1");
        let proof = cache_verified_proof(&sample_rekor_entry(), Duration::from_secs(3600));

        cache.insert(key.clone(), proof);
        assert_eq!(cache.len(), 1);

        cache.invalidate(&key);
        assert_eq!(cache.len(), 0);
        assert!(cache.get(&key).is_none());
    }

    #[test]
    fn test_memory_cache_expiry() {
        // Use a very short TTL
        let cache = MemoryProofCache::new(Duration::from_millis(1));
        let key = CacheKey::from_hash("abc", "uuid-1");
        let proof = cache_verified_proof(&sample_rekor_entry(), Duration::from_millis(1));

        cache.insert(key.clone(), proof);

        // Wait for expiry
        std::thread::sleep(Duration::from_millis(10));

        // Should return None because expired
        assert!(cache.get(&key).is_none());
    }

    #[test]
    fn test_memory_cache_evict_expired() {
        let cache = MemoryProofCache::new(Duration::from_millis(1));
        let key = CacheKey::from_hash("abc", "uuid-1");
        let proof = cache_verified_proof(&sample_rekor_entry(), Duration::from_millis(1));

        cache.insert(key, proof);
        std::thread::sleep(Duration::from_millis(10));

        // len() still counts expired entries
        assert_eq!(cache.len(), 1);

        // evict_expired removes them
        cache.evict_expired();
        assert_eq!(cache.len(), 0);
    }

    #[test]
    fn test_memory_cache_multiple_entries() {
        let cache = MemoryProofCache::new(Duration::from_secs(3600));

        for i in 0..10 {
            let key = CacheKey::from_hash(&format!("hash-{}", i), &format!("uuid-{}", i));
            let proof = cache_verified_proof(&sample_rekor_entry(), Duration::from_secs(3600));
            cache.insert(key, proof);
        }

        assert_eq!(cache.len(), 10);

        // Verify specific entry
        let key5 = CacheKey::from_hash("hash-5", "uuid-5");
        assert!(cache.get(&key5).is_some());
    }

    #[test]
    fn test_default_ttl() {
        let cache = MemoryProofCache::default_ttl();
        assert_eq!(cache.ttl(), Duration::from_secs(86400));
    }

    #[test]
    fn test_cache_key_serialization() {
        let key = CacheKey::from_hash("abc123", "uuid-456");
        let json = serde_json::to_string(&key).unwrap();
        let parsed: CacheKey = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed, key);
    }

    #[test]
    fn test_cached_proof_serialization() {
        let proof = CachedProof {
            entry: sample_rekor_entry(),
            cached_at_epoch: 1710720000,
            ttl_secs: 86400,
        };

        let json = serde_json::to_string_pretty(&proof).unwrap();
        let parsed: CachedProof = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.entry.uuid, "test-uuid-1234");
        assert_eq!(parsed.ttl_secs, 86400);
    }
}