tor_keymgr/keystore/ctor/
client.rs

1//! Read-only C Tor client key store implementation
2//!
3//! See [`CTorClientKeystore`] for more details.
4
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::result::Result as StdResult;
8use std::str::FromStr as _;
9use std::sync::Arc;
10
11use crate::keystore::ctor::CTorKeystore;
12use crate::keystore::ctor::err::{CTorKeystoreError, MalformedClientKeyError};
13use crate::keystore::fs_utils::{FilesystemAction, FilesystemError, RelKeyPath, checked_op};
14use crate::keystore::{EncodableItem, ErasedKey, KeySpecifier, Keystore};
15use crate::raw::{RawEntryId, RawKeystoreEntry};
16use crate::{
17    CTorPath, KeyPath, KeystoreEntry, KeystoreEntryResult, KeystoreId, Result,
18    UnrecognizedEntryError,
19};
20
21use fs_mistrust::Mistrust;
22use itertools::Itertools as _;
23use tor_basic_utils::PathExt;
24use tor_error::debug_report;
25use tor_hscrypto::pk::{HsClientDescEncKeypair, HsId};
26use tor_key_forge::{KeyType, KeystoreItemType};
27use tor_llcrypto::pk::curve25519;
28use tracing::debug;
29
30/// A read-only C Tor client keystore.
31///
32/// This keystore provides read-only access to the client restricted discovery keys
33/// rooted at a given `ClientOnionAuthDir` directory (see `ClientOnionAuthDir` in `tor(1)`).
34///
35/// The key files must be in the
36/// `<hsid>:descriptor:x25519:<base32-encoded-x25519-public-key>` format
37/// and have the `.auth_private` extension.
38/// Invalid keys, and keys that don't have the expected extension, will be ignored.
39///
40/// The only supported [`Keystore`] operations are [`contains`](Keystore::contains),
41/// [`get`](Keystore::get), and [`list`](Keystore::list). All other keystore operations
42/// will return an error.
43///
44/// This keystore implementation uses the [`CTorPath`] of the requested [`KeySpecifier`]
45/// and the [`KeystoreItemType`] to identify the appropriate restricted discovery keypair.
46/// If the requested `CTorPath` is not [`ClientHsDescEncKey`](CTorPath::ClientHsDescEncKey),
47/// the keystore will declare the key not found.
48/// If the requested `CTorPath` is [`ClientHsDescEncKey`](CTorPath::ClientHsDescEncKey),
49/// but the `KeystoreItemType` is not [`X25519StaticKeypair`](KeyType::X25519StaticKeypair),
50/// an error is returned.
51pub struct CTorClientKeystore(CTorKeystore);
52
53impl CTorClientKeystore {
54    /// Create a new `CTorKeystore` rooted at the specified `keystore_dir` directory.
55    ///
56    /// This function returns an error if `keystore_dir` is not a directory,
57    /// or if it does not conform to the requirements of the specified `Mistrust`.
58    pub fn from_path_and_mistrust(
59        keystore_dir: impl AsRef<Path>,
60        mistrust: &Mistrust,
61        id: KeystoreId,
62    ) -> Result<Self> {
63        CTorKeystore::from_path_and_mistrust(keystore_dir, mistrust, id).map(Self)
64    }
65}
66
67/// Extract the HsId from `spec, or return `res`.
68macro_rules! hsid_if_supported {
69    ($spec:expr, $ret:expr, $key_type:expr) => {{
70        // If the key specifier doesn't have a CTorPath,
71        // we can't possibly handle this key.
72        let Some(ctor_path) = $spec.ctor_path() else {
73            return $ret;
74        };
75
76        // This keystore only deals with service keys...
77        let CTorPath::ClientHsDescEncKey(hsid) = ctor_path else {
78            return $ret;
79        };
80
81        if *$key_type != KeyType::X25519StaticKeypair.into() {
82            return Err(CTorKeystoreError::InvalidKeystoreItemType {
83                item_type: $key_type.clone(),
84                item: "client restricted discovery key".into(),
85            }
86            .into());
87        }
88
89        hsid
90    }};
91}
92
93impl CTorClientKeystore {
94    /// List all the key entries in the keystore_dir.
95    fn list_entries(&self, dir: &RelKeyPath) -> Result<fs::ReadDir> {
96        let entries = checked_op!(read_directory, dir)
97            .map_err(|e| FilesystemError::FsMistrust {
98                action: FilesystemAction::Read,
99                path: dir.rel_path_unchecked().into(),
100                err: e.into(),
101            })
102            .map_err(CTorKeystoreError::Filesystem)?;
103
104        Ok(entries)
105    }
106}
107
108/// The extension of the client keys stored in this store.
109const KEY_EXTENSION: &str = "auth_private";
110
111impl CTorClientKeystore {
112    /// Read the contents of the specified key.
113    ///
114    /// Returns `Ok(None)` if the file doesn't exist.
115    fn read_key(&self, key_path: &Path) -> StdResult<Option<String>, CTorKeystoreError> {
116        let key_path = self.0.rel_path(key_path.into());
117
118        // TODO: read and parse the key, see if it matches the specified hsid
119        let content = match checked_op!(read_to_string, key_path) {
120            Err(fs_mistrust::Error::NotFound(_)) => {
121                // Someone removed the file between the time we read the directory and now.
122                return Ok(None);
123            }
124            res => res
125                .map_err(|err| FilesystemError::FsMistrust {
126                    action: FilesystemAction::Read,
127                    path: key_path.rel_path_unchecked().into(),
128                    err: err.into(),
129                })
130                .map_err(CTorKeystoreError::Filesystem)?,
131        };
132
133        Ok(Some(content))
134    }
135
136    /// List all entries in this store
137    ///
138    /// Returns a list of results, where `Ok` signifies a recognized entry,
139    /// and [`Err(CTorKeystoreError)`](crate::keystore::ctor::CTorKeystoreError)
140    /// an unrecognized one.
141    /// A key is said to be recognized if its file name ends with `.auth_private`,
142    /// and it presents this format:
143    /// `<hsid>:descriptor:x25519:<base32-encoded-x25519-public-key>`
144    fn list_keys(
145        &self,
146    ) -> Result<
147        impl Iterator<Item = StdResult<(HsId, HsClientDescEncKeypair), CTorKeystoreError>> + '_,
148    > {
149        use CTorKeystoreError::*;
150
151        let dir = self.0.rel_path(PathBuf::from("."));
152        Ok(self.list_entries(&dir)?.filter_map(|entry| {
153            let entry = entry
154                .map_err(|e| {
155                    // NOTE: can't use debug_report here, because debug_report
156                    // expects the ErrorKind (returned by e.kind()) to be
157                    // tor_error::ErrorKind (which has a is_always_a_warning() function
158                    // used by the macro).
159                    //
160                    // We have an io::Error here, which has an io::ErrorKind,
161                    // and thus can't be used with debug_report.
162                    debug!("cannot access key entry: {e}");
163                })
164                .ok()?;
165
166            let file_name = entry.file_name();
167            let path: &Path = file_name.as_ref();
168            let Some(KEY_EXTENSION) = path.extension().and_then(|e| e.to_str()) else {
169                return Some(Err(MalformedKey {
170                    path: entry.path(),
171                    err: MalformedClientKeyError::InvalidFormat.into(),
172                }));
173            };
174
175            let content = match self.read_key(path) {
176                Ok(c) => c,
177                Err(e) => {
178                    debug_report!(&e, "failed to read {}", path.display_lossy());
179                    return Some(Err(e));
180                }
181            }?;
182            Some(
183                parse_client_keypair(content.trim()).map_err(|e| MalformedKey {
184                    path: path.into(),
185                    err: e.into(),
186                }),
187            )
188        }))
189    }
190}
191
192/// Parse a client restricted discovery keypair,
193/// returning the [`HsId`] of the service the key is meant for,
194/// and the corresponding [`HsClientDescEncKeypair`].
195///
196/// `key` is expected to be in the
197/// `<hsid>:descriptor:x25519:<base32-encoded-x25519-public-key>`
198/// format.
199///
200/// TODO: we might want to move this to tor-hscrypto at some point,
201/// but for now, we don't actually *need* to expose this publicly.
202fn parse_client_keypair(
203    key: impl AsRef<str>,
204) -> StdResult<(HsId, HsClientDescEncKeypair), MalformedClientKeyError> {
205    let key = key.as_ref();
206    let (hsid, auth_type, key_type, encoded_key) = key
207        .split(':')
208        .collect_tuple()
209        .ok_or(MalformedClientKeyError::InvalidFormat)?;
210
211    if auth_type != "descriptor" {
212        return Err(MalformedClientKeyError::InvalidAuthType(auth_type.into()));
213    }
214
215    if key_type != "x25519" {
216        return Err(MalformedClientKeyError::InvalidKeyType(key_type.into()));
217    }
218
219    // Note: Tor's base32 decoder is case-insensitive, so we can't assume the input
220    // is all uppercase.
221    //
222    // TODO: consider using `data_encoding_macro::new_encoding` to create a new Encoding
223    // with an alphabet that includes lowercase letters instead of to_uppercase()ing the string.
224    let encoded_key = encoded_key.to_uppercase();
225    let x25519_sk = data_encoding::BASE32_NOPAD.decode(encoded_key.as_bytes())?;
226    let x25519_sk: [u8; 32] = x25519_sk
227        .try_into()
228        .map_err(|_| MalformedClientKeyError::InvalidKeyMaterial)?;
229
230    let secret = curve25519::StaticSecret::from(x25519_sk);
231    let public = (&secret).into();
232    let x25519_keypair = curve25519::StaticKeypair { secret, public };
233    let hsid = HsId::from_str(&format!("{hsid}.onion"))?;
234
235    Ok((hsid, x25519_keypair.into()))
236}
237
238impl Keystore for CTorClientKeystore {
239    fn id(&self) -> &KeystoreId {
240        &self.0.id
241    }
242
243    fn contains(&self, key_spec: &dyn KeySpecifier, item_type: &KeystoreItemType) -> Result<bool> {
244        self.get(key_spec, item_type).map(|k| k.is_some())
245    }
246
247    fn get(
248        &self,
249        key_spec: &dyn KeySpecifier,
250        item_type: &KeystoreItemType,
251    ) -> Result<Option<ErasedKey>> {
252        let want_hsid = hsid_if_supported!(key_spec, Ok(None), item_type);
253        Ok(self
254            .list_keys()?
255            .find_map(|entry| {
256                if let Ok((hsid, key)) = entry {
257                    (hsid == want_hsid).then(|| key.into())
258                } else {
259                    None
260                }
261            })
262            .map(|k: curve25519::StaticKeypair| Box::new(k) as ErasedKey))
263    }
264
265    #[cfg(feature = "onion-service-cli-extra")]
266    fn raw_entry_id(&self, raw_id: &str) -> Result<RawEntryId> {
267        Ok(RawEntryId::Path(PathBuf::from(raw_id.to_string())))
268    }
269
270    fn insert(&self, _key: &dyn EncodableItem, _key_spec: &dyn KeySpecifier) -> Result<()> {
271        Err(CTorKeystoreError::NotSupported { action: "insert" }.into())
272    }
273
274    fn remove(
275        &self,
276        _key_spec: &dyn KeySpecifier,
277        _item_type: &KeystoreItemType,
278    ) -> Result<Option<()>> {
279        Err(CTorKeystoreError::NotSupported { action: "remove" }.into())
280    }
281
282    #[cfg(feature = "onion-service-cli-extra")]
283    fn remove_unchecked(&self, _entry_id: &RawEntryId) -> Result<()> {
284        Err(CTorKeystoreError::NotSupported {
285            action: "remove_unchecked",
286        }
287        .into())
288    }
289
290    fn list(&self) -> Result<Vec<KeystoreEntryResult<KeystoreEntry>>> {
291        use CTorKeystoreError::*;
292
293        let keys = self
294            .list_keys()?
295            .filter_map(|entry| match entry {
296                Ok((hsid, _)) => {
297                    let key_path: KeyPath = CTorPath::ClientHsDescEncKey(hsid).into();
298                    let key_type: KeystoreItemType = KeyType::X25519StaticKeypair.into();
299                    let raw_id = RawEntryId::Path(key_path.ctor()?.to_string().into());
300                    Some(Ok(KeystoreEntry::new(
301                        key_path,
302                        key_type,
303                        self.id(),
304                        raw_id,
305                    )))
306                }
307                Err(e) => match e {
308                    MalformedKey { ref path, err: _ } => {
309                        let raw_id = RawEntryId::Path(path.clone());
310                        let entry = RawKeystoreEntry::new(raw_id, self.id().clone()).into();
311                        Some(Err(UnrecognizedEntryError::new(entry, Arc::new(e))))
312                    }
313                    // `InvalidKeystoreItemType` variant is filtered out because it can't
314                    // be returned by [`CTorClientKeystore::list_keys`].
315                    InvalidKeystoreItemType { .. } => None,
316                    // The following variants are irrelevant at this level because they
317                    // cannot represent an unrecognized key.
318                    Filesystem(_) => None,
319                    NotSupported { .. } => None,
320                    Bug(_) => None,
321                },
322            })
323            .collect();
324
325        Ok(keys)
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    // @@ begin test lint list maintained by maint/add_warning @@
332    #![allow(clippy::bool_assert_comparison)]
333    #![allow(clippy::clone_on_copy)]
334    #![allow(clippy::dbg_macro)]
335    #![allow(clippy::mixed_attributes_style)]
336    #![allow(clippy::print_stderr)]
337    #![allow(clippy::print_stdout)]
338    #![allow(clippy::single_char_pattern)]
339    #![allow(clippy::unwrap_used)]
340    #![allow(clippy::unchecked_time_subtraction)]
341    #![allow(clippy::useless_vec)]
342    #![allow(clippy::needless_pass_by_value)]
343    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
344    use super::*;
345    use std::fs;
346    use tempfile::{TempDir, tempdir};
347
348    use crate::test_utils::{DummyKey, TestCTorSpecifier, assert_found};
349
350    #[cfg(unix)]
351    use std::os::unix::fs::PermissionsExt;
352
353    /// A valid client restricted discovery key.
354    const ALICE_AUTH_PRIVATE_VALID: &str = include_str!("../../../testdata/alice.auth_private");
355
356    /// An invalid client restricted discovery key.
357    const BOB_AUTH_PRIVATE_INVALID: &str = include_str!("../../../testdata/bob.auth_private");
358
359    /// A valid client restricted discovery key.
360    const CAROL_AUTH_PRIVATE_VALID: &str = include_str!("../../../testdata/carol.auth_private");
361
362    /// A valid client restricted discovery key.
363    const DAN_AUTH_PRIVATE_VALID: &str = include_str!("../../../testdata/dan.auth_private");
364
365    // An .onion addr we don't have a client key for.
366    const HSID: &str = "mnyizjj7m3hpcr7i5afph3zt7maa65johyu2ruis6z7cmnjmaj3h6tad.onion";
367
368    fn init_keystore(id: &str) -> (CTorClientKeystore, TempDir) {
369        let keystore_dir = tempdir().unwrap();
370
371        #[cfg(unix)]
372        fs::set_permissions(&keystore_dir, fs::Permissions::from_mode(0o700)).unwrap();
373
374        let id = KeystoreId::from_str(id).unwrap();
375        let keystore =
376            CTorClientKeystore::from_path_and_mistrust(&keystore_dir, &Mistrust::default(), id)
377                .unwrap();
378
379        let keys: &[(&str, &str)] = &[
380            ("alice.auth_private", ALICE_AUTH_PRIVATE_VALID),
381            // A couple of malformed key, added to check that our impl doesn't trip over them
382            ("bob.auth_private", BOB_AUTH_PRIVATE_INVALID),
383            (
384                "alice-truncated.auth_private",
385                &ALICE_AUTH_PRIVATE_VALID[..100],
386            ),
387            // A valid key, but with the wrong extension (so it should be ignored)
388            ("carol.auth", CAROL_AUTH_PRIVATE_VALID),
389            ("dan.auth_private", DAN_AUTH_PRIVATE_VALID),
390        ];
391
392        for (name, key) in keys {
393            fs::write(keystore_dir.path().join(name), key).unwrap();
394        }
395
396        (keystore, keystore_dir)
397    }
398
399    #[test]
400    fn get() {
401        let (keystore, _keystore_dir) = init_keystore("foo");
402        let path = CTorPath::ClientHsDescEncKey(HsId::from_str(HSID).unwrap());
403
404        // Not found!
405        assert_found!(
406            keystore,
407            &TestCTorSpecifier(path.clone()),
408            &KeyType::X25519StaticKeypair,
409            false
410        );
411
412        for hsid in &[ALICE_AUTH_PRIVATE_VALID, DAN_AUTH_PRIVATE_VALID] {
413            // Extract the HsId associated with this key.
414            let onion = hsid.split(":").next().unwrap();
415            let hsid = HsId::from_str(&format!("{onion}.onion")).unwrap();
416            let path = CTorPath::ClientHsDescEncKey(hsid.clone());
417
418            // Found!
419            assert_found!(
420                keystore,
421                &TestCTorSpecifier(path.clone()),
422                &KeyType::X25519StaticKeypair,
423                true
424            );
425        }
426
427        let keys: Vec<_> = keystore
428            .list()
429            .unwrap()
430            .into_iter()
431            .filter(|e| e.is_ok())
432            .collect();
433
434        assert_eq!(keys.len(), 2);
435        assert!(keys.iter().all(|entry| {
436            entry.as_ref().unwrap().key_type() == &KeyType::X25519StaticKeypair.into()
437        }));
438    }
439
440    #[test]
441    fn unsupported_operation() {
442        let (keystore, _keystore_dir) = init_keystore("foo");
443        let path = CTorPath::ClientHsDescEncKey(HsId::from_str(HSID).unwrap());
444
445        let err = keystore
446            .remove(
447                &TestCTorSpecifier(path.clone()),
448                &KeyType::X25519StaticKeypair.into(),
449            )
450            .unwrap_err();
451
452        assert_eq!(err.to_string(), "Operation not supported: remove");
453
454        let err = keystore
455            .insert(&DummyKey, &TestCTorSpecifier(path))
456            .unwrap_err();
457
458        assert_eq!(err.to_string(), "Operation not supported: insert");
459    }
460
461    #[test]
462    fn wrong_keytype() {
463        let (keystore, _keystore_dir) = init_keystore("foo");
464        let path = CTorPath::ClientHsDescEncKey(HsId::from_str(HSID).unwrap());
465
466        let err = keystore
467            .get(
468                &TestCTorSpecifier(path.clone()),
469                &KeyType::Ed25519PublicKey.into(),
470            )
471            .map(|_| ())
472            .unwrap_err();
473
474        assert_eq!(
475            err.to_string(),
476            "Invalid item type Ed25519PublicKey for client restricted discovery key"
477        );
478    }
479
480    #[test]
481    fn list() {
482        let (keystore, _keystore_dir) = init_keystore("foo");
483        // The keystore contains two recognized entries and three
484        // unrecognized entries.
485        let mut recognized = 0;
486        let mut unrecognized = 0;
487        for e in keystore.list().unwrap() {
488            if e.is_ok() {
489                recognized += 1;
490            } else {
491                unrecognized += 1;
492            }
493        }
494        assert_eq!(recognized, 2);
495        assert_eq!(unrecognized, 3);
496    }
497}