Skip to content
C.W.K.
Lesson 03 of 05 · published

Pin the Certificate, Skip the Authority

~14 min · tls, certificate-pinning, self-signed, libressl, lan

Level 0Loose File
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

A Shared Wire Needs a Sealed Envelope

A ticket proves who is calling. It does not hide what is said, and it does not prove who is answering. On a Thunderbolt bridge that is acceptable: the wire is a cable between two Macs on one desk, and nothing else is on it. On the house's Ethernet and Wi-Fi it is not. Every device in the house shares those segments (televisions, a printer, a guest's phone), and a ticket sent in clear text there can be copied by anything that listens. So Delta's rule for the shared LAN is short: TLS only, and the ticket rides inside it.

The gateway enforces the first half before it reads a single header. On a connection classified as lan, it peeks at the first byte. A TLS connection opens with a handshake record whose first byte is 0x16; anything else is plain HTTP, and the gateway closes the connection with a log line: "refused plain HTTP on the shared LAN … (TLS required)". There is no fallback to clear text on that route, for anyone.

Who Signs a Family Mac's Certificate?

Ordinary TLS answers "who is answering?" with a certificate authority: a third party signs a certificate for a hostname, and the client checks the signature chain and the name. That model fits a public website. It fits a family of Macs badly. Their LAN addresses are private and change with DHCP, no public CA will sign for them, and running a private CA means a signing key that has to live somewhere and be protected forever.

Delta skips the authority entirely. Each Mac generates its own self-signed certificate once, the first time the gateway needs it: RSA 2048, valid for ten years, named delta-<label>, written under <state root>/tls/ with the folder at 0700 and the key at 0600. The key is never copied anywhere. Then the Mac publishes the SHA-256 of that certificate (over its DER bytes) as tls_sha256 in its identity document.

Here is the move that makes it work: the identity document is served on /v1/identity, and that path, like /v1/tickets, answers only over the tailnet. So the fingerprint a peer learns was delivered by the Mac it claims to describe, authenticated by WireGuard. The pin has the same root of trust as the ticket.

Pinning, Step by Step

  1. The calling Mac reads the peer's identity document over the tailnet (kept for five minutes) and picks a LAN route with Track 3's rules. The route carries the fingerprint it was told.
  2. It opens TLS with a client context that checks neither hostname nor chain: check_hostname = False, verify_mode = CERT_NONE. That looks alarming until you read the next step.
  3. Right after the handshake, before any request is written, it hashes the certificate the peer actually presented and compares it with the pin. Equal: proceed, and send the ticket inside the encrypted channel. Different, or no pin at all: close the connection, forget the cached identity document, and refuse the route with a named cause.

Turning off the library's verification is safe here only because a stricter check replaces it. A CA check asks "did somebody trustworthy vouch for this name?". The pin asks "is this exactly the certificate this Mac told me, over an authenticated channel, that it holds?". For two machines that already know each other, the second question is the better one. Forgetting the identity document on a mismatch matters too: if the peer legitimately regenerated its certificate (its state folder was reset), the next attempt reads the new fingerprint over the tailnet instead of failing forever on a stale pin.

This Is Not Trust on First Use

SSH's familiar prompt ("The authenticity of host … can't be established") is trust on first use: the client believes whatever key it sees the first time and complains only if it changes later. The first contact is the weak moment, and an attacker on the wire at that moment wins. Delta has no first-use moment. The pin always arrives over WireGuard, so even the very first LAN connection to a new peer is checked against a fingerprint that came from that peer.

Why the Other Two Routes Carry No TLS

