diff --git a/Cargo.lock b/Cargo.lock index 39cad7b3a..e01b8fff1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,10 +17,21 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "affine_common" +version = "0.1.0" +dependencies = [ + "chrono", + "rand", + "rayon", + "sha3", +] + [[package]] name = "affine_native" version = "0.0.0" dependencies = [ + "affine_common", "affine_schema", "anyhow", "chrono", @@ -49,6 +60,7 @@ version = "0.0.0" name = "affine_server_native" version = "1.0.0" dependencies = [ + "affine_common", "chrono", "file-format", "mimalloc", diff --git a/Cargo.toml b/Cargo.toml index c8282b0cd..1a3af2181 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,30 +1,36 @@ [workspace] -members = ["./packages/backend/native", "./packages/frontend/native", "./packages/frontend/native/schema"] +members = [ + "./packages/backend/native", + "./packages/common/native", + "./packages/frontend/native", + "./packages/frontend/native/schema" +] resolver = "2" [workspace.dependencies] -anyhow = "1" -chrono = "0.4" -dotenv = "0.15" -file-format = { version = "0.26", features = ["reader"] } -mimalloc = "0.1" -napi = { version = "3.0.0-alpha.12", features = ["async", "chrono_date", "error_anyhow", "napi9", "serde"] } -napi-build = { version = "2" } -napi-derive = { version = "3.0.0-alpha.12" } -notify = { version = "7", features = ["serde"] } -once_cell = "1" -parking_lot = "0.12" -rand = "0.8" -rayon = "1.10" -serde = "1" -serde_json = "1" -sha3 = "0.10" -sqlx = { version = "0.8", default-features = false, features = ["chrono", "macros", "migrate", "runtime-tokio", "sqlite", "tls-rustls"] } -tiktoken-rs = "0.6" -tokio = "1.37" -uuid = "1.8" -v_htmlescape = "0.15" -y-octo = { git = "https://github.com/y-crdt/y-octo.git", branch = "main" } +affine_common = { path = "./packages/common/native" } +anyhow = "1" +chrono = "0.4" +dotenv = "0.15" +file-format = { version = "0.26", features = ["reader"] } +mimalloc = "0.1" +napi = { version = "3.0.0-alpha.12", features = ["async", "chrono_date", "error_anyhow", "napi9", "serde"] } +napi-build = { version = "2" } +napi-derive = { version = "3.0.0-alpha.12" } +notify = { version = "7", features = ["serde"] } +once_cell = "1" +parking_lot = "0.12" +rand = "0.8" +rayon = "1.10" +serde = "1" +serde_json = "1" +sha3 = "0.10" +sqlx = { version = "0.8", default-features = false, features = ["chrono", "macros", "migrate", "runtime-tokio", "sqlite", "tls-rustls"] } +tiktoken-rs = "0.6" +tokio = "1.37" +uuid = "1.8" +v_htmlescape = "0.15" +y-octo = { git = "https://github.com/y-crdt/y-octo.git", branch = "main" } [profile.dev.package.sqlx-macros] opt-level = 3 diff --git a/packages/backend/native/Cargo.toml b/packages/backend/native/Cargo.toml index 2d85b32f7..f1c55f33a 100644 --- a/packages/backend/native/Cargo.toml +++ b/packages/backend/native/Cargo.toml @@ -7,15 +7,16 @@ version = "1.0.0" crate-type = ["cdylib"] [dependencies] -chrono = { workspace = true } -file-format = { workspace = true } -napi = { workspace = true } -napi-derive = { workspace = true } -rand = { workspace = true } -sha3 = { workspace = true } -tiktoken-rs = { workspace = true } -v_htmlescape = { workspace = true } -y-octo = { workspace = true } +affine_common = { workspace = true } +chrono = { workspace = true } +file-format = { workspace = true } +napi = { workspace = true } +napi-derive = { workspace = true } +rand = { workspace = true } +sha3 = { workspace = true } +tiktoken-rs = { workspace = true } +v_htmlescape = { workspace = true } +y-octo = { workspace = true } [target.'cfg(not(target_os = "linux"))'.dependencies] mimalloc = { workspace = true } diff --git a/packages/backend/native/src/hashcash.rs b/packages/backend/native/src/hashcash.rs deleted file mode 120000 index 37297e192..000000000 --- a/packages/backend/native/src/hashcash.rs +++ /dev/null @@ -1 +0,0 @@ -../../../frontend/native/src/hashcash.rs \ No newline at end of file diff --git a/packages/backend/native/src/hashcash.rs b/packages/backend/native/src/hashcash.rs new file mode 100644 index 000000000..a9aa75f0b --- /dev/null +++ b/packages/backend/native/src/hashcash.rs @@ -0,0 +1,69 @@ +use std::convert::TryFrom; + +use affine_common::hashcash::Stamp; +use napi::{bindgen_prelude::AsyncTask, Env, JsBoolean, JsString, Result as NapiResult, Task}; +use napi_derive::napi; + +pub struct AsyncVerifyChallengeResponse { + response: String, + bits: u32, + resource: String, +} + +#[napi] +impl Task for AsyncVerifyChallengeResponse { + type Output = bool; + type JsValue = JsBoolean; + + fn compute(&mut self) -> NapiResult { + Ok(if let Ok(stamp) = Stamp::try_from(self.response.as_str()) { + stamp.check(self.bits, &self.resource) + } else { + false + }) + } + + fn resolve(&mut self, env: Env, output: bool) -> NapiResult { + env.get_boolean(output) + } +} + +#[napi] +pub fn verify_challenge_response( + response: String, + bits: u32, + resource: String, +) -> AsyncTask { + AsyncTask::new(AsyncVerifyChallengeResponse { + response, + bits, + resource, + }) +} + +pub struct AsyncMintChallengeResponse { + bits: Option, + resource: String, +} + +#[napi] +impl Task for AsyncMintChallengeResponse { + type Output = String; + type JsValue = JsString; + + fn compute(&mut self) -> NapiResult { + Ok(Stamp::mint(self.resource.clone(), self.bits).format()) + } + + fn resolve(&mut self, env: Env, output: String) -> NapiResult { + env.create_string(&output) + } +} + +#[napi] +pub fn mint_challenge_response( + resource: String, + bits: Option, +) -> AsyncTask { + AsyncTask::new(AsyncMintChallengeResponse { bits, resource }) +} diff --git a/packages/backend/server/migrations/migration_lock.toml b/packages/backend/server/migrations/migration_lock.toml index fbffa92c2..99e4f2009 100644 --- a/packages/backend/server/migrations/migration_lock.toml +++ b/packages/backend/server/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (i.e. Git) -provider = "postgresql" \ No newline at end of file +provider = "postgresql" diff --git a/packages/common/native/Cargo.toml b/packages/common/native/Cargo.toml new file mode 100644 index 000000000..88b585d2c --- /dev/null +++ b/packages/common/native/Cargo.toml @@ -0,0 +1,12 @@ +[package] +edition = "2021" +name = "affine_common" +version = "0.1.0" + +[dependencies] +chrono = { workspace = true } +rand = { workspace = true } +sha3 = { workspace = true } + +[dev-dependencies] +rayon = { workspace = true } diff --git a/packages/common/native/src/hashcash.rs b/packages/common/native/src/hashcash.rs new file mode 100644 index 000000000..d5cee2890 --- /dev/null +++ b/packages/common/native/src/hashcash.rs @@ -0,0 +1,204 @@ +use std::convert::TryFrom; + +use chrono::{DateTime, Duration, NaiveDateTime, Utc}; +use rand::{ + distributions::{Alphanumeric, Distribution}, + thread_rng, +}; +use sha3::{Digest, Sha3_256}; + +const SALT_LENGTH: usize = 16; + +#[derive(Debug)] +pub struct Stamp { + version: String, + claim: u32, + ts: String, + resource: String, + ext: String, + rand: String, + counter: String, +} + +impl Stamp { + fn check_expiration(&self) -> bool { + NaiveDateTime::parse_from_str(&self.ts, "%Y%m%d%H%M%S") + .ok() + .map(|ts| DateTime::::from_naive_utc_and_offset(ts, Utc)) + .and_then(|utc| { + utc + .checked_add_signed(Duration::minutes(5)) + .map(|utc| Utc::now() <= utc) + }) + .unwrap_or(false) + } + + pub fn check>(&self, bits: u32, resource: S) -> bool { + if self.version == "1" + && bits <= self.claim + && self.check_expiration() + && self.resource == resource.as_ref() + { + let hex_digits = ((self.claim as f32) / 4.).floor() as usize; + + // check challenge + let mut hasher = Sha3_256::new(); + hasher.update(self.format().as_bytes()); + let result = format!("{:x}", hasher.finalize()); + result[..hex_digits] == String::from_utf8(vec![b'0'; hex_digits]).unwrap() + } else { + false + } + } + + pub fn format(&self) -> String { + format!( + "{}:{}:{}:{}:{}:{}:{}", + self.version, self.claim, self.ts, self.resource, self.ext, self.rand, self.counter + ) + } + + /// Mint a new hashcash stamp. + pub fn mint(resource: String, bits: Option) -> Self { + let version = "1"; + let now = Utc::now(); + let ts = now.format("%Y%m%d%H%M%S"); + let bits = bits.unwrap_or(20); + let rand = String::from_iter( + Alphanumeric + .sample_iter(thread_rng()) + .take(SALT_LENGTH) + .map(char::from), + ); + let challenge = format!("{}:{}:{}:{}:{}:{}", version, bits, ts, &resource, "", rand); + + Stamp { + version: version.to_string(), + claim: bits, + ts: ts.to_string(), + resource, + ext: "".to_string(), + rand, + counter: { + let mut hasher = Sha3_256::new(); + let mut counter = 0; + let hex_digits = ((bits as f32) / 4.).ceil() as usize; + let zeros = String::from_utf8(vec![b'0'; hex_digits]).unwrap(); + loop { + hasher.update(format!("{}:{:x}", challenge, counter).as_bytes()); + let result = format!("{:x}", hasher.finalize_reset()); + if result[..hex_digits] == zeros { + break format!("{:x}", counter); + }; + counter += 1 + } + }, + } + } +} + +impl TryFrom<&str> for Stamp { + type Error = String; + + fn try_from(value: &str) -> Result { + let stamp_vec = value.split(':').collect::>(); + if stamp_vec.len() != 7 + || stamp_vec + .iter() + .enumerate() + .any(|(i, s)| i != 4 && s.is_empty()) + { + return Err(format!( + "Malformed stamp, expected 6 parts, got {}", + stamp_vec.len() + )); + } + Ok(Stamp { + version: stamp_vec[0].to_string(), + claim: stamp_vec[1] + .parse() + .map_err(|_| "Malformed stamp".to_string())?, + ts: stamp_vec[2].to_string(), + resource: stamp_vec[3].to_string(), + ext: stamp_vec[4].to_string(), + rand: stamp_vec[5].to_string(), + counter: stamp_vec[6].to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::Stamp; + use rand::{distributions::Alphanumeric, Rng}; + use rayon::prelude::*; + + #[test] + fn test_mint() { + { + let response = Stamp::mint("test".into(), Some(20)).format(); + assert!( + Stamp::try_from(response.as_str()) + .unwrap() + .check(20, "test"), + "should pass" + ); + } + + { + let response = Stamp::mint("test".into(), Some(19)).format(); + assert!( + !Stamp::try_from(response.as_str()) + .unwrap() + .check(20, "test"), + "should fail with lower bits" + ); + } + { + let response = Stamp::mint("test".into(), Some(20)).format(); + assert!( + !Stamp::try_from(response.as_str()) + .unwrap() + .check(20, "test2"), + "should fail with different resource" + ); + } + } + + #[test] + fn test_check_expiration() { + let response = Stamp::mint("test".into(), Some(20)); + assert!(response.check_expiration()); + } + + #[test] + fn test_format() { + let response = Stamp::mint("test".into(), Some(20)); + assert_eq!( + response.format(), + format!( + "1:20:{}:test::{}:{}", + response.ts, response.rand, response.counter + ) + ); + } + + #[test] + fn test_fuzz() { + (0..1000).into_par_iter().for_each(|_| { + let bit = rand::random::() % 20 + 1; + let resource = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(7) + .map(char::from) + .collect::(); + let response = Stamp::mint(resource.clone(), Some(bit)).format(); + assert!( + Stamp::try_from(response.as_str()) + .unwrap() + .check(bit, resource), + "should pass" + ); + }); + } +} diff --git a/packages/common/native/src/lib.rs b/packages/common/native/src/lib.rs new file mode 100644 index 000000000..908ae1a77 --- /dev/null +++ b/packages/common/native/src/lib.rs @@ -0,0 +1 @@ +pub mod hashcash; diff --git a/packages/frontend/native/Cargo.toml b/packages/frontend/native/Cargo.toml index aa29af594..0fc3c7e3f 100644 --- a/packages/frontend/native/Cargo.toml +++ b/packages/frontend/native/Cargo.toml @@ -7,6 +7,7 @@ version = "0.0.0" crate-type = ["cdylib"] [dependencies] +affine_common = { workspace = true } affine_schema = { path = "./schema" } anyhow = { workspace = true } chrono = { workspace = true } @@ -24,7 +25,7 @@ tokio = { workspace = true, features = ["full"] } uuid = { workspace = true, features = ["fast-rng", "serde", "v4"] } [dev-dependencies] -rayon = { workspace = true } +rayon = { workspace = true } [build-dependencies] affine_schema = { path = "./schema" } diff --git a/packages/frontend/native/src/hashcash.rs b/packages/frontend/native/src/hashcash.rs index 30120b3c8..a9aa75f0b 100644 --- a/packages/frontend/native/src/hashcash.rs +++ b/packages/frontend/native/src/hashcash.rs @@ -1,133 +1,8 @@ use std::convert::TryFrom; -use chrono::{DateTime, Duration, NaiveDateTime, Utc}; +use affine_common::hashcash::Stamp; use napi::{bindgen_prelude::AsyncTask, Env, JsBoolean, JsString, Result as NapiResult, Task}; use napi_derive::napi; -use rand::{ - distributions::{Alphanumeric, Distribution}, - thread_rng, -}; -use sha3::{Digest, Sha3_256}; - -const SALT_LENGTH: usize = 16; - -#[derive(Debug)] -struct Stamp { - version: String, - claim: u32, - ts: String, - resource: String, - ext: String, - rand: String, - counter: String, -} - -impl Stamp { - fn check_expiration(&self) -> bool { - NaiveDateTime::parse_from_str(&self.ts, "%Y%m%d%H%M%S") - .ok() - .map(|ts| DateTime::::from_naive_utc_and_offset(ts, Utc)) - .and_then(|utc| { - utc - .checked_add_signed(Duration::minutes(5)) - .map(|utc| Utc::now() <= utc) - }) - .unwrap_or(false) - } - - pub fn check>(&self, bits: u32, resource: S) -> bool { - if self.version == "1" - && bits <= self.claim - && self.check_expiration() - && self.resource == resource.as_ref() - { - let hex_digits = ((self.claim as f32) / 4.).floor() as usize; - - // check challenge - let mut hasher = Sha3_256::new(); - hasher.update(self.format().as_bytes()); - let result = format!("{:x}", hasher.finalize()); - result[..hex_digits] == String::from_utf8(vec![b'0'; hex_digits]).unwrap() - } else { - false - } - } - - fn format(&self) -> String { - format!( - "{}:{}:{}:{}:{}:{}:{}", - self.version, self.claim, self.ts, self.resource, self.ext, self.rand, self.counter - ) - } - - /// Mint a new hashcash stamp. - pub fn mint(resource: String, bits: Option) -> Self { - let version = "1"; - let now = Utc::now(); - let ts = now.format("%Y%m%d%H%M%S"); - let bits = bits.unwrap_or(20); - let rand = String::from_iter( - Alphanumeric - .sample_iter(thread_rng()) - .take(SALT_LENGTH) - .map(char::from), - ); - let challenge = format!("{}:{}:{}:{}:{}:{}", version, bits, ts, &resource, "", rand); - - Stamp { - version: version.to_string(), - claim: bits, - ts: ts.to_string(), - resource, - ext: "".to_string(), - rand, - counter: { - let mut hasher = Sha3_256::new(); - let mut counter = 0; - let hex_digits = ((bits as f32) / 4.).ceil() as usize; - let zeros = String::from_utf8(vec![b'0'; hex_digits]).unwrap(); - loop { - hasher.update(format!("{}:{:x}", challenge, counter).as_bytes()); - let result = format!("{:x}", hasher.finalize_reset()); - if result[..hex_digits] == zeros { - break format!("{:x}", counter); - }; - counter += 1 - } - }, - } - } -} - -impl TryFrom<&str> for Stamp { - type Error = String; - - fn try_from(value: &str) -> Result { - let stamp_vec = value.split(':').collect::>(); - if stamp_vec.len() != 7 - || stamp_vec - .iter() - .enumerate() - .any(|(i, s)| i != 4 && s.is_empty()) - { - return Err(format!( - "Malformed stamp, expected 6 parts, got {}", - stamp_vec.len() - )); - } - Ok(Stamp { - version: stamp_vec[0].to_string(), - claim: stamp_vec[1] - .parse() - .map_err(|_| "Malformed stamp".to_string())?, - ts: stamp_vec[2].to_string(), - resource: stamp_vec[3].to_string(), - ext: stamp_vec[4].to_string(), - rand: stamp_vec[5].to_string(), - counter: stamp_vec[6].to_string(), - }) - } -} pub struct AsyncVerifyChallengeResponse { response: String, @@ -192,79 +67,3 @@ pub fn mint_challenge_response( ) -> AsyncTask { AsyncTask::new(AsyncMintChallengeResponse { bits, resource }) } - -#[cfg(test)] -mod tests { - use super::Stamp; - use rand::{distributions::Alphanumeric, Rng}; - use rayon::prelude::*; - - #[test] - fn test_mint() { - { - let response = Stamp::mint("test".into(), Some(20)).format(); - assert!( - Stamp::try_from(response.as_str()) - .unwrap() - .check(20, "test"), - "should pass" - ); - } - - { - let response = Stamp::mint("test".into(), Some(19)).format(); - assert!( - !Stamp::try_from(response.as_str()) - .unwrap() - .check(20, "test"), - "should fail with lower bits" - ); - } - { - let response = Stamp::mint("test".into(), Some(20)).format(); - assert!( - !Stamp::try_from(response.as_str()) - .unwrap() - .check(20, "test2"), - "should fail with different resource" - ); - } - } - - #[test] - fn test_check_expiration() { - let response = Stamp::mint("test".into(), Some(20)); - assert!(response.check_expiration()); - } - - #[test] - fn test_format() { - let response = Stamp::mint("test".into(), Some(20)); - assert_eq!( - response.format(), - format!( - "1:20:{}:test::{}:{}", - response.ts, response.rand, response.counter - ) - ); - } - - #[test] - fn test_fuzz() { - (0..1000).into_par_iter().for_each(|_| { - let bit = rand::random::() % 20 + 1; - let resource = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(7) - .map(char::from) - .collect::(); - let response = Stamp::mint(resource.clone(), Some(bit)).format(); - assert!( - Stamp::try_from(response.as_str()) - .unwrap() - .check(bit, resource), - "should pass" - ); - }); - } -}