VeilNet

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.

Fig. 01 — Nothing is usable before the proof01 / 04
diallerlistenerKNOCK · CHAIN + ML-DSA SIGNATURESTRANGERADMITS ADDRESS + ANCHOR FOR 10 SDISCARDED · NO ANSWERED25519 TLS CERT+ML-DSA BINDINGTHE CERTIFICATE CARRIES THE IDENTITYCHAIN → PINNED ROOTTAINTS = THE CREDENTIAL'S GRANTREALM RELATION IN THE TREESAME REALM · COMPATIBLE TAINTS

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 (ErrCredential if 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:

SenderWhat it gets
Anyone outside the tree, including a scannerSilence
An anchor from a tree with a different rootSilence, because the listener cannot read its knock
A member whose credential has expired, or is not valid yetSilence
A member whose clock is two hours outSilence, because its knock is too old. Up to an hour of difference always passes.
A member under a blockSilence
A dial that names a different anchorSilence
An attacker on the network who delays a member's knock and forwards it laterThe listener's certificate, then ErrNotAdmitted
A member of a sibling or cousin realmRefused at the proof: ErrRealmRelation
A member across a lapsed delegationRefused at the proof: ErrRealmIsolated
A member with taints its credential does not grantRefused at the proof: ErrTaintsNotGranted
A memberAdmitted. 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:

0 of 5 done
  1. $ sudo conflux anchorctl metrics | grep knock

    On the listener, anchor_knocks_refused_total counts refused knocks by cause. anchor_knock_chunks_refused_total counts 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.