From e7aa1d6b6251102f9b2b0e382ad2a1f7b5b47ee5 Mon Sep 17 00:00:00 2001 From: Micah Elizabeth Scott Date: Wed, 28 Jun 2023 15:15:17 -0700 Subject: [PATCH] Start implementing Proposal 327 This adds a new tor-hspow crate with the first layers of support in place for onion service client puzzles as described in Proposal 327. The API here is experimental, and it's currently only implementing the self-contained parts of the client puzzle. So, it can verify and solve puzzles, but it has no event loop integration or nonce replay tracking or prioritization code yet. These things seem like they would eventually live in the same crate. Signed-off-by: Micah Elizabeth Scott --- Cargo.lock | 14 + Cargo.toml | 1 + crates/tor-hspow/Cargo.toml | 30 ++ crates/tor-hspow/README.md | 24 ++ crates/tor-hspow/src/err.rs | 51 ++++ crates/tor-hspow/src/lib.rs | 47 +++ crates/tor-hspow/src/v1.rs | 15 + crates/tor-hspow/src/v1/challenge.rs | 125 ++++++++ crates/tor-hspow/src/v1/err.rs | 32 +++ crates/tor-hspow/src/v1/solve.rs | 127 ++++++++ crates/tor-hspow/src/v1/types.rs | 164 +++++++++++ crates/tor-hspow/src/v1/verify.rs | 72 +++++ crates/tor-hspow/tests/hspow_vectors.rs | 368 ++++++++++++++++++++++++ 13 files changed, 1070 insertions(+) create mode 100644 crates/tor-hspow/Cargo.toml create mode 100644 crates/tor-hspow/README.md create mode 100644 crates/tor-hspow/src/err.rs create mode 100644 crates/tor-hspow/src/lib.rs create mode 100644 crates/tor-hspow/src/v1.rs create mode 100644 crates/tor-hspow/src/v1/challenge.rs create mode 100644 crates/tor-hspow/src/v1/err.rs create mode 100644 crates/tor-hspow/src/v1/solve.rs create mode 100644 crates/tor-hspow/src/v1/types.rs create mode 100644 crates/tor-hspow/src/v1/verify.rs create mode 100644 crates/tor-hspow/tests/hspow_vectors.rs diff --git a/Cargo.lock b/Cargo.lock index edaeba084..dceda6f6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4896,6 +4896,20 @@ dependencies = [ "tor-units", ] +[[package]] +name = "tor-hspow" +version = "0.1.0" +dependencies = [ + "arrayvec", + "blake2", + "derive_more", + "equix", + "hex-literal", + "rand 0.8.5", + "thiserror", + "tor-hscrypto", +] + [[package]] name = "tor-hsservice" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 0e1a4823a..a8ae68c8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "crates/tor-protover", "crates/tor-bytes", "crates/tor-hscrypto", + "crates/tor-hspow", "crates/tor-socksproto", "crates/tor-checkable", "crates/tor-cert", diff --git a/crates/tor-hspow/Cargo.toml b/crates/tor-hspow/Cargo.toml new file mode 100644 index 000000000..520430f4d --- /dev/null +++ b/crates/tor-hspow/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "tor-hspow" +version = "0.1.0" +authors = ["The Tor Project, Inc.", "Micah Elizabeth Scott "] +edition = "2021" +rust-version = "1.65" +license = "MIT OR Apache-2.0" +homepage = "https://gitlab.torproject.org/tpo/core/arti/-/wikis/home" +description = "Solve and verify proof-of-work client puzzles used by onion services" +keywords = ["tor", "arti", "cryptography"] +categories = ["cryptography"] +repository = "https://gitlab.torproject.org/tpo/core/arti.git/" + +publish = false + +[features] +default = [] +full = ["tor-hscrypto/full"] + +[dependencies] +arrayvec = "0.7.4" +blake2 = "0.10.6" +derive_more = "0.99.3" +equix = { path = "../equix", version = "0.1.0" } +rand = "0.8.5" +thiserror = "1" +tor-hscrypto = { version = "0.3.0", path = "../tor-hscrypto" } + +[dev-dependencies] +hex-literal = "0.4.1" diff --git a/crates/tor-hspow/README.md b/crates/tor-hspow/README.md new file mode 100644 index 000000000..feccd54e6 --- /dev/null +++ b/crates/tor-hspow/README.md @@ -0,0 +1,24 @@ +# tor-hspow + +Tor supports optional proof-of-work client puzzles, for mitigating denial of +service attacks on onion services. This crate implements the specific puzzle +algorithms we use, and infrastructure for solving puzzles asynchronously. + +[Proposal 327] introduced our first algorithm variant, named `v1`. +It is based on the Equi-X asymmetric puzzle and an adjustable effort check +using a Blake2b hash of the proof. The underlying algorithm is provided by +the [`equix`] crate. + +[Proposal 327]: https://gitlab.torproject.org/tpo/core/torspec/-/blob/main/proposals/327-pow-over-intro.txt + +## EXPERIMENTAL DRAFT + +Just here as a proof-of-concept and to test the algorithms. +None of the API here is final, the current status is that lower layers are +exposed unnecessarily and upper layers are unwritten. + +For Tor client puzzle support in Arti. ([#889]) + +[#889]: https://gitlab.torproject.org/tpo/core/arti/-/issues/889 + + diff --git a/crates/tor-hspow/src/err.rs b/crates/tor-hspow/src/err.rs new file mode 100644 index 000000000..4bcb0be8e --- /dev/null +++ b/crates/tor-hspow/src/err.rs @@ -0,0 +1,51 @@ +//! Define error types for the `tor-hspow` crate + +/// Error type for the onion service proof of work subsystem +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// Solution was incorrect + /// + /// In general the detailed reason for failure should be ignored, + /// and it certainly should not be shared with clients. It's useful + /// for unit testing and possibly debugging. A particular type of flaw + /// in a solution could be exposed at a variety of layers in the + /// verification process depending on luck and algorithm parameters. + #[error("Incorrect solution to a client puzzle")] + BadSolution(#[source] SolutionError), + + /// Runtime error while solving a proof of work puzzle + /// + /// Something went wrong in the environment to prevent the + /// solver from completing. + #[error("Runtime error while solving a client puzzle: {0}")] + SolveRuntime(#[source] RuntimeError), + + /// Runtime error while verifying a proof of work puzzle + /// + /// Something went wrong in the environment to prevent the + /// verifier from coming to any conclusion. + #[error("Runtime error while verifying a client puzzle: {0}")] + VerifyRuntime(#[source] RuntimeError), +} + +/// Detailed errors for ways a solution can fail verification +/// +/// These errors must not be exposed to clients, who might +/// use them to gain an advantage in computing solutions. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SolutionError { + /// Solution errors from the v1 protocol implementation + #[error("V1, {0}")] + V1(#[from] crate::v1::SolutionError), +} + +/// Detailed runtime errors +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum RuntimeError { + /// Runtime errors from the v1 protocol implementation + #[error("V1, {0}")] + V1(#[from] crate::v1::RuntimeError), +} diff --git a/crates/tor-hspow/src/lib.rs b/crates/tor-hspow/src/lib.rs new file mode 100644 index 000000000..574a5c653 --- /dev/null +++ b/crates/tor-hspow/src/lib.rs @@ -0,0 +1,47 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg, doc_cfg))] +#![doc = include_str!("../README.md")] +// @@ begin lint list maintained by maint/add_warning @@ +#![cfg_attr(not(ci_arti_stable), allow(renamed_and_removed_lints))] +#![cfg_attr(not(ci_arti_nightly), allow(unknown_lints))] +#![deny(missing_docs)] +#![warn(noop_method_call)] +#![deny(unreachable_pub)] +#![warn(clippy::all)] +#![deny(clippy::await_holding_lock)] +#![deny(clippy::cargo_common_metadata)] +#![deny(clippy::cast_lossless)] +#![deny(clippy::checked_conversions)] +#![warn(clippy::cognitive_complexity)] +#![deny(clippy::debug_assert_with_mut_call)] +#![deny(clippy::exhaustive_enums)] +#![deny(clippy::exhaustive_structs)] +#![deny(clippy::expl_impl_clone_on_copy)] +#![deny(clippy::fallible_impl_from)] +#![deny(clippy::implicit_clone)] +#![deny(clippy::large_stack_arrays)] +#![warn(clippy::manual_ok_or)] +#![deny(clippy::missing_docs_in_private_items)] +#![deny(clippy::missing_panics_doc)] +#![warn(clippy::needless_borrow)] +#![warn(clippy::needless_pass_by_value)] +#![warn(clippy::option_option)] +#![deny(clippy::print_stderr)] +#![deny(clippy::print_stdout)] +#![warn(clippy::rc_buffer)] +#![deny(clippy::ref_option_ref)] +#![warn(clippy::semicolon_if_nothing_returned)] +#![warn(clippy::trait_duplication_in_bounds)] +#![deny(clippy::unnecessary_wraps)] +#![warn(clippy::unseparated_literal_suffix)] +#![deny(clippy::unwrap_used)] +#![allow(clippy::let_unit_value)] // This can reasonably be done for explicitness +#![allow(clippy::uninlined_format_args)] +#![allow(clippy::significant_drop_in_scrutinee)] // arti/-/merge_requests/588/#note_2812945 +#![allow(clippy::result_large_err)] // temporary workaround for arti#587 +//! + +pub mod v1; + +mod err; + +pub use err::{Error, RuntimeError, SolutionError}; diff --git a/crates/tor-hspow/src/v1.rs b/crates/tor-hspow/src/v1.rs new file mode 100644 index 000000000..ac83cbe04 --- /dev/null +++ b/crates/tor-hspow/src/v1.rs @@ -0,0 +1,15 @@ +//! Version 1 client puzzle from [Proposal 327], using [`equix`] +//! +//! [Proposal 327]: https://gitlab.torproject.org/tpo/core/torspec/-/blob/main/proposals/327-pow-over-intro.txt + +mod challenge; +mod err; +mod solve; +mod types; +mod verify; + +pub use equix::{RuntimeOption, SolutionByteArray}; +pub use err::{RuntimeError, SolutionError}; +pub use solve::{Solver, SolverInput}; +pub use types::*; +pub use verify::Verifier; diff --git a/crates/tor-hspow/src/v1/challenge.rs b/crates/tor-hspow/src/v1/challenge.rs new file mode 100644 index 000000000..37cc8b87f --- /dev/null +++ b/crates/tor-hspow/src/v1/challenge.rs @@ -0,0 +1,125 @@ +//! Implement the `v1` protocol's challenge string format +//! +//! This is a packed byte-string which encodes our puzzle's parameters +//! as inputs for Equi-X. We need to construct challenge strings both to +//! solve and to verify puzzles. + +use crate::v1::{Effort, Instance, Nonce, Seed, SolutionError, NONCE_LEN, SEED_LEN}; +use arrayvec::ArrayVec; +use blake2::{digest::consts::U4, Blake2b, Digest}; + +/// Algorithm personalization string (P) +/// +/// This becomes part of the challenge string, binding a puzzle solution to +/// this particular algorithm even if other similar protocols exist using +/// the same building blocks. +const P_STRING: &[u8] = b"Tor hs intro v1\0"; + +/// Length of the personalization string, in bytes +const P_STRING_LEN: usize = 16; + +/// Length of the HsBlindId +const ID_LEN: usize = 32; + +/// Location of the [`Seed`] within a [`Challenge`] +const SEED_OFFSET: usize = P_STRING_LEN + ID_LEN; + +/// Location of the [`Nonce`] within a [`Challenge`] +const NONCE_OFFSET: usize = SEED_OFFSET + SEED_LEN; + +/// Location of the [`Effort`] within a [`Challenge`] +const EFFORT_OFFSET: usize = NONCE_OFFSET + NONCE_LEN; + +/// Packed length of an [`Effort`], in bytes +const EFFORT_LEN: usize = 4; + +/// Total length of our Equi-X challenge string +const CHALLENGE_LEN: usize = EFFORT_OFFSET + EFFORT_LEN; + +/// A fully assembled challenge string, with some access to inner fields +/// +/// This is the combined input to Equi-X. Defined by Proposal 327 +/// as `(P || ID || C || N || INT_32(E))` +#[derive(derive_more::AsRef, Debug, Clone, Eq, PartialEq)] +pub(super) struct Challenge([u8; CHALLENGE_LEN]); + +impl Challenge { + /// Build a new [`Challenge`] + /// + /// Copies [`Instance`], [`Effort`], and [`Nonce`] values into + /// a new byte array. + pub(super) fn new(instance: &Instance, effort: Effort, nonce: &Nonce) -> Self { + let mut result = ArrayVec::::new(); + result.extend(P_STRING.iter().copied()); + result.extend(instance.service().as_ref().iter().copied()); + assert_eq!(result.len(), SEED_OFFSET); + result.extend(instance.seed().as_ref().iter().copied()); + assert_eq!(result.len(), NONCE_OFFSET); + result.extend(nonce.as_ref().iter().copied()); + assert_eq!(result.len(), EFFORT_OFFSET); + result.extend(effort.as_ref().to_be_bytes().into_iter()); + Self(result.into_inner().expect("matching CHALLENGE_LEN")) + } + + /// Clone the [`Seed`] portion of this challenge + pub(super) fn seed(&self) -> Seed { + let array: [u8; SEED_LEN] = self.0[SEED_OFFSET..(SEED_OFFSET + SEED_LEN)] + .try_into() + .expect("slice length correct"); + array.into() + } + + /// Clone the [`Nonce`] portion of this challenge + pub(super) fn nonce(&self) -> Nonce { + let array: [u8; NONCE_LEN] = self.0[NONCE_OFFSET..(NONCE_OFFSET + NONCE_LEN)] + .try_into() + .expect("slice length correct"); + array.into() + } + + /// Return the [`Effort`] used in this challenge + pub(super) fn effort(&self) -> Effort { + u32::from_be_bytes( + self.0[EFFORT_OFFSET..(EFFORT_OFFSET + EFFORT_LEN)] + .try_into() + .expect("slice length correct"), + ) + .into() + } + + /// Increment the [`Nonce`] value inside this challenge + pub(super) fn increment_nonce(&mut self) { + /// Wrapping increment for a serialized little endian value of arbitrary width. + fn inc_le_bytes(slice: &mut [u8]) { + for byte in slice { + let (value, overflow) = (*byte).overflowing_add(1); + *byte = value; + if !overflow { + break; + } + } + } + inc_le_bytes(&mut self.0[NONCE_OFFSET..(NONCE_OFFSET + NONCE_LEN)]); + } + + /// Verify that a solution proof passes the effort test + /// + /// This computes a Blake2b hash of the challenge and the serialized + /// Equi-X solution, and tests the result against the effort encoded + /// in the challenge string. + /// + /// Used by both the [`crate::v1::Solver`] and the [`crate::v1::Verifier`]. + pub(super) fn check_effort( + &self, + proof: &equix::SolutionByteArray, + ) -> Result<(), SolutionError> { + let mut hasher = Blake2b::::new(); + hasher.update(self.as_ref()); + hasher.update(proof.as_ref()); + let value = u32::from_be_bytes(hasher.finalize().into()); + match value.checked_mul(*self.effort().as_ref()) { + Some(_) => Ok(()), + None => Err(SolutionError::Effort), + } + } +} diff --git a/crates/tor-hspow/src/v1/err.rs b/crates/tor-hspow/src/v1/err.rs new file mode 100644 index 000000000..1cd060f01 --- /dev/null +++ b/crates/tor-hspow/src/v1/err.rs @@ -0,0 +1,32 @@ +//! Error types local to the `v1` protocol implementation + +/// Protocol-specific ways a solution can fail verification +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SolutionError { + /// Mismatch between [`super::SeedHead`] and [`super::Instance`] + #[error("Solution has an unrecognized Seed value")] + Seed, + /// The effort constraint `H(challenge | proof) * effort` failed + #[error("Failed to verify solution effort")] + Effort, + /// The Equi-X proof is not well-formed, it failed at least one order test + #[error("Failed to verify order of Equi-X proof")] + Order, + /// The Equi-X proof does not apply to this particular challenge + #[error("Failed to verify hash sums for Equi-X proof")] + HashSum, + /// Couldn't construct the HashX function, this tells us a working + /// solver should have rejected this [`super::Nonce`] value. + #[error("Solution requires a challenge string that fails HashX constraints")] + ChallengeConstraints, +} + +/// Protocol-specific runtime errors +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum RuntimeError { + /// Unexpected error or runtime compiler error from the Equi-X layer + #[error("Equi-X error, {0}")] + EquiX(#[from] equix::Error), +} diff --git a/crates/tor-hspow/src/v1/solve.rs b/crates/tor-hspow/src/v1/solve.rs new file mode 100644 index 000000000..3e0aa7e65 --- /dev/null +++ b/crates/tor-hspow/src/v1/solve.rs @@ -0,0 +1,127 @@ +//! Solver implementation for v1 client puzzles + +use crate::v1::challenge::Challenge; +use crate::v1::{Effort, Instance, Nonce, RuntimeError, RuntimeOption, Solution, NONCE_LEN}; +use equix::{EquiXBuilder, HashError, SolverMemory}; +use rand::{CryptoRng, Rng, RngCore}; + +/// All inputs necessary to run the [`Solver`] +#[derive(Debug, Clone)] +pub struct SolverInput { + /// The puzzle instance we're solving + instance: Instance, + /// Effort chosen by the client for this solver run + effort: Effort, + /// Configuration settings for Equi-X, as an [`EquiXBuilder`] instance + equix: EquiXBuilder, +} + +impl SolverInput { + /// Construct a [`SolverInput`] by wrapping an [`Instance`]. + /// + /// This is a lower-level constructor. + /// Prefer [`Instance::with_effort()`] + pub fn new(instance: Instance, effort: Effort) -> Self { + SolverInput { + instance, + effort, + equix: Default::default(), + } + } + + /// Select the HashX runtime to use for this Solver input. + /// By default, uses [`RuntimeOption::TryCompile`] + pub fn runtime(&mut self, option: RuntimeOption) -> &mut Self { + self.equix.runtime(option); + self + } + + /// Begin solving with this input and a new random Nonce value generated + /// by a supplied [`Rng`]. May be parallelized if desired, by cloning + /// the [`SolverInput`] first. + pub fn solve(self, rng: &mut R) -> Solver { + self.solve_with_nonce(&rng.gen::<[u8; NONCE_LEN]>().into()) + } + + /// Begin solving with a specified [`Nonce`]. + /// This is not generally useful, but it's great for unit tests if you'd + /// like to skip to a deterministic location in the search. + pub fn solve_with_nonce(self, nonce: &Nonce) -> Solver { + Solver { + challenge: Challenge::new(&self.instance, self.effort, nonce), + equix: self.equix, + mem: SolverMemory::new(), + } + } +} + +/// Make progress toward finding a [`Solution`] +/// +/// Each [`Solver`] instance will own about 1.8 MB of temporary memory until +/// it is dropped. This interface supports cancelling an ongoing solve and it +/// supports multithreaded use, but it requires an external thread pool +/// implementation. +pub struct Solver { + /// The next assembled [`Challenge`] to try + challenge: Challenge, + /// Configuration settings for Equi-X, as an [`EquiXBuilder`] instance + equix: EquiXBuilder, + /// Temporary memory for Equi-X to use + mem: SolverMemory, +} + +impl Solver { + /// Run the solver until it produces a [`Solution`] + /// + /// This takes a random amount of time to finish, with no possibility + /// to cancel early. If you need cancellation, use [`Self::run_step()`] + /// instead. + pub fn run(&mut self) -> Result { + loop { + if let Some(solution) = self.run_step()? { + return Ok(solution); + } + } + } + + /// Run the solver algorithm, returning when we are at a good stopping point. + /// + /// Typical durations would be very roughly 10ms with the compiled hash + /// implementation or 250ms with the interpreted implementation. + /// + /// These durations are far too long to include in any event loop + /// that's not built specifically for blocking operations, but they're + /// short enough that we still have a chance of cancelling a high-effort + /// solve. Step duration does not depend on effort choice. + /// + /// Internally, this checks only one [`Nonce`] value. That's the only good + /// stopping point we have in Equi-X right now. If we really need finer + /// grained cancellation the equix crate could be modified to support + /// this but at a performance penalty. + /// + /// It's possible to call this again after a solution has already + /// been returned, but the resulting solutions will have nearby [`Nonce`] + /// values so this is not recommended except for benchmarking. + pub fn run_step(&mut self) -> Result, RuntimeError> { + match self.equix.build(self.challenge.as_ref()) { + Ok(equix) => { + for candidate in equix.solve_with_memory(&mut self.mem) { + if self.challenge.check_effort(&candidate.to_bytes()).is_ok() { + return Ok(Some(Solution::new( + self.challenge.nonce(), + self.challenge.effort(), + self.challenge.seed().head(), + candidate, + ))); + } + } + } + Err(equix::Error::Hash(HashError::ProgramConstraints)) => (), + Err(e) => { + return Err(e.into()); + } + }; + self.challenge.increment_nonce(); + Ok(None) + } +} diff --git a/crates/tor-hspow/src/v1/types.rs b/crates/tor-hspow/src/v1/types.rs new file mode 100644 index 000000000..6066fae80 --- /dev/null +++ b/crates/tor-hspow/src/v1/types.rs @@ -0,0 +1,164 @@ +//! Basic types used by the v1 client puzzle + +use crate::v1::{SolutionByteArray, SolutionError, SolverInput, Verifier}; +use tor_hscrypto::pk::HsBlindId; + +/// Effort setting, a u32 value with linear scale +/// +/// The numerical value is roughly the expected number of times we will +/// need to invoke the underlying solver (Equi-X) for the v1 proof-of-work +/// protocol to find a solution. +#[derive( + derive_more::AsRef, derive_more::From, Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, +)] +pub struct Effort(u32); + +/// Length of the random seed generated by servers and included in HsDir +pub const SEED_LEN: usize = 32; + +/// The random portion of a challenge, distributed through HsDir +#[derive(derive_more::AsRef, derive_more::From, Debug, Clone, Eq, PartialEq)] +pub struct Seed([u8; SEED_LEN]); + +impl Seed { + /// Make a new [`SeedHead`] from a prefix of this seed + pub fn head(&self) -> SeedHead { + SeedHead( + self.0[..SEED_HEAD_LEN] + .try_into() + .expect("slice length correct"), + ) + } +} + +/// Length of a seed prefix used to identify the entire seed +pub const SEED_HEAD_LEN: usize = 4; + +/// A short seed prefix used in solutions to reference the complete seed +#[derive(derive_more::AsRef, derive_more::From, Debug, Clone, Copy, Eq, PartialEq)] +pub struct SeedHead([u8; SEED_HEAD_LEN]); + +/// Length of the nonce value generated by clients and included in the solution +pub const NONCE_LEN: usize = 16; + +/// Generated randomly by solvers and included in the solution +#[derive(derive_more::AsRef, derive_more::From, Debug, Clone, Eq, PartialEq)] +pub struct Nonce([u8; NONCE_LEN]); + +/// One instance of this proof-of-work puzzle +/// +/// Identified uniquely by the combination of onion service blinded Id key +/// plus a rotating seed chosen by the service. +#[derive(Debug, Clone)] +pub struct Instance { + /// Blinded public Id key, binding this puzzle to a specific onion service + service: HsBlindId, + /// Seed value distributed in the HsDir by that service + seed: Seed, +} + +impl Instance { + /// A new puzzle instance, wrapping a service Id and service-chosen seed + pub fn new(service: HsBlindId, seed: Seed) -> Self { + Self { service, seed } + } + + /// Start preparing a particular [`SolverInput`] for this [`Instance`], + /// by choosing an effort. All other settings are optional, accessed + /// via builder methods on [`SolverInput`]. + pub fn with_effort(self, effort: Effort) -> SolverInput { + SolverInput::new(self, effort) + } + + /// Use this instance to construct a [`Verifier`], which can verify + /// solutions and collect configuration options that control verification. + pub fn verifier(self) -> Verifier { + Verifier::new(self) + } + + /// Get the [`HsBlindId`] identifying the service this puzzle is for + pub fn service(&self) -> &HsBlindId { + &self.service + } + + /// Get the rotating random [`Seed`] used in this puzzle instance + pub fn seed(&self) -> &Seed { + &self.seed + } +} + +/// One potential solution to some puzzle [`Instance`] +/// +/// The existence of a [`Solution`] guarantees that the solution is well formed +/// (for example, the correct length, the correct order in the Equi-X solution) +/// but it makes no guarantee to actually solve any specific puzzle instance. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Solution { + /// Arbitrary value chosen by the solver to reach a valid solution. + /// Services are responsible for remembering used values to prevent replay. + nonce: Nonce, + /// The effort chosen by the client, verifiable due to its inclusion + /// in the Equi-X challenge construction. + effort: Effort, + /// Prefix of the Seed used in this puzzle Instance + seed_head: SeedHead, + /// Equi-X solution which claims to prove the above effort choice + proof: equix::Solution, +} + +impl Solution { + /// Construct a new Solution around a well-formed [`equix::Solution`] proof + pub(super) fn new( + nonce: Nonce, + effort: Effort, + seed_head: SeedHead, + proof: equix::Solution, + ) -> Self { + Solution { + nonce, + effort, + seed_head, + proof, + } + } + + /// Try to build a [`Solution`] from an unvalidated [`SolutionByteArray`] + pub fn try_from_bytes( + nonce: Nonce, + effort: Effort, + seed_head: SeedHead, + bytes: &SolutionByteArray, + ) -> Result { + Ok(Self::new( + nonce, + effort, + seed_head, + equix::Solution::try_from_bytes(bytes).map_err(|_| SolutionError::Order)?, + )) + } + + /// Get the winning [`Nonce`] value used in this solution + pub fn nonce(&self) -> &Nonce { + &self.nonce + } + + /// Get the client-chosen and provable [`Effort`] value used in this solution + pub fn effort(&self) -> Effort { + self.effort + } + + /// Get the [`SeedHead`] value identifying the puzzle this solution is for + pub fn seed_head(&self) -> SeedHead { + self.seed_head + } + + /// Internal, access the [`equix::Solution`] backing the proof portion + pub(super) fn proof(&self) -> &equix::Solution { + &self.proof + } + + /// Clone the proof portion of the solution in its canonical byte string format + pub fn proof_to_bytes(&self) -> SolutionByteArray { + self.proof.to_bytes() + } +} diff --git a/crates/tor-hspow/src/v1/verify.rs b/crates/tor-hspow/src/v1/verify.rs new file mode 100644 index 000000000..5c568d41b --- /dev/null +++ b/crates/tor-hspow/src/v1/verify.rs @@ -0,0 +1,72 @@ +//! Verifier implementation for v1 client puzzles + +use crate::v1::challenge::Challenge; +use crate::v1::{Instance, RuntimeError, RuntimeOption, Solution, SolutionError}; +use crate::Error; +use equix::{EquiXBuilder, HashError}; + +/// Checker for potential [`Solution`]s to a particular puzzle [`Instance`] +/// +/// Holds information about the puzzle instance, and optional configuration +/// settings. +pub struct Verifier { + /// The puzzle instance we're verifying + instance: Instance, + /// Configuration settings for Equi-X, as an [`EquiXBuilder`] instance + equix: EquiXBuilder, +} + +impl Verifier { + /// Construct a new [`Verifier`] by wrapping an [`Instance`] + pub fn new(instance: Instance) -> Self { + Self { + instance, + equix: Default::default(), + } + } + + /// Select the HashX runtime to use for this verifier. + /// + /// By default, uses [`RuntimeOption::TryCompile`] + pub fn runtime(&mut self, option: RuntimeOption) -> &mut Self { + self.equix.runtime(option); + self + } + + /// Check whether a solution is valid for this puzzle instance + /// + /// May return a [`SolutionError`] or a [`RuntimeError`] + pub fn check(&self, solution: &Solution) -> Result<(), Error> { + match self.check_seed(solution) { + Err(e) => Err(Error::BadSolution(e.into())), + Ok(()) => { + let challenge = Challenge::new(&self.instance, solution.effort(), solution.nonce()); + match challenge.check_effort(&solution.proof_to_bytes()) { + Err(e) => Err(Error::BadSolution(e.into())), + Ok(()) => match self.equix.verify(challenge.as_ref(), solution.proof()) { + Ok(()) => Ok(()), + Err(equix::Error::HashSum) => { + Err(Error::BadSolution(SolutionError::HashSum.into())) + } + Err(equix::Error::Hash(HashError::ProgramConstraints)) => Err( + Error::BadSolution(SolutionError::ChallengeConstraints.into()), + ), + Err(e) => Err(Error::VerifyRuntime(RuntimeError::EquiX(e).into())), + }, + } + } + } + } + + /// Check the [`super::SeedHead`] of a solution against an [`Instance`]. + /// + /// This is a very cheap test, this should come first so a service + /// can verify every [`Solution`] against its last two [`Instance`]s. + fn check_seed(&self, solution: &Solution) -> Result<(), SolutionError> { + if solution.seed_head() == self.instance.seed().head() { + Ok(()) + } else { + Err(SolutionError::Seed) + } + } +} diff --git a/crates/tor-hspow/tests/hspow_vectors.rs b/crates/tor-hspow/tests/hspow_vectors.rs new file mode 100644 index 000000000..c73e37929 --- /dev/null +++ b/crates/tor-hspow/tests/hspow_vectors.rs @@ -0,0 +1,368 @@ +//! Test vectors from the C tor implementation + +use hex_literal::hex; +use tor_hscrypto::pk::HsBlindId; +use tor_hspow::v1::{Effort, Instance, Nonce, Seed, Solution, SolutionByteArray, SolutionError}; +use tor_hspow::Error; + +/// Short test vectors from C tor, covering verification only +#[test] +fn verify_only() { + // All zero, but only claims an effort of 1. + // Expect it will last until hash sum checks before failing. + assert!(matches!( + Instance::new( + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), + ) + .verifier() + .check( + &Solution::try_from_bytes( + hex!("00000000000000000000000000000000").into(), + 1_u32.into(), + hex!("00000000").into(), + &hex!("00000000000000000000000000000000"), + ) + .unwrap(), + ), + Err(Error::BadSolution(tor_hspow::SolutionError::V1( + SolutionError::HashSum + ))) + )); + + // All zero, but a higher effort claim. Should fail the effort check. + assert!(matches!( + Instance::new( + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), + ) + .verifier() + .check( + &Solution::try_from_bytes( + hex!("00000000000000000000000000000000").into(), + 10_u32.into(), + hex!("00000000").into(), + &hex!("00000000000000000000000000000000"), + ) + .unwrap(), + ), + Err(Error::BadSolution(tor_hspow::SolutionError::V1( + SolutionError::Effort + ))) + )); + + // Seed head mismatch + assert!(matches!( + Instance::new( + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), + ) + .verifier() + .check( + &Solution::try_from_bytes( + hex!("00000000000000000000000000000000").into(), + 0_u32.into(), + hex!("00000001").into(), + &hex!("00000000000000000000000000000000"), + ) + .unwrap(), + ), + Err(Error::BadSolution(tor_hspow::SolutionError::V1( + SolutionError::Seed + ))) + )); + + // Valid zero-effort solution + assert!(Instance::new( + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").into() + ) + .verifier() + .check( + &Solution::try_from_bytes( + hex!("55555555555555555555555555555555").into(), + 0_u32.into(), + hex!("aaaaaaaa").into(), + &hex!("4312f87ceab844c78e1c793a913812d7") + ) + .unwrap() + ) + .is_ok()); + + // Valid high-effort solution + assert!(Instance::new( + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").into() + ) + .verifier() + .check( + &Solution::try_from_bytes( + hex!("59217255555555555555555555555555").into(), + 1_000_000_u32.into(), + hex!("aaaaaaaa").into(), + &hex!("0f3db97b9cac20c1771680a1a34848d3") + ) + .unwrap() + ) + .is_ok()); + + // The claimed effort must exactly match what's was in the challenge + // when the Equi-X proof was created, or it will fail either the + // Effort or HashSum checks. + assert!(matches!( + Instance::new( + hex!("bfd298428562e530c52bdb36d81a0e293ef4a0e94d787f0f8c0c611f4f9e78ed").into(), + hex!("86fb0acf4932cda44dbb451282f415479462dd10cb97ff5e7e8e2a53c3767a7f").into() + ) + .verifier() + .check( + &Solution::try_from_bytes( + hex!("2eff9fdbc34326d9d2f18ed277469c63").into(), + 99_999_u32.into(), + hex!("86fb0acf").into(), + &hex!("400cb091139f86b352119f6e131802d6") + ) + .unwrap() + ), + Err(Error::BadSolution(tor_hspow::SolutionError::V1( + SolutionError::Effort + ))) + )); + + // Otherwise good solution but with a corrupted nonce. This may fail + // either the Effort or HashSum checks. + assert!(matches!( + Instance::new( + hex!("bfd298428562e530c52bdb36d81a0e293ef4a0e94d787f0f8c0c611f4f9e78ed").into(), + hex!("86fb0acf4932cda44dbb451282f415479462dd10cb97ff5e7e8e2a53c3767a7f").into() + ) + .verifier() + .check( + &Solution::try_from_bytes( + hex!("2eff9fdbc34326d9a2f18ed277469c63").into(), + 100_000_u32.into(), + hex!("86fb0acf").into(), + &hex!("400cb091139f86b352119f6e131802d6") + ) + .unwrap() + ), + Err(Error::BadSolution(tor_hspow::SolutionError::V1( + SolutionError::Effort + ))) + )); + + assert!(Instance::new( + hex!("bfd298428562e530c52bdb36d81a0e293ef4a0e94d787f0f8c0c611f4f9e78ed").into(), + hex!("86fb0acf4932cda44dbb451282f415479462dd10cb97ff5e7e8e2a53c3767a7f").into() + ) + .verifier() + .check( + &Solution::try_from_bytes( + hex!("2eff9fdbc34326d9d2f18ed277469c63").into(), + 100_000_u32.into(), + hex!("86fb0acf").into(), + &hex!("400cb091139f86b352119f6e131802d6") + ) + .unwrap() + ) + .is_ok()); +} + +/// Utility to solve and verify one puzzle +fn solve_and_verify( + effort: Effort, + first_nonce: Nonce, + seed: Seed, + service: HsBlindId, + expected_nonce: Nonce, + expected_proof: SolutionByteArray, +) { + let instance = Instance::new(service, seed); + let solution = instance + .clone() + .with_effort(effort) + .solve_with_nonce(&first_nonce) + .run() + .unwrap(); + assert_eq!(solution.seed_head(), instance.seed().head()); + assert_eq!(solution.effort(), effort); + assert_eq!(solution.nonce(), &expected_nonce); + assert_eq!(solution.proof_to_bytes(), expected_proof); + assert!(Solution::try_from_bytes( + expected_nonce, + effort, + instance.seed().head(), + &expected_proof + ) + .is_ok()); + assert!(instance.verifier().check(&solution).is_ok()); +} + +/// Longer running tests from C tor, covering solve and verify both +/// +/// These tests are still optimized to complete without wasting too much time, +/// by artificially choosing a `first_nonce` only slightly lower than the +/// `expected_nonce` we want to find. +#[test] +fn solver() { + solve_and_verify( + 0_u32.into(), + hex!("55555555555555555555555555555555").into(), + hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").into(), + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("55555555555555555555555555555555").into(), + hex!("4312f87ceab844c78e1c793a913812d7"), + ); + solve_and_verify( + 1_u32.into(), + hex!("55555555555555555555555555555555").into(), + hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").into(), + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("55555555555555555555555555555555").into(), + hex!("84355542ab2b3f79532ef055144ac5ab"), + ); + solve_and_verify( + 1_u32.into(), + hex!("55555555555555555555555555555555").into(), + hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").into(), + hex!("1111111111111111111111111111111111111111111111111111111111111110").into(), + hex!("55555555555555555555555555555555").into(), + hex!("115e4b70da858792fc205030b8c83af9"), + ); + solve_and_verify( + 2_u32.into(), + hex!("55555555555555555555555555555555").into(), + hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").into(), + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("55555555555555555555555555555555").into(), + hex!("4600a93a535ed76dc746c99942ab7de2"), + ); + solve_and_verify( + 10_u32.into(), + hex!("55555555555555555555555555555555").into(), + hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").into(), + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("56555555555555555555555555555555").into(), + hex!("128bbda5df2929c3be086de2aad34aed"), + ); + solve_and_verify( + 10_u32.into(), + hex!("ffffffffffffffffffffffffffffffff").into(), + hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").into(), + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("01000000000000000000000000000000").into(), + hex!("203af985537fadb23f3ed5873b4c81ce"), + ); + solve_and_verify( + 1337_u32.into(), + hex!("7fffffffffffffffffffffffffffffff").into(), + hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").into(), + hex!("4111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("01000000000000000000000000000000").into(), + hex!("31c377cb72796ed80ae77df6ac1d6bfd"), + ); + solve_and_verify( + 31337_u32.into(), + hex!("34a20000000000000000000000000000").into(), + hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").into(), + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("36a20000000000000000000000000000").into(), + hex!("ca6899b91113aaf7536f28db42526bff"), + ); + solve_and_verify( + 100_u32.into(), + hex!("55555555555555555555555555555555").into(), + hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").into(), + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("56555555555555555555555555555555").into(), + hex!("3a4122a240bd7abfc922ab3cbb9479ed"), + ); + solve_and_verify( + 1000_u32.into(), + hex!("d3555555555555555555555555555555").into(), + hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").into(), + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("d4555555555555555555555555555555").into(), + hex!("338cc08f57697ce8ac2e4b453057d6e9"), + ); + solve_and_verify( + 10_000_u32.into(), + hex!("c5715555555555555555555555555555").into(), + hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").into(), + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("c8715555555555555555555555555555").into(), + hex!("9f2d3d4ed831ac96ad34c25fb59ff3e2"), + ); + solve_and_verify( + 100_000_u32.into(), + hex!("418d5655555555555555555555555555").into(), + hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").into(), + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("428d5655555555555555555555555555").into(), + hex!("9863f3acd2d15adfd244a7ca61d4c6ff"), + ); + solve_and_verify( + 1_000_000_u32.into(), + hex!("58217255555555555555555555555555").into(), + hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").into(), + hex!("1111111111111111111111111111111111111111111111111111111111111111").into(), + hex!("59217255555555555555555555555555").into(), + hex!("0f3db97b9cac20c1771680a1a34848d3"), + ); + solve_and_verify( + 1_u32.into(), + hex!("d0aec1669384bfe5ed39cd724d6c7954").into(), + hex!("c52be1f8a5e6cc3b8fb71cfdbe272cbc91d4d035400f2f94fb0d0074794e0a07").into(), + hex!("bfd298428562e530c52bdb36d81a0e293ef4a0e94d787f0f8c0c611f4f9e78ed").into(), + hex!("d1aec1669384bfe5ed39cd724d6c7954").into(), + hex!("462606e5f8c2f3f844127b8bfdd6b4ff"), + ); + solve_and_verify( + 1_u32.into(), + hex!("b4d0e611e6935750fcf9406aae131f62").into(), + hex!("86fb0acf4932cda44dbb451282f415479462dd10cb97ff5e7e8e2a53c3767a7f").into(), + hex!("bfd298428562e530c52bdb36d81a0e293ef4a0e94d787f0f8c0c611f4f9e78ed").into(), + hex!("b4d0e611e6935750fcf9406aae131f62").into(), + hex!("9f3fbd50b1a83fb63284bde44318c0fd"), + ); + solve_and_verify( + 1_u32.into(), + hex!("b4d0e611e6935750fcf9406aae131f62").into(), + hex!("9dfbd06d86fed8e12de3ab214e1a63ea61f46253fe08346a20378da70c4a327d").into(), + hex!("bec632eb76123956f99a06d394fcbee8f135b8ed01f2e90aabe404cb0346744a").into(), + hex!("b4d0e611e6935750fcf9406aae131f62").into(), + hex!("161baa7490356292d020065fdbe55ffc"), + ); + solve_and_verify( + 1_u32.into(), + hex!("40559fdbc34326d9d2f18ed277469c63").into(), + hex!("86fb0acf4932cda44dbb451282f415479462dd10cb97ff5e7e8e2a53c3767a7f").into(), + hex!("bfd298428562e530c52bdb36d81a0e293ef4a0e94d787f0f8c0c611f4f9e78ed").into(), + hex!("40559fdbc34326d9d2f18ed277469c63").into(), + hex!("fa649c6a2c5c0bb6a3511b9ea4b448d1"), + ); + solve_and_verify( + 10_000_u32.into(), + hex!("34569fdbc34326d9d2f18ed277469c63").into(), + hex!("86fb0acf4932cda44dbb451282f415479462dd10cb97ff5e7e8e2a53c3767a7f").into(), + hex!("bfd298428562e530c52bdb36d81a0e293ef4a0e94d787f0f8c0c611f4f9e78ed").into(), + hex!("36569fdbc34326d9d2f18ed277469c63").into(), + hex!("2802951e623c74adc443ab93e99633ee"), + ); + solve_and_verify( + 100_000_u32.into(), + hex!("2cff9fdbc34326d9d2f18ed277469c63").into(), + hex!("86fb0acf4932cda44dbb451282f415479462dd10cb97ff5e7e8e2a53c3767a7f").into(), + hex!("bfd298428562e530c52bdb36d81a0e293ef4a0e94d787f0f8c0c611f4f9e78ed").into(), + hex!("2eff9fdbc34326d9d2f18ed277469c63").into(), + hex!("400cb091139f86b352119f6e131802d6"), + ); + solve_and_verify( + 1_000_000_u32.into(), + hex!("5243b3dbc34326d9d2f18ed277469c63").into(), + hex!("86fb0acf4932cda44dbb451282f415479462dd10cb97ff5e7e8e2a53c3767a7f").into(), + hex!("bfd298428562e530c52bdb36d81a0e293ef4a0e94d787f0f8c0c611f4f9e78ed").into(), + hex!("5543b3dbc34326d9d2f18ed277469c63").into(), + hex!("b47c718b56315e9697173a6bac1feaa4"), + ); +}