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}