From 07e6562eaecc5302a7100bda2a97cd2f2fcf8595 Mon Sep 17 00:00:00 2001 From: Florian Uekermann Date: Wed, 21 Jan 2026 19:59:56 +0100 Subject: [PATCH] add proof-of-work for token acquisition --- Cargo.lock | 63 +++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 ++- examples/api_cli.rs | 4 +-- src/client.rs | 32 +++++++++++++++++++---- src/cmd/mod.rs | 9 ++++--- src/lib.rs | 1 + src/pow.rs | 59 ++++++++++++++++++++++++++++++++++++++++++ src/token.rs | 5 ++-- src/types.rs | 4 +-- 9 files changed, 165 insertions(+), 15 deletions(-) create mode 100644 src/pow.rs diff --git a/Cargo.lock b/Cargo.lock index 0e4a2ca..70ab72f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -221,6 +221,15 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.17.0" @@ -380,6 +389,16 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -493,6 +512,16 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -620,6 +649,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -1216,6 +1255,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "sha2", "static_assertions", "strum", "thiserror", @@ -1759,6 +1799,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2141,6 +2192,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -2212,6 +2269,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffb16c19000204197604686ecda606fbeecb2626cfa664dd468d072d99698417" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index e7ba1e8..728f742 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "obscuravpn-api" version = "0.0.0" -edition = "2021" +edition = "2024" description = "API client for Obscura VPN." homepage = "https://github.com/Sovereign-Engineering/obscuravpn-api" @@ -23,6 +23,7 @@ semver = "1.0.26" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" serde_with = { version = "3.12.0", features = ["base64"] } +sha2 = "0.10.9" static_assertions = "1.1.0" strum = { version = "0.27.1", features = ["derive"] } thiserror = "2.0.12" diff --git a/examples/api_cli.rs b/examples/api_cli.rs index c752dc1..043d534 100644 --- a/examples/api_cli.rs +++ b/examples/api_cli.rs @@ -1,10 +1,10 @@ use anyhow::bail; -use base64::{engine::general_purpose::STANDARD, Engine as _}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use clap::{Parser, Subcommand}; +use obscuravpn_api::Client; use obscuravpn_api::cmd::*; use obscuravpn_api::types::{AccountId, TunnelConfig, WgPubkey}; use obscuravpn_api::wg_conf::build_wg_conf; -use obscuravpn_api::Client; use qrcode::QrCode; use rand::rngs::OsRng; use x25519_dalek::{PublicKey, StaticSecret}; diff --git a/src/client.rs b/src/client.rs index 8f9712e..ed83186 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,9 +1,9 @@ -use crate::cmd::{parse_response, ApiError, ApiErrorKind, Cmd, ETagCmd, ProtocolError}; +use crate::cmd::{ApiError, ApiErrorBody, ApiErrorKind, Cmd, ETagCmd, ProtocolError, parse_response}; use crate::resolver_fallback::{GaiResolverWithFallback, NoResolverFallbackCache, ResolverFallbackCache}; use crate::response::Response; use crate::token::{AcquireToken, AcquireToken2Output}; use crate::types::{AccountId, AuthToken}; -use anyhow::{anyhow, Context}; +use anyhow::{Context, anyhow}; use http::HeaderValue; use itertools::Itertools; use reqwest::ClientBuilder; @@ -128,9 +128,31 @@ impl Client { }); } let account_id = self.account_id.clone(); - let request = AcquireToken { account_id }.to_request2(&self.base_url)?; - let res = self.send_http(request).await?; - let res = parse_response::(res).await?; + let mut pow = None; + let res = loop { + let request = AcquireToken { + account_id: account_id.clone(), + pow: pow.clone(), + } + .to_request2(&self.base_url)?; + let res = self.send_http(request).await?; + let res = parse_response::(res).await; + if let Err(ClientError::ApiError(ApiError { + status: _, + body: + ApiErrorBody { + error: ApiErrorKind::RateLimitExceeded { pow_input: Some(pow_input) }, + msg: _, + detail: _, + }, + })) = &res + && pow.is_none() + { + pow = Some(pow_input.solve(&account_id)); + continue; + } + break res; + }?; let body = res.into_body().context("No auth token in response")?; self.set_auth_token(Some(body.auth_token.clone().into())); diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 97be326..155de0c 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -29,14 +29,15 @@ pub use tunnel::*; use std::any::Any; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use thiserror::Error; use tokio_stream::StreamExt; use url::Url; +use crate::ClientError; +use crate::pow::PowInput; use crate::response::Response; use crate::types::AuthToken; -use crate::ClientError; pub trait Cmd: Serialize + DeserializeOwned + std::fmt::Debug { type Output: Serialize + DeserializeOwned + 'static + std::fmt::Debug; @@ -98,7 +99,9 @@ pub enum ApiErrorKind { NoApiRoute {}, NoLongerSupported {}, NoMatchingExit {}, - RateLimitExceeded {}, + RateLimitExceeded { + pow_input: Option, + }, SaleNotFound {}, SignupLimitExceeded {}, TunnelLimitExceeded {}, diff --git a/src/lib.rs b/src/lib.rs index d368080..6bb131e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod wg_conf; mod client; #[cfg(feature = "client")] pub mod notices; +mod pow; pub mod relay_protocol; #[cfg(feature = "client")] mod resolver_fallback; diff --git a/src/pow.rs b/src/pow.rs new file mode 100644 index 0000000..005347a --- /dev/null +++ b/src/pow.rs @@ -0,0 +1,59 @@ +use crate::types::AccountId; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct PowInput { + pub nonce: u128, + pub difficulty: u8, + pub puzzles: u16, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PowOutput { + pub nonce: u128, + pub solutions: Vec, +} + +impl PowInput { + pub fn solve(&self, account_id: &AccountId) -> PowOutput { + let account_id = account_id.to_string(); + let mut solutions = Vec::with_capacity(self.puzzles.into()); + for puzzle in 0..self.puzzles { + let mut candidate = 0u128; + loop { + let mut hasher = Sha256::new(); + hasher.update(self.nonce.to_be_bytes()); + hasher.update(&account_id); + hasher.update(candidate.to_be_bytes()); + hasher.update(puzzle.to_be_bytes()); + let hash = hasher.finalize(); + if is_solution(self.difficulty, hash.as_ref()) { + solutions.push(candidate); + break; + } + candidate = candidate.wrapping_add(1); + } + } + PowOutput { + solutions, + nonce: self.nonce, + } + } +} + +fn is_solution(difficulty: u8, hash: &[u8; 32]) -> bool { + let mut difficulty: u32 = difficulty.into(); + for chunk in hash.as_chunks::<8>().0 { + let chunk = u64::from_be_bytes(*chunk); + let chunk_zeros = chunk.leading_zeros(); + if chunk_zeros >= difficulty { + return true; + } + if chunk_zeros < 64 { + return false; + } + difficulty -= 64; + } + true +} diff --git a/src/token.rs b/src/token.rs index 304fa92..06138ff 100644 --- a/src/token.rs +++ b/src/token.rs @@ -1,8 +1,8 @@ +use crate::pow::PowOutput; +use crate::types::AccountId; use serde::{Deserialize, Serialize}; use url::Url; -use crate::types::AccountId; - #[derive(Clone, Debug, Deserialize, Serialize)] pub struct UrlOverride { pub api: String, @@ -18,6 +18,7 @@ pub struct AcquireToken2Output { #[derive(Debug, Serialize, Deserialize, Clone)] pub struct AcquireToken { pub account_id: AccountId, + pub pow: Option, } impl AcquireToken { diff --git a/src/types.rs b/src/types.rs index 5f520a4..9c88ccc 100644 --- a/src/types.rs +++ b/src/types.rs @@ -244,7 +244,7 @@ pub struct WgPubkey(#[serde_as(as = "Base64")] pub [u8; WG_PUBKEY_LENGTH]); impl std::fmt::Debug for WgPubkey { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - use base64::{engine::general_purpose::STANDARD, Engine as _}; + use base64::{Engine as _, engine::general_purpose::STANDARD}; f.debug_tuple("WgPubKey").field(&STANDARD.encode(self.0)).finish() } } @@ -260,7 +260,7 @@ pub enum ParseWgPubkeyError { impl FromStr for WgPubkey { type Err = ParseWgPubkeyError; fn from_str(s: &str) -> Result { - use base64::{engine::general_purpose::STANDARD, Engine as _}; + use base64::{Engine as _, engine::general_purpose::STANDARD}; let decoded = STANDARD.decode(s)?; let bytes = decoded.try_into().map_err(|d: Vec| ParseWgPubkeyError::InvalidLength(d.len()))?; Ok(WgPubkey(bytes))