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, TraversalAddressObservation, TraversalAnswer,
TraversalOffer,
};
pub(super) const FRESHNESS_SKEW_TOLERANCE_MS: u64 = 60_000;
pub(super) struct SignalEnvelope<T> {
pub(super) payload: T,
pub(super) event_id: Option<EventId>,
pub(super) sender_npub: String,
}
pub(crate) struct UnwrappedSignal {
pub(crate) sender: PublicKey,
pub(crate) rumor: UnsignedEvent,
}
pub(crate) 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(crate) 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,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FreshnessOutcome {
Fresh,
FreshWithinSkewTolerance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct TraversalSignalTiming {
issued_at: u64,
ttl_ms: u64,
}
impl TraversalSignalTiming {
pub(super) fn new(issued_at: u64, ttl_ms: u64) -> Self {
Self { issued_at, ttl_ms }
}
fn expires_at(self) -> u64 {
self.issued_at + self.ttl_ms
}
}
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) {
Some(o) => o,
None => return Err(BootstrapError::Protocol("expired-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)
}
pub(super) fn create_traversal_offer(
session_id: String,
timing: TraversalSignalTiming,
nonce: String,
sender_npub: String,
recipient_npub: String,
observation: TraversalAddressObservation,
) -> TraversalOffer {
let TraversalAddressObservation {
reflexive_address,
local_addresses,
stun_server,
} = observation;
TraversalOffer {
message_type: "offer".to_string(),
session_id,
issued_at: timing.issued_at,
expires_at: timing.expires_at(),
nonce,
sender_npub,
recipient_npub,
reflexive_address,
local_addresses,
stun_server,
}
}
pub(super) fn create_traversal_answer(
offer: &TraversalOffer,
timing: TraversalSignalTiming,
nonce: String,
sender_npub: String,
observation: TraversalAddressObservation,
punch: Option<PunchHint>,
offer_received_at: Option<u64>,
) -> TraversalAnswer {
let accepted = observation.has_usable_address();
let TraversalAddressObservation {
reflexive_address,
local_addresses,
stun_server,
} = observation;
TraversalAnswer {
message_type: "answer".to_string(),
session_id: offer.session_id.clone(),
issued_at: timing.issued_at,
expires_at: timing.expires_at(),
nonce,
sender_npub,
recipient_npub: offer.sender_npub.clone(),
in_reply_to: offer.nonce.clone(),
accepted,
reflexive_address,
local_addresses,
stun_server,
punch: if accepted { punch } else { None },
reason: (!accepted).then_some("no-usable-addresses".to_string()),
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)
{
Some(o) => o,
None => return Err(BootstrapError::Protocol("expired-answer".to_string())),
};
let answer_outcome =
match check_freshness(answer.issued_at, answer.expires_at, now, signal_ttl_ms) {
Some(o) => o,
None => return Err(BootstrapError::Protocol("expired-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(),
));
}
Ok(
if offer_outcome == FreshnessOutcome::Fresh && answer_outcome == FreshnessOutcome::Fresh {
FreshnessOutcome::Fresh
} else {
FreshnessOutcome::FreshWithinSkewTolerance
},
)
}
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)
}
fn check_freshness(
issued_at: u64,
expires_at: u64,
now: u64,
signal_ttl_ms: u64,
) -> Option<FreshnessOutcome> {
let strict_ok = expires_at > now && now.saturating_sub(issued_at) <= signal_ttl_ms;
if strict_ok {
return Some(FreshnessOutcome::Fresh);
}
let tolerated_ok = expires_at.saturating_add(FRESHNESS_SKEW_TOLERANCE_MS) > now
&& now.saturating_sub(issued_at) <= signal_ttl_ms + FRESHNESS_SKEW_TOLERANCE_MS;
if tolerated_ok {
Some(FreshnessOutcome::FreshWithinSkewTolerance)
} else {
None
}
}