1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
//! The community's link to its VTA at runtime: Trust Tasks this VTC sends the
//! VTA as an integration holding an ACL entry there, and the replies it awaits.
//!
//! Two uses share it:
//!
//! - an OpenID4VP verifier identity whose key the VTA keeps signs each request
//! object through `keys/sign-jws/0.1` (#1988, custody B), so the VTC never
//! holds the key. The VTC's DID needs an entry at the VTA in the key's
//! context carrying `sign-jws`;
//! - room files in a `vta-account` storage config are read and written with
//! presigned requests the VTA issues one object at a time
//! (`external/credentials/issue/0.1`, [`crate::rooms::vta_storage`]).
//!
//! In both the VTA decides, as for any other caller.
//!
//! # Transport
//!
//! [`PeerTasks`]: TSP > DIDComm, by the service types the VTA's DID document
//! advertises, on the VTC's existing mediator socket — the mediator permits one
//! per DID, and a TSP send is an HTTP post on the same profile. There is no
//! REST arm: the VTA answers HTTPS Trust Tasks only to a session, and a link
//! that fell back to one would need a credential this VTC does not keep. Nor
//! does the VTA need an address of its own that this VTC could reach. A VTA
//! that advertises neither is refused by name, never downgraded past.
//!
//! Each document is signed with the VTC's operational key, and a reply is
//! accepted only when its own proof verifies as the VTA ([`PendingReplies`]).
//! One request, one attempt: the caller owns the retry, so a lost reply is an
//! error, not a resend.
use std::sync::Arc;
use std::time::Duration;
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use serde_json::{Map, Value, json};
use tokio::sync::OnceCell;
use trust_tasks_rs::TrustTask;
use vti_common::error::AppError;
use crate::credentials::LocalSigner;
use crate::hooks::PendingReplies;
use crate::messaging::VtcMessaging;
use crate::outbound::{PeerError, PeerTasks};
/// How long a VTA has to answer one `keys/sign-jws`. A wallet is waiting on it.
const REPLY_TIMEOUT: Duration = Duration::from_secs(20);
/// A Trust-Task link from this VTC to its VTA.
pub struct VtaLink {
vta_did: String,
peers: PeerTasks,
reply_timeout: Duration,
}
impl VtaLink {
pub fn new(
vta_did: String,
messaging: Arc<OnceCell<Arc<VtcMessaging>>>,
signer: Arc<LocalSigner>,
replies: PendingReplies,
resolver: DIDCacheClient,
) -> Self {
Self {
vta_did,
peers: PeerTasks {
didcomm: messaging,
signer: Some(signer),
replies,
resolver: Some(resolver),
},
reply_timeout: REPLY_TIMEOUT,
}
}
/// The VTA this link reaches.
pub fn vta_did(&self) -> &str {
&self.vta_did
}
/// Sign one document of `type_uri` to the VTA, send it over the best
/// transport both sides advertise, and await the VTA's signed reply for up
/// to `timeout`.
pub async fn exchange(
&self,
type_uri: &str,
payload: &Value,
timeout: Duration,
) -> Result<TrustTask<Value>, PeerError> {
self.peers
.exchange(&self.vta_did, type_uri, payload, timeout)
.await
}
/// [`Self::exchange`] for a caller that reports in [`AppError`].
async fn round_trip(
&self,
type_uri: &str,
payload: Value,
) -> Result<TrustTask<Value>, AppError> {
self.exchange(type_uri, &payload, self.reply_timeout)
.await
.map_err(|e| match e {
PeerError::Transient(m) => AppError::Internal(format!("the VTA link: {m}")),
PeerError::NoMatchingProtocol(m) => AppError::Internal(format!(
"no transport to the VTA: {m} (this VTC speaks TSP or DIDComm once its \
messaging is up)"
)),
})
}
}
/// The JWS out of a `keys/sign-jws` reply, or the VTA's refusal named.
fn sign_jws_answer(reply: &TrustTask<Value>, key_id: &str) -> Result<String, AppError> {
if reply.type_uri.slug() == "trust-task-error" {
let code = reply
.payload
.get("code")
.and_then(Value::as_str)
.unwrap_or("unknown");
let message = reply
.payload
.get("message")
.and_then(Value::as_str)
.unwrap_or("");
let hint = if code == "permissionDenied" {
" — grant this VTC's DID `sign-jws` in the key's context at the VTA"
} else {
""
};
return Err(AppError::Internal(format!(
"the VTA refused keys/sign-jws for key {key_id}: {code} {message}{hint}"
)));
}
if !reply.type_uri.is_response() || reply.type_uri.slug() != "keys/sign-jws" {
return Err(AppError::Internal(format!(
"expected a keys/sign-jws response from the VTA, got `{}`",
reply.type_uri
)));
}
reply
.payload
.get("jws")
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| AppError::Internal("the VTA's keys/sign-jws response has no jws".into()))
}
#[async_trait::async_trait]
impl crate::oid4vp::VtaJwsSigner for VtaLink {
/// The messaging transport's own connection signal — the one
/// `vtc/registry/diagnostics` reports as `messagingStatus` — so a link
/// whose mediator socket dropped reads as down until it reconnects.
fn reachable(&self) -> bool {
self.peers.didcomm.get().is_some_and(|m| {
matches!(
m.service.status(),
affinidi_messaging_delivery::MessagingStatus::Connected
)
})
}
async fn sign_jws(
&self,
key_id: &str,
header: Map<String, Value>,
claims: Map<String, Value>,
) -> Result<String, AppError> {
let mut payload = json!({ "keyId": key_id, "payload": claims });
if !header.is_empty() {
payload["header"] = Value::Object(header);
}
let reply = self
.round_trip(vta_sdk::trust_tasks::TASK_KEYS_SIGN_JWS_0_1, payload)
.await?;
sign_jws_answer(&reply, key_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn reply(type_uri: &str, payload: Value) -> TrustTask<Value> {
serde_json::from_value(json!({
"id": "urn:uuid:7f0e1a52-3c4b-4d5e-8f60-718293a4b5c6",
"type": type_uri,
"threadId": "urn:uuid:00000000-0000-4000-8000-000000000001",
"issuer": "did:example:vta",
"recipient": "did:example:vtc",
"issuedAt": "2026-10-13T09:00:00Z",
"payload": payload,
}))
.unwrap()
}
/// The JWS comes out of a `keys/sign-jws` response; a refusal is named,
/// with the grant that fixes a `permissionDenied`; anything else is not a
/// JWS.
#[test]
fn the_answer_is_the_jws_or_the_refusal_named() {
let ok = reply(
"https://trusttasks.org/spec/keys/sign-jws/0.1#response",
json!({ "keyId": "k", "alg": "ES256", "jws": "a.b.c" }),
);
assert_eq!(sign_jws_answer(&ok, "k").unwrap(), "a.b.c");
let denied = reply(
"https://trusttasks.org/spec/trust-task-error/0.5",
json!({ "code": "permissionDenied", "message": "no sign-jws" }),
);
let err = sign_jws_answer(&denied, "k").unwrap_err().to_string();
assert!(
err.contains("permissionDenied") && err.contains("grant this VTC"),
"{err}"
);
let other = reply(
"https://trusttasks.org/spec/keys/sign/0.1#response",
json!({ "keyId": "k", "signature": "x" }),
);
assert!(sign_jws_answer(&other, "k").is_err());
}
}