stem_rs/response/
onion_client_auth.rs

1//! ONION_CLIENT_AUTH_VIEW response parsing.
2//!
3//! This module parses responses from the `ONION_CLIENT_AUTH_VIEW` command,
4//! which lists client authentication credentials for v3 hidden services.
5//! These credentials allow access to hidden services that require client
6//! authentication.
7//!
8//! # Response Format
9//!
10//! A successful response lists credentials for one or all hidden services:
11//!
12//! ```text
13//! 250-ONION_CLIENT_AUTH_VIEW [service_id]
14//! 250-CLIENT <service_id> <key_type>:<private_key> [ClientName=<name>] [Flags=<flags>]
15//! 250 OK
16//! ```
17//!
18//! # Example
19//!
20//! ```rust
21//! use stem_rs::response::{ControlMessage, OnionClientAuthViewResponse};
22//!
23//! let response_text = "250-ONION_CLIENT_AUTH_VIEW\r\n\
24//!                      250-CLIENT service1 x25519:privatekey123\r\n\
25//!                      250 OK\r\n";
26//! let msg = ControlMessage::from_str(response_text, None, false).unwrap();
27//! let response = OnionClientAuthViewResponse::from_message(&msg).unwrap();
28//!
29//! let cred = response.credentials.get("service1").unwrap();
30//! assert_eq!(cred.key_type, "x25519");
31//! ```
32//!
33//! # Security Considerations
34//!
35//! - Private keys should be handled securely and not logged
36//! - Credentials provide access to hidden services; protect them accordingly
37//! - The `Permanent` flag indicates credentials persist across Tor restarts
38//!
39//! # See Also
40//!
41//! - [Tor Control Protocol: ONION_CLIENT_AUTH_VIEW](https://spec.torproject.org/control-spec/commands.html#onion_client_auth_view)
42
43use std::collections::HashMap;
44
45use super::ControlMessage;
46use crate::Error;
47
48/// Client authentication credential for a v3 hidden service.
49///
50/// Contains the cryptographic key and metadata needed to authenticate
51/// to a hidden service that requires client authentication.
52///
53/// # Example
54///
55/// ```rust
56/// use stem_rs::response::{ControlMessage, OnionClientAuthViewResponse};
57///
58/// let msg = ControlMessage::from_str(
59///     "250-ONION_CLIENT_AUTH_VIEW\r\n\
60///      250-CLIENT myservice x25519:secretkey ClientName=alice Flags=Permanent\r\n\
61///      250 OK\r\n",
62///     None,
63///     false
64/// ).unwrap();
65///
66/// let response = OnionClientAuthViewResponse::from_message(&msg).unwrap();
67/// let cred = response.credentials.get("myservice").unwrap();
68///
69/// assert_eq!(cred.key_type, "x25519");
70/// assert_eq!(cred.client_name, Some("alice".to_string()));
71/// assert!(cred.flags.contains(&"Permanent".to_string()));
72/// ```
73///
74/// # Security
75///
76/// The `private_key` field contains sensitive cryptographic material.
77/// Handle it securely and avoid logging or displaying it.
78#[derive(Debug, Clone)]
79pub struct HiddenServiceCredential {
80    /// The hidden service address (without `.onion` suffix).
81    ///
82    /// For v3 services, this is a 56-character base32 string.
83    pub service_id: String,
84
85    /// The base64-encoded private key for authentication.
86    ///
87    /// This is the client's private key used to prove identity to the
88    /// hidden service. Keep this value secure.
89    pub private_key: String,
90
91    /// The cryptographic algorithm used for the key.
92    ///
93    /// Currently, v3 hidden services use `x25519` for client authentication.
94    pub key_type: String,
95
96    /// Optional human-readable name for this credential.
97    ///
98    /// Useful for identifying which credential is which when multiple
99    /// credentials are stored.
100    pub client_name: Option<String>,
101
102    /// Flags associated with this credential.
103    ///
104    /// Common flags:
105    /// - `Permanent`: Credential persists across Tor restarts
106    /// - `Generated`: Credential was generated by Tor (not imported)
107    pub flags: Vec<String>,
108}
109
110/// Parsed response from the ONION_CLIENT_AUTH_VIEW command.
111///
112/// Contains credentials for accessing v3 hidden services that require
113/// client authentication.
114///
115/// # Example
116///
117/// ```rust
118/// use stem_rs::response::{ControlMessage, OnionClientAuthViewResponse};
119///
120/// // View all credentials
121/// let msg = ControlMessage::from_str(
122///     "250-ONION_CLIENT_AUTH_VIEW\r\n\
123///      250-CLIENT service1 x25519:key1\r\n\
124///      250-CLIENT service2 x25519:key2\r\n\
125///      250 OK\r\n",
126///     None,
127///     false
128/// ).unwrap();
129///
130/// let response = OnionClientAuthViewResponse::from_message(&msg).unwrap();
131/// assert!(response.requested.is_none()); // Viewing all
132/// assert_eq!(response.credentials.len(), 2);
133/// ```
134#[derive(Debug, Clone)]
135pub struct OnionClientAuthViewResponse {
136    /// The specific service ID that was requested, if any.
137    ///
138    /// `None` if viewing all credentials, `Some(service_id)` if viewing
139    /// credentials for a specific hidden service.
140    pub requested: Option<String>,
141
142    /// Map of service IDs to their credentials.
143    ///
144    /// Keys are hidden service addresses (without `.onion` suffix).
145    pub credentials: HashMap<String, HiddenServiceCredential>,
146}
147
148impl OnionClientAuthViewResponse {
149    /// Parses an ONION_CLIENT_AUTH_VIEW response from a control message.
150    ///
151    /// Extracts credentials for one or all hidden services from the response.
152    ///
153    /// # Arguments
154    ///
155    /// * `message` - The control message to parse
156    ///
157    /// # Errors
158    ///
159    /// Returns [`Error::Protocol`](crate::Error::Protocol) if:
160    /// - The response status is not OK
161    /// - The response is empty
162    /// - The response doesn't start with "ONION_CLIENT_AUTH_VIEW"
163    /// - A CLIENT line has fewer than 3 fields
164    /// - A CLIENT line doesn't start with "CLIENT"
165    /// - The credential format is invalid (missing `:` separator)
166    /// - An attribute is not a key=value mapping
167    ///
168    /// # Example
169    ///
170    /// ```rust
171    /// use stem_rs::response::{ControlMessage, OnionClientAuthViewResponse};
172    ///
173    /// // View credentials for a specific service
174    /// let msg = ControlMessage::from_str(
175    ///     "250-ONION_CLIENT_AUTH_VIEW myservice\r\n\
176    ///      250-CLIENT myservice x25519:secretkey\r\n\
177    ///      250 OK\r\n",
178    ///     None,
179    ///     false
180    /// ).unwrap();
181    ///
182    /// let response = OnionClientAuthViewResponse::from_message(&msg).unwrap();
183    /// assert_eq!(response.requested, Some("myservice".to_string()));
184    /// assert!(response.credentials.contains_key("myservice"));
185    /// ```
186    pub fn from_message(message: &ControlMessage) -> Result<Self, Error> {
187        if !message.is_ok() {
188            return Err(Error::Protocol(format!(
189                "ONION_CLIENT_AUTH_VIEW response didn't have an OK status: {}",
190                message
191            )));
192        }
193
194        let mut requested = None;
195        let mut credentials = HashMap::new();
196
197        let lines: Vec<String> = message.iter().map(|l| l.to_string()).collect();
198
199        if lines.is_empty() {
200            return Err(Error::Protocol(
201                "Empty ONION_CLIENT_AUTH_VIEW response".to_string(),
202            ));
203        }
204
205        let first_line = &lines[0];
206        if !first_line.starts_with("ONION_CLIENT_AUTH_VIEW") {
207            return Err(Error::Protocol(format!(
208                "Response should begin with 'ONION_CLIENT_AUTH_VIEW': {}",
209                message
210            )));
211        }
212
213        if first_line.contains(' ') {
214            let parts: Vec<&str> = first_line.split(' ').collect();
215            if parts.len() > 1 {
216                requested = Some(parts[1].to_string());
217            }
218        }
219
220        for line in lines.iter().skip(1) {
221            if line == "OK" {
222                continue;
223            }
224
225            let attributes: Vec<&str> = line.split(' ').collect();
226
227            if attributes.len() < 3 {
228                return Err(Error::Protocol(format!(
229                    "ONION_CLIENT_AUTH_VIEW lines must contain an address and credential: {}",
230                    message
231                )));
232            }
233
234            if attributes[0] != "CLIENT" {
235                return Err(Error::Protocol(format!(
236                    "ONION_CLIENT_AUTH_VIEW lines should begin with 'CLIENT': {}",
237                    message
238                )));
239            }
240
241            if !attributes[2].contains(':') {
242                return Err(Error::Protocol(format!(
243                    "ONION_CLIENT_AUTH_VIEW credentials must be of the form 'encryption_type:key': {}",
244                    message
245                )));
246            }
247
248            let service_id = attributes[1].to_string();
249            let (key_type, private_key) = attributes[2].split_once(':').unwrap();
250
251            let mut client_name = None;
252            let mut flags = Vec::new();
253
254            for attr in attributes.iter().skip(3) {
255                if !attr.contains('=') {
256                    return Err(Error::Protocol(format!(
257                        "'{}' expected to be a 'key=value' mapping: {}",
258                        attr, message
259                    )));
260                }
261
262                let (key, value) = attr.split_once('=').unwrap();
263                match key {
264                    "ClientName" => client_name = Some(value.to_string()),
265                    "Flags" => flags = value.split(',').map(|s| s.to_string()).collect(),
266                    _ => {}
267                }
268            }
269
270            credentials.insert(
271                service_id.clone(),
272                HiddenServiceCredential {
273                    service_id,
274                    private_key: private_key.to_string(),
275                    key_type: key_type.to_string(),
276                    client_name,
277                    flags,
278                },
279            );
280        }
281
282        Ok(Self {
283            requested,
284            credentials,
285        })
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn create_message(lines: Vec<&str>) -> ControlMessage {
294        let parsed: Vec<(String, char, Vec<u8>)> = lines
295            .iter()
296            .enumerate()
297            .map(|(i, line)| {
298                let divider = if i == lines.len() - 1 { ' ' } else { '-' };
299                ("250".to_string(), divider, line.as_bytes().to_vec())
300            })
301            .collect();
302        let raw = lines.join("\r\n");
303        ControlMessage::new(parsed, raw.into_bytes(), None).unwrap()
304    }
305
306    #[test]
307    fn test_onion_client_auth_view_basic() {
308        let msg = create_message(vec![
309            "ONION_CLIENT_AUTH_VIEW yvhz3ofkv7gwf5hpzqvhonpr3gbax2cc7dee3xcnt7dmtlx2gu7vyvid",
310            "CLIENT yvhz3ofkv7gwf5hpzqvhonpr3gbax2cc7dee3xcnt7dmtlx2gu7vyvid x25519:FCV0c0ELDKKDpSFgVIB8Yow8Evj5iD+GoiTtK878NkQ=",
311            "OK",
312        ]);
313        let response = OnionClientAuthViewResponse::from_message(&msg).unwrap();
314        assert_eq!(
315            response.requested,
316            Some("yvhz3ofkv7gwf5hpzqvhonpr3gbax2cc7dee3xcnt7dmtlx2gu7vyvid".to_string())
317        );
318        assert_eq!(response.credentials.len(), 1);
319
320        let cred = response
321            .credentials
322            .get("yvhz3ofkv7gwf5hpzqvhonpr3gbax2cc7dee3xcnt7dmtlx2gu7vyvid")
323            .unwrap();
324        assert_eq!(cred.key_type, "x25519");
325        assert_eq!(
326            cred.private_key,
327            "FCV0c0ELDKKDpSFgVIB8Yow8Evj5iD+GoiTtK878NkQ="
328        );
329    }
330
331    #[test]
332    fn test_onion_client_auth_view_all_credentials() {
333        let msg = create_message(vec![
334            "ONION_CLIENT_AUTH_VIEW",
335            "CLIENT service1 x25519:key1",
336            "CLIENT service2 x25519:key2",
337            "OK",
338        ]);
339        let response = OnionClientAuthViewResponse::from_message(&msg).unwrap();
340        assert!(response.requested.is_none());
341        assert_eq!(response.credentials.len(), 2);
342    }
343
344    #[test]
345    fn test_onion_client_auth_view_with_client_name() {
346        let msg = create_message(vec![
347            "ONION_CLIENT_AUTH_VIEW",
348            "CLIENT service1 x25519:key1 ClientName=myname",
349            "OK",
350        ]);
351        let response = OnionClientAuthViewResponse::from_message(&msg).unwrap();
352        let cred = response.credentials.get("service1").unwrap();
353        assert_eq!(cred.client_name, Some("myname".to_string()));
354    }
355
356    #[test]
357    fn test_onion_client_auth_view_with_flags() {
358        let msg = create_message(vec![
359            "ONION_CLIENT_AUTH_VIEW",
360            "CLIENT service1 x25519:key1 Flags=Permanent,Generated",
361            "OK",
362        ]);
363        let response = OnionClientAuthViewResponse::from_message(&msg).unwrap();
364        let cred = response.credentials.get("service1").unwrap();
365        assert_eq!(cred.flags, vec!["Permanent", "Generated"]);
366    }
367
368    #[test]
369    fn test_onion_client_auth_view_missing_header() {
370        let msg = create_message(vec!["CLIENT service1 x25519:key1", "OK"]);
371        assert!(OnionClientAuthViewResponse::from_message(&msg).is_err());
372    }
373
374    #[test]
375    fn test_onion_client_auth_view_malformed_credential() {
376        let msg = create_message(vec![
377            "ONION_CLIENT_AUTH_VIEW",
378            "CLIENT service1 malformed_no_colon",
379            "OK",
380        ]);
381        assert!(OnionClientAuthViewResponse::from_message(&msg).is_err());
382    }
383}