trust_tasks_proof/affinidi/sign.rs
1//! [`sign_trust_task`] — the sign-side counterpart to the
2//! [`Verifier`](super::Verifier).
3//!
4//! Produces documents that the stock [`Verifier`](super::Verifier)
5//! accepts by construction: the proof is computed over the document with
6//! the `proof` member removed (the same canonicalisation contract the
7//! verify side applies), and the in-band `issuer` is checked *before*
8//! signing to equal the DID of the signer's `verificationMethod` — the
9//! §4.7/§4.8 issuer binding the verify side enforces. A document that
10//! would fail its own round-trip is rejected at sign time rather than at
11//! the consumer.
12//!
13//! Defaults match the reference ecosystem's signing profile:
14//! `proofPurpose: authentication` — a Trust Task document's proof shows
15//! that the party controlling the issuer identifier produced it, and
16//! `assertionMethod` is reserved for keys a specification authorises to
17//! attest — and the `eddsa-jcs-2022` cryptosuite. Both are applied here
18//! whenever the caller does not choose, overriding the upstream and
19//! signer-declared defaults so the emitted proof is deterministic.
20//! Override either via [`SignOptions`]; a `proofPurpose` naming no signing
21//! relationship (`keyAgreement`, or an unknown value) is refused.
22//!
23//! The signer's key must be listed under the chosen relationship in the
24//! issuer's DID document, or the stock verifier refuses the proof.
25//!
26//! ```rust,ignore
27//! use trust_tasks_proof::affinidi::{sign_trust_task, SignOptions};
28//!
29//! // `doc` is the Trust Task document as serde_json::Value, `issuer`
30//! // already set to the DID the secret's verification method belongs to.
31//! let signed = sign_trust_task(&doc, &secret, SignOptions::new()).await?;
32//! assert!(signed.get("proof").is_some());
33//! ```
34
35use affinidi_data_integrity::crypto_suites::CryptoSuite;
36use affinidi_data_integrity::signer::Signer;
37use affinidi_data_integrity::{DataIntegrityError, DataIntegrityProof, SignOptions};
38use serde_json::Value;
39
40use super::purpose::ProofPurpose;
41
42/// Errors surfaced by [`sign_trust_task`].
43#[derive(Debug, thiserror::Error)]
44#[non_exhaustive]
45pub enum SignError {
46 /// The supplied document is not a JSON object; a Trust Task document
47 /// is always a top-level object (SPEC.md §4.1).
48 #[error("Trust Task document must be a JSON object")]
49 NotAnObject,
50
51 /// The document carries no in-band `issuer` member. The stock
52 /// verifier binds every proof to the in-band issuer (SPEC.md §4.7 /
53 /// §4.8), so a proof minted without one could never verify; set
54 /// `issuer` before signing.
55 #[error("document carries no in-band `issuer` to bind the proof to")]
56 MissingIssuer,
57
58 /// The document's `issuer` is not the DID controlling the signer's
59 /// `verificationMethod`. Signing would succeed cryptographically but
60 /// the emitted document would be rejected by every conforming
61 /// verifier as an issuer-spoofing attempt.
62 #[error(
63 "signer's verificationMethod is controlled by {vm_did}, not the document issuer {issuer}"
64 )]
65 IssuerMismatch {
66 /// DID portion (before `#`) of the signer's `verificationMethod`.
67 vm_did: String,
68 /// The document's in-band `issuer`.
69 issuer: String,
70 },
71
72 /// The produced proof failed to serialise back to JSON.
73 #[error("serialise proof: {0}")]
74 Serialize(#[from] serde_json::Error),
75
76 /// The proof this function emitted could not be read back into the
77 /// framework's typed [`Proof`](trust_tasks_rs::Proof).
78 ///
79 /// Raised only by [`ProofExt::sign`](crate::ProofExt::sign), which
80 /// lifts the emitted `proof` member onto a typed document. Reaching
81 /// it means the upstream Data Integrity crate emitted a proof object
82 /// that is not shaped like SPEC.md §4.7 — a dependency-version
83 /// mismatch, not a caller error.
84 #[error("read back the emitted proof: {0}")]
85 ProofRoundTrip(String),
86
87 /// The underlying Data Integrity signing operation failed.
88 #[error(transparent)]
89 DataIntegrity(#[from] DataIntegrityError),
90}
91
92/// Sign a Trust Task document and return it with an embedded `proof`.
93///
94/// The proof is computed over the document with the `proof` member
95/// removed, exactly as the [`Verifier`](super::Verifier) canonicalises on
96/// the verify side. **Any existing `proof` member is discarded and
97/// replaced** — re-signing an already-signed document is treated as "mint
98/// a fresh proof over the current content", never as appending a proof
99/// set or signing over the old proof.
100///
101/// Defaults: `proofPurpose` falls back to `"authentication"` and the
102/// cryptosuite to [`CryptoSuite::EddsaJcs2022`] whenever the caller sets
103/// neither — deliberately overriding the upstream (`assertionMethod`) and
104/// the signer's own declared defaults so the proof does not silently vary
105/// with the dependency or signer implementation. Pass
106/// [`SignOptions::with_cryptosuite`] / [`SignOptions::with_proof_purpose`]
107/// to choose different values. A `proofPurpose` that names no signing
108/// relationship is refused with [`SignError::DataIntegrity`].
109///
110/// The document **must** already carry an in-band `issuer` equal to the
111/// DID of the signer's `verificationMethod` (the portion before `#`,
112/// compared by exact string equality per SPEC.md §4.8). This is the same
113/// binding the stock verifier enforces; checking it here means a document
114/// that could never verify is refused before a signature is produced.
115///
116/// `signer` is anything implementing the upstream
117/// [`Signer`](affinidi_data_integrity::signer::Signer) trait —
118/// an `affinidi_secrets_resolver::secrets::Secret` works directly, as do
119/// KMS/HSM-backed remote signers.
120pub async fn sign_trust_task(
121 doc: &Value,
122 signer: &dyn Signer,
123 options: SignOptions,
124) -> Result<Value, SignError> {
125 let Some(obj) = doc.as_object() else {
126 return Err(SignError::NotAnObject);
127 };
128
129 // ─── 1. Strip any existing proof: the signature is over the document
130 // minus `proof`, and a re-sign replaces rather than nests.
131 let mut unsigned = obj.clone();
132 unsigned.remove("proof");
133
134 // ─── 2. Pre-flight the issuer binding (SPEC §4.7/§4.8): the verify
135 // side rejects any proof whose verificationMethod DID differs
136 // from the in-band issuer, so refuse to mint one.
137 let issuer = unsigned
138 .get("issuer")
139 .and_then(|v| v.as_str())
140 .ok_or(SignError::MissingIssuer)?;
141 let vm = signer.verification_method();
142 let vm_did = vm.split('#').next().unwrap_or(vm);
143 if vm_did != issuer {
144 return Err(SignError::IssuerMismatch {
145 vm_did: vm_did.to_string(),
146 issuer: issuer.to_string(),
147 });
148 }
149
150 // ─── 3. Apply the ecosystem defaults when the caller picked none.
151 // Done here (not left to the upstream or signer defaults) so
152 // the emitted proof is deterministic across implementations.
153 // A purpose no verifier accepts is refused before signing.
154 let mut options = options;
155 if options.cryptosuite.is_none() {
156 options.cryptosuite = Some(CryptoSuite::EddsaJcs2022);
157 }
158 let purpose = match options.proof_purpose.as_deref() {
159 None => ProofPurpose::Authentication,
160 Some(value) => ProofPurpose::parse(value)?,
161 };
162 options.proof_purpose = Some(purpose.as_str().to_string());
163
164 // ─── 4. Sign the proof-less document and embed the result.
165 let unsigned = Value::Object(unsigned);
166 let proof = DataIntegrityProof::sign(&unsigned, signer, options).await?;
167
168 let Value::Object(mut signed) = unsigned else {
169 unreachable!("constructed as an object above");
170 };
171 signed.insert("proof".to_string(), serde_json::to_value(&proof)?);
172 Ok(Value::Object(signed))
173}