Files
fips/src/nostr/signal.rs
T
Johnathan Corgan d1ae1a254c Merge branch 'maint'
Carries the five security commits up. Five of the nine behaviours merged as
text; four had to be re-implemented against this line refactored structure,
and two files this line had deleted needed their content relocated.

Relocated: the path-MTU source reset into the protocol MMP module, adapted
because this line stores the first-increase timestamp as milliseconds
rather than an instant; the lookup-carrier deadline into the renamed lookup
handler, where it routes through the write action rather than touching the
map directly; and the flap-dampening test into the spanning-tree limits
tests, rewritten against this line injected clock so it needs no real
sleep. The maint copies of both deleted files are removed.

The lookup write action gains a timestamp field so both stores it feeds are
stamped at the same instant, which is what the deadline reasoning depends
on. The alternative was a second clock read in the shell.

Config keys and the new validator error strings are expressed against this
line rendezvous naming rather than the maintenance line discovery naming.

Three defects survived the automatic merge and were caught by the compiler:
a serde default naming the old config struct, an import of a constant from
the old module path, and two tests missing an import this line requires per
test. The automatic merge also duplicated a test and a relocated block that
already existed here; both were dropped.

Green: fmt, build, clippy and test --lib, 1861 passed.
2026-08-15 07:56:54 +00:00

328 lines
12 KiB
Rust

