Skip to main content

continuity_receipt/
verification.rs

1//! Verification receipts (companion, version 1).
2//!
3//! Port of `continuity_receipt/verification.py`: a receipt is a signed record
4//! of a verification run — the full result (verdict, errors, reasons,
5//! summary) bound to the JCS-canonical bundle digest. Wire format and
6//! semantics: `VERIFICATION_RECEIPTS.md`.
7//!
8//! Malformed-input notes (same policy as `verify.rs`): Python compares the
9//! `version` field with `!= 1`, which accepts `1.0`/`true`; this port accepts
10//! only integer `1` and fails closed with `bad_version` otherwise. Nothing in
11//! the format or vectors depends on the looser comparison.
12
13use serde_json::{Map, Value};
14
15use crate::canon::{canonical_bytes, sha256_prefixed, CanonError};
16use crate::didkey;
17use crate::verify::{parse_timestamp, validate_timestamp, verify_revocation_statements};
18
19pub const KIND: &str = "continuity-receipt-verification";
20pub const VERSION: u64 = 1;
21pub const VERDICTS: [&str; 4] = [
22    "TRUSTED",
23    "PROVISIONAL",
24    "INSUFFICIENT_EVIDENCE",
25    "UNTRUSTED",
26];
27
28/// Mirrors Python's `VerificationReceiptResult` (`as_dict` shape).
29#[derive(Debug, Clone, Default)]
30pub struct VerificationReceiptResult {
31    pub valid: bool,
32    pub errors: Vec<String>,
33    pub issuer: Option<String>,
34    pub verdict: Option<String>,
35    pub verified_at: Option<String>,
36    pub bundle_digest: Option<String>,
37    pub digest_match: Option<bool>,
38}
39
40impl VerificationReceiptResult {
41    pub fn as_dict(&self) -> Value {
42        let mut map = Map::new();
43        map.insert("valid".to_string(), Value::Bool(self.valid));
44        map.insert(
45            "errors".to_string(),
46            Value::Array(self.errors.iter().map(|e| Value::String(e.clone())).collect()),
47        );
48        map.insert(
49            "issuer".to_string(),
50            self.issuer.clone().map(Value::String).unwrap_or(Value::Null),
51        );
52        map.insert(
53            "verdict".to_string(),
54            self.verdict.clone().map(Value::String).unwrap_or(Value::Null),
55        );
56        map.insert(
57            "verified_at".to_string(),
58            self.verified_at.clone().map(Value::String).unwrap_or(Value::Null),
59        );
60        map.insert(
61            "bundle_digest".to_string(),
62            self.bundle_digest.clone().map(Value::String).unwrap_or(Value::Null),
63        );
64        map.insert(
65            "digest_match".to_string(),
66            self.digest_match.map(Value::Bool).unwrap_or(Value::Null),
67        );
68        Value::Object(map)
69    }
70}
71
72/// SHA-256 of the canonical bytes of a verification receipt minus its `sig`
73/// (the digest to anchor — `VERIFICATION_RECEIPTS.md` §Anchoring).
74pub fn receipt_digest(receipt: &Value) -> Result<String, CanonError> {
75    let Some(object) = receipt.as_object() else {
76        return Err(CanonError("receipt is not an object".to_string()));
77    };
78    let mut unsigned = object.clone();
79    unsigned.remove("sig");
80    Ok(sha256_prefixed(&canonical_bytes(&Value::Object(unsigned))?))
81}
82
83fn is_digest(text: &str) -> bool {
84    match text.strip_prefix("sha256:") {
85        Some(hex) => {
86            hex.len() == 64
87                && hex
88                    .bytes()
89                    .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
90        }
91        None => false,
92    }
93}
94
95fn errors_well_formed(value: &Value) -> bool {
96    value
97        .as_array()
98        .map(|items| {
99            items.iter().all(|item| {
100                item.as_object()
101                    .map(|entry| entry.get("code").and_then(Value::as_str).is_some())
102                    .unwrap_or(false)
103            })
104        })
105        .unwrap_or(false)
106}
107
108fn string_list(value: &Value) -> bool {
109    value
110        .as_array()
111        .map(|items| items.iter().all(Value::is_string))
112        .unwrap_or(false)
113}
114
115fn list_non_empty(value: &Value) -> bool {
116    value
117        .as_array()
118        .map(|items| !items.is_empty())
119        .unwrap_or(false)
120}
121
122fn verdict_class(errors: &Value, provisional: &Value, insufficient: &Value) -> &'static str {
123    if list_non_empty(errors) {
124        "UNTRUSTED"
125    } else if list_non_empty(insufficient) {
126        "INSUFFICIENT_EVIDENCE"
127    } else if list_non_empty(provisional) {
128        "PROVISIONAL"
129    } else {
130        "TRUSTED"
131    }
132}
133
134/// Verify a verification receipt (Python `verify_verification_receipt`).
135///
136/// `bundle` (already parsed), when supplied, additionally checks
137/// `bundle_digest` against the bundle's canonical bytes and reports
138/// `digest_match`. `revocations` (statements in the 0.2 §7.5 shape), when
139/// supplied, checks whether the issuer key was revoked at or before
140/// `verified_at` (`key_revoked`).
141pub fn verify_verification_receipt(
142    receipt: &Value,
143    bundle: Option<&Value>,
144    revocations: Option<&[Value]>,
145) -> VerificationReceiptResult {
146    let Some(object) = receipt.as_object() else {
147        return VerificationReceiptResult {
148            valid: false,
149            errors: vec!["not_an_object".to_string()],
150            ..Default::default()
151        };
152    };
153    let mut errors: Vec<String> = Vec::new();
154
155    if object.get("kind").and_then(Value::as_str) != Some(KIND) {
156        errors.push("bad_kind".to_string());
157    }
158    if object.get("version").and_then(Value::as_u64) != Some(VERSION) {
159        errors.push("bad_version".to_string());
160    }
161    let verdict = object.get("verdict").and_then(Value::as_str);
162    if !verdict.map(|value| VERDICTS.contains(&value)).unwrap_or(false) {
163        errors.push("bad_verdict".to_string());
164    }
165    let verified_at = object.get("verified_at").and_then(Value::as_str);
166    if !validate_timestamp(object.get("verified_at")) {
167        errors.push("bad_verified_at".to_string());
168    }
169    if !object
170        .get("bundle_digest")
171        .and_then(Value::as_str)
172        .map(is_digest)
173        .unwrap_or(false)
174    {
175        errors.push("bad_bundle_digest".to_string());
176    }
177
178    let result_errors = object.get("errors");
179    let errors_ok = result_errors.map(errors_well_formed).unwrap_or(false);
180    if !errors_ok {
181        errors.push("bad_errors".to_string());
182    }
183    let provisional = object.get("provisional_reasons");
184    let provisional_ok = provisional.map(string_list).unwrap_or(false);
185    if !provisional_ok {
186        errors.push("bad_provisional_reasons".to_string());
187    }
188    let insufficient = object.get("insufficient_reasons");
189    let insufficient_ok = insufficient.map(string_list).unwrap_or(false);
190    if !insufficient_ok {
191        errors.push("bad_insufficient_reasons".to_string());
192    }
193    if !object.get("summary").map(Value::is_object).unwrap_or(false) {
194        errors.push("bad_summary".to_string());
195    }
196
197    let error_codes = object.get("error_codes");
198    let codes_ok = error_codes.map(string_list).unwrap_or(false);
199    if !codes_ok {
200        errors.push("bad_error_codes".to_string());
201    } else if errors_ok {
202        let expected: Vec<&str> = result_errors
203            .and_then(Value::as_array)
204            .map(|items| {
205                items
206                    .iter()
207                    .map(|entry| entry.get("code").and_then(Value::as_str).unwrap_or_default())
208                    .collect()
209            })
210            .unwrap_or_default();
211        let actual: Vec<&str> = error_codes
212            .and_then(Value::as_array)
213            .map(|items| items.iter().map(Value::as_str).map(|v| v.unwrap_or_default()).collect())
214            .unwrap_or_default();
215        if expected != actual {
216            errors.push("error_codes_mismatch".to_string());
217        }
218    }
219    if verdict.map(|value| VERDICTS.contains(&value)).unwrap_or(false)
220        && errors_ok
221        && provisional_ok
222        && insufficient_ok
223    {
224        let class = verdict_class(
225            result_errors.unwrap(),
226            provisional.unwrap(),
227            insufficient.unwrap(),
228        );
229        if verdict != Some(class) {
230            errors.push("verdict_mismatch".to_string());
231        }
232    }
233
234    let issuer = object.get("issuer").and_then(Value::as_str);
235    let sig = object.get("sig").and_then(Value::as_object);
236    let sig_shape_ok = sig
237        .map(|entry| {
238            entry.get("alg").and_then(Value::as_str) == Some("ed25519")
239                && entry.get("key").and_then(Value::as_str).is_some()
240                && entry
241                    .get("value")
242                    .and_then(Value::as_str)
243                    .map(|value| !value.is_empty())
244                    .unwrap_or(false)
245        })
246        .unwrap_or(false);
247    if !sig_shape_ok {
248        errors.push("bad_signature_shape".to_string());
249    } else {
250        let key_matches = issuer.map(|value| !value.is_empty()).unwrap_or(false)
251            && sig.and_then(|entry| entry.get("key")).and_then(Value::as_str) == issuer;
252        if !key_matches {
253            errors.push("bad_signature".to_string());
254        } else {
255            let mut unsigned = object.clone();
256            unsigned.remove("sig");
257            let verified = match canonical_bytes(&Value::Object(unsigned)) {
258                Ok(message) => didkey::verify(
259                    issuer.unwrap_or_default(),
260                    &message,
261                    sig.and_then(|entry| entry.get("value"))
262                        .and_then(Value::as_str)
263                        .unwrap_or_default(),
264                ),
265                Err(_) => false,
266            };
267            if !verified {
268                errors.push("bad_signature".to_string());
269            }
270        }
271    }
272
273    let mut digest_match: Option<bool> = None;
274    if let Some(bundle_value) = bundle {
275        match canonical_bytes(bundle_value) {
276            Ok(canonical) => {
277                let expected = sha256_prefixed(&canonical);
278                digest_match = Some(
279                    object.get("bundle_digest").and_then(Value::as_str) == Some(expected.as_str()),
280                );
281            }
282            Err(_) => digest_match = Some(false),
283        }
284        if digest_match != Some(true) {
285            errors.push("bundle_digest_mismatch".to_string());
286        }
287    }
288
289    if let Some(statements) = revocations.filter(|items| !items.is_empty()) {
290        let (revoked, statement_errors) = verify_revocation_statements(statements);
291        errors.extend(statement_errors.into_iter().map(|entry| entry.code));
292        if let (Some(at_text), Some(issuer_id)) = (verified_at, issuer) {
293            if let Some(at) = parse_timestamp(at_text) {
294                for (key_id, revoked_at) in &revoked {
295                    if key_id == issuer_id && at >= *revoked_at {
296                        errors.push("key_revoked".to_string());
297                    }
298                }
299            }
300        }
301    }
302
303    VerificationReceiptResult {
304        valid: errors.is_empty(),
305        errors,
306        issuer: issuer.map(str::to_string),
307        verdict: verdict.map(str::to_string),
308        verified_at: verified_at.map(str::to_string),
309        bundle_digest: object
310            .get("bundle_digest")
311            .and_then(Value::as_str)
312            .map(str::to_string),
313        digest_match,
314    }
315}