baseid_vc/
data_integrity.rs

1//! Data Integrity proof creation and verification.
2//!
3//! Implements the eddsa-rdfc-2022 cryptosuite for signing and verifying
4//! W3C Verifiable Credentials with Data Integrity proofs.
5//!
6//! # Canonicalization
7//!
8//! This implementation uses RDFC-1.0 (RDF Dataset Canonicalization) via
9//! the `baseid-jsonld` crate. The document and proof options are expanded
10//! from JSON-LD into N-Quads and then canonicalized per the W3C RDFC-1.0
11//! specification, producing deterministic output for hashing and signing.
12//!
13//! # References
14//!
15//! - [W3C Data Integrity](https://www.w3.org/TR/vc-data-integrity/)
16//! - [eddsa-rdfc-2022](https://www.w3.org/TR/vc-di-eddsa/)
17//! - [RDFC-1.0](https://www.w3.org/TR/rdf-canon/)
18
19use baseid_core::error::CryptoError;
20use baseid_core::types::SignatureAlgorithm;
21use baseid_crypto::signer::{Signer, Verifier};
22use serde_json::Value;
23use sha2::{Digest, Sha256};
24
25use crate::credential::VerifiableCredential;
26use crate::proof::{Cryptosuite, DataIntegrityProof};
27
28/// Sign a Verifiable Credential with a Data Integrity proof (eddsa-rdfc-2022).
29///
30/// Returns a new VerifiableCredential with the proof attached.
31pub fn sign_credential_di(
32    vc: &VerifiableCredential,
33    signer: &dyn Signer,
34    verification_method: &str,
35) -> baseid_core::Result<VerifiableCredential> {
36    // Verify signer uses Ed25519 (eddsa-rdfc-2022 is Ed25519-only)
37    if signer.algorithm() != SignatureAlgorithm::EdDsa {
38        return Err(CryptoError::UnsupportedAlgorithm.into());
39    }
40
41    // 1. Serialize VC without proof
42    let mut vc_value = serde_json::to_value(vc).map_err(|_| CryptoError::SigningFailed)?;
43    if let Some(obj) = vc_value.as_object_mut() {
44        obj.remove("proof");
45    }
46
47    // 2. Create proof options (without proofValue)
48    let created = current_timestamp();
49    let proof_options = serde_json::json!({
50        "type": "DataIntegrityProof",
51        "cryptosuite": Cryptosuite::EddsaRdfc2022.as_str(),
52        "created": created,
53        "verificationMethod": verification_method,
54        "proofPurpose": "assertionMethod",
55    });
56
57    // 3. Canonicalize document and proof options using RDFC-1.0
58    let canonical_doc = canonicalize_document(&vc_value);
59    let canonical_opts = canonicalize_document(&proof_options);
60
61    // 4. Hash both: SHA-256(proof_options) || SHA-256(document)
62    let opts_hash = Sha256::digest(canonical_opts.as_bytes());
63    let doc_hash = Sha256::digest(canonical_doc.as_bytes());
64    let mut to_sign = Vec::with_capacity(64);
65    to_sign.extend_from_slice(&opts_hash);
66    to_sign.extend_from_slice(&doc_hash);
67
68    // 5. Sign the concatenated hashes
69    let signature = signer
70        .sign(&to_sign)
71        .map_err(|_| CryptoError::SigningFailed)?;
72
73    // 6. Encode signature as multibase base58btc
74    let proof_value = multibase::encode(multibase::Base::Base58Btc, &signature);
75
76    // 7. Build the signed VC with proof
77    let proof = DataIntegrityProof {
78        r#type: "DataIntegrityProof".to_string(),
79        cryptosuite: Cryptosuite::EddsaRdfc2022.as_str().to_string(),
80        created,
81        verification_method: verification_method.to_string(),
82        proof_purpose: "assertionMethod".to_string(),
83        proof_value,
84    };
85
86    let mut signed_vc = vc.clone();
87    signed_vc.proof = Some(serde_json::to_value(&proof).map_err(|_| CryptoError::SigningFailed)?);
88
89    Ok(signed_vc)
90}
91
92/// Verify a Data Integrity proof on a Verifiable Credential.
93///
94/// Returns Ok(()) if the proof is valid, Err otherwise.
95pub fn verify_credential_di(
96    vc: &VerifiableCredential,
97    verifier: &dyn Verifier,
98) -> baseid_core::Result<()> {
99    // 1. Extract and parse the proof
100    let proof_value = vc.proof.as_ref().ok_or(CryptoError::VerificationFailed)?;
101    let proof: DataIntegrityProof =
102        serde_json::from_value(proof_value.clone()).map_err(|_| CryptoError::VerificationFailed)?;
103
104    // 2. Verify cryptosuite
105    if Cryptosuite::parse_str(&proof.cryptosuite).is_none() {
106        return Err(CryptoError::UnsupportedAlgorithm.into());
107    }
108
109    // 3. Decode the multibase proof value
110    let (_, signature_bytes) =
111        multibase::decode(&proof.proof_value).map_err(|_| CryptoError::VerificationFailed)?;
112
113    // 4. Reconstruct the proof options (without proofValue)
114    let proof_options = serde_json::json!({
115        "type": proof.r#type,
116        "cryptosuite": proof.cryptosuite,
117        "created": proof.created,
118        "verificationMethod": proof.verification_method,
119        "proofPurpose": proof.proof_purpose,
120    });
121
122    // 5. Serialize VC without proof
123    let mut vc_value = serde_json::to_value(vc).map_err(|_| CryptoError::VerificationFailed)?;
124    if let Some(obj) = vc_value.as_object_mut() {
125        obj.remove("proof");
126    }
127
128    // 6. Canonicalize and hash using RDFC-1.0
129    let canonical_doc = canonicalize_document(&vc_value);
130    let canonical_opts = canonicalize_document(&proof_options);
131    let opts_hash = Sha256::digest(canonical_opts.as_bytes());
132    let doc_hash = Sha256::digest(canonical_doc.as_bytes());
133    let mut to_verify = Vec::with_capacity(64);
134    to_verify.extend_from_slice(&opts_hash);
135    to_verify.extend_from_slice(&doc_hash);
136
137    // 7. Verify the signature
138    let valid = verifier
139        .verify(&to_verify, &signature_bytes)
140        .map_err(|_| CryptoError::VerificationFailed)?;
141    if !valid {
142        return Err(CryptoError::VerificationFailed.into());
143    }
144
145    Ok(())
146}
147
148/// Canonicalize a JSON value using RDFC-1.0 (via baseid-jsonld).
149///
150/// Expands the JSON-LD document and applies W3C RDFC-1.0 canonicalization,
151/// producing deterministic canonical N-Quads that are then SHA-256 hashed.
152fn canonicalize_document(value: &Value) -> String {
153    baseid_jsonld::canonicalize_vc(value)
154}
155
156/// JCS (JSON Canonicalization Scheme, RFC 8785) canonicalization.
157///
158/// Produces a deterministic JSON string with:
159/// - Object keys sorted lexicographically
160/// - No whitespace
161/// - Numbers in shortest representation
162/// - Strings escaped per JSON spec
163#[cfg(test)]
164fn canonicalize_jcs(value: &Value) -> String {
165    match value {
166        Value::Object(map) => {
167            let mut entries: Vec<(&String, &Value)> = map.iter().collect();
168            entries.sort_by_key(|(k, _)| *k);
169            let parts: Vec<String> = entries
170                .iter()
171                .map(|(k, v)| {
172                    format!(
173                        "{}:{}",
174                        canonicalize_jcs(&Value::String((*k).clone())),
175                        canonicalize_jcs(v)
176                    )
177                })
178                .collect();
179            format!("{{{}}}", parts.join(","))
180        }
181        Value::Array(arr) => {
182            let parts: Vec<String> = arr.iter().map(canonicalize_jcs).collect();
183            format!("[{}]", parts.join(","))
184        }
185        Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null => {
186            serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
187        }
188    }
189}
190
191/// Generate an RFC 3339 timestamp for the current time.
192fn current_timestamp() -> String {
193    let secs = std::time::SystemTime::now()
194        .duration_since(std::time::UNIX_EPOCH)
195        .unwrap_or_default()
196        .as_secs();
197    // Simple UTC timestamp (matching signing.rs pattern)
198    let days = secs / 86400;
199    let rem = secs % 86400;
200    let hours = rem / 3600;
201    let minutes = (rem % 3600) / 60;
202    let seconds = rem % 60;
203    // Approximate date calculation
204    let (year, month, day) = epoch_days_to_ymd(days);
205    format!(
206        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
207        year, month, day, hours, minutes, seconds
208    )
209}
210
211fn epoch_days_to_ymd(days: u64) -> (u64, u64, u64) {
212    // Simplified - matches the pattern used in signing.rs
213    let y = 1970 + days / 365;
214    let remaining = days - (y - 1970) * 365 - ((y - 1969) / 4);
215    let m = remaining / 30 + 1;
216    let d = remaining % 30 + 1;
217    (y, m.min(12), d.min(28))
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use baseid_core::types::KeyType;
224    use baseid_crypto::KeyPair;
225
226    use crate::credential::{Issuer, VerifiableCredential};
227
228    fn sample_vc() -> VerifiableCredential {
229        VerifiableCredential {
230            context: vec!["https://www.w3.org/ns/credentials/v2".to_string()],
231            id: Some("urn:uuid:test-123".to_string()),
232            r#type: vec![
233                "VerifiableCredential".to_string(),
234                "TestCredential".to_string(),
235            ],
236            issuer: Issuer::Uri("did:key:z6MkIssuer".to_string()),
237            valid_from: Some("2024-01-01T00:00:00Z".to_string()),
238            valid_until: None,
239            credential_subject: serde_json::json!({"id": "did:key:z6MkHolder", "name": "Alice", "score": 95}),
240            credential_status: None,
241            proof: None,
242        }
243    }
244
245    fn test_keypair() -> KeyPair {
246        KeyPair::generate(KeyType::Ed25519).unwrap()
247    }
248
249    #[test]
250    fn sign_verify_roundtrip_ed25519() {
251        let kp = test_keypair();
252        let vc = sample_vc();
253        let signed = sign_credential_di(&vc, &kp, "did:key:z6MkIssuer#key-1").unwrap();
254        assert!(signed.proof.is_some());
255        verify_credential_di(&signed, &kp.public).unwrap();
256    }
257
258    #[test]
259    fn proof_structure() {
260        let kp = test_keypair();
261        let vc = sample_vc();
262        let signed = sign_credential_di(&vc, &kp, "did:key:z6MkIssuer#key-1").unwrap();
263        let proof: DataIntegrityProof = serde_json::from_value(signed.proof.unwrap()).unwrap();
264        assert_eq!(proof.r#type, "DataIntegrityProof");
265        assert_eq!(proof.cryptosuite, "eddsa-rdfc-2022");
266        assert_eq!(proof.proof_purpose, "assertionMethod");
267        assert_eq!(proof.verification_method, "did:key:z6MkIssuer#key-1");
268    }
269
270    #[test]
271    fn proof_value_is_multibase() {
272        let kp = test_keypair();
273        let vc = sample_vc();
274        let signed = sign_credential_di(&vc, &kp, "did:key:z6MkIssuer#key-1").unwrap();
275        let proof: DataIntegrityProof = serde_json::from_value(signed.proof.unwrap()).unwrap();
276        assert!(
277            proof.proof_value.starts_with('z'),
278            "Should be base58btc multibase (z prefix)"
279        );
280        // Decode should succeed
281        let (base, _) = multibase::decode(&proof.proof_value).unwrap();
282        assert_eq!(base, multibase::Base::Base58Btc);
283    }
284
285    #[test]
286    fn wrong_key_rejected() {
287        let signer = test_keypair();
288        let wrong_verifier = test_keypair(); // Different key
289        let vc = sample_vc();
290        let signed = sign_credential_di(&vc, &signer, "did:key:z6MkIssuer#key-1").unwrap();
291        let result = verify_credential_di(&signed, &wrong_verifier.public);
292        assert!(result.is_err());
293    }
294
295    #[test]
296    fn tampered_credential_rejected() {
297        let kp = test_keypair();
298        let vc = sample_vc();
299        let mut signed = sign_credential_di(&vc, &kp, "did:key:z6MkIssuer#key-1").unwrap();
300        // Tamper with a claim
301        signed.credential_subject = serde_json::json!({"id": "did:key:z6MkEvil", "name": "Eve"});
302        let result = verify_credential_di(&signed, &kp.public);
303        assert!(result.is_err());
304    }
305
306    #[test]
307    fn tampered_proof_rejected() {
308        let kp = test_keypair();
309        let vc = sample_vc();
310        let mut signed = sign_credential_di(&vc, &kp, "did:key:z6MkIssuer#key-1").unwrap();
311        // Tamper with proof value
312        if let Some(proof) = signed.proof.as_mut() {
313            proof["proofValue"] = serde_json::Value::String("zINVALID".to_string());
314        }
315        let result = verify_credential_di(&signed, &kp.public);
316        assert!(result.is_err());
317    }
318
319    #[test]
320    fn canonicalization_determinism() {
321        let a = serde_json::json!({"z": 1, "a": 2, "m": 3});
322        let b = serde_json::json!({"a": 2, "m": 3, "z": 1});
323        assert_eq!(canonicalize_jcs(&a), canonicalize_jcs(&b));
324    }
325
326    #[test]
327    fn canonicalization_nested() {
328        let val = serde_json::json!({"b": {"z": 1, "a": 2}, "a": [3, 1, 2]});
329        let canonical = canonicalize_jcs(&val);
330        assert_eq!(canonical, "{\"a\":[3,1,2],\"b\":{\"a\":2,\"z\":1}}");
331    }
332
333    #[test]
334    fn proof_created_timestamp() {
335        let kp = test_keypair();
336        let vc = sample_vc();
337        let signed = sign_credential_di(&vc, &kp, "did:key:z6MkIssuer#key-1").unwrap();
338        let proof: DataIntegrityProof = serde_json::from_value(signed.proof.unwrap()).unwrap();
339        // Should be a reasonable ISO 8601 timestamp
340        assert!(proof.created.contains('T'));
341        assert!(proof.created.ends_with('Z'));
342        assert!(proof.created.starts_with("20"));
343    }
344
345    #[test]
346    fn no_proof_verification_fails() {
347        let kp = test_keypair();
348        let vc = sample_vc(); // No proof
349        let result = verify_credential_di(&vc, &kp.public);
350        assert!(result.is_err());
351    }
352
353    #[test]
354    fn credential_unchanged_after_signing() {
355        let kp = test_keypair();
356        let vc = sample_vc();
357        let signed = sign_credential_di(&vc, &kp, "did:key:z6MkIssuer#key-1").unwrap();
358        // Core fields should be preserved
359        assert_eq!(signed.context, vc.context);
360        assert_eq!(signed.r#type, vc.r#type);
361        assert_eq!(signed.credential_subject, vc.credential_subject);
362        assert_eq!(signed.valid_from, vc.valid_from);
363    }
364}