continuity_receipt/
verification.rs1use 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#[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
72pub 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
134pub 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}