stem_rs/response/
add_onion.rs

1//! ADD_ONION response parsing.
2//!
3//! This module parses responses from the `ADD_ONION` command, which creates
4//! ephemeral (non-persistent) hidden services. These services exist only for
5//! the lifetime of the Tor connection and are not written to disk.
6//!
7//! # Response Format
8//!
9//! A successful ADD_ONION response contains:
10//!
11//! ```text
12//! 250-ServiceID=<onion_address>
13//! 250-PrivateKey=<key_type>:<base64_key>  (if requested)
14//! 250-ClientAuth=<username>:<credential>  (if client auth enabled)
15//! 250 OK
16//! ```
17//!
18//! # Example
19//!
20//! ```rust,no_run
21//! use stem_rs::response::{ControlMessage, AddOnionResponse};
22//!
23//! // Parse an ADD_ONION response
24//! let response_text = "250-ServiceID=gfzprpioee3hoppz\r\n\
25//!                      250-PrivateKey=RSA1024:MIICXgIBAAKBgQDZ...\r\n\
26//!                      250 OK\r\n";
27//! let msg = ControlMessage::from_str(response_text, None, false).unwrap();
28//! let response = AddOnionResponse::from_message(&msg).unwrap();
29//!
30//! println!("Service ID: {}", response.service_id);
31//! if let Some(key) = &response.private_key {
32//!     println!("Private key type: {:?}", response.private_key_type);
33//! }
34//! ```
35//!
36//! # See Also
37//!
38//! - [`crate::Controller::create_ephemeral_hidden_service`]: High-level API for creating hidden services
39//! - [Tor Control Protocol: ADD_ONION](https://spec.torproject.org/control-spec/commands.html#add_onion)
40
41use std::collections::HashMap;
42
43use super::ControlMessage;
44use crate::Error;
45
46/// Parsed response from the ADD_ONION command.
47///
48/// This struct contains the information returned when creating an ephemeral
49/// hidden service via the ADD_ONION command. The service exists only for the
50/// lifetime of the Tor connection.
51///
52/// # Fields
53///
54/// - `service_id`: The `.onion` address (without the `.onion` suffix)
55/// - `private_key`: The base64-encoded private key (if requested with `NEW:BEST` or similar)
56/// - `private_key_type`: The cryptographic algorithm used (e.g., "RSA1024", "ED25519-V3")
57/// - `client_auth`: Map of client usernames to their credentials (if client auth enabled)
58///
59/// # Key Types
60///
61/// | Type | Description |
62/// |------|-------------|
63/// | `RSA1024` | Legacy v2 hidden service key (deprecated) |
64/// | `ED25519-V3` | Modern v3 hidden service key (recommended) |
65///
66/// # Example
67///
68/// ```rust
69/// use stem_rs::response::{ControlMessage, AddOnionResponse};
70///
71/// // Response with service ID and private key
72/// let msg = ControlMessage::from_str(
73///     "250-ServiceID=gfzprpioee3hoppz\r\n\
74///      250-PrivateKey=ED25519-V3:base64encodedkey\r\n\
75///      250 OK\r\n",
76///     None,
77///     false
78/// ).unwrap();
79///
80/// let response = AddOnionResponse::from_message(&msg).unwrap();
81/// assert_eq!(response.service_id, "gfzprpioee3hoppz");
82/// assert_eq!(response.private_key_type, Some("ED25519-V3".to_string()));
83/// ```
84///
85/// # Security Considerations
86///
87/// - The private key should be stored securely if you need to recreate the service
88/// - Client auth credentials should be distributed securely to authorized clients
89/// - Consider using v3 (ED25519-V3) services for better security
90#[derive(Debug, Clone)]
91pub struct AddOnionResponse {
92    /// The hidden service address without the `.onion` suffix.
93    ///
94    /// For v2 services, this is a 16-character base32 string.
95    /// For v3 services, this is a 56-character base32 string.
96    pub service_id: String,
97
98    /// The base64-encoded private key, if requested.
99    ///
100    /// This is only present if the ADD_ONION command included a key generation
101    /// request (e.g., `NEW:BEST` or `NEW:ED25519-V3`). Store this securely if
102    /// you need to recreate the same hidden service later.
103    pub private_key: Option<String>,
104
105    /// The type of cryptographic key used.
106    ///
107    /// Common values:
108    /// - `"RSA1024"`: Legacy v2 hidden service (deprecated)
109    /// - `"ED25519-V3"`: Modern v3 hidden service
110    pub private_key_type: Option<String>,
111
112    /// Client authentication credentials, if enabled.
113    ///
114    /// Maps client usernames to their base64-encoded credentials.
115    /// These credentials must be provided to clients for them to access
116    /// the hidden service.
117    pub client_auth: HashMap<String, String>,
118}
119
120impl AddOnionResponse {
121    /// Parses an ADD_ONION response from a control message.
122    ///
123    /// Extracts the service ID, optional private key, and any client
124    /// authentication credentials from the response.
125    ///
126    /// # Arguments
127    ///
128    /// * `message` - The control message to parse
129    ///
130    /// # Errors
131    ///
132    /// Returns [`Error::Protocol`](crate::Error::Protocol) if:
133    /// - The response status is not OK (2xx)
134    /// - The response doesn't contain a ServiceID
135    /// - PrivateKey line is malformed (missing `:` separator)
136    /// - ClientAuth line is malformed (missing `:` separator)
137    ///
138    /// # Example
139    ///
140    /// ```rust
141    /// use stem_rs::response::{ControlMessage, AddOnionResponse};
142    ///
143    /// // Basic response with just service ID
144    /// let msg = ControlMessage::from_str(
145    ///     "250-ServiceID=gfzprpioee3hoppz\r\n250 OK\r\n",
146    ///     None,
147    ///     false
148    /// ).unwrap();
149    ///
150    /// let response = AddOnionResponse::from_message(&msg).unwrap();
151    /// assert_eq!(response.service_id, "gfzprpioee3hoppz");
152    /// assert!(response.private_key.is_none());
153    ///
154    /// // Response with client auth
155    /// let msg = ControlMessage::from_str(
156    ///     "250-ServiceID=test\r\n\
157    ///      250-ClientAuth=bob:credential123\r\n\
158    ///      250 OK\r\n",
159    ///     None,
160    ///     false
161    /// ).unwrap();
162    ///
163    /// let response = AddOnionResponse::from_message(&msg).unwrap();
164    /// assert_eq!(response.client_auth.get("bob"), Some(&"credential123".to_string()));
165    /// ```
166    pub fn from_message(message: &ControlMessage) -> Result<Self, Error> {
167        if !message.is_ok() {
168            return Err(Error::Protocol(format!(
169                "ADD_ONION response didn't have an OK status: {}",
170                message
171            )));
172        }
173
174        let mut service_id = None;
175        let mut private_key = None;
176        let mut private_key_type = None;
177        let mut client_auth = HashMap::new();
178
179        for line in message.iter() {
180            let content = line.to_string();
181            if let Some(eq_pos) = content.find('=') {
182                let key = &content[..eq_pos];
183                let value = &content[eq_pos + 1..];
184
185                match key {
186                    "ServiceID" => {
187                        service_id = Some(value.to_string());
188                    }
189                    "PrivateKey" => {
190                        if !value.contains(':') {
191                            return Err(Error::Protocol(format!(
192                                "ADD_ONION PrivateKey lines should be of the form 'PrivateKey=[type]:[key]': {}",
193                                message
194                            )));
195                        }
196                        let (key_type, key_value) = value.split_once(':').unwrap();
197                        private_key_type = Some(key_type.to_string());
198                        private_key = Some(key_value.to_string());
199                    }
200                    "ClientAuth" => {
201                        if !value.contains(':') {
202                            return Err(Error::Protocol(format!(
203                                "ADD_ONION ClientAuth lines should be of the form 'ClientAuth=[username]:[credential]': {}",
204                                message
205                            )));
206                        }
207                        let (username, credential) = value.split_once(':').unwrap();
208                        client_auth.insert(username.to_string(), credential.to_string());
209                    }
210                    _ => {}
211                }
212            }
213        }
214
215        let service_id = service_id.ok_or_else(|| {
216            Error::Protocol(format!(
217                "ADD_ONION response should start with the service id: {}",
218                message
219            ))
220        })?;
221
222        Ok(Self {
223            service_id,
224            private_key,
225            private_key_type,
226            client_auth,
227        })
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn create_message(lines: Vec<&str>) -> ControlMessage {
236        let parsed: Vec<(String, char, Vec<u8>)> = lines
237            .iter()
238            .enumerate()
239            .map(|(i, line)| {
240                let divider = if i == lines.len() - 1 { ' ' } else { '-' };
241                ("250".to_string(), divider, line.as_bytes().to_vec())
242            })
243            .collect();
244        let raw = lines.join("\r\n");
245        ControlMessage::new(parsed, raw.into_bytes(), None).unwrap()
246    }
247
248    #[test]
249    fn test_add_onion_basic() {
250        let msg = create_message(vec!["ServiceID=gfzprpioee3hoppz", "OK"]);
251        let response = AddOnionResponse::from_message(&msg).unwrap();
252        assert_eq!(response.service_id, "gfzprpioee3hoppz");
253        assert!(response.private_key.is_none());
254        assert!(response.client_auth.is_empty());
255    }
256
257    #[test]
258    fn test_add_onion_with_private_key() {
259        let msg = create_message(vec![
260            "ServiceID=gfzprpioee3hoppz",
261            "PrivateKey=RSA1024:MIICXgIBAAKBgQDZvYVxv",
262            "OK",
263        ]);
264        let response = AddOnionResponse::from_message(&msg).unwrap();
265        assert_eq!(response.service_id, "gfzprpioee3hoppz");
266        assert_eq!(response.private_key_type, Some("RSA1024".to_string()));
267        assert_eq!(
268            response.private_key,
269            Some("MIICXgIBAAKBgQDZvYVxv".to_string())
270        );
271    }
272
273    #[test]
274    fn test_add_onion_with_client_auth() {
275        let msg = create_message(vec![
276            "ServiceID=gfzprpioee3hoppz",
277            "ClientAuth=bob:l4BT016McqV2Oail+Bwe6w",
278            "ClientAuth=alice:abc123def456",
279            "OK",
280        ]);
281        let response = AddOnionResponse::from_message(&msg).unwrap();
282        assert_eq!(response.service_id, "gfzprpioee3hoppz");
283        assert_eq!(
284            response.client_auth.get("bob"),
285            Some(&"l4BT016McqV2Oail+Bwe6w".to_string())
286        );
287        assert_eq!(
288            response.client_auth.get("alice"),
289            Some(&"abc123def456".to_string())
290        );
291    }
292
293    #[test]
294    fn test_add_onion_missing_service_id() {
295        let msg = create_message(vec!["PrivateKey=RSA1024:key", "OK"]);
296        assert!(AddOnionResponse::from_message(&msg).is_err());
297    }
298
299    #[test]
300    fn test_add_onion_malformed_private_key() {
301        let msg = create_message(vec![
302            "ServiceID=test",
303            "PrivateKey=malformed_no_colon",
304            "OK",
305        ]);
306        assert!(AddOnionResponse::from_message(&msg).is_err());
307    }
308
309    #[test]
310    fn test_add_onion_with_full_private_key() {
311        let msg = create_message(vec![
312            "ServiceID=gfzprpioee3hoppz",
313            "PrivateKey=RSA1024:MIICXgIBAAKBgQDZvYVxvKPTWhId/8Ss9fVxjAoFDsrJ3pk6HjHrEFRm3ypkK/vArbG9BrupzzYcyms+lO06O8b/iOSHuZI5mUEGkrYqQ+hpB2SkPUEzW7vcp8SQQivna3+LfkWH4JDqfiwZutU6MMEvU6g1OqK4Hll6uHbLpsfxkS/mGjyu1C9a9wIDAQABAoGBAJxsC3a25xZJqaRFfxwmIiptSTFy+/nj4T4gPQo6k/fHMKP/+P7liT9bm+uUwbITNNIjmPzxvrcKt+pNRR/92fizxr8QXr8l0ciVOLerbvdqvVUaQ/K1IVsblOLbactMvXcHactmqqLFUaZU9PPSDla7YkzikLDIUtHXQBEt4HEhAkEA/c4n+kpwi4odCaF49ESPbZC/Qejh7U9Tq10vAHzfrrGgQjnLw2UGDxJQXc9P12fGTvD2q3Q3VaMI8TKKFqZXsQJBANufh1zfP+xX/UfxJ4QzDUCHCu2gnyTDj3nG9Bc80E5g7NwR2VBXF1R+QQCK9GZcXd2y6vBYgrHOSUiLbVjGrycCQQDpOcs0zbjUEUuTsQUT+fiO50dJSrZpus6ZFxz85sMppeItWSzsVeYWbW7adYnZ2Gu72OPjM/0xPYsXEakhHSRRAkAxlVauNQjthv/72god4pi/VL224GiNmEkwKSa6iFRPHbrcBHuXk9IElWx/ft+mrHvUraw1DwaStgv9gNzzCghJAkEA08RegCRnIzuGvgeejLk4suIeCMD/11AvmSvxbRWS5rq1leSVo7uGLSnqDbwlzE4dGb5kH15NNAp14/l2Fu/yZg==",
314            "OK",
315        ]);
316        let response = AddOnionResponse::from_message(&msg).unwrap();
317        assert_eq!(response.service_id, "gfzprpioee3hoppz");
318        assert_eq!(response.private_key_type, Some("RSA1024".to_string()));
319        assert!(response
320            .private_key
321            .as_ref()
322            .unwrap()
323            .starts_with("MIICXgIBAAKB"));
324    }
325
326    #[test]
327    fn test_add_onion_ed25519_key() {
328        let msg = create_message(vec![
329            "ServiceID=oekn5sqrvcu4wote",
330            "PrivateKey=ED25519-V3:somebase64key",
331            "OK",
332        ]);
333        let response = AddOnionResponse::from_message(&msg).unwrap();
334        assert_eq!(response.service_id, "oekn5sqrvcu4wote");
335        assert_eq!(response.private_key_type, Some("ED25519-V3".to_string()));
336        assert_eq!(response.private_key, Some("somebase64key".to_string()));
337    }
338
339    #[test]
340    fn test_add_onion_wrong_first_key() {
341        let msg = create_message(vec![
342            "MyKey=gfzprpioee3hoppz",
343            "ServiceID=gfzprpioee3hoppz",
344            "OK",
345        ]);
346        let result = AddOnionResponse::from_message(&msg);
347        assert!(result.is_ok());
348        assert_eq!(result.unwrap().service_id, "gfzprpioee3hoppz");
349    }
350
351    #[test]
352    fn test_add_onion_malformed_client_auth() {
353        let msg = create_message(vec![
354            "ServiceID=test",
355            "ClientAuth=malformed_no_colon",
356            "OK",
357        ]);
358        assert!(AddOnionResponse::from_message(&msg).is_err());
359    }
360
361    #[test]
362    fn test_add_onion_not_ok_status() {
363        let parsed = vec![(
364            "512".to_string(),
365            ' ',
366            "Invalid argument".as_bytes().to_vec(),
367        )];
368        let msg = ControlMessage::new(parsed, "512 Invalid argument".into(), None).unwrap();
369        assert!(AddOnionResponse::from_message(&msg).is_err());
370    }
371}