Skip to main content

vta_sdk/
credentials.rs

1use serde::{Deserialize, Serialize};
2
3/// A portable credential bundle issued by a VTA for client authentication.
4///
5/// Post-Phase-5 the canonical transport for this type is
6/// [`crate::sealed_transfer`] (HPKE-sealed armored bundle); in-process it is
7/// passed as a struct. `serde_json::to_string` / `serde_json::from_str` are
8/// the canonical serialization points when a plaintext on-disk form is
9/// genuinely needed (e.g. at-rest keyring storage, where the OS already
10/// provides confidentiality).
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct CredentialBundle {
14    pub did: String,
15    #[serde(rename = "privateKeyMultibase")]
16    pub private_key_multibase: String,
17    #[serde(rename = "vtaDid")]
18    pub vta_did: String,
19    #[serde(rename = "vtaUrl", default, skip_serializing_if = "Option::is_none")]
20    pub vta_url: Option<String>,
21}
22
23impl CredentialBundle {
24    /// Create a new credential bundle.
25    pub fn new(
26        did: impl Into<String>,
27        private_key_multibase: impl Into<String>,
28        vta_did: impl Into<String>,
29    ) -> Self {
30        Self {
31            did: did.into(),
32            private_key_multibase: private_key_multibase.into(),
33            vta_did: vta_did.into(),
34            vta_url: None,
35        }
36    }
37
38    /// Set the VTA URL on this bundle.
39    pub fn vta_url(mut self, url: impl Into<String>) -> Self {
40        self.vta_url = Some(url.into());
41        self
42    }
43
44    /// Build a [`CredentialBundle`] from a private-key multibase by
45    /// deriving its `did:key`.
46    ///
47    /// Shared between the online path (`vta-cli-common::commands::contexts::credential_from_key`
48    /// → `client.get_key_secret` → this helper) and the offline path
49    /// (`vta-service::operations::export::credential_from_key_offline`
50    /// → local keystore read → this helper). Previously each side had
51    /// its own byte-for-byte copy; keeping the derivation in one place
52    /// prevents drift if the `did:key` encoding ever changes (e.g.
53    /// different multicodec, different multibase alphabet).
54    ///
55    /// `private_key_multibase` must be an Ed25519 seed (32 raw bytes,
56    /// multicodec-prefixed `0x1300` or naked) — the shape every
57    /// `get_key_secret` path returns for admin-role keys.
58    ///
59    /// Returns `(bundle, admin_did)` so the caller can reuse the
60    /// derived DID for ACL / audit without re-deriving.
61    #[cfg(feature = "sealed-transfer")]
62    pub fn from_ed25519_seed_multibase(
63        private_key_multibase: &str,
64        vta_did: &str,
65        vta_url: Option<&str>,
66    ) -> Result<(Self, String), crate::did_key::DidKeyError> {
67        let seed = crate::did_key::decode_private_key_multibase(private_key_multibase)?;
68        let public_key = ed25519_dalek::SigningKey::from_bytes(&seed)
69            .verifying_key()
70            .to_bytes();
71        let admin_did = format!(
72            "did:key:{}",
73            crate::did_key::ed25519_multibase_pubkey(&public_key)
74        );
75        let bundle = Self {
76            did: admin_did.clone(),
77            private_key_multibase: private_key_multibase.to_string(),
78            vta_did: vta_did.to_string(),
79            vta_url: vta_url.map(String::from),
80        };
81        Ok((bundle, admin_did))
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn test_credential_bundle_full() {
91        let json = r#"{
92            "did": "did:key:z6Mk123",
93            "privateKeyMultibase": "z1234567890",
94            "vtaDid": "did:key:z6MkVTA",
95            "vtaUrl": "https://vta.example.com"
96        }"#;
97        let bundle: CredentialBundle = serde_json::from_str(json).unwrap();
98        assert_eq!(bundle.did, "did:key:z6Mk123");
99        assert_eq!(bundle.private_key_multibase, "z1234567890");
100        assert_eq!(bundle.vta_did, "did:key:z6MkVTA");
101        assert_eq!(bundle.vta_url.as_deref(), Some("https://vta.example.com"));
102    }
103
104    #[test]
105    fn test_credential_bundle_without_url() {
106        let json = r#"{
107            "did": "did:key:z6Mk123",
108            "privateKeyMultibase": "z1234567890",
109            "vtaDid": "did:key:z6MkVTA"
110        }"#;
111        let bundle: CredentialBundle = serde_json::from_str(json).unwrap();
112        assert!(bundle.vta_url.is_none());
113    }
114
115    #[test]
116    fn test_credential_bundle_missing_did_fails() {
117        let json = r#"{
118            "privateKeyMultibase": "z1234567890",
119            "vtaDid": "did:key:z6MkVTA"
120        }"#;
121        assert!(serde_json::from_str::<CredentialBundle>(json).is_err());
122    }
123
124    #[test]
125    fn test_serde_json_roundtrip() {
126        let bundle = CredentialBundle {
127            did: "did:key:z6Mk123".to_string(),
128            private_key_multibase: "z1234567890".to_string(),
129            vta_did: "did:key:z6MkVTA".to_string(),
130            vta_url: Some("https://vta.example.com".to_string()),
131        };
132        let json = serde_json::to_string(&bundle).unwrap();
133        let decoded: CredentialBundle = serde_json::from_str(&json).unwrap();
134        assert_eq!(decoded.did, bundle.did);
135        assert_eq!(decoded.private_key_multibase, bundle.private_key_multibase);
136        assert_eq!(decoded.vta_did, bundle.vta_did);
137        assert_eq!(decoded.vta_url, bundle.vta_url);
138    }
139
140    #[test]
141    fn test_serde_json_roundtrip_without_url() {
142        let bundle = CredentialBundle {
143            did: "did:key:z6Mk123".to_string(),
144            private_key_multibase: "z1234567890".to_string(),
145            vta_did: "did:key:z6MkVTA".to_string(),
146            vta_url: None,
147        };
148        let json = serde_json::to_string(&bundle).unwrap();
149        // vta_url is skipped when None — field must be absent from output.
150        assert!(!json.contains("vtaUrl"));
151        let decoded: CredentialBundle = serde_json::from_str(&json).unwrap();
152        assert!(decoded.vta_url.is_none());
153    }
154}