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"), + ); +}