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
//! Convenience methods for paginating + bundling key secrets via [`VtaClient`].
use super::VtaClient;
use crate::error::VtaError;
impl VtaClient {
/// Fetch all secrets for a context, paginating through all keys.
///
/// Returns TDK `Secret` objects ready for use with DIDComm or signing.
pub async fn fetch_context_secrets(
&self,
context_id: &str,
) -> Result<Vec<affinidi_tdk::secrets_resolver::secrets::Secret>, VtaError> {
let page_size = 100u64;
let mut offset = 0u64;
let mut secrets = Vec::new();
loop {
let resp = self
.list_keys(offset, page_size, Some("active"), Some(context_id))
.await?;
if resp.keys.is_empty() {
break;
}
for key in &resp.keys {
let secret_resp = self.get_key_secret(&key.key_id).await?;
let secret = crate::did_key::secret_from_key_response(&secret_resp)?;
secrets.push(secret);
}
offset += resp.keys.len() as u64;
if offset >= resp.total {
break;
}
}
Ok(secrets)
}
/// Fetch all secrets for a context as a portable
/// [`DidSecretsBundle`](crate::did_secrets::DidSecretsBundle).
///
/// One `vta/contexts/secrets/1.0` call: the VTA resolves the context DID,
/// walks its active keys and returns the bundle.
///
/// # Why this used to be N+1, and why that mattered
///
/// It used to do the walk here — `get_context`, then `list_keys`, then one
/// `get_key_secret` per key. Three problems, and only the first is about
/// speed:
///
/// - `get_key_secret` is gated on **global Admin**, so a service that
/// wanted the keys of its own context had to hold authority over every
/// other context in the VTA to get them. The new task is `Application`
/// plus the caller's own context.
/// - The kid-selection rule ([`select_secret_kid`]) had to be applied by
/// every caller, so a second implementation could quietly disagree about
/// which secrets belong to the DID. It now lives on one side.
/// - A bundle assembled from N independent answers has no single point at
/// which the VTA decided to release it — nothing to audit, and nothing a
/// future "this key may not leave" rule could hook — which
/// `keys/set-exportability` now is.
pub async fn fetch_did_secrets_bundle(
&self,
context_id: &str,
) -> Result<crate::did_secrets::DidSecretsBundle, VtaError> {
let body: crate::protocols::context_management::secrets::ContextSecretsResultBody = self
.rpc_tt(
crate::trust_tasks::TASK_CONTEXTS_SECRETS_1_0,
serde_json::json!({ "id": context_id }),
30,
)
.await?;
Ok(body.into())
}
}
#[cfg(all(test, feature = "test-loopback", feature = "client"))]
mod tests {
use super::*;
use crate::client::loopback::LoopbackSink;
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
const DID: &str = "did:webvh:QmExample:example.com:rooms:host-1";
/// Answers each task URI from a table, recording the order they arrive in.
struct Scripted {
seen: Mutex<Vec<String>>,
answer: Box<dyn Fn(&str) -> Result<Value, VtaError> + Send + Sync>,
}
impl LoopbackSink for Scripted {
fn dispatch(&self, type_uri: &str, _payload: &Value) -> Result<Value, VtaError> {
self.seen
.lock()
.expect("not poisoned")
.push(type_uri.to_string());
(self.answer)(type_uri)
}
}
fn client_of(
answer: impl Fn(&str) -> Result<Value, VtaError> + Send + Sync + 'static,
) -> (crate::client::VtaClient, Arc<Scripted>) {
let sink = Arc::new(Scripted {
seen: Mutex::new(Vec::new()),
answer: Box::new(answer),
});
(
crate::client::VtaClient::loopback(sink.clone() as Arc<dyn LoopbackSink>),
sink,
)
}
/// The point of the change: one call, not one per key — and the response
/// is read in the spec's lowerCamelCase, not the internal snake_case.
#[tokio::test]
async fn the_bundle_is_one_call() {
let (client, sink) = client_of(|uri| {
assert_eq!(uri, crate::trust_tasks::TASK_CONTEXTS_SECRETS_1_0);
Ok(json!({
"did": DID,
"secrets": [
{ "keyId": format!("{DID}#key-0"), "keyType": "ed25519",
"privateKeyMultibase": "zSigning" },
{ "keyId": format!("{DID}#key-1"), "keyType": "x25519",
"privateKeyMultibase": "zAgreement" },
]
}))
});
let bundle = client
.fetch_did_secrets_bundle("rooms/host-1")
.await
.expect("the bundle comes back");
assert_eq!(bundle.did, DID);
assert_eq!(bundle.secrets.len(), 2);
assert_eq!(bundle.secrets[0].key_id, format!("{DID}#key-0"));
assert_eq!(bundle.secrets[0].private_key_multibase, "zSigning");
assert_eq!(
sink.seen.lock().expect("not poisoned").len(),
1,
"one task, not one per key"
);
}
/// One route, and no second one to drift. The fallback that used to sit
/// here existed so a service could be upgraded before the VTA it talked to
/// was; that gap has been closed by deployment, and keeping a second path
/// to the same bundle would keep `seeds/export-mnemonic` alive to serve it.
#[tokio::test]
async fn an_old_vta_is_a_clear_failure_not_a_silent_downgrade() {
let (client, sink) = client_of(|uri| {
Err(VtaError::UnsupportedTaskType {
type_uri: uri.to_string(),
served_versions: Vec::new(),
})
});
let err = client
.fetch_did_secrets_bundle("rooms/host-1")
.await
.expect_err("a VTA that does not serve the task cannot answer");
assert!(
matches!(err, VtaError::UnsupportedTaskType { .. }),
"the refusal must say which task is missing, so the fix is 'upgrade the VTA' \
rather than a puzzle; got {err:?}"
);
assert_eq!(
sink.seen.lock().expect("not poisoned").len(),
1,
"one attempt: no per-key walk to fall back to"
);
}
/// A refusal surfaces as itself. There is nothing to retry it against, and
/// nothing that could turn a permission error into a different one.
#[tokio::test]
async fn a_refusal_surfaces_as_itself() {
let (client, sink) = client_of(|_| Err(VtaError::Forbidden("no access".into())));
let err = client
.fetch_did_secrets_bundle("rooms/host-1")
.await
.expect_err("a refusal is a refusal");
assert!(matches!(err, VtaError::Forbidden(_)), "got: {err:?}");
assert_eq!(
sink.seen.lock().expect("not poisoned").len(),
1,
"one attempt"
);
}
}