tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 024c0c327c740b87778580bb06bccdfbeeca9cbf
parent 232632fde643dd12e97b291ccc70cc9ddbdd2ef8
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 03:01:12 -0700

runtime: enforce auth timestamp skew

- add required AUTH created_at skew config and production example coverage
- validate AUTH timestamp windows alongside kind, id, signature, relay, challenge, and TTL checks
- promote the phase2 auth skew acceptance target and expand auth edge tests
- keep runtime, session, CLI, benchmark, and helper configs aligned with explicit skew values

Diffstat:
Mcrates/tangle/tests/version.rs | 3++-
Mcrates/tangle_bench/src/lib.rs | 2+-
Mcrates/tangle_runtime/src/config.rs | 52+++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/tangle_runtime/src/relay/auth.rs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/tangle_runtime/src/relay/core.rs | 8+++++---
Mcrates/tangle_runtime/src/runtime.rs | 3++-
Mcrates/tangle_runtime/src/server.rs | 28++++++++++++++++++++++------
Mcrates/tangle_runtime/src/session.rs | 3++-
Mcrates/tangle_runtime/tests/base_relay_v2.rs | 8++++----
Mcrates/tangle_runtime/tests/phase2_acceptance_targets.rs | 43+++++++++++++++++++++++++++++++++++++++----
10 files changed, 288 insertions(+), 34 deletions(-)

diff --git a/crates/tangle/tests/version.rs b/crates/tangle/tests/version.rs @@ -125,7 +125,8 @@ fn tangle_run_starts_server_and_stays_alive_until_shutdown() { } }, "auth": { - "challenge_ttl_seconds": 300 + "challenge_ttl_seconds": 300, + "created_at_skew_seconds": 600 }, "limits": { "max_pending_events": 1024 diff --git a/crates/tangle_bench/src/lib.rs b/crates/tangle_bench/src/lib.rs @@ -1132,7 +1132,7 @@ fn group_config() -> Result<tangle_groups::GroupRuntimeConfig, String> { fn authenticated(key: FixtureKey) -> Result<BaseAuthState, String> { let mut auth = - BaseAuthState::new(TANGLE_V2_RELAY_URL, 60).map_err(|error| error.to_string())?; + BaseAuthState::new(TANGLE_V2_RELAY_URL, 60, 600).map_err(|error| error.to_string())?; auth.issue_challenge("challenge-a", tangle_protocol::UnixTimestamp::new(100)) .map_err(|error| error.to_string())?; let event = tangle_v2_auth_event(key, "challenge-a", 120)?; diff --git a/crates/tangle_runtime/src/config.rs b/crates/tangle_runtime/src/config.rs @@ -16,6 +16,7 @@ pub struct BaseRelayRuntimeConfig { pocket: PocketStoreConfig, groups: GroupRuntimeConfig, auth_ttl_seconds: u64, + auth_created_at_skew_seconds: u64, max_pending_events: usize, tracing: BaseRelayTracingConfig, } @@ -41,6 +42,10 @@ impl BaseRelayRuntimeConfig { self.auth_ttl_seconds } + pub fn auth_created_at_skew_seconds(&self) -> u64 { + self.auth_created_at_skew_seconds + } + pub fn max_pending_events(&self) -> usize { self.max_pending_events } @@ -54,7 +59,11 @@ impl BaseRelayRuntimeConfig { } pub fn auth_state(&self) -> Result<BaseAuthState, BaseRelayError> { - BaseAuthState::new(self.relay_url.clone(), self.auth_ttl_seconds) + BaseAuthState::new( + self.relay_url.clone(), + self.auth_ttl_seconds, + self.auth_created_at_skew_seconds, + ) } } @@ -158,6 +167,7 @@ enum BaseRelayPocketSyncPolicyDocument { #[derive(Debug, Deserialize)] struct BaseRelayAuthConfigDocument { challenge_ttl_seconds: u64, + created_at_skew_seconds: u64, } #[derive(Debug, Deserialize)] @@ -221,6 +231,11 @@ pub fn parse_base_relay_runtime_config_json( "limits.max_pending_events must be greater than zero", )); } + if document.auth.created_at_skew_seconds == 0 { + return Err(BaseRelayError::invalid( + "auth.created_at_skew_seconds must be greater than zero", + )); + } let tracing = base_relay_tracing_config_from_document(document.observability.tracing)?; Ok(BaseRelayRuntimeConfig { listen_addr, @@ -228,6 +243,7 @@ pub fn parse_base_relay_runtime_config_json( pocket, groups, auth_ttl_seconds: document.auth.challenge_ttl_seconds, + auth_created_at_skew_seconds: document.auth.created_at_skew_seconds, max_pending_events: document.limits.max_pending_events, tracing, }) @@ -279,9 +295,43 @@ mod tests { ); assert!(config.groups().enabled()); assert_eq!(config.auth_ttl_seconds(), 300); + assert_eq!(config.auth_created_at_skew_seconds(), 600); assert_eq!(config.max_pending_events(), 1024); assert!(config.tracing().enabled()); assert_eq!(config.tracing().format(), BaseRelayTracingFormat::Json); config.auth_state().expect("auth"); } + + #[test] + fn base_relay_runtime_config_rejects_zero_auth_skew() { + let raw = r#"{ + "server": { + "listen_addr": "127.0.0.1:0", + "relay_url": "wss://relay.radroots.test" + }, + "pocket": { + "data_directory": "runtime/pocket", + "map_size_bytes": 1073741824, + "reader_slots": 128, + "sync_policy": "flush_on_shutdown" + }, + "groups": { + "enabled": false + }, + "auth": { + "challenge_ttl_seconds": 300, + "created_at_skew_seconds": 0 + }, + "limits": { + "max_pending_events": 8 + } + }"#; + + assert_eq!( + parse_base_relay_runtime_config_json(raw) + .expect_err("zero skew") + .prefixed_message(), + "invalid: auth.created_at_skew_seconds must be greater than zero" + ); + } } diff --git a/crates/tangle_runtime/src/relay/auth.rs b/crates/tangle_runtime/src/relay/auth.rs @@ -16,25 +16,36 @@ pub fn generate_auth_challenge() -> Result<String, BaseRelayError> { #[derive(Debug, Clone, PartialEq, Eq)] pub struct BaseAuthState { relay_url: String, - ttl_seconds: u64, + challenge_ttl_seconds: u64, + created_at_skew_seconds: u64, challenge: Option<BaseAuthChallenge>, authenticated_pubkeys: BTreeSet<PublicKeyHex>, } impl BaseAuthState { - pub fn new(relay_url: impl Into<String>, ttl_seconds: u64) -> Result<Self, BaseRelayError> { + pub fn new( + relay_url: impl Into<String>, + challenge_ttl_seconds: u64, + created_at_skew_seconds: u64, + ) -> Result<Self, BaseRelayError> { let relay_url = relay_url.into(); if relay_url.trim().is_empty() { return Err(BaseRelayError::invalid("auth relay URL must not be empty")); } - if ttl_seconds == 0 { + if challenge_ttl_seconds == 0 { return Err(BaseRelayError::invalid( "auth challenge ttl must be greater than zero", )); } + if created_at_skew_seconds == 0 { + return Err(BaseRelayError::invalid( + "auth created_at skew must be greater than zero", + )); + } Ok(Self { relay_url, - ttl_seconds, + challenge_ttl_seconds, + created_at_skew_seconds, challenge: None, authenticated_pubkeys: BTreeSet::new(), }) @@ -83,10 +94,22 @@ impl BaseAuthState { > challenge .issued_at .as_u64() - .saturating_add(self.ttl_seconds) + .saturating_add(self.challenge_ttl_seconds) { return Err(BaseRelayError::auth_required("auth challenge expired")); } + if auth + .created_at() + .as_u64() + .saturating_add(self.created_at_skew_seconds) + < now.as_u64() + || auth.created_at().as_u64() + > now.as_u64().saturating_add(self.created_at_skew_seconds) + { + return Err(BaseRelayError::auth_required( + "auth event created_at is outside configured skew", + )); + } let pubkey = auth.pubkey().clone(); self.authenticated_pubkeys.insert(pubkey.clone()); Ok(pubkey) @@ -106,6 +129,7 @@ struct BaseAuthChallenge { #[derive(Debug, Clone, PartialEq, Eq)] struct BaseRelayAuthEvent { pubkey: PublicKeyHex, + created_at: UnixTimestamp, relay: String, challenge: String, } @@ -115,6 +139,10 @@ impl BaseRelayAuthEvent { &self.pubkey } + fn created_at(&self) -> UnixTimestamp { + self.created_at + } + fn relay(&self) -> &str { &self.relay } @@ -138,6 +166,7 @@ fn parse_base_relay_auth_event(event: &Event) -> Result<Option<BaseRelayAuthEven } Ok(Some(BaseRelayAuthEvent { pubkey: event.unsigned().pubkey().clone(), + created_at: event.unsigned().created_at(), relay, challenge, })) @@ -175,11 +204,12 @@ fn lower_hex(bytes: &[u8]) -> String { mod tests { use super::{BaseAuthState, generate_auth_challenge}; use tangle_crypto::RelaySigner; - use tangle_protocol::{Event, Kind, RelayMessage, Tag, UnixTimestamp, UnsignedEvent}; + use tangle_protocol::{Event, EventId, Kind, RelayMessage, Tag, UnixTimestamp, UnsignedEvent}; #[test] fn auth_state_issues_challenges_and_accepts_multiple_pubkeys() { - let mut auth = BaseAuthState::new("wss://relay.radroots.test", 60).expect("auth state"); + let mut auth = + BaseAuthState::new("wss://relay.radroots.test", 60, 600).expect("auth state"); let issued = UnixTimestamp::new(100); assert_eq!( @@ -211,6 +241,116 @@ mod tests { } #[test] + fn auth_state_rejects_invalid_event_shape_and_signature() { + let mut auth = + BaseAuthState::new("wss://relay.radroots.test", 60, 600).expect("auth state"); + auth.issue_challenge("challenge-a", UnixTimestamp::new(100)) + .expect("challenge"); + let valid = signed_auth_event(7, "challenge-a", 120); + let wrong_id = Event::new( + EventId::new(&"0".repeat(EventId::HEX_LENGTH)).expect("id"), + valid.unsigned().clone(), + valid.sig().clone(), + ); + assert!( + auth.authenticate(&wrong_id, UnixTimestamp::new(120)) + .expect_err("id") + .prefixed_message() + .starts_with("invalid: event id mismatch:") + ); + + let other = signed_auth_event(8, "challenge-a", 120); + let wrong_signature = Event::new( + valid.id().clone(), + valid.unsigned().clone(), + other.sig().clone(), + ); + assert_eq!( + auth.authenticate(&wrong_signature, UnixTimestamp::new(120)) + .expect_err("signature") + .prefixed_message(), + "invalid: event signature verification failed" + ); + + assert_eq!( + auth.authenticate( + &signed_event(7, 1, auth_tags("challenge-a"), 120), + UnixTimestamp::new(120) + ) + .expect_err("kind") + .prefixed_message(), + "invalid: AUTH message must contain kind 22242" + ); + + assert_eq!( + auth.authenticate( + &signed_event( + 7, + 22_242, + vec![Tag::from_parts("challenge", &["challenge-a"]).expect("challenge")], + 120 + ), + UnixTimestamp::new(120) + ) + .expect_err("relay") + .prefixed_message(), + "invalid: tag `relay` is required" + ); + + assert_eq!( + auth.authenticate( + &signed_event( + 7, + 22_242, + vec![Tag::from_parts("relay", &["wss://relay.radroots.test"]).expect("relay")], + 120 + ), + UnixTimestamp::new(120) + ) + .expect_err("challenge") + .prefixed_message(), + "invalid: tag `challenge` is required" + ); + } + + #[test] + fn auth_state_rejects_created_at_outside_configured_skew() { + let mut auth = BaseAuthState::new("wss://relay.radroots.test", 60, 10).expect("auth state"); + auth.issue_challenge("challenge-a", UnixTimestamp::new(100)) + .expect("challenge"); + + auth.authenticate( + &signed_auth_event(7, "challenge-a", 90), + UnixTimestamp::new(100), + ) + .expect("lower boundary"); + auth.authenticate( + &signed_auth_event(8, "challenge-a", 110), + UnixTimestamp::new(100), + ) + .expect("upper boundary"); + + assert_eq!( + auth.authenticate( + &signed_auth_event(9, "challenge-a", 89), + UnixTimestamp::new(100) + ) + .expect_err("stale") + .prefixed_message(), + "auth-required: auth event created_at is outside configured skew" + ); + assert_eq!( + auth.authenticate( + &signed_auth_event(10, "challenge-a", 111), + UnixTimestamp::new(100) + ) + .expect_err("future") + .prefixed_message(), + "auth-required: auth event created_at is outside configured skew" + ); + } + + #[test] fn generated_auth_challenge_is_lowercase_hex_nonce() { let first = generate_auth_challenge().expect("first"); let second = generate_auth_challenge().expect("second"); @@ -222,18 +362,26 @@ mod tests { } fn signed_auth_event(secret_byte: u8, challenge: &str, created_at: u64) -> Event { + signed_event(secret_byte, 22_242, auth_tags(challenge), created_at) + } + + fn signed_event(secret_byte: u8, kind: u64, tags: Vec<Tag>, created_at: u64) -> Event { let secret = format!("{:02x}", secret_byte).repeat(32); let signer = RelaySigner::from_secret_hex(&secret).expect("signer"); let unsigned = UnsignedEvent::new( signer.public_key().clone(), UnixTimestamp::new(created_at), - Kind::new(22_242).expect("kind"), - vec![ - Tag::from_parts("relay", &["wss://relay.radroots.test"]).expect("relay"), - Tag::from_parts("challenge", &[challenge]).expect("challenge"), - ], + Kind::new(kind).expect("kind"), + tags, "", ); signer.sign_unsigned_event(unsigned) } + + fn auth_tags(challenge: &str) -> Vec<Tag> { + vec![ + Tag::from_parts("relay", &["wss://relay.radroots.test"]).expect("relay"), + Tag::from_parts("challenge", &[challenge]).expect("challenge"), + ] + } } diff --git a/crates/tangle_runtime/src/relay/core.rs b/crates/tangle_runtime/src/relay/core.rs @@ -482,7 +482,7 @@ mod tests { 4, &enabled_groups_for_owner(&owner), ); - let auth = BaseAuthState::new("wss://relay.radroots.test", 60).expect("auth"); + let auth = BaseAuthState::new("wss://relay.radroots.test", 60, 600).expect("auth"); let event = signed_group_create_event(7, "Farm"); assert_eq!( @@ -1136,7 +1136,8 @@ mod tests { #[test] fn base_relay_client_message_dispatch_handles_count_and_auth() { let mut relay = test_relay("base-relay-dispatch", 4); - let mut auth = BaseAuthState::new("wss://relay.radroots.test", 60).expect("auth state"); + let mut auth = + BaseAuthState::new("wss://relay.radroots.test", 60, 600).expect("auth state"); auth.issue_challenge("challenge-a", UnixTimestamp::new(100)) .expect("challenge"); let auth_event = signed_auth_event(7, "challenge-a", 120); @@ -1286,7 +1287,8 @@ mod tests { } fn authenticated_state(secret_byte: u8) -> BaseAuthState { - let mut auth = BaseAuthState::new("wss://relay.radroots.test", 60).expect("auth state"); + let mut auth = + BaseAuthState::new("wss://relay.radroots.test", 60, 600).expect("auth state"); auth.issue_challenge("challenge-a", UnixTimestamp::new(100)) .expect("challenge"); let event = signed_auth_event(secret_byte, "challenge-a", 120); diff --git a/crates/tangle_runtime/src/runtime.rs b/crates/tangle_runtime/src/runtime.rs @@ -362,7 +362,8 @@ mod tests { "owner_pubkeys": ["0202020202020202020202020202020202020202020202020202020202020202"] }, "auth": { - "challenge_ttl_seconds": 300 + "challenge_ttl_seconds": 300, + "created_at_skew_seconds": 600 }, "limits": { "max_pending_events": max_pending_events diff --git a/crates/tangle_runtime/src/server.rs b/crates/tangle_runtime/src/server.rs @@ -160,7 +160,10 @@ mod tests { use futures_util::{SinkExt, StreamExt}; use http::{Request, header}; use serde_json::json; - use std::path::{Path, PathBuf}; + use std::{ + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, + }; use tangle_protocol::event_to_value; use tangle_test_support::{FixtureKey, tangle_v2_auth_event, tangle_v2_event}; use tokio::net::TcpListener; @@ -293,10 +296,15 @@ mod tests { assert_eq!(challenge.len(), 64); assert_eq!(challenge, challenge.to_ascii_lowercase()); - let owner_auth = - tangle_v2_auth_event(FixtureKey::Owner, &challenge, 1_714_124_434).expect("owner auth"); - let admin_auth = - tangle_v2_auth_event(FixtureKey::Admin, &challenge, 1_714_124_435).expect("admin auth"); + let auth_created_at = current_unix_timestamp(); + let owner_auth = tangle_v2_auth_event(FixtureKey::Owner, &challenge, auth_created_at) + .expect("owner auth"); + let admin_auth = tangle_v2_auth_event( + FixtureKey::Admin, + &challenge, + auth_created_at.saturating_add(1), + ) + .expect("admin auth"); send_client_text(&mut socket, "{").await; let notice = read_relay_value(&mut socket).await; @@ -461,7 +469,8 @@ mod tests { "owner_pubkeys": ["0202020202020202020202020202020202020202020202020202020202020202"] }, "auth": { - "challenge_ttl_seconds": 300 + "challenge_ttl_seconds": 300, + "created_at_skew_seconds": 600 }, "limits": { "max_pending_events": 8 @@ -507,4 +516,11 @@ mod tests { assert_eq!(auth[0], "AUTH"); auth[1].as_str().expect("auth challenge").to_owned() } + + fn current_unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_secs() + } } diff --git a/crates/tangle_runtime/src/session.rs b/crates/tangle_runtime/src/session.rs @@ -275,7 +275,8 @@ mod tests { "enabled": false }, "auth": { - "challenge_ttl_seconds": 300 + "challenge_ttl_seconds": 300, + "created_at_skew_seconds": 600 }, "limits": { "max_pending_events": 8 diff --git a/crates/tangle_runtime/tests/base_relay_v2.rs b/crates/tangle_runtime/tests/base_relay_v2.rs @@ -86,7 +86,7 @@ fn nip11_integration_reports_group_contracts() { #[test] fn auth_integration_covers_challenge_edges() { - let mut auth = BaseAuthState::new(TANGLE_V2_RELAY_URL, 20).expect("auth"); + let mut auth = BaseAuthState::new(TANGLE_V2_RELAY_URL, 20, 600).expect("auth"); assert_eq!( auth.issue_challenge("challenge-a", UnixTimestamp::new(100)) @@ -117,7 +117,7 @@ fn auth_integration_covers_challenge_edges() { "auth-required: auth challenge does not match" ); - let expired = BaseAuthState::new(TANGLE_V2_RELAY_URL, 1).expect("expired"); + let expired = BaseAuthState::new(TANGLE_V2_RELAY_URL, 1, 600).expect("expired"); let mut expired = issue_challenge(expired, "challenge-b", 100); assert_eq!( expired @@ -130,7 +130,7 @@ fn auth_integration_covers_challenge_edges() { "auth-required: auth challenge expired" ); - let mut wrong_relay = BaseAuthState::new("wss://other.radroots.test", 20).expect("relay"); + let mut wrong_relay = BaseAuthState::new("wss://other.radroots.test", 20, 600).expect("relay"); wrong_relay .issue_challenge("challenge-a", UnixTimestamp::new(100)) .expect("challenge"); @@ -673,7 +673,7 @@ fn group_config() -> GroupRuntimeConfig { } fn authenticated(key: FixtureKey) -> BaseAuthState { - let auth = BaseAuthState::new(TANGLE_V2_RELAY_URL, 60).expect("auth"); + let auth = BaseAuthState::new(TANGLE_V2_RELAY_URL, 60, 600).expect("auth"); let mut auth = issue_challenge(auth, "challenge-a", 100); let event = tangle_v2_auth_event(key, "challenge-a", 120).expect("auth event"); auth.authenticate(&event, UnixTimestamp::new(120)) diff --git a/crates/tangle_runtime/tests/phase2_acceptance_targets.rs b/crates/tangle_runtime/tests/phase2_acceptance_targets.rs @@ -6,12 +6,16 @@ use std::{ path::{Path, PathBuf}, time::{Duration, Instant}, }; +use tangle_protocol::{RelayMessage, UnixTimestamp}; use tangle_runtime::{ config::{BaseRelayRuntimeConfig, parse_base_relay_runtime_config_json}, + relay::auth::BaseAuthState, runtime::TangleRuntime, server::serve_listener_until_shutdown, }; -use tangle_test_support::{FixtureKey, TANGLE_V2_RELAY_SECRET_HEX}; +use tangle_test_support::{ + FixtureKey, TANGLE_V2_RELAY_SECRET_HEX, TANGLE_V2_RELAY_URL, tangle_v2_auth_event, +}; use tokio::{net::TcpListener, time::timeout}; #[tokio::test] @@ -64,9 +68,39 @@ fn nip11_includes_cors_headers_and_truthful_supported_nips() { } #[test] -#[ignore = "phase2 target: auth skew"] fn auth_rejects_events_outside_created_at_skew() { - pending("AUTH must validate created_at against configured skew"); + let mut auth = BaseAuthState::new(TANGLE_V2_RELAY_URL, 300, 10).expect("auth"); + + assert_eq!( + auth.issue_challenge("challenge-a", UnixTimestamp::new(100)) + .expect("challenge"), + RelayMessage::Auth("challenge-a".to_owned()) + ); + + auth.authenticate( + &tangle_v2_auth_event(FixtureKey::Owner, "challenge-a", 100).expect("fresh"), + UnixTimestamp::new(100), + ) + .expect("fresh"); + + assert_eq!( + auth.authenticate( + &tangle_v2_auth_event(FixtureKey::Admin, "challenge-a", 89).expect("auth"), + UnixTimestamp::new(100), + ) + .expect_err("stale") + .prefixed_message(), + "auth-required: auth event created_at is outside configured skew" + ); + assert_eq!( + auth.authenticate( + &tangle_v2_auth_event(FixtureKey::Member, "challenge-a", 111).expect("auth"), + UnixTimestamp::new(100), + ) + .expect_err("future") + .prefixed_message(), + "auth-required: auth event created_at is outside configured skew" + ); } #[test] @@ -144,7 +178,8 @@ fn runtime_config(root: &Path, listen_addr: SocketAddr) -> BaseRelayRuntimeConfi "owner_pubkeys": [FixtureKey::Owner.public_key().as_str()] }, "auth": { - "challenge_ttl_seconds": 300 + "challenge_ttl_seconds": 300, + "created_at_skew_seconds": 600 }, "limits": { "max_pending_events": 8