vta_sdk/did_secrets.rs
1use serde::{Deserialize, Serialize};
2
3use crate::keys::KeyType;
4
5/// A portable bundle of DID secrets for import/export.
6///
7/// Post-Phase-5 the canonical transport is [`crate::sealed_transfer`]
8/// (`SealedPayloadV1::DidSecrets`). On-disk for local operator exports the
9/// canonical form is pretty-printed JSON.
10///
11/// # Example — constructing secrets for DIDComm
12///
13/// Applications using `affinidi_tdk` can reconstruct `Secret` objects from the
14/// entries in this bundle using `Secret::from_multibase()`, which handles all
15/// key types (Ed25519, X25519, P-256) via their multicodec prefix:
16///
17/// ```ignore
18/// use affinidi_tdk::secrets_resolver::secrets::Secret;
19///
20/// let bundle = client.fetch_did_secrets_bundle("my-context").await?;
21/// for entry in &bundle.secrets {
22/// let secret = Secret::from_multibase(
23/// &entry.private_key_multibase,
24/// Some(&entry.key_id),
25/// )?;
26/// resolver.insert(secret);
27/// }
28/// ```
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct DidSecretsBundle {
31 /// The DID these secrets belong to.
32 pub did: String,
33 /// Secret entries (one per verification method).
34 pub secrets: Vec<SecretEntry>,
35}
36
37/// A single secret entry within a [`DidSecretsBundle`].
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SecretEntry {
40 /// Verification method ID (e.g. `did:webvh:...#key-0`).
41 pub key_id: String,
42 /// Key type — determines how to reconstruct the secret.
43 pub key_type: KeyType,
44 /// Multibase-encoded (Base58BTC) private key with multicodec prefix.
45 ///
46 /// The multicodec prefix identifies the key type:
47 /// - Ed25519 private: `0x1300` — 32-byte Ed25519 seed
48 /// - X25519 private: `0x1302` — 32-byte X25519 scalar (from `get_key_secret`)
49 /// or Ed25519 seed for conversion (from provisioning)
50 /// - P256 private: `0x1306` — 32-byte P-256 scalar
51 ///
52 /// Compatible with `Secret::from_multibase()` for direct use in DIDComm.
53 pub private_key_multibase: String,
54}
55
56/// Convert a [`GetKeySecretResponse`](crate::client::GetKeySecretResponse) into a [`SecretEntry`].
57#[cfg(feature = "client")]
58impl From<crate::client::GetKeySecretResponse> for SecretEntry {
59 fn from(resp: crate::client::GetKeySecretResponse) -> Self {
60 Self {
61 key_id: resp.key_id,
62 key_type: resp.key_type,
63 private_key_multibase: resp.private_key_multibase,
64 }
65 }
66}
67
68/// Decide the verification-method id (kid) to publish for a context secret
69/// in a [`DidSecretsBundle`], or `None` if the secret must be excluded.
70///
71/// The kid a VTA-managed mediator matches inbound JWE recipients against
72/// must be a verification-method id of the bundle's DID (`{did}#...`).
73/// Rules, in order:
74///
75/// 1. If the store's `record_key_id` is already a VM id of `did`, use it.
76/// Every DID-operating-key flow stores exactly this
77/// (`save_entity_key_records` → `{did}#key-0` / `#key-1`), so this is
78/// the common path.
79/// 2. Otherwise, if the human `label` is *itself* a strict VM id of `did`
80/// (correct prefix, no embedded whitespace), adopt it. Covers generic
81/// `/keys` records whose id defaulted to a derivation path while the
82/// label carries the VM id.
83/// 3. Otherwise the secret is not a verification method of this DID — an
84/// admin `did:key` minted into the same context, or a free-text label
85/// such as `"<did:key> signing key"`. Exclude it.
86///
87/// Rule 3 is the fix for the storm.ws outage (PR #337): the previous
88/// logic adopted the label as the kid whenever it merely *started with*
89/// `did:` or *contained* `#`, so a decorative label like
90/// `"did:key:z6Mkr4J… signing key"` silently overwrote the correct
91/// `{did}#key-1` kid. The mediator then found no local secret matching
92/// the recipient kid published in its own DID document and failed every
93/// unpack with `No local secret matches any JWE recipient`.
94///
95/// Shared between the online SDK path (`VtaClient::fetch_did_secrets_bundle`)
96/// and the offline VTA-service path
97/// (`vta_service::operations::export::build_did_secrets_bundle`) so the
98/// two cannot drift.
99pub fn select_secret_kid(did: &str, record_key_id: &str, label: Option<&str>) -> Option<String> {
100 let vm_prefix = format!("{did}#");
101 if record_key_id.starts_with(&vm_prefix) {
102 return Some(record_key_id.to_string());
103 }
104 if let Some(label) = label
105 && label.starts_with(&vm_prefix)
106 && !label.chars().any(char::is_whitespace)
107 {
108 return Some(label.to_string());
109 }
110 None
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn test_serde_json_roundtrip() {
119 let bundle = DidSecretsBundle {
120 did: "did:webvh:abc123:example.com".to_string(),
121 secrets: vec![
122 SecretEntry {
123 key_id: "did:webvh:abc123:example.com#key-0".to_string(),
124 key_type: KeyType::Ed25519,
125 private_key_multibase: "z6Mk...signing".to_string(),
126 },
127 SecretEntry {
128 key_id: "did:webvh:abc123:example.com#key-1".to_string(),
129 key_type: KeyType::X25519,
130 private_key_multibase: "z6Mk...ka".to_string(),
131 },
132 ],
133 };
134
135 let json = serde_json::to_string(&bundle).unwrap();
136 let decoded: DidSecretsBundle = serde_json::from_str(&json).unwrap();
137
138 assert_eq!(decoded.did, bundle.did);
139 assert_eq!(decoded.secrets.len(), 2);
140 assert_eq!(decoded.secrets[0].key_id, bundle.secrets[0].key_id);
141 assert_eq!(decoded.secrets[0].key_type, KeyType::Ed25519);
142 assert_eq!(
143 decoded.secrets[0].private_key_multibase,
144 bundle.secrets[0].private_key_multibase
145 );
146 assert_eq!(decoded.secrets[1].key_type, KeyType::X25519);
147 }
148
149 #[test]
150 fn test_serde_json_empty_secrets() {
151 let bundle = DidSecretsBundle {
152 did: "did:example:123".to_string(),
153 secrets: vec![],
154 };
155 let json = serde_json::to_string(&bundle).unwrap();
156 let decoded: DidSecretsBundle = serde_json::from_str(&json).unwrap();
157 assert_eq!(decoded.did, "did:example:123");
158 assert!(decoded.secrets.is_empty());
159 }
160
161 // `select_secret_kid` lives in this module; its regression tests live
162 // here alongside it (moved from `client::secrets`, where the function
163 // used to be, in the WS-setup follow-up).
164 const KID_DID: &str =
165 "did:webvh:QmQjq4GHRH9fwSXCg4884kxpCMT5EUqHB9XY2U7aXisP8R:webvh.storm.ws:mediator-2";
166
167 #[test]
168 fn keeps_store_vm_id_and_ignores_decorative_label() {
169 // The store key_id is already the canonical VM id; the label is
170 // free text. The kid must be the VM id, regardless of label.
171 let kid = select_secret_kid(
172 KID_DID,
173 &format!("{KID_DID}#key-0"),
174 Some("did:key:z6Mkr4JCdsEVcQvYKxcyjf39tPmVriDfg3gALvqv4GQHc5BH signing key"),
175 );
176 assert_eq!(kid.as_deref(), Some(format!("{KID_DID}#key-0").as_str()));
177 }
178
179 #[test]
180 fn regression_did_prefixed_label_must_not_clobber_key_id() {
181 // Exact storm.ws failure mode: a `did:key:… key-agreement key`
182 // label must NOT replace the correct `#key-1` kid. The old code
183 // returned the label here, which is what bricked DIDComm unpack.
184 let kid = select_secret_kid(
185 KID_DID,
186 &format!("{KID_DID}#key-1"),
187 Some("did:key:z6Mkr4JCdsEVcQvYKxcyjf39tPmVriDfg3gALvqv4GQHc5BH key-agreement key"),
188 );
189 assert_eq!(kid.as_deref(), Some(format!("{KID_DID}#key-1").as_str()));
190 assert!(!kid.as_deref().unwrap().contains(' '));
191 }
192
193 #[test]
194 fn excludes_admin_did_key_minted_into_context() {
195 // The admin credential's did:key shares the context tag but is a
196 // different DID; its VM id does not belong in this DID's bundle.
197 let admin = "did:key:z6Mkt6eNM38RhFfjSdmXBtT1SRL7sPgPZD1MkXZbwjYBhTLf";
198 let kid = select_secret_kid(
199 KID_DID,
200 &format!("{admin}#z6Mkt6eNM38RhFfjSdmXBtT1SRL7sPgPZD1MkXZbwjYBhTLf"),
201 Some("admin DID for context mediator-test"),
202 );
203 assert_eq!(kid, None);
204 }
205
206 #[test]
207 fn adopts_label_when_it_is_a_strict_vm_id_and_record_id_is_not() {
208 // Generic /keys record whose id defaulted to a derivation path,
209 // with the real VM id carried in the label.
210 let kid = select_secret_kid(KID_DID, "m/26'/2'/3'/4'", Some(&format!("{KID_DID}#key-1")));
211 assert_eq!(kid.as_deref(), Some(format!("{KID_DID}#key-1").as_str()));
212 }
213
214 #[test]
215 fn rejects_label_vm_id_with_trailing_free_text() {
216 // A label that starts with the VM prefix but has trailing text is
217 // not a VM id — must not be adopted.
218 let kid = select_secret_kid(
219 KID_DID,
220 "m/26'/2'/3'/4'",
221 Some(&format!("{KID_DID}#key-1 rotated")),
222 );
223 assert_eq!(kid, None);
224 }
225}