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