Reminder: this is a hobby project. It has not had a security audit. The model below is what's intended; treat it as design notes, not a guarantee.
The mechanics of how a connection forms are in architecture.md; this doc is about the trust decisions at each step.
Identity is anchored to a Dilithium (ML-DSA-65, FIPS-204) keypair. The
address is Base58Check(ripemd160(sha256(dilithium_pub))). A Dilithium-bound
Ed25519 operational key does per-envelope signing and carries the libp2p
transport, authorised by a Dilithium-signed IdentityBinding.
The honest post-quantum boundary: the identity layer is PQ-safe, but the transport key exchange (libp2p Noise, Tor v3) is still Curve25519. The full accounting of what that does and doesn't buy you is ADR-0001.
Peer records carry an address hash, onion, timestamp, the announcer's Dilithium pubkey, and a signature. Recipients verify the pubkey hashes to the claimed address and the signature checks out — so records can't be spoofed or stale-replayed.
This trades the old "hash-only, least-information" posture for authenticity: a full DHT scrape now yields a global address↔Dilithium-pubkey table. Because the pubkey is post-quantum-safe, that's a correlation trade-off, not a confidentiality break. The DHT remains a routing layer, not an authorization layer — identity is re-verified on the wire at handshake time.
An unknown dialer's CONN_REQUEST carries its Dilithium pubkey and a signature
over the request body. The responder verifies the dialer owns the claimed
address before the accept prompt fires — so you never get prompted to accept a
peer who can't prove who they are. The Dilithium pubkey is PQ-safe, so revealing
it early to an unknown peer costs nothing; the Ed25519 operational key is
exchanged only after both sides accept.
- Dialer sends address + Dilithium proof. Responder verifies before prompting.
- Responder proves its own identity + binding.
- Initiator proves its binding.
Operational Ed25519 keys cross only after mutual accept (in CONN_CONFIRM,
inside the IdentityBinding). Real IPs are encrypted to each side's key and
exchanged over Tor — your IP is revealed only to a peer you accepted.
Ensemble hosts many services, each under its own per-service keypair / E-address. The model for who may connect to a hosted service is deliberately conservative — locked down by default, services opt into their own leniency. Canonical rationale: ADR-0002.
1. A service dials as itself. When a hosted service opens a connection, the daemon signs the signaling handshake with that service's keypair — the responder sees the service's E-address as the connecting identity, not the host daemon's node address. The service keypair never leaves the daemon; the client just asks (over its registration stream) to dial. The libp2p transport underneath stays node-to-node; only the signaling/ACL identity is the service.
2. A service decides its own inbound policy. Every service declares an ACL in its manifest, and the gate is default-deny:
| ACL | Inbound unknown peer |
|---|---|
public |
accepted |
allowlist |
accepted iff the address is listed |
contacts / unset |
rejected |
A service that needs per-peer decisions sets the manifest flag
decides_connections. Every unknown-peer request is then delivered to the
service's own code as a connection_request event (carrying the dialer's
service address); the service replies accept or reject. A service that doesn't
set the flag keeps the synchronous default-deny gate. If a service sets the flag
but registers no handler, the gate fails closed.
Introductions are information, never authorization. A service (e.g. a matchmaker) can introduce two peers, but an introduction creates no grant — it's delivered as an event, and the receiving service decides for itself whether to accept the resulting connection. There are no automatic grants: nothing a third party can send you punches a hole in your gate.
Worked example (matchmaker → players): a matchmaker introduces player A to
player B by service address. Each player's service sets decides_connections
and, on the connection_request, accepts iff the dialer is a peer the
verified matchmaker just introduced it to. Player A dials player B as its own
player service, so B's gate sees A's player address and matches it against the
introduction. All matchmaking-specific policy lives in the game service;
Ensemble's core stays generic and default-deny.
Rejected alternatives (reasoning in ADR-0002): auto-grant on introduction turns the introduction primitive into a general ACL-bypass for every hosted service; node-address gating drags node identity into the per-service ACL layer and is coarser-grained.
The service-install and registry publish family is gated by where the call
arrives: free over the daemon's local Unix socket (filesystem permission is
the auth), admin-signature-required on any network transport. A daemon with no
admin key disables the install family on the network entirely — an open install
RPC is remote code execution as a feature, so there is no soft default. See
ADR-0009 §10.
The browser SPA holds a non-extractable WebCrypto Ed25519 key and signs each
gRPC-Web request with the admin seed — the same auth that protects the
TCP/UDS gRPC endpoints protects the browser. The seed-handling SPA refuses to
serve over plaintext on a non-loopback bind unless you explicitly assert a TLS
terminator (--trust-proxy-tls) or accept the risk on a trusted LAN
(--unsafe-plaintext-ui).
The daemon SHA-256-verifies the downloaded Tor Expert Bundle against a hash
pinned in source. That pin is trust-on-first-use — set by whoever last
bumped the bundle. make verify-tor-bundle adds an out-of-band PGP check to
close the mirror-substitution gap; run it before any hash bump
(setup.md).
| Concern | Mechanism |
|---|---|
| Identity forgery | Dilithium-anchored address, signature-verified at handshake |
| DHT record spoofing | Signed PeerRecords (pubkey hashes to address) |
| Accepting an impostor | Verified-before-prompt; ownership proven before any prompt |
| IP exposure | Real IPs encrypted to peer's key, swapped only after mutual accept |
| Unwanted service connections | Per-service default-deny ACL; opt-in decides_connections |
| Privilege via introduction | None — introductions carry no grant |
| Remote service install | Off-socket calls require admin signature; disabled with no admin key |
| Tor supply chain | SHA-256 pin + out-of-band PGP verification on bump |