Skip to main content

affinidi_data_integrity/
multi.rs

1//! Multi-proof signing and verification.
2//!
3//! The W3C Data Integrity spec allows more than one proof on a single
4//! document. This module provides first-class ergonomics for two key
5//! use cases:
6//!
7//! - **Hybrid / PQC migration.** Attach both an Ed25519 proof and an
8//!   ML-DSA-44 proof to the same credential so classical and
9//!   post-quantum verifiers can each verify their preferred suite,
10//!   during the (multi-year) transition period.
11//! - **Witness / threshold schemes.** Collect signatures from N
12//!   witnesses, accept the document if at least `t` of them verify —
13//!   e.g. did:webvh-style update logs.
14//!
15//! # sign_multi semantics
16//!
17//! [`DataIntegrityProof::sign_multi`] is **fail-fast**: if any signer
18//! errors, no proofs are emitted and the error bubbles up. This matches
19//! the typical "issuer wants the credential fully signed or not at all"
20//! model. Callers who want best-effort semantics can loop
21//! [`DataIntegrityProof::sign`] themselves and collect results.
22//!
23//! # verify_multi semantics
24//!
25//! Configurable via [`VerifyPolicy`]. The library returns a
26//! [`MultiVerifyResult`] that lists which proofs passed, which failed
27//! (with per-proof structured errors), and whether the overall policy
28//! was satisfied. Callers can log the full audit trail while only
29//! needing to check one bool for the go/no-go decision.
30
31use serde::Serialize;
32
33use crate::{
34    DataIntegrityError, DataIntegrityProof, SignOptions, VerificationMethodResolver, VerifyOptions,
35    signer::Signer,
36};
37
38/// Acceptance rule for [`verify_multi`].
39///
40/// # Examples
41///
42/// ```ignore
43/// use affinidi_data_integrity::multi::VerifyPolicy;
44///
45/// // Every proof must verify:
46/// let strict = VerifyPolicy::RequireAll;
47///
48/// // At least three of N witnesses must verify:
49/// let quorum = VerifyPolicy::RequireThreshold(3);
50///
51/// // Hybrid classical+PQC: accept if *any* proof verifies (client picks
52/// // the strongest proof it understands):
53/// let hybrid = VerifyPolicy::RequireAny;
54/// ```
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56#[non_exhaustive]
57pub enum VerifyPolicy {
58    /// Every proof in the set must verify. The set must be non-empty.
59    RequireAll,
60    /// At least one proof must verify.
61    RequireAny,
62    /// At least `n` proofs must verify. A threshold of zero is treated
63    /// as [`RequireAll`] (the degenerate case is a user error —
64    /// callers should pick a meaningful threshold).
65    ///
66    /// [`RequireAll`]: VerifyPolicy::RequireAll
67    RequireThreshold(usize),
68}
69
70/// Per-proof verification outcome and the overall policy decision.
71#[derive(Debug)]
72#[non_exhaustive]
73pub struct MultiVerifyResult<'a> {
74    /// Proofs that verified successfully.
75    pub passed: Vec<&'a DataIntegrityProof>,
76    /// Proofs that failed, paired with the structured error returned by
77    /// the verifier.
78    pub failed: Vec<(&'a DataIntegrityProof, DataIntegrityError)>,
79    /// Did the combination satisfy the requested policy?
80    pub policy_satisfied: bool,
81}
82
83impl MultiVerifyResult<'_> {
84    /// Returns `Err(MultiProofPolicyFailed)` if the policy was not
85    /// satisfied, otherwise `Ok(())`. Use this when you want a single
86    /// `?`-propagatable result instead of inspecting the struct.
87    pub fn into_result(self) -> Result<(), DataIntegrityError> {
88        if self.policy_satisfied {
89            Ok(())
90        } else {
91            Err(DataIntegrityError::Conformance(format!(
92                "multi-proof policy not satisfied ({} passed, {} failed)",
93                self.passed.len(),
94                self.failed.len()
95            )))
96        }
97    }
98}
99
100impl DataIntegrityProof {
101    /// Signs `data_doc` with every signer in `signers`, producing one
102    /// [`DataIntegrityProof`] per signer.
103    ///
104    /// **Fail-fast**: if any signer errors, no proofs are emitted and
105    /// the error is returned immediately.
106    ///
107    /// All signers share the same [`SignOptions`] — the per-signer
108    /// cryptosuite still defaults via [`Signer::cryptosuite`], so a
109    /// heterogeneous list of signers (Ed25519 + ML-DSA-44) produces one
110    /// proof per suite automatically.
111    pub async fn sign_multi<S>(
112        data_doc: &S,
113        signers: &[&dyn Signer],
114        options: SignOptions,
115    ) -> Result<Vec<DataIntegrityProof>, DataIntegrityError>
116    where
117        S: Serialize,
118    {
119        if signers.is_empty() {
120            return Err(DataIntegrityError::MalformedProof(
121                "sign_multi called with no signers".to_string(),
122            ));
123        }
124
125        // Pin `created` once for the whole batch so every emitted proof
126        // carries an identical timestamp. Without this, the first signer
127        // might run at t0 and the last at t0+~ms, producing proofs with
128        // different `created` fields — surprising for callers that do
129        // byte-exact interop.
130        let mut options = options;
131        if options.created.is_none() {
132            options.created = Some(chrono::Utc::now());
133        }
134
135        let mut proofs = Vec::with_capacity(signers.len());
136        for signer in signers {
137            let proof = DataIntegrityProof::sign(data_doc, *signer, options.clone()).await?;
138            proofs.push(proof);
139        }
140        Ok(proofs)
141    }
142}
143
144/// Verifies multiple proofs against the same document, enforcing
145/// [`VerifyPolicy`].
146///
147/// Every proof is verified independently via
148/// [`DataIntegrityProof::verify`] (i.e. the `verificationMethod` of each
149/// proof is resolved through `resolver`). The returned
150/// [`MultiVerifyResult`] lists per-proof outcomes plus the policy
151/// decision.
152pub async fn verify_multi<'a, S, R>(
153    proofs: &'a [DataIntegrityProof],
154    data_doc: &S,
155    resolver: &R,
156    options: VerifyOptions,
157    policy: VerifyPolicy,
158) -> MultiVerifyResult<'a>
159where
160    S: Serialize + Sync,
161    R: VerificationMethodResolver + ?Sized,
162{
163    let mut passed = Vec::new();
164    let mut failed = Vec::new();
165
166    for proof in proofs {
167        match proof.verify(data_doc, resolver, options.clone()).await {
168            Ok(()) => passed.push(proof),
169            Err(e) => failed.push((proof, e)),
170        }
171    }
172
173    let policy_satisfied = match policy {
174        VerifyPolicy::RequireAll => !proofs.is_empty() && failed.is_empty(),
175        VerifyPolicy::RequireAny => !passed.is_empty(),
176        VerifyPolicy::RequireThreshold(n) => {
177            if n == 0 {
178                !proofs.is_empty() && failed.is_empty()
179            } else {
180                passed.len() >= n
181            }
182        }
183    };
184
185    MultiVerifyResult {
186        passed,
187        failed,
188        policy_satisfied,
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::DidKeyResolver;
196    use affinidi_secrets_resolver::secrets::Secret;
197    use serde_json::json;
198
199    fn make_signer(kind: &str, seed: u8) -> Secret {
200        let secret = match kind {
201            "ed25519" => Secret::generate_ed25519(None, Some(&[seed; 32])),
202            #[cfg(feature = "ml-dsa")]
203            "ml-dsa-44" => Secret::generate_ml_dsa_44(None, Some(&[seed; 32])),
204            _ => panic!("unknown kind {kind}"),
205        };
206        let pk_mb = secret.get_public_keymultibase().unwrap();
207        let mut s = secret.clone();
208        s.id = format!("did:key:{pk_mb}#{pk_mb}");
209        s
210    }
211
212    #[tokio::test]
213    async fn sign_multi_pins_created_across_batch() {
214        // Without an explicit `created` in options, sign_multi still
215        // emits the same timestamp on every proof. Guards against
216        // timestamp drift from sequential Utc::now() calls.
217        let a = make_signer("ed25519", 10);
218        let b = make_signer("ed25519", 11);
219        let signers: Vec<&dyn Signer> = vec![&a, &b];
220        let doc = json!({"pin": "created"});
221        let proofs = DataIntegrityProof::sign_multi(&doc, &signers, SignOptions::new())
222            .await
223            .unwrap();
224        assert_eq!(proofs.len(), 2);
225        assert_eq!(proofs[0].created, proofs[1].created);
226    }
227
228    #[tokio::test]
229    async fn sign_multi_emits_one_proof_per_signer() {
230        let a = make_signer("ed25519", 1);
231        let b = make_signer("ed25519", 2);
232        let signers: Vec<&dyn Signer> = vec![&a, &b];
233        let doc = json!({"multi": true});
234        let proofs = DataIntegrityProof::sign_multi(&doc, &signers, SignOptions::new())
235            .await
236            .unwrap();
237        assert_eq!(proofs.len(), 2);
238    }
239
240    #[cfg(feature = "ml-dsa")]
241    #[tokio::test]
242    async fn sign_multi_hybrid_classical_and_pqc() {
243        // Ed25519 + ML-DSA-44 proofs on the same document — the PQC
244        // migration use case.
245        let classical = make_signer("ed25519", 9);
246        let pqc = make_signer("ml-dsa-44", 9);
247        let signers: Vec<&dyn Signer> = vec![&classical, &pqc];
248        let doc = json!({"hybrid": "yes"});
249
250        let proofs = DataIntegrityProof::sign_multi(&doc, &signers, SignOptions::new())
251            .await
252            .unwrap();
253        assert_eq!(proofs.len(), 2);
254        assert_eq!(
255            proofs[0].cryptosuite,
256            crate::crypto_suites::CryptoSuite::EddsaJcs2022
257        );
258        assert_eq!(
259            proofs[1].cryptosuite,
260            crate::crypto_suites::CryptoSuite::MlDsa44Jcs2024
261        );
262
263        // RequireAll: both must verify.
264        let result = verify_multi(
265            &proofs,
266            &doc,
267            &DidKeyResolver,
268            VerifyOptions::new(),
269            VerifyPolicy::RequireAll,
270        )
271        .await;
272        assert!(result.policy_satisfied);
273        assert_eq!(result.passed.len(), 2);
274        assert!(result.failed.is_empty());
275    }
276
277    #[tokio::test]
278    async fn verify_multi_require_any_tolerates_one_bad_proof() {
279        let good = make_signer("ed25519", 3);
280        let signers: Vec<&dyn Signer> = vec![&good];
281        let doc = json!({"x": 1});
282        let mut proofs = DataIntegrityProof::sign_multi(&doc, &signers, SignOptions::new())
283            .await
284            .unwrap();
285
286        // Corrupt a clone of the real proof so it fails verification.
287        let mut bad = proofs[0].clone();
288        let pv = bad.proof_value.take().unwrap();
289        let mut raw = multibase::decode(&pv).unwrap().1;
290        raw[0] ^= 0xff;
291        bad.proof_value = Some(multibase::encode(multibase::Base::Base58Btc, raw));
292        proofs.push(bad);
293
294        // RequireAny: one valid proof is enough.
295        let result = verify_multi(
296            &proofs,
297            &doc,
298            &DidKeyResolver,
299            VerifyOptions::new(),
300            VerifyPolicy::RequireAny,
301        )
302        .await;
303        assert!(result.policy_satisfied);
304        assert_eq!(result.passed.len(), 1);
305        assert_eq!(result.failed.len(), 1);
306
307        // RequireAll would fail on the same input.
308        let result = verify_multi(
309            &proofs,
310            &doc,
311            &DidKeyResolver,
312            VerifyOptions::new(),
313            VerifyPolicy::RequireAll,
314        )
315        .await;
316        assert!(!result.policy_satisfied);
317    }
318
319    #[tokio::test]
320    async fn verify_multi_threshold() {
321        let a = make_signer("ed25519", 1);
322        let b = make_signer("ed25519", 2);
323        let c = make_signer("ed25519", 3);
324        let signers: Vec<&dyn Signer> = vec![&a, &b, &c];
325        let doc = json!({"witnesses": 3});
326        let proofs = DataIntegrityProof::sign_multi(&doc, &signers, SignOptions::new())
327            .await
328            .unwrap();
329
330        let result = verify_multi(
331            &proofs,
332            &doc,
333            &DidKeyResolver,
334            VerifyOptions::new(),
335            VerifyPolicy::RequireThreshold(2),
336        )
337        .await;
338        assert!(result.policy_satisfied);
339        assert_eq!(result.passed.len(), 3);
340    }
341
342    /// `RequireThreshold(0)` must behave identically to `RequireAll` so
343    /// the degenerate case (caller accidentally passing 0) doesn't
344    /// silently accept every empty-proof set.
345    #[tokio::test]
346    async fn verify_multi_threshold_zero_equals_require_all() {
347        let a = make_signer("ed25519", 1);
348        let signers: Vec<&dyn Signer> = vec![&a];
349        let doc = json!({"t": 0});
350        let proofs = DataIntegrityProof::sign_multi(&doc, &signers, SignOptions::new())
351            .await
352            .unwrap();
353
354        let require_all = verify_multi(
355            &proofs,
356            &doc,
357            &DidKeyResolver,
358            VerifyOptions::new(),
359            VerifyPolicy::RequireAll,
360        )
361        .await;
362        let threshold_zero = verify_multi(
363            &proofs,
364            &doc,
365            &DidKeyResolver,
366            VerifyOptions::new(),
367            VerifyPolicy::RequireThreshold(0),
368        )
369        .await;
370        assert_eq!(
371            require_all.policy_satisfied,
372            threshold_zero.policy_satisfied
373        );
374        assert_eq!(require_all.passed.len(), threshold_zero.passed.len());
375
376        // Also: both must fail on an empty proof set.
377        let empty: Vec<DataIntegrityProof> = vec![];
378        let r = verify_multi(
379            &empty,
380            &doc,
381            &DidKeyResolver,
382            VerifyOptions::new(),
383            VerifyPolicy::RequireThreshold(0),
384        )
385        .await;
386        assert!(!r.policy_satisfied);
387    }
388
389    #[tokio::test]
390    async fn sign_multi_empty_signer_list_is_error() {
391        let doc = json!({});
392        let err = DataIntegrityProof::sign_multi(&doc, &[], SignOptions::new())
393            .await
394            .unwrap_err();
395        assert!(matches!(err, DataIntegrityError::MalformedProof(_)));
396    }
397}