Skip to main content

boundary_compiler/
digest.rs

1//! Domain-separated BLAKE3 digests over RFC 8785 bytes.
2
3use crate::{Canonicalizer, JcsError};
4use blake3::Hash;
5use serde::{Deserialize, Serialize};
6
7/// Default domain separator for canonical JSON content identity.
8pub const CANONICAL_JSON_DOMAIN: &str = "recursiveintell:canonical-json:v1";
9/// Digest algorithm identifier.
10pub const DIGEST_ALGORITHM: &str = "blake3-256";
11/// Canonicalization profile identifier.
12pub const CANONICALIZATION_PROFILE: &str = "rfc8785";
13/// Schema identifier used when no application schema is supplied.
14pub const DEFAULT_SCHEMA_ID: &str = "application/json";
15/// Version of the default JSON schema identity.
16pub const DEFAULT_SCHEMA_VERSION: &str = "rfc8785-ijson";
17
18/// Identity context cryptographically bound into a content digest.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct DigestMetadata {
21    /// Hash algorithm.
22    pub algorithm: String,
23    /// Canonicalization algorithm/profile.
24    pub canonicalization_profile: String,
25    /// Schema identifier for the hashed value.
26    pub schema_id: String,
27    /// Schema version for the hashed value.
28    pub schema_version: String,
29    /// Domain separator preventing cross-protocol reuse.
30    pub domain_separator: String,
31}
32
33impl DigestMetadata {
34    /// Creates RFC 8785 JSON digest metadata for an application schema.
35    pub fn canonical_json(schema_id: impl Into<String>, schema_version: impl Into<String>) -> Self {
36        Self {
37            algorithm: DIGEST_ALGORITHM.to_owned(),
38            canonicalization_profile: CANONICALIZATION_PROFILE.to_owned(),
39            schema_id: schema_id.into(),
40            schema_version: schema_version.into(),
41            domain_separator: CANONICAL_JSON_DOMAIN.to_owned(),
42        }
43    }
44}
45
46impl Default for DigestMetadata {
47    fn default() -> Self {
48        Self::canonical_json(DEFAULT_SCHEMA_ID, DEFAULT_SCHEMA_VERSION)
49    }
50}
51
52/// A domain-separated BLAKE3 digest of RFC 8785 canonical JSON.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ContentDigest {
55    hash: Hash,
56    metadata: DigestMetadata,
57}
58
59impl ContentDigest {
60    /// Computes a digest with the default JSON schema identity.
61    pub fn compute(value: &serde_json::Value) -> Result<Self, JcsError> {
62        Self::compute_with_schema(value, DEFAULT_SCHEMA_ID, DEFAULT_SCHEMA_VERSION)
63    }
64
65    /// Computes a digest bound to an explicit schema ID and version.
66    pub fn compute_with_schema(
67        value: &serde_json::Value,
68        schema_id: impl Into<String>,
69        schema_version: impl Into<String>,
70    ) -> Result<Self, JcsError> {
71        let canonical_bytes = Canonicalizer::new().canonicalize_bytes(value)?;
72        let metadata = DigestMetadata::canonical_json(schema_id, schema_version);
73        let hash = hash_with_metadata(&metadata, &canonical_bytes);
74        Ok(Self { hash, metadata })
75    }
76
77    /// Returns the hex-encoded digest.
78    pub fn hex(&self) -> String {
79        self.hash.to_hex().to_string()
80    }
81
82    /// Returns the raw digest bytes.
83    pub fn as_bytes(&self) -> [u8; 32] {
84        *self.hash.as_bytes()
85    }
86
87    /// Returns the identity context bound into the digest.
88    pub fn metadata(&self) -> &DigestMetadata {
89        &self.metadata
90    }
91}
92
93fn hash_with_metadata(metadata: &DigestMetadata, bytes: &[u8]) -> Hash {
94    let mut hasher = blake3::Hasher::new();
95    for field in [
96        metadata.algorithm.as_bytes(),
97        metadata.canonicalization_profile.as_bytes(),
98        metadata.schema_id.as_bytes(),
99        metadata.schema_version.as_bytes(),
100        metadata.domain_separator.as_bytes(),
101        bytes,
102    ] {
103        hasher.update(&(field.len() as u64).to_be_bytes());
104        hasher.update(field);
105    }
106    hasher.finalize()
107}
108
109impl std::fmt::Display for ContentDigest {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        write!(f, "{}", self.hex())
112    }
113}
114
115impl std::fmt::LowerHex for ContentDigest {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        write!(f, "{}", self.hex())
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use serde_json::json;
125
126    #[test]
127    fn digest_binds_value_and_schema_identity() {
128        let value = json!({"b": 1, "a": 2});
129        let first = ContentDigest::compute_with_schema(&value, "claim", "1").unwrap();
130        let same = ContentDigest::compute_with_schema(&value, "claim", "1").unwrap();
131        let other_value =
132            ContentDigest::compute_with_schema(&json!({"a": 3}), "claim", "1").unwrap();
133        let other_schema = ContentDigest::compute_with_schema(&value, "claim", "2").unwrap();
134        assert_eq!(first, same);
135        assert_ne!(first, other_value);
136        assert_ne!(first, other_schema);
137        assert_eq!(first.metadata().algorithm, DIGEST_ALGORITHM);
138        assert_eq!(
139            first.metadata().canonicalization_profile,
140            CANONICALIZATION_PROFILE
141        );
142        assert_eq!(first.metadata().domain_separator, CANONICAL_JSON_DOMAIN);
143    }
144}