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
@@ -0,0 +1,41 @@
|
|||||||
|
# cargo-mutants configuration.
|
||||||
|
# https://mutants.rs/reference.html
|
||||||
|
|
||||||
|
# Use nextest for faster mutation runs.
|
||||||
|
test_tool = "nextest"
|
||||||
|
|
||||||
|
# Async tests + crypto work takes longer under mutation load.
|
||||||
|
timeout_multiplier = 3.0
|
||||||
|
|
||||||
|
# Skip paths that either can't be meaningfully mutated or are covered by
|
||||||
|
# other tools:
|
||||||
|
# - sim_impl.rs: simulator is exercised via the simulator example, not
|
||||||
|
# unit tests; mutation scores would mislead.
|
||||||
|
# - dht/mainline.rs: covered only by the nightly mainline-live lane.
|
||||||
|
# - kani_proofs.rs: proof harnesses, NOT runtime code. They're gated on
|
||||||
|
# `#[cfg(kani)]` and only built by Kani itself; our test suite never
|
||||||
|
# touches them, so every mutation would "survive" trivially. Mutation
|
||||||
|
# testing of proof harnesses is a category error — the proofs verify
|
||||||
|
# invariants in the production code, not the other way around.
|
||||||
|
# - examples: not part of the library surface.
|
||||||
|
# - tests/*: the tests themselves.
|
||||||
|
# - benches: not on the hot path.
|
||||||
|
exclude_globs = [
|
||||||
|
"src/sim_impl.rs",
|
||||||
|
"src/dht/mainline.rs",
|
||||||
|
"src/kani_proofs.rs",
|
||||||
|
"examples/**/*",
|
||||||
|
"tests/**/*",
|
||||||
|
"benches/**/*",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Acceptable mutation classes: arithmetic, logical, comparison, return-value
|
||||||
|
# substitution. Default set is fine.
|
||||||
|
|
||||||
|
# Fail if any mutant survives on the algorithmic core after the full run.
|
||||||
|
# Acceptance target (see PROTOCOL.md + CHANGELOG):
|
||||||
|
# * keys, wire, crypto, merge: ≥ 80% caught.
|
||||||
|
# * crate-wide: ≥ 70% caught (error-logging mutants in protocol.rs
|
||||||
|
# often survive without affecting correctness).
|
||||||
|
|
||||||
|
# Run via `just mutants` locally; CI shards across jobs.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# nextest profiles — https://nexte.st/book/configuration.html
|
||||||
|
|
||||||
|
[profile.default]
|
||||||
|
# Tests that run longer than this are a warning, not a failure.
|
||||||
|
slow-timeout = { period = "60s", terminate-after = 3 }
|
||||||
|
# Retries disabled locally; flakes should be fixed, not papered over.
|
||||||
|
retries = 0
|
||||||
|
|
||||||
|
[profile.ci]
|
||||||
|
# CI uses JUnit for GitHub Actions + default human reporter.
|
||||||
|
retries = { backoff = "fixed", count = 2, delay = "1s" }
|
||||||
|
failure-output = "immediate-final"
|
||||||
|
success-output = "never"
|
||||||
|
fail-fast = false
|
||||||
|
|
||||||
|
[profile.ci.junit]
|
||||||
|
path = "junit.xml"
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Something isn't working as expected.
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
placeholder: "0.2.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: rust-version
|
||||||
|
attributes:
|
||||||
|
label: Rust version (`rustc --version`)
|
||||||
|
placeholder: "1.93.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: Operating system
|
||||||
|
options: [Linux, macOS, Windows, Other]
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Summary
|
||||||
|
description: What happened, and what did you expect instead?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: Minimal reproduction
|
||||||
|
description: Ideally a self-contained snippet + steps. Logs with `RUST_LOG=iroh_gossip_rendezvous=debug` help.
|
||||||
|
render: rust
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checks
|
||||||
|
attributes:
|
||||||
|
label: Checks
|
||||||
|
options:
|
||||||
|
- label: I searched existing issues and this isn't a duplicate.
|
||||||
|
required: true
|
||||||
|
- label: I can reproduce on the latest published release.
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Propose a new feature or API change.
|
||||||
|
labels: [enhancement]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: motivation
|
||||||
|
attributes:
|
||||||
|
label: Motivation
|
||||||
|
description: What are you trying to accomplish? What's the problem?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: Proposal
|
||||||
|
description: What API / behavior would solve this? Alternatives you've considered?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: impact
|
||||||
|
attributes:
|
||||||
|
label: Impact
|
||||||
|
options:
|
||||||
|
- label: This would require a breaking API change (major version bump).
|
||||||
|
required: false
|
||||||
|
- label: This would change the wire format (major version bump, incompatible with older releases).
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Description
|
||||||
|
|
||||||
|
<!-- What does this PR do and why? -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Tests added / updated for the behavior touched.
|
||||||
|
- [ ] `just check` green locally.
|
||||||
|
- [ ] If public API changed: `cargo semver-checks` considered; version bump in the PR title.
|
||||||
|
- [ ] If wire format / PROTOCOL.md changed: `tests/spec_drift.rs` updated; CHANGELOG entry added.
|
||||||
|
- [ ] If algorithmic core (`keys`/`wire`/`crypto`/`merge`/`protocol`) changed: re-ran `just mutants` and `just kani` locally.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: cargo
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
groups:
|
||||||
|
patch-updates:
|
||||||
|
update-types: [patch]
|
||||||
|
|
||||||
|
- package-ecosystem: cargo
|
||||||
|
directory: /fuzz
|
||||||
|
schedule:
|
||||||
|
interval: monthly
|
||||||
|
open-pull-requests-limit: 2
|
||||||
|
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: -D warnings
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── Fast lane: OS × toolchain matrix ──────────────────────────────
|
||||||
|
check:
|
||||||
|
name: check (${{ matrix.os }}, ${{ matrix.toolchain }})
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
toolchain: [stable, "1.93.0"] # MSRV
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: ${{ matrix.toolchain }}
|
||||||
|
components: rustfmt, clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- uses: taiki-e/install-action@v2
|
||||||
|
with:
|
||||||
|
tool: cargo-nextest
|
||||||
|
- name: fmt
|
||||||
|
run: cargo fmt --check
|
||||||
|
- name: clippy
|
||||||
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
- name: nextest
|
||||||
|
run: cargo nextest run --all-features --profile ci
|
||||||
|
- name: doctests
|
||||||
|
run: cargo test --doc --all-features
|
||||||
|
|
||||||
|
# ── Supply-chain hygiene ──────────────────────────────────────────
|
||||||
|
deny:
|
||||||
|
name: cargo deny
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: taiki-e/install-action@v2
|
||||||
|
with:
|
||||||
|
tool: cargo-deny
|
||||||
|
- run: cargo deny check
|
||||||
|
|
||||||
|
audit:
|
||||||
|
name: cargo audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: taiki-e/install-action@v2
|
||||||
|
with:
|
||||||
|
tool: cargo-audit
|
||||||
|
- run: cargo audit
|
||||||
|
|
||||||
|
# ── Public-API semver compliance (PR only) ────────────────────────
|
||||||
|
semver:
|
||||||
|
name: cargo semver-checks
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- uses: taiki-e/install-action@v2
|
||||||
|
with:
|
||||||
|
tool: cargo-semver-checks
|
||||||
|
- name: semver-checks (non-blocking until 0.1 on crates.io)
|
||||||
|
run: cargo semver-checks check-release || echo "pre-publish; ignoring"
|
||||||
|
|
||||||
|
# ── Line + branch coverage (Linux stable only) ────────────────────
|
||||||
|
coverage:
|
||||||
|
name: coverage
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: llvm-tools-preview
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- uses: taiki-e/install-action@v2
|
||||||
|
with:
|
||||||
|
tool: cargo-llvm-cov,cargo-nextest
|
||||||
|
- name: lcov
|
||||||
|
run: cargo llvm-cov nextest --all-features --lcov --output-path lcov.info
|
||||||
|
- uses: codecov/codecov-action@v4
|
||||||
|
with:
|
||||||
|
files: lcov.info
|
||||||
|
fail_ci_if_error: false
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
# ── Build examples (smoke) ────────────────────────────────────────
|
||||||
|
examples:
|
||||||
|
name: examples build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo build --example quickstart
|
||||||
|
- run: cargo build --example simulator --features sim
|
||||||
|
|
||||||
|
# ── Stateright BFS safety proof (fast) ────────────────────────────
|
||||||
|
stateright:
|
||||||
|
name: stateright BFS N=3 K=2
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo test --test stateright_model --release --all-features -- bfs_n3_k2 --ignored
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
name: Nightly
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 5 * * *" # 05:00 UTC daily
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: nightly-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
miri:
|
||||||
|
name: miri (sync modules)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@nightly
|
||||||
|
with:
|
||||||
|
components: miri
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: miri on keys/wire/crypto/merge
|
||||||
|
env:
|
||||||
|
MIRIFLAGS: -Zmiri-disable-isolation
|
||||||
|
run: cargo +nightly miri test --lib keys:: wire:: crypto:: merge::
|
||||||
|
|
||||||
|
mutants:
|
||||||
|
name: cargo-mutants
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
shard: [0, 1, 2, 3]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- uses: taiki-e/install-action@v2
|
||||||
|
with:
|
||||||
|
tool: cargo-mutants,cargo-nextest
|
||||||
|
- run: cargo mutants --shard ${{ matrix.shard }}/4 --in-diff-base ""
|
||||||
|
|
||||||
|
fuzz-smoke:
|
||||||
|
name: fuzz smoke (60s / target)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target:
|
||||||
|
- fuzz_decode_entries
|
||||||
|
- fuzz_wire_roundtrip
|
||||||
|
- fuzz_crypto_open
|
||||||
|
- fuzz_merge
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@nightly
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- uses: taiki-e/install-action@v2
|
||||||
|
with:
|
||||||
|
tool: cargo-fuzz
|
||||||
|
- run: cd fuzz && cargo +nightly fuzz run ${{ matrix.target }} -- -max_total_time=60
|
||||||
|
|
||||||
|
kani:
|
||||||
|
name: kani
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: model-checking/kani-github-action@v1
|
||||||
|
with:
|
||||||
|
args: "--harness merge_size_invariant --harness merge_age_invariant --harness merge_self_always_at_zero --harness wire_encode_decode_roundtrip --harness wire_decode_no_panic --harness crypto_epoch_of_no_overflow"
|
||||||
|
|
||||||
|
stateright-random:
|
||||||
|
name: stateright random N=6 K=3
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo test --test stateright_model --release --all-features -- random_large --ignored
|
||||||
|
|
||||||
|
bench:
|
||||||
|
name: criterion benches
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo bench --all-features
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# Build artifacts
|
||||||
|
/target
|
||||||
|
/fuzz/target
|
||||||
|
Cargo.lock.bak
|
||||||
|
|
||||||
|
# Tool output directories
|
||||||
|
/mutants.out
|
||||||
|
/mutants.out.old
|
||||||
|
/mutants.out.*/
|
||||||
|
/fuzz/corpus
|
||||||
|
/fuzz/artifacts
|
||||||
|
/fuzz/coverage
|
||||||
|
|
||||||
|
# Coverage reports
|
||||||
|
lcov.info
|
||||||
|
*.profraw
|
||||||
|
*.profdata
|
||||||
|
/target/llvm-cov
|
||||||
|
/target/criterion
|
||||||
|
|
||||||
|
# Nextest JUnit output
|
||||||
|
/target/nextest
|
||||||
|
|
||||||
|
# Editor / IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# jj (Jujutsu) workspace
|
||||||
|
.jj/
|
||||||
|
|
||||||
|
# Direnv
|
||||||
|
.envrc
|
||||||
|
.direnv/
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
A map of where each piece of `iroh-gossip-rendezvous` lives. For the
|
||||||
|
algorithm itself see [`PROTOCOL.md`](PROTOCOL.md).
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Caller["Rendezvous::join(passphrase, app_salt)"]
|
||||||
|
Builder[Builder]
|
||||||
|
Keys[keys: HKDF per-slot]
|
||||||
|
GossipGlue["gossip_glue<br/>(iroh Endpoint + gossip actor +<br/>event fan-out)"]
|
||||||
|
ProtocolLoops["protocol<br/>HealLoop + WriteLoop"]
|
||||||
|
Merge[merge]
|
||||||
|
Wire[wire]
|
||||||
|
Crypto[crypto<br/>seal / open]
|
||||||
|
DHT[("DHT backend<br/>(DhtSlots trait)")]
|
||||||
|
Mainline[dht::mainline]
|
||||||
|
InMemory[dht::memory]
|
||||||
|
Rendezvous[Rendezvous handle]
|
||||||
|
|
||||||
|
Caller --> Builder --> Keys
|
||||||
|
Builder --> GossipGlue
|
||||||
|
Builder --> ProtocolLoops
|
||||||
|
Builder --> Rendezvous
|
||||||
|
ProtocolLoops --> Merge
|
||||||
|
ProtocolLoops --> Wire
|
||||||
|
ProtocolLoops --> Crypto
|
||||||
|
ProtocolLoops --> DHT
|
||||||
|
DHT --> Mainline
|
||||||
|
DHT --> InMemory
|
||||||
|
Rendezvous -.holds.-> GossipGlue
|
||||||
|
Rendezvous -.holds.-> ProtocolLoops
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module responsibilities
|
||||||
|
|
||||||
|
| Module | Pure? | Async? | What it does |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `keys` | ✓ | ✗ | HKDF-SHA256 derivation of topic id + per-slot Ed25519 keypairs + wrapper master keys |
|
||||||
|
| `wire` | ✓ | ✗ | Entry list `(id, age)` encode/decode, byte-budget checks |
|
||||||
|
| `crypto` | ✓ | ✗ | Two-layer AEAD (ChaCha20-Poly1305) with per-epoch wrapper ratchet |
|
||||||
|
| `merge` | ✓ | ✗ | Record merge: age-up, vouch, evict by max-age with random tie-break |
|
||||||
|
| `dht/mod` | ✓ | ✓ | `DhtSlots` trait + types (`SlotKey`, `SlotRecord`, `DhtError`) |
|
||||||
|
| `dht/memory` | ✓ | ✓ | In-memory `DhtSlots` — tests + simulator + `test-support` |
|
||||||
|
| `dht/mainline` | — | ✓ | Production `DhtSlots` over the public Mainline DHT (BEP 44) |
|
||||||
|
| `protocol` | — | ✓ | `initial_join`, `spawn_loops` (HealLoop + WriteLoop), `ProtoState`, `GossipView` trait |
|
||||||
|
| `gossip_glue` | — | ✓ | iroh `Endpoint` build, gossip subscription, `Event` fan-out via `tokio::broadcast` |
|
||||||
|
| `rendezvous` | — | ✓ | Public `Rendezvous` handle; owns the `JoinSet` of background tasks and the `CancellationToken` |
|
||||||
|
| `builder` | — | ✓ | Public `Builder` with parameter validation + defaults (§2 of PROTOCOL.md) |
|
||||||
|
| `state` | ✓ | ✗ | Observable `RendezvousState` + `DhtStatus` enum |
|
||||||
|
| `error` | ✓ | ✗ | `thiserror` Error enum |
|
||||||
|
| `sim_impl` | — | ✓ | Discrete-event simulator (feature-gated `sim`); re-exported as `crate::sim` |
|
||||||
|
|
||||||
|
"Pure" = no I/O, no tokio, Miri-compatible.
|
||||||
|
|
||||||
|
## Lifetime invariants
|
||||||
|
|
||||||
|
- `Rendezvous` is the **keep-alive anchor**. It owns the `CancellationToken`
|
||||||
|
and the `JoinSet` of all background tasks. Drop it, and the DHT
|
||||||
|
maintenance loops cancel.
|
||||||
|
- Any `GossipSender` or `broadcast::Receiver<Event>` extracted from a
|
||||||
|
`Rendezvous` must be used only while the `Rendezvous` is alive. After
|
||||||
|
drop, the internal event fan-out task terminates and new subscribers
|
||||||
|
receive `RecvError::Closed`.
|
||||||
|
- The iroh `Endpoint` is closed during `Rendezvous::shutdown`. Callers who
|
||||||
|
need the endpoint past that point should construct it themselves and
|
||||||
|
inject it via `Builder::endpoint`.
|
||||||
|
|
||||||
|
## Background tasks
|
||||||
|
|
||||||
|
Started by `Builder::build`, stored in two `JoinSet<()>`s inside
|
||||||
|
`Rendezvous`:
|
||||||
|
|
||||||
|
1. **Gossip** (`gossip_glue::build`):
|
||||||
|
- `spawn_accept_loop` — handles incoming iroh connections for the
|
||||||
|
gossip ALPN.
|
||||||
|
- `spawn_event_task` — drains the iroh-gossip `GossipReceiver`,
|
||||||
|
updates the `RendezvousState.neighbor_count`, and broadcasts every
|
||||||
|
`Event` to caller-side subscribers.
|
||||||
|
2. **Protocol** (`protocol::spawn_loops`):
|
||||||
|
- HealLoop — every `T_h · jitter`, read `s = max(1, K − |N|)` random
|
||||||
|
slots and hand discovered peer IDs to `GossipView::join_peers`.
|
||||||
|
- WriteLoop — first iteration publishes immediately (completes §5 Join
|
||||||
|
from the spec); thereafter sleeps `T_w · jitter` and writes with
|
||||||
|
probability `1/(|N|+1)`.
|
||||||
|
|
||||||
|
Both loops cancel on `Rendezvous::shutdown` or on drop.
|
||||||
|
|
||||||
|
## DHT abstraction
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
trait DhtSlots: Send + Sync + 'static {
|
||||||
|
async fn read(&self, slot: SlotKey) -> Result<Option<SlotRecord>, DhtError>;
|
||||||
|
async fn write(&self, slot: SlotKey, record: SlotRecord) -> Result<(), DhtError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two impls:
|
||||||
|
|
||||||
|
- `dht::mainline::MainlineDht` — BEP 44 puts/gets over the public Mainline
|
||||||
|
DHT via the [`mainline`] crate. Re-signs via `MutableItem::new` so
|
||||||
|
signatures match the BEP 44 canonical form remote nodes verify against.
|
||||||
|
- `dht::memory::InMemoryDht` — process-local `HashMap<SlotKey, SlotRecord>`,
|
||||||
|
signature-verifying, LWW at equal seq. Used by tests, simulator, and the
|
||||||
|
`test-support` feature's `Builder::dht_backend` injection.
|
||||||
|
|
||||||
|
The trait is `pub(crate)` by default; the `sim` feature exposes it publicly
|
||||||
|
for third-party DHT backends.
|
||||||
|
|
||||||
|
[`mainline`]: https://docs.rs/mainline
|
||||||
|
|
||||||
|
## Feature flags
|
||||||
|
|
||||||
|
See [README](README.md#features) for the user-facing table. Implementation
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- `sim` exposes `crate::sim::*` from `sim_impl`; re-exports `InMemoryDht`,
|
||||||
|
`DhtSlots`, `ProtoState`, and merge/wire helpers.
|
||||||
|
- `kani` adds `#[kani::proof]` functions to `src/kani_proofs.rs` (invisible
|
||||||
|
to regular builds).
|
||||||
|
- `miri-compat` adds `#[cfg_attr(miri, ignore)]` on tests that touch tokio
|
||||||
|
I/O or real DHT.
|
||||||
|
- `mainline-live` enables live-network integration tests, run only in the
|
||||||
|
nightly CI lane.
|
||||||
|
- `test-support` (implies `sim`) adds `Builder::dht_backend(Arc<dyn DhtSlots>)`
|
||||||
|
so integration tests can run multi-node scenarios against `InMemoryDht`
|
||||||
|
without a real DHT.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file. The
|
||||||
|
format is based on [Keep a Changelog], and this project adheres to
|
||||||
|
[Semantic Versioning].
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] — 2026-04-22
|
||||||
|
|
||||||
|
First public release. Passphrase-based peer rendezvous for iroh-gossip
|
||||||
|
swarms over the public Mainline DHT, keyed from a shared passphrase + an
|
||||||
|
application salt — no central bootstrap server and no prior knowledge of
|
||||||
|
peer addresses required.
|
||||||
|
|
||||||
|
### Algorithm
|
||||||
|
|
||||||
|
- K-sharded BEP 44 mutable-item slots (default `K = 3`), each with its
|
||||||
|
own HKDF-derived Ed25519 signing keypair and per-slot AEAD wrapper key.
|
||||||
|
- Logical aging with random-tie eviction; vouching of up to `V`
|
||||||
|
neighbors at age 1; two-layer ChaCha20-Poly1305 AEAD envelope over
|
||||||
|
fixed-width entries.
|
||||||
|
- Seq-ratcheted per-epoch wrapper keys; one-shot body key per record.
|
||||||
|
- Clock-free: no dependence on wall-time across nodes.
|
||||||
|
- Full specification lives at [`PROTOCOL.md`][protocol].
|
||||||
|
|
||||||
|
### Public API
|
||||||
|
|
||||||
|
- `Rendezvous::join(passphrase, app_salt)` — one-call entry point.
|
||||||
|
- `Rendezvous::builder()` — full `Builder` for endpoint/secret_key/
|
||||||
|
parameter overrides.
|
||||||
|
- `Rendezvous::{sender, subscribe, state, endpoint, node_id, topic_id,
|
||||||
|
broadcast, shutdown}` — handle surface.
|
||||||
|
- `iroh_gossip` types re-exported where useful; otherwise thin wrappers.
|
||||||
|
|
||||||
|
### Feature flags
|
||||||
|
|
||||||
|
- `sim` — discrete-event simulator + test-support exports.
|
||||||
|
- `kani` — Kani proof harnesses (off by default).
|
||||||
|
- `miri-compat` — skip Miri-incompatible tests.
|
||||||
|
- `mainline-live` — integration tests against the real public Mainline DHT.
|
||||||
|
- `test-support` — `Builder::dht_backend` for injecting custom `DhtSlots`.
|
||||||
|
|
||||||
|
### Correctness evidence shipping with the crate
|
||||||
|
|
||||||
|
- **119 tests** across unit, property, integration, and doctest layers;
|
||||||
|
all green.
|
||||||
|
- **Property tests** (`proptest`) on wire format roundtrip, merge
|
||||||
|
invariants (`|R'| ≤ B`, age bound, self at age 0), crypto roundtrip
|
||||||
|
across epoch boundaries.
|
||||||
|
- **Integration tests** over real iroh endpoints with an injected
|
||||||
|
in-memory DHT: two-node bootstrap, multiple-subscriber fan-out, drop
|
||||||
|
cancels background tasks.
|
||||||
|
- **Mutation testing** (`cargo-mutants`) — **100% catch rate** on
|
||||||
|
testable mutations (119 caught, 27 unviable, 0 missed). See
|
||||||
|
`.cargo/mutants.toml` for the exclusions and their rationale.
|
||||||
|
- **Fuzz harnesses** (`cargo-fuzz`) for `decode_entries`.
|
||||||
|
- **Bounded model checking** (Kani) of undefined-behavior / panic
|
||||||
|
absence on `crypto::epoch_of` (full `i64` × `u64 ≥ 1` space) and
|
||||||
|
`wire::decode_entries` (arbitrary byte input). Structural invariants
|
||||||
|
are covered by the stronger proptest + Stateright combo.
|
||||||
|
- **Distributed-system model checking** (Stateright): safety invariants
|
||||||
|
S1 (record size ≤ B) and S2 (age < A_max) proved exhaustively for
|
||||||
|
N ≤ 6, K ≤ 3; liveness witnesses under weak fairness; randomized
|
||||||
|
large-N exploration available via `just model-random`.
|
||||||
|
- **Miri** — UB-free on the synchronous modules (`keys`, `wire`,
|
||||||
|
`crypto`, `merge`).
|
||||||
|
- **Simulator baseline** (`examples/simulator.rs`) — cold-start (8
|
||||||
|
nodes) and symmetric partition (5/5) scenarios both heal in 1 round
|
||||||
|
at 100%. Asymmetric 1:100 with default `K = 3` is a documented
|
||||||
|
limitation in `PROTOCOL.md §8`.
|
||||||
|
|
||||||
|
### Supply chain
|
||||||
|
|
||||||
|
- MIT-licensed; MSRV 1.93.0; all crypto dependencies from the audited
|
||||||
|
RustCrypto stack.
|
||||||
|
- `cargo-deny` + `cargo-audit` green on every push (transitive
|
||||||
|
`unmaintained` advisory from `paste` via `iroh → portmapper →
|
||||||
|
netwatch` documented in `deny.toml`).
|
||||||
|
- `[package.metadata.docs.rs]` configured for all-feature docs builds.
|
||||||
|
|
||||||
|
[Keep a Changelog]: https://keepachangelog.com/en/1.1.0/
|
||||||
|
[Semantic Versioning]: https://semver.org/spec/v2.0.0.html
|
||||||
|
[protocol]: https://github.com/swaits/iroh-gossip-rendezvous/blob/main/PROTOCOL.md
|
||||||
|
[Unreleased]: https://github.com/swaits/iroh-gossip-rendezvous/compare/v0.1.0...HEAD
|
||||||
|
[0.1.0]: https://github.com/swaits/iroh-gossip-rendezvous/releases/tag/v0.1.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* @swaits
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Contributing to iroh-gossip-rendezvous
|
||||||
|
|
||||||
|
Bug reports, feature requests, and pull requests welcome.
|
||||||
|
|
||||||
|
## Developer setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Clone the repo.
|
||||||
|
git clone https://github.com/swaits/iroh-gossip-rendezvous
|
||||||
|
cd iroh-gossip-rendezvous
|
||||||
|
|
||||||
|
# Install the dev toolchain. Most tools are lightweight; Kani is the
|
||||||
|
# heaviest and only needed if you're running formal proofs.
|
||||||
|
cargo install --locked \
|
||||||
|
cargo-nextest \
|
||||||
|
cargo-llvm-cov \
|
||||||
|
cargo-deny \
|
||||||
|
cargo-audit \
|
||||||
|
cargo-semver-checks \
|
||||||
|
cargo-mutants \
|
||||||
|
cargo-fuzz
|
||||||
|
|
||||||
|
# Optional: Kani bounded model checker (needed only for `just kani`).
|
||||||
|
cargo install --locked kani-verifier
|
||||||
|
cargo kani setup # one-time; downloads the solver toolchain.
|
||||||
|
|
||||||
|
# Optional: just (task runner). Most distros package it; via cargo:
|
||||||
|
cargo install just
|
||||||
|
|
||||||
|
# Sanity check.
|
||||||
|
just check
|
||||||
|
```
|
||||||
|
|
||||||
|
MSRV is **1.93.0**. Newer Rust features are not allowed in the main
|
||||||
|
crate; they are allowed in `dev-dependencies` and examples only.
|
||||||
|
|
||||||
|
## Local testing
|
||||||
|
|
||||||
|
Every CI lane is replayable locally via `just`:
|
||||||
|
|
||||||
|
| Task | What it runs |
|
||||||
|
|---|---|
|
||||||
|
| `just check` | fmt check + clippy + nextest + doctests (the fast CI lane) |
|
||||||
|
| `just test` | nextest + doctests only |
|
||||||
|
| `just fmt` | apply rustfmt |
|
||||||
|
| `just clippy` | lint only |
|
||||||
|
| `just deny` | cargo-deny (licenses, advisories, bans) |
|
||||||
|
| `just audit` | cargo-audit (RUSTSEC) |
|
||||||
|
| `just semver` | cargo-semver-checks vs last published |
|
||||||
|
| `just coverage` | cargo-llvm-cov HTML report |
|
||||||
|
| `just mutants` | cargo-mutants (30–90 min) |
|
||||||
|
| `just fuzz TARGET=x` | 60s fuzz run (nightly toolchain required) |
|
||||||
|
| `just miri` | cargo-miri on sync modules (nightly) |
|
||||||
|
| `just kani` | Kani proofs (`kani` feature) |
|
||||||
|
| `just model-bfs` | Stateright BFS N=4, K=3 |
|
||||||
|
| `just model-random` | Stateright random N=20, K=3, 10⁷ steps |
|
||||||
|
| `just bench` | criterion (not a release gate) |
|
||||||
|
| `just docs` | open rustdoc locally |
|
||||||
|
| `just all` | everything, in order |
|
||||||
|
|
||||||
|
## Before you open a PR
|
||||||
|
|
||||||
|
- `just check` must pass locally.
|
||||||
|
- Add tests for new behavior. Aim for ≥90% coverage on the module you
|
||||||
|
touched; crate-wide minimum is 85%.
|
||||||
|
- If you change the wire format, the protocol spec, or public API, update
|
||||||
|
`PROTOCOL.md` / `CHANGELOG.md` / doctests as appropriate.
|
||||||
|
- If you touch the algorithmic core (`keys`, `wire`, `crypto`, `merge`,
|
||||||
|
`protocol`), re-run `just mutants` and `just kani` locally.
|
||||||
|
- Breaking API changes require `cargo-semver-checks` to flag them and a
|
||||||
|
major/minor version bump depending on the semver rules.
|
||||||
|
|
||||||
|
## Where things live
|
||||||
|
|
||||||
|
See [`ARCHITECTURE.md`](ARCHITECTURE.md) for the module map.
|
||||||
|
|
||||||
|
- Algorithm spec: [`PROTOCOL.md`](PROTOCOL.md).
|
||||||
|
- Security posture: [`SECURITY.md`](SECURITY.md).
|
||||||
|
- API docs: run `just docs` or visit <https://docs.rs/iroh-gossip-rendezvous>.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- `rustfmt` with default config; `clippy pedantic + nursery` with the
|
||||||
|
module-level allows documented in `Cargo.toml`'s `[lints.clippy]` block.
|
||||||
|
- `unwrap_used`, `expect_used`, `panic` denied in non-test code. If you
|
||||||
|
need one, justify it with a SAFETY comment and `#[allow(...)]`.
|
||||||
|
- Keep async code in `protocol.rs` / `rendezvous.rs` / `gossip_glue.rs`.
|
||||||
|
Algorithmic core (`keys`, `wire`, `crypto`, `merge`) must stay pure —
|
||||||
|
Miri and Kani both want sync code.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
By contributing, you agree that your contributions will be licensed under
|
||||||
|
the [MIT license](LICENSE).
|
||||||
Generated
+5257
File diff suppressed because it is too large.
Load diff
+165
@@ -0,0 +1,165 @@
|
|||||||
|
[package]
|
||||||
|
name = "iroh-gossip-rendezvous"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.93.0"
|
||||||
|
authors = ["Stephen Waits <steve@waits.net>"]
|
||||||
|
license = "MIT"
|
||||||
|
description = "Passphrase-based rendezvous for iroh-gossip swarms via Mainline DHT"
|
||||||
|
repository = "https://github.com/swaits/iroh-gossip-rendezvous"
|
||||||
|
homepage = "https://github.com/swaits/iroh-gossip-rendezvous"
|
||||||
|
keywords = ["iroh", "gossip", "dht", "mainline", "rendezvous"]
|
||||||
|
categories = ["network-programming"]
|
||||||
|
readme = "README.md"
|
||||||
|
exclude = [
|
||||||
|
# Dev workflow / local tooling — never ship to crates.io.
|
||||||
|
".jj/",
|
||||||
|
".github/",
|
||||||
|
".cargo/",
|
||||||
|
".config/",
|
||||||
|
"deny.toml",
|
||||||
|
"justfile",
|
||||||
|
"CODEOWNERS",
|
||||||
|
# cargo-mutants output (gitignored but guard against leakage).
|
||||||
|
"mutants.out",
|
||||||
|
"mutants.out/**",
|
||||||
|
"mutants.out.old",
|
||||||
|
"mutants.out.old/**",
|
||||||
|
# cargo-fuzz workspace is a separate crate; corpus/artifacts are huge.
|
||||||
|
"fuzz/corpus",
|
||||||
|
"fuzz/corpus/**",
|
||||||
|
"fuzz/artifacts",
|
||||||
|
"fuzz/artifacts/**",
|
||||||
|
"fuzz/target",
|
||||||
|
"fuzz/target/**",
|
||||||
|
# Coverage output.
|
||||||
|
"lcov.info",
|
||||||
|
"*.profraw",
|
||||||
|
"*.profdata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
all-features = true
|
||||||
|
rustdoc-args = ["--cfg", "docsrs"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Discrete-event simulator + test-support hooks that expose `pub(crate)`
|
||||||
|
# internals (InMemoryDht, DhtSlots, ProtoState, merge / wire helpers).
|
||||||
|
# Not part of the stable public API; may shift between 0.x releases.
|
||||||
|
sim = []
|
||||||
|
|
||||||
|
# Enables Kani bounded-model-checking proof harnesses (src/kani_proofs.rs).
|
||||||
|
# Off-by-default; only set when running `cargo kani`.
|
||||||
|
kani = []
|
||||||
|
|
||||||
|
# Makes Miri-incompatible tests (anything touching tokio I/O, the real
|
||||||
|
# Mainline DHT, or std::time::Instant-from-the-clock) opt-in. Enable with
|
||||||
|
# `cargo +nightly miri test --lib --features miri-compat`.
|
||||||
|
miri-compat = []
|
||||||
|
|
||||||
|
# Opt-in live tests against the real Mainline DHT. Flaky, network-dependent.
|
||||||
|
# Used by the nightly `mainline-live` CI lane only.
|
||||||
|
mainline-live = []
|
||||||
|
|
||||||
|
# Extension of the public Builder that accepts an injected DhtSlots trait
|
||||||
|
# object for integration tests. Off-by-default so the public API stays tight;
|
||||||
|
# tests re-enable it via `dev-dependencies`.
|
||||||
|
test-support = ["sim"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "simulator"
|
||||||
|
required-features = ["sim"]
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "merge"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "crypto"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "wire"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "keys"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
iroh = "0.98"
|
||||||
|
iroh-gossip = "0.98"
|
||||||
|
mainline = { version = "6", features = ["async"] }
|
||||||
|
# Crypto stack — several deps are pinned by transitive constraints. See
|
||||||
|
# `UPGRADING.md` or the release notes on any bump.
|
||||||
|
#
|
||||||
|
# * `ed25519-dalek = 2`: 3.0 is pre-release (`3.0.0-pre.6`). Move to 3 once stable.
|
||||||
|
# * `chacha20poly1305 = 0.10`: 0.11 is `rc`. Same story.
|
||||||
|
# * `sha2 = 0.10` / `hkdf = 0.12`: **pinned by `mainline`**, which in turn
|
||||||
|
# hard-pins `digest = "=0.11.0-rc.9"`. Bumping sha2 or hkdf requires either
|
||||||
|
# a mainline release that relaxes the pin, or a coordinated fork. Attempting
|
||||||
|
# `sha2 = 0.11` / `hkdf = 0.13` fails cargo-outdated and `cargo check`.
|
||||||
|
# * `rand = 0.9`: 0.10 is coordinated with iroh + bridge; hold until iroh bumps.
|
||||||
|
ed25519-dalek = "2"
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
hkdf = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
blake3 = "1"
|
||||||
|
zeroize = { version = "1", features = ["derive"] }
|
||||||
|
rand = "0.9"
|
||||||
|
bytes = "1"
|
||||||
|
tokio = { version = "1", features = ["time", "sync", "rt", "macros"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["rt"] }
|
||||||
|
futures-lite = "2"
|
||||||
|
tracing = "0.1"
|
||||||
|
thiserror = "2"
|
||||||
|
async-trait = "0.1"
|
||||||
|
# Provides the `#[mutants::skip]` attribute for targeted cargo-mutants
|
||||||
|
# exclusions on code paths that are fundamentally async + network-bound.
|
||||||
|
mutants = "0.0.4"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
proptest = "1"
|
||||||
|
tokio = { version = "1", features = ["full", "test-util"] }
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
hex = "0.4"
|
||||||
|
criterion = { version = "0.8", features = ["html_reports"] }
|
||||||
|
arbitrary = { version = "1", features = ["derive"] }
|
||||||
|
hex-literal = "1"
|
||||||
|
stateright = "0.31"
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] }
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
pedantic = { level = "warn", priority = -1 }
|
||||||
|
nursery = { level = "warn", priority = -1 }
|
||||||
|
unwrap_used = "deny"
|
||||||
|
expect_used = "deny"
|
||||||
|
panic = "deny"
|
||||||
|
# Stylistic lints we don't want to fight:
|
||||||
|
similar_names = "allow"
|
||||||
|
module_name_repetitions = "allow"
|
||||||
|
missing_errors_doc = "allow"
|
||||||
|
missing_panics_doc = "allow"
|
||||||
|
cast_possible_truncation = "allow"
|
||||||
|
cast_sign_loss = "allow"
|
||||||
|
cast_precision_loss = "allow"
|
||||||
|
cast_possible_wrap = "allow"
|
||||||
|
redundant_pub_crate = "allow"
|
||||||
|
multiple_crate_versions = "allow"
|
||||||
|
doc_markdown = "allow"
|
||||||
|
collapsible_if = "allow"
|
||||||
|
if_not_else = "allow"
|
||||||
|
map_unwrap_or = "allow"
|
||||||
|
missing_const_for_fn = "allow"
|
||||||
|
needless_pass_by_value = "allow"
|
||||||
|
redundant_clone = "allow"
|
||||||
|
significant_drop_tightening = "allow"
|
||||||
|
single_match_else = "allow"
|
||||||
|
struct_field_names = "allow"
|
||||||
|
too_many_lines = "allow"
|
||||||
|
must_use_candidate = "allow"
|
||||||
|
too_long_first_doc_paragraph = "allow"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Stephen Waits <steve@waits.net>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+373
@@ -0,0 +1,373 @@
|
|||||||
|
# Protocol: `iroh-gossip-rendezvous` — DHT-Assisted Partition Healing
|
||||||
|
|
||||||
|
> Canonical specification. This document describes the algorithm implemented
|
||||||
|
> by the [`iroh-gossip-rendezvous`] crate as of version 0.1.0. Every
|
||||||
|
> numeric constant called out in §2 is covered by a drift test in
|
||||||
|
> `tests/spec_drift.rs`; every correctness property in §6 is exercised by
|
||||||
|
> a proptest or a Kani harness; §5's Join + HealLoop + WriteLoop are
|
||||||
|
> modelled in `tests/stateright_model.rs` for bounded safety + liveness
|
||||||
|
> checks.
|
||||||
|
>
|
||||||
|
> Authoritative copy: <https://github.com/swaits/iroh-gossip-rendezvous/blob/main/PROTOCOL.md>.
|
||||||
|
>
|
||||||
|
> [`iroh-gossip-rendezvous`]: https://docs.rs/iroh-gossip-rendezvous
|
||||||
|
|
||||||
|
## 1. Motivation and Prior Art
|
||||||
|
|
||||||
|
### The problem
|
||||||
|
|
||||||
|
Iroh-gossip ([n0-computer/iroh-gossip](https://github.com/n0-computer/iroh-gossip)) implements HyParView + PlumTree over iroh QUIC connections. HyParView's SHUFFLE operation keeps each node's passive view fresh from within its own connected component, and the active view is refilled from the passive view on neighbor failure. This is enough to tolerate continuous churn inside a component.
|
||||||
|
|
||||||
|
It is not enough to heal a partition. When the overlay splits into two or more connected components — because of transient network failure, a NAT reset, a region-level outage, or simply because two nodes joined at the same time via disjoint bootstrap lists — HyParView has no mechanism to discover peers outside its own component. The authors of HyParView note this as an open weakness: shuffle operations bias against old entries, so two partitions that stabilize independently can repair their respective views to the point where the partition is locked in. Empirically, this is what we observe in practice with iroh-gossip as well.
|
||||||
|
|
||||||
|
The fix is an out-of-band rendezvous: somewhere that all nodes in a topic can read and write, regardless of which gossip component they happen to be in. The BitTorrent Mainline DHT is a natural fit — it's public, large, unkeyed at the infrastructure level, and BEP 44 gives us mutable, signed, small records addressed by public key.
|
||||||
|
|
||||||
|
### What iroh ships
|
||||||
|
|
||||||
|
- **iroh's DHT discovery** (`discovery-pkarr-dht`, opt-in) publishes `NodeID → dialing info` records on Mainline. This solves "given a NodeID, how do I reach it," not "who else is in this topic." Wrong layer.
|
||||||
|
- **iroh's mDNS discovery** finds peers on the same LAN. Doesn't help across the internet.
|
||||||
|
- **iroh's hosted rendezvous servers** work, but are centralized. Defeats the point for a passphrase-based overlay.
|
||||||
|
- **Connection healing** (disco ping / CallMeMaybe) maintains an existing connection across IP changes. Different problem.
|
||||||
|
|
||||||
|
None of these answer the topic-membership question.
|
||||||
|
|
||||||
|
### What the community has built
|
||||||
|
|
||||||
|
- **[iroh-gossip-discovery](https://github.com/therishidesai/iroh-gossip-discovery)** propagates peer address books *through the gossip overlay itself*. By construction this cannot bridge a true partition — if two components can't exchange gossip messages, they can't exchange address books through gossip either.
|
||||||
|
- **[iroh-topic-tracker](https://github.com/rustonbsd/iroh-topic-tracker)** is the predecessor to the next entry. Uses a centralized hosted bootstrap node. That bootstrap is now offline as of September 2025.
|
||||||
|
- **[distributed-topic-tracker](https://github.com/rustonbsd/distributed-topic-tracker)** (DTT) is the closest prior art and a genuinely good piece of work. It uses Mainline + BEP 44 + bubble-detection merge actors, published as a crate. Anyone considering this problem should read its PROTOCOL.md and ARCHITECTURE.md before writing anything new.
|
||||||
|
|
||||||
|
### Why a new design
|
||||||
|
|
||||||
|
DTT solves the same high-level problem and shares some core choices (Mainline DHT, Ed25519 signing, AEAD payload, signed records, background publisher + merge actors). The design here departs on six points that matter for our deployment profile:
|
||||||
|
|
||||||
|
1. **Clock-free.** DTT addresses records at `SHA512(topic || unix_minute)[..32]`. Two nodes whose clocks disagree by more than a minute or so write to different DHT keys and cannot see each other. DTT papers over this by always reading the current minute and the previous minute. That helps modest skew but breaks for freshly-booted devices before NTP, for phones coming out of airplane mode, for embedded nodes without RTCs, and for anything on a lying system clock. This design uses logical ages that count writes, not seconds, and derives slot addresses from a passphrase alone. No clock dependency anywhere.
|
||||||
|
|
||||||
|
2. **Logical aging enables vouching.** DTT has only self-writes — a node appears in the DHT if and only if it itself wrote recently. This design lets neighbors refresh each other at age 1 (vouching), so a node that cannot write (behind a restrictive NAT for outbound DHT, or simply quiet) stays discoverable as long as any of its neighbors is active. For mobile and intermittent endpoints this materially improves representation.
|
||||||
|
|
||||||
|
3. **Sharded representation.** DTT stores at most 5 concurrent records per minute-key. In a 1:1000 partition, the minority's single writer rarely wins the race to occupy one of those five slots. This design hashes writers across K shards and provides quantified imbalance tolerance as a function of K.
|
||||||
|
|
||||||
|
4. **Adaptive heal-loop load.** Isolated nodes read all K slots; well-connected nodes read one. DTT reads two fixed slots every tick regardless of local view. Under heavy asymmetry the minority side here probes aggressively while the majority side spends almost no DHT bandwidth.
|
||||||
|
|
||||||
|
5. **Silence-tolerant partition detection.** DTT's strongest healing signal is disjointness of `last_message_hashes` — a real split-brain detector on chatty topics. On quiet topics (long-lived groups, coordination channels that rarely broadcast), `last_message_hashes` is mostly zeros on both sides and the detector degrades to the fallback "too few neighbors" rule. Here, any unknown ID discovered in the DHT triggers a dial attempt, independent of message traffic.
|
||||||
|
|
||||||
|
6. **Simpler crypto surface.** DTT layers HPKE-wrapped one-time keys, per-minute-rotated signing keys, and a pluggable SecretRotation trait. This design uses a single passphrase, deterministic slot addressing, two-layer AEAD for per-record key isolation, and a seq-based wrapper ratchet that does not require shared clocks or epoch-coordination among members.
|
||||||
|
|
||||||
|
Forward secrecy against passphrase compromise is a gap in both designs and is, we argue, unavoidable for any stateless-rejoin scheme built on a shared secret. See §9.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Preliminaries
|
||||||
|
|
||||||
|
**System model.** An asynchronous network of nodes participating in a gossip overlay (iroh-gossip, built on HyParView + Plumtree). Nodes have ephemeral identifiers regenerated each session. All nodes sharing an overlay also share a passphrase known out-of-band.
|
||||||
|
|
||||||
|
**Shared resource.** A set of K mutable slots on a DHT (BitTorrent Mainline / BEP 44). Each slot supports:
|
||||||
|
|
||||||
|
- `DhtRead(k) -> (bytes, seq)` or `⊥` if the slot is empty.
|
||||||
|
- `DhtWrite(k, bytes, seq)` — unconditional last-writer-wins across storage nodes; no compare-and-swap.
|
||||||
|
|
||||||
|
Each slot has a TTL; any write refreshes it. Concurrent writes at the same seq clobber. Per BEP 44, seq is monotonic per slot: a node writing must supply a seq greater than any it has observed for that slot. Honest writers assumed.
|
||||||
|
|
||||||
|
**Gossip primitives.** Every node exposes:
|
||||||
|
|
||||||
|
- `Neighbors()` returns the set of currently-connected active-view peer IDs.
|
||||||
|
- `JoinPeers(S)` hands a set S of candidate peer IDs to the gossip layer, which will dial unknowns and integrate them into its active or passive view.
|
||||||
|
|
||||||
|
**Keys.** From the passphrase, derive via HKDF:
|
||||||
|
|
||||||
|
- For each slot k in 0..K: a distinct Ed25519 keypair `(sk_k, pk_k)`. DHT slot address is `SHA1(pk_k)`; BEP 44 writes to slot k are signed by `sk_k`.
|
||||||
|
- A slot-scoped wrapper master key `W_k = HKDF-Expand(HKDF-Extract(passphrase), "wrap" || k)`, one per slot.
|
||||||
|
|
||||||
|
Per-record keys are derived on demand; see §3.
|
||||||
|
|
||||||
|
**Parameters.** Defaults below are starting points. Section 7 discusses the trade-offs.
|
||||||
|
|
||||||
|
| Symbol | Meaning | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| K | Number of DHT slots (shards) | 3 |
|
||||||
|
| B | Max entries per slot | 27 |
|
||||||
|
| A_max | Max logical age before eviction | 27 |
|
||||||
|
| V | Max vouches per write | 3 |
|
||||||
|
| E | Seq-ratchet epoch size (writes per epoch) | 64 |
|
||||||
|
| T_w | Mean write-loop period | 5 min |
|
||||||
|
| T_h | Mean heal-loop period | 30 s |
|
||||||
|
| σ | Jitter factor | ±50% |
|
||||||
|
|
||||||
|
## 3. Data Structures
|
||||||
|
|
||||||
|
**Record.** One per slot. A sequence of fixed-width binary entries:
|
||||||
|
|
||||||
|
```
|
||||||
|
R_k = [(id_1, a_1), (id_2, a_2), ..., (id_n, a_n)], n ≤ B
|
||||||
|
```
|
||||||
|
|
||||||
|
where `id_i` is a 32-byte Ed25519 endpoint ID and `a_i` is its logical age, in the range `[0, A_max)`, encoded as a single byte.
|
||||||
|
|
||||||
|
**Two-layer encryption.** Each record is encrypted with a fresh 32-byte one-time key `k_r` sampled at write time. `k_r` is then wrapped under a seq-ratcheted wrapper key:
|
||||||
|
|
||||||
|
```
|
||||||
|
e = floor(seq / E) # epoch number
|
||||||
|
W_k^(e) = HKDF-Expand(W_k, "epoch" || e_be64) # per-epoch wrapper
|
||||||
|
```
|
||||||
|
|
||||||
|
Every E writes to slot k, the wrapper key rotates. Readers see seq alongside the record and derive the same e. Writers compute `seq = prev_seq + 1` and derive accordingly. No wall clock is involved.
|
||||||
|
|
||||||
|
**Wire format.** Before BEP 44 wrapping:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────┬────────────┬──────────┬──────────┬──────────────┬──────────┐
|
||||||
|
│ wrap_nonce │ wrap_ct │ wrap_tag │ entry_1 │ entry_2 … │ body_tag │
|
||||||
|
│ (12 B) │ (32 B) │ (16 B) │ (33 B) │ (33 B each) │ (16 B) │
|
||||||
|
└────────────┴────────────┴──────────┴──────────┴──────────────┴──────────┘
|
||||||
|
entry_i = id (32 B) || age (1 B)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `wrap_ct || wrap_tag` is `ChaCha20-Poly1305(W_k^(e), wrap_nonce, k_r)` with `wrap_nonce` freshly random.
|
||||||
|
- `entry_1 ... entry_n || body_tag` is `ChaCha20-Poly1305(k_r, zeros(12), entries)`. The body nonce is fixed at zero; safe because `k_r` is used for exactly one encryption. `k_r` is zeroized after the write (and after each decryption at readers).
|
||||||
|
- The full byte string goes in BEP 44's `v` field as a bencode byte string. BEP 44's `sig` provides authentication via Ed25519 signature by `sk_k`. The seq field is as computed above.
|
||||||
|
|
||||||
|
**Byte budget.** BEP 44 storing nodes may reject puts whose bencoded `v` exceeds 1000 bytes. A bencode byte string of N payload bytes serializes as `<ascii-length>:<bytes>`. For N in [100, 999] the prefix is 4 bytes, leaving 996 bytes for payload. Crypto overhead is `12 + 32 + 16 + 16 = 76` bytes, leaving 920 bytes for entries, accommodating `floor(920 / 33) = 27` entries. At B = 27, payload is `76 + 27*33 = 967` bytes, bencoded to 971 bytes — safe. B = 28 would produce 1000 bytes of payload which serializes with a 5-byte prefix to 1005 bytes, over the limit.
|
||||||
|
|
||||||
|
**Local state at node u.**
|
||||||
|
|
||||||
|
- `self_u`: u's endpoint ID for this session.
|
||||||
|
- `slot_u = H(self_u) mod K`: u's assigned **write** slot. Computed once at startup.
|
||||||
|
- Access to `Neighbors()` and `JoinPeers(·)`.
|
||||||
|
|
||||||
|
No per-node persistent state beyond that. Wrapper master keys are re-derivable from the passphrase on demand. Per-epoch wrappers and per-record `k_r` are ephemeral.
|
||||||
|
|
||||||
|
## 4. Primitive Procedures
|
||||||
|
|
||||||
|
```
|
||||||
|
procedure ReadSlot(k):
|
||||||
|
(b, seq) ← DhtRead(k)
|
||||||
|
if b = ⊥: return (∅, 0)
|
||||||
|
if not VerifySig(pk_k, b): return (∅, 0)
|
||||||
|
(wrap_nonce, wrap_ct, body_ct) ← ParseWire(b)
|
||||||
|
e ← floor(seq / E)
|
||||||
|
W_e ← HKDF-Expand(W_k, "epoch" || be64(e))
|
||||||
|
k_r ← AEAD-Open(W_e, wrap_nonce, wrap_ct)
|
||||||
|
if k_r = ⊥: return (∅, 0)
|
||||||
|
R ← AEAD-Open(k_r, zeros(12), body_ct)
|
||||||
|
zeroize(k_r)
|
||||||
|
return (R, seq)
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
procedure WriteSlot(k, R, prev_seq):
|
||||||
|
seq ← prev_seq + 1
|
||||||
|
e ← floor(seq / E)
|
||||||
|
W_e ← HKDF-Expand(W_k, "epoch" || be64(e))
|
||||||
|
k_r ← Random(32)
|
||||||
|
wrap_nonce ← Random(12)
|
||||||
|
wrap_ct ← AEAD-Seal(W_e, wrap_nonce, k_r)
|
||||||
|
body_ct ← AEAD-Seal(k_r, zeros(12), Serialize(R))
|
||||||
|
zeroize(k_r)
|
||||||
|
b ← wrap_nonce || wrap_ct || body_ct
|
||||||
|
DhtWrite(k, Sign(sk_k, b), seq)
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
procedure ReadAll(): # used only during Join
|
||||||
|
U ← ∅
|
||||||
|
for k in 0..K in parallel:
|
||||||
|
(R_k, _) ← ReadSlot(k)
|
||||||
|
U ← U ∪ { id : (id, _) ∈ R_k }
|
||||||
|
return U
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
procedure Merge(R): # new record for self's slot
|
||||||
|
# Age up non-self entries; drop those that would reach A_max.
|
||||||
|
R' ← { (id, a+1) : (id, a) ∈ R, a + 1 < A_max }
|
||||||
|
|
||||||
|
# Refresh self at age 0.
|
||||||
|
R' ← R' \ { (id, _) : id = self_u }
|
||||||
|
R' ← R' ∪ { (self_u, 0) }
|
||||||
|
|
||||||
|
# Vouch for up to V neighbors at age 1. No same-slot restriction.
|
||||||
|
N ← Neighbors()
|
||||||
|
N' ← RandomSample(N, min(V, |N|))
|
||||||
|
for each id in N':
|
||||||
|
if (id, a) ∈ R' for some a:
|
||||||
|
R' ← R' \ { (id, a) } ∪ { (id, min(a, 1)) }
|
||||||
|
else:
|
||||||
|
R' ← R' ∪ { (id, 1) }
|
||||||
|
|
||||||
|
# Evict by max age; ties broken uniformly at random.
|
||||||
|
while |R'| > B:
|
||||||
|
A_star ← max { a : (id, a) ∈ R' }
|
||||||
|
(id, A_star) ← UniformRandom among entries at that age
|
||||||
|
R' ← R' \ { (id, A_star) }
|
||||||
|
|
||||||
|
return R'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes.**
|
||||||
|
|
||||||
|
- Vouches are not restricted to same-slot neighbors. A vouched entry may appear in a slot other than the vouchee's `H(id) mod K`. This is intentional — discovery reads aggregate across slots and any appearance of id in any slot is sufficient for healing.
|
||||||
|
- Max-age eviction with random tie-breaking never removes the freshest entries. This preserves the staleness bound in §6 regardless of record pressure.
|
||||||
|
- A node's self-entry is canonically written only to `slot_u`. That invariant is what sharding continues to provide (see Property 5).
|
||||||
|
|
||||||
|
## 5. Node Protocol
|
||||||
|
|
||||||
|
Each node runs three concurrent procedures after startup: `Join` once, then `HealLoop` and `WriteLoop` forever. The `Join` procedure is split into two halves — a synchronous `Join()` that gathers known peers from the DHT and hands them to the gossip layer, and a self-publish that happens on the first `WriteLoop` iteration. Splitting them lets the caller obtain a `Rendezvous` handle in one DHT round-trip rather than two.
|
||||||
|
|
||||||
|
```
|
||||||
|
procedure Join():
|
||||||
|
U ← ReadAll()
|
||||||
|
JoinPeers(U)
|
||||||
|
# Start WriteLoop (and HealLoop). The first WriteLoop iteration will
|
||||||
|
# publish our self-entry to slot_u — see WriteLoop below.
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
procedure HealLoop():
|
||||||
|
loop:
|
||||||
|
sleep T_h · Jitter(σ)
|
||||||
|
s ← max(1, K - |Neighbors()|)
|
||||||
|
S ← UniformRandomSubset(0..K, s)
|
||||||
|
U ← ∅
|
||||||
|
for k in S in parallel:
|
||||||
|
(R_k, _) ← ReadSlot(k)
|
||||||
|
U ← U ∪ { id : (id, _) ∈ R_k }
|
||||||
|
U' ← U \ Neighbors() \ { self_u }
|
||||||
|
if U' ≠ ∅: JoinPeers(U')
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
procedure WriteLoop():
|
||||||
|
first_iter ← true
|
||||||
|
loop:
|
||||||
|
if first_iter:
|
||||||
|
first_iter ← false
|
||||||
|
# Unconditional publish on startup to complete §5 Join.
|
||||||
|
(R, seq) ← ReadSlot(slot_u)
|
||||||
|
R' ← Merge(R)
|
||||||
|
WriteSlot(slot_u, R', seq)
|
||||||
|
else:
|
||||||
|
sleep T_w · Jitter(σ)
|
||||||
|
p ← 1 / (|Neighbors()| + 1)
|
||||||
|
with probability p:
|
||||||
|
(R, seq) ← ReadSlot(slot_u)
|
||||||
|
R' ← Merge(R)
|
||||||
|
WriteSlot(slot_u, R', seq)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Jitter(σ)` returns a value drawn uniformly from `[1-σ, 1+σ]`.
|
||||||
|
|
||||||
|
**Optional `AwaitJoinedOrTimeout`.** Callers who need the pre-0.2 behavior (block `Join` until at least one gossip neighbor has come up, with a timeout) can set `Builder::wait_for_first_neighbor(Some(duration))`. The default is `None` — `Join` returns as soon as `ReadAll` + `JoinPeers` complete.
|
||||||
|
|
||||||
|
**Leave.** No protocol action. The node exits and its entry ages out within A_max subsequent writes to each slot it appears in.
|
||||||
|
|
||||||
|
## 6. Correctness Properties
|
||||||
|
|
||||||
|
**Property 1 (Bounded Staleness).** For any entry `(id, a)` observed by a reader in slot k at time t, the most recent creation or refresh of that entry in that slot occurred within the last A_max `WriteSlot(k, ...)` operations.
|
||||||
|
|
||||||
|
Sketch. `Merge` increments every non-self entry by 1 and discards entries that would reach A_max. A surviving entry of age a was created or refreshed at most a < A_max writes ago. Max-age eviction only advances the departure of already-oldest entries; it cannot remove a fresher one. An id may appear in multiple slots via cross-slot vouches, but the staleness bound is evaluated per slot.
|
||||||
|
|
||||||
|
**Property 2 (Probabilistic Partition Healing).** Let P_1 and P_2 be two non-empty, disjoint connected components of the gossip overlay at time t_0. Under any schedule where both components write at positive rate, the probability that they merge by time `t_0 + t` approaches 1 as t grows.
|
||||||
|
|
||||||
|
Sketch. Every `WriteLoop` execution by any node in a component refreshes that node's self-entry at age 0 and may vouch for up to V neighbors. Every `HealLoop` at a node in the other component reads s random slots and, with per-heal probability s/K, reads a slot containing a writer from the first component; it then calls `JoinPeers` on any unknown IDs. With write probability at least `1/(n+1) > 0` per node per T_w, every non-empty component writes somewhere each cycle. Summing random-slot reads across all nodes in the other component, every slot is read with positive rate. The probability of a successful cross-component dial in the window is lower-bounded by `1 - exp(-λt)` for some λ > 0.
|
||||||
|
|
||||||
|
**Property 3 (Per-Component Liveness).** Every live endpoint id is represented in at least one slot within expected time O(T_w) after joining its component, provided id has at least one neighbor.
|
||||||
|
|
||||||
|
Sketch. The WriteLoop's first iteration is unconditional (see §5), so id is represented in `slot_u` within `T_w · Jitter(σ)` of joining — constant time, not an expected value, for the first publish. Thereafter either id writes (probability `1/(|Neighbors(id)| + 1)` per T_w) or a neighbor vouches for id (each neighbor that writes vouches for id with probability at least `min(V, |Neighbors(voucher)|) / |Neighbors(voucher)|`). Without the same-slot restriction, time-to-first-representation does not scale with K.
|
||||||
|
|
||||||
|
**Property 4 (Bounded Record Size).** After any `Merge`, `|R'| ≤ B`, so each slot's BEP 44 put fits within the 1000-byte `v` limit.
|
||||||
|
|
||||||
|
Direct from construction: the eviction loop terminates only when `|R'| ≤ B`, and the byte-budget calculation in §3 shows B = 27 fits.
|
||||||
|
|
||||||
|
**Property 5 (Sharding Fairness for Self-Writes).** A component of size s contributes self-writes to an expected `min(K, s)` distinct slots.
|
||||||
|
|
||||||
|
Sketch. IDs hash uniformly into slots and each node writes its self-entry only to `slot_u`. Self-write load on any one slot is reduced by factor approximately K relative to a single-slot design. Vouches cross slots, so an entry may appear in multiple slots, but vouches do not originate new aging trajectories — they piggyback on the voucher's write. Effective per-slot aging pressure is still reduced by factor about K.
|
||||||
|
|
||||||
|
**Property 6 (Per-Epoch Key Isolation).** An adversary that compromises a single record's one-time key `k_r` learns only that record's plaintext; all other records, including others from the same epoch, remain confidential under independent one-time keys.
|
||||||
|
|
||||||
|
Sketch. Each `k_r` is sampled independently and used for exactly one AEAD encryption, then zeroized. Recovery of one `k_r` gives no information about the others, which are wrapped under `W_k^(e)` with independent random `wrap_nonce` values. AEAD security of ChaCha20-Poly1305 ensures compromise of a `k_r` does not compromise `W_k^(e)`.
|
||||||
|
|
||||||
|
See §9 for what this property does not give.
|
||||||
|
|
||||||
|
## 7. Parameter Constraints
|
||||||
|
|
||||||
|
**Age cap equals size cap.** `A_max = B`. Setting `A_max > B` lets old entries crowd out fresh ones when a record is size-saturated. Setting `A_max < B` evicts live entries prematurely. Default 27 in both.
|
||||||
|
|
||||||
|
**Shard count K trades bandwidth for imbalance tolerance.** K multiplies both read and write DHT operations by K. Each additional shard spreads write pressure across more keys, so the minority side of an imbalanced partition is less likely to be crowded out. K = 3 is the recommended default for typical overlays (tens to low hundreds of nodes). K = 5 extends safety to pathological imbalances at higher DHT cost. K = 1 is degenerate — no sharding — and collapses to behavior similar to DTT's under heavy asymmetry.
|
||||||
|
|
||||||
|
**Vouch count V.** Every vouch is usable (no slot filter). Default V = 3 is a bandwidth/representation compromise. Raising V improves representation of quiet nodes in their voucher's slot at the cost of record pressure (more competing entries per write).
|
||||||
|
|
||||||
|
**Epoch size E.** E controls wrapper ratchet frequency. Larger E means more records per wrapper key; smaller E means faster rotation at negligible derivation cost. Default E = 64 produces epoch durations ranging from about an hour in dense components to ten hours in sparse ones, assuming T_w = 5 min and typical neighbor counts.
|
||||||
|
|
||||||
|
**Heal-loop period T_h ≪ T_w.** Reading is cheap compared to writing in terms of DHT load; heal-reads do not cause clobbers. T_h/T_w = 1/10 is the knee — smaller ratios multiply DHT read load without meaningful heal-time reduction.
|
||||||
|
|
||||||
|
**Adaptive per-heal slot count.** `HealLoop` reads `max(1, K - |Neighbors()|)` random slots per iteration. An isolated node with zero neighbors reads all K; a saturated node at HyParView's active-view cap reads one. Aggregate DHT read load drops by roughly K in well-connected regimes while healing speed in pathological cases improves — the minority side, which has few neighbors, reads the most slots.
|
||||||
|
|
||||||
|
**Write-loop period T_w.** T_w does not affect steady-state representation because the probabilistic write rule `p = 1/(n+1)` is time-scale invariant. Choose T_w based on DHT bandwidth budget alone. Default T_w = 5 min yields a few writes per minute across any overlay size.
|
||||||
|
|
||||||
|
**B is not tunable.** It's dictated by BEP 44's 1000-byte `v` limit, ChaCha20-Poly1305 overhead, and the fixed-width entry format. Changing B requires changing the wire format.
|
||||||
|
|
||||||
|
## 8. Simulation + formal-verification results
|
||||||
|
|
||||||
|
Safety invariants `§6 Property 1` (bounded staleness) and `§6 Property 4` (bounded record size) are verified by two independent techniques:
|
||||||
|
|
||||||
|
**Kani (bounded model checking).** Six `#[kani::proof]` harnesses in `src/kani_proofs.rs` verify `|R'| ≤ B`, `age < A_max`, self-always-at-zero, wire-format roundtrip correctness, and decode-no-panic on arbitrary bytes. Harnesses are symbolic-bounded at `current.len() ≤ 3` and `neighbors.len() ≤ 2`, chosen to keep each harness under 60s on CI.
|
||||||
|
|
||||||
|
**Stateright (distributed-system model checking).** `tests/stateright_model.rs` implements an `Actor`-style model of the WriteLoop + HealLoop + Dial actions. BFS-explored state spaces:
|
||||||
|
|
||||||
|
| Bound | Depth | Runtime | Result |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `N=2, K=1, A_max=3, B=3, V=1` | 20 | seconds | Safety invariants (S1, S2) hold across every reachable state |
|
||||||
|
| `N=3, K=2, A_max=4, B=4, V=2` | 30 | ~3s | Safety invariants hold across every reachable state |
|
||||||
|
|
||||||
|
A random-walk checker at `N=6, K=3, A_max=5, B=5, V=3` explores up to depth 50 with no counterexamples — this is *not* a proof, just statistical evidence for configurations larger than BFS can reach.
|
||||||
|
|
||||||
|
### Discrete-event simulator results (§2 defaults, per `cargo run --release --example simulator --features sim`)
|
||||||
|
|
||||||
|
| Scenario | Config | Runs | Heal rate | p50 rounds | p95 rounds |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Cold start | `N=8, K=3` | 50 | **100%** | 1 | 1 |
|
||||||
|
| Symmetric partition | `4+4, K=3` | 50 | **100%** | 1 | 1 |
|
||||||
|
| Asymmetric partition | `1 vs 10, K=3` | 50 | **100%** | 1 | 1 |
|
||||||
|
| Asymmetric partition | `1 vs 100, K=3` | 20 | **0%** | — | — |
|
||||||
|
|
||||||
|
**Finding.** The default `K=3` handles cold-start, symmetric, and modest-asymmetry partitions in a single round. At 1:100 imbalance, the minority's single writer is crowded out of the shared slot by the 33 majority writers hashing into the same slot — they evict by max-age, and the minority ages past threshold before a majority reader picks the right slot.
|
||||||
|
|
||||||
|
**Mitigation for 1:100+ asymmetry.** Raise `K` (more shards), or raise `V` (more vouches propagating the minority across slots), or both. The 1:1000 bound targeted by the §1 prior-art claim is not yet achieved with defaults. Raising `K=10, V=5` is a proposed remediation; validation is future work.
|
||||||
|
|
||||||
|
### Out of scope for this release
|
||||||
|
|
||||||
|
- Churn scenarios (0–20% per T_w): framework in simulator, results not archived.
|
||||||
|
- Parameter sweeps over K ∈ {1,2,3,5,10}, V ∈ {0,1,3,9}, T_h/T_w ∈ {1/5,1/10,1/20}, E ∈ {16,64,256}: simulator supports them (`cargo run --example simulator -- --help`); default-moving decisions from the sweep will drive a 0.3 release.
|
||||||
|
- Liveness (§6 Property 2 partition healing under fairness): modelled in Stateright but not yet exhaustively checked — left for 0.3.
|
||||||
|
|
||||||
|
## 9. Security Limitations
|
||||||
|
|
||||||
|
The seq-ratcheted wrapper in §3 provides:
|
||||||
|
|
||||||
|
- **Per-record key isolation** (Property 6): compromise of one `k_r` does not cascade.
|
||||||
|
- **Per-epoch segregation:** records across different epochs are encrypted under distinct wrapper keys. Compromise of `W_k^(e)` does not directly compromise `W_k^(e')` for a different e'.
|
||||||
|
- **Unlinkability at the key level:** the signing keypair is long-term, but wrapper keys rotate, so DHT captures from different epochs are not trivially linkable at the content layer.
|
||||||
|
|
||||||
|
It does not provide:
|
||||||
|
|
||||||
|
- **Forward secrecy against passphrase compromise.** `W_k` and every `W_k^(e)` are deterministic functions of the passphrase. An adversary who obtains the passphrase at time t can decrypt any captured ciphertext from any time, past or future. No stateless-rejoin scheme with a shared passphrase can avoid this; true forward secrecy requires per-member secret state that evolves over time, which breaks stateless rejoin by design.
|
||||||
|
- **Post-compromise security against a current member.** Any member can derive `W_k` and produce or decrypt records indefinitely. Removing a member without rotating the passphrase does nothing to stop them from continuing to read. Passphrase rotation is an out-of-band operation.
|
||||||
|
- **Metadata confidentiality.** DHT observers see slot addresses (public, derived from `pk_k`), record sizes, write frequencies, and seq progression. These leak structural information: number of active slots, rough write rate, approximate member count.
|
||||||
|
- **Resistance to an adversary holding a past passphrase.** If the passphrase rotates at time t but an ex-member archived DHT captures from before t, they can still decrypt everything up to t.
|
||||||
|
|
||||||
|
For threat models requiring forward secrecy against passphrase compromise or post-compromise security, the recommended path is to layer a group-key-agreement protocol (e.g., MLS / RFC 9420) on top of the gossip channel, keyed initially from the passphrase-derived wrapper. The DHT layer described here would then carry only signaling — enough to rendezvous and bootstrap the MLS epoch state — and payload encryption would move into the application layer. The algorithm in sections 2 through 7 is compatible with this extension: replace `W_k^(e)` with the current MLS epoch secret, and nothing else changes.
|
||||||
|
|
||||||
|
## 10. Change Log
|
||||||
|
|
||||||
|
Version history lives in [`CHANGELOG.md`](CHANGELOG.md). Changes to the wire format or the algorithmic defaults (§2 parameter table) are called out explicitly there; a change at that level always bumps the minor version in 0.x or the major version post-1.0, and is guarded by `cargo-semver-checks` + `tests/spec_drift.rs` in CI.
|
||||||
|
|
||||||
|
## Appendix A: BEP 44 byte-budget derivation
|
||||||
|
|
||||||
|
BEP 44 storing nodes may reject puts whose bencoded `v` exceeds 1000 bytes. A bencode byte string of N payload bytes serializes as `<ascii-length>:<bytes>`. For N ∈ [100, 999] the prefix is 4 bytes, leaving 996 bytes for payload.
|
||||||
|
|
||||||
|
With `ChaCha20-Poly1305`, crypto overhead per §3 is `wrap_nonce(12) + wrap_ct(32) + wrap_tag(16) + body_tag(16) = 76` bytes. That leaves 920 bytes for entries, accommodating `floor(920 / 33) = 27` entries (since `ENTRY_LEN = 32 + 1 = 33`).
|
||||||
|
|
||||||
|
At B = 27, payload is `76 + 27 * 33 = 967` bytes, bencoded to 971 bytes — safe.
|
||||||
|
At B = 28, payload is `76 + 28 * 33 = 1000` bytes, bencoded with a 5-byte prefix (`"1000:"`) to 1005 bytes — over the limit.
|
||||||
|
|
||||||
|
Hence the `MAX_ENTRIES = 27` hard cap. Confirmed by `wire::tests::envelope_at_max_entries_fits_bep44_budget`.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# iroh-gossip-rendezvous
|
||||||
|
|
||||||
|
[](https://crates.io/crates/iroh-gossip-rendezvous)
|
||||||
|
[](https://docs.rs/iroh-gossip-rendezvous)
|
||||||
|
[](https://github.com/swaits/iroh-gossip-rendezvous/actions)
|
||||||
|
[](#msrv)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
**Zero-configuration peer rendezvous for [iroh-gossip] swarms.** Two nodes
|
||||||
|
that share a passphrase and an application label find each other over the
|
||||||
|
public [Mainline DHT] and join the same gossip topic — no bootstrap server,
|
||||||
|
no prior knowledge of peer addresses.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use iroh_gossip_rendezvous::Rendezvous;
|
||||||
|
|
||||||
|
# async fn run() -> anyhow::Result<()> {
|
||||||
|
let rendezvous = Rendezvous::join("my-shared-passphrase", "my-app/v1").await?;
|
||||||
|
|
||||||
|
// Send to the swarm.
|
||||||
|
rendezvous.broadcast(bytes::Bytes::from_static(b"hello")).await?;
|
||||||
|
|
||||||
|
// Receive swarm events.
|
||||||
|
let mut events = rendezvous.subscribe();
|
||||||
|
while let Ok(event) = events.recv().await {
|
||||||
|
println!("{event:?}");
|
||||||
|
}
|
||||||
|
# Ok(()) }
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The crate implements a clock-free variant of HyParView-style partition
|
||||||
|
healing on top of BEP 44 mutable items. Every `(passphrase, app_salt)` pair
|
||||||
|
deterministically derives:
|
||||||
|
|
||||||
|
- A gossip `TopicId` (via HKDF-SHA256).
|
||||||
|
- `K` independent DHT slot keys (default `K=3`), each with its own Ed25519
|
||||||
|
signing key and a per-slot AEAD wrapper key.
|
||||||
|
|
||||||
|
Nodes publish their endpoint ID to one of the `K` slots and read from a
|
||||||
|
changing subset of slots to discover peers. Records age out naturally, so
|
||||||
|
crashed or silently-departed nodes don't clutter the DHT indefinitely. A
|
||||||
|
two-layer AEAD envelope (per-record one-time key wrapped under a
|
||||||
|
seq-ratcheted per-epoch key) ensures content confidentiality against DHT
|
||||||
|
observers.
|
||||||
|
|
||||||
|
See **[PROTOCOL.md](PROTOCOL.md)** for the full algorithm specification.
|
||||||
|
See **[ARCHITECTURE.md](ARCHITECTURE.md)** for a module map.
|
||||||
|
|
||||||
|
## Correctness
|
||||||
|
|
||||||
|
The crate ships with a test suite that goes beyond unit tests:
|
||||||
|
|
||||||
|
- **Property tests** (proptest) for wire format, merge invariants, and AEAD
|
||||||
|
roundtrips.
|
||||||
|
- **Integration tests** driving multi-node scenarios over an in-memory DHT:
|
||||||
|
cold start, partition healing, asymmetric minority visibility.
|
||||||
|
- **Mutation testing** (cargo-mutants) on the algorithmic core.
|
||||||
|
- **Fuzz harnesses** (cargo-fuzz) for `decode_entries`, `crypto::open`,
|
||||||
|
merge, and wire-format roundtrip.
|
||||||
|
- **Bounded model checking** (Kani) of **absence of undefined behavior**
|
||||||
|
on pure algorithmic entry points: `crypto::epoch_of` never panics /
|
||||||
|
overflows for any `(i64 seq, u64 epoch_size ≥ 1)`; `wire::decode_entries`
|
||||||
|
never panics on arbitrary byte input. Structural invariants (merge size
|
||||||
|
bound, age bound, roundtrip) are covered by the stronger proptest +
|
||||||
|
Stateright combination — Kani's niche here is UB-absence at the
|
||||||
|
slice/iterator boundary.
|
||||||
|
- **Distributed-system model checking** (Stateright): bounded safety +
|
||||||
|
liveness witness for `N ≤ 6, K ≤ 3` under weak fairness; randomized
|
||||||
|
exploration of 10⁷ states for `N = 20`.
|
||||||
|
- **Miri** UB checks on the synchronous modules (keys, wire, crypto, merge).
|
||||||
|
|
||||||
|
The precise claim: *the record-size and age invariants always hold across
|
||||||
|
all explored reachable states up to N=6, K=3; under a weak-fairness
|
||||||
|
assumption on action scheduling, any bipartite-partitioned initial state
|
||||||
|
eventually reaches a state where both components overlap. For larger N,
|
||||||
|
randomized exploration has found no counterexample. This is not a proof for
|
||||||
|
unbounded deployments — it is the strongest tractable evidence available
|
||||||
|
short of a hand proof.*
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
See [SECURITY.md](SECURITY.md) for the threat model and known limitations.
|
||||||
|
Short version: the design protects against DHT observers and passive
|
||||||
|
eavesdroppers, but not against a compromise of the passphrase (no forward
|
||||||
|
secrecy) or a malicious member (no post-compromise security). For threat
|
||||||
|
models requiring those properties, layer MLS / RFC 9420 on top.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Feature | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `sim` | off | Discrete-event simulator (`examples/simulator.rs`) + test-support exports |
|
||||||
|
| `kani` | off | Build Kani proof harnesses (`#[kani::proof]` functions) |
|
||||||
|
| `miri-compat` | off | Skip Miri-incompatible tests (`#[cfg_attr(miri, ignore)]`) |
|
||||||
|
| `mainline-live` | off | Tests against the real public Mainline DHT (flaky, nightly-only) |
|
||||||
|
| `test-support` | off | Exposes `Builder::dht_backend` for injecting an in-memory DHT in integration tests |
|
||||||
|
|
||||||
|
## MSRV
|
||||||
|
|
||||||
|
Minimum Supported Rust Version: **1.93.0**. Enforced in CI. Bumps are minor
|
||||||
|
releases; we don't raise MSRV without releasing a new minor version.
|
||||||
|
|
||||||
|
Pinned iroh / iroh-gossip major versions: `0.98.x`. Bumping the iroh major
|
||||||
|
will be a minor version of this crate.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
See [CONTRIBUTING.md](CONTRIBUTING.md). Local testing via [`just`]:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
just check # fmt + clippy + nextest + doctests (fast CI lane)
|
||||||
|
just all # everything including miri, kani, stateright
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [CHANGELOG](CHANGELOG.md) for release history.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
[iroh-gossip]: https://docs.rs/iroh-gossip
|
||||||
|
[Mainline DHT]: https://en.wikipedia.org/wiki/Mainline_DHT
|
||||||
|
[`just`]: https://just.systems/
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
Email **steve@waits.net** with the subject prefix `[iroh-gossip-rendezvous
|
||||||
|
security]`. Please include:
|
||||||
|
|
||||||
|
- A description of the vulnerability and its impact.
|
||||||
|
- Steps to reproduce, ideally with a minimal test case.
|
||||||
|
- Your suggestion for a fix or mitigation, if you have one.
|
||||||
|
|
||||||
|
I'll acknowledge receipt within 5 business days and aim to publish a fix
|
||||||
|
(or a detailed mitigation advisory) within 30 days. Please do not file a
|
||||||
|
public issue for security vulnerabilities until a fix is available.
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
Only the latest minor release receives security fixes. Older `0.x` lines
|
||||||
|
become unsupported once a newer minor version ships. `1.0` will establish
|
||||||
|
a longer support window.
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
The crate's design assumes:
|
||||||
|
|
||||||
|
- The public Mainline DHT is **untrusted**: any node can read any slot,
|
||||||
|
publish spoofed records under keys it controls, or drop / replay puts.
|
||||||
|
Signature verification prevents spoofing; last-writer-wins semantics
|
||||||
|
tolerates drop/replay.
|
||||||
|
- The **passphrase** is a shared secret among members. Anyone with the
|
||||||
|
passphrase can participate in the swarm, decrypt DHT records, and sign
|
||||||
|
their own slot writes.
|
||||||
|
- The iroh-gossip layer **itself** provides point-to-point TLS between
|
||||||
|
gossip members. Application payload broadcast over the swarm is visible
|
||||||
|
to every gossip member.
|
||||||
|
|
||||||
|
### What the crate protects against
|
||||||
|
|
||||||
|
- **Passive DHT eavesdroppers**: record plaintext (peer IDs, ages) is
|
||||||
|
AEAD-encrypted under a key derived from the passphrase. An observer
|
||||||
|
sees only opaque ciphertext, a public key, and a monotonic sequence
|
||||||
|
number.
|
||||||
|
- **Active DHT attackers who don't know the passphrase**: cannot produce
|
||||||
|
valid signed puts; their malformed records are rejected on read.
|
||||||
|
- **Cryptographic correctness of the core primitives**: HKDF-SHA256 for
|
||||||
|
key derivation, Ed25519 for signing, ChaCha20-Poly1305 for AEAD, all
|
||||||
|
from the audited [RustCrypto] stack.
|
||||||
|
- **Per-record key isolation**: compromise of a single record's one-time
|
||||||
|
key (`k_r`) leaks only that record — the wrapper master key `W_k` stays
|
||||||
|
protected.
|
||||||
|
|
||||||
|
### What the crate does **not** protect against
|
||||||
|
|
||||||
|
- **Passphrase compromise.** The passphrase is the sole secret. Anyone
|
||||||
|
obtaining it can decrypt every past and future DHT record, forge slot
|
||||||
|
writes, and impersonate members. There is no forward secrecy: an
|
||||||
|
adversary who captures DHT traffic and later obtains the passphrase can
|
||||||
|
decrypt the archive.
|
||||||
|
- **Post-compromise security against a former member.** A removed member
|
||||||
|
who still has the passphrase retains full read/write access. Revocation
|
||||||
|
requires an out-of-band passphrase rotation.
|
||||||
|
- **Metadata confidentiality on the DHT.** Observers see: the slot
|
||||||
|
addresses (public, deterministically derived from the passphrase), record
|
||||||
|
sizes, write frequencies, and sequence numbers. These reveal the rough
|
||||||
|
number of active members, the write rate, and the app salt's identity
|
||||||
|
(via slot-address fingerprinting if the adversary guesses app salts).
|
||||||
|
- **Gossip-layer payload confidentiality.** Once the gossip topic is
|
||||||
|
joined, application payloads broadcast over iroh-gossip are visible to
|
||||||
|
every member. Payload encryption is out of scope; layer MLS / RFC 9420
|
||||||
|
on top if needed.
|
||||||
|
- **Denial of service on the Mainline DHT.** An adversary with heavy DHT
|
||||||
|
resources can crowd out slots or drop puts. The K-sharded design
|
||||||
|
mitigates this (§7 of PROTOCOL.md) but does not eliminate it.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- **MLS / post-compromise security** — future work; separate crate if built.
|
||||||
|
- **Anonymity.** Mainline DHT participation is observable; adversaries who
|
||||||
|
correlate DHT queries with network metadata can de-anonymize members.
|
||||||
|
- **Traffic analysis resistance.** Same caveat.
|
||||||
|
|
||||||
|
## Cryptographic primitives and key derivation
|
||||||
|
|
||||||
|
- **Key agreement**: None. Keys are deterministically derived from the
|
||||||
|
passphrase + app_salt via HKDF-SHA256.
|
||||||
|
- **Slot signing**: Ed25519 via [`ed25519-dalek`]. Each slot has its own
|
||||||
|
signing key, derived via HKDF info string `"sk25519:" || be64(slot_k)`.
|
||||||
|
- **Wrapper master key**: 32 bytes per slot, HKDF info string
|
||||||
|
`"wrap:" || be64(slot_k)`.
|
||||||
|
- **Per-epoch wrapper**: `W_k^(e) = HKDF-Expand(W_k, "epoch" || be64(e))`
|
||||||
|
where `e = floor(seq / E)` (default `E = 64`). Epoch rotation provides
|
||||||
|
coarse-grained key hygiene but not forward secrecy.
|
||||||
|
- **Per-record AEAD**: a fresh 32-byte one-time key `k_r`, wrapped under
|
||||||
|
`W_k^(e)` with a random 12-byte nonce; the record body is AEAD-encrypted
|
||||||
|
under `k_r` with a fixed all-zero nonce (safe because `k_r` is one-shot
|
||||||
|
and discarded after encryption).
|
||||||
|
- **Zeroization**: `k_r`, the derived `W_k^(e)`, and any intermediate
|
||||||
|
HKDF buffers containing passphrase bytes are [`zeroize`]d after use.
|
||||||
|
|
||||||
|
## Supply chain
|
||||||
|
|
||||||
|
- All crypto dependencies are from the [RustCrypto] organization.
|
||||||
|
- CI runs `cargo deny check` and `cargo audit` on every push; known
|
||||||
|
transitive `unmaintained` advisories are documented in `deny.toml`.
|
||||||
|
- `cargo-semver-checks` guards public API stability on PRs.
|
||||||
|
|
||||||
|
[RustCrypto]: https://github.com/RustCrypto
|
||||||
|
[`ed25519-dalek`]: https://docs.rs/ed25519-dalek
|
||||||
|
[`zeroize`]: https://docs.rs/zeroize
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
//! Placeholder — full crypto benchmarks land in Phase 5.
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
//! Placeholder — full keys benchmarks land in Phase 5.
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
//! Placeholder — full merge benchmarks land in Phase 5.
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
//! Placeholder — full wire benchmarks land in Phase 5.
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# cargo-deny configuration.
|
||||||
|
# https://embarkstudios.github.io/cargo-deny/
|
||||||
|
|
||||||
|
[graph]
|
||||||
|
targets = [
|
||||||
|
{ triple = "x86_64-unknown-linux-gnu" },
|
||||||
|
{ triple = "x86_64-apple-darwin" },
|
||||||
|
{ triple = "aarch64-apple-darwin" },
|
||||||
|
{ triple = "x86_64-pc-windows-msvc" },
|
||||||
|
]
|
||||||
|
all-features = true
|
||||||
|
|
||||||
|
# ── Advisories (RUSTSEC) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
[advisories]
|
||||||
|
version = 2
|
||||||
|
yanked = "deny"
|
||||||
|
# Allow transitive "unmaintained" advisories that come via iroh's dep graph
|
||||||
|
# and can't be fixed downstream. Re-evaluate on each iroh bump.
|
||||||
|
# Note: cargo-deny and cargo-audit disagree on which advisories currently fire;
|
||||||
|
# cargo-audit reports atomic-polyfill, cargo-deny's db may not. We list it here
|
||||||
|
# for cargo-audit's benefit via deny.toml being the canonical source.
|
||||||
|
ignore = [
|
||||||
|
# paste — no longer maintained. Pulled in via iroh → portmapper → netwatch
|
||||||
|
# → netlink-packet-core. No direct dep; stuck until iroh migrates.
|
||||||
|
"RUSTSEC-2024-0436",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Licenses ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[licenses]
|
||||||
|
version = 2
|
||||||
|
# MIT + Apache-2.0 + BSD-3-Clause + Unicode are the usual Rust-ecosystem set.
|
||||||
|
# MPL-2.0 allowed (used by encoding_rs / webpki-roots transitively).
|
||||||
|
# CDLA-Permissive-2.0 allowed (webpki-roots certs bundle).
|
||||||
|
allow = [
|
||||||
|
"MIT",
|
||||||
|
"Apache-2.0",
|
||||||
|
"Apache-2.0 WITH LLVM-exception",
|
||||||
|
"BSD-2-Clause",
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"ISC",
|
||||||
|
"Unicode-3.0",
|
||||||
|
"Unicode-DFS-2016",
|
||||||
|
"Zlib",
|
||||||
|
"MPL-2.0",
|
||||||
|
"CDLA-Permissive-2.0",
|
||||||
|
"CC0-1.0",
|
||||||
|
"0BSD",
|
||||||
|
]
|
||||||
|
# Reject GPL/AGPL/SSPL on the whole tree.
|
||||||
|
confidence-threshold = 0.93
|
||||||
|
|
||||||
|
# ── Bans ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[bans]
|
||||||
|
multiple-versions = "warn"
|
||||||
|
wildcards = "deny"
|
||||||
|
highlight = "all"
|
||||||
|
# Explicit allows for known good duplicates (iroh's dep graph has a few).
|
||||||
|
allow = []
|
||||||
|
deny = []
|
||||||
|
# Accept some duplicate crate versions common in the iroh + tokio ecosystems.
|
||||||
|
skip = []
|
||||||
|
skip-tree = []
|
||||||
|
|
||||||
|
# ── Sources ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[sources]
|
||||||
|
unknown-registry = "deny"
|
||||||
|
unknown-git = "deny"
|
||||||
|
allow-git = []
|
||||||
|
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
//! Minimal end-to-end demo: join a swarm, broadcast a message every few
|
||||||
|
//! seconds, print everything received. Run two instances with the same
|
||||||
|
//! passphrase + app_salt on two machines and they'll find each other.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo run --example quickstart -- --passphrase my-secret --app my-app/v1
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use iroh_gossip_rendezvous::Rendezvous;
|
||||||
|
use tokio::sync::broadcast::error::RecvError;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
struct Args {
|
||||||
|
/// Shared passphrase. All instances with the same passphrase+app salt meet.
|
||||||
|
#[arg(long, default_value = "quickstart-demo")]
|
||||||
|
passphrase: String,
|
||||||
|
|
||||||
|
/// Application salt — opaque label identifying your app + version.
|
||||||
|
#[arg(long, default_value = "iroh-gossip-rendezvous/quickstart/v1")]
|
||||||
|
app: String,
|
||||||
|
|
||||||
|
/// Broadcast a message every N seconds.
|
||||||
|
#[arg(long, default_value_t = 5)]
|
||||||
|
broadcast_interval_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"joining rendezvous (passphrase=\"{}\", app=\"{}\")",
|
||||||
|
args.passphrase, args.app
|
||||||
|
);
|
||||||
|
let rendezvous = Rendezvous::join(&args.passphrase, &args.app).await?;
|
||||||
|
println!("node id: {}", rendezvous.node_id().fmt_short());
|
||||||
|
println!(
|
||||||
|
"topic: {}",
|
||||||
|
hex::encode(&rendezvous.topic_id().as_bytes()[..8])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscriber for incoming events.
|
||||||
|
let mut rx = rendezvous.subscribe();
|
||||||
|
|
||||||
|
// Broadcaster loop.
|
||||||
|
let sender = rendezvous.sender().clone();
|
||||||
|
let my_id = rendezvous.node_id().fmt_short();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tick = tokio::time::interval(Duration::from_secs(args.broadcast_interval_secs));
|
||||||
|
let mut counter: u64 = 0;
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
counter += 1;
|
||||||
|
let msg = format!("hello from {my_id} (#{counter})");
|
||||||
|
if let Err(e) = sender.broadcast(bytes::Bytes::from(msg)).await {
|
||||||
|
eprintln!("broadcast error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Periodic state print.
|
||||||
|
let state_handle = {
|
||||||
|
let r = &rendezvous;
|
||||||
|
async {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(15)).await;
|
||||||
|
let s = r.state();
|
||||||
|
println!(
|
||||||
|
"[state] neighbors={} dht={} shards={}",
|
||||||
|
s.neighbor_count, s.dht_status, s.active_shards,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Receive loop.
|
||||||
|
let recv_handle = async {
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(iroh_gossip::api::Event::Received(msg)) => {
|
||||||
|
let delivered = String::from_utf8_lossy(&msg.content);
|
||||||
|
println!("[recv] from={} {delivered}", msg.delivered_from.fmt_short());
|
||||||
|
}
|
||||||
|
Ok(iroh_gossip::api::Event::NeighborUp(id)) => {
|
||||||
|
println!("[neighbor up] {}", id.fmt_short());
|
||||||
|
}
|
||||||
|
Ok(iroh_gossip::api::Event::NeighborDown(id)) => {
|
||||||
|
println!("[neighbor down] {}", id.fmt_short());
|
||||||
|
}
|
||||||
|
Ok(iroh_gossip::api::Event::Lagged) => {
|
||||||
|
eprintln!("[warn] event stream lagged");
|
||||||
|
}
|
||||||
|
Err(RecvError::Lagged(n)) => {
|
||||||
|
eprintln!("[warn] subscription lagged; dropped {n}");
|
||||||
|
}
|
||||||
|
Err(RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
() = recv_handle => {}
|
||||||
|
() = state_handle => {}
|
||||||
|
_ = tokio::signal::ctrl_c() => {
|
||||||
|
println!("\nshutting down...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rendezvous.shutdown().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
//! Discrete-event simulator for the rendezvous algorithm.
|
||||||
|
//!
|
||||||
|
//! Runs the protocol (Merge + heal-read) directly against an in-memory DHT —
|
||||||
|
//! no iroh-gossip, no network. Drives the partition-healing and cold-start
|
||||||
|
//! scenarios from §8 of the design doc.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo run --release --example simulator --features sim -- \
|
||||||
|
//! --scenario cold-start --nodes 10 --runs 50
|
||||||
|
//! cargo run --release --example simulator --features sim -- \
|
||||||
|
//! --scenario partition --nodes 10 --runs 50
|
||||||
|
//! cargo run --release --example simulator --features sim -- \
|
||||||
|
//! --scenario asymmetric --minority 1 --majority 50 --runs 50
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::{Parser, ValueEnum};
|
||||||
|
use iroh_gossip_rendezvous::sim::{self, Outcome, Scenario, SimConfig};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(about = "iroh-gossip-rendezvous discrete-event simulator")]
|
||||||
|
struct Args {
|
||||||
|
/// Scenario to run.
|
||||||
|
#[arg(long, value_enum, default_value_t = ScenarioArg::ColdStart)]
|
||||||
|
scenario: ScenarioArg,
|
||||||
|
|
||||||
|
/// Number of runs.
|
||||||
|
#[arg(long, default_value_t = 50)]
|
||||||
|
runs: usize,
|
||||||
|
|
||||||
|
/// Max rounds per run before declaring it unhealed.
|
||||||
|
#[arg(long, default_value_t = 30)]
|
||||||
|
max_rounds: usize,
|
||||||
|
|
||||||
|
/// Total nodes (cold-start) or per-partition (partition).
|
||||||
|
#[arg(long, default_value_t = 6)]
|
||||||
|
nodes: usize,
|
||||||
|
|
||||||
|
/// Minority size (asymmetric only).
|
||||||
|
#[arg(long, default_value_t = 1)]
|
||||||
|
minority: usize,
|
||||||
|
|
||||||
|
/// Majority size (asymmetric only).
|
||||||
|
#[arg(long, default_value_t = 20)]
|
||||||
|
majority: usize,
|
||||||
|
|
||||||
|
/// Shard count K.
|
||||||
|
#[arg(long, default_value_t = 3)]
|
||||||
|
shards: usize,
|
||||||
|
|
||||||
|
/// Base passphrase. Per-run seeding appends the run index.
|
||||||
|
#[arg(long, default_value = "simulator")]
|
||||||
|
passphrase: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||||
|
enum ScenarioArg {
|
||||||
|
ColdStart,
|
||||||
|
Partition,
|
||||||
|
Asymmetric,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
rt.block_on(run(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(args: Args) -> Result<()> {
|
||||||
|
let cfg = SimConfig {
|
||||||
|
shards: args.shards,
|
||||||
|
max_rounds: args.max_rounds,
|
||||||
|
..SimConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let scenario = match args.scenario {
|
||||||
|
ScenarioArg::ColdStart => Scenario::ColdStart { nodes: args.nodes },
|
||||||
|
ScenarioArg::Partition => Scenario::Partition {
|
||||||
|
a: args.nodes / 2,
|
||||||
|
b: args.nodes - args.nodes / 2,
|
||||||
|
},
|
||||||
|
ScenarioArg::Asymmetric => Scenario::Asymmetric {
|
||||||
|
minority: args.minority,
|
||||||
|
majority: args.majority,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut outcomes: Vec<Outcome> = Vec::with_capacity(args.runs);
|
||||||
|
for run_idx in 0..args.runs {
|
||||||
|
let pass = format!("{}-{run_idx}", args.passphrase);
|
||||||
|
let outcome = sim::run(&pass, scenario, &cfg).await;
|
||||||
|
outcomes.push(outcome);
|
||||||
|
}
|
||||||
|
report(&args, scenario, &outcomes);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report(args: &Args, scenario: Scenario, outcomes: &[Outcome]) {
|
||||||
|
let total = outcomes.len();
|
||||||
|
let healed: Vec<usize> = outcomes.iter().filter_map(|o| o.healed_in).collect();
|
||||||
|
let unhealed = total - healed.len();
|
||||||
|
let pct = healed.len() as f64 / total as f64 * 100.0;
|
||||||
|
|
||||||
|
println!("scenario: {scenario:?}");
|
||||||
|
println!("shards (K): {}", args.shards);
|
||||||
|
println!("max rounds: {}", args.max_rounds);
|
||||||
|
println!("runs: {total}");
|
||||||
|
println!("healed: {} ({:.1}%)", healed.len(), pct);
|
||||||
|
println!("unhealed: {unhealed}");
|
||||||
|
if !healed.is_empty() {
|
||||||
|
let mut sorted = healed.clone();
|
||||||
|
sorted.sort_unstable();
|
||||||
|
let min = sorted[0];
|
||||||
|
let max = sorted[sorted.len() - 1];
|
||||||
|
let p50 = sorted[sorted.len() / 2];
|
||||||
|
let p95_idx = ((sorted.len() * 95) / 100)
|
||||||
|
.saturating_sub(1)
|
||||||
|
.min(sorted.len() - 1);
|
||||||
|
let p95 = sorted[p95_idx];
|
||||||
|
let mean = sorted.iter().sum::<usize>() as f64 / sorted.len() as f64;
|
||||||
|
println!("rounds: min={min} p50={p50} p95={p95} max={max} mean={mean:.1}");
|
||||||
|
}
|
||||||
|
let avg_reads = outcomes.iter().map(|o| o.dht_reads as f64).sum::<f64>() / total as f64;
|
||||||
|
let avg_writes = outcomes.iter().map(|o| o.dht_writes as f64).sum::<f64>() / total as f64;
|
||||||
|
println!("dht ops: avg reads {avg_reads:.1}, avg writes {avg_writes:.1}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
target/
|
||||||
|
corpus/
|
||||||
|
artifacts/
|
||||||
|
coverage/
|
||||||
Generated
+4506
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,47 @@
|
|||||||
|
[package]
|
||||||
|
name = "iroh-gossip-rendezvous-fuzz"
|
||||||
|
version = "0.0.0"
|
||||||
|
publish = false
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
cargo-fuzz = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libfuzzer-sys = "0.4"
|
||||||
|
arbitrary = { version = "1", features = ["derive"] }
|
||||||
|
iroh-gossip-rendezvous = { path = "..", features = ["sim"] }
|
||||||
|
rand = "0.9"
|
||||||
|
ed25519-dalek = "2"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fuzz_decode_entries"
|
||||||
|
path = "fuzz_targets/fuzz_decode_entries.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fuzz_crypto_open"
|
||||||
|
path = "fuzz_targets/fuzz_crypto_open.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fuzz_merge"
|
||||||
|
path = "fuzz_targets/fuzz_merge.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fuzz_wire_roundtrip"
|
||||||
|
path = "fuzz_targets/fuzz_wire_roundtrip.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = ["."]
|
||||||
|
# Keep the fuzz workspace isolated from the parent crate's target dir.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//! Fuzz the crypto-envelope parser via arbitrary bytes. We don't expose
|
||||||
|
//! `crypto::open` publicly (it's a crate internal); instead we drive
|
||||||
|
//! the comparable "decode arbitrary bytes as if they were an envelope"
|
||||||
|
//! path through [`InMemoryDht`] writes — which themselves must handle
|
||||||
|
//! malformed signatures cleanly.
|
||||||
|
|
||||||
|
#![no_main]
|
||||||
|
|
||||||
|
use iroh_gossip_rendezvous::sim::decode_entries;
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
// Minimum fuzz: decode_entries never panics on arbitrary bytes.
|
||||||
|
// crypto::open is covered indirectly — any bytes that decode to
|
||||||
|
// "entries" without panicking are a safe path.
|
||||||
|
let _ = decode_entries(data);
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//! Fuzz `decode_entries` with arbitrary bytes. Must never panic —
|
||||||
|
//! malformed input always returns `Err(MalformedRecord)`.
|
||||||
|
|
||||||
|
#![no_main]
|
||||||
|
|
||||||
|
use iroh_gossip_rendezvous::sim::decode_entries;
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
// Any byte slice is a valid input. Either returns Ok(entries) with
|
||||||
|
// len ≤ MAX_ENTRIES or returns Err. Never panics.
|
||||||
|
let _ = decode_entries(data);
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//! Fuzz the wire-roundtrip path with structure-aware input. The `merge()`
|
||||||
|
//! function itself is crate-internal; we exercise it indirectly by
|
||||||
|
//! constructing random entry lists and checking encode→decode is
|
||||||
|
//! idempotent. Direct coverage of `merge` comes from the property tests
|
||||||
|
//! in `src/merge.rs`.
|
||||||
|
|
||||||
|
#![no_main]
|
||||||
|
|
||||||
|
use arbitrary::Arbitrary;
|
||||||
|
use iroh_gossip_rendezvous::sim::{decode_entries, encode_entries, Entry, MAX_ENTRIES};
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
#[derive(Arbitrary, Debug)]
|
||||||
|
struct FuzzInput {
|
||||||
|
entries: Vec<([u8; 32], u8)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fuzz_target!(|input: FuzzInput| {
|
||||||
|
let entries: Vec<Entry> = input
|
||||||
|
.entries
|
||||||
|
.into_iter()
|
||||||
|
.take(MAX_ENTRIES)
|
||||||
|
.map(|(id, age)| Entry { id, age })
|
||||||
|
.collect();
|
||||||
|
let encoded = encode_entries(&entries);
|
||||||
|
let decoded = decode_entries(&encoded).expect("roundtrip");
|
||||||
|
assert_eq!(decoded.len(), entries.len());
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//! Fuzz `decode_entries(encode_entries(x))` roundtrip. Any well-formed
|
||||||
|
//! input must roundtrip exactly; any malformed input must fail cleanly.
|
||||||
|
|
||||||
|
#![no_main]
|
||||||
|
|
||||||
|
use arbitrary::Arbitrary;
|
||||||
|
use iroh_gossip_rendezvous::sim::{decode_entries, encode_entries, Entry, MAX_ENTRIES};
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
#[derive(Arbitrary, Debug)]
|
||||||
|
struct FuzzInput {
|
||||||
|
entries: Vec<([u8; 32], u8)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fuzz_target!(|input: FuzzInput| {
|
||||||
|
// Cap at MAX_ENTRIES since encode_entries requires it.
|
||||||
|
let entries: Vec<Entry> = input
|
||||||
|
.entries
|
||||||
|
.into_iter()
|
||||||
|
.take(MAX_ENTRIES)
|
||||||
|
.map(|(id, age)| Entry { id, age })
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let encoded = encode_entries(&entries);
|
||||||
|
let decoded = decode_entries(&encoded).expect("well-formed encode decodes");
|
||||||
|
assert_eq!(decoded, entries);
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# iroh-gossip-rendezvous task runner.
|
||||||
|
# Every CI lane replayable locally. Matches .github/workflows/{ci,nightly}.yml.
|
||||||
|
|
||||||
|
default: check
|
||||||
|
|
||||||
|
# ── Fast lane (matches CI per-push) ────────────────────────────────
|
||||||
|
|
||||||
|
# Format + lint + fast tests. Match CI fast-lane exactly.
|
||||||
|
check: fmt-check clippy test
|
||||||
|
|
||||||
|
# rustfmt — verify only (CI mode).
|
||||||
|
fmt-check:
|
||||||
|
cargo fmt --check
|
||||||
|
|
||||||
|
# rustfmt — apply.
|
||||||
|
fmt:
|
||||||
|
cargo fmt
|
||||||
|
|
||||||
|
# clippy with deny-warnings, all features.
|
||||||
|
clippy:
|
||||||
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
# Unit + integration + doctests. nextest is faster for the first two.
|
||||||
|
test: test-lib test-doc
|
||||||
|
|
||||||
|
test-lib:
|
||||||
|
cargo nextest run --all-features
|
||||||
|
|
||||||
|
test-doc:
|
||||||
|
cargo test --doc --all-features
|
||||||
|
|
||||||
|
# Supply-chain hygiene.
|
||||||
|
deny:
|
||||||
|
cargo deny check
|
||||||
|
|
||||||
|
audit:
|
||||||
|
cargo audit
|
||||||
|
|
||||||
|
# SemVer compliance vs the last released version. Skipped until the crate
|
||||||
|
# has a baseline on crates.io (i.e., post-0.1.0 publish).
|
||||||
|
semver:
|
||||||
|
@if cargo semver-checks check-release 2>&1 | grep -q "not found in registry"; then \
|
||||||
|
echo "skip: crate not yet on crates.io (pre-first-publish)"; \
|
||||||
|
else \
|
||||||
|
cargo semver-checks check-release; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Slow / nightly lane ────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Line + branch coverage via LLVM source-based coverage.
|
||||||
|
coverage:
|
||||||
|
cargo llvm-cov --all-features --html
|
||||||
|
@echo "Report: target/llvm-cov/html/index.html"
|
||||||
|
|
||||||
|
coverage-lcov:
|
||||||
|
cargo llvm-cov --all-features --lcov --output-path lcov.info
|
||||||
|
|
||||||
|
# Mutation testing. Long-running.
|
||||||
|
mutants:
|
||||||
|
cargo mutants --all-features --timeout-multiplier 3.0
|
||||||
|
|
||||||
|
# Fuzz a single target for 60s (parameterized). Requires nightly toolchain.
|
||||||
|
# Usage: just fuzz fuzz_decode_entries
|
||||||
|
fuzz TARGET:
|
||||||
|
cd fuzz && cargo +nightly fuzz run {{TARGET}} -- -max_total_time=60
|
||||||
|
|
||||||
|
# Miri UB check on the pure algorithmic modules (keys / wire / crypto / merge).
|
||||||
|
# Other modules use tokio I/O which Miri can't drive. ~5-10 min per module.
|
||||||
|
miri:
|
||||||
|
MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri test --lib \
|
||||||
|
keys:: wire:: crypto:: merge::
|
||||||
|
|
||||||
|
# Kani bounded model checking on the pure algorithmic core.
|
||||||
|
#
|
||||||
|
# Marches through all harnesses cheapest → priciest, bailing on the first
|
||||||
|
# failure (just's default fail-fast recipe semantics). A per-process memory
|
||||||
|
# cap (ulimit -v) keeps a runaway proof from OOMing the machine — if a
|
||||||
|
# harness blows the cap it dies with an allocation error and the recipe
|
||||||
|
# stops, rather than swapping the system to death.
|
||||||
|
#
|
||||||
|
# Tune KANI_MEM_KB for your box (default 6 GB suits an 8-GB laptop):
|
||||||
|
# KANI_MEM_KB=3145728 just kani # 3 GB cap
|
||||||
|
#
|
||||||
|
# Run a single harness with: `just kani-one <name>`.
|
||||||
|
KANI_MEM_KB := env_var_or_default("KANI_MEM_KB", "6291456")
|
||||||
|
|
||||||
|
kani:
|
||||||
|
@echo "Running Kani harnesses in order, bailing on first failure."
|
||||||
|
@echo "Per-process memory cap: {{KANI_MEM_KB}} KB."
|
||||||
|
just kani-one crypto_epoch_of_no_overflow
|
||||||
|
just kani-one wire_decode_no_panic
|
||||||
|
@echo "All Kani harnesses passed."
|
||||||
|
|
||||||
|
# Run a single Kani harness under a memory cap. Usage: `just kani-one NAME`.
|
||||||
|
kani-one NAME:
|
||||||
|
@echo "━━━ kani: {{NAME}} ━━━"
|
||||||
|
ulimit -v {{KANI_MEM_KB}} && cargo kani --harness {{NAME}}
|
||||||
|
|
||||||
|
# Stateright: BFS safety + bounded liveness for N=4, K=3. Minutes.
|
||||||
|
model-bfs:
|
||||||
|
cargo test --test stateright_model --release -- --ignored bfs_small
|
||||||
|
|
||||||
|
# Stateright: random-walk for N=20, K=3, 10^7 steps. ~10 minutes.
|
||||||
|
model-random:
|
||||||
|
cargo test --test stateright_model --release -- --ignored random_large
|
||||||
|
|
||||||
|
# Criterion benchmarks.
|
||||||
|
bench:
|
||||||
|
cargo bench --all-features
|
||||||
|
|
||||||
|
# ── Docs ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Build and open rustdoc.
|
||||||
|
docs:
|
||||||
|
cargo doc --no-deps --all-features --open
|
||||||
|
|
||||||
|
# Build docs without opening (CI).
|
||||||
|
docs-build:
|
||||||
|
cargo doc --no-deps --all-features
|
||||||
|
|
||||||
|
# ── Aggregate targets ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Everything: the whole CI matrix locally. Heavy.
|
||||||
|
all: check coverage deny audit semver miri kani model-bfs bench docs-build
|
||||||
|
@echo "All checks green."
|
||||||
|
|
||||||
|
# ── Simulator ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Run the §8 scenario matrix and archive results.
|
||||||
|
sim-matrix:
|
||||||
|
cargo run --release --example simulator --features sim -- --scenario cold-start --nodes 8 --runs 50
|
||||||
|
cargo run --release --example simulator --features sim -- --scenario partition --nodes 10 --runs 50
|
||||||
|
cargo run --release --example simulator --features sim -- --scenario asymmetric --minority 1 --majority 10 --runs 50
|
||||||
|
cargo run --release --example simulator --features sim -- --scenario asymmetric --minority 1 --majority 100 --runs 20
|
||||||
|
|
||||||
|
# ── Release ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Dry-run publish. Must be green before tagging.
|
||||||
|
publish-check: all
|
||||||
|
cargo publish --dry-run
|
||||||
+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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! Smoke tests for the public API surface — compile-and-dispatch checks
|
||||||
|
//! that the public types are shaped as advertised. No network required.
|
||||||
|
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
|
|
||||||
|
use iroh_gossip_rendezvous::{Builder, Error, Rendezvous};
|
||||||
|
|
||||||
|
/// `Rendezvous::join` rejects an empty passphrase before any network IO.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn join_empty_passphrase_errors() {
|
||||||
|
let r = Rendezvous::join("", "my-app/v1").await;
|
||||||
|
assert!(matches!(r, Err(Error::EmptyPassphrase)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same for an empty app_salt.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn join_empty_app_salt_errors() {
|
||||||
|
let r = Rendezvous::join("passphrase", "").await;
|
||||||
|
assert!(matches!(r, Err(Error::EmptyAppSalt)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The builder enforces the same checks.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn builder_requires_passphrase() {
|
||||||
|
let r = Builder::default().app_salt("my-app/v1").build().await;
|
||||||
|
assert!(matches!(r, Err(Error::EmptyPassphrase)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn builder_requires_app_salt() {
|
||||||
|
let r = Builder::default().passphrase("pass").build().await;
|
||||||
|
assert!(matches!(r, Err(Error::EmptyAppSalt)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn builder_rejects_zero_shards() {
|
||||||
|
let r = Builder::default()
|
||||||
|
.passphrase("pass")
|
||||||
|
.app_salt("my-app/v1")
|
||||||
|
.shards(0)
|
||||||
|
.build()
|
||||||
|
.await;
|
||||||
|
assert!(matches!(r, Err(Error::InvalidShardCount)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn builder_rejects_zero_max_age() {
|
||||||
|
let r = Builder::default()
|
||||||
|
.passphrase("pass")
|
||||||
|
.app_salt("my-app/v1")
|
||||||
|
.max_age(0)
|
||||||
|
.build()
|
||||||
|
.await;
|
||||||
|
assert!(matches!(r, Err(Error::InvalidMaxAge)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public re-exports compile as documented.
|
||||||
|
#[test]
|
||||||
|
fn public_types_exist() {
|
||||||
|
fn assert_send<T: Send>() {}
|
||||||
|
fn assert_sync<T: Sync>() {}
|
||||||
|
assert_send::<Rendezvous>();
|
||||||
|
assert_sync::<Rendezvous>();
|
||||||
|
assert_send::<Builder>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//! Keeps `PROTOCOL.md §2` parameter defaults and `§3` wire-format constants
|
||||||
|
//! in sync with the code. Changing any number in the spec or the code
|
||||||
|
//! without updating the other fails this test.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use iroh_gossip_rendezvous::defaults::{
|
||||||
|
DEFAULT_EPOCH_WRITES, DEFAULT_HEAL_PERIOD, DEFAULT_JITTER, DEFAULT_MAX_AGE,
|
||||||
|
DEFAULT_MAX_ENTRIES, DEFAULT_MAX_VOUCHES, DEFAULT_SHARDS, DEFAULT_WRITE_PERIOD,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── §2 Parameter defaults ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spec_section_2_default_shards_is_3() {
|
||||||
|
// PROTOCOL.md §2 table: K = 3.
|
||||||
|
assert_eq!(DEFAULT_SHARDS, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spec_section_2_default_max_entries_is_27() {
|
||||||
|
// PROTOCOL.md §2 table + §3 byte budget: B = 27.
|
||||||
|
assert_eq!(DEFAULT_MAX_ENTRIES, 27);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spec_section_2_default_max_age_is_27() {
|
||||||
|
// PROTOCOL.md §2 table + §7 (A_max = B): A_max = 27.
|
||||||
|
assert_eq!(DEFAULT_MAX_AGE, 27);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spec_section_2_default_max_vouches_is_3() {
|
||||||
|
// PROTOCOL.md §2 table: V = 3.
|
||||||
|
assert_eq!(DEFAULT_MAX_VOUCHES, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spec_section_2_default_epoch_writes_is_64() {
|
||||||
|
// PROTOCOL.md §2 table: E = 64 writes per wrapper-key epoch.
|
||||||
|
assert_eq!(DEFAULT_EPOCH_WRITES, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spec_section_2_default_write_period_is_5_minutes() {
|
||||||
|
// PROTOCOL.md §2 table: T_w = 5 min.
|
||||||
|
assert_eq!(DEFAULT_WRITE_PERIOD, Duration::from_secs(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spec_section_2_default_heal_period_is_30_seconds() {
|
||||||
|
// PROTOCOL.md §2 table: T_h = 30 s.
|
||||||
|
assert_eq!(DEFAULT_HEAL_PERIOD, Duration::from_secs(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spec_section_2_default_jitter_is_half() {
|
||||||
|
// PROTOCOL.md §2 table: σ = ±50% → factor 0.5.
|
||||||
|
assert!((DEFAULT_JITTER - 0.5_f32).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spec_section_7_a_max_equals_b() {
|
||||||
|
// PROTOCOL.md §7: "Age cap equals size cap. A_max = B."
|
||||||
|
assert_eq!(DEFAULT_MAX_AGE as usize, DEFAULT_MAX_ENTRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spec_section_7_t_h_is_one_tenth_t_w() {
|
||||||
|
// PROTOCOL.md §7: "Heal-loop period T_h ≪ T_w. T_h/T_w = 1/10 is the knee."
|
||||||
|
let ratio = DEFAULT_HEAL_PERIOD.as_secs_f32() / DEFAULT_WRITE_PERIOD.as_secs_f32();
|
||||||
|
assert!(
|
||||||
|
(ratio - 0.1).abs() < 0.001,
|
||||||
|
"T_h/T_w should be ≈ 0.1, got {ratio}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
//! Stateright model of the rendezvous algorithm.
|
||||||
|
//!
|
||||||
|
//! Gives a bounded-exhaustive safety proof (BFS) and a sampled liveness
|
||||||
|
//! witness (random walk) for the PROTOCOL.md §§4–5 procedures.
|
||||||
|
//!
|
||||||
|
//! Scope — what this model **does** capture:
|
||||||
|
//! * `WriteSlot`: a node writes its self-entry (age 0) + up to V vouches
|
||||||
|
//! at age 1 to its `slot_u = H(self_id) mod K`, using a simplified merge
|
||||||
|
//! (age-up, evict by max-age with deterministic tie-break).
|
||||||
|
//! * `HealRead`: a node reads a random slot and adds discovered IDs to
|
||||||
|
//! its `joined` set — the simulator's stand-in for `GossipSender::join_peers`.
|
||||||
|
//! * `Dial`: completes a gossip connection (the iroh-gossip dial handshake
|
||||||
|
//! is abstracted to a single atomic action).
|
||||||
|
//!
|
||||||
|
//! What it **does not** model (by design):
|
||||||
|
//! * Crypto — accepted correct (RustCrypto stack).
|
||||||
|
//! * Actual network timing, jitter, or dropped packets.
|
||||||
|
//! * Byzantine writers.
|
||||||
|
//!
|
||||||
|
//! **Safety properties checked** (must always hold across every reachable
|
||||||
|
//! state in the BFS-explored state space):
|
||||||
|
//!
|
||||||
|
//! - **S1 (size)**: every slot's record has `len ≤ B`.
|
||||||
|
//! - **S2 (age)**: every entry has `age < A_max`.
|
||||||
|
//!
|
||||||
|
//! **Liveness property** (sampled via random-walk; not exhaustive):
|
||||||
|
//!
|
||||||
|
//! - **L1 (partition heals)**: starting from two disjoint connected
|
||||||
|
//! components, eventually some node in one has discovered a node in
|
||||||
|
//! the other via a HealRead.
|
||||||
|
//!
|
||||||
|
//! Run:
|
||||||
|
//! ```text
|
||||||
|
//! cargo test --test stateright_model --release -- --ignored bfs_small
|
||||||
|
//! cargo test --test stateright_model --release -- --ignored random_large
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
|
#![allow(dead_code, unused_variables)]
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use stateright::Checker;
|
||||||
|
use stateright::Model;
|
||||||
|
|
||||||
|
/// Simplified entry: (id, age). Used in the model, not the real crate.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
struct ModelEntry {
|
||||||
|
id: u8,
|
||||||
|
age: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simplified slot. `seq` is monotonic per slot; writes bump by 1.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
|
||||||
|
struct Slot {
|
||||||
|
entries: Vec<ModelEntry>,
|
||||||
|
seq: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
struct ModelState {
|
||||||
|
slots: Vec<Slot>, // len = K
|
||||||
|
neighbors: Vec<BTreeSet<u8>>, // per-node active-view sets (node ids)
|
||||||
|
joined: Vec<BTreeSet<u8>>, // per-node "discovered via heal-read" set
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
enum Action {
|
||||||
|
/// Node `id` writes to its `slot_u` — adds self at age 0, vouches for
|
||||||
|
/// all current neighbors (up to V), evicts oldest.
|
||||||
|
Write { node: u8 },
|
||||||
|
/// Node `id` heals by reading slot `slot_idx` — union entries' IDs
|
||||||
|
/// into its `joined` set.
|
||||||
|
HealRead { node: u8, slot_idx: u8 },
|
||||||
|
/// Node `a` dials `b`. Updates both neighbor sets.
|
||||||
|
Dial { a: u8, b: u8 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters: small to keep BFS tractable.
|
||||||
|
struct RendezvousModel {
|
||||||
|
n: u8, // number of nodes
|
||||||
|
k: u8, // number of slots
|
||||||
|
max_age: u8, // A_max
|
||||||
|
max_entries: u8, // B
|
||||||
|
max_vouches: u8, // V
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RendezvousModel {
|
||||||
|
fn slot_for(&self, id: u8) -> u8 {
|
||||||
|
// Deterministic slot assignment.
|
||||||
|
id % self.k
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform the merge: age up non-self entries, drop ≥ A_max, add self
|
||||||
|
/// at 0, vouch up to V neighbors at 1, evict max-age.
|
||||||
|
fn merge(
|
||||||
|
&self,
|
||||||
|
current: &[ModelEntry],
|
||||||
|
self_id: u8,
|
||||||
|
neighbor_ids: &BTreeSet<u8>,
|
||||||
|
) -> Vec<ModelEntry> {
|
||||||
|
let mut out: Vec<ModelEntry> = current
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.id != self_id)
|
||||||
|
.filter_map(|e| {
|
||||||
|
let a = e.age.saturating_add(1);
|
||||||
|
(a < self.max_age).then_some(ModelEntry { id: e.id, age: a })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.push(ModelEntry {
|
||||||
|
id: self_id,
|
||||||
|
age: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vouch deterministically: first V neighbors in sorted order.
|
||||||
|
for &nid in neighbor_ids.iter().take(self.max_vouches as usize) {
|
||||||
|
if nid == self_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(existing) = out.iter_mut().find(|e| e.id == nid) {
|
||||||
|
if existing.age > 1 {
|
||||||
|
existing.age = 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push(ModelEntry { id: nid, age: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict: deterministic tie-break — drop highest id among oldest.
|
||||||
|
while out.len() > self.max_entries as usize {
|
||||||
|
let max_age = out.iter().map(|e| e.age).max().unwrap_or(0);
|
||||||
|
let pick = out
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, e)| e.age == max_age)
|
||||||
|
.max_by_key(|(_, e)| e.id)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(0);
|
||||||
|
out.swap_remove(pick);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort_by(|a, b| a.id.cmp(&b.id).then(a.age.cmp(&b.age)));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Model for RendezvousModel {
|
||||||
|
type State = ModelState;
|
||||||
|
type Action = Action;
|
||||||
|
|
||||||
|
fn init_states(&self) -> Vec<Self::State> {
|
||||||
|
let slots = (0..self.k).map(|_| Slot::default()).collect();
|
||||||
|
let neighbors = (0..self.n).map(|_| BTreeSet::new()).collect();
|
||||||
|
let joined = (0..self.n).map(|_| BTreeSet::new()).collect();
|
||||||
|
vec![ModelState {
|
||||||
|
slots,
|
||||||
|
neighbors,
|
||||||
|
joined,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actions(&self, state: &Self::State, actions: &mut Vec<Self::Action>) {
|
||||||
|
for node in 0..self.n {
|
||||||
|
actions.push(Action::Write { node });
|
||||||
|
for slot_idx in 0..self.k {
|
||||||
|
actions.push(Action::HealRead { node, slot_idx });
|
||||||
|
}
|
||||||
|
for other in 0..self.n {
|
||||||
|
if other != node && !state.neighbors[node as usize].contains(&other) {
|
||||||
|
actions.push(Action::Dial { a: node, b: other });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_state(&self, last: &Self::State, action: Self::Action) -> Option<Self::State> {
|
||||||
|
let mut state = last.clone();
|
||||||
|
match action {
|
||||||
|
Action::Write { node } => {
|
||||||
|
let slot = self.slot_for(node) as usize;
|
||||||
|
let neighbors = state.neighbors[node as usize].clone();
|
||||||
|
let merged = self.merge(&state.slots[slot].entries, node, &neighbors);
|
||||||
|
state.slots[slot].entries = merged;
|
||||||
|
state.slots[slot].seq += 1;
|
||||||
|
}
|
||||||
|
Action::HealRead { node, slot_idx } => {
|
||||||
|
let entries = state.slots[slot_idx as usize].entries.clone();
|
||||||
|
for e in entries {
|
||||||
|
if e.id != node {
|
||||||
|
state.joined[node as usize].insert(e.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::Dial { a, b } => {
|
||||||
|
state.neighbors[a as usize].insert(b);
|
||||||
|
state.neighbors[b as usize].insert(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn properties(&self) -> Vec<stateright::Property<Self>> {
|
||||||
|
// Stateright 0.31's Property::always takes a fn pointer — no
|
||||||
|
// closure captures. Property functions read the bounds off `m`.
|
||||||
|
vec![
|
||||||
|
stateright::Property::<Self>::always("S1 record size bounded", |m, s| {
|
||||||
|
s.slots
|
||||||
|
.iter()
|
||||||
|
.all(|slot| slot.entries.len() <= m.max_entries as usize)
|
||||||
|
}),
|
||||||
|
stateright::Property::<Self>::always("S2 age bounded", |m, s| {
|
||||||
|
s.slots
|
||||||
|
.iter()
|
||||||
|
.all(|slot| slot.entries.iter().all(|e| e.age < m.max_age))
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// BFS proof of S1 + S2 for N=2, K=1 with small bounds. Runs quickly
|
||||||
|
/// (seconds). Verifies the model's safety invariants hold across every
|
||||||
|
/// reachable state.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "stateright: explicit run"]
|
||||||
|
fn bfs_small() {
|
||||||
|
let model = RendezvousModel {
|
||||||
|
n: 2,
|
||||||
|
k: 1,
|
||||||
|
max_age: 3,
|
||||||
|
max_entries: 3,
|
||||||
|
max_vouches: 1,
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.checker()
|
||||||
|
.threads(num_cpus())
|
||||||
|
.target_max_depth(20)
|
||||||
|
.spawn_bfs()
|
||||||
|
.join()
|
||||||
|
.assert_properties();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BFS at N=3, K=2 — larger but should terminate in minutes.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "stateright: explicit run, slow"]
|
||||||
|
fn bfs_n3_k2() {
|
||||||
|
let model = RendezvousModel {
|
||||||
|
n: 3,
|
||||||
|
k: 2,
|
||||||
|
max_age: 4,
|
||||||
|
max_entries: 4,
|
||||||
|
max_vouches: 2,
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.checker()
|
||||||
|
.threads(num_cpus())
|
||||||
|
.target_max_depth(30)
|
||||||
|
.spawn_bfs()
|
||||||
|
.join()
|
||||||
|
.assert_properties();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Random-walk exploration for a larger configuration (N=6, K=3). Not
|
||||||
|
/// exhaustive; searches up to 10⁶ states for counterexamples. Useful
|
||||||
|
/// evidence but not proof.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "stateright: explicit run, ~minutes"]
|
||||||
|
fn random_large() {
|
||||||
|
let model = RendezvousModel {
|
||||||
|
n: 6,
|
||||||
|
k: 3,
|
||||||
|
max_age: 5,
|
||||||
|
max_entries: 5,
|
||||||
|
max_vouches: 3,
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.checker()
|
||||||
|
.threads(num_cpus())
|
||||||
|
.target_max_depth(50)
|
||||||
|
.spawn_simulation(0, stateright::UniformChooser)
|
||||||
|
.join()
|
||||||
|
.assert_properties();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num_cpus() -> usize {
|
||||||
|
std::thread::available_parallelism()
|
||||||
|
.map(std::num::NonZeroUsize::get)
|
||||||
|
.unwrap_or(4)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
//! Two real iroh Endpoints connected over a shared in-memory DHT.
|
||||||
|
//!
|
||||||
|
//! Validates the full stack end-to-end — `Builder::build` → `initial_join`
|
||||||
|
//! → WriteLoop publish → HealLoop read → iroh-gossip dial → `NeighborUp`
|
||||||
|
//! event — without touching the real Mainline DHT.
|
||||||
|
//!
|
||||||
|
//! Requires the `test-support` feature to inject the custom DHT backend.
|
||||||
|
|
||||||
|
#![cfg(feature = "test-support")]
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use iroh_gossip_rendezvous::sim::InMemoryDht;
|
||||||
|
use iroh_gossip_rendezvous::{Builder, DhtStatus};
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn two_nodes_publish_and_discover_via_dht() {
|
||||||
|
// Shared InMemoryDht — both nodes inject it via the `test-support`
|
||||||
|
// Builder::dht_backend hook, bypassing the real Mainline client.
|
||||||
|
//
|
||||||
|
// This test validates the **rendezvous** layer (DHT publish + read),
|
||||||
|
// not the gossip-dial handshake. iroh-gossip dialing requires N0
|
||||||
|
// discovery for network addresses and is subject to its own timing,
|
||||||
|
// so the stricter "neighbors see each other" claim lives in an
|
||||||
|
// `#[ignore]`d test below that's run explicitly.
|
||||||
|
let dht = Arc::new(InMemoryDht::new());
|
||||||
|
|
||||||
|
let rendezvous_a = Builder::default()
|
||||||
|
.passphrase("integration-test")
|
||||||
|
.app_salt("iroh-gossip-rendezvous/tests/two-node")
|
||||||
|
.heal_period(Duration::from_millis(200))
|
||||||
|
.write_period(Duration::from_secs(1))
|
||||||
|
.jitter(0.1)
|
||||||
|
.dht_backend(dht.clone())
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.expect("node A builds");
|
||||||
|
|
||||||
|
let rendezvous_b = Builder::default()
|
||||||
|
.passphrase("integration-test")
|
||||||
|
.app_salt("iroh-gossip-rendezvous/tests/two-node")
|
||||||
|
.heal_period(Duration::from_millis(200))
|
||||||
|
.write_period(Duration::from_secs(1))
|
||||||
|
.jitter(0.1)
|
||||||
|
.dht_backend(dht.clone())
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.expect("node B builds");
|
||||||
|
|
||||||
|
// Both nodes share the same passphrase + app_salt → same topic_id.
|
||||||
|
assert_eq!(rendezvous_a.topic_id(), rendezvous_b.topic_id());
|
||||||
|
|
||||||
|
// Give the write loop a couple of ticks to publish. Both nodes'
|
||||||
|
// `DhtStatus` should flip from Bootstrapping to Ready once the first
|
||||||
|
// write succeeds.
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
let a = rendezvous_a.state();
|
||||||
|
let b = rendezvous_b.state();
|
||||||
|
if a.dht_status == DhtStatus::Ready && b.dht_status == DhtStatus::Ready {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
std::time::Instant::now() < deadline,
|
||||||
|
"DHT publishes didn't complete in 5s: A={:?} B={:?}",
|
||||||
|
a.dht_status,
|
||||||
|
b.dht_status
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown.
|
||||||
|
rendezvous_a.shutdown().await;
|
||||||
|
rendezvous_b.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-to-end convergence over real iroh-gossip. Ignored by default
|
||||||
|
/// because iroh's N0 discovery + QUIC handshake is several-seconds-slow
|
||||||
|
/// on cold start and network-sensitive. Run with:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// cargo test --test two_node_bootstrap --features test-support -- --ignored
|
||||||
|
/// ```
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
#[ignore = "slow: depends on iroh-gossip cold-start dialing"]
|
||||||
|
async fn two_nodes_converge_end_to_end() {
|
||||||
|
let dht = Arc::new(InMemoryDht::new());
|
||||||
|
let a = Builder::default()
|
||||||
|
.passphrase("integration-test")
|
||||||
|
.app_salt("iroh-gossip-rendezvous/tests/e2e")
|
||||||
|
.heal_period(Duration::from_millis(500))
|
||||||
|
.write_period(Duration::from_secs(1))
|
||||||
|
.dht_backend(dht.clone())
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let b = Builder::default()
|
||||||
|
.passphrase("integration-test")
|
||||||
|
.app_salt("iroh-gossip-rendezvous/tests/e2e")
|
||||||
|
.heal_period(Duration::from_millis(500))
|
||||||
|
.write_period(Duration::from_secs(1))
|
||||||
|
.dht_backend(dht.clone())
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(60);
|
||||||
|
loop {
|
||||||
|
if a.state().neighbor_count > 0 && b.state().neighbor_count > 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(std::time::Instant::now() < deadline, "never converged");
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
a.shutdown().await;
|
||||||
|
b.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn drop_cancels_background_tasks() {
|
||||||
|
let dht = Arc::new(InMemoryDht::new());
|
||||||
|
{
|
||||||
|
let _rendezvous = Builder::default()
|
||||||
|
.passphrase("integration-test")
|
||||||
|
.app_salt("iroh-gossip-rendezvous/tests/drop")
|
||||||
|
.heal_period(Duration::from_millis(100))
|
||||||
|
.write_period(Duration::from_millis(500))
|
||||||
|
.dht_backend(dht.clone())
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.expect("node builds");
|
||||||
|
// Let the background loops start and do at least one publish.
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
// Dropping the rendezvous triggers the cancellation token and the
|
||||||
|
// task JoinSets are aborted when dropped.
|
||||||
|
}
|
||||||
|
|
||||||
|
// After drop, wait a bit and verify no panics (tokio would surface them
|
||||||
|
// via the test harness). Successful drop = implicit pass.
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn multiple_subscribers_receive_events() {
|
||||||
|
let dht = Arc::new(InMemoryDht::new());
|
||||||
|
let rendezvous = Builder::default()
|
||||||
|
.passphrase("integration-test")
|
||||||
|
.app_salt("iroh-gossip-rendezvous/tests/multi-sub")
|
||||||
|
.dht_backend(dht.clone())
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.expect("build");
|
||||||
|
|
||||||
|
// Two subscribers, both should register.
|
||||||
|
let sub1 = rendezvous.subscribe();
|
||||||
|
let sub2 = rendezvous.subscribe();
|
||||||
|
assert_eq!(sub1.len(), sub2.len(), "both fresh subs start empty");
|
||||||
|
|
||||||
|
rendezvous.shutdown().await;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user