v2 control channel keys are publicly derivable when auth.method = oidc
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)
keyisauth.EncryptionKey(), which returns[]byte(resolved.Token).resolved.Tokenis only populated insideif resolved.Method == v1.AuthMethodToken(pkg/auth/auth.go, bothBuildClientAuthandBuildServerAuth). 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. infois 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
- 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
Note that
HashCryptoTranscripttakes only the two hello payloads, both of which are written to the wire beforeNewAEADCryptoReadWriteris installed.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:
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?
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.
Scope. The v1 control channel and work connections using
transport.useEncryptionare also keyed by the raw auth token and have the same weakness under OIDC. I did not touch them. Should they be handled separately?
Source: fatedier/frp