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