diff --git a/internal/vowifi/ike/provider.go b/internal/vowifi/ike/provider.go index 16ed560..fa18969 100644 --- a/internal/vowifi/ike/provider.go +++ b/internal/vowifi/ike/provider.go @@ -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, diff --git a/internal/vowifi/ike/provider_test.go b/internal/vowifi/ike/provider_test.go index dc88151..a7075c7 100644 --- a/internal/vowifi/ike/provider_test.go +++ b/internal/vowifi/ike/provider_test.go @@ -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{ diff --git a/internal/vowifi/ike/transport.go b/internal/vowifi/ike/transport.go index 425936b..1b14665 100644 --- a/internal/vowifi/ike/transport.go +++ b/internal/vowifi/ike/transport.go @@ -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") diff --git a/internal/vowifi/ike/transport_test.go b/internal/vowifi/ike/transport_test.go index 1d13979..7a17c10 100644 --- a/internal/vowifi/ike/transport_test.go +++ b/internal/vowifi/ike/transport_test.go @@ -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", diff --git a/internal/vowifi/ike/wire.go b/internal/vowifi/ike/wire.go index b7d84df..dc16f8a 100644 --- a/internal/vowifi/ike/wire.go +++ b/internal/vowifi/ike/wire.go @@ -59,6 +59,7 @@ const ( notifyMOBIKESupported = 16396 notifyNATSource = 16388 notifyNATDestination = 16389 + notifyCookie = 16390 notifyEAPOnlyAuth = 16417 notifyDeviceIdentity = 41101 notifyInvalidKE = 17