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}