#5532·frp

v2 control channel keys are publicly derivable when auth.method = oidc

Author: shani1998Created Sep 10, 2026Updated Sep 10, 2026

Bug Description

With auth.method = "oidc", the v2 wire control channel derives its AEAD keys from input keying material that is empty, and from a salt that is fully public. Any observer of the handshake can therefore derive the session keys for both directions.

pkg/util/net/conn.go derives the control-channel keys with:

hkdf.Key(sha256.New, key, transcriptHash, info, libcrypto.AEADKeySize)
  • key is auth.EncryptionKey(), which returns []byte(resolved.Token).
  • resolved.Token is only populated inside if resolved.Method == v1.AuthMethodToken (pkg/auth/auth.go, both BuildClientAuth and BuildServerAuth). With OIDC that branch never runs, so the IKM is empty on both sides.
  • The salt is HashCryptoTranscript(clientHelloPayload, serverHelloPayload), computed over two frames that are exchanged in cleartext before encryption begins.
  • info is a constant string.

Every input to the KDF is then public, so the AEAD layer provides no confidentiality against anyone who can see the handshake. This matters most when frp is deployed behind TLS-terminating infrastructure (reverse proxies, API gateways, service meshes), since those components see the hellos.

With auth.method = "token" the derivation is sound, because the pre-shared token is mixed in. The problem is specific to auth methods that have no pre-shared secret.

frpc Version

0.71.0 (dev @ 832df8df)

frps Version

0.71.0 (dev @ 832df8df)

System Architecture

All. This is in protocol code and is not platform specific.

Configurations

Any OIDC configuration with transport.wireProtocol = "v2", for example:

# frpc
transport.wireProtocol = "v2"
auth.method = "oidc"
auth.oidc.clientID = "..."
auth.oidc.clientSecret = "..."
auth.oidc.tokenEndpointURL = "https://issuer.example.com/token"
# frps
auth.method = "oidc"
auth.oidc.issuer = "https://issuer.example.com"
auth.oidc.audience = "..."

Logs

Not applicable. The connection succeeds normally; nothing is logged, which is part of why this is easy to miss.

Steps to reproduce

  1. Confirm the encryption key is empty under OIDC:
cfg := &v1.AuthClientConfig{Method: v1.AuthMethodOIDC}
cfg.OIDC.ClientID = "cid"
cfg.OIDC.ClientSecret = "secret"
cfg.OIDC.TokenEndpointURL = "https://issuer.example.invalid/token"
ca, _ := auth.BuildClientAuth(cfg)
len(ca.EncryptionKey()) // 0
  1. Note that HashCryptoTranscript takes only the two hello payloads, both of which are written to the wire before NewAEADCryptoReadWriter is installed.

  2. Given a capture of the ClientHello and ServerHello frames, recompute the transcript hash and run the same HKDF with an empty IKM to obtain the client-to-server and server-to-client keys.

Affected area

Security

Proposed fix

Add an ephemeral X25519 key share to ClientHello / ServerHello and mix the ECDH output into the HKDF input keying material. Token deployments keep contributing their pre-shared secret and additionally gain forward secrecy.

I have opened #5527 with an implementation, but I am happy to change the approach. Three points I would especially like your opinion on before it is reviewed:

  1. Fail-closed behaviour. The PR makes both sides refuse to open a v2 channel when the resulting keying material is empty, so a stripped key share cannot silently fall back to a publicly derivable key. The cost is that, under an auth method with no pre-shared secret, a peer with this change will not talk to a v2 peer without it. Token deployments are unaffected. Is that trade-off acceptable to you, or would you prefer it stay compatible and log a warning instead?

  2. Authentication. The exchange is unauthenticated on its own, so an active attacker who rewrites both hellos can still man-in-the-middle. Closing that needs a transcript confirmation after login. I left it out to keep the change small, but I can add it.

  3. Scope. The v1 control channel and work connections using transport.useEncryption are also keyed by the raw auth token and have the same weakness under OIDC. I did not touch them. Should they be handled separately?