|
|
@@ -1,425 +1,685 @@
|
|
|
-# Design: swarm overlay for subnet discovery
|
|
|
+# Design: swarm overlay for subnet rendezvous
|
|
|
|
|
|
## Context
|
|
|
|
|
|
-Today a "network" is one `P2p` instance identified by `{magic_bytes,
|
|
|
-app_name, app_version}`. Magic bytes are checked at channel setup
|
|
|
-(`src/net/channel.rs`, raw frame read) before the version/verack handshake
|
|
|
-checks `app_name`. Hostlists, refinement, and datastores are per-`P2p`.
|
|
|
-Lilith spawns one `P2p` per configured network (`bin/lilith/src/main.rs`,
|
|
|
-`spawn_net()`), each needing its own listener, datastore, and config section.
|
|
|
-Apps construct their own `P2p` at startup from static seed lists.
|
|
|
-
|
|
|
-Extension points already public in `src/net` and sufficient for an overlay
|
|
|
-without core surgery:
|
|
|
-
|
|
|
-- `ProtocolRegistry::register(session_flags, constructor)` — attach custom
|
|
|
- protocols per session type (`protocol_registry.rs`).
|
|
|
-- `#[macro_export] impl_p2p_message!` (`message.rs`) — define new wire
|
|
|
- messages with metering; already used by `event_graph` and fud.
|
|
|
-- `GetAddrsMessage`/`AddrsMessage` gossip via `ProtocolAddress` establishes
|
|
|
- the pattern ads should follow: relayed, unsigned, refinement-filtered.
|
|
|
-- `src/dht` exists for content-keyed lookup (Kademlia) and is used by fud;
|
|
|
- it is the wrong tool for membership (structured lookup paths are linkable).
|
|
|
-
|
|
|
-See proposal.md for motivation. Constraints that shape this design: no
|
|
|
-changes to channel framing, magic-byte gating, or handshake semantics; the
|
|
|
-overlay must not become a cross-subnet correlation point; no stable node
|
|
|
-identity may cross subnets.
|
|
|
+See `proposal.md` for motivation and delta specs for normative behavior. Current
|
|
|
+implementation constraints are:
|
|
|
+
|
|
|
+- One DarkFi network is one `P2p`, isolated by magic bytes and an app-name plus
|
|
|
+ major/minor handshake. Host state, sessions, registry, and persistence belong
|
|
|
+ to that instance.
|
|
|
+- `Settings.seeds` uses short-lived `SESSION_SEED`; it cannot carry swarm
|
|
|
+ queries. `Settings.peers` creates ordinary manual channels.
|
|
|
+- Current manual peers carry only `Url` and transports resolve internally.
|
|
|
+ Exact cached sockets therefore require an explicit pre-start manual-target
|
|
|
+ API and connector path; they cannot be represented by `Settings.peers`.
|
|
|
+- `ManualSession::reload()`, `SeedSyncSession::reload()`, and
|
|
|
+ `InboundSession::reload()` currently do not reconcile changed addresses.
|
|
|
+ Bootstrap fallback, source fallback, and serving promotion cannot depend on
|
|
|
+ reload.
|
|
|
+- `Hosts::subscribe_channel()` publishes completed seed/refinement channels as
|
|
|
+ well as ordinary channels. Join completion must filter session type.
|
|
|
+- `VersionMessage.features` is retained remotely but sent locally as empty.
|
|
|
+ Existing variable version fields can combine past `VERSION_MAX_BYTES`, so
|
|
|
+ feature validation alone is insufficient.
|
|
|
+- An ad store knows only one-way `SubnetId` and addresses. It cannot perform a
|
|
|
+ subnet handshake and must not become an attacker-controlled dialer.
|
|
|
+- Current Tor state is process-global and does not guarantee independent onion
|
|
|
+ identities per subnet; I2P does not provide a general inbound listener.
|
|
|
+- Apps such as darkirc/fud construct substantial state from `P2pPtr` before
|
|
|
+ protocol registration. A registration closure alone is insufficient.
|
|
|
|
|
|
## Goals / Non-Goals
|
|
|
|
|
|
**Goals:**
|
|
|
|
|
|
-- One overlay seed list bootstraps discovery for all subnets, forever.
|
|
|
-- Subnets remain ordinary `P2p` networks: their own magic bytes, hostlist,
|
|
|
- refinement, datastore — joined by direct dial after discovery.
|
|
|
-- Subnet spawn/stop at runtime, driven by `SubnetId`.
|
|
|
-- Anti-linkability as a designed property, not a config flag.
|
|
|
-- Lilith collapses to one listener + persistent ad store.
|
|
|
-- First-class transient (mobile) participants: cheap lookups, no relay
|
|
|
- obligations, no on-disk state beyond an optional overlay hostlist cache,
|
|
|
- invisible to third parties.
|
|
|
-
|
|
|
-**Non-Goals (design-level):**
|
|
|
-
|
|
|
-- No connection multiplexing (one connection carrying multiple subnets).
|
|
|
-- No DHT lookup for membership; gossip only.
|
|
|
-- No signatures on ads; authenticity is refinement's job.
|
|
|
-- No changes to `src/net` semantics — additive exports only.
|
|
|
-- No automatic serving of every joined subnet; serving is explicit per
|
|
|
- subnet.
|
|
|
+- Reuse ordinary `P2p` instances without changing framing, compatibility, or
|
|
|
+ seed-session semantics.
|
|
|
+- Keep all untrusted wire, persistence, queue, request, and work state bounded.
|
|
|
+- Make bootstrap/source attempts, join completion, serving creation, rollback,
|
|
|
+ recreation, and teardown explicit and testable.
|
|
|
+- Store/relay hints passively and validate only inside a joining subnet.
|
|
|
+- Add no third-party dependency.
|
|
|
+- State realistic protocol disclosure and persistence boundaries.
|
|
|
|
|
|
-## Decisions
|
|
|
-
|
|
|
-### D1. Thin overlay, not multiplexing or subnet-tagged address protocol
|
|
|
-
|
|
|
-Three alternatives were considered:
|
|
|
+**Non-Goals:**
|
|
|
|
|
|
-- **Multiplexed overlay** (one connection, virtual streams per subnet):
|
|
|
- breaks the 1:1 channel↔network invariant across `session/`+`channel.rs`,
|
|
|
- and mixes subnet traffic on one wire — a traffic-analysis surface that
|
|
|
- violates the anonymity constraints.
|
|
|
-- **Subnet-tagged `GetAddrs`/`Addrs`** (one global network carrying all
|
|
|
- subnets): smallest diff, but merges all hostlists into one refinement
|
|
|
- state, exposing cross-subnet membership in a node's address book and
|
|
|
- connection churn.
|
|
|
-- **Thin overlay (chosen)**: `Swarm` owns one overlay `P2p` plus a
|
|
|
- `HashMap<SubnetId, SubnetEntry>`; each `SubnetEntry` owns an ordinary
|
|
|
- spawned `P2p`. Overlay only bootstraps; subnet health remains
|
|
|
- self-maintaining via existing refinery.
|
|
|
+- Multiplexing subnet traffic over overlay channels.
|
|
|
+- Authenticating ad authors or proving address ownership.
|
|
|
+- PIR, cover traffic, Sybil resistance, or global-observer resistance.
|
|
|
+- Automatic independent Tor/I2P provisioning.
|
|
|
+- Runtime reconciliation of manual, seed, or inbound session settings.
|
|
|
|
|
|
-### D2. `SubnetId` = `blake3(canonical descriptor)`
|
|
|
+## Decisions
|
|
|
|
|
|
-```
|
|
|
-descriptor := app_name || magic_bytes || version_constraint || secret?
|
|
|
-SubnetId := blake3(descriptor)
|
|
|
+### D1. Isolated `net::swarm` module and feature
|
|
|
+
|
|
|
+```text
|
|
|
+src/net/swarm/
|
|
|
+├── mod.rs
|
|
|
+├── settings.rs
|
|
|
+├── descriptor.rs
|
|
|
+├── message.rs
|
|
|
+├── protocol.rs
|
|
|
+├── store.rs
|
|
|
+├── bootstrap.rs
|
|
|
+└── lifecycle.rs
|
|
|
```
|
|
|
|
|
|
-- Apps pin known IDs (e.g. darkirc mainnet) — a pin is a spec of the
|
|
|
- descriptor fields.
|
|
|
-- `secret` present → unguessable ID: a non-member cannot even name the
|
|
|
- subnet, giving obscurity-based access control (rendezvous-string style).
|
|
|
-- `version_constraint` is part of the descriptor so a subnet's version
|
|
|
- policy is fixed at creation; exact pin initially, ranges deferred.
|
|
|
-
|
|
|
-Alternative: human-readable subnet names — rejected: global names leak the
|
|
|
-set of private subnets into gossip and invite squatting.
|
|
|
-
|
|
|
-### D3. Ad format and propagation: unsigned gossip with TTL
|
|
|
-
|
|
|
-```
|
|
|
-SubnetAd { subnet_id, addrs: Vec<(Url, u64)>, ttl_secs }
|
|
|
-```
|
|
|
+`src/net/mod.rs` exposes the module only with `feature = "swarm"`. The feature
|
|
|
+enables existing `net`, `blake3`, `kvdb-overlay`, and serialization facilities.
|
|
|
+Lilith and the pilot opt in explicitly. Any newly required dependency,
|
|
|
+`build.rs`, or proc-macro stops implementation for human supply-chain review.
|
|
|
|
|
|
-- Propagated by flood/gossip identical in spirit to `AddrsMessage`; the peer
|
|
|
- an ad is received from is not its author → origin ambiguity.
|
|
|
-- Unsigned, no node identity. Poisoning is bounded by (a) refinement — ads
|
|
|
- land in the target subnet's greylist and dead addrs are dropped by
|
|
|
- handshake checks, and (b) per-message metering + ban policy for floods.
|
|
|
-- Overlay ad stores keep entries until `ttl` expiry + refinery liveness
|
|
|
- checks (lilith's existing whitelist-refinery pattern, retargeted at ads).
|
|
|
-- Ads are re-gossiped on a slow, jittered cadence (like refinery intervals),
|
|
|
- never event-triggered on subnet start — see R3.
|
|
|
+Nested placement gives `darkfi::net::swarm` and narrow crate-private host access
|
|
|
+without making orchestration core `P2p` behavior. A top-level module would need
|
|
|
+more public helper surface and a separate network-dependent root.
|
|
|
|
|
|
-Alternative: per-subnet signing keys. Gives poisoning resistance but tempts
|
|
|
-key reuse across subnets (linkability) and adds key management; deferred
|
|
|
-until refinement proves insufficient.
|
|
|
+### D2. Fixed overlay identity
|
|
|
|
|
|
-### D4. New messages and `ProtocolSwarm`, all outside `src/net` core
|
|
|
+All overlay instances use:
|
|
|
|
|
|
-```
|
|
|
-SubnetAd (gossip, unsolicited)
|
|
|
-GetSubnets → Subnets (list known subnet_ids)
|
|
|
-GetSubnetAddrs{subnet_id} → SubnetAddrs{subnet_id, addrs}
|
|
|
+```text
|
|
|
+app_name: "darkfi-swarm"
|
|
|
+app_version: 1.0.0
|
|
|
+magic_bytes: [0x78, 0x85, 0xa4, 0x2a]
|
|
|
```
|
|
|
|
|
|
-Defined via `impl_p2p_message!` with metering configurations and
|
|
|
-`MAX_BYTES` estimates in the existing style. `ProtocolSwarm` is registered
|
|
|
-via `ProtocolRegistry` on outbound+inbound sessions of the overlay `P2p`
|
|
|
-only. `GetSubnets` responses are built from local ad-store state; queries
|
|
|
-reveal participation in the overlay but not in any particular subnet.
|
|
|
-
|
|
|
-### D5. Serving vs joining
|
|
|
-
|
|
|
-- **Joining** (default): pull `SubnetAddrs`, seed the subnet `P2p`'s greylist
|
|
|
- (via `Hosts::insert`, grey), dial. No ad is emitted.
|
|
|
-- **Serving** (opt-in per subnet): requires inbound addrs for that subnet —
|
|
|
- one tor/i2p onion per subnet is the recommended deployment so overlay
|
|
|
- observers cannot correlate a shared endpoint across subnets. Emits ads on
|
|
|
- the D3 cadence.
|
|
|
-
|
|
|
-### D6. Subnet lifecycle under `Swarm`
|
|
|
-
|
|
|
-- `Swarm::join(subnet_descriptor)` → resolve via overlay → spawn subnet
|
|
|
- `P2p` with per-subnet `p2p_datastore`/`hostlist` paths derived from
|
|
|
- `SubnetId` under a swarm-managed directory; register app protocols onto
|
|
|
- that `P2p`'s registry before `start()`.
|
|
|
-- `Swarm::serve(subnet_descriptor, inbound_cfg)` → join + advertise.
|
|
|
-- `Swarm::leave(subnet_id)` → `P2p::stop()` + deregister; ads simply expire
|
|
|
- via TTL (no "leave" message — a departure broadcast would create a
|
|
|
- timing-correlation surface).
|
|
|
-- Dynamic spawn after startup is the main new runtime pattern; apps
|
|
|
- currently build all networks before `start()`. Watch item: executor
|
|
|
- shutdown ordering when many subnet `P2p`s stop concurrently.
|
|
|
-
|
|
|
-### D7. Lilith becomes an overlay seed
|
|
|
-
|
|
|
-One config section (`[overlay]`: accept addrs, datastore), no per-network
|
|
|
-sections. Runs the overlay `P2p` with `inbound_connections` high,
|
|
|
-`outbound_connections` 0 (unchanged posture: no outbound dialing), plus:
|
|
|
-persistent ad store (ads survive restarts until TTL), refinery-based ad
|
|
|
-expiry, and the `spawns` RPC retargeted at overlay stats (known subnets,
|
|
|
-ad counts). Existing per-network sections keep working during migration
|
|
|
-(lilith simply spawns those nets as before, alongside the overlay).
|
|
|
-
|
|
|
-### D8. Overlay node roles: persistent vs transient, declared not negotiated
|
|
|
-
|
|
|
-Desktop daemons and lilith run on always-on machines and carry the overlay;
|
|
|
-mobile apps (and any short-lived client) join the overlay only to look
|
|
|
-subnets up and leave. The distinction is declared through the existing
|
|
|
-`VersionMessage.features` vector (`src/net/message.rs`), which is on the
|
|
|
-wire today but sent empty: a persistent node advertises
|
|
|
-`("swarm-store", 1)`; a transient node sends no swarm feature. Role is
|
|
|
-self-declared and unauthenticated — it is a hint for policy and load, never
|
|
|
-a privilege.
|
|
|
-
|
|
|
-| | persistent | transient |
|
|
|
-|----------------------|--------------------------------|----------------------------------|
|
|
|
-| typical host | desktop daemon, lilith | mobile app doing a lookup |
|
|
|
-| inbound addrs | typical (often onion) | none (`inbound_connections: 0`) |
|
|
|
-| ad store | disk-backed, TTL + refinery | none (optional in-memory cache) |
|
|
|
-| gossip relay | yes | only while connected (brief) |
|
|
|
-| subnet serving | per-subnet opt-in (D5) | never |
|
|
|
-| overlay outbound | default slots | minimal (1–2), query then leave |
|
|
|
-| datastore/hostlist | persisted | overlay hostlist cache (TSV); no ad store |
|
|
|
-| heartbeat tuning | default | longer intervals (battery, NAT) |
|
|
|
-
|
|
|
-- **Uniform protocol behavior**: both roles answer `GetSubnets`/
|
|
|
- `GetSubnetAddrs` from whatever local state exists while connected. Role
|
|
|
- changes *what state exists*, never message handling — role-specific wire
|
|
|
- behavior would fingerprint peers and split the anonymity set.
|
|
|
-- **Transients are invisible to third parties**: no inbound addrs and no ads
|
|
|
- means a transient node never appears in any hostlist or ad store. Its
|
|
|
- overlay peers see only a short-lived connection — the same exposure a
|
|
|
- client of today's per-network seeds has.
|
|
|
-- **Transient hostlist cache**: a transient node persists its overlay
|
|
|
- hostlist (peer addresses only, via the existing `net::Settings.hostlist`
|
|
|
- TSV — zero new machinery) so later sessions dial cached overlay peers
|
|
|
- first and fall back to configured seeds only on miss/failure. The cache
|
|
|
- MUST NOT record queried or joined subnets — it contains overlay peer
|
|
|
- addresses and nothing else. Local-device forensics trade-off: the cache
|
|
|
- proves overlay participation but not subnet membership; privacy-maximal
|
|
|
- deployments disable it (also weaker against stale-entry churn, handled by
|
|
|
- normal greylist refinement).
|
|
|
-- **Lilith is just the canonical persistent node**; any persistent daemon
|
|
|
- relays ads and can cold-start others, which strengthens the R4 mitigation.
|
|
|
-- **Load spreading without new messages**: persistent nodes are reachable
|
|
|
- addrs in the overlay's own hostlist (they advertise inbound via the normal
|
|
|
- address protocol), so a transient node that wants to avoid hammering seeds
|
|
|
- dials overlay peers from `GetAddrs` and simply keeps the ones whose
|
|
|
- handshake carries the `swarm-store` feature. A dedicated
|
|
|
- feature-filtered query can be added later inside swarm's message set if
|
|
|
- wasted dials prove costly; not needed initially.
|
|
|
-- Mobile constraints shape defaults, not the protocol: battery (longer
|
|
|
- heartbeat via `NetworkProfile`, disconnect after lookup), NAT (no inbound,
|
|
|
- no hole punching required), metered data (small `GetSubnetAddrs` replies
|
|
|
- bounded by metering).
|
|
|
-
|
|
|
-Alternative considered: no declared role at all (purely emergent — transients
|
|
|
-are just nodes that leave quickly). Rejected: without the feature bit,
|
|
|
-persistent nodes cannot preferentially keep slots for ad-carrying peers, and
|
|
|
-transients cannot find store-keeping peers without trial dialing everyone.
|
|
|
-
|
|
|
-## API Sketch
|
|
|
-
|
|
|
-Illustrative signatures — names may shift during implementation; the shape
|
|
|
-is what apps program against. Everything mirrors the existing `P2p` idiom:
|
|
|
-async constructors returning `Result`, `Arc` pointers, `StoppableTask`
|
|
|
-lifecycle, `net::Settings` for transport-level config.
|
|
|
-
|
|
|
-### Core types
|
|
|
-
|
|
|
-```rust
|
|
|
-/// Declared overlay role (D8)
|
|
|
-pub enum SwarmRole {
|
|
|
- /// Disk-backed ad store, gossip relay, may serve subnets
|
|
|
- Persistent { datastore: PathBuf },
|
|
|
- /// Lookup client; no ads, no inbound, optional overlay hostlist cache
|
|
|
- /// (`None` leaves no on-device overlay trace)
|
|
|
- Transient { hostlist: Option<PathBuf> },
|
|
|
-}
|
|
|
+The magic is the first four bytes of
|
|
|
+`BLAKE3("darkfi-swarm-overlay-v1")`. Callers cannot override these fields.
|
|
|
+Future incompatible overlay changes follow existing major/minor rules.
|
|
|
|
|
|
-/// Canonical subnet descriptor (D2)
|
|
|
-pub struct SubnetDescriptor { /* app_name, magic_bytes, version, secret? */ }
|
|
|
+### D3. Manual canonical descriptor encoding
|
|
|
|
|
|
-impl SubnetDescriptor {
|
|
|
- /// Pin a released network; shipped as constants in app code
|
|
|
- pub const fn pinned(app_name: &str, magic_bytes: [u8; 4], version: &'static str) -> Self;
|
|
|
+`descriptor.rs` writes the exact spec bytes with checked lengths and explicit
|
|
|
+big-endian integers; it does not depend on general serializer stability.
|
|
|
+Application names are restricted to 32 UTF-8 bytes to remain valid in both
|
|
|
+version and verack bounds. Private construction accepts `[u8; 32]`, while
|
|
|
+generation fills it from `OsRng`. The golden vector is tested before any app pin
|
|
|
+is accepted.
|
|
|
|
|
|
- /// Secret-bearing descriptor for private subnets
|
|
|
- pub fn private(app_name: &str, magic_bytes: [u8; 4], version: &str, secret: &[u8]) -> Self;
|
|
|
+String concatenation and generic struct serialization are rejected because
|
|
|
+field/format ambiguity would split deployed IDs.
|
|
|
|
|
|
- /// BLAKE3 of the canonical serialization
|
|
|
- pub fn id(&self) -> SubnetId;
|
|
|
-}
|
|
|
+### D4. Correlated bounded wire protocol
|
|
|
|
|
|
-/// Handle to a joined or served subnet
|
|
|
-pub struct SubnetHandle { /* ... */ }
|
|
|
+Initial messages are:
|
|
|
|
|
|
-impl SubnetHandle {
|
|
|
- pub fn id(&self) -> SubnetId;
|
|
|
- /// The subnet's own P2p instance, for app-level messaging
|
|
|
- pub fn p2p(&self) -> P2pPtr;
|
|
|
+```text
|
|
|
+SubnetAd {
|
|
|
+ subnet_id, visibility, ad_id: [u8; 32],
|
|
|
+ lifetime_secs, addrs: Vec<Url> // 1..=32
|
|
|
}
|
|
|
-
|
|
|
-pub struct Swarm { /* overlay P2p + subnet registry + ad store */ }
|
|
|
-pub type SwarmPtr = Arc<Swarm>;
|
|
|
+GetSubnetAddrs { request_id: [u8; 16], subnet_id, cursor? }
|
|
|
+SubnetAddrs { request_id: [u8; 16], subnet_id, addrs, next? }
|
|
|
+GetPublicSubnets { request_id: [u8; 16], cursor? }
|
|
|
+PublicSubnets { request_id: [u8; 16], subnet_ids, next? }
|
|
|
+SwarmError { request_id: [u8; 16], bounded_code }
|
|
|
```
|
|
|
|
|
|
-### Usage: persistent daemon (desktop, e.g. darkirc)
|
|
|
+Commands are fixed to `swarm.ad`, `swarm.geta`, `swarm.addrs`, `swarm.gets`,
|
|
|
+`swarm.subs`, and `swarm.err` respectively. Struct field order is exactly the
|
|
|
+order shown in `swarm-overlay`; existing DarkFi encoding is used. Visibility is
|
|
|
+`u8` (`0` public, `1` non-public), lifetime is `u32`, error codes are fixed
|
|
|
+`u8` values 0 through 3, and cursor version is one. No new serializer is added.
|
|
|
|
|
|
-```rust
|
|
|
-// Overlay settings: one seed list, forever
|
|
|
-let overlay = net::Settings {
|
|
|
- app_name: "swarm".into(),
|
|
|
- magic_bytes: OVERLAY_MAGIC,
|
|
|
- seeds: OVERLAY_SEEDS.into(),
|
|
|
- inbound_connections: 64,
|
|
|
- ..Default::default()
|
|
|
-};
|
|
|
+Request IDs come from `OsRng`; a per-channel map permits at most 32 pending
|
|
|
+requests and removes entries on response, disconnect, or the default 10-second
|
|
|
+timeout (configurable to at most 60 seconds). Timeout is local; late, unknown,
|
|
|
+duplicate, or wrong-type responses are unsolicited and penalized.
|
|
|
|
|
|
-let role = SwarmRole::Persistent {
|
|
|
- datastore: "~/.local/share/darkirc/swarm/ads".into(),
|
|
|
-};
|
|
|
-let swarm = Swarm::new(role, overlay, ex.clone()).await?;
|
|
|
-swarm.clone().start().await?;
|
|
|
+Every URL is at most 1,024 encoded bytes. Message maxima are fixed as in the
|
|
|
+spec: ad and address response 65,536; address/public requests 128; public
|
|
|
+response 16,384; error 128. Count and byte validation precede store/work.
|
|
|
|
|
|
-// Pinned descriptor shipped with the app (D2)
|
|
|
-const DARKIRC: SubnetDescriptor =
|
|
|
- SubnetDescriptor::pinned("darkirc", [251, 229, 199, 181], "0.5.1");
|
|
|
+All swarm, version, and verack decoders are audited as attacker-input paths.
|
|
|
+They use checked reads and return errors for truncation/invalid structure; no
|
|
|
+`unwrap`, `expect`, explicit panic, unchecked slice/index, or allocation from an
|
|
|
+unvalidated declared size is permitted. Tests truncate valid payloads at every
|
|
|
+byte and feed hostile-length/arbitrary payloads under unwind and allocation
|
|
|
+instrumentation.
|
|
|
|
|
|
-// Client-only participation (default)
|
|
|
-let subnet = swarm.join(&DARKIRC, darkirc_protocols).await?;
|
|
|
+`PageCursor` is fixed 65 bytes:
|
|
|
|
|
|
-// Or serve it, with this subnet's own onion (D5)
|
|
|
-let inbound = vec![Url::parse("tor://darkirc-7.onion:9440")?];
|
|
|
-let subnet = swarm.serve(&DARKIRC, inbound, darkirc_protocols).await?;
|
|
|
-
|
|
|
-// App messaging rides the subnet's ordinary P2p — unchanged app code
|
|
|
-let _ = subnet.p2p();
|
|
|
+```text
|
|
|
+version: u8 | last_key: [u8; 32] | terminal_key: [u8; 32]
|
|
|
+```
|
|
|
|
|
|
-// Later: silent leave (D6) — no departure message, ads expire by TTL
|
|
|
-swarm.leave(DARKIRC.id()).await?;
|
|
|
+Address pages use BLAKE3 of canonical URL bytes as ordered key; public pages use
|
|
|
+`SubnetId`. The first page captures the greatest current live key as a terminal.
|
|
|
+Later pages return live keys strictly after `last_key` and no greater than that
|
|
|
+terminal, then advance `last_key`. Mutation may make a traversal include or omit
|
|
|
+records, but never invalidates a well-formed cursor, allocates a server snapshot,
|
|
|
+or extends traversal beyond its initial terminal. This trades snapshot
|
|
|
+consistency for bounded progress under adversarial mutation.
|
|
|
+
|
|
|
+Responses derive canonical item keys and require strict ascending uniqueness in
|
|
|
+the cursor window. `next.last_key` equals the greatest returned key; empty pages
|
|
|
+have no next cursor. Requesters independently derive keys, retain a bounded seen
|
|
|
+set, and reject within/across-page duplicates, unordered/out-of-window items,
|
|
|
+changed terminals, non-advancing cursors, or cursor/item disagreement.
|
|
|
+
|
|
|
+Public enumeration is disabled by default. It indexes IDs with at least one live
|
|
|
+normalized address record marked public and is available to all connected
|
|
|
+protocol-correct peers when enabled. Direct lookup ignores visibility. Because
|
|
|
+visibility is unsigned, an attacker can re-advertise an observed ID as public.
|
|
|
+
|
|
|
+### D5. Passive store with protected replay admission
|
|
|
+
|
|
|
+Persistent nodes use existing `kvdb-overlay` trees for address records, public
|
|
|
+index, seen IDs, and monotonic-epoch metadata. Address keys are
|
|
|
+`(SubnetId, hash(canonical_url))`. Atomic batches update records and indexes.
|
|
|
+Seen keys remain global by `ad_id`; their values bind the advertised `SubnetId`
|
|
|
+and general/local-reserve class only for quota/replay accounting. Reusing one ad
|
|
|
+ID under another subnet is therefore still a duplicate. No persistence API
|
|
|
+receives a source channel/address.
|
|
|
+
|
|
|
+Each key has one normalized record containing URL, current visibility, expiry,
|
|
|
+and accepting ad ID. A fresh ad atomically updates records for its included
|
|
|
+addresses; absent addresses remain until separately updated/expired/evicted.
|
|
|
+The public index contains an ID iff at least one live record is public, so a
|
|
|
+same-address visibility update can add/remove catalog membership while mixed
|
|
|
+records keep it public. Direct lookup reads all live records. Restart rebuilds
|
|
|
+or verifies the public index from normalized records.
|
|
|
+
|
|
|
+Defaults and hard maxima are:
|
|
|
+
|
|
|
+| Local limit | Default | Maximum |
|
|
|
+|---|---:|---:|
|
|
|
+| addresses per subnet | 256 | 1,024 |
|
|
|
+| total addresses | 16,384 | 65,536 |
|
|
|
+| general protected IDs per subnet | 256 | 1,024 |
|
|
|
+| protected ad IDs | 65,536 | 262,144 |
|
|
|
+| local-author reserve subnet partitions | 32 | 256 |
|
|
|
+| accepted/authored address lifetime | 7,200 s | 86,400 s |
|
|
|
+| replay checkpoint interval | 300 s | 600 s |
|
|
|
+| relay fanout | 16 | 64 |
|
|
|
+| bootstrap-stage timeout | 30 s | 300 s |
|
|
|
+| complete join timeout | 120 s | 900 s |
|
|
|
+
|
|
|
+The local expiry for each accepted address is the lesser of wire lifetime and
|
|
|
+the configured receive cap. Local author lifetime uses the same default and hard
|
|
|
+maximum. Relay preserves the original validated wire lifetime; each receiver
|
|
|
+applies its own cap.
|
|
|
+
|
|
|
+Address capacity evicts expired first, then earliest expiry, then lexical key.
|
|
|
+Seen IDs remain protected exactly through local address expiry plus 86,400
|
|
|
+seconds, for at most 172,800 seconds from acceptance. Expired IDs
|
|
|
+are removed first. Remote IDs occupy a general pool with a per-subnet quota; if
|
|
|
+that quota or the global general pool contains only protected IDs, the fresh ad
|
|
|
+is rejected before address mutation or relay. This prevents one claimed subnet
|
|
|
+from consuming the whole general pool, but generated subnet IDs can still cause
|
|
|
+distributed saturation.
|
|
|
+
|
|
|
+Authoring configuration reserves a default 32, at most 256, subnet partitions of
|
|
|
+256 slots each inside the global cap. Checked multiplication/subtraction derives
|
|
|
+reserve and nonzero general capacities. Remote ads cannot consume a partition;
|
|
|
+locally authored IDs use their subnet's partition until expiry. A serving
|
|
|
+transition atomically allocates/reuses a partition before listener/author start;
|
|
|
+stopping retains it until every protected local ID expires. Sequential churn may
|
|
|
+therefore return a typed capacity failure rather than overwrite protection. The
|
|
|
+last-ID expiry releases a stopped subnet's partition atomically; resumed serving
|
|
|
+retains it.
|
|
|
+
|
|
|
+Startup assigns persisted local IDs to partitions by distinct subnet and checks
|
|
|
+each partition's 256 slots separately. Persisted general IDs are checked only
|
|
|
+against the remaining general capacity and per-subnet quota; local IDs already
|
|
|
+inside reserve are not double-counted. The 256-slot partition exceeds the
|
|
|
+maximum IDs produced by the fixed 20-minute minimum cadence during the
|
|
|
+172,800-second maximum protection window. Protected IDs are never evicted early;
|
|
|
+replay semantics still take priority over general remote-ad availability.
|
|
|
+
|
|
|
+Reserve records necessarily identify to the local store which ephemeral ad IDs
|
|
|
+this process generated. They contain no peer/source address or stable author
|
|
|
+identity; reserve occupancy/use/failure/timing and local-origin classification
|
|
|
+are excluded from wire, RPC, status, metrics, and telemetry, including
|
|
|
+aggregate counters. This local-only authorship fact is an explicit cost of
|
|
|
+preventing remote saturation from blocking the process's own cadence.
|
|
|
+
|
|
|
+Ad acceptance atomically commits the global seen-ID record, pool accounting,
|
|
|
+address records, and public index before relay enqueue. A failed commit causes no
|
|
|
+mutation or relay. Restoring a database snapshot from before this commit removes
|
|
|
+the seen ID and can permit replay; rollback-resistant replay suppression would
|
|
|
+require external non-rollbackable state and is not claimed.
|
|
|
+
|
|
|
+Runtime expiry uses `Instant`. Persistence records accepted wall time, absolute
|
|
|
+expiry, original lifetime, and last-observed store wall time. Address records
|
|
|
+restore remaining time, clamped by local/original/protocol lifetime; rollback
|
|
|
+may expire addresses conservatively.
|
|
|
+
|
|
|
+Seen IDs instead use unsigned 64-bit seconds on a durable monotonic epoch. One
|
|
|
+metadata value atomically checkpoints elapsed ticks every 300 seconds by default,
|
|
|
+at most every 600 seconds, and on clean shutdown. Restart compares before
|
|
|
+subtracting: `deadline <= checkpoint` expires; otherwise checked subtraction must
|
|
|
+produce at most 173,400 seconds, valid remainder is clamped to 172,800, and
|
|
|
+checked duration conversion/`Instant::checked_add` builds the new deadline.
|
|
|
+Underflow, overflow, larger delta, or missing/incoherent metadata is a typed
|
|
|
+startup error.
|
|
|
+
|
|
|
+A checkpoint write that cannot complete before 600 seconds places persistent
|
|
|
+admission/authoring in fail-closed mode until checkpoint recovery or controlled
|
|
|
+shutdown; bounded reads may continue. This prevents new deadlines from exceeding
|
|
|
+the maximum validated delta.
|
|
|
+
|
|
|
+All surviving records and the new epoch replace the prior epoch atomically;
|
|
|
+interruption leaves the old epoch loadable. Uncheckpointed run time and downtime
|
|
|
+are not subtracted, so they can extend a record present in the loaded database.
|
|
|
+Repeated restart does not reset it to a fresh full horizon. Rollback before ID
|
|
|
+commit can remove the record entirely and is outside this guarantee. General and
|
|
|
+reserve partitions are validated independently before conversion.
|
|
|
+
|
|
|
+Transient stores use the same validation in bounded memory only. No store
|
|
|
+contains an active dialer. Active refinement is rejected because it cannot
|
|
|
+verify subnet attribution and creates scanning amplification.
|
|
|
+
|
|
|
+### D6. Validate features and full version size
|
|
|
+
|
|
|
+`net::Settings` gains a bounded local feature vector, empty by default.
|
|
|
+
|
|
|
+Feature validation rejects duplicate/overlong names, excess count, and invalid
|
|
|
+versions. Version permits at most 10 external addresses and 10 features;
|
|
|
+node ID is capped at 64 bytes, app name at 32, URL at 1,024, feature name at 32,
|
|
|
+and semver prerelease/build at 32 each. Before sending, protocol encodes and
|
|
|
+checks complete `VersionMessage` and `VerackMessage` against their maxima.
|
|
|
+Each overlay/subnet `P2p` receives a fresh CSPRNG node ID scoped to that instance;
|
|
|
+it is not persisted or reused across networks/restarts.
|
|
|
+
|
|
|
+Inbound `VersionMessage` and `VerackMessage` use manual bounded decoders that
|
|
|
+preserve existing field order and bytes while reading every declared
|
|
|
+string/vector length before reservation. They reject over-limit node/app and
|
|
|
+semver prerelease/build strings, external-address count/URL lengths, feature
|
|
|
+count, and feature-name length before `try_reserve` or allocation. Golden tests
|
|
|
+compare custom encoding/decoding with existing valid wire vectors.
|
|
|
+Compatibility rules remain unchanged.
|
|
|
+
|
|
|
+### D7. Protocol registration and independent work accounting
|
|
|
+
|
|
|
+`ProtocolSwarm` registers on `SESSION_DEFAULT` only: ordinary inbound,
|
|
|
+outbound, manual, and direct channels, excluding seed/refinement. Per-channel
|
|
|
+instances share store, work limiter, and bounded relay queue.
|
|
|
+
|
|
|
+Generic message metering is supplemented by token buckets keyed by ephemeral
|
|
|
+channel ID for validation/write, query/response bytes, pending/cursor work, and
|
|
|
+relay enqueue. Global semaphores bound durable writes, reads, and relay jobs.
|
|
|
+Channel accounting is removed on disconnect and never keyed by peer address.
|
|
|
+
|
|
|
+Protocol rates per channel are 32 ads, 16 direct requests, 16 direct responses,
|
|
|
+4 public-list requests, 4 public-list responses, and 16 errors per 10 seconds;
|
|
|
+work rates are 32 store writes and 32 relay enqueues per 10 seconds plus
|
|
|
+1,048,576 response bytes per 60 seconds. Initial local defaults/maxima are:
|
|
|
+
|
|
|
+| Resource | Default | Maximum |
|
|
|
+|---|---:|---:|
|
|
|
+| relay queue | 1,024 | 4,096 |
|
|
|
+| concurrent durable writes | 8 | 32 |
|
|
|
+| concurrent reads | 16 | 64 |
|
|
|
+| relay workers | 8 | 32 |
|
|
|
+| pages per direct join lookup | 16 | 16 |
|
|
|
+| pages per public enumeration | 4 | 16 |
|
|
|
+| candidate addresses per attempt | 64 | 256 |
|
|
|
+| previously compatible retries | 16 | 64 |
|
|
|
+| persisted compatible retry URLs/subnet | 64 | 256 |
|
|
|
+| local-author reserve subnet partitions | 32 | 256 |
|
|
|
+| active subnets | 32 | 256 |
|
|
|
+| concurrent lifecycle attempts | 8 | 32 |
|
|
|
+| shutdown deadline | 120 s | 600 s |
|
|
|
+| pending request timeout | 10 s | 60 s |
|
|
|
+| configured ordinary peers | 8 | 256 |
|
|
|
+| overlay bind addresses | 1 | 16 |
|
|
|
+| serving bind addresses/subnet | 1 | 16 |
|
|
|
+| serving external addresses/subnet | 1 | 32 |
|
|
|
+| overlay inbound channels | 64 | 256 |
|
|
|
+| overlay outbound channels | 8 | 64 |
|
|
|
+| overlay manual channels | 8 | 256 |
|
|
|
+| total overlay channels | 80 | 512 |
|
|
|
+| untrusted dial concurrency | 4 | 16 |
|
|
|
+| untrusted dial starts/minute | 32 | 128 |
|
|
|
+| DNS resolutions/join attempt | 64 | 256 |
|
|
|
+| dials/resolved destination/attempt | 1 | 1 |
|
|
|
+
|
|
|
+Each accepted ad queues at most one relay job and sends to no more than fanout
|
|
|
+eligible ordinary channels, excluding source by channel ID. Queries answer only
|
|
|
+their requesting channel. Overlay uses strict ban policy. This bounds work but
|
|
|
+does not claim Sybil resistance.
|
|
|
+
|
|
|
+### D8. Bootstrap by constructing one stage at a time
|
|
|
+
|
|
|
+Swarm owns at most one running overlay candidate. A cache record is a bounded
|
|
|
+pair of the original connect URL and exact resolved endpoint used by a
|
|
|
+successfully completed persistent-feature channel. It never comes from
|
|
|
+`VersionMessage.ext_send_addr`. For a transient:
|
|
|
+
|
|
|
+1. Parse the endpoint-only cache under file/count/URL/shareability bounds and
|
|
|
+ revalidate each stored socket without DNS.
|
|
|
+2. Construct overlay settings with `Settings.peers = []` and
|
|
|
+ `Settings.seeds = []`, then install cached targets through the explicit
|
|
|
+ pre-start manual-target API.
|
|
|
+3. Register protocol, subscribe to channels, start, and wait for a compatible
|
|
|
+ ordinary channel.
|
|
|
+4. On timeout/failure, fully stop and discard the candidate.
|
|
|
+5. Resolve/validate configured ordinary peers once, construct a fresh overlay
|
|
|
+ candidate, install those exact targets through the same pre-start API, and
|
|
|
+ repeat one bounded stage.
|
|
|
+6. Publish the successful `P2pPtr` as Swarm's active overlay only after success.
|
|
|
+
|
|
|
+No settings reload is used. Persistent nodes construct directly from configured
|
|
|
+ordinary topology. After an ordinary channel exposes `swarm-ad-store`, only its
|
|
|
+actual connect/resolved pair may enter the cache via atomic replacement. The
|
|
|
+peer's advertised external addresses are ignored for caching.
|
|
|
+
|
|
|
+Swarm does not configure a standard overlay hostlist/datastore for a
|
|
|
+privacy-maximal transient. Separately configured subnet and transport state is
|
|
|
+outside that overlay-cache guarantee and documented.
|
|
|
+
|
|
|
+Untrusted dial paths use a new narrow target model:
|
|
|
+
|
|
|
+```text
|
|
|
+ValidatedDialTarget { original_url, exact_socket, route }
|
|
|
+route = Direct | TrustedProxy { destination_kind }
|
|
|
+ManualSession::add_targets_before_start(Vec<ValidatedDialTarget>)
|
|
|
+ManualSession::add_target_plan_before_start({ first, second, switch_at })
|
|
|
+Connector::connect_validated(ValidatedDialTarget)
|
|
|
```
|
|
|
|
|
|
-`darkirc_protocols` is the registration closure the swarm runs against the
|
|
|
-subnet `P2p`'s protocol registry *before* `start()` — the same
|
|
|
-`registry.register(session_flags, init)` hook `register_default_protocols`
|
|
|
-uses internally. Apps keep registering their protocols exactly as today;
|
|
|
-they just do it through the closure.
|
|
|
-
|
|
|
-### Usage: transient lookup (mobile)
|
|
|
-
|
|
|
-```rust
|
|
|
-let overlay = net::Settings {
|
|
|
- app_name: "swarm".into(),
|
|
|
- magic_bytes: OVERLAY_MAGIC,
|
|
|
- seeds: OVERLAY_SEEDS.into(),
|
|
|
- outbound_connections: 2,
|
|
|
- inbound_connections: 0,
|
|
|
- ..Default::default()
|
|
|
-};
|
|
|
-
|
|
|
-let role = SwarmRole::Transient {
|
|
|
- // Cache overlay peers between sessions so later sessions dial cached
|
|
|
- // peers first and only fall back to seeds. `None` for a device free
|
|
|
- // of overlay traces.
|
|
|
- hostlist: Some(cache_dir.join("overlay_hostlist.tsv")),
|
|
|
-};
|
|
|
-let swarm = Swarm::new(role, overlay, ex.clone()).await?;
|
|
|
-
|
|
|
-// Lookup only: resolve addresses, no subnet participation, then disconnect
|
|
|
-let addrs: Vec<Url> = swarm.lookup(&FUD_CHANNEL).await?;
|
|
|
-
|
|
|
-// Or join for the duration of the app session (recommended over
|
|
|
-// per-lookup connections — R7 battery-vs-mixing guidance)
|
|
|
-let subnet = swarm.join(&FUD_CHANNEL, fud_protocols).await?;
|
|
|
+The pre-start method creates ordinary manual slots before `P2p::start()` and is
|
|
|
+not reload/reconciliation. The two-phase plan preinstalls already validated
|
|
|
+targets, activates only `first` at start, cancels it at a monotonic switch time,
|
|
|
+then activates `second`; a missing first phase activates second immediately.
|
|
|
+Overlay bootstrap uses the one-phase method. Fresh clearnet targets resolve once
|
|
|
+and cache their socket; cached targets skip DNS and revalidate their stored
|
|
|
+socket. In a direct route, `exact_socket` is the destination socket and the
|
|
|
+connector opens that exact socket without re-resolving; `original_url` supplies
|
|
|
+only TLS server-name identity.
|
|
|
+
|
|
|
+In a trusted-proxy route, `exact_socket` is the exact locally configured proxy
|
|
|
+socket, not an advertised destination. The advertised URL cannot choose or
|
|
|
+override it. The destination is restricted to a globally routable IP literal or
|
|
|
+a canonical Tor/I2P hidden-service name matching the transport. A hidden name is
|
|
|
+never locally DNS-resolved and is passed only inside proxy negotiation and, when
|
|
|
+applicable, TLS identity. Arbitrary clearnet hostnames are rejected rather than
|
|
|
+remotely resolved. The configured proxy socket may be loopback/private under
|
|
|
+local trust policy; that exception never applies to direct advertised targets.
|
|
|
+Missing/malformed proxy configuration is a candidate error. Production lilith
|
|
|
+forces direct-target local-test mode off.
|
|
|
+
|
|
|
+The full untrusted candidate pipeline—URL parse, allowlisted scheme, host/port,
|
|
|
+DNS result handling, address classification, target construction, proxy
|
|
|
+selection/negotiation, transport/TLS dial, and compatibility—is fallible and
|
|
|
+contains no panic, unchecked indexing, or unimplemented branch. Empty DNS sets,
|
|
|
+more than 16 results for one URL, malformed/missing proxy targets, unsupported
|
|
|
+or unaudited schemes, timeout, and cancellation return bounded errors. A
|
|
|
+bounded nonempty multi-address result is iterated safely, every address consumes
|
|
|
+the join resolution budget and is classified, and at most one allowed exact
|
|
|
+socket is selected for that URL. Only schemes whose adapters satisfy this rule
|
|
|
+are accepted from advertisements; enabled but unaudited transports are rejected
|
|
|
+before dialer construction.
|
|
|
+
|
|
|
+Resolution and dialing consume the D7 concurrency/rate/total budgets. A join
|
|
|
+attempt tries one resolved destination once. This does not prove endpoint
|
|
|
+ownership, but prevents local-network SSRF/DNS-rebinding and bounds public
|
|
|
+victim reflection.
|
|
|
+
|
|
|
+### D9. Keep overlay control channels and subnet data channels separate
|
|
|
+
|
|
|
+“Ordinary overlay session” means an inbound/outbound/manual/direct non-seed
|
|
|
+session on the overlay `P2p`; it does not mean every client keeps it for process
|
|
|
+lifetime. The channel remains bound to overlay magic/app identity, channel
|
|
|
+store, hosts, and `ProtocolSwarm`. Streams are never handed to another `P2p`,
|
|
|
+re-handshaken under subnet identity, or extended with subnet-tag multiplexing.
|
|
|
+
|
|
|
+Persistent nodes retain the overlay while storing/relaying. Serving nodes retain
|
|
|
+it while authoring ads. Transient settings expose two policies:
|
|
|
+
|
|
|
+```text
|
|
|
+SessionBound // default; retain overlay for application session
|
|
|
+ImmediateAfterOperation // explicit reduced-privacy mode
|
|
|
```
|
|
|
|
|
|
-### Usage: lilith (the canonical persistent node)
|
|
|
+The default never reacts to lookup/join completion by disconnecting; it retains
|
|
|
+the overlay until an explicit `stop_overlay()` or full Swarm shutdown ends the
|
|
|
+application session. Immediate mode deterministically stops after every
|
|
|
+caller-visible lookup or join reaches a terminal outcome—success, empty result,
|
|
|
+error, timeout, or cancellation—but not after an internal lookup phase within a
|
|
|
+join. Its configuration warning states that the responder and a same-operator
|
|
|
+subnet server may correlate query, subnet connection, and teardown timing. In
|
|
|
+either mode the subnet handle owns an independent `P2p` and outlives overlay
|
|
|
+stop. Later discovery runs staged bootstrap again only when no active overlay
|
|
|
+remains.
|
|
|
+
|
|
|
+Swarm tracks overlay lifetime separately from subnet registry lifetime.
|
|
|
+`stop_overlay()` rejects persistent/serving duties, but for an eligible
|
|
|
+transient it stops only overlay tasks/channels and leaves subnet entries
|
|
|
+untouched. Full Swarm shutdown still stops every subnet and any active overlay.
|
|
|
+Transport reuse was rejected because it requires multiplexing or handoff,
|
|
|
+correlates overlay queries with subnet membership, mixes host/protocol state,
|
|
|
+and works only when the overlay peer also serves the subnet.
|
|
|
+
|
|
|
+### D10. Registry-owned lifecycle and explicit source attempts
|
|
|
+
|
|
|
+Registry states are:
|
|
|
+
|
|
|
+```text
|
|
|
+Initializing -> Joining -> Joined
|
|
|
+Initializing -> Serving
|
|
|
+any active state -> Stopping -> Absent
|
|
|
+```
|
|
|
|
|
|
-```rust
|
|
|
-let role = SwarmRole::Persistent { datastore: cfg.adstore };
|
|
|
-let swarm = Swarm::new(role, overlay_settings, ex).await?;
|
|
|
-swarm.clone().start().await?;
|
|
|
-// Nothing else. No per-subnet config, no join calls: ads arrive by
|
|
|
-// gossip, and the durable store + refinery (D7) make lilith the
|
|
|
-// cold-start anchor. Legacy per-network sections still spawn ordinary
|
|
|
-// P2p instances alongside, during migration.
|
|
|
+One lock owns transitions per ID; different IDs proceed concurrently. Registry
|
|
|
+entries retain `P2pPtr`, type-erased `Arc<dyn Any + Send + Sync>` app state, a
|
|
|
+shutdown hook, mode, and persistence policy. Caller handles clone the typed
|
|
|
+`Arc`; dropping them cannot drop registry ownership.
|
|
|
+
|
|
|
+A join attempt:
|
|
|
+
|
|
|
+1. validates descriptor and reserves `Initializing`;
|
|
|
+2. builds namespaced settings and `P2p` for that attempt's sources;
|
|
|
+3. subscribes to completed channels before start;
|
|
|
+4. runs the fallible initializer, stores app ownership/shutdown, and registers
|
|
|
+ protocols before start;
|
|
|
+5. loads the complete at-most-256-entry compatible retry index and performs
|
|
|
+ bounded `OsRng` reservoir sampling over every valid URL returned by the fresh
|
|
|
+ lookup's fixed-terminal traversal through completion or its 16-page cap;
|
|
|
+6. independently shuffles both tiers and resolves/validates them under a
|
|
|
+ candidate-preparation subdeadline capped at half the then-remaining overall
|
|
|
+ time; verified and fresh resolution each receive half that subdeadline, so
|
|
|
+ verified DNS/transport preparation cannot consume fresh preparation time;
|
|
|
+7. retains selected candidates as ephemeral `ValidatedDialTarget` values and
|
|
|
+ installs a two-phase verified/fresh plan through the subnet's pre-start manual-
|
|
|
+ target API, not its URL-only hostlist/refinery;
|
|
|
+8. starts and waits for a channel whose session flag is inbound, outbound, or
|
|
|
+ manual; at start it snapshots remaining dial time, cancels verified targets at
|
|
|
+ its monotonic midpoint, and activates fresh targets for the second half,
|
|
|
+ explicitly rejecting temporary direct, seed, and refinement notifications;
|
|
|
+9. confirms the channel remains an ordinary registered peer, then atomically
|
|
|
+ transitions to `Joined`; or
|
|
|
+10. on error/timeout/cancel, stops all attempt state and removes ownership.
|
|
|
+
|
|
|
+Failed pre-compatibility targets are dropped and never persisted. A successful
|
|
|
+ordinary channel may register its canonical peer URL in normal host state.
|
|
|
+Every later outbound retry/refinement resolves and validates a fresh exact
|
|
|
+socket under the same egress/rate rules before connection; no path may connect
|
|
|
+by reusing a prior validation followed by a second resolver call. Tests count
|
|
|
+DNS queries and inspect exact sockets across manual, outbound, retry, refine,
|
|
|
+and persisted-host paths. URL, store, hash, and DNS-answer order never selects
|
|
|
+the resolution/dial prefix: full bounded persisted state is shuffled, fresh URLs
|
|
|
+use reservoir sampling across the terminal traversal through completion or its
|
|
|
+16-page cap, and each bounded allowed DNS answer set is CSPRNG-shuffled before
|
|
|
+selection. The dedicated retry
|
|
|
+index defaults to 64 and never exceeds 256; when full, a newly compatible peer
|
|
|
+is usable now but does not evict an existing retry entry merely for admission.
|
|
|
+This reduces ordering/grinding bias but cannot force a malicious responder to
|
|
|
+return an honest candidate.
|
|
|
+
|
|
|
+Source policies:
|
|
|
+
|
|
|
+- overlay-only: no static seeds;
|
|
|
+- static-only: configured seeds, explicitly activated for that attempt;
|
|
|
+- combined: both sources are intentionally configured in one attempt; and
|
|
|
+- overlay-then-static: complete one overlay-only attempt; on failure stop it,
|
|
|
+ then create a fresh static-only `P2p`, rerun initializer, and remain under one
|
|
|
+ overall deadline.
|
|
|
+
|
|
|
+This avoids nonfunctional manual/seed reload. Rerunning initialization is an
|
|
|
+explicit observable cost and both attempts have independent rollback.
|
|
|
+
|
|
|
+### D11. Serving is initial configuration or controlled recreation
|
|
|
+
|
|
|
+`ServeSettings` separates:
|
|
|
+
|
|
|
+```text
|
|
|
+bind_addrs: Vec<Url> // local listeners
|
|
|
+external_addrs: Vec<Url> // advertised endpoints
|
|
|
+source_policy: optional peer discovery after readiness
|
|
|
+visibility/lifetime
|
|
|
```
|
|
|
|
|
|
-### API-enforced invariants
|
|
|
-
|
|
|
-- `serve()` on a `Transient` swarm fails fast — the transient role has no
|
|
|
- serving path (spec: swarm-overlay, node roles).
|
|
|
-- `join`/`serve` take a descriptor, never a raw id: a caller cannot join a
|
|
|
- subnet it cannot describe, and the spawned `P2p` still enforces the
|
|
|
- subnet's own magic bytes and `app_name` handshake independently of the
|
|
|
- overlay.
|
|
|
-- Protocol registration happens only before subnet `start()` via the
|
|
|
- closure — there is no window where a subnet accepts connections without
|
|
|
- its app protocols attached.
|
|
|
-- `lookup()` answers from local state first (on a persistent node: the ad
|
|
|
- store) and queries the overlay only on miss; on a transient node it may
|
|
|
- reuse the session cache.
|
|
|
-- A transient swarm dials its cached overlay hostlist before configured
|
|
|
- seeds; seeds are only the first-ever-run and fallback path.
|
|
|
+Create-and-serve validates persistent role and all fields, builds `P2p` with
|
|
|
+listeners before start, atomically allocates/reuses the subnet's local-author
|
|
|
+reserve partition, runs the initializer, and calls `P2p::start()`. Reserve
|
|
|
+exhaustion fails before initializer/listener/author activity. Success requires
|
|
|
+listener readiness, not an existing peer, enabling a first server. Peer
|
|
|
+discovery may continue afterward. Authoring is registered only after readiness
|
|
|
+and still waits for cadence. A newly allocated empty partition is released on
|
|
|
+pre-author failure; stopping retains a nonempty partition until its IDs expire.
|
|
|
+
|
|
|
+Promoting joined to serving marks it stopping, fully stops P2p/app state, and
|
|
|
+constructs a fresh serving-configured instance with another initializer call.
|
|
|
+It never calls inbound reload. Failure leaves no partial server and returns a
|
|
|
+typed stopped/recreation error. Namespaced persisted state may be reused.
|
|
|
+
|
|
|
+An externally provisioned onion/I2P endpoint may forward to a distinct local
|
|
|
+bind; the API does not conflate them. Built-in transport identity provisioning
|
|
|
+is not promised. Locally reused external endpoints produce a linkability
|
|
|
+warning.
|
|
|
+
|
|
|
+### D12. Cadence is independent of lifecycle events
|
|
|
+
|
|
|
+One author task uses a fixed version-one 30-minute base interval with
|
|
|
+independent uniformly sampled ±10-minute `OsRng` jitter; it is not configurable.
|
|
|
+Each emission uses a fresh 32-byte `OsRng` ad ID, configured lifetime capped at
|
|
|
+24 hours and defaulting to two hours, and only that subnet's external addresses.
|
|
|
+Multi-subnet emission order is shuffled with independent jitter.
|
|
|
+
|
|
|
+Initialization, listener readiness, peer connection, recreation, new overlay
|
|
|
+channel, and stop only mutate local author state. They never invoke immediate
|
|
|
+send. Stop removes future snapshots; relayed ads expire locally.
|
|
|
+
|
|
|
+### D13. Lilith uses ordinary persistent behavior
|
|
|
+
|
|
|
+Lilith `[overlay]` maps to normal persistent Swarm settings and strict policy. It
|
|
|
+may be inbound-only or have ordinary outbound peers; it never places overlay
|
|
|
+bootstrap into `Settings.seeds`.
|
|
|
+
|
|
|
+Lilith loads the durable store under caps, starts one ordinary overlay, and has
|
|
|
+no ad refinery/dialer or local authoring, so its reserve-partition count is zero.
|
|
|
+Corrupt records decode fallibly. Malformed/unverifiable seen-ID, quota/reserve,
|
|
|
+or epoch state fails startup; address/index state may be quarantined/rebuilt only
|
|
|
+when authoritative replay/accounting remains intact. Status RPC reads only
|
|
|
+aggregate listener/connection/capacity/address/dedup/eviction/expiry/rejection
|
|
|
+counters, including aggregate per-subnet-quota and epoch-checkpoint failures. It
|
|
|
+never reports local-author reserve occupancy/use/transition timing and never
|
|
|
+walks full IDs/addresses or query mappings.
|
|
|
+
|
|
|
+Legacy instances retain separate settings, paths, registry, policy, and
|
|
|
+shutdown.
|
|
|
+
|
|
|
+### D14. Scoped metadata threat model
|
|
|
+
|
|
|
+Protected properties are no stable author identity, no overlay-source-peer to
|
|
|
+subnet/authorship persistence, and isolated subnet state.
|
|
|
+
|
|
|
+Disclosed properties are requested ID to responder, connection-level query
|
|
|
+linkage, IDs and advertised endpoints observed/mapped by gossip/store peers,
|
|
|
+timing/topology evidence, public catalog, endpoint reuse, local full-ID paths
|
|
|
+when subnet persistence is enabled, and remote peer retention. The
|
|
|
+ID-to-endpoint mapping is intentional rendezvous output. Separate anonymity
|
|
|
+circuits may reduce linkage but are not provisioned or guaranteed by Swarm.
|
|
|
+
|
|
|
+Absolute cross-subnet unlinkability is rejected because direct query and shared
|
|
|
+connections make it false.
|
|
|
|
|
|
## Risks / Trade-offs
|
|
|
|
|
|
-- **R1 Overlay as correlation point** (new metadata surface) → per-subnet
|
|
|
- addresses (D5), unsigned per-subnet ads with no node identity (D3),
|
|
|
- gossip origin ambiguity (D3/D4), query design that reveals only overlay
|
|
|
- participation (D4). Residual: a global adversary observing all overlay
|
|
|
- traffic plus all subnet on/off timings can still correlate — documented
|
|
|
- as a known limit; timing jitter is the only partial defense.
|
|
|
-- **R2 Ad poisoning / flood** → no signatures means ads are cheap to forge;
|
|
|
- bounded by refinement liveness checks, metering thresholds, and ban
|
|
|
- policy; per-subnet ad-store caps (like GREYLIST_MAX_LEN) bound memory.
|
|
|
-- **R3 Timing linkage of fresh serving nodes** → ads on jittered cadence
|
|
|
- only, never event-driven; deployment guidance recommends pre-registered
|
|
|
- onions.
|
|
|
-- **R4 Cold-start still depends on overlay seeds** → same trust profile as
|
|
|
- today's per-network seeds, but strictly reduced: the seed sees overlay
|
|
|
- participation only, never which subnets are joined (dials are direct and
|
|
|
- subnet-scoped). Multiple overlay seeds can be listed, any serving node's
|
|
|
- overlay connection also relays ads, and repeat sessions bootstrap from
|
|
|
- the transient hostlist cache rather than seeds.
|
|
|
-- **R5 Private-subnet obscurity is not access control** → the secret names
|
|
|
- the subnet; it does not encrypt subnet traffic. Documented; end-to-end
|
|
|
- protections remain the apps' job (e.g. darkirc saltbox, event-graph RLN).
|
|
|
-- **R6 Tor/onion-per-subnet operational cost** → serving on clearnet tcp is
|
|
|
- possible but exposes a shared endpoint; the design allows it, deployment
|
|
|
- guidance should not recommend it.
|
|
|
-- **R7 Transient nodes are timing-fingerprintable** (connect → query →
|
|
|
- leave) → while connected, transients send the same messages any node may
|
|
|
- send, and third parties never observe them at all (no hostlist presence).
|
|
|
- The connection-lifetime pattern itself is the residual fingerprint; cover
|
|
|
- traffic is out of scope for battery-constrained devices. Documented
|
|
|
- battery-vs-mixing tension: staying connected longer mixes better and costs
|
|
|
- more — deployment guidance may suggest holding the overlay connection for
|
|
|
- the app session rather than per lookup.
|
|
|
-- **R8 Transient load concentrates on overlay seeds** → transients spread
|
|
|
- across persistent nodes via the overlay's own address gossip plus the
|
|
|
- `swarm-store` handshake feature (D8, no new messages); after the first
|
|
|
- session, the transient hostlist cache (D8) means seeds are fallback-only.
|
|
|
- If concentration persists, add a feature-filtered query inside swarm's
|
|
|
- message set.
|
|
|
-
|
|
|
+- **[Global metadata]** Responders/gossip peers observe IDs → Minimize fields,
|
|
|
+ disable public enumeration by default, document disclosure.
|
|
|
+- **[Unsigned poisoning]** Fresh forged ads and malicious compatible peers →
|
|
|
+ Bound state/work, reservoir-sample every URL in the at-most-16-page terminal
|
|
|
+ traversal, CSPRNG-shuffle bounded tiers, partition verified/fresh time and
|
|
|
+ attempts, validate compatibility, retain app authorization/static fallback,
|
|
|
+ make no authenticity claim.
|
|
|
+- **[Dedup saturation]** Protected IDs can fill capacity → Per-subnet general
|
|
|
+ quotas prevent one-ID monopolization, local-author partitions preserve
|
|
|
+ allocated local cadence, and strict global bounds reject distributed-ID floods
|
|
|
+ without early eviction; general remote-ad availability and serving transitions
|
|
|
+ can still fail under saturation/churn.
|
|
|
+- **[Stale addresses]** No probing retains offline hints → Two-hour receiver and
|
|
|
+ author defaults, 24-hour hard maximum, shuffled bounded candidates, deadlines,
|
|
|
+ pilot metrics, static fallback.
|
|
|
+- **[Client dialing/reflection]** Joiners still try attacker-selected public
|
|
|
+ addresses → Resolve once, reject local/reserved ranges, connect the exact
|
|
|
+ validated direct socket or configured trusted proxy socket, never locally
|
|
|
+ resolve hidden names, reject proxy DNS bypass, enforce per-destination/rate/
|
|
|
+ concurrency/total budgets, and retain subnet handshake checks.
|
|
|
+- **[Transport abort]** Existing transport constructors/dialers may assume
|
|
|
+ validated configuration → Reject unaudited schemes before construction and
|
|
|
+ require fallible no-unwind handling across every attacker-selected candidate
|
|
|
+ stage and accepted transport adapter.
|
|
|
+- **[Clock uncertainty]** Monotonic time does not survive reboot → Restore ad
|
|
|
+ address expiry from wall time and replay protection from a periodically saved
|
|
|
+ checked monotonic-epoch remainder; crash/downtime may extend records present in
|
|
|
+ loaded state, but restart does not reset each to the full horizon.
|
|
|
+- **[Storage rollback]** Same-database checkpoints cannot detect rollback before
|
|
|
+ an ID commit → Commit seen/address/index state before relay, test/document that
|
|
|
+ restoring an older snapshot can permit replay, and make no rollback-resistant
|
|
|
+ guarantee.
|
|
|
+- **[Cursor churn]** An attacker can mutate indexes between pages → Stateless
|
|
|
+ last/terminal-key traversal plus requester key/order/dedup validation guarantees
|
|
|
+ bounded forward progress while accepting non-snapshot omissions/additions.
|
|
|
+- **[Local reserve metadata]** Reserve records reveal local ephemeral authorship
|
|
|
+ to the local database → Store no peer/stable identity and exclude reserve use,
|
|
|
+ occupancy, subnet labels, and timing from RPC/status/telemetry.
|
|
|
+- **[Role Sybil]** Attackers claim persistent feature → Treat only as hint,
|
|
|
+ cache multiple peers, grant no privilege.
|
|
|
+- **[Bootstrap concentration]** Configured peers can observe/censor → Multiple
|
|
|
+ peers/cache, staged deadlines, static subnet fallback.
|
|
|
+- **[Reconstruction cost]** Failed stages rerun P2p/app initialization → Explicit
|
|
|
+ bounded attempts and complete cleanup; no unsupported reload semantics.
|
|
|
+- **[Serving downtime]** Promotion requires stop/recreate → Require serving mode
|
|
|
+ at initial creation where possible and return typed recreation failures.
|
|
|
+- **[Transport traces/endpoints]** Transport state or reused endpoints link →
|
|
|
+ Separate scope/config, document state, warn reuse, no provisioning claim.
|
|
|
+
|
|
|
+## Deferred Follow-up Changes
|
|
|
+
|
|
|
+These are deliberately not `swarm` completion criteria:
|
|
|
+
|
|
|
+- **Endpoint-reuse enforcement:** A later transport-identity change may reject
|
|
|
+ cross-subnet external-endpoint reuse by default and require an explicit
|
|
|
+ reduced-privacy override. This change only detects, warns, and documents reuse
|
|
|
+ because independent Tor/I2P identity provisioning is unresolved.
|
|
|
+- **Query-peer privacy budget:** A later discovery-policy change may specify
|
|
|
+ random single-responder lookup and bounded sequential fallback. This change
|
|
|
+ retains the current bounded query mechanism and explicitly discloses responder
|
|
|
+ and connection-level linkage; it does not promise a selection/fanout policy.
|
|
|
## Migration Plan
|
|
|
|
|
|
-1. Land `src/swarm` with the overlay protocol; lilith gains the overlay
|
|
|
- section alongside legacy per-network sections. No app changes.
|
|
|
-2. Apps adopt `Swarm` optionally, keeping static seed lists as fallback;
|
|
|
- pinned `SubnetId` constants added per app (values equal to existing
|
|
|
- `{app_name, magic_bytes, version}` triples so current networks are
|
|
|
- discoverable).
|
|
|
-3. Once overlay coverage is healthy, deprecate lilith's per-network
|
|
|
- sections (warn, then refuse across a release boundary).
|
|
|
-4. Rollback: overlay is additive; apps revert to static seeds, lilith drops
|
|
|
- the overlay section. Ads and `SubnetId` paths are all namespaced and
|
|
|
- removable.
|
|
|
-
|
|
|
-## Open Questions
|
|
|
-
|
|
|
-- Exact `ttl_secs` default and per-subnet ad-store cap values (tune during
|
|
|
- implementation against refinery intervals).
|
|
|
-- Whether `GetSubnets` responses should be rate-limited per peer beyond
|
|
|
- generic metering (decide when metering thresholds are set).
|
|
|
-- Whether darkfid's consensus networks adopt `Swarm` at all, or only
|
|
|
- latency-tolerant apps do (darkirc, taud, fud) — an adoption-policy
|
|
|
- question, not a protocol one.
|
|
|
-- Whether persistent nodes should cap the share of inbound slots given to
|
|
|
- transient (feature-less) overlay peers, and at what ratio — deployment
|
|
|
- tuning once real transient traffic exists.
|
|
|
+1. Land the gated module, descriptor vectors, feature settings, full version size
|
|
|
+ validation, bounded messages, and in-memory tests with no app default.
|
|
|
+2. Add passive durable storage, protocol work limits, staged fresh-instance
|
|
|
+ bootstrap, and lifecycle attempts/recreation using local transports.
|
|
|
+3. Add lilith's optional overlay section alongside unchanged legacy sections.
|
|
|
+4. Run local multi-node tests for replay saturation, poisoning, no probing,
|
|
|
+ per-subnet quota/local reserve, monotonic-epoch restart, two-hour TTL clamp,
|
|
|
+ mutation-tolerant terminal cursors, CSPRNG candidate ordering, DNS rebinding/
|
|
|
+ local-range/reflection rejection, decoder truncation/hostile lengths, direct/
|
|
|
+ proxy exact routing, hidden-service no-local-DNS, full candidate-pipeline no-
|
|
|
+ unwind behavior, channel filtering, rollback, first-server creation,
|
|
|
+ recreation, and source/query-free stores.
|
|
|
+5. Add a default-off application pilot with overlay-then-static policy and
|
|
|
+ aggregate privacy-safe metrics.
|
|
|
+6. Run required Makefile gates, `@anon-security-review`, and human `src/net`
|
|
|
+ review. Broader adoption or legacy deprecation is a later change.
|
|
|
+
|
|
|
+Rollback disables pilot/overlay config and returns to static seeds and legacy
|
|
|
+lilith. State is namespaced and removable after shutdown; existing wire and
|
|
|
+subnet persistence formats are unchanged.
|