Skip to main content

vti_common/auth/
di_proof.rs

1//! Single `eddsa-jcs-2022` Data-Integrity proof verifier for Trust Task
2//! documents (P1.4).
3//!
4//! Every place that verifies a holder's DI proof on a Trust Task and recovers
5//! the cryptographically-proven signer DID delegates here. In the **VTA**: the
6//! canonical REST authenticate path (`routes/auth.rs::
7//! verify_authenticate_proof`, signer unknown a priori) and the did-signed
8//! step-up gate (`trust_tasks/step_up.rs::verify_did_signed_gate`, signer
9//! checked against the document issuer). In the **VTC**: the same REST
10//! authenticate path, and the join-request dispatcher's holder-binding check
11//! (`trust_tasks/helpers.rs::verify_trust_task_proof`).
12//!
13//! It started as one implementation in the VTA that had already drifted into
14//! two copies there, then a third when the VTC ported it. It lives in
15//! `vti-common` because *both services verify the same holder proof over the
16//! same wire shape* — a divergence between them is a divergence in what a
17//! signature means, which is not a thing to let happen twice.
18//!
19//! `did:key` resolution is local (no network I/O) — the mobile holder key is
20//! always a `did:key`, matching the engine's signing side, and it keeps proof
21//! verification off the network on an unauthenticated route.
22
23use affinidi_data_integrity::{DataIntegrityProof, DidKeyResolver, VerifyOptions};
24use serde_json::Value;
25use trust_tasks_rs::TrustTask;
26
27/// Why a Trust Task DI-proof verification failed. Callers map these onto their
28/// own transport error types (`AppError::Authentication`, `GateError`, …).
29#[derive(Debug)]
30pub enum DiProofError {
31    /// The document carries no `proof`.
32    NoProof,
33    /// The `proof` block is not a Data-Integrity proof.
34    NotDataIntegrity,
35    /// The proof's `verificationMethod` carries no DID.
36    NoDid,
37    /// The signature failed to verify (carries the underlying reason).
38    VerifyFailed(String),
39}
40
41impl std::fmt::Display for DiProofError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::NoProof => write!(f, "document has no proof"),
45            Self::NotDataIntegrity => write!(f, "proof is not a Data Integrity proof"),
46            Self::NoDid => write!(f, "proof verificationMethod carries no DID"),
47            Self::VerifyFailed(e) => write!(f, "proof verification failed: {e}"),
48        }
49    }
50}
51
52/// Verify the `eddsa-jcs-2022` Data-Integrity proof on `doc` and return the
53/// proven signer DID — the base DID (before `#`) of the proof's
54/// `verificationMethod`.
55///
56/// The signature is verified over the document with its `proof` block removed
57/// (`eddsa-jcs-2022` canonicalises the proofless document via JCS). The
58/// returned DID is *proven*, not merely claimed; binding it to an expected
59/// identity (session DID, document issuer) is the caller's job.
60pub async fn verify_trust_task_proof(doc: &TrustTask<Value>) -> Result<String, DiProofError> {
61    let proof = doc.proof.as_ref().ok_or(DiProofError::NoProof)?;
62
63    // The framework `Proof` round-trips into a `DataIntegrityProof` (same shape;
64    // the mobile engine builds it the same way).
65    let di: DataIntegrityProof = serde_json::to_value(proof)
66        .ok()
67        .and_then(|v| serde_json::from_value(v).ok())
68        .ok_or(DiProofError::NotDataIntegrity)?;
69
70    let signer_did = di
71        .verification_method
72        .split('#')
73        .next()
74        .unwrap_or_default()
75        .to_string();
76    if signer_did.is_empty() {
77        return Err(DiProofError::NoDid);
78    }
79
80    let mut unsigned = doc.clone();
81    unsigned.proof = None;
82    di.verify(&unsigned, &DidKeyResolver, VerifyOptions::new())
83        .await
84        .map_err(|e| DiProofError::VerifyFailed(e.to_string()))?;
85
86    Ok(signer_did)
87}