Skip to main content

volt_client_grpc/
credential.rs

1//! Volt credential management.
2//!
3//! Handles key pairs, certificates, and authentication tokens.
4
5use crate::config::{CredentialCache, VoltClientConfig, VoltConfig};
6use crate::constants::AUTH_TOKEN_NAME;
7use crate::crypto::{fingerprint_from_key, public_key_from_pem, SigningKeyPair};
8use crate::error::{Result, VoltError};
9use tonic::metadata::MetadataMap;
10
11/// Identity token with metadata
12#[derive(Debug, Clone)]
13pub struct IdentityToken {
14    /// The JWT token
15    pub token: String,
16    /// gRPC metadata containing the token
17    pub metadata: MetadataMap,
18    /// Shared key for tunnelling (if applicable)
19    pub shared_key: Option<crate::crypto::KeyExchange>,
20}
21
22/// Manages credentials for connecting to a Volt
23pub struct VoltCredential {
24    config: VoltClientConfig,
25    config_path: Option<String>,
26    persist_cache: bool,
27    key_pair: Option<SigningKeyPair>,
28    volt_public_key: Option<String>,
29    volt_fingerprint: Option<String>,
30}
31
32impl VoltCredential {
33    /// Create a new credential manager
34    pub fn new(config: VoltClientConfig, config_path: Option<String>) -> Result<Self> {
35        let persist_cache = config_path.is_some();
36
37        // Extract Volt public key from config
38        let (volt_public_key, volt_fingerprint) = Self::extract_volt_key(&config.volt)?;
39
40        let mut credential = Self {
41            config,
42            config_path,
43            persist_cache,
44            key_pair: None,
45            volt_public_key,
46            volt_fingerprint,
47        };
48
49        // Load existing key if present
50        if let Some(ref cache) = credential.config.credential {
51            if let Some(ref key_pem) = cache.key {
52                credential.key_pair = Some(SigningKeyPair::from_pem(key_pem)?);
53            }
54        }
55
56        Ok(credential)
57    }
58
59    fn extract_volt_key(volt_config: &VoltConfig) -> Result<(Option<String>, Option<String>)> {
60        // Try to get public key from config
61        if let Some(ref public_key) = volt_config.public_key {
62            let key = public_key_from_pem(public_key)?;
63            let fingerprint = fingerprint_from_key(&key);
64            return Ok((Some(public_key.clone()), Some(fingerprint)));
65        }
66
67        // TODO: Extract from CA cert if public_key not present
68        // This requires parsing X509 certificates
69
70        Ok((None, None))
71    }
72
73    /// Initialize the credential (create key if needed)
74    pub async fn initialise(&mut self) -> Result<()> {
75        self.create_key().await?;
76        self.save_cache().await?;
77        Ok(())
78    }
79
80    /// Get or create the signing key pair
81    pub async fn create_key(&mut self) -> Result<&SigningKeyPair> {
82        if self.key_pair.is_none() {
83            tracing::debug!("Creating new signing key");
84            let key = SigningKeyPair::generate();
85
86            // Store in cache
87            let cache = self
88                .config
89                .credential
90                .get_or_insert_with(CredentialCache::default);
91            cache.key = Some(key.private_key_pem());
92
93            self.key_pair = Some(key);
94        }
95
96        Ok(self.key_pair.as_ref().unwrap())
97    }
98
99    /// Get the key pair
100    pub fn get_key(&self) -> Result<&SigningKeyPair> {
101        self.key_pair
102            .as_ref()
103            .ok_or_else(|| VoltError::key("No key available"))
104    }
105
106    /// Get the public key in PEM format
107    pub fn public_key_pem(&self) -> Result<String> {
108        Ok(self.get_key()?.public_key_pem())
109    }
110
111    /// Check if the client is bound (has a certificate)
112    pub fn is_bound(&self) -> bool {
113        self.config
114            .credential
115            .as_ref()
116            .and_then(|c| c.cert.as_ref())
117            .is_some()
118    }
119
120    /// Get the certificate
121    pub fn certificate(&self) -> Option<&str> {
122        self.config
123            .credential
124            .as_ref()
125            .and_then(|c| c.cert.as_deref())
126    }
127
128    /// Get the session ID
129    pub fn session_id(&self) -> Option<&str> {
130        self.config
131            .credential
132            .as_ref()
133            .and_then(|c| c.session_id.as_deref())
134    }
135
136    /// Get the identity DID
137    pub fn identity_did(&self) -> Option<&str> {
138        self.config
139            .credential
140            .as_ref()
141            .and_then(|c| c.identity_did.as_deref())
142    }
143
144    /// Get the credential cache
145    pub fn cache(&self) -> Option<&CredentialCache> {
146        self.config.credential.as_ref()
147    }
148
149    /// Get mutable credential cache
150    pub fn cache_mut(&mut self) -> &mut CredentialCache {
151        self.config
152            .credential
153            .get_or_insert_with(CredentialCache::default)
154    }
155
156    /// Get the Volt public key
157    pub fn volt_public_key(&self) -> Option<&str> {
158        self.volt_public_key.as_deref()
159    }
160
161    /// Get the Volt fingerprint
162    pub fn volt_fingerprint(&self) -> Option<&str> {
163        self.volt_fingerprint.as_deref()
164    }
165
166    /// Get the full config
167    pub fn config(&self) -> &VoltClientConfig {
168        &self.config
169    }
170
171    /// Get mutable config
172    pub fn config_mut(&mut self) -> &mut VoltClientConfig {
173        &mut self.config
174    }
175
176    /// Create identity metadata for gRPC calls
177    pub fn get_identity_metadata(
178        &self,
179        audience: Option<&str>,
180        relay_public_key: Option<&str>,
181        tunnelling: bool,
182        ttl: u64,
183    ) -> Result<IdentityToken> {
184        let key = self.get_key()?;
185        let session_id = self
186            .session_id()
187            .ok_or_else(|| VoltError::session("No session ID"))?;
188
189        let aud = audience.unwrap_or(&self.config.volt.id);
190        tracing::info!(
191            "Creating JWT: sub={}, aud={}, tunnelling={}",
192            session_id,
193            aud,
194            tunnelling
195        );
196
197        let token = create_identity_token(session_id, key, aud, relay_public_key, tunnelling, ttl)?;
198
199        tracing::info!("JWT full token: {}", token);
200
201        let mut metadata = MetadataMap::new();
202        metadata.insert(
203            AUTH_TOKEN_NAME,
204            token
205                .parse()
206                .map_err(|_| VoltError::internal("Invalid token format"))?,
207        );
208
209        // If tunnelling, we need to set up key exchange
210        let shared_key = if tunnelling {
211            Some(crate::crypto::KeyExchange::new())
212        } else {
213            None
214        };
215
216        Ok(IdentityToken {
217            token,
218            metadata,
219            shared_key,
220        })
221    }
222
223    /// Save the credential cache to file
224    pub async fn save_cache(&self) -> Result<VoltClientConfig> {
225        let config = self.config.clone();
226
227        if self.persist_cache {
228            if let Some(ref path) = self.config_path {
229                config.save_to_file(path).await?;
230            }
231        }
232
233        Ok(config)
234    }
235}
236
237/// Create a JWT identity token
238fn create_identity_token(
239    session_id: &str,
240    key: &SigningKeyPair,
241    audience: &str,
242    relay_public_key: Option<&str>,
243    tunnelling: bool,
244    ttl: u64,
245) -> Result<String> {
246    use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
247    use serde::{Deserialize, Serialize};
248
249    #[derive(Debug, Serialize, Deserialize)]
250    struct Claims {
251        /// Subject (session ID)
252        sub: String,
253        /// Audience
254        aud: String,
255        /// Issued at
256        iat: u64,
257        /// Expiration
258        exp: u64,
259        /// Public key for key exchange (when tunnelling) - stripped PEM
260        #[serde(skip_serializing_if = "Option::is_none")]
261        k: Option<String>,
262    }
263
264    let now = std::time::SystemTime::now()
265        .duration_since(std::time::UNIX_EPOCH)
266        .unwrap()
267        .as_secs();
268
269    // Allow TTL either side of the current time (like JS does)
270    let claims = Claims {
271        sub: session_id.to_string(),
272        aud: audience.to_string(),
273        iat: now.saturating_sub(ttl),
274        exp: now + ttl,
275        k: if tunnelling {
276            relay_public_key.map(|k| k.to_string())
277        } else {
278            None
279        },
280    };
281
282    // Create header with EdDSA algorithm
283    let header = Header::new(Algorithm::EdDSA);
284
285    // Get the private key in PKCS#8 PEM format
286    // The jsonwebtoken crate requires PKCS#8 format for EdDSA
287    let pkcs8_pem = key.private_key_pkcs8_pem();
288    let encoding_key =
289        EncodingKey::from_ed_pem(pkcs8_pem.as_bytes()).map_err(VoltError::JwtError)?;
290
291    encode(&header, &claims, &encoding_key).map_err(VoltError::from)
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::config::VoltConfig;
298
299    #[tokio::test]
300    async fn test_credential_creation() {
301        let config = VoltClientConfig {
302            client_name: "test".to_string(),
303            volt: VoltConfig {
304                id: "did:volt:test".to_string(),
305                ..Default::default()
306            },
307            ..Default::default()
308        };
309
310        let mut cred = VoltCredential::new(config, None).unwrap();
311        cred.initialise().await.unwrap();
312
313        assert!(cred.key_pair.is_some());
314        assert!(cred.config.credential.is_some());
315    }
316}