mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-05 19:18:25 +00:00
Merge the deployed-line comment sweep and key-material work
Carries the comment sweep, the key-material clearing, and the two constant-reconciliation commits up from master. Two of the five items on that line are deliberately excluded, and most of the resolution work was keeping them out. Excluded, and why: - The frame-length validation does not come. This branch needs its own design for msg2 and msg3 rather than an extra arm, and that work is sequenced separately. It arrived silently in four files that merged without a conflict, so it was removed from each: the reject variant, the stats counter, the wire helper and its tests, and the receive-path call site with the dispatch visibility widening its tests wanted. Landing only the counters would have left a metric that reports zero forever with nothing able to increment it. - The post-handshake identity confirmation does not come, and cannot. The older lines run a pattern that learns the initiator's static key at message 1; this branch does not learn it until message 3, so there is no identity to confirm at that point and no insertion point for the check. Its type, its classifier and its confirmation block all conflicted and were resolved to this branch's side, but two further pieces auto-merged with no conflict and had to be removed by hand: the module visibility widening, and the classifier call site. - The transport framing constants are not re-sourced here. This branch has rewritten that whole block: message 1 is a different size, message 2 and message 3 are minimums rather than exact values, and the version gate is a different version. Taking the incoming side would have sourced a minimum from an exact value. Carried, with adaptation where the patterns differ: - Key-material clearing applies to this branch's own handshake, which is XX at both layers rather than IK and XK. The incoming code could not be taken as written, since it carries whole method bodies for patterns this branch does not use. The erasing guard, the parameter erase in both constructors, and the clearing of each Diffie-Hellman output and secret-key copy were applied to this branch's own sites instead. - The security and session-layer documents keep this branch's pattern names and gain the correction about the handshake AEAD, which passes an empty associated-data field here too. - The drain-window test needed this branch's optional-identity constructor, since an anonymous dial is a first-class case here.
This commit is contained in:
@@ -69,6 +69,8 @@ jobs:
|
||||
run: bash testing/check-image-scoping.sh
|
||||
- name: Check every action is pinned to a commit SHA
|
||||
run: bash testing/check-action-pins.sh
|
||||
- name: Check every source comment resolves in-repo
|
||||
run: bash testing/check-comment-refs.sh
|
||||
# Hermetic: synthetic ping functions, no containers, ~45s. Lives beside
|
||||
# the other two so both runners gate on it identically — putting it in
|
||||
# only one would create exactly the drift check-ci-parity.sh exists to
|
||||
@@ -515,8 +517,9 @@ jobs:
|
||||
# NM+dnsmasq, dns-delegate, no-resolver) across five distros,
|
||||
# plus end-to-end scenarios that boot a real fips daemon with a
|
||||
# real TUN and assert `dig @127.0.0.53 AAAA <npub>.fips`
|
||||
# returns AAAA. Pins the production DNS bind path that
|
||||
# ISSUE-2026-0002 lived in. Single matrix entry runs all 13
|
||||
# returns AAAA. Pins the production DNS bind path where a
|
||||
# loopback-delivered query was once misattributed to the mesh
|
||||
# interface and dropped. Single matrix entry runs all 13
|
||||
# scenarios sequentially; ~7-12 min warm, ~12-15 min cold.
|
||||
- suite: dns-resolver
|
||||
type: dns-resolver
|
||||
|
||||
Generated
+15
@@ -1127,6 +1127,7 @@ dependencies = [
|
||||
"tun",
|
||||
"windows-service",
|
||||
"wintun",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4215,6 +4216,20 @@ name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
||||
@@ -26,6 +26,7 @@ sha2 = "0.10"
|
||||
hkdf = "0.12"
|
||||
ring = "0.17"
|
||||
libm = "0.2"
|
||||
zeroize = { version = "1.9", features = ["zeroize_derive"] }
|
||||
rand = "0.10.1"
|
||||
crossbeam-channel = "0.5"
|
||||
thiserror = "2.0"
|
||||
|
||||
@@ -226,7 +226,10 @@ than network addresses. A session survives:
|
||||
### Noise XX Pattern
|
||||
|
||||
FSP uses the same Noise XX pattern as the link layer (FMP). The full
|
||||
Noise descriptor is `Noise_XX_secp256k1_ChaChaPoly_SHA256`.
|
||||
Noise descriptor is `Noise_XX_secp256k1_ChaChaPoly_SHA256`, with one
|
||||
deviation recorded in [the security reference](../reference/security.md):
|
||||
the handshake AEAD uses an empty associated-data field where standard
|
||||
Noise `EncryptAndHash` uses the handshake hash `h`.
|
||||
|
||||
The XX pattern (no pre-message):
|
||||
|
||||
|
||||
@@ -58,16 +58,34 @@ idempotent).
|
||||
| Curve | secp256k1 | FMP XX, FSP XX, Schnorr signatures |
|
||||
| Diffie-Hellman | ECDH on secp256k1 (x-only normalized) | Noise XX (both layers) |
|
||||
| AEAD | ChaCha20-Poly1305 | FMP link encryption, FSP session encryption |
|
||||
| Hash | SHA-256 | NodeAddr derivation, Noise transcript |
|
||||
| Hash | SHA-256 | NodeAddr derivation, Noise key schedule |
|
||||
| Key derivation | HKDF-SHA256 | Noise key schedule |
|
||||
| Signatures | secp256k1 Schnorr | TreeAnnounce, LookupResponse proof, Nostr adverts |
|
||||
| Noise pattern (link) | `Noise_XX_secp256k1_ChaChaPoly_SHA256` | FMP link layer (XX with negotiation payload) |
|
||||
| Noise pattern (session) | `Noise_XX_secp256k1_ChaChaPoly_SHA256` | FSP session layer (XX with negotiation payload) |
|
||||
| Noise pattern (link) | `Noise_XX_secp256k1_ChaChaPoly_SHA256`, with the deviation below | FMP link layer (XX with negotiation payload) |
|
||||
| Noise pattern (session) | `Noise_XX_secp256k1_ChaChaPoly_SHA256`, with the deviation below | FSP session layer (XX with negotiation payload) |
|
||||
|
||||
These choices align with the Nostr cryptographic stack
|
||||
(secp256k1 + ChaCha20-Poly1305 + SHA-256) and the NIP-44 encrypted
|
||||
messaging standard.
|
||||
|
||||
### Deviation: Empty Associated Data in the Handshake AEAD
|
||||
|
||||
Both Noise patterns above deviate from the standard construction in one
|
||||
respect. The handshake AEAD uses an empty associated-data field where
|
||||
standard Noise `EncryptAndHash` uses the handshake hash `h`.
|
||||
|
||||
The choice was deliberate. Using secp256k1 rather than 25519 already put the
|
||||
construction outside standard Noise, so no standard-Noise peer could be
|
||||
confused with it, and the transcript hash bought no distinguishing value.
|
||||
|
||||
That argument is about domain separation, and on those grounds it holds. It
|
||||
does not cover transcript binding, which is the property actually absent.
|
||||
Domain separation and DH binding survive through the chaining key `ck`, which
|
||||
`mix_key` chains from `ck = h`, seeded from the protocol name in
|
||||
`SymmetricState::initialize` (`src/noise/handshake.rs`). The handshake hash
|
||||
`h` is maintained at every step and is never fed to the AEAD, so it binds
|
||||
nothing.
|
||||
|
||||
## Rekey Defaults
|
||||
|
||||
Both link-layer and session-layer Noise sessions rekey under one of
|
||||
|
||||
@@ -117,8 +117,7 @@ EOF
|
||||
# - The IPV6_PKTINFO ifindex attribution behaviour where Linux reports
|
||||
# a packet to fips0's own IPv6 address as arriving on fips0 (despite
|
||||
# loopback delivery), which would otherwise be silently dropped by
|
||||
# the daemon's mesh-interface filter — see ISSUE-2026-0002 in the
|
||||
# project tracker.
|
||||
# the daemon's mesh-interface filter.
|
||||
#
|
||||
# This backend is the recommended path on every systemd-resolved host
|
||||
# that doesn't have native dns-delegate support (systemd >= 258).
|
||||
|
||||
@@ -299,7 +299,7 @@ async fn main() {
|
||||
}
|
||||
};
|
||||
|
||||
// Install inbound port-forward rules (TASK-2026-0061).
|
||||
// Install inbound port-forward rules.
|
||||
if let Err(e) = nat_mgr.set_port_forwards(&gw_config.port_forwards) {
|
||||
error!(error = %e, "Failed to install port-forward rules");
|
||||
let _ = nat_mgr.cleanup();
|
||||
|
||||
+11
-2
@@ -10,6 +10,7 @@ use fips::{Config, Node};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{debug, error, info};
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// FIPS mesh network daemon
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -130,7 +131,7 @@ async fn run_daemon(
|
||||
fips::node::warn_on_legacy_config_paths();
|
||||
|
||||
// Identity provisioning: config nsec > key file > generate ephemeral
|
||||
let resolved = match resolve_identity(&config, &loaded_paths) {
|
||||
let mut resolved = match resolve_identity(&config, &loaded_paths) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Failed to resolve identity: {}", e);
|
||||
@@ -150,7 +151,15 @@ async fn run_daemon(
|
||||
|
||||
// Create node with resolved identity
|
||||
let mut config = config;
|
||||
config.node.identity.nsec = Some(resolved.nsec);
|
||||
// Take the nsec rather than move it: `ResolvedIdentity` clears its copy
|
||||
// on drop, so it cannot be left partially moved. Clear whatever the field
|
||||
// already held first — assigning over it drops the old `String` in place,
|
||||
// which does not run `Drop for IdentityConfig`, so a key that came from
|
||||
// the config file would be freed uncleared.
|
||||
if let Some(mut old) = config.node.identity.nsec.take() {
|
||||
old.zeroize();
|
||||
}
|
||||
config.node.identity.nsec = Some(std::mem::take(&mut resolved.nsec));
|
||||
debug!("Creating node");
|
||||
let mut node = match Node::new(config) {
|
||||
Ok(node) => node,
|
||||
|
||||
+10
-2
@@ -15,6 +15,7 @@ use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{Ipv6Addr, SocketAddrV6};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// FIPS control client
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -413,11 +414,18 @@ fn main() {
|
||||
// Commands that don't require a running daemon
|
||||
if let Commands::Keygen { dir, force, stdout } = &cli.command {
|
||||
let identity = Identity::generate();
|
||||
let nsec = encode_nsec(&identity.keypair().secret_key());
|
||||
// `keypair()` and `secret_key()` each hand back a whole private key
|
||||
// rather than a handle, so both temporaries are bound and erased. The
|
||||
// nsec is that same key in another encoding, so it is guarded too.
|
||||
let mut our_keypair = identity.keypair();
|
||||
let mut secret_key = our_keypair.secret_key();
|
||||
let nsec = Zeroizing::new(encode_nsec(&secret_key));
|
||||
secret_key.non_secure_erase();
|
||||
our_keypair.non_secure_erase();
|
||||
let npub = identity.npub();
|
||||
|
||||
if *stdout {
|
||||
println!("{nsec}");
|
||||
println!("{}", nsec.as_str());
|
||||
println!("{npub}");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ pub struct GatewayConfig {
|
||||
#[serde(default)]
|
||||
pub conntrack: ConntrackConfig,
|
||||
|
||||
/// Inbound mesh port forwarding rules. See TASK-2026-0061.
|
||||
/// Inbound mesh port forwarding rules.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub port_forwards: Vec<PortForward>,
|
||||
}
|
||||
|
||||
+83
-18
@@ -32,6 +32,7 @@ use crate::{Identity, IdentityError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig, PortForward, Proto};
|
||||
@@ -69,8 +70,7 @@ const PUB_FILENAME: &str = "fips.pub";
|
||||
/// Recognizes IPv4 `127.x.x.x`, IPv6 `::1` (with or without brackets), and
|
||||
/// the literal string `localhost`. Hostnames are conservatively assumed to
|
||||
/// be non-loopback. Used by `Config::validate()` to reject misconfigured
|
||||
/// loopback UDP binds combined with non-loopback peer addresses (see
|
||||
/// ISSUE-2026-0005).
|
||||
/// loopback UDP binds combined with non-loopback peer addresses.
|
||||
fn is_loopback_addr_str(addr: &str) -> bool {
|
||||
// Bracketed IPv6: `[::1]:port`
|
||||
if let Some(rest) = addr.strip_prefix('[')
|
||||
@@ -310,11 +310,16 @@ pub fn default_gateway_path() -> PathBuf {
|
||||
}
|
||||
|
||||
/// Read a bare bech32 nsec from a key file.
|
||||
///
|
||||
/// The file contents are the private key, and trimming copies it into a
|
||||
/// second string, so the read buffer is cleared on every exit path rather
|
||||
/// than dropped as it stands. The returned nsec is the caller's.
|
||||
pub fn read_key_file(path: &Path) -> Result<String, ConfigError> {
|
||||
let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
let contents = Zeroizing::new(contents);
|
||||
let nsec = contents.trim().to_string();
|
||||
if nsec.is_empty() {
|
||||
return Err(ConfigError::EmptyKeyFile {
|
||||
@@ -535,7 +540,9 @@ pub fn resolve_identity(
|
||||
if config.node.identity.persistent {
|
||||
// Persistent mode: load existing key file or generate-and-persist
|
||||
if key_path.exists() {
|
||||
let nsec = read_key_file(&key_path)?;
|
||||
// Held in a guard, not a bare `String`: if the parse below fails,
|
||||
// the `?` returns and a bare local would be freed uncleared.
|
||||
let nsec = Zeroizing::new(read_key_file(&key_path)?);
|
||||
let identity = Identity::from_secret_str(&nsec)?;
|
||||
warn_unmanaged_key_file(&key_path);
|
||||
if let Err(e) = write_pub_file(&pub_path, &identity.npub()) {
|
||||
@@ -546,7 +553,7 @@ pub fn resolve_identity(
|
||||
);
|
||||
}
|
||||
return Ok(ResolvedIdentity {
|
||||
nsec,
|
||||
nsec: nsec.to_string(),
|
||||
source: IdentitySource::KeyFile(key_path),
|
||||
});
|
||||
}
|
||||
@@ -560,7 +567,8 @@ pub fn resolve_identity(
|
||||
Path::new(SYSTEM_CONFIG_DIR),
|
||||
Path::new(LEGACY_SYSTEM_CONFIG_DIR),
|
||||
) {
|
||||
let nsec = read_key_file(&legacy)?;
|
||||
// Guarded for the same reason as the current-path read above.
|
||||
let nsec = Zeroizing::new(read_key_file(&legacy)?);
|
||||
let identity = Identity::from_secret_str(&nsec)?;
|
||||
tracing::warn!(
|
||||
legacy = %legacy.display(),
|
||||
@@ -577,14 +585,20 @@ pub fn resolve_identity(
|
||||
);
|
||||
}
|
||||
return Ok(ResolvedIdentity {
|
||||
nsec,
|
||||
nsec: nsec.to_string(),
|
||||
source: IdentitySource::KeyFile(legacy),
|
||||
});
|
||||
}
|
||||
|
||||
// No key file anywhere — generate and persist
|
||||
let identity = Identity::generate();
|
||||
let nsec = encode_nsec(&identity.keypair().secret_key());
|
||||
// `keypair()` and `secret_key()` each hand back a whole private key
|
||||
// rather than a handle, so both temporaries are bound and erased.
|
||||
let mut our_keypair = identity.keypair();
|
||||
let mut secret_key = our_keypair.secret_key();
|
||||
let nsec = encode_nsec(&secret_key);
|
||||
secret_key.non_secure_erase();
|
||||
our_keypair.non_secure_erase();
|
||||
let npub = identity.npub();
|
||||
|
||||
if let Some(parent) = key_path.parent() {
|
||||
@@ -622,7 +636,13 @@ pub fn resolve_identity(
|
||||
// Ephemeral mode (default): fresh keypair every start, write key files
|
||||
// for operator visibility
|
||||
let identity = Identity::generate();
|
||||
let nsec = encode_nsec(&identity.keypair().secret_key());
|
||||
// `keypair()` and `secret_key()` each hand back a whole private key
|
||||
// rather than a handle, so both temporaries are bound and erased.
|
||||
let mut our_keypair = identity.keypair();
|
||||
let mut secret_key = our_keypair.secret_key();
|
||||
let nsec = encode_nsec(&secret_key);
|
||||
secret_key.non_secure_erase();
|
||||
our_keypair.non_secure_erase();
|
||||
let npub = identity.npub();
|
||||
|
||||
if let Some(parent) = key_path.parent() {
|
||||
@@ -664,6 +684,14 @@ pub fn resolve_identity(
|
||||
}
|
||||
|
||||
/// Result of identity resolution.
|
||||
///
|
||||
/// `nsec` is the node's private key in plaintext. Every local that carries it
|
||||
/// through [`resolve_identity`] either moves into this struct or is held in a
|
||||
/// guard that clears it, so this is where the surviving string lives and where
|
||||
/// clearing it belongs.
|
||||
/// A caller that wants the value out should take it with [`Option::take`] or
|
||||
/// `std::mem::take` rather than moving the field, which the `Drop` below
|
||||
/// forbids.
|
||||
pub struct ResolvedIdentity {
|
||||
/// The nsec string (bech32 or hex) for creating an Identity.
|
||||
pub nsec: String,
|
||||
@@ -671,6 +699,14 @@ pub struct ResolvedIdentity {
|
||||
pub source: IdentitySource,
|
||||
}
|
||||
|
||||
impl Drop for ResolvedIdentity {
|
||||
/// Clear the plaintext private key rather than dropping the allocation
|
||||
/// with the key still in it.
|
||||
fn drop(&mut self) {
|
||||
self.nsec.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a resolved identity originated.
|
||||
pub enum IdentitySource {
|
||||
/// From explicit nsec in config file.
|
||||
@@ -732,6 +768,20 @@ pub struct IdentityConfig {
|
||||
pub persistent: bool,
|
||||
}
|
||||
|
||||
impl Drop for IdentityConfig {
|
||||
/// Clear the plaintext private key.
|
||||
///
|
||||
/// This field holds the node's private key for the whole process
|
||||
/// lifetime, which is the longest any secret lives in this crate, so
|
||||
/// leaving the allocation to be freed with the key still in it is the
|
||||
/// largest residue the crate can reach. A caller that needs the value out
|
||||
/// should take it with [`Option::take`]; moving the field is what the
|
||||
/// `Drop` forbids.
|
||||
fn drop(&mut self) {
|
||||
self.nsec.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
/// Root configuration structure.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
@@ -802,10 +852,16 @@ impl Config {
|
||||
|
||||
/// Load configuration from a single file.
|
||||
pub fn load_file(path: &Path) -> Result<Self, ConfigError> {
|
||||
let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
// The config file is the highest-priority home of a plaintext key:
|
||||
// `node.identity.nsec` is read straight out of it, so the whole file
|
||||
// text is treated as secret for as long as it is held.
|
||||
let contents =
|
||||
Zeroizing::new(
|
||||
std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?,
|
||||
);
|
||||
|
||||
let mut config: Config =
|
||||
serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
|
||||
@@ -897,10 +953,19 @@ impl Config {
|
||||
/// Merge another configuration into this one.
|
||||
///
|
||||
/// Values from `other` override values in `self` when present.
|
||||
pub fn merge(&mut self, other: Config) {
|
||||
// Merge node.identity section
|
||||
pub fn merge(&mut self, mut other: Config) {
|
||||
// Merge node.identity section. The nsec is taken rather than moved
|
||||
// out of `other.node.identity`, which clears its private key on drop
|
||||
// and so cannot be left partially moved.
|
||||
if other.node.identity.nsec.is_some() {
|
||||
self.node.identity.nsec = other.node.identity.nsec;
|
||||
// Clear whatever this field already held before overwriting it.
|
||||
// Assigning over the field drops the old `String` in place, which
|
||||
// does not run `Drop for IdentityConfig` and would free a
|
||||
// plaintext key uncleared when two config files both carry one.
|
||||
if let Some(mut old) = self.node.identity.nsec.take() {
|
||||
old.zeroize();
|
||||
}
|
||||
self.node.identity.nsec = other.node.identity.nsec.take();
|
||||
}
|
||||
if other.node.identity.persistent {
|
||||
self.node.identity.persistent = true;
|
||||
@@ -1052,9 +1117,9 @@ impl Config {
|
||||
// Reject loopback UDP bind combined with non-loopback peer addresses.
|
||||
// Linux pins the source IP to a loopback-bound socket, so packets
|
||||
// sent from such a socket to external peers are dropped at the
|
||||
// routing layer with no clear error in the daemon log. See
|
||||
// ISSUE-2026-0005. Outbound-only mode is exempt because it
|
||||
// overrides bind_addr to 0.0.0.0:0 (kernel-picked source).
|
||||
// routing layer with no clear error in the daemon log.
|
||||
// Outbound-only mode is exempt because it overrides bind_addr to
|
||||
// 0.0.0.0:0 (kernel-picked source).
|
||||
for (name, cfg) in self.transports.udp.iter() {
|
||||
if cfg.outbound_only() {
|
||||
continue;
|
||||
|
||||
@@ -103,7 +103,7 @@ pub struct UdpConfig {
|
||||
/// unfamiliar addresses. The Node-level gate at
|
||||
/// `src/node/handlers/handshake.rs` carves out msg1 from peers
|
||||
/// already established on this transport (so rekey continues to
|
||||
/// work) — see ISSUE-2026-0004.
|
||||
/// work).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub accept_connections: Option<bool>,
|
||||
}
|
||||
|
||||
+2
-2
@@ -78,8 +78,8 @@ where
|
||||
match serde_json::from_str::<Request>(line.trim()) {
|
||||
Ok(request) => {
|
||||
// First try to serve the request entirely off-loop from the
|
||||
// read handle. In R0 this always returns None (no query is
|
||||
// cut over yet); R1+ adds the per-command snapshot branches.
|
||||
// read handle. It returns None for any command with no snapshot
|
||||
// branch, and those fall through to the rx_loop path below.
|
||||
match snapshot_dispatch(&request, &read_handle) {
|
||||
Some(resp) => resp,
|
||||
None => {
|
||||
|
||||
+23
-22
@@ -2700,8 +2700,8 @@ mod tests {
|
||||
assert_snapshot("show_stats_history_all_peers", &render_response(resp));
|
||||
}
|
||||
|
||||
/// The five Category-D queries cut over to off-loop serving in R3. Served
|
||||
/// via `snapshot_dispatch`; coverage asserted in
|
||||
/// The five derived/routing/cache queries served off-loop via
|
||||
/// `snapshot_dispatch`; coverage asserted in
|
||||
/// `snapshot_dispatch_serves_category_d_queries` below.
|
||||
const OFF_LOOP_CATEGORY_D: &[&str] = &[
|
||||
"show_tree",
|
||||
@@ -2711,7 +2711,7 @@ mod tests {
|
||||
"show_identity_cache",
|
||||
];
|
||||
|
||||
/// The six Category-E queries cut over to off-loop serving in R4. Served via
|
||||
/// The six per-entity table queries served off-loop via
|
||||
/// `snapshot_dispatch`; coverage asserted in
|
||||
/// `snapshot_dispatch_serves_category_e_queries`.
|
||||
const OFF_LOOP_CATEGORY_E: &[&str] = &[
|
||||
@@ -2723,7 +2723,7 @@ mod tests {
|
||||
"show_mmp",
|
||||
];
|
||||
|
||||
/// Milestone-completion contract: every pure-read `show_*` query is served
|
||||
/// Contract: every pure-read `show_*` query is served
|
||||
/// off-loop via `snapshot_dispatch`, and the rx_loop control path carries no
|
||||
/// `show_*` arm at all — only the mutating COMMAND handlers (`connect` /
|
||||
/// `disconnect`) reach it. This test enumerates the full read surface and
|
||||
@@ -2797,8 +2797,8 @@ mod tests {
|
||||
/// the rx_loop source carries no `queries::dispatch` call and no
|
||||
/// `starts_with("show_")` routing branch. Reads the committed source of
|
||||
/// `src/node/dataplane/rx_loop.rs` and asserts both markers are absent. This
|
||||
/// is the milestone's "remove `show_*` from the data-plane dispatch path"
|
||||
/// invariant, guarded against regression.
|
||||
/// guards the "no `show_*` on the data-plane dispatch path" invariant
|
||||
/// against regression.
|
||||
#[test]
|
||||
fn rx_loop_has_no_show_dispatch() {
|
||||
let src = include_str!("../node/dataplane/rx_loop.rs");
|
||||
@@ -2863,10 +2863,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The R1/R2 scalar-and-series queries are served off-loop via
|
||||
/// The scalar-and-series queries are served off-loop via
|
||||
/// `snapshot_dispatch`; mutations return `None` and take the rx_loop COMMAND
|
||||
/// path. (`show_stats_peers` / `show_stats_history_all_peers`, formerly
|
||||
/// asserted on-loop here, were cut over in R5 — see
|
||||
/// asserted on-loop here, are now served off-loop too — see
|
||||
/// `snapshot_dispatch_serves_every_read_query` for the full read surface.)
|
||||
#[test]
|
||||
fn snapshot_dispatch_serves_scalar_and_series_queries() {
|
||||
@@ -2901,7 +2901,7 @@ mod tests {
|
||||
"show_stats_all_history",
|
||||
Some(json!({ "window": "10s", "granularity": "1s" })),
|
||||
),
|
||||
// R3 Category-D cutover.
|
||||
// Derived/routing/cache queries, served off-loop.
|
||||
("show_tree", None),
|
||||
("show_bloom", None),
|
||||
("show_cache", None),
|
||||
@@ -2923,7 +2923,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// R5 cutover + byte-identity: after a `record_stats_history()` tick the
|
||||
/// Byte-identity: after a `record_stats_history()` tick the
|
||||
/// off-loop `show_acl` / `show_stats_peers` / `show_stats_history_all_peers`
|
||||
/// renders each equal their on-loop oracle byte-for-byte, and all three are
|
||||
/// served off-loop via `snapshot_dispatch`.
|
||||
@@ -3009,7 +3009,7 @@ mod tests {
|
||||
assert_eq!(snap.connection_count, node.connection_count());
|
||||
assert_eq!(snap.estimated_mesh_size, node.estimated_mesh_size());
|
||||
assert_eq!(snap.effective_ipv6_mtu, node.effective_ipv6_mtu());
|
||||
// R5: the ACL status projection matches the node's live ACL status.
|
||||
// The ACL status projection matches the node's live ACL status.
|
||||
assert_eq!(snap.acl_status, node.peer_acl_status());
|
||||
|
||||
// Off-loop render must equal the on-loop render byte-for-byte.
|
||||
@@ -3021,9 +3021,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The five Category-D queries are served off-loop via `snapshot_dispatch`
|
||||
/// (return `Some` with status ok); everything not cut over stays on the
|
||||
/// rx_loop path (`None`).
|
||||
/// The five derived/routing/cache queries are served off-loop via
|
||||
/// `snapshot_dispatch` (return `Some` with status ok); everything not cut
|
||||
/// over stays on the rx_loop path (`None`).
|
||||
#[test]
|
||||
fn snapshot_dispatch_serves_category_d_queries() {
|
||||
use super::super::protocol::Request;
|
||||
@@ -3046,7 +3046,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// Mutations take the rx_loop COMMAND path. (Every read query, including
|
||||
// the per-peer stats-series queries, is served off-loop as of R5.)
|
||||
// the per-peer stats-series queries, is served off-loop.)
|
||||
for cmd in ["connect", "disconnect"] {
|
||||
assert!(
|
||||
snapshot_dispatch(&req(cmd), &handle).is_none(),
|
||||
@@ -3056,7 +3056,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The tick-published `RoutingSnapshot` reflects node state, and each
|
||||
/// off-loop Category-D render equals its on-loop render byte-for-byte
|
||||
/// off-loop routing render equals its on-loop render byte-for-byte
|
||||
/// (modulo the volatile-key redaction the wire-schema tests already apply).
|
||||
#[test]
|
||||
fn routing_snapshot_matches_on_loop_after_tick() {
|
||||
@@ -3115,10 +3115,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- R4 Category-E coverage ------------------------------------------
|
||||
// ---- per-entity table coverage ---------------------------------------
|
||||
|
||||
/// The six Category-E queries are served off-loop via `snapshot_dispatch`
|
||||
/// (return `Some` with status ok); mutations take the rx_loop COMMAND path.
|
||||
/// The six per-entity table queries are served off-loop via
|
||||
/// `snapshot_dispatch` (return `Some` with status ok); mutations take the
|
||||
/// rx_loop COMMAND path.
|
||||
#[test]
|
||||
fn snapshot_dispatch_serves_category_e_queries() {
|
||||
use super::super::protocol::Request;
|
||||
@@ -3141,7 +3142,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// Mutations take the rx_loop COMMAND path. (Every read query is served
|
||||
// off-loop as of R5.)
|
||||
// off-loop.)
|
||||
for cmd in ["connect", "disconnect"] {
|
||||
assert!(
|
||||
snapshot_dispatch(&req(cmd), &handle).is_none(),
|
||||
@@ -3151,7 +3152,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Freshness + fidelity: after a `record_stats_history()` tick (the entity
|
||||
/// publisher site) each off-loop Category-E render equals its on-loop render
|
||||
/// publisher site) each off-loop per-entity render equals its on-loop render
|
||||
/// byte-for-byte, and the seeded snapshot is empty before the first tick.
|
||||
#[test]
|
||||
fn entity_snapshot_matches_on_loop_after_tick() {
|
||||
@@ -3201,7 +3202,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Structural sharing (the R4 umbrella mandate): a republish in which only
|
||||
/// Structural sharing: a republish in which only
|
||||
/// one row changed re-allocates only that one `Arc<Row>` — every unchanged
|
||||
/// row is reused by pointer (`Arc::ptr_eq`). Exercises
|
||||
/// [`reconcile_rows`](super::super::snapshot::reconcile_rows), the
|
||||
|
||||
+50
-42
@@ -2,27 +2,33 @@
|
||||
//! queries can render off the rx_loop hot path instead of round-tripping the
|
||||
//! mpsc → rx_loop oneshot.
|
||||
//!
|
||||
//! This is the stable seam of the control read-isolation milestone
|
||||
//! (TASK-2026-0152, phase R0). The handle bundles the state that is already
|
||||
//! independently shareable, and grows one `ArcSwap` snapshot cell per phase as
|
||||
//! each subsystem's read state is published from its natural mutator:
|
||||
//! The handle bundles the node state that is independently shareable, plus one
|
||||
//! `ArcSwap` snapshot cell per read subsystem:
|
||||
//!
|
||||
//! - `context` / `metrics` — already `Arc`-shared (refactor steps B/C).
|
||||
//! - `stats` (R2) — `ArcSwap<StatsSnapshot>`: stats_history dual-ring + the
|
||||
//! scalar gauges `show_status` needs, published from the tick.
|
||||
//! - `routing` (R3) — `ArcSwap<RoutingSnapshot>`: tree / bloom / coord /
|
||||
//! identity, published from their announce / discovery mutators.
|
||||
//! - `entities` (R4) — `ArcSwap<EntitySnapshot>`: peers / sessions / links /
|
||||
//! connections / transports, published per-entity with `Vec<Arc<Row>>`
|
||||
//! - `context` / `metrics` — already `Arc`-shared.
|
||||
//! - `stats` — `ArcSwap<StatsSnapshot>`: stats_history dual-ring + the scalar
|
||||
//! gauges `show_status` needs, published from the tick.
|
||||
//! - `routing` — `ArcSwap<RoutingSnapshot>`: tree / bloom / coord / identity,
|
||||
//! published from the tick.
|
||||
//! - `entities` — `ArcSwap<EntitySnapshot>`: peers / sessions / links /
|
||||
//! connections / transports, published from the tick with `Vec<Arc<Row>>`
|
||||
//! structural sharing.
|
||||
//!
|
||||
//! Publisher placement follows the Q1 rules in
|
||||
//! `design/fast-path-refactoring-r0-read-handle.md`: every snapshot is
|
||||
//! published at its state's natural mutation site (on-change), never by the
|
||||
//! contended rx_loop task it is meant to bypass.
|
||||
//! Publisher placement: all three snapshot cells are published from the
|
||||
//! periodic tick, which runs as one arm of the rx_loop's `select!`. Publishing
|
||||
//! therefore costs the rx_loop; what the handle removes is the read-side round
|
||||
//! trip out to the rx_loop and back, not the cost of publishing. The
|
||||
//! `publish_routing_snapshot` and `publish_entities_snapshot` doc comments on
|
||||
//! `Node` carry the reasoning for the two projections that need coherent
|
||||
//! `&Node` access across subsystems.
|
||||
//!
|
||||
//! R0 ships only the type and the dispatch seam ([`snapshot_dispatch`]); no
|
||||
//! query reads the handle yet. Cutover begins in R1.
|
||||
//! A projection is a point-in-time copy, not a live view. The entity tables in
|
||||
//! particular are mutated on the packet path between ticks, so a reader sees
|
||||
//! the state as of the last publish.
|
||||
//!
|
||||
//! [`snapshot_dispatch`] is the seam: it serves the commands in its match arms
|
||||
//! directly from the handle and returns `None` for everything else, so the
|
||||
//! caller falls back to the mpsc → rx_loop path.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -37,9 +43,9 @@ use super::snapshot::{EntitySnapshot, RoutingSnapshot, StatsSnapshot};
|
||||
/// Cloneable read-only view of node state for off-loop control serving.
|
||||
///
|
||||
/// All fields are `Arc` / `ArcSwap` handles, so cloning is cheap and a clone
|
||||
/// can be held by every accepted control connection. Fields are consumed
|
||||
/// starting R1 as `show_*` queries cut over to off-loop rendering; until then
|
||||
/// they are wired but unread.
|
||||
/// can be held by every accepted control connection. The snapshot cells are
|
||||
/// read by the `*_from_handle` query functions that [`snapshot_dispatch`]
|
||||
/// routes to.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ControlReadHandle {
|
||||
/// Effectively-immutable node context (config, identity, limits).
|
||||
@@ -47,14 +53,14 @@ pub(crate) struct ControlReadHandle {
|
||||
/// Metrics registry (counters / gauges) for `show_stats_*`.
|
||||
metrics: Arc<MetricsRegistry>,
|
||||
/// stats_history dual-ring read copy + the scalar gauges/counts
|
||||
/// `show_status` needs, published from the tick (R2, Q1-b).
|
||||
/// `show_status` needs, published from the tick.
|
||||
stats: Arc<ArcSwap<StatsSnapshot>>,
|
||||
/// Category-D derived/routing/cache read view (tree / bloom / coord /
|
||||
/// identity + F-queue scalars), published from the tick (R3).
|
||||
/// Derived/routing/cache read view (tree / bloom / coord /
|
||||
/// identity + F-queue scalars), published from the tick.
|
||||
routing: Arc<ArcSwap<RoutingSnapshot>>,
|
||||
/// Category-E per-entity table read view (peers / sessions / links /
|
||||
/// Per-entity table read view (peers / sessions / links /
|
||||
/// connections / transports + mmp), published from the tick with
|
||||
/// `Vec<Arc<Row>>` structural sharing (R4).
|
||||
/// `Vec<Arc<Row>>` structural sharing.
|
||||
entities: Arc<ArcSwap<EntitySnapshot>>,
|
||||
}
|
||||
|
||||
@@ -90,19 +96,19 @@ impl ControlReadHandle {
|
||||
}
|
||||
|
||||
/// Load the latest published stats snapshot (the freshest available by
|
||||
/// construction; no IO_TIMEOUT staleness gate, per Q1-e).
|
||||
/// construction; no IO_TIMEOUT staleness gate).
|
||||
pub(crate) fn stats(&self) -> arc_swap::Guard<Arc<StatsSnapshot>> {
|
||||
self.stats.load()
|
||||
}
|
||||
|
||||
/// Load the latest published Category-D routing snapshot (freshest
|
||||
/// available by construction; no staleness gate, per Q1-e).
|
||||
/// Load the latest published routing snapshot (freshest
|
||||
/// available by construction; no staleness gate).
|
||||
pub(crate) fn routing(&self) -> arc_swap::Guard<Arc<RoutingSnapshot>> {
|
||||
self.routing.load()
|
||||
}
|
||||
|
||||
/// Load the latest published Category-E entity snapshot (freshest available
|
||||
/// by construction; no staleness gate, per Q1-e).
|
||||
/// Load the latest published entity snapshot (freshest available
|
||||
/// by construction; no staleness gate).
|
||||
pub(crate) fn entities(&self) -> arc_swap::Guard<Arc<EntitySnapshot>> {
|
||||
self.entities.load()
|
||||
}
|
||||
@@ -110,14 +116,16 @@ impl ControlReadHandle {
|
||||
|
||||
/// Attempt to serve a request entirely from the read handle, off the rx_loop.
|
||||
///
|
||||
/// Returns `Some(response)` when the command is a pure-snapshot query that has
|
||||
/// been cut over to off-loop rendering, or `None` when it must take the
|
||||
/// mpsc → rx_loop path (parameterized queries, mutations, and any query not
|
||||
/// yet cut over).
|
||||
/// Returns `Some(response)` when the command can be rendered from the bundled
|
||||
/// snapshot cells, or `None` when it must take the mpsc → rx_loop path.
|
||||
///
|
||||
/// Cutover queries (R1) read only `NodeContext` / `MetricsRegistry` (the state
|
||||
/// the read handle already bundles) plus host-OS facts (`/proc`, nftables), so
|
||||
/// they render entirely in the control task without touching `Node`.
|
||||
/// The queries served here read any of the cells the handle bundles —
|
||||
/// `context`, `metrics`, `stats`, `routing`, `entities` — plus host-OS facts
|
||||
/// gathered in [`super::listening`] and [`super::firewall_state`], so they
|
||||
/// render in the control task without touching `Node`. Taking a parameter is
|
||||
/// not what decides it: `show_stats_history` is parameterized and is served
|
||||
/// here. What falls back is a query needing live `Node` state the snapshot does
|
||||
/// not carry, and every mutation.
|
||||
///
|
||||
/// **It now also carries mutating commands**, namely the `profile_tick_*`
|
||||
/// family under the `profiling` feature. They are served here rather than on
|
||||
@@ -158,18 +166,18 @@ pub(crate) fn snapshot_dispatch(request: &Request, handle: &ControlReadHandle) -
|
||||
)),
|
||||
"show_stats_list" => Some(Response::ok(queries::show_stats_list())),
|
||||
"show_metrics" => Some(Response::ok(queries::show_metrics_from_handle(handle))),
|
||||
// R5: peer-ACL status, served from the tick-published `StatsSnapshot`.
|
||||
// Peer-ACL status, served from the tick-published `StatsSnapshot`.
|
||||
// The ACL is an `arc_swap::ArcSwap<PeerAcl>` reloaded only on the tick;
|
||||
// its status projection is captured at the same tick.
|
||||
"show_acl" => Some(Response::ok(queries::show_acl_from_handle(handle))),
|
||||
// R2: served from the tick-published `StatsSnapshot` (rings + scalar
|
||||
// Served from the tick-published `StatsSnapshot` (rings + scalar
|
||||
// gauges/counts). `show_status` and the two node-level/per-peer series
|
||||
// queries carry enough data in the snapshot to render faithfully
|
||||
// off-loop, including the parameterized series selectors (the snapshot
|
||||
// holds the full rings, so any metric / window / granularity is
|
||||
// satisfiable).
|
||||
//
|
||||
// R5 closes out the per-peer stats queries: `show_stats_peers` and
|
||||
// The per-peer stats queries: `show_stats_peers` and
|
||||
// `show_stats_history_all_peers` now read the snapshot's per-peer
|
||||
// `peer_meta` (live `is_active`, resolved npub / display name, captured
|
||||
// at publish time) joined against the `history` rings, so they no longer
|
||||
@@ -188,7 +196,7 @@ pub(crate) fn snapshot_dispatch(request: &Request, handle: &ControlReadHandle) -
|
||||
handle,
|
||||
request.params.as_ref(),
|
||||
)),
|
||||
// R3: served from the tick-published `RoutingSnapshot` (tree / bloom /
|
||||
// Served from the tick-published `RoutingSnapshot` (tree / bloom /
|
||||
// coord cache / identity cache + F-queue scalars). Display names are
|
||||
// resolved at publish time, so these render entirely off-loop. The
|
||||
// counter-family `stats` blocks come from the `MetricsRegistry` (also
|
||||
@@ -200,7 +208,7 @@ pub(crate) fn snapshot_dispatch(request: &Request, handle: &ControlReadHandle) -
|
||||
"show_identity_cache" => Some(Response::ok(queries::show_identity_cache_from_handle(
|
||||
handle,
|
||||
))),
|
||||
// R4: served from the tick-published `EntitySnapshot` (per-entity
|
||||
// Served from the tick-published `EntitySnapshot` (per-entity
|
||||
// `Vec<Arc<Row>>` tables with structural sharing). Display names,
|
||||
// tree-relationship flags, and Nostr-traversal state are resolved at
|
||||
// publish time, so these render entirely off-loop. All six are
|
||||
|
||||
+39
-42
@@ -1,8 +1,7 @@
|
||||
//! Read-side state snapshots published from the node's natural mutators so
|
||||
//! pure-snapshot `show_*` queries render off the rx_loop hot path.
|
||||
//!
|
||||
//! [`StatsSnapshot`] is the R2 reference implementation of the canonical
|
||||
//! snapshot pattern (see `design/fast-path-refactoring-r0-read-handle.md`): a
|
||||
//! [`StatsSnapshot`] is the reference implementation of the canonical snapshot pattern: a
|
||||
//! read-only data bundle published via `ArcSwap` from the tick after
|
||||
//! `StatsHistory::tick()`. It carries
|
||||
//!
|
||||
@@ -13,10 +12,10 @@
|
||||
//! peer / session / link / connection / transport counts), plus
|
||||
//! `peer_aliases` (effectively immutable after construction).
|
||||
//!
|
||||
//! The snapshot holds *data*, not rendered `Response` envelopes (Q1-d):
|
||||
//! The snapshot holds *data*, not rendered `Response` envelopes:
|
||||
//! rendering happens in the control task off the rx_loop. Staleness is bounded
|
||||
//! by the tick interval and is never staler than the underlying data, which
|
||||
//! also advances only on the tick (Q1-b).
|
||||
//! also advances only on the tick.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -28,7 +27,7 @@ use crate::node::stats_history::StatsHistory;
|
||||
use crate::upper::tun::TunState;
|
||||
|
||||
/// Read-only snapshot of the stats-history rings plus the scalar gauges and
|
||||
/// counts `show_status` reports. Published from the tick (Q1-b).
|
||||
/// counts `show_status` reports. Published from the tick.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct StatsSnapshot {
|
||||
/// Cloned read copy of the history rings (the dual-ring read side).
|
||||
@@ -66,14 +65,14 @@ pub(crate) struct StatsSnapshot {
|
||||
pub peer_aliases: Arc<HashMap<NodeAddr, String>>,
|
||||
/// Loaded peer-ACL status (`show_acl`). The ACL itself is an
|
||||
/// `arc_swap::ArcSwap<PeerAcl>` mutated only by the tick's `reload_peer_acl`;
|
||||
/// the human-readable status is a cheap projection of it (R5).
|
||||
/// the human-readable status is a cheap projection of it.
|
||||
pub acl_status: PeerAclStatus,
|
||||
/// Per-stats-history-peer metadata resolved against the live peer/session
|
||||
/// tables and host map at publish time (`show_stats_peers` /
|
||||
/// `show_stats_history_all_peers`), keyed by `NodeAddr`. The lifecycle
|
||||
/// timestamps and per-peer metric rings stay in `history`; this map carries
|
||||
/// only the cross-subsystem fields a renderer can't derive from the rings
|
||||
/// alone (`is_active`, resolved `npub`, resolved `display_name`) (R5).
|
||||
/// alone (`is_active`, resolved `npub`, resolved `display_name`).
|
||||
pub peer_meta: Arc<HashMap<NodeAddr, StatsPeerMeta>>,
|
||||
}
|
||||
|
||||
@@ -138,34 +137,34 @@ fn empty_acl_status() -> PeerAclStatus {
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// RoutingSnapshot (R3 — Category-D derived/routing/cache read view)
|
||||
// RoutingSnapshot (derived/routing/cache read view)
|
||||
// =====================================================================
|
||||
|
||||
/// Read-only snapshot of the Category-D derived/routing/cache subsystems that
|
||||
/// Read-only snapshot of the derived/routing/cache subsystems that
|
||||
/// the pure-snapshot `show_tree` / `show_bloom` / `show_cache` / `show_routing`
|
||||
/// / `show_identity_cache` queries render. Published via `ArcSwap`.
|
||||
///
|
||||
/// The R0 stub (`design/fast-path-refactoring-r0-read-handle.md`) names a
|
||||
/// single combined `ArcSwap<RoutingSnapshot>` for R3. This is that cell: one
|
||||
/// This is the single combined `ArcSwap<RoutingSnapshot>` cell for the routing
|
||||
/// subsystems: one
|
||||
/// cohesive routing view holding the four subsystems (tree / bloom / coord
|
||||
/// cache / identity cache) plus the F-queue summary scalars.
|
||||
///
|
||||
/// **Publisher placement (Q1).** The four subsystems mutate at many scattered
|
||||
/// **Publisher placement.** The four subsystems mutate at many scattered
|
||||
/// handler sites (28 `coord_cache_mut` call sites, 16 `tree_state_mut`, ~32
|
||||
/// identity-cache touches), and every projected row needs a *display name*
|
||||
/// resolved against the live peer/session tables and host map — Category-E
|
||||
/// resolved against the live peer/session tables and host map — per-entity
|
||||
/// state reachable only with `&Node`. Wiring an on-change `publish_*` at each
|
||||
/// mutation site would be large, error-prone surgery, and each call would still
|
||||
/// need `&Node` to resolve names across subsystem boundaries. So this snapshot
|
||||
/// is published from the **tick** (Q1-b acceptable-at-mutator / the documented
|
||||
/// interim the spec permits, mirroring R2's stats publish): the tick is the one
|
||||
/// site with coherent `&Node` access to resolve all display names together. A
|
||||
/// is published from the **tick**, the same placement the stats snapshot
|
||||
/// above uses: the tick is the one site with coherent `&Node` access to
|
||||
/// resolve all display names together. A
|
||||
/// single combined cell is the natural shape because there is exactly one
|
||||
/// publisher — the multi-mutator "rebuild the whole snapshot N times" hazard
|
||||
/// that Q1-c warns against does not arise.
|
||||
/// does not arise.
|
||||
///
|
||||
/// The snapshot holds *data* (typed rows + scalars), not rendered `Response`
|
||||
/// envelopes (Q1-d); rendering happens off the rx_loop in the control task. The
|
||||
/// envelopes; rendering happens off the rx_loop in the control task. The
|
||||
/// counter-family `stats` blocks the queries also emit come from the
|
||||
/// `MetricsRegistry` (already `Arc`-shared in the handle) at render time, not
|
||||
/// from this snapshot.
|
||||
@@ -174,9 +173,9 @@ fn empty_acl_status() -> PeerAclStatus {
|
||||
/// the captured absolute timestamps, so the rendered age stays fresh relative
|
||||
/// to the read, exactly as the on-loop queries computed it.
|
||||
///
|
||||
/// Forward-compat: when step 5 structurally extracts the Category-D subsystems
|
||||
/// into typed types, these projections become thin views over them without
|
||||
/// changing the read-handle interface or this publisher placement.
|
||||
/// Forward-compat: if the derived/routing/cache subsystems are later
|
||||
/// extracted into typed types, these projections become thin views over them
|
||||
/// without changing the read-handle interface or this publisher placement.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RoutingSnapshot {
|
||||
/// Spanning-tree read view (`show_tree`).
|
||||
@@ -397,20 +396,18 @@ pub(crate) struct IdentityRow {
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// EntitySnapshot (R4 — Category-E per-entity table read views)
|
||||
// EntitySnapshot (per-entity table read views)
|
||||
// =====================================================================
|
||||
|
||||
/// Read-only snapshot of the Category-E per-entity tables that the
|
||||
/// Read-only snapshot of the per-entity tables that the
|
||||
/// pure-snapshot `show_peers` / `show_sessions` / `show_links` /
|
||||
/// `show_connections` / `show_transports` / `show_mmp` queries render.
|
||||
/// Published via `ArcSwap`.
|
||||
///
|
||||
/// The R0 stub (`design/fast-path-refactoring-r0-read-handle.md`) pre-scopes
|
||||
/// R4 as `entities — ArcSwap<EntitySnapshot>: peers / sessions / links /
|
||||
/// connections / transports, published per-entity with `Vec<Arc<Row>>`
|
||||
/// structural sharing`. This is that cell.
|
||||
/// This is the `entities` cell: peers / sessions / links / connections /
|
||||
/// transports, published per-entity with `Vec<Arc<Row>>` structural sharing.
|
||||
///
|
||||
/// **Structural sharing (the umbrella mandate).** Every entity table is a
|
||||
/// **Structural sharing.** Every entity table is a
|
||||
/// `Vec<Arc<Row>>`, so a republish in which only one row changed re-allocates
|
||||
/// only that one `Arc<Row>` — the unchanged rows are reused by pointer from the
|
||||
/// previous snapshot (`Arc::ptr_eq`-stable). The publisher diffs each freshly
|
||||
@@ -418,10 +415,11 @@ pub(crate) struct IdentityRow {
|
||||
/// keeps the old `Arc` when they are equal. A clone of the snapshot for each
|
||||
/// accepted control connection is then a vector of cheap pointer clones, not a
|
||||
/// deep table copy. This is what keeps the per-tick publish cost off the hot
|
||||
/// path at scale, as the umbrella requires for R4.
|
||||
/// path at scale.
|
||||
///
|
||||
/// **Publisher placement (Q1).** Like R3, this is published from the **tick**,
|
||||
/// not per-mutator. Two reasons, both stronger than for R3:
|
||||
/// **Publisher placement.** Like the routing snapshot, this is published from
|
||||
/// the **tick**, not per-mutator. Two reasons, both stronger here than for the
|
||||
/// routing snapshot:
|
||||
///
|
||||
/// 1. Every projected row needs a *display name* resolved against the live
|
||||
/// peer/session tables and host map (`&Node`), and `show_peers` additionally
|
||||
@@ -432,23 +430,22 @@ pub(crate) struct IdentityRow {
|
||||
/// metrics, `last_seen`, noise counters, replay/decrypt counters) are
|
||||
/// mutated continuously on the **data plane / rx_loop**, not at the discrete
|
||||
/// peer/session/link lifecycle mutators. Per-lifecycle-mutator publication
|
||||
/// (Q1-a) would therefore not even capture freshness for those fields; the
|
||||
/// would therefore not even capture freshness for those fields; the
|
||||
/// tick is the natural cadence at which this read view advances.
|
||||
///
|
||||
/// The diff-and-reuse therefore satisfies the structural-sharing goal the
|
||||
/// umbrella mandates (only changed rows re-allocate) while keeping a single
|
||||
/// coherent `&Node` publisher — the "no monolithic per-tick *re-allocation* of
|
||||
/// every row" warning is honored because unchanged rows are reused, not rebuilt.
|
||||
/// This is the documented acceptable interim (the spec's tick-publish-with-
|
||||
/// Arc-reuse fallback), consistent with R3.
|
||||
/// The diff-and-reuse therefore satisfies the structural-sharing goal (only
|
||||
/// changed rows re-allocate) while keeping a single coherent `&Node`
|
||||
/// publisher: there is no monolithic per-tick *re-allocation* of every row,
|
||||
/// because unchanged rows are reused rather than rebuilt. This is the same
|
||||
/// tick publish placement the routing snapshot uses, for the same reason.
|
||||
///
|
||||
/// The snapshot holds typed rows (Q1-d data, not rendered `Response`
|
||||
/// The snapshot holds typed rows (data, not rendered `Response`
|
||||
/// envelopes). Time-relative fields (`idle_ms`) are derived at render time from
|
||||
/// captured absolute timestamps, so the rendered age stays fresh relative to
|
||||
/// the read, exactly as the on-loop queries computed it.
|
||||
///
|
||||
/// Forward-compat: step 10 later extracts the session table into a typed
|
||||
/// `(transport_id, our_index)`-indexed type; these projections then become thin
|
||||
/// Forward-compat: if the session table is later extracted into a typed
|
||||
/// `(transport_id, our_index)`-indexed type, these projections become thin
|
||||
/// views over it without changing the read-handle interface or this publisher
|
||||
/// placement.
|
||||
#[derive(Clone)]
|
||||
@@ -735,7 +732,7 @@ pub(crate) struct MmpSessionRow {
|
||||
/// one, preserving structural sharing: an `Arc<Row>` from `prev` is reused
|
||||
/// (kept by pointer) whenever a new row matches an old row by identity `key`
|
||||
/// **and** compares equal by value, so only changed/new rows allocate a fresh
|
||||
/// `Arc`. This is the `Vec<Arc<Row>>` discipline the R4 umbrella mandates — a
|
||||
/// `Arc`. This is the `Vec<Arc<Row>>` structural-sharing discipline — a
|
||||
/// single-row change re-allocates one row, not the whole table, keeping the
|
||||
/// per-tick publish cost off the hot path at scale.
|
||||
///
|
||||
|
||||
+2
-2
@@ -66,7 +66,7 @@ pub struct NatManager {
|
||||
lan_interface: String,
|
||||
/// Active mappings keyed by virtual IP.
|
||||
mappings: HashMap<Ipv6Addr, NatMapping>,
|
||||
/// Inbound port-forward rules (TASK-2026-0061).
|
||||
/// Inbound port-forward rules.
|
||||
port_forwards: Vec<PortForward>,
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ impl NatManager {
|
||||
batch.add(&snat_rule, MsgType::Add);
|
||||
}
|
||||
|
||||
// Inbound port-forward rules (TASK-2026-0061). Each forward is
|
||||
// Inbound port-forward rules. Each forward is
|
||||
// one DNAT rule in prerouting keyed on (iif fips0, nfproto ipv6,
|
||||
// l4proto, th dport). When any forwards are configured, emit a
|
||||
// single LAN-side masquerade in postrouting so the LAN target
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use bech32::{Bech32, Hrp};
|
||||
use secp256k1::{SecretKey, XOnlyPublicKey};
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use super::IdentityError;
|
||||
|
||||
@@ -33,14 +34,25 @@ pub fn decode_npub(npub: &str) -> Result<XOnlyPublicKey, IdentityError> {
|
||||
}
|
||||
|
||||
/// Encode a secret key as a bech32 nsec string (NIP-19).
|
||||
///
|
||||
/// The returned string is the private key in another encoding, so it is the
|
||||
/// caller's to clear. What this function clears is the raw byte copy
|
||||
/// `secret_bytes` hands back, which would otherwise sit in an unnamed
|
||||
/// temporary until the end of the statement.
|
||||
pub fn encode_nsec(secret_key: &SecretKey) -> String {
|
||||
bech32::encode::<Bech32>(NSEC_HRP, &secret_key.secret_bytes())
|
||||
.expect("nsec encoding cannot fail")
|
||||
let mut secret_bytes = secret_key.secret_bytes();
|
||||
let nsec =
|
||||
bech32::encode::<Bech32>(NSEC_HRP, &secret_bytes).expect("nsec encoding cannot fail");
|
||||
secret_bytes.zeroize();
|
||||
nsec
|
||||
}
|
||||
|
||||
/// Decode an nsec string to a secret key.
|
||||
pub fn decode_nsec(nsec: &str) -> Result<SecretKey, IdentityError> {
|
||||
let (hrp, data) = bech32::decode(nsec)?;
|
||||
// `data` is the raw private key. The guard clears it on every exit path,
|
||||
// including the two length and prefix rejections below.
|
||||
let data = Zeroizing::new(data);
|
||||
|
||||
if hrp != NSEC_HRP {
|
||||
return Err(IdentityError::InvalidNsecPrefix(hrp.to_string()));
|
||||
@@ -59,7 +71,9 @@ pub fn decode_secret(s: &str) -> Result<SecretKey, IdentityError> {
|
||||
if s.starts_with("nsec1") {
|
||||
decode_nsec(s)
|
||||
} else {
|
||||
let bytes = hex::decode(s)?;
|
||||
// `bytes` is the raw private key; the guard clears it on both the
|
||||
// length rejection and the normal return.
|
||||
let bytes = Zeroizing::new(hex::decode(s)?);
|
||||
if bytes.len() != 32 {
|
||||
return Err(IdentityError::InvalidNsecLength(bytes.len()));
|
||||
}
|
||||
|
||||
+77
-12
@@ -2,6 +2,7 @@
|
||||
|
||||
use secp256k1::{Keypair, PublicKey, SecretKey, XOnlyPublicKey};
|
||||
use std::fmt;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use super::auth::{AuthResponse, auth_challenge_digest};
|
||||
use super::encoding::{decode_secret, encode_npub};
|
||||
@@ -11,6 +12,13 @@ use super::{FipsAddress, IdentityError, NodeAddr, sha256};
|
||||
///
|
||||
/// The identity holds the secp256k1 keypair and provides methods for signing
|
||||
/// and verifying protocol messages.
|
||||
///
|
||||
/// The keypair is the node's long-term private key. It is erased when the
|
||||
/// identity is dropped, and every constructor below erases the intermediate
|
||||
/// secret it built the identity from. All of that clears the copies this
|
||||
/// crate owns, not every copy that ever existed: `secp256k1` names its erase
|
||||
/// non-secure because the compiler may duplicate or move the bytes to places
|
||||
/// no code here can name.
|
||||
#[derive(Clone)]
|
||||
pub struct Identity {
|
||||
keypair: Keypair,
|
||||
@@ -23,39 +31,51 @@ impl Identity {
|
||||
pub fn generate() -> Self {
|
||||
let mut secret_bytes = [0u8; 32];
|
||||
rand::Rng::fill_bytes(&mut rand::rng(), &mut secret_bytes);
|
||||
let secret_key =
|
||||
let mut secret_key =
|
||||
SecretKey::from_slice(&secret_bytes).expect("32 random bytes is a valid secret key");
|
||||
Self::from_secret_key(secret_key)
|
||||
let identity = Self::from_secret_key(secret_key);
|
||||
secret_bytes.zeroize();
|
||||
secret_key.non_secure_erase();
|
||||
identity
|
||||
}
|
||||
|
||||
/// Create an identity from an existing keypair.
|
||||
pub fn from_keypair(keypair: Keypair) -> Self {
|
||||
pub fn from_keypair(mut keypair: Keypair) -> Self {
|
||||
let (pubkey, _parity) = keypair.x_only_public_key();
|
||||
let node_addr = NodeAddr::from_pubkey(&pubkey);
|
||||
let address = FipsAddress::from_node_addr(&node_addr);
|
||||
Self {
|
||||
let identity = Self {
|
||||
keypair,
|
||||
node_addr,
|
||||
address,
|
||||
}
|
||||
};
|
||||
keypair.non_secure_erase();
|
||||
identity
|
||||
}
|
||||
|
||||
/// Create an identity from a secret key.
|
||||
pub fn from_secret_key(secret_key: SecretKey) -> Self {
|
||||
let keypair = Keypair::from_secret_key(&super::SECP, &secret_key);
|
||||
Self::from_keypair(keypair)
|
||||
pub fn from_secret_key(mut secret_key: SecretKey) -> Self {
|
||||
let mut keypair = Keypair::from_secret_key(&super::SECP, &secret_key);
|
||||
let identity = Self::from_keypair(keypair);
|
||||
keypair.non_secure_erase();
|
||||
secret_key.non_secure_erase();
|
||||
identity
|
||||
}
|
||||
|
||||
/// Create an identity from secret key bytes.
|
||||
pub fn from_secret_bytes(bytes: &[u8; 32]) -> Result<Self, IdentityError> {
|
||||
let secret_key = SecretKey::from_slice(bytes)?;
|
||||
Ok(Self::from_secret_key(secret_key))
|
||||
let mut secret_key = SecretKey::from_slice(bytes)?;
|
||||
let identity = Self::from_secret_key(secret_key);
|
||||
secret_key.non_secure_erase();
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
/// Create an identity from an nsec string (bech32) or hex-encoded secret.
|
||||
pub fn from_secret_str(s: &str) -> Result<Self, IdentityError> {
|
||||
let secret_key = decode_secret(s)?;
|
||||
Ok(Self::from_secret_key(secret_key))
|
||||
let mut secret_key = decode_secret(s)?;
|
||||
let identity = Self::from_secret_key(secret_key);
|
||||
secret_key.non_secure_erase();
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
/// Return the underlying keypair.
|
||||
@@ -110,6 +130,51 @@ impl Identity {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Identity {
|
||||
/// Erase the long-term private key this identity owns.
|
||||
///
|
||||
/// `Keypair` is `Copy` and so cannot clear itself on drop; `Identity` is
|
||||
/// not, so it does it for the copy it holds. See the type's own
|
||||
/// documentation for what that does and does not reach.
|
||||
fn drop(&mut self) {
|
||||
self.keypair.non_secure_erase();
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Keypair` copy that is erased when it goes out of scope.
|
||||
///
|
||||
/// `Keypair` is `Copy` and so cannot clear itself on drop. A frame that holds
|
||||
/// a copy of the node's long-term private key across several exit paths —
|
||||
/// early error returns, `?`, a normal return — would otherwise need an erase
|
||||
/// written at each one, and a missed path is invisible. Holding the copy here
|
||||
/// instead makes the clearing structural.
|
||||
///
|
||||
/// This clears the copy this guard owns, not every copy that ever existed:
|
||||
/// `secp256k1` names its erase non-secure because the compiler may duplicate
|
||||
/// or move the bytes to places no code here can name.
|
||||
pub(crate) struct ErasingKeypair(Keypair);
|
||||
|
||||
impl ErasingKeypair {
|
||||
/// Take a copy of `source` into the guard and erase `source` in place, so
|
||||
/// the caller's own binding does not outlive the move.
|
||||
pub(crate) fn take(source: &mut Keypair) -> Self {
|
||||
let guarded = Self(*source);
|
||||
source.non_secure_erase();
|
||||
guarded
|
||||
}
|
||||
|
||||
/// Borrow the guarded keypair.
|
||||
pub(crate) fn get(&self) -> &Keypair {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ErasingKeypair {
|
||||
fn drop(&mut self) {
|
||||
self.0.non_secure_erase();
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Identity {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Identity")
|
||||
|
||||
@@ -20,6 +20,7 @@ use thiserror::Error;
|
||||
pub use address::FipsAddress;
|
||||
pub use auth::{AuthChallenge, AuthResponse};
|
||||
pub use encoding::{decode_npub, decode_nsec, decode_secret, encode_npub, encode_nsec};
|
||||
pub(crate) use local::ErasingKeypair;
|
||||
pub use local::Identity;
|
||||
pub use node_addr::NodeAddr;
|
||||
pub use peer::PeerIdentity;
|
||||
|
||||
@@ -93,8 +93,10 @@ use tracing::{debug, trace, warn};
|
||||
/// inside the worker. That second alloc + ~1.5 KB memcpy per packet at
|
||||
/// line rate cost ~150 MB/sec of memory bandwidth on the hot worker.)
|
||||
pub(crate) struct FmpSendJob {
|
||||
/// Cloned FMP send cipher. `LessSafeKey` is `Clone` (`ring::aead`)
|
||||
/// — the clone is just a refcount bump on the inner key material.
|
||||
/// Cloned FMP send cipher. `LessSafeKey` is `Clone` (`ring::aead`), but
|
||||
/// the clone is not a refcount bump: `ring` stores the ChaCha20 key
|
||||
/// inline as `[u32; 8]`, so cloning copies the key material outright and
|
||||
/// leaves a second copy that nothing outside `ring` can clear.
|
||||
pub cipher: LessSafeKey,
|
||||
/// Pre-reserved monotonic counter (via `take_send_counter`).
|
||||
pub counter: u64,
|
||||
|
||||
@@ -350,15 +350,19 @@ impl Node {
|
||||
// Create FMP negotiation payload for msg2 (includes profile, MMP bits, bloom TLV)
|
||||
let neg_payload = NegotiationPayload::fmp(1, 1, self.node_profile()).encode();
|
||||
|
||||
let our_keypair = self.identity().keypair();
|
||||
// This frame's own copy of the node's long-term private key; the
|
||||
// handshake state keeps its own and clears that on drop.
|
||||
let mut our_keypair = self.identity().keypair();
|
||||
let noise_msg1 = &packet.data[header.noise_msg1_offset..];
|
||||
let msg2_response = match machine.receive_handshake_init(
|
||||
let init_result = machine.receive_handshake_init(
|
||||
our_keypair,
|
||||
self.startup_epoch(),
|
||||
noise_msg1,
|
||||
Some(&neg_payload),
|
||||
packet.timestamp_ms,
|
||||
) {
|
||||
);
|
||||
our_keypair.non_secure_erase();
|
||||
let msg2_response = match init_result {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
|
||||
@@ -308,8 +308,11 @@ impl Node {
|
||||
};
|
||||
|
||||
// Create XX initiator handshake directly (no handshake leg)
|
||||
let our_keypair = self.identity().keypair();
|
||||
// This frame's own copy of the node's long-term private key; the
|
||||
// handshake state keeps its own and clears that on drop.
|
||||
let mut our_keypair = self.identity().keypair();
|
||||
let mut hs = HandshakeState::new_initiator(our_keypair);
|
||||
our_keypair.non_secure_erase();
|
||||
hs.set_local_epoch(self.startup_epoch());
|
||||
|
||||
let noise_msg1 = match hs.write_message_1() {
|
||||
@@ -810,8 +813,11 @@ impl Node {
|
||||
let _dest_pubkey = *entry.remote_pubkey();
|
||||
|
||||
// Create Noise XX initiator handshake (rekey: no negotiation payload)
|
||||
let our_keypair = self.identity().keypair();
|
||||
// This frame's own copy of the node's long-term private key; the
|
||||
// handshake state keeps its own and clears that on drop.
|
||||
let mut our_keypair = self.identity().keypair();
|
||||
let mut handshake = HandshakeState::new_initiator(our_keypair);
|
||||
our_keypair.non_secure_erase();
|
||||
handshake.set_local_epoch(self.startup_epoch());
|
||||
|
||||
let msg1 = match handshake.write_message_1() {
|
||||
|
||||
@@ -651,8 +651,11 @@ impl Node {
|
||||
.record_reject(RejectReason::Session(SessionReject::RekeyPending));
|
||||
return;
|
||||
}
|
||||
let our_keypair = self.identity().keypair();
|
||||
// This frame's own copy of the node's long-term private key;
|
||||
// the handshake state keeps its own and clears that on drop.
|
||||
let mut our_keypair = self.identity().keypair();
|
||||
let mut handshake = HandshakeState::new_responder(our_keypair);
|
||||
our_keypair.non_secure_erase();
|
||||
handshake.set_local_epoch(self.startup_epoch());
|
||||
|
||||
if let Err(e) = handshake.read_message_1(&setup.handshake_payload) {
|
||||
@@ -707,8 +710,11 @@ impl Node {
|
||||
}
|
||||
|
||||
// Create XX responder handshake and process msg1
|
||||
let our_keypair = self.identity().keypair();
|
||||
// This frame's own copy of the node's long-term private key; the
|
||||
// handshake state keeps its own and clears that on drop.
|
||||
let mut our_keypair = self.identity().keypair();
|
||||
let mut handshake = HandshakeState::new_responder(our_keypair);
|
||||
our_keypair.non_secure_erase();
|
||||
handshake.set_local_epoch(self.startup_epoch());
|
||||
|
||||
if let Err(e) = handshake.read_message_1(&setup.handshake_payload) {
|
||||
@@ -756,7 +762,11 @@ impl Node {
|
||||
// Store session entry in AwaitingMsg3 state with ack payload for potential resend.
|
||||
// Use a dummy pubkey since we don't know the initiator's identity yet.
|
||||
// We use our own pubkey as placeholder; it will be replaced in handle_session_msg3.
|
||||
let placeholder_pubkey = self.identity().keypair().public_key();
|
||||
// `keypair()` hands back a copy of the long-term private key, so the
|
||||
// temporary is bound and erased rather than left to the statement end.
|
||||
let mut our_keypair = self.identity().keypair();
|
||||
let placeholder_pubkey = our_keypair.public_key();
|
||||
our_keypair.non_secure_erase();
|
||||
let now_ms = Self::now_ms();
|
||||
let resend_interval = self.config().node.rate_limit.handshake_resend_interval_ms;
|
||||
let mut entry = SessionEntry::new(
|
||||
@@ -2188,8 +2198,11 @@ impl Node {
|
||||
}
|
||||
|
||||
// Create Noise XX initiator handshake
|
||||
let our_keypair = self.identity().keypair();
|
||||
// This frame's own copy of the node's long-term private key; the
|
||||
// handshake state keeps its own and clears that on drop.
|
||||
let mut our_keypair = self.identity().keypair();
|
||||
let mut handshake = HandshakeState::new_initiator(our_keypair);
|
||||
our_keypair.non_secure_erase();
|
||||
handshake.set_local_epoch(self.startup_epoch());
|
||||
let msg1 = handshake
|
||||
.write_message_1()
|
||||
|
||||
@@ -638,14 +638,17 @@ impl Node {
|
||||
};
|
||||
|
||||
// Start the Noise handshake and get message 1
|
||||
let our_keypair = self.identity().keypair();
|
||||
// This frame's own copy of the node's long-term private key; the
|
||||
// handshake state keeps its own and clears that on drop.
|
||||
let mut our_keypair = self.identity().keypair();
|
||||
let startup_epoch = self.startup_epoch();
|
||||
let noise_msg1 = match self
|
||||
let start_result = self
|
||||
.peer_machines
|
||||
.get_mut(&link_id)
|
||||
.expect("dial-time machine carries the connection")
|
||||
.start_handshake(our_keypair, startup_epoch, current_time_ms)
|
||||
{
|
||||
.start_handshake(our_keypair, startup_epoch, current_time_ms);
|
||||
our_keypair.non_secure_erase();
|
||||
let noise_msg1 = match start_result {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => {
|
||||
// Clean up the index, link, and dial-time machine
|
||||
|
||||
+10
-10
@@ -459,19 +459,19 @@ pub struct Node {
|
||||
/// live mutable `stats_history` above stays on the tick.
|
||||
stats_snapshot: std::sync::Arc<arc_swap::ArcSwap<crate::control::snapshot::StatsSnapshot>>,
|
||||
|
||||
/// Read-side snapshot of the Category-D derived/routing/cache subsystems
|
||||
/// Read-side snapshot of the derived/routing/cache subsystems
|
||||
/// (tree / bloom / coord cache / identity cache + F-queue scalars) that the
|
||||
/// `show_tree` / `show_bloom` / `show_cache` / `show_routing` /
|
||||
/// `show_identity_cache` queries render off the rx_loop. Published from the
|
||||
/// tick (see [`Self::publish_routing_snapshot`] for the Q1 rationale).
|
||||
/// tick (see [`Self::publish_routing_snapshot`] for the rationale).
|
||||
routing_snapshot: std::sync::Arc<arc_swap::ArcSwap<crate::control::snapshot::RoutingSnapshot>>,
|
||||
|
||||
/// Read-side snapshot of the Category-E per-entity tables (peers / sessions
|
||||
/// Read-side snapshot of the per-entity tables (peers / sessions
|
||||
/// / links / connections / transports + mmp) that the `show_peers` /
|
||||
/// `show_sessions` / `show_links` / `show_connections` / `show_transports`
|
||||
/// / `show_mmp` queries render off the rx_loop. Published from the tick with
|
||||
/// `Vec<Arc<Row>>` structural sharing (unchanged rows reused by pointer);
|
||||
/// see [`Self::publish_entities_snapshot`] for the Q1 rationale.
|
||||
/// see [`Self::publish_entities_snapshot`] for the rationale.
|
||||
entities_snapshot: std::sync::Arc<arc_swap::ArcSwap<crate::control::snapshot::EntitySnapshot>>,
|
||||
|
||||
// === TUN Interface ===
|
||||
@@ -1650,11 +1650,11 @@ impl Node {
|
||||
};
|
||||
self.stats_snapshot.store(std::sync::Arc::new(snapshot));
|
||||
|
||||
// Publish the Category-D routing read view alongside the stats
|
||||
// Publish the routing read view alongside the stats
|
||||
// snapshot, from the same tick.
|
||||
self.publish_routing_snapshot();
|
||||
|
||||
// Publish the Category-E per-entity read view from the same tick, with
|
||||
// Publish the per-entity read view from the same tick, with
|
||||
// `Vec<Arc<Row>>` structural sharing against the previous snapshot.
|
||||
self.publish_entities_snapshot();
|
||||
}
|
||||
@@ -1681,7 +1681,7 @@ impl Node {
|
||||
None
|
||||
}
|
||||
|
||||
/// Project the Category-D derived/routing/cache state into a
|
||||
/// Project the derived/routing/cache state into a
|
||||
/// [`RoutingSnapshot`](crate::control::snapshot::RoutingSnapshot) and
|
||||
/// publish it via `ArcSwap`, so `show_tree` / `show_bloom` / `show_cache`
|
||||
/// / `show_routing` / `show_identity_cache` render off the rx_loop.
|
||||
@@ -1882,7 +1882,7 @@ impl Node {
|
||||
self.routing_snapshot.store(std::sync::Arc::new(snapshot));
|
||||
}
|
||||
|
||||
/// Project the Category-E per-entity tables (peers / sessions / links /
|
||||
/// Project the per-entity tables (peers / sessions / links /
|
||||
/// connections / transports + mmp) into an
|
||||
/// [`EntitySnapshot`](crate::control::snapshot::EntitySnapshot) and publish
|
||||
/// it via `ArcSwap`, so `show_peers` / `show_sessions` / `show_links` /
|
||||
@@ -1908,8 +1908,8 @@ impl Node {
|
||||
/// `Arc` is reused (kept by pointer) whenever it matches the prior row by
|
||||
/// identity and compares equal by value, so a tick in which only one
|
||||
/// peer/session changed re-allocates only that one row, not the whole table.
|
||||
/// This is what keeps the publish cost off the hot path at scale (the exact
|
||||
/// thing the umbrella warns a naive per-tick rebuild would violate).
|
||||
/// This is what keeps the publish cost off the hot path at scale, which a
|
||||
/// naive whole-table rebuild on every tick would not.
|
||||
fn publish_entities_snapshot(&self) {
|
||||
use crate::control::snapshot as snap;
|
||||
|
||||
|
||||
@@ -1685,7 +1685,7 @@ async fn test_initiate_peer_connections_schedules_retry_on_no_transport() {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// transport_mtu() — ISSUE-2026-0011 regression coverage
|
||||
// transport_mtu() — minimum-across-transports regression coverage
|
||||
// ============================================================================
|
||||
|
||||
/// Helper: spawn a UdpTransport with the given mtu, started and operational.
|
||||
@@ -1710,7 +1710,7 @@ async fn make_udp_transport_with_mtu(id: u32, mtu: u16) -> TransportHandle {
|
||||
async fn test_transport_mtu_returns_min_across_operational() {
|
||||
// Multiple operational transports with varied MTUs. The picker must
|
||||
// return the smallest, deterministically, regardless of HashMap
|
||||
// iteration order. This is the core ISSUE-2026-0011 regression test.
|
||||
// iteration order. This is the core regression test for that.
|
||||
let mut node = make_node();
|
||||
let (packet_tx, packet_rx) = packet_channel(64);
|
||||
node.supervisor.packet_tx = Some(packet_tx);
|
||||
|
||||
+108
-20
@@ -8,6 +8,7 @@ use rand::Rng;
|
||||
use secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey, ecdh::shared_secret_point};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fmt;
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||
|
||||
/// Symmetric state during handshake.
|
||||
///
|
||||
@@ -16,13 +17,24 @@ use std::fmt;
|
||||
/// `Clone` exists for [`HandshakeState::try_read_message_2`], which has to put
|
||||
/// the pre-read state back after a message that mixed material in before
|
||||
/// failing to authenticate.
|
||||
#[derive(Clone)]
|
||||
///
|
||||
/// `ck` and `h` are cleared on drop, including on the clone above once it
|
||||
/// goes out of scope. `cipher` is skipped because [`CipherState`] clears its
|
||||
/// own retained key.
|
||||
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
|
||||
struct SymmetricState {
|
||||
/// Chaining key for key derivation.
|
||||
ck: [u8; 32],
|
||||
/// Handshake hash for transcript binding.
|
||||
/// Running SHA-256 accumulator over the handshake transcript.
|
||||
///
|
||||
/// Maintained by `mix_hash` at every step, but never fed to the AEAD:
|
||||
/// `encrypt_and_hash` passes an empty associated-data field. Nothing in
|
||||
/// production reads it, so it provides no transcript binding today, and
|
||||
/// anything built on `handshake_hash()` (channel binding, an exporter,
|
||||
/// cookie binding) will silently not work until the AAD carries `h`.
|
||||
h: [u8; 32],
|
||||
/// Current cipher state for encrypting handshake payloads.
|
||||
#[zeroize(skip)]
|
||||
cipher: CipherState,
|
||||
}
|
||||
|
||||
@@ -69,6 +81,9 @@ impl SymmetricState {
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&output[32..64]);
|
||||
self.cipher.initialize_key(key);
|
||||
key.zeroize();
|
||||
|
||||
output.zeroize();
|
||||
}
|
||||
|
||||
/// Encrypt and mix into hash.
|
||||
@@ -97,10 +112,16 @@ impl SymmetricState {
|
||||
k1.copy_from_slice(&output[..32]);
|
||||
k2.copy_from_slice(&output[32..64]);
|
||||
|
||||
(CipherState::new(k1), CipherState::new(k2))
|
||||
let ciphers = (CipherState::new(k1), CipherState::new(k2));
|
||||
|
||||
output.zeroize();
|
||||
k1.zeroize();
|
||||
k2.zeroize();
|
||||
|
||||
ciphers
|
||||
}
|
||||
|
||||
/// Get the handshake hash (for channel binding).
|
||||
/// Get the handshake hash.
|
||||
fn handshake_hash(&self) -> [u8; 32] {
|
||||
self.h
|
||||
}
|
||||
@@ -152,9 +173,9 @@ impl HandshakeState {
|
||||
/// Create a new XX handshake as initiator.
|
||||
///
|
||||
/// XX: neither side knows the other's static key. No pre-message.
|
||||
pub fn new_initiator(static_keypair: Keypair) -> Self {
|
||||
pub fn new_initiator(mut static_keypair: Keypair) -> Self {
|
||||
let secp = Secp256k1::new();
|
||||
Self {
|
||||
let state = Self {
|
||||
pattern: NoisePattern::Xx,
|
||||
role: HandshakeRole::Initiator,
|
||||
progress: HandshakeProgress::Initial,
|
||||
@@ -166,16 +187,22 @@ impl HandshakeState {
|
||||
secp,
|
||||
local_epoch: None,
|
||||
remote_epoch: None,
|
||||
}
|
||||
};
|
||||
// No pre-message: neither side's static is mixed into hash.
|
||||
|
||||
// The struct now holds its own copy of the long-term private key and
|
||||
// clears it on drop, so this frame's parameter copy is erased here.
|
||||
static_keypair.non_secure_erase();
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
/// Create a new XX handshake as responder.
|
||||
///
|
||||
/// XX: neither side knows the other's static key. No pre-message.
|
||||
pub fn new_responder(static_keypair: Keypair) -> Self {
|
||||
pub fn new_responder(mut static_keypair: Keypair) -> Self {
|
||||
let secp = Secp256k1::new();
|
||||
Self {
|
||||
let state = Self {
|
||||
pattern: NoisePattern::Xx,
|
||||
role: HandshakeRole::Responder,
|
||||
progress: HandshakeProgress::Initial,
|
||||
@@ -187,8 +214,14 @@ impl HandshakeState {
|
||||
secp,
|
||||
local_epoch: None,
|
||||
remote_epoch: None,
|
||||
}
|
||||
};
|
||||
// No pre-message: neither side's static is mixed into hash.
|
||||
|
||||
// The struct now holds its own copy of the long-term private key and
|
||||
// clears it on drop, so this frame's parameter copy is erased here.
|
||||
static_keypair.non_secure_erase();
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
/// Get our role.
|
||||
@@ -227,9 +260,15 @@ impl HandshakeState {
|
||||
let mut secret_bytes = [0u8; 32];
|
||||
rng.fill_bytes(&mut secret_bytes);
|
||||
|
||||
let secret_key =
|
||||
let mut secret_key =
|
||||
SecretKey::from_slice(&secret_bytes).expect("32 random bytes is valid secret key");
|
||||
self.ephemeral_keypair = Some(Keypair::from_secret_key(&self.secp, &secret_key));
|
||||
secret_bytes.zeroize();
|
||||
// The erase is called non-secure because the private key may sit in
|
||||
// further copies this function cannot name. It still clears the copy
|
||||
// this frame owns; the keypair the key was just stored in is cleared
|
||||
// by `Drop for HandshakeState`.
|
||||
secret_key.non_secure_erase();
|
||||
}
|
||||
|
||||
/// Perform ECDH between our secret and their public key.
|
||||
@@ -240,15 +279,26 @@ impl HandshakeState {
|
||||
/// may have the wrong parity for the responder's static key. Since P and
|
||||
/// -P produce ECDH result points with the same x-coordinate, hashing
|
||||
/// only x ensures both sides derive the same shared secret.
|
||||
///
|
||||
/// `our_secret` is borrowed, so this frame makes no copy of it. Every
|
||||
/// caller below binds the value `Keypair::secret_key` hands back and
|
||||
/// erases that binding once the DH is done, because the returned
|
||||
/// `SecretKey` is a whole private key rather than a handle to one. Those
|
||||
/// erases clear the copies this crate owns; `secp256k1` names its erase
|
||||
/// non-secure because the compiler may hold further copies that no code
|
||||
/// here can name.
|
||||
fn ecdh(&self, our_secret: &SecretKey, their_public: &PublicKey) -> [u8; 32] {
|
||||
// Get raw (x, y) coordinates (64 bytes) without any hashing
|
||||
let point = shared_secret_point(their_public, our_secret);
|
||||
let mut point = shared_secret_point(their_public, our_secret);
|
||||
// Hash only the x-coordinate (first 32 bytes), ignoring y/parity
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&point[..32]);
|
||||
let hash = hasher.finalize();
|
||||
let mut hash = hasher.finalize();
|
||||
let mut result = [0u8; 32];
|
||||
result.copy_from_slice(&hash);
|
||||
hash.as_mut_slice().zeroize();
|
||||
point.zeroize();
|
||||
// `result` is moved out, so clearing it belongs to the caller.
|
||||
result
|
||||
}
|
||||
|
||||
@@ -368,8 +418,11 @@ impl HandshakeState {
|
||||
self.symmetric.mix_hash(&e_pub);
|
||||
|
||||
// <- ee: DH(e, re), mix into key
|
||||
let ee = self.ecdh(&ephemeral.secret_key(), &re);
|
||||
let mut sk = ephemeral.secret_key();
|
||||
let mut ee = self.ecdh(&sk, &re);
|
||||
self.symmetric.mix_key(&ee);
|
||||
ee.zeroize();
|
||||
sk.non_secure_erase();
|
||||
|
||||
// <- s: encrypt our static and send
|
||||
let our_static = self.static_keypair.public_key().serialize();
|
||||
@@ -377,8 +430,11 @@ impl HandshakeState {
|
||||
message.extend_from_slice(&encrypted_static);
|
||||
|
||||
// <- es: DH(s, re), mix into key
|
||||
let es = self.ecdh(&self.static_keypair.secret_key(), &re);
|
||||
let mut sk = self.static_keypair.secret_key();
|
||||
let mut es = self.ecdh(&sk, &re);
|
||||
self.symmetric.mix_key(&es);
|
||||
es.zeroize();
|
||||
sk.non_secure_erase();
|
||||
|
||||
// <- epoch: encrypt startup epoch for restart detection
|
||||
let encrypted_epoch = self.symmetric.encrypt_and_hash(&epoch)?;
|
||||
@@ -422,8 +478,11 @@ impl HandshakeState {
|
||||
|
||||
// <- ee: DH(e, re), mix into key
|
||||
let ephemeral = self.ephemeral_keypair.as_ref().unwrap();
|
||||
let ee = self.ecdh(&ephemeral.secret_key(), &re);
|
||||
let mut sk = ephemeral.secret_key();
|
||||
let mut ee = self.ecdh(&sk, &re);
|
||||
self.symmetric.mix_key(&ee);
|
||||
ee.zeroize();
|
||||
sk.non_secure_erase();
|
||||
|
||||
// <- s: decrypt responder's static
|
||||
let encrypted_static_end = PUBKEY_SIZE + PUBKEY_SIZE + super::TAG_SIZE;
|
||||
@@ -434,8 +493,11 @@ impl HandshakeState {
|
||||
self.remote_static = Some(rs);
|
||||
|
||||
// <- es: DH(e, rs), mix into key
|
||||
let es = self.ecdh(&ephemeral.secret_key(), &rs);
|
||||
let mut sk = ephemeral.secret_key();
|
||||
let mut es = self.ecdh(&sk, &rs);
|
||||
self.symmetric.mix_key(&es);
|
||||
es.zeroize();
|
||||
sk.non_secure_erase();
|
||||
|
||||
// <- epoch: decrypt responder's startup epoch
|
||||
let encrypted_epoch = &message[encrypted_static_end..];
|
||||
@@ -551,8 +613,11 @@ impl HandshakeState {
|
||||
message.extend_from_slice(&encrypted_static);
|
||||
|
||||
// -> se: DH(s, re), mix into key
|
||||
let se = self.ecdh(&self.static_keypair.secret_key(), &re);
|
||||
let mut sk = self.static_keypair.secret_key();
|
||||
let mut se = self.ecdh(&sk, &re);
|
||||
self.symmetric.mix_key(&se);
|
||||
se.zeroize();
|
||||
sk.non_secure_erase();
|
||||
|
||||
// -> epoch: encrypt startup epoch for restart detection
|
||||
let encrypted_epoch = self.symmetric.encrypt_and_hash(&epoch)?;
|
||||
@@ -602,8 +667,11 @@ impl HandshakeState {
|
||||
.ephemeral_keypair
|
||||
.as_ref()
|
||||
.expect("should have ephemeral after msg2");
|
||||
let se = self.ecdh(&ephemeral.secret_key(), &rs);
|
||||
let mut sk = ephemeral.secret_key();
|
||||
let mut se = self.ecdh(&sk, &rs);
|
||||
self.symmetric.mix_key(&se);
|
||||
se.zeroize();
|
||||
sk.non_secure_erase();
|
||||
|
||||
// -> epoch: decrypt initiator's startup epoch
|
||||
let encrypted_epoch = &message[encrypted_static_end..];
|
||||
@@ -669,12 +737,32 @@ impl HandshakeState {
|
||||
))
|
||||
}
|
||||
|
||||
/// Get the handshake hash (for channel binding, available after complete).
|
||||
/// Get the handshake hash (available after complete).
|
||||
pub fn handshake_hash(&self) -> [u8; 32] {
|
||||
self.symmetric.handshake_hash()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HandshakeState {
|
||||
/// Erase the two private keys this state holds.
|
||||
///
|
||||
/// `static_keypair` is the node's long-term private key and
|
||||
/// `ephemeral_keypair` is the handshake's own. Both live here for the
|
||||
/// whole handshake, which is longer than in any other frame, so this is
|
||||
/// where clearing them matters most. `Keypair` is `Copy` and so cannot
|
||||
/// clear itself on drop; `HandshakeState` is not, so it does it for both.
|
||||
///
|
||||
/// This clears the copies this crate owns, not every copy that ever
|
||||
/// existed: `secp256k1` names its erase non-secure because the compiler
|
||||
/// may duplicate or move the bytes to places no code here can name.
|
||||
fn drop(&mut self) {
|
||||
self.static_keypair.non_secure_erase();
|
||||
if let Some(ephemeral) = self.ephemeral_keypair.as_mut() {
|
||||
ephemeral.non_secure_erase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for HandshakeState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("HandshakeState")
|
||||
|
||||
+40
-12
@@ -44,6 +44,7 @@ mod session;
|
||||
use ring::aead::{Aad, CHACHA20_POLY1305, LessSafeKey, Nonce, UnboundKey};
|
||||
use std::fmt;
|
||||
use thiserror::Error;
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||
|
||||
pub use handshake::{HandshakeState, Message2Rollback};
|
||||
pub use replay::ReplayWindow;
|
||||
@@ -171,21 +172,32 @@ impl fmt::Display for HandshakeProgress {
|
||||
/// AEAD is `ring`'s ChaCha20-Poly1305 (BoringSSL backend), which dispatches
|
||||
/// to NEON on aarch64 and AVX2/AVX-512 on x86_64. The 32-byte key is
|
||||
/// retained alongside a cached `LessSafeKey` so the per-packet AEAD skips
|
||||
/// the keyed-cipher construction (key copy + Poly1305 key derivation).
|
||||
/// `LessSafeKey` itself doesn't implement `Clone` (deliberate, for safety),
|
||||
/// so `CipherState`'s manual `Clone` impl rebuilds the keyed AEAD from the
|
||||
/// retained key bytes — cheap for ChaCha20-Poly1305 since the construction
|
||||
/// is essentially a key copy plus a constant-time check.
|
||||
/// rebuilding the keyed cipher. (It does not skip Poly1305 key derivation,
|
||||
/// which is nonce-dependent and happens per message inside seal and open.)
|
||||
/// `CipherState`'s manual `Clone` impl rebuilds the keyed AEAD from those
|
||||
/// retained bytes, and so does `cipher_clone` for each off-task AEAD worker;
|
||||
/// those two are why the bytes are kept. The rebuild is cheap for
|
||||
/// ChaCha20-Poly1305: a length check and a conversion of the 32 key bytes
|
||||
/// into little-endian words.
|
||||
///
|
||||
/// Because the key bytes are retained, every clone leaves a second copy in
|
||||
/// memory; each copy is cleared when it goes out of scope. Only `key` is
|
||||
/// zeroized. `cipher` is skipped because `LessSafeKey`
|
||||
/// holds its material behind `ring`'s own API and cannot be cleared from
|
||||
/// here; `nonce` and `has_key` are skipped because they are not secret.
|
||||
#[derive(Zeroize, ZeroizeOnDrop)]
|
||||
pub struct CipherState {
|
||||
/// Encryption key (32 bytes). Retained so we can rebuild the keyed
|
||||
/// AEAD on `Clone` and on `initialize_key` (ring's `UnboundKey` /
|
||||
/// `LessSafeKey` do not implement `Clone`).
|
||||
/// Encryption key (32 bytes). Retained because `Clone` and
|
||||
/// `cipher_clone` rebuild the keyed AEAD from these bytes.
|
||||
key: [u8; 32],
|
||||
/// Cached keyed AEAD, valid iff `has_key`. None for an un-keyed state.
|
||||
#[zeroize(skip)]
|
||||
cipher: Option<LessSafeKey>,
|
||||
/// Nonce counter (8 bytes used, 4 bytes zero prefix).
|
||||
#[zeroize(skip)]
|
||||
pub(super) nonce: u64,
|
||||
/// Whether this cipher has a valid key.
|
||||
#[zeroize(skip)]
|
||||
has_key: bool,
|
||||
}
|
||||
|
||||
@@ -207,14 +219,19 @@ impl Clone for CipherState {
|
||||
|
||||
impl CipherState {
|
||||
/// Create a new cipher state with the given key.
|
||||
pub(crate) fn new(key: [u8; 32]) -> Self {
|
||||
///
|
||||
/// The parameter is this frame's own copy of live key material, so it is
|
||||
/// cleared before returning. The caller's copy stays the caller's to clear.
|
||||
pub(crate) fn new(mut key: [u8; 32]) -> Self {
|
||||
let cipher = Self::build_cipher(&key);
|
||||
Self {
|
||||
let state = Self {
|
||||
key,
|
||||
cipher,
|
||||
nonce: 0,
|
||||
has_key: true,
|
||||
}
|
||||
};
|
||||
key.zeroize();
|
||||
state
|
||||
}
|
||||
|
||||
/// Create an empty cipher state (no key yet).
|
||||
@@ -228,11 +245,15 @@ impl CipherState {
|
||||
}
|
||||
|
||||
/// Initialize with a key.
|
||||
pub(super) fn initialize_key(&mut self, key: [u8; 32]) {
|
||||
///
|
||||
/// The parameter is this frame's own copy of live key material, so it is
|
||||
/// cleared before returning. The caller's copy stays the caller's to clear.
|
||||
pub(super) fn initialize_key(&mut self, mut key: [u8; 32]) {
|
||||
self.key = key;
|
||||
self.cipher = Self::build_cipher(&key);
|
||||
self.nonce = 0;
|
||||
self.has_key = true;
|
||||
key.zeroize();
|
||||
}
|
||||
|
||||
/// Build a ring `LessSafeKey` from raw key bytes. Centralized so the
|
||||
@@ -385,6 +406,13 @@ impl CipherState {
|
||||
self.has_key
|
||||
}
|
||||
|
||||
/// Copy out the retained key bytes, so a test can observe zeroization
|
||||
/// without reading freed memory.
|
||||
#[cfg(test)]
|
||||
fn key_bytes(&self) -> [u8; 32] {
|
||||
self.key
|
||||
}
|
||||
|
||||
/// Clone the underlying keyed AEAD, for off-task AEAD workers.
|
||||
///
|
||||
/// Returns `None` if no key. The cloned `LessSafeKey` pairs with
|
||||
|
||||
@@ -14,7 +14,7 @@ pub struct NoiseSession {
|
||||
send_cipher: CipherState,
|
||||
/// Cipher for receiving.
|
||||
recv_cipher: CipherState,
|
||||
/// Handshake hash for channel binding.
|
||||
/// Handshake hash.
|
||||
handshake_hash: [u8; 32],
|
||||
/// Remote peer's static public key.
|
||||
remote_static: PublicKey,
|
||||
@@ -190,7 +190,7 @@ impl NoiseSession {
|
||||
self.replay_window.reset();
|
||||
}
|
||||
|
||||
/// Get the handshake hash for channel binding.
|
||||
/// Get the handshake hash.
|
||||
pub fn handshake_hash(&self) -> &[u8; 32] {
|
||||
&self.handshake_hash
|
||||
}
|
||||
|
||||
@@ -371,6 +371,30 @@ fn test_cipher_state_nonce_sequence() {
|
||||
assert_eq!(cipher.nonce(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cipher_state_drop_impl_clears_the_retained_key() {
|
||||
// Reading the bytes back out of a dropped `CipherState` would mean
|
||||
// reading freed memory, so the drop behaviour is asserted through two
|
||||
// observable proxies instead: that `CipherState` implements
|
||||
// `ZeroizeOnDrop`, and that an explicit `zeroize()` leaves `key`
|
||||
// all-zero while `has_key` is untouched.
|
||||
fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
|
||||
assert_zeroize_on_drop::<CipherState>();
|
||||
|
||||
let mut cipher = CipherState::new([7u8; 32]);
|
||||
// `key_bytes` hands back a copy of live key material, so the observation
|
||||
// is bound and cleared rather than left in an unnamed temporary.
|
||||
let mut observed = cipher.key_bytes();
|
||||
assert_eq!(observed, [7u8; 32]);
|
||||
observed.zeroize();
|
||||
assert!(cipher.has_key());
|
||||
|
||||
cipher.zeroize();
|
||||
|
||||
assert_eq!(cipher.key_bytes(), [0u8; 32]);
|
||||
assert!(cipher.has_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_remote_static() {
|
||||
let keypair1 = generate_keypair();
|
||||
|
||||
+10
-1
@@ -15,6 +15,7 @@ use serde::Serialize;
|
||||
use tokio::sync::{Mutex, Notify, RwLock, broadcast, mpsc, oneshot};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, info, trace, warn};
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use super::advert::{AdvertMachine, PublishPlan};
|
||||
use super::failure_state::FailureState;
|
||||
@@ -286,7 +287,15 @@ impl NostrRendezvous {
|
||||
return Err(BootstrapError::Disabled);
|
||||
}
|
||||
|
||||
let keys = nostr::Keys::parse(&hex::encode(identity.keypair().secret_bytes()))
|
||||
// Three copies of the private key are made to reach `Keys::parse`:
|
||||
// the keypair, its raw bytes, and the hex string. Each is bound and
|
||||
// cleared here; `nostr::Keys` clears its own on drop.
|
||||
let mut our_keypair = identity.keypair();
|
||||
let mut secret_bytes = our_keypair.secret_bytes();
|
||||
let secret_hex = Zeroizing::new(hex::encode(secret_bytes));
|
||||
secret_bytes.zeroize();
|
||||
our_keypair.non_secure_erase();
|
||||
let keys = nostr::Keys::parse(secret_hex.as_str())
|
||||
.map_err(|e| BootstrapError::Nostr(e.to_string()))?;
|
||||
let client = Client::builder()
|
||||
.signer(keys.clone())
|
||||
|
||||
@@ -275,7 +275,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn replay_first_then_repeat() {
|
||||
// R1: first id Fresh; same id within window Replay.
|
||||
// First id Fresh; same id within window Replay.
|
||||
let m = machine();
|
||||
assert_eq!(
|
||||
m.note_session_seen("s1", 1000),
|
||||
@@ -286,7 +286,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn replay_prunes_expired() {
|
||||
// R2: an entry past its expiry is pruned, so re-seeing it is Fresh.
|
||||
// An entry past its expiry is pruned, so re-seeing it is Fresh.
|
||||
let m = machine(); // replay_window_ms = 1_000_000
|
||||
assert_eq!(
|
||||
m.note_session_seen("s1", 1000),
|
||||
@@ -302,7 +302,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn replay_cap_evicts_oldest_by_expiry() {
|
||||
// R3: cap overflow evicts oldest-by-expiry, returns (evicted, retained).
|
||||
// Cap overflow evicts oldest-by-expiry, returns (evicted, retained).
|
||||
let m = machine(); // cap = 3, window huge so nothing expires here
|
||||
assert_eq!(
|
||||
m.note_session_seen("s1", 1),
|
||||
|
||||
+116
-8
@@ -83,6 +83,7 @@
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::identity::ErasingKeypair;
|
||||
use crate::noise::{self, NoiseError, NoiseSession};
|
||||
use crate::proto::fmp::{
|
||||
ConnAction, ConnSnapshot, ConnectionState, EstablishSnapshot, Fmp, InboundDecision,
|
||||
@@ -115,10 +116,24 @@ const RESEND_BACKOFF: f64 = 2.0;
|
||||
const REKEY_CADENCE_INTERVAL_MS: u64 = 60_000;
|
||||
const REKEY_RESEND_INTERVAL_MS: u64 = 1_000;
|
||||
const REKEY_MAX_RESENDS: u32 = 5;
|
||||
const REKEY_AFTER_SECS: u64 = 3_600;
|
||||
const REKEY_AFTER_MESSAGES: u64 = 1_000_000;
|
||||
const DRAIN_WINDOW_MS: u64 = 5_000;
|
||||
const LIVENESS_INTERVAL_MS: u64 = 15_000;
|
||||
// `REKEY_AFTER_SECS`, `REKEY_AFTER_MESSAGES` and `LIVENESS_INTERVAL_MS` below
|
||||
// are placeholders pinned to today's `RekeyConfig` and `NodeConfig` defaults.
|
||||
// They are not a wiring to the config: nothing here reads a config value, so
|
||||
// an operator override is not tracked. They are what the machine falls back to
|
||||
// until it is wired to config. The tie to the defaults is asserted by
|
||||
// `rekey_constants_match_the_rekey_config_defaults` and
|
||||
// `liveness_interval_matches_the_heartbeat_config_default` rather than stated
|
||||
// in these declarations, because `Default for NodeConfig` is an ordinary impl
|
||||
// and cannot be called from a `const` initializer.
|
||||
const REKEY_AFTER_SECS: u64 = 120;
|
||||
const REKEY_AFTER_MESSAGES: u64 = 65_536;
|
||||
/// Drain-window deadline armed at rekey cutover. Sourced from the value that
|
||||
/// actually governs the live drain so the two cannot drift; the armed timer
|
||||
/// is currently stored and never fired (`drive_peer_timers` has no
|
||||
/// `DrainExpiry` arm), so this is a stored-value correction, not a live
|
||||
/// timing change.
|
||||
const DRAIN_WINDOW_MS: u64 = crate::proto::fsp::limits::DRAIN_WINDOW_SECS * 1_000;
|
||||
const LIVENESS_INTERVAL_MS: u64 = 10_000;
|
||||
const REKEY_DAMPEN_MS: u64 = 30_000;
|
||||
const CLOSED_BACKOFF_MS: u64 = 5_000;
|
||||
|
||||
@@ -777,10 +792,15 @@ impl PeerMachine {
|
||||
/// XX msg1 is ephemeral-only (33 bytes) — no identity or epoch.
|
||||
pub(crate) fn start_handshake(
|
||||
&mut self,
|
||||
our_keypair: Keypair,
|
||||
mut our_keypair: Keypair,
|
||||
epoch: [u8; 8],
|
||||
current_time_ms: u64,
|
||||
) -> Result<Vec<u8>, NoiseError> {
|
||||
// The parameter is this frame's own copy of the node's long-term
|
||||
// private key, and the state checks below return before it is used.
|
||||
// The guard clears it on every exit path.
|
||||
let our_keypair = ErasingKeypair::take(&mut our_keypair);
|
||||
|
||||
let msg1 = {
|
||||
let direction = self.conn.direction();
|
||||
let leg = self.leg.as_mut().ok_or_else(no_pending_connection)?;
|
||||
@@ -798,7 +818,9 @@ impl PeerMachine {
|
||||
// creates the Noise handshake handle, so there is no
|
||||
// `take().expect()` to protect, and its Err path was unreachable in
|
||||
// production (one call per fresh outbound handshake).
|
||||
let mut hs = noise::HandshakeState::new_initiator(our_keypair);
|
||||
let mut kp = *our_keypair.get();
|
||||
let mut hs = noise::HandshakeState::new_initiator(kp);
|
||||
kp.non_secure_erase();
|
||||
hs.set_local_epoch(epoch);
|
||||
let msg1 = hs.write_message_1()?;
|
||||
|
||||
@@ -821,12 +843,16 @@ impl PeerMachine {
|
||||
/// to the returned msg2 bytes.
|
||||
pub(crate) fn receive_handshake_init(
|
||||
&mut self,
|
||||
our_keypair: Keypair,
|
||||
mut our_keypair: Keypair,
|
||||
epoch: [u8; 8],
|
||||
message: &[u8],
|
||||
negotiation_payload: Option<&[u8]>,
|
||||
current_time_ms: u64,
|
||||
) -> Result<Vec<u8>, NoiseError> {
|
||||
// Same as `start_handshake`: the parameter copy outlives two early
|
||||
// returns, so the guard owns it rather than an erase per exit path.
|
||||
let our_keypair = ErasingKeypair::take(&mut our_keypair);
|
||||
|
||||
let msg2 = {
|
||||
let direction = self.conn.direction();
|
||||
let leg = self.leg.as_mut().ok_or_else(no_pending_connection)?;
|
||||
@@ -838,7 +864,9 @@ impl PeerMachine {
|
||||
});
|
||||
}
|
||||
|
||||
let mut hs = noise::HandshakeState::new_responder(our_keypair);
|
||||
let mut kp = *our_keypair.get();
|
||||
let mut hs = noise::HandshakeState::new_responder(kp);
|
||||
kp.non_secure_erase();
|
||||
hs.set_local_epoch(epoch);
|
||||
|
||||
// Process XX message 1 (ephemeral only — no identity learned)
|
||||
@@ -3974,6 +4002,86 @@ mod tests {
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// `LIVENESS_INTERVAL_MS` stays pinned to `NodeConfig`'s heartbeat default.
|
||||
///
|
||||
/// The expectation is read from the default rather than repeated as a
|
||||
/// literal, so raising or lowering `heartbeat_interval_secs` without
|
||||
/// re-pinning the constant reds here instead of drifting unnoticed.
|
||||
#[test]
|
||||
fn liveness_interval_matches_the_heartbeat_config_default() {
|
||||
assert_eq!(
|
||||
LIVENESS_INTERVAL_MS,
|
||||
crate::config::NodeConfig::default().heartbeat_interval_secs * 1_000
|
||||
);
|
||||
}
|
||||
|
||||
/// `REKEY_AFTER_SECS` and `REKEY_AFTER_MESSAGES` stay pinned to
|
||||
/// `RekeyConfig`'s defaults, read from the impl for the same reason.
|
||||
#[test]
|
||||
fn rekey_constants_match_the_rekey_config_defaults() {
|
||||
let defaults = crate::config::RekeyConfig::default();
|
||||
assert_eq!(REKEY_AFTER_SECS, defaults.after_secs);
|
||||
assert_eq!(REKEY_AFTER_MESSAGES, defaults.after_messages);
|
||||
}
|
||||
|
||||
/// A rekey cutover arms the drain timer for the drain window FSP uses.
|
||||
///
|
||||
/// The expected offset is the literal `10_000`: `DRAIN_WINDOW_SECS` in
|
||||
/// `src/proto/fsp/limits.rs` is 10 seconds, and that is the value this
|
||||
/// deadline is meant to carry. Writing it out rather than reusing
|
||||
/// `DRAIN_WINDOW_MS` is what keeps the assertion able to fail; expressed
|
||||
/// in terms of the constant under test it would move with any re-pointing
|
||||
/// of that constant and assert nothing.
|
||||
#[test]
|
||||
fn drain_expiry_deadline_is_the_configured_drain_window() {
|
||||
let mut alloc = IndexAllocator::new();
|
||||
let id = peer_identity();
|
||||
let addr = *id.node_addr();
|
||||
let mut m = PeerMachine::new_outbound(LinkId::new(1), Some(id), 0);
|
||||
m.state = PeerState::Maintaining {
|
||||
addr,
|
||||
kind: MaintainKind::Rekey(RekeyPhase::PendingCutover),
|
||||
};
|
||||
m.rekey_our_index = Some(SessionIndex::new(0x2222));
|
||||
m.conn.set_our_index(SessionIndex::new(0x1111));
|
||||
m.remote_epoch = Some([9u8; 8]);
|
||||
m.session_established_at_ms = 0;
|
||||
|
||||
let actions = m.step(
|
||||
PeerEvent::Timeout {
|
||||
kind: TimerKind::RekeyCadence,
|
||||
},
|
||||
7_000,
|
||||
&mut alloc,
|
||||
);
|
||||
|
||||
let deadline = actions
|
||||
.iter()
|
||||
.find_map(|a| match a {
|
||||
PeerAction::SetTimer {
|
||||
kind: TimerKind::DrainExpiry,
|
||||
at_ms,
|
||||
} => Some(*at_ms),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the cutover must arm a DrainExpiry timer");
|
||||
assert_eq!(deadline, 7_000 + 10_000);
|
||||
}
|
||||
|
||||
/// `DRAIN_WINDOW_MS` is the FSP drain limit in milliseconds.
|
||||
///
|
||||
/// This is a tautology as the constant is now declared, and is not
|
||||
/// coverage: it is an executable statement of where the value comes from.
|
||||
/// It reds only if a later edit replaces the const expression with a
|
||||
/// literal that disagrees with the limit.
|
||||
#[test]
|
||||
fn drain_window_ms_is_sourced_from_the_fsp_limit() {
|
||||
assert_eq!(
|
||||
DRAIN_WINDOW_MS,
|
||||
crate::proto::fsp::limits::DRAIN_WINDOW_SECS * 1_000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// T-SANSIO: the action vocabulary must stay plain, comparable data.
|
||||
|
||||
+3
-3
@@ -21,10 +21,10 @@ pub enum LinkMessageType {
|
||||
/// Payload is opaque to intermediate nodes (end-to-end encrypted).
|
||||
SessionDatagram = 0x00,
|
||||
|
||||
// MMP reports (0x01-0x02) — content defined in TASK-2026-0006
|
||||
/// Sender-side MMP report (stub).
|
||||
// MMP reports (0x01-0x02) — payload is an encoded SenderReport or ReceiverReport
|
||||
/// Sender-side MMP report.
|
||||
SenderReport = 0x01,
|
||||
/// Receiver-side MMP report (stub).
|
||||
/// Receiver-side MMP report.
|
||||
ReceiverReport = 0x02,
|
||||
|
||||
// Tree protocol (0x10-0x1F)
|
||||
|
||||
@@ -144,8 +144,8 @@ pub(crate) fn plan_forward(request: &mut LookupRequest, rv: &impl RoutingView) -
|
||||
/// NOTE: unlike [`plan_forward`], this does NOT fall back to non-tree
|
||||
/// (cross-link) bloom-matching peers. That asymmetry is preserved verbatim from
|
||||
/// the pre-sans-IO `initiate_lookup` to keep this extraction behavior-neutral;
|
||||
/// it is a known origination gap (ISSUE-2026-0059) whose fix adds the fallback
|
||||
/// branch as a separate, behavior-changing change.
|
||||
/// it is a known origination gap whose fix adds the fallback branch as a
|
||||
/// separate, behavior-changing change.
|
||||
pub(crate) fn plan_initiate(request: &LookupRequest, rv: &impl RoutingView) -> Vec<LookupAction> {
|
||||
let min_mtu = request.min_mtu;
|
||||
let targets: Vec<NodeAddr> = rv
|
||||
|
||||
@@ -23,6 +23,15 @@ pub trait BleStream: Send + Sync {
|
||||
/// Receive data from the L2CAP connection.
|
||||
///
|
||||
/// Returns the number of bytes read into `buf`.
|
||||
///
|
||||
/// A single call must never return bytes drawn from more than one SDU.
|
||||
/// The receive loop emits what one call returns as one FMP frame. A
|
||||
/// concatenation is caught by the frame-length check in the node's
|
||||
/// dispatch only when the leading frame is an established one; where it
|
||||
/// is a handshake frame, that check passes and the buffer is dropped one
|
||||
/// step later by that frame kind's exact-size parse. Returning less than
|
||||
/// a whole SDU is allowed: a truncated frame fails the AEAD tag or the
|
||||
/// exact-size parse regardless.
|
||||
fn recv(
|
||||
&self,
|
||||
buf: &mut [u8],
|
||||
|
||||
@@ -415,8 +415,8 @@ impl Transport for UdpTransport {
|
||||
/// Whether the transport accepts inbound handshake initiations.
|
||||
/// `outbound_only` mode forces this to false; otherwise reflects the
|
||||
/// `accept_connections` config field (default: true). Note that the
|
||||
/// hard gate is at the Node level (see ISSUE-2026-0004 fix in
|
||||
/// `src/node/handlers/handshake.rs`); this method is what that gate
|
||||
/// hard gate is at the Node level (in `src/node/handlers/handshake.rs`);
|
||||
/// this method is what that gate
|
||||
/// consults for transports that lack runtime-state-based filtering.
|
||||
fn accept_connections(&self) -> bool {
|
||||
if self.config.outbound_only() {
|
||||
|
||||
@@ -72,7 +72,7 @@ netem:
|
||||
# `interval_secs`, the policies on the two listed edges are swapped.
|
||||
# This drives n04 to alternate parents between n02 and n03 each
|
||||
# round. The 4s cadence and 5ms-vs-100ms delta come from the
|
||||
# original ISSUE-2026-0019 reproduction harness — they are
|
||||
# original reproduction harness for this flap — they are
|
||||
# calibrated to produce a parent switch per round under the
|
||||
# zero-hysteresis FIPS overrides below.
|
||||
link_swap:
|
||||
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/bin/bash
|
||||
# ── Source comment reference guard ──────────────────────────────────────────
|
||||
# Every reference a source comment makes must resolve for a reader who has
|
||||
# only this repository.
|
||||
#
|
||||
# A comment that names a private planning artifact — by identifier, by file
|
||||
# path, or in programme vocabulary that has no in-repo referent — is dead text
|
||||
# to everyone outside the workspace that holds the artifact, and it publishes
|
||||
# the existence and shape of that workspace to everyone else. A hand-run grep
|
||||
# closes the population that exists on the day it runs; this closes it for
|
||||
# every commit after.
|
||||
#
|
||||
# Three checks, each a `git grep` at HEAD rather than over the working tree,
|
||||
# because the thing being gated is what is committed:
|
||||
#
|
||||
# 1. identifiers, repo-wide. A local item identifier anywhere in the tree.
|
||||
# Repo-wide because packaging/, testing/ and the workflow files are as
|
||||
# public as src/ — more so in the packaging case, which ships to users.
|
||||
# 2. document paths, scoped to src/. A comment under src/ citing an *.md
|
||||
# path that does not exist in the tree at the checked commit. This is a
|
||||
# resolvability rule rather than a denylist, so it catches private
|
||||
# documents nobody has thought of. Scoped to src/ because resolving the
|
||||
# whole tree's relative documentation links against the repository root
|
||||
# is a different checker with its own population to triage first.
|
||||
# 3. programme vocabulary, scoped to src/. Phase, rung, milestone and step
|
||||
# labels that name a plan a reader cannot open. The pattern is shared
|
||||
# verbatim with the sweep that produced today's clean tree, so the two
|
||||
# cannot drift apart.
|
||||
#
|
||||
# Known coverage gaps, recorded rather than discovered:
|
||||
# - a bare "milestone" in some other phrasing (the narrow alternatives keep
|
||||
# the Tor bootstrap loop in src/transport/tor/mod.rs out of check 3);
|
||||
# - the CATEGORY_D / CATEGORY_E code identifiers and test names, which
|
||||
# check 3's case-sensitive Category-[A-Z] deliberately does not match;
|
||||
# - programme vocabulary, or a document path, outside src/;
|
||||
# - a reference to a private artifact made in free prose with no marker at
|
||||
# all, at any scope: no check here matches it;
|
||||
# - a pathspec typo introduced after this file lands. git grep returns 1
|
||||
# with empty output for a pathspec that matches nothing, which is
|
||||
# indistinguishable from health. The non-empty-population assertion below
|
||||
# narrows that for the src/-scoped checks and closes nothing for check 1.
|
||||
#
|
||||
# Exit 0 = every reference resolves. Exit 1 = one or more do not; every hit is
|
||||
# printed, not just the first. Exit 2 = the check could not look (not in a work
|
||||
# tree, wrong directory, empty pathspec, or a git-level failure); never treated
|
||||
# as a pass.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -uo pipefail
|
||||
|
||||
# A pathspec is resolved against the current directory even when a rev is
|
||||
# supplied, and a run from the wrong directory returns rc 1 with empty output
|
||||
# on every check — the same shape as a clean tree. Assert the directory, then
|
||||
# assert the post-condition rather than trusting the cd: `cd ""` succeeds and
|
||||
# does not move, so a one-line form silently leaves the script where it began
|
||||
# whenever the command substitution comes back empty.
|
||||
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) \
|
||||
|| { echo "check-comment-refs: not inside a git work tree" >&2; exit 2; }
|
||||
[[ -n "$ROOT" ]] \
|
||||
|| { echo "check-comment-refs: empty work-tree root" >&2; exit 2; }
|
||||
cd "$ROOT" || exit 2
|
||||
[[ -z "$(git rev-parse --show-prefix)" ]] \
|
||||
|| { echo "check-comment-refs: not at the work-tree root" >&2; exit 2; }
|
||||
|
||||
# The src/ pathspec must select something. wc -l is deliberate where the rest
|
||||
# of this script preserves exit statuses: it always exits 0, so the decision is
|
||||
# made on n and never on a status, and a failing git ls-tree yields n=0 and
|
||||
# fires the assertion. This covers checks 2 and 3 only; check 1's pathspec is
|
||||
# "." and stays non-empty from anywhere, so its wrong-directory case is covered
|
||||
# by the --show-prefix assertion above and its mistyped-pathspec case by
|
||||
# nothing.
|
||||
n=$(git ls-tree -r --name-only HEAD -- src/ | wc -l)
|
||||
(( n > 0 )) || { echo "check-comment-refs: pathspec src/ matched no files" >&2; exit 2; }
|
||||
|
||||
# Deliberately a superset of the sweep's own acceptance regex: the year group
|
||||
# is optional, so the three-digit form is caught as well, and the separator is
|
||||
# optional so underscores and spaces are caught alongside hyphens. Narrowing
|
||||
# this is how a whole class goes unguarded while every break-check still
|
||||
# passes.
|
||||
ID_PAT='(TASK|ISSUE|IDEA|QUICK|RECUR)[-_ ]?(20[0-9]{2}[-_ ])?[0-9]{3,4}'
|
||||
|
||||
# Verbatim from the sweep's enumerating pattern. Do not edit one without the
|
||||
# other. If check 3's scope is ever widened beyond src/, this text matches
|
||||
# itself through six of its alternatives and the widening must add
|
||||
# ':(exclude)testing/check-comment-refs.sh' — never an exclusion of testing/,
|
||||
# which holds real check-1 hits a directory-wide exclusion would drop.
|
||||
VOCAB_PAT='\b[RQ][0-9]\b|Category-[A-Z]|\bumbrella\b|refactor steps?|\bpre-scopes\b|R0 stub|read-isolation|cut over yet|Cutover begins|in Step [0-9]|(the|this) milestone|Milestone-'
|
||||
|
||||
MD_PAT='[A-Za-z0-9_./-]+\.md\b'
|
||||
|
||||
FAILED=0
|
||||
|
||||
# ── Check 1: local item identifiers, repo-wide ──────────────────────────────
|
||||
# The capture preserves git grep's status instead of flattening it with
|
||||
# `|| true`: a legitimate no-hit run exits 1, but so would a malformed
|
||||
# pathspec magic, an unresolvable rev or a bad regex exit 128, and collapsing
|
||||
# both into "clean" is the single likeliest way this script reports green
|
||||
# while checking nothing. The hit decision is made on the output; the
|
||||
# could-not-look decision is made on the status.
|
||||
rc=0
|
||||
out=$(git grep -nEI "$ID_PAT" HEAD -- .) || rc=$?
|
||||
if (( rc > 1 )); then
|
||||
echo "check-comment-refs: check 1 grep failed (rc=$rc)" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -n "$out" ]]; then
|
||||
printf '%s\n' "$out" \
|
||||
| sed -E 's|^HEAD:([^:]*):([0-9]+):|\1:\2: names a local work item: |'
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# ── Check 2: document paths under src/ must resolve in-repo ─────────────────
|
||||
rc=0
|
||||
md_lines=$(git grep -nI '\.md' HEAD -- src/) || rc=$?
|
||||
if (( rc > 1 )); then
|
||||
echo "check-comment-refs: check 2 grep failed (rc=$rc)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Drop any line carrying a URL before extracting a token from it. Keying the
|
||||
# skip on the extracted token cannot work: ':' is outside the token character
|
||||
# class, so the scheme is stripped first and what survives does not begin with
|
||||
# "http". Inert on today's tree, and kept so a future URL citation does not red.
|
||||
cited=$(printf '%s\n' "$md_lines" | grep -vE 'https?://')
|
||||
|
||||
declare -A CITES=()
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" ]] || continue
|
||||
loc=${line#HEAD:}
|
||||
loc=$(printf '%s' "$loc" | sed -E 's|^([^:]*):([0-9]+):.*|\1:\2|')
|
||||
content=$(printf '%s' "$line" | sed -E 's|^HEAD:[^:]*:[0-9]+:||')
|
||||
while IFS= read -r tok; do
|
||||
[[ -n "$tok" ]] || continue
|
||||
CITES["$tok"]+="$loc "
|
||||
done < <(printf '%s\n' "$content" | grep -oE "$MD_PAT")
|
||||
done <<< "$cited"
|
||||
|
||||
if (( ${#CITES[@]} > 0 )); then
|
||||
while IFS= read -r tok; do
|
||||
[[ -n "$tok" ]] || continue
|
||||
lrc=0
|
||||
lsout=$(git ls-tree HEAD -- "$tok" 2>/dev/null) || lrc=$?
|
||||
# A '../'-prefixed or absolute token makes git ls-tree exit 128 with
|
||||
# "is outside repository". That is neither a hit nor clean: the check
|
||||
# could not look, and reporting it as a hit would red a legitimate
|
||||
# citation while reporting it as clean would hide one.
|
||||
if (( lrc != 0 )); then
|
||||
echo "check-comment-refs: could not resolve '$tok' (git ls-tree rc=$lrc)" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -z "$lsout" ]]; then
|
||||
for loc in ${CITES["$tok"]}; do
|
||||
printf '%s: cites %s, which does not exist in this repository\n' \
|
||||
"$loc" "$tok"
|
||||
done
|
||||
FAILED=1
|
||||
fi
|
||||
done < <(printf '%s\n' "${!CITES[@]}" | sort)
|
||||
fi
|
||||
|
||||
# ── Check 3: programme vocabulary under src/ ────────────────────────────────
|
||||
rc=0
|
||||
out=$(git grep -nEI "$VOCAB_PAT" HEAD -- src/) || rc=$?
|
||||
if (( rc > 1 )); then
|
||||
echo "check-comment-refs: check 3 grep failed (rc=$rc)" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -n "$out" ]]; then
|
||||
printf '%s\n' "$out" \
|
||||
| sed -E 's|^HEAD:([^:]*):([0-9]+):|\1:\2: names a plan this repository does not carry: |'
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
if (( FAILED != 0 )); then
|
||||
echo "" >&2
|
||||
echo "check-comment-refs: a source comment references something a reader holding" >&2
|
||||
echo "only this repository cannot resolve. Rewrite the comment to say what the" >&2
|
||||
echo "code does, citing nothing outside the tree." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1281,6 +1281,18 @@ run_action_pins() {
|
||||
record "action-pins" $rc
|
||||
}
|
||||
|
||||
# Every reference a source comment makes must resolve for a reader who has only
|
||||
# this repository. A comment naming a private planning artifact is dead text to
|
||||
# everyone outside the workspace holding it, and publishes that workspace's
|
||||
# shape to everyone else. A hand-run grep closes the population that exists on
|
||||
# the day it runs; this closes it for every commit after.
|
||||
run_comment_refs() {
|
||||
local rc=0
|
||||
info "[comment-refs] Checking that every source comment resolves in-repo"
|
||||
"$SCRIPT_DIR/check-comment-refs.sh" || rc=$?
|
||||
record "comment-refs" $rc
|
||||
}
|
||||
|
||||
# Every daemon log string a test matches on must still be emitted by src/.
|
||||
# A stale one does not fail — it stops observing, and an expect-zero assertion
|
||||
# built on it then passes for the wrong reason.
|
||||
@@ -1336,6 +1348,7 @@ main() {
|
||||
run_trailing_log
|
||||
run_image_scoping
|
||||
run_action_pins
|
||||
run_comment_refs
|
||||
run_wait_converge
|
||||
|
||||
if [[ "$TEST_ONLY" == true ]]; then
|
||||
|
||||
@@ -949,7 +949,7 @@ echo ""
|
||||
# versions. A mixed-version bloom/tree-encoding divergence shows up as a
|
||||
# node that never produces an in-band estimate (or returns null). Strict
|
||||
# band = [0.75N, 1.25N]; polled up to MESH_SIZE_TIMEOUT (the estimate
|
||||
# converges over minutes — see ISSUE-2026-0046 on its transient jitter).
|
||||
# converges over minutes and is transiently jittery).
|
||||
echo "Phase 7: Mesh-size estimate convergence (strict ±25% of true N=$NUM_NODES)"
|
||||
PASSED=0; FAILED=0
|
||||
ms_lo="$(awk -v n="$NUM_NODES" 'BEGIN{printf "%.2f", 0.75*n}')"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Compose override that bumps RUST_LOG to trace level on the modules
|
||||
# relevant to NAT-traversal handshake-completion flake evidence
|
||||
# collection (ISSUE-2026-0027):
|
||||
# collection:
|
||||
#
|
||||
# - fips::nostr — overlay advert publish/consume
|
||||
# (where the cross-init race begins)
|
||||
|
||||
@@ -176,7 +176,7 @@ RECONVERGE_STALL=10
|
||||
TIMEOUT=5
|
||||
CONVERGENCE_PING_TIMEOUT=1
|
||||
# Strict-ping retry policy for the per-phase ping_all asserts. Under 1%
|
||||
# i.i.d. packet loss (the lab condition surfaced by ISSUE-2026-0028) a
|
||||
# i.i.d. packet loss (the lab condition this retry policy is sized for) a
|
||||
# single-shot ping fails at roughly 2% per directed pair, so a 20-pair
|
||||
# strict assert misses with probability ~1 - (0.98)^20 ≈ 33%, which is
|
||||
# below the per-pair loss-math floor but well above the routing-state
|
||||
|
||||
Reference in New Issue
Block a user