Identity
Node Identity & Cryptographic Keys
The DID method follows the did:nostr specification.
Implementations MUST parse the identifier, verify that the 32-byte hex string represents a valid on-curve secp256k1 x-only point, and construct Multikey verification methods accordingly.
Two alternatives were considered and rejected. did:web needs DNS and a
certificate, which excludes every edge and leaf and reintroduces the CA system as a
dependency of federation. did:key carries no service endpoints and cannot rotate, so it can name a node but never route to one. It remains appropriate for the ephemeral session keys in § Control plane.
Three keys, one identity
The nostr key is a secp256k1 Schnorr key. DIDComm v2 requires X25519 for its ECDH-ES and ECDH-1PU envelopes, and both iroh and libp2p identify hosts by Ed25519. There is no honest way to make one key do all three, so a node holds three and binds the other two by listing them in a descriptor signed by the first.
| Key | Curve / codec | Purpose | Rotation |
|---|---|---|---|
| node | secp256k1 x-only · 0xe7 |
The DID subject. Signs the descriptor, grants, reports, receipts, and every nostr event the node authors. | Never. Rotating it creates a new node. |
| agreement | X25519 · 0xec |
keyAgreement. DIDComm v2 envelope encryption, both anonymous (ECDH-ES) and authenticated
(ECDH-1PU). |
SHOULD rotate every 90 days; overlap for one window. |
| transport | Ed25519 · 0xed |
iroh NodeId and libp2p PeerId. Signs gossipsub messages under StrictSign.
|
MAY rotate freely; a new descriptor is the only requirement. |
Multikey values use the multibase f (base16-lower) prefix throughout, matching
what the did:nostr draft specifies for secp256k1, so one document never mixes bases.
A verifier MUST treat transport keys as bound to a DID only through currently valid descriptors.
An inbound connection from an Ed25519 key not named in an active descriptor originates from an unknown node.
Three keys, or one key and a KDF
Managing three keys introduces operational backup complexity. Key loss is a primary operational failure mode on standalone hardware.
What derivation does not buy
It does not remove the binding problem. A KDF runs on the private key, so no verifier can recompute the derived public keys from the node's public key. Every peer still learns the agreement and transport keys from the signed descriptor, exactly as it does now. The descriptor, its signature and its expiry are required either way, so the "fewer things to bind" argument is illusory — there is exactly one binding artefact in both designs.
Deriving rotatable keys from a root secret requires stateful counters in KDF info strings. Losing the counter state destroys key recovery.
What it costs
The resolution
Three independent keys are normative.
Derivation is permitted strictly as a provisioning-time convenience.
Implementations MAY derive agreement and transport keys from the node key at initialization via HKDF-SHA256(ikm = node_sk, info = "fleetmesh/node/<role>/<counter>").
Implementations MUST persist derived keys to independent storage and MUST NOT retain the root secret in active network processes.
This provides single-secret backup while maintaining cryptographic key separation at runtime.
Stated as a rule: derive at provisioning, never at runtime. An implementation that keeps the root online to re-derive on demand has taken the cost and thrown away the mitigation.
The node descriptor — kind 11801
Replaceable, one per node, signed by the node key. It is the sole input, beyond the kind 0
and 10002 events did:nostr already consumes, that turns a bare public key into
a resolvable, routable node. Resolvers MUST reject a descriptor whose pubkey
is not the DID subject.
kind 11801 — node descriptor
{
"kind": 11801,
"pubkey": "<node secp256k1 pubkey, hex>",
"created_at": 1787000000,
"content": "Community relay for the Bristol nostr meetup.",
"tags": [
["class", "edge"],
["protocol", "fleetmesh/0.1-draft", "6d8c8126a619e25c…"], // the live digest
["software", "https://fleetmesh.org", "0.2.0"],
["key", "agreement", "fec01<x25519 pubkey hex>", "1795000000"],
["key", "transport", "fed01<ed25519 pubkey hex>"],
["endpoint", "iroh", "<iroh NodeAddr ticket>", "10"],
["endpoint", "libp2p", "/dnsaddr/relay.example.org/p2p/12D3Koo…", "20"],
["endpoint", "wss", "wss://relay.example.org", "40"],
["mediator", "did:nostr:<anchor pubkey>"],
["c", "https://fleetmesh.org/peering/1.0"], // the protocols it implements; these two are required
["c", "https://fleetmesh.org/budget/1.0"],
["c", "https://fleetmesh.org/sync/1.0"],
["c", "relay"], ["c", "blossom"], // plain capabilities are hints
["c", "mediator"], ["c", "negentropy"],
["nips", "1", "2", "9", "11", "17", "40", "42", "44", "45", "50", "59", "70", "77"],
["coverage", "<sha256 of the coverage claim set>"],
["policy", "<sha256 of the operator policy document>", "https://example.org/policy.txt"],
["operator", "<operator nostr pubkey>"],
["expiration", "1787604800"],
["nonce", "<NIP-13 proof-of-work nonce>", "20"]
]
}
protocol is mandatory and is the first thing a peer reads. Its second element
is the version digest defined in § Governance, and it is what the handshake
actually compares — the name beside it is for humans. A descriptor without a
protocol tag is not a fleetmesh node descriptor, and a node whose behaviour
contradicts the version it declares has misreported itself: S2.
endpoint's fourth element is a preference value, low first; it is the input to
transport selection in § Transports. expiration is NIP-40 and is
mandatory here — a descriptor that outlives its operator's interest is how stale
federations accumulate dead peers. Maximum lifetime is 30 days for anchors and edges, 24
hours for leaves. policy pins the human-readable rules the operator claims to
follow; changing the document without republishing the digest is itself an S2 misreport.
operator binds the node to its declared human or organizational Nostr pubkey.
Nodes operated by the same entity MUST declare the same operator pubkey;
declaring false independence (e.g. running shadow nodes under fake keys to subvert leaf dual-mediation
or capture bootstrap slots) is a structural breach priced directly as an
S2 misreport.
The resolved DID document
A did:nostr resolver gains a node profile: given a DID whose subject has a live kind 11801, it emits the enhanced document below instead of the minimal one. Resolution of ordinary user identities is unaffected, so a resolver can add this without changing any answer it already gives.
GET /resolve/did:nostr:<hex> — node profile
{
"@context": ["https://www.w3.org/ns/cid/v1", "https://w3id.org/nostr/context"],
"id": "did:nostr:<hex>",
"verificationMethod": [
{ "id": "#nostr", "type": "Multikey", "controller": "did:nostr:<hex>",
"publicKeyMultibase": "fe70102<x>" },
{ "id": "#agree", "type": "Multikey", "controller": "did:nostr:<hex>",
"publicKeyMultibase": "fec01<x25519>" },
{ "id": "#wire", "type": "Multikey", "controller": "did:nostr:<hex>",
"publicKeyMultibase": "fed01<ed25519>" }
],
"authentication": ["#nostr"],
"assertionMethod": ["#nostr"],
"keyAgreement": ["#agree"],
"service": [
{ "id": "#didcomm", "type": "DIDCommMessaging",
"serviceEndpoint": {
"uri": "wss://relay.example.org",
"accept": ["didcomm/v2"],
"routingKeys": ["did:nostr:<anchor>#agree"]
} },
{ "id": "#relay", "type": "NostrRelay", "serviceEndpoint": "wss://relay.example.org" },
{ "id": "#blossom", "type": "BlossomServer","serviceEndpoint": "https://blobs.example.org" },
{ "id": "#iroh", "type": "IrohNode", "serviceEndpoint": "<ticket>" }
]
}
Revocation — kind 11802
A compromised or retired node publishes a replaceable Kind 11802 event declaring revocation and an optional successor DID. Revocation events prove only that the key holder requested retirement. Legitimate operators and compromise adversaries can both publish revocations. Peers MUST zero active grants upon receiving a valid revocation and MUST NOT automatically transfer accumulated standing to replacement keys. A successor node starts on probation and earns standing through clean windows.
kind 11802 — node revocation
{
"kind": 11802,
"pubkey": "<the node key being retired>",
"tags": [
["reason", "key-compromise"], // free text; retired | key-compromise | migrated
["successor", "<pubkey of the replacement node>"]
],
"content": "Host decommissioned; storage destroyed."
}
successor is advisory and carries no standing whatsoever. A peer that follows
it grants the successor probation like any stranger, because a revocation
proves only that whoever holds the key asked to retire it — which is exactly what an
attacker who has stolen it would also do. Treating a successor as inheriting the
predecessor's tier would make key theft an upgrade path.
Discovery
Peer Discovery Without Central Registries
A new node needs three things: somewhere to publish its descriptor, a way to be told about other descriptors, and a first peer willing to grant it anything at all. None of them may require any centralized service, or the mesh is a hub with extra steps.
Bootstrap
- Publish. The node writes its kind 11801 descriptor to every relay in its own kind 10002 relay list. Any nostr relay will do; the descriptor is a normal replaceable event and needs no special support.
-
Announce. The node joins the libp2p gossipsub topic
/fleetmesh/announce/1and republishes the descriptor there, signed under StrictSign by the transport key. Gossipsub carries the descriptor to nodes that have never shared a relay with it, which no relay-published descriptor can do. It does not remove the need for an introduction: reaching the topic at all takes one peer already known, from the bootstrap list or from an operator. Every path in this list begins somewhere else. -
Bootstrap is editable.
Operators can edit or empty
bootstrap.tomlfreely. Builds shipping an empty bootstrap list MUST operate given a single--bootstrapCLI flag. Shipped list composition rules are defined in § Governance. -
Ask. Pick a peer whose descriptor declares the protocols you need, then run
the peering handshake in the next section.
https://didcomm.org/discover-features/2.0confirms the declaration once a channel exists; it does not replace it. A peer SHOULD answer apeers-querywith descriptors it holds, capped and rate-limited under the asker's grant like any other request.
Both events are published to standard Nostr relays to maintain auditable reputation. Nodes without a libp2p stack participate fully over standard WebSocket connections.
Note that steps 1–4 are alternatives, not a pipeline. Relays alone are enough.
Gossipsub alone is enough. One --bootstrap address typed by hand is enough.
A node that can reach any nostr relay in the world can find the mesh, which is what makes
the unplug test in § Two networks passable rather than aspirational.
Enrolment, and why it can be open
A node may accept peering requests from strangers. Safety relies on initial grants carrying minimal capacity rather than identity being expensive. Probation grants restrict capacity so a thousand sybil nodes consume less bandwidth than one active peer. Reputational standing accumulates strictly through clean windows over physical time, which cannot be accelerated by generating keys.
One optional admission cost is defined, and it is a refinement rather than load-bearing. Proof of work: NIP-13 on the descriptor, with difficulty declared by the admitting node in its policy document. Default 20 bits: seconds on a laptop, meaningful at ten thousand identities.
An anchor MUST NOT require a Lightning deposit forfeit on an S4 finding, or any other bond. Slashing means one node keeping another's money on the strength of evidence it produced itself. That needs an arbiter, and an arbiter is the centralised authority this whole design is arranged to avoid. The deterrent it was reaching for is already present and does not need custody of anyone's funds: a probation grant is worth almost nothing, and standing is bought only with observed time. See § Grants & receipts for what replaces it.
Closed federations are the same mechanism with the door shut: an operator whose policy is invitation-only simply never issues a grant that was not preceded by an out-of-band invitation. The protocol does not distinguish the two cases, and no node can tell from the outside whether another is open — it can only observe whether it got a grant.
Invitation
Scanning an invitation QR code on a mobile device enrolls the leaf immediately. The invitation encodes the anchor's DID and mediator endpoint, eliminating accounts, registration forms, and passwords.
Coverage — kind 30803
Discovery of data is separate from discovery of nodes. A node publishes one
addressable kind 30803 per shard it claims to hold complete, where a shard is a filter plus a
time range, and the d tag is a stable shard identifier.
kind 30803 — coverage claim
{
"kind": 30803,
"tags": [
["d", "bristol-meetup-2026H2"],
["filter", "{\"kinds\":[0,1,3,7,10002],\"#t\":[\"bristol\"]}"],
["since", "1751328000"], ["until", "1767225600"],
["completeness", "asserted"], // asserted | best-effort
["fingerprint", "<negentropy root over the shard>", "1787000000"],
["count", "418902"]
],
"content": ""
}
asserted is a falsifiable claim, and that is the point. Any peer can sample the shard, find a hole, and file a report carrying the missing event's id as evidence. A node
unwilling to be held to it publishes best-effort, which no one may report
against and no one may rely on. Leaves MUST NOT publish coverage claims at all — a
device that is offline whenever its screen is has nothing to claim.
Gossip topic membership — kind 30804
A group conversation on the mesh is a gossip/1.0 topic: a set of nodes that flood one another's already-encrypted events without any relay in the path. Who is in the set is never inferred from traffic. The topic admin publishes one addressable kind 30804 naming every member, and a node grafts into (or accepts a graft for) a topic only while the current event at that coordinate names its own pubkey. Removing a member is publishing a new version that omits them; a republished roster supersedes the one before it exactly as a grant envelope or a coverage claim does.
Delivery has an eager and a lazy half, as in gossipsub. Eager: a publish is flooded to the
node's mesh peers for the topic, a bounded set (degree D) grafted from the roster.
Lazy: each tick a node offers the ids it has recently seen (ihave) to a few
roster members outside its mesh, and a member missing any of them asks by id
(iwant) and is sent the sealed envelope directly from a short message cache.
The lazy half is what carries a message across a partitioned mesh — grafts that failed,
a topic larger than D that split into cliques, a member that was down for the flood
— still with no relay in the path. Both halves carry only NIP-59 wraps; the
durable seen-id store is the sole loop-prevention authority for both.
kind 30804 — gossip topic membership
{
"kind": 30804,
"tags": [
["d", "<admin pubkey>:bristol-ops"],
["member", "<member pubkey>"], // one tag per member, repeatable
["member", "<member pubkey>"],
["member", "<member pubkey>"],
["expiration", "1790000000"]
],
"content": ""
}
The d coordinate is scoped to the admin's pubkey, so a different key cannot
shadow a roster it does not own. expiration is a NIP-40 timestamp: an expired
roster stops authorising new grafts and the topic's mesh peers are pruned on the next tick.
Who may become admin, and any delegation of that role, is the application's to decide.
Liveness — kind 21801
Ephemeral, unstored, and the only thing an anchor is obliged to publish on a schedule. A heartbeat says a node is up and what it currently looks like — and, deliberately, nothing about who it peers with. It is what replaces the positive attestation § Grants & receipts forbids.
kind 21801 — node heartbeat
{
"kind": 21801,
"tags": [
["class", "anchor"],
["endpoints", "<sha256 over the node's current endpoint set>"],
["load", "0.42"]
],
"content": ""
}
class is the only required tag, and it MUST match the node's live descriptor
— a heartbeat contradicting the descriptor is a misreport about itself, and an
S2. endpoints is a digest over the endpoint set
the node is actually serving — SHA-256 over the canonical JSON of the sorted
[scheme, address] pairs, preference excluded — so a peer holding a descriptor
can tell it has gone stale without dialling every address in it. load is an advisory scalar with no
normative meaning; nothing may be reported against it. The heartbeat carries nothing about
accounting, deliberately: a node holds one receipt chain per issuer, so a single "latest
head" has no referent, and any commitment a peer could actually open would name that
peer. The head an issuer holds travels privately in budget/1.0/receipt-ack.
An anchor MUST publish one at least once an hour, the default window length, and at least once per its shortest active grant window if that is shorter. Edges SHOULD; leaves MUST NOT — a device that is offline whenever its screen is has no liveness to assert, and § Privacy rules would rather it did not announce its waking hours.
Third-party liveness is separate and better. NIP-66 monitors publish kind 30166 status events about mesh nodes using ordinary relay monitoring flows, needing no permission from the node and reading only public data. A measurement somebody else took is worth more than a claim the subject published about itself, which is why the heartbeat carries as little as it does.
Control plane
Private Node Communication via DIDComm v2
Nostr events are broadcast and public, which is right for descriptors, grants and reports and wrong for the conversation that produces them. Negotiating a peering. Agreeing a sync range. Presenting evidence to a peer before publishing a complaint. Asking a mediator to hold messages. Each is an addressed, threaded, private exchange between two named parties. DIDComm v2 provides sender-authenticated encryption, message threading, transport independence, and established mediation protocols out of the box, avoiding bespoke reimplementations.
Standard protocols, used unmodified
| Protocol | Used for | Required of |
|---|---|---|
| out-of-band/2.0 | Invitation URIs and QR pairing. | anchor, edge |
| discover-features/2.0 | Capability query once a channel exists. Its answer MUST agree with the protocol identifiers
in the descriptor's c tags, which are binding; a disagreement is
BAD_DESCRIPTOR. The plain capability words beside them are hints. |
all |
| trust-ping/2.0 | Liveness and round-trip measurement on an established channel. | all |
| coordinate-mediation/2.0 | Requesting mediation and being granted it. The registered protocol for the handshake
that routing/2.0 then carries traffic over. |
anchor (as mediator), leaf |
| routing/2.0 | Mediation. The mechanism by which a leaf has an address at all. | anchor (as mediator), leaf (as recipient) |
| messagepickup/2.0 | A leaf asking its mediator what is held for it and taking delivery. The other half of
mediation: routing/2.0 brings a message to the mediator, this brings it the rest
of the way when the leaf is back. |
anchor (as mediator), leaf |
| report-problem/2.0 | Transport and protocol errors. Not the reputation channel — a malformed message is a bug, not an offence. | all |
| empty/1.0 | Headers with no body. It carries the ack a stored message asked for with
please_ack when no other message is due. A message that is never acknowledged by a peer
that has spoken since is how a node learns a relay dropped the stored path. |
all |
Six new protocols
Defined under https://fleetmesh.org/… because nothing registered covers them.
Each is a small state machine; each message is a DIDComm plaintext message encrypted
ECDH-1PU to the peer's #agree key, except first contact, which is ECDH-ES
anonymous.
A DIDComm PIURI is an identifier, never fetched, so this namespace creates no runtime dependency on a domain anyone controls — a lapsed registration breaks nothing. It does put a company's name on a protocol the mesh is meant to outlive. That is a naming problem rather than an engineering one, and § Open questions lists it as such. Implementations MUST treat the string as opaque and compare it byte-for-byte.
| PIURI | Messages | Purpose |
|---|---|---|
| peering/1.0 | propose, offer, accept, decline, amend, terminate | Establish and revise a peering. Terminates in a pair of grants, one per direction. |
| sync/1.0 | open, range, have, need, deliver, done | Set reconciliation over a shard, carrying NIP-77 negentropy payloads. |
| budget/1.0 | receipt, receipt-ack, close, dispute | The self-accounting channel. Carries receipts and window closes. |
| complaint/1.0 | notice, evidence, remedy, appeal, withdraw | Present a finding to its subject before publishing it, and let the subject answer. |
| standing/1.0 | ask, tell, decline | Ask a peer what it makes of a third node, now that nobody can read it from a relay. Answering is discretionary and refusing is normal. See § Trust weighting. |
| compute/1.0 | quote, accept, sealed, settled, refute | Negotiate and deliver one compute job. Quotes stay off the wire as events so a seller’s price to one buyer is not a public commitment to every buyer. See § Compute market. |
complaint/1.0 is mandatory-first for S1 and S2: an issuer MUST deliver a
notice and wait one window before publishing a kind 30802 report against a
reachable peer. Most conformance failures are clock skew, a bug, or a misread limit, and
a protocol that publishes before it asks produces a permanent public record of transient
faults. S3 and S4 may be published immediately.
peering/1.0/propose — plaintext, before encryption
{
"id": "6f2b…", "type": "https://fleetmesh.org/peering/1.0/propose",
"from": "did:nostr:<proposer>", "to": ["did:nostr:<target>"],
"created_time": 1787000123, "expires_time": 1787003723,
"body": {
"class": "edge",
"protocol": ["fleetmesh/0.1-draft", "<digest>"],
"protocols": ["https://fleetmesh.org/peering/1.0", // what this node implements, and
"https://fleetmesh.org/budget/1.0", // the same list its descriptor
"https://fleetmesh.org/sync/1.0"], // declares as c tags
"want": [ { "shard": "bristol-meetup-2026H2",
"filter": {"kinds":[0,1,3,7,10002],"#t":["bristol"]},
"mode": "pull", "since": 1751328000 } ],
"offer": [ { "shard": "bristol-meetup-2026H2", "completeness": "asserted",
"fingerprint": "<negentropy root>" } ],
"transports": ["iroh", "libp2p", "wss"],
"budget_self": { "events_in": 600, "bytes_in": 8388608, "window": 3600 },
"policy": "<sha256 of proposer policy doc>",
"pow": { "bits": 20, "event": "<descriptor event id>" }
}
}
Peering acceptance occurs when the recipient publishes a reciprocal grant. A peering consists of two independent bilateral grants negotiated concurrently. Either operator retains the sovereign right to reduce its outbound grant at any time.
Transport binding, including the one that always works
A DIDComm message is a self-contained encrypted envelope, so its transport is interchangeable. In preference order:
fleetmesh/didcomm/1. One bidirectional QUIC stream per thread. Default whenever both nodes
advertise iroh./fleetmesh/didcomm/1.0.0 over Noise + yamux. Used when iroh is unavailable or when
a circuit relay is already established.POST /didcomm, Content-Type: application/didcomm-encrypted+json, per the DIDComm
HTTPS binding. Anchors MUST implement it; it is the only transport a corporate network reliably permits.
The last row is what keeps this from being a second network beside nostr. If iroh, libp2p and HTTPS all fail, federation does not stop; it degrades to something every nostr relay in the world already forwards. The cost is latency and a public record that two node keys exchanged an envelope of a certain size at a certain time, which is why it is last.
Which gift wrap
NIP-59 defines two: kind 1059, stored by relays, and kind 21059, ephemeral and explicitly intended for real-time use. The choice is by message, not by node:
- 21059 for anything belonging to a live exchange — a sync session, a trust ping, receipts flowing during an open window, discovery queries. Both endpoints are online by construction, so persistence buys nothing, and using the ephemeral kind keeps the mesh's control traffic off every relay's disk. This is the default.
- 1059 only when the message must survive the recipient being offline: a grant amendment, a complaint notice, a window close the peer has not acknowledged. These are rare, small, and genuinely need store-and-forward.
Nodes MUST NOT use Kind 1059 as a bulk transport. It is an encrypted message class, not a high-throughput queue. A peer flooding relays with uncollected control envelopes consumes third-party storage without authorization, triggering an S1 overrun finding.
Neither kind is this protocol's. NIP-59 does not namespace what is inside a wrap, so a
subscription for {kinds:[21059], "#p":[self]} receives every NIP-59 wrap
addressed to that key, whatever protocol wrote it. One machine running fleetmesh and another
NIP-59 protocol under one key will see each other's envelopes; both directions fail closed,
because the inner payload is not a JWE this node can read, but the drop is real and an
operator should be told what it was rather than left reading it as a peer sending malformed
envelopes. Implementations SHOULD say which protocol a rejected payload appears to belong to.
A node that wants the ambiguity gone runs its control traffic under a key it uses for
nothing else.
NIP-59 notes that gift wraps are signed by random one-time keys, so a relay cannot apply pubkey-based rate limiting or reputation to them at all. Control traffic that a relay cannot attribute and cannot drop is exactly the thing this specification asks nodes not to send each other, and it would be incoherent to exempt ourselves. Ephemeral by default is the version of that rule we can actually keep.
Mediation is how a phone gets an address
A leaf establishes mediation over routing/2.0 with a peered anchor.
The anchor serves as mediator, and the leaf advertises the mediator's key in its descriptor routingKeys.
Inbound messages arrive at the anchor wrapped in DIDComm forward envelopes.
The anchor holds forwarded envelopes until the leaf reconnects.
Anchors cannot decrypt payload contents: the inner envelope is encrypted exclusively to the leaf's #agree key.
Because mediators observe message timing and metadata, leaves SHOULD maintain dual-mediation across independent anchors.
Cold-Start Mediator Discovery via NIP-66
A mobile leaf initializing for the first time without peers, without an initial QR invitation, and with no native P2P gossipsub stack discovers its initial mediators purely through standard Nostr queries:
-
Directory Discovery: The leaf connects over
wss://to public Nostr bootstrap relays (or relays from the user's NIP-65 relay list) and queries for active mediator anchors:{ "kinds": [11801], "#c": ["mediator"] } -
Independent Anchor Selection: The leaf inspects the returned descriptors, filtering
for anchors that support WebSocket endpoints (
wss://), and selects two anchors publishing distinctoperatorpubkeys (satisfying the 3-Layer Operator Independence Model). -
Admission Handshake: The leaf attaches a 20-bit Proof-of-Work to its ephemeral
descriptor and transmits a DIDComm
coordinate-mediation/2.0/mediate-request. The mediator admits the leaf attier: probation, establishing mediated message forwarding with zero proprietary coordination servers.
Transports
Modular Pluggable Transports
The core protocol is strictly independent of transport mechanisms.
DIDComm v2 enveloping, NIP-77 negentropy reconciliation, bilateral grant accounting, and the severity ladder operate across any byte-stream carrier.
All wire I/O is abstracted behind the MeshTransport adapter interface.
This enables fleetmesh to run seamlessly across server clusters, mobile leaves, field hardware, and privacy overlays without code changes.
The reference implementation combines both iroh and libp2p for complementary roles.
Pairwise synchronization requires high-throughput, content-verified streaming between known peers (iroh QUIC and BLAKE3 verification).
In contrast, network-wide discovery requires broadcast across unacquainted nodes (libp2p gossipsub v1.1).
The MeshTransport Trait Interface
crates/mesh — Modular Transport Trait
#[async_trait]
pub trait MeshTransport: Send + Sync + 'static {
/// Scheme identifier advertised in descriptors (e.g. "iroh", "libp2p", "wss", "unix", "ble")
fn scheme(&self) -> &'static str;
/// Listen on an advertised endpoint
async fn listen(&self, endpoint: &TransportEndpoint)
-> Result<Box<dyn TransportListener>, TransportError>;
/// Dial an outbound peer given their advertised descriptor endpoint
async fn dial(&self, target: &TransportEndpoint, peer_key: &PublicKey)
-> Result<Box<dyn TransportStream>, TransportError>;
}
Standard & Extensible Scheme Registry
| Scheme | Category | Carries | Target Environments & Notes |
|---|---|---|---|
iroh |
built-in | Pairwise: DIDComm streams, negentropy reconciliation, blob transfer. | QUIC with relay-assisted hole punching (Anchor ↔ Anchor, Edge ↔ Edge). NodeId is the descriptor's transport key (Ed25519), per § Identity. |
libp2p |
built-in | Broadcast: descriptors, grants, reports. Discovery via Kademlia. | gossipsub v1.1 with StrictSign; circuit-relay-v2 + DCUtR when iroh hole punching fails. |
wss / https |
built-in | Universal fallback and mediated leaf synchronization. | Standard Nostr WebSocket frames + gift-wrapped DIDComm. Zero native dependencies; ideal for mobile leaves. |
unix |
built-in | Zero-copy local inter-process communication. | Unix domain sockets (/var/run/fleetmesh/node.sock) for co-located multi-process relays on the same host. |
webrtc |
adapter | In-browser direct client-to-relay sync. | libp2p-webrtc / WebTransport for browser execution contexts without raw TCP/UDP socket access. |
tor / i2p / nym |
adapter | Anonymized and metadata-resistant traffic routing. | End-to-end hidden services (.onion) for high-adversary or censorship environments. |
ble / lora |
adapter | Off-grid and disaster-recovery field synchronization. | Bluetooth Low Energy / LoRa packet radio meshes (e.g. Reticulum integration) for local air-gapped sync. |
Negotiation & Fallback Ladder
Nodes select transports collaboratively during connection setup.
Both peers parse descriptor endpoint tags, compute the intersection of supported schemes, and rank candidates by the sum of advertised preference weights.
Neither peer unilaterally dictates the transport.
Equal sums rank by scheme name, ascending bytewise (transport.ladder_tie_break in constants.json), so two implementations pick the same rung for the same pair of descriptors.
Initiators attempt the highest-priority candidate with a 5-second timeout, falling through sequentially upon failure. A node MUST NOT report a peer for unreachability until the entire mutually supported transport ladder is exhausted. Nodes MUST cache negotiated transports for subsequent sessions.
Transport fallback is unidirectional within a single session.
Having fallen back (e.g. to wss), a node retries higher-priority transports on subsequent sessions rather than mid-stream.
Re-probing failed connections during active synchronization is prohibited; connection storms are debited directly against the initiator's token bucket budget.
Content Addressing & CIDs
Blossom blobs are SHA-256 addressed, mapping directly to IPFS Content Identifiers (CIDs): multibase
b, CIDv1, raw codec 0x55, multihash sha2-256. Nodes
MUST expose that mapping, providing seamless interoperability with IPFS pinning services without
mandating IPFS as an active runtime dependency.
Whatever a multi-process relay uses internally to move live events between its own instances — a message bus, a shared channel, a database notification — stays internal. That is an intra-deployment concern with intra-deployment trust. Mesh egress is a separate path with its own shaping and its own accounting, and a peer never joins anyone's internal bus.