Skip to main content

idprova_verify/
lib.rs

1//! # idprova-verify
2//!
3//! High-level verification utilities for the IDProva protocol.
4//!
5//! Provides three focused functions:
6//!
7//! - [`verify_dat`] — Full pipeline: signature + timing + scope + constraints
8//! - [`verify_dat_from_jws`] — Signature + timing only (no scope/constraint check)
9//! - [`verify_receipt_log`] — Hash-chain integrity check for a receipt log
10//!
11//! ## Example
12//!
13//! ```rust,no_run
14//! use idprova_verify::{verify_dat, verify_dat_from_jws};
15//! use idprova_core::dat::constraints::EvaluationContext;
16//!
17//! let compact_jws = "header.payload.signature"; // compact JWS from token issuer
18//! let pub_key = [0u8; 32]; // issuer's Ed25519 public key bytes
19//!
20//! // Full verification (signature + timing + scope + constraints)
21//! let result = verify_dat(compact_jws, &pub_key, "mcp:tool:filesystem:read", &EvaluationContext::default());
22//!
23//! // Signature + timing only (no scope/constraint check)
24//! let dat = verify_dat_from_jws(compact_jws, &pub_key);
25//! ```
26
27use idprova_core::{
28    dat::{constraints::EvaluationContext, Dat},
29    receipt::Receipt,
30    Result,
31};
32
33// ── Public API ────────────────────────────────────────────────────────────────
34
35/// Verify a compact JWS DAT token through the full pipeline.
36///
37/// Runs in order:
38/// 1. Decode and parse the compact JWS
39/// 2. Hard-reject non-EdDSA algorithms (SEC-3)
40/// 3. Verify Ed25519 signature against `pub_key`
41/// 4. Validate timing (`exp` / `nbf`)
42/// 5. Check `required_scope` is granted (pass `""` to skip)
43/// 6. Evaluate all constraint policies (rate limit, IP, trust level, delegation
44///    depth, geofence, time windows, config attestation)
45///
46/// Returns the decoded [`Dat`] on success so callers can inspect claims.
47///
48/// # Errors
49///
50/// Returns [`IdprovaError`](idprova_core::IdprovaError) on any failure.
51pub fn verify_dat(
52    compact_jws: &str,
53    pub_key: &[u8; 32],
54    required_scope: &str,
55    ctx: &EvaluationContext,
56) -> Result<Dat> {
57    let dat = Dat::from_compact(compact_jws)?;
58    dat.verify(pub_key, required_scope, ctx)?;
59    Ok(dat)
60}
61
62/// Verify a compact JWS DAT token — signature and timing only.
63///
64/// Skips scope and constraint checks. Useful for:
65/// - Token introspection / admin inspection
66/// - Extracting claims before applying custom policy logic
67/// - Testing / debugging
68///
69/// Runs:
70/// 1. Decode and parse the compact JWS
71/// 2. Hard-reject non-EdDSA algorithms
72/// 3. Verify Ed25519 signature
73/// 4. Validate timing (`exp` / `nbf`)
74///
75/// Returns the decoded [`Dat`] on success.
76pub fn verify_dat_from_jws(compact_jws: &str, pub_key: &[u8; 32]) -> Result<Dat> {
77    let dat = Dat::from_compact(compact_jws)?;
78    dat.verify_signature(pub_key)?;
79    dat.validate_timing()?;
80    Ok(dat)
81}
82
83/// Verify the hash-chain integrity of a receipt log.
84///
85/// Checks that:
86/// - Sequence numbers are contiguous starting from 0
87/// - Each receipt's `previous_hash` matches the hash of the preceding receipt
88/// - The first receipt's `previous_hash` is `"genesis"`
89///
90/// Does **not** verify individual receipt signatures — this is a structural
91/// integrity check only.
92///
93/// # Errors
94///
95/// Returns [`IdprovaError::ReceiptChainBroken`](idprova_core::IdprovaError::ReceiptChainBroken)
96/// with the index of the first broken link.
97pub fn verify_receipt_log(receipts: &[Receipt]) -> Result<()> {
98    use idprova_core::receipt::ReceiptLog;
99    let log = ReceiptLog::from_entries(receipts.to_vec());
100    log.verify_integrity()
101}
102
103// ── Tests ─────────────────────────────────────────────────────────────────────
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use chrono::{Duration, Utc};
109    use idprova_core::receipt::entry::ChainLink;
110    use idprova_core::{
111        crypto::KeyPair,
112        dat::{constraints::DatConstraints, Dat},
113        receipt::{ActionDetails, Receipt, ReceiptLog},
114    };
115
116    // ── Helpers ───────────────────────────────────────────────────────────────
117
118    fn make_dat(kp: &KeyPair, scope: &str, valid: bool) -> Dat {
119        let expires = if valid {
120            Utc::now() + Duration::hours(24)
121        } else {
122            Utc::now() - Duration::hours(1)
123        };
124        Dat::issue(
125            "did:idprova:test:issuer",
126            "did:idprova:test:agent",
127            vec![scope.to_string()],
128            expires,
129            None,
130            None,
131            kp,
132        )
133        .unwrap()
134    }
135
136    fn make_receipt(log: &ReceiptLog) -> Receipt {
137        Receipt {
138            id: ulid::Ulid::new().to_string(),
139            timestamp: Utc::now(),
140            agent: "did:idprova:test:agent".to_string(),
141            dat: "dat_test".to_string(),
142            action: ActionDetails {
143                action_type: "mcp:tool-call".to_string(),
144                server: None,
145                tool: Some("test_tool".to_string()),
146                input_hash: "blake3:abc123".to_string(),
147                output_hash: Some("blake3:def456".to_string()),
148                status: "success".to_string(),
149                duration_ms: None,
150            },
151            context: None,
152            chain: ChainLink {
153                previous_hash: log.last_hash(),
154                sequence_number: log.next_sequence(),
155            },
156            signature: "placeholder".to_string(),
157        }
158    }
159
160    // ── verify_dat ────────────────────────────────────────────────────────────
161
162    #[test]
163    fn test_verify_dat_happy_path() {
164        let kp = KeyPair::generate();
165        let dat = make_dat(&kp, "mcp:tool:filesystem:read", true);
166        let compact = dat.to_compact().unwrap();
167        let ctx = EvaluationContext::default();
168
169        let result = verify_dat(
170            &compact,
171            &kp.public_key_bytes(),
172            "mcp:tool:filesystem:read",
173            &ctx,
174        );
175        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
176        let verified = result.unwrap();
177        assert_eq!(verified.claims.iss, "did:idprova:test:issuer");
178        assert_eq!(verified.claims.sub, "did:idprova:test:agent");
179    }
180
181    #[test]
182    fn test_verify_dat_wrong_key_fails() {
183        let kp = KeyPair::generate();
184        let kp2 = KeyPair::generate();
185        let dat = make_dat(&kp, "mcp:tool:filesystem:read", true);
186        let compact = dat.to_compact().unwrap();
187        let ctx = EvaluationContext::default();
188
189        let result = verify_dat(
190            &compact,
191            &kp2.public_key_bytes(),
192            "mcp:tool:filesystem:read",
193            &ctx,
194        );
195        assert!(result.is_err(), "wrong key must fail");
196    }
197
198    #[test]
199    fn test_verify_dat_expired_fails() {
200        let kp = KeyPair::generate();
201        let dat = make_dat(&kp, "mcp:tool:filesystem:read", false); // expired
202        let compact = dat.to_compact().unwrap();
203        let ctx = EvaluationContext::default();
204
205        let result = verify_dat(
206            &compact,
207            &kp.public_key_bytes(),
208            "mcp:tool:filesystem:read",
209            &ctx,
210        );
211        assert!(result.is_err());
212        assert!(result.unwrap_err().to_string().contains("expired"));
213    }
214
215    #[test]
216    fn test_verify_dat_scope_denied_fails() {
217        let kp = KeyPair::generate();
218        let dat = make_dat(&kp, "mcp:tool:filesystem:read", true);
219        let compact = dat.to_compact().unwrap();
220        let ctx = EvaluationContext::default();
221
222        let result = verify_dat(
223            &compact,
224            &kp.public_key_bytes(),
225            "mcp:tool:filesystem:write",
226            &ctx,
227        );
228        assert!(result.is_err());
229        assert!(result.unwrap_err().to_string().contains("scope"));
230    }
231
232    #[test]
233    fn test_verify_dat_wildcard_scope_passes() {
234        let kp = KeyPair::generate();
235        let dat = make_dat(&kp, "mcp:*:*:*", true);
236        let compact = dat.to_compact().unwrap();
237        let ctx = EvaluationContext::default();
238
239        assert!(verify_dat(
240            &compact,
241            &kp.public_key_bytes(),
242            "mcp:tool:filesystem:write",
243            &ctx
244        )
245        .is_ok());
246        assert!(verify_dat(
247            &compact,
248            &kp.public_key_bytes(),
249            "mcp:resource:data:read",
250            &ctx
251        )
252        .is_ok());
253    }
254
255    #[test]
256    fn test_verify_dat_empty_scope_skips_check() {
257        let kp = KeyPair::generate();
258        let dat = make_dat(&kp, "mcp:tool:filesystem:read", true);
259        let compact = dat.to_compact().unwrap();
260        let ctx = EvaluationContext::default();
261
262        // empty scope = skip scope check
263        assert!(verify_dat(&compact, &kp.public_key_bytes(), "", &ctx).is_ok());
264    }
265
266    #[test]
267    fn test_verify_dat_constraint_rate_limit_blocks() {
268        let kp = KeyPair::generate();
269        let dat = Dat::issue(
270            "did:idprova:test:issuer",
271            "did:idprova:test:agent",
272            vec!["mcp:tool:filesystem:read".to_string()],
273            Utc::now() + Duration::hours(24),
274            Some(DatConstraints {
275                rate_limit: Some(idprova_core::dat::constraints::RateLimit {
276                    max_actions: 5,
277                    window_secs: 60,
278                }),
279                ..Default::default()
280            }),
281            None,
282            &kp,
283        )
284        .unwrap();
285        let compact = dat.to_compact().unwrap();
286        let mut ctx = EvaluationContext::default();
287        ctx.actions_in_window = 10; // exceeds limit
288
289        let result = verify_dat(
290            &compact,
291            &kp.public_key_bytes(),
292            "mcp:tool:filesystem:read",
293            &ctx,
294        );
295        assert!(result.is_err());
296        assert!(result.unwrap_err().to_string().contains("rate limit"));
297    }
298
299    #[test]
300    fn test_verify_dat_malformed_token_fails() {
301        let kp = KeyPair::generate();
302        let ctx = EvaluationContext::default();
303
304        assert!(verify_dat("not.a.token", &kp.public_key_bytes(), "", &ctx).is_err());
305        assert!(verify_dat("", &kp.public_key_bytes(), "", &ctx).is_err());
306        assert!(verify_dat("only.two", &kp.public_key_bytes(), "", &ctx).is_err());
307    }
308
309    // ── verify_dat_from_jws ───────────────────────────────────────────────────
310
311    #[test]
312    fn test_verify_dat_from_jws_happy_path() {
313        let kp = KeyPair::generate();
314        let dat = make_dat(&kp, "mcp:tool:filesystem:read", true);
315        let compact = dat.to_compact().unwrap();
316
317        let result = verify_dat_from_jws(&compact, &kp.public_key_bytes());
318        assert!(result.is_ok());
319        let verified = result.unwrap();
320        assert_eq!(verified.claims.iss, "did:idprova:test:issuer");
321    }
322
323    #[test]
324    fn test_verify_dat_from_jws_skips_scope_check() {
325        // Token grants mcp:tool:read only — verify_dat would reject mcp:tool:write,
326        // but verify_dat_from_jws should succeed because scope is not checked.
327        let kp = KeyPair::generate();
328        let dat = make_dat(&kp, "mcp:tool:filesystem:read", true);
329        let compact = dat.to_compact().unwrap();
330
331        // No scope param — should succeed even though scope is restricted
332        assert!(verify_dat_from_jws(&compact, &kp.public_key_bytes()).is_ok());
333    }
334
335    #[test]
336    fn test_verify_dat_from_jws_wrong_key_fails() {
337        let kp = KeyPair::generate();
338        let kp2 = KeyPair::generate();
339        let dat = make_dat(&kp, "mcp:tool:filesystem:read", true);
340        let compact = dat.to_compact().unwrap();
341
342        assert!(verify_dat_from_jws(&compact, &kp2.public_key_bytes()).is_err());
343    }
344
345    #[test]
346    fn test_verify_dat_from_jws_expired_fails() {
347        let kp = KeyPair::generate();
348        let dat = make_dat(&kp, "mcp:tool:filesystem:read", false); // expired
349        let compact = dat.to_compact().unwrap();
350
351        assert!(verify_dat_from_jws(&compact, &kp.public_key_bytes()).is_err());
352    }
353
354    // ── verify_receipt_log ────────────────────────────────────────────────────
355
356    #[test]
357    fn test_verify_receipt_log_empty_passes() {
358        assert!(verify_receipt_log(&[]).is_ok());
359    }
360
361    #[test]
362    fn test_verify_receipt_log_single_receipt_passes() {
363        let mut log = ReceiptLog::new();
364        let r = make_receipt(&log);
365        log.append(r.clone());
366
367        assert!(verify_receipt_log(log.entries()).is_ok());
368    }
369
370    #[test]
371    fn test_verify_receipt_log_chain_passes() {
372        let mut log = ReceiptLog::new();
373        for _ in 0..5 {
374            let r = make_receipt(&log);
375            log.append(r);
376        }
377        assert_eq!(log.len(), 5);
378        assert!(verify_receipt_log(log.entries()).is_ok());
379    }
380
381    #[test]
382    fn test_verify_receipt_log_broken_chain_fails() {
383        let mut log = ReceiptLog::new();
384        let r0 = make_receipt(&log);
385        log.append(r0);
386        let r1 = make_receipt(&log);
387        log.append(r1);
388
389        // Build a tampered entry with wrong previous_hash
390        let tampered = Receipt {
391            id: ulid::Ulid::new().to_string(),
392            timestamp: Utc::now(),
393            agent: "did:idprova:test:agent".to_string(),
394            dat: "dat_test".to_string(),
395            action: ActionDetails {
396                action_type: "mcp:tool-call".to_string(),
397                server: None,
398                tool: None,
399                input_hash: "blake3:bad".to_string(),
400                output_hash: None,
401                status: "success".to_string(),
402                duration_ms: None,
403            },
404            context: None,
405            chain: ChainLink {
406                previous_hash: "wrong_hash_here".to_string(), // broken link
407                sequence_number: 2,
408            },
409            signature: "placeholder".to_string(),
410        };
411
412        let mut entries = log.entries().to_vec();
413        entries.push(tampered);
414
415        let result = verify_receipt_log(&entries);
416        assert!(result.is_err(), "broken chain must be detected");
417    }
418}