Skip to main content

waitprims_core/
digest.rs

1//! SHA-256 helpers for registration digests.
2//!
3//! Digest input must be RFC 8785 canonical UTF-8 bytes of `registrations`
4//! only, then encoded as lowercase hex. Public entry is raw JSON of that
5//! array; [`sha256_hex`] is a low-level crate-private helper.
6
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9
10use crate::error::{NormativeReason, ValidationError};
11use crate::jcs::{self, JcsError};
12
13/// SHA-256 of `bytes`, encoded as lowercase hex.
14///
15/// Low-level helper. Public digest entry is [`registration_digest`].
16pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
17    let hash = Sha256::digest(bytes);
18    hash.iter().map(|b| format!("{b:02x}")).collect()
19}
20
21/// RFC 8785 SHA-256 of the `registrations` array only.
22///
23/// `registrations_json` is the raw JSON array. Duplicate keys, lone
24/// surrogates, and non-I-JSON numbers fail before hashing.
25pub 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
38/// Recompute the digest of a `registrations` value already parsed by
39/// [`jcs::parse_strict`] and reject a mismatch against the claimed hex.
40pub(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}