Lines
81.05 %
Functions
71.93 %
Branches
100 %
//! Read-only C Tor client key store implementation
//!
//! See [`CTorClientKeystore`] for more details.
use std::fs;
use std::path::{Path, PathBuf};
use std::result::Result as StdResult;
use std::str::FromStr as _;
use std::sync::Arc;
use crate::keystore::ctor::CTorKeystore;
use crate::keystore::ctor::err::{CTorKeystoreError, MalformedClientKeyError};
use crate::keystore::fs_utils::{FilesystemAction, FilesystemError, RelKeyPath, checked_op};
use crate::keystore::{EncodableItem, ErasedKey, KeySpecifier, Keystore};
use crate::raw::{RawEntryId, RawKeystoreEntry};
use crate::{
CTorPath, KeyPath, KeystoreEntry, KeystoreEntryResult, KeystoreId, Result,
UnrecognizedEntryError,
};
use fs_mistrust::Mistrust;
use itertools::Itertools as _;
use tor_basic_utils::PathExt;
use tor_error::debug_report;
use tor_hscrypto::pk::{HsClientDescEncKeypair, HsId};
use tor_key_forge::{KeyType, KeystoreItemType};
use tor_llcrypto::pk::curve25519;
use tracing::debug;
/// A read-only C Tor client keystore.
///
/// This keystore provides read-only access to the client restricted discovery keys
/// rooted at a given `ClientOnionAuthDir` directory (see `ClientOnionAuthDir` in `tor(1)`).
/// The key files must be in the
/// `<hsid>:descriptor:x25519:<base32-encoded-x25519-public-key>` format
/// and have the `.auth_private` extension.
/// Invalid keys, and keys that don't have the expected extension, will be ignored.
/// The only supported [`Keystore`] operations are [`contains`](Keystore::contains),
/// [`get`](Keystore::get), and [`list`](Keystore::list). All other keystore operations
/// will return an error.
/// This keystore implementation uses the [`CTorPath`] of the requested [`KeySpecifier`]
/// and the [`KeystoreItemType`] to identify the appropriate restricted discovery keypair.
/// If the requested `CTorPath` is not [`HsClientDescEncKeypair`](CTorPath::HsClientDescEncKeypair),
/// the keystore will declare the key not found.
/// If the requested `CTorPath` is [`HsClientDescEncKeypair`](CTorPath::HsClientDescEncKeypair),
/// but the `KeystoreItemType` is not [`X25519StaticKeypair`](KeyType::X25519StaticKeypair),
/// an error is returned.
pub struct CTorClientKeystore(CTorKeystore);
impl CTorClientKeystore {
/// Create a new `CTorKeystore` rooted at the specified `keystore_dir` directory.
/// This function returns an error if `keystore_dir` is not a directory,
/// or if it does not conform to the requirements of the specified `Mistrust`.
pub fn from_path_and_mistrust(
keystore_dir: impl AsRef<Path>,
mistrust: &Mistrust,
id: KeystoreId,
) -> Result<Self> {
CTorKeystore::from_path_and_mistrust(keystore_dir, mistrust, id).map(Self)
}
/// Extract the HsId from `spec, or return `res`.
macro_rules! hsid_if_supported {
($spec:expr, $ret:expr, $key_type:expr) => {{
// If the key specifier doesn't have a CTorPath,
// we can't possibly handle this key.
let Some(ctor_path) = $spec.ctor_path() else {
return $ret;
// This keystore only deals with service keys...
let CTorPath::HsClientDescEncKeypair { hs_id } = ctor_path else {
if *$key_type != KeyType::X25519StaticKeypair.into() {
return Err(CTorKeystoreError::InvalidKeystoreItemType {
item_type: $key_type.clone(),
item: "client restricted discovery key".into(),
.into());
hs_id
}};
/// List all the key entries in the keystore_dir.
fn list_entries(&self, dir: &RelKeyPath) -> Result<fs::ReadDir> {
let entries = checked_op!(read_directory, dir)
.map_err(|e| FilesystemError::FsMistrust {
action: FilesystemAction::Read,
path: dir.rel_path_unchecked().into(),
err: e.into(),
})
.map_err(CTorKeystoreError::Filesystem)?;
Ok(entries)
/// The extension of the client keys stored in this store.
const KEY_EXTENSION: &str = "auth_private";
/// Read the contents of the specified key.
/// Returns `Ok(None)` if the file doesn't exist.
fn read_key(&self, key_path: &Path) -> StdResult<Option<String>, CTorKeystoreError> {
let key_path = self.0.rel_path(key_path.into());
// TODO: read and parse the key, see if it matches the specified hsid
let content = match checked_op!(read_to_string, key_path) {
Err(fs_mistrust::Error::NotFound(_)) => {
// Someone removed the file between the time we read the directory and now.
return Ok(None);
res => res
.map_err(|err| FilesystemError::FsMistrust {
path: key_path.rel_path_unchecked().into(),
err: err.into(),
.map_err(CTorKeystoreError::Filesystem)?,
Ok(Some(content))
/// List all entries in this store
/// Returns a list of results, where `Ok` signifies a recognized entry,
/// and [`Err(CTorKeystoreError)`](crate::keystore::ctor::CTorKeystoreError)
/// an unrecognized one.
/// A key is said to be recognized if its file name ends with `.auth_private`,
/// and it presents this format:
/// `<hsid>:descriptor:x25519:<base32-encoded-x25519-public-key>`
fn list_keys(
&self,
) -> Result<
impl Iterator<Item = StdResult<(HsId, HsClientDescEncKeypair), CTorKeystoreError>> + '_,
> {
use CTorKeystoreError::*;
let dir = self.0.rel_path(PathBuf::from("."));
Ok(self.list_entries(&dir)?.filter_map(|entry| {
let entry = entry
.map_err(|e| {
// NOTE: can't use debug_report here, because debug_report
// expects the ErrorKind (returned by e.kind()) to be
// tor_error::ErrorKind (which has a is_always_a_warning() function
// used by the macro).
//
// We have an io::Error here, which has an io::ErrorKind,
// and thus can't be used with debug_report.
debug!("cannot access key entry: {e}");
.ok()?;
let file_name = entry.file_name();
let path: &Path = file_name.as_ref();
let Some(KEY_EXTENSION) = path.extension().and_then(|e| e.to_str()) else {
return Some(Err(MalformedKey {
path: entry.path(),
err: MalformedClientKeyError::InvalidFormat.into(),
}));
let content = match self.read_key(path) {
Ok(c) => c,
Err(e) => {
debug_report!(&e, "failed to read {}", path.display_lossy());
return Some(Err(e));
}?;
Some(
parse_client_keypair(content.trim()).map_err(|e| MalformedKey {
path: path.into(),
}),
)
}))
/// Parse a client restricted discovery keypair,
/// returning the [`HsId`] of the service the key is meant for,
/// and the corresponding [`HsClientDescEncKeypair`].
/// `key` is expected to be in the
/// format.
/// TODO: we might want to move this to tor-hscrypto at some point,
/// but for now, we don't actually *need* to expose this publicly.
fn parse_client_keypair(
key: impl AsRef<str>,
) -> StdResult<(HsId, HsClientDescEncKeypair), MalformedClientKeyError> {
let key = key.as_ref();
let (hsid, auth_type, key_type, encoded_key) = key
.split(':')
.collect_tuple()
.ok_or(MalformedClientKeyError::InvalidFormat)?;
if auth_type != "descriptor" {
return Err(MalformedClientKeyError::InvalidAuthType(auth_type.into()));
if key_type != "x25519" {
return Err(MalformedClientKeyError::InvalidKeyType(key_type.into()));
// Note: Tor's base32 decoder is case-insensitive, so we can't assume the input
// is all uppercase.
// TODO: consider using `data_encoding_macro::new_encoding` to create a new Encoding
// with an alphabet that includes lowercase letters instead of to_uppercase()ing the string.
let encoded_key = encoded_key.to_uppercase();
let x25519_sk = data_encoding::BASE32_NOPAD.decode(encoded_key.as_bytes())?;
let x25519_sk: [u8; 32] = x25519_sk
.try_into()
.map_err(|_| MalformedClientKeyError::InvalidKeyMaterial)?;
let secret = curve25519::StaticSecret::from(x25519_sk);
let public = (&secret).into();
let x25519_keypair = curve25519::StaticKeypair { secret, public };
let hsid = HsId::from_str(&format!("{hsid}.onion"))?;
Ok((hsid, x25519_keypair.into()))
impl Keystore for CTorClientKeystore {
fn id(&self) -> &KeystoreId {
&self.0.id
fn contains(&self, key_spec: &dyn KeySpecifier, item_type: &KeystoreItemType) -> Result<bool> {
self.get(key_spec, item_type).map(|k| k.is_some())
fn get(
key_spec: &dyn KeySpecifier,
item_type: &KeystoreItemType,
) -> Result<Option<ErasedKey>> {
let want_hsid = hsid_if_supported!(key_spec, Ok(None), item_type);
Ok(self
.list_keys()?
.find_map(|entry| {
if let Ok((hsid, key)) = entry {
(hsid == want_hsid).then(|| key.into())
} else {
None
.map(|k: curve25519::StaticKeypair| Box::new(k) as ErasedKey))
#[cfg(feature = "onion-service-cli-extra")]
fn raw_entry_id(&self, raw_id: &str) -> Result<RawEntryId> {
Ok(RawEntryId::Path(PathBuf::from(raw_id.to_string())))
fn insert(&self, _key: &dyn EncodableItem, _key_spec: &dyn KeySpecifier) -> Result<()> {
Err(CTorKeystoreError::NotSupported { action: "insert" }.into())
fn remove(
_key_spec: &dyn KeySpecifier,
_item_type: &KeystoreItemType,
) -> Result<Option<()>> {
Err(CTorKeystoreError::NotSupported { action: "remove" }.into())
fn remove_unchecked(&self, _entry_id: &RawEntryId) -> Result<()> {
Err(CTorKeystoreError::NotSupported {
action: "remove_unchecked",
.into())
fn list(&self) -> Result<Vec<KeystoreEntryResult<KeystoreEntry>>> {
let keys = self
.filter_map(|entry| match entry {
Ok((hs_id, _)) => {
let key_path: KeyPath = CTorPath::HsClientDescEncKeypair { hs_id }.into();
let key_type: KeystoreItemType = KeyType::X25519StaticKeypair.into();
let raw_id = RawEntryId::Path(key_path.ctor()?.to_string().into());
Some(Ok(KeystoreEntry::new(
key_path,
key_type,
self.id(),
raw_id,
)))
Err(e) => match e {
MalformedKey { ref path, err: _ } => {
let raw_id = RawEntryId::Path(path.clone());
let entry = RawKeystoreEntry::new(raw_id, self.id().clone()).into();
Some(Err(UnrecognizedEntryError::new(entry, Arc::new(e))))
// `InvalidKeystoreItemType` variant is filtered out because it can't
// be returned by [`CTorClientKeystore::list_keys`].
InvalidKeystoreItemType { .. } => None,
// The following variants are irrelevant at this level because they
// cannot represent an unrecognized key.
Filesystem(_) => None,
NotSupported { .. } => None,
Bug(_) => None,
},
.collect();
Ok(keys)
#[cfg(test)]
mod tests {
// @@ begin test lint list maintained by maint/add_warning @@
#![allow(clippy::bool_assert_comparison)]
#![allow(clippy::clone_on_copy)]
#![allow(clippy::dbg_macro)]
#![allow(clippy::mixed_attributes_style)]
#![allow(clippy::print_stderr)]
#![allow(clippy::print_stdout)]
#![allow(clippy::single_char_pattern)]
#![allow(clippy::unwrap_used)]
#![allow(clippy::unchecked_time_subtraction)]
#![allow(clippy::useless_vec)]
#![allow(clippy::needless_pass_by_value)]
//! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
use super::*;
use tempfile::{TempDir, tempdir};
use crate::test_utils::{DummyKey, TestCTorSpecifier, assert_found};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
/// A valid client restricted discovery key.
const ALICE_AUTH_PRIVATE_VALID: &str = include_str!("../../../testdata/alice.auth_private");
/// An invalid client restricted discovery key.
const BOB_AUTH_PRIVATE_INVALID: &str = include_str!("../../../testdata/bob.auth_private");
const CAROL_AUTH_PRIVATE_VALID: &str = include_str!("../../../testdata/carol.auth_private");
const DAN_AUTH_PRIVATE_VALID: &str = include_str!("../../../testdata/dan.auth_private");
// An .onion addr we don't have a client key for.
const HSID: &str = "mnyizjj7m3hpcr7i5afph3zt7maa65johyu2ruis6z7cmnjmaj3h6tad.onion";
fn init_keystore(id: &str) -> (CTorClientKeystore, TempDir) {
let keystore_dir = tempdir().unwrap();
fs::set_permissions(&keystore_dir, fs::Permissions::from_mode(0o700)).unwrap();
let id = KeystoreId::from_str(id).unwrap();
let keystore =
CTorClientKeystore::from_path_and_mistrust(&keystore_dir, &Mistrust::default(), id)
.unwrap();
let keys: &[(&str, &str)] = &[
("alice.auth_private", ALICE_AUTH_PRIVATE_VALID),
// A couple of malformed key, added to check that our impl doesn't trip over them
("bob.auth_private", BOB_AUTH_PRIVATE_INVALID),
(
"alice-truncated.auth_private",
&ALICE_AUTH_PRIVATE_VALID[..100],
),
// A valid key, but with the wrong extension (so it should be ignored)
("carol.auth", CAROL_AUTH_PRIVATE_VALID),
("dan.auth_private", DAN_AUTH_PRIVATE_VALID),
];
for (name, key) in keys {
fs::write(keystore_dir.path().join(name), key).unwrap();
(keystore, keystore_dir)
#[test]
fn get() {
let (keystore, _keystore_dir) = init_keystore("foo");
let path = CTorPath::HsClientDescEncKeypair {
hs_id: HsId::from_str(HSID).unwrap(),
// Not found!
assert_found!(
keystore,
&TestCTorSpecifier(path.clone()),
&KeyType::X25519StaticKeypair,
false
);
for hsid in &[ALICE_AUTH_PRIVATE_VALID, DAN_AUTH_PRIVATE_VALID] {
// Extract the HsId associated with this key.
let onion = hsid.split(":").next().unwrap();
let hsid = HsId::from_str(&format!("{onion}.onion")).unwrap();
hs_id: hsid.clone(),
// Found!
true
let keys: Vec<_> = keystore
.list()
.unwrap()
.into_iter()
.filter(|e| e.is_ok())
assert_eq!(keys.len(), 2);
assert!(keys.iter().all(|entry| {
entry.as_ref().unwrap().key_type() == &KeyType::X25519StaticKeypair.into()
fn unsupported_operation() {
let err = keystore
.remove(
&KeyType::X25519StaticKeypair.into(),
.unwrap_err();
assert_eq!(err.to_string(), "Operation not supported: remove");
.insert(&DummyKey, &TestCTorSpecifier(path))
assert_eq!(err.to_string(), "Operation not supported: insert");
fn wrong_keytype() {
.get(
&KeyType::Ed25519PublicKey.into(),
.map(|_| ())
assert_eq!(
err.to_string(),
"Invalid item type Ed25519PublicKey for client restricted discovery key"
fn list() {
// The keystore contains two recognized entries and three
// unrecognized entries.
let mut recognized = 0;
let mut unrecognized = 0;
for e in keystore.list().unwrap() {
if e.is_ok() {
recognized += 1;
unrecognized += 1;
assert_eq!(recognized, 2);
assert_eq!(unrecognized, 3);