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/0.1 { credential_offer }
12//! holder → issuer credential-exchange/request/0.1 { credential_request } (key-binding proof)
13//! issuer → holder credential-exchange/issue/0.1 { credential_response | sealed }
14//!
15//! Presentation (OID4VP + DCQL):
16//! verifier → holder credential-exchange/query/0.1 { dcql_query, nonce, purpose }
17//! holder → verifier credential-exchange/present/0.1 { 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/0.1";
38/// holder → issuer: a credential request.
39pub const REQUEST: &str = "https://trusttasks.org/spec/credential-exchange/request/0.1";
40/// issuer → holder: the issued credential.
41pub const ISSUE: &str = "https://trusttasks.org/spec/credential-exchange/issue/0.1";
42/// verifier → holder: a DCQL query.
43pub const QUERY: &str = "https://trusttasks.org/spec/credential-exchange/query/0.1";
44/// holder → verifier: a presentation.
45pub const PRESENT: &str = "https://trusttasks.org/spec/credential-exchange/present/0.1";
46
47// ── Deferred-presentation approval surface (holder operator → own VTA) ──
48//
49// When a verifier the holder hasn't pre-trusted sends a `query/0.1`, 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/0.1";
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/0.1";
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/0.1";
64
65/// `offer/0.1` — issuer → holder. An OID4VCI credential offer.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct OfferBody {
68 pub credential_offer: CredentialOffer,
69}
70
71/// `request/0.1` — 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/0.1` — 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/0.1` — 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/0.1` — 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/0.1` 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/// Wire form is camelCase: unlike the OID4VCI / OID4VP bodies above, every
120/// member here is our own, so the registry's casing convention applies
121/// (canonical `credential-exchange/_shared/0.1/deferred-presentation`).
122#[derive(Debug, Clone, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub struct PendingPresentationSummary {
125 /// Approval handle (the verifier's DIDComm thread id).
126 pub id: String,
127 /// The verifier that asked. The approved presentation binds to this audience.
128 pub verifier_did: String,
129 /// Every held credential the query would disclose — what the approver authorizes.
130 pub requested: Vec<RequestedCredentialSummary>,
131 /// The verifier's stated purpose (purpose binding), shown to the approver.
132 pub purpose: String,
133 /// When the deferral was recorded.
134 pub created_at: DateTime<Utc>,
135 /// After this the deferral is stale and approval refuses (the verifier's
136 /// nonce is no longer fresh).
137 pub expires_at: DateTime<Utc>,
138}
139
140/// One held credential a deferred query asked for.
141/// Wire form is camelCase — see [`PendingPresentationSummary`].
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct RequestedCredentialSummary {
145 /// The DCQL `credential_query_id` this credential satisfied.
146 pub credential_query_id: String,
147 /// The held credential that would satisfy it.
148 pub credential_id: String,
149 /// The claims the query asks to disclose.
150 pub claims: Vec<String>,
151}
152
153/// `pending/list/0.1` response — the actionable deferrals (`Pending`, not yet
154/// expired). Terminal and stale records are omitted (they can't be acted on).
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
156pub struct PendingListResponse {
157 pub pending: Vec<PendingPresentationSummary>,
158}
159
160/// `pending/approve/0.1` request — the deferral id to approve and re-present.
161/// The response is a [`PresentBody`] carrying the freshly-minted `vp_token`.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct PendingApproveBody {
164 pub id: String,
165}
166
167/// `pending/deny/0.1` request — the deferral id to refuse.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct PendingDenyBody {
170 pub id: String,
171}
172
173/// `pending/deny/0.1` response — the refused id and its terminal status
174/// (`"denied"`). The record is removed (delete-on-terminal), so a follow-up
175/// list won't show it.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct PendingDenyResponse {
178 pub id: String,
179 pub status: String,
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use serde_json::json;
186
187 #[test]
188 fn query_body_round_trips_with_a_dcql_query() {
189 let dcql = DcqlQuery::from_json(&json!({
190 "credentials": [{
191 "id": "membership",
192 "format": "dc+sd-jwt",
193 "meta": { "vct_values": ["https://openvtc.org/credentials/MembershipCredential"] }
194 }]
195 }))
196 .unwrap();
197 let body = QueryBody {
198 dcql_query: dcql,
199 nonce: "n-123".into(),
200 purpose: "join the Acme community".into(),
201 };
202 let wire = serde_json::to_string(&body).unwrap();
203 let back: QueryBody = serde_json::from_str(&wire).unwrap();
204 assert_eq!(back.nonce, "n-123");
205 assert_eq!(back.purpose, "join the Acme community");
206 assert_eq!(back.dcql_query.credentials.len(), 1);
207 }
208
209 #[test]
210 fn issue_body_carries_a_sealed_bundle() {
211 let body = IssueBody {
212 credential_response: None,
213 sealed: Some("-----BEGIN VTA SEALED-----\n…\n-----END VTA SEALED-----".into()),
214 };
215 let wire = serde_json::to_value(&body).unwrap();
216 assert!(wire.get("sealed").is_some());
217 assert!(
218 wire.get("credentialResponse").is_none() && wire.get("credential_response").is_none(),
219 "absent cleartext response is omitted: {wire}"
220 );
221 let back: IssueBody = serde_json::from_value(wire).unwrap();
222 assert!(back.sealed.is_some() && back.credential_response.is_none());
223 }
224
225 #[test]
226 fn present_body_round_trips() {
227 let body = PresentBody {
228 vp_token: json!("<jws>~<disclosure>~<kb-jwt>"),
229 };
230 let back: PresentBody =
231 serde_json::from_str(&serde_json::to_string(&body).unwrap()).unwrap();
232 assert_eq!(back.vp_token, json!("<jws>~<disclosure>~<kb-jwt>"));
233 }
234
235 #[test]
236 fn uris_are_versioned_and_distinct() {
237 let all = [
238 OFFER,
239 REQUEST,
240 ISSUE,
241 QUERY,
242 PRESENT,
243 PENDING_LIST,
244 PENDING_APPROVE,
245 PENDING_DENY,
246 ];
247 for u in all {
248 assert!(u.starts_with("https://trusttasks.org/spec/credential-exchange/"));
249 assert!(u.ends_with("/0.1"), "{u}");
250 }
251 // The operator surface is nested under `pending/`, not flat siblings of
252 // the party-to-party protocol tasks. Pinned because the flat form is
253 // what shipped first and is the easy thing to reintroduce by hand.
254 for u in [PENDING_LIST, PENDING_APPROVE, PENDING_DENY] {
255 assert!(
256 u.starts_with("https://trusttasks.org/spec/credential-exchange/pending/"),
257 "{u}"
258 );
259 }
260 // all distinct
261 for (i, a) in all.iter().enumerate() {
262 for b in &all[i + 1..] {
263 assert_ne!(a, b);
264 }
265 }
266 }
267}