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/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}