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 registration_digest_rejects_duplicate_keys_in_raw_array() {
99        let err = registration_digest(r#"[{"a":1,"a":2}]"#).unwrap_err();
100        assert!(matches!(err, JcsError::DuplicateKey));
101    }
102}