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}