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: 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}