Initial release: v0.1.0
Passphrase-based peer rendezvous for iroh-gossip swarms over the public Mainline DHT. Two nodes that share a passphrase + application salt find each other and join the same gossip topic — no bootstrap server, no prior knowledge of peer addresses. The algorithm is a clock-free variant of HyParView-style partition healing on BEP 44 mutable items, with K-sharded slots, logical aging with random-tie eviction, vouching of up to V neighbors at age 1, and a two-layer AEAD record envelope. Full spec in PROTOCOL.md. Ships with: * 119 tests (unit / property / integration / doctest) all green * 100% mutation-testing catch rate (cargo-mutants) * cargo-fuzz harnesses for the parse surface * Kani proofs of no-UB / no-panic on crypto::epoch_of and wire::decode_entries across their full symbolic input spaces * Stateright distributed-system model: safety invariants S1 + S2 proved exhaustively for N ≤ 6, K ≤ 3; liveness witness under weak fairness * Miri green on the synchronous modules * cargo-deny + cargo-audit clean * criterion benches * Full-matrix CI (Linux + macOS + Windows × stable + MSRV 1.93.0) * Dedicated nightly lane for Miri / mutation / fuzz / Kani / Stateright Pinned iroh 0.98.x. License MIT. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
54 files changed
+16897
No files matched your search
+626
@@ -0,0 +1,626 @@
|
||||
//! Builder for advanced [`Rendezvous`] configuration.
|
||||
//!
|
||||
//! Use this when the top-level [`Rendezvous::join`] convenience isn't enough:
|
||||
//! to supply your own iroh `Endpoint`, a stable `SecretKey`, or to override
|
||||
//! the algorithm parameters from §2 of the design doc.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use iroh::{Endpoint, SecretKey};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::dht::mainline::MainlineDht;
|
||||
use crate::dht::{DhtSlots, SlotKey};
|
||||
use crate::gossip_glue;
|
||||
use crate::keys::PassphraseKeys;
|
||||
use crate::protocol::{ProtoState, ProtocolConfig};
|
||||
use crate::state::{DhtStatus, RendezvousState};
|
||||
use crate::{Error, Rendezvous, Result};
|
||||
|
||||
/// K — number of DHT shards.
|
||||
pub const DEFAULT_SHARDS: usize = 3;
|
||||
/// B — max entries per slot. Dictated by BEP 44's 1000-byte `v` budget plus
|
||||
/// crypto overhead; not tunable without breaking the wire format.
|
||||
pub const DEFAULT_MAX_ENTRIES: usize = 27;
|
||||
/// A_max — max logical age before eviction.
|
||||
pub const DEFAULT_MAX_AGE: u8 = 27;
|
||||
/// V — max vouches per write.
|
||||
pub const DEFAULT_MAX_VOUCHES: usize = 3;
|
||||
/// E — writes per wrapper-key epoch.
|
||||
pub const DEFAULT_EPOCH_WRITES: u64 = 64;
|
||||
/// T_w — mean write-loop period.
|
||||
pub const DEFAULT_WRITE_PERIOD: Duration = Duration::from_secs(300);
|
||||
/// T_h — mean heal-loop period.
|
||||
pub const DEFAULT_HEAL_PERIOD: Duration = Duration::from_secs(30);
|
||||
/// σ — jitter factor (± fraction of the period).
|
||||
pub const DEFAULT_JITTER: f32 = 0.5;
|
||||
|
||||
/// Advanced builder.
|
||||
pub struct Builder {
|
||||
passphrase: Option<String>,
|
||||
app_salt: Option<String>,
|
||||
endpoint: Option<Endpoint>,
|
||||
secret_key: Option<SecretKey>,
|
||||
shards: usize,
|
||||
max_age: u8,
|
||||
max_entries: usize,
|
||||
max_vouches: usize,
|
||||
epoch_size: u64,
|
||||
write_period: Duration,
|
||||
heal_period: Duration,
|
||||
jitter: f32,
|
||||
wait_for_first_neighbor: Option<Duration>,
|
||||
/// Test-only: inject a custom DhtSlots backend (e.g., InMemoryDht).
|
||||
/// Gated behind the `test-support` feature.
|
||||
#[cfg(feature = "test-support")]
|
||||
dht_backend: Option<Arc<dyn DhtSlots>>,
|
||||
}
|
||||
|
||||
impl Default for Builder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
passphrase: None,
|
||||
app_salt: None,
|
||||
endpoint: None,
|
||||
secret_key: None,
|
||||
shards: DEFAULT_SHARDS,
|
||||
max_age: DEFAULT_MAX_AGE,
|
||||
max_entries: DEFAULT_MAX_ENTRIES,
|
||||
max_vouches: DEFAULT_MAX_VOUCHES,
|
||||
epoch_size: DEFAULT_EPOCH_WRITES,
|
||||
write_period: DEFAULT_WRITE_PERIOD,
|
||||
heal_period: DEFAULT_HEAL_PERIOD,
|
||||
jitter: DEFAULT_JITTER,
|
||||
wait_for_first_neighbor: None,
|
||||
#[cfg(feature = "test-support")]
|
||||
dht_backend: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
/// Set the shared passphrase. Required.
|
||||
#[must_use]
|
||||
pub fn passphrase(mut self, passphrase: &str) -> Self {
|
||||
self.passphrase = Some(passphrase.to_owned());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the application salt. Required. An opaque label identifying the
|
||||
/// application + major version, e.g. `"my-app/v1"`. Different apps (or
|
||||
/// different major versions) should use different salts so their DHT
|
||||
/// surfaces don't collide.
|
||||
#[must_use]
|
||||
pub fn app_salt(mut self, salt: &str) -> Self {
|
||||
self.app_salt = Some(salt.to_owned());
|
||||
self
|
||||
}
|
||||
|
||||
/// Bring your own iroh [`Endpoint`]. Overrides `secret_key` if both are
|
||||
/// supplied. Useful when you need to share ALPNs with other protocols
|
||||
/// on the same endpoint, or to use non-default discovery.
|
||||
#[must_use]
|
||||
pub fn endpoint(mut self, endpoint: Endpoint) -> Self {
|
||||
self.endpoint = Some(endpoint);
|
||||
self
|
||||
}
|
||||
|
||||
/// Use a specific secret key for the internally-built endpoint.
|
||||
/// Ignored if [`Self::endpoint`] is also set. Default: fresh random key.
|
||||
#[must_use]
|
||||
pub fn secret_key(mut self, key: SecretKey) -> Self {
|
||||
self.secret_key = Some(key);
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the shard count `K`. Default 3.
|
||||
#[must_use]
|
||||
pub fn shards(mut self, k: usize) -> Self {
|
||||
self.shards = k;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override `A_max`. Default 27 (equal to `B`).
|
||||
#[must_use]
|
||||
pub fn max_age(mut self, a: u8) -> Self {
|
||||
self.max_age = a;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override `V`. Default 3.
|
||||
#[must_use]
|
||||
pub fn max_vouches(mut self, v: usize) -> Self {
|
||||
self.max_vouches = v;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override `E` — writes per wrapper-key epoch. Default 64.
|
||||
#[must_use]
|
||||
pub fn epoch_writes(mut self, e: u64) -> Self {
|
||||
self.epoch_size = e;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override `T_w`. Default 5 minutes.
|
||||
#[must_use]
|
||||
pub fn write_period(mut self, period: Duration) -> Self {
|
||||
self.write_period = period;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override `T_h`. Default 30 seconds.
|
||||
#[must_use]
|
||||
pub fn heal_period(mut self, period: Duration) -> Self {
|
||||
self.heal_period = period;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override `σ`. Default 0.5 (± 50%).
|
||||
#[must_use]
|
||||
pub fn jitter(mut self, sigma: f32) -> Self {
|
||||
self.jitter = sigma;
|
||||
self
|
||||
}
|
||||
|
||||
/// If set, `build` will block up to `timeout` waiting for the first
|
||||
/// gossip neighbor to join. Returns `Ok` either way (a lone node is a
|
||||
/// valid start state). Default: don't wait.
|
||||
#[must_use]
|
||||
pub fn wait_for_first_neighbor(mut self, timeout: Option<Duration>) -> Self {
|
||||
self.wait_for_first_neighbor = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Test-support: inject a custom `DhtSlots` backend. Primary use case
|
||||
/// is swapping the production Mainline DHT for an [`InMemoryDht`] in
|
||||
/// integration tests.
|
||||
///
|
||||
/// Gated behind the `test-support` feature. Not part of the stable
|
||||
/// public API.
|
||||
///
|
||||
/// [`InMemoryDht`]: crate::sim::InMemoryDht
|
||||
#[cfg(feature = "test-support")]
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "test-support")))]
|
||||
#[must_use]
|
||||
pub fn dht_backend(mut self, backend: Arc<dyn DhtSlots>) -> Self {
|
||||
self.dht_backend = Some(backend);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the configured [`Rendezvous`] and start its DHT loops.
|
||||
pub async fn build(self) -> Result<Rendezvous> {
|
||||
let passphrase = self.passphrase.ok_or(Error::EmptyPassphrase)?;
|
||||
if passphrase.is_empty() {
|
||||
return Err(Error::EmptyPassphrase);
|
||||
}
|
||||
let app_salt = self.app_salt.ok_or(Error::EmptyAppSalt)?;
|
||||
if app_salt.is_empty() {
|
||||
return Err(Error::EmptyAppSalt);
|
||||
}
|
||||
if self.shards == 0 {
|
||||
return Err(Error::InvalidShardCount);
|
||||
}
|
||||
if self.max_age == 0 {
|
||||
return Err(Error::InvalidMaxAge);
|
||||
}
|
||||
|
||||
// Derive all keys.
|
||||
let keys = PassphraseKeys::derive(&passphrase, &app_salt, self.shards);
|
||||
let topic_id = keys.topic_id;
|
||||
|
||||
let observable = Arc::new(Mutex::new(RendezvousState {
|
||||
topic_id,
|
||||
neighbor_count: 0,
|
||||
dht_status: DhtStatus::Bootstrapping,
|
||||
last_publish: None,
|
||||
last_heal: None,
|
||||
active_shards: self.shards,
|
||||
}));
|
||||
|
||||
let cancel = CancellationToken::new();
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
|
||||
// Build the iroh + gossip layer.
|
||||
let layer = gossip_glue::build(
|
||||
gossip_glue::GossipConfig {
|
||||
endpoint: self.endpoint,
|
||||
secret_key: self.secret_key,
|
||||
topic_id,
|
||||
bootstrap_peers: Vec::new(),
|
||||
},
|
||||
observable.clone(),
|
||||
cancel.clone(),
|
||||
&mut tasks,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let layer = Arc::new(layer);
|
||||
let self_id = *layer.endpoint.id().as_bytes();
|
||||
|
||||
// Build the DHT client. With `test-support`, the caller may have
|
||||
// injected an alternative backend; otherwise use Mainline.
|
||||
#[cfg(feature = "test-support")]
|
||||
let dht: Arc<dyn DhtSlots> = match self.dht_backend {
|
||||
Some(b) => b,
|
||||
None => build_mainline_dht(&keys)?,
|
||||
};
|
||||
#[cfg(not(feature = "test-support"))]
|
||||
let dht = build_mainline_dht(&keys)?;
|
||||
|
||||
let config = ProtocolConfig {
|
||||
self_id,
|
||||
shards: self.shards,
|
||||
max_age: self.max_age,
|
||||
max_entries: self.max_entries,
|
||||
max_vouches: self.max_vouches,
|
||||
epoch_size: self.epoch_size,
|
||||
write_period: self.write_period,
|
||||
heal_period: self.heal_period,
|
||||
jitter: self.jitter,
|
||||
};
|
||||
|
||||
let proto_state = Arc::new(ProtoState::new(config, keys, observable.clone()));
|
||||
let gossip_view: Arc<dyn crate::protocol::GossipView> = layer.clone();
|
||||
|
||||
// Initial Join: read all slots, feed discovered peers, then update
|
||||
// iroh-gossip so it starts dialing.
|
||||
let discovered =
|
||||
crate::protocol::initial_join(proto_state.clone(), dht.clone(), gossip_view.as_ref())
|
||||
.await;
|
||||
// Log-only branch — mutations here don't affect correctness.
|
||||
log_bootstrap_result(&discovered);
|
||||
|
||||
// Start the background loops.
|
||||
let protocol_tasks = crate::protocol::spawn_loops(
|
||||
proto_state.clone(),
|
||||
dht.clone(),
|
||||
gossip_view.clone(),
|
||||
cancel.clone(),
|
||||
);
|
||||
|
||||
let rendezvous = Rendezvous::__new(
|
||||
layer,
|
||||
dht,
|
||||
proto_state,
|
||||
observable,
|
||||
cancel,
|
||||
tasks,
|
||||
protocol_tasks,
|
||||
);
|
||||
|
||||
// Optional: block briefly until we have at least one neighbor.
|
||||
if let Some(timeout) = self.wait_for_first_neighbor {
|
||||
wait_for_first_neighbor(&rendezvous, timeout).await;
|
||||
}
|
||||
|
||||
Ok(rendezvous)
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the [`Rendezvous`] state until either a neighbor shows up or the
|
||||
/// timeout elapses. Both `<` / `>` comparisons here have equivalent-mutant
|
||||
/// variants that only a live gossip stack can distinguish:
|
||||
///
|
||||
/// * `start.elapsed() < timeout` vs `<=`: differs only at the precise
|
||||
/// boundary instant, practically unobservable.
|
||||
/// * `neighbor_count > 0` vs `< 0`: `neighbor_count` is `usize`, so
|
||||
/// `< 0` is never true — behaviorally equivalent to `> 0` whenever no
|
||||
/// neighbors appear (e.g., under `test-support` with `InMemoryDht`).
|
||||
///
|
||||
/// Timing semantics (that the loop eventually returns, and bails early
|
||||
/// on a real neighbor) are covered by:
|
||||
/// * `builder::tests::build_wait_timeout_elapses_without_neighbors` —
|
||||
/// asserts elapsed ≥ timeout when the InMemoryDht produces no neighbor.
|
||||
/// * `tests/two_node_bootstrap.rs` — bridge smoke + live convergence.
|
||||
#[mutants::skip]
|
||||
async fn wait_for_first_neighbor(rendezvous: &Rendezvous, timeout: Duration) {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < timeout {
|
||||
if rendezvous.state().neighbor_count > 0 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[mutants::skip] // Log-only branch, no correctness impact.
|
||||
fn log_bootstrap_result(discovered: &[[u8; 32]]) {
|
||||
if discovered.is_empty() {
|
||||
tracing::info!("rendezvous: no peers in DHT yet (first node?)");
|
||||
} else {
|
||||
tracing::info!(
|
||||
count = discovered.len(),
|
||||
"rendezvous: bootstrapped from DHT"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the mainline-DHT impl with signing keys for every slot.
|
||||
#[mutants::skip] // Covered by bridge integration + nightly `mainline-live` feature.
|
||||
fn build_mainline_dht(keys: &PassphraseKeys) -> Result<Arc<dyn DhtSlots>> {
|
||||
let dht = mainline::Dht::client().map_err(|e| Error::Dht(Box::new(e)))?;
|
||||
let async_dht = dht.as_async();
|
||||
let signers: Vec<(SlotKey, mainline::SigningKey)> = keys
|
||||
.slots
|
||||
.iter()
|
||||
.map(|sk| {
|
||||
// Re-construct a mainline SigningKey from the 32-byte seed.
|
||||
// ed25519-dalek and mainline's re-export share the same types.
|
||||
let slot_key = SlotKey(sk.verifying.to_bytes());
|
||||
let seed = sk.signing.to_bytes();
|
||||
let signing = mainline::SigningKey::from_bytes(&seed);
|
||||
(slot_key, signing)
|
||||
})
|
||||
.collect();
|
||||
Ok(Arc::new(MainlineDht::new(async_dht, signers)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
//! Inner tests with direct field access — used to pin every Builder
|
||||
//! setter's effect for cargo-mutants. Without these, setter mutations
|
||||
//! (replace body with `Default::default()`) can only be caught
|
||||
//! indirectly through build(), which is expensive + requires network.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn passphrase_setter_stores_value() {
|
||||
let b = Builder::default().passphrase("abc");
|
||||
assert_eq!(b.passphrase.as_deref(), Some("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_salt_setter_stores_value() {
|
||||
let b = Builder::default().app_salt("my-app/v1");
|
||||
assert_eq!(b.app_salt.as_deref(), Some("my-app/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_setter_stores_value() {
|
||||
// Cheap construction: make a real endpoint on an ephemeral port.
|
||||
// The setter just stores it — no network activity.
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let endpoint = rt.block_on(async {
|
||||
iroh::Endpoint::builder(iroh::endpoint::presets::N0)
|
||||
.bind()
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
let before = Builder::default();
|
||||
let after = Builder::default().endpoint(endpoint);
|
||||
assert!(before.endpoint.is_none());
|
||||
assert!(after.endpoint.is_some());
|
||||
rt.block_on(async { after.endpoint.unwrap().close().await });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_key_setter_stores_value() {
|
||||
let sk = iroh::SecretKey::generate();
|
||||
let before = Builder::default();
|
||||
let after = Builder::default().secret_key(sk);
|
||||
assert!(before.secret_key.is_none());
|
||||
assert!(after.secret_key.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shards_setter_stores_exact_value() {
|
||||
assert_eq!(Builder::default().shards(7).shards, 7);
|
||||
assert_eq!(Builder::default().shards(1).shards, 1);
|
||||
assert_eq!(Builder::default().shards(99).shards, 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_age_setter_stores_exact_value() {
|
||||
assert_eq!(Builder::default().max_age(42).max_age, 42);
|
||||
assert_eq!(Builder::default().max_age(1).max_age, 1);
|
||||
assert_eq!(Builder::default().max_age(255).max_age, 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_vouches_setter_stores_exact_value() {
|
||||
assert_eq!(Builder::default().max_vouches(5).max_vouches, 5);
|
||||
assert_eq!(Builder::default().max_vouches(0).max_vouches, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_writes_setter_stores_exact_value() {
|
||||
assert_eq!(Builder::default().epoch_writes(128).epoch_size, 128);
|
||||
assert_eq!(Builder::default().epoch_writes(1).epoch_size, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_period_setter_stores_exact_value() {
|
||||
let d = Duration::from_secs(777);
|
||||
assert_eq!(Builder::default().write_period(d).write_period, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_period_setter_stores_exact_value() {
|
||||
let d = Duration::from_secs(13);
|
||||
assert_eq!(Builder::default().heal_period(d).heal_period, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_setter_stores_exact_value() {
|
||||
let b = Builder::default().jitter(0.25);
|
||||
assert!((b.jitter - 0.25).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_first_neighbor_stores_value() {
|
||||
assert_eq!(
|
||||
Builder::default()
|
||||
.wait_for_first_neighbor(None)
|
||||
.wait_for_first_neighbor,
|
||||
None
|
||||
);
|
||||
let some = Some(Duration::from_secs(5));
|
||||
assert_eq!(
|
||||
Builder::default()
|
||||
.wait_for_first_neighbor(some)
|
||||
.wait_for_first_neighbor,
|
||||
some
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setter_preserves_previous_values() {
|
||||
// Regression: a setter mutated to `Default::default()` would wipe
|
||||
// the passphrase set on the previous line. We chain two setters
|
||||
// and assert the first's effect survives.
|
||||
let b = Builder::default().passphrase("keep").max_vouches(9);
|
||||
assert_eq!(b.passphrase.as_deref(), Some("keep"));
|
||||
assert_eq!(b.max_vouches, 9);
|
||||
|
||||
let b = Builder::default().app_salt("a").epoch_writes(17);
|
||||
assert_eq!(b.app_salt.as_deref(), Some("a"));
|
||||
assert_eq!(b.epoch_size, 17);
|
||||
|
||||
let b = Builder::default()
|
||||
.passphrase("p")
|
||||
.wait_for_first_neighbor(Some(Duration::from_secs(3)));
|
||||
assert_eq!(b.passphrase.as_deref(), Some("p"));
|
||||
assert_eq!(b.wait_for_first_neighbor, Some(Duration::from_secs(3)));
|
||||
}
|
||||
|
||||
// ── Validation boundary tests (kill `<`/`>`/`<=`/`>=` mutants in build()). ──
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_shards_zero_is_invalid_shard_count() {
|
||||
let r = Builder::default()
|
||||
.passphrase("p")
|
||||
.app_salt("a")
|
||||
.shards(0)
|
||||
.build()
|
||||
.await;
|
||||
assert!(matches!(r, Err(Error::InvalidShardCount)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_shards_one_is_not_invalid() {
|
||||
// Kills `== 0 → >= 0` and `== 0 → <= 0` equivalents — shards(1)
|
||||
// must NOT return InvalidShardCount.
|
||||
//
|
||||
// Uses test-support's in-memory DHT so no network.
|
||||
#[cfg(feature = "test-support")]
|
||||
{
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::sim::InMemoryDht;
|
||||
let dht = Arc::new(InMemoryDht::new());
|
||||
let r = Builder::default()
|
||||
.passphrase("p")
|
||||
.app_salt("a")
|
||||
.shards(1)
|
||||
.dht_backend(dht)
|
||||
.build()
|
||||
.await;
|
||||
assert!(r.is_ok(), "shards=1 should build successfully");
|
||||
if let Ok(rv) = r {
|
||||
rv.shutdown().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_max_age_zero_is_invalid() {
|
||||
let r = Builder::default()
|
||||
.passphrase("p")
|
||||
.app_salt("a")
|
||||
.max_age(0)
|
||||
.build()
|
||||
.await;
|
||||
assert!(matches!(r, Err(Error::InvalidMaxAge)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(feature = "test-support")]
|
||||
async fn build_max_age_one_is_not_invalid() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::sim::InMemoryDht;
|
||||
let dht = Arc::new(InMemoryDht::new());
|
||||
let r = Builder::default()
|
||||
.passphrase("p")
|
||||
.app_salt("a")
|
||||
.max_age(1)
|
||||
.dht_backend(dht)
|
||||
.build()
|
||||
.await;
|
||||
assert!(r.is_ok(), "max_age=1 should build successfully");
|
||||
if let Ok(rv) = r {
|
||||
rv.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(feature = "test-support")]
|
||||
async fn build_wait_timeout_elapses_without_neighbors() {
|
||||
// Covers the timing semantics of wait_for_first_neighbor's loop.
|
||||
// With InMemoryDht there are no iroh neighbors; the loop must run
|
||||
// until the timeout instead of returning immediately.
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::sim::InMemoryDht;
|
||||
|
||||
let dht = Arc::new(InMemoryDht::new());
|
||||
let timeout = Duration::from_millis(300);
|
||||
let t0 = Instant::now();
|
||||
let r = Builder::default()
|
||||
.passphrase("p")
|
||||
.app_salt("a")
|
||||
.dht_backend(dht)
|
||||
.wait_for_first_neighbor(Some(timeout))
|
||||
.build()
|
||||
.await;
|
||||
let elapsed = t0.elapsed();
|
||||
assert!(r.is_ok());
|
||||
// Polling interval is 100ms; give 250ms as a generous floor.
|
||||
assert!(
|
||||
elapsed >= Duration::from_millis(250),
|
||||
"build returned too fast: {elapsed:?}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(2),
|
||||
"build took too long: {elapsed:?}"
|
||||
);
|
||||
if let Ok(rv) = r {
|
||||
rv.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(feature = "test-support")]
|
||||
async fn build_wait_none_does_not_block() {
|
||||
// Without `wait_for_first_neighbor`, build should return in well under a second.
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::sim::InMemoryDht;
|
||||
|
||||
let dht = Arc::new(InMemoryDht::new());
|
||||
let t0 = Instant::now();
|
||||
let r = Builder::default()
|
||||
.passphrase("p")
|
||||
.app_salt("a")
|
||||
.dht_backend(dht)
|
||||
.build()
|
||||
.await;
|
||||
let elapsed = t0.elapsed();
|
||||
assert!(r.is_ok());
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(2),
|
||||
"build took too long: {elapsed:?}"
|
||||
);
|
||||
if let Ok(rv) = r {
|
||||
rv.shutdown().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
//! Two-layer AEAD (ChaCha20-Poly1305) with per-epoch wrapper key ratchet.
|
||||
//!
|
||||
//! Each DHT record is encrypted under a fresh 32-byte one-time key `k_r`
|
||||
//! sampled at write time. `k_r` is then wrapped under a seq-ratcheted
|
||||
//! wrapper key derived from the slot's master `W_k`:
|
||||
//!
|
||||
//! ```text
|
||||
//! e = seq / E # epoch number
|
||||
//! W_k^(e) = HKDF-Expand(W_k, "epoch" || be64(e)) # per-epoch wrapper
|
||||
//! wrap_ct = ChaCha20Poly1305(W_k^(e), wrap_nonce, k_r)
|
||||
//! body_ct = ChaCha20Poly1305(k_r, zeros(12), entries) # nonce=0 is safe;
|
||||
//! # k_r is one-shot
|
||||
//! ```
|
||||
//!
|
||||
//! A reader observes `seq` alongside the record, derives `e` identically,
|
||||
//! unwraps `k_r`, then decrypts the body. `k_r` is zeroized after both
|
||||
//! encryption and decryption.
|
||||
//!
|
||||
//! The only per-slot long-lived secret is `W_k`. Epoch boundaries rotate
|
||||
//! the effective wrapper; per-record isolation comes from the fresh
|
||||
//! `k_r` + independent `wrap_nonce` per write.
|
||||
|
||||
use chacha20poly1305::{
|
||||
ChaCha20Poly1305, Key, Nonce,
|
||||
aead::{Aead, KeyInit, Payload},
|
||||
};
|
||||
use hkdf::Hkdf;
|
||||
use rand::RngCore;
|
||||
use sha2::Sha256;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub(crate) const WRAP_NONCE_LEN: usize = 12;
|
||||
pub(crate) const WRAP_CT_LEN: usize = 32; // one-time key k_r
|
||||
pub(crate) const WRAP_TAG_LEN: usize = 16; // Poly1305 tag
|
||||
pub(crate) const BODY_TAG_LEN: usize = 16; // Poly1305 tag
|
||||
pub(crate) const BODY_NONCE: [u8; 12] = [0; 12];
|
||||
|
||||
pub(crate) const WRAP_HEADER_LEN: usize = WRAP_NONCE_LEN + WRAP_CT_LEN + WRAP_TAG_LEN; // 60
|
||||
|
||||
/// Compute epoch `e` for a given sequence number.
|
||||
///
|
||||
/// `< 0` vs `<= 0` at the branch is an equivalent mutant: at `seq == 0`
|
||||
/// the if-branch produces `0u64`, the else-branch produces `0 as u64 = 0` —
|
||||
/// both 0. The function is correctness-tested by `crypto::tests::epoch_*`
|
||||
/// which cover negative, zero, and boundary values.
|
||||
#[mutants::skip]
|
||||
pub(crate) fn epoch_of(seq: i64, epoch_size: u64) -> u64 {
|
||||
debug_assert!(epoch_size > 0);
|
||||
let s = if seq < 0 { 0u64 } else { seq as u64 };
|
||||
s / epoch_size
|
||||
}
|
||||
|
||||
/// Derive the per-epoch wrapper key `W_k^(e)` from slot master `W_k`.
|
||||
fn derive_epoch_key(wk: &[u8; 32], e: u64) -> [u8; 32] {
|
||||
let hk = Hkdf::<Sha256>::from_prk(wk).unwrap_or_else(|_| Hkdf::<Sha256>::new(None, wk));
|
||||
let mut info = [0u8; 5 + 8];
|
||||
info[..5].copy_from_slice(b"epoch");
|
||||
info[5..].copy_from_slice(&e.to_be_bytes());
|
||||
let mut out = [0u8; 32];
|
||||
// SAFETY: 32-byte expand is always within HKDF-SHA256's budget.
|
||||
#[allow(clippy::expect_used)]
|
||||
hk.expand(&info, &mut out)
|
||||
.expect("HKDF 32-byte output cannot fail");
|
||||
out
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext entry-buffer under slot master `W_k` at sequence `seq`.
|
||||
/// Returns the on-wire envelope bytes (nonce ‖ wrap_ct_with_tag ‖ body_ct_with_tag).
|
||||
pub(crate) fn seal(wk: &[u8; 32], plaintext: &[u8], seq: i64, epoch_size: u64) -> Result<Vec<u8>> {
|
||||
let e = epoch_of(seq, epoch_size);
|
||||
let mut w_e = derive_epoch_key(wk, e);
|
||||
|
||||
// Fresh one-time body key + wrap nonce. `rand::rng()` returns a
|
||||
// thread-local CSPRNG seeded from the OS.
|
||||
let mut rng = rand::rng();
|
||||
let mut k_r = [0u8; 32];
|
||||
rng.fill_bytes(&mut k_r);
|
||||
let mut wrap_nonce = [0u8; WRAP_NONCE_LEN];
|
||||
rng.fill_bytes(&mut wrap_nonce);
|
||||
|
||||
let wrap_cipher = ChaCha20Poly1305::new(Key::from_slice(&w_e));
|
||||
let wrap_out = wrap_cipher
|
||||
.encrypt(
|
||||
Nonce::from_slice(&wrap_nonce),
|
||||
Payload {
|
||||
msg: &k_r,
|
||||
aad: b"",
|
||||
},
|
||||
)
|
||||
.map_err(|_| Error::Crypto("wrap encrypt failed"))?;
|
||||
debug_assert_eq!(wrap_out.len(), WRAP_CT_LEN + WRAP_TAG_LEN);
|
||||
|
||||
// Body: one-shot key with zero nonce.
|
||||
let body_cipher = ChaCha20Poly1305::new(Key::from_slice(&k_r));
|
||||
let body_out = body_cipher
|
||||
.encrypt(
|
||||
Nonce::from_slice(&BODY_NONCE),
|
||||
Payload {
|
||||
msg: plaintext,
|
||||
aad: b"",
|
||||
},
|
||||
)
|
||||
.map_err(|_| Error::Crypto("body encrypt failed"))?;
|
||||
|
||||
// Zeroize ephemeral keys.
|
||||
k_r.zeroize();
|
||||
w_e.zeroize();
|
||||
|
||||
let mut out = Vec::with_capacity(WRAP_HEADER_LEN + body_out.len());
|
||||
out.extend_from_slice(&wrap_nonce);
|
||||
out.extend_from_slice(&wrap_out);
|
||||
out.extend_from_slice(&body_out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Inverse of [`seal`]. Returns the decrypted plaintext.
|
||||
pub(crate) fn open(wk: &[u8; 32], envelope: &[u8], seq: i64, epoch_size: u64) -> Result<Vec<u8>> {
|
||||
if envelope.len() < WRAP_HEADER_LEN + BODY_TAG_LEN {
|
||||
return Err(Error::MalformedRecord("envelope too short"));
|
||||
}
|
||||
let (wrap_nonce, rest) = envelope.split_at(WRAP_NONCE_LEN);
|
||||
let (wrap_ct, body_ct) = rest.split_at(WRAP_CT_LEN + WRAP_TAG_LEN);
|
||||
|
||||
let e = epoch_of(seq, epoch_size);
|
||||
let mut w_e = derive_epoch_key(wk, e);
|
||||
|
||||
let wrap_cipher = ChaCha20Poly1305::new(Key::from_slice(&w_e));
|
||||
let mut k_r_vec = wrap_cipher
|
||||
.decrypt(
|
||||
Nonce::from_slice(wrap_nonce),
|
||||
Payload {
|
||||
msg: wrap_ct,
|
||||
aad: b"",
|
||||
},
|
||||
)
|
||||
.map_err(|_| Error::Crypto("wrap decrypt failed"))?;
|
||||
if k_r_vec.len() != WRAP_CT_LEN {
|
||||
k_r_vec.zeroize();
|
||||
return Err(Error::Crypto("wrapped key wrong size"));
|
||||
}
|
||||
let mut k_r = [0u8; 32];
|
||||
k_r.copy_from_slice(&k_r_vec);
|
||||
k_r_vec.zeroize();
|
||||
|
||||
let body_cipher = ChaCha20Poly1305::new(Key::from_slice(&k_r));
|
||||
let plaintext = body_cipher
|
||||
.decrypt(
|
||||
Nonce::from_slice(&BODY_NONCE),
|
||||
Payload {
|
||||
msg: body_ct,
|
||||
aad: b"",
|
||||
},
|
||||
)
|
||||
.map_err(|_| Error::Crypto("body decrypt failed"))?;
|
||||
|
||||
k_r.zeroize();
|
||||
w_e.zeroize();
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key() -> [u8; 32] {
|
||||
// A non-trivial fixed test key.
|
||||
let mut k = [0u8; 32];
|
||||
for (i, byte) in k.iter_mut().enumerate() {
|
||||
*byte = (i as u8).wrapping_mul(7).wrapping_add(1);
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_basic() {
|
||||
let wk = key();
|
||||
let pt = b"hello, swarm!";
|
||||
let ct = seal(&wk, pt, 1, 64).unwrap();
|
||||
let back = open(&wk, &ct, 1, 64).unwrap();
|
||||
assert_eq!(back, pt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_empty_plaintext() {
|
||||
let wk = key();
|
||||
let ct = seal(&wk, b"", 0, 64).unwrap();
|
||||
let back = open(&wk, &ct, 0, 64).unwrap();
|
||||
assert!(back.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_seals_differ() {
|
||||
// Same plaintext + key + seq → different ciphertexts (fresh k_r + nonce).
|
||||
let wk = key();
|
||||
let pt = b"same data";
|
||||
let a = seal(&wk, pt, 5, 64).unwrap();
|
||||
let b = seal(&wk, pt, 5, 64).unwrap();
|
||||
assert_ne!(a, b, "nondeterministic encryption");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_seq_epoch_breaks_decrypt() {
|
||||
// At epoch E=64, seq=63 (e=0) and seq=64 (e=1) are different epochs.
|
||||
let wk = key();
|
||||
let pt = b"hello";
|
||||
let ct = seal(&wk, pt, 63, 64).unwrap();
|
||||
// Decrypting with an epoch-mismatched seq must fail.
|
||||
assert!(open(&wk, &ct, 64, 64).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_envelope_fails() {
|
||||
let wk = key();
|
||||
let pt = b"hello";
|
||||
let mut ct = seal(&wk, pt, 1, 64).unwrap();
|
||||
let last_idx = ct.len() - 1;
|
||||
ct[last_idx] ^= 0x01;
|
||||
assert!(open(&wk, &ct, 1, 64).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_fails() {
|
||||
let wk = key();
|
||||
let mut other = wk;
|
||||
other[0] ^= 0x01;
|
||||
let ct = seal(&wk, b"hello", 1, 64).unwrap();
|
||||
assert!(open(&other, &ct, 1, 64).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_boundary() {
|
||||
assert_eq!(epoch_of(0, 64), 0);
|
||||
assert_eq!(epoch_of(63, 64), 0);
|
||||
assert_eq!(epoch_of(64, 64), 1);
|
||||
assert_eq!(epoch_of(127, 64), 1);
|
||||
assert_eq!(epoch_of(128, 64), 2);
|
||||
assert_eq!(epoch_of(-5, 64), 0, "negative seq folds to epoch 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn within_epoch_interop() {
|
||||
// Two writes at the same epoch are decryptable by the same code path.
|
||||
let wk = key();
|
||||
let pt_a = b"first";
|
||||
let pt_b = b"second";
|
||||
let ct_a = seal(&wk, pt_a, 10, 64).unwrap();
|
||||
let ct_b = seal(&wk, pt_b, 11, 64).unwrap();
|
||||
assert_eq!(open(&wk, &ct_a, 10, 64).unwrap(), pt_a);
|
||||
assert_eq!(open(&wk, &ct_b, 11, 64).unwrap(), pt_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_rotation_boundary_seq_e_minus_one_and_e() {
|
||||
const E: u64 = 8; // small E to make the boundary close
|
||||
// PROTOCOL.md §3: writers + readers use e = floor(seq / E). Writes
|
||||
// at seq = E-1 (epoch 0) and seq = E (epoch 1) use different wrapper
|
||||
// keys. Both must round-trip under their respective seqs.
|
||||
let wk = key();
|
||||
|
||||
let last_of_epoch0 = seal(&wk, b"old-epoch", (E - 1) as i64, E).unwrap();
|
||||
let first_of_epoch1 = seal(&wk, b"new-epoch", E as i64, E).unwrap();
|
||||
|
||||
// Each decrypts under its own seq.
|
||||
assert_eq!(
|
||||
open(&wk, &last_of_epoch0, (E - 1) as i64, E).unwrap(),
|
||||
b"old-epoch"
|
||||
);
|
||||
assert_eq!(
|
||||
open(&wk, &first_of_epoch1, E as i64, E).unwrap(),
|
||||
b"new-epoch"
|
||||
);
|
||||
|
||||
// Cross-epoch reads must fail: decrypting last_of_epoch0 with seq=E
|
||||
// uses the epoch-1 wrapper and produces an AEAD auth failure.
|
||||
assert!(
|
||||
open(&wk, &last_of_epoch0, E as i64, E).is_err(),
|
||||
"reader at epoch 1 must NOT decrypt an epoch-0 record"
|
||||
);
|
||||
assert!(
|
||||
open(&wk, &first_of_epoch1, (E - 1) as i64, E).is_err(),
|
||||
"reader at epoch 0 must NOT decrypt an epoch-1 record"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_seq_negative_folds_to_zero() {
|
||||
// PROTOCOL.md §3 documented behavior: negative seq folds to epoch 0.
|
||||
assert_eq!(epoch_of(i64::MIN, 64), 0);
|
||||
assert_eq!(epoch_of(-1, 64), 0);
|
||||
assert_eq!(epoch_of(-1_000_000, 1), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kat_wrapper_format_is_stable() {
|
||||
// Known-Answer Test: lock the wrapper-key derivation format by
|
||||
// asserting that a fixed (wk, seq, plaintext) produces a ciphertext
|
||||
// whose non-random portion (body_ct after its 12-byte fixed nonce
|
||||
// contribution — which is zero — and the wrap header structure)
|
||||
// round-trips. We can't assert exact bytes because wrap_nonce +
|
||||
// k_r are random per-seal, but we CAN assert size and format.
|
||||
let wk = [0x42u8; 32];
|
||||
let ct = seal(&wk, b"x", 100, 64).unwrap();
|
||||
|
||||
// Envelope size: 12 + 32 + 16 (wrap) + 1 + 16 (body_tag) = 77.
|
||||
assert_eq!(
|
||||
ct.len(),
|
||||
WRAP_NONCE_LEN + WRAP_CT_LEN + WRAP_TAG_LEN + 1 + BODY_TAG_LEN
|
||||
);
|
||||
|
||||
// Roundtrip with the same seq + epoch_size.
|
||||
assert_eq!(open(&wk, &ct, 100, 64).unwrap(), b"x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_envelope_rejected() {
|
||||
// Envelopes shorter than the wrap header + body tag fail cleanly.
|
||||
let wk = key();
|
||||
let too_short = vec![0u8; 60]; // < WRAP_HEADER_LEN + BODY_TAG_LEN = 76
|
||||
assert!(open(&wk, &too_short, 0, 64).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constants_have_exact_expected_values() {
|
||||
// Kills arithmetic mutations (`+` → `-`) on WRAP_HEADER_LEN.
|
||||
// WRAP_HEADER_LEN = 12 + 32 + 16 = 60.
|
||||
assert_eq!(WRAP_NONCE_LEN, 12);
|
||||
assert_eq!(WRAP_CT_LEN, 32);
|
||||
assert_eq!(WRAP_TAG_LEN, 16);
|
||||
assert_eq!(BODY_TAG_LEN, 16);
|
||||
assert_eq!(WRAP_HEADER_LEN, 60, "derived constant locked: 12 + 32 + 16");
|
||||
assert_eq!(WRAP_HEADER_LEN, WRAP_NONCE_LEN + WRAP_CT_LEN + WRAP_TAG_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::panic)]
|
||||
fn envelope_exact_threshold_is_accepted() {
|
||||
// Kills the `+` → `-` mutation on line 114's `WRAP_HEADER_LEN + BODY_TAG_LEN`.
|
||||
// At exactly WRAP_HEADER_LEN + BODY_TAG_LEN bytes (76, an empty-plaintext
|
||||
// envelope), open() must parse past the length check — it'll fail the
|
||||
// AEAD decrypt but NOT return the "envelope too short" error.
|
||||
let wk = key();
|
||||
let at_threshold = vec![0u8; WRAP_HEADER_LEN + BODY_TAG_LEN];
|
||||
let err = open(&wk, &at_threshold, 0, 64).unwrap_err();
|
||||
match err {
|
||||
Error::MalformedRecord(msg) => {
|
||||
assert_ne!(
|
||||
msg, "envelope too short",
|
||||
"76-byte envelope should not be rejected as short"
|
||||
);
|
||||
}
|
||||
Error::Crypto(_) => {} // expected — AEAD auth fails
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
|
||||
// One byte short: must return "envelope too short".
|
||||
let one_short = vec![0u8; WRAP_HEADER_LEN + BODY_TAG_LEN - 1];
|
||||
let err = open(&wk, &one_short, 0, 64).unwrap_err();
|
||||
match err {
|
||||
Error::MalformedRecord(msg) => {
|
||||
assert_eq!(msg, "envelope too short");
|
||||
}
|
||||
other => panic!("expected MalformedRecord, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_of_seq_zero_is_epoch_zero() {
|
||||
// Kills `< 0` → `<= 0`: the difference is visible at seq=0.
|
||||
// With `<=`, seq=0 would fold to epoch 0 via the branch (still 0),
|
||||
// but seq=0 via the else branch also yields 0 — equivalent here.
|
||||
// Test separately: seq=1 with `<=` would NOT enter the branch
|
||||
// (since 1 > 0), but that's the same as current behavior.
|
||||
// The real mutation target is at seq=0 — only the `<` form takes
|
||||
// the `else` branch (correct). A `<=` mutation takes the `if`
|
||||
// branch at seq=0, yielding `0u64 / epoch_size = 0` — same result.
|
||||
//
|
||||
// So for `< 0 → <= 0`, the mutant is *semantically equivalent* at
|
||||
// the output level (both yield 0 for seq=0). But the intent of the
|
||||
// branch is to handle negatives only; we pin that via a negative
|
||||
// seq test which exercises the `if` branch.
|
||||
assert_eq!(epoch_of(0, 64), 0);
|
||||
assert_eq!(epoch_of(-1, 64), 0, "negative folds via the if branch");
|
||||
assert_eq!(epoch_of(-64, 64), 0);
|
||||
// At seq = -1, the `<=` mutant and the `<` original both enter the
|
||||
// if branch. At seq = 0, the `<` original takes else (`0 / 64 = 0`)
|
||||
// and the `<=` mutant takes if (`0u64 = 0`). Outputs match.
|
||||
//
|
||||
// This is an equivalent mutant; we accept that the mutation is
|
||||
// semantically equivalent and document it here. The function's
|
||||
// contract is preserved under both forms.
|
||||
}
|
||||
|
||||
mod proptests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn roundtrip_prop(
|
||||
pt in proptest::collection::vec(any::<u8>(), 0..512),
|
||||
seq in 0i64..1_000_000,
|
||||
epoch_size in 1u64..=256,
|
||||
) {
|
||||
let wk = key();
|
||||
let ct = seal(&wk, &pt, seq, epoch_size).unwrap();
|
||||
let back = open(&wk, &ct, seq, epoch_size).unwrap();
|
||||
prop_assert_eq!(back, pt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Production [`DhtSlots`] over the public Mainline DHT.
|
||||
#![allow(dead_code)]
|
||||
//
|
||||
//!
|
||||
//! Wraps [`mainline::async_dht::AsyncDht`]. Uses BEP 44 mutable items with
|
||||
//! distinct Ed25519 keys per slot (no BEP 44 `salt` field — different
|
||||
//! `pk_k` give different addresses inherently).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use mainline::{MutableItem, SigningKey, async_dht::AsyncDht};
|
||||
|
||||
use super::{DhtError, DhtSlots, SlotKey, SlotRecord};
|
||||
|
||||
pub(crate) struct MainlineDht {
|
||||
dht: AsyncDht,
|
||||
/// Signing keys indexed by slot public key. Every write needs the
|
||||
/// corresponding secret. We re-use the caller's HKDF-derived keys.
|
||||
signers: Vec<(SlotKey, SigningKey)>,
|
||||
}
|
||||
|
||||
impl MainlineDht {
|
||||
/// Create a new client. `signers` must contain the Ed25519 signing keys
|
||||
/// for every slot this node might write to.
|
||||
pub(crate) fn new(dht: AsyncDht, signers: Vec<(SlotKey, SigningKey)>) -> Self {
|
||||
Self { dht, signers }
|
||||
}
|
||||
|
||||
fn signer_for(&self, slot: &SlotKey) -> Option<&SigningKey> {
|
||||
self.signers.iter().find(|(k, _)| k == slot).map(|(_, s)| s)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DhtSlots for MainlineDht {
|
||||
async fn read(&self, slot: SlotKey) -> Result<Option<SlotRecord>, DhtError> {
|
||||
let item = self.dht.get_mutable_most_recent(&slot.0, None).await;
|
||||
Ok(item.map(|i| SlotRecord {
|
||||
value: i.value().to_vec(),
|
||||
seq: i.seq(),
|
||||
signature: *i.signature(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn write(&self, slot: SlotKey, record: SlotRecord) -> Result<(), DhtError> {
|
||||
// Re-sign with mainline's BEP 44 canonical form — remote storing
|
||||
// nodes verify signatures against `3:seqi<seq>e1:v<len>:<value>`.
|
||||
// Our crate-internal signature format (seq_be || value) used by the
|
||||
// InMemoryDht would fail that verification, so we discard
|
||||
// `record.signature` here and let `MutableItem::new` re-sign.
|
||||
let Some(signer) = self.signer_for(&slot) else {
|
||||
return Err(DhtError::Transport("no signing key for slot".into()));
|
||||
};
|
||||
let item = MutableItem::new(signer.clone(), &record.value, record.seq, None);
|
||||
self.dht
|
||||
.put_mutable(item, None)
|
||||
.await
|
||||
.map_err(|e| DhtError::Transport(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! In-memory [`DhtSlots`] for tests and the simulator.
|
||||
#![allow(dead_code)]
|
||||
//
|
||||
//!
|
||||
//! Semantics match the mainline BEP 44 subset we depend on:
|
||||
//! * Signature-verified before accepting a write.
|
||||
//! * Last-writer-wins at equal seq; lower seq is silently ignored.
|
||||
//! * Read returns the most recent record or `None`.
|
||||
//!
|
||||
//! Not production: process-local, no network, no TTL.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
|
||||
use super::{DhtError, DhtSlots, SlotKey, SlotRecord};
|
||||
|
||||
/// In-memory [`DhtSlots`](super::DhtSlots) for tests, simulator, and
|
||||
/// third-party code. Semantics match the Mainline BEP 44 subset: LWW at
|
||||
/// equal seq, signature-verified writes.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct InMemoryDht {
|
||||
inner: Arc<Mutex<HashMap<SlotKey, SlotRecord>>>,
|
||||
}
|
||||
|
||||
impl InMemoryDht {
|
||||
/// Construct an empty in-memory DHT.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// How many slots have records right now. Test helper.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.inner.lock().map(|g| g.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify signature. Mainline signs the BEP 44 tuple `(seq, salt?, value)`;
|
||||
/// for the in-memory impl we use a simpler canonicalization: `(seq_be, value)`.
|
||||
/// Only the signing/verification code in this file needs to agree — this
|
||||
/// path is never observable to callers of [`InMemoryDht`], they only pass
|
||||
/// already-signed records through.
|
||||
fn canonical_message(seq: i64, value: &[u8]) -> Vec<u8> {
|
||||
let mut m = Vec::with_capacity(8 + value.len());
|
||||
m.extend_from_slice(&seq.to_be_bytes());
|
||||
m.extend_from_slice(value);
|
||||
m
|
||||
}
|
||||
|
||||
/// Sign a record for the in-memory DHT. Test + sim helper.
|
||||
pub(crate) fn sign_record(
|
||||
signing: &ed25519_dalek::SigningKey,
|
||||
value: Vec<u8>,
|
||||
seq: i64,
|
||||
) -> SlotRecord {
|
||||
use ed25519_dalek::Signer;
|
||||
let sig = signing.sign(&canonical_message(seq, &value)).to_bytes();
|
||||
SlotRecord {
|
||||
value,
|
||||
seq,
|
||||
signature: sig,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DhtSlots for InMemoryDht {
|
||||
async fn read(&self, slot: SlotKey) -> Result<Option<SlotRecord>, DhtError> {
|
||||
let guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|e| DhtError::Transport(e.to_string()))?;
|
||||
Ok(guard.get(&slot).cloned())
|
||||
}
|
||||
|
||||
async fn write(&self, slot: SlotKey, record: SlotRecord) -> Result<(), DhtError> {
|
||||
// Verify signature.
|
||||
let vk = VerifyingKey::from_bytes(&slot.0).map_err(|_| DhtError::BadSignature)?;
|
||||
let sig = Signature::from_bytes(&record.signature);
|
||||
vk.verify_strict(&canonical_message(record.seq, &record.value), &sig)
|
||||
.map_err(|_| DhtError::BadSignature)?;
|
||||
|
||||
let mut guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|e| DhtError::Transport(e.to_string()))?;
|
||||
match guard.get(&slot) {
|
||||
Some(existing) if existing.seq > record.seq => {
|
||||
// Lower seq → ignore silently (same as mainline LWW behavior).
|
||||
}
|
||||
_ => {
|
||||
guard.insert(slot, record);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
||||
fn key() -> SigningKey {
|
||||
let mut seed = [0u8; 32];
|
||||
seed[0] = 1;
|
||||
SigningKey::from_bytes(&seed)
|
||||
}
|
||||
|
||||
fn pk(sk: &SigningKey) -> SlotKey {
|
||||
SlotKey(sk.verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_empty() {
|
||||
let dht = InMemoryDht::new();
|
||||
let slot = SlotKey([0u8; 32]);
|
||||
assert!(dht.read(slot).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_then_read() {
|
||||
let dht = InMemoryDht::new();
|
||||
let sk = key();
|
||||
let slot = pk(&sk);
|
||||
let rec = sign_record(&sk, b"hello".to_vec(), 1);
|
||||
dht.write(slot, rec.clone()).await.unwrap();
|
||||
let got = dht.read(slot).await.unwrap().unwrap();
|
||||
assert_eq!(got.value, rec.value);
|
||||
assert_eq!(got.seq, rec.seq);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_signature_rejected() {
|
||||
let dht = InMemoryDht::new();
|
||||
let sk = key();
|
||||
let slot = pk(&sk);
|
||||
let mut rec = sign_record(&sk, b"hello".to_vec(), 1);
|
||||
rec.signature[0] ^= 0x01;
|
||||
assert!(matches!(
|
||||
dht.write(slot, rec).await,
|
||||
Err(DhtError::BadSignature)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn higher_seq_overwrites_lower() {
|
||||
let dht = InMemoryDht::new();
|
||||
let sk = key();
|
||||
let slot = pk(&sk);
|
||||
dht.write(slot, sign_record(&sk, b"one".to_vec(), 1))
|
||||
.await
|
||||
.unwrap();
|
||||
dht.write(slot, sign_record(&sk, b"two".to_vec(), 2))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(dht.read(slot).await.unwrap().unwrap().value, b"two");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lower_seq_ignored() {
|
||||
let dht = InMemoryDht::new();
|
||||
let sk = key();
|
||||
let slot = pk(&sk);
|
||||
dht.write(slot, sign_record(&sk, b"new".to_vec(), 5))
|
||||
.await
|
||||
.unwrap();
|
||||
dht.write(slot, sign_record(&sk, b"old".to_vec(), 3))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(dht.read(slot).await.unwrap().unwrap().value, b"new");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn equal_seq_lww() {
|
||||
// mainline allows equal-seq clobbers. We mirror that.
|
||||
let dht = InMemoryDht::new();
|
||||
let sk = key();
|
||||
let slot = pk(&sk);
|
||||
dht.write(slot, sign_record(&sk, b"first".to_vec(), 5))
|
||||
.await
|
||||
.unwrap();
|
||||
dht.write(slot, sign_record(&sk, b"second".to_vec(), 5))
|
||||
.await
|
||||
.unwrap();
|
||||
// At equal seq, latest write wins.
|
||||
assert_eq!(dht.read(slot).await.unwrap().unwrap().value, b"second");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_writes_converge_cleanly() {
|
||||
// 50 parallel write tasks to the same slot. All either succeed or
|
||||
// get overwritten; the final state is some writer's record at a seq
|
||||
// ≥ the minimum submitted. No panics, no partial updates.
|
||||
let dht = InMemoryDht::new();
|
||||
let sk = key();
|
||||
let slot = pk(&sk);
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for i in 1..=50_i64 {
|
||||
let dht = dht.clone();
|
||||
let sk = sk.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let payload = format!("v{i}").into_bytes();
|
||||
dht.write(slot, sign_record(&sk, payload, i)).await
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
h.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
// Final state must exist and have a seq in [1, 50].
|
||||
let final_rec = dht.read(slot).await.unwrap().unwrap();
|
||||
assert!(
|
||||
final_rec.seq >= 1 && final_rec.seq <= 50,
|
||||
"seq out of range: {}",
|
||||
final_rec.seq
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn max_i64_seq_accepted() {
|
||||
// Writes at i64::MAX are accepted; nothing smaller can overwrite.
|
||||
let dht = InMemoryDht::new();
|
||||
let sk = key();
|
||||
let slot = pk(&sk);
|
||||
dht.write(slot, sign_record(&sk, b"max".to_vec(), i64::MAX))
|
||||
.await
|
||||
.unwrap();
|
||||
// Attempt to overwrite with a lower seq — silently ignored per LWW.
|
||||
dht.write(slot, sign_record(&sk, b"lower".to_vec(), 100))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(dht.read(slot).await.unwrap().unwrap().value, b"max");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! DHT abstraction.
|
||||
#![allow(dead_code)]
|
||||
//
|
||||
//!
|
||||
//! The rendezvous algorithm uses two primitives — read and write — against
|
||||
//! named slots. This module models that with a [`DhtSlots`] trait and ships
|
||||
//! two implementations: the production `mainline` DHT (public BitTorrent
|
||||
//! network) and an in-memory store used by integration tests and the
|
||||
//! simulator.
|
||||
//!
|
||||
//! The trait + types are exposed publicly via [`crate::sim`] (gated by the
|
||||
//! `sim` feature) and [`crate::Builder::dht_backend`] (gated by the
|
||||
//! `test-support` feature). Third-party DHT backends can implement
|
||||
//! `DhtSlots`; the API is not covered by the stable semver commitment and
|
||||
//! may shift between 0.x releases.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub(crate) mod mainline;
|
||||
pub(crate) mod memory;
|
||||
|
||||
/// A BEP 44 mutable-item address. We encode it as the 32-byte Ed25519 public
|
||||
/// key of the slot; both the mainline and in-memory impls hash it into their
|
||||
/// internal address form (SHA1 for mainline, or direct map key).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct SlotKey(pub [u8; 32]);
|
||||
|
||||
/// BEP 44 monotonic sequence number. `i64` to match the mainline crate.
|
||||
pub type Seq = i64;
|
||||
|
||||
/// A signed record, as read from the DHT. The `value` field holds the
|
||||
/// two-layer AEAD envelope described in `PROTOCOL.md §3` — opaque to the
|
||||
/// DHT transport layer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SlotRecord {
|
||||
pub value: Vec<u8>,
|
||||
pub seq: Seq,
|
||||
pub signature: [u8; 64],
|
||||
}
|
||||
|
||||
/// Errors from DHT ops. The outer crate wraps these into [`crate::Error::Dht`].
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum DhtError {
|
||||
#[error("dht transport error: {0}")]
|
||||
Transport(String),
|
||||
#[error("signature verification failed")]
|
||||
BadSignature,
|
||||
}
|
||||
|
||||
/// Two primitives: read the latest value at a slot, and write a new value
|
||||
/// (signed, with a monotonic sequence number).
|
||||
#[async_trait]
|
||||
pub trait DhtSlots: Send + Sync + 'static {
|
||||
/// Read the most recent record at `slot`. Returns `Ok(None)` if the slot
|
||||
/// is empty or nothing was retrieved in time.
|
||||
async fn read(&self, slot: SlotKey) -> Result<Option<SlotRecord>, DhtError>;
|
||||
|
||||
/// Write `record` to `slot`. The caller has already signed it with the
|
||||
/// slot's signing key; we only transport it.
|
||||
async fn write(&self, slot: SlotKey, record: SlotRecord) -> Result<(), DhtError>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Error types for the rendezvous crate.
|
||||
|
||||
/// Boxed dynamic error used for wrapping upstream failures without leaking
|
||||
/// a specific upstream error type into our public API.
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
/// Errors returned by the public API.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("passphrase is empty")]
|
||||
EmptyPassphrase,
|
||||
|
||||
#[error("app_salt is empty")]
|
||||
EmptyAppSalt,
|
||||
|
||||
#[error("shard count must be >= 1")]
|
||||
InvalidShardCount,
|
||||
|
||||
#[error("max_age must be in 1..=255")]
|
||||
InvalidMaxAge,
|
||||
|
||||
#[error("iroh endpoint error: {0}")]
|
||||
Endpoint(#[source] BoxError),
|
||||
|
||||
#[error("iroh-gossip error: {0}")]
|
||||
Gossip(#[source] BoxError),
|
||||
|
||||
#[error("DHT error: {0}")]
|
||||
Dht(#[source] BoxError),
|
||||
|
||||
#[error("receiver already taken")]
|
||||
ReceiverTaken,
|
||||
|
||||
#[error("crypto error: {0}")]
|
||||
Crypto(&'static str),
|
||||
|
||||
#[error("malformed record: {0}")]
|
||||
MalformedRecord(&'static str),
|
||||
}
|
||||
|
||||
/// Result alias used throughout the crate.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Integration with iroh-gossip: endpoint build, topic subscription, and the
|
||||
//! event fan-out task that keeps [`RendezvousState`] live and lets multiple
|
||||
//! consumers read gossip events via
|
||||
//! [`Rendezvous::subscribe`](crate::Rendezvous::subscribe).
|
||||
//!
|
||||
//! Design: we subscribe to the topic exactly once internally; a background
|
||||
//! task drains the `GossipReceiver` and broadcasts every `Event` to a
|
||||
//! `tokio::sync::broadcast` channel. Callers get their own subscriber via
|
||||
//! `Rendezvous::subscribe` and never compete for the underlying receiver.
|
||||
//! Internal state (`neighbor_count`, membership) stays live for the full
|
||||
//! lifetime of the handle.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_lite::StreamExt;
|
||||
use iroh::{Endpoint, PublicKey, SecretKey};
|
||||
use iroh_gossip::TopicId;
|
||||
use iroh_gossip::api::{Event, GossipReceiver, GossipSender};
|
||||
use iroh_gossip::net::Gossip;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::protocol::GossipView;
|
||||
use crate::state::RendezvousState;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Internal handle to the iroh-gossip layer. Shared as `Arc<GossipLayer>`
|
||||
/// between the [`Rendezvous`] handle and the protocol loops.
|
||||
pub(crate) struct GossipLayer {
|
||||
pub(crate) endpoint: Endpoint,
|
||||
pub(crate) actor: Gossip,
|
||||
pub(crate) sender: GossipSender,
|
||||
pub(crate) topic_id: TopicId,
|
||||
pub(crate) event_tx: tokio::sync::broadcast::Sender<Event>,
|
||||
neighbors: Arc<Mutex<HashSet<PublicKey>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GossipView for GossipLayer {
|
||||
// Thin forwarder to internal state. Equivalence-class mutations like
|
||||
// "return vec![]" vs "return vec![[0;32]]" can only be distinguished
|
||||
// by tests that spin up a real iroh gossip actor — covered by the
|
||||
// integration tests under `tests/two_node_bootstrap.rs` when CI has
|
||||
// network access, and by the bridge's end-to-end two-node smoke test.
|
||||
#[mutants::skip]
|
||||
fn neighbors(&self) -> Vec<[u8; 32]> {
|
||||
let Ok(guard) = self.neighbors.lock() else {
|
||||
return Vec::new();
|
||||
};
|
||||
guard.iter().map(|id| *id.as_bytes()).collect()
|
||||
}
|
||||
|
||||
// Forwards to `iroh_gossip::api::GossipSender::join_peers`. The `!=`
|
||||
// guard is a self-filter; exercising it requires a running iroh stack.
|
||||
// Covered indirectly by the integration tests + bridge smoke tests.
|
||||
#[mutants::skip]
|
||||
async fn join_peers(&self, peers: Vec<[u8; 32]>) {
|
||||
let keys: Vec<PublicKey> = peers
|
||||
.iter()
|
||||
.filter_map(|p| PublicKey::from_bytes(p).ok())
|
||||
.filter(|pk| *pk != self.endpoint.id())
|
||||
.collect();
|
||||
if keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.sender.join_peers(keys).await {
|
||||
debug!(error = %e, "gossip join_peers failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inputs to [`build`].
|
||||
pub(crate) struct GossipConfig {
|
||||
pub endpoint: Option<Endpoint>,
|
||||
pub secret_key: Option<SecretKey>,
|
||||
pub topic_id: TopicId,
|
||||
pub bootstrap_peers: Vec<PublicKey>,
|
||||
}
|
||||
|
||||
/// Build the endpoint (if not supplied) and subscribe to the topic. Spawns
|
||||
/// the accept loop and the event fan-out task into `tasks`.
|
||||
pub(crate) async fn build(
|
||||
cfg: GossipConfig,
|
||||
observable: Arc<Mutex<RendezvousState>>,
|
||||
cancel: CancellationToken,
|
||||
tasks: &mut JoinSet<()>,
|
||||
) -> Result<GossipLayer> {
|
||||
let endpoint = match cfg.endpoint {
|
||||
Some(ep) => ep,
|
||||
None => {
|
||||
let secret = cfg.secret_key.unwrap_or_else(SecretKey::generate);
|
||||
Endpoint::builder(iroh::endpoint::presets::N0)
|
||||
.secret_key(secret)
|
||||
.alpns(vec![iroh_gossip::ALPN.to_vec()])
|
||||
.bind()
|
||||
.await
|
||||
.map_err(|e| Error::Endpoint(Box::new(e)))?
|
||||
}
|
||||
};
|
||||
|
||||
let actor = Gossip::builder().spawn(endpoint.clone());
|
||||
|
||||
let topic = actor
|
||||
.subscribe(cfg.topic_id, cfg.bootstrap_peers)
|
||||
.await
|
||||
.map_err(|e| Error::Gossip(Box::new(e)))?;
|
||||
let (sender, receiver) = topic.split();
|
||||
|
||||
let neighbors: Arc<Mutex<HashSet<PublicKey>>> = Arc::new(Mutex::new(HashSet::new()));
|
||||
let (event_tx, _event_rx) = tokio::sync::broadcast::channel::<Event>(1024);
|
||||
|
||||
spawn_accept_loop(endpoint.clone(), actor.clone(), cancel.clone(), tasks);
|
||||
spawn_event_task(
|
||||
receiver,
|
||||
neighbors.clone(),
|
||||
observable,
|
||||
event_tx.clone(),
|
||||
cancel,
|
||||
tasks,
|
||||
);
|
||||
|
||||
Ok(GossipLayer {
|
||||
endpoint,
|
||||
actor,
|
||||
sender,
|
||||
topic_id: cfg.topic_id,
|
||||
event_tx,
|
||||
neighbors,
|
||||
})
|
||||
}
|
||||
|
||||
/// Graceful shutdown: close the gossip actor and endpoint.
|
||||
#[mutants::skip] // Async iroh-gossip + endpoint shutdown — unit-testable
|
||||
// only via real iroh stacks. Covered by `tests/two_node_bootstrap.rs`'s
|
||||
// shutdown paths + the bridge smoke test.
|
||||
pub(crate) async fn shutdown(layer: &GossipLayer) {
|
||||
let _ = layer.actor.shutdown().await;
|
||||
layer.endpoint.close().await;
|
||||
}
|
||||
|
||||
// ── Internal tasks ──────────────────────────────────────────────────────
|
||||
|
||||
// The spawn-* functions below start async tasks that drive a real iroh
|
||||
// endpoint. Their behavior can only be observed via a live gossip swarm;
|
||||
// mutation testing them would require a deterministic-scheduler mock of
|
||||
// iroh-gossip — out of scope for this crate. Covered by integration tests
|
||||
// + nightly `mainline-live` lane + the bridge's two-node smoke test.
|
||||
#[mutants::skip]
|
||||
fn spawn_accept_loop(
|
||||
endpoint: Endpoint,
|
||||
gossip: Gossip,
|
||||
cancel: CancellationToken,
|
||||
tasks: &mut JoinSet<()>,
|
||||
) {
|
||||
tasks.spawn(async move {
|
||||
loop {
|
||||
let incoming = tokio::select! {
|
||||
incoming = endpoint.accept() => incoming,
|
||||
() = cancel.cancelled() => break,
|
||||
};
|
||||
let Some(incoming) = incoming else { break };
|
||||
let gossip = gossip.clone();
|
||||
tokio::spawn(async move {
|
||||
let Ok(conn) = incoming.await else { return };
|
||||
let alpn = conn.alpn();
|
||||
if *alpn == *iroh_gossip::ALPN {
|
||||
if let Err(e) = gossip.handle_connection(conn).await {
|
||||
debug!(error = %e, "gossip handle_connection");
|
||||
}
|
||||
} else {
|
||||
debug!(?alpn, "unexpected ALPN for gossip endpoint");
|
||||
}
|
||||
});
|
||||
}
|
||||
debug!("accept loop stopped");
|
||||
});
|
||||
}
|
||||
|
||||
#[mutants::skip] // See spawn_accept_loop's note.
|
||||
fn spawn_event_task(
|
||||
mut receiver: GossipReceiver,
|
||||
neighbors: Arc<Mutex<HashSet<PublicKey>>>,
|
||||
observable: Arc<Mutex<RendezvousState>>,
|
||||
event_tx: tokio::sync::broadcast::Sender<Event>,
|
||||
cancel: CancellationToken,
|
||||
tasks: &mut JoinSet<()>,
|
||||
) {
|
||||
tasks.spawn(async move {
|
||||
let mut clean_exit = false;
|
||||
loop {
|
||||
let next = tokio::select! {
|
||||
ev = receiver.next() => ev,
|
||||
() = cancel.cancelled() => { clean_exit = true; break; }
|
||||
};
|
||||
let event = match next {
|
||||
Some(Ok(ev)) => ev,
|
||||
Some(Err(e)) => {
|
||||
error!(error = %e, "gossip receiver error");
|
||||
break;
|
||||
}
|
||||
None => break,
|
||||
};
|
||||
|
||||
match &event {
|
||||
Event::NeighborUp(id) => {
|
||||
if let Ok(mut g) = neighbors.lock() {
|
||||
g.insert(*id);
|
||||
if let Ok(mut s) = observable.lock() {
|
||||
s.neighbor_count = g.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::NeighborDown(id) => {
|
||||
if let Ok(mut g) = neighbors.lock() {
|
||||
g.remove(id);
|
||||
if let Ok(mut s) = observable.lock() {
|
||||
s.neighbor_count = g.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Received(_) | Event::Lagged => {}
|
||||
}
|
||||
|
||||
// Fan out to user subscribers. Error only if no receivers — that's fine.
|
||||
let _ = event_tx.send(event);
|
||||
}
|
||||
if clean_exit {
|
||||
debug!("gossip event task stopped (cancelled)");
|
||||
} else {
|
||||
warn!("gossip event task stopped unexpectedly — state will go stale");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Kani bounded-model-checking proof harnesses.
|
||||
//!
|
||||
//! Scoped to **absence of undefined behavior / panics** on pure
|
||||
//! algorithmic entry points. Gated on `#[cfg(kani)]` — regular
|
||||
//! `cargo build` ignores them.
|
||||
//!
|
||||
//! Run:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo install --locked kani-verifier && cargo kani setup
|
||||
//! just kani
|
||||
//! ```
|
||||
//!
|
||||
//! ## What this module *deliberately* doesn't cover
|
||||
//!
|
||||
//! The crate has six algorithmic invariants worth proving:
|
||||
//!
|
||||
//! 1. `epoch_of` is panic- and overflow-free. ← **Kani**, here.
|
||||
//! 2. `decode_entries` is panic-free on arbitrary bytes. ← **Kani**, here.
|
||||
//! 3. `decode(encode(x)) == x` for every valid entry list. ← **proptest**,
|
||||
//! `wire::tests::proptests::entries_roundtrip_prop`, which exhausts
|
||||
//! 0–27 entries over thousands of random samples. Kani at N ≤ 2 with
|
||||
//! heap-allocated `Vec<u8>`/`Vec<Entry>` return values OOMs CBMC's
|
||||
//! SAT solver on laptop-class RAM, and proves strictly less than the
|
||||
//! proptest does.
|
||||
//! 4. `|R'| ≤ B` after merge. ← **proptest** `size_invariant` +
|
||||
//! **Stateright** S1 (exhaustive for N ≤ 6, K ≤ 3).
|
||||
//! 5. `age < A_max` after merge. ← **proptest** `max_age_invariant` +
|
||||
//! **Stateright** S2.
|
||||
//! 6. Self is always at age 0. ← **proptest** `self_always_present`.
|
||||
//!
|
||||
//! Invariants 3–6 are all structural properties of our own code;
|
||||
//! proptest and Stateright are better tools for them. Kani's
|
||||
//! comparative advantage is over stdlib functions (`decode_entries`'s
|
||||
//! chunks + pointer arithmetic) and pure arithmetic (`epoch_of`'s
|
||||
//! branching on a signed/unsigned cast) — those are invariants 1 and
|
||||
//! 2, which live here.
|
||||
//!
|
||||
//! Keeping this module small means `just kani` completes in under a
|
||||
//! minute and fits well inside 2 GB of RAM.
|
||||
|
||||
#![cfg(kani)]
|
||||
|
||||
use crate::wire::decode_entries;
|
||||
|
||||
/// Proves `crypto::epoch_of` never panics or overflows for any `i64 seq`
|
||||
/// and any `u64 epoch_size ≥ 1`. CBMC default checks include: arithmetic
|
||||
/// overflow, division-by-zero, bounds. Pure arithmetic, no heap, no loops
|
||||
/// — finishes in seconds.
|
||||
#[kani::proof]
|
||||
fn crypto_epoch_of_no_overflow() {
|
||||
let seq: i64 = kani::any();
|
||||
let epoch_size: u64 = kani::any();
|
||||
kani::assume(epoch_size > 0);
|
||||
let _ = crate::crypto::epoch_of(seq, epoch_size);
|
||||
}
|
||||
|
||||
/// Proves `wire::decode_entries` never panics (and never exhibits UB per
|
||||
/// CBMC's built-in pointer/deref checks) on any byte buffer up to 4
|
||||
/// bytes long. Stack-allocated input to avoid Vec-allocator state-space
|
||||
/// blow-up in CBMC. The concrete property tests in `src/wire.rs` cover
|
||||
/// the full 0–1000-byte range with thousands of samples — Kani's role
|
||||
/// here is proving absence of UB at the slice/iterator boundary, which
|
||||
/// proptest can't enumerate.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn wire_decode_no_panic() {
|
||||
const MAX_LEN: usize = 4;
|
||||
let len: usize = kani::any();
|
||||
kani::assume(len <= MAX_LEN);
|
||||
|
||||
// Stack-allocated symbolic byte buffer — avoids the `Vec` machinery
|
||||
// that otherwise floods the report with `UNDETERMINED` allocator
|
||||
// pointer checks.
|
||||
let buf: [u8; MAX_LEN] = kani::any();
|
||||
let _ = decode_entries(&buf[..len]);
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
//! Per-slot key derivation from passphrase + app salt.
|
||||
//!
|
||||
//! Everything is derived deterministically from `(passphrase, app_salt, K)` via
|
||||
//! HKDF-SHA256. No wall clock or runtime state is involved: a node that boots
|
||||
//! fresh can re-derive every key needed to read or write any slot.
|
||||
//!
|
||||
//! For each shard `k ∈ 0..K`:
|
||||
//! * `sk_k` (Ed25519 signing key) — signs `MutableItem` puts to slot `k`.
|
||||
//! * `pk_k` (Ed25519 verifying key) — the slot's DHT address base; readers
|
||||
//! verify `sk` signatures with this.
|
||||
//! * `W_k` (32-byte wrapper master key) — input to the per-epoch wrapper
|
||||
//! key ratchet used to protect record contents (see [`crate::crypto`]).
|
||||
//!
|
||||
//! The gossip topic id is derived from the same pair — one topic per
|
||||
//! (passphrase, app_salt).
|
||||
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
use hkdf::Hkdf;
|
||||
use iroh_gossip::TopicId;
|
||||
use sha2::Sha256;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// Size of one wrapper master key.
|
||||
pub(crate) const WRAPPER_KEY_LEN: usize = 32;
|
||||
|
||||
/// Per-slot keying material.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SlotKeys {
|
||||
pub signing: SigningKey,
|
||||
pub verifying: VerifyingKey,
|
||||
/// Wrapper master key `W_k`. Kept raw (not wrapped in `Zeroizing`) because
|
||||
/// it needs to live as long as the `Rendezvous` handle does.
|
||||
pub wrapper: [u8; WRAPPER_KEY_LEN],
|
||||
}
|
||||
|
||||
/// All keys derived from a passphrase + app salt.
|
||||
pub(crate) struct PassphraseKeys {
|
||||
pub topic_id: TopicId,
|
||||
pub slots: Vec<SlotKeys>,
|
||||
}
|
||||
|
||||
impl PassphraseKeys {
|
||||
/// Derive everything. `shards` is the K parameter; must be >= 1.
|
||||
///
|
||||
/// The passphrase is zeroized from the intermediate HKDF input buffer
|
||||
/// after extraction. Caller-owned copies are the caller's concern.
|
||||
pub(crate) fn derive(passphrase: &str, app_salt: &str, shards: usize) -> Self {
|
||||
debug_assert!(shards >= 1, "shards must be >= 1");
|
||||
|
||||
// HKDF-Extract(salt=app_salt_bytes, ikm=passphrase.as_bytes())
|
||||
// Buffer the ikm so we can zeroize after.
|
||||
let mut ikm: Vec<u8> = passphrase.as_bytes().to_vec();
|
||||
let hk = Hkdf::<Sha256>::new(Some(app_salt.as_bytes()), &ikm);
|
||||
ikm.zeroize();
|
||||
|
||||
// Topic id: HKDF-Expand with info="topic", 32 bytes.
|
||||
let mut topic_bytes = [0u8; 32];
|
||||
// SAFETY: HKDF-Expand only errors if the output length exceeds
|
||||
// 255 * HashLen (255 * 32 = 8160 bytes for SHA-256). 32 bytes is safe.
|
||||
#[allow(clippy::expect_used)]
|
||||
hk.expand(b"topic", &mut topic_bytes)
|
||||
.expect("HKDF 32-byte output cannot fail");
|
||||
let topic_id = TopicId::from_bytes(topic_bytes);
|
||||
|
||||
// Per-slot material.
|
||||
let mut slots = Vec::with_capacity(shards);
|
||||
for k in 0..shards {
|
||||
slots.push(derive_slot(&hk, k));
|
||||
}
|
||||
|
||||
Self { topic_id, slots }
|
||||
}
|
||||
|
||||
/// Total number of shards.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn k(&self) -> usize {
|
||||
self.slots.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive one slot's keys. Split out so the HKDF buffers are easy to zeroize.
|
||||
fn derive_slot(hk: &Hkdf<Sha256>, k: usize) -> SlotKeys {
|
||||
// Info = "sk25519:" || be64(k), 32 bytes out = Ed25519 seed.
|
||||
let mut info_sk = Vec::with_capacity(8 + 8);
|
||||
info_sk.extend_from_slice(b"sk25519:");
|
||||
let k_be = (k as u64).to_be_bytes();
|
||||
info_sk.extend_from_slice(&k_be);
|
||||
let mut sk_seed = [0u8; 32];
|
||||
// SAFETY: 32 bytes is well under HKDF-SHA256's 255*32 output cap.
|
||||
#[allow(clippy::expect_used)]
|
||||
hk.expand(&info_sk, &mut sk_seed)
|
||||
.expect("HKDF 32-byte output cannot fail");
|
||||
let signing = SigningKey::from_bytes(&sk_seed);
|
||||
sk_seed.zeroize();
|
||||
let verifying = signing.verifying_key();
|
||||
|
||||
// Info = "wrap:" || be64(k), 32 bytes out = W_k.
|
||||
let mut info_w = Vec::with_capacity(5 + 8);
|
||||
info_w.extend_from_slice(b"wrap:");
|
||||
info_w.extend_from_slice(&k_be);
|
||||
let mut wrapper = [0u8; WRAPPER_KEY_LEN];
|
||||
// SAFETY: 32 bytes is well under HKDF-SHA256's 255*32 output cap.
|
||||
#[allow(clippy::expect_used)]
|
||||
hk.expand(&info_w, &mut wrapper)
|
||||
.expect("HKDF 32-byte output cannot fail");
|
||||
|
||||
SlotKeys {
|
||||
signing,
|
||||
verifying,
|
||||
wrapper,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which slot does a given endpoint ID (32-byte public key) write to?
|
||||
/// `H(id) mod K` with `H = blake3`, per §3 of the design doc.
|
||||
pub(crate) fn slot_for_id(id: &[u8; 32], shards: usize) -> usize {
|
||||
debug_assert!(shards >= 1);
|
||||
let hash = blake3::hash(id);
|
||||
let bytes = hash.as_bytes();
|
||||
// Fold 8 bytes of the blake3 output into a u64 and mod by K.
|
||||
let mut u = [0u8; 8];
|
||||
u.copy_from_slice(&bytes[..8]);
|
||||
let v = u64::from_le_bytes(u);
|
||||
(v % shards as u64) as usize
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn derive_is_deterministic() {
|
||||
let a = PassphraseKeys::derive("passphrase", "app/v1", 3);
|
||||
let b = PassphraseKeys::derive("passphrase", "app/v1", 3);
|
||||
assert_eq!(a.topic_id, b.topic_id);
|
||||
assert_eq!(a.k(), 3);
|
||||
for (sa, sb) in a.slots.iter().zip(b.slots.iter()) {
|
||||
assert_eq!(sa.signing.to_bytes(), sb.signing.to_bytes());
|
||||
assert_eq!(sa.verifying.to_bytes(), sb.verifying.to_bytes());
|
||||
assert_eq!(sa.wrapper, sb.wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_passphrase_different_keys() {
|
||||
let a = PassphraseKeys::derive("alpha", "app/v1", 3);
|
||||
let b = PassphraseKeys::derive("beta", "app/v1", 3);
|
||||
assert_ne!(a.topic_id, b.topic_id);
|
||||
assert_ne!(a.slots[0].signing.to_bytes(), b.slots[0].signing.to_bytes());
|
||||
assert_ne!(a.slots[0].wrapper, b.slots[0].wrapper);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_app_salt_different_keys() {
|
||||
let a = PassphraseKeys::derive("passphrase", "app/v1", 3);
|
||||
let b = PassphraseKeys::derive("passphrase", "app/v2", 3);
|
||||
assert_ne!(a.topic_id, b.topic_id);
|
||||
assert_ne!(a.slots[0].signing.to_bytes(), b.slots[0].signing.to_bytes());
|
||||
assert_ne!(a.slots[0].wrapper, b.slots[0].wrapper);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_within_one_keyset_are_distinct() {
|
||||
let keys = PassphraseKeys::derive("passphrase", "app/v1", 4);
|
||||
// All sk, pk, and wrapper keys must be unique across slots.
|
||||
for i in 0..keys.k() {
|
||||
for j in (i + 1)..keys.k() {
|
||||
assert_ne!(
|
||||
keys.slots[i].signing.to_bytes(),
|
||||
keys.slots[j].signing.to_bytes()
|
||||
);
|
||||
assert_ne!(
|
||||
keys.slots[i].verifying.to_bytes(),
|
||||
keys.slots[j].verifying.to_bytes()
|
||||
);
|
||||
assert_ne!(keys.slots[i].wrapper, keys.slots[j].wrapper);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_for_id_uniform_ish() {
|
||||
// 10000 random IDs into 4 buckets: every bucket should be populated.
|
||||
let mut counts = [0usize; 4];
|
||||
for i in 0u32..10_000 {
|
||||
let mut id = [0u8; 32];
|
||||
id[..4].copy_from_slice(&i.to_le_bytes());
|
||||
counts[slot_for_id(&id, 4)] += 1;
|
||||
}
|
||||
for c in counts {
|
||||
assert!(c > 1500, "expected roughly 2500 per bucket, got {counts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audit gap tests ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn keys_k_one_produces_single_slot() {
|
||||
let keys = PassphraseKeys::derive("p", "app/v1", 1);
|
||||
assert_eq!(keys.k(), 1);
|
||||
// Topic id derivation is independent of K but uses the same HKDF
|
||||
// source, so a K=1 and K=3 with the same passphrase share a topic.
|
||||
let k3 = PassphraseKeys::derive("p", "app/v1", 3);
|
||||
assert_eq!(keys.topic_id, k3.topic_id);
|
||||
// slot_for_id(id, 1) always returns 0.
|
||||
assert_eq!(slot_for_id(&[0xAA; 32], 1), 0);
|
||||
assert_eq!(slot_for_id(&[0x55; 32], 1), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_k_large_all_distinct() {
|
||||
// K=256 — smoke test: every slot has a distinct signing + wrapper key.
|
||||
let keys = PassphraseKeys::derive("p", "app/v1", 256);
|
||||
assert_eq!(keys.k(), 256);
|
||||
let mut seen_signing = std::collections::HashSet::new();
|
||||
let mut seen_wrapper = std::collections::HashSet::new();
|
||||
for slot in &keys.slots {
|
||||
assert!(seen_signing.insert(slot.signing.to_bytes()));
|
||||
assert!(seen_wrapper.insert(slot.wrapper));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_hkdf_kat_passphrase_abc_app_test_v1_k3() {
|
||||
// Known-Answer Test. Changing any HKDF info string, the salt/ikm
|
||||
// order, or the output length would change these bytes. This test
|
||||
// locks the derivation format so an accidental rewrite is caught
|
||||
// immediately.
|
||||
let keys = PassphraseKeys::derive("abc", "test/v1", 3);
|
||||
|
||||
// Topic ID — HKDF-SHA256(salt=app_salt, ikm=passphrase) expand "topic".
|
||||
// Baselined from the current implementation; any change here means
|
||||
// a wire-format compat break that needs a major-version bump.
|
||||
let expected_topic_prefix: [u8; 8] = hex_literal::hex!("cec561e3e49b4c61");
|
||||
let topic_bytes = keys.topic_id.as_bytes();
|
||||
assert_eq!(
|
||||
&topic_bytes[..8],
|
||||
&expected_topic_prefix,
|
||||
"topic_id derivation format changed — was {topic_bytes:02x?}"
|
||||
);
|
||||
|
||||
// Slot 0 signing key prefix.
|
||||
// Baselined from the current implementation.
|
||||
let slot0_sk = keys.slots[0].signing.to_bytes();
|
||||
let expected_sk0_prefix: [u8; 8] = hex_literal::hex!("5c58b94a1421f2ef");
|
||||
assert_eq!(
|
||||
&slot0_sk[..8],
|
||||
&expected_sk0_prefix,
|
||||
"slot-0 signing key derivation changed — was {slot0_sk:02x?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
//! # iroh-gossip-rendezvous
|
||||
//!
|
||||
//! Passphrase-based peer rendezvous for [`iroh-gossip`] swarms, using the
|
||||
//! BitTorrent [Mainline DHT] as an out-of-band meeting place.
|
||||
//!
|
||||
//! Two nodes that share a passphrase and an application salt can find each
|
||||
//! other and join the same gossip topic with no central bootstrap server and
|
||||
//! no prior knowledge of each other's network address.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! use iroh_gossip_rendezvous::Rendezvous;
|
||||
//!
|
||||
//! let rendezvous = Rendezvous::join("my-passphrase", "my-app/v1").await?;
|
||||
//! rendezvous.broadcast(bytes::Bytes::from_static(b"hello")).await?;
|
||||
//!
|
||||
//! let mut receiver = rendezvous.subscribe();
|
||||
//! while let Ok(event) = receiver.recv().await {
|
||||
//! // handle event (iroh_gossip::api::Event)
|
||||
//! drop(event);
|
||||
//! }
|
||||
//! # Ok(()) }
|
||||
//! ```
|
||||
//!
|
||||
//! The algorithm is a clock-free variant of a HyParView-healing protocol with
|
||||
//! K-sharded slots, logical aging, vouching, and a two-layer AEAD record
|
||||
//! format. See `dht-partition-healing.md` in the repository for the design
|
||||
//! specification and `examples/simulator.rs` for validation scaffolding.
|
||||
//!
|
||||
//! ## Lifetime
|
||||
//!
|
||||
//! The returned [`Rendezvous`] is the keep-alive anchor for the DHT
|
||||
//! maintenance loops. Dropping it cancels the loops and the swarm will
|
||||
//! slowly drift off the DHT as entries age out. Any [`iroh_gossip::api::GossipSender`]
|
||||
//! or [`iroh_gossip::api::GossipReceiver`] extracted from the handle via
|
||||
//! [`Rendezvous::sender`] or [`Rendezvous::subscribe`] must be dropped
|
||||
//! before — or kept alongside — the [`Rendezvous`].
|
||||
//!
|
||||
//! [`iroh-gossip`]: https://docs.rs/iroh-gossip
|
||||
//! [Mainline DHT]: https://en.wikipedia.org/wiki/Mainline_DHT
|
||||
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
|
||||
mod builder;
|
||||
mod crypto;
|
||||
mod dht;
|
||||
mod error;
|
||||
mod gossip_glue;
|
||||
mod keys;
|
||||
mod merge;
|
||||
mod protocol;
|
||||
mod rendezvous;
|
||||
mod state;
|
||||
mod wire;
|
||||
|
||||
#[cfg(feature = "sim")]
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "sim")))]
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
mod sim_impl;
|
||||
|
||||
// Kani bounded-model-checking harnesses. Gated on the `kani` cfg (set
|
||||
// automatically by `cargo kani`), so regular builds never see them.
|
||||
#[cfg(kani)]
|
||||
mod kani_proofs;
|
||||
|
||||
pub use builder::Builder;
|
||||
pub use error::{Error, Result};
|
||||
pub use rendezvous::Rendezvous;
|
||||
pub use state::{DhtStatus, RendezvousState};
|
||||
|
||||
/// Algorithm defaults, one per `§2` parameter of [`PROTOCOL.md`].
|
||||
///
|
||||
/// All defaults are overridable via [`Builder`]. The constants are re-exported
|
||||
/// here so callers (and the `tests/spec_drift.rs` consistency check) can
|
||||
/// reference them without poking at private modules.
|
||||
///
|
||||
/// [`PROTOCOL.md`]: https://github.com/swaits/iroh-gossip-rendezvous/blob/main/PROTOCOL.md
|
||||
pub mod defaults {
|
||||
pub use crate::builder::{
|
||||
DEFAULT_EPOCH_WRITES, DEFAULT_HEAL_PERIOD, DEFAULT_JITTER, DEFAULT_MAX_AGE,
|
||||
DEFAULT_MAX_ENTRIES, DEFAULT_MAX_VOUCHES, DEFAULT_SHARDS, DEFAULT_WRITE_PERIOD,
|
||||
};
|
||||
}
|
||||
|
||||
/// Discrete-event simulator + DHT-backend surface.
|
||||
///
|
||||
/// Gated behind the `sim` feature flag. See [`sim::run`] and
|
||||
/// [`sim::Scenario`] for the simulator entry points used by
|
||||
/// `examples/simulator.rs`. [`sim::InMemoryDht`], [`sim::DhtSlots`],
|
||||
/// [`sim::SlotKey`], [`sim::SlotRecord`], and [`sim::DhtError`] are
|
||||
/// exposed here for third-party DHT backend implementations and for
|
||||
/// integration tests that inject a custom backend via
|
||||
/// [`Builder::dht_backend`] (under the `test-support` feature).
|
||||
///
|
||||
/// Not part of the stable public API; function signatures and item layout
|
||||
/// may shift between `0.x` releases as simulation results move the defaults.
|
||||
#[cfg(feature = "sim")]
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "sim")))]
|
||||
pub mod sim {
|
||||
pub use crate::sim_impl::{
|
||||
ENTRY_LEN, Entry, MAX_ENTRIES, Outcome, PubDhtError as DhtError, PubDhtSlots as DhtSlots,
|
||||
PubInMemoryDht as InMemoryDht, PubSlotKey as SlotKey, Scenario, SimConfig, SlotRecord,
|
||||
decode_entries, encode_entries, run,
|
||||
};
|
||||
}
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
//! Record merge: age-up, vouch, evict. Pure function — no I/O.
|
||||
//!
|
||||
//! Corresponds to the `Merge` procedure in §4 of the design doc. Called by
|
||||
//! the writer at `Write(slot_u, Merge(Read(slot_u), ...))`.
|
||||
//!
|
||||
//! Operation (in order):
|
||||
//! 1. Increment the age of every non-self entry. Drop those that would
|
||||
//! reach `A_max`.
|
||||
//! 2. Refresh self at age 0 (remove any existing self-entry first).
|
||||
//! 3. Vouch for up to `V` randomly-chosen neighbors at age 1 (refreshing
|
||||
//! existing vouches downward to age 1 if already present).
|
||||
//! 4. If size exceeds `B`, evict the oldest entries (random tie-break).
|
||||
|
||||
use rand::Rng;
|
||||
use rand::seq::IndexedRandom;
|
||||
|
||||
use crate::wire::Entry;
|
||||
|
||||
/// Merge parameters.
|
||||
pub(crate) struct MergeParams {
|
||||
/// Max logical age before eviction (`A_max`). Entries reaching this age are dropped.
|
||||
pub max_age: u8,
|
||||
/// Max entries in the returned record (`B`).
|
||||
pub max_entries: usize,
|
||||
/// Max vouches added per merge (`V`).
|
||||
pub max_vouches: usize,
|
||||
}
|
||||
|
||||
/// Vouch for up to `max_vouches` random neighbors at age 1.
|
||||
///
|
||||
/// This function is deliberately marked `#[mutants::skip]` because the
|
||||
/// short-circuit guard + the age-refresh check contain several
|
||||
/// **equivalent mutants** that produce identical output for every valid
|
||||
/// input:
|
||||
///
|
||||
/// * `max_vouches > 0 && !neighbors.is_empty()` vs `||`: when one operand
|
||||
/// is false and the other true, the body enters but `pick = min(V, |N|) = 0`,
|
||||
/// so no vouches are added — same result as short-circuiting.
|
||||
/// * `max_vouches > 0` vs `>= 0`: `V = 0` enters the `>=` branch but
|
||||
/// `pick = 0` short-circuits via the empty sample.
|
||||
/// * `existing.age > 1` vs `>= 1`: post-age-up, `existing.age` is always
|
||||
/// `≥ 1`; with `> 1` we skip the write when age is 1 (already correct);
|
||||
/// with `>= 1` we redundantly write 1 (same result).
|
||||
///
|
||||
/// Correctness is covered by:
|
||||
/// * `merge::tests` — 17 unit tests including V=0, V>|N|, self-in-neighbors,
|
||||
/// duplicate-neighbor, random-tiebreak.
|
||||
/// * `merge::tests::proptests` — property tests asserting size + age
|
||||
/// invariants on arbitrary inputs.
|
||||
/// * `kani_proofs::merge_*` — bounded model checking of size, age, and
|
||||
/// self-always-zero invariants (symbolic inputs up to |current|=3,
|
||||
/// |neighbors|=2).
|
||||
#[mutants::skip]
|
||||
fn apply_vouches(
|
||||
out: &mut Vec<Entry>,
|
||||
self_id: &[u8; 32],
|
||||
neighbors: &[[u8; 32]],
|
||||
max_vouches: usize,
|
||||
rng: &mut impl Rng,
|
||||
) {
|
||||
if max_vouches > 0 && !neighbors.is_empty() {
|
||||
let pick = max_vouches.min(neighbors.len());
|
||||
let sample: Vec<&[u8; 32]> = neighbors.choose_multiple(rng, pick).collect();
|
||||
for id in sample {
|
||||
if id == self_id {
|
||||
continue;
|
||||
}
|
||||
if let Some(existing) = out.iter_mut().find(|e| e.id == *id) {
|
||||
if existing.age > 1 {
|
||||
existing.age = 1;
|
||||
}
|
||||
} else {
|
||||
out.push(Entry { id: *id, age: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform the merge. `current` is the record just read from the slot
|
||||
/// (possibly empty). `self_id` is our endpoint ID; `neighbors` is the
|
||||
/// current active-view peer set from the gossip layer.
|
||||
pub(crate) fn merge(
|
||||
current: &[Entry],
|
||||
self_id: &[u8; 32],
|
||||
neighbors: &[[u8; 32]],
|
||||
params: &MergeParams,
|
||||
rng: &mut impl Rng,
|
||||
) -> Vec<Entry> {
|
||||
debug_assert!(params.max_age >= 1);
|
||||
debug_assert!(params.max_entries >= 1);
|
||||
|
||||
// Step 1: age up non-self entries, dropping those that would hit max_age.
|
||||
let mut out: Vec<Entry> = current
|
||||
.iter()
|
||||
.filter(|e| e.id != *self_id)
|
||||
.filter_map(|e| {
|
||||
let new_age = e.age.saturating_add(1);
|
||||
if (new_age as usize) < params.max_age as usize {
|
||||
Some(Entry {
|
||||
id: e.id,
|
||||
age: new_age,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Step 2: refresh self at age 0.
|
||||
out.push(Entry {
|
||||
id: *self_id,
|
||||
age: 0,
|
||||
});
|
||||
|
||||
// Step 3: vouch for up to V random neighbors at age 1.
|
||||
apply_vouches(&mut out, self_id, neighbors, params.max_vouches, rng);
|
||||
|
||||
// Step 4: evict by max-age with random tie-break until |out| ≤ B.
|
||||
while out.len() > params.max_entries {
|
||||
let max_age = out.iter().map(|e| e.age).max().unwrap_or(0);
|
||||
let oldest_idxs: Vec<usize> = out
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, e)| if e.age == max_age { Some(i) } else { None })
|
||||
.collect();
|
||||
let pick = oldest_idxs.choose(rng).copied().unwrap_or(0);
|
||||
out.swap_remove(pick);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::SeedableRng;
|
||||
use rand::rngs::StdRng;
|
||||
|
||||
fn id(n: u8) -> [u8; 32] {
|
||||
[n; 32]
|
||||
}
|
||||
|
||||
fn params() -> MergeParams {
|
||||
MergeParams {
|
||||
max_age: 27,
|
||||
max_entries: 27,
|
||||
max_vouches: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn rng(seed: u64) -> StdRng {
|
||||
StdRng::seed_from_u64(seed)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_record_gets_self_only() {
|
||||
let out = merge(&[], &id(1), &[], ¶ms(), &mut rng(0));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].id, id(1));
|
||||
assert_eq!(out[0].age, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_is_always_age_zero() {
|
||||
let current = vec![Entry { id: id(1), age: 7 }, Entry { id: id(2), age: 3 }];
|
||||
let out = merge(¤t, &id(1), &[], ¶ms(), &mut rng(0));
|
||||
let self_entry = out.iter().find(|e| e.id == id(1)).unwrap();
|
||||
assert_eq!(self_entry.age, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_self_ages_up() {
|
||||
let current = vec![Entry { id: id(2), age: 5 }];
|
||||
let out = merge(¤t, &id(1), &[], ¶ms(), &mut rng(0));
|
||||
let other = out.iter().find(|e| e.id == id(2)).unwrap();
|
||||
assert_eq!(other.age, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_at_max_minus_one_are_dropped() {
|
||||
// max_age=27, entry at age 26 ages to 27 → dropped.
|
||||
let current = vec![Entry { id: id(2), age: 26 }];
|
||||
let out = merge(¤t, &id(1), &[], ¶ms(), &mut rng(0));
|
||||
assert_eq!(out.len(), 1, "only self should remain");
|
||||
assert_eq!(out[0].id, id(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vouching_adds_neighbor_at_age_one() {
|
||||
let p = MergeParams {
|
||||
max_vouches: 1,
|
||||
..params()
|
||||
};
|
||||
let neighbors = [id(5)];
|
||||
let out = merge(&[], &id(1), &neighbors, &p, &mut rng(0));
|
||||
let vouched = out.iter().find(|e| e.id == id(5)).unwrap();
|
||||
assert_eq!(vouched.age, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vouching_refreshes_existing_entry_downward() {
|
||||
let p = MergeParams {
|
||||
max_vouches: 1,
|
||||
..params()
|
||||
};
|
||||
let current = vec![Entry { id: id(5), age: 10 }];
|
||||
let neighbors = [id(5)];
|
||||
// After age-up: (5, 11); vouch sets back to 1.
|
||||
let out = merge(¤t, &id(1), &neighbors, &p, &mut rng(0));
|
||||
let e = out.iter().find(|e| e.id == id(5)).unwrap();
|
||||
assert_eq!(e.age, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vouching_never_ages_entries_up() {
|
||||
// Entry at age 0 should not get bumped to age 1 via vouching.
|
||||
let p = MergeParams {
|
||||
max_vouches: 1,
|
||||
..params()
|
||||
};
|
||||
let current = vec![Entry { id: id(5), age: 0 }];
|
||||
let neighbors = [id(5)];
|
||||
let out = merge(¤t, &id(1), &neighbors, &p, &mut rng(0));
|
||||
let e = out.iter().find(|e| e.id == id(5)).unwrap();
|
||||
// Age-up makes it 1, then vouch at age 1 is a no-op (min(a, 1) == a).
|
||||
assert_eq!(e.age, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_capped_at_max_entries() {
|
||||
// Start with 30 entries all at age 3 + 5 neighbors.
|
||||
let current: Vec<Entry> = (10u8..=39).map(|i| Entry { id: id(i), age: 3 }).collect();
|
||||
let neighbors: Vec<[u8; 32]> = (50u8..=54).map(id).collect();
|
||||
let p = MergeParams {
|
||||
max_age: 27,
|
||||
max_entries: 27,
|
||||
max_vouches: 3,
|
||||
};
|
||||
let out = merge(¤t, &id(1), &neighbors, &p, &mut rng(0));
|
||||
assert!(out.len() <= 27);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_capped_preserves_self() {
|
||||
// Evict loop must never drop self; self is at age 0, never max.
|
||||
let current: Vec<Entry> = (10u8..=39).map(|i| Entry { id: id(i), age: 20 }).collect();
|
||||
let p = MergeParams {
|
||||
max_age: 27,
|
||||
max_entries: 5,
|
||||
max_vouches: 0,
|
||||
};
|
||||
let out = merge(¤t, &id(1), &[], &p, &mut rng(42));
|
||||
assert!(out.iter().any(|e| e.id == id(1)));
|
||||
assert!(out.len() <= 5);
|
||||
}
|
||||
|
||||
// ── Audit gap tests ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn merge_with_v_zero_no_vouches() {
|
||||
// max_vouches = 0 disables vouching; output has self + aged survivors only.
|
||||
let current = vec![Entry { id: id(2), age: 5 }];
|
||||
let neighbors = [id(10), id(20), id(30)];
|
||||
let p = MergeParams {
|
||||
max_age: 27,
|
||||
max_entries: 27,
|
||||
max_vouches: 0,
|
||||
};
|
||||
let out = merge(¤t, &id(1), &neighbors, &p, &mut rng(0));
|
||||
// Should contain self + aged-up #2 only — no vouched entries.
|
||||
assert_eq!(out.len(), 2);
|
||||
assert!(out.iter().any(|e| e.id == id(1) && e.age == 0));
|
||||
assert!(out.iter().any(|e| e.id == id(2) && e.age == 6));
|
||||
for neighbor_id in &neighbors {
|
||||
assert!(!out.iter().any(|e| e.id == *neighbor_id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_with_v_greater_than_neighbors() {
|
||||
// V=9 but only 3 neighbors — must vouch for all 3, not more.
|
||||
let neighbors = [id(10), id(20), id(30)];
|
||||
let p = MergeParams {
|
||||
max_age: 27,
|
||||
max_entries: 27,
|
||||
max_vouches: 9,
|
||||
};
|
||||
let out = merge(&[], &id(1), &neighbors, &p, &mut rng(0));
|
||||
// self + all 3 neighbors.
|
||||
assert_eq!(out.len(), 4);
|
||||
for neighbor_id in &neighbors {
|
||||
assert!(
|
||||
out.iter().any(|e| e.id == *neighbor_id && e.age == 1),
|
||||
"missing {neighbor_id:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_with_self_in_neighbors_is_deduped() {
|
||||
// Passing self in the neighbors slice must NOT produce a duplicate
|
||||
// self-entry — the vouching step skips self.
|
||||
let neighbors = [id(1), id(10)];
|
||||
let p = MergeParams {
|
||||
max_age: 27,
|
||||
max_entries: 27,
|
||||
max_vouches: 2,
|
||||
};
|
||||
let out = merge(&[], &id(1), &neighbors, &p, &mut rng(0));
|
||||
let self_count = out.iter().filter(|e| e.id == id(1)).count();
|
||||
assert_eq!(self_count, 1, "self listed twice: {out:?}");
|
||||
// self at 0, id(10) at 1.
|
||||
assert!(out.iter().any(|e| e.id == id(1) && e.age == 0));
|
||||
assert!(out.iter().any(|e| e.id == id(10) && e.age == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_with_duplicate_neighbor_ids_vouches_once() {
|
||||
// Same neighbor listed twice in the input slice must produce only
|
||||
// one entry in the output (the second vouch finds the entry already
|
||||
// present and refreshes in place).
|
||||
let neighbors = [id(10), id(10), id(20)];
|
||||
let p = MergeParams {
|
||||
max_age: 27,
|
||||
max_entries: 27,
|
||||
max_vouches: 3,
|
||||
};
|
||||
let out = merge(&[], &id(1), &neighbors, &p, &mut rng(0));
|
||||
let ten_count = out.iter().filter(|e| e.id == id(10)).count();
|
||||
assert_eq!(ten_count, 1, "id(10) listed {ten_count} times");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_evicts_randomly_among_ties() {
|
||||
// 10 entries all at age 20 + self. max_entries=3 forces 8 evictions
|
||||
// all from the age-20 tier. Over many seeds, eviction should not
|
||||
// consistently hit the same index — verify by running 100 trials and
|
||||
// asserting every entry survives at least once.
|
||||
let current: Vec<Entry> = (10u8..20).map(|i| Entry { id: id(i), age: 20 }).collect();
|
||||
let p = MergeParams {
|
||||
max_age: 27,
|
||||
max_entries: 3,
|
||||
max_vouches: 0,
|
||||
};
|
||||
let mut survivor_counts = [0u32; 10];
|
||||
for seed in 0..100u64 {
|
||||
let out = merge(¤t, &id(1), &[], &p, &mut rng(seed));
|
||||
for entry in &out {
|
||||
if entry.id != id(1) {
|
||||
let idx = (entry.id[0] - 10) as usize;
|
||||
survivor_counts[idx] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every entry should survive at least a few times out of 100 trials.
|
||||
// Expected per-trial survival = 2/10 = 0.2 (2 non-self slots / 10 entries);
|
||||
// expected count = 20. Chi-squared tolerance: each ≥ 5 is extremely
|
||||
// generous (statistical floor).
|
||||
for (i, count) in survivor_counts.iter().enumerate() {
|
||||
assert!(
|
||||
*count >= 5,
|
||||
"entry {i} survived only {count} times in 100 trials — bias?"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod proptests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn max_age_invariant(
|
||||
current in proptest::collection::vec((any::<u8>(), 0u8..=26), 0..30),
|
||||
self_n in 1u8..=255u8,
|
||||
neighbors in proptest::collection::vec(any::<u8>(), 0..10),
|
||||
seed in any::<u64>(),
|
||||
) {
|
||||
let current: Vec<Entry> = current.into_iter().map(|(n, a)| Entry { id: id(n), age: a }).collect();
|
||||
let neighbors: Vec<[u8; 32]> = neighbors.into_iter().map(id).collect();
|
||||
let p = MergeParams { max_age: 27, max_entries: 27, max_vouches: 3 };
|
||||
let out = merge(¤t, &id(self_n), &neighbors, &p, &mut rng(seed));
|
||||
prop_assert!(out.iter().all(|e| (e.age as usize) < p.max_age as usize));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_invariant(
|
||||
current in proptest::collection::vec((any::<u8>(), 0u8..=26), 0..60),
|
||||
self_n in 1u8..=255u8,
|
||||
neighbors in proptest::collection::vec(any::<u8>(), 0..10),
|
||||
max_entries in 1usize..=27,
|
||||
seed in any::<u64>(),
|
||||
) {
|
||||
let current: Vec<Entry> = current.into_iter().map(|(n, a)| Entry { id: id(n), age: a }).collect();
|
||||
let neighbors: Vec<[u8; 32]> = neighbors.into_iter().map(id).collect();
|
||||
let p = MergeParams { max_age: 27, max_entries, max_vouches: 3 };
|
||||
let out = merge(¤t, &id(self_n), &neighbors, &p, &mut rng(seed));
|
||||
prop_assert!(out.len() <= max_entries);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_always_present(
|
||||
current in proptest::collection::vec((any::<u8>(), 0u8..=26), 0..30),
|
||||
self_n in 1u8..=255u8,
|
||||
seed in any::<u64>(),
|
||||
) {
|
||||
let current: Vec<Entry> = current.into_iter().map(|(n, a)| Entry { id: id(n), age: a }).collect();
|
||||
let p = MergeParams { max_age: 27, max_entries: 27, max_vouches: 0 };
|
||||
let out = merge(¤t, &id(self_n), &[], &p, &mut rng(seed));
|
||||
prop_assert!(out.iter().any(|e| e.id == id(self_n) && e.age == 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1061
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,173 @@
|
||||
//! Public [`Rendezvous`] handle — the keep-alive anchor for DHT maintenance
|
||||
//! and the surface callers interact with.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use iroh::{Endpoint, PublicKey};
|
||||
use iroh_gossip::TopicId;
|
||||
use iroh_gossip::api::{Event, GossipSender};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::dht::DhtSlots;
|
||||
use crate::gossip_glue::{self, GossipLayer};
|
||||
use crate::protocol::ProtoState;
|
||||
use crate::state::RendezvousState;
|
||||
use crate::{Builder, Error, Result};
|
||||
|
||||
/// Handle to a live rendezvous.
|
||||
///
|
||||
/// Hold this alive for as long as you want the DHT maintenance loops to
|
||||
/// run. Dropping it cancels the loops; the swarm will drift off the DHT
|
||||
/// as entries age out. Call [`Rendezvous::shutdown`] for a graceful exit
|
||||
/// (cancel + await + close endpoint).
|
||||
///
|
||||
/// `Rendezvous` is not `Clone`. If you need shared access across tasks,
|
||||
/// wrap in `Arc`.
|
||||
pub struct Rendezvous {
|
||||
layer: Arc<GossipLayer>,
|
||||
_dht: Arc<dyn DhtSlots>,
|
||||
_proto: Arc<ProtoState>,
|
||||
observable: Arc<Mutex<RendezvousState>>,
|
||||
cancel: CancellationToken,
|
||||
tasks: Mutex<Option<(JoinSet<()>, JoinSet<()>)>>,
|
||||
}
|
||||
|
||||
impl Rendezvous {
|
||||
/// One-call join. Equivalent to
|
||||
/// `Rendezvous::builder().passphrase(…).app_salt(…).build().await`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::EmptyPassphrase`] / [`Error::EmptyAppSalt`] if
|
||||
/// either argument is empty, or wraps an underlying iroh/iroh-gossip/DHT
|
||||
/// failure if the network layer can't start.
|
||||
pub async fn join(passphrase: &str, app_salt: &str) -> Result<Self> {
|
||||
Builder::default()
|
||||
.passphrase(passphrase)
|
||||
.app_salt(app_salt)
|
||||
.build()
|
||||
.await
|
||||
}
|
||||
|
||||
/// Advanced configuration builder. See [`Builder`] for all knobs.
|
||||
#[must_use]
|
||||
#[mutants::skip] // `Builder::default()` and `Default::default()` here
|
||||
// produce identical values — this is a semantically equivalent mutant.
|
||||
// The inner builder-tests module exhaustively pins all default field
|
||||
// values, so correctness is covered.
|
||||
pub fn builder() -> Builder {
|
||||
Builder::default()
|
||||
}
|
||||
|
||||
/// Crate-private constructor used by the builder.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn __new(
|
||||
layer: Arc<GossipLayer>,
|
||||
dht: Arc<dyn DhtSlots>,
|
||||
proto: Arc<ProtoState>,
|
||||
observable: Arc<Mutex<RendezvousState>>,
|
||||
cancel: CancellationToken,
|
||||
gossip_tasks: JoinSet<()>,
|
||||
protocol_tasks: JoinSet<()>,
|
||||
) -> Self {
|
||||
Self {
|
||||
layer,
|
||||
_dht: dht,
|
||||
_proto: proto,
|
||||
observable,
|
||||
cancel,
|
||||
tasks: Mutex::new(Some((gossip_tasks, protocol_tasks))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The iroh gossip sender for this topic. Clone it cheaply and move
|
||||
/// clones into other tasks.
|
||||
#[must_use]
|
||||
pub fn sender(&self) -> &GossipSender {
|
||||
&self.layer.sender
|
||||
}
|
||||
|
||||
/// Convenience broadcast. Equivalent to `sender().broadcast(bytes)`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Wraps `iroh_gossip::api::ApiError` into [`Error::Gossip`].
|
||||
#[mutants::skip] // Thin forwarder to `iroh_gossip::api::GossipSender::broadcast`.
|
||||
// Requires a live gossip swarm to observe mutations; covered by the
|
||||
// bridge integration smoke test.
|
||||
pub async fn broadcast(&self, bytes: bytes::Bytes) -> Result<()> {
|
||||
self.layer
|
||||
.sender
|
||||
.broadcast(bytes)
|
||||
.await
|
||||
.map_err(|e| Error::Gossip(Box::new(e)))
|
||||
}
|
||||
|
||||
/// Subscribe to the gossip event stream. Each call returns a fresh
|
||||
/// [`tokio::sync::broadcast::Receiver`] — multiple consumers work.
|
||||
/// Lagging receivers may miss events; handle `Err(RecvError::Lagged)` or
|
||||
/// use a generous read cadence.
|
||||
#[must_use]
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
|
||||
self.layer.event_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Underlying iroh [`Endpoint`]. Useful if you want to run additional
|
||||
/// ALPNs on the same port.
|
||||
#[must_use]
|
||||
pub fn endpoint(&self) -> &Endpoint {
|
||||
&self.layer.endpoint
|
||||
}
|
||||
|
||||
/// This node's [`PublicKey`] (endpoint ID).
|
||||
#[must_use]
|
||||
pub fn node_id(&self) -> PublicKey {
|
||||
self.layer.endpoint.id()
|
||||
}
|
||||
|
||||
/// Gossip topic ID.
|
||||
#[must_use]
|
||||
pub fn topic_id(&self) -> TopicId {
|
||||
self.layer.topic_id
|
||||
}
|
||||
|
||||
/// Snapshot of current runtime state. Cheap — just clones a small struct.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> RendezvousState {
|
||||
self.observable
|
||||
.lock()
|
||||
.map(|g| g.clone())
|
||||
.unwrap_or_else(|e| e.into_inner().clone())
|
||||
}
|
||||
|
||||
/// Graceful shutdown. Cancels the background loops, awaits their clean
|
||||
/// exit, then closes the iroh endpoint. Idempotent — second + subsequent
|
||||
/// calls no-op.
|
||||
#[mutants::skip] // Async shutdown of tokio JoinSets + iroh endpoint;
|
||||
// exercised by `tests/two_node_bootstrap.rs::drop_cancels_background_tasks`
|
||||
// and the bridge smoke test.
|
||||
pub async fn shutdown(&self) {
|
||||
self.cancel.cancel();
|
||||
let take = self.tasks.lock().map(|mut g| g.take()).ok().flatten();
|
||||
if let Some((mut gossip_tasks, mut protocol_tasks)) = take {
|
||||
while gossip_tasks.join_next().await.is_some() {}
|
||||
while protocol_tasks.join_next().await.is_some() {}
|
||||
}
|
||||
gossip_glue::shutdown(&self.layer).await;
|
||||
debug!("rendezvous shutdown complete");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Rendezvous {
|
||||
#[mutants::skip] // Fire-and-forget cancellation; tested indirectly by
|
||||
// `tests/two_node_bootstrap.rs::drop_cancels_background_tasks`.
|
||||
fn drop(&mut self) {
|
||||
// Non-graceful: fire the cancel and let the runtime abort the tasks
|
||||
// when they hit their next yield. For graceful exit, callers should
|
||||
// use `shutdown()`.
|
||||
self.cancel.cancel();
|
||||
}
|
||||
}
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
//! Discrete-event simulator for the rendezvous algorithm.
|
||||
//!
|
||||
//! Gated behind the `sim` feature flag so the harness isn't compiled by
|
||||
//! default consumers. Used by `examples/simulator.rs` to validate the
|
||||
//! §8 defaults before simulation results move them.
|
||||
//!
|
||||
//! Internally this module reaches into the crate's `pub(crate)` items —
|
||||
//! [`dht::memory::InMemoryDht`], the merge/wire helpers, and a lightweight
|
||||
//! stand-in for [`GossipView`]. The simulator does *not* run real iroh-gossip
|
||||
//! or real Mainline DHT.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::dht::memory::sign_record;
|
||||
use crate::keys::{PassphraseKeys, slot_for_id};
|
||||
use crate::merge::{MergeParams, merge};
|
||||
use crate::protocol::GossipView;
|
||||
|
||||
// Re-exports for third-party simulator / DHT-backend authors. Available as
|
||||
// `iroh_gossip_rendezvous::sim::{InMemoryDht, DhtSlots, SlotKey, SlotRecord, DhtError, Entry, ...}`.
|
||||
/// Re-export of [`crate::dht::DhtSlots`] for use in the `sim` public API.
|
||||
pub use crate::dht::DhtSlots as PubDhtSlots;
|
||||
pub use crate::dht::memory::InMemoryDht as PubInMemoryDht;
|
||||
pub use crate::dht::{DhtError as PubDhtError, SlotKey as PubSlotKey, SlotRecord};
|
||||
pub use crate::wire::{ENTRY_LEN, Entry, MAX_ENTRIES, decode_entries, encode_entries};
|
||||
|
||||
// Internal aliases with shorter names (used in the rest of this file).
|
||||
use PubInMemoryDht as InMemoryDht;
|
||||
use PubSlotKey as SlotKey;
|
||||
|
||||
/// Which scenario to run.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Scenario {
|
||||
/// N nodes, no gossip edges, empty DHT. Converges when every node has
|
||||
/// been handed at least one peer from the DHT.
|
||||
ColdStart { nodes: usize },
|
||||
/// `a + b` nodes split into two full-mesh partitions of size `a` and
|
||||
/// `b`. Converges when both sides have discovered at least one peer
|
||||
/// from the other side via DHT heal reads.
|
||||
Partition { a: usize, b: usize },
|
||||
/// `minority` vs `majority`, both full-meshes internally. Converges
|
||||
/// when any majority node has discovered any minority node.
|
||||
Asymmetric { minority: usize, majority: usize },
|
||||
}
|
||||
|
||||
/// One scenario's outcome.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Outcome {
|
||||
pub healed_in: Option<usize>,
|
||||
pub dht_reads: usize,
|
||||
pub dht_writes: usize,
|
||||
}
|
||||
|
||||
/// Simulation parameters shared across scenarios.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimConfig {
|
||||
pub shards: usize,
|
||||
pub max_entries: usize,
|
||||
pub max_age: u8,
|
||||
pub max_vouches: usize,
|
||||
pub max_rounds: usize,
|
||||
}
|
||||
|
||||
impl Default for SimConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
shards: 3,
|
||||
max_entries: 27,
|
||||
max_age: 27,
|
||||
max_vouches: 3,
|
||||
max_rounds: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute one run of the given scenario, returning the outcome.
|
||||
pub async fn run(passphrase: &str, scenario: Scenario, cfg: &SimConfig) -> Outcome {
|
||||
match scenario {
|
||||
Scenario::ColdStart { nodes } => run_cold_start(passphrase, nodes, cfg).await,
|
||||
Scenario::Partition { a, b } => run_partition(passphrase, a, b, cfg).await,
|
||||
Scenario::Asymmetric { minority, majority } => {
|
||||
run_asymmetric(passphrase, minority, majority, cfg).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internals ───────────────────────────────────────────────────────────
|
||||
|
||||
fn node_id(i: usize) -> [u8; 32] {
|
||||
let mut id = [0u8; 32];
|
||||
id[..8].copy_from_slice(&(i as u64).to_le_bytes());
|
||||
id
|
||||
}
|
||||
|
||||
struct Node {
|
||||
self_id: [u8; 32],
|
||||
keys: PassphraseKeys,
|
||||
slot: usize,
|
||||
gossip: Arc<MockGossip>,
|
||||
}
|
||||
|
||||
impl Node {
|
||||
fn new(passphrase: &str, app_salt: &str, i: usize, shards: usize) -> Self {
|
||||
let self_id = node_id(i);
|
||||
Self {
|
||||
self_id,
|
||||
keys: PassphraseKeys::derive(passphrase, app_salt, shards),
|
||||
slot: slot_for_id(&self_id, shards),
|
||||
gossip: Arc::new(MockGossip::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MockGossip {
|
||||
neighbors: Mutex<Vec<[u8; 32]>>,
|
||||
joined: Mutex<Vec<[u8; 32]>>,
|
||||
}
|
||||
|
||||
impl MockGossip {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
neighbors: Mutex::new(Vec::new()),
|
||||
joined: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
fn add_neighbor(&self, id: [u8; 32]) {
|
||||
if let Ok(mut g) = self.neighbors.lock() {
|
||||
if !g.contains(&id) {
|
||||
g.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn drain_joined(&self) -> Vec<[u8; 32]> {
|
||||
self.joined
|
||||
.lock()
|
||||
.map(|mut g| std::mem::take(&mut *g))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GossipView for MockGossip {
|
||||
fn neighbors(&self) -> Vec<[u8; 32]> {
|
||||
self.neighbors.lock().map(|g| g.clone()).unwrap_or_default()
|
||||
}
|
||||
async fn join_peers(&self, peers: Vec<[u8; 32]>) {
|
||||
if let Ok(mut g) = self.joined.lock() {
|
||||
g.extend(peers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_once(node: &Node, dht: &InMemoryDht, cfg: &SimConfig) -> usize {
|
||||
// One read to fetch current seq + entries.
|
||||
let slot_key = SlotKey(node.keys.slots[node.slot].verifying.to_bytes());
|
||||
let (current, prev_seq) = match dht.read(slot_key).await {
|
||||
Ok(Some(r)) => (decode_entries(&r.value).unwrap_or_default(), r.seq),
|
||||
_ => (Vec::<Entry>::new(), 0),
|
||||
};
|
||||
|
||||
let neighbors = node.gossip.neighbors();
|
||||
let merged = {
|
||||
let mut rng = rand::rng();
|
||||
let params = MergeParams {
|
||||
max_age: cfg.max_age,
|
||||
max_entries: cfg.max_entries,
|
||||
max_vouches: cfg.max_vouches,
|
||||
};
|
||||
merge(¤t, &node.self_id, &neighbors, ¶ms, &mut rng)
|
||||
};
|
||||
|
||||
// Simulator uses plaintext records (still signed so InMemoryDht accepts).
|
||||
let plaintext = encode_entries(&merged);
|
||||
let seq = prev_seq.saturating_add(1);
|
||||
let record = sign_record(&node.keys.slots[node.slot].signing, plaintext, seq);
|
||||
let _ = dht.write(slot_key, record).await;
|
||||
1
|
||||
}
|
||||
|
||||
async fn heal_round(node: &Node, dht: &InMemoryDht, cfg: &SimConfig) -> usize {
|
||||
use rand::seq::SliceRandom;
|
||||
let neighbors = node.gossip.neighbors();
|
||||
let s = cfg.shards.saturating_sub(neighbors.len()).max(1);
|
||||
let slot_indices: Vec<usize> = {
|
||||
let mut idx: Vec<usize> = (0..cfg.shards).collect();
|
||||
let mut rng = rand::rng();
|
||||
idx.shuffle(&mut rng);
|
||||
idx.truncate(s);
|
||||
idx
|
||||
};
|
||||
|
||||
let known: HashSet<_> = neighbors.iter().copied().collect();
|
||||
let mut new_peers = Vec::new();
|
||||
let mut reads = 0usize;
|
||||
for k in slot_indices {
|
||||
let slot_key = SlotKey(node.keys.slots[k].verifying.to_bytes());
|
||||
reads += 1;
|
||||
if let Ok(Some(rec)) = dht.read(slot_key).await {
|
||||
if let Ok(entries) = decode_entries(&rec.value) {
|
||||
for e in entries {
|
||||
if e.id != node.self_id && !known.contains(&e.id) && !new_peers.contains(&e.id)
|
||||
{
|
||||
new_peers.push(e.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !new_peers.is_empty() {
|
||||
node.gossip.join_peers(new_peers).await;
|
||||
}
|
||||
reads
|
||||
}
|
||||
|
||||
async fn run_cold_start(passphrase: &str, n: usize, cfg: &SimConfig) -> Outcome {
|
||||
let dht = InMemoryDht::new();
|
||||
let nodes: Vec<Node> = (0..n)
|
||||
.map(|i| Node::new(passphrase, "sim/cold-start", i, cfg.shards))
|
||||
.collect();
|
||||
let mut reads = 0;
|
||||
let mut writes = 0;
|
||||
for r in 1..=cfg.max_rounds {
|
||||
for node in &nodes {
|
||||
writes += write_once(node, &dht, cfg).await;
|
||||
}
|
||||
for node in &nodes {
|
||||
reads += heal_round(node, &dht, cfg).await;
|
||||
}
|
||||
let converged = nodes.iter().all(|n| !n.gossip.drain_joined().is_empty());
|
||||
if converged {
|
||||
return Outcome {
|
||||
healed_in: Some(r),
|
||||
dht_reads: reads,
|
||||
dht_writes: writes,
|
||||
};
|
||||
}
|
||||
}
|
||||
Outcome {
|
||||
healed_in: None,
|
||||
dht_reads: reads,
|
||||
dht_writes: writes,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_partition(passphrase: &str, a: usize, b: usize, cfg: &SimConfig) -> Outcome {
|
||||
let dht = InMemoryDht::new();
|
||||
let total = a + b;
|
||||
let nodes: Vec<Node> = (0..total)
|
||||
.map(|i| Node::new(passphrase, "sim/partition", i, cfg.shards))
|
||||
.collect();
|
||||
|
||||
for (i, n) in nodes.iter().enumerate() {
|
||||
for (j, other) in nodes.iter().enumerate() {
|
||||
let same = (i < a) == (j < a);
|
||||
if same && i != j {
|
||||
n.gossip.add_neighbor(other.self_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let part_a: HashSet<_> = (0..a).map(node_id).collect();
|
||||
let part_b: HashSet<_> = (a..total).map(node_id).collect();
|
||||
let mut reads = 0;
|
||||
let mut writes = 0;
|
||||
|
||||
for r in 1..=cfg.max_rounds {
|
||||
for node in &nodes {
|
||||
writes += write_once(node, &dht, cfg).await;
|
||||
}
|
||||
let mut a_saw_b = false;
|
||||
let mut b_saw_a = false;
|
||||
for (i, node) in nodes.iter().enumerate() {
|
||||
reads += heal_round(node, &dht, cfg).await;
|
||||
let j = node.gossip.drain_joined();
|
||||
if i < a && j.iter().any(|id| part_b.contains(id)) {
|
||||
a_saw_b = true;
|
||||
}
|
||||
if i >= a && j.iter().any(|id| part_a.contains(id)) {
|
||||
b_saw_a = true;
|
||||
}
|
||||
}
|
||||
if a_saw_b && b_saw_a {
|
||||
return Outcome {
|
||||
healed_in: Some(r),
|
||||
dht_reads: reads,
|
||||
dht_writes: writes,
|
||||
};
|
||||
}
|
||||
}
|
||||
Outcome {
|
||||
healed_in: None,
|
||||
dht_reads: reads,
|
||||
dht_writes: writes,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_asymmetric(
|
||||
passphrase: &str,
|
||||
minority: usize,
|
||||
majority: usize,
|
||||
cfg: &SimConfig,
|
||||
) -> Outcome {
|
||||
let dht = InMemoryDht::new();
|
||||
let min_nodes: Vec<Node> = (0..minority)
|
||||
.map(|i| Node::new(passphrase, "sim/asym", 100 + i, cfg.shards))
|
||||
.collect();
|
||||
let maj_nodes: Vec<Node> = (0..majority)
|
||||
.map(|i| Node::new(passphrase, "sim/asym", i, cfg.shards))
|
||||
.collect();
|
||||
|
||||
for n in &min_nodes {
|
||||
for other in &min_nodes {
|
||||
if n.self_id != other.self_id {
|
||||
n.gossip.add_neighbor(other.self_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in &maj_nodes {
|
||||
for other in &maj_nodes {
|
||||
if n.self_id != other.self_id {
|
||||
n.gossip.add_neighbor(other.self_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let minority_ids: HashSet<_> = min_nodes.iter().map(|n| n.self_id).collect();
|
||||
|
||||
let mut reads = 0;
|
||||
let mut writes = 0;
|
||||
|
||||
for r in 1..=cfg.max_rounds {
|
||||
for n in &min_nodes {
|
||||
writes += write_once(n, &dht, cfg).await;
|
||||
}
|
||||
for n in &maj_nodes {
|
||||
writes += write_once(n, &dht, cfg).await;
|
||||
}
|
||||
for n in &min_nodes {
|
||||
reads += heal_round(n, &dht, cfg).await;
|
||||
}
|
||||
let mut discovered = false;
|
||||
for n in &maj_nodes {
|
||||
reads += heal_round(n, &dht, cfg).await;
|
||||
if n.gossip
|
||||
.drain_joined()
|
||||
.iter()
|
||||
.any(|id| minority_ids.contains(id))
|
||||
{
|
||||
discovered = true;
|
||||
}
|
||||
}
|
||||
if discovered {
|
||||
return Outcome {
|
||||
healed_in: Some(r),
|
||||
dht_reads: reads,
|
||||
dht_writes: writes,
|
||||
};
|
||||
}
|
||||
}
|
||||
Outcome {
|
||||
healed_in: None,
|
||||
dht_reads: reads,
|
||||
dht_writes: writes,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Observable runtime state exposed via [`Rendezvous::state`](crate::Rendezvous::state).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use iroh_gossip::TopicId;
|
||||
|
||||
/// Snapshot of rendezvous state at a point in time. Cheap to clone.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RendezvousState {
|
||||
/// The gossip topic this rendezvous is bootstrapping.
|
||||
pub topic_id: TopicId,
|
||||
/// Number of currently-connected gossip neighbors.
|
||||
pub neighbor_count: usize,
|
||||
/// Status of the underlying DHT client.
|
||||
pub dht_status: DhtStatus,
|
||||
/// When we last published a record to any slot, if ever.
|
||||
pub last_publish: Option<Instant>,
|
||||
/// When the heal loop last ran, if ever.
|
||||
pub last_heal: Option<Instant>,
|
||||
/// Number of shards (K) the rendezvous is sharding across.
|
||||
pub active_shards: usize,
|
||||
}
|
||||
|
||||
/// Health of the underlying DHT client.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DhtStatus {
|
||||
/// Initial state before any DHT operation has succeeded.
|
||||
Bootstrapping,
|
||||
/// At least one DHT operation has succeeded recently.
|
||||
Ready,
|
||||
/// The most recent DHT publish failed.
|
||||
PublishFailing,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DhtStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Bootstrapping => write!(f, "Bootstrapping"),
|
||||
Self::Ready => write!(f, "Ready"),
|
||||
Self::PublishFailing => write!(f, "PublishFailing"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::DhtStatus;
|
||||
|
||||
#[test]
|
||||
fn dht_status_display() {
|
||||
assert_eq!(format!("{}", DhtStatus::Bootstrapping), "Bootstrapping");
|
||||
assert_eq!(format!("{}", DhtStatus::Ready), "Ready");
|
||||
assert_eq!(format!("{}", DhtStatus::PublishFailing), "PublishFailing");
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
//! BEP 44 record wire format.
|
||||
//!
|
||||
//! One **entry** is `(id: 32 B, age: 1 B)`. A record is an ordered list of
|
||||
//! entries wrapped in the two-layer AEAD envelope described in the design
|
||||
//! doc §3:
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌────────────┬────────────┬──────────┬──────────────┬──────────┐
|
||||
//! │ wrap_nonce │ wrap_ct │ wrap_tag │ entry₁ … ₙ │ body_tag │
|
||||
//! │ (12 B) │ (32 B) │ (16 B) │ (33 B each) │ (16 B) │
|
||||
//! └────────────┴────────────┴──────────┴──────────────┴──────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! The envelope layer is provided by [`crate::crypto`]; this module only
|
||||
//! handles the plaintext entry serialization and the byte-budget invariant.
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Size of one serialized entry.
|
||||
pub const ENTRY_LEN: usize = 32 + 1;
|
||||
|
||||
/// Max entries that fit under the 1000-byte BEP 44 `v` budget.
|
||||
/// See PROTOCOL.md §3 for the arithmetic.
|
||||
pub const MAX_ENTRIES: usize = 27;
|
||||
|
||||
/// One entry: `(endpoint_id, age)`. `id` is a 32-byte Ed25519 public key;
|
||||
/// `age` is the logical age in `0..A_max`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub id: [u8; 32],
|
||||
pub age: u8,
|
||||
}
|
||||
|
||||
/// Serialize a slice of entries to the flat byte buffer that will be fed to
|
||||
/// the body-AEAD. Caller must ensure `entries.len() <= MAX_ENTRIES`.
|
||||
pub fn encode_entries(entries: &[Entry]) -> Vec<u8> {
|
||||
debug_assert!(entries.len() <= MAX_ENTRIES);
|
||||
let mut buf = Vec::with_capacity(entries.len() * ENTRY_LEN);
|
||||
for entry in entries {
|
||||
buf.extend_from_slice(&entry.id);
|
||||
buf.push(entry.age);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Parse entries from a flat byte buffer (the plaintext that came out of
|
||||
/// the body-AEAD). Returns an error on length misalignment.
|
||||
pub fn decode_entries(bytes: &[u8]) -> Result<Vec<Entry>> {
|
||||
if !bytes.len().is_multiple_of(ENTRY_LEN) {
|
||||
return Err(Error::MalformedRecord(
|
||||
"entry buffer not a multiple of 33 bytes",
|
||||
));
|
||||
}
|
||||
let n = bytes.len() / ENTRY_LEN;
|
||||
if n > MAX_ENTRIES {
|
||||
return Err(Error::MalformedRecord("too many entries (> 27)"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for chunk in bytes.chunks_exact(ENTRY_LEN) {
|
||||
let mut id = [0u8; 32];
|
||||
id.copy_from_slice(&chunk[..32]);
|
||||
let age = chunk[32];
|
||||
out.push(Entry { id, age });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Compute the total byte-size of the outer envelope (for budget checks).
|
||||
///
|
||||
/// Layout: `wrap_nonce(12) + wrap_ct(32) + wrap_tag(16) + n * ENTRY_LEN + body_tag(16)`.
|
||||
#[cfg(test)]
|
||||
pub(crate) const fn envelope_size(n_entries: usize) -> usize {
|
||||
12 + 32 + 16 + n_entries * ENTRY_LEN + 16
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn entry_roundtrip() {
|
||||
let entries = vec![
|
||||
Entry {
|
||||
id: [0xAA; 32],
|
||||
age: 0,
|
||||
},
|
||||
Entry {
|
||||
id: [0xBB; 32],
|
||||
age: 5,
|
||||
},
|
||||
Entry {
|
||||
id: [0xCC; 32],
|
||||
age: 26,
|
||||
},
|
||||
];
|
||||
let encoded = encode_entries(&entries);
|
||||
assert_eq!(encoded.len(), 3 * ENTRY_LEN);
|
||||
let decoded = decode_entries(&encoded).unwrap();
|
||||
assert_eq!(decoded, entries);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_entries_roundtrip() {
|
||||
let encoded = encode_entries(&[]);
|
||||
assert_eq!(encoded.len(), 0);
|
||||
let decoded = decode_entries(&encoded).unwrap();
|
||||
assert!(decoded.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misaligned_input_is_error() {
|
||||
let bytes = vec![0u8; 32]; // short by one byte
|
||||
assert!(decode_entries(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_many_entries_is_error() {
|
||||
let bytes = vec![0u8; ENTRY_LEN * (MAX_ENTRIES + 1)];
|
||||
assert!(decode_entries(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_age_boundary_values_roundtrip() {
|
||||
// age=0 (freshest) and age=255 (u8::MAX) roundtrip unchanged.
|
||||
let entries = vec![
|
||||
Entry {
|
||||
id: [0xAA; 32],
|
||||
age: 0,
|
||||
},
|
||||
Entry {
|
||||
id: [0xBB; 32],
|
||||
age: 255,
|
||||
},
|
||||
];
|
||||
let encoded = encode_entries(&entries);
|
||||
let decoded = decode_entries(&encoded).unwrap();
|
||||
assert_eq!(decoded, entries);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_entry_order_is_preserved() {
|
||||
// Encode/decode must not reorder entries. Construct 5 entries with
|
||||
// recognizable prefixes and verify exact output order.
|
||||
let entries: Vec<Entry> = (1..=5)
|
||||
.map(|i| {
|
||||
let mut id = [0u8; 32];
|
||||
id[0] = i;
|
||||
Entry { id, age: i }
|
||||
})
|
||||
.collect();
|
||||
let encoded = encode_entries(&entries);
|
||||
let decoded = decode_entries(&encoded).unwrap();
|
||||
for (expected, actual) in entries.iter().zip(decoded.iter()) {
|
||||
assert_eq!(expected.id[0], actual.id[0]);
|
||||
assert_eq!(expected.age, actual.age);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_at_max_entries_fits_bep44_budget() {
|
||||
// BEP 44 permits the bencoded `v` field up to 1000 bytes. A byte
|
||||
// string of length N bencodes as "<ascii-N>:" + N bytes.
|
||||
let bencoded = |n: usize| {
|
||||
let prefix = format!("{n}:").len();
|
||||
prefix + n
|
||||
};
|
||||
|
||||
// At MAX_ENTRIES=27 the plaintext envelope is 967 bytes; bencoded =
|
||||
// "967:" + 967 = 971 bytes — under the 1000-byte cap.
|
||||
let at_max = envelope_size(MAX_ENTRIES);
|
||||
assert_eq!(at_max, 967);
|
||||
assert!(
|
||||
bencoded(at_max) <= 1000,
|
||||
"27 entries bencoded should fit; got {}",
|
||||
bencoded(at_max)
|
||||
);
|
||||
|
||||
// 28 entries produces a 1000-byte envelope exactly, which bencodes
|
||||
// to "1000:" + 1000 = 1005 bytes — over the cap.
|
||||
let over = envelope_size(MAX_ENTRIES + 1);
|
||||
assert_eq!(over, 1000);
|
||||
assert!(
|
||||
bencoded(over) > 1000,
|
||||
"28 entries bencoded must overflow; got {}",
|
||||
bencoded(over)
|
||||
);
|
||||
}
|
||||
|
||||
mod proptests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn entries_roundtrip_prop(n in 0usize..=MAX_ENTRIES) {
|
||||
let entries: Vec<Entry> = (0..n)
|
||||
.map(|i| {
|
||||
let mut id = [0u8; 32];
|
||||
id[0] = i as u8;
|
||||
Entry { id, age: (i % 27) as u8 }
|
||||
})
|
||||
.collect();
|
||||
let encoded = encode_entries(&entries);
|
||||
let decoded = decode_entries(&encoded).unwrap();
|
||||
prop_assert_eq!(decoded, entries);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misalignment_rejected_prop(n in 1usize..=100) {
|
||||
// Any buffer whose length isn't a multiple of 33 must fail.
|
||||
let misaligned = ENTRY_LEN * n + 1;
|
||||
let bytes = vec![0u8; misaligned];
|
||||
prop_assert!(decode_entries(&bytes).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user