7 Commits
Author SHA1 Message Date
Meng MengandGitHub 2dceb74e61 Feat/admin credentials pr limit (#19)
* feat: reset admin credentials without requiring current password and update related prompts

* feat: update password requirements to a minimum of 6 characters for admin credentials
2026-08-14 21:52:53 +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
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
29 changed files with 1048 additions and 65 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)
+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())
+12
View File
@@ -2328,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)
@@ -2370,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 {
+14
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" {
+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()
}
+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{
+56 -5
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, "@") ||
@@ -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(
`<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"
@@ -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",
)
+69
View File
@@ -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 != "[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++ {
+15
View File
@@ -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"
+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