Skip to main content

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}