trust_tasks_proof/proof_ext.rs
1//! [`ProofExt`] — `.sign()` and `.verify()` as methods on a typed
2//! `TrustTask<P>`. Private module; the trait is re-exported at the crate
3//! root and carries the documentation.
4
5use async_trait::async_trait;
6use serde::Serialize;
7use trust_tasks_rs::{ProofVerifier, TrustTask, VerificationError};
8
9#[cfg(feature = "affinidi")]
10use crate::affinidi::{sign_trust_task, AffinidiSigner, SignError, SignOptions};
11#[cfg(feature = "affinidi")]
12use trust_tasks_rs::Proof;
13
14/// Extension trait adding [`sign`](Self::sign) and
15/// [`verify`](Self::verify) to the framework's [`TrustTask<P>`], for
16/// every payload type `P` a producer can serialise.
17///
18/// # Why this trait exists
19///
20/// [`sign_trust_task`](crate::affinidi::sign_trust_task) operates on a
21/// [`serde_json::Value`], because a W3C Data Integrity proof is computed
22/// over the *document's JSON form* and the framework's document type is
23/// generic over its payload. That is the right shape for the primitive
24/// and the wrong shape for a producer, who holds a `TrustTask<P>` and
25/// wants a signed `TrustTask<P>` back. Without this trait, signing is a
26/// five-step ritual — serialise, call, check, deserialise, reassign —
27/// that every producer writes out by hand and can get subtly wrong (most
28/// often by mutating the document *after* signing it).
29///
30/// `ProofExt` is a thin typed wrapper over the free functions, not a
31/// replacement for them. It reuses
32/// [`sign_trust_task`](crate::affinidi::sign_trust_task) verbatim, so the
33/// canonicalisation contract, the deterministic `eddsa-jcs-2022` default,
34/// the replace-don't-nest rule for an existing proof, and the SPEC.md
35/// §4.7/§4.8 issuer↔`verificationMethod` pre-flight are all exactly what
36/// that function already implements. A document signed through this trait
37/// and one signed through the free function are byte-identical.
38///
39/// ```rust,ignore
40/// use trust_tasks_proof::{affinidi::{SignOptions, Verifier}, ProofExt};
41/// use trust_tasks_rs::{specs::acl::grant::v0_1 as grant, TrustTask};
42///
43/// let mut req = TrustTask::for_payload(new_id(), grant::Payload { /* … */ });
44/// req.issuer = Some(my_did.clone()); // set every member first …
45/// req.recipient = Some(server_did.clone());
46/// req.sign(&secret, SignOptions::new()).await?; // … then sign.
47///
48/// // Consumer side, same trait:
49/// req.verify(&Verifier::for_did_key()).await?;
50/// ```
51///
52/// # ⚠ Sign last
53///
54/// The proof covers the document as it stands at the moment
55/// [`sign`](Self::sign) is called. Mutating any member afterwards
56/// invalidates the signature, and nothing in the type system stops you —
57/// `sign` takes `&mut self` precisely so the call reads as the final step
58/// of composing the document. Re-signing after a change is always safe:
59/// the existing proof is discarded and a fresh one minted over the
60/// current content.
61#[async_trait]
62pub trait ProofExt {
63 /// Sign this document in place, attaching the resulting Data
64 /// Integrity proof to its `proof` member.
65 ///
66 /// Equivalent to serialising the document, calling
67 /// [`sign_trust_task`](crate::affinidi::sign_trust_task), and
68 /// deserialising the result — the round-trip through
69 /// [`serde_json::Value`] happens inside, over exactly the same
70 /// unsigned bytes, so the emitted proof is identical to the free
71 /// function's.
72 ///
73 /// Every rule the free function applies applies here:
74 ///
75 /// * The signature is computed over the document **minus** its
76 /// `proof` member. An existing proof is replaced, never nested and
77 /// never signed over.
78 /// * The document MUST already carry an in-band `issuer` equal to
79 /// the DID of the signer's `verificationMethod` (the part before
80 /// `#`). Otherwise the call fails with
81 /// [`SignError::MissingIssuer`] / [`SignError::IssuerMismatch`]
82 /// *before* a signature is produced, rather than emitting a
83 /// document no conforming verifier could accept.
84 /// * `proofPurpose` defaults to `assertionMethod` and the
85 /// cryptosuite to `eddsa-jcs-2022` unless
86 /// [`SignOptions`](crate::affinidi::SignOptions) says otherwise.
87 ///
88 /// On error, `self` is left untouched — a failed sign never leaves a
89 /// half-signed document behind.
90 ///
91 /// Available with the `affinidi` feature (on by default).
92 #[cfg(feature = "affinidi")]
93 async fn sign(
94 &mut self,
95 signer: &dyn AffinidiSigner,
96 options: SignOptions,
97 ) -> Result<(), SignError>;
98
99 /// Verify this document's `proof` with `verifier`.
100 ///
101 /// The mirror of [`sign`](Self::sign), and the argument-order flip of
102 /// [`ProofVerifier::verify`] — the same check, spelled from the
103 /// document's point of view so a consumer holding a `TrustTask<P>`
104 /// does not have to reach for the verifier first.
105 ///
106 /// Returns [`VerificationError`] on every failure mode, including a
107 /// document that carries no `proof` at all (the framework's
108 /// `proofRequired` check is a separate, earlier concern — see
109 /// [`TrustTask::enforce_spec_policy`]).
110 async fn verify<V>(&self, verifier: &V) -> Result<(), VerificationError>
111 where
112 V: ProofVerifier + ?Sized;
113}
114
115#[async_trait]
116impl<P> ProofExt for TrustTask<P>
117where
118 P: Serialize + Send + Sync,
119{
120 #[cfg(feature = "affinidi")]
121 async fn sign(
122 &mut self,
123 signer: &dyn AffinidiSigner,
124 options: SignOptions,
125 ) -> Result<(), SignError> {
126 // The document as the verifier will see it. `sign_trust_task`
127 // strips any existing `proof` itself, so nothing is done to
128 // `self` before the signature exists — which is what makes the
129 // failure paths below non-destructive.
130 let unsigned = serde_json::to_value(&*self)?;
131 let signed = sign_trust_task(&unsigned, signer, options).await?;
132
133 // Lift only the `proof` member back onto the typed document.
134 // Every other member came from `self` unchanged, so re-parsing
135 // the whole document into `TrustTask<P>` would add a lossy
136 // deserialise step for no gain — and a payload type whose serde
137 // round-trip is not exact would silently invalidate the proof it
138 // was just given.
139 let proof = signed
140 .get("proof")
141 .cloned()
142 .ok_or_else(|| SignError::ProofRoundTrip("signed document carries no proof".into()))?;
143 let proof: Proof =
144 serde_json::from_value(proof).map_err(|e| SignError::ProofRoundTrip(e.to_string()))?;
145
146 self.proof = Some(proof);
147 Ok(())
148 }
149
150 async fn verify<V>(&self, verifier: &V) -> Result<(), VerificationError>
151 where
152 V: ProofVerifier + ?Sized,
153 {
154 verifier.verify(self).await
155 }
156}