From 812bfe7cdcb74b66ac7e8464f864cfa8d35e5a43 Mon Sep 17 00:00:00 2001 From: Meng Meng <227010654+MengMengCode@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:38:15 +0800 Subject: [PATCH 1/3] feat: reset admin credentials without requiring current password and update related prompts (#17) --- .github/workflows/pr-size-limit.yml | 80 +++++++++++++++++++++++++++++ cmd/vocat/menu.go | 43 ++++++++-------- cmd/vocat/menu_test.go | 17 ++++++ internal/auth/service.go | 18 +++++++ internal/auth/service_test.go | 41 +++++++++++++++ 5 files changed, 176 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/pr-size-limit.yml diff --git a/.github/workflows/pr-size-limit.yml b/.github/workflows/pr-size-limit.yml new file mode 100644 index 0000000..65ad327 --- /dev/null +++ b/.github/workflows/pr-size-limit.yml @@ -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 diff --git a/cmd/vocat/menu.go b/cmd/vocat/menu.go index d4746a8..4f90c7a 100644 --- a/cmd/vocat/menu.go +++ b/cmd/vocat/menu.go @@ -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_username": {"新用户名(直接回车保留 %s): ", "New username (Enter to keep %s): "}, "new_pw": {"新密码 (至少 12 位): ", "New password (min 12 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." diff --git a/cmd/vocat/menu_test.go b/cmd/vocat/menu_test.go index 2cf3d40..a440370 100644 --- a/cmd/vocat/menu_test.go +++ b/cmd/vocat/menu_test.go @@ -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) + } + } +} diff --git a/internal/auth/service.go b/internal/auth/service.go index 7b587e7..e9266b4 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -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) < 12 || len(password) > 1024 { + return errors.New("administrator password must contain between 12 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) { diff --git a/internal/auth/service_test.go b/internal/auth/service_test.go index b804a2e..7a9f028 100644 --- a/internal/auth/service_test.go +++ b/internal/auth/service_test.go @@ -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) From edbd0ef4d1139c59431ca8607c344029652b1253 Mon Sep 17 00:00:00 2001 From: Meng Meng <227010654+MengMengCode@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:59:45 +0800 Subject: [PATCH 2/3] fix: prevent schema-incompatible installer downgrades (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add schema 17–19 compatibility migrations --- cmd/vocat/install_script_test.go | 31 ++++++++ internal/store/domain_test.go | 118 +++++++++++++++++++++++++++++++ internal/store/migrations.go | 117 ++++++++++++++++++++++++++++++ internal/store/store.go | 5 +- scripts/install.sh | 14 ++-- 5 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 cmd/vocat/install_script_test.go diff --git a/cmd/vocat/install_script_test.go b/cmd/vocat/install_script_test.go new file mode 100644 index 0000000..14668cc --- /dev/null +++ b/cmd/vocat/install_script_test.go @@ -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") + } +} diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index e7129a8..14cd68e 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -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") diff --git a/internal/store/migrations.go b/internal/store/migrations.go index cd0ca99..f3c7222 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -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 } diff --git a/internal/store/store.go b/internal/store/store.go index c165bda..e68f87c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 } diff --git a/scripts/install.sh b/scripts/install.sh index 1a2eef6..f0a0913 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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 From 636d4e8a693dd447d5888e5662032ccc0fa74d17 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:15:34 +0800 Subject: [PATCH 3/3] feat: add XeSIM CTE and RedPocket VoWiFi compatibility --- internal/vowifi/carrier_compat.go | 60 ++++++++++ internal/vowifi/carrier_compat_test.go | 56 ++++++++++ internal/vowifi/ec20_adapter.go | 145 +++++++++++++++++++++++-- internal/vowifi/ec20_adapter_test.go | 94 ++++++++++++++++ internal/vowifi/ims/digest.go | 14 ++- internal/vowifi/ims/digest_test.go | 37 +++++++ internal/vowifi/ims/provider.go | 61 ++++++++++- internal/vowifi/ims/provider_test.go | 69 ++++++++++++ internal/vowifi/ims/security.go | 15 +++ internal/vowifi/orchestrator.go | 13 +-- internal/vowifi/pcsc_adapter.go | 6 +- internal/vowifi/phone_test.go | 10 ++ internal/vowifi/types.go | 8 ++ 13 files changed, 559 insertions(+), 29 deletions(-) create mode 100644 internal/vowifi/carrier_compat.go create mode 100644 internal/vowifi/carrier_compat_test.go diff --git a/internal/vowifi/carrier_compat.go b/internal/vowifi/carrier_compat.go new file mode 100644 index 0000000..8283389 --- /dev/null +++ b/internal/vowifi/carrier_compat.go @@ -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), + ) +} diff --git a/internal/vowifi/carrier_compat_test.go b/internal/vowifi/carrier_compat_test.go new file mode 100644 index 0000000..51ee4c5 --- /dev/null +++ b/internal/vowifi/carrier_compat_test.go @@ -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) + } + } +} diff --git a/internal/vowifi/ec20_adapter.go b/internal/vowifi/ec20_adapter.go index 19ce5ea..8614dee 100644 --- a/internal/vowifi/ec20_adapter.go +++ b/internal/vowifi/ec20_adapter.go @@ -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 diff --git a/internal/vowifi/ec20_adapter_test.go b/internal/vowifi/ec20_adapter_test.go index 6d28cdf..9551e84 100644 --- a/internal/vowifi/ec20_adapter_test.go +++ b/internal/vowifi/ec20_adapter_test.go @@ -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() +} diff --git a/internal/vowifi/ims/digest.go b/internal/vowifi/ims/digest.go index e19f6f9..bdfa15c 100644 --- a/internal/vowifi/ims/digest.go +++ b/internal/vowifi/ims/digest.go @@ -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 { diff --git a/internal/vowifi/ims/digest_test.go b/internal/vowifi/ims/digest_test.go index c1f953b..e80ced3 100644 --- a/internal/vowifi/ims/digest_test.go +++ b/internal/vowifi/ims/digest_test.go @@ -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{ diff --git a/internal/vowifi/ims/provider.go b/internal/vowifi/ims/provider.go index 44ce987..a8dffc5 100644 --- a/internal/vowifi/ims/provider.go +++ b/internal/vowifi/ims/provider.go @@ -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, "@") || @@ -536,6 +545,9 @@ func newSession( } 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 @@ -554,6 +566,10 @@ func newSession( 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", @@ -732,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 } @@ -792,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 @@ -810,6 +834,17 @@ func (session *Session) buildRegister( session.instanceID, "urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel", ) + if att310280 { + contact = fmt.Sprintf( + `;+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" @@ -820,6 +855,13 @@ func (session *Session) buildRegister( 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), @@ -833,14 +875,23 @@ func (session *Session) buildRegister( fmt.Sprintf("Expires: %d", expires), "Supported: " + supported, "Allow: " + allow, - "User-Agent: " + session.provider.config.UserAgent, + "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(), + "Security-Client: "+session.securityClientValue(), "Require: sec-agree", "Proxy-Require: sec-agree", ) diff --git a/internal/vowifi/ims/provider_test.go b/internal/vowifi/ims/provider_test.go index ed1fda2..4c9631d 100644 --- a/internal/vowifi/ims/provider_test.go +++ b/internal/vowifi/ims/provider_test.go @@ -442,6 +442,75 @@ func TestO2GermanyInitialRegisterMatchesSupportedIMSProfile(t *testing.T) { } } +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 != "310280229187733@private.att.net" || + identities.public != "sip:310280229187733@one.att.net" { + 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: ", + `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="310280229187733@private.att.net"`, + `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++ { diff --git a/internal/vowifi/ims/security.go b/internal/vowifi/ims/security.go index 563acc4..ab1e91e 100644 --- a/internal/vowifi/ims/security.go +++ b/internal/vowifi/ims/security.go @@ -12,6 +12,8 @@ import ( "sort" "strconv" "strings" + + "vocat/internal/vowifi" ) type SecurityMode string @@ -143,6 +145,19 @@ func (proposal securityProposal) headerValue() string { 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" diff --git a/internal/vowifi/orchestrator.go b/internal/vowifi/orchestrator.go index 3c29661..ed5f665 100644 --- a/internal/vowifi/orchestrator.go +++ b/internal/vowifi/orchestrator.go @@ -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) { diff --git a/internal/vowifi/pcsc_adapter.go b/internal/vowifi/pcsc_adapter.go index 4709109..0b3551e 100644 --- a/internal/vowifi/pcsc_adapter.go +++ b/internal/vowifi/pcsc_adapter.go @@ -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) { diff --git a/internal/vowifi/phone_test.go b/internal/vowifi/phone_test.go index 9b5e3f4..055bc4e 100644 --- a/internal/vowifi/phone_test.go +++ b/internal/vowifi/phone_test.go @@ -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{ diff --git a/internal/vowifi/types.go b/internal/vowifi/types.go index cfe09ad..c5a8d11 100644 --- a/internal/vowifi/types.go +++ b/internal/vowifi/types.go @@ -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.