Skip to main content

affinidi_data_integrity/
lib.rs

1/*!
2W3C Data Integrity — sign and verify [Data Integrity Proofs] for
3Verifiable Credentials, DID documents, and arbitrary JSON documents.
4
5# Quickstart — sign and verify
6
7```no_run
8use affinidi_data_integrity::{DataIntegrityProof, SignOptions, VerifyOptions};
9use affinidi_secrets_resolver::secrets::Secret;
10use serde_json::json;
11
12# async fn demo() -> Result<(), affinidi_data_integrity::DataIntegrityError> {
13let secret = Secret::generate_ed25519(Some("did:key:z6Mk...#key-0"), None);
14let doc = json!({ "name": "Alice" });
15
16// Sign — the library picks `eddsa-jcs-2022` automatically via
17// Signer::cryptosuite() because `secret` is an Ed25519 key.
18let proof = DataIntegrityProof::sign(&doc, &secret, SignOptions::new()).await?;
19
20// Verify — pass the raw public-key bytes.
21proof.verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())?;
22# Ok(()) }
23```
24
25# Post-quantum cryptography
26
27Enable the `post-quantum` feature (off by default) to sign with
28ML-DSA-44 or SLH-DSA-SHA2-128s:
29
30```ignore
31[dependencies]
32affinidi-data-integrity = { version = "0.5", features = ["post-quantum"] }
33```
34
35Then generate a PQC key — the library selects `mldsa44-jcs-2024` or
36`slhdsa128-jcs-2024` automatically from the key type.
37
38# Cryptosuites
39
40See [`crypto_suites::CryptoSuite`] for the full list. Each suite has a
41canonicalization (JCS or RDFC), a signing algorithm, and a
42[`compatible_key_types`] list. Callers rarely need to pick a suite
43directly — [`Signer::cryptosuite`] provides a sensible default per key
44type, and `SignOptions::with_cryptosuite` is the escape hatch for
45explicit selection (e.g. forcing RDFC).
46
47# Forward compatibility
48
49All public enums (`KeyType`, [`CryptoSuite`], [`DataIntegrityError`])
50are `#[non_exhaustive]`. Future algorithms and error variants arrive in
51minor releases without breaking callers that include a `_ =>` arm.
52
53# Out of scope
54
55This crate implements W3C Data Integrity only. JOSE / JWS / COSE
56post-quantum profiles are being standardised separately by IETF and
57will live in sibling crates (`affinidi-data-integrity-jose`,
58`-cose`) when those drafts stabilise.
59
60[Data Integrity Proofs]: https://www.w3.org/TR/vc-data-integrity/
61[`compatible_key_types`]: crate::crypto_suites::CryptoSuite::compatible_key_types
62[`CryptoSuite`]: crate::crypto_suites::CryptoSuite
63[`Signer::cryptosuite`]: crate::signer::Signer::cryptosuite
64*/
65
66use chrono::{DateTime, Utc};
67use crypto_suites::CryptoSuite;
68use multibase::Base;
69use serde::{Deserialize, Serialize};
70use serde_json_canonicalizer::to_string;
71use sha2::{Digest, Sha256};
72use signer::Signer;
73use tracing::debug;
74
75pub mod caching_signer;
76pub mod conformance;
77pub mod crypto_suites;
78pub mod did_vm;
79pub mod error;
80pub mod multi;
81pub mod options;
82pub mod signer;
83pub mod suite_ops;
84pub mod verification_proof;
85
86pub use caching_signer::{CachingSigner, GetPrivateBytes};
87pub use conformance::verify_conformance;
88pub use did_vm::{DidKeyResolver, ResolvedKey, VerificationMethodResolver};
89pub use multi::{MultiVerifyResult, VerifyPolicy, verify_multi};
90
91/// BBS-2023 Data Integrity Cryptosuite for zero-knowledge selective disclosure.
92///
93/// Enabled via the `bbs-2023` feature flag.
94#[cfg(feature = "bbs-2023")]
95pub mod bbs_2023;
96
97/// W3C vc-di-bbs `bbs-2023` transformation primitives (RDF-canonical, standards
98/// interoperable). The standards-track replacement for [`bbs_2023`]'s internal
99/// statement encoding; built incrementally and pinned to the official vectors.
100#[cfg(feature = "bbs-2023")]
101pub mod bbs_2023_transform;
102
103pub use error::{DataIntegrityError, SignatureFailure};
104pub use options::{SignOptions, VerifyOptions};
105
106/// Serialized Data Integrity proof.
107#[derive(Clone, Debug, Deserialize, Serialize)]
108#[serde(rename_all = "camelCase")]
109pub struct DataIntegrityProof {
110    /// Must be 'DataIntegrityProof'
111    #[serde(rename = "type")]
112    pub type_: String,
113
114    pub cryptosuite: CryptoSuite,
115
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub created: Option<String>,
118
119    pub verification_method: String,
120
121    pub proof_purpose: String,
122
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub proof_value: Option<String>,
125
126    #[serde(rename = "@context", skip_serializing_if = "Option::is_none")]
127    pub context: Option<Vec<String>>,
128}
129
130impl DataIntegrityProof {
131    /// Produces a Data Integrity proof over `data_doc`.
132    ///
133    /// The cryptosuite is picked from [`SignOptions::cryptosuite`] if
134    /// set, otherwise from [`Signer::cryptosuite`]. Canonicalization
135    /// (JCS or RDFC) is derived from the suite.
136    ///
137    pub async fn sign<S>(
138        data_doc: &S,
139        signer: &dyn Signer,
140        options: SignOptions,
141    ) -> Result<DataIntegrityProof, DataIntegrityError>
142    where
143        S: Serialize,
144    {
145        let crypto_suite = options.cryptosuite.unwrap_or_else(|| signer.cryptosuite());
146        crypto_suite
147            .validate_key_type(signer.key_type())
148            .map_err(|_| DataIntegrityError::KeyTypeMismatch {
149                expected: crypto_suite
150                    .compatible_key_types()
151                    .first()
152                    .copied()
153                    .unwrap_or(affinidi_secrets_resolver::secrets::KeyType::Unknown),
154                actual: signer.key_type(),
155                suite: crypto_suite,
156            })?;
157
158        let created_str = options
159            .created
160            .map(format_created)
161            .unwrap_or_else(|| format_created(Utc::now()));
162
163        let proof_purpose = options
164            .proof_purpose
165            .unwrap_or_else(|| "assertionMethod".to_string());
166
167        if crypto_suite.is_rdfc() {
168            sign_rdfc(
169                data_doc,
170                crypto_suite,
171                options.context,
172                signer,
173                created_str,
174                proof_purpose,
175            )
176            .await
177        } else {
178            sign_jcs(
179                data_doc,
180                crypto_suite,
181                options.context,
182                signer,
183                created_str,
184                proof_purpose,
185            )
186            .await
187        }
188    }
189
190    /// Verifies a proof against `data_doc` using caller-provided public
191    /// key bytes.
192    ///
193    /// Sync because this is pure CPU — callers who already have the key
194    /// should not be forced into an async runtime. See [`verify`] for
195    /// the resolver-based async variant.
196    ///
197    /// [`verify`]: Self::verify
198    #[must_use = "ignoring a verification result is a security bug"]
199    pub fn verify_with_public_key<S>(
200        &self,
201        data_doc: &S,
202        public_key_bytes: &[u8],
203        options: VerifyOptions,
204    ) -> Result<(), DataIntegrityError>
205    where
206        S: Serialize,
207    {
208        verify_proof_internal(self, data_doc, public_key_bytes, &options)
209    }
210
211    /// Verifies a proof by resolving the public key from its
212    /// `verificationMethod` via a [`VerificationMethodResolver`].
213    ///
214    /// Use [`did_vm::DidKeyResolver`] for `did:key:` URIs (no I/O); plug
215    /// in a custom resolver for `did:web`, `did:webvh`, or any other
216    /// method. The library also checks that the resolved key's
217    /// [`KeyType`] matches the proof's cryptosuite — a cheap guard
218    /// against "right proof, wrong key" class bugs.
219    ///
220    /// Async because typical resolvers perform I/O (HTTP, cache lookups,
221    /// HSM introspection).
222    ///
223    /// [`KeyType`]: affinidi_secrets_resolver::secrets::KeyType
224    #[must_use = "ignoring a verification result is a security bug"]
225    pub async fn verify<S, R>(
226        &self,
227        data_doc: &S,
228        resolver: &R,
229        options: VerifyOptions,
230    ) -> Result<(), DataIntegrityError>
231    where
232        S: Serialize + Sync,
233        R: VerificationMethodResolver + ?Sized,
234    {
235        let resolved = resolver.resolve_vm(&self.verification_method).await?;
236
237        // Belt-and-braces key-type check. The CryptoSuiteOps verify will
238        // fail on a mismatched key anyway, but a typed error here is
239        // clearer to callers and saves the canonicalization work.
240        let compatible = self.cryptosuite.compatible_key_types();
241        if !compatible.is_empty() && !compatible.contains(&resolved.key_type) {
242            return Err(DataIntegrityError::KeyTypeMismatch {
243                expected: compatible
244                    .first()
245                    .copied()
246                    .unwrap_or(affinidi_secrets_resolver::secrets::KeyType::Unknown),
247                actual: resolved.key_type,
248                suite: self.cryptosuite,
249            });
250        }
251
252        verify_proof_internal(self, data_doc, &resolved.public_key_bytes, &options)
253    }
254}
255
256// -----------------------------------------------------------------------
257// Internal signing helpers
258// -----------------------------------------------------------------------
259
260async fn sign_jcs<S>(
261    data_doc: &S,
262    crypto_suite: CryptoSuite,
263    context: Option<Vec<String>>,
264    signer: &dyn Signer,
265    created: String,
266    proof_purpose: String,
267) -> Result<DataIntegrityProof, DataIntegrityError>
268where
269    S: Serialize,
270{
271    let jcs = to_string(data_doc)
272        .map_err(|e| DataIntegrityError::Canonicalization(format!("document: {e}")))?;
273    debug!("Document (JCS): {}", jcs);
274
275    let mut proof_options = DataIntegrityProof {
276        type_: "DataIntegrityProof".to_string(),
277        cryptosuite: crypto_suite,
278        created: Some(created),
279        verification_method: signer.verification_method().to_string(),
280        proof_purpose,
281        proof_value: None,
282        context,
283    };
284
285    let proof_jcs = to_string(&proof_options)
286        .map_err(|e| DataIntegrityError::Canonicalization(format!("proof config: {e}")))?;
287    debug!("Proof options (JCS): {}", proof_jcs);
288
289    let hash_data = hashing_jcs(&jcs, &proof_jcs);
290    let signed = signer.sign(&hash_data).await?;
291    proof_options.proof_value = Some(multibase::encode(Base::Base58Btc, &signed));
292
293    Ok(proof_options)
294}
295
296async fn sign_rdfc<S>(
297    data_doc: &S,
298    crypto_suite: CryptoSuite,
299    context: Option<Vec<String>>,
300    signer: &dyn Signer,
301    created: String,
302    proof_purpose: String,
303) -> Result<DataIntegrityProof, DataIntegrityError>
304where
305    S: Serialize,
306{
307    let doc_value = serde_json::to_value(data_doc)
308        .map_err(|e| DataIntegrityError::Canonicalization(format!("document serialize: {e}")))?;
309
310    // Proof context: caller override, else pulled from document @context.
311    let proof_context = if let Some(ctx) = context {
312        Some(ctx)
313    } else {
314        match doc_value.get("@context") {
315            Some(serde_json::Value::Array(arr)) => Some(
316                arr.iter()
317                    .filter_map(|v| v.as_str().map(str::to_string))
318                    .collect(),
319            ),
320            Some(serde_json::Value::String(s)) => Some(vec![s.clone()]),
321            Some(_) => {
322                return Err(DataIntegrityError::MalformedProof(
323                    "Invalid @context format in document".to_string(),
324                ));
325            }
326            None => {
327                return Err(DataIntegrityError::MalformedProof(
328                    "Document must contain @context for RDFC signing".to_string(),
329                ));
330            }
331        }
332    };
333
334    let mut proof_options = DataIntegrityProof {
335        type_: "DataIntegrityProof".to_string(),
336        cryptosuite: crypto_suite,
337        created: Some(created),
338        verification_method: signer.verification_method().to_string(),
339        proof_purpose,
340        proof_value: None,
341        context: proof_context,
342    };
343
344    let proof_value = serde_json::to_value(&proof_options).map_err(|e| {
345        DataIntegrityError::Canonicalization(format!("proof config serialize: {e}"))
346    })?;
347
348    let hash_data = hashing_rdfc(&doc_value, &proof_value)?;
349    let signed = signer.sign(&hash_data).await?;
350    proof_options.proof_value = Some(multibase::encode(Base::Base58Btc, &signed));
351
352    Ok(proof_options)
353}
354
355fn verify_proof_internal<S>(
356    proof: &DataIntegrityProof,
357    signed_doc: &S,
358    public_key_bytes: &[u8],
359    options: &VerifyOptions,
360) -> Result<(), DataIntegrityError>
361where
362    S: Serialize,
363{
364    // Cryptosuite allowlist.
365    if !options.allowed_suites.is_empty() && !options.allowed_suites.contains(&proof.cryptosuite) {
366        return Err(DataIntegrityError::Conformance(format!(
367            "cryptosuite {} is not in the caller's allowed suites",
368            String::try_from(proof.cryptosuite).unwrap_or_default()
369        )));
370    }
371
372    // Context match (only when caller explicitly supplied one).
373    if let Some(expected) = &options.expected_context
374        && proof.context.as_ref() != Some(expected)
375    {
376        return Err(DataIntegrityError::Conformance(
377            "Document context does not match proof context".to_string(),
378        ));
379    }
380
381    // Decode proofValue.
382    let Some(proof_value) = &proof.proof_value else {
383        return Err(DataIntegrityError::MalformedProof(
384            "proofValue is missing in the proof".to_string(),
385        ));
386    };
387    let proof_value = multibase::decode(proof_value)
388        .map_err(|e| DataIntegrityError::MalformedProof(format!("Invalid proof value: {e}")))?
389        .1;
390
391    // Strip the proof_value from the proof config for re-hashing.
392    let proof_config = DataIntegrityProof {
393        proof_value: None,
394        ..proof.clone()
395    };
396
397    if proof_config.type_ != "DataIntegrityProof" {
398        return Err(DataIntegrityError::Conformance(
399            "Invalid proof type, expected 'DataIntegrityProof'".to_string(),
400        ));
401    }
402
403    if let Some(created) = &proof_config.created {
404        let now = Utc::now();
405        let created = created
406            .parse::<DateTime<Utc>>()
407            .map_err(|e| DataIntegrityError::Conformance(format!("Invalid created date: {e}")))?;
408        if created > now {
409            return Err(DataIntegrityError::Conformance(
410                "Created date is in the future".to_string(),
411            ));
412        }
413    }
414
415    // Canonicalize & hash (JCS or RDFC depending on suite).
416    let hash_data = if proof_config.cryptosuite.is_rdfc() {
417        let doc_value = serde_json::to_value(signed_doc).map_err(|e| {
418            DataIntegrityError::Canonicalization(format!("document serialize: {e}"))
419        })?;
420        let proof_value_json = serde_json::to_value(&proof_config).map_err(|e| {
421            DataIntegrityError::Canonicalization(format!("proof config serialize: {e}"))
422        })?;
423        hashing_rdfc(&doc_value, &proof_value_json)?
424    } else {
425        #[cfg(feature = "bbs-2023")]
426        if matches!(proof_config.cryptosuite, CryptoSuite::Bbs2023) {
427            return Err(DataIntegrityError::UnsupportedCryptoSuite {
428                name: "bbs-2023 proofs must be verified via bbs_2023::verify_proof".to_string(),
429            });
430        }
431        let jcs_doc = to_string(&signed_doc)
432            .map_err(|e| DataIntegrityError::Canonicalization(format!("document: {e}")))?;
433        let jcs_proof_config = to_string(&proof_config)
434            .map_err(|e| DataIntegrityError::Canonicalization(format!("proof config: {e}")))?;
435        hashing_jcs(&jcs_doc, &jcs_proof_config)
436    };
437
438    proof_config
439        .cryptosuite
440        .verify(public_key_bytes, &hash_data, &proof_value)
441}
442
443// -----------------------------------------------------------------------
444// Hashing pipelines (shared by all cryptosuites in this family)
445// -----------------------------------------------------------------------
446
447/// Hashing Algorithm for EDDSA JCS
448fn hashing_jcs(transformed_document: &str, canonical_proof_config: &str) -> Vec<u8> {
449    [
450        Sha256::digest(canonical_proof_config),
451        Sha256::digest(transformed_document),
452    ]
453    .concat()
454}
455
456/// Hashing Algorithm for EDDSA RDFC.
457/// Runs both document and proof config through the RDFC pipeline
458/// (JSON-LD expansion → RDF Dataset → RDFC-1.0 canonicalization → SHA-256)
459/// and concatenates the two 32-byte hashes.
460fn hashing_rdfc(
461    document: &serde_json::Value,
462    proof_config: &serde_json::Value,
463) -> Result<Vec<u8>, DataIntegrityError> {
464    let doc_hash = affinidi_rdf_encoding::expand_canonicalize_and_hash(document)
465        .map_err(|e| DataIntegrityError::Canonicalization(format!("RDFC document hash: {e}")))?;
466
467    let proof_hash =
468        affinidi_rdf_encoding::expand_canonicalize_and_hash(proof_config).map_err(|e| {
469            DataIntegrityError::Canonicalization(format!("RDFC proof config hash: {e}"))
470        })?;
471
472    Ok([proof_hash.as_slice(), doc_hash.as_slice()].concat())
473}
474
475// -----------------------------------------------------------------------
476// Remote-signer helper: returns the exact bytes a signer is expected to
477// sign over, so remote-signing protocols can compute the input ahead of
478// time without recomputing the canonicalization/hash pipeline.
479// -----------------------------------------------------------------------
480
481/// Returns the byte string a [`Signer`] is expected to sign over, given
482/// a document, a partial proof config, and the target cryptosuite.
483///
484/// Remote signers (KMS, HSM) typically want this so they can submit a
485/// well-formed "sign these bytes" request to their backend without
486/// re-implementing canonicalization. The returned bytes are exactly
487/// what [`DataIntegrityProof::sign`] passes to `signer.sign(data)`.
488///
489/// `proof_config` should be the proof JSON value with `proofValue`
490/// absent but all other fields set (cryptosuite, verificationMethod,
491/// proofPurpose, created, optional @context).
492pub fn prepare_sign_input<S>(
493    data_doc: &S,
494    proof_config: &DataIntegrityProof,
495    cryptosuite: CryptoSuite,
496) -> Result<Vec<u8>, DataIntegrityError>
497where
498    S: Serialize,
499{
500    if cryptosuite.is_rdfc() {
501        let doc_value = serde_json::to_value(data_doc).map_err(|e| {
502            DataIntegrityError::Canonicalization(format!("document serialize: {e}"))
503        })?;
504        let proof_value = serde_json::to_value(proof_config).map_err(|e| {
505            DataIntegrityError::Canonicalization(format!("proof config serialize: {e}"))
506        })?;
507        hashing_rdfc(&doc_value, &proof_value)
508    } else {
509        let jcs_doc = to_string(data_doc)
510            .map_err(|e| DataIntegrityError::Canonicalization(format!("document: {e}")))?;
511        let jcs_proof = to_string(proof_config)
512            .map_err(|e| DataIntegrityError::Canonicalization(format!("proof config: {e}")))?;
513        Ok(hashing_jcs(&jcs_doc, &jcs_proof))
514    }
515}
516
517// -----------------------------------------------------------------------
518// Internal date helpers
519// -----------------------------------------------------------------------
520
521fn format_created(dt: DateTime<Utc>) -> String {
522    dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
523}
524
525#[cfg(test)]
526mod tests {
527    use affinidi_secrets_resolver::secrets::Secret;
528    use serde_json::json;
529
530    use crate::{DataIntegrityProof, SignOptions, VerifyOptions, hashing_jcs};
531
532    #[test]
533    fn hashing_working() {
534        let hash = hashing_jcs("test1", "test2");
535        let mut output = String::new();
536        for x in hash {
537            output.push_str(&format!("{x:02x}"));
538        }
539
540        assert_eq!(
541            output.as_str(),
542            "60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c7521b4f0e9851971998e732078544c96b36c3d01cedf7caa332359d6f1d83567014",
543        );
544    }
545
546    #[tokio::test]
547    async fn sign_and_verify_via_did_key_resolver_ed25519() {
548        use crate::{DidKeyResolver, VerifyOptions};
549
550        let secret = Secret::generate_ed25519(None, Some(&[11u8; 32]));
551        let pk_mb = secret.get_public_keymultibase().unwrap();
552        // Use the library-built VM URI so the resolver can find the key.
553        let mut signer_secret = secret.clone();
554        signer_secret.id = format!("did:key:{pk_mb}#{pk_mb}");
555
556        let doc = json!({ "hello": "did:key" });
557        let proof = DataIntegrityProof::sign(&doc, &signer_secret, SignOptions::new())
558            .await
559            .expect("sign");
560
561        proof
562            .verify(&doc, &DidKeyResolver, VerifyOptions::new())
563            .await
564            .expect("verify via resolver");
565    }
566
567    #[cfg(feature = "ml-dsa")]
568    #[tokio::test]
569    async fn sign_and_verify_via_did_key_resolver_ml_dsa() {
570        use crate::{DidKeyResolver, VerifyOptions};
571
572        let secret = Secret::generate_ml_dsa_44(None, Some(&[21u8; 32]));
573        let pk_mb = secret.get_public_keymultibase().unwrap();
574        let mut signer_secret = secret.clone();
575        signer_secret.id = format!("did:key:{pk_mb}#{pk_mb}");
576
577        let doc = json!({ "pqc": "did:key" });
578        let proof = DataIntegrityProof::sign(&doc, &signer_secret, SignOptions::new())
579            .await
580            .expect("sign");
581
582        proof
583            .verify(&doc, &DidKeyResolver, VerifyOptions::new())
584            .await
585            .expect("verify via resolver");
586    }
587
588    #[tokio::test]
589    async fn unified_sign_verify_ed25519_jcs() {
590        let secret = Secret::generate_ed25519(Some("did:key:k#k"), Some(&[4u8; 32]));
591        let doc = json!({"hello": "world"});
592        let proof = DataIntegrityProof::sign(&doc, &secret, SignOptions::new())
593            .await
594            .expect("sign");
595        proof
596            .verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
597            .expect("verify");
598    }
599
600    #[cfg(feature = "ml-dsa")]
601    #[tokio::test]
602    async fn unified_sign_verify_ml_dsa_44_jcs() {
603        let secret = Secret::generate_ml_dsa_44(Some("did:key:k#k"), Some(&[8u8; 32]));
604        let doc = json!({"pqc": true});
605        let proof = DataIntegrityProof::sign(&doc, &secret, SignOptions::new())
606            .await
607            .expect("sign");
608        // Signer defaulted to mldsa44-jcs-2024 via Signer::cryptosuite().
609        assert_eq!(
610            proof.cryptosuite,
611            crate::crypto_suites::CryptoSuite::MlDsa44Jcs2024
612        );
613        proof
614            .verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
615            .expect("verify");
616    }
617
618    #[cfg(feature = "ml-dsa")]
619    #[tokio::test]
620    async fn override_suite_via_sign_options() {
621        // An Ed25519 signer asked to produce mldsa44 must fail with
622        // KeyTypeMismatch — the caller overrode the default.
623        let secret = Secret::generate_ed25519(Some("did:key:k#k"), Some(&[1u8; 32]));
624        let doc = json!({"x": 1});
625        let err = DataIntegrityProof::sign(
626            &doc,
627            &secret,
628            SignOptions::new().with_cryptosuite(crate::crypto_suites::CryptoSuite::MlDsa44Jcs2024),
629        )
630        .await
631        .unwrap_err();
632        assert!(matches!(
633            err,
634            crate::DataIntegrityError::KeyTypeMismatch { .. }
635        ));
636    }
637
638    /// An attacker rewrites the `cryptosuite` field on a proof they
639    /// otherwise can't forge — e.g. swaps `eddsa-jcs-2022` to
640    /// `eddsa-rdfc-2022`. Verification must fail because the
641    /// canonicalization axis changes the hashed bytes.
642    #[tokio::test]
643    async fn verify_rejects_cryptosuite_tampering() {
644        use crate::crypto_suites::CryptoSuite;
645
646        let secret = Secret::generate_ed25519(Some("did:key:k#k"), Some(&[77u8; 32]));
647        let doc = json!({"tamper": "target"});
648        let mut proof = DataIntegrityProof::sign(&doc, &secret, SignOptions::new())
649            .await
650            .expect("sign");
651        assert_eq!(proof.cryptosuite, CryptoSuite::EddsaJcs2022);
652
653        // Attacker flips the suite.
654        proof.cryptosuite = CryptoSuite::EddsaRdfc2022;
655
656        let err = proof
657            .verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
658            .unwrap_err();
659        assert!(
660            matches!(err, crate::DataIntegrityError::InvalidSignature { .. }),
661            "expected InvalidSignature after cryptosuite tampering, got: {err:?}"
662        );
663    }
664
665    #[tokio::test]
666    async fn deterministic_signing_same_input_same_output() {
667        let secret = Secret::generate_ed25519(Some("did:key:k#k"), Some(&[2u8; 32]));
668        let doc = json!({"deterministic": "yes"});
669        let created = chrono::Utc::now();
670        let opts = || SignOptions::new().with_created(created);
671        let a = DataIntegrityProof::sign(&doc, &secret, opts())
672            .await
673            .unwrap();
674        let b = DataIntegrityProof::sign(&doc, &secret, opts())
675            .await
676            .unwrap();
677        assert_eq!(
678            a.proof_value, b.proof_value,
679            "Ed25519 must be deterministic"
680        );
681    }
682
683    #[cfg(feature = "ml-dsa")]
684    #[tokio::test]
685    async fn deterministic_signing_ml_dsa() {
686        let secret = Secret::generate_ml_dsa_44(Some("did:key:k#k"), Some(&[5u8; 32]));
687        let doc = json!({"deterministic": "pqc"});
688        let created = chrono::Utc::now();
689        let opts = || SignOptions::new().with_created(created);
690        let a = DataIntegrityProof::sign(&doc, &secret, opts())
691            .await
692            .unwrap();
693        let b = DataIntegrityProof::sign(&doc, &secret, opts())
694            .await
695            .unwrap();
696        assert_eq!(a.proof_value, b.proof_value, "ML-DSA must be deterministic");
697    }
698
699    #[tokio::test]
700    async fn test_sign_bad_key() {
701        let generic_doc = json!({"test": "test_data"});
702        let pub_key = "zruqgFba156mDWfMUjJUSAKUvgCgF5NfgSYwSuEZuXpixts8tw3ot5BasjeyM65f8dzk5k6zgXf7pkbaaBnPrjCUmcJ";
703        let pri_key = "z42tmXtqqQBLmEEwn8tfi1bA2ghBx9cBo6wo8a44kVJEiqyA";
704        let secret = Secret::from_multibase(pri_key, Some(&format!("did:key:{pub_key}#{pub_key}")))
705            .expect("Couldn't create test key data");
706        assert!(
707            DataIntegrityProof::sign(&generic_doc, &secret, SignOptions::new())
708                .await
709                .is_err()
710        );
711    }
712
713    #[tokio::test]
714    async fn test_sign_good() {
715        let generic_doc = json!({"test": "test_data"});
716        let pub_key = "z6MktDNePDZTvVcF5t6u362SsonU7HkuVFSMVCjSspQLDaBm";
717        let pri_key = "z3u2UQyiY96d7VQaua8yiaSyQxq5Z5W5Qkpz7o2H2pc9BkEa";
718        let secret = Secret::from_multibase(pri_key, Some(&format!("did:key:{pub_key}#{pub_key}")))
719            .expect("Couldn't create test key data");
720        let context = vec![
721            "context1".to_string(),
722            "context2".to_string(),
723            "context3".to_string(),
724        ];
725        assert!(
726            DataIntegrityProof::sign(
727                &generic_doc,
728                &secret,
729                SignOptions::new().with_context(context)
730            )
731            .await
732            .is_ok(),
733            "Signing failed"
734        );
735    }
736
737    #[cfg(feature = "ml-dsa")]
738    #[tokio::test]
739    async fn sign_verify_jcs_ml_dsa_44() {
740        use crate::crypto_suites::CryptoSuite;
741
742        let secret = Secret::generate_ml_dsa_44(Some("k-did#k-did"), Some(&[5u8; 32]));
743        let doc = json!({"hello": "pqc"});
744
745        let proof = DataIntegrityProof::sign(
746            &doc,
747            &secret,
748            SignOptions::new().with_cryptosuite(CryptoSuite::MlDsa44Jcs2024),
749        )
750        .await
751        .expect("sign ml-dsa");
752
753        assert_eq!(proof.cryptosuite, CryptoSuite::MlDsa44Jcs2024);
754
755        proof
756            .verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
757            .expect("verify ml-dsa");
758    }
759
760    #[cfg(feature = "ml-dsa")]
761    #[tokio::test]
762    async fn sign_wrong_suite_for_key_fails() {
763        use crate::crypto_suites::CryptoSuite;
764
765        let secret = Secret::generate_ml_dsa_44(Some("k"), Some(&[1u8; 32]));
766        let doc = json!({"x": 1});
767        let err = DataIntegrityProof::sign(
768            &doc,
769            &secret,
770            SignOptions::new().with_cryptosuite(CryptoSuite::EddsaJcs2022),
771        )
772        .await;
773        assert!(err.is_err());
774    }
775
776    #[cfg(feature = "slh-dsa")]
777    #[tokio::test]
778    async fn sign_verify_jcs_slh_dsa_128s() {
779        use crate::crypto_suites::CryptoSuite;
780
781        let secret = Secret::generate_slh_dsa_sha2_128s(Some("k#k"));
782        let doc = json!({"hello": "slh"});
783
784        let proof = DataIntegrityProof::sign(
785            &doc,
786            &secret,
787            SignOptions::new().with_cryptosuite(CryptoSuite::SlhDsa128Jcs2024),
788        )
789        .await
790        .expect("sign slh-dsa");
791
792        proof
793            .verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
794            .expect("verify slh-dsa");
795    }
796}