Skip to main content

affinidi_data_integrity/
lib.rs

1/*!
2*   W3C Data Integrity Implementation
3*/
4
5use chrono::Utc;
6use crypto_suites::CryptoSuite;
7use multibase::Base;
8use serde::{Deserialize, Serialize};
9use serde_json_canonicalizer::to_string;
10use sha2::{Digest, Sha256};
11use signer::Signer;
12use thiserror::Error;
13use tracing::debug;
14
15pub mod crypto_suites;
16pub mod signer;
17pub mod verification_proof;
18
19/// Affinidi Data Integrity Library Errors
20#[derive(Error, Debug)]
21pub enum DataIntegrityError {
22    #[error("Input Data Error: {0}")]
23    InputDataError(String),
24    #[error("Crypto Error: {0}")]
25    CryptoError(String),
26    #[error("Secrets Error: {0}")]
27    SecretsError(String),
28    #[error("Verification Error: {0}")]
29    VerificationError(String),
30    #[error("RDF Encoding Error: {0}")]
31    RdfEncodingError(String),
32}
33
34#[derive(Clone, Debug, Deserialize, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct DataIntegrityProof {
37    /// Must be 'DataIntegrityProof'
38    #[serde(rename = "type")]
39    pub type_: String,
40
41    pub cryptosuite: CryptoSuite,
42
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub created: Option<String>,
45
46    pub verification_method: String,
47
48    pub proof_purpose: String,
49
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub proof_value: Option<String>,
52
53    #[serde(rename = "@context", skip_serializing_if = "Option::is_none")]
54    pub context: Option<Vec<String>>,
55}
56
57impl DataIntegrityProof {
58    /// Creates a JCS (JSON Canonicalization Scheme) signature for the given data.
59    /// data_doc: Serializable Struct
60    /// context: Optional context for the proof
61    /// signer: Implementation of the Signer trait (e.g. a Secret, or a KMS/HSM backend)
62    /// created: Optional timestamp for the proof creation ("2023-02-24T23:36:38Z")
63    ///
64    /// Returns a Result containing a proof if successful
65    pub async fn sign_jcs_data<S>(
66        data_doc: &S,
67        context: Option<Vec<String>>,
68        signer: &dyn Signer,
69        created: Option<String>,
70    ) -> Result<DataIntegrityProof, DataIntegrityError>
71    where
72        S: Serialize,
73    {
74        // Initialise as required
75        let crypto_suite = CryptoSuite::EddsaJcs2022;
76        crypto_suite.validate_key_type(signer.key_type())?;
77        debug!(
78            "CryptoSuite: {}",
79            <CryptoSuite as TryInto<String>>::try_into(crypto_suite).unwrap()
80        );
81
82        // Step 1: Serialize the data document to a canonical JSON string
83        let jcs = match to_string(data_doc) {
84            Ok(jcs) => jcs,
85            Err(e) => {
86                return Err(DataIntegrityError::InputDataError(format!(
87                    "Failed to serialize data document: {e}",
88                )));
89            }
90        };
91        debug!("Document: {}", jcs);
92
93        let created = if created.is_some() {
94            created
95        } else {
96            Some(Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
97        };
98
99        // Create a Proof Options struct
100        let mut proof_options = DataIntegrityProof {
101            type_: "DataIntegrityProof".to_string(),
102            cryptosuite: crypto_suite,
103            created,
104            verification_method: signer.verification_method().to_string(),
105            proof_purpose: "assertionMethod".to_string(),
106            proof_value: None,
107            context,
108        };
109
110        let proof_jcs = match to_string(&proof_options) {
111            Ok(jcs) => jcs,
112            Err(e) => {
113                return Err(DataIntegrityError::InputDataError(format!(
114                    "Failed to serialize proof options: {e}",
115                )));
116            }
117        };
118        debug!("proof options (JCS): {}", proof_jcs);
119
120        let hash_data = hashing_eddsa_jcs(&jcs, &proof_jcs);
121
122        // Step 6: Sign the final hash
123        let signed = signer.sign(hash_data.as_slice()).await?;
124        debug!(
125            "signature data = {}",
126            signed
127                .iter()
128                .map(|b| format!("{b:02x}"))
129                .collect::<Vec<String>>()
130                .join("")
131        );
132
133        // Step 7: Encode using base58btc
134        proof_options.proof_value = Some(multibase::encode(Base::Base58Btc, &signed).to_string());
135
136        Ok(proof_options)
137    }
138
139    /// Creates an RDFC (RDF Dataset Canonicalization) signature for the given JSON-LD data.
140    /// data_doc: JSON-LD document (must contain `@context`)
141    /// context: Optional context override for the proof; if None, uses the document's `@context`
142    /// signer: Implementation of the Signer trait (e.g. a Secret, or a KMS/HSM backend)
143    /// created: Optional timestamp for the proof creation ("2023-02-24T23:36:38Z")
144    ///
145    /// Returns a Result containing a proof if successful
146    pub async fn sign_rdfc_data(
147        data_doc: &serde_json::Value,
148        context: Option<Vec<String>>,
149        signer: &dyn Signer,
150        created: Option<String>,
151    ) -> Result<DataIntegrityProof, DataIntegrityError> {
152        let crypto_suite = CryptoSuite::EddsaRdfc2022;
153        crypto_suite.validate_key_type(signer.key_type())?;
154        debug!(
155            "CryptoSuite: {}",
156            <CryptoSuite as TryInto<String>>::try_into(crypto_suite).unwrap()
157        );
158
159        // Extract @context from document (required for JSON-LD)
160        let doc_context = data_doc
161            .get("@context")
162            .ok_or_else(|| {
163                DataIntegrityError::InputDataError(
164                    "Document must contain @context for RDFC signing".to_string(),
165                )
166            })?
167            .clone();
168
169        // Use provided context or extract from document
170        let proof_context = if let Some(ctx) = context {
171            ctx
172        } else {
173            match &doc_context {
174                serde_json::Value::Array(arr) => arr
175                    .iter()
176                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
177                    .collect(),
178                serde_json::Value::String(s) => vec![s.clone()],
179                _ => {
180                    return Err(DataIntegrityError::InputDataError(
181                        "Invalid @context format in document".to_string(),
182                    ));
183                }
184            }
185        };
186
187        let created = if created.is_some() {
188            created
189        } else {
190            Some(Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
191        };
192
193        // Create proof options (without proof_value)
194        let mut proof_options = DataIntegrityProof {
195            type_: "DataIntegrityProof".to_string(),
196            cryptosuite: crypto_suite,
197            created,
198            verification_method: signer.verification_method().to_string(),
199            proof_purpose: "assertionMethod".to_string(),
200            proof_value: None,
201            context: Some(proof_context),
202        };
203
204        // Serialize proof options to Value for RDFC pipeline
205        let proof_value = serde_json::to_value(&proof_options).map_err(|e| {
206            DataIntegrityError::InputDataError(format!("Failed to serialize proof options: {e}"))
207        })?;
208
209        let hash_data = hashing_eddsa_rdfc(data_doc, &proof_value)?;
210
211        // Sign the final hash
212        let signed = signer.sign(hash_data.as_slice()).await?;
213        debug!(
214            "signature data = {}",
215            signed
216                .iter()
217                .map(|b| format!("{b:02x}"))
218                .collect::<Vec<String>>()
219                .join("")
220        );
221
222        // Encode using base58btc
223        proof_options.proof_value = Some(multibase::encode(Base::Base58Btc, &signed).to_string());
224
225        Ok(proof_options)
226    }
227}
228
229/// Hashing Algorithm for EDDSA JCS
230fn hashing_eddsa_jcs(transformed_document: &str, canonical_proof_config: &str) -> Vec<u8> {
231    [
232        Sha256::digest(canonical_proof_config),
233        Sha256::digest(transformed_document),
234    ]
235    .concat()
236}
237
238/// Hashing Algorithm for EDDSA RDFC
239/// Runs both document and proof config through the RDFC pipeline
240/// (JSON-LD expansion → RDF Dataset → RDFC-1.0 canonicalization → SHA-256)
241/// and concatenates the two 32-byte hashes.
242fn hashing_eddsa_rdfc(
243    document: &serde_json::Value,
244    proof_config: &serde_json::Value,
245) -> Result<Vec<u8>, DataIntegrityError> {
246    let doc_hash = affinidi_rdf_encoding::expand_canonicalize_and_hash(document).map_err(|e| {
247        DataIntegrityError::RdfEncodingError(format!("Failed to hash document: {e}"))
248    })?;
249
250    let proof_hash =
251        affinidi_rdf_encoding::expand_canonicalize_and_hash(proof_config).map_err(|e| {
252            DataIntegrityError::RdfEncodingError(format!("Failed to hash proof config: {e}"))
253        })?;
254
255    Ok([proof_hash.as_slice(), doc_hash.as_slice()].concat())
256}
257
258#[cfg(test)]
259mod tests {
260    use affinidi_secrets_resolver::secrets::Secret;
261    use serde_json::json;
262
263    use crate::{DataIntegrityProof, hashing_eddsa_jcs};
264
265    #[test]
266    fn hashing_working() {
267        let hash = hashing_eddsa_jcs("test1", "test2");
268        let mut output = String::new();
269        for x in hash {
270            output.push_str(&format!("{x:02x}"));
271        }
272
273        assert_eq!(
274            output.as_str(),
275            "60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c7521b4f0e9851971998e732078544c96b36c3d01cedf7caa332359d6f1d83567014",
276        );
277    }
278
279    #[tokio::test]
280    async fn test_sign_jcs_data_bad_key() {
281        let generic_doc = json!({"test": "test_data"});
282
283        let pub_key = "zruqgFba156mDWfMUjJUSAKUvgCgF5NfgSYwSuEZuXpixts8tw3ot5BasjeyM65f8dzk5k6zgXf7pkbaaBnPrjCUmcJ";
284        let pri_key = "z42tmXtqqQBLmEEwn8tfi1bA2ghBx9cBo6wo8a44kVJEiqyA";
285        let secret = Secret::from_multibase(pri_key, Some(&format!("did:key:{pub_key}#{pub_key}")))
286            .expect("Couldn't create test key data");
287
288        assert!(DataIntegrityProof::sign_jcs_data(&generic_doc, None, &secret, None).await.is_err());
289    }
290
291    #[tokio::test]
292    async fn test_sign_jcs_data_good() {
293        let generic_doc = json!({"test": "test_data"});
294
295        let pub_key = "z6MktDNePDZTvVcF5t6u362SsonU7HkuVFSMVCjSspQLDaBm";
296        let pri_key = "z3u2UQyiY96d7VQaua8yiaSyQxq5Z5W5Qkpz7o2H2pc9BkEa";
297        let secret = Secret::from_multibase(pri_key, Some(&format!("did:key:{pub_key}#{pub_key}")))
298            .expect("Couldn't create test key data");
299
300        let context = vec![
301            "context1".to_string(),
302            "context2".to_string(),
303            "context3".to_string(),
304        ];
305        assert!(
306            DataIntegrityProof::sign_jcs_data(&generic_doc, Some(context), &secret, None).await.is_ok(),
307            "Signing failed"
308        );
309    }
310}