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 underlying Data Integrity signing operation failed.
69    #[error(transparent)]
70    DataIntegrity(#[from] DataIntegrityError),
71}
72
73/// Sign a Trust Task document and return it with an embedded `proof`.
74///
75/// The proof is computed over the document with the `proof` member
76/// removed, exactly as the [`Verifier`](super::Verifier) canonicalises on
77/// the verify side. **Any existing `proof` member is discarded and
78/// replaced** — re-signing an already-signed document is treated as "mint
79/// a fresh proof over the current content", never as appending a proof
80/// set or signing over the old proof.
81///
82/// Defaults: `proofPurpose` falls back to `"assertionMethod"` (upstream
83/// default) and the cryptosuite to [`CryptoSuite::EddsaJcs2022`] whenever
84/// [`SignOptions::cryptosuite`] is unset — deliberately overriding the
85/// signer's own declared default so the wire suite does not silently vary
86/// with the signer implementation. Pass
87/// [`SignOptions::with_cryptosuite`] / [`SignOptions::with_proof_purpose`]
88/// to choose different values.
89///
90/// The document **must** already carry an in-band `issuer` equal to the
91/// DID of the signer's `verificationMethod` (the portion before `#`,
92/// compared by exact string equality per SPEC.md §4.8). This is the same
93/// binding the stock verifier enforces; checking it here means a document
94/// that could never verify is refused before a signature is produced.
95///
96/// `signer` is anything implementing the upstream
97/// [`Signer`](affinidi_data_integrity::signer::Signer) trait —
98/// an `affinidi_secrets_resolver::secrets::Secret` works directly, as do
99/// KMS/HSM-backed remote signers.
100pub async fn sign_trust_task(
101    doc: &Value,
102    signer: &dyn Signer,
103    options: SignOptions,
104) -> Result<Value, SignError> {
105    let Some(obj) = doc.as_object() else {
106        return Err(SignError::NotAnObject);
107    };
108
109    // ─── 1. Strip any existing proof: the signature is over the document
110    //        minus `proof`, and a re-sign replaces rather than nests.
111    let mut unsigned = obj.clone();
112    unsigned.remove("proof");
113
114    // ─── 2. Pre-flight the issuer binding (SPEC §4.7/§4.8): the verify
115    //        side rejects any proof whose verificationMethod DID differs
116    //        from the in-band issuer, so refuse to mint one.
117    let issuer = unsigned
118        .get("issuer")
119        .and_then(|v| v.as_str())
120        .ok_or(SignError::MissingIssuer)?;
121    let vm = signer.verification_method();
122    let vm_did = vm.split('#').next().unwrap_or(vm);
123    if vm_did != issuer {
124        return Err(SignError::IssuerMismatch {
125            vm_did: vm_did.to_string(),
126            issuer: issuer.to_string(),
127        });
128    }
129
130    // ─── 3. Apply the ecosystem default suite when the caller picked
131    //        none. Done here (not left to the signer's declared default)
132    //        so the emitted suite is deterministic across signer
133    //        implementations.
134    let mut options = options;
135    if options.cryptosuite.is_none() {
136        options.cryptosuite = Some(CryptoSuite::EddsaJcs2022);
137    }
138
139    // ─── 4. Sign the proof-less document and embed the result.
140    let unsigned = Value::Object(unsigned);
141    let proof = DataIntegrityProof::sign(&unsigned, signer, options).await?;
142
143    let Value::Object(mut signed) = unsigned else {
144        unreachable!("constructed as an object above");
145    };
146    signed.insert("proof".to_string(), serde_json::to_value(&proof)?);
147    Ok(Value::Object(signed))
148}