Skip to main content

vta_cli_common/
local_keygen.rs

1//! Client-side did:key generation.
2//!
3//! Replaces the server-side `POST /auth/credentials` endpoint for flows where
4//! an operator or consumer wants an admin identity bound to a context. The
5//! key never crosses the wire:
6//!
7//! 1. Caller mints a random 32-byte Ed25519 seed locally.
8//! 2. Derives `did:key:...` from the public half.
9//! 3. Sends `POST /acl` with the public DID + desired role/contexts.
10//! 4. Keeps the private half in the returned [`CredentialBundle`] — either
11//!    to use locally or to seal via `sealed_producer` for transport.
12//!
13//! The VTA never sees the private key. Contrast with the pre-5c6 flow where
14//! `POST /auth/credentials` generated the key server-side and returned it in
15//! a base64 JSON field — a private key in flight over plaintext JSON.
16
17use ed25519_dalek::SigningKey;
18use rand::Rng;
19use vta_sdk::credentials::CredentialBundle;
20use vta_sdk::prelude::ed25519_multibase_pubkey;
21
22/// Mint a fresh Ed25519 keypair and derive a `did:key`.
23///
24/// Returns `(did, private_key_multibase)` where `private_key_multibase` is
25/// the raw 32-byte seed encoded as Base58Btc multibase — matching the
26/// format used by `CredentialBundle.private_key_multibase` and the rest
27/// of the workspace.
28fn mint_ed25519_did_key() -> (String, String) {
29    let mut seed = [0u8; 32];
30    rand::rng().fill_bytes(&mut seed);
31    let signing_key = SigningKey::from_bytes(&seed);
32    let public_key = signing_key.verifying_key().to_bytes();
33    let multibase_pubkey = ed25519_multibase_pubkey(&public_key);
34    let did = format!("did:key:{multibase_pubkey}");
35    let private_key_multibase = multibase::encode(multibase::Base::Base58Btc, seed);
36    (did, private_key_multibase)
37}
38
39/// Generate a fresh Ed25519 keypair, derive a `did:key`, and package the
40/// result as a [`CredentialBundle`] bound to the given VTA DID/URL.
41///
42/// Returns `(bundle, did)`. The `did` is a convenience echo of
43/// `bundle.did` for callers that also need it for the ACL entry.
44pub fn generate_admin_did_key(
45    vta_did: impl Into<String>,
46    vta_url: Option<String>,
47) -> (CredentialBundle, String) {
48    let (did, private_key_multibase) = mint_ed25519_did_key();
49    let bundle = CredentialBundle {
50        did: did.clone(),
51        private_key_multibase,
52        vta_did: vta_did.into(),
53        vta_url,
54    };
55    (bundle, did)
56}
57
58/// Mint a fresh Ed25519 `did:key` with no VTA binding.
59///
60/// Returns `(did, private_key_multibase)`. Used by the deferred-VTA-DID
61/// `pnm setup` flow: phase 1 mints this keypair and parks it in the
62/// keyring as a `PendingVtaBinding` session; phase 2 supplies the VTA
63/// DID and lifts the session to `PendingRotation`.
64///
65/// Unlike [`generate_admin_did_key`], this does not construct a
66/// [`CredentialBundle`] — the `CredentialBundle.vta_did` field is
67/// required and not yet known at mint time.
68pub fn generate_unbound_admin_did_key() -> (String, String) {
69    mint_ed25519_did_key()
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    #[test]
77    fn generated_did_is_did_key() {
78        let (bundle, did) = generate_admin_did_key("did:key:z6MkVTA", None);
79        assert_eq!(bundle.did, did);
80        assert!(did.starts_with("did:key:z"));
81        assert_eq!(bundle.vta_did, "did:key:z6MkVTA");
82        assert!(bundle.vta_url.is_none());
83    }
84
85    #[test]
86    fn generated_dids_are_unique() {
87        let a = generate_admin_did_key("did:key:z6MkVTA", None).1;
88        let b = generate_admin_did_key("did:key:z6MkVTA", None).1;
89        assert_ne!(a, b);
90    }
91
92    #[test]
93    fn private_key_multibase_roundtrips_to_seed() {
94        let (bundle, _) = generate_admin_did_key("did:key:z6MkVTA", None);
95        let (_, decoded) = multibase::decode(&bundle.private_key_multibase).unwrap();
96        assert_eq!(decoded.len(), 32, "Ed25519 seed must be 32 bytes");
97    }
98
99    #[test]
100    fn derived_did_matches_seed() {
101        let (bundle, did) = generate_admin_did_key("did:key:z6MkVTA", None);
102        // Re-derive from the private key and confirm we land on the same DID.
103        let (_, seed_bytes) = multibase::decode(&bundle.private_key_multibase).unwrap();
104        let seed: [u8; 32] = seed_bytes.try_into().unwrap();
105        let signing_key = SigningKey::from_bytes(&seed);
106        let pubkey = signing_key.verifying_key().to_bytes();
107        let rederived = format!("did:key:{}", ed25519_multibase_pubkey(&pubkey));
108        assert_eq!(rederived, did);
109    }
110
111    #[test]
112    fn unbound_did_key_has_valid_shape() {
113        let (did, private_key_multibase) = generate_unbound_admin_did_key();
114        assert!(did.starts_with("did:key:z"));
115        let (_, decoded) = multibase::decode(&private_key_multibase).unwrap();
116        assert_eq!(decoded.len(), 32, "Ed25519 seed must be 32 bytes");
117    }
118
119    #[test]
120    fn unbound_dids_are_unique() {
121        let (a, _) = generate_unbound_admin_did_key();
122        let (b, _) = generate_unbound_admin_did_key();
123        assert_ne!(a, b);
124    }
125
126    #[test]
127    fn unbound_seed_round_trips_to_did() {
128        let (did, private_key_multibase) = generate_unbound_admin_did_key();
129        let (_, seed_bytes) = multibase::decode(&private_key_multibase).unwrap();
130        let seed: [u8; 32] = seed_bytes.try_into().unwrap();
131        let signing_key = SigningKey::from_bytes(&seed);
132        let pubkey = signing_key.verifying_key().to_bytes();
133        let rederived = format!("did:key:{}", ed25519_multibase_pubkey(&pubkey));
134        assert_eq!(rederived, did);
135    }
136}