Skip to content

Post-quantum protection depends on address secrecy; proposal: ephemeral ML-KEM-768 in the meow bootstrap for a per-connection PSK #133

Open

Description

@XavierXIEXIN

Problem

Since v0.6.0 (#84, #85) every tailcat address carries a WireGuard pre-shared key, and the docs describe it as providing post-quantum confidentiality and keeping a DERP operator who sees the node keys out of the tunnel. Both properties hold only while the address is secret: the PSK is a symmetric secret that lives in the address.

But the README also documents, as a first-class flow, publishing the address in a DNS TXT record and gating access with tailcat serve --allow=.... In that flow the PSK is public, so harvest-now-decrypt-later protection is zero, and --psk=true vs --psk=false makes no security difference at all. Nothing says so today: ConnInfo.PresharedKey's doc comment and the --psk help text still attribute post-quantum confidentiality to the PSK unconditionally. (Filippo's suggestion in #84 was explicitly conditioned on the addresses "already [being] secret tokens".)

Everything else in tailcat is already public-key based (Noise IK / X25519 for the tunnel, client node keys for --allow, separate disco keys). The PQ layer is the one piece that is symmetric and address-secrecy-dependent.

Question

Is any asymmetric / post-quantum key agreement planned? If not, would you be open to a PR along the lines below? I have a fuller writeup (byte layouts, KDF, negotiation matrix, test plan) and can attach it or send the PR directly if the direction is acceptable.

Proposal: ephemeral ML-KEM-768 in the meow bootstrap, per-connection PSK

  • The client generates an ephemeral ML-KEM-768 keypair per Client (Go stdlib crypto/mlkem, no new dependency).
  • New meow ping type 0x03: today's fields plus a KEM id and the encapsulation key (1184 B; packet ≈ 1254 B). The server runs the usual onMeow / allow-list path, encapsulates, and answers with a new meowed type 0x04: KEM id, an 8-byte tag of the ek, and the ciphertext (1088 B; packet ≈ 1102 B). Well under derp.MaxPacketSize.
  • Both sides derive a per-peer PSK:
    HKDF-SHA256(ikm = kem_ss, salt = addr_psk, info = "tailcat pq psk v1" || kem_id || server_pub || client_pub || H(ek) || H(ct)).
    The server keeps a small per-client PSK map behind the existing SetPeerConfigFunc source (peerConfig is already per-peer), memoised by (client, H(ek)) so the 1 s ping resends and replays are idempotent, and calls SyncDevicePeer when a client shows up with a new ek. The client installs its PSK before meowWait closes, so the very first WireGuard handshake is already hybrid.
  • Address format unchanged, no new persisted key material, PQ forward secrecy per connection (the server never holds a KEM private key). The address PSK stays as the salt, so secret-address deployments keep every property they have now.
  • Compatibility: old clients send 0x01 and get classical service as today; new clients send 0x03 and, in auto mode, start alternating with 0x01 after ~2 s so an old server (which never answers 0x03) still works. --pq=auto|require|off on serve and on client modes lets either side refuse the downgrade. tailcat ping reports (post-quantum) / (classical), served processes get TAILCAT_PEER_PQ.

Why an unauthenticated exchange over DERP is fine

The KEM secret is only ever used as the WireGuard PSK, which is mixed into the Noise IK handshake. If anyone (including the DERP operator) substitutes ek or ct in flight, the two sides derive different PSKs and the handshake fails; the attacker cannot complete a handshake without the server's static private key. So an active attacker gets denial of service, which a relay already has, and a passive / harvest-now-decrypt-later attacker must break both X25519 and ML-KEM-768. This is the same trust model as TLS 1.3 X25519MLKEM768: the key share is unauthenticated in flight and authentication is bound in afterwards. Authentication itself stays classical, as in WireGuard, TLS hybrid KEX, and Tailscale.

Alternatives I considered and would not propose

  • A static ML-KEM encapsulation key in the address: addresses go from ~60 to ~1650 characters, no PQ forward secrecy, key-file and wasm-storage migration.
  • A Rosenpass-style in-tunnel upgrade after a classical handshake: same security, two extra round trips before the first byte, and more state to get the gating right.

Questions for you

  1. Are you comfortable with a ~1.25 KB meow ping (resent each second, max 10 s) on the shared tailcat.dev relays?
  2. ML-KEM-768 (the TLS default) or 1024?
  3. Should addresses gain a "server speaks PQ meow" capability bit so new clients can skip the fallback dance for new addresses? One CBOR bool that old clients ignore. I would leave it out of v1.
  4. Independently of the feature: the README "Key Management" text and the --psk help should probably stop attributing post-quantum protection to the address PSK once the address is published.

Rough size if you want it: ~350 lines of non-test Go, ~400 lines of tests, small doc changes.

Activity

  1. bradfitz commented on Sep 26, 2026

    @bradfitz
    Member

    Thanks for writing this up. I like the proposal. Couple misc thoughts:

    • yes, 1.25 KB setup packets over shared DERP relays are fine
    • ML-KEM-768 seems like the direction we're going with Tailscale itself. No need for 1024.
    • We are working on PQ stuff in Tailscale proper at the moment (probably doing ML-KEM-768 blended in during the handshake), which might mean Tailcat picks that all up for free and can just drop its PSKs from its addresses entirely.
    • Backwards compat: yes, it'd be nice to have at least one tagged release that understands some "needs new protocol" bit and fails with "I can't do that" and have that out for bit in advance, so when the new PQ stuff comes, the interop story is less chaotic. Maybe we instead send a capability version integer like Tailscale does.

    But the one requirement I didn't see you address:

    • As the operator of the shared DERP relays, we don't want to have access to know WireGuard public keys that then let us do a MEOW to servers and connect to them. They shouldn't have such power. The PSK let them be dumber in that they only knew the Wireguard part but couldn't then MEOW to the servers without the PSK. So if we do ML-KEM during MEOW, we lose that property and the shared relays again become a juicy target. So I think we still need some non-WireGuard-public-key shared secret in the address that we blend in to the PSK, even if ML-KEM does the heavy lifting. This is the part we'd still want even if Tailscale's PQ handshake changes land soon. In which case, our tailcat might need no changes at all: the PSK becomes the protection-from-shared-relay-operators part, and the ML-KEM bit from Tailscale itself becomes the PQ part.

    Thoughts?

  2. XavierXIEXIN commented on Sep 27, 2026

    @XavierXIEXIN
    Author

    Thanks for the quick and thorough reply, and for the clear answers on packet size and ML-KEM-768. Taking your points in turn, with the goal of leaving this in a state where you don't need to come back to it until you want to.

    The shared-relay-operator property is kept, I just buried it

    The address PSK is not dropped in the proposal. It stays in the address and is the HKDF salt:

    psk_peer = HKDF-SHA256(ikm = kem_ss, salt = addr_psk,
                           info = "tailcat pq psk v1" || kem_id || server_pub || client_pub || H(ek) || H(ct))
    

    So the KEM does the post-quantum work and the address PSK does exactly the job you describe: a relay operator who knows the server's WireGuard public key can send a meow, can even complete the ML-KEM exchange and learn kem_ss, but without addr_psk they derive a different psk_peer and the Noise handshake fails. That is the same outcome they get today, where the meow succeeds and only the handshake fails. Nothing the operator sees on the wire (ek, ct, both node keys) helps, because addr_psk never leaves the address.

    If you want the property to hold one layer earlier, so the operator can't even get a meowed or make the server do KEM work and allocate peer state, a 32-byte tag on the PQ ping does it: HMAC-SHA256(addr_psk, client_pub || ek), checked against the DERP source key before anything else. Pings from anyone who doesn't know the address PSK are dropped before encapsulation. The server would still accept --psk=false addresses without a tag, as today. I'd include that; it's cheap and it makes the "relays should be dumb" argument hold at every step rather than only at the handshake.

    For addresses that are deliberately published (TXT record + --allow), the PSK is public and the relay-operator property is gone regardless of mechanism, which matches your answer in #105: at that point --allow is the access control. The KEM still gives those deployments the HNDL protection they currently lack, which was the original motivation.

    Tailscale's in-handshake ML-KEM

    If wireguard-go gets ML-KEM-768 blended into the handshake, I agree that's the better end state and tailcat should just inherit it: the PSK becomes the anti-relay-operator part, the handshake becomes the PQ part, and the meow-level KEM in this proposal becomes unnecessary. I don't think tailcat should ship two PQ mechanisms. So I'd treat the meow-KEM design as the standalone fallback in case the handshake work is far off, and otherwise leave it on the shelf; the writeup is here if it's ever useful.

    One small follow-up that applies either way: the current README "Key Management" text and the --psk help say the address PSK provides post-quantum protection. That's true for secret addresses and false for published ones; once the handshake change lands it becomes simply true. Might be worth a one-line qualification in the meantime.

    Capability version, and what I'd ship now regardless

    Fully agree on getting a "needs newer protocol" signal out ahead of time, and a capability integer is better than a bit. Concretely:

    • Address: a new CBOR field carrying the minimum client capability version the server requires. fxamacker/cbor's default decode mode ignores unknown struct fields (ExtraDecErrorUnknownField is opt-in), so today's clients parse such an address fine and then fail later with a timeout; that's unavoidable for already-shipped builds. The prep release adds the check and fails immediately with something like this address requires tailcat protocol v2; this build supports v1, please upgrade. tailcat parse prints it.
    • Meow ping: a new ping type that carries the client's capability version (and, later, whatever the PQ mechanism needs). The address field doubles as the negotiation signal: if the address carries a version, the server understands the new ping; if it doesn't, the client sends today's ping. That removes the "send v2, then alternate with v1" fallback dance from my proposal entirely.
    • Server side: a --min-client-version (or fold it into --pq=require later) so operators can refuse old clients once they choose to, with a log line saying which client was refused and why.

    That prep change is independent of which PQ mechanism eventually lands and is small (wire field, parse-time check, one meow type, help text, tests). I'm happy to send it as a PR if that's welcome; if you'd rather do it in-house alongside the Tailscale handshake work, the notes above should be enough and no reply is needed on my side.

    Thanks again for engaging with this so carefully.

  3. bradfitz commented on Nov 4, 2026

    @bradfitz
    Member

    @XavierXIEXIN, see #160. Thoughts?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions