1use serde_json::Value;
8use sha2::{Digest, Sha256};
9
10use crate::error::{NormativeReason, ValidationError};
11use crate::jcs::{self, JcsError};
12
13pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
17 let hash = Sha256::digest(bytes);
18 hash.iter().map(|b| format!("{b:02x}")).collect()
19}
20
21pub fn registration_digest(registrations_json: &str) -> Result<String, JcsError> {
26 let parsed = jcs::parse_strict(registrations_json)?;
27 digest_unique_registrations(&parsed)
28}
29
30fn digest_unique_registrations(registrations: &Value) -> Result<String, JcsError> {
31 if !registrations.is_array() {
32 return Err(JcsError::Unsupported);
33 }
34 let canonical = jcs::encode_unique(registrations)?;
35 Ok(sha256_hex(&canonical))
36}
37
38pub(crate) fn verify_registration_digest(
41 registrations: &Value,
42 claimed: &str,
43) -> Result<(), ValidationError> {
44 let got = digest_unique_registrations(registrations).map_err(|_| {
45 ValidationError::normative(
46 "/registration_digest",
47 "rfc8785_sha256",
48 NormativeReason::RegistrationDigestMismatch,
49 )
50 })?;
51 if got != claimed {
52 return Err(ValidationError::normative(
53 "/registration_digest/value",
54 "digest_mismatch",
55 NormativeReason::RegistrationDigestMismatch,
56 ));
57 }
58 Ok(())
59}
60
61#[cfg(test)]
62mod tests {
63 use super::*;
64
65 #[test]
66 fn empty_input_matches_known_vector() {
67 assert_eq!(
68 sha256_hex(b""),
69 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
70 );
71 }
72
73 #[test]
74 fn example_registration_digest_matches_pin() {
75 let registrations = r#"[{
76 "registration_id": "reg:job-complete-1",
77 "method_id": "job_complete",
78 "subject_kind": "service_job",
79 "subject_id": "job:transcribe-1",
80 "baseline_policy": "latest",
81 "required": true,
82 "source_instance_ref": "source:provider-a",
83 "predicate_ref": "pred:job-complete",
84 "capability_ref": "cap:wait",
85 "lease_expires_at": "2026-08-16T00:00:00Z",
86 "bounds": {
87 "max_events": 50,
88 "max_bytes": 524288
89 }
90 }]"#;
91 assert_eq!(
92 registration_digest(registrations).unwrap(),
93 "cb5c843991542fca328ea9916d810e601f83429a496bd94986a1e7b5cfbeb7c1"
94 );
95 }
96
97 #[test]
98 fn omitted_priority_digest_differs_from_explicit_50() {
99 let omitted = r#"[{
100 "registration_id": "reg:job-complete-1",
101 "method_id": "job_complete",
102 "subject_kind": "service_job",
103 "subject_id": "job:transcribe-1",
104 "baseline_policy": "latest",
105 "required": true,
106 "source_instance_ref": "source:provider-a",
107 "predicate_ref": "pred:job-complete",
108 "capability_ref": "cap:wait",
109 "lease_expires_at": "2026-08-16T00:00:00Z",
110 "bounds": {
111 "max_events": 50,
112 "max_bytes": 524288
113 }
114 }]"#;
115 let explicit_50 = r#"[{
116 "registration_id": "reg:job-complete-1",
117 "method_id": "job_complete",
118 "subject_kind": "service_job",
119 "subject_id": "job:transcribe-1",
120 "baseline_policy": "latest",
121 "required": true,
122 "source_instance_ref": "source:provider-a",
123 "predicate_ref": "pred:job-complete",
124 "capability_ref": "cap:wait",
125 "lease_expires_at": "2026-08-16T00:00:00Z",
126 "bounds": {
127 "max_events": 50,
128 "max_bytes": 524288
129 },
130 "priority": 50
131 }]"#;
132 let omit = registration_digest(omitted).unwrap();
133 let explicit = registration_digest(explicit_50).unwrap();
134 assert_ne!(omit, explicit);
135 assert_eq!(
136 omit,
137 "cb5c843991542fca328ea9916d810e601f83429a496bd94986a1e7b5cfbeb7c1"
138 );
139 assert_eq!(
140 explicit,
141 "64c5e57bafbfd792d289fa9ccf0bfdca3b643319165ddb23de9602814ab4cdcd"
142 );
143 }
144
145 #[test]
146 fn registration_digest_rejects_duplicate_keys_in_raw_array() {
147 let err = registration_digest(r#"[{"a":1,"a":2}]"#).unwrap_err();
148 assert!(matches!(err, JcsError::DuplicateKey));
149 }
150}