diff --git a/internal/vowifi/ike/child.go b/internal/vowifi/ike/child.go index 6d28818..34b171e 100644 --- a/internal/vowifi/ike/child.go +++ b/internal/vowifi/ike/child.go @@ -16,6 +16,7 @@ const ( configInternalIPv4Address = 1 configInternalIPv4DNS = 3 + configApplicationVersion = 7 configInternalIPv6Address = 8 configInternalIPv6DNS = 10 configPCSCFIPv4Address = 20 @@ -186,6 +187,9 @@ func configurationRequest() payload { configInternalIPv6DNS, configPCSCFIPv4Address, configPCSCFIPv6Address, + // Android's IKE library always appends APPLICATION_VERSION to the + // initial configuration request, even when the value is empty. + configApplicationVersion, } body := []byte{configRequest, 0, 0, 0} for _, attribute := range attributes { diff --git a/internal/vowifi/ike/ike3gpp.go b/internal/vowifi/ike/ike3gpp.go new file mode 100644 index 0000000..89a2c3a --- /dev/null +++ b/internal/vowifi/ike/ike3gpp.go @@ -0,0 +1,57 @@ +package ike + +import ( + "encoding/binary" + "errors" + "strings" +) + +// deviceIdentityRequested reports the 3GPP DEVICE_IDENTITY request defined by +// TS 24.302. Android remembers this request and answers it in a later EAP +// IKE_AUTH request, but only after authenticating the ePDG. +func deviceIdentityRequested(payloads []payload) (bool, error) { + for _, item := range payloadsOfType(payloads, payloadNotify) { + kind, _, err := parseNotify(item) + if err != nil { + return false, err + } + if kind == notifyDeviceIdentity { + return true, nil + } + } + return false, nil +} + +func deviceIdentityNotify(identity string) (payload, error) { + identity = strings.TrimSpace(identity) + if (len(identity) != 15 && len(identity) != 16) || !decimalDigits(identity) { + return payload{}, errors.New("ike: device identity must contain 15 or 16 digits") + } + identityType := byte(1) // IMEI + if len(identity) == 16 { + identityType = 2 // IMEISV + } + data := make([]byte, 11) + // TS 24.302 Figure 8.2.9.2: this inner length excludes its own two + // octets, and is therefore 9 for an IMEI/IMEISV value. + binary.BigEndian.PutUint16(data[:2], 9) + data[2] = identityType + for index := 0; index < 8; index++ { + low := identity[index*2] - '0' + high := byte(0x0f) + if index*2+1 < len(identity) { + high = identity[index*2+1] - '0' + } + data[index+3] = high<<4 | low + } + return makeNotify(notifyDeviceIdentity, data), nil +} + +func decimalDigits(value string) bool { + for _, digit := range value { + if digit < '0' || digit > '9' { + return false + } + } + return value != "" +} diff --git a/internal/vowifi/ike/ike3gpp_test.go b/internal/vowifi/ike/ike3gpp_test.go new file mode 100644 index 0000000..94ebf52 --- /dev/null +++ b/internal/vowifi/ike/ike3gpp_test.go @@ -0,0 +1,39 @@ +package ike + +import ( + "bytes" + "testing" +) + +func TestDeviceIdentityNotifyMatchesAndroidEncoding(t *testing.T) { + item, err := deviceIdentityNotify("123456789012345") + if err != nil { + t.Fatal(err) + } + kind, data, err := parseNotify(item) + if err != nil { + t.Fatal(err) + } + want := []byte{0, 9, 1, 0x21, 0x43, 0x65, 0x87, 0x09, 0x21, 0x43, 0xf5} + if kind != notifyDeviceIdentity || !bytes.Equal(data, want) { + t.Fatalf("DEVICE_IDENTITY = %d/%x, want %d/%x", kind, data, notifyDeviceIdentity, want) + } +} + +func TestDeviceIdentityNotifyAcceptsIMEISV(t *testing.T) { + item, err := deviceIdentityNotify("1234567890123456") + if err != nil { + t.Fatal(err) + } + _, data, _ := parseNotify(item) + if data[2] != 2 || data[10] != 0x65 { + t.Fatalf("IMEISV data = %x", data) + } +} + +func TestDeviceIdentityRequested(t *testing.T) { + requested, err := deviceIdentityRequested([]payload{makeNotify(notifyDeviceIdentity, nil)}) + if err != nil || !requested { + t.Fatalf("deviceIdentityRequested() = %v, %v", requested, err) + } +} diff --git a/internal/vowifi/ike/provider.go b/internal/vowifi/ike/provider.go index c192617..1f24454 100644 --- a/internal/vowifi/ike/provider.go +++ b/internal/vowifi/ike/provider.go @@ -111,7 +111,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques group := uint16(dhMODP2048) legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC) - eapOnly := eapOnlyAuthentication(request.Identity.HomeMCC, request.Identity.HomeMNC) + advertiseEAPOnly := advertiseEAPOnlyAuthentication(request.Identity.HomeMCC, request.Identity.HomeMNC) if legacyFirst { group = dhMODP1024 } @@ -246,6 +246,23 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques return nil, err } } + cleanupPendingIKE := true + cleanupMessageID := uint32(2) + defer func() { + if cleanupPendingIKE { + cleanupContext, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = sendIKESADelete( + cleanupContext, + transport, + ikeSuite, + keys, + initiatorSPI, + responseHeader.ResponderSPI, + cleanupMessageID, + ) + } + }() var childInboundSPIBytes [4]byte if err := fillNonzero(provider.config.Random, childInboundSPIBytes[:]); err != nil { @@ -260,7 +277,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques requestedIDr := payload{Type: payloadIDr, Body: append([]byte{2, 0, 0, 0}, []byte(provider.config.APN)...)} tsi := dualStackTrafficSelectors(payloadTSi) tsr := dualStackTrafficSelectors(payloadTSr) - firstAuthPayloads := buildInitialEAPAuth(idi, requestedIDr, childOfferBody, tsi, tsr, eapOnly) + firstAuthPayloads := buildInitialEAPAuth(idi, requestedIDr, childOfferBody, tsi, tsr, advertiseEAPOnly) authHeader := ikeHeader{ InitiatorSPI: initiatorSPI, ResponderSPI: responseHeader.ResponderSPI, @@ -293,15 +310,19 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques initiatorNonce, ikeSuite, keys.SKpr, - serverName, + "", // Android Iwlan enables IKE_OPTION_ACCEPT_ANY_REMOTE_ID. serverName, provider.config.RootCAs, provider.config.ResponderPublicKey, - eapOnly, // RFC 5998 EAP-only authentication defers responder AUTH. + true, // Some ePDGs, including O2 Germany, implicitly defer AUTH without accepting the RFC 5998 notify. ) if err != nil { return nil, err } + deviceIdentityPending, err := deviceIdentityRequested(authResponsePayloads) + if err != nil { + return nil, err + } messageID := uint32(1) currentPayloads := authResponsePayloads for round := 0; round < 10; round++ { @@ -320,16 +341,29 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques return nil, errors.New("ike: EAP state machine produced no response") } messageID++ + cleanupMessageID = messageID + 1 + requestPayloads := []payload{{Type: payloadEAP, Body: action.Response}} + if deviceIdentityPending && responderAUTH == vowifi.ResponderAUTHVerified { + deviceIdentity, identityErr := deviceIdentityNotify(request.Identity.IMEI) + if identityErr == nil { + requestPayloads = append(requestPayloads, deviceIdentity) + } + } eapRequest, err := encryptPayloads(ikeHeader{ InitiatorSPI: initiatorSPI, ResponderSPI: responseHeader.ResponderSPI, Exchange: exchangeIKEAuth, Flags: flagInitiator, MessageID: messageID, - }, []payload{{Type: payloadEAP, Body: action.Response}}, ikeSuite, keys.SKei, keys.SKai, provider.config.Random) + }, requestPayloads, ikeSuite, keys.SKei, keys.SKai, provider.config.Random) if err != nil { return nil, err } + if requested, notifyErr := deviceIdentityRequested(currentPayloads); notifyErr != nil { + return nil, notifyErr + } else if requested { + deviceIdentityPending = true + } eapResponse, err := transport.RoundTrip(ctx, eapRequest) if err != nil { return nil, err @@ -359,6 +393,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques return nil, err } messageID++ + cleanupMessageID = messageID + 1 finalRequest, err := encryptPayloads(ikeHeader{ InitiatorSPI: initiatorSPI, ResponderSPI: responseHeader.ResponderSPI, @@ -394,7 +429,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques return nil, errors.New("ike: duplicate final responder IDr payload") } if len(finalIDs) == 1 { - if err := validateFQDNIDr(finalIDs[0], provider.config.APN, "final APN"); err != nil { + if err := validateFQDNIDr(finalIDs[0], "", "final responder"); err != nil { return nil, fmt.Errorf("ike: final APN IDr: %w", err) } } @@ -473,9 +508,11 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques keys, initiatorSPI, responseHeader.ResponderSPI, + messageID+1, natDetected, provider.config.KeepaliveInterval, ) + cleanupPendingIKE = false installed, err := provider.config.Installer.Install(ctx, ChildSAConfig{ Name: name, OuterLocal: append(net.IP(nil), transport.LocalAddr().IP...), @@ -500,11 +537,11 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques Relay: relay, }) if err != nil { - _ = relay.Close() + _ = relay.CloseWithDelete(ctx) return nil, fmt.Errorf("ike: install CHILD_SA: %w", err) } if installed == nil { - _ = relay.Close() + _ = relay.CloseWithDelete(ctx) return nil, errors.New("ike: CHILD_SA installer returned a nil handle") } dataplaneMode := "unknown" @@ -554,14 +591,19 @@ func legacyIKEProfile(mcc, mnc string) bool { return plmn == "23415" || plmn == "2044" } -func eapOnlyAuthentication(mcc, mnc string) bool { +func advertiseEAPOnlyAuthentication(mcc, mnc string) bool { // Android exposes the ePDG authentication method as carrier policy rather // than unconditionally requesting RFC 5998 EAP-only authentication. O2 - // Germany's 262-03 ePDG rejects an EAP-only initial IKE_AUTH before it sends - // an EAP-AKA identity or challenge. Use the certificate-authenticated EAP - // flow for that PLMN while preserving the established behavior elsewhere. + // Germany's 262-03 ePDG rejects an initial IKE_AUTH that explicitly carries + // EAP_ONLY_AUTHENTICATION, but then implicitly defers responder AUTH when the + // notify is omitted. Do not advertise RFC 5998 for that PLMN; the final + // responder AUTH derived from the EAP-AKA MSK remains mandatory. + return !o2GermanyIKECompatibility(mcc, mnc) +} + +func o2GermanyIKECompatibility(mcc, mnc string) bool { plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0") - return plmn != "2623" + return plmn == "2623" } func buildInitialEAPAuth( @@ -572,10 +614,16 @@ func buildInitialEAPAuth( tsr payload, eapOnly bool, ) []payload { + // Match Android's IkeSessionStateMachine.buildIkeAuthReq ordering. Some + // carrier ePDGs inspect this first encrypted exchange before starting EAP. payloads := []payload{idi, requestedIDr} if eapOnly { payloads = append(payloads, makeNotify(notifyEAPOnlyAuth, nil)) } + payloads = append(payloads, + makeNotify(notifyMOBIKESupported, nil), + makeNotify(notifyInitialContact, nil), + ) return append(payloads, payload{Type: payloadSA, Body: append([]byte(nil), childOfferBody...)}, tsi, @@ -900,7 +948,7 @@ func (session *Session) Close(ctx context.Context) error { } } if relay != nil { - if err := relay.Close(); err != nil { + if err := relay.CloseWithDelete(ctx); err != nil { errs = append(errs, fmt.Errorf("close session relay: %w", err)) } } diff --git a/internal/vowifi/ike/relay.go b/internal/vowifi/ike/relay.go index 5aacf98..b4bb0d3 100644 --- a/internal/vowifi/ike/relay.go +++ b/internal/vowifi/ike/relay.go @@ -15,6 +15,7 @@ type sessionRelay struct { keys ikeKeys spii [8]byte spir [8]byte + deleteID uint32 natt bool keepalive time.Duration @@ -33,6 +34,7 @@ func newSessionRelay( keys ikeKeys, initiatorSPI [8]byte, responderSPI [8]byte, + deleteMessageID uint32, natt bool, keepalive time.Duration, ) *sessionRelay { @@ -46,6 +48,7 @@ func newSessionRelay( keys: keys, spii: initiatorSPI, spir: responderSPI, + deleteID: deleteMessageID, natt: natt, keepalive: keepalive, ctx: ctx, @@ -214,6 +217,53 @@ func (relay *sessionRelay) Close() error { return errors.Join(relay.terminalErrorIfFailure(), transportErr) } +func (relay *sessionRelay) CloseWithDelete(ctx context.Context) error { + deleteErr := relay.sendIKEDelete(ctx) + return errors.Join(deleteErr, relay.Close()) +} + +func (relay *sessionRelay) sendIKEDelete(ctx context.Context) error { + return sendIKESADelete(ctx, relay.transport, relay.suite, relay.keys, relay.spii, relay.spir, relay.deleteID) +} + +func sendIKESADelete( + ctx context.Context, + transport datagramTransport, + suite negotiatedSuite, + keys ikeKeys, + initiatorSPI [8]byte, + responderSPI [8]byte, + messageID uint32, +) error { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + // Teardown is often called with the operation context already canceled. + // Give the protocol-level release a short independent chance to leave. + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(context.Background(), time.Second) + defer cancel() + } + request, err := encryptPayloads(ikeHeader{ + InitiatorSPI: initiatorSPI, + ResponderSPI: responderSPI, + Exchange: exchangeInformational, + Flags: flagInitiator, + MessageID: messageID, + }, []payload{{ + Type: payloadDelete, + Body: []byte{protocolIKE, 0, 0, 0}, + }}, suite, keys.SKei, keys.SKai, nil) + if err != nil { + return fmt.Errorf("ike: build IKE SA delete: %w", err) + } + if err := transport.SendSessionPacket(ctx, request, true); err != nil { + return fmt.Errorf("ike: send IKE SA delete: %w", err) + } + return nil +} + func (relay *sessionRelay) terminalErrorIfFailure() error { relay.mu.Lock() defer relay.mu.Unlock() diff --git a/internal/vowifi/ike/relay_test.go b/internal/vowifi/ike/relay_test.go index 8d64203..dae6da9 100644 --- a/internal/vowifi/ike/relay_test.go +++ b/internal/vowifi/ike/relay_test.go @@ -119,6 +119,7 @@ func TestSessionRelayCloseInterruptsStuckTransportRead(t *testing.T) { ikeKeys{}, [8]byte{1}, [8]byte{2}, + 9, true, time.Hour, ) @@ -156,7 +157,7 @@ func TestSessionRelayDemuxesESPAndAnswersEncryptedDPD(t *testing.T) { } spii := [8]byte{1} spir := [8]byte{2} - relay := newSessionRelay(transport, suite, keys, spii, spir, true, time.Hour) + relay := newSessionRelay(transport, suite, keys, spii, spir, 9, true, time.Hour) defer relay.Close() esp := []byte{0, 0, 0, 9, 0, 0, 0, 1, 0xaa} @@ -201,6 +202,47 @@ func TestSessionRelayDemuxesESPAndAnswersEncryptedDPD(t *testing.T) { } } +func TestSessionRelaySendsEncryptedIKESADelete(t *testing.T) { + transport := newFakeSessionTransport() + suite := legacyTestSuite() + keys := ikeKeys{ + SKai: bytes.Repeat([]byte{0x11}, 20), + SKar: bytes.Repeat([]byte{0x12}, 20), + SKei: bytes.Repeat([]byte{0x13}, 16), + SKer: bytes.Repeat([]byte{0x14}, 16), + } + spii := [8]byte{1} + spir := [8]byte{2} + relay := newSessionRelay(transport, suite, keys, spii, spir, 9, true, time.Hour) + defer relay.Close() + + if err := relay.sendIKEDelete(context.Background()); err != nil { + t.Fatalf("sendIKEDelete() error = %v", err) + } + select { + case sent := <-transport.sent: + if !sent.ike { + t.Fatal("IKE SA delete was sent as ESP") + } + header, payloads, err := decryptPayloads(sent.data, suite, keys.SKei, keys.SKai) + if err != nil { + t.Fatalf("decrypt IKE SA delete: %v", err) + } + if header.Exchange != exchangeInformational || header.MessageID != 9 || header.Flags != flagInitiator { + t.Fatalf("IKE SA delete header = %#v", header) + } + item, err := onePayload(payloads, payloadDelete) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(item.Body, []byte{protocolIKE, 0, 0, 0}) { + t.Fatalf("IKE SA delete body = %x", item.Body) + } + case <-time.After(time.Second): + t.Fatal("relay did not send IKE SA delete") + } +} + func TestSessionRelaySendsNATKeepalive(t *testing.T) { transport := newFakeSessionTransport() relay := newSessionRelay( @@ -209,6 +251,7 @@ func TestSessionRelaySendsNATKeepalive(t *testing.T) { ikeKeys{}, [8]byte{1}, [8]byte{2}, + 9, true, 10*time.Millisecond, ) @@ -233,6 +276,7 @@ func TestSessionRelayDropsDelayedIKEPacketFromPreviousSA(t *testing.T) { ikeKeys{}, spii, spir, + 9, true, time.Hour, ) diff --git a/internal/vowifi/ike/wire.go b/internal/vowifi/ike/wire.go index 6f1fd6d..b7d84df 100644 --- a/internal/vowifi/ike/wire.go +++ b/internal/vowifi/ike/wire.go @@ -28,6 +28,7 @@ const ( payloadAuth = 39 payloadNonce = 40 payloadNotify = 41 + payloadDelete = 42 payloadTSi = 44 payloadTSr = 45 payloadEncrypted = 46 @@ -54,11 +55,14 @@ const ( dhMODP2048 = 14 transformAttributeKeyLen = 14 - notifyNATSource = 16388 - notifyNATDestination = 16389 - notifyEAPOnlyAuth = 16417 - notifyInvalidKE = 17 - notifyNoProposal = 14 + notifyInitialContact = 16384 + notifyMOBIKESupported = 16396 + notifyNATSource = 16388 + notifyNATDestination = 16389 + notifyEAPOnlyAuth = 16417 + notifyDeviceIdentity = 41101 + notifyInvalidKE = 17 + notifyNoProposal = 14 ) var ( diff --git a/internal/vowifi/ike/wire_auth_test.go b/internal/vowifi/ike/wire_auth_test.go index b2abec5..fd56892 100644 --- a/internal/vowifi/ike/wire_auth_test.go +++ b/internal/vowifi/ike/wire_auth_test.go @@ -3,6 +3,7 @@ package ike import ( "bytes" "errors" + "slices" "testing" "vocat/internal/vowifi" @@ -60,7 +61,7 @@ func TestInitialEAPOnlyAuthCarriesAPNIDrAndNotify(t *testing.T) { dualStackTrafficSelectors(payloadTSi), dualStackTrafficSelectors(payloadTSr), ) - if len(payloads) != 7 || payloads[0].Type != payloadIDi || payloads[1].Type != payloadIDr { + if len(payloads) != 9 || payloads[0].Type != payloadIDi || payloads[1].Type != payloadIDr { t.Fatalf("initial auth payload order = %#v", payloads) } if got := string(payloads[1].Body[4:]); got != "ims" || payloads[1].Body[0] != 2 { @@ -68,11 +69,25 @@ func TestInitialEAPOnlyAuthCarriesAPNIDrAndNotify(t *testing.T) { } kind, data, err := parseNotify(payloads[2]) if err != nil { - t.Fatalf("parseNotify() error = %v", err) + t.Fatalf("parseNotify(EAP_ONLY_AUTHENTICATION) error = %v", err) } if kind != notifyEAPOnlyAuth || len(data) != 0 { t.Fatalf("notify = %d/%x, want EAP_ONLY_AUTHENTICATION", kind, data) } + kind, data, err = parseNotify(payloads[3]) + if err != nil { + t.Fatalf("parseNotify() error = %v", err) + } + if kind != notifyMOBIKESupported || len(data) != 0 { + t.Fatalf("notify = %d/%x, want MOBIKE_SUPPORTED", kind, data) + } + kind, data, err = parseNotify(payloads[4]) + if err != nil { + t.Fatalf("parseNotify() error = %v", err) + } + if kind != notifyInitialContact || len(data) != 0 { + t.Fatalf("notify = %d/%x, want INITIAL_CONTACT", kind, data) + } for _, kind := range []uint8{payloadTSi, payloadTSr} { item, err := onePayload(payloads, kind) if err != nil { @@ -99,9 +114,11 @@ func TestInitialStandardEAPAuthOmitsEAPOnlyNotify(t *testing.T) { dualStackTrafficSelectors(payloadTSr), false, ) - if len(payloads) != 6 || payloads[0].Type != payloadIDi || payloads[1].Type != payloadIDr { + if len(payloads) != 8 || payloads[0].Type != payloadIDi || payloads[1].Type != payloadIDr { t.Fatalf("initial standard EAP payload order = %#v", payloads) } + initialContact := 0 + mobikeSupported := 0 for _, item := range payloadsOfType(payloads, payloadNotify) { kind, _, err := parseNotify(item) if err != nil { @@ -110,16 +127,55 @@ func TestInitialStandardEAPAuthOmitsEAPOnlyNotify(t *testing.T) { if kind == notifyEAPOnlyAuth { t.Fatal("standard EAP initial request contains EAP_ONLY_AUTHENTICATION") } + if kind == notifyInitialContact { + initialContact++ + } + if kind == notifyMOBIKESupported { + mobikeSupported++ + } + } + if initialContact != 1 { + t.Fatalf("standard EAP initial request INITIAL_CONTACT count = %d, want 1", initialContact) + } + if mobikeSupported != 1 { + t.Fatalf("standard EAP initial request MOBIKE_SUPPORTED count = %d, want 1", mobikeSupported) + } + if payloads[2].Type != payloadNotify || payloads[3].Type != payloadNotify { + t.Fatalf("standard EAP Android notify order = %#v", payloads[:4]) + } +} + +func TestConfigurationRequestMatchesAndroidAttributes(t *testing.T) { + cp := configurationRequest() + if cp.Type != payloadCP || len(cp.Body) < 4 || cp.Body[0] != configRequest { + t.Fatalf("configuration request = %#v", cp) + } + var attributes []uint16 + for offset := 4; offset < len(cp.Body); { + if offset+4 > len(cp.Body) { + t.Fatalf("truncated attribute at %d", offset) + } + kind := uint16(cp.Body[offset])<<8 | uint16(cp.Body[offset+1]) + length := int(cp.Body[offset+2])<<8 | int(cp.Body[offset+3]) + if offset+4+length > len(cp.Body) { + t.Fatalf("attribute %d exceeds payload", kind) + } + attributes = append(attributes, kind) + offset += 4 + length + } + want := []uint16{1, 8, 3, 10, 20, 21, configApplicationVersion} + if !slices.Equal(attributes, want) { + t.Fatalf("configuration attributes = %v, want %v", attributes, want) } } func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) { for _, mnc := range []string{"03", "003"} { - if eapOnlyAuthentication("262", mnc) { + if advertiseEAPOnlyAuthentication("262", mnc) { t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc) } } - if !eapOnlyAuthentication("262", "02") || !eapOnlyAuthentication("234", "15") { + if !advertiseEAPOnlyAuthentication("262", "02") || !advertiseEAPOnlyAuthentication("234", "15") { t.Fatal("non-O2 PLMN lost the existing EAP-only policy") } } diff --git a/internal/vowifi/ims/provider.go b/internal/vowifi/ims/provider.go index 82a6cdf..44ce987 100644 --- a/internal/vowifi/ims/provider.go +++ b/internal/vowifi/ims/provider.go @@ -534,15 +534,26 @@ func newSession( refreshCancel() return nil, errors.New("ims: protected local IP address is unavailable") } + protectedClientPort := provider.config.ProtectedClientPort + protectedServerPort := provider.config.ProtectedServerPort + if securityEncryptionForIdentity(request.Identity) == "null" { + if protectedClientPort == 0 { + protectedClientPort = 5062 + } + if protectedServerPort == 0 { + protectedServerPort = 5063 + } + } proposal, err := newSecurityProposal( localIP, - provider.config.ProtectedClientPort, - provider.config.ProtectedServerPort, + protectedClientPort, + protectedServerPort, ) if err != nil { refreshCancel() return nil, err } + proposal.encryption = securityEncryptionForIdentity(request.Identity) session.securityProposal = proposal protectedTCP, err := net.ListenTCP( "tcp", @@ -567,6 +578,21 @@ func newSession( return session, nil } +func securityEncryptionForIdentity(identity vowifi.SIMIdentity) string { + if usesO2GermanyIMSProfile(identity) { + // O2 Germany's P-CSCF advertises the 3GPP integrity-only ESP profile. + // Proposing aes-cbc is rejected before the AKA challenge is issued. + return "null" + } + return "aes-cbc" +} + +func usesO2GermanyIMSProfile(identity vowifi.SIMIdentity) bool { + mcc := strings.TrimSpace(identity.HomeMCC) + mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0") + return mcc+mnc == "2623" +} + func (session *Session) abort() { session.refreshCancel() _ = session.conn.Close() @@ -635,6 +661,15 @@ func registrationRejectionError(response *sipResponse, phase string) error { } } } + // P-Debug-Info is carrier-generated but can contain subscriber identifiers. + // Surface only a fixed classification for the O2 security-agreement error; + // never copy the raw header into logs or API responses. + for _, value := range response.values("P-Debug-Info") { + if strings.Contains(strings.ToLower(value), "no matched security item") { + message += "; carrier detail: no matched IMS security item" + break + } + } return fmt.Errorf("%w: %s", ErrRegistrationRejected, message) } @@ -775,6 +810,16 @@ func (session *Session) buildRegister( session.instanceID, "urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel", ) + o2Germany := usesO2GermanyIMSProfile(session.request.Identity) + supported := "path, gruu" + allow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS" + if o2Germany { + // Match the complete IMS capability set used by the previously working + // VoHive client. O2 validates more of the initial UE security profile + // than the other tested carriers do. + supported = "path, gruu, outbound, sec-agree, 100rel, timer" + allow = "INVITE, ACK, CANCEL, BYE, PRACK, UPDATE, INFO, MESSAGE, OPTIONS" + } lines := []string{ "REGISTER " + requestURI + " SIP/2.0", fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", transportUpper, local, branch), @@ -786,13 +831,15 @@ func (session *Session) buildRegister( fmt.Sprintf("CSeq: %d REGISTER", cseq), "Contact: " + contact, fmt.Sprintf("Expires: %d", expires), - "Supported: path, gruu", - "Allow: REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS", + "Supported: " + supported, + "Allow: " + allow, "User-Agent: " + session.provider.config.UserAgent, } + if o2Germany { + lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">") + } if session.securityOffered() { - lines = append( - lines, + lines = append(lines, "Security-Client: "+session.securityProposal.headerValue(), "Require: sec-agree", "Proxy-Require: sec-agree", @@ -1194,7 +1241,14 @@ func (session *Session) Close(ctx context.Context) error { session.closeInboundConnections() session.receiveDone.Wait() if session.ipsecHandle != nil { - if err := session.ipsecHandle.Close(ctx); err != nil { + // XFRM teardown is local and must still run when SIP deregistration has + // consumed the caller's deadline. Use a fresh bounded context so a + // service restart or Profile switch cannot strand the previous SIM's + // transport-mode policies in the kernel. + cleanupContext, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second) + err := session.ipsecHandle.Close(cleanupContext) + cleanupCancel() + if err != nil { cleanupErrors = append(cleanupErrors, err) } } diff --git a/internal/vowifi/ims/provider_test.go b/internal/vowifi/ims/provider_test.go index 5cb3dc2..ed1fda2 100644 --- a/internal/vowifi/ims/provider_test.go +++ b/internal/vowifi/ims/provider_test.go @@ -379,6 +379,69 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err return nil } +func TestO2GermanyInitialRegisterMatchesSupportedIMSProfile(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + identity := vowifi.SIMIdentity{ + IMSI: "262030123456789", + HomeMCC: "262", + HomeMNC: "03", + } + identities, err := deriveIdentities(identity, Config{}) + if err != nil { + t.Fatalf("deriveIdentities() error = %v", err) + } + session := &Session{ + provider: &Provider{config: Config{ + SecurityMode: SecurityRequired, + UserAgent: "vocat-test", + }}, + request: vowifi.IMSRequest{Identity: identity}, + identity: identities, + endpoint: pcscfEndpoint{host: "pcscf.example", port: 5060}, + transport: "tcp", + conn: client, + callID: "o2-test", + fromTag: "tag", + instanceID: "urn:uuid:test", + securityProposal: securityProposal{ + spiClient: 101, + spiServer: 102, + portClient: 5062, + portServer: 5063, + encryption: "null", + }, + } + + packet, err := session.buildRegister(1, 3600, "", "") + if err != nil { + t.Fatalf("buildRegister() error = %v", err) + } + _, headers, err := parseTestRequest(packet) + if err != nil { + t.Fatalf("parseTestRequest() error = %v", err) + } + if got, want := headers["security-client"], "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null;spi-c=0000000101;spi-s=0000000102;port-c=5062;port-s=5063"; got != want { + t.Fatalf("Security-Client = %q, want %q", got, want) + } + if headers["proxy-require"] != "sec-agree" || !strings.Contains(headers["authorization"], "integrity-protected=no") { + t.Fatalf("initial O2 headers omitted standardized sec-agree/IMS-AKA fields: %#v", headers) + } + if got, want := headers["p-preferred-identity"], "<"+identities.public+">"; got != want { + t.Fatalf("P-Preferred-Identity = %q, want %q", got, want) + } + for name, token := range map[string]string{ + "supported": "sec-agree", + "allow": "MESSAGE", + } { + if !strings.Contains(headers[name], token) { + t.Fatalf("%s = %q, want token %q", name, headers[name], token) + } + } +} + func serveRefreshFailure(listener *net.UDPConn, nonce string) error { var callID string for step := 0; step < 3; step++ { diff --git a/internal/vowifi/ims/security.go b/internal/vowifi/ims/security.go index 600c1fc..563acc4 100644 --- a/internal/vowifi/ims/security.go +++ b/internal/vowifi/ims/security.go @@ -39,6 +39,12 @@ var ( type IPSecSAConfig struct { LocalIP net.IP RemoteIP net.IP + // IntegrityAlgorithm is the negotiated 3GPP Security-Server alg value. + // Supported values are hmac-md5-96 and hmac-sha-1-96. + IntegrityAlgorithm string + // EncryptionAlgorithm is the negotiated 3GPP Security-Server ealg value. + // Supported values are null, des-ede3-cbc, and aes-cbc. + EncryptionAlgorithm string UEClientSPI uint32 UEServerSPI uint32 @@ -63,10 +69,14 @@ type IPSecSAInstaller interface { } type securityProposal struct { - spiClient uint32 - spiServer uint32 - portClient int - portServer int + spiClient uint32 + spiServer uint32 + portClient int + portServer int + encryption string + fallbackEncryption string + integrityAlgorithms []string + encryptionAlgorithmsList []string } func newSecurityProposal(localIP net.IP, configuredClientPort int, configuredServerPort int) (securityProposal, error) { @@ -96,21 +106,85 @@ func newSecurityProposal(localIP net.IP, configuredClientPort int, configuredSer return securityProposal{}, errors.New("ims: protected UE ports must be distinct non-standard SIP ports") } return securityProposal{ - spiClient: spiClient, - spiServer: spiServer, - portClient: portClient, - portServer: portServer, + spiClient: spiClient, + spiServer: spiServer, + portClient: portClient, + portServer: portServer, + integrityAlgorithms: []string{"hmac-md5-96", "hmac-sha-1-96"}, + encryptionAlgorithmsList: []string{"null", "des-ede3-cbc", "aes-cbc"}, }, nil } func (proposal securityProposal) headerValue() string { - return fmt.Sprintf( - "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=aes-cbc;spi-c=%010d;spi-s=%010d;port-c=%d;port-s=%d", - proposal.spiClient, - proposal.spiServer, - proposal.portClient, - proposal.portServer, - ) + // TS 33.203 defines spi-c and spi-s as exactly 10 decimal digits. Keep the + // complete mechanism explicit even where ESP/null defaults would permit a + // shorter form; this is the interoperable handset/IMS profile. + values := make([]string, 0, len(proposal.integrities())*len(proposal.encryptionAlgorithms())) + index := 0 + for _, integrity := range proposal.integrities() { + for _, encryption := range proposal.encryptionAlgorithms() { + preference := fmt.Sprintf("0.%03d", 999-index) + if index == 0 { + preference = "1.000" + } + values = append(values, fmt.Sprintf( + "ipsec-3gpp;q=%s;alg=%s;prot=esp;mod=trans;ealg=%s;spi-c=%010d;spi-s=%010d;port-c=%d;port-s=%d", + preference, + integrity, + encryption, + proposal.spiClient, + proposal.spiServer, + proposal.portClient, + proposal.portServer, + )) + index++ + } + } + return strings.Join(values, ", ") +} + +func (proposal securityProposal) encryptionAlgorithm() string { + if strings.EqualFold(strings.TrimSpace(proposal.encryption), "null") { + return "null" + } + return "aes-cbc" +} + +func (proposal securityProposal) encryptionAlgorithms() []string { + if len(proposal.encryptionAlgorithmsList) > 0 { + return append([]string(nil), proposal.encryptionAlgorithmsList...) + } + algorithms := []string{proposal.encryptionAlgorithm()} + fallback := strings.ToLower(strings.TrimSpace(proposal.fallbackEncryption)) + if (fallback == "aes-cbc" || fallback == "null") && fallback != algorithms[0] { + algorithms = append(algorithms, fallback) + } + return algorithms +} + +func (proposal securityProposal) integrities() []string { + if len(proposal.integrityAlgorithms) > 0 { + return append([]string(nil), proposal.integrityAlgorithms...) + } + return []string{"hmac-sha-1-96"} +} + +func (proposal securityProposal) supportsIntegrity(integrity string) bool { + for _, offered := range proposal.integrities() { + if strings.EqualFold(integrity, offered) { + return true + } + } + return false +} + +func (proposal securityProposal) supportsEncryption(encryption string) bool { + for _, offered := range proposal.encryptionAlgorithms() { + if strings.EqualFold(encryption, offered) { + return true + } + } + return false } func randomSPI(exclude uint32) (uint32, error) { @@ -198,10 +272,10 @@ func parseSecurityAgreement(values []string, proposal securityProposal) (securit continue } if !strings.EqualFold(mechanism.name, "ipsec-3gpp") || - !strings.EqualFold(mechanism.algorithm, "hmac-sha-1-96") || + !proposal.supportsIntegrity(mechanism.algorithm) || !strings.EqualFold(mechanism.protocol, "esp") || !strings.EqualFold(mechanism.mode, "trans") || - !strings.EqualFold(mechanism.encryption, "aes-cbc") { + !proposal.supportsEncryption(mechanism.encryption) { continue } if mechanism.spiClient == 0 || mechanism.spiServer == 0 || @@ -349,16 +423,48 @@ func preferenceValue(value string) (int, error) { return numeric, nil } -func expandIPSecKeys(ck []byte, ik []byte) (encryption []byte, integrity []byte, err error) { +func expandIPSecKeys(ck []byte, ik []byte, encryptionAlgorithm, integrityAlgorithm string) (encryption []byte, integrity []byte, err error) { if len(ck) != 16 || len(ik) != 16 { return nil, nil, errors.New("ims: AKA did not return 16-byte CK and IK") } - encryption = append([]byte(nil), ck...) - integrity = make([]byte, 20) - copy(integrity, ik) + switch strings.ToLower(strings.TrimSpace(encryptionAlgorithm)) { + case "null": + encryption = nil + case "aes-cbc", "": + encryption = append([]byte(nil), ck...) + case "des-ede3-cbc": + encryption = append(encryption, ck...) + encryption = append(encryption, ck[:8]...) + for index, value := range encryption { + encryption[index] = withOddDESParity(value) + } + default: + return nil, nil, errors.New("ims: unsupported ipsec-3gpp encryption algorithm") + } + switch strings.ToLower(strings.TrimSpace(integrityAlgorithm)) { + case "hmac-md5-96": + integrity = append([]byte(nil), ik...) + case "hmac-sha-1-96", "": + integrity = make([]byte, 20) + copy(integrity, ik) + default: + return nil, nil, errors.New("ims: unsupported ipsec-3gpp integrity algorithm") + } return encryption, integrity, nil } +func withOddDESParity(value byte) byte { + value &^= 1 + ones := 0 + for bits := value; bits != 0; bits >>= 1 { + ones += int(bits & 1) + } + if ones%2 == 0 { + value |= 1 + } + return value +} + type xfrmOperation struct { description string arguments []string @@ -382,20 +488,31 @@ func buildXFRMInstallPlan(config IPSecSAConfig) ([]xfrmOperation, error) { {"outbound UE-server to P-CSCF-client state", config.LocalIP, config.RemoteIP, config.PCSCFClientSPI, serverPairReqID(config)}, } for _, state := range states { + arguments := []string{ + "xfrm", "state", "add", + "src", state.source.String(), + "dst", state.destination.String(), + "proto", "esp", + "spi", fmt.Sprintf("0x%08x", state.spi), + "reqid", strconv.FormatUint(uint64(state.reqid), 10), + "mode", "transport", + "replay-window", "32", + "auth-trunc", xfrmIntegrityAlgorithm(config), "0x" + hex.EncodeToString(config.IntegrityKey), "96", + } + switch ipsecEncryptionAlgorithm(config) { + case "aes-cbc": + arguments = append(arguments, "enc", "cbc(aes)", "0x"+hex.EncodeToString(config.EncryptionKey)) + case "des-ede3-cbc": + arguments = append(arguments, "enc", "cbc(des3_ede)", "0x"+hex.EncodeToString(config.EncryptionKey)) + default: + // Linux requires an explicit encryption transform for ESP even when + // 3GPP negotiates ealg=null. iproute2 must receive a genuinely empty + // key argument; the textual value "0x" is rejected by XFRM as EINVAL. + arguments = append(arguments, "enc", "cipher_null", "") + } operations = append(operations, xfrmOperation{ description: state.description, - arguments: []string{ - "xfrm", "state", "add", - "src", state.source.String(), - "dst", state.destination.String(), - "proto", "esp", - "spi", fmt.Sprintf("0x%08x", state.spi), - "reqid", strconv.FormatUint(uint64(state.reqid), 10), - "mode", "transport", - "replay-window", "32", - "auth-trunc", "hmac(sha1)", "0x" + hex.EncodeToString(config.IntegrityKey), "96", - "enc", "cbc(aes)", "0x" + hex.EncodeToString(config.EncryptionKey), - }, + arguments: arguments, }) } for _, flow := range xfrmFlows(config) { @@ -588,12 +705,43 @@ func validateIPSecSAConfig(config IPSecSAConfig) error { config.PCSCFClientPort == config.PCSCFServerPort { return errors.New("ims: client and server protected ports must differ") } - if len(config.EncryptionKey) != 16 || len(config.IntegrityKey) != 20 { + if ipsecEncryptionAlgorithm(config) != "null" && ipsecEncryptionAlgorithm(config) != "aes-cbc" && ipsecEncryptionAlgorithm(config) != "des-ede3-cbc" { + return errors.New("ims: unsupported ipsec-3gpp encryption algorithm") + } + if ipsecIntegrityAlgorithm(config) != "hmac-md5-96" && ipsecIntegrityAlgorithm(config) != "hmac-sha-1-96" { + return errors.New("ims: unsupported ipsec-3gpp integrity algorithm") + } + wantEncryptionKey := map[string]int{"null": 0, "aes-cbc": 16, "des-ede3-cbc": 24}[ipsecEncryptionAlgorithm(config)] + wantIntegrityKey := map[string]int{"hmac-md5-96": 16, "hmac-sha-1-96": 20}[ipsecIntegrityAlgorithm(config)] + if len(config.EncryptionKey) != wantEncryptionKey || len(config.IntegrityKey) != wantIntegrityKey { return errors.New("ims: ipsec-3gpp key length is invalid") } return nil } +func ipsecIntegrityAlgorithm(config IPSecSAConfig) string { + algorithm := strings.ToLower(strings.TrimSpace(config.IntegrityAlgorithm)) + if algorithm == "" { + return "hmac-sha-1-96" + } + return algorithm +} + +func xfrmIntegrityAlgorithm(config IPSecSAConfig) string { + if ipsecIntegrityAlgorithm(config) == "hmac-md5-96" { + return "hmac(md5)" + } + return "hmac(sha1)" +} + +func ipsecEncryptionAlgorithm(config IPSecSAConfig) string { + algorithm := strings.ToLower(strings.TrimSpace(config.EncryptionAlgorithm)) + if algorithm == "" { + return "aes-cbc" + } + return algorithm +} + func cloneIPSecSAConfig(config IPSecSAConfig) IPSecSAConfig { config.LocalIP = append(net.IP(nil), config.LocalIP...) config.RemoteIP = append(net.IP(nil), config.RemoteIP...) @@ -657,7 +805,7 @@ func (session *Session) activateIPSec( if !session.securityOffered() { return ErrIPSecAgreementRequired } - encryptionKey, integrityKey, err := expandIPSecKeys(ck, ik) + encryptionKey, integrityKey, err := expandIPSecKeys(ck, ik, agreement.selected.encryption, agreement.selected.algorithm) if err != nil { return err } @@ -671,18 +819,20 @@ func (session *Session) activateIPSec( } selected := agreement.selected config := IPSecSAConfig{ - LocalIP: localIP, - RemoteIP: remoteIP, - UEClientSPI: session.securityProposal.spiClient, - UEServerSPI: session.securityProposal.spiServer, - PCSCFClientSPI: selected.spiClient, - PCSCFServerSPI: selected.spiServer, - UEClientPort: session.securityProposal.portClient, - UEServerPort: session.securityProposal.portServer, - PCSCFClientPort: selected.portClient, - PCSCFServerPort: selected.portServer, - EncryptionKey: encryptionKey, - IntegrityKey: integrityKey, + LocalIP: localIP, + RemoteIP: remoteIP, + IntegrityAlgorithm: selected.algorithm, + EncryptionAlgorithm: selected.encryption, + UEClientSPI: session.securityProposal.spiClient, + UEServerSPI: session.securityProposal.spiServer, + PCSCFClientSPI: selected.spiClient, + PCSCFServerSPI: selected.spiServer, + UEClientPort: session.securityProposal.portClient, + UEServerPort: session.securityProposal.portServer, + PCSCFClientPort: selected.portClient, + PCSCFServerPort: selected.portServer, + EncryptionKey: encryptionKey, + IntegrityKey: integrityKey, } handle, err := session.provider.installer.Install(ctx, config) if err != nil { diff --git a/internal/vowifi/ims/security_provider_test.go b/internal/vowifi/ims/security_provider_test.go index 72cade1..92c84fe 100644 --- a/internal/vowifi/ims/security_provider_test.go +++ b/internal/vowifi/ims/security_provider_test.go @@ -23,8 +23,9 @@ type fakeIPSecInstaller struct { } type fakeIPSecHandle struct { - mu sync.Mutex - closeCount int + mu sync.Mutex + closeCount int + closeContextErr error } func (installer *fakeIPSecInstaller) Install( @@ -53,10 +54,11 @@ func (installer *fakeIPSecInstaller) installed() []IPSecSAConfig { return result } -func (handle *fakeIPSecHandle) Close(context.Context) error { +func (handle *fakeIPSecHandle) Close(ctx context.Context) error { handle.mu.Lock() defer handle.mu.Unlock() handle.closeCount++ + handle.closeContextErr = ctx.Err() return nil } @@ -66,6 +68,38 @@ func (handle *fakeIPSecHandle) closes() int { return handle.closeCount } +func (handle *fakeIPSecHandle) contextError() error { + handle.mu.Lock() + defer handle.mu.Unlock() + return handle.closeContextErr +} + +func TestSessionCloseCleansIPSecAfterCallerDeadline(t *testing.T) { + client, server := net.Pipe() + defer server.Close() + refreshDone := make(chan struct{}) + close(refreshDone) + handle := &fakeIPSecHandle{} + session := &Session{ + conn: client, + refreshCancel: func() {}, + refreshDone: refreshDone, + ipsecHandle: handle, + calls: make(map[string]*imsCall), + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := session.Close(ctx); err != nil { + t.Fatalf("Close() error = %v", err) + } + if handle.closes() != 1 { + t.Fatalf("IPsec close count = %d, want 1", handle.closes()) + } + if err := handle.contextError(); err != nil { + t.Fatalf("IPsec cleanup inherited expired caller context: %v", err) + } +} + func TestProviderNegotiatesIPSecAndRegistersOverProtectedTCP(t *testing.T) { localIP := net.ParseIP("127.0.0.1") remoteIP := net.ParseIP("127.0.0.2") @@ -386,7 +420,12 @@ func serveProtectedRegistrar( return result, fmt.Errorf("initial sec-agree headers = %#v", headers) } result.securityClient = headers["security-client"] - proposal, err := parseSecurityMechanism(result.securityClient) + offers := splitHeaderValues([]string{result.securityClient}) + if len(offers) != 6 { + _ = initialConnection.Close() + return result, fmt.Errorf("initial Security-Client offers = %d, want 6", len(offers)) + } + proposal, err := parseSecurityMechanism(offers[0]) if err != nil { _ = initialConnection.Close() return result, fmt.Errorf("parse initial Security-Client: %w", err) diff --git a/internal/vowifi/ims/security_test.go b/internal/vowifi/ims/security_test.go index cc99109..6343df7 100644 --- a/internal/vowifi/ims/security_test.go +++ b/internal/vowifi/ims/security_test.go @@ -6,6 +6,8 @@ import ( "reflect" "strings" "testing" + + "vocat/internal/vowifi" ) func TestParseSecurityAgreementSelectsSupportedIPSec(t *testing.T) { @@ -36,6 +38,51 @@ func TestParseSecurityAgreementSelectsSupportedIPSec(t *testing.T) { } } +func TestO2GermanySecurityProposalUsesIntegrityOnlyESP(t *testing.T) { + identity := vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "03"} + if got := securityEncryptionForIdentity(identity); got != "null" { + t.Fatalf("O2 security encryption = %q, want null", got) + } + proposal := securityProposal{ + spiClient: 1001, spiServer: 1002, + portClient: 40666, portServer: 55610, + encryption: securityEncryptionForIdentity(identity), + } + if got, want := proposal.headerValue(), "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null;spi-c=0000001001;spi-s=0000001002;port-c=40666;port-s=55610"; got != want { + t.Fatalf("O2 Security-Client = %q, want %q", got, want) + } + selected := "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;" + + "ealg=null;spi-c=2001;spi-s=2002;port-c=50601;port-s=50600" + if _, err := parseSecurityAgreement([]string{selected}, proposal); err != nil { + t.Fatalf("O2 null Security-Server rejected: %v", err) + } + + identity.HomeMNC = "02" + if got := securityEncryptionForIdentity(identity); got != "aes-cbc" { + t.Fatalf("non-O2 security encryption = %q, want aes-cbc", got) + } +} + +func TestSecurityAgreementAcceptsO2FallbackEncryption(t *testing.T) { + proposal := securityProposal{ + spiClient: 1001, + spiServer: 1002, + portClient: 5062, + portServer: 5063, + encryption: "null", + fallbackEncryption: "aes-cbc", + } + value := "ipsec-3gpp;q=0.5;alg=hmac-sha-1-96;prot=esp;mod=trans;" + + "ealg=aes-cbc;spi-c=2001;spi-s=2002;port-c=50601;port-s=50600" + agreement, err := parseSecurityAgreement([]string{value}, proposal) + if err != nil { + t.Fatalf("parseSecurityAgreement(aes-cbc fallback) error = %v", err) + } + if got := agreement.selected.encryption; got != "aes-cbc" { + t.Fatalf("selected encryption = %q, want aes-cbc", got) + } +} + func TestParseSecurityAgreementSkipsIncompleteCarrierAlternatives(t *testing.T) { proposal := securityProposal{ spiClient: 1001, @@ -121,7 +168,7 @@ func TestParseSecurityAgreementFailsClosed(t *testing.T) { func TestExpandIPSecKeys(t *testing.T) { ck := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} ik := []byte{16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31} - encryption, integrity, err := expandIPSecKeys(ck, ik) + encryption, integrity, err := expandIPSecKeys(ck, ik, "aes-cbc", "hmac-sha-1-96") if err != nil { t.Fatalf("expandIPSecKeys() error = %v", err) } @@ -139,6 +186,71 @@ func TestExpandIPSecKeys(t *testing.T) { } } +func TestExpandIPSecKeysForAndroidAlgorithmSet(t *testing.T) { + ck := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18} + ik := []byte{0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38} + tripleDES, md5Key, err := expandIPSecKeys(ck, ik, "des-ede3-cbc", "hmac-md5-96") + if err != nil { + t.Fatal(err) + } + if len(tripleDES) != 24 || len(md5Key) != 16 { + t.Fatalf("3DES/MD5 key lengths = %d/%d", len(tripleDES), len(md5Key)) + } + for _, value := range tripleDES { + ones := 0 + for bits := value; bits != 0; bits >>= 1 { + ones += int(bits & 1) + } + if ones%2 != 1 { + t.Fatalf("3DES byte %02x does not have odd parity", value) + } + } + nullKey, sha1Key, err := expandIPSecKeys(ck, ik, "null", "hmac-sha-1-96") + if err != nil || len(nullKey) != 0 || len(sha1Key) != 20 { + t.Fatalf("null/SHA1 keys = %d/%d, %v", len(nullKey), len(sha1Key), err) + } +} + +func TestAndroidIMSProposalOffersAllDefaultAlgorithms(t *testing.T) { + proposal := securityProposal{ + spiClient: 1001, spiServer: 1002, portClient: 5062, portServer: 5063, + integrityAlgorithms: []string{"hmac-md5-96", "hmac-sha-1-96"}, + encryptionAlgorithmsList: []string{"null", "des-ede3-cbc", "aes-cbc"}, + } + header := proposal.headerValue() + for _, combination := range []string{ + "alg=hmac-md5-96;prot=esp;mod=trans;ealg=null", + "alg=hmac-md5-96;prot=esp;mod=trans;ealg=des-ede3-cbc", + "alg=hmac-md5-96;prot=esp;mod=trans;ealg=aes-cbc", + "alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null", + "alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=des-ede3-cbc", + "alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=aes-cbc", + } { + if !strings.Contains(header, combination) { + t.Fatalf("Android IMS Security-Client omitted %q: %s", combination, header) + } + } + if got := len(splitHeaderValues([]string{header})); got != 6 { + t.Fatalf("Security-Client mechanism count = %d, want 6", got) + } +} + +func TestNewSecurityProposalUsesAndroidDefaultsForEveryCarrier(t *testing.T) { + proposal, err := newSecurityProposal(net.ParseIP("127.0.0.1"), 45062, 45063) + if err != nil { + t.Fatalf("newSecurityProposal() error = %v", err) + } + if got, want := proposal.integrities(), []string{"hmac-md5-96", "hmac-sha-1-96"}; !reflect.DeepEqual(got, want) { + t.Fatalf("integrity algorithms = %v, want %v", got, want) + } + if got, want := proposal.encryptionAlgorithms(), []string{"null", "des-ede3-cbc", "aes-cbc"}; !reflect.DeepEqual(got, want) { + t.Fatalf("encryption algorithms = %v, want %v", got, want) + } + if got := len(splitHeaderValues([]string{proposal.headerValue()})); got != 6 { + t.Fatalf("Security-Client mechanism count = %d, want 6", got) + } +} + func TestXFRMPlanContainsFourStatesAndProtocolSpecificPolicies(t *testing.T) { config := testIPSecSAConfig() install, err := buildXFRMInstallPlan(config) @@ -209,6 +321,46 @@ func TestXFRMPlanContainsFourStatesAndProtocolSpecificPolicies(t *testing.T) { } } +func TestXFRMPlanSupportsIntegrityOnlyESP(t *testing.T) { + config := testIPSecSAConfig() + config.EncryptionAlgorithm = "null" + config.EncryptionKey = nil + install, err := buildXFRMInstallPlan(config) + if err != nil { + t.Fatalf("buildXFRMInstallPlan(null) error = %v", err) + } + for index, operation := range install[:4] { + joined := strings.Join(operation.arguments, " ") + if !strings.Contains(joined, "auth-trunc hmac(sha1)") { + t.Fatalf("null state %d omitted integrity: %v", index, operation.arguments) + } + if !containsArguments(operation.arguments, "enc", "cipher_null", "") { + t.Fatalf("null state %d omitted Linux NULL cipher: %v", index, operation.arguments) + } + } +} + +func TestXFRMPlanSupportsAndroidMD5AndTripleDES(t *testing.T) { + config := testIPSecSAConfig() + config.IntegrityAlgorithm = "hmac-md5-96" + config.EncryptionAlgorithm = "des-ede3-cbc" + config.IntegrityKey = []byte(strings.Repeat("\x22", 16)) + config.EncryptionKey = []byte(strings.Repeat("\x11", 24)) + + install, err := buildXFRMInstallPlan(config) + if err != nil { + t.Fatalf("buildXFRMInstallPlan() error = %v", err) + } + for index, operation := range install[:4] { + if !containsArguments(operation.arguments, "auth-trunc", "hmac(md5)", "0x"+strings.Repeat("22", 16), "96") { + t.Fatalf("state %d omitted HMAC-MD5-96 transform: %v", index, operation.arguments) + } + if !containsArguments(operation.arguments, "enc", "cbc(des3_ede)", "0x"+strings.Repeat("11", 24)) { + t.Fatalf("state %d omitted 3DES-CBC transform: %v", index, operation.arguments) + } + } +} + func TestValidateIPSecSAConfigRejectsDuplicateSPI(t *testing.T) { config := testIPSecSAConfig() config.PCSCFServerSPI = config.UEClientSPI