design.md 36 KB

Design: swarm overlay for subnet rendezvous

Context

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:

  • 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.

Non-Goals:

  • 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.

Decisions

D1. Isolated net::swarm module and feature

src/net/swarm/
├── mod.rs
├── settings.rs
├── descriptor.rs
├── message.rs
├── protocol.rs
├── store.rs
├── bootstrap.rs
└── lifecycle.rs

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.

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.

D2. Fixed overlay identity

All overlay instances use:

app_name:     "darkfi-swarm"
app_version:  1.0.0
magic_bytes:  [0x78, 0x85, 0xa4, 0x2a]

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.

D3. Manual canonical descriptor encoding

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.

String concatenation and generic struct serialization are rejected because field/format ambiguity would split deployed IDs.

D4. Correlated bounded wire protocol

Initial messages are:

SubnetAd {
    subnet_id, visibility, ad_id: [u8; 32],
    lifetime_secs, addrs: Vec<Url> // 1..=32
}
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 }

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.

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.

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.

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.

PageCursor is fixed 65 bytes:

version: u8 | last_key: [u8; 32] | terminal_key: [u8; 32]

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:

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)

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:

SessionBound                 // default; retain overlay for application session
ImmediateAfterOperation      // explicit reduced-privacy mode

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:

Initializing -> Joining -> Joined
Initializing -> Serving
any active state -> Stopping -> Absent

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:

bind_addrs:       Vec<Url> // local listeners
external_addrs:   Vec<Url> // advertised endpoints
source_policy:    optional peer discovery after readiness
visibility/lifetime

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

  • [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 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.