mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 17:55:53 -10:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f577142a35 | ||
|
|
5d182be9c8 | ||
|
|
955023e328 | ||
|
|
c65bd616d5 | ||
|
|
8dcb4934ff | ||
|
|
c7e8293aeb | ||
|
|
904e49f3bb | ||
|
|
16adbd8ade | ||
|
|
70cefe3b0c | ||
|
|
ccebc3fa54 | ||
|
|
04ba7a6792 | ||
|
|
46474bcd23 | ||
|
|
9f4efcc90b |
No files matched your search
@@ -122,15 +122,48 @@ impl HttpSignature {
|
||||
})
|
||||
}
|
||||
|
||||
/// Reject if the `created` timestamp is more than 5 minutes from now.
|
||||
/// Reject a `created` timestamp outside the acceptance window, which is
|
||||
/// ASYMMETRIC: up to 5 minutes into the past, but only 60 seconds into the
|
||||
/// future.
|
||||
///
|
||||
/// The two directions are two comparisons rather than one distance, because
|
||||
/// they are not the same event. A signature that arrives late is ordinary:
|
||||
/// clocks run slow, requests queue, and networks are not instant, so the
|
||||
/// past side keeps the full 300-second allowance. A signature stamped ahead
|
||||
/// of this node's clock is not ordinary, and folding both sides into an
|
||||
/// `abs()` let a signer extend its own validity: signed at T with
|
||||
/// `created = T + 300`, it stayed acceptable until roughly T + 600, so the
|
||||
/// effective window was double the one this function advertises. The
|
||||
/// forward tolerance exists only to absorb honest drift between two hosts,
|
||||
/// which is what 60 seconds buys; it is the same allowance the gossip
|
||||
/// path's freshness check uses for the same reason.
|
||||
pub fn check_created(&self) -> Result<()> {
|
||||
const MAX_AGE_SECS: i64 = 300;
|
||||
const MAX_FUTURE_SECS: i64 = 60;
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
let skew = (now - self.created).abs();
|
||||
if skew > 300 {
|
||||
|
||||
// Saturating, not plain subtraction. `created` is parsed as an
|
||||
// unrestricted i64 straight from the Signature-Input header, so a
|
||||
// sender picks it: at i64::MIN the past-side subtraction overflows,
|
||||
// which panics a debug build and wraps a release one into an absurd
|
||||
// value that can read as acceptable. Saturating gives the answer the
|
||||
// check wants at both extremes, since a timestamp that far away is
|
||||
// refused by whichever side it saturates toward.
|
||||
let age = now.saturating_sub(self.created);
|
||||
if age > MAX_AGE_SECS {
|
||||
return Err(Error::HttpSignature(format!(
|
||||
"clock skew too large: {skew}s (max 300s)"
|
||||
"clock skew too large: created {age}s in the past (max {MAX_AGE_SECS}s)"
|
||||
)));
|
||||
}
|
||||
|
||||
let ahead = self.created.saturating_sub(now);
|
||||
if ahead > MAX_FUTURE_SECS {
|
||||
return Err(Error::HttpSignature(format!(
|
||||
"clock skew too large: created {ahead}s in the future (max {MAX_FUTURE_SECS}s)"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -456,6 +489,75 @@ mod tests {
|
||||
assert!(sig.check_created().is_err());
|
||||
}
|
||||
|
||||
/// Build a parseable signature whose `created` is `offset` seconds from now
|
||||
/// (negative for the past), so a test can pin one side of the window.
|
||||
fn sig_created_at_offset(offset: i64) -> HttpSignature {
|
||||
let kp = Keypair::generate();
|
||||
let did = kp.did();
|
||||
let created = Utc::now().timestamp() + offset;
|
||||
let sig_input = format!(
|
||||
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created={created}"#
|
||||
);
|
||||
HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap()
|
||||
}
|
||||
|
||||
/// Build a signature at an ABSOLUTE `created`, so a test can drive the
|
||||
/// extremes an offset from now cannot reach.
|
||||
fn sig_created_absolute(created: i64) -> HttpSignature {
|
||||
let kp = Keypair::generate();
|
||||
let did = kp.did();
|
||||
let sig_input = format!(
|
||||
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created={created}"#
|
||||
);
|
||||
HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap()
|
||||
}
|
||||
|
||||
/// `created` is parsed as an unrestricted i64 from a header the sender
|
||||
/// writes, so the window check has to survive both ends of the type. Before
|
||||
/// the saturating form this panicked with "attempt to subtract with
|
||||
/// overflow" on the past side, which is a debug-build crash any client
|
||||
/// could trigger by sending one header value.
|
||||
#[test]
|
||||
fn extreme_created_values_are_refused_rather_than_overflowing() {
|
||||
for created in [i64::MIN, i64::MIN + 1, i64::MAX - 1, i64::MAX] {
|
||||
let err = sig_created_absolute(created)
|
||||
.check_created()
|
||||
.expect_err("an extreme created must be refused, not accepted");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("past") || msg.contains("future"),
|
||||
"created={created} must be refused by a named direction, got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_301s_in_past_rejected() {
|
||||
let err = sig_created_at_offset(-301).check_created().unwrap_err();
|
||||
assert!(err.to_string().contains("past"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_299s_in_past_accepted() {
|
||||
assert!(sig_created_at_offset(-299).check_created().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_61s_in_future_rejected() {
|
||||
let err = sig_created_at_offset(61).check_created().unwrap_err();
|
||||
assert!(err.to_string().contains("future"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_30s_in_future_accepted() {
|
||||
assert!(sig_created_at_offset(30).check_created().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_exactly_now_accepted() {
|
||||
assert!(sig_created_at_offset(0).check_created().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_signature_passes_clock_skew() {
|
||||
let kp = Keypair::generate();
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
//! `gitlawb_webhook_deliveries_total{result}`
|
||||
//! * is inbound gossip being admitted or shed, and for which reason?
|
||||
//! `gitlawb_gossip_ingest_events_total{outcome}`
|
||||
//! * is the gossip replay seen-set full, and therefore admitting events it
|
||||
//! cannot deduplicate?
|
||||
//! `gitlawb_gossip_replay_guard_saturated_total`
|
||||
//! * how big are the packs we're sending and receiving? —
|
||||
//! `gitlawb_pack_size_bytes`
|
||||
//! * a single `gitlawb_info{version, did}` gauge = 1, for joins/dashboards
|
||||
@@ -35,8 +38,8 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use prometheus::{
|
||||
Encoder, Histogram, HistogramOpts, IntCounterVec, IntGauge, IntGaugeVec, Opts, Registry,
|
||||
TextEncoder,
|
||||
Encoder, Histogram, HistogramOpts, IntCounter, IntCounterVec, IntGauge, IntGaugeVec, Opts,
|
||||
Registry, TextEncoder,
|
||||
};
|
||||
|
||||
/// The single, process-wide metrics registry. Initialized by [`init`].
|
||||
@@ -52,6 +55,7 @@ static AUTH_FAILURES: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static SYNC_PROCESSED: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static WEBHOOK_DELIVERIES: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static GOSSIP_INGEST: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static GOSSIP_REPLAY_GUARD_SATURATED: OnceLock<IntCounter> = OnceLock::new();
|
||||
static PACK_SIZE: OnceLock<Histogram> = OnceLock::new();
|
||||
static PEERS_CONNECTED: OnceLock<IntGauge> = OnceLock::new();
|
||||
/// 1 when the node started without its p2p identity because the key storage
|
||||
@@ -191,6 +195,18 @@ fn init_inner(version: &str, node_did: &str) {
|
||||
.set(gossip_ingest)
|
||||
.expect("set GOSSIP_INGEST once");
|
||||
|
||||
let gossip_replay_guard_saturated = IntCounter::with_opts(Opts::new(
|
||||
"gitlawb_gossip_replay_guard_saturated_total",
|
||||
"Inbound gossip ref-updates admitted without being recorded because the replay seen-set was at capacity",
|
||||
))
|
||||
.expect("gitlawb_gossip_replay_guard_saturated_total definition");
|
||||
registry
|
||||
.register(Box::new(gossip_replay_guard_saturated.clone()))
|
||||
.expect("register gitlawb_gossip_replay_guard_saturated_total");
|
||||
GOSSIP_REPLAY_GUARD_SATURATED
|
||||
.set(gossip_replay_guard_saturated)
|
||||
.expect("set GOSSIP_REPLAY_GUARD_SATURATED once");
|
||||
|
||||
let pack_size = Histogram::with_opts(
|
||||
HistogramOpts::new(
|
||||
"gitlawb_pack_size_bytes",
|
||||
@@ -307,7 +323,8 @@ pub fn record_webhook_delivery(result: &str) {
|
||||
|
||||
/// Record what the gossip ingest path decided about one inbound ref-update.
|
||||
/// `outcome` ∈ {accepted, unsigned_admitted, write_failed, rejected,
|
||||
/// source_rate_limited, author_rate_limited, unsigned_source_rate_limited}.
|
||||
/// source_rate_limited, author_rate_limited, unsigned_source_rate_limited,
|
||||
/// replayed, stale_timestamp}.
|
||||
///
|
||||
/// `accepted` is reserved for signature-verified events. An unsigned event that
|
||||
/// survives the rolling-upgrade window is `unsigned_admitted`, so the
|
||||
@@ -332,6 +349,38 @@ pub fn record_gossip_ingest(outcome: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one inbound gossip ref-update that the replay seen-set could not
|
||||
/// record because it was at capacity.
|
||||
///
|
||||
/// Label-less on purpose: there is one degradation mode and nothing about it
|
||||
/// varies per event, so a label would only add cardinality.
|
||||
///
|
||||
/// This is an alert-worthy signal, not a debug counter, and it is the ONLY way
|
||||
/// the degraded state is visible. At capacity the guard fails open, which means
|
||||
/// the event is admitted and `gitlawb_gossip_ingest_events_total{outcome=
|
||||
/// "accepted"}` counts it exactly as it counts a healthy admission. Fail-open is
|
||||
/// the right policy (a saturated set that dropped all fresh gossip would convert
|
||||
/// a loud resource attack into quiet mesh-wide censorship), but it is not a free
|
||||
/// one: while this counter is moving, a captured signature can again be replayed
|
||||
/// against the freshness window's full 10 minutes, spending a `peer_exists`
|
||||
/// round trip and a debit from the victim author's budget on every replay.
|
||||
pub fn record_gossip_replay_guard_saturated() {
|
||||
if let Some(c) = GOSSIP_REPLAY_GUARD_SATURATED.get() {
|
||||
c.inc();
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only: current `gitlawb_gossip_replay_guard_saturated_total` value (0 if
|
||||
/// the registry is not initialized). Lets the seen-set tests assert the
|
||||
/// saturation signal fires without scraping the encoded text.
|
||||
#[cfg(test)]
|
||||
pub fn replay_guard_saturated_count_for_test() -> u64 {
|
||||
GOSSIP_REPLAY_GUARD_SATURATED
|
||||
.get()
|
||||
.map(|c| c.get())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Record a pack body size observation (bytes).
|
||||
pub fn observe_pack_size(bytes: f64) {
|
||||
if let Some(h) = PACK_SIZE.get() {
|
||||
@@ -434,6 +483,11 @@ mod tests {
|
||||
record_auth_failure("test/route", "test_reason");
|
||||
record_sync_processed("done");
|
||||
record_webhook_delivery("ok");
|
||||
// `record_gossip_replay_guard_saturated` is deliberately NOT called
|
||||
// here. It is a label-less process-wide counter and the seen-set test
|
||||
// that asserts it asserts a before/after delta, so an increment from a
|
||||
// second test running concurrently in this binary would be a flake with
|
||||
// no diagnostic value.
|
||||
record_gossip_ingest("accepted");
|
||||
observe_pack_size(1024.0);
|
||||
set_peers_connected(0);
|
||||
|
||||
+3163
-55
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user