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, verify_conformance_with_skew};
88pub use did_vm::{DidKeyResolver, ResolvedKey, VerificationMethodResolver};
89pub use multi::{MultiVerifyResult, VerifyPolicy, verify_multi};
90
91/// **Deprecated** — the legacy affinidi-internal `bbs-2023` encoding (not
92/// interoperable with other vc-di-bbs implementations). Use
93/// [`bbs_2023_transform`] instead. Enabled via the `bbs-2023` feature flag.
94#[cfg(feature = "bbs-2023")]
95pub mod bbs_2023;
96
97/// W3C vc-di-bbs `bbs-2023` cryptosuite (RDF-canonical, standards-interoperable)
98/// — **the** `bbs-2023` implementation. Pinned byte-for-byte to the official
99/// `w3c/vc-di-bbs` vectors: issuer ([`bbs_2023_transform::sign_base_document`]),
100/// holder ([`bbs_2023_transform::create_derived_proof`]), verifier
101/// ([`bbs_2023_transform::verify_derived_proof`]), plus per-verifier pseudonym /
102/// holder binding. Supersedes the legacy [`bbs_2023`] module.
103#[cfg(feature = "bbs-2023")]
104pub mod bbs_2023_transform;
105
106pub use error::{DataIntegrityError, SignatureFailure};
107pub use options::{DEFAULT_CLOCK_SKEW, SignOptions, VerifyOptions};
108
109/// Serialized Data Integrity proof.
110///
111/// `#[non_exhaustive]`: produce via [`DataIntegrityProof::sign`] or
112/// [`DataIntegrityProof::new`] rather than a struct literal. Fields stay public
113/// for reads.
114#[derive(Clone, Debug, Deserialize, Serialize)]
115#[serde(rename_all = "camelCase")]
116#[non_exhaustive]
117pub struct DataIntegrityProof {
118    /// Must be 'DataIntegrityProof'
119    #[serde(rename = "type")]
120    pub type_: String,
121
122    pub cryptosuite: CryptoSuite,
123
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub created: Option<String>,
126
127    pub verification_method: String,
128
129    pub proof_purpose: String,
130
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub proof_value: Option<String>,
133
134    #[serde(rename = "@context", skip_serializing_if = "Option::is_none")]
135    pub context: Option<Vec<String>>,
136}
137
138impl DataIntegrityProof {
139    /// Assemble a Data Integrity proof from its parts. `type_` is fixed to
140    /// `"DataIntegrityProof"`. Most callers should use [`DataIntegrityProof::sign`];
141    /// this is for reconstructing a proof from known components (e.g. tests or
142    /// custom flows).
143    #[allow(clippy::too_many_arguments)]
144    pub fn new(
145        cryptosuite: CryptoSuite,
146        verification_method: String,
147        proof_purpose: String,
148        proof_value: Option<String>,
149        created: Option<String>,
150        context: Option<Vec<String>>,
151    ) -> Self {
152        Self {
153            type_: "DataIntegrityProof".to_string(),
154            cryptosuite,
155            created,
156            verification_method,
157            proof_purpose,
158            proof_value,
159            context,
160        }
161    }
162
163    /// Produces a Data Integrity proof over `data_doc`.
164    ///
165    /// The cryptosuite is picked from [`SignOptions::cryptosuite`] if
166    /// set, otherwise from [`Signer::cryptosuite`]. Canonicalization
167    /// (JCS or RDFC) is derived from the suite.
168    ///
169    pub async fn sign<S>(
170        data_doc: &S,
171        signer: &dyn Signer,
172        options: SignOptions,
173    ) -> Result<DataIntegrityProof, DataIntegrityError>
174    where
175        S: Serialize,
176    {
177        let crypto_suite = options.cryptosuite.unwrap_or_else(|| signer.cryptosuite());
178        crypto_suite
179            .validate_key_type(signer.key_type())
180            .map_err(|_| DataIntegrityError::KeyTypeMismatch {
181                expected: crypto_suite
182                    .compatible_key_types()
183                    .first()
184                    .copied()
185                    .unwrap_or(affinidi_secrets_resolver::secrets::KeyType::Unknown),
186                actual: signer.key_type(),
187                suite: crypto_suite,
188            })?;
189
190        let created_str = options
191            .created
192            .map(format_created)
193            .unwrap_or_else(|| format_created(Utc::now()));
194
195        let proof_purpose = options
196            .proof_purpose
197            .unwrap_or_else(|| "assertionMethod".to_string());
198
199        if crypto_suite.is_rdfc() {
200            sign_rdfc(
201                data_doc,
202                crypto_suite,
203                options.context,
204                signer,
205                created_str,
206                proof_purpose,
207            )
208            .await
209        } else {
210            sign_jcs(
211                data_doc,
212                crypto_suite,
213                options.context,
214                signer,
215                created_str,
216                proof_purpose,
217            )
218            .await
219        }
220    }
221
222    /// Verifies a proof against `data_doc` using caller-provided public
223    /// key bytes.
224    ///
225    /// Sync because this is pure CPU — callers who already have the key
226    /// should not be forced into an async runtime. See [`verify`] for
227    /// the resolver-based async variant.
228    ///
229    /// [`verify`]: Self::verify
230    #[must_use = "ignoring a verification result is a security bug"]
231    pub fn verify_with_public_key<S>(
232        &self,
233        data_doc: &S,
234        public_key_bytes: &[u8],
235        options: VerifyOptions,
236    ) -> Result<(), DataIntegrityError>
237    where
238        S: Serialize,
239    {
240        verify_proof_internal(self, data_doc, public_key_bytes, &options)
241    }
242
243    /// Verifies a proof by resolving the public key from its
244    /// `verificationMethod` via a [`VerificationMethodResolver`].
245    ///
246    /// Use [`did_vm::DidKeyResolver`] for `did:key:` URIs (no I/O); plug
247    /// in a custom resolver for `did:web`, `did:webvh`, or any other
248    /// method. The library also checks that the resolved key's
249    /// [`KeyType`] matches the proof's cryptosuite — a cheap guard
250    /// against "right proof, wrong key" class bugs.
251    ///
252    /// Async because typical resolvers perform I/O (HTTP, cache lookups,
253    /// HSM introspection).
254    ///
255    /// [`KeyType`]: affinidi_secrets_resolver::secrets::KeyType
256    #[must_use = "ignoring a verification result is a security bug"]
257    pub async fn verify<S, R>(
258        &self,
259        data_doc: &S,
260        resolver: &R,
261        options: VerifyOptions,
262    ) -> Result<(), DataIntegrityError>
263    where
264        S: Serialize + Sync,
265        R: VerificationMethodResolver + ?Sized,
266    {
267        let resolved = resolver.resolve_vm(&self.verification_method).await?;
268
269        // Belt-and-braces key-type check. The CryptoSuiteOps verify will
270        // fail on a mismatched key anyway, but a typed error here is
271        // clearer to callers and saves the canonicalization work.
272        let compatible = self.cryptosuite.compatible_key_types();
273        if !compatible.is_empty() && !compatible.contains(&resolved.key_type) {
274            return Err(DataIntegrityError::KeyTypeMismatch {
275                expected: compatible
276                    .first()
277                    .copied()
278                    .unwrap_or(affinidi_secrets_resolver::secrets::KeyType::Unknown),
279                actual: resolved.key_type,
280                suite: self.cryptosuite,
281            });
282        }
283
284        verify_proof_internal(self, data_doc, &resolved.public_key_bytes, &options)
285    }
286}
287
288// -----------------------------------------------------------------------
289// Internal signing helpers
290// -----------------------------------------------------------------------
291
292async fn sign_jcs<S>(
293    data_doc: &S,
294    crypto_suite: CryptoSuite,
295    context: Option<Vec<String>>,
296    signer: &dyn Signer,
297    created: String,
298    proof_purpose: String,
299) -> Result<DataIntegrityProof, DataIntegrityError>
300where
301    S: Serialize,
302{
303    let jcs = to_string(data_doc)
304        .map_err(|e| DataIntegrityError::Canonicalization(format!("document: {e}")))?;
305    debug!("Document (JCS): {}", jcs);
306
307    let mut proof_options = DataIntegrityProof {
308        type_: "DataIntegrityProof".to_string(),
309        cryptosuite: crypto_suite,
310        created: Some(created),
311        verification_method: signer.verification_method().to_string(),
312        proof_purpose,
313        proof_value: None,
314        context,
315    };
316
317    let proof_jcs = to_string(&proof_options)
318        .map_err(|e| DataIntegrityError::Canonicalization(format!("proof config: {e}")))?;
319    debug!("Proof options (JCS): {}", proof_jcs);
320
321    let hash_data = hashing_jcs(&jcs, &proof_jcs);
322    let signed = signer.sign(&hash_data).await?;
323    proof_options.proof_value = Some(multibase::encode(Base::Base58Btc, &signed));
324
325    Ok(proof_options)
326}
327
328async fn sign_rdfc<S>(
329    data_doc: &S,
330    crypto_suite: CryptoSuite,
331    context: Option<Vec<String>>,
332    signer: &dyn Signer,
333    created: String,
334    proof_purpose: String,
335) -> Result<DataIntegrityProof, DataIntegrityError>
336where
337    S: Serialize,
338{
339    let doc_value = serde_json::to_value(data_doc)
340        .map_err(|e| DataIntegrityError::Canonicalization(format!("document serialize: {e}")))?;
341
342    // Proof context: caller override, else pulled from document @context.
343    let proof_context = if let Some(ctx) = context {
344        Some(ctx)
345    } else {
346        match doc_value.get("@context") {
347            Some(serde_json::Value::Array(arr)) => Some(
348                arr.iter()
349                    .filter_map(|v| v.as_str().map(str::to_string))
350                    .collect(),
351            ),
352            Some(serde_json::Value::String(s)) => Some(vec![s.clone()]),
353            Some(_) => {
354                return Err(DataIntegrityError::MalformedProof(
355                    "Invalid @context format in document".to_string(),
356                ));
357            }
358            None => {
359                return Err(DataIntegrityError::MalformedProof(
360                    "Document must contain @context for RDFC signing".to_string(),
361                ));
362            }
363        }
364    };
365
366    let mut proof_options = DataIntegrityProof {
367        type_: "DataIntegrityProof".to_string(),
368        cryptosuite: crypto_suite,
369        created: Some(created),
370        verification_method: signer.verification_method().to_string(),
371        proof_purpose,
372        proof_value: None,
373        context: proof_context,
374    };
375
376    let proof_value = serde_json::to_value(&proof_options).map_err(|e| {
377        DataIntegrityError::Canonicalization(format!("proof config serialize: {e}"))
378    })?;
379
380    let hash_data = hashing_rdfc(&doc_value, &proof_value)?;
381    let signed = signer.sign(&hash_data).await?;
382    proof_options.proof_value = Some(multibase::encode(Base::Base58Btc, &signed));
383
384    Ok(proof_options)
385}
386
387fn verify_proof_internal<S>(
388    proof: &DataIntegrityProof,
389    signed_doc: &S,
390    public_key_bytes: &[u8],
391    options: &VerifyOptions,
392) -> Result<(), DataIntegrityError>
393where
394    S: Serialize,
395{
396    // Cryptosuite allowlist.
397    if !options.allowed_suites.is_empty() && !options.allowed_suites.contains(&proof.cryptosuite) {
398        return Err(DataIntegrityError::Conformance(format!(
399            "cryptosuite {} is not in the caller's allowed suites",
400            String::try_from(proof.cryptosuite).unwrap_or_default()
401        )));
402    }
403
404    // Context match (only when caller explicitly supplied one).
405    if let Some(expected) = &options.expected_context
406        && proof.context.as_ref() != Some(expected)
407    {
408        return Err(DataIntegrityError::Conformance(
409            "Document context does not match proof context".to_string(),
410        ));
411    }
412
413    // Decode proofValue.
414    let Some(proof_value) = &proof.proof_value else {
415        return Err(DataIntegrityError::MalformedProof(
416            "proofValue is missing in the proof".to_string(),
417        ));
418    };
419    let proof_value = multibase::decode(proof_value)
420        .map_err(|e| DataIntegrityError::MalformedProof(format!("Invalid proof value: {e}")))?
421        .1;
422
423    // Strip the proof_value from the proof config for re-hashing.
424    let proof_config = DataIntegrityProof {
425        proof_value: None,
426        ..proof.clone()
427    };
428
429    if proof_config.type_ != "DataIntegrityProof" {
430        return Err(DataIntegrityError::Conformance(
431            "Invalid proof type, expected 'DataIntegrityProof'".to_string(),
432        ));
433    }
434
435    // `created` is stamped by the signer's clock and checked against
436    // ours, so a strict comparison makes acceptance a race between clock
437    // skew and delivery latency. Allow `options.clock_skew` (default 60s)
438    // of drift; a negative setting is clamped to zero rather than making
439    // the check *stricter* than exact.
440    if let Some(created) = &proof_config.created {
441        let skew = options.clock_skew.max(chrono::TimeDelta::zero());
442        // `checked_add_signed` so an absurd caller-supplied skew saturates
443        // instead of panicking on overflow, as `+` would.
444        let horizon = Utc::now()
445            .checked_add_signed(skew)
446            .unwrap_or(DateTime::<Utc>::MAX_UTC);
447        let created = created
448            .parse::<DateTime<Utc>>()
449            .map_err(|e| DataIntegrityError::Conformance(format!("Invalid created date: {e}")))?;
450        if created > horizon {
451            return Err(DataIntegrityError::Conformance(format!(
452                "Created date is in the future (beyond the {}s clock-skew allowance)",
453                skew.num_seconds()
454            )));
455        }
456    }
457
458    // Canonicalize & hash (JCS or RDFC depending on suite).
459    let hash_data = if proof_config.cryptosuite.is_rdfc() {
460        let doc_value = serde_json::to_value(signed_doc).map_err(|e| {
461            DataIntegrityError::Canonicalization(format!("document serialize: {e}"))
462        })?;
463        let proof_value_json = serde_json::to_value(&proof_config).map_err(|e| {
464            DataIntegrityError::Canonicalization(format!("proof config serialize: {e}"))
465        })?;
466        hashing_rdfc(&doc_value, &proof_value_json)?
467    } else {
468        #[cfg(feature = "bbs-2023")]
469        if matches!(proof_config.cryptosuite, CryptoSuite::Bbs2023) {
470            return Err(DataIntegrityError::UnsupportedCryptoSuite {
471                name: "bbs-2023 derived proofs are verified via \
472                       bbs_2023_transform::verify_derived_proof (or \
473                       verify_pseudonym_derived_proof), not the generic verify path"
474                    .to_string(),
475            });
476        }
477        let jcs_doc = to_string(&signed_doc)
478            .map_err(|e| DataIntegrityError::Canonicalization(format!("document: {e}")))?;
479        let jcs_proof_config = to_string(&proof_config)
480            .map_err(|e| DataIntegrityError::Canonicalization(format!("proof config: {e}")))?;
481        hashing_jcs(&jcs_doc, &jcs_proof_config)
482    };
483
484    proof_config
485        .cryptosuite
486        .verify(public_key_bytes, &hash_data, &proof_value)
487}
488
489// -----------------------------------------------------------------------
490// Hashing pipelines (shared by all cryptosuites in this family)
491// -----------------------------------------------------------------------
492
493/// Hashing Algorithm for EDDSA JCS
494fn hashing_jcs(transformed_document: &str, canonical_proof_config: &str) -> Vec<u8> {
495    [
496        Sha256::digest(canonical_proof_config),
497        Sha256::digest(transformed_document),
498    ]
499    .concat()
500}
501
502/// Hashing Algorithm for EDDSA RDFC.
503/// Runs both document and proof config through the RDFC pipeline
504/// (JSON-LD expansion → RDF Dataset → RDFC-1.0 canonicalization → SHA-256)
505/// and concatenates the two 32-byte hashes.
506fn hashing_rdfc(
507    document: &serde_json::Value,
508    proof_config: &serde_json::Value,
509) -> Result<Vec<u8>, DataIntegrityError> {
510    let doc_hash = affinidi_rdf_encoding::expand_canonicalize_and_hash(document)
511        .map_err(|e| DataIntegrityError::Canonicalization(format!("RDFC document hash: {e}")))?;
512
513    let proof_hash =
514        affinidi_rdf_encoding::expand_canonicalize_and_hash(proof_config).map_err(|e| {
515            DataIntegrityError::Canonicalization(format!("RDFC proof config hash: {e}"))
516        })?;
517
518    Ok([proof_hash.as_slice(), doc_hash.as_slice()].concat())
519}
520
521// -----------------------------------------------------------------------
522// Remote-signer helper: returns the exact bytes a signer is expected to
523// sign over, so remote-signing protocols can compute the input ahead of
524// time without recomputing the canonicalization/hash pipeline.
525// -----------------------------------------------------------------------
526
527/// Returns the byte string a [`Signer`] is expected to sign over, given
528/// a document, a partial proof config, and the target cryptosuite.
529///
530/// Remote signers (KMS, HSM) typically want this so they can submit a
531/// well-formed "sign these bytes" request to their backend without
532/// re-implementing canonicalization. The returned bytes are exactly
533/// what [`DataIntegrityProof::sign`] passes to `signer.sign(data)`.
534///
535/// `proof_config` should be the proof JSON value with `proofValue`
536/// absent but all other fields set (cryptosuite, verificationMethod,
537/// proofPurpose, created, optional @context).
538pub fn prepare_sign_input<S>(
539    data_doc: &S,
540    proof_config: &DataIntegrityProof,
541    cryptosuite: CryptoSuite,
542) -> Result<Vec<u8>, DataIntegrityError>
543where
544    S: Serialize,
545{
546    if cryptosuite.is_rdfc() {
547        let doc_value = serde_json::to_value(data_doc).map_err(|e| {
548            DataIntegrityError::Canonicalization(format!("document serialize: {e}"))
549        })?;
550        let proof_value = serde_json::to_value(proof_config).map_err(|e| {
551            DataIntegrityError::Canonicalization(format!("proof config serialize: {e}"))
552        })?;
553        hashing_rdfc(&doc_value, &proof_value)
554    } else {
555        let jcs_doc = to_string(data_doc)
556            .map_err(|e| DataIntegrityError::Canonicalization(format!("document: {e}")))?;
557        let jcs_proof = to_string(proof_config)
558            .map_err(|e| DataIntegrityError::Canonicalization(format!("proof config: {e}")))?;
559        Ok(hashing_jcs(&jcs_doc, &jcs_proof))
560    }
561}
562
563// -----------------------------------------------------------------------
564// Internal date helpers
565// -----------------------------------------------------------------------
566
567fn format_created(dt: DateTime<Utc>) -> String {
568    dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
569}
570
571#[cfg(test)]
572mod tests {
573    use affinidi_secrets_resolver::secrets::Secret;
574    use chrono::Utc;
575    use serde_json::json;
576
577    use crate::{DataIntegrityError, DataIntegrityProof, SignOptions, VerifyOptions, hashing_jcs};
578
579    #[test]
580    fn hashing_working() {
581        let hash = hashing_jcs("test1", "test2");
582        let mut output = String::new();
583        for x in hash {
584            output.push_str(&format!("{x:02x}"));
585        }
586
587        assert_eq!(
588            output.as_str(),
589            "60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c7521b4f0e9851971998e732078544c96b36c3d01cedf7caa332359d6f1d83567014",
590        );
591    }
592
593    #[tokio::test]
594    async fn sign_and_verify_via_did_key_resolver_ed25519() {
595        use crate::{DidKeyResolver, VerifyOptions};
596
597        let secret = Secret::generate_ed25519(None, Some(&[11u8; 32]));
598        let pk_mb = secret.get_public_keymultibase().unwrap();
599        // Use the library-built VM URI so the resolver can find the key.
600        let mut signer_secret = secret.clone();
601        signer_secret.id = format!("did:key:{pk_mb}#{pk_mb}");
602
603        let doc = json!({ "hello": "did:key" });
604        let proof = DataIntegrityProof::sign(&doc, &signer_secret, SignOptions::new())
605            .await
606            .expect("sign");
607
608        proof
609            .verify(&doc, &DidKeyResolver, VerifyOptions::new())
610            .await
611            .expect("verify via resolver");
612    }
613
614    /// The end-to-end property that matters: a P-256 key signs a document and
615    /// the proof verifies through the ordinary `did:key` resolver path, with no
616    /// suite named by hand at either end.
617    #[tokio::test]
618    async fn sign_and_verify_via_did_key_resolver_p256() {
619        use crate::{DidKeyResolver, VerifyOptions};
620
621        let secret = Secret::generate_p256(None, None).expect("generate P-256");
622        let pk_mb = secret.get_public_keymultibase().unwrap();
623        let mut signer_secret = secret.clone();
624        signer_secret.id = format!("did:key:{pk_mb}#{pk_mb}");
625
626        let doc = json!({ "ecdsa": "did:key" });
627        let proof = DataIntegrityProof::sign(&doc, &signer_secret, SignOptions::new())
628            .await
629            .expect("sign");
630        assert_eq!(
631            proof.cryptosuite,
632            crate::crypto_suites::CryptoSuite::EcdsaJcs2019,
633            "a P-256 signer must default to ecdsa-jcs-2019 without being told"
634        );
635
636        proof
637            .verify(&doc, &DidKeyResolver, VerifyOptions::new())
638            .await
639            .expect("verify via resolver");
640    }
641
642    /// A proof must not survive its document changing — ECDSA is randomised, so
643    /// a round-trip test alone would still pass against a verify that ignored
644    /// the payload.
645    #[tokio::test]
646    async fn a_p256_proof_does_not_verify_against_a_tampered_document() {
647        use crate::{DidKeyResolver, VerifyOptions};
648
649        let secret = Secret::generate_p256(None, None).expect("generate P-256");
650        let pk_mb = secret.get_public_keymultibase().unwrap();
651        let mut signer_secret = secret.clone();
652        signer_secret.id = format!("did:key:{pk_mb}#{pk_mb}");
653
654        let doc = json!({ "amount": 10 });
655        let proof = DataIntegrityProof::sign(&doc, &signer_secret, SignOptions::new())
656            .await
657            .expect("sign");
658
659        let tampered = json!({ "amount": 1000 });
660        assert!(
661            proof
662                .verify(&tampered, &DidKeyResolver, VerifyOptions::new())
663                .await
664                .is_err(),
665            "a P-256 proof must not verify against a document it did not sign"
666        );
667    }
668
669    #[cfg(feature = "ml-dsa")]
670    #[tokio::test]
671    async fn sign_and_verify_via_did_key_resolver_ml_dsa() {
672        use crate::{DidKeyResolver, VerifyOptions};
673
674        let secret = Secret::generate_ml_dsa_44(None, Some(&[21u8; 32]));
675        let pk_mb = secret.get_public_keymultibase().unwrap();
676        let mut signer_secret = secret.clone();
677        signer_secret.id = format!("did:key:{pk_mb}#{pk_mb}");
678
679        let doc = json!({ "pqc": "did:key" });
680        let proof = DataIntegrityProof::sign(&doc, &signer_secret, SignOptions::new())
681            .await
682            .expect("sign");
683
684        proof
685            .verify(&doc, &DidKeyResolver, VerifyOptions::new())
686            .await
687            .expect("verify via resolver");
688    }
689
690    #[tokio::test]
691    async fn unified_sign_verify_ed25519_jcs() {
692        let secret = Secret::generate_ed25519(Some("did:key:k#k"), Some(&[4u8; 32]));
693        let doc = json!({"hello": "world"});
694        let proof = DataIntegrityProof::sign(&doc, &secret, SignOptions::new())
695            .await
696            .expect("sign");
697        proof
698            .verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
699            .expect("verify");
700    }
701
702    #[cfg(feature = "ml-dsa")]
703    #[tokio::test]
704    async fn unified_sign_verify_ml_dsa_44_jcs() {
705        let secret = Secret::generate_ml_dsa_44(Some("did:key:k#k"), Some(&[8u8; 32]));
706        let doc = json!({"pqc": true});
707        let proof = DataIntegrityProof::sign(&doc, &secret, SignOptions::new())
708            .await
709            .expect("sign");
710        // Signer defaulted to mldsa44-jcs-2024 via Signer::cryptosuite().
711        assert_eq!(
712            proof.cryptosuite,
713            crate::crypto_suites::CryptoSuite::MlDsa44Jcs2024
714        );
715        proof
716            .verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
717            .expect("verify");
718    }
719
720    #[cfg(feature = "ml-dsa")]
721    #[tokio::test]
722    async fn override_suite_via_sign_options() {
723        // An Ed25519 signer asked to produce mldsa44 must fail with
724        // KeyTypeMismatch — the caller overrode the default.
725        let secret = Secret::generate_ed25519(Some("did:key:k#k"), Some(&[1u8; 32]));
726        let doc = json!({"x": 1});
727        let err = DataIntegrityProof::sign(
728            &doc,
729            &secret,
730            SignOptions::new().with_cryptosuite(crate::crypto_suites::CryptoSuite::MlDsa44Jcs2024),
731        )
732        .await
733        .unwrap_err();
734        assert!(matches!(
735            err,
736            crate::DataIntegrityError::KeyTypeMismatch { .. }
737        ));
738    }
739
740    /// A signer whose clock runs slightly ahead of the verifier's stamps
741    /// `created` in the verifier's future. With zero tolerance this made
742    /// acceptance a race between clock skew and delivery latency — the
743    /// same proof verified or not depending on how fast it arrived. The
744    /// default allowance must absorb it.
745    #[tokio::test]
746    async fn verify_tolerates_default_clock_skew_on_created() {
747        let secret = Secret::generate_ed25519(Some("did:key:k#k"), Some(&[3u8; 32]));
748        let doc = json!({"skewed": true});
749        let proof = DataIntegrityProof::sign(
750            &doc,
751            &secret,
752            SignOptions::new().with_created(Utc::now() + chrono::TimeDelta::seconds(5)),
753        )
754        .await
755        .expect("sign");
756
757        proof
758            .verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
759            .expect("a 5s-ahead signer is inside the default 60s allowance");
760    }
761
762    /// The allowance is bounded: a `created` beyond it is still rejected,
763    /// and the error names the allowance so the cause is diagnosable from
764    /// the wire message alone.
765    #[tokio::test]
766    async fn verify_rejects_created_beyond_the_allowance() {
767        let secret = Secret::generate_ed25519(Some("did:key:k#k"), Some(&[4u8; 32]));
768        let doc = json!({"skewed": "far"});
769        let proof = DataIntegrityProof::sign(
770            &doc,
771            &secret,
772            SignOptions::new().with_created(Utc::now() + chrono::TimeDelta::hours(1)),
773        )
774        .await
775        .expect("sign");
776
777        let err = proof
778            .verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
779            .expect_err("an hour ahead is well beyond 60s");
780        let msg = format!("{err}");
781        assert!(msg.contains("future"), "unexpected message: {msg}");
782        assert!(
783            msg.contains("60s"),
784            "message should name the allowance: {msg}"
785        );
786    }
787
788    /// Zero skew restores the strict pre-allowance behaviour for callers
789    /// that want it.
790    #[tokio::test]
791    async fn verify_zero_skew_rejects_any_future_created() {
792        let secret = Secret::generate_ed25519(Some("did:key:k#k"), Some(&[5u8; 32]));
793        let doc = json!({"strict": true});
794        let proof = DataIntegrityProof::sign(
795            &doc,
796            &secret,
797            SignOptions::new().with_created(Utc::now() + chrono::TimeDelta::seconds(5)),
798        )
799        .await
800        .expect("sign");
801
802        let err = proof
803            .verify_with_public_key(
804                &doc,
805                secret.get_public_bytes(),
806                VerifyOptions::new().with_clock_skew(chrono::TimeDelta::zero()),
807            )
808            .expect_err("zero tolerance must reject a future created");
809        assert!(matches!(err, DataIntegrityError::Conformance(_)));
810    }
811
812    /// An attacker rewrites the `cryptosuite` field on a proof they
813    /// otherwise can't forge — e.g. swaps `eddsa-jcs-2022` to
814    /// `eddsa-rdfc-2022`. Verification must fail because the
815    /// canonicalization axis changes the hashed bytes.
816    #[tokio::test]
817    async fn verify_rejects_cryptosuite_tampering() {
818        use crate::crypto_suites::CryptoSuite;
819
820        let secret = Secret::generate_ed25519(Some("did:key:k#k"), Some(&[77u8; 32]));
821        let doc = json!({"tamper": "target"});
822        let mut proof = DataIntegrityProof::sign(&doc, &secret, SignOptions::new())
823            .await
824            .expect("sign");
825        assert_eq!(proof.cryptosuite, CryptoSuite::EddsaJcs2022);
826
827        // Attacker flips the suite.
828        proof.cryptosuite = CryptoSuite::EddsaRdfc2022;
829
830        let err = proof
831            .verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
832            .unwrap_err();
833        assert!(
834            matches!(err, crate::DataIntegrityError::InvalidSignature { .. }),
835            "expected InvalidSignature after cryptosuite tampering, got: {err:?}"
836        );
837    }
838
839    #[tokio::test]
840    async fn deterministic_signing_same_input_same_output() {
841        let secret = Secret::generate_ed25519(Some("did:key:k#k"), Some(&[2u8; 32]));
842        let doc = json!({"deterministic": "yes"});
843        let created = chrono::Utc::now();
844        let opts = || SignOptions::new().with_created(created);
845        let a = DataIntegrityProof::sign(&doc, &secret, opts())
846            .await
847            .unwrap();
848        let b = DataIntegrityProof::sign(&doc, &secret, opts())
849            .await
850            .unwrap();
851        assert_eq!(
852            a.proof_value, b.proof_value,
853            "Ed25519 must be deterministic"
854        );
855    }
856
857    #[cfg(feature = "ml-dsa")]
858    #[tokio::test]
859    async fn deterministic_signing_ml_dsa() {
860        let secret = Secret::generate_ml_dsa_44(Some("did:key:k#k"), Some(&[5u8; 32]));
861        let doc = json!({"deterministic": "pqc"});
862        let created = chrono::Utc::now();
863        let opts = || SignOptions::new().with_created(created);
864        let a = DataIntegrityProof::sign(&doc, &secret, opts())
865            .await
866            .unwrap();
867        let b = DataIntegrityProof::sign(&doc, &secret, opts())
868            .await
869            .unwrap();
870        assert_eq!(a.proof_value, b.proof_value, "ML-DSA must be deterministic");
871    }
872
873    /// This key used to be the crate's "bad key" fixture, on the strength of
874    /// signing failing for it. It is a **P-256** key, and the failure was only
875    /// ever "no suite compiled in for this key type" — so `ecdsa-jcs-2019`
876    /// turns it into a supported key, and the old assertion asserted the
877    /// absence of a feature rather than any property of the key.
878    #[tokio::test]
879    async fn test_sign_p256_key_now_produces_an_ecdsa_jcs_2019_proof() {
880        let generic_doc = json!({"test": "test_data"});
881        let pub_key = "zruqgFba156mDWfMUjJUSAKUvgCgF5NfgSYwSuEZuXpixts8tw3ot5BasjeyM65f8dzk5k6zgXf7pkbaaBnPrjCUmcJ";
882        let pri_key = "z42tmXtqqQBLmEEwn8tfi1bA2ghBx9cBo6wo8a44kVJEiqyA";
883        let secret = Secret::from_multibase(pri_key, Some(&format!("did:key:{pub_key}#{pub_key}")))
884            .expect("Couldn't create test key data");
885        assert_eq!(
886            secret.get_key_type(),
887            affinidi_secrets_resolver::secrets::KeyType::P256
888        );
889
890        let proof = DataIntegrityProof::sign(&generic_doc, &secret, SignOptions::new())
891            .await
892            .expect("a P-256 key signs under ecdsa-jcs-2019");
893        assert_eq!(
894            proof.cryptosuite,
895            crate::crypto_suites::CryptoSuite::EcdsaJcs2019
896        );
897    }
898
899    /// The genuine "unsupported key type" case the previous test was standing
900    /// in for. secp256k1 has no Data Integrity suite here, so it must still
901    /// fail — otherwise a key type nothing can verify would sign silently.
902    #[tokio::test]
903    async fn test_sign_rejects_a_key_type_with_no_suite() {
904        let generic_doc = json!({"test": "test_data"});
905        let secret =
906            Secret::generate_secp256k1(Some("did:key:k#k"), None).expect("generate secp256k1");
907        assert!(
908            DataIntegrityProof::sign(&generic_doc, &secret, SignOptions::new())
909                .await
910                .is_err(),
911            "a key type with no compiled-in suite must not sign"
912        );
913    }
914
915    #[tokio::test]
916    async fn test_sign_good() {
917        let generic_doc = json!({"test": "test_data"});
918        let pub_key = "z6MktDNePDZTvVcF5t6u362SsonU7HkuVFSMVCjSspQLDaBm";
919        let pri_key = "z3u2UQyiY96d7VQaua8yiaSyQxq5Z5W5Qkpz7o2H2pc9BkEa";
920        let secret = Secret::from_multibase(pri_key, Some(&format!("did:key:{pub_key}#{pub_key}")))
921            .expect("Couldn't create test key data");
922        let context = vec![
923            "context1".to_string(),
924            "context2".to_string(),
925            "context3".to_string(),
926        ];
927        assert!(
928            DataIntegrityProof::sign(
929                &generic_doc,
930                &secret,
931                SignOptions::new().with_context(context)
932            )
933            .await
934            .is_ok(),
935            "Signing failed"
936        );
937    }
938
939    #[cfg(feature = "ml-dsa")]
940    #[tokio::test]
941    async fn sign_verify_jcs_ml_dsa_44() {
942        use crate::crypto_suites::CryptoSuite;
943
944        let secret = Secret::generate_ml_dsa_44(Some("k-did#k-did"), Some(&[5u8; 32]));
945        let doc = json!({"hello": "pqc"});
946
947        let proof = DataIntegrityProof::sign(
948            &doc,
949            &secret,
950            SignOptions::new().with_cryptosuite(CryptoSuite::MlDsa44Jcs2024),
951        )
952        .await
953        .expect("sign ml-dsa");
954
955        assert_eq!(proof.cryptosuite, CryptoSuite::MlDsa44Jcs2024);
956
957        proof
958            .verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
959            .expect("verify ml-dsa");
960    }
961
962    #[cfg(feature = "ml-dsa")]
963    #[tokio::test]
964    async fn sign_wrong_suite_for_key_fails() {
965        use crate::crypto_suites::CryptoSuite;
966
967        let secret = Secret::generate_ml_dsa_44(Some("k"), Some(&[1u8; 32]));
968        let doc = json!({"x": 1});
969        let err = DataIntegrityProof::sign(
970            &doc,
971            &secret,
972            SignOptions::new().with_cryptosuite(CryptoSuite::EddsaJcs2022),
973        )
974        .await;
975        assert!(err.is_err());
976    }
977
978    #[cfg(feature = "slh-dsa")]
979    #[tokio::test]
980    async fn sign_verify_jcs_slh_dsa_128s() {
981        use crate::crypto_suites::CryptoSuite;
982
983        let secret = Secret::generate_slh_dsa_sha2_128s(Some("k#k"));
984        let doc = json!({"hello": "slh"});
985
986        let proof = DataIntegrityProof::sign(
987            &doc,
988            &secret,
989            SignOptions::new().with_cryptosuite(CryptoSuite::SlhDsa128Jcs2024),
990        )
991        .await
992        .expect("sign slh-dsa");
993
994        proof
995            .verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
996            .expect("verify slh-dsa");
997    }
998}