use nostr::EventId;
use nostr::nips::{nip44, nip59};
use nostr::prelude::{
Event, EventBuilder, JsonUtil, Kind, NostrSigner, PublicKey, Tag, Timestamp, UnsignedEvent,
};
use super::types::{BootstrapError, PunchHint, SIGNAL_KIND, TraversalAnswer, TraversalOffer};
/// Wall-clock skew tolerance applied to offer/answer freshness checks, in
/// milliseconds. Constant rather than configurable because loosening this
/// past ~minutes erodes the freshness guarantee that backstops session-id
/// replay protection. Tightening it below the size of a typical un-NTP'd
/// drift defeats the purpose. 60s sits comfortably between those.
///
/// `pub(crate)` because `Config::validate` derives the freshness window from
/// it rather than restating the number, so changing it here moves the
/// validation boundary with it.
pub(crate) const FRESHNESS_SKEW_TOLERANCE_MS: u64 = 60_000;
pub(super) struct SignalEnvelope<T> {
pub(super) payload: T,
pub(super) event_id: EventId,
pub(super) sender_npub: String,
}
pub(super) struct UnwrappedSignal {
pub(super) sender: PublicKey,
pub(super) rumor: UnsignedEvent,
}
pub(super) async fn build_signal_event(
signer: &nostr::Keys,
receiver: PublicKey,
rumor: UnsignedEvent,
expiration: Timestamp,
) -> Result<Event, BootstrapError> {
let seal = nip59::make_seal(signer, &receiver, rumor)
.await
.map_err(|e| BootstrapError::Nostr(e.to_string()))?
.sign(signer)
.await
.map_err(|e| BootstrapError::Nostr(e.to_string()))?;
let ephemeral = nostr::Keys::generate();
let content = nip44::encrypt(
ephemeral.secret_key(),
&receiver,
seal.as_json(),
nip44::Version::default(),
)
.map_err(|e| BootstrapError::Nostr(e.to_string()))?;
EventBuilder::new(Kind::Custom(SIGNAL_KIND), content)
.tags([Tag::public_key(receiver), Tag::expiration(expiration)])
.sign_with_keys(&ephemeral)
.map_err(|e| BootstrapError::Nostr(e.to_string()))
}
pub(super) async fn unwrap_signal_event(
signer: &nostr::Keys,
event: &Event,
) -> Result<UnwrappedSignal, BootstrapError> {
if event.kind != Kind::Custom(SIGNAL_KIND) {
return Err(BootstrapError::Protocol(
"not a traversal signal".to_string(),
));
}
let seal_json = signer
.nip44_decrypt(&event.pubkey, &event.content)
.await
.map_err(|e| BootstrapError::Nostr(e.to_string()))?;
let seal =
Event::from_json(seal_json).map_err(|e| BootstrapError::EventParse(e.to_string()))?;
seal.verify()
.map_err(|e| BootstrapError::Nostr(e.to_string()))?;
let rumor_json = signer
.nip44_decrypt(&seal.pubkey, &seal.content)
.await
.map_err(|e| BootstrapError::Nostr(e.to_string()))?;
let rumor = UnsignedEvent::from_json(rumor_json)
.map_err(|e| BootstrapError::EventParse(e.to_string()))?;
Ok(UnwrappedSignal {
sender: seal.pubkey,
rumor,
})
}
/// Result of a freshness check. `Fresh` means the offer/answer is within the
/// strict TTL window; `FreshWithinSkewTolerance` means it was only accepted
/// after applying `FRESHNESS_SKEW_TOLERANCE_MS` grace, which is a useful
/// signal for operators to know clock skew is in play.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FreshnessOutcome {
Fresh,
FreshWithinSkewTolerance,
}
/// Why a freshness check rejected a signal. The two classes are operationally
/// different and must not be collapsed into one reason string: one is the
/// expected consequence of relay lag, the other cannot arise from lag at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FreshnessReject {
/// Issued too long ago: past the configured TTL plus skew tolerance.
/// Benign; relay delivery delay produces this routinely.
Stale,
/// Issued further ahead of the local clock than the skew tolerance
/// allows. Not reachable through delivery delay, so it means either a
/// clock broken past the tolerance or a forged stamp.
FutureDated,
}
pub(super) fn validate_offer_freshness(
offer: &TraversalOffer,
now: u64,
signal_ttl_ms: u64,
actual_sender_npub: &str,
local_npub: &str,
) -> Result<FreshnessOutcome, BootstrapError> {
if offer.message_type != "offer" {
return Err(BootstrapError::Protocol("invalid-offer".to_string()));
}
let outcome = match check_freshness(offer.issued_at, offer.expires_at, now, signal_ttl_ms) {
Ok(o) => o,
Err(FreshnessReject::Stale) => {
return Err(BootstrapError::Protocol("expired-offer".to_string()));
}
Err(FreshnessReject::FutureDated) => {
return Err(BootstrapError::Protocol("future-dated-offer".to_string()));
}
};
if offer.sender_npub != actual_sender_npub || offer.recipient_npub != local_npub {
return Err(BootstrapError::Protocol("identity-mismatch".to_string()));
}
Ok(outcome)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn create_traversal_offer(
session_id: String,
issued_at: u64,
ttl_ms: u64,
nonce: String,
sender_npub: String,
recipient_npub: String,
reflexive_address: Option<super::TraversalAddress>,
local_addresses: Vec<super::TraversalAddress>,
stun_server: Option<String>,
) -> TraversalOffer {
TraversalOffer {
message_type: "offer".to_string(),
session_id,
issued_at,
expires_at: issued_at + ttl_ms,
nonce,
sender_npub,
recipient_npub,
reflexive_address,
local_addresses,
stun_server,
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn create_traversal_answer(
session_id: String,
issued_at: u64,
ttl_ms: u64,
nonce: String,
sender_npub: String,
recipient_npub: String,
in_reply_to: String,
accepted: bool,
reflexive_address: Option<super::TraversalAddress>,
local_addresses: Vec<super::TraversalAddress>,
stun_server: Option<String>,
punch: Option<PunchHint>,
reason: Option<String>,
offer_received_at: Option<u64>,
) -> TraversalAnswer {
TraversalAnswer {
message_type: "answer".to_string(),
session_id,
issued_at,
expires_at: issued_at + ttl_ms,
nonce,
sender_npub,
recipient_npub,
in_reply_to,
accepted,
reflexive_address,
local_addresses,
stun_server,
punch,
reason,
offer_received_at,
}
}
pub(super) fn validate_traversal_answer_for_offer(
offer: &TraversalOffer,
answer: &TraversalAnswer,
now: u64,
signal_ttl_ms: u64,
actual_sender_npub: &str,
local_npub: &str,
) -> Result<FreshnessOutcome, BootstrapError> {
if answer.message_type != "answer" {
return Err(BootstrapError::Protocol("invalid-answer".to_string()));
}
let offer_outcome = match check_freshness(offer.issued_at, offer.expires_at, now, signal_ttl_ms)
{
Ok(o) => o,
Err(FreshnessReject::Stale) => {
return Err(BootstrapError::Protocol(
"expired-offer-in-answer".to_string(),
));
}
Err(FreshnessReject::FutureDated) => {
return Err(BootstrapError::Protocol(
"future-dated-offer-in-answer".to_string(),
));
}
};
let answer_outcome =
match check_freshness(answer.issued_at, answer.expires_at, now, signal_ttl_ms) {
Ok(o) => o,
Err(FreshnessReject::Stale) => {
return Err(BootstrapError::Protocol("expired-answer".to_string()));
}
Err(FreshnessReject::FutureDated) => {
return Err(BootstrapError::Protocol("future-dated-answer".to_string()));
}
};
if offer.session_id != answer.session_id || answer.in_reply_to != offer.nonce {
return Err(BootstrapError::Protocol("session-mismatch".to_string()));
}
if offer.sender_npub != local_npub
|| offer.recipient_npub != actual_sender_npub
|| answer.sender_npub != actual_sender_npub
|| answer.recipient_npub != local_npub
{
return Err(BootstrapError::Protocol("identity-mismatch".to_string()));
}
if answer.accepted && answer.reflexive_address.is_none() && answer.local_addresses.is_empty() {
return Err(BootstrapError::Protocol("missing-addresses".to_string()));
}
if !answer.accepted && answer.reason.as_deref().unwrap_or_default().is_empty() {
return Err(BootstrapError::Protocol(
"missing-rejection-reason".to_string(),
));
}
// Surface skew if either side was tolerated. The strict-Fresh case wins
// when both are strict; otherwise tolerance applied somewhere.
Ok(
if offer_outcome == FreshnessOutcome::Fresh && answer_outcome == FreshnessOutcome::Fresh {
FreshnessOutcome::Fresh
} else {
FreshnessOutcome::FreshWithinSkewTolerance
},
)
}
/// NTP-style clock-skew estimate from a completed offer/answer round-trip.
/// Returns the responder's apparent offset relative to the initiator in
/// milliseconds (positive = responder clock ahead). Requires the responder
/// to have populated `answer.offer_received_at`; older responders won't, in
/// which case this returns `None`.
///
/// Symmetric one-way-delay assumption (the standard NTP offset formula):
/// offset = ((T2 - T1) + (T3 - T4)) / 2
/// where T1 = offer.issued_at, T2 = answer.offer_received_at,
/// T3 = answer.issued_at, T4 = answer_received_at.
pub(super) fn estimate_clock_skew(
offer: &TraversalOffer,
answer: &TraversalAnswer,
answer_received_at: u64,
) -> Option<i64> {
let t1 = offer.issued_at as i64;
let t2 = answer.offer_received_at? as i64;
let t3 = answer.issued_at as i64;
let t4 = answer_received_at as i64;
Some(((t2 - t1) + (t3 - t4)) / 2)
}
/// Returns Ok(outcome) if the (issued_at, expires_at) pair is acceptable
/// against `now` under the configured TTL plus `FRESHNESS_SKEW_TOLERANCE_MS`
/// of clock-skew grace on each side. Returns Err with the rejection class if
/// the message is genuinely outside the tolerated window.
///
/// Both sides are bounded. Backwards, a signal is accepted up to
/// `signal_ttl_ms + FRESHNESS_SKEW_TOLERANCE_MS` after it was issued.
/// Forwards, a signal issued ahead of the local clock is accepted only within
/// `FRESHNESS_SKEW_TOLERANCE_MS` and only as tolerated, never as strictly
/// fresh. The wire `expires_at` is chosen by the sender and is independent of
/// its `issued_at`, so it is clamped to the issuer's own stamp plus our
/// configured TTL; a sender may shorten its own expiry but cannot widen the
/// window we apply. One stamp is therefore acceptable over
/// `signal_ttl_ms + 2 * FRESHNESS_SKEW_TOLERANCE_MS` of wall clock, whatever
/// the sender declares.
fn check_freshness(
issued_at: u64,
expires_at: u64,
now: u64,
signal_ttl_ms: u64,
) -> Result<FreshnessOutcome, FreshnessReject> {
let effective_expiry = expires_at.min(issued_at.saturating_add(signal_ttl_ms));
// Exactly one of these is non-zero: a saturating signed age without a
// signed type.
let ahead = issued_at.saturating_sub(now);
let age = now.saturating_sub(issued_at);
let strict_ok = ahead == 0 && effective_expiry > now && age <= signal_ttl_ms;
if strict_ok {
return Ok(FreshnessOutcome::Fresh);
}
if ahead > FRESHNESS_SKEW_TOLERANCE_MS {
return Err(FreshnessReject::FutureDated);
}
let tolerated_ok = effective_expiry.saturating_add(FRESHNESS_SKEW_TOLERANCE_MS) > now
&& age <= signal_ttl_ms.saturating_add(FRESHNESS_SKEW_TOLERANCE_MS);
if tolerated_ok {
Ok(FreshnessOutcome::FreshWithinSkewTolerance)
} else {
Err(FreshnessReject::Stale)
}
}