mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
Merge maint into master (rekey jitter, Phase 5 settle, acl-allowlist, CI concurrency)
This commit is contained in:
@@ -11,6 +11,10 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
checks: write
|
||||
contents: read
|
||||
|
||||
@@ -22,15 +22,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
assistant policy, and project communication channels. Added
|
||||
`docs/branching.md` as the long-form companion covering the release
|
||||
workflow, version conventions, and merge-direction rationale.
|
||||
- CI and release-publish workflows hardened:
|
||||
- `ci.yml` declares a top-level `concurrency` block keyed on
|
||||
`(workflow, ref)` with `cancel-in-progress: true`. Force-pushes
|
||||
and rapid successive pushes to the same ref now retire any
|
||||
in-flight run rather than letting superseded and current-tip runs
|
||||
both burn runner minutes.
|
||||
- `aur-publish.yml` rewritten to fetch the upstream source tarball
|
||||
and compute its `b2sum` in CI, then patch `pkgver` and the
|
||||
`b2sums` SKIP placeholder in `PKGBUILD` in-place. Previously
|
||||
`updpkgsums: true` downloaded the tarball into the AUR working
|
||||
tree, where it was rejected by AUR's 488 KiB max-blob hook —
|
||||
silently no-op'ing the v0.3.0 stable AUR push. `fips.sysusers` /
|
||||
`fips.tmpfiles` asset b2sums are recomputed in the same step to
|
||||
stay in sync with the local files. `workflow_dispatch` gains a
|
||||
tag input so historical release tags can be re-published
|
||||
manually, and `continue-on-error: true` is dropped so future
|
||||
regressions surface in CI.
|
||||
- New `aur-publish-git.yml` workflow for the `fips-git` VCS
|
||||
PKGBUILD, triggered on master pushes touching `PKGBUILD-git` or
|
||||
companion files plus `workflow_dispatch`. `pkgver` is computed at
|
||||
build time by the PKGBUILD's `pkgver()` function, so this workflow
|
||||
is not tied to release tags.
|
||||
- Tag-triggered `package-*` release-build workflows remain
|
||||
untouched.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Apply ±15s symmetric jitter per session to the FMP and FSP rekey
|
||||
timer trigger. Eliminates the steady-state dual-initiation race
|
||||
in symmetric-start meshes; previously the smaller-NodeAddr
|
||||
tie-breaker resolved correctness after every cycle's collision.
|
||||
`node.rekey.after_secs` becomes the nominal interval rather than
|
||||
a floor; mean is preserved.
|
||||
- Rekey integration test (`testing/static/scripts/rekey-test.sh`):
|
||||
bumped Phase 1 baseline-convergence headroom from 36s to 60s.
|
||||
Eliminates the intermittent GitHub-runner Phase 1 timeout that
|
||||
previously required `gh run rerun --failed`. Cost on the success
|
||||
path is unchanged because the wait loop returns as soon as all 20
|
||||
pairs converge.
|
||||
- Rekey integration test (`testing/static/scripts/rekey-test.sh`):
|
||||
added a post-second-rekey settle window in Phase 5, mirroring
|
||||
Phase 3's existing 12-second pattern. Closes the intermittent
|
||||
GitHub-runner Phase 5 per-pair-ping flake caused by post-rekey
|
||||
routing convergence exceeding the per-ping 5-second timeout under
|
||||
runner CPU contention. Cost on the success path is a fixed 12s per
|
||||
suite run.
|
||||
- ACL-allowlist integration test (`testing/acl-allowlist/test.sh`):
|
||||
converted `assert_log_contains` from a one-shot `docker logs | grep`
|
||||
snapshot into a bounded poll with the same wait-with-timeout shape
|
||||
as `wait_for_peers_exact`. Absorbs the millisecond-to-second
|
||||
variance in the XX-handshake cross-connection tie-breaker: the
|
||||
inbound-handshake-context rejection can land tens of milliseconds
|
||||
after the test's previous one-shot grep gave up, producing a
|
||||
pre-existing flake on next-branch CI. Success-path cost is
|
||||
unchanged — the helper returns as soon as the pattern appears.
|
||||
|
||||
## [0.3.0] - 2026-05-11
|
||||
|
||||
|
||||
@@ -74,7 +74,11 @@ impl Node {
|
||||
.map(|s| s.current_send_counter())
|
||||
.unwrap_or(0);
|
||||
|
||||
if elapsed >= rekey_after_secs || counter >= rekey_after_messages {
|
||||
// Apply per-session symmetric jitter to desynchronize
|
||||
// dual-initiation in symmetric-start meshes.
|
||||
let effective_after_secs =
|
||||
rekey_after_secs.saturating_add_signed(peer.rekey_jitter_secs());
|
||||
if elapsed >= effective_after_secs || counter >= rekey_after_messages {
|
||||
peers_to_rekey.push(*node_addr);
|
||||
}
|
||||
}
|
||||
@@ -323,7 +327,11 @@ impl Node {
|
||||
let elapsed_secs = now_ms.saturating_sub(entry.session_start_ms()) / 1000;
|
||||
let counter = entry.send_counter();
|
||||
|
||||
if elapsed_secs >= rekey_after_secs || counter >= rekey_after_messages {
|
||||
// Apply per-session symmetric jitter to desynchronize
|
||||
// dual-initiation in symmetric-start meshes.
|
||||
let effective_after_secs =
|
||||
rekey_after_secs.saturating_add_signed(entry.rekey_jitter_secs());
|
||||
if elapsed_secs >= effective_after_secs || counter >= rekey_after_messages {
|
||||
sessions_to_rekey.push(*node_addr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@ pub(crate) mod wire;
|
||||
use self::discovery_rate_limit::{DiscoveryBackoff, DiscoveryForwardRateLimiter};
|
||||
use self::rate_limit::HandshakeRateLimiter;
|
||||
use self::routing_error_rate_limit::RoutingErrorRateLimiter;
|
||||
|
||||
/// Half-range of the symmetric jitter applied to the per-session rekey timer.
|
||||
/// Each session draws an offset uniformly from `[-REKEY_JITTER_SECS,
|
||||
/// +REKEY_JITTER_SECS]` seconds at construction. Desynchronizes
|
||||
/// dual-initiation in symmetric-start meshes; the configured
|
||||
/// `node.rekey.after_secs` remains the nominal interval (mean preserved).
|
||||
pub(crate) const REKEY_JITTER_SECS: i64 = 15;
|
||||
use self::wire::{
|
||||
FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, build_encrypted, build_established_header,
|
||||
prepend_inner_header,
|
||||
|
||||
@@ -10,9 +10,16 @@ use std::time::Instant;
|
||||
use crate::NodeAddr;
|
||||
use crate::config::SessionMmpConfig;
|
||||
use crate::mmp::MmpSessionState;
|
||||
use crate::node::REKEY_JITTER_SECS;
|
||||
use crate::noise::{HandshakeState, NoiseSession};
|
||||
use rand::RngExt;
|
||||
use secp256k1::PublicKey;
|
||||
|
||||
/// Draw a fresh per-session rekey jitter from `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`.
|
||||
fn draw_rekey_jitter() -> i64 {
|
||||
rand::rng().random_range(-REKEY_JITTER_SECS..=REKEY_JITTER_SECS)
|
||||
}
|
||||
|
||||
/// State machine for an end-to-end session.
|
||||
///
|
||||
/// `Established` is intentionally larger than the handshake variants:
|
||||
@@ -121,6 +128,12 @@ pub(crate) struct SessionEntry {
|
||||
/// When the FSP rekey handshake completed (initiator sent msg3, Unix ms).
|
||||
/// Used to defer cutover until msg3 has time to reach the responder.
|
||||
rekey_completed_ms: u64,
|
||||
/// Per-session symmetric jitter applied to the rekey timer trigger.
|
||||
/// Drawn once at construction (and at each cutover) uniformly from
|
||||
/// `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`. Desynchronizes
|
||||
/// dual-initiation in symmetric-start meshes; mean interval is
|
||||
/// preserved.
|
||||
rekey_jitter_secs: i64,
|
||||
}
|
||||
|
||||
impl SessionEntry {
|
||||
@@ -157,6 +170,7 @@ impl SessionEntry {
|
||||
rekey_initiator: false,
|
||||
last_peer_rekey_ms: 0,
|
||||
rekey_completed_ms: 0,
|
||||
rekey_jitter_secs: draw_rekey_jitter(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,6 +412,16 @@ impl SessionEntry {
|
||||
self.rekey_completed_ms
|
||||
}
|
||||
|
||||
/// Per-session symmetric rekey-timer jitter offset (seconds).
|
||||
///
|
||||
/// Drawn at session construction and at each rekey cutover; uniform
|
||||
/// over `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`. Callers add this
|
||||
/// to the configured `node.rekey.after_secs` to obtain the effective
|
||||
/// trigger interval for this session.
|
||||
pub(crate) fn rekey_jitter_secs(&self) -> i64 {
|
||||
self.rekey_jitter_secs
|
||||
}
|
||||
|
||||
/// Record when the FSP rekey handshake completed (initiator side).
|
||||
pub(crate) fn set_rekey_completed_ms(&mut self, ms: u64) {
|
||||
self.rekey_completed_ms = ms;
|
||||
@@ -443,6 +467,7 @@ impl SessionEntry {
|
||||
self.rekey_state = None;
|
||||
self.rekey_initiator = false;
|
||||
self.rekey_completed_ms = 0;
|
||||
self.rekey_jitter_secs = draw_rekey_jitter();
|
||||
|
||||
// Reset MMP counters to avoid metric discontinuity
|
||||
let now = Instant::now();
|
||||
@@ -471,6 +496,7 @@ impl SessionEntry {
|
||||
self.session_start_ms = now_ms;
|
||||
self.rekey_state = None;
|
||||
self.rekey_initiator = false;
|
||||
self.rekey_jitter_secs = draw_rekey_jitter();
|
||||
|
||||
// Reset MMP counters to avoid metric discontinuity
|
||||
let now = Instant::now();
|
||||
|
||||
@@ -67,6 +67,68 @@ fn test_session_entry_new_initiating() {
|
||||
assert_eq!(entry.last_activity(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_entry_rekey_jitter_in_range() {
|
||||
use crate::node::REKEY_JITTER_SECS;
|
||||
use crate::noise::HandshakeState;
|
||||
|
||||
// Every newly constructed SessionEntry's jitter must lie in the
|
||||
// symmetric range [-REKEY_JITTER_SECS, +REKEY_JITTER_SECS].
|
||||
for _ in 0..100 {
|
||||
let identity_a = Identity::generate();
|
||||
let identity_b = Identity::generate();
|
||||
let handshake =
|
||||
HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
|
||||
let entry = crate::node::session::SessionEntry::new(
|
||||
*identity_b.node_addr(),
|
||||
identity_b.pubkey_full(),
|
||||
EndToEndState::Initiating(handshake),
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
let j = entry.rekey_jitter_secs();
|
||||
assert!(
|
||||
(-REKEY_JITTER_SECS..=REKEY_JITTER_SECS).contains(&j),
|
||||
"jitter {} outside [-{}, +{}]",
|
||||
j,
|
||||
REKEY_JITTER_SECS,
|
||||
REKEY_JITTER_SECS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_entry_rekey_jitter_mean_near_zero() {
|
||||
use crate::noise::HandshakeState;
|
||||
|
||||
// Sanity check that the distribution is roughly symmetric and not
|
||||
// stuck at one extreme. With N=200 draws from a uniform ~30-second
|
||||
// range, the empirical mean should be well under 5 in absolute value.
|
||||
let mut sum: i64 = 0;
|
||||
let n: i64 = 200;
|
||||
for _ in 0..n {
|
||||
let identity_a = Identity::generate();
|
||||
let identity_b = Identity::generate();
|
||||
let handshake =
|
||||
HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
|
||||
let entry = crate::node::session::SessionEntry::new(
|
||||
*identity_b.node_addr(),
|
||||
identity_b.pubkey_full(),
|
||||
EndToEndState::Initiating(handshake),
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
sum += entry.rekey_jitter_secs();
|
||||
}
|
||||
let mean = sum / n;
|
||||
assert!(
|
||||
mean.abs() < 5,
|
||||
"empirical mean {} not within 5 of 0 over {} samples",
|
||||
mean,
|
||||
n
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_entry_touch() {
|
||||
use crate::noise::HandshakeState;
|
||||
|
||||
@@ -5,15 +5,22 @@
|
||||
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::mmp::{MmpConfig, MmpPeerState};
|
||||
use crate::node::REKEY_JITTER_SECS;
|
||||
use crate::noise::{HandshakeState as NoiseHandshakeState, NoiseError, NoiseSession};
|
||||
use crate::transport::{LinkId, LinkStats, TransportAddr, TransportId};
|
||||
use crate::tree::{ParentDeclaration, TreeCoordinate};
|
||||
use crate::utils::index::SessionIndex;
|
||||
use crate::{FipsAddress, NodeAddr, PeerIdentity};
|
||||
use rand::RngExt;
|
||||
use secp256k1::XOnlyPublicKey;
|
||||
use std::fmt;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Draw a fresh per-session rekey jitter from `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`.
|
||||
fn draw_rekey_jitter() -> i64 {
|
||||
rand::rng().random_range(-REKEY_JITTER_SECS..=REKEY_JITTER_SECS)
|
||||
}
|
||||
|
||||
/// Connectivity state for an active peer.
|
||||
///
|
||||
/// This is simpler than the full PeerState since authentication is complete.
|
||||
@@ -156,6 +163,12 @@ pub struct ActivePeer {
|
||||
// === Rekey (Key Rotation) ===
|
||||
/// When the current Noise session was established (for rekey timer).
|
||||
session_established_at: Instant,
|
||||
/// Per-session symmetric jitter applied to the rekey timer trigger.
|
||||
/// Drawn once at construction (and at each cutover) uniformly from
|
||||
/// `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`. Desynchronizes
|
||||
/// dual-initiation in symmetric-start meshes; mean interval is
|
||||
/// preserved.
|
||||
rekey_jitter_secs: i64,
|
||||
/// Current K-bit epoch value (alternates each rekey).
|
||||
current_k_bit: bool,
|
||||
/// Previous session kept alive during drain window after cutover.
|
||||
@@ -220,6 +233,7 @@ impl ActivePeer {
|
||||
replay_suppressed_count: 0,
|
||||
consecutive_decrypt_failures: 0,
|
||||
session_established_at: now,
|
||||
rekey_jitter_secs: draw_rekey_jitter(),
|
||||
current_k_bit: false,
|
||||
previous_session: None,
|
||||
previous_our_index: None,
|
||||
@@ -300,6 +314,7 @@ impl ActivePeer {
|
||||
replay_suppressed_count: 0,
|
||||
consecutive_decrypt_failures: 0,
|
||||
session_established_at: now,
|
||||
rekey_jitter_secs: draw_rekey_jitter(),
|
||||
current_k_bit: false,
|
||||
previous_session: None,
|
||||
previous_our_index: None,
|
||||
@@ -783,6 +798,16 @@ impl ActivePeer {
|
||||
self.session_established_at
|
||||
}
|
||||
|
||||
/// Per-session symmetric rekey-timer jitter offset (seconds).
|
||||
///
|
||||
/// Drawn at session construction and at each rekey cutover; uniform
|
||||
/// over `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`. Callers add this
|
||||
/// to the configured `node.rekey.after_secs` to obtain the effective
|
||||
/// trigger interval for this session.
|
||||
pub fn rekey_jitter_secs(&self) -> i64 {
|
||||
self.rekey_jitter_secs
|
||||
}
|
||||
|
||||
/// Current K-bit epoch value.
|
||||
pub fn current_k_bit(&self) -> bool {
|
||||
self.current_k_bit
|
||||
@@ -887,6 +912,7 @@ impl ActivePeer {
|
||||
self.session_established_at = Instant::now();
|
||||
self.session_start = Instant::now();
|
||||
self.rekey_in_progress = false;
|
||||
self.rekey_jitter_secs = draw_rekey_jitter();
|
||||
self.reset_replay_suppressed();
|
||||
|
||||
// Reset MMP counters to avoid metric discontinuity
|
||||
@@ -922,6 +948,7 @@ impl ActivePeer {
|
||||
self.session_established_at = Instant::now();
|
||||
self.session_start = Instant::now();
|
||||
self.rekey_in_progress = false;
|
||||
self.rekey_jitter_secs = draw_rekey_jitter();
|
||||
self.reset_replay_suppressed();
|
||||
|
||||
// Reset MMP counters to avoid metric discontinuity
|
||||
@@ -1252,4 +1279,44 @@ mod tests {
|
||||
assert_eq!(peer.increment_decrypt_failures(), 1);
|
||||
assert_eq!(peer.consecutive_decrypt_failures(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rekey_jitter_in_range() {
|
||||
// Every newly constructed peer's jitter must lie in the
|
||||
// symmetric range [-REKEY_JITTER_SECS, +REKEY_JITTER_SECS].
|
||||
for _ in 0..100 {
|
||||
let identity = make_peer_identity();
|
||||
let peer = ActivePeer::new(identity, LinkId::new(1), 1000);
|
||||
let j = peer.rekey_jitter_secs();
|
||||
assert!(
|
||||
(-REKEY_JITTER_SECS..=REKEY_JITTER_SECS).contains(&j),
|
||||
"jitter {} outside [-{}, +{}]",
|
||||
j,
|
||||
REKEY_JITTER_SECS,
|
||||
REKEY_JITTER_SECS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rekey_jitter_mean_near_zero() {
|
||||
// Sanity check that the distribution is roughly symmetric and
|
||||
// not stuck at one extreme. With N=200 draws from a uniform
|
||||
// ~30-second-wide range, the empirical mean should be well
|
||||
// under 5 in absolute value with overwhelming probability.
|
||||
let mut sum: i64 = 0;
|
||||
let n: i64 = 200;
|
||||
for _ in 0..n {
|
||||
let identity = make_peer_identity();
|
||||
let peer = ActivePeer::new(identity, LinkId::new(1), 1000);
|
||||
sum += peer.rekey_jitter_secs();
|
||||
}
|
||||
let mean = sum / n;
|
||||
assert!(
|
||||
mean.abs() < 5,
|
||||
"empirical mean {} not within 5 of 0 over {} samples",
|
||||
mean,
|
||||
n
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,13 +98,25 @@ wait_for_peers_exact() {
|
||||
assert_log_contains() {
|
||||
local container="$1"
|
||||
local pattern="$2"
|
||||
local timeout="${3:-15}"
|
||||
local logs
|
||||
logs="$(docker logs "$container" 2>&1 | python3 -c 'import re,sys; print(re.sub(r"\x1b\[[0-9;]*m", "", sys.stdin.read()), end="")' || true)"
|
||||
if ! printf '%s' "$logs" | grep -F "$pattern" >/dev/null; then
|
||||
echo "FAIL: missing log pattern in $container: $pattern" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: $container logs contain expected ACL rejection"
|
||||
|
||||
# Poll docker logs instead of one-shot reading: under XX handshake,
|
||||
# the cross-connection tie-breaker determines which side reaches
|
||||
# its ACL-check point first, so the inbound-handshake-context
|
||||
# rejection may not emit until a later retry. Same wait-with-timeout
|
||||
# shape as wait_for_peers_exact above.
|
||||
for _ in $(seq 1 "$timeout"); do
|
||||
logs="$(docker logs "$container" 2>&1 | python3 -c 'import re,sys; print(re.sub(r"\x1b\[[0-9;]*m", "", sys.stdin.read()), end="")' || true)"
|
||||
if printf '%s' "$logs" | grep -F "$pattern" >/dev/null; then
|
||||
echo "PASS: $container logs contain expected ACL rejection"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "FAIL: missing log pattern in $container: $pattern (waited ${timeout}s)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ "$SKIP_BUILD" = false ]; then
|
||||
|
||||
@@ -368,7 +368,8 @@ echo "Phase 4: Second rekey cycle (waiting ${SECOND_REKEY_WAIT}s)"
|
||||
sleep "$SECOND_REKEY_WAIT"
|
||||
|
||||
# Verify connectivity after second rekey (back-to-back)
|
||||
echo "Phase 5: Post-second-rekey connectivity"
|
||||
echo "Phase 5: Post-second-rekey connectivity (settling ${REKEY_SETTLE}s)"
|
||||
sleep "$REKEY_SETTLE"
|
||||
ping_all
|
||||
phase_result "Post-second-rekey (all 20 pairs)"
|
||||
echo ""
|
||||
|
||||
Reference in New Issue
Block a user