diff --git a/.gitignore b/.gitignore index ea8c4bf..d573ab2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +pr.md diff --git a/Cargo.lock b/Cargo.lock index e9ff149..d726bf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -104,6 +113,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + [[package]] name = "base64" version = "0.22.1" @@ -173,6 +191,20 @@ dependencies = [ "windows-link", ] +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -233,6 +265,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "equivalent" version = "1.0.2" @@ -249,6 +287,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -270,6 +314,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -277,6 +336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -285,6 +345,40 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -297,8 +391,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -494,7 +593,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -651,6 +750,15 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -763,6 +871,7 @@ dependencies = [ "dashmap", "hex", "hmac", + "redis", "reqwest", "serde", "serde_json", @@ -785,6 +894,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -885,7 +1013,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -922,7 +1050,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -977,6 +1105,32 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redis" +version = "0.27.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" +dependencies = [ + "arc-swap", + "async-trait", + "backon", + "bytes", + "combine", + "futures", + "futures-util", + "itertools", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1186,6 +1340,12 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -1234,6 +1394,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.4" @@ -1373,7 +1543,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -1399,6 +1569,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.4.13" diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..27234a0 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,47 @@ +# Webhooks Documentation + +Nodus Protocol Core Engine uses webhooks to notify your application when an event occurs in your account. + +## Signature Verification + +Nodus signs the webhook events it sends to your endpoints by including a signature in each event's `x-nodus-signature` header. This allows you to verify that the events were sent by Nodus, not by a third party. + +### Header Format + +The `x-nodus-signature` header contains the timestamp and the signature, separated by a comma: +`x-nodus-signature: t=,v1=` + +### Signed Payload Structure + +The signed payload is created by concatenating: +1. The timestamp (as a string) +2. The character `\n` +3. The actual JSON payload (i.e., the request body) + +Format: +`t=\n` + +### Replay Tolerance + +To prevent replay attacks, we recommend rejecting webhooks with timestamps older than 5 minutes (300 seconds). + +### Example Verification Code (Python) + +```python +import hmac +import hashlib +import time + +def verify_signature(secret: str, header: str, body: str, tolerance_seconds: int = 300) -> bool: + parts = dict(p.split('=', 1) for p in header.split(',')) + timestamp = int(parts['t']) + signature = parts['v1'] + + # Reject events older than tolerance_seconds + if abs(time.time() - timestamp) > tolerance_seconds: + raise ValueError("Webhook timestamp too old — possible replay attack") + + signed_payload = f"t={timestamp}\n{body}" + expected = hmac.new(secret.encode(), signed_payload.encode(), hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, signature) +``` diff --git a/src/webhook.rs b/src/webhook.rs index 5c7aa63..b7645e6 100644 --- a/src/webhook.rs +++ b/src/webhook.rs @@ -2,6 +2,7 @@ use dashmap::DashMap; use hmac::{Hmac, Mac}; use serde::{Deserialize, Serialize}; use sha2::Sha256; +use std::time::{SystemTime, UNIX_EPOCH}; use uuid::Uuid; use crate::utils::{now_utc, EngineError, Payment}; @@ -133,13 +134,19 @@ impl WebhookStore { } }; - let sig = hmac_sign(&hook.secret, &body); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let signed_payload = format!("t={}\n{}", timestamp, body); + let sig = hmac_sign(&hook.secret, &signed_payload); match self .client .post(&hook.url) .header("content-type", "application/json") - .header("x-nodus-signature", &sig) + .header("x-nodus-signature", format!("t={},v1={}", timestamp, sig)) .body(body) .send() .await diff --git a/tests/webhook_tests.rs b/tests/webhook_tests.rs new file mode 100644 index 0000000..b8b168f --- /dev/null +++ b/tests/webhook_tests.rs @@ -0,0 +1,73 @@ +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn verify_signature(secret: &str, header: &str, body: &str, tolerance: u64) -> bool { + let parts: Vec<&str> = header.split(',').collect(); + if parts.len() != 2 { + return false; + } + + let t_part = parts[0].strip_prefix("t=").unwrap_or(""); + let v1_part = parts[1].strip_prefix("v1=").unwrap_or(""); + + let timestamp = match t_part.parse::() { + Ok(t) => t, + Err(_) => return false, + }; + + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + + if now > timestamp + tolerance || timestamp > now + tolerance { + return false; + } + + let signed_payload = format!("t={}\n{}", timestamp, body); + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(signed_payload.as_bytes()); + let expected = hex::encode(mac.finalize().into_bytes()); + + expected == v1_part +} + +fn create_signature(secret: &str, body: &str, timestamp: u64) -> String { + let signed_payload = format!("t={}\n{}", timestamp, body); + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(signed_payload.as_bytes()); + let sig = hex::encode(mac.finalize().into_bytes()); + format!("t={},v1={}", timestamp, sig) +} + +#[test] +fn test_verify_signature_correct_timestamp_passes() { + let secret = "my_secret_key"; + let body = r#"{"event":"payment.confirmed"}"#; + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + + let header = create_signature(secret, body, timestamp); + + assert!(verify_signature(secret, &header, body, 300)); +} + +#[test] +fn test_verify_signature_old_timestamp_fails() { + let secret = "my_secret_key"; + let body = r#"{"event":"payment.confirmed"}"#; + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() - 400; // 400s old + + let header = create_signature(secret, body, timestamp); + + assert!(!verify_signature(secret, &header, body, 300)); +} + +#[test] +fn test_verify_signature_tampered_body_fails() { + let secret = "my_secret_key"; + let body = r#"{"event":"payment.confirmed"}"#; + let tampered_body = r#"{"event":"payment.failed"}"#; + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + + let header = create_signature(secret, body, timestamp); + + assert!(!verify_signature(secret, &header, tampered_body, 300)); +}