Skip to main content

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