Skip to main content

peerbadge_protocol/
canonical.rs

1//! RFC 8785 / JSON Canonicalization Scheme helpers.
2//!
3//! These helpers canonicalize arbitrary JSON values before they are signed,
4//! verified, or hashed. The output is UTF-8 JSON with deterministic object key
5//! ordering, no insignificant whitespace, and RFC 8785-compatible number and
6//! string serialization.
7
8use serde_json::{json, Value};
9
10use crate::{
11    authorization::HolderAuthorizationStatement, Credential, Issuer, IssuerId, ProtocolV1,
12    Revocation,
13};
14
15/// Canonicalize a JSON value using RFC 8785 / JCS and return UTF-8 bytes.
16///
17/// This is a small protocol-facing wrapper around
18/// [`serde_json_canonicalizer::to_vec`], which implements RFC 8785 for
19/// `serde`/`serde_json`.
20///
21/// # Errors
22///
23/// Returns a [`serde_json::Error`] if serialization fails.
24fn canonicalize_json_value(value: &Value) -> serde_json::Result<Vec<u8>> {
25    serde_json_canonicalizer::to_vec(value)
26}
27
28/// Canonicalized payload type string for issuer-visible public credential information.
29pub const PBRSA_PUBLIC_INFO_CANONICAL_TYPE: &str = "fedibtc.credentials.public-info";
30
31/// Canonicalized payload type string for holder-hidden blind-message information.
32pub const PBRSA_BLIND_MSG_CANONICAL_TYPE: &str = "fedibtc.credentials.blind-msg";
33
34/// Canonicalized payload type string for issuer authority signatures.
35pub const ISSUER_AUTHORITY_CANONICAL_TYPE: &str = "fedibtc.credentials.issuer-authority";
36
37/// Canonicalized payload type string for signed revocations.
38pub const REVOCATION_CANONICAL_TYPE: &str = "fedibtc.credentials.revocation";
39
40/// Canonicalized payload type string for holder authorization signatures.
41pub const HOLDER_AUTHORIZATION_CANONICAL_TYPE: &str = "fedibtc.credentials.holder-authorization";
42
43/// Build JCS canonical bytes for the PBRSA public credential info.
44///
45/// The canonicalized value includes a type string, protocol version, issuer
46/// identifier, and issuer-visible credential `info` JSON.
47pub fn canonicalize_pbrsa_info(
48    version: ProtocolV1,
49    issuer_id: &IssuerId,
50    info: &Value,
51) -> serde_json::Result<Vec<u8>> {
52    let payload = json!({
53        "type": PBRSA_PUBLIC_INFO_CANONICAL_TYPE,
54        "version": version,
55        "issuer_id_pubkey": issuer_id,
56        "info": info,
57    });
58
59    canonicalize_json_value(&payload)
60}
61
62/// Build JCS canonical bytes for the PBRSA blind message.
63///
64/// The canonicalized value includes a type string, protocol version, and
65/// holder-hidden credential `blind_msg` JSON.
66pub fn canonicalize_pbrsa_blind_msg(
67    version: ProtocolV1,
68    blind_msg: &Value,
69) -> serde_json::Result<Vec<u8>> {
70    let payload = json!({
71        "type": PBRSA_BLIND_MSG_CANONICAL_TYPE,
72        "version": version,
73        "blind_msg": blind_msg,
74    });
75
76    canonicalize_json_value(&payload)
77}
78
79/// Build JCS canonical bytes for the issuer metadata signed in an issuer authority.
80pub fn canonicalize_issuer_authority(issuer: &Issuer) -> serde_json::Result<Vec<u8>> {
81    let payload = json!({
82        "type": ISSUER_AUTHORITY_CANONICAL_TYPE,
83        "issuer": issuer,
84    });
85
86    canonicalize_json_value(&payload)
87}
88
89/// Build JCS canonical bytes for a signed revocation payload.
90pub fn canonicalize_revocation(revocation: &Revocation) -> serde_json::Result<Vec<u8>> {
91    let payload = json!({
92        "type": REVOCATION_CANONICAL_TYPE,
93        "revocation": revocation,
94    });
95
96    canonicalize_json_value(&payload)
97}
98
99/// Build JCS canonical bytes for a holder authorization statement.
100pub fn canonicalize_holder_authorization(
101    authorization: &HolderAuthorizationStatement,
102) -> serde_json::Result<Vec<u8>> {
103    let payload = json!({
104        "type": HOLDER_AUTHORIZATION_CANONICAL_TYPE,
105        "version": ProtocolV1,
106        "authorization": authorization,
107    });
108
109    canonicalize_json_value(&payload)
110}
111
112/// Build JCS canonical bytes for a credential payload.
113pub fn canonicalize_credential(credential: &Credential) -> serde_json::Result<Vec<u8>> {
114    let value = serde_json::to_value(credential)?;
115    canonicalize_json_value(&value)
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use serde_json::json;
122
123    use crate::{
124        CredentialDigest, HolderAuthorizationStatement, HolderId, IssuerId, ProtocolV1,
125        SubjectPubkey, Timestamp,
126    };
127
128    #[test]
129    fn canonicalizes_object_keys_recursively() {
130        let value = json!({
131            "z": 1,
132            "a": {
133                "b": true,
134                "a": false,
135            },
136            "m": [ { "y": null, "x": "value" } ],
137        });
138
139        assert_eq!(
140            canonicalize_json_value(&value).unwrap(),
141            br#"{"a":{"a":false,"b":true},"m":[{"x":"value","y":null}],"z":1}"#
142        );
143    }
144
145    #[test]
146    fn canonicalizes_numbers_without_insignificant_syntax() {
147        let value: Value =
148            serde_json::from_str(r#"{"b": false, "c": 12e1, "a": "Hello!"}"#).expect("valid json");
149
150        assert_eq!(
151            canonicalize_json_value(&value).unwrap(),
152            br#"{"a":"Hello!","b":false,"c":120}"#
153        );
154    }
155
156    #[test]
157    fn timestamp_serializes_as_json_number() {
158        assert_eq!(serde_json::to_value(Timestamp(1_000)).unwrap(), json!(1000));
159
160        let timestamp: Timestamp = serde_json::from_value(json!(2_000)).unwrap();
161        assert_eq!(timestamp, Timestamp(2_000));
162    }
163
164    #[test]
165    fn public_info_payload_is_jcs_canonical() {
166        let issuer_id = IssuerId(nostr::PublicKey::from_byte_array([1u8; 32]));
167        let info = json!({
168            "z": 1,
169            "a": {
170                "b": true,
171                "a": false,
172            },
173        });
174
175        let canonicalized = canonicalize_pbrsa_info(ProtocolV1, &issuer_id, &info).unwrap();
176        let expected = format!(
177            r#"{{"info":{{"a":{{"a":false,"b":true}},"z":1}},"issuer_id_pubkey":"{}","type":"{}","version":1}}"#,
178            issuer_id.0, PBRSA_PUBLIC_INFO_CANONICAL_TYPE,
179        );
180
181        assert_eq!(canonicalized, expected.as_bytes());
182    }
183
184    #[test]
185    fn blind_msg_payload_is_jcs_canonical() {
186        let blind_msg = json!({
187            "holder": "alice",
188            "nonce": 7,
189        });
190
191        assert_eq!(
192            canonicalize_pbrsa_blind_msg(ProtocolV1, &blind_msg).unwrap(),
193            format!(
194                r#"{{"blind_msg":{{"holder":"alice","nonce":7}},"type":"{}","version":1}}"#,
195                PBRSA_BLIND_MSG_CANONICAL_TYPE,
196            )
197            .as_bytes()
198        );
199    }
200
201    #[test]
202    fn holder_authorization_payload_is_jcs_canonical() {
203        let holder_id = HolderId(nostr::PublicKey::from_byte_array([4u8; 32]));
204        let subject_pubkey = SubjectPubkey(nostr::PublicKey::from_byte_array([2u8; 32]));
205        let authorization = HolderAuthorizationStatement {
206            holder_id_pubkey: holder_id.clone(),
207            subject_pubkey: subject_pubkey.clone(),
208            credential_digest: CredentialDigest([3u8; 32].into()),
209            issued_at: Timestamp(1000),
210        };
211
212        let canonicalized = canonicalize_holder_authorization(&authorization).unwrap();
213        let expected = format!(
214            r#"{{"authorization":{{"credential_digest":"AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM","holder_id_pubkey":"{}","issued_at":1000,"subject_pubkey":"{}"}},"type":"{}","version":1}}"#,
215            holder_id.0, subject_pubkey.0, HOLDER_AUTHORIZATION_CANONICAL_TYPE,
216        );
217
218        assert_eq!(canonicalized, expected.as_bytes());
219    }
220}