15 Commits
Author SHA1 Message Date
MengMengCode dfc6afb839 Merge remote-tracking branch 'origin/master' into feat/admin-credentials-pr-limit
# Conflicts:
#	cmd/vocat/menu.go
#	internal/auth/service.go
2026-08-14 21:43:57 +08:00
MengMengCode 09c0ffc88b feat: update password requirements to a minimum of 6 characters for admin credentials 2026-08-14 21:24:08 +08:00
MengMengCode 636d4e8a69 feat: add XeSIM CTE and RedPocket VoWiFi compatibility 2026-08-14 21:15:34 +08:00
Meng MengandGitHub edbd0ef4d1 fix: prevent schema-incompatible installer downgrades (#18)
Add schema 17–19 compatibility migrations
2026-08-14 20:59:45 +08:00
Meng MengandGitHub 812bfe7cdc feat: reset admin credentials without requiring current password and update related prompts (#17) 2026-08-14 20:38:15 +08:00
MengMengCode 2d8552b670 feat: reset admin credentials without requiring current password and update related prompts 2026-08-14 20:34:30 +08:00
Meng MengandGitHub dbc6db4f08 Merge pull request #16 from CwithW/fix-dockerfile-missing-iproute2
fix: install iproute2 in runtime image
2026-08-14 20:00:40 +08:00
Chara White 57539df603 fix: install iproute2 in runtime image 2026-08-14 19:50:01 +08:00
MengMengCode ecce22eacf feat: add notification destination context for Telegram polling and implement related test 2026-08-14 19:09:31 +08:00
MengMengCode d37a191011 feat: add home carrier information to ModemSummary interface 2026-08-14 00:22:39 +08:00
MengMengCode ba28c7f79a Rollback 2026-08-13 23:38:45 +08:00
MengMengCode 773b44aad6 Implement 3GPP Device Identity Handling in IKE
- Added support for DEVICE_IDENTITY request as per TS 24.302.
- Implemented deviceIdentityRequested function to check for DEVICE_IDENTITY notifications.
- Created deviceIdentityNotify function to construct DEVICE_IDENTITY notifications with IMEI/IMEISV.
- Enhanced security proposal to accommodate O2 Germany's integrity-only ESP profile.
- Updated tests to validate DEVICE_IDENTITY encoding and request handling.
- Refactored security proposal to include fallback encryption and integrity algorithms.
- Improved session management to ensure proper cleanup of IPSec resources after caller deadline.
- Added comprehensive tests for security agreement parsing and algorithm support.

FIX #11
2026-08-13 23:30:00 +08:00
MengMengCode 2917378da0 fix: support O2 Germany certificate-authenticated EAP 2026-08-13 21:28:38 +08:00
MengMengCode 69b1c3eec6 fix: stabilize EM7430 hotplug support 2026-08-13 20:58:24 +08:00
MengMengCode 01e25ff12c feat: support Sierra EM7430 MBIM modems 2026-08-13 20:05:07 +08:00
68 changed files with 7950 additions and 308 deletions
+80
View File
@@ -0,0 +1,80 @@
name: Pull request size limit
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
contents: read
pull-requests: write
concurrency:
group: pr-size-limit-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
enforce-size-limit:
name: Enforce 5,000-line limit
runs-on: ubuntu-latest
timeout-minutes: 2
env:
MAX_CHANGED_LINES: "5000"
PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Reject oversized pull request
shell: bash
run: |
set -euo pipefail
api_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}"
response="$({
curl --fail-with-body --silent --show-error \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer ${GH_TOKEN}" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"${api_url}"
})"
additions="$(jq -r '.additions' <<<"${response}")"
deletions="$(jq -r '.deletions' <<<"${response}")"
if [[ ! "${additions}" =~ ^[0-9]+$ || ! "${deletions}" =~ ^[0-9]+$ ]]; then
echo "Unable to read pull request line statistics." >&2
exit 1
fi
changed_lines=$((additions + deletions))
{
echo "### Pull request size"
echo
echo "- Additions: ${additions}"
echo "- Deletions: ${deletions}"
echo "- Total changed lines: ${changed_lines}"
echo "- Limit: ${MAX_CHANGED_LINES}"
} >>"${GITHUB_STEP_SUMMARY}"
if (( changed_lines <= MAX_CHANGED_LINES )); then
echo "Pull request is within the ${MAX_CHANGED_LINES}-line limit."
exit 0
fi
curl --fail-with-body --silent --show-error \
--request PATCH \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer ${GH_TOKEN}" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"${api_url}" \
--data '{"state":"closed"}' >/dev/null
message="This pull request changes ${changed_lines} lines (${additions} additions + ${deletions} deletions), exceeding the repository limit of ${MAX_CHANGED_LINES} changed lines. It has been closed automatically. Please split the changes into smaller pull requests."
comment_payload="$(jq -nc --arg body "${message}" '{body: $body}')"
curl --fail-with-body --silent --show-error \
--request POST \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer ${GH_TOKEN}" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--data "${comment_payload}" >/dev/null
echo "::error::Pull request changes ${changed_lines} lines; the maximum is ${MAX_CHANGED_LINES}."
exit 1
+1
View File
@@ -10,6 +10,7 @@
*.dll
*.so
*.dylib
/fix
# ---- Cookie / secret files (NEVER commit) ----
vc.jar
+1 -1
View File
@@ -36,7 +36,7 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
# ---- Stage 3: minimal runtime ----
FROM alpine:3.20
RUN apk add --no-cache ca-certificates ccid pcsc-lite tzdata && \
RUN apk add --no-cache ca-certificates ccid iproute2 pcsc-lite tzdata && \
addgroup -S -g 1000 vocat && \
adduser -S -D -H -u 1000 -G vocat vocat
+2 -2
View File
@@ -29,8 +29,8 @@ func runBootstrapAdmin(args []string) error {
return fmt.Errorf("read password: %w", err)
}
password = strings.TrimSuffix(strings.TrimSuffix(password, "\n"), "\r")
if len(password) < 12 || len(password) > 1024 {
return errors.New("bootstrap password must contain between 12 and 1024 characters")
if len(password) < 6 || len(password) > 1024 {
return errors.New("bootstrap password must contain between 6 and 1024 characters")
}
adminUsername := strings.TrimSpace(*username)
if len(adminUsername) < 1 || len(adminUsername) > 64 || strings.ContainsAny(adminUsername, "\r\n\t") {
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"os"
"strings"
"testing"
)
func TestInstallerValidatesDatabaseBeforeReplacingBinary(t *testing.T) {
scriptBytes, err := os.ReadFile("../../scripts/install.sh")
if err != nil {
t.Fatal(err)
}
script := string(scriptBytes)
mainStart := strings.LastIndex(script, "# --- Main ")
if mainStart < 0 {
t.Fatal("installer main section not found")
}
main := script[mainStart:]
validateAt := strings.Index(main, `bootstrap_admin "${VOCAT_TMP}/vocat"`)
installAt := strings.Index(main, "install_binary")
if validateAt < 0 {
t.Fatal("installer does not validate the database with the downloaded binary")
}
if installAt < 0 {
t.Fatal("installer does not install the downloaded binary")
}
if validateAt > installAt {
t.Fatal("installer replaces the current binary before validating database compatibility")
}
}
+21 -24
View File
@@ -88,7 +88,7 @@ func menuEnvFilePath() string {
return envFilePath
}
// runMenu is the interactive lifecycle menu: toggle language, change password,
// runMenu is the interactive lifecycle menu: toggle language, reset credentials,
// change the Web listener port, restart the systemd unit, self-update, or fully
// uninstall vocat. It must run as root on the host (needs systemctl + the 0600
// env file). Docker deployments do not use it.
@@ -128,7 +128,7 @@ func runMenu(logger *slog.Logger) error {
fmt.Println(menu.errorPrefix(err))
}
case "2":
if err := menuChangePassword(reader, menu); err != nil {
if err := menuResetAdminCredentials(reader, menu); err != nil {
fmt.Println(menu.errorPrefix(err))
}
case "3":
@@ -193,7 +193,7 @@ func loadMenuLanguage() (string, error) {
return "en", nil
}
func menuChangePassword(reader *bufio.Reader, m *menu) error {
func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("%w: %v", errMenuConfig, err)
@@ -216,10 +216,14 @@ func menuChangePassword(reader *bufio.Reader, m *menu) error {
return fmt.Errorf("%w: %v", errMenuStore, err)
}
fmt.Print(m.currentPassword())
currentPw, err := readPasswordMasked()
fmt.Print(m.newUsername(admin.Username))
username, err := reader.ReadString('\n')
if err != nil {
return err
return fmt.Errorf("read administrator username: %w", err)
}
username = strings.TrimSpace(username)
if username == "" {
username = admin.Username
}
fmt.Print(m.newPassword())
newPw, err := readPasswordMasked()
@@ -235,10 +239,7 @@ func menuChangePassword(reader *bufio.Reader, m *menu) error {
if newPw != confirmPw {
return errPasswordsDiffer
}
if err := authService.ChangePassword(ctx, admin.Username, currentPw, newPw); err != nil {
if errors.Is(err, auth.ErrInvalidCredentials) {
return errCurrentWrong
}
if err := authService.ResetAdminCredentials(ctx, username, newPw); err != nil {
return fmt.Errorf("%w: %v", errMenuAuth, err)
}
fmt.Println(m.passwordChanged())
@@ -563,7 +564,6 @@ func menuUninstall(reader *bufio.Reader, m *menu) error {
// menu-local sentinel errors so callers can map them to localized messages.
var (
errCurrentWrong = errors.New("menu: current password is incorrect")
errPasswordsDiffer = errors.New("menu: passwords do not match")
errNoSystemctl = errors.New("menu: systemctl not found")
errRestartFailed = errors.New("menu: restart failed")
@@ -588,17 +588,17 @@ func (m *menu) msg(key string) string {
table := map[string][2]string{
"title": {"vocat 管理菜单", "vocat management menu"},
"opt_lang": {"1) 切换中英文", "1) Toggle language"},
"opt_change": {"2) 修改账号密码", "2) Change admin password"},
"opt_change": {"2) 修改账号密码", "2) Change admin credentials"},
"opt_port": {"3) 修改 Web 监听端口", "3) Change Web listening port"},
"opt_restart": {"4) 重启软件", "4) Restart software"},
"opt_update": {"5) 更新软件", "5) Update software"},
"opt_uninstall": {"0) 卸载软件", "0) Uninstall software"},
"prompt": {"请选择: ", "Select: "},
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
"cur_pw": {"当前密码: ", "Current password: "},
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
"new_username": {"新用户名(直接回车保留 %s): ", "New username (Enter to keep %s): "},
"new_pw": {"新密码 (至少 6 位): ", "New password (min 6 chars): "},
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
"pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."},
"pw_changed": {"管理员账号密码已修改,现有 Web 会话已退出。", "Administrator credentials changed; existing Web sessions were signed out."},
"current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"},
"new_web_port": {"新端口 (1-65535,直接回车取消,当前 %s): ", "New port (1-65535, Enter to cancel, current %s): "},
"web_port_cancelled": {"已取消修改端口。", "Web port change cancelled."},
@@ -632,10 +632,12 @@ func (m *menu) msg(key string) string {
return entry[zh]
}
func (m *menu) title() string { return m.msg("title") }
func (m *menu) prompt() string { return m.msg("prompt") }
func (m *menu) invalid() string { return m.msg("invalid") }
func (m *menu) currentPassword() string { return m.msg("cur_pw") }
func (m *menu) title() string { return m.msg("title") }
func (m *menu) prompt() string { return m.msg("prompt") }
func (m *menu) invalid() string { return m.msg("invalid") }
func (m *menu) newUsername(current string) string {
return fmt.Sprintf(m.msg("new_username"), current)
}
func (m *menu) newPassword() string { return m.msg("new_pw") }
func (m *menu) confirmPassword() string { return m.msg("confirm_pw") }
func (m *menu) passwordChanged() string { return m.msg("pw_changed") }
@@ -670,11 +672,6 @@ func (m *menu) options() []string {
func (m *menu) errorPrefix(err error) string {
switch {
case errors.Is(err, errCurrentWrong):
if m.lang == "en" {
return "Current password is incorrect."
}
return "当前密码不正确。"
case errors.Is(err, errPasswordsDiffer):
if m.lang == "en" {
return "Passwords do not match."
+17
View File
@@ -72,3 +72,20 @@ func TestMenuIncludesWebPortOptionInBothLanguages(t *testing.T) {
}
}
}
func TestMenuCredentialResetPromptsDoNotRequestCurrentPassword(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
menu := newMenu(lang)
prompts := strings.Join([]string{
menu.newUsername("admin"),
menu.newPassword(),
menu.confirmPassword(),
}, "\n")
if strings.Contains(strings.ToLower(prompts), "current password") || strings.Contains(prompts, "当前密码") {
t.Fatalf("%s credential reset still requests the current password: %q", lang, prompts)
}
if !strings.Contains(prompts, "admin") {
t.Fatalf("%s username prompt does not show the current username: %q", lang, prompts)
}
}
}
+20 -2
View File
@@ -118,6 +118,24 @@ func (s *Service) EnsureAdminIfMissing(ctx context.Context, username string, pas
return true, nil
}
// ResetAdminCredentials replaces the single administrator without requiring
// the previous credentials. It is intended for trusted local recovery flows
// such as the root-only management CLI. Store.SetAdmin atomically revokes all
// existing sessions when the credentials change.
func (s *Service) ResetAdminCredentials(ctx context.Context, username string, password string) error {
username = strings.TrimSpace(username)
if len(username) < 1 || len(username) > 64 || strings.ContainsAny(username, "\r\n\t") {
return errors.New("administrator username must contain between 1 and 64 characters without control whitespace")
}
if len(password) < 6 || len(password) > 1024 {
return errors.New("administrator password must contain between 6 and 1024 characters")
}
if err := s.EnsureAdmin(ctx, username, password); err != nil {
return fmt.Errorf("auth: reset administrator credentials: %w", err)
}
return nil
}
func (s *Service) Login(ctx context.Context, username string, password string) (Credentials, error) {
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
if errors.Is(err, store.ErrNotFound) {
@@ -266,8 +284,8 @@ func (s *Service) ChangePassword(
currentPassword string,
newPassword string,
) error {
if len(newPassword) < 12 || len(newPassword) > 1024 {
return errors.New("new password must contain between 12 and 1024 characters")
if len(newPassword) < 6 || len(newPassword) > 1024 {
return errors.New("new password must contain between 6 and 1024 characters")
}
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
if errors.Is(err, store.ErrNotFound) {
+41
View File
@@ -97,6 +97,47 @@ func TestEnsureAdminRevokesSessionOnPasswordChange(t *testing.T) {
}
}
func TestResetAdminCredentialsChangesUsernameAndPasswordWithoutOldPassword(t *testing.T) {
ctx := context.Background()
service := newTestService(t)
credentials, err := service.Login(ctx, "admin", "correct-password")
if err != nil {
t.Fatal(err)
}
if err := service.ResetAdminCredentials(ctx, "new-admin", "replacement-password"); err != nil {
t.Fatalf("ResetAdminCredentials() error = %v", err)
}
if _, err := service.Login(ctx, "admin", "correct-password"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("old credentials error = %v, want ErrInvalidCredentials", err)
}
if _, err := service.Login(ctx, "new-admin", "replacement-password"); err != nil {
t.Fatalf("new credentials login error = %v", err)
}
if _, err := service.Authenticate(ctx, credentials.SessionToken); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("old session error = %v, want ErrUnauthorized", err)
}
}
func TestResetAdminCredentialsValidatesInput(t *testing.T) {
service := newTestService(t)
for _, test := range []struct {
name string
username string
password string
}{
{name: "empty username", password: "replacement-password"},
{name: "control whitespace", username: "bad\tname", password: "replacement-password"},
{name: "short password", username: "admin", password: "short"},
} {
t.Run(test.name, func(t *testing.T) {
if err := service.ResetAdminCredentials(context.Background(), test.username, test.password); err == nil {
t.Fatal("ResetAdminCredentials() accepted invalid input")
}
})
}
}
func TestEnsureAdminIfMissingDoesNotOverwriteChangedPassword(t *testing.T) {
ctx := context.Background()
service := newTestService(t)
+153 -1
View File
@@ -14,7 +14,41 @@ import (
var carrierDatabaseJSON []byte
type carrierDatabase struct {
Carriers map[string][]string `json:"c"`
Carriers map[string][]string `json:"c"`
Countries map[string]string `json:"i"`
Rules []carrierRule `json:"r"`
}
type carrierRule struct {
Name string `json:"n"`
PLMNs []string `json:"m"`
IMSIPatterns []string `json:"x"`
SPNs []string `json:"s"`
GID1Prefixes []string `json:"g1"`
GID2Prefixes []string `json:"g2"`
ICCIDPrefixes []string `json:"i"`
}
// CarrierIdentity contains the SIM-issued values used by Android's carrier
// resolver. MNC length comes from EF_AD; GID values come from EF_GID1/2.
type CarrierIdentity struct {
IMSI string
ICCID string
SPN string
GID1 string
GID2 string
MNCLength int
}
// CountryForMCC returns the ISO alpha-2 country/territory code associated with
// a three-digit mobile country code in the embedded Android carrier database.
func CountryForMCC(mcc string) (string, bool) {
mcc = strings.TrimSpace(mcc)
if len(mcc) != 3 {
return "", false
}
country := strings.ToUpper(strings.TrimSpace(globalCarrierDatabase.Countries[mcc]))
return country, len(country) == 2
}
var globalCarrierDatabase = func() carrierDatabase {
@@ -64,3 +98,121 @@ func CarrierForIMSI(imsi string) (plmn, name, countryCode string, ok bool) {
}
return "", "", "", false
}
// CarrierForSIM applies the constrained Android carrier-ID rules before the
// MCC/MNC fallback. This is important for MVNO and travel eSIM profiles where
// several customer-facing carriers authenticate through the same home PLMN.
func CarrierForSIM(identity CarrierIdentity) (plmn, name, countryCode string, ok bool) {
imsi := strings.TrimSpace(identity.IMSI)
if !decimalDigits(imsi, 5, 20) {
return "", "", "", false
}
plmns := carrierPLMNCandidates(imsi, identity.MNCLength)
bestScore := -1
for _, rule := range globalCarrierDatabase.Rules {
matchedPLMN := firstMatchingValue(rule.PLMNs, func(value string) bool {
return containsString(plmns, value)
})
if matchedPLMN == "" {
continue
}
score := 1 << 8
if len(rule.IMSIPatterns) > 0 {
if firstMatchingValue(rule.IMSIPatterns, func(pattern string) bool { return imsiPatternMatch(imsi, pattern) }) == "" {
continue
}
score += 1 << 7
}
if len(rule.ICCIDPrefixes) > 0 {
if firstMatchingValue(rule.ICCIDPrefixes, func(prefix string) bool { return strings.HasPrefix(identity.ICCID, prefix) }) == "" {
continue
}
score += 1 << 6
}
if len(rule.GID1Prefixes) > 0 {
if firstMatchingValue(rule.GID1Prefixes, func(prefix string) bool { return prefixFold(identity.GID1, prefix) }) == "" {
continue
}
score += 1 << 5
}
if len(rule.GID2Prefixes) > 0 {
if firstMatchingValue(rule.GID2Prefixes, func(prefix string) bool { return prefixFold(identity.GID2, prefix) }) == "" {
continue
}
score += 1 << 4
}
if len(rule.SPNs) > 0 {
if !containsFold(rule.SPNs, identity.SPN) {
continue
}
score += 1 << 1
}
if score > bestScore {
bestScore = score
plmn = matchedPLMN
name = strings.TrimSpace(rule.Name)
}
}
if bestScore >= 0 && name != "" {
countryCode, _ = CountryForMCC(plmn[:3])
return plmn, name, countryCode, true
}
return CarrierForIMSI(imsi)
}
func carrierPLMNCandidates(imsi string, mncLength int) []string {
if (mncLength == 2 || mncLength == 3) && len(imsi) >= 3+mncLength {
return []string{imsi[:3+mncLength]}
}
result := make([]string, 0, 2)
for _, length := range []int{6, 5} {
if len(imsi) >= length {
result = append(result, imsi[:length])
}
}
return result
}
func firstMatchingValue(values []string, match func(string) bool) string {
for _, value := range values {
if match(value) {
return value
}
}
return ""
}
func containsString(values []string, wanted string) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}
func containsFold(values []string, wanted string) bool {
for _, value := range values {
if strings.EqualFold(value, wanted) {
return true
}
}
return false
}
func prefixFold(value, prefix string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(value)), strings.ToLower(strings.TrimSpace(prefix)))
}
func imsiPatternMatch(imsi, pattern string) bool {
pattern = strings.TrimSpace(pattern)
if len(imsi) < len(pattern) {
return false
}
for index, value := range pattern {
if value != 'x' && value != 'X' && byte(value) != imsi[index] {
return false
}
}
return true
}
+9 -1
View File
@@ -411,7 +411,14 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
return Snapshot{}, err
}
previousICCID := state.lastICCID
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, client)
var previousSnapshot *Snapshot
manager.mu.RLock()
if state.snapshot != nil {
copy := *state.snapshot
previousSnapshot = &copy
}
manager.mu.RUnlock()
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, previousSnapshot, client)
if err == nil && strings.TrimSpace(snapshot.ICCID) != "" {
state.lastICCID = strings.TrimSpace(snapshot.ICCID)
}
@@ -447,6 +454,7 @@ func (manager *Manager) refreshCardReader(ctx context.Context, id string, state
result.ICCID = card.Identity.ICCID
result.IMSI = card.Identity.IMSI
result.SPN = card.Identity.SPN
result.MNCLength = card.Identity.MNCLength
result.SIMChanged = previousICCID != "" && !strings.EqualFold(previousICCID, result.ICCID)
state.lastICCID = result.ICCID
}
+14 -1
View File
@@ -64,6 +64,12 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
{command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")},
{command: "AT+CIMI", response: okResponse("460001234567890")},
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"00434D4343FFFFFFFFFFFFFFFFFFFFFFFF"`)},
{command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020004FFFF"`)},
{command: "AT+CRSM=176,28589,0,0,4", response: okResponse(`+CRSM: 144,0,"00000002"`)},
{command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020002FFFF"`)},
{command: "AT+CRSM=176,28478,0,0,2", response: okResponse(`+CRSM: 144,0,"0102"`)},
{command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
{command: "AT+CRSM=176,28479,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
{command: "AT+CSQ", response: okResponse("+CSQ: 20,99")},
{
command: `AT+QENG="servingcell"`,
@@ -112,7 +118,8 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
}
if snapshot.IMEI != "867123456789012" ||
snapshot.ICCID != "8986001234567890123" ||
snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" {
snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" ||
snapshot.MNCLength != 2 || snapshot.GID1 != "0102" || snapshot.GID2 != "" {
t.Fatalf("subscriber identifiers = %#v", snapshot)
}
if !snapshot.ModeKnown || snapshot.OperatingMode != 1 ||
@@ -198,6 +205,12 @@ func TestManagerForcesRFOffBeforeInspectingChangedSIMNetwork(t *testing.T) {
{command: "AT+CFUN=4", response: okResponse()},
{command: "AT+CIMI", response: okResponse("234150000000002")},
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)},
{command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020004FFFF"`)},
{command: "AT+CRSM=176,28589,0,0,4", response: okResponse(`+CRSM: 144,0,"00000002"`)},
{command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
{command: "AT+CRSM=176,28478,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
{command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
{command: "AT+CRSM=176,28479,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
{command: "AT+CSQ", response: okResponse("+CSQ: 99,99")},
{command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)},
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
File diff suppressed because one or more lines are too long
+12 -2
View File
@@ -22,6 +22,13 @@ var BlockedMCCs = map[string]string{
// code. The MCC is the leading three digits and the MNC the following two or
// three. Empty strings are returned for an unusable IMSI.
func CardMCCMNC(imsi string) (mcc string, mnc string) {
return CardMCCMNCWithLength(imsi, 0)
}
// CardMCCMNCWithLength uses the MNC length advertised by EF_AD when available.
// Without it the historical three-digit behavior is retained for callers that
// have only an IMSI.
func CardMCCMNCWithLength(imsi string, mncLength int) (mcc string, mnc string) {
digits := strings.TrimSpace(imsi)
if len(digits) < 5 ||
strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
@@ -29,8 +36,11 @@ func CardMCCMNC(imsi string) (mcc string, mnc string) {
}
mcc = digits[:3]
mnc = digits[3:]
if len(mnc) > 3 {
mnc = mnc[:3]
if mncLength != 2 && mncLength != 3 {
mncLength = 3
}
if len(mnc) > mncLength {
mnc = mnc[:mncLength]
}
return mcc, mnc
}
+3
View File
@@ -23,6 +23,9 @@ func TestCardMCCMNC(t *testing.T) {
if mcc, _ := CardMCCMNC("460001234567890"); mcc != "460" {
t.Fatalf("CardMCCMNC mcc = %q, want 460", mcc)
}
if mcc, mnc := CardMCCMNCWithLength("454006395879502", 2); mcc != "454" || mnc != "00" {
t.Fatalf("CardMCCMNCWithLength = (%q, %q), want (454, 00)", mcc, mnc)
}
for _, bad := range []string{"", "4600", "4600X1234"} {
if mcc, _ := CardMCCMNC(bad); mcc != "" {
t.Fatalf("CardMCCMNC(%q) mcc = %q, want empty", bad, mcc)
+37
View File
@@ -55,6 +55,25 @@ func TestCarrierForPLMNReturnsCountryCode(t *testing.T) {
}
}
func TestCountryForMCCUsesEmbeddedCountryIndex(t *testing.T) {
tests := map[string]string{
"234": "GB",
"262": "DE",
"310": "US",
"460": "CN",
}
for mcc, want := range tests {
if got, ok := CountryForMCC(mcc); !ok || got != want {
t.Errorf("CountryForMCC(%q) = (%q, %v), want %q", mcc, got, ok, want)
}
}
for _, invalid := range []string{"", "23", "999", "abcd"} {
if got, ok := CountryForMCC(invalid); ok || got != "" {
t.Errorf("CountryForMCC(%q) = (%q, %v), want unknown", invalid, got, ok)
}
}
}
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
tests := []struct {
imsi string
@@ -64,6 +83,7 @@ func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
{imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"},
{imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"},
{imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"},
{imsi: "454006395879502", wantPLMN: "45400", wantCountry: "HK"},
{imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"},
}
for _, item := range tests {
@@ -73,3 +93,20 @@ func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
}
}
}
func TestCarrierForSIMUsesAndroidGIDRuleBeforePLMNFallback(t *testing.T) {
plmn, name, country, ok := CarrierForSIM(CarrierIdentity{
IMSI: "454006395879502", ICCID: "89852350126077295027",
SPN: "Saily", GID1: "536E617065", GID2: "536E617065000012", MNCLength: 2,
})
if !ok || plmn != "45400" || name != "Webbing" || country != "HK" {
t.Fatalf("CarrierForSIM exact rule = (%q, %q, %q, %v)", plmn, name, country, ok)
}
plmn, name, country, ok = CarrierForSIM(CarrierIdentity{
IMSI: "454006395879502", SPN: "Saily", MNCLength: 2,
})
if !ok || plmn != "45400" || name != "1O1O / csl / Club Sim" || country != "HK" {
t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok)
}
}
+105
View File
@@ -21,6 +21,7 @@ func (manager *Manager) readSnapshot(
candidate modem.Candidate,
backend string,
previousICCID string,
previousSnapshot *Snapshot,
client modem.Client,
) (Snapshot, error) {
snapshot := Snapshot{
@@ -81,6 +82,25 @@ func (manager *Manager) readSnapshot(
if response, spnErr := manager.command(ctx, client, "AT+CRSM=176,28486,0,0,17"); spnErr == nil {
snapshot.SPN = parseSPN(response)
}
if previousSnapshot != nil && previousSnapshot.IdentityFilesRead &&
strings.EqualFold(strings.TrimSpace(previousSnapshot.ICCID), strings.TrimSpace(snapshot.ICCID)) {
snapshot.MNCLength = previousSnapshot.MNCLength
snapshot.GID1 = previousSnapshot.GID1
snapshot.GID2 = previousSnapshot.GID2
snapshot.IdentityFilesRead = true
} else {
// Android's carrier resolver does not identify MVNOs from MCC/MNC alone.
// Read these files once per inserted ICCID and cache even an empty result;
// repeatedly probing unsupported EFs would add avoidable modem traffic.
if efAD := manager.readTransparentSIMFile(ctx, client, 28589); len(efAD) >= 4 {
if length := int(efAD[3] & 0x0f); length == 2 || length == 3 {
snapshot.MNCLength = length
}
}
snapshot.GID1 = encodeSIMGroupID(manager.readTransparentSIMFile(ctx, client, 28478))
snapshot.GID2 = encodeSIMGroupID(manager.readTransparentSIMFile(ctx, client, 28479))
snapshot.IdentityFilesRead = true
}
if response, ok := optional("AT+CSQ"); ok {
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
}
@@ -164,6 +184,91 @@ func (manager *Manager) readSnapshot(
return snapshot, nil
}
func (manager *Manager) readTransparentSIMFile(ctx context.Context, client modem.Client, fileID int) []byte {
response, err := manager.command(ctx, client, fmt.Sprintf("AT+CRSM=192,%d,0,0,0", fileID))
if err != nil {
return nil
}
size := transparentSIMFileSize(crsmPayload(response))
if size <= 0 || size > 64 {
return nil
}
response, err = manager.command(ctx, client, fmt.Sprintf("AT+CRSM=176,%d,0,0,%d", fileID, size))
if err != nil {
return nil
}
return crsmPayload(response)
}
func transparentSIMFileSize(payload []byte) int {
// USIM FCP templates contain file size in tag 0x80. Skip the outer 0x62
// template and walk its immediate TLVs.
content := payload
if len(content) >= 2 && content[0] == 0x62 {
length, header, ok := berLength(content[1:])
if !ok || 1+header+length > len(content) {
return 0
}
content = content[1+header : 1+header+length]
}
for offset := 0; offset+2 <= len(content); {
tag := content[offset]
length, header, ok := berLength(content[offset+1:])
start := offset + 1 + header
end := start + length
if !ok || end > len(content) {
break
}
if tag == 0x80 && (length == 1 || length == 2) {
size := 0
for _, value := range content[start:end] {
size = size<<8 | int(value)
}
return size
}
offset = end
}
// Legacy GSM GET RESPONSE data stores file size in bytes 2 and 3.
if len(payload) >= 4 && payload[0] != 0x62 {
return int(payload[2])<<8 | int(payload[3])
}
return 0
}
func berLength(value []byte) (length, header int, ok bool) {
if len(value) == 0 {
return 0, 0, false
}
if value[0] < 0x80 {
return int(value[0]), 1, true
}
count := int(value[0] & 0x7f)
if count == 0 || count > 2 || len(value) < count+1 {
return 0, 0, false
}
for _, item := range value[1 : count+1] {
length = length<<8 | int(item)
}
return length, count + 1, true
}
func encodeSIMGroupID(value []byte) string {
if len(value) == 0 {
return ""
}
allPadding := true
for _, item := range value {
if item != 0xff {
allPadding = false
break
}
}
if allPadding {
return ""
}
return strings.ToUpper(hex.EncodeToString(value))
}
func parseSPN(response modem.Response) string {
value := valueAfterPrefix(response, "+CRSM:")
fields := csvValues(value)
+4
View File
@@ -105,6 +105,10 @@ type Snapshot struct {
ICCID string `json:"iccid"`
IMSI string `json:"imsi"`
SPN string `json:"spn,omitempty"`
MNCLength int `json:"mncLength,omitempty"`
GID1 string `json:"gid1,omitempty"`
GID2 string `json:"gid2,omitempty"`
IdentityFilesRead bool `json:"-"`
OperatingMode int `json:"operatingMode"`
ModeKnown bool `json:"modeKnown"`
FlightMode bool `json:"flightMode"`
@@ -31,6 +31,7 @@ type automaticTaskNotification struct {
}
func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTask, run store.AutomaticTaskRun) {
ctx = s.notificationDestinationContext(ctx)
deviceLabel := task.DeviceID
if configured, err := s.store.Device(ctx, task.DeviceID); err == nil {
deviceLabel = firstNonEmpty(configured.Name, configured.ID)
+64 -54
View File
@@ -1904,66 +1904,76 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
if snapshot == nil {
return map[string]any{
"operator": "",
"native_mcc": "",
"native_mnc": "",
"native_spn": "",
"operator_country_code": "",
"card_mcc": "",
"card_mnc": "",
"card_country": "",
"service_blocked": false,
"blocked_reason": "",
"network_mode": "",
"radio_band": "",
"radio_channel": 0,
"signal_dbm": 0,
"signal_sinr": 0,
"imei": "",
"iccid": "",
"reg_status": 0,
"reg_status_text": "not refreshed",
"sim_inserted": false,
"phone_number": phone,
"phone_number_source": phoneSource,
"model": "",
"operator": "",
"native_mcc": "",
"native_mnc": "",
"native_spn": "",
"operator_country_code": "",
"card_mcc": "",
"card_mnc": "",
"card_country": "",
"home_carrier_name": "",
"home_carrier_plmn": "",
"home_carrier_country_code": "",
"service_blocked": false,
"blocked_reason": "",
"network_mode": "",
"radio_band": "",
"radio_channel": 0,
"signal_dbm": 0,
"signal_sinr": 0,
"imei": "",
"iccid": "",
"reg_status": 0,
"reg_status_text": "not refreshed",
"sim_inserted": false,
"phone_number": phone,
"phone_number_source": phoneSource,
"model": "",
}
}
mcc, mnc := splitPLMN(snapshot.OperatorCode)
_, operatorCountryCode, _ := device.CarrierForPLMN(snapshot.OperatorCode)
cardMCC, cardMNC := device.CardMCCMNC(snapshot.IMSI)
cardMCC, cardMNC := device.CardMCCMNCWithLength(snapshot.IMSI, snapshot.MNCLength)
homePLMN, homeCarrier, homeCountry, _ := device.CarrierForSIM(device.CarrierIdentity{
IMSI: snapshot.IMSI, ICCID: snapshot.ICCID, SPN: snapshot.SPN,
GID1: snapshot.GID1, GID2: snapshot.GID2, MNCLength: snapshot.MNCLength,
})
blockedReason := device.RegionBlockReason(snapshot.IMSI)
return map[string]any{
"operator": snapshot.OperatorName,
"native_mcc": mcc,
"native_mnc": mnc,
"native_spn": snapshot.SPN,
"operator_country_code": operatorCountryCode,
"card_mcc": cardMCC,
"card_mnc": cardMNC,
"card_country": countryNameForMCC(cardMCC),
"service_blocked": blockedReason != "",
"blocked_reason": blockedReason,
"network_mode": snapshot.AccessTech,
"network_duplex": "",
"radio_band": snapshot.Band,
"radio_channel": parseDecimal(snapshot.Channel),
"signal_dbm": pointerInt(snapshot.RSSIDBm),
"signal_rsrp": pointerInt(snapshot.RSRP),
"signal_rsrq": pointerInt(snapshot.RSRQ),
"signal_sinr": pointerInt(snapshot.SINR),
"imei": snapshot.IMEI,
"iccid": snapshot.ICCID,
"imsi": snapshot.IMSI,
"firmware": snapshot.Firmware,
"model": snapshot.Model,
"reg_status": snapshot.RegistrationStatus,
"reg_status_text": registrationText(snapshot),
"ps_attached": snapshot.PSAttached,
"sim_inserted": snapshotHasSIM(snapshot),
"operating_mode": snapshot.OperatingMode,
"phone_number": phone,
"phone_number_source": phoneSource,
"operator": snapshot.OperatorName,
"native_mcc": mcc,
"native_mnc": mnc,
"native_spn": snapshot.SPN,
"operator_country_code": operatorCountryCode,
"card_mcc": cardMCC,
"card_mnc": cardMNC,
"card_country": countryNameForMCC(cardMCC),
"home_carrier_name": homeCarrier,
"home_carrier_plmn": homePLMN,
"home_carrier_country_code": homeCountry,
"service_blocked": blockedReason != "",
"blocked_reason": blockedReason,
"network_mode": snapshot.AccessTech,
"network_duplex": "",
"radio_band": snapshot.Band,
"radio_channel": parseDecimal(snapshot.Channel),
"signal_dbm": pointerInt(snapshot.RSSIDBm),
"signal_rsrp": pointerInt(snapshot.RSRP),
"signal_rsrq": pointerInt(snapshot.RSRQ),
"signal_sinr": pointerInt(snapshot.SINR),
"imei": snapshot.IMEI,
"iccid": snapshot.ICCID,
"imsi": snapshot.IMSI,
"firmware": snapshot.Firmware,
"model": snapshot.Model,
"reg_status": snapshot.RegistrationStatus,
"reg_status_text": registrationText(snapshot),
"ps_attached": snapshot.PSAttached,
"sim_inserted": snapshotHasSIM(snapshot),
"operating_mode": snapshot.OperatingMode,
"phone_number": phone,
"phone_number_source": phoneSource,
}
}
+1 -1
View File
@@ -506,7 +506,7 @@ func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
switch {
case errors.Is(err, auth.ErrInvalidCredentials):
writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password is incorrect")
case strings.Contains(err.Error(), "between 12 and 1024"):
case strings.Contains(err.Error(), "between 6 and 1024"):
writeError(w, http.StatusBadRequest, "weak_password", err.Error())
case strings.Contains(err.Error(), "must differ"):
writeError(w, http.StatusBadRequest, "password_reused", err.Error())
+41 -7
View File
@@ -429,17 +429,18 @@ func (s *Server) handleNotificationTest(
return
}
notificationContext := s.notificationDestinationContext(r.Context())
switch channel {
case "webhook":
err = sendWebhookNotificationTest(r.Context(), resolved)
err = sendWebhookNotificationTest(notificationContext, resolved)
case "telegram":
err = sendTelegramNotificationTest(r.Context(), resolved)
err = sendTelegramNotificationTest(notificationContext, resolved)
case "email":
err = sendEmailNotificationTest(r.Context(), resolved)
err = sendEmailNotificationTest(notificationContext, resolved)
case "bark":
err = sendBarkNotificationTest(r.Context(), resolved)
err = sendBarkNotificationTest(notificationContext, resolved)
case "wecom":
err = sendWecomNotificationTest(r.Context(), resolved)
err = sendWecomNotificationTest(notificationContext, resolved)
}
if err != nil {
redacted := store.RedactText(err.Error(), provider)
@@ -1073,6 +1074,39 @@ func dialRestricted(
return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...))
}
type notificationAllowedNetworksKey struct{}
func (s *Server) notificationDestinationContext(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
access := s.currentAccessConfig()
return context.WithValue(ctx, notificationAllowedNetworksKey{}, append([]netip.Prefix(nil), access.cidrs...))
}
func notificationAddressAllowed(ctx context.Context, address netip.Addr) bool {
address = address.Unmap()
// Even an administrator-provided exception must never turn a notification
// endpoint into a loopback or cloud-metadata request. Private/LAN and
// benchmark ranges may be explicitly allowed for local push gateways and
// DNS Fake-IP deployments, but these process-local destinations stay closed.
if !address.IsValid() || address.IsUnspecified() || address.IsLoopback() ||
address.IsMulticast() || address.IsLinkLocalUnicast() ||
address == netip.MustParseAddr("100.100.100.200") {
return false
}
if publicNotificationAddress(address) {
return true
}
prefixes, _ := ctx.Value(notificationAllowedNetworksKey{}).([]netip.Prefix)
for _, prefix := range prefixes {
if prefix.Contains(address) {
return true
}
}
return false
}
func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, error) {
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
if normalized == "" || normalized == "localhost" ||
@@ -1084,7 +1118,7 @@ func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, err
}
if literal, err := netip.ParseAddr(normalized); err == nil {
literal = literal.Unmap()
if !publicNotificationAddress(literal) {
if !notificationAddressAllowed(ctx, literal) {
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal)
}
return []netip.Addr{literal}, nil
@@ -1099,7 +1133,7 @@ func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, err
result := make([]netip.Addr, 0, len(addresses))
for _, address := range addresses {
address = address.Unmap()
if !publicNotificationAddress(address) {
if !notificationAddressAllowed(ctx, address) {
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address)
}
result = append(result, address)
+21
View File
@@ -789,6 +789,27 @@ func TestNotificationDestinationAddressPolicy(t *testing.T) {
); err == nil {
t.Fatal("metadata IP was not blocked")
}
allowedContext := context.WithValue(
context.Background(),
notificationAllowedNetworksKey{},
[]netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")},
)
if addresses, err := resolvePublicAddresses(allowedContext, "198.18.0.1"); err != nil || len(addresses) != 1 {
t.Fatalf("explicit Fake-IP notification allowlist = %v, %v", addresses, err)
}
if _, err := resolvePublicAddresses(allowedContext, "169.254.169.254"); err == nil {
t.Fatal("unlisted metadata IP was allowed")
}
wideAllowedContext := context.WithValue(
context.Background(),
notificationAllowedNetworksKey{},
[]netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")},
)
for _, address := range []string{"127.0.0.1", "169.254.169.254", "100.100.100.200"} {
if _, err := resolvePublicAddresses(wideAllowedContext, address); err == nil {
t.Fatalf("non-overridable destination %s was allowed", address)
}
}
}
func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) {
+1 -1
View File
@@ -105,7 +105,7 @@ func (s *Server) runSMSNotificationChannel(ctx context.Context, channel string)
} else {
for _, message := range messages {
notification := s.newSMSNotification(ctx, message)
if sendErr := sendSMSNotification(ctx, channel, config, notification); sendErr != nil {
if sendErr := sendSMSNotification(s.notificationDestinationContext(ctx), channel, config, notification); sendErr != nil {
if sendErr.Error() != lastError || time.Since(lastErrorAt) >= time.Minute {
s.logSMSNotificationError(channel, sendErr)
lastError, lastErrorAt = sendErr.Error(), time.Now()
+34 -12
View File
@@ -850,7 +850,7 @@ func (bot *telegramBot) sendDeviceStatus(ctx context.Context, config telegramRun
"ICCID"+firstNonEmpty(snapshot.ICCID, "--"),
"IMSI"+firstNonEmpty(snapshot.IMSI, "--"),
"号码:"+resolveTelegramPhoneNumber(associationNumber, wfcState, snapshot),
"原运营商:"+telegramHomeCarrier(snapshot.IMSI, snapshot.SPN),
"原运营商:"+telegramSnapshotHomeCarrier(snapshot),
"当前网络:"+telegramCurrentNetwork(snapshot),
"蜂窝模式:"+map[bool]string{true: "飞行模式", false: "开启"}[snapshot.FlightMode],
)
@@ -929,20 +929,30 @@ func usableTelegramPhoneNumber(value string) bool {
}
func telegramHomeCarrier(imsi string, spn ...string) string {
plmn, name, country, ok := device.CarrierForIMSI(imsi)
if !ok {
if len(spn) > 0 && strings.TrimSpace(spn[0]) != "" {
return strings.TrimSpace(spn[0])
}
identity := device.CarrierIdentity{IMSI: imsi}
if len(spn) > 0 {
identity.SPN = spn[0]
}
return telegramResolvedHomeCarrier(identity)
}
func telegramSnapshotHomeCarrier(snapshot *device.Snapshot) string {
if snapshot == nil {
return "--"
}
if len(spn) > 0 && strings.TrimSpace(spn[0]) != "" {
brand := strings.TrimSpace(spn[0])
brandCountry := country
if strings.Contains(strings.ToLower(brand), "lebara") && strings.HasPrefix(strings.TrimSpace(imsi), "20404") {
brandCountry = "GB"
return telegramResolvedHomeCarrier(device.CarrierIdentity{
IMSI: snapshot.IMSI, ICCID: snapshot.ICCID, SPN: snapshot.SPN,
GID1: snapshot.GID1, GID2: snapshot.GID2, MNCLength: snapshot.MNCLength,
})
}
func telegramResolvedHomeCarrier(identity device.CarrierIdentity) string {
plmn, name, country, ok := device.CarrierForSIM(identity)
if !ok {
if strings.TrimSpace(identity.SPN) != "" {
return strings.TrimSpace(identity.SPN)
}
return strings.TrimSpace(strings.Join([]string{telegramCountryFlag(brandCountry), brand, "(认证核心 " + plmn + ""}, " "))
return "--"
}
return strings.TrimSpace(strings.Join([]string{telegramCountryFlag(country), name, "(" + plmn + ")"}, " "))
}
@@ -2318,6 +2328,11 @@ func (bot *telegramBot) loadConfig(ctx context.Context) (telegramRuntimeConfig,
}
func (bot *telegramBot) call(ctx context.Context, config telegramRuntimeConfig, method string, payload any, result any) error {
// Telegram polling is a long-lived notification channel and must use the
// same administrator-configured destination exceptions as test messages,
// SMS pushes and automatic-task notifications. This keeps SSRF protection
// enabled while allowing explicit DNS Fake-IP ranges such as 198.18/15.
ctx = bot.notificationDestinationContext(ctx)
base, err := validateTelegramAPIURL(ctx, config.BaseURL, config.Token, method)
if err != nil {
return redactTelegramError(err, config.Token)
@@ -2360,6 +2375,13 @@ func (bot *telegramBot) call(ctx context.Context, config telegramRuntimeConfig,
return nil
}
func (bot *telegramBot) notificationDestinationContext(ctx context.Context) context.Context {
if bot.server == nil {
return ctx
}
return bot.server.notificationDestinationContext(ctx)
}
func (bot *telegramBot) sendText(ctx context.Context, config telegramRuntimeConfig, chatID int64, text string, replyMarkup any) error {
target := config.ChatID
if chatID != 0 {
+19 -2
View File
@@ -3,6 +3,7 @@ package server
import (
"context"
"errors"
"net/netip"
"strings"
"testing"
"time"
@@ -57,6 +58,19 @@ func TestTelegramAPIURLRejectsMalformedTemplates(t *testing.T) {
}
}
func TestTelegramPollingUsesExplicitFakeIPDestinationAllowlist(t *testing.T) {
bot := &telegramBot{server: &Server{access: parsedAccessConfig{
cidrs: []netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")},
}}}
ctx := bot.notificationDestinationContext(context.Background())
if _, err := validateTelegramAPIURL(ctx, "https://198.18.0.34", "123456:test-token", "getUpdates"); err != nil {
t.Fatalf("explicitly allowed Telegram Fake-IP was rejected: %v", err)
}
if _, err := validateTelegramAPIURL(ctx, "https://169.254.169.254", "123456:test-token", "getUpdates"); err == nil {
t.Fatal("metadata address became reachable through Telegram allowlist")
}
}
func TestParseTelegramCommand(t *testing.T) {
command, remainder := parseTelegramCommand(" /sms@vocat_bot EC20 +447700900123 hello world ")
if command != "sms" || remainder != "EC20 +447700900123 hello world" {
@@ -127,8 +141,11 @@ func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T
if got := telegramHomeCarrier("234336570710174"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
t.Fatalf("home carrier = %q", got)
}
if got := telegramHomeCarrier("204040123456789", "Lebara"); !strings.Contains(got, "Lebara") || !strings.Contains(got, "20404") || !strings.Contains(got, "🇬🇧") || strings.Contains(got, "🇳🇱") {
t.Fatalf("branded foreign-core carrier = %q", got)
if got := telegramHomeCarrier("454006395879502", "Saily"); !strings.Contains(got, "1O1O / csl / Club Sim") || !strings.Contains(got, "45400") || !strings.Contains(got, "🇭🇰") || strings.Contains(got, "Saily") {
t.Fatalf("profile brand overrode home carrier = %q", got)
}
if got := telegramHomeCarrier("999991234567890", "Unknown Brand"); got != "Unknown Brand" {
t.Fatalf("unknown home carrier did not fall back to SPN: %q", got)
}
flight := &device.Snapshot{FlightMode: true, OperatorName: "stale network", RegistrationStatus: 1}
if got := telegramCurrentNetwork(flight); got != "--(飞行模式)" {
+118
View File
@@ -220,6 +220,124 @@ func TestMigration8DefaultsExistingDevicesToPCIeType(t *testing.T) {
}
}
func TestMigration19AcceptsDevelopmentDatabaseAndPreservesCardData(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "development-schema.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for version := 1; version <= 16; version++ {
for _, statement := range migrationStatements(version) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create v%d schema: %v", version, err)
}
}
}
if _, err := raw.ExecContext(ctx, `
INSERT INTO devices (id, name, created_at, updated_at)
VALUES ('ec20-1', 'EC20', 100, 100);
INSERT INTO card_policies (
iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, source, created_at, updated_at, custom_phone_number
) VALUES (
'8900000000000000019', 0, 1, 1,
'ims', 'IPV4V6', 'user', 100, 100, '447700900019'
);
INSERT INTO card_apn_profiles (
iccid, apn, ip_version, created_at, updated_at,
username, password, proxy, mcc, mnc, roaming_ip_version, auth_type
) VALUES (
'8900000000000000019', 'mobile.example', 'IPV4V6', 100, 100,
'user', 'secret', '', '234', '10', 'IP', 'PAP'
);
PRAGMA user_version = 16;
`); err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
database := openTestStore(t, path)
policy, err := database.CardPolicy(ctx, "8900000000000000019")
if err != nil {
t.Fatal(err)
}
if !policy.VoWiFiEnabled || !policy.AirplaneEnabled || policy.CustomPhoneNumber != "447700900019" {
t.Fatalf("migrated card policy = %#v", policy)
}
profiles, err := database.ListCardAPNProfiles(ctx, "8900000000000000019")
if err != nil {
t.Fatal(err)
}
if len(profiles) != 1 || profiles[0].APN != "mobile.example" || profiles[0].Username != "user" || profiles[0].AuthType != "PAP" {
t.Fatalf("migrated APN profiles = %#v", profiles)
}
var version int
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
t.Fatal(err)
}
if version != 19 {
t.Fatalf("schema version = %d, want 19", version)
}
for _, column := range []string{
"ims_apn", "ims_private_identity", "ims_public_identity", "ims_sms_center",
"ims_transport", "ims_allow_imsi_derived_identity", "vowifi_eap_method",
"vowifi_allow_sha1", "vowifi_use_modp1024",
} {
var count int
if err := database.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM pragma_table_info('devices') WHERE name = ?
`, column).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("migration 19 column %q count = %d", column, count)
}
}
}
func TestMigration19AcceptsDevelopmentColumnsAlreadyPresent(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "development-columns.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for version := 1; version <= 18; version++ {
for _, statement := range migrationStatements(version) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create v%d schema: %v", version, err)
}
}
}
// The development build added these columns while still reporting schema
// 18. Migration 19 must treat that layout as compatible rather than fail on
// the first duplicate ALTER TABLE statement.
for _, statement := range migrationStatements(19) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create development column: %v", err)
}
}
if _, err := raw.ExecContext(ctx, `PRAGMA user_version = 18`); err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
database := openTestStore(t, path)
var version int
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
t.Fatal(err)
}
if version != 19 {
t.Fatalf("schema version = %d, want 19", version)
}
}
func TestMigration4PreservesIMSRedeliveryAndUsesReceiptTime(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "ims-redelivery.db")
+117
View File
@@ -264,6 +264,123 @@ func migrationStatements(version int) []string {
return []string{
`ALTER TABLE devices ADD COLUMN sim_pin TEXT NOT NULL DEFAULT ''`,
}
case 17:
// Some development builds recorded automatic-task support in an older
// migration. Recreate the objects idempotently so databases from either
// history converge before later migrations run.
return []string{
`CREATE TABLE IF NOT EXISTS automatic_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
device_id TEXT NOT NULL,
profile_iccid TEXT NOT NULL,
profile_aid TEXT NOT NULL DEFAULT '',
task_type TEXT NOT NULL CHECK (task_type IN ('sms', 'call', 'public_ip')),
environment TEXT NOT NULL CHECK (environment IN ('vowifi', 'cellular')),
interval_days INTEGER NOT NULL CHECK (interval_days BETWEEN 1 AND 365),
start_date TEXT NOT NULL,
run_time TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'Local',
payload_json TEXT NOT NULL DEFAULT '{}',
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count BETWEEN 0 AND 10),
notify INTEGER NOT NULL DEFAULT 0 CHECK (notify IN (0, 1)),
next_run_at INTEGER NOT NULL,
last_run_at INTEGER NOT NULL DEFAULT 0,
last_status TEXT NOT NULL DEFAULT '',
last_error TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS automatic_tasks_due_idx ON automatic_tasks(enabled, next_run_at, id)`,
`CREATE INDEX IF NOT EXISTS automatic_tasks_device_idx ON automatic_tasks(device_id, next_run_at, id)`,
`CREATE TABLE IF NOT EXISTS automatic_task_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
device_id TEXT NOT NULL,
scheduled_at INTEGER NOT NULL,
started_at INTEGER NOT NULL DEFAULT 0,
finished_at INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'success', 'failed')),
attempts INTEGER NOT NULL DEFAULT 0,
output TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (task_id) REFERENCES automatic_tasks(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS automatic_task_runs_task_idx ON automatic_task_runs(task_id, id DESC)`,
`CREATE INDEX IF NOT EXISTS automatic_task_runs_status_idx ON automatic_task_runs(status, id)`,
}
case 18:
// A short-lived schema lineage kept the original card-policy CHECK,
// which rejected the supported VoWiFi + airplane-mode state. Rebuild
// both related tables so all released and development databases converge
// without dropping policies or custom APNs.
return []string{
`ALTER TABLE card_apn_profiles RENAME TO card_apn_profiles_v17`,
`ALTER TABLE card_policies RENAME TO card_policies_v17`,
`CREATE TABLE card_policies (
iccid TEXT PRIMARY KEY,
network_enabled INTEGER NOT NULL DEFAULT 0 CHECK (network_enabled IN (0, 1)),
vowifi_enabled INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_enabled IN (0, 1)),
airplane_enabled INTEGER NOT NULL DEFAULT 0 CHECK (airplane_enabled IN (0, 1)),
apn TEXT NOT NULL DEFAULT '',
ip_version TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
custom_phone_number TEXT NOT NULL DEFAULT ''
)`,
`INSERT INTO card_policies (
iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, source, created_at, updated_at, custom_phone_number
) SELECT
iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, source, created_at, updated_at, custom_phone_number
FROM card_policies_v17`,
`CREATE TABLE card_apn_profiles_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
iccid TEXT NOT NULL,
apn TEXT NOT NULL,
ip_version TEXT NOT NULL DEFAULT 'IPV4V6' CHECK (ip_version IN ('IP', 'IPV6', 'IPV4V6')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
username TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
proxy TEXT NOT NULL DEFAULT '',
mcc TEXT NOT NULL DEFAULT '',
mnc TEXT NOT NULL DEFAULT '',
roaming_ip_version TEXT NOT NULL DEFAULT 'IP' CHECK (roaming_ip_version IN ('IP', 'IPV6', 'IPV4V6')),
auth_type TEXT NOT NULL DEFAULT 'NONE' CHECK (auth_type IN ('NONE', 'PAP', 'CHAP', 'PAP_OR_CHAP')),
UNIQUE (iccid, apn, ip_version),
FOREIGN KEY (iccid) REFERENCES card_policies(iccid) ON DELETE CASCADE
)`,
`INSERT INTO card_apn_profiles_new
SELECT id, iccid, apn, ip_version, created_at, updated_at,
username, password, proxy, mcc, mnc, roaming_ip_version, auth_type
FROM card_apn_profiles_v17`,
`DROP TABLE card_apn_profiles_v17`,
`DROP TABLE card_policies_v17`,
`ALTER TABLE card_apn_profiles_new RENAME TO card_apn_profiles`,
`CREATE INDEX card_apn_profiles_iccid_idx ON card_apn_profiles(iccid, id)`,
}
case 19:
// Compatibility columns written by the Qualcomm/IMS development build.
// The stable server may leave them unused, but retaining them makes a
// database created by that build safely readable after an upgrade.
return []string{
`ALTER TABLE devices ADD COLUMN ims_apn TEXT NOT NULL DEFAULT 'ims'`,
`ALTER TABLE devices ADD COLUMN ims_private_identity TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE devices ADD COLUMN ims_public_identity TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE devices ADD COLUMN ims_sms_center TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE devices ADD COLUMN ims_transport TEXT NOT NULL DEFAULT 'tcp'`,
`ALTER TABLE devices ADD COLUMN ims_allow_imsi_derived_identity INTEGER NOT NULL DEFAULT 1 CHECK (ims_allow_imsi_derived_identity IN (0, 1))`,
`ALTER TABLE devices ADD COLUMN vowifi_eap_method TEXT NOT NULL DEFAULT 'aka'`,
`ALTER TABLE devices ADD COLUMN vowifi_allow_sha1 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_allow_sha1 IN (0, 1))`,
`ALTER TABLE devices ADD COLUMN vowifi_use_modp1024 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_use_modp1024 IN (0, 1))`,
}
default:
return nil
}
+3 -2
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 16
const schemaVersion = 19
var ErrNotFound = errors.New("store: not found")
@@ -123,7 +123,8 @@ func migrate(ctx context.Context, db *sql.DB) error {
duplicateAdditiveColumn := (nextVersion == 7 && strings.Contains(statement, "ADD COLUMN modem_imei")) ||
(nextVersion == 8 && strings.Contains(statement, "ADD COLUMN device_type")) ||
(nextVersion == 14 && strings.Contains(statement, "ADD COLUMN")) ||
(nextVersion == 16 && strings.Contains(statement, "ADD COLUMN sim_pin"))
(nextVersion == 16 && strings.Contains(statement, "ADD COLUMN sim_pin")) ||
(nextVersion == 19 && strings.Contains(statement, "ADD COLUMN"))
if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
continue
}
+60
View File
@@ -0,0 +1,60 @@
package vowifi
import (
"fmt"
"strings"
)
const att310280EPDG = "epdg.epc.att.net"
// AssignedRoutePLMN returns a narrowly matched ePDG route PLMN without
// changing the subscription PLMN used for AKA identities. Some multi-profile
// and MVNO SIMs authenticate against their own HPLMN but use a host network's
// VoWiFi access gateway.
func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) {
iccid = strings.TrimSpace(iccid)
imsi = strings.TrimSpace(imsi)
switch {
case strings.HasPrefix(iccid, "894416") && strings.HasPrefix(imsi, "204047"):
// XeSIM/Lebara: keep 204/04 for AKA and use Vodafone UK's ePDG.
return "234", "15", true
case strings.HasPrefix(iccid, "894430") && strings.HasPrefix(imsi, "23433"):
// CTExcel UK: keep 234/33 for AKA and use the EE UK ePDG used by
// the initial VoWiFi provisioning path.
return "234", "30", true
default:
return "", "", false
}
}
// IsATT310280 reports whether the live subscription is on AT&T's three-digit
// 310/280 PLMN. It is shared by SWu and IMS so the carrier exception cannot
// drift between protocol layers.
func IsATT310280(identity SIMIdentity) bool {
mcc := strings.TrimSpace(identity.HomeMCC)
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
imsi := strings.TrimSpace(identity.IMSI)
return mcc == "310" && mnc == "280" && strings.HasPrefix(imsi, "310280")
}
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
if strings.TrimSpace(identity.EPDG) != "" {
return identity
}
if routeMCC, routeMNC, ok := AssignedRoutePLMN(identity.ICCID, identity.IMSI); ok {
identity.EPDG = standardEPDGHostname(routeMCC, routeMNC)
}
return identity
}
func standardEPDGHostname(mcc, mnc string) string {
mnc = strings.TrimSpace(mnc)
for len(mnc) < 3 {
mnc = "0" + mnc
}
return fmt.Sprintf(
"epdg.epc.mnc%s.mcc%s.pub.3gppnetwork.org",
mnc,
strings.TrimSpace(mcc),
)
}
+56
View File
@@ -0,0 +1,56 @@
package vowifi
import "testing"
func TestAssignedRoutePLMNUsesNarrowCardAndSubscriptionMatches(t *testing.T) {
tests := []struct {
name string
iccid string
imsi string
wantMCC string
wantMNC string
wantAssigned bool
}{
{name: "XeSIM Lebara route", iccid: "89441600001001576265", imsi: "204047666157626", wantMCC: "234", wantMNC: "15", wantAssigned: true},
{name: "CTExcel initial route", iccid: "8944303773524055208", imsi: "234336570712415", wantMCC: "234", wantMNC: "30", wantAssigned: true},
{name: "XeSIM ICCID without matching subscription", iccid: "89441600001001576265", imsi: "204041666157626"},
{name: "similar ICCID must not match", iccid: "89441000001001576265", imsi: "204047666157626"},
{name: "generic EE SIM must not match CTExcel", iccid: "8944110000000000000", imsi: "234336570712415"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mcc, mnc, assigned := AssignedRoutePLMN(test.iccid, test.imsi)
if mcc != test.wantMCC || mnc != test.wantMNC || assigned != test.wantAssigned {
t.Fatalf("AssignedRoutePLMN() = %q/%q,%v, want %q/%q,%v", mcc, mnc, assigned, test.wantMCC, test.wantMNC, test.wantAssigned)
}
})
}
}
func TestApplyAssignedCarrierRoutePreservesAuthenticationPLMN(t *testing.T) {
identity := applyAssignedCarrierRoute(SIMIdentity{
ICCID: "8944303773524055208", IMSI: "234336570712415",
HomeMCC: "234", HomeMNC: "33",
})
if identity.HomeMCC != "234" || identity.HomeMNC != "33" {
t.Fatalf("authentication PLMN = %s/%s, want 234/33", identity.HomeMCC, identity.HomeMNC)
}
if identity.EPDG != "epdg.epc.mnc030.mcc234.pub.3gppnetwork.org" {
t.Fatalf("route ePDG = %q", identity.EPDG)
}
}
func TestIsATT310280RequiresMatchingPLMNAndIMSI(t *testing.T) {
if !IsATT310280(SIMIdentity{IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280"}) {
t.Fatal("AT&T 310/280 identity was not recognized")
}
for _, identity := range []SIMIdentity{
{IMSI: "310410229187733", HomeMCC: "310", HomeMNC: "280"},
{IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "28"},
{IMSI: "310280229187733", HomeMCC: "311", HomeMNC: "280"},
} {
if IsATT310280(identity) {
t.Fatalf("unrelated identity matched AT&T 310/280: %#v", identity)
}
}
}
+135 -10
View File
@@ -97,9 +97,10 @@ type ec20RadioCheckpoint struct {
}
var (
_ SIMIdentityReader = (*EC20Adapter)(nil)
_ AKAProvider = (*EC20Adapter)(nil)
_ RadioController = (*EC20Adapter)(nil)
_ SIMIdentityReader = (*EC20Adapter)(nil)
_ AKAProvider = (*EC20Adapter)(nil)
_ PreferredAKAProvider = (*EC20Adapter)(nil)
_ RadioController = (*EC20Adapter)(nil)
)
func NewEC20Adapter(
@@ -172,6 +173,7 @@ func (adapter *EC20Adapter) ReadIdentity(
HomeMCC: homeMCC,
HomeMNC: homeMNC,
}
identity = applyAssignedCarrierRoute(identity)
adapter.mu.Lock()
adapter.bindings[iccid] = ec20SIMBinding{
deviceID: deviceID,
@@ -208,6 +210,11 @@ func (adapter *EC20Adapter) readHomePLMN(
iccid string,
imsi string,
) (string, string, error) {
// AT&T 310/280 is a three-digit MNC. Prefer the assigned subscription
// prefix when EF_AD is stale or ambiguous after a profile switch.
if strings.HasPrefix(strings.TrimSpace(imsi), "310280") {
return "310", "280", nil
}
mncLength, efErr := adapter.readExplicitMNCLength(ctx, deviceID)
if efErr == nil {
if len(imsi) < 3+mncLength {
@@ -238,9 +245,10 @@ func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) {
prefix string
mncLength int
}{
{prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions.
{prefix: "23415", mncLength: 2}, // Vodafone UK.
{prefix: "23487", mncLength: 2}, // Lebara Mobile UK.
{prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions.
{prefix: "23415", mncLength: 2}, // Vodafone UK.
{prefix: "23487", mncLength: 2}, // Lebara Mobile UK.
{prefix: "310280", mncLength: 3}, // AT&T / RedPocket GSMA.
}
for _, assignment := range assignments {
if strings.HasPrefix(imsi, assignment.prefix) {
@@ -391,11 +399,46 @@ func (adapter *EC20Adapter) Authenticate(
ctx context.Context,
identity SIMIdentity,
challenge AKAChallenge,
) (AKAResult, error) {
return adapter.authenticateWithApplication(ctx, identity, challenge, "")
}
func (adapter *EC20Adapter) AuthenticateWithPreference(
ctx context.Context,
identity SIMIdentity,
challenge AKAChallenge,
preference string,
) (AKAResult, error) {
return adapter.authenticateWithApplication(ctx, identity, challenge, preference)
}
func (adapter *EC20Adapter) authenticateWithApplication(
ctx context.Context,
identity SIMIdentity,
challenge AKAChallenge,
preference string,
) (AKAResult, error) {
binding, err := adapter.bindingFor(identity)
if err != nil {
return AKAResult{}, err
}
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") && binding.application != "ISIM" {
aid, application, err := adapter.discoverPreferredAKAApplication(
ctx,
binding.deviceID,
isimAIDPrefix,
"ISIM",
)
if err != nil {
return AKAResult{}, err
}
binding.aid = aid
binding.application = application
binding.basicChannel = false
adapter.mu.Lock()
adapter.bindings[binding.iccid] = binding
adapter.mu.Unlock()
}
if binding.aid == "" {
if _, err := adapter.CheckReady(ctx, identity); err != nil {
return AKAResult{}, err
@@ -405,6 +448,14 @@ func (adapter *EC20Adapter) Authenticate(
return AKAResult{}, err
}
}
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") && binding.application != "ISIM" {
return AKAResult{}, fmt.Errorf(
"%w: ISIM strict requested, selected %s (%s)",
ErrEC20ApplicationAbsent,
binding.application,
binding.aid,
)
}
if err := adapter.verifyLiveICCID(ctx, binding); err != nil {
return AKAResult{}, err
}
@@ -907,6 +958,28 @@ func (adapter *EC20Adapter) discoverAKAApplication(
return usimAIDPrefix, "USIM", nil
}
func (adapter *EC20Adapter) discoverPreferredAKAApplication(
ctx context.Context,
deviceID string,
aidPrefix string,
application string,
) (string, string, error) {
response, err := adapter.execute(ctx, deviceID, "AT+CUAD")
if err == nil {
data, parseErr := parseCUADData(response)
if parseErr == nil {
for _, candidate := range collectApplicationAIDs(data) {
if strings.HasPrefix(candidate, aidPrefix) {
return candidate, application, nil
}
}
}
}
// AT+CUAD is optional. Returning the standard AID prefix still lets CCHO
// perform the authoritative application probe on older EC20 firmware.
return aidPrefix, application, nil
}
func (adapter *EC20Adapter) openLogicalChannel(
ctx context.Context,
deviceID string,
@@ -1146,12 +1219,27 @@ func parseCRSMData(response modem.Response) ([]byte, error) {
}
func parseCUADData(response modem.Response) ([]byte, error) {
fields := parseCSV(valueAfterATPrefix(response, "+CUAD:"))
if len(fields) == 0 {
// EC20 firmware may split the BER-TLV stream across adjacent quoted chunks
// and continuation lines. Concatenating every hex fragment prevents an ISIM
// AID after a USIM entry from being silently discarded.
var encoded strings.Builder
collect := false
for _, line := range response.Lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(strings.ToUpper(line), "+CUAD:") {
collect = true
line = strings.TrimSpace(line[len("+CUAD:"):])
} else if !collect {
continue
}
for _, fragment := range quotedHexFragments(line) {
encoded.WriteString(fragment)
}
}
if encoded.Len() == 0 {
return nil, errors.New("CUAD response has no data")
}
value := fields[len(fields)-1]
data, err := hex.DecodeString(strings.Trim(value, `"`))
data, err := hex.DecodeString(encoded.String())
if err != nil || len(data) == 0 {
return nil, errors.New("CUAD response data is invalid")
}
@@ -1161,11 +1249,48 @@ func parseCUADData(response modem.Response) ([]byte, error) {
return data, nil
}
func quotedHexFragments(line string) []string {
var fragments []string
for {
start := strings.IndexByte(line, '"')
if start < 0 {
break
}
line = line[start+1:]
end := strings.IndexByte(line, '"')
if end < 0 {
break
}
fragment := strings.ToUpper(strings.TrimSpace(line[:end]))
line = line[end+1:]
if fragment == "" || len(fragment)%2 != 0 {
continue
}
valid := true
for _, character := range fragment {
if (character < '0' || character > '9') && (character < 'A' || character > 'F') {
valid = false
break
}
}
if valid {
fragments = append(fragments, fragment)
}
}
return fragments
}
func collectApplicationAIDs(data []byte) []string {
var result []string
var walk func([]byte)
walk = func(value []byte) {
for len(value) > 0 {
for len(value) > 0 && value[0] == 0xff {
value = value[1:]
}
if len(value) == 0 {
return
}
tag, constructed, body, consumed, err := decodeBERTLV(value)
if err != nil || consumed == 0 {
return
+94
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"reflect"
"strings"
"sync"
"testing"
@@ -421,6 +422,7 @@ func TestAssignedHomePLMNIncludesLebaraUKCores(t *testing.T) {
"204040123456789": "204/04",
"234150123456789": "234/15",
"234870123456789": "234/87",
"310280229187733": "310/280",
}
for imsi, want := range tests {
mcc, mnc, ok := assignedHomePLMN(imsi)
@@ -430,6 +432,23 @@ func TestAssignedHomePLMNIncludesLebaraUKCores(t *testing.T) {
}
}
func TestEC20AdapterTreatsATT310280AsThreeDigitMNC(t *testing.T) {
t.Parallel()
transcript := &ec20Transcript{t: t, steps: identityTranscriptStepsWithoutEFAD("310280229187733")}
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
if err != nil {
t.Fatal(err)
}
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
if err != nil {
t.Fatalf("ReadIdentity: %v", err)
}
if identity.HomeMCC != "310" || identity.HomeMNC != "280" {
t.Fatalf("home PLMN = %s/%s, want 310/280", identity.HomeMCC, identity.HomeMNC)
}
transcript.assertDone()
}
func TestEC20AdapterRadioTransactionRestoresCFUNAndPDPContexts(
t *testing.T,
) {
@@ -598,3 +617,78 @@ func synchronizationFailureUSIMResponse() []byte {
raw = append(raw, auts...)
return append(raw, 0x90, 0x00)
}
func TestCollectApplicationAIDsSkipsCUADPadding(t *testing.T) {
t.Parallel()
response := modem.Response{Lines: []string{
`+CUAD: "61184F10A0000000871002FFFFFFFF890302000050045553494DFFFFFFFFFFFFFFFFFFFFFFFF""61184F10A0000000871004FFFFFFFF890302000050044953494DFFFFFFFFFFFFFFFFFFFFFFFF"`,
`"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"`,
}}
data, err := parseCUADData(response)
if err != nil {
t.Fatal(err)
}
aids := collectApplicationAIDs(data)
want := []string{
"A0000000871002FFFFFFFF8903020000",
"A0000000871004FFFFFFFF8903020000",
}
if !reflect.DeepEqual(aids, want) {
t.Fatalf("AIDs = %v, want %v", aids, want)
}
}
func TestEC20AdapterISIMStrictUsesCUADFullAID(t *testing.T) {
var challenge AKAChallenge
for index := range challenge.RAND {
challenge.RAND[index] = byte(index)
challenge.AUTN[index] = byte(0xf0 + index)
}
authAPDU := buildUSIMAuthenticateAPDU(challenge)
authCommand := fmt.Sprintf(
`AT+CGLA=1,%d,"%s"`,
len(authAPDU)*2,
strings.ToUpper(hex.EncodeToString(authAPDU)),
)
encodedResponse := strings.ToUpper(hex.EncodeToString(successfulUSIMResponse()))
fullISIM := "A0000000871004FFFFFFFF8903020000"
cuad := `61184F10A0000000871002FFFFFFFF890302000050045553494D61184F10A0000000871004FFFFFFFF890302000050044953494D`
transcript := &ec20Transcript{
t: t,
steps: []ec20TranscriptStep{
{command: "AT+CPIN?", lines: []string{"+CPIN: READY"}},
{command: "AT+CIMI", lines: []string{"310280229187733"}},
{command: "AT+CCID", lines: []string{"+CCID: 89012804332291663965"}},
{command: "AT+CGSN", lines: []string{"863212060022487"}},
{command: "AT+CUAD", lines: []string{`+CUAD: "` + cuad + `"`}},
{command: "AT+CCID", lines: []string{"+CCID: 89012804332291663965"}},
{command: `AT+CCHO="` + fullISIM + `"`, lines: []string{"+CCHO: 1"}},
{
command: authCommand,
sensitive: true,
lines: []string{fmt.Sprintf(
`+CGLA: %d,"%s"`,
len(encodedResponse),
encodedResponse,
)},
},
{command: "AT+CCHC=1"},
},
}
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
if err != nil {
t.Fatal(err)
}
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
if err != nil {
t.Fatalf("ReadIdentity: %v", err)
}
result, err := adapter.AuthenticateWithPreference(context.Background(), identity, challenge, "isim_strict")
if err != nil {
t.Fatalf("AuthenticateWithPreference: %v", err)
}
if !bytes.Equal(result.RES, []byte{1, 2, 3, 4, 5, 6, 7, 8}) {
t.Fatalf("RES = %x", result.RES)
}
transcript.assertDone()
}
+4
View File
@@ -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 {
+11 -3
View File
@@ -285,6 +285,7 @@ type akaClient struct {
simIdentity vowifi.SIMIdentity
provider vowifi.AKAProvider
keys akaKeys
lastResponseStage string
challengeComplete bool
resultIndication bool
protectedSuccess bool
@@ -298,7 +299,12 @@ func newAKAClient(identity vowifi.SIMIdentity, provider vowifi.AKAProvider) (*ak
if err != nil {
return nil, err
}
return &akaClient{identity: nai, simIdentity: identity, provider: provider}, nil
return &akaClient{
identity: nai,
simIdentity: identity,
provider: provider,
lastResponseStage: "in the initial IKE_AUTH identity exchange",
}, nil
}
func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction, error) {
@@ -308,9 +314,9 @@ func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction,
}
switch packet.Code {
case eapFailure:
stage := "before the SIM AKA challenge (identity or subscription rejected)"
stage := client.lastResponseStage
if client.challengeComplete {
stage = "after the SIM AKA response (AKA result or subscription rejected)"
stage = "after the SIM AKA challenge response"
}
return eapAction{}, fmt.Errorf("%w %s", vowifi.ErrEAPAuthenticationRejected, stage)
case eapSuccess:
@@ -333,6 +339,7 @@ func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction,
Type: eapTypeIdentity,
Data: client.identity,
})
client.lastResponseStage = "after EAP-Response/Identity"
return eapAction{Response: response}, err
case eapTypeAKA:
return client.handleAKARequest(ctx, packet)
@@ -405,6 +412,7 @@ func (client *akaClient) respondAKAIdentity(identifier uint8, attributes []akaAt
Type: eapTypeAKA,
Data: data,
})
client.lastResponseStage = "after EAP-Response/AKA-Identity"
return eapAction{Response: response}, err
}
+20 -2
View File
@@ -106,16 +106,34 @@ func TestEAPFailureReportsAuthenticationStage(t *testing.T) {
t.Fatal(err)
}
_, err = client.handle(context.Background(), failure)
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "before the SIM AKA challenge") {
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "initial IKE_AUTH identity exchange") {
t.Fatalf("pre-challenge failure = %v", err)
}
client.challengeComplete = true
_, err = client.handle(context.Background(), failure)
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after the SIM AKA response") {
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after the SIM AKA challenge response") {
t.Fatalf("post-challenge failure = %v", err)
}
}
func TestEAPFailureReportsIdentityResponseStage(t *testing.T) {
client, err := newAKAClient(testSIMIdentity(), &testAKAProvider{})
if err != nil {
t.Fatal(err)
}
identityRequest, _ := marshalEAPPacket(eapPacket{
Code: eapRequest, Identifier: 4, Type: eapTypeIdentity,
})
if _, err := client.handle(context.Background(), identityRequest); err != nil {
t.Fatal(err)
}
failure, _ := marshalEAPPacket(eapPacket{Code: eapFailure, Identifier: 5})
_, err = client.handle(context.Background(), failure)
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after EAP-Response/Identity") {
t.Fatalf("identity-stage failure = %v", err)
}
}
func TestEAPAKAChallengeTypedSIMAndMAC(t *testing.T) {
result := vowifi.AKAResult{
RES: bytes.Repeat([]byte{0x91}, 8),
+57
View File
@@ -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 != ""
}
+39
View File
@@ -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)
}
}
+90 -19
View File
@@ -111,6 +111,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
group := uint16(dhMODP2048)
legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC)
advertiseEAPOnly := advertiseEAPOnlyAuthentication(request.Identity.HomeMCC, request.Identity.HomeMNC)
if legacyFirst {
group = dhMODP1024
}
@@ -245,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 {
@@ -259,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 := buildInitialEAPOnlyAuth(idi, requestedIDr, childOfferBody, tsi, tsr)
firstAuthPayloads := buildInitialEAPAuth(idi, requestedIDr, childOfferBody, tsi, tsr, advertiseEAPOnly)
authHeader := ikeHeader{
InitiatorSPI: initiatorSPI,
ResponderSPI: responseHeader.ResponderSPI,
@@ -292,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,
true, // 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++ {
@@ -319,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
@@ -358,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,
@@ -383,17 +419,17 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
}
finalAUTHs := payloadsOfType(finalPayloads, payloadAuth)
if len(finalAUTHs) != 1 {
return nil, fmt.Errorf("%w: final EAP-only response must contain exactly one MSK AUTH payload", vowifi.ErrResponderAUTHRequired)
return nil, fmt.Errorf("%w: final EAP response must contain exactly one MSK AUTH payload", vowifi.ErrResponderAUTHRequired)
}
if len(responderID.Body) == 0 {
return nil, errors.New("ike: EAP-only exchange has no initial ePDG IDr for the responder AUTH transcript")
return nil, errors.New("ike: EAP exchange has no responder IDr for the AUTH transcript")
}
finalIDs := payloadsOfType(finalPayloads, payloadIDr)
if len(finalIDs) > 1 {
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)
}
}
@@ -472,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...),
@@ -499,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"
@@ -553,6 +591,47 @@ func legacyIKEProfile(mcc, mnc string) bool {
return plmn == "23415" || plmn == "2044"
}
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 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"
}
func buildInitialEAPAuth(
idi payload,
requestedIDr payload,
childOfferBody []byte,
tsi payload,
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,
tsr,
configurationRequest(),
)
}
func buildInitialEAPOnlyAuth(
idi payload,
requestedIDr payload,
@@ -560,15 +639,7 @@ func buildInitialEAPOnlyAuth(
tsi payload,
tsr payload,
) []payload {
return []payload{
idi,
requestedIDr,
makeNotify(notifyEAPOnlyAuth, nil),
{Type: payloadSA, Body: append([]byte(nil), childOfferBody...)},
tsi,
tsr,
configurationRequest(),
}
return buildInitialEAPAuth(idi, requestedIDr, childOfferBody, tsi, tsr, true)
}
func ikeOffer(group uint16, legacyFirst bool) proposal {
@@ -877,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))
}
}
+75 -17
View File
@@ -42,15 +42,17 @@ func (reader constantReader) Read(destination []byte) (int, error) {
}
type firstAuthCaptureTransport struct {
t *testing.T
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
}
func (transport *firstAuthCaptureTransport) LocalAddr() *net.UDPAddr {
@@ -96,8 +98,16 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
return nil, err
}
group := uint16(ke.Body[0])<<8 | uint16(ke.Body[1])
if group != dhMODP1024 || len(ke.Body[4:]) != 128 {
transport.t.Fatalf("Vodafone init KE = group %d length %d", group, len(ke.Body[4:]))
wantGroup := transport.wantGroup
if wantGroup == 0 {
wantGroup = dhMODP1024
}
wantKELength := 128
if wantGroup == dhMODP2048 {
wantKELength = 256
}
if group != wantGroup || len(ke.Body[4:]) != wantKELength {
transport.t.Fatalf("init KE = group %d length %d, want group %d length %d", group, len(ke.Body[4:]), wantGroup, wantKELength)
}
serverDH, err := newDHExchange(group, constantReader{value: 0x77})
if err != nil {
@@ -108,6 +118,15 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
return nil, err
}
transport.suite = legacyTestSuite()
if group == dhMODP2048 {
transport.suite = negotiatedSuite{
EncryptionID: encryptionAESCBC,
EncryptionBits: 128,
PRFID: prfHMACSHA256,
IntegrityID: integrityHMACSHA256_128,
DHID: dhMODP2048,
}
}
transport.spii = header.InitiatorSPI
transport.spir = [8]byte{0x80, 1, 2, 3, 4, 5, 6, 7}
transport.nonceI = append([]byte(nil), nonce.Body...)
@@ -128,9 +147,9 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
Protocol: protocolIKE,
Transforms: []transform{
{Type: transformEncryption, ID: encryptionAESCBC, KeyLength: 128},
{Type: transformPRF, ID: prfHMACSHA1},
{Type: transformIntegrity, ID: integrityHMACSHA1_96},
{Type: transformDH, ID: dhMODP1024},
{Type: transformPRF, ID: transport.suite.PRFID},
{Type: transformIntegrity, ID: transport.suite.IntegrityID},
{Type: transformDH, ID: group},
},
}})
keBody := make([]byte, 4+len(serverDH.Public))
@@ -180,8 +199,8 @@ func (transport *firstAuthCaptureTransport) observeFirstAuth(packet []byte) erro
foundEAPOnly = true
}
}
if !foundEAPOnly {
transport.t.Fatal("first IKE_AUTH omitted EAP_ONLY_AUTHENTICATION")
if foundEAPOnly != transport.wantEAPOnly {
transport.t.Fatalf("first IKE_AUTH EAP_ONLY_AUTHENTICATION present=%v, want %v", foundEAPOnly, transport.wantEAPOnly)
}
for _, kind := range []uint8{payloadIDi, payloadSA, payloadTSi, payloadTSr, payloadCP} {
if _, err := onePayload(payloads, kind); err != nil {
@@ -212,7 +231,7 @@ func (unusedInstaller) Install(context.Context, ChildSAConfig) (ChildSAHandle, e
}
func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) {
capture := &firstAuthCaptureTransport{t: t}
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: true}
provider, err := NewProvider(Config{
Random: constantReader{value: 0x42},
Timeout: time.Second,
@@ -250,5 +269,44 @@ func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) {
}
}
func TestProviderO2GermanyFirstAuthUsesStandardEAPAndRequestsIMSAPN(t *testing.T) {
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: false, wantGroup: dhMODP2048}
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-o2",
Identity: vowifi.SIMIdentity{
ICCID: "8949200000000000000",
IMSI: "262030123456789",
HomeMCC: "262",
HomeMNC: "03",
},
EPDG: "epdg.epc.mnc003.mcc262.pub.3gppnetwork.org",
AKA: aka,
})
if !errors.Is(err, errFirstAuthObserved) {
t.Fatalf("Start() error = %v, want capture sentinel", err)
}
if capture.calls != 2 || capture.floated || aka.calls != 0 {
t.Fatalf("capture calls=%d floated=%v AKA calls=%d", capture.calls, capture.floated, aka.calls)
}
}
var _ io.Reader = constantReader{}
var _ datagramTransport = (*firstAuthCaptureTransport)(nil)
+50
View File
@@ -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()
+45 -1
View File
@@ -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,
)
+9 -5
View File
@@ -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 (
+94 -2
View File
@@ -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 {
@@ -88,6 +103,83 @@ func TestInitialEAPOnlyAuthCarriesAPNIDrAndNotify(t *testing.T) {
}
}
func TestInitialStandardEAPAuthOmitsEAPOnlyNotify(t *testing.T) {
idi := payload{Type: payloadIDi, Body: []byte{3, 0, 0, 0, 'u'}}
idr := payload{Type: payloadIDr, Body: []byte{2, 0, 0, 0, 'i', 'm', 's'}}
payloads := buildInitialEAPAuth(
idi,
idr,
[]byte{1, 2, 3},
dualStackTrafficSelectors(payloadTSi),
dualStackTrafficSelectors(payloadTSr),
false,
)
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 {
t.Fatal(err)
}
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 advertiseEAPOnlyAuthentication("262", mnc) {
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
}
}
if !advertiseEAPOnlyAuthentication("262", "02") || !advertiseEAPOnlyAuthentication("234", "15") {
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
}
}
func TestResponderIDrValidatorsSeparateEPDGAndAPN(t *testing.T) {
epdg := payload{
Type: payloadIDr,
+12 -2
View File
@@ -168,6 +168,7 @@ func authenticateAKA(
provider vowifi.AKAProvider,
identity vowifi.SIMIdentity,
challenge digestChallenge,
preference string,
) (akaMaterial, error) {
nonce, err := decodeAKANonce(challenge.Nonce)
if err != nil {
@@ -178,9 +179,18 @@ func authenticateAKA(
var akaChallenge vowifi.AKAChallenge
copy(akaChallenge.RAND[:], nonce[:16])
copy(akaChallenge.AUTN[:], nonce[16:32])
result, err := provider.Authenticate(ctx, identity, akaChallenge)
var result vowifi.AKAResult
if preferred, ok := provider.(vowifi.PreferredAKAProvider); ok && strings.TrimSpace(preference) != "" {
result, err = preferred.AuthenticateWithPreference(ctx, identity, akaChallenge, preference)
} else {
result, err = provider.Authenticate(ctx, identity, akaChallenge)
}
if err != nil {
return akaMaterial{}, fmt.Errorf("ims: USIM AKA authentication failed: %w", err)
application := "USIM"
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") {
application = "ISIM"
}
return akaMaterial{}, fmt.Errorf("ims: %s AKA authentication failed: %w", application, err)
}
if result.SynchronizationFailure || len(result.AUTS) > 0 {
if !result.SynchronizationFailure || len(result.AUTS) != 14 {
+37
View File
@@ -16,6 +16,21 @@ type recordingAKA struct {
challenges []vowifi.AKAChallenge
}
type recordingPreferredAKA struct {
recordingAKA
preference string
}
func (aka *recordingPreferredAKA) AuthenticateWithPreference(
ctx context.Context,
identity vowifi.SIMIdentity,
challenge vowifi.AKAChallenge,
preference string,
) (vowifi.AKAResult, error) {
aka.preference = preference
return aka.Authenticate(ctx, identity, challenge)
}
func (aka *recordingAKA) CheckReady(context.Context, vowifi.SIMIdentity) (vowifi.AKAEvidence, error) {
return vowifi.AKAEvidence{Ready: true, Application: "usim"}, nil
}
@@ -60,6 +75,7 @@ func TestAuthenticateAKAMapsNonceToTypedChallenge(t *testing.T) {
aka,
vowifi.SIMIdentity{IMSI: "001010123456789"},
digestChallenge{Nonce: base64.StdEncoding.EncodeToString(nonceBytes)},
"",
)
if err != nil {
t.Fatalf("authenticateAKA() error = %v", err)
@@ -93,6 +109,7 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) {
aka,
vowifi.SIMIdentity{},
digestChallenge{Nonce: nonce},
"",
)
if err != nil {
t.Fatalf("authenticateAKA() error = %v", err)
@@ -102,6 +119,26 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) {
}
}
func TestAuthenticateAKAUsesPreferredApplicationWhenSupported(t *testing.T) {
nonce := base64.StdEncoding.EncodeToString(make([]byte, 32))
aka := &recordingPreferredAKA{recordingAKA: recordingAKA{
result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4}},
}}
_, err := authenticateAKA(
context.Background(),
aka,
vowifi.SIMIdentity{IMSI: "310280229187733"},
digestChallenge{Nonce: nonce},
"isim_strict",
)
if err != nil {
t.Fatalf("authenticateAKA() error = %v", err)
}
if aka.preference != "isim_strict" {
t.Fatalf("preference = %q, want isim_strict", aka.preference)
}
}
func TestBuildDigestAuthorizationCarriesAUTSWithEmptyResponse(t *testing.T) {
authorization := buildDigestAuthorization(
digestChallenge{
+117 -12
View File
@@ -271,13 +271,22 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
mnc = "0" + mnc
}
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
privateDomain := domain
publicDomain := domain
if vowifi.IsATT310280(identity) {
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
// the generic 3GPP PLMN IMS domain.
domain = "one.att.net"
privateDomain = "private.att.net"
publicDomain = "one.att.net"
}
privateIdentity := config.PrivateIdentity
if privateIdentity == "" {
privateIdentity = imsi + "@" + domain
privateIdentity = imsi + "@" + privateDomain
}
publicIdentity := config.PublicIdentity
if publicIdentity == "" {
publicIdentity = "sip:" + imsi + "@" + domain
publicIdentity = "sip:" + imsi + "@" + publicDomain
}
if strings.ContainsAny(privateIdentity+publicIdentity, "\r\n") ||
!strings.Contains(privateIdentity, "@") ||
@@ -534,15 +543,33 @@ func newSession(
refreshCancel()
return nil, errors.New("ims: protected local IP address is unavailable")
}
protectedClientPort := provider.config.ProtectedClientPort
protectedServerPort := provider.config.ProtectedServerPort
if vowifi.IsATT310280(request.Identity) && protectedServerPort == 0 {
protectedServerPort = 6000
}
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)
if vowifi.IsATT310280(request.Identity) {
proposal.integrityAlgorithms = []string{"hmac-sha-1-96"}
proposal.encryptionAlgorithmsList = []string{"aes-cbc"}
}
session.securityProposal = proposal
protectedTCP, err := net.ListenTCP(
"tcp",
@@ -567,6 +594,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 +677,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)
}
@@ -697,7 +748,11 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse
if err != nil {
return nil, err
}
material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge)
preference := ""
if vowifi.IsATT310280(session.request.Identity) {
preference = "isim_strict"
}
material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge, preference)
if err != nil {
return nil, err
}
@@ -757,6 +812,10 @@ func (session *Session) buildRegister(
authorizationHeader string,
authorization string,
) ([]byte, error) {
att310280 := vowifi.IsATT310280(session.request.Identity)
if att310280 {
expires = 18400
}
branch, err := randomHex(12)
if err != nil {
return nil, err
@@ -775,6 +834,34 @@ func (session *Session) buildRegister(
session.instanceID,
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
)
if att310280 {
contact = fmt.Sprintf(
`<sip:%s@%s;transport=%s>;+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip;`+
`+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`,
session.identity.user,
contactAddress,
session.transport,
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
session.instanceID,
)
}
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"
}
if att310280 {
supported = "path,sec-agree,gruu"
}
userAgent := strings.TrimSpace(session.provider.config.UserAgent)
if att310280 && (userAgent == "" || userAgent == "vocat/1") {
userAgent = "SimAdmin VoWiFi"
}
lines := []string{
"REGISTER " + requestURI + " SIP/2.0",
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", transportUpper, local, branch),
@@ -786,14 +873,25 @@ 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",
"User-Agent: " + session.provider.config.UserAgent,
"Supported: " + supported,
"Allow: " + allow,
"User-Agent: " + userAgent,
}
if o2Germany {
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
} else if att310280 {
lines = append(lines,
"P-Preferred-Identity: <"+session.identity.public+">",
`P-Visited-Network-ID: "one.att.net"`,
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
"Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0",
"Accept-Contact: *;+g.3gpp.smsip",
`Accept-Contact: *;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"`,
)
}
if session.securityOffered() {
lines = append(
lines,
"Security-Client: "+session.securityProposal.headerValue(),
lines = append(lines,
"Security-Client: "+session.securityClientValue(),
"Require: sec-agree",
"Proxy-Require: sec-agree",
)
@@ -1194,7 +1292,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)
}
}
+132
View File
@@ -379,6 +379,138 @@ 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 TestATT310280DeriveIdentitiesUsesISIMDomains(t *testing.T) {
identities, err := deriveIdentities(vowifi.SIMIdentity{
IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280",
}, Config{})
if err != nil {
t.Fatalf("deriveIdentities() error = %v", err)
}
if identities.domain != "one.att.net" ||
identities.private != "[email protected]" ||
identities.public != "sip:[email protected]" {
t.Fatalf("AT&T identities = %#v", identities)
}
}
func TestATT310280InitialRegisterMatchesProvisionedProfile(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
identity := vowifi.SIMIdentity{
IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280",
}
identities, err := deriveIdentities(identity, Config{})
if err != nil {
t.Fatal(err)
}
session := &Session{
provider: &Provider{config: Config{SecurityMode: SecurityRequired, UserAgent: "vocat/1"}},
request: vowifi.IMSRequest{Identity: identity},
identity: identities,
endpoint: pcscfEndpoint{host: "pcscf.example", port: 5060},
transport: "tcp",
conn: client,
callID: "att-test",
fromTag: "tag",
instanceID: "urn:uuid:test",
securityProposal: securityProposal{
spiClient: 1546543, spiServer: 1546542,
portClient: 32773, portServer: 6000,
integrityAlgorithms: []string{"hmac-sha-1-96"},
encryptionAlgorithmsList: []string{"aes-cbc"},
},
}
packet, err := session.buildRegister(1, 3600, "", "")
if err != nil {
t.Fatalf("buildRegister() error = %v", err)
}
request := string(packet)
for _, want := range []string{
"REGISTER sip:one.att.net SIP/2.0",
"Expires: 18400",
"Supported: path,sec-agree,gruu",
"User-Agent: SimAdmin VoWiFi",
`+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip`,
"P-Preferred-Identity: <sip:[email protected]>",
`P-Visited-Network-ID: "one.att.net"`,
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
"Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0",
"Accept-Contact: *;+g.3gpp.smsip",
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=1546543; spi-s=1546542; port-c=32773; port-s=6000",
`username="[email protected]"`,
`uri="sip:one.att.net"`,
} {
if !strings.Contains(request, want) {
t.Fatalf("AT&T REGISTER omits %q:\n%s", want, request)
}
}
}
func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
var callID string
for step := 0; step < 3; step++ {
+212 -47
View File
@@ -12,6 +12,8 @@ import (
"sort"
"strconv"
"strings"
"vocat/internal/vowifi"
)
type SecurityMode string
@@ -39,6 +41,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 +71,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 +108,98 @@ 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 (session *Session) securityClientValue() string {
if vowifi.IsATT310280(session.request.Identity) {
return fmt.Sprintf(
"ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=%d; spi-s=%d; port-c=%d; port-s=%d",
session.securityProposal.spiClient,
session.securityProposal.spiServer,
session.securityProposal.portClient,
session.securityProposal.portServer,
)
}
return session.securityProposal.headerValue()
}
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 +287,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 +438,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 +503,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 +720,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 +820,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 +834,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 {
+43 -4
View File
@@ -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)
+153 -1
View File
@@ -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
+24 -8
View File
@@ -28,21 +28,37 @@ func (resolver ProxyResolver) Resolve(
}
deviceID := strings.TrimSpace(request.DeviceID)
iccid := strings.TrimSpace(request.ICCID)
if deviceID == "" || iccid == "" {
if deviceID == "" {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
if errors.Is(err, store.ErrNotFound) {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
var upstreamID string
if iccid != "" {
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
if err == nil {
upstreamID = binding.UpstreamProxyID
} else if !errors.Is(err, store.ErrNotFound) {
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for ICCID %s: %w", iccid, err)
}
}
if err != nil {
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for ICCID %s: %w", iccid, err)
if upstreamID == "" {
country, found := device.CountryForMCC(strings.TrimSpace(request.HomeMCC))
if !found {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
rule, ruleErr := resolver.Store.CountryRule(ctx, country)
if errors.Is(ruleErr, store.ErrNotFound) || (ruleErr == nil && !rule.Enabled) {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
if ruleErr != nil {
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy country rule for MCC %s: %w", request.HomeMCC, ruleErr)
}
upstreamID = rule.UpstreamProxyID
}
upstream, err := resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID)
upstream, err := resolver.Store.UpstreamProxy(ctx, upstreamID)
if err != nil {
return vowifi.ProxyRoute{}, fmt.Errorf(
"load upstream proxy %q for device %s: %w",
binding.UpstreamProxyID,
upstreamID,
deviceID,
err,
)
+37 -3
View File
@@ -79,7 +79,7 @@ func TestProxyResolverDoesNotLeakBindingToAnotherProfileOnSameDevice(t *testing.
}
}
func TestProxyResolverDoesNotUseCountryRuleWithoutDeviceBinding(t *testing.T) {
func TestProxyResolverUsesCountryRuleWithoutICCIDBinding(t *testing.T) {
database := testStore(t)
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{
ID: "legacy", Name: "Legacy", Addr: "127.0.0.1:1080", Enabled: true,
@@ -98,8 +98,42 @@ func TestProxyResolverDoesNotUseCountryRuleWithoutDeviceBinding(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if route.Mode != vowifi.ProxyModeDirect {
t.Fatalf("route = %#v, want direct", route)
if route.Mode != vowifi.ProxyModeSOCKS5 || route.ID != "legacy" {
t.Fatalf("route = %#v, want MCC country fallback", route)
}
}
func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
database := testStore(t)
for _, proxy := range []store.UpstreamProxy{
{ID: "profile", Name: "Profile", Addr: "127.0.0.1:1080", Enabled: true},
{ID: "country", Name: "Country", Addr: "127.0.0.1:1081", Enabled: true},
} {
if err := database.UpsertUpstreamProxy(context.Background(), proxy); err != nil {
t.Fatal(err)
}
}
if err := database.UpsertCountryRule(context.Background(), store.CountryRule{
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "country", Enabled: true,
}); err != nil {
t.Fatal(err)
}
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil {
t.Fatal(err)
}
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Physical SIM", UpstreamProxyID: "profile",
}); err != nil {
t.Fatal(err)
}
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
})
if err != nil {
t.Fatal(err)
}
if route.ID != "profile" {
t.Fatalf("route = %#v, want ICCID binding", route)
}
}
+4 -9
View File
@@ -645,18 +645,13 @@ func DeriveEPDG(identity SIMIdentity) (string, error) {
}
return strings.ToLower(configured), nil
}
if IsATT310280(identity) {
return att310280EPDG, nil
}
if err := identity.validate(); err != nil {
return "", err
}
mnc := strings.TrimSpace(identity.HomeMNC)
for len(mnc) < 3 {
mnc = "0" + mnc
}
return fmt.Sprintf(
"epdg.epc.mnc%s.mcc%s.pub.3gppnetwork.org",
mnc,
strings.TrimSpace(identity.HomeMCC),
), nil
return standardEPDGHostname(identity.HomeMCC, identity.HomeMNC), nil
}
func normalizeProxyRoute(route ProxyRoute) (ProxyRoute, error) {
+3 -3
View File
@@ -53,18 +53,18 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
mncLength := identity.MNCLength
if mncLength != 2 && mncLength != 3 {
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
return SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}, nil
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}), nil
}
return SIMIdentity{}, ErrEC20MNCUnavailable
}
if len(identity.IMSI) < 3+mncLength {
return SIMIdentity{}, errors.New("vocat: USB SIM IMSI is shorter than its EF_AD home PLMN")
}
return SIMIdentity{
return applyAssignedCarrierRoute(SIMIdentity{
ICCID: identity.ICCID, IMSI: identity.IMSI,
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
SMSC: identity.SMSC,
}, nil
}), nil
}
func (adapter *PCSCAdapter) ReadSMSCenter(ctx context.Context, deviceID string) (string, error) {
+10
View File
@@ -125,6 +125,16 @@ func TestDeriveEPDGUsesExplicitPLMNAndNeverIMSIHeuristics(t *testing.T) {
},
want: "epdg.epc.mnc260.mcc310.pub.3gppnetwork.org",
},
{
name: "AT&T 310280 uses carrier endpoint",
identity: SIMIdentity{
ICCID: "89012804332291663965",
IMSI: "310280229187733",
HomeMCC: "310",
HomeMNC: "280",
},
want: "epdg.epc.att.net",
},
{
name: "explicit endpoint",
identity: SIMIdentity{
+8
View File
@@ -317,6 +317,14 @@ type AKAProvider interface {
Authenticate(context.Context, SIMIdentity, AKAChallenge) (AKAResult, error)
}
// PreferredAKAProvider optionally lets an AKA provider select a carrier-
// provisioned application such as ISIM. Providers that only expose USIM keep
// implementing AKAProvider unchanged.
type PreferredAKAProvider interface {
AKAProvider
AuthenticateWithPreference(context.Context, SIMIdentity, AKAChallenge, string) (AKAResult, error)
}
// RadioController owns the host/modem radio projection. EnterVoWiFiRFOff must
// not toggle the independent pure-airplane policy; Restore must return to the
// captured pre-transaction state.
+10 -4
View File
@@ -345,11 +345,14 @@ FIRST_INSTALL=0
INITIAL_ADMIN_PASSWORD=""
bootstrap_admin() {
local candidate="${1:-$BINARY_PATH}"
local secret result
secret=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')
[ -n "$secret" ] || die "Failed to generate a random secret." "Failed to generate a random secret."
result=$(printf '%s\n' "$secret" | "$BINARY_PATH" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \
die "Failed to initialize the administrator." "Failed to initialize the administrator."
result=$(printf '%s\n' "$secret" | "$candidate" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \
die \
"待安装版本无法读取或升级现有数据库;当前程序尚未被替换,请检查数据库与版本兼容性。" \
"The candidate version cannot read or migrate the existing database; the installed program was not replaced. Check database and version compatibility."
if [ "$result" = "created" ]; then
FIRST_INSTALL=1
INITIAL_ADMIN_PASSWORD="$secret"
@@ -532,9 +535,12 @@ fi
resolve_target_version
skip_if_equal
download_and_verify
install_binary
ensure_data_dir
bootstrap_admin
# Validate the database with the downloaded binary before replacing the
# installed program. In particular, a release with an older schema must never
# overwrite a newer working binary and leave the service in a restart loop.
bootstrap_admin "${VOCAT_TMP}/vocat"
install_binary
setup_env
write_service
enable_and_start
+63 -8
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""Refresh VoCat's offline PLMN name table from Android's carrier database.
"""Refresh VoCat's offline carrier table from Android's carrier database.
The AOSP carrier ID table is maintained for Android's own carrier recognition.
Only unconstrained MCC/MNC records are used here: MVNO matches that also require
an SPN, IMSI prefix, GID or ICCID prefix must not rename the serving MNO.
The compact ``c`` map remains the MCC/MNC-only fallback. The ``r`` list keeps
Android's SIM-identity rules (IMSI, ICCID, SPN and GID) so travel eSIMs and
MVNOs sharing an MNO's PLMN can be identified without hard-coded exceptions.
"""
from __future__ import annotations
@@ -125,10 +125,61 @@ def aosp_carriers(text: str) -> dict[str, str]:
return carriers
RULE_FIELDS = {
"mccmnc_tuple": "m",
"imsi_prefix_xpattern": "x",
"spn": "s",
"gid1": "g1",
"gid2": "g2",
"iccid_prefix": "i",
}
def textproto_strings(block: str, field: str) -> list[str]:
return [
json.loads(value)
for value in re.findall(
rf"^\s*{re.escape(field)}:\s*(\"(?:\\.|[^\"\\])*\")",
block,
re.M,
)
]
def aosp_identity_rules(text: str) -> list[dict[str, object]]:
"""Return rules VoCat can evaluate using values read directly from a SIM.
Android combines different fields with AND and repeated values of one field
with OR. Rules that also require APN, PNN/PLMN or carrier certificates are
omitted until those inputs are available; treating an unavailable input as
a wildcard would incorrectly identify subscriptions.
"""
supported = set(RULE_FIELDS)
rules: list[dict[str, object]] = []
for carrier in braced_blocks(text, "carrier_id"):
name = textproto_string(carrier, "carrier_name").strip()
if not name:
continue
for attribute in braced_blocks(carrier, "carrier_attribute"):
fields = set(re.findall(r"^\s*([a-zA-Z0-9_]+)\s*:", attribute, re.M))
if not fields.issubset(supported) or fields == {"mccmnc_tuple"}:
continue
rule: dict[str, object] = {"n": name}
for source_field, output_field in RULE_FIELDS.items():
values = textproto_strings(attribute, source_field)
if values:
rule[output_field] = values
if rule.get("m"):
rules.append(rule)
return rules
def main() -> None:
with urllib.request.urlopen(SOURCE_URL, timeout=30) as response:
source = base64.b64decode(response.read()).decode("utf-8")
names = aosp_carriers(source)
identity_rules = aosp_identity_rules(source)
table = json.loads(FRONTEND_TABLE.read_text(encoding="utf-8"))
countries: dict[str, str] = table["i"]
countries.update({str(mcc): "us" for mcc in range(310, 317)})
@@ -149,19 +200,23 @@ def main() -> None:
"c": dict(sorted(carriers.items())),
"i": dict(sorted(countries.items())),
"t": sorted(set(table["t"])),
"r": identity_rules,
"meta": {
"source": "Android Open Source Project carrier_list.textpb",
"source_url": SOURCE_URL.removesuffix("?format=TEXT"),
"aosp_version": version_match.group(1) if version_match else "unknown",
"aosp_generic_records": len(names),
"aosp_identity_rules": len(identity_rules),
},
}
encoded = json.dumps(output, ensure_ascii=False, separators=(",", ":")) + "\n"
FRONTEND_TABLE.write_text(encoded, encoding="utf-8", newline="\n")
BACKEND_TABLE.write_text(encoded, encoding="utf-8", newline="\n")
frontend_encoded = json.dumps(output, ensure_ascii=False, indent=4) + "\n"
backend_encoded = json.dumps(output, ensure_ascii=False, separators=(",", ":")) + "\n"
FRONTEND_TABLE.write_text(frontend_encoded, encoding="utf-8", newline="\n")
BACKEND_TABLE.write_text(backend_encoded, encoding="utf-8", newline="\n")
print(
f"updated {len(carriers)} PLMN records "
f"({len(names)} generic AOSP records, version {output['meta']['aosp_version']})"
f"({len(names)} generic records, {len(identity_rules)} identity rules, "
f"version {output['meta']['aosp_version']})"
)
@@ -4,7 +4,7 @@ import { FieldRow } from "./FieldRow";
import { useShowSensitive } from "./shared";
import type { DeviceDetail } from "./types";
import { useI18n } from "../../lib/i18n";
import { carrierBrandIso } from "../../lib/carrier";
import { carrierIso } from "../../lib/carrier";
import { CountryFlag } from "../CountryFlag";
export interface OverviewSimPanelProps {
@@ -22,7 +22,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, customPhoneNumber
const sensitive = !showSensitive;
const activeEsim = (device.activeEsimProfileName || "").trim();
const flightOn = device.vowifiActive || modem?.operatingMode === 0 || modem?.operatingMode === 4;
const carrierCountryCode = carrierBrandIso(modem?.nativeSpn, modem?.imsi);
const carrierCountryCode = String(modem?.homeCarrierCountryCode ?? "").trim() || carrierIso(modem?.imsi);
const displayedPhoneNumber = customPhoneNumber?.trim() || device.localPhone || "--";
const backendLabel =
device.backendMode === "qmi" ? "QMI" : device.backendMode === "mbim" ? "MBIM" : device.backendMode === "at" ? "AT" : "Auto";
@@ -94,7 +94,7 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {
<div className="space-y-1.5 border-t border-gray-100 px-3 pb-2 pt-2 text-sm text-gray-700 dark:border-white/5 dark:text-gray-200">
{eapRejected ? (
<div className="mb-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800 dark:border-amber-500/25 dark:bg-amber-500/10 dark:text-amber-200">
{t("已连接到运营商 ePDG,但运营商拒绝了此 SIM 的 EAP-AKA 鉴权。通常表示该 Profile 未开通 IMS/WiFi Calling;重复重连不会解决,需要换用支持 VoWiFi 的运营商 Profile。")}
{t("已连接到运营商 ePDG,但 EAP-AKA 流程被拒绝。可能是初始身份、运营商 IKE/EAP 兼容性或订阅策略问题;请根据错误详情确认失败阶段。")}
</div>
) : null}
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
+10 -12
View File
@@ -259,20 +259,18 @@ export function simOperatorDisplay(device?: DeviceDetail | null): string {
const spn = String(modem?.nativeSpn ?? "").trim();
const name = oplPnnName(modem) || firstPnnName(modem?.pnn);
const plmn = plmnOf(modem);
// EF_SPN is the SIM's customer-facing brand. Do not append the currently
// visited PLMN: a roaming Lebara UK SIM on a Chinese network would otherwise
// be mislabeled as "Lebara (460xx)". Append the home/authentication PLMN
// resolved from IMSI instead, so GigSky on 222-01 renders as
// "GigSky (22201)" even while roaming.
if (spn) {
const home = lookupCarrier(modem?.imsi);
return withPlmn(spn, home ? home.mcc + home.mnc : cardPlmnOf(modem));
}
if (name) return withPlmn(name, plmn);
// Home ("original") carrier resolved from the SIM's IMSI via the MCC/MNC table.
// Readable even when the modem isn't camped (VoWiFi RF-off / flight mode).
// "Original Carrier" means the IMSI home/authentication network. EF_SPN is
// only a profile-supplied display brand (travel eSIMs and MVNOs may put their
// storefront name there), so it must not override a known home PLMN.
const resolvedName = String(modem?.homeCarrierName ?? "").trim();
const resolvedPLMN = String(modem?.homeCarrierPlmn ?? "").trim();
if (resolvedName) return withPlmn(resolvedName, resolvedPLMN);
const carrier = lookupCarrier(modem?.imsi);
if (carrier) return withPlmn(carrier.name, carrier.mcc + carrier.mnc);
// If the bundled carrier database cannot resolve the home PLMN, retain the
// card's own labels as graceful, data-driven fallbacks.
if (spn) return withPlmn(spn, cardPlmnOf(modem));
if (name) return withPlmn(name, plmn);
if (plmn) return plmn;
const cardPlmn = cardPlmnOf(modem);
if (cardPlmn) return cardPlmn;
@@ -21,6 +21,10 @@ function profileLabel(profile: { name?: string; serviceProviderName?: string; ic
return String(profile.name || profile.serviceProviderName || profile.iccid).trim();
}
function currentDeviceICCID(device: DeviceListItem) {
return String(device.modem?.iccid || device.vowifiRuntime?.iccid || "").trim();
}
export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
const { t } = useI18n();
const { open, proxy, proxies, devices, bindings, busy, onAdd, onDelete, onClose } = props;
@@ -30,7 +34,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
const proxyName = proxy?.name || proxy?.id || "";
const deviceKey = devices
.map((device) => `${device.id}:${String(device.modem?.iccid || "").trim()}`)
.map((device) => `${device.id}:${currentDeviceICCID(device)}`)
.sort()
.join("|");
const current = useMemo(
@@ -53,7 +57,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
let active = true;
setLoadingProfiles(true);
Promise.allSettled(devices.map(async (device) => {
const currentICCID = String(device.modem?.iccid || "").trim();
const currentICCID = currentDeviceICCID(device);
let installed: ProfileProxyCandidate[] = [];
try {
const data = await api<EsimOverview>(`/devices/${encodeURIComponent(device.id)}/esim`);
@@ -101,7 +101,7 @@ export function NetworkAccessCard({
</Button>
</div>
<p className="text-[10px] text-gray-400">
{t("在内置内网网段之外始终放行的 CIDR 或单个 IP(例如 203.0.113.0/24)。")}
{t("在内置内网网段之外始终放行的 CIDR 或单个 IP;也允许通知推送访问这些目标地址(例如 198.18.0.0/15)。")}
</p>
{cidrs.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/30 py-2 text-center text-xs text-gray-400 dark:border-white/10 dark:bg-white/5">
-10
View File
@@ -47,13 +47,3 @@ export function carrierIso(imsi?: string): string {
if (hit) return hit.iso;
return data.i[imsiDigits(imsi).slice(0, 3)] ?? "";
}
// carrierBrandIso keeps the normal IMSI country flag for branded/MVNO SIMs.
// Lebara UK's Vodafone-NL-hosted 204-04 eSIM is the one known exception: its
// customer-facing country is GB even though AKA must continue using 204-04.
export function carrierBrandIso(spn?: string, imsi?: string): string {
const brand = String(spn ?? "").trim().toLowerCase();
const digits = imsiDigits(imsi);
if (brand.includes("lebara") && digits.startsWith("20404")) return "gb";
return carrierIso(imsi);
}
+4 -4
View File
@@ -352,8 +352,8 @@ export const EN_DICT: Record<string, string> = {
"Opening to the public internet greatly expands the attack surface. Use a strong password and switch back to Internal Only as soon as possible.",
: "Additional Allowed Ranges",
: "Add Range",
"在内置内网网段之外始终放行的 CIDR 或单个 IP(例如 203.0.113.0/24)。":
"CIDRs or single IPs always allowed in addition to the built-in internal ranges (e.g. 203.0.113.0/24).",
"在内置内网网段之外始终放行的 CIDR 或单个 IP;也允许通知推送访问这些目标地址(例如 198.18.0.0/15)。":
"CIDRs or single IPs always allowed in addition to the built-in internal ranges; notification delivery may also access these destinations (e.g. 198.18.0.0/15).",
: "No additional allowed ranges",
: "Trust Proxy Headers",
"仅在系统位于可信反向代理之后时开启,按 X-Forwarded-For 判定来源;否则客户端可伪造该头绕过内网限制。":
@@ -753,8 +753,8 @@ export const EN_DICT: Record<string, string> = {
"扫描中...": "Scanning...",
"扫描可用网络": "Scan Available Networks",
"不可注册": "Unavailable",
"已连接到运营商 ePDG,但运营商拒绝了此 SIM 的 EAP-AKA 鉴权。通常表示该 Profile 未开通 IMS/WiFi Calling;重复重连不会解决,需要换用支持 VoWiFi 的运营商 Profile。":
"The carrier ePDG was reached, but it rejected EAP-AKA authentication for this SIM. The profile usually has no IMS/Wi-Fi Calling entitlement; reconnecting will not fix it, so use a carrier profile that supports VoWiFi.",
"已连接到运营商 ePDG,但 EAP-AKA 流程被拒绝。可能是初始身份、运营商 IKE/EAP 兼容性或订阅策略问题;请根据错误详情确认失败阶段。":
"The carrier ePDG was reached, but the EAP-AKA flow was rejected. This can be caused by the initial identity, carrier IKE/EAP interoperability, or subscription policy; check the error details for the failing stage.",
"扫描结果只代表模组在当前位置实际收到的运营商信号,不代表模组支持的全部运营商;禁用网络表示当前 SIM 不允许注册。":
"Scan results show only networks the modem can currently receive, not every operator the hardware supports. A forbidden network cannot be used by the current SIM.",
"扫描网络失败": "Network scan failed",
+5247 -2
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -64,6 +64,9 @@ export interface ModemSummary {
cardMcc?: string;
cardMnc?: string;
cardCountry?: string;
homeCarrierName?: string;
homeCarrierPlmn?: string;
homeCarrierCountryCode?: string;
serviceBlocked?: boolean;
blockedReason?: string;
networkMode: string;