Skip to main content

vta_sdk/
did_key.rs

1/// Encode an Ed25519 public key as a multibase Base58BTC string with multicodec prefix `0xed01`.
2pub fn ed25519_multibase_pubkey(public_key_bytes: &[u8; 32]) -> String {
3    let mut buf = Vec::with_capacity(34);
4    buf.extend_from_slice(&[0xed, 0x01]);
5    buf.extend_from_slice(public_key_bytes);
6    multibase::encode(multibase::Base::Base58Btc, &buf)
7}
8
9/// Known 2-byte multicodec varint prefix for Ed25519 public keys.
10const ED25519_PUB_CODEC: [u8; 2] = [0xed, 0x01];
11
12/// Decode an Ed25519 public key from its multibase form. Inverse of
13/// [`ed25519_multibase_pubkey`]. Accepts both multicodec-prefixed
14/// (`0xed01`) and raw-bytes encodings; returns the 32-byte key.
15///
16/// **Length disambiguates, not the leading bytes** — see
17/// [`decode_private_key_multibase`] for the failure that rule prevents.
18pub fn decode_ed25519_public_key_multibase(mb: &str) -> Result<[u8; 32], DidKeyError> {
19    let (_, raw) = multibase::decode(mb).map_err(|e| DidKeyError::Multibase(e.to_string()))?;
20    let key_bytes = if raw.len() == PREFIXED_KEY_LEN && [raw[0], raw[1]] == ED25519_PUB_CODEC {
21        &raw[2..]
22    } else {
23        &raw[..]
24    };
25    key_bytes
26        .try_into()
27        .map_err(|_| DidKeyError::InvalidSeedLength)
28}
29
30/// A 2-byte multicodec prefix followed by a 32-byte key.
31///
32/// The **only** thing that distinguishes a prefixed key from a bare one, because a bare key
33/// is free to begin with any two bytes at all — including a prefix's. Matching on the
34/// leading bytes alone truncated a bare 32-byte key to 30 whenever its first two happened to
35/// collide, which for randomly generated keys is 3 chances in 65536: rare enough to look
36/// like noise and frequent enough to fail CI. It did, on `room-host`'s
37/// `a_member_without_a_nomination_cannot_claim`.
38const PREFIXED_KEY_LEN: usize = 34;
39
40/// Known 2-byte multicodec varint prefixes for private keys.
41const ED25519_PRIV_CODEC: [u8; 2] = [0x80, 0x26]; // 0x1300
42const X25519_PRIV_CODEC: [u8; 2] = [0x82, 0x26]; // 0x1302
43const P256_PRIV_CODEC: [u8; 2] = [0x86, 0x26]; // 0x1306
44
45/// Decode a multibase-encoded private key to raw bytes.
46///
47/// Accepts both:
48/// - Multicodec-prefixed: 2-byte prefix + raw key bytes (standard format)
49/// - Raw: just the key bytes (legacy/backwards-compatible)
50///
51/// Strips known private-key multicodec prefixes (Ed25519, X25519, P256)
52/// before returning the raw key bytes.
53pub fn decode_private_key_multibase(mb: &str) -> Result<[u8; 32], DidKeyError> {
54    let (_, raw) = multibase::decode(mb).map_err(|e| DidKeyError::Multibase(e.to_string()))?;
55    let key_bytes = if raw.len() == PREFIXED_KEY_LEN
56        && matches!(
57            [raw[0], raw[1]],
58            ED25519_PRIV_CODEC | X25519_PRIV_CODEC | P256_PRIV_CODEC
59        ) {
60        &raw[2..]
61    } else {
62        &raw[..]
63    };
64    key_bytes
65        .try_into()
66        .map_err(|_| DidKeyError::InvalidSeedLength)
67}
68
69/// Ed25519 signing + X25519 key-agreement secrets for a `did:key`.
70#[cfg(feature = "didcomm")]
71pub struct DidKeySecrets {
72    pub signing: affinidi_tdk::secrets_resolver::secrets::Secret,
73    pub key_agreement: affinidi_tdk::secrets_resolver::secrets::Secret,
74}
75
76/// Construct Ed25519 signing + X25519 key-agreement secrets for a `did:key`.
77///
78/// The `did` must start with `did:key:`. The `seed` is the 32-byte Ed25519
79/// private key seed.
80#[cfg(feature = "didcomm")]
81pub fn secrets_from_did_key(did: &str, seed: &[u8; 32]) -> Result<DidKeySecrets, DidKeyError> {
82    use affinidi_tdk::secrets_resolver::secrets::Secret;
83
84    let ed_pub_mb = did
85        .strip_prefix("did:key:")
86        .ok_or(DidKeyError::InvalidDidKey)?;
87
88    // Ed25519 signing secret
89    let mut signing = Secret::generate_ed25519(None, Some(seed));
90    signing.id = format!("{did}#{ed_pub_mb}");
91
92    // X25519 key-agreement secret (derived from Ed25519)
93    let mut key_agreement = signing
94        .to_x25519()
95        .map_err(|e| DidKeyError::X25519Conversion(e.to_string()))?;
96    let x_pub_mb = key_agreement
97        .get_public_keymultibase()
98        .map_err(|e| DidKeyError::X25519Conversion(e.to_string()))?;
99    key_agreement.id = format!("{did}#{x_pub_mb}");
100
101    Ok(DidKeySecrets {
102        signing,
103        key_agreement,
104    })
105}
106
107/// Build the ordered set of DIDComm [`Secret`]s for a
108/// [`DidSecretsBundle`](crate::did_secrets::DidSecretsBundle).
109///
110/// This is the `did:webvh` (and any hosted-DID) counterpart to
111/// [`secrets_from_did_key`]: rather than *deriving* both keys from one
112/// Ed25519 seed, it reconstructs each verification method's secret from its
113/// own `private_key_multibase`, preserving the bundle's verification-method
114/// ids verbatim. In particular the X25519 key-agreement key is a **separate**
115/// key (not derived from the signing key).
116///
117/// Each entry's `private_key_multibase` is a multicodec-prefixed multibase
118/// string; [`Secret::from_multibase`](affinidi_tdk::secrets_resolver::secrets::Secret::from_multibase)
119/// decodes the prefix and builds the right secret type (Ed25519 via
120/// `generate_ed25519`, X25519 via `generate_x25519`) — the same call the VTA
121/// uses to load its own `#key-1` X25519 secret
122/// (`vta_service::operations::did_webvh::load_key_as_secret`). The returned
123/// secret's id is set to the entry's `key_id`.
124///
125/// The returned `Vec` preserves the bundle's entry order; callers that emit a
126/// signing-first bundle (`create-did-webvh` puts `#key-0` first) therefore get
127/// signing first.
128///
129/// `KeyType::P256` entries are accepted (P-256 is a valid signing key type);
130/// the multicodec prefix in `private_key_multibase` is authoritative for the
131/// actual decode, so the declared `key_type` is used only for an up-front
132/// sanity check that the bundle is well-formed.
133#[cfg(feature = "didcomm")]
134pub fn secrets_from_bundle(
135    bundle: &crate::did_secrets::DidSecretsBundle,
136) -> Result<Vec<affinidi_tdk::secrets_resolver::secrets::Secret>, DidKeyError> {
137    use affinidi_tdk::secrets_resolver::secrets::Secret;
138
139    let mut secrets = Vec::with_capacity(bundle.secrets.len());
140    for entry in &bundle.secrets {
141        // Validate the multibase decodes to a 32-byte key before handing it to
142        // the resolver, so a malformed entry yields our typed error (and the
143        // round-trip is exercised) rather than an opaque resolver string.
144        decode_private_key_multibase(&entry.private_key_multibase)?;
145
146        // `from_multibase` reads the multicodec prefix to pick the secret type
147        // (Ed25519 → generate_ed25519, X25519 → generate_x25519) and sets the
148        // verification-method id to the entry's key_id. This is the same call
149        // the VTA uses to load its own secrets.
150        let secret = Secret::from_multibase(&entry.private_key_multibase, Some(&entry.key_id))
151            .map_err(|e| DidKeyError::SecretConstruction(e.to_string()))?;
152        secrets.push(secret);
153    }
154    Ok(secrets)
155}
156
157#[derive(Debug)]
158pub enum DidKeyError {
159    Multibase(String),
160    InvalidSeedLength,
161    InvalidDidKey,
162    #[cfg(feature = "didcomm")]
163    X25519Conversion(String),
164    #[cfg(feature = "didcomm")]
165    SecretConstruction(String),
166}
167
168impl std::fmt::Display for DidKeyError {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        match self {
171            Self::Multibase(e) => write!(f, "invalid private key multibase: {e}"),
172            Self::InvalidSeedLength => write!(f, "private key seed must be 32 bytes"),
173            Self::InvalidDidKey => write!(f, "invalid did:key format"),
174            #[cfg(feature = "didcomm")]
175            Self::X25519Conversion(e) => write!(f, "X25519 conversion failed: {e}"),
176            #[cfg(feature = "didcomm")]
177            Self::SecretConstruction(e) => write!(f, "failed to construct secret from bundle: {e}"),
178        }
179    }
180}
181
182impl std::error::Error for DidKeyError {}
183
184/// Convert a [`GetKeySecretResponse`](crate::client::GetKeySecretResponse) into
185/// an `affinidi_tdk` [`Secret`](affinidi_tdk::secrets_resolver::secrets::Secret).
186///
187/// The response's `private_key_multibase` is a multicodec-prefixed multibase
188/// string (e.g. ed25519-priv `0x8026`). `Secret::from_multibase` handles the
189/// decoding for all supported key types.
190#[cfg(feature = "client")]
191pub fn secret_from_key_response(
192    resp: &crate::client::GetKeySecretResponse,
193) -> Result<affinidi_tdk::secrets_resolver::secrets::Secret, DidKeyError> {
194    affinidi_tdk::secrets_resolver::secrets::Secret::from_multibase(
195        &resp.private_key_multibase,
196        None,
197    )
198    .map_err(|e| DidKeyError::Multibase(e.to_string()))
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_ed25519_multibase_pubkey_format() {
207        let key = [0u8; 32];
208        let result = ed25519_multibase_pubkey(&key);
209        // Should start with 'z' (Base58BTC) and decode to [0xed, 0x01] + key
210        assert!(result.starts_with('z'));
211
212        let (_, decoded) = multibase::decode(&result).unwrap();
213        assert_eq!(decoded.len(), 34);
214        assert_eq!(decoded[0], 0xed);
215        assert_eq!(decoded[1], 0x01);
216        assert_eq!(&decoded[2..], &key);
217    }
218
219    #[test]
220    fn test_decode_private_key_multibase_roundtrip() {
221        let seed = [42u8; 32];
222        let encoded = multibase::encode(multibase::Base::Base58Btc, seed);
223        let decoded = decode_private_key_multibase(&encoded).unwrap();
224        assert_eq!(decoded, seed);
225    }
226
227    #[test]
228    fn test_decode_private_key_multibase_with_codec_prefix() {
229        let seed = [42u8; 32];
230        let mut prefixed = Vec::with_capacity(34);
231        prefixed.extend_from_slice(&ED25519_PRIV_CODEC);
232        prefixed.extend_from_slice(&seed);
233        let encoded = multibase::encode(multibase::Base::Base58Btc, &prefixed);
234        let decoded = decode_private_key_multibase(&encoded).unwrap();
235        assert_eq!(decoded, seed);
236    }
237
238    #[test]
239    fn test_decode_private_key_multibase_invalid() {
240        let result = decode_private_key_multibase("!!!bad!!!");
241        assert!(result.is_err());
242    }
243
244    #[test]
245    fn test_decode_private_key_multibase_wrong_length() {
246        let encoded = multibase::encode(multibase::Base::Base58Btc, [1u8; 16]);
247        let result = decode_private_key_multibase(&encoded);
248        assert!(matches!(result, Err(DidKeyError::InvalidSeedLength)));
249    }
250
251    /// Pin the verification-method-ID contract for `did:key` secrets.
252    ///
253    /// Regression guard: a previous PR landed VTA `did:key` support where
254    /// downstream DIDComm consumers hardcoded `{did}#key-0` / `{did}#key-1`
255    /// as fragment IDs. For `did:key` the spec says VM IDs are the
256    /// multibase public-key fragment (`{did}#{ed_pub_mb}` /
257    /// `{did}#{x_pub_mb}`), so those lookups missed and the secrets vector
258    /// was empty. This test pins the fragment shape `secrets_from_did_key`
259    /// produces so that contract is checked at the SDK boundary, not just
260    /// at the consumer site.
261    #[cfg(feature = "didcomm")]
262    #[test]
263    fn test_secrets_from_did_key_uses_multibase_fragment_ids() {
264        use affinidi_tdk::secrets_resolver::secrets::Secret;
265
266        let seed = [42u8; 32];
267        let ed_secret = Secret::generate_ed25519(None, Some(&seed));
268        let ed_pub_mb = ed_secret.get_public_keymultibase().unwrap();
269        let did = format!("did:key:{ed_pub_mb}");
270
271        let secrets = secrets_from_did_key(&did, &seed).expect("did:key secrets");
272
273        // Signing VM ID must be {did}#{ed_pub_mb} — not the legacy
274        // #key-0 webvh convention.
275        assert_eq!(secrets.signing.id, format!("{did}#{ed_pub_mb}"));
276        assert_ne!(secrets.signing.id, format!("{did}#key-0"));
277
278        // Key-agreement VM ID must use a multibase fragment that differs
279        // from the signing fragment (X25519 ≠ Ed25519 public bytes), and
280        // must not be the legacy #key-1.
281        assert!(
282            secrets.key_agreement.id.starts_with(&format!("{did}#z")),
283            "key_agreement.id should start with `{did}#z`, got: {}",
284            secrets.key_agreement.id
285        );
286        assert_ne!(secrets.key_agreement.id, format!("{did}#key-1"));
287        assert_ne!(secrets.key_agreement.id, secrets.signing.id);
288    }
289
290    /// `secrets_from_did_key` is the only place the runtime X25519 secret
291    /// is constructed for a `did:key` VTA. Make sure repeated calls with
292    /// the same seed produce the same key-agreement ID, so a peer that
293    /// resolves the DID document encrypts to the same key the VTA holds.
294    #[cfg(feature = "didcomm")]
295    #[test]
296    fn test_secrets_from_did_key_is_deterministic() {
297        use affinidi_tdk::secrets_resolver::secrets::Secret;
298
299        let seed = [7u8; 32];
300        let ed_secret = Secret::generate_ed25519(None, Some(&seed));
301        let did = format!("did:key:{}", ed_secret.get_public_keymultibase().unwrap());
302
303        let a = secrets_from_did_key(&did, &seed).unwrap();
304        let b = secrets_from_did_key(&did, &seed).unwrap();
305        assert_eq!(a.signing.id, b.signing.id);
306        assert_eq!(a.key_agreement.id, b.key_agreement.id);
307    }
308
309    #[cfg(feature = "didcomm")]
310    #[test]
311    fn test_secrets_from_did_key_rejects_non_did_key() {
312        let seed = [1u8; 32];
313        let result = secrets_from_did_key("did:webvh:abc:example.com:vta", &seed);
314        assert!(matches!(result, Err(DidKeyError::InvalidDidKey)));
315    }
316
317    /// A `did:webvh` bundle with an Ed25519 signing key (`#key-0`) and a
318    /// separate X25519 key-agreement key (`#key-1`) must produce two secrets
319    /// whose ids are the entries' `key_id`s verbatim, with the correct key
320    /// types, signing first. This is the contract DIDComm consumers rely on:
321    /// the resolver must have a secret keyed by the exact VM id published in
322    /// the DID document.
323    #[cfg(feature = "didcomm")]
324    #[test]
325    fn test_secrets_from_bundle_ed25519_and_x25519() {
326        use crate::did_secrets::{DidSecretsBundle, SecretEntry};
327        use crate::keys::KeyType;
328        use affinidi_tdk::secrets_resolver::secrets::{KeyType as ResolverKeyType, Secret};
329
330        let did = "did:webvh:QmAbc:example.com:agent";
331
332        // Signing key: Ed25519 seed, multicodec-prefixed.
333        let ed_seed = [11u8; 32];
334        let mut ed_prefixed = Vec::with_capacity(34);
335        ed_prefixed.extend_from_slice(&ED25519_PRIV_CODEC);
336        ed_prefixed.extend_from_slice(&ed_seed);
337        let signing_mb = multibase::encode(multibase::Base::Base58Btc, &ed_prefixed);
338
339        // Key-agreement key: a SEPARATE X25519 scalar, multicodec-prefixed.
340        let x_scalar = [22u8; 32];
341        let mut x_prefixed = Vec::with_capacity(34);
342        x_prefixed.extend_from_slice(&X25519_PRIV_CODEC);
343        x_prefixed.extend_from_slice(&x_scalar);
344        let ka_mb = multibase::encode(multibase::Base::Base58Btc, &x_prefixed);
345
346        let bundle = DidSecretsBundle {
347            did: did.to_string(),
348            secrets: vec![
349                SecretEntry {
350                    key_id: format!("{did}#key-0"),
351                    key_type: KeyType::Ed25519,
352                    private_key_multibase: signing_mb.clone(),
353                },
354                SecretEntry {
355                    key_id: format!("{did}#key-1"),
356                    key_type: KeyType::X25519,
357                    private_key_multibase: ka_mb,
358                },
359            ],
360        };
361
362        let secrets = secrets_from_bundle(&bundle).expect("bundle secrets");
363        assert_eq!(secrets.len(), 2, "signing + key-agreement");
364
365        // Order is preserved: signing (#key-0) first.
366        assert_eq!(secrets[0].id, format!("{did}#key-0"));
367        assert_eq!(secrets[1].id, format!("{did}#key-1"));
368
369        // Key types come out right: Ed25519 signing, X25519 key-agreement.
370        assert_eq!(secrets[0].get_key_type(), ResolverKeyType::Ed25519);
371        assert_eq!(secrets[1].get_key_type(), ResolverKeyType::X25519);
372
373        // The X25519 key-agreement secret is the SEPARATE scalar we supplied,
374        // NOT one derived from the signing seed. Reconstructing the same
375        // scalar via `from_multibase` must yield the same public key, while the
376        // Ed25519-derived X25519 (the did:key path) would differ.
377        let mut x_prefixed2 = Vec::with_capacity(34);
378        x_prefixed2.extend_from_slice(&X25519_PRIV_CODEC);
379        x_prefixed2.extend_from_slice(&x_scalar);
380        let same = Secret::from_multibase(
381            &multibase::encode(multibase::Base::Base58Btc, &x_prefixed2),
382            None,
383        )
384        .unwrap();
385        assert_eq!(
386            secrets[1].get_public_keymultibase().unwrap(),
387            same.get_public_keymultibase().unwrap(),
388        );
389    }
390
391    /// The multibase round-trips: an entry encoded with the X25519 codec
392    /// decodes back to the 32-byte scalar via `decode_private_key_multibase`,
393    /// and the resulting secret is keyed by the entry id.
394    #[cfg(feature = "didcomm")]
395    #[test]
396    fn test_secrets_from_bundle_roundtrips_multibase() {
397        use crate::did_secrets::{DidSecretsBundle, SecretEntry};
398        use crate::keys::KeyType;
399
400        let scalar = [7u8; 32];
401        let mut prefixed = Vec::with_capacity(34);
402        prefixed.extend_from_slice(&X25519_PRIV_CODEC);
403        prefixed.extend_from_slice(&scalar);
404        let mb = multibase::encode(multibase::Base::Base58Btc, &prefixed);
405
406        // Direct decode round-trips to the raw scalar.
407        assert_eq!(decode_private_key_multibase(&mb).unwrap(), scalar);
408
409        let bundle = DidSecretsBundle {
410            did: "did:webvh:x:example.com:a".to_string(),
411            secrets: vec![SecretEntry {
412                key_id: "did:webvh:x:example.com:a#key-1".to_string(),
413                key_type: KeyType::X25519,
414                private_key_multibase: mb,
415            }],
416        };
417        let secrets = secrets_from_bundle(&bundle).unwrap();
418        assert_eq!(secrets.len(), 1);
419        assert_eq!(secrets[0].id, "did:webvh:x:example.com:a#key-1");
420    }
421
422    /// A malformed `private_key_multibase` surfaces our typed error, not an
423    /// opaque resolver string.
424    #[cfg(feature = "didcomm")]
425    #[test]
426    fn test_secrets_from_bundle_rejects_bad_multibase() {
427        use crate::did_secrets::{DidSecretsBundle, SecretEntry};
428        use crate::keys::KeyType;
429
430        let bundle = DidSecretsBundle {
431            did: "did:webvh:x:example.com:a".to_string(),
432            secrets: vec![SecretEntry {
433                key_id: "did:webvh:x:example.com:a#key-0".to_string(),
434                key_type: KeyType::Ed25519,
435                private_key_multibase: "!!!bad!!!".to_string(),
436            }],
437        };
438        assert!(matches!(
439            secrets_from_bundle(&bundle),
440            Err(DidKeyError::Multibase(_))
441        ));
442    }
443}