RouteEncryptionWhy
tailnetWireGuard (already)A second handshake stacked inside the tunnel would add cost and no secrecy (Dad's ruling, 2026-11-09)
Thunderbolt bridgenone; ticket in the clearA cable between two Macs carries nobody else's traffic; it measured 28.3 Gbit/s plain
shared LANTLS, pinnedEverything in the house shares the segment

TLS is not free on these Macs. Delta runs on Apple's python3, whose ssl module is built on LibreSSL 2.8.3. Over 10GbE, plain HTTP moved 9.0 to 9.2 Gbit/s; TLS moved 1.9 Gbit/s on one stream and 3.6 on four. The architecture doc gives the ciphers themselves at about 400 MB/s per stream for ChaCha20 and 115 to 150 MB/s for AES-GCM, which has no hardware help in that LibreSSL; a whole transfer also hashes and writes every byte, and one stream of it measured the lower 1.9 Gbit/s. The gap between the ciphers is why the cipher list puts ChaCha20 first, and the slow single stream is why the LAN runs six workers. The same old LibreSSL is also why the key is RSA: it refused an elliptic-curve key with "no shared cipher".

Code

Delta's own certificate and the pin check after the handshake (trimmed excerpt)·python
# delta/identity.py and delta/peer.py (trimmed excerpt)

def ensure_tls(label: str):
    """Generate this Mac's self-signed certificate once (never copied anywhere)."""
    cert, key = tls_paths()                      # <state root>/tls/cert.pem, key.pem
    if cert.exists() and key.exists():
        return cert, key
    cert.parent.mkdir(parents=True, exist_ok=True)
    os.chmod(cert.parent, 0o700)
    # RSA: the LibreSSL 2.8 behind Apple's python3 refused an EC key ("no shared cipher").
    subprocess.run([openssl, "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "3650",
                    "-subj", f"/CN=delta-{label}", "-keyout", str(key), "-out", str(cert)],
                   check=True, capture_output=True, timeout=60)
    os.chmod(key, 0o600)
    return cert, key


def cert_fingerprint(pem_path) -> str:             # published as tls_sha256 on /v1/identity
    der = ssl.PEM_cert_to_DER_cert(pem_path.read_text())
    return hashlib.sha256(der).hexdigest()


def client_tls_context() -> ssl.SSLContext:
    """Verification is by pinned fingerprint (checked after the handshake)."""
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    ctx.set_ciphers("ECDHE+CHACHA20:ECDHE+AESGCM")
    return ctx


# PeerClient._connect, after the handshake on a "lan" route
if route.tls:
    seen = identity.peer_fingerprint(conn.sock)    # SHA-256 of the DER the peer presented
    if not route.fingerprint or seen != route.fingerprint:
        conn.close()
        self._identity.pop(label, None)            # read the identity document again next time
        raise Forbidden(f"{label}'s TLS certificate at {route.host} does not match the one it "
                        "published over the tailnet; refusing the LAN route")
Pinning you can run: a loopback TLS peer, a matching pin, and an impostor·python
#!/usr/bin/env python3
"""Pin a peer's certificate by SHA-256 instead of trusting a CA (stdlib + openssl)."""
import hashlib
import socket
import ssl
import subprocess
import tempfile
import threading
from pathlib import Path


def make_cert(folder: Path, name: str) -> tuple:
    cert, key = folder / f"{name}.pem", folder / f"{name}.key"
    subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "30",
                    "-subj", f"/CN=delta-{name}", "-keyout", str(key), "-out", str(cert)],
                   check=True, capture_output=True)
    return cert, key


def fingerprint_of_file(cert: Path) -> str:
    return hashlib.sha256(ssl.PEM_cert_to_DER_cert(cert.read_text())).hexdigest()


def serve_once(cert: Path, key: Path) -> int:
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ctx.load_cert_chain(str(cert), str(key))
    listener = socket.create_server(("127.0.0.1", 0))

    def run():
        conn, _ = listener.accept()
        try:
            with ctx.wrap_socket(conn, server_side=True) as tls:
                tls.sendall(b"hello from the peer\n")
        except (ssl.SSLError, OSError):
            pass
        listener.close()

    threading.Thread(target=run, daemon=True).start()
    return listener.getsockname()[1]


def connect_pinned(port: int, pinned: str) -> bytes:
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    ctx.check_hostname = False          # no CA and no hostname: the pin is the check
    ctx.verify_mode = ssl.CERT_NONE
    with socket.create_connection(("127.0.0.1", port)) as raw:
        with ctx.wrap_socket(raw) as tls:
            seen = hashlib.sha256(tls.getpeercert(binary_form=True)).hexdigest()
            if seen != pinned:
                raise PermissionError("certificate does not match the published pin; refusing the route")
            return tls.recv(100)


with tempfile.TemporaryDirectory() as tmp:
    folder = Path(tmp)
    cert, key = make_cert(folder, "peer")
    published = fingerprint_of_file(cert)     # in Delta: tls_sha256 on /v1/identity, over the tailnet
    print("pinned", published[:16], "...")
    print(connect_pinned(serve_once(cert, key), published))

    impostor_cert, impostor_key = make_cert(folder, "impostor")
    try:
        connect_pinned(serve_once(impostor_cert, impostor_key), published)
    except PermissionError as exc:
        print("impostor:", exc)

External links

Exercise

Run the pinning script above. Then change it so the published fingerprint reaches the client the way Delta's does: serve it from a second, plain HTTP endpoint on loopback that stands in for the tailnet, and have the client fetch it before connecting. Finally regenerate the peer's certificate between two connections and make the client recover on its second attempt by re-reading the fingerprint.
Hint
Keep the fingerprint in a small cache keyed by peer, and delete that cache entry whenever the comparison fails. The first attempt after the regeneration must fail with the named cause; the second must succeed. If both succeed, you are not actually checking the pin.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.