Skip to main content

vta_sdk/protocols/
credential_exchange.rs

1//! `credential-exchange/*` Trust Task family — Phase 3 (spec §6).
2//!
3//! The **Trust Task is the transport / auth / threading / relayer envelope**;
4//! the **body is OID4VCI** (issuance) or **OID4VP + DCQL** (presentation). This
5//! module is the *message-type layer* both sides build on: the versioned URIs +
6//! the request/response body shapes. Handlers (issuer/verifier on the VTC,
7//! holder on the VTA) land in later Phase 3 slices.
8//!
9//! ```text
10//! Issuance (OID4VCI):
11//!   issuer → holder    credential-exchange/offer/1.0     { credential_offer }
12//!   holder → issuer    credential-exchange/request/1.0   { credential_request }   (key-binding proof)
13//!   issuer → holder    credential-exchange/issue/1.0     { credential_response | sealed }
14//!
15//! Presentation (OID4VP + DCQL):
16//!   verifier → holder  credential-exchange/query/1.0     { dcql_query, nonce, purpose }
17//!   holder → verifier  credential-exchange/present/1.0   { vp_token }
18//! ```
19//!
20//! **Format-agnostic** (spec D4): the issued `credential` and the `vp_token`
21//! carry whichever credential format — SD-JWT-VC, W3C Data-Integrity, or BBS+ —
22//! the DCQL `format` selector negotiated. Nothing here is format-specific.
23//!
24//! `purpose` on a [`QueryBody`] is **mandatory** and shown to the holder
25//! (purpose binding): a verifier cannot ask for a credential without stating
26//! why.
27
28use affinidi_openid4vci::{CredentialOffer, CredentialRequest, CredentialResponse};
29use affinidi_openid4vp::DcqlQuery;
30use chrono::{DateTime, Utc};
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34// ── Canonical Trust Task URIs (trusttasks.org/spec form) ──
35
36/// issuer → holder: a credential offer.
37pub const OFFER: &str = "https://trusttasks.org/spec/credential-exchange/offer/1.0";
38/// holder → issuer: a credential request.
39pub const REQUEST: &str = "https://trusttasks.org/spec/credential-exchange/request/1.0";
40/// issuer → holder: the issued credential.
41pub const ISSUE: &str = "https://trusttasks.org/spec/credential-exchange/issue/1.0";
42/// verifier → holder: a DCQL query.
43pub const QUERY: &str = "https://trusttasks.org/spec/credential-exchange/query/1.0";
44/// holder → verifier: a presentation.
45pub const PRESENT: &str = "https://trusttasks.org/spec/credential-exchange/present/1.0";
46
47// ── Deferred-presentation approval surface (holder operator → own VTA) ──
48//
49// When a verifier the holder hasn't pre-trusted sends a `query/1.0`, the VTA
50// **defers** it: it persists a pending record and tells the verifier "consent
51// required" (see `vta-service`'s `handle_credential_query`). These three tasks
52// are the holder operator's out-of-band surface over that backlog — list the
53// deferrals, then approve (re-present, producing the `vp_token`) or deny. All
54// three are **super-admin only**: the credentials presented are the VTA's own.
55
56/// holder operator → own VTA: list deferred presentations awaiting a decision.
57pub const PENDING_LIST: &str = "https://trusttasks.org/spec/credential-exchange/pending-list/1.0";
58/// holder operator → own VTA: approve a deferral and re-present (returns the
59/// `vp_token` in a [`PresentBody`]).
60pub const PENDING_APPROVE: &str =
61    "https://trusttasks.org/spec/credential-exchange/pending-approve/1.0";
62/// holder operator → own VTA: deny a deferral (no presentation is made).
63pub const PENDING_DENY: &str = "https://trusttasks.org/spec/credential-exchange/pending-deny/1.0";
64
65/// `offer/1.0` — issuer → holder. An OID4VCI credential offer.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct OfferBody {
68    pub credential_offer: CredentialOffer,
69}
70
71/// `request/1.0` — holder → issuer. An OID4VCI credential request carrying the
72/// holder's key-binding proof.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct RequestBody {
75    pub credential_request: CredentialRequest,
76}
77
78/// `issue/1.0` — issuer → holder. Exactly one of:
79///
80/// - `credential_response` — the cleartext OID4VCI response (the issued
81///   credential), for a known holder over an authenticated channel; or
82/// - `sealed` — an armored [`crate::sealed_transfer`] bundle, when the
83///   credential is secret-bearing or issued to an **unknown holder** (the
84///   invite / air-gap case): only the holder can open it.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct IssueBody {
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub credential_response: Option<CredentialResponse>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub sealed: Option<String>,
91}
92
93/// `query/1.0` — verifier → holder. A DCQL query + freshness nonce + a
94/// **mandatory** `purpose` shown to the holder.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct QueryBody {
97    pub dcql_query: DcqlQuery,
98    pub nonce: String,
99    /// The verifier's stated reason for the request — shown to the holder
100    /// (purpose binding). Never optional.
101    pub purpose: String,
102}
103
104/// `present/1.0` — holder → verifier. The OID4VP `vp_token` carrying the
105/// selectively-disclosed, holder-bound presentation.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct PresentBody {
108    pub vp_token: Value,
109}
110
111/// `pending-list/1.0` request — empty. The caller's super-admin authentication
112/// scopes the result to this VTA's own deferred presentations.
113#[derive(Debug, Clone, Default, Serialize, Deserialize)]
114pub struct PendingListBody {}
115
116/// One deferred presentation awaiting the holder's decision — the
117/// approver-facing view. The internal record additionally stores the full DCQL
118/// query for a byte-faithful re-present; that is **not** exposed here.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct PendingPresentationSummary {
121    /// Approval handle (the verifier's DIDComm thread id).
122    pub id: String,
123    /// The verifier that asked. The approved presentation binds to this audience.
124    pub verifier_did: String,
125    /// Every held credential the query would disclose — what the approver authorizes.
126    pub requested: Vec<RequestedCredentialSummary>,
127    /// The verifier's stated purpose (purpose binding), shown to the approver.
128    pub purpose: String,
129    /// When the deferral was recorded.
130    pub created_at: DateTime<Utc>,
131    /// After this the deferral is stale and approval refuses (the verifier's
132    /// nonce is no longer fresh).
133    pub expires_at: DateTime<Utc>,
134}
135
136/// One held credential a deferred query asked for.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct RequestedCredentialSummary {
139    /// The DCQL `credential_query_id` this credential satisfied.
140    pub credential_query_id: String,
141    /// The held credential that would satisfy it.
142    pub credential_id: String,
143    /// The claims the query asks to disclose.
144    pub claims: Vec<String>,
145}
146
147/// `pending-list/1.0` response — the actionable deferrals (`Pending`, not yet
148/// expired). Terminal and stale records are omitted (they can't be acted on).
149#[derive(Debug, Clone, Default, Serialize, Deserialize)]
150pub struct PendingListResponse {
151    pub pending: Vec<PendingPresentationSummary>,
152}
153
154/// `pending-approve/1.0` request — the deferral id to approve and re-present.
155/// The response is a [`PresentBody`] carrying the freshly-minted `vp_token`.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct PendingApproveBody {
158    pub id: String,
159}
160
161/// `pending-deny/1.0` request — the deferral id to refuse.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct PendingDenyBody {
164    pub id: String,
165}
166
167/// `pending-deny/1.0` response — the refused id and its terminal status
168/// (`"denied"`). The record is removed (delete-on-terminal), so a follow-up
169/// list won't show it.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct PendingDenyResponse {
172    pub id: String,
173    pub status: String,
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use serde_json::json;
180
181    #[test]
182    fn query_body_round_trips_with_a_dcql_query() {
183        let dcql = DcqlQuery::from_json(&json!({
184            "credentials": [{
185                "id": "membership",
186                "format": "dc+sd-jwt",
187                "meta": { "vct_values": ["https://openvtc.org/credentials/MembershipCredential"] }
188            }]
189        }))
190        .unwrap();
191        let body = QueryBody {
192            dcql_query: dcql,
193            nonce: "n-123".into(),
194            purpose: "join the Acme community".into(),
195        };
196        let wire = serde_json::to_string(&body).unwrap();
197        let back: QueryBody = serde_json::from_str(&wire).unwrap();
198        assert_eq!(back.nonce, "n-123");
199        assert_eq!(back.purpose, "join the Acme community");
200        assert_eq!(back.dcql_query.credentials.len(), 1);
201    }
202
203    #[test]
204    fn issue_body_carries_a_sealed_bundle() {
205        let body = IssueBody {
206            credential_response: None,
207            sealed: Some("-----BEGIN VTA SEALED-----\n…\n-----END VTA SEALED-----".into()),
208        };
209        let wire = serde_json::to_value(&body).unwrap();
210        assert!(wire.get("sealed").is_some());
211        assert!(
212            wire.get("credentialResponse").is_none() && wire.get("credential_response").is_none(),
213            "absent cleartext response is omitted: {wire}"
214        );
215        let back: IssueBody = serde_json::from_value(wire).unwrap();
216        assert!(back.sealed.is_some() && back.credential_response.is_none());
217    }
218
219    #[test]
220    fn present_body_round_trips() {
221        let body = PresentBody {
222            vp_token: json!("<jws>~<disclosure>~<kb-jwt>"),
223        };
224        let back: PresentBody =
225            serde_json::from_str(&serde_json::to_string(&body).unwrap()).unwrap();
226        assert_eq!(back.vp_token, json!("<jws>~<disclosure>~<kb-jwt>"));
227    }
228
229    #[test]
230    fn uris_are_versioned_and_distinct() {
231        let all = [
232            OFFER,
233            REQUEST,
234            ISSUE,
235            QUERY,
236            PRESENT,
237            PENDING_LIST,
238            PENDING_APPROVE,
239            PENDING_DENY,
240        ];
241        for u in all {
242            assert!(u.starts_with("https://trusttasks.org/spec/credential-exchange/"));
243            assert!(u.ends_with("/1.0"));
244        }
245        // all distinct
246        for (i, a) in all.iter().enumerate() {
247            for b in &all[i + 1..] {
248                assert_ne!(a, b);
249            }
250        }
251    }
252}