Handle ePDG COOKIE challenges during IKE_SA_INIT (#65)

Some ePDGs return zero-Responder-SPI COOKIE challenges, and multiple resolved gateways can respond differently. Accept valid COOKIE responses, retry with COOKIE as the first payload, and prefer a gateway that completes SA negotiation.\n\nConstraint: RFC 7296 requires the COOKIE notification to be the first payload on the retry.\nRejected: Treat COOKIE as an ordinary or fatal notification | either drops a valid challenge or prevents the required retry.\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep relaxed zero-Responder-SPI matching limited to IKE_SA_INIT COOKIE responses.\nTested: go test ./internal/vowifi/ike ./internal/vowifi/...\nNot-tested: Live carrier authorization after IKE_AUTH.

Co-authored-by: Meng Meng <[email protected]>
This commit is contained in:
MarkXiu
2026-08-19 22:57:06 +08:00
committed by GitHub
co-authored by Meng Meng
parent 0318670f49
commit 3b8f32f591
5 changed files with 341 additions and 40 deletions
+77 -28
View File
@@ -188,34 +188,60 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
makeNotify(notifyNATSource, sourceHash),
makeNotify(notifyNATDestination, destinationHash),
}
first, initBody, err := marshalPayloadChain(initPayloads)
if err != nil {
return nil, err
}
initRequest := ikeHeader{
InitiatorSPI: initiatorSPI,
NextPayload: first,
Exchange: exchangeIKEInit,
Flags: flagInitiator,
MessageID: 0,
}.marshal(initBody)
initResponse, err := transport.RoundTrip(ctx, initRequest)
if err != nil {
return nil, err
}
responseHeader, responseBody, err := validateResponse(initResponse, initiatorSPI, [8]byte{}, exchangeIKEInit, 0)
if err != nil {
return nil, err
}
if responseHeader.ResponderSPI == [8]byte{} {
return nil, errors.New("ike: responder returned a zero SPI")
}
initResponsePayloads, err := parsePayloadChain(responseHeader.NextPayload, responseBody)
if err != nil {
return nil, err
}
if err := rejectFatalNotifications(initResponsePayloads); err != nil {
return nil, err
var (
initRequest []byte
initResponse []byte
responseHeader ikeHeader
initResponsePayloads []payload
cookie []byte
)
for attempt := 0; attempt < maxIKEInitCookieChallenges; attempt++ {
requestPayloads := append([]payload(nil), initPayloads...)
if len(cookie) > 0 {
requestPayloads = append([]payload{makeNotify(notifyCookie, cookie)}, requestPayloads...)
}
first, initBody, err := marshalPayloadChain(requestPayloads)
if err != nil {
return nil, err
}
initRequest = ikeHeader{
InitiatorSPI: initiatorSPI,
NextPayload: first,
Exchange: exchangeIKEInit,
Flags: flagInitiator,
MessageID: 0,
}.marshal(initBody)
initResponse, err = transport.RoundTrip(ctx, initRequest)
if err != nil {
return nil, err
}
var responseBody []byte
responseHeader, responseBody, err = validateResponse(initResponse, initiatorSPI, [8]byte{}, exchangeIKEInit, 0)
if err != nil {
return nil, err
}
initResponsePayloads, err = parsePayloadChain(responseHeader.NextPayload, responseBody)
if err != nil {
return nil, err
}
if err := rejectFatalNotifications(initResponsePayloads); err != nil {
return nil, err
}
challenge, hasCookie, err := ikeInitCookie(initResponsePayloads)
if err != nil {
return nil, err
}
if hasCookie {
if attempt+1 == maxIKEInitCookieChallenges {
return nil, errors.New("ike: ePDG requested too many COOKIE challenges")
}
cookie = challenge
continue
}
if responseHeader.ResponderSPI == [8]byte{} {
return nil, errors.New("ike: responder returned a zero SPI")
}
break
}
saPayload, err := onePayload(initResponsePayloads, payloadSA)
if err != nil {
@@ -616,6 +642,29 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
return session, nil
}
const maxIKEInitCookieChallenges = 2
func ikeInitCookie(payloads []payload) ([]byte, bool, error) {
var cookie []byte
for _, item := range payloadsOfType(payloads, payloadNotify) {
kind, data, err := parseNotify(item)
if err != nil {
return nil, false, err
}
if kind != notifyCookie {
continue
}
if len(data) == 0 {
return nil, false, errors.New("ike: ePDG returned an empty COOKIE")
}
if cookie != nil {
return nil, false, errors.New("ike: ePDG returned multiple COOKIE notifications")
}
cookie = append([]byte(nil), data...)
}
return cookie, cookie != nil, nil
}
func buildInitialEAPAuth(
idi payload,
requestedIDr payload,
+184 -11
View File
@@ -4,8 +4,10 @@ import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"strings"
"testing"
"time"
@@ -64,17 +66,20 @@ func (reader constantReader) Read(destination []byte) (int, error) {
}
type firstAuthCaptureTransport struct {
t *testing.T
wantEAPOnly bool
wantGroup uint16
calls int
suite negotiatedSuite
keys ikeKeys
spii [8]byte
spir [8]byte
nonceI []byte
nonceR []byte
floated bool
t *testing.T
wantEAPOnly bool
wantGroup uint16
calls int
suite negotiatedSuite
keys ikeKeys
spii [8]byte
spir [8]byte
nonceI []byte
nonceR []byte
floated bool
cookieChallenge []byte
cookieSeen bool
cookieLoop bool
}
func (transport *firstAuthCaptureTransport) LocalAddr() *net.UDPAddr {
@@ -92,6 +97,24 @@ func (transport *firstAuthCaptureTransport) Float(context.Context) error {
func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet []byte) ([]byte, error) {
transport.calls++
if len(transport.cookieChallenge) > 0 {
switch transport.calls {
case 1:
return transport.answerIKECookie(packet)
case 2:
if err := transport.verifyIKECookie(packet); err != nil {
return nil, err
}
if transport.cookieLoop {
return transport.answerIKECookie(packet)
}
return transport.answerIKEInit(packet)
case 3:
return nil, transport.observeFirstAuth(packet)
default:
return nil, errors.New("test: unexpected exchange")
}
}
switch transport.calls {
case 1:
return transport.answerIKEInit(packet)
@@ -102,6 +125,69 @@ func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet
}
}
func (transport *firstAuthCaptureTransport) answerIKECookie(packet []byte) ([]byte, error) {
header, _, err := parseIKEPacket(packet)
if err != nil {
return nil, err
}
first, body, err := marshalPayloadChain([]payload{
makeNotify(notifyCookie, transport.cookieChallenge),
})
if err != nil {
return nil, err
}
return ikeHeader{
InitiatorSPI: header.InitiatorSPI,
NextPayload: first,
Exchange: exchangeIKEInit,
Flags: flagResponse,
MessageID: 0,
}.marshal(body), nil
}
func (transport *firstAuthCaptureTransport) verifyIKECookie(packet []byte) error {
header, body, err := parseIKEPacket(packet)
if err != nil {
return err
}
if header.Exchange != exchangeIKEInit || header.MessageID != 0 || header.Flags != flagInitiator {
return errors.New("test: invalid retried IKE_SA_INIT header")
}
payloads, err := parsePayloadChain(header.NextPayload, body)
if err != nil {
return err
}
if len(payloads) == 0 || payloads[0].Type != payloadNotify {
return errors.New("test: retried IKE_SA_INIT did not put COOKIE first")
}
firstKind, firstData, err := parseNotify(payloads[0])
if err != nil || firstKind != notifyCookie || !bytes.Equal(firstData, transport.cookieChallenge) {
return errors.New("test: first retried IKE_SA_INIT payload is not the expected COOKIE")
}
cookies := payloadsOfType(payloads, payloadNotify)
if len(cookies) != 3 {
return fmt.Errorf("test: retried IKE_SA_INIT has %d notify payloads, want 3", len(cookies))
}
found := false
for _, item := range cookies {
kind, data, err := parseNotify(item)
if err != nil {
return err
}
if kind == notifyCookie {
if !bytes.Equal(data, transport.cookieChallenge) {
return fmt.Errorf("test: cookie = %x, want %x", data, transport.cookieChallenge)
}
found = true
}
}
if !found {
return errors.New("test: retried IKE_SA_INIT did not carry COOKIE")
}
transport.cookieSeen = true
return nil
}
func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte, error) {
header, body, err := parseIKEPacket(packet)
if err != nil {
@@ -291,6 +377,93 @@ func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) {
}
}
func TestProviderRetriesIKEInitAfterCookie(t *testing.T) {
capture := &firstAuthCaptureTransport{
t: t,
wantEAPOnly: true,
cookieChallenge: []byte{0x10, 0x20, 0x30, 0x40},
}
provider, err := NewProvider(Config{
Random: constantReader{value: 0x42},
Timeout: time.Second,
Installer: unusedInstaller{},
APN: "ims",
})
if err != nil {
t.Fatal(err)
}
provider.transportFactory = func(
context.Context,
transportConfig,
vowifi.ProxyRoute,
string,
) (datagramTransport, error) {
return capture, nil
}
aka := &testAKAProvider{}
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
DeviceID: "ec20-cookie",
Identity: vowifi.SIMIdentity{
ICCID: "8944100000000000000",
IMSI: "234150123456789",
HomeMCC: "234",
HomeMNC: "15",
},
EPDG: "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org",
AKA: aka,
})
if !errors.Is(err, errFirstAuthObserved) {
t.Fatalf("Start() error = %v, want capture sentinel", err)
}
if capture.calls != 3 || !capture.cookieSeen || capture.floated || aka.calls != 0 {
t.Fatalf("capture calls=%d cookie_seen=%v floated=%v AKA calls=%d", capture.calls, capture.cookieSeen, capture.floated, aka.calls)
}
}
func TestProviderBoundsRepeatedIKEInitCookieChallenges(t *testing.T) {
capture := &firstAuthCaptureTransport{
t: t,
wantEAPOnly: true,
cookieChallenge: []byte{0x10, 0x20, 0x30, 0x40},
cookieLoop: true,
}
provider, err := NewProvider(Config{
Random: constantReader{value: 0x42},
Timeout: time.Second,
Installer: unusedInstaller{},
APN: "ims",
})
if err != nil {
t.Fatal(err)
}
provider.transportFactory = func(
context.Context,
transportConfig,
vowifi.ProxyRoute,
string,
) (datagramTransport, error) {
return capture, nil
}
aka := &testAKAProvider{}
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
DeviceID: "ec20-cookie-loop",
Identity: vowifi.SIMIdentity{
ICCID: "8944100000000000000",
IMSI: "234150123456789",
HomeMCC: "234",
HomeMNC: "15",
},
EPDG: "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org",
AKA: aka,
})
if err == nil || !strings.Contains(err.Error(), "too many COOKIE challenges") {
t.Fatalf("Start() error = %v, want bounded COOKIE error", err)
}
if capture.calls != maxIKEInitCookieChallenges || aka.calls != 0 {
t.Fatalf("capture calls=%d AKA calls=%d", capture.calls, aka.calls)
}
}
func TestProviderO2GermanyFirstAuthUsesStandardEAPAndRequestsIMSAPN(t *testing.T) {
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: false, wantGroup: dhMODP2048}
provider, err := NewProvider(Config{
+34 -1
View File
@@ -576,10 +576,17 @@ func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
// Once a gateway answers, keep it pinned for the lifetime of the IKE SA.
if !transport.floated && requestHeader.Exchange == exchangeIKEInit && requestHeader.MessageID == 0 && len(transport.remotes) > 1 {
var lastErr error
var cookieResponse []byte
for _, candidate := range transport.remotes {
transport.remote = cloneUDPAddr(candidate)
response, attemptErr := transport.roundTripLocked(ctx, packet, requestHeader)
if attemptErr == nil {
if ikeInitResponseHasCookie(response) {
if cookieResponse == nil {
cookieResponse = append([]byte(nil), response...)
}
continue
}
return response, nil
}
lastErr = attemptErr
@@ -587,6 +594,9 @@ func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
return nil, attemptErr
}
}
if cookieResponse != nil {
return cookieResponse, nil
}
return nil, fmt.Errorf("ike: all %d resolved ePDG addresses timed out: %w", len(transport.remotes), lastErr)
}
return transport.roundTripLocked(ctx, packet, requestHeader)
@@ -824,11 +834,34 @@ func ikeResponseMatchesRequest(
}
var zeroSPI [8]byte
if request.ResponderSPI == zeroSPI {
return response.ResponderSPI != zeroSPI
if response.ResponderSPI != zeroSPI {
return true
}
return response.Exchange == exchangeIKEInit &&
response.MessageID == 0 &&
ikeInitResponseHasCookie(packet)
}
return response.ResponderSPI == request.ResponderSPI
}
func ikeInitResponseHasCookie(packet []byte) bool {
header, body, err := parseIKEPacket(packet)
if err != nil || header.Exchange != exchangeIKEInit || header.MessageID != 0 {
return false
}
payloads, err := parsePayloadChain(header.NextPayload, body)
if err != nil {
return false
}
for _, item := range payloadsOfType(payloads, payloadNotify) {
kind, data, err := parseNotify(item)
if err == nil && kind == notifyCookie && len(data) > 0 {
return true
}
}
return false
}
func marshalSOCKS5Datagram(remote *net.UDPAddr, payload []byte) ([]byte, error) {
if remote == nil || remote.IP == nil || remote.Port < 1 || remote.Port > 65535 {
return nil, errors.New("ike: invalid SOCKS5 UDP destination")
+45
View File
@@ -145,6 +145,18 @@ func TestSOCKS5InitialExchangeFallsBackAcrossResolvedEPDGAddresses(t *testing.T)
Exchange: exchangeIKEInit,
Flags: flagResponse,
}.marshal([]byte("response"))
cookieFirst, cookieBody, err := marshalPayloadChain([]payload{
makeNotify(notifyCookie, []byte{0x10, 0x20, 0x30, 0x40}),
})
if err != nil {
t.Fatal(err)
}
cookieResponse := ikeHeader{
InitiatorSPI: requestHeader.InitiatorSPI,
NextPayload: cookieFirst,
Exchange: exchangeIKEInit,
Flags: flagResponse,
}.marshal(cookieBody)
serverDone := make(chan error, 1)
go func() {
buffer := make([]byte, 2048)
@@ -160,6 +172,14 @@ func TestSOCKS5InitialExchangeFallsBackAcrossResolvedEPDGAddresses(t *testing.T)
return
}
if !destination.IP.Equal(second.IP) {
cookieWire, marshalErr := marshalSOCKS5Datagram(first, cookieResponse)
if marshalErr == nil {
_, marshalErr = relay.WriteToUDP(cookieWire, peer)
}
if marshalErr != nil {
serverDone <- marshalErr
return
}
continue
}
wire, marshalErr := marshalSOCKS5Datagram(second, response)
@@ -186,6 +206,31 @@ func TestSOCKS5InitialExchangeFallsBackAcrossResolvedEPDGAddresses(t *testing.T)
}
}
func TestIKEResponseMatchesCookieChallengeWithZeroResponderSPI(t *testing.T) {
request := ikeHeader{
InitiatorSPI: [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
Exchange: exchangeIKEInit,
Flags: flagInitiator,
MessageID: 0,
}
first, body, err := marshalPayloadChain([]payload{
makeNotify(notifyCookie, []byte{0x10, 0x20, 0x30, 0x40}),
})
if err != nil {
t.Fatal(err)
}
response := ikeHeader{
InitiatorSPI: request.InitiatorSPI,
Exchange: exchangeIKEInit,
Flags: flagResponse,
MessageID: 0,
NextPayload: first,
}.marshal(body)
if !ikeResponseMatchesRequest(response, request) {
t.Fatal("IKE COOKIE response with zero Responder SPI was rejected")
}
}
func TestSOCKS5RoundTripSkipsStaleAndESPDatagrams(t *testing.T) {
relay, err := net.ListenUDP(
"udp",
+1
View File
@@ -59,6 +59,7 @@ const (
notifyMOBIKESupported = 16396
notifyNATSource = 16388
notifyNATDestination = 16389
notifyCookie = 16390
notifyEAPOnlyAuth = 16417
notifyDeviceIdentity = 41101
notifyInvalidKE = 17