Admission
Before two anchors exchange any data, they admit each other in three stages: a knock, a handshake and a proof of membership.
Admission Stages
Admission is how two anchors decide whether to talk to each other. It keeps everyone outside your tree away from your machines, and keeps each machine's port silent to scanners.
Each stage costs more than the one before, and each stops a different kind of sender:
- The knock decides who gets an answer at all. It is a signed message that carries the dialler's credential chain.
- The handshake proves who the peer is.
- The proof of membership shows that the peer belongs to the tree, and ties that proof to this connection.
No connection carries data until all three stages succeed.
Knock. The dialler sends a knock before anything else. The knock carries its credential chain and an ML-DSA signature. If the knock passes, the listener lets that address and anchor continue for ten seconds.
Anyone else gets no reply at all: no alert, no reset and no certificate.
Handshake. The anchors run TLS 1.3 over QUIC, with one hybrid post-quantum key exchange and no fallback. Each anchor's ML-DSA identity is bound to its TLS certificate.
Proof. Each side uses its ML-DSA key to sign a value unique to this session, together with its credential chain and its taints.
The peer checks the chain against the pinned root and the taints against the credential. It also checks how the two realms relate in the tree.
Usable. Only now can anything else happen. Data flows only if the two anchors share a realm, and one anchor's taints include all of the other's.
A listener discards a stranger's packet at almost no cost. Checking a member's knock takes under a millisecond.
The Knock
The knock is the first thing a dialler sends. It carries the dialler's identity key, its credential chain and an ML-DSA signature. The signature names the anchor the dialler expects to answer and the address it dials.
The knock is encrypted with a key that only members of your tree can derive, so nobody outside the tree can read it. It also records the hour in which it was made.
The listener runs the cheapest checks first, and stops at the first one that fails. It refuses a knock that:
- does not come from your tree
- carries a time too far from the listener's own clock (a difference of up to an hour always passes)
- was made for another anchor, another address or another purpose
- is a copy of a knock already seen from another source
- arrives during a flood, beyond the anchor's limit of 64 knocks a second
- has a signature or credential chain that does not verify against the pinned root
A knock that passes lets its address and anchor go on to the handshake for ten seconds.
The Handshake
After a knock passes, the two anchors run a TLS 1.3 handshake over QUIC. The key exchange uses exactly one hybrid post-quantum group, SecP384r1MLKEM1024 in the default suite. A peer that cannot use that group fails the handshake.
The TLS certificate uses a short-lived Ed25519 key that is never stored. An ML-DSA signature binds the anchor's identity to that certificate, so the certificate shows who the peer is.
The handshake adds these checks:
- A dialler checks that the anchor that answered is the one it dialled.
- A listener checks that the anchor in the certificate is the one whose knock it accepted from that address. It refuses anything else with
ErrNotAdmitted. - Sessions are never resumed, so every connection proves its keys from the start.
Proof of Membership
The handshake proves who the peer is, but not that it belongs to the tree. A credential chain is too large for the handshake, so each side proves its membership right after it.
Each side uses its ML-DSA key to sign a value unique to this TLS session. The signature also covers both AnchorIDs, the signer's credential chain and its taints. Because the proof is tied to the session, it cannot be replayed on another connection.
Neither side can use the connection until it has verified the other's proof and checked:
- the credential chain, from the pinned root down (
ErrCredentialif it fails) - that the peer's taints are exactly the ones its credential grants (
ErrTaintsNotGranted) - how the two realms relate in the tree. A sibling or cousin realm is refused (
ErrRealmRelation), and so is a realm that a lapsed delegation has cut off (ErrRealmIsolated)
The knock already checked the same chain, but only to decide who gets an answer. The proof ties membership to this connection. Even an attacker who could forge Ed25519 signatures and sit between two anchors could not produce it, because it needs the ML-DSA key.
Refused Senders
Each kind of sender gets a different response:
| Sender | What it gets |
|---|---|
| Anyone outside the tree, including a scanner | Silence |
| An anchor from a tree with a different root | Silence, because the listener cannot read its knock |
| A member whose credential has expired, or is not valid yet | Silence |
| A member whose clock is two hours out | Silence, because its knock is too old. Up to an hour of difference always passes. |
| A member under a block | Silence |
| A dial that names a different anchor | Silence |
| An attacker on the network who delays a member's knock and forwards it later | The listener's certificate, then ErrNotAdmitted |
| A member of a sibling or cousin realm | Refused at the proof: ErrRealmRelation |
| A member across a lapsed delegation | Refused at the proof: ErrRealmIsolated |
| A member with taints its credential does not grant | Refused at the proof: ErrTaintsNotGranted |
| A member | Admitted. From here on, it gets the ordinary protocol errors. |
A listener cannot say why it refused without answering. A dialler that gets no answer therefore fails with ErrNoAnswer, and the message lists the possible causes.
Port Silence
An anchor's port sends nothing to anyone who has not passed the knock. If the host also drops unsolicited UDP, a scanner sees the port as an ordinary filtered port.
A closed UDP port is different, because the host answers it with an ICMP port-unreachable message. An anchor's port is open, so the host sends no such message, and a scan reports the port as open or filtered.
To keep the port silent, configure the host's firewall to drop unsolicited UDP rather than reject it. On a host that rejects it, the anchor's port stands out as the only silent one.
Diagnosing a Silent Dial
When a dial gets no answer, the listener does not say why. It does not log the knocks it refuses, but it counts them by cause. Work through these steps on both machines:
$ sudo conflux anchorctl metrics | grep knock
On the listener,
anchor_knocks_refused_totalcounts refused knocks by cause.anchor_knock_chunks_refused_totalcounts packets from another tree, packets from a clock outside the window, and copies. The counter that rises while you dial shows the cause.
Under heavy load, also watch anchor_gate_dropped_total{reason="queue_full"}. It counts knock packets that were dropped because the listener's queues were full.