Skip to content

device: add support for optional hybrid PQ handshake - #98

Open
danderson wants to merge 1 commit into
tailscalefrom
push-zwvlolylkrut
Open

danderson wants to merge 1 commit into
tailscalefrom
push-zwvlolylkrut

Conversation

@danderson

@danderson danderson commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

When enabled for a peer, the handshake uses the Noise IKhfs+psk2 pattern instead of the usual Noise IKpsk2. The hfs extension gives the handshake hybrid forward secrecy, protecting past traffic from a future quantum computer able to break X25519.

@danderson
danderson force-pushed the push-zwvlolylkrut branch 2 times, most recently from 5a52503 to e430f79 Compare October 7, 2026 19:08
@danderson
danderson marked this pull request as ready for review October 7, 2026 19:10
@danderson
danderson requested a review from jwhited October 7, 2026 19:10
Comment thread device/uapi.go
Comment thread device/device.go Outdated
When enabled for a peer, the handshake uses the Noise IKhfs+psk2 pattern
instead of the usual Noise IKpsk2. The hfs extension gives the handshake
hybrid forward secrecy, protecting past traffic from a future quantum
computer able to break X25519.
Comment thread device/noise-types.go
Comment on lines +18 to +19
NoiseKEMEncapsulationKeySize = 1184
NoiseKEMCiphertextSize = 1088

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: we can point at https://pkg.go.dev/crypto/mlkem#pkg-constants, which is already a dependency

Comment thread device/uapi.go
Comment on lines +343 to +344
peer.handshake.mutex.Lock()
peer.SetHybridHandshake(value == "true")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Peer.SetHybridHandshake also acquires Peer.handshake.mutex.

Comment thread device/noise-protocol.go
)
setZero(ss[:])
aead, _ := chacha20poly1305New(key[:])
ss, ciphertext := handshake.remoteKEM.Encapsulate()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we consume a classical initiation, but then hybrid is enabled before we enter CreateMessageResponse, we'll panic on a nil remoteKem field here.

We probably want to bail out in such a case? Or just invalidate the state when mode switches. Or both.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The simplest solution I see is to use localKEM and remoteKEM nilness more intentionally, e.g. only set/gen localKEM when hybridHandshake is set, check remoteKEM nilness here instead of hybridHandshake, etc.

Relates to #98 (comment)

Comment thread device/noise-protocol.go
mixHash(&InitialHash, &InitialChainKey, []byte(WGIdentifier))
HybridInitialChainKey = blake2s.Sum256([]byte(HybridNoiseConstruction))
mixHash(&HybridInitialHash, &HybridInitialChainKey, []byte(WGIdentifier))
OneNonce[0] = 1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With 12-byte nonce noise specifies a 4-byte all zeroes prefix: https://noiseprotocol.org/noise.html#the-chachapoly-cipher-functions

So OneNonce[4] = 1

Comment thread device/noise-protocol.go
handshake := &peer.handshake

// verify that handshake type matches expectation
if msg.HasKEM != handshake.hybridHandshake {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handshake.mutex guards this field, but isn't acquired here.

There's a handshake.mutex.RLock() a few lines down.

Comment thread device/noise-protocol.go
MessageEncapsulatingTransportSize = 8 // size of optional, free (for use by conn.Bind.Send()) space preceding the transport header
MessageTransportSize = MessageTransportHeaderSize + poly1305.TagSize // size of empty transport
MessageKeepaliveSize = MessageTransportSize // size of keepalive
MessageHandshakeSize = MessageInitiationSize // size of largest handshake related message

@jwhited jwhited Oct 8, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This constant is unused within wireguard-go and tailscaled, but its comment is now outdated/misleading. We should probably just delete it, or update to point at ml-kem initiation message size constant.

Comment thread device/uapi.go
device.log.Verbosef("%v - UAPI: Updating hybrid", peer.Peer)

peer.handshake.mutex.Lock()
peer.SetHybridHandshake(value == "true")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should validate value is true or false, otherwise we can mislead the operator into thinking they changed something when they didn't

replace_allowed_ips is an example bool line that validates

Comment thread device/noise-protocol.go
if err != nil {
return nil, err
}
handshake.localKEM, err = mlkem.GenerateKey768()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this runs for classical init, so we pay resource taxes even though classical will never use the field in a useful way

one could argue it's acceptable in the interest of simplicity, if the intent is to move most/all handshakes to hfs over time

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants