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:
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