mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-15 12:23:42 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dceb74e61 | ||
|
|
636d4e8a69 | ||
|
|
edbd0ef4d1 | ||
|
|
812bfe7cdc | ||
|
|
dbc6db4f08 | ||
|
|
57539df603 | ||
|
|
ecce22eacf | ||
|
|
d37a191011 | ||
|
|
ba28c7f79a | ||
|
|
773b44aad6 | ||
|
|
2917378da0 | ||
|
|
69b1c3eec6 | ||
|
|
01e25ff12c |
@@ -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
|
||||
@@ -10,6 +10,7 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
/fix
|
||||
|
||||
# ---- Cookie / secret files (NEVER commit) ----
|
||||
vc.jar
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -14,7 +14,41 @@ import (
|
||||
var carrierDatabaseJSON []byte
|
||||
|
||||
type carrierDatabase struct {
|
||||
Carriers map[string][]string `json:"c"`
|
||||
Carriers map[string][]string `json:"c"`
|
||||
Countries map[string]string `json:"i"`
|
||||
Rules []carrierRule `json:"r"`
|
||||
}
|
||||
|
||||
type carrierRule struct {
|
||||
Name string `json:"n"`
|
||||
PLMNs []string `json:"m"`
|
||||
IMSIPatterns []string `json:"x"`
|
||||
SPNs []string `json:"s"`
|
||||
GID1Prefixes []string `json:"g1"`
|
||||
GID2Prefixes []string `json:"g2"`
|
||||
ICCIDPrefixes []string `json:"i"`
|
||||
}
|
||||
|
||||
// CarrierIdentity contains the SIM-issued values used by Android's carrier
|
||||
// resolver. MNC length comes from EF_AD; GID values come from EF_GID1/2.
|
||||
type CarrierIdentity struct {
|
||||
IMSI string
|
||||
ICCID string
|
||||
SPN string
|
||||
GID1 string
|
||||
GID2 string
|
||||
MNCLength int
|
||||
}
|
||||
|
||||
// CountryForMCC returns the ISO alpha-2 country/territory code associated with
|
||||
// a three-digit mobile country code in the embedded Android carrier database.
|
||||
func CountryForMCC(mcc string) (string, bool) {
|
||||
mcc = strings.TrimSpace(mcc)
|
||||
if len(mcc) != 3 {
|
||||
return "", false
|
||||
}
|
||||
country := strings.ToUpper(strings.TrimSpace(globalCarrierDatabase.Countries[mcc]))
|
||||
return country, len(country) == 2
|
||||
}
|
||||
|
||||
var globalCarrierDatabase = func() carrierDatabase {
|
||||
@@ -64,3 +98,121 @@ func CarrierForIMSI(imsi string) (plmn, name, countryCode string, ok bool) {
|
||||
}
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
// CarrierForSIM applies the constrained Android carrier-ID rules before the
|
||||
// MCC/MNC fallback. This is important for MVNO and travel eSIM profiles where
|
||||
// several customer-facing carriers authenticate through the same home PLMN.
|
||||
func CarrierForSIM(identity CarrierIdentity) (plmn, name, countryCode string, ok bool) {
|
||||
imsi := strings.TrimSpace(identity.IMSI)
|
||||
if !decimalDigits(imsi, 5, 20) {
|
||||
return "", "", "", false
|
||||
}
|
||||
plmns := carrierPLMNCandidates(imsi, identity.MNCLength)
|
||||
bestScore := -1
|
||||
for _, rule := range globalCarrierDatabase.Rules {
|
||||
matchedPLMN := firstMatchingValue(rule.PLMNs, func(value string) bool {
|
||||
return containsString(plmns, value)
|
||||
})
|
||||
if matchedPLMN == "" {
|
||||
continue
|
||||
}
|
||||
score := 1 << 8
|
||||
if len(rule.IMSIPatterns) > 0 {
|
||||
if firstMatchingValue(rule.IMSIPatterns, func(pattern string) bool { return imsiPatternMatch(imsi, pattern) }) == "" {
|
||||
continue
|
||||
}
|
||||
score += 1 << 7
|
||||
}
|
||||
if len(rule.ICCIDPrefixes) > 0 {
|
||||
if firstMatchingValue(rule.ICCIDPrefixes, func(prefix string) bool { return strings.HasPrefix(identity.ICCID, prefix) }) == "" {
|
||||
continue
|
||||
}
|
||||
score += 1 << 6
|
||||
}
|
||||
if len(rule.GID1Prefixes) > 0 {
|
||||
if firstMatchingValue(rule.GID1Prefixes, func(prefix string) bool { return prefixFold(identity.GID1, prefix) }) == "" {
|
||||
continue
|
||||
}
|
||||
score += 1 << 5
|
||||
}
|
||||
if len(rule.GID2Prefixes) > 0 {
|
||||
if firstMatchingValue(rule.GID2Prefixes, func(prefix string) bool { return prefixFold(identity.GID2, prefix) }) == "" {
|
||||
continue
|
||||
}
|
||||
score += 1 << 4
|
||||
}
|
||||
if len(rule.SPNs) > 0 {
|
||||
if !containsFold(rule.SPNs, identity.SPN) {
|
||||
continue
|
||||
}
|
||||
score += 1 << 1
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
plmn = matchedPLMN
|
||||
name = strings.TrimSpace(rule.Name)
|
||||
}
|
||||
}
|
||||
if bestScore >= 0 && name != "" {
|
||||
countryCode, _ = CountryForMCC(plmn[:3])
|
||||
return plmn, name, countryCode, true
|
||||
}
|
||||
return CarrierForIMSI(imsi)
|
||||
}
|
||||
|
||||
func carrierPLMNCandidates(imsi string, mncLength int) []string {
|
||||
if (mncLength == 2 || mncLength == 3) && len(imsi) >= 3+mncLength {
|
||||
return []string{imsi[:3+mncLength]}
|
||||
}
|
||||
result := make([]string, 0, 2)
|
||||
for _, length := range []int{6, 5} {
|
||||
if len(imsi) >= length {
|
||||
result = append(result, imsi[:length])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func firstMatchingValue(values []string, match func(string) bool) string {
|
||||
for _, value := range values {
|
||||
if match(value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func containsString(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if value == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsFold(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if strings.EqualFold(value, wanted) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func prefixFold(value, prefix string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(value)), strings.ToLower(strings.TrimSpace(prefix)))
|
||||
}
|
||||
|
||||
func imsiPatternMatch(imsi, pattern string) bool {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if len(imsi) < len(pattern) {
|
||||
return false
|
||||
}
|
||||
for index, value := range pattern {
|
||||
if value != 'x' && value != 'X' && byte(value) != imsi[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -411,7 +411,14 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
|
||||
return Snapshot{}, err
|
||||
}
|
||||
previousICCID := state.lastICCID
|
||||
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, client)
|
||||
var previousSnapshot *Snapshot
|
||||
manager.mu.RLock()
|
||||
if state.snapshot != nil {
|
||||
copy := *state.snapshot
|
||||
previousSnapshot = ©
|
||||
}
|
||||
manager.mu.RUnlock()
|
||||
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, previousSnapshot, client)
|
||||
if err == nil && strings.TrimSpace(snapshot.ICCID) != "" {
|
||||
state.lastICCID = strings.TrimSpace(snapshot.ICCID)
|
||||
}
|
||||
@@ -447,6 +454,7 @@ func (manager *Manager) refreshCardReader(ctx context.Context, id string, state
|
||||
result.ICCID = card.Identity.ICCID
|
||||
result.IMSI = card.Identity.IMSI
|
||||
result.SPN = card.Identity.SPN
|
||||
result.MNCLength = card.Identity.MNCLength
|
||||
result.SIMChanged = previousICCID != "" && !strings.EqualFold(previousICCID, result.ICCID)
|
||||
state.lastICCID = result.ICCID
|
||||
}
|
||||
|
||||
@@ -64,6 +64,12 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
{command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")},
|
||||
{command: "AT+CIMI", response: okResponse("460001234567890")},
|
||||
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"00434D4343FFFFFFFFFFFFFFFFFFFFFFFF"`)},
|
||||
{command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020004FFFF"`)},
|
||||
{command: "AT+CRSM=176,28589,0,0,4", response: okResponse(`+CRSM: 144,0,"00000002"`)},
|
||||
{command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020002FFFF"`)},
|
||||
{command: "AT+CRSM=176,28478,0,0,2", response: okResponse(`+CRSM: 144,0,"0102"`)},
|
||||
{command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
|
||||
{command: "AT+CRSM=176,28479,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
|
||||
{command: "AT+CSQ", response: okResponse("+CSQ: 20,99")},
|
||||
{
|
||||
command: `AT+QENG="servingcell"`,
|
||||
@@ -112,7 +118,8 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
}
|
||||
if snapshot.IMEI != "867123456789012" ||
|
||||
snapshot.ICCID != "8986001234567890123" ||
|
||||
snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" {
|
||||
snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" ||
|
||||
snapshot.MNCLength != 2 || snapshot.GID1 != "0102" || snapshot.GID2 != "" {
|
||||
t.Fatalf("subscriber identifiers = %#v", snapshot)
|
||||
}
|
||||
if !snapshot.ModeKnown || snapshot.OperatingMode != 1 ||
|
||||
@@ -198,6 +205,12 @@ func TestManagerForcesRFOffBeforeInspectingChangedSIMNetwork(t *testing.T) {
|
||||
{command: "AT+CFUN=4", response: okResponse()},
|
||||
{command: "AT+CIMI", response: okResponse("234150000000002")},
|
||||
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)},
|
||||
{command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020004FFFF"`)},
|
||||
{command: "AT+CRSM=176,28589,0,0,4", response: okResponse(`+CRSM: 144,0,"00000002"`)},
|
||||
{command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
|
||||
{command: "AT+CRSM=176,28478,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
|
||||
{command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
|
||||
{command: "AT+CRSM=176,28479,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
|
||||
{command: "AT+CSQ", response: okResponse("+CSQ: 99,99")},
|
||||
{command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)},
|
||||
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -22,6 +22,13 @@ var BlockedMCCs = map[string]string{
|
||||
// code. The MCC is the leading three digits and the MNC the following two or
|
||||
// three. Empty strings are returned for an unusable IMSI.
|
||||
func CardMCCMNC(imsi string) (mcc string, mnc string) {
|
||||
return CardMCCMNCWithLength(imsi, 0)
|
||||
}
|
||||
|
||||
// CardMCCMNCWithLength uses the MNC length advertised by EF_AD when available.
|
||||
// Without it the historical three-digit behavior is retained for callers that
|
||||
// have only an IMSI.
|
||||
func CardMCCMNCWithLength(imsi string, mncLength int) (mcc string, mnc string) {
|
||||
digits := strings.TrimSpace(imsi)
|
||||
if len(digits) < 5 ||
|
||||
strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
|
||||
@@ -29,8 +36,11 @@ func CardMCCMNC(imsi string) (mcc string, mnc string) {
|
||||
}
|
||||
mcc = digits[:3]
|
||||
mnc = digits[3:]
|
||||
if len(mnc) > 3 {
|
||||
mnc = mnc[:3]
|
||||
if mncLength != 2 && mncLength != 3 {
|
||||
mncLength = 3
|
||||
}
|
||||
if len(mnc) > mncLength {
|
||||
mnc = mnc[:mncLength]
|
||||
}
|
||||
return mcc, mnc
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ func TestCardMCCMNC(t *testing.T) {
|
||||
if mcc, _ := CardMCCMNC("460001234567890"); mcc != "460" {
|
||||
t.Fatalf("CardMCCMNC mcc = %q, want 460", mcc)
|
||||
}
|
||||
if mcc, mnc := CardMCCMNCWithLength("454006395879502", 2); mcc != "454" || mnc != "00" {
|
||||
t.Fatalf("CardMCCMNCWithLength = (%q, %q), want (454, 00)", mcc, mnc)
|
||||
}
|
||||
for _, bad := range []string{"", "4600", "4600X1234"} {
|
||||
if mcc, _ := CardMCCMNC(bad); mcc != "" {
|
||||
t.Fatalf("CardMCCMNC(%q) mcc = %q, want empty", bad, mcc)
|
||||
|
||||
@@ -55,6 +55,25 @@ func TestCarrierForPLMNReturnsCountryCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountryForMCCUsesEmbeddedCountryIndex(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"234": "GB",
|
||||
"262": "DE",
|
||||
"310": "US",
|
||||
"460": "CN",
|
||||
}
|
||||
for mcc, want := range tests {
|
||||
if got, ok := CountryForMCC(mcc); !ok || got != want {
|
||||
t.Errorf("CountryForMCC(%q) = (%q, %v), want %q", mcc, got, ok, want)
|
||||
}
|
||||
}
|
||||
for _, invalid := range []string{"", "23", "999", "abcd"} {
|
||||
if got, ok := CountryForMCC(invalid); ok || got != "" {
|
||||
t.Errorf("CountryForMCC(%q) = (%q, %v), want unknown", invalid, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||
tests := []struct {
|
||||
imsi string
|
||||
@@ -64,6 +83,7 @@ func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||
{imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"},
|
||||
{imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"},
|
||||
{imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"},
|
||||
{imsi: "454006395879502", wantPLMN: "45400", wantCountry: "HK"},
|
||||
{imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"},
|
||||
}
|
||||
for _, item := range tests {
|
||||
@@ -73,3 +93,20 @@ func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierForSIMUsesAndroidGIDRuleBeforePLMNFallback(t *testing.T) {
|
||||
plmn, name, country, ok := CarrierForSIM(CarrierIdentity{
|
||||
IMSI: "454006395879502", ICCID: "89852350126077295027",
|
||||
SPN: "Saily", GID1: "536E617065", GID2: "536E617065000012", MNCLength: 2,
|
||||
})
|
||||
if !ok || plmn != "45400" || name != "Webbing" || country != "HK" {
|
||||
t.Fatalf("CarrierForSIM exact rule = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||
}
|
||||
|
||||
plmn, name, country, ok = CarrierForSIM(CarrierIdentity{
|
||||
IMSI: "454006395879502", SPN: "Saily", MNCLength: 2,
|
||||
})
|
||||
if !ok || plmn != "45400" || name != "1O1O / csl / Club Sim" || country != "HK" {
|
||||
t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func (manager *Manager) readSnapshot(
|
||||
candidate modem.Candidate,
|
||||
backend string,
|
||||
previousICCID string,
|
||||
previousSnapshot *Snapshot,
|
||||
client modem.Client,
|
||||
) (Snapshot, error) {
|
||||
snapshot := Snapshot{
|
||||
@@ -81,6 +82,25 @@ func (manager *Manager) readSnapshot(
|
||||
if response, spnErr := manager.command(ctx, client, "AT+CRSM=176,28486,0,0,17"); spnErr == nil {
|
||||
snapshot.SPN = parseSPN(response)
|
||||
}
|
||||
if previousSnapshot != nil && previousSnapshot.IdentityFilesRead &&
|
||||
strings.EqualFold(strings.TrimSpace(previousSnapshot.ICCID), strings.TrimSpace(snapshot.ICCID)) {
|
||||
snapshot.MNCLength = previousSnapshot.MNCLength
|
||||
snapshot.GID1 = previousSnapshot.GID1
|
||||
snapshot.GID2 = previousSnapshot.GID2
|
||||
snapshot.IdentityFilesRead = true
|
||||
} else {
|
||||
// Android's carrier resolver does not identify MVNOs from MCC/MNC alone.
|
||||
// Read these files once per inserted ICCID and cache even an empty result;
|
||||
// repeatedly probing unsupported EFs would add avoidable modem traffic.
|
||||
if efAD := manager.readTransparentSIMFile(ctx, client, 28589); len(efAD) >= 4 {
|
||||
if length := int(efAD[3] & 0x0f); length == 2 || length == 3 {
|
||||
snapshot.MNCLength = length
|
||||
}
|
||||
}
|
||||
snapshot.GID1 = encodeSIMGroupID(manager.readTransparentSIMFile(ctx, client, 28478))
|
||||
snapshot.GID2 = encodeSIMGroupID(manager.readTransparentSIMFile(ctx, client, 28479))
|
||||
snapshot.IdentityFilesRead = true
|
||||
}
|
||||
if response, ok := optional("AT+CSQ"); ok {
|
||||
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
|
||||
}
|
||||
@@ -164,6 +184,91 @@ func (manager *Manager) readSnapshot(
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) readTransparentSIMFile(ctx context.Context, client modem.Client, fileID int) []byte {
|
||||
response, err := manager.command(ctx, client, fmt.Sprintf("AT+CRSM=192,%d,0,0,0", fileID))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
size := transparentSIMFileSize(crsmPayload(response))
|
||||
if size <= 0 || size > 64 {
|
||||
return nil
|
||||
}
|
||||
response, err = manager.command(ctx, client, fmt.Sprintf("AT+CRSM=176,%d,0,0,%d", fileID, size))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return crsmPayload(response)
|
||||
}
|
||||
|
||||
func transparentSIMFileSize(payload []byte) int {
|
||||
// USIM FCP templates contain file size in tag 0x80. Skip the outer 0x62
|
||||
// template and walk its immediate TLVs.
|
||||
content := payload
|
||||
if len(content) >= 2 && content[0] == 0x62 {
|
||||
length, header, ok := berLength(content[1:])
|
||||
if !ok || 1+header+length > len(content) {
|
||||
return 0
|
||||
}
|
||||
content = content[1+header : 1+header+length]
|
||||
}
|
||||
for offset := 0; offset+2 <= len(content); {
|
||||
tag := content[offset]
|
||||
length, header, ok := berLength(content[offset+1:])
|
||||
start := offset + 1 + header
|
||||
end := start + length
|
||||
if !ok || end > len(content) {
|
||||
break
|
||||
}
|
||||
if tag == 0x80 && (length == 1 || length == 2) {
|
||||
size := 0
|
||||
for _, value := range content[start:end] {
|
||||
size = size<<8 | int(value)
|
||||
}
|
||||
return size
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
// Legacy GSM GET RESPONSE data stores file size in bytes 2 and 3.
|
||||
if len(payload) >= 4 && payload[0] != 0x62 {
|
||||
return int(payload[2])<<8 | int(payload[3])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func berLength(value []byte) (length, header int, ok bool) {
|
||||
if len(value) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if value[0] < 0x80 {
|
||||
return int(value[0]), 1, true
|
||||
}
|
||||
count := int(value[0] & 0x7f)
|
||||
if count == 0 || count > 2 || len(value) < count+1 {
|
||||
return 0, 0, false
|
||||
}
|
||||
for _, item := range value[1 : count+1] {
|
||||
length = length<<8 | int(item)
|
||||
}
|
||||
return length, count + 1, true
|
||||
}
|
||||
|
||||
func encodeSIMGroupID(value []byte) string {
|
||||
if len(value) == 0 {
|
||||
return ""
|
||||
}
|
||||
allPadding := true
|
||||
for _, item := range value {
|
||||
if item != 0xff {
|
||||
allPadding = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allPadding {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(hex.EncodeToString(value))
|
||||
}
|
||||
|
||||
func parseSPN(response modem.Response) string {
|
||||
value := valueAfterPrefix(response, "+CRSM:")
|
||||
fields := csvValues(value)
|
||||
|
||||
@@ -105,6 +105,10 @@ type Snapshot struct {
|
||||
ICCID string `json:"iccid"`
|
||||
IMSI string `json:"imsi"`
|
||||
SPN string `json:"spn,omitempty"`
|
||||
MNCLength int `json:"mncLength,omitempty"`
|
||||
GID1 string `json:"gid1,omitempty"`
|
||||
GID2 string `json:"gid2,omitempty"`
|
||||
IdentityFilesRead bool `json:"-"`
|
||||
OperatingMode int `json:"operatingMode"`
|
||||
ModeKnown bool `json:"modeKnown"`
|
||||
FlightMode bool `json:"flightMode"`
|
||||
|
||||
@@ -31,6 +31,7 @@ type automaticTaskNotification struct {
|
||||
}
|
||||
|
||||
func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTask, run store.AutomaticTaskRun) {
|
||||
ctx = s.notificationDestinationContext(ctx)
|
||||
deviceLabel := task.DeviceID
|
||||
if configured, err := s.store.Device(ctx, task.DeviceID); err == nil {
|
||||
deviceLabel = firstNonEmpty(configured.Name, configured.ID)
|
||||
|
||||
@@ -1904,66 +1904,76 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
|
||||
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
|
||||
if snapshot == nil {
|
||||
return map[string]any{
|
||||
"operator": "",
|
||||
"native_mcc": "",
|
||||
"native_mnc": "",
|
||||
"native_spn": "",
|
||||
"operator_country_code": "",
|
||||
"card_mcc": "",
|
||||
"card_mnc": "",
|
||||
"card_country": "",
|
||||
"service_blocked": false,
|
||||
"blocked_reason": "",
|
||||
"network_mode": "",
|
||||
"radio_band": "",
|
||||
"radio_channel": 0,
|
||||
"signal_dbm": 0,
|
||||
"signal_sinr": 0,
|
||||
"imei": "",
|
||||
"iccid": "",
|
||||
"reg_status": 0,
|
||||
"reg_status_text": "not refreshed",
|
||||
"sim_inserted": false,
|
||||
"phone_number": phone,
|
||||
"phone_number_source": phoneSource,
|
||||
"model": "",
|
||||
"operator": "",
|
||||
"native_mcc": "",
|
||||
"native_mnc": "",
|
||||
"native_spn": "",
|
||||
"operator_country_code": "",
|
||||
"card_mcc": "",
|
||||
"card_mnc": "",
|
||||
"card_country": "",
|
||||
"home_carrier_name": "",
|
||||
"home_carrier_plmn": "",
|
||||
"home_carrier_country_code": "",
|
||||
"service_blocked": false,
|
||||
"blocked_reason": "",
|
||||
"network_mode": "",
|
||||
"radio_band": "",
|
||||
"radio_channel": 0,
|
||||
"signal_dbm": 0,
|
||||
"signal_sinr": 0,
|
||||
"imei": "",
|
||||
"iccid": "",
|
||||
"reg_status": 0,
|
||||
"reg_status_text": "not refreshed",
|
||||
"sim_inserted": false,
|
||||
"phone_number": phone,
|
||||
"phone_number_source": phoneSource,
|
||||
"model": "",
|
||||
}
|
||||
}
|
||||
mcc, mnc := splitPLMN(snapshot.OperatorCode)
|
||||
_, operatorCountryCode, _ := device.CarrierForPLMN(snapshot.OperatorCode)
|
||||
cardMCC, cardMNC := device.CardMCCMNC(snapshot.IMSI)
|
||||
cardMCC, cardMNC := device.CardMCCMNCWithLength(snapshot.IMSI, snapshot.MNCLength)
|
||||
homePLMN, homeCarrier, homeCountry, _ := device.CarrierForSIM(device.CarrierIdentity{
|
||||
IMSI: snapshot.IMSI, ICCID: snapshot.ICCID, SPN: snapshot.SPN,
|
||||
GID1: snapshot.GID1, GID2: snapshot.GID2, MNCLength: snapshot.MNCLength,
|
||||
})
|
||||
blockedReason := device.RegionBlockReason(snapshot.IMSI)
|
||||
return map[string]any{
|
||||
"operator": snapshot.OperatorName,
|
||||
"native_mcc": mcc,
|
||||
"native_mnc": mnc,
|
||||
"native_spn": snapshot.SPN,
|
||||
"operator_country_code": operatorCountryCode,
|
||||
"card_mcc": cardMCC,
|
||||
"card_mnc": cardMNC,
|
||||
"card_country": countryNameForMCC(cardMCC),
|
||||
"service_blocked": blockedReason != "",
|
||||
"blocked_reason": blockedReason,
|
||||
"network_mode": snapshot.AccessTech,
|
||||
"network_duplex": "",
|
||||
"radio_band": snapshot.Band,
|
||||
"radio_channel": parseDecimal(snapshot.Channel),
|
||||
"signal_dbm": pointerInt(snapshot.RSSIDBm),
|
||||
"signal_rsrp": pointerInt(snapshot.RSRP),
|
||||
"signal_rsrq": pointerInt(snapshot.RSRQ),
|
||||
"signal_sinr": pointerInt(snapshot.SINR),
|
||||
"imei": snapshot.IMEI,
|
||||
"iccid": snapshot.ICCID,
|
||||
"imsi": snapshot.IMSI,
|
||||
"firmware": snapshot.Firmware,
|
||||
"model": snapshot.Model,
|
||||
"reg_status": snapshot.RegistrationStatus,
|
||||
"reg_status_text": registrationText(snapshot),
|
||||
"ps_attached": snapshot.PSAttached,
|
||||
"sim_inserted": snapshotHasSIM(snapshot),
|
||||
"operating_mode": snapshot.OperatingMode,
|
||||
"phone_number": phone,
|
||||
"phone_number_source": phoneSource,
|
||||
"operator": snapshot.OperatorName,
|
||||
"native_mcc": mcc,
|
||||
"native_mnc": mnc,
|
||||
"native_spn": snapshot.SPN,
|
||||
"operator_country_code": operatorCountryCode,
|
||||
"card_mcc": cardMCC,
|
||||
"card_mnc": cardMNC,
|
||||
"card_country": countryNameForMCC(cardMCC),
|
||||
"home_carrier_name": homeCarrier,
|
||||
"home_carrier_plmn": homePLMN,
|
||||
"home_carrier_country_code": homeCountry,
|
||||
"service_blocked": blockedReason != "",
|
||||
"blocked_reason": blockedReason,
|
||||
"network_mode": snapshot.AccessTech,
|
||||
"network_duplex": "",
|
||||
"radio_band": snapshot.Band,
|
||||
"radio_channel": parseDecimal(snapshot.Channel),
|
||||
"signal_dbm": pointerInt(snapshot.RSSIDBm),
|
||||
"signal_rsrp": pointerInt(snapshot.RSRP),
|
||||
"signal_rsrq": pointerInt(snapshot.RSRQ),
|
||||
"signal_sinr": pointerInt(snapshot.SINR),
|
||||
"imei": snapshot.IMEI,
|
||||
"iccid": snapshot.ICCID,
|
||||
"imsi": snapshot.IMSI,
|
||||
"firmware": snapshot.Firmware,
|
||||
"model": snapshot.Model,
|
||||
"reg_status": snapshot.RegistrationStatus,
|
||||
"reg_status_text": registrationText(snapshot),
|
||||
"ps_attached": snapshot.PSAttached,
|
||||
"sim_inserted": snapshotHasSIM(snapshot),
|
||||
"operating_mode": snapshot.OperatingMode,
|
||||
"phone_number": phone,
|
||||
"phone_number_source": phoneSource,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -429,17 +429,18 @@ func (s *Server) handleNotificationTest(
|
||||
return
|
||||
}
|
||||
|
||||
notificationContext := s.notificationDestinationContext(r.Context())
|
||||
switch channel {
|
||||
case "webhook":
|
||||
err = sendWebhookNotificationTest(r.Context(), resolved)
|
||||
err = sendWebhookNotificationTest(notificationContext, resolved)
|
||||
case "telegram":
|
||||
err = sendTelegramNotificationTest(r.Context(), resolved)
|
||||
err = sendTelegramNotificationTest(notificationContext, resolved)
|
||||
case "email":
|
||||
err = sendEmailNotificationTest(r.Context(), resolved)
|
||||
err = sendEmailNotificationTest(notificationContext, resolved)
|
||||
case "bark":
|
||||
err = sendBarkNotificationTest(r.Context(), resolved)
|
||||
err = sendBarkNotificationTest(notificationContext, resolved)
|
||||
case "wecom":
|
||||
err = sendWecomNotificationTest(r.Context(), resolved)
|
||||
err = sendWecomNotificationTest(notificationContext, resolved)
|
||||
}
|
||||
if err != nil {
|
||||
redacted := store.RedactText(err.Error(), provider)
|
||||
@@ -1073,6 +1074,39 @@ func dialRestricted(
|
||||
return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...))
|
||||
}
|
||||
|
||||
type notificationAllowedNetworksKey struct{}
|
||||
|
||||
func (s *Server) notificationDestinationContext(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
access := s.currentAccessConfig()
|
||||
return context.WithValue(ctx, notificationAllowedNetworksKey{}, append([]netip.Prefix(nil), access.cidrs...))
|
||||
}
|
||||
|
||||
func notificationAddressAllowed(ctx context.Context, address netip.Addr) bool {
|
||||
address = address.Unmap()
|
||||
// Even an administrator-provided exception must never turn a notification
|
||||
// endpoint into a loopback or cloud-metadata request. Private/LAN and
|
||||
// benchmark ranges may be explicitly allowed for local push gateways and
|
||||
// DNS Fake-IP deployments, but these process-local destinations stay closed.
|
||||
if !address.IsValid() || address.IsUnspecified() || address.IsLoopback() ||
|
||||
address.IsMulticast() || address.IsLinkLocalUnicast() ||
|
||||
address == netip.MustParseAddr("100.100.100.200") {
|
||||
return false
|
||||
}
|
||||
if publicNotificationAddress(address) {
|
||||
return true
|
||||
}
|
||||
prefixes, _ := ctx.Value(notificationAllowedNetworksKey{}).([]netip.Prefix)
|
||||
for _, prefix := range prefixes {
|
||||
if prefix.Contains(address) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
if normalized == "" || normalized == "localhost" ||
|
||||
@@ -1084,7 +1118,7 @@ func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, err
|
||||
}
|
||||
if literal, err := netip.ParseAddr(normalized); err == nil {
|
||||
literal = literal.Unmap()
|
||||
if !publicNotificationAddress(literal) {
|
||||
if !notificationAddressAllowed(ctx, literal) {
|
||||
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal)
|
||||
}
|
||||
return []netip.Addr{literal}, nil
|
||||
@@ -1099,7 +1133,7 @@ func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, err
|
||||
result := make([]netip.Addr, 0, len(addresses))
|
||||
for _, address := range addresses {
|
||||
address = address.Unmap()
|
||||
if !publicNotificationAddress(address) {
|
||||
if !notificationAddressAllowed(ctx, address) {
|
||||
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address)
|
||||
}
|
||||
result = append(result, address)
|
||||
|
||||
@@ -789,6 +789,27 @@ func TestNotificationDestinationAddressPolicy(t *testing.T) {
|
||||
); err == nil {
|
||||
t.Fatal("metadata IP was not blocked")
|
||||
}
|
||||
allowedContext := context.WithValue(
|
||||
context.Background(),
|
||||
notificationAllowedNetworksKey{},
|
||||
[]netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")},
|
||||
)
|
||||
if addresses, err := resolvePublicAddresses(allowedContext, "198.18.0.1"); err != nil || len(addresses) != 1 {
|
||||
t.Fatalf("explicit Fake-IP notification allowlist = %v, %v", addresses, err)
|
||||
}
|
||||
if _, err := resolvePublicAddresses(allowedContext, "169.254.169.254"); err == nil {
|
||||
t.Fatal("unlisted metadata IP was allowed")
|
||||
}
|
||||
wideAllowedContext := context.WithValue(
|
||||
context.Background(),
|
||||
notificationAllowedNetworksKey{},
|
||||
[]netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")},
|
||||
)
|
||||
for _, address := range []string{"127.0.0.1", "169.254.169.254", "100.100.100.200"} {
|
||||
if _, err := resolvePublicAddresses(wideAllowedContext, address); err == nil {
|
||||
t.Fatalf("non-overridable destination %s was allowed", address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) {
|
||||
|
||||
@@ -105,7 +105,7 @@ func (s *Server) runSMSNotificationChannel(ctx context.Context, channel string)
|
||||
} else {
|
||||
for _, message := range messages {
|
||||
notification := s.newSMSNotification(ctx, message)
|
||||
if sendErr := sendSMSNotification(ctx, channel, config, notification); sendErr != nil {
|
||||
if sendErr := sendSMSNotification(s.notificationDestinationContext(ctx), channel, config, notification); sendErr != nil {
|
||||
if sendErr.Error() != lastError || time.Since(lastErrorAt) >= time.Minute {
|
||||
s.logSMSNotificationError(channel, sendErr)
|
||||
lastError, lastErrorAt = sendErr.Error(), time.Now()
|
||||
|
||||
@@ -850,7 +850,7 @@ func (bot *telegramBot) sendDeviceStatus(ctx context.Context, config telegramRun
|
||||
"ICCID:"+firstNonEmpty(snapshot.ICCID, "--"),
|
||||
"IMSI:"+firstNonEmpty(snapshot.IMSI, "--"),
|
||||
"号码:"+resolveTelegramPhoneNumber(associationNumber, wfcState, snapshot),
|
||||
"原运营商:"+telegramHomeCarrier(snapshot.IMSI, snapshot.SPN),
|
||||
"原运营商:"+telegramSnapshotHomeCarrier(snapshot),
|
||||
"当前网络:"+telegramCurrentNetwork(snapshot),
|
||||
"蜂窝模式:"+map[bool]string{true: "飞行模式", false: "开启"}[snapshot.FlightMode],
|
||||
)
|
||||
@@ -929,20 +929,30 @@ func usableTelegramPhoneNumber(value string) bool {
|
||||
}
|
||||
|
||||
func telegramHomeCarrier(imsi string, spn ...string) string {
|
||||
plmn, name, country, ok := device.CarrierForIMSI(imsi)
|
||||
if !ok {
|
||||
if len(spn) > 0 && strings.TrimSpace(spn[0]) != "" {
|
||||
return strings.TrimSpace(spn[0])
|
||||
}
|
||||
identity := device.CarrierIdentity{IMSI: imsi}
|
||||
if len(spn) > 0 {
|
||||
identity.SPN = spn[0]
|
||||
}
|
||||
return telegramResolvedHomeCarrier(identity)
|
||||
}
|
||||
|
||||
func telegramSnapshotHomeCarrier(snapshot *device.Snapshot) string {
|
||||
if snapshot == nil {
|
||||
return "--"
|
||||
}
|
||||
if len(spn) > 0 && strings.TrimSpace(spn[0]) != "" {
|
||||
brand := strings.TrimSpace(spn[0])
|
||||
brandCountry := country
|
||||
if strings.Contains(strings.ToLower(brand), "lebara") && strings.HasPrefix(strings.TrimSpace(imsi), "20404") {
|
||||
brandCountry = "GB"
|
||||
return telegramResolvedHomeCarrier(device.CarrierIdentity{
|
||||
IMSI: snapshot.IMSI, ICCID: snapshot.ICCID, SPN: snapshot.SPN,
|
||||
GID1: snapshot.GID1, GID2: snapshot.GID2, MNCLength: snapshot.MNCLength,
|
||||
})
|
||||
}
|
||||
|
||||
func telegramResolvedHomeCarrier(identity device.CarrierIdentity) string {
|
||||
plmn, name, country, ok := device.CarrierForSIM(identity)
|
||||
if !ok {
|
||||
if strings.TrimSpace(identity.SPN) != "" {
|
||||
return strings.TrimSpace(identity.SPN)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join([]string{telegramCountryFlag(brandCountry), brand, "(认证核心 " + plmn + ")"}, " "))
|
||||
return "--"
|
||||
}
|
||||
return strings.TrimSpace(strings.Join([]string{telegramCountryFlag(country), name, "(" + plmn + ")"}, " "))
|
||||
}
|
||||
@@ -2318,6 +2328,11 @@ func (bot *telegramBot) loadConfig(ctx context.Context) (telegramRuntimeConfig,
|
||||
}
|
||||
|
||||
func (bot *telegramBot) call(ctx context.Context, config telegramRuntimeConfig, method string, payload any, result any) error {
|
||||
// Telegram polling is a long-lived notification channel and must use the
|
||||
// same administrator-configured destination exceptions as test messages,
|
||||
// SMS pushes and automatic-task notifications. This keeps SSRF protection
|
||||
// enabled while allowing explicit DNS Fake-IP ranges such as 198.18/15.
|
||||
ctx = bot.notificationDestinationContext(ctx)
|
||||
base, err := validateTelegramAPIURL(ctx, config.BaseURL, config.Token, method)
|
||||
if err != nil {
|
||||
return redactTelegramError(err, config.Token)
|
||||
@@ -2360,6 +2375,13 @@ func (bot *telegramBot) call(ctx context.Context, config telegramRuntimeConfig,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bot *telegramBot) notificationDestinationContext(ctx context.Context) context.Context {
|
||||
if bot.server == nil {
|
||||
return ctx
|
||||
}
|
||||
return bot.server.notificationDestinationContext(ctx)
|
||||
}
|
||||
|
||||
func (bot *telegramBot) sendText(ctx context.Context, config telegramRuntimeConfig, chatID int64, text string, replyMarkup any) error {
|
||||
target := config.ChatID
|
||||
if chatID != 0 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -57,6 +58,19 @@ func TestTelegramAPIURLRejectsMalformedTemplates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramPollingUsesExplicitFakeIPDestinationAllowlist(t *testing.T) {
|
||||
bot := &telegramBot{server: &Server{access: parsedAccessConfig{
|
||||
cidrs: []netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")},
|
||||
}}}
|
||||
ctx := bot.notificationDestinationContext(context.Background())
|
||||
if _, err := validateTelegramAPIURL(ctx, "https://198.18.0.34", "123456:test-token", "getUpdates"); err != nil {
|
||||
t.Fatalf("explicitly allowed Telegram Fake-IP was rejected: %v", err)
|
||||
}
|
||||
if _, err := validateTelegramAPIURL(ctx, "https://169.254.169.254", "123456:test-token", "getUpdates"); err == nil {
|
||||
t.Fatal("metadata address became reachable through Telegram allowlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTelegramCommand(t *testing.T) {
|
||||
command, remainder := parseTelegramCommand(" /sms@vocat_bot EC20 +447700900123 hello world ")
|
||||
if command != "sms" || remainder != "EC20 +447700900123 hello world" {
|
||||
@@ -127,8 +141,11 @@ func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T
|
||||
if got := telegramHomeCarrier("234336570710174"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
|
||||
t.Fatalf("home carrier = %q", got)
|
||||
}
|
||||
if got := telegramHomeCarrier("204040123456789", "Lebara"); !strings.Contains(got, "Lebara") || !strings.Contains(got, "20404") || !strings.Contains(got, "🇬🇧") || strings.Contains(got, "🇳🇱") {
|
||||
t.Fatalf("branded foreign-core carrier = %q", got)
|
||||
if got := telegramHomeCarrier("454006395879502", "Saily"); !strings.Contains(got, "1O1O / csl / Club Sim") || !strings.Contains(got, "45400") || !strings.Contains(got, "🇭🇰") || strings.Contains(got, "Saily") {
|
||||
t.Fatalf("profile brand overrode home carrier = %q", got)
|
||||
}
|
||||
if got := telegramHomeCarrier("999991234567890", "Unknown Brand"); got != "Unknown Brand" {
|
||||
t.Fatalf("unknown home carrier did not fall back to SPN: %q", got)
|
||||
}
|
||||
flight := &device.Snapshot{FlightMode: true, OperatorName: "stale network", RegistrationStatus: 1}
|
||||
if got := telegramCurrentNetwork(flight); got != "--(飞行模式)" {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
|
||||
configInternalIPv4Address = 1
|
||||
configInternalIPv4DNS = 3
|
||||
configApplicationVersion = 7
|
||||
configInternalIPv6Address = 8
|
||||
configInternalIPv6DNS = 10
|
||||
configPCSCFIPv4Address = 20
|
||||
@@ -186,6 +187,9 @@ func configurationRequest() payload {
|
||||
configInternalIPv6DNS,
|
||||
configPCSCFIPv4Address,
|
||||
configPCSCFIPv6Address,
|
||||
// Android's IKE library always appends APPLICATION_VERSION to the
|
||||
// initial configuration request, even when the value is empty.
|
||||
configApplicationVersion,
|
||||
}
|
||||
body := []byte{configRequest, 0, 0, 0}
|
||||
for _, attribute := range attributes {
|
||||
|
||||
@@ -285,6 +285,7 @@ type akaClient struct {
|
||||
simIdentity vowifi.SIMIdentity
|
||||
provider vowifi.AKAProvider
|
||||
keys akaKeys
|
||||
lastResponseStage string
|
||||
challengeComplete bool
|
||||
resultIndication bool
|
||||
protectedSuccess bool
|
||||
@@ -298,7 +299,12 @@ func newAKAClient(identity vowifi.SIMIdentity, provider vowifi.AKAProvider) (*ak
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &akaClient{identity: nai, simIdentity: identity, provider: provider}, nil
|
||||
return &akaClient{
|
||||
identity: nai,
|
||||
simIdentity: identity,
|
||||
provider: provider,
|
||||
lastResponseStage: "in the initial IKE_AUTH identity exchange",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction, error) {
|
||||
@@ -308,9 +314,9 @@ func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction,
|
||||
}
|
||||
switch packet.Code {
|
||||
case eapFailure:
|
||||
stage := "before the SIM AKA challenge (identity or subscription rejected)"
|
||||
stage := client.lastResponseStage
|
||||
if client.challengeComplete {
|
||||
stage = "after the SIM AKA response (AKA result or subscription rejected)"
|
||||
stage = "after the SIM AKA challenge response"
|
||||
}
|
||||
return eapAction{}, fmt.Errorf("%w %s", vowifi.ErrEAPAuthenticationRejected, stage)
|
||||
case eapSuccess:
|
||||
@@ -333,6 +339,7 @@ func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction,
|
||||
Type: eapTypeIdentity,
|
||||
Data: client.identity,
|
||||
})
|
||||
client.lastResponseStage = "after EAP-Response/Identity"
|
||||
return eapAction{Response: response}, err
|
||||
case eapTypeAKA:
|
||||
return client.handleAKARequest(ctx, packet)
|
||||
@@ -405,6 +412,7 @@ func (client *akaClient) respondAKAIdentity(identifier uint8, attributes []akaAt
|
||||
Type: eapTypeAKA,
|
||||
Data: data,
|
||||
})
|
||||
client.lastResponseStage = "after EAP-Response/AKA-Identity"
|
||||
return eapAction{Response: response}, err
|
||||
}
|
||||
|
||||
|
||||
@@ -106,16 +106,34 @@ func TestEAPFailureReportsAuthenticationStage(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = client.handle(context.Background(), failure)
|
||||
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "before the SIM AKA challenge") {
|
||||
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "initial IKE_AUTH identity exchange") {
|
||||
t.Fatalf("pre-challenge failure = %v", err)
|
||||
}
|
||||
client.challengeComplete = true
|
||||
_, err = client.handle(context.Background(), failure)
|
||||
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after the SIM AKA response") {
|
||||
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after the SIM AKA challenge response") {
|
||||
t.Fatalf("post-challenge failure = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEAPFailureReportsIdentityResponseStage(t *testing.T) {
|
||||
client, err := newAKAClient(testSIMIdentity(), &testAKAProvider{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identityRequest, _ := marshalEAPPacket(eapPacket{
|
||||
Code: eapRequest, Identifier: 4, Type: eapTypeIdentity,
|
||||
})
|
||||
if _, err := client.handle(context.Background(), identityRequest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failure, _ := marshalEAPPacket(eapPacket{Code: eapFailure, Identifier: 5})
|
||||
_, err = client.handle(context.Background(), failure)
|
||||
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after EAP-Response/Identity") {
|
||||
t.Fatalf("identity-stage failure = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEAPAKAChallengeTypedSIMAndMAC(t *testing.T) {
|
||||
result := vowifi.AKAResult{
|
||||
RES: bytes.Repeat([]byte{0x91}, 8),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package ike
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// deviceIdentityRequested reports the 3GPP DEVICE_IDENTITY request defined by
|
||||
// TS 24.302. Android remembers this request and answers it in a later EAP
|
||||
// IKE_AUTH request, but only after authenticating the ePDG.
|
||||
func deviceIdentityRequested(payloads []payload) (bool, error) {
|
||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||
kind, _, err := parseNotify(item)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if kind == notifyDeviceIdentity {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func deviceIdentityNotify(identity string) (payload, error) {
|
||||
identity = strings.TrimSpace(identity)
|
||||
if (len(identity) != 15 && len(identity) != 16) || !decimalDigits(identity) {
|
||||
return payload{}, errors.New("ike: device identity must contain 15 or 16 digits")
|
||||
}
|
||||
identityType := byte(1) // IMEI
|
||||
if len(identity) == 16 {
|
||||
identityType = 2 // IMEISV
|
||||
}
|
||||
data := make([]byte, 11)
|
||||
// TS 24.302 Figure 8.2.9.2: this inner length excludes its own two
|
||||
// octets, and is therefore 9 for an IMEI/IMEISV value.
|
||||
binary.BigEndian.PutUint16(data[:2], 9)
|
||||
data[2] = identityType
|
||||
for index := 0; index < 8; index++ {
|
||||
low := identity[index*2] - '0'
|
||||
high := byte(0x0f)
|
||||
if index*2+1 < len(identity) {
|
||||
high = identity[index*2+1] - '0'
|
||||
}
|
||||
data[index+3] = high<<4 | low
|
||||
}
|
||||
return makeNotify(notifyDeviceIdentity, data), nil
|
||||
}
|
||||
|
||||
func decimalDigits(value string) bool {
|
||||
for _, digit := range value {
|
||||
if digit < '0' || digit > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package ike
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeviceIdentityNotifyMatchesAndroidEncoding(t *testing.T) {
|
||||
item, err := deviceIdentityNotify("123456789012345")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kind, data, err := parseNotify(item)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []byte{0, 9, 1, 0x21, 0x43, 0x65, 0x87, 0x09, 0x21, 0x43, 0xf5}
|
||||
if kind != notifyDeviceIdentity || !bytes.Equal(data, want) {
|
||||
t.Fatalf("DEVICE_IDENTITY = %d/%x, want %d/%x", kind, data, notifyDeviceIdentity, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceIdentityNotifyAcceptsIMEISV(t *testing.T) {
|
||||
item, err := deviceIdentityNotify("1234567890123456")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, data, _ := parseNotify(item)
|
||||
if data[2] != 2 || data[10] != 0x65 {
|
||||
t.Fatalf("IMEISV data = %x", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceIdentityRequested(t *testing.T) {
|
||||
requested, err := deviceIdentityRequested([]payload{makeNotify(notifyDeviceIdentity, nil)})
|
||||
if err != nil || !requested {
|
||||
t.Fatalf("deviceIdentityRequested() = %v, %v", requested, err)
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
|
||||
group := uint16(dhMODP2048)
|
||||
legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
||||
advertiseEAPOnly := advertiseEAPOnlyAuthentication(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
||||
if legacyFirst {
|
||||
group = dhMODP1024
|
||||
}
|
||||
@@ -245,6 +246,23 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cleanupPendingIKE := true
|
||||
cleanupMessageID := uint32(2)
|
||||
defer func() {
|
||||
if cleanupPendingIKE {
|
||||
cleanupContext, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = sendIKESADelete(
|
||||
cleanupContext,
|
||||
transport,
|
||||
ikeSuite,
|
||||
keys,
|
||||
initiatorSPI,
|
||||
responseHeader.ResponderSPI,
|
||||
cleanupMessageID,
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
var childInboundSPIBytes [4]byte
|
||||
if err := fillNonzero(provider.config.Random, childInboundSPIBytes[:]); err != nil {
|
||||
@@ -259,7 +277,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
requestedIDr := payload{Type: payloadIDr, Body: append([]byte{2, 0, 0, 0}, []byte(provider.config.APN)...)}
|
||||
tsi := dualStackTrafficSelectors(payloadTSi)
|
||||
tsr := dualStackTrafficSelectors(payloadTSr)
|
||||
firstAuthPayloads := buildInitialEAPOnlyAuth(idi, requestedIDr, childOfferBody, tsi, tsr)
|
||||
firstAuthPayloads := buildInitialEAPAuth(idi, requestedIDr, childOfferBody, tsi, tsr, advertiseEAPOnly)
|
||||
authHeader := ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
ResponderSPI: responseHeader.ResponderSPI,
|
||||
@@ -292,15 +310,19 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
initiatorNonce,
|
||||
ikeSuite,
|
||||
keys.SKpr,
|
||||
serverName,
|
||||
"", // Android Iwlan enables IKE_OPTION_ACCEPT_ANY_REMOTE_ID.
|
||||
serverName,
|
||||
provider.config.RootCAs,
|
||||
provider.config.ResponderPublicKey,
|
||||
true, // RFC 5998 EAP-only authentication defers responder AUTH.
|
||||
true, // Some ePDGs, including O2 Germany, implicitly defer AUTH without accepting the RFC 5998 notify.
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceIdentityPending, err := deviceIdentityRequested(authResponsePayloads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messageID := uint32(1)
|
||||
currentPayloads := authResponsePayloads
|
||||
for round := 0; round < 10; round++ {
|
||||
@@ -319,16 +341,29 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
return nil, errors.New("ike: EAP state machine produced no response")
|
||||
}
|
||||
messageID++
|
||||
cleanupMessageID = messageID + 1
|
||||
requestPayloads := []payload{{Type: payloadEAP, Body: action.Response}}
|
||||
if deviceIdentityPending && responderAUTH == vowifi.ResponderAUTHVerified {
|
||||
deviceIdentity, identityErr := deviceIdentityNotify(request.Identity.IMEI)
|
||||
if identityErr == nil {
|
||||
requestPayloads = append(requestPayloads, deviceIdentity)
|
||||
}
|
||||
}
|
||||
eapRequest, err := encryptPayloads(ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
ResponderSPI: responseHeader.ResponderSPI,
|
||||
Exchange: exchangeIKEAuth,
|
||||
Flags: flagInitiator,
|
||||
MessageID: messageID,
|
||||
}, []payload{{Type: payloadEAP, Body: action.Response}}, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
||||
}, requestPayloads, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if requested, notifyErr := deviceIdentityRequested(currentPayloads); notifyErr != nil {
|
||||
return nil, notifyErr
|
||||
} else if requested {
|
||||
deviceIdentityPending = true
|
||||
}
|
||||
eapResponse, err := transport.RoundTrip(ctx, eapRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -358,6 +393,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
return nil, err
|
||||
}
|
||||
messageID++
|
||||
cleanupMessageID = messageID + 1
|
||||
finalRequest, err := encryptPayloads(ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
ResponderSPI: responseHeader.ResponderSPI,
|
||||
@@ -383,17 +419,17 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
}
|
||||
finalAUTHs := payloadsOfType(finalPayloads, payloadAuth)
|
||||
if len(finalAUTHs) != 1 {
|
||||
return nil, fmt.Errorf("%w: final EAP-only response must contain exactly one MSK AUTH payload", vowifi.ErrResponderAUTHRequired)
|
||||
return nil, fmt.Errorf("%w: final EAP response must contain exactly one MSK AUTH payload", vowifi.ErrResponderAUTHRequired)
|
||||
}
|
||||
if len(responderID.Body) == 0 {
|
||||
return nil, errors.New("ike: EAP-only exchange has no initial ePDG IDr for the responder AUTH transcript")
|
||||
return nil, errors.New("ike: EAP exchange has no responder IDr for the AUTH transcript")
|
||||
}
|
||||
finalIDs := payloadsOfType(finalPayloads, payloadIDr)
|
||||
if len(finalIDs) > 1 {
|
||||
return nil, errors.New("ike: duplicate final responder IDr payload")
|
||||
}
|
||||
if len(finalIDs) == 1 {
|
||||
if err := validateFQDNIDr(finalIDs[0], provider.config.APN, "final APN"); err != nil {
|
||||
if err := validateFQDNIDr(finalIDs[0], "", "final responder"); err != nil {
|
||||
return nil, fmt.Errorf("ike: final APN IDr: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -472,9 +508,11 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
keys,
|
||||
initiatorSPI,
|
||||
responseHeader.ResponderSPI,
|
||||
messageID+1,
|
||||
natDetected,
|
||||
provider.config.KeepaliveInterval,
|
||||
)
|
||||
cleanupPendingIKE = false
|
||||
installed, err := provider.config.Installer.Install(ctx, ChildSAConfig{
|
||||
Name: name,
|
||||
OuterLocal: append(net.IP(nil), transport.LocalAddr().IP...),
|
||||
@@ -499,11 +537,11 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
Relay: relay,
|
||||
})
|
||||
if err != nil {
|
||||
_ = relay.Close()
|
||||
_ = relay.CloseWithDelete(ctx)
|
||||
return nil, fmt.Errorf("ike: install CHILD_SA: %w", err)
|
||||
}
|
||||
if installed == nil {
|
||||
_ = relay.Close()
|
||||
_ = relay.CloseWithDelete(ctx)
|
||||
return nil, errors.New("ike: CHILD_SA installer returned a nil handle")
|
||||
}
|
||||
dataplaneMode := "unknown"
|
||||
@@ -553,6 +591,47 @@ func legacyIKEProfile(mcc, mnc string) bool {
|
||||
return plmn == "23415" || plmn == "2044"
|
||||
}
|
||||
|
||||
func advertiseEAPOnlyAuthentication(mcc, mnc string) bool {
|
||||
// Android exposes the ePDG authentication method as carrier policy rather
|
||||
// than unconditionally requesting RFC 5998 EAP-only authentication. O2
|
||||
// Germany's 262-03 ePDG rejects an initial IKE_AUTH that explicitly carries
|
||||
// EAP_ONLY_AUTHENTICATION, but then implicitly defers responder AUTH when the
|
||||
// notify is omitted. Do not advertise RFC 5998 for that PLMN; the final
|
||||
// responder AUTH derived from the EAP-AKA MSK remains mandatory.
|
||||
return !o2GermanyIKECompatibility(mcc, mnc)
|
||||
}
|
||||
|
||||
func o2GermanyIKECompatibility(mcc, mnc string) bool {
|
||||
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
|
||||
return plmn == "2623"
|
||||
}
|
||||
|
||||
func buildInitialEAPAuth(
|
||||
idi payload,
|
||||
requestedIDr payload,
|
||||
childOfferBody []byte,
|
||||
tsi payload,
|
||||
tsr payload,
|
||||
eapOnly bool,
|
||||
) []payload {
|
||||
// Match Android's IkeSessionStateMachine.buildIkeAuthReq ordering. Some
|
||||
// carrier ePDGs inspect this first encrypted exchange before starting EAP.
|
||||
payloads := []payload{idi, requestedIDr}
|
||||
if eapOnly {
|
||||
payloads = append(payloads, makeNotify(notifyEAPOnlyAuth, nil))
|
||||
}
|
||||
payloads = append(payloads,
|
||||
makeNotify(notifyMOBIKESupported, nil),
|
||||
makeNotify(notifyInitialContact, nil),
|
||||
)
|
||||
return append(payloads,
|
||||
payload{Type: payloadSA, Body: append([]byte(nil), childOfferBody...)},
|
||||
tsi,
|
||||
tsr,
|
||||
configurationRequest(),
|
||||
)
|
||||
}
|
||||
|
||||
func buildInitialEAPOnlyAuth(
|
||||
idi payload,
|
||||
requestedIDr payload,
|
||||
@@ -560,15 +639,7 @@ func buildInitialEAPOnlyAuth(
|
||||
tsi payload,
|
||||
tsr payload,
|
||||
) []payload {
|
||||
return []payload{
|
||||
idi,
|
||||
requestedIDr,
|
||||
makeNotify(notifyEAPOnlyAuth, nil),
|
||||
{Type: payloadSA, Body: append([]byte(nil), childOfferBody...)},
|
||||
tsi,
|
||||
tsr,
|
||||
configurationRequest(),
|
||||
}
|
||||
return buildInitialEAPAuth(idi, requestedIDr, childOfferBody, tsi, tsr, true)
|
||||
}
|
||||
|
||||
func ikeOffer(group uint16, legacyFirst bool) proposal {
|
||||
@@ -877,7 +948,7 @@ func (session *Session) Close(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
if relay != nil {
|
||||
if err := relay.Close(); err != nil {
|
||||
if err := relay.CloseWithDelete(ctx); err != nil {
|
||||
errs = append(errs, fmt.Errorf("close session relay: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,15 +42,17 @@ func (reader constantReader) Read(destination []byte) (int, error) {
|
||||
}
|
||||
|
||||
type firstAuthCaptureTransport struct {
|
||||
t *testing.T
|
||||
calls int
|
||||
suite negotiatedSuite
|
||||
keys ikeKeys
|
||||
spii [8]byte
|
||||
spir [8]byte
|
||||
nonceI []byte
|
||||
nonceR []byte
|
||||
floated bool
|
||||
t *testing.T
|
||||
wantEAPOnly bool
|
||||
wantGroup uint16
|
||||
calls int
|
||||
suite negotiatedSuite
|
||||
keys ikeKeys
|
||||
spii [8]byte
|
||||
spir [8]byte
|
||||
nonceI []byte
|
||||
nonceR []byte
|
||||
floated bool
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) LocalAddr() *net.UDPAddr {
|
||||
@@ -96,8 +98,16 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
|
||||
return nil, err
|
||||
}
|
||||
group := uint16(ke.Body[0])<<8 | uint16(ke.Body[1])
|
||||
if group != dhMODP1024 || len(ke.Body[4:]) != 128 {
|
||||
transport.t.Fatalf("Vodafone init KE = group %d length %d", group, len(ke.Body[4:]))
|
||||
wantGroup := transport.wantGroup
|
||||
if wantGroup == 0 {
|
||||
wantGroup = dhMODP1024
|
||||
}
|
||||
wantKELength := 128
|
||||
if wantGroup == dhMODP2048 {
|
||||
wantKELength = 256
|
||||
}
|
||||
if group != wantGroup || len(ke.Body[4:]) != wantKELength {
|
||||
transport.t.Fatalf("init KE = group %d length %d, want group %d length %d", group, len(ke.Body[4:]), wantGroup, wantKELength)
|
||||
}
|
||||
serverDH, err := newDHExchange(group, constantReader{value: 0x77})
|
||||
if err != nil {
|
||||
@@ -108,6 +118,15 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
|
||||
return nil, err
|
||||
}
|
||||
transport.suite = legacyTestSuite()
|
||||
if group == dhMODP2048 {
|
||||
transport.suite = negotiatedSuite{
|
||||
EncryptionID: encryptionAESCBC,
|
||||
EncryptionBits: 128,
|
||||
PRFID: prfHMACSHA256,
|
||||
IntegrityID: integrityHMACSHA256_128,
|
||||
DHID: dhMODP2048,
|
||||
}
|
||||
}
|
||||
transport.spii = header.InitiatorSPI
|
||||
transport.spir = [8]byte{0x80, 1, 2, 3, 4, 5, 6, 7}
|
||||
transport.nonceI = append([]byte(nil), nonce.Body...)
|
||||
@@ -128,9 +147,9 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
|
||||
Protocol: protocolIKE,
|
||||
Transforms: []transform{
|
||||
{Type: transformEncryption, ID: encryptionAESCBC, KeyLength: 128},
|
||||
{Type: transformPRF, ID: prfHMACSHA1},
|
||||
{Type: transformIntegrity, ID: integrityHMACSHA1_96},
|
||||
{Type: transformDH, ID: dhMODP1024},
|
||||
{Type: transformPRF, ID: transport.suite.PRFID},
|
||||
{Type: transformIntegrity, ID: transport.suite.IntegrityID},
|
||||
{Type: transformDH, ID: group},
|
||||
},
|
||||
}})
|
||||
keBody := make([]byte, 4+len(serverDH.Public))
|
||||
@@ -180,8 +199,8 @@ func (transport *firstAuthCaptureTransport) observeFirstAuth(packet []byte) erro
|
||||
foundEAPOnly = true
|
||||
}
|
||||
}
|
||||
if !foundEAPOnly {
|
||||
transport.t.Fatal("first IKE_AUTH omitted EAP_ONLY_AUTHENTICATION")
|
||||
if foundEAPOnly != transport.wantEAPOnly {
|
||||
transport.t.Fatalf("first IKE_AUTH EAP_ONLY_AUTHENTICATION present=%v, want %v", foundEAPOnly, transport.wantEAPOnly)
|
||||
}
|
||||
for _, kind := range []uint8{payloadIDi, payloadSA, payloadTSi, payloadTSr, payloadCP} {
|
||||
if _, err := onePayload(payloads, kind); err != nil {
|
||||
@@ -212,7 +231,7 @@ func (unusedInstaller) Install(context.Context, ChildSAConfig) (ChildSAHandle, e
|
||||
}
|
||||
|
||||
func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) {
|
||||
capture := &firstAuthCaptureTransport{t: t}
|
||||
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: true}
|
||||
provider, err := NewProvider(Config{
|
||||
Random: constantReader{value: 0x42},
|
||||
Timeout: time.Second,
|
||||
@@ -250,5 +269,44 @@ func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderO2GermanyFirstAuthUsesStandardEAPAndRequestsIMSAPN(t *testing.T) {
|
||||
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: false, wantGroup: dhMODP2048}
|
||||
provider, err := NewProvider(Config{
|
||||
Random: constantReader{value: 0x42},
|
||||
Timeout: time.Second,
|
||||
Installer: unusedInstaller{},
|
||||
APN: "ims",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider.transportFactory = func(
|
||||
context.Context,
|
||||
transportConfig,
|
||||
vowifi.ProxyRoute,
|
||||
string,
|
||||
) (datagramTransport, error) {
|
||||
return capture, nil
|
||||
}
|
||||
aka := &testAKAProvider{}
|
||||
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
|
||||
DeviceID: "ec20-o2",
|
||||
Identity: vowifi.SIMIdentity{
|
||||
ICCID: "8949200000000000000",
|
||||
IMSI: "262030123456789",
|
||||
HomeMCC: "262",
|
||||
HomeMNC: "03",
|
||||
},
|
||||
EPDG: "epdg.epc.mnc003.mcc262.pub.3gppnetwork.org",
|
||||
AKA: aka,
|
||||
})
|
||||
if !errors.Is(err, errFirstAuthObserved) {
|
||||
t.Fatalf("Start() error = %v, want capture sentinel", err)
|
||||
}
|
||||
if capture.calls != 2 || capture.floated || aka.calls != 0 {
|
||||
t.Fatalf("capture calls=%d floated=%v AKA calls=%d", capture.calls, capture.floated, aka.calls)
|
||||
}
|
||||
}
|
||||
|
||||
var _ io.Reader = constantReader{}
|
||||
var _ datagramTransport = (*firstAuthCaptureTransport)(nil)
|
||||
|
||||
@@ -15,6 +15,7 @@ type sessionRelay struct {
|
||||
keys ikeKeys
|
||||
spii [8]byte
|
||||
spir [8]byte
|
||||
deleteID uint32
|
||||
natt bool
|
||||
keepalive time.Duration
|
||||
|
||||
@@ -33,6 +34,7 @@ func newSessionRelay(
|
||||
keys ikeKeys,
|
||||
initiatorSPI [8]byte,
|
||||
responderSPI [8]byte,
|
||||
deleteMessageID uint32,
|
||||
natt bool,
|
||||
keepalive time.Duration,
|
||||
) *sessionRelay {
|
||||
@@ -46,6 +48,7 @@ func newSessionRelay(
|
||||
keys: keys,
|
||||
spii: initiatorSPI,
|
||||
spir: responderSPI,
|
||||
deleteID: deleteMessageID,
|
||||
natt: natt,
|
||||
keepalive: keepalive,
|
||||
ctx: ctx,
|
||||
@@ -214,6 +217,53 @@ func (relay *sessionRelay) Close() error {
|
||||
return errors.Join(relay.terminalErrorIfFailure(), transportErr)
|
||||
}
|
||||
|
||||
func (relay *sessionRelay) CloseWithDelete(ctx context.Context) error {
|
||||
deleteErr := relay.sendIKEDelete(ctx)
|
||||
return errors.Join(deleteErr, relay.Close())
|
||||
}
|
||||
|
||||
func (relay *sessionRelay) sendIKEDelete(ctx context.Context) error {
|
||||
return sendIKESADelete(ctx, relay.transport, relay.suite, relay.keys, relay.spii, relay.spir, relay.deleteID)
|
||||
}
|
||||
|
||||
func sendIKESADelete(
|
||||
ctx context.Context,
|
||||
transport datagramTransport,
|
||||
suite negotiatedSuite,
|
||||
keys ikeKeys,
|
||||
initiatorSPI [8]byte,
|
||||
responderSPI [8]byte,
|
||||
messageID uint32,
|
||||
) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
// Teardown is often called with the operation context already canceled.
|
||||
// Give the protocol-level release a short independent chance to leave.
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
request, err := encryptPayloads(ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
ResponderSPI: responderSPI,
|
||||
Exchange: exchangeInformational,
|
||||
Flags: flagInitiator,
|
||||
MessageID: messageID,
|
||||
}, []payload{{
|
||||
Type: payloadDelete,
|
||||
Body: []byte{protocolIKE, 0, 0, 0},
|
||||
}}, suite, keys.SKei, keys.SKai, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ike: build IKE SA delete: %w", err)
|
||||
}
|
||||
if err := transport.SendSessionPacket(ctx, request, true); err != nil {
|
||||
return fmt.Errorf("ike: send IKE SA delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (relay *sessionRelay) terminalErrorIfFailure() error {
|
||||
relay.mu.Lock()
|
||||
defer relay.mu.Unlock()
|
||||
|
||||
@@ -119,6 +119,7 @@ func TestSessionRelayCloseInterruptsStuckTransportRead(t *testing.T) {
|
||||
ikeKeys{},
|
||||
[8]byte{1},
|
||||
[8]byte{2},
|
||||
9,
|
||||
true,
|
||||
time.Hour,
|
||||
)
|
||||
@@ -156,7 +157,7 @@ func TestSessionRelayDemuxesESPAndAnswersEncryptedDPD(t *testing.T) {
|
||||
}
|
||||
spii := [8]byte{1}
|
||||
spir := [8]byte{2}
|
||||
relay := newSessionRelay(transport, suite, keys, spii, spir, true, time.Hour)
|
||||
relay := newSessionRelay(transport, suite, keys, spii, spir, 9, true, time.Hour)
|
||||
defer relay.Close()
|
||||
|
||||
esp := []byte{0, 0, 0, 9, 0, 0, 0, 1, 0xaa}
|
||||
@@ -201,6 +202,47 @@ func TestSessionRelayDemuxesESPAndAnswersEncryptedDPD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRelaySendsEncryptedIKESADelete(t *testing.T) {
|
||||
transport := newFakeSessionTransport()
|
||||
suite := legacyTestSuite()
|
||||
keys := ikeKeys{
|
||||
SKai: bytes.Repeat([]byte{0x11}, 20),
|
||||
SKar: bytes.Repeat([]byte{0x12}, 20),
|
||||
SKei: bytes.Repeat([]byte{0x13}, 16),
|
||||
SKer: bytes.Repeat([]byte{0x14}, 16),
|
||||
}
|
||||
spii := [8]byte{1}
|
||||
spir := [8]byte{2}
|
||||
relay := newSessionRelay(transport, suite, keys, spii, spir, 9, true, time.Hour)
|
||||
defer relay.Close()
|
||||
|
||||
if err := relay.sendIKEDelete(context.Background()); err != nil {
|
||||
t.Fatalf("sendIKEDelete() error = %v", err)
|
||||
}
|
||||
select {
|
||||
case sent := <-transport.sent:
|
||||
if !sent.ike {
|
||||
t.Fatal("IKE SA delete was sent as ESP")
|
||||
}
|
||||
header, payloads, err := decryptPayloads(sent.data, suite, keys.SKei, keys.SKai)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt IKE SA delete: %v", err)
|
||||
}
|
||||
if header.Exchange != exchangeInformational || header.MessageID != 9 || header.Flags != flagInitiator {
|
||||
t.Fatalf("IKE SA delete header = %#v", header)
|
||||
}
|
||||
item, err := onePayload(payloads, payloadDelete)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(item.Body, []byte{protocolIKE, 0, 0, 0}) {
|
||||
t.Fatalf("IKE SA delete body = %x", item.Body)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("relay did not send IKE SA delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRelaySendsNATKeepalive(t *testing.T) {
|
||||
transport := newFakeSessionTransport()
|
||||
relay := newSessionRelay(
|
||||
@@ -209,6 +251,7 @@ func TestSessionRelaySendsNATKeepalive(t *testing.T) {
|
||||
ikeKeys{},
|
||||
[8]byte{1},
|
||||
[8]byte{2},
|
||||
9,
|
||||
true,
|
||||
10*time.Millisecond,
|
||||
)
|
||||
@@ -233,6 +276,7 @@ func TestSessionRelayDropsDelayedIKEPacketFromPreviousSA(t *testing.T) {
|
||||
ikeKeys{},
|
||||
spii,
|
||||
spir,
|
||||
9,
|
||||
true,
|
||||
time.Hour,
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ const (
|
||||
payloadAuth = 39
|
||||
payloadNonce = 40
|
||||
payloadNotify = 41
|
||||
payloadDelete = 42
|
||||
payloadTSi = 44
|
||||
payloadTSr = 45
|
||||
payloadEncrypted = 46
|
||||
@@ -54,11 +55,14 @@ const (
|
||||
dhMODP2048 = 14
|
||||
transformAttributeKeyLen = 14
|
||||
|
||||
notifyNATSource = 16388
|
||||
notifyNATDestination = 16389
|
||||
notifyEAPOnlyAuth = 16417
|
||||
notifyInvalidKE = 17
|
||||
notifyNoProposal = 14
|
||||
notifyInitialContact = 16384
|
||||
notifyMOBIKESupported = 16396
|
||||
notifyNATSource = 16388
|
||||
notifyNATDestination = 16389
|
||||
notifyEAPOnlyAuth = 16417
|
||||
notifyDeviceIdentity = 41101
|
||||
notifyInvalidKE = 17
|
||||
notifyNoProposal = 14
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -3,6 +3,7 @@ package ike
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
@@ -60,7 +61,7 @@ func TestInitialEAPOnlyAuthCarriesAPNIDrAndNotify(t *testing.T) {
|
||||
dualStackTrafficSelectors(payloadTSi),
|
||||
dualStackTrafficSelectors(payloadTSr),
|
||||
)
|
||||
if len(payloads) != 7 || payloads[0].Type != payloadIDi || payloads[1].Type != payloadIDr {
|
||||
if len(payloads) != 9 || payloads[0].Type != payloadIDi || payloads[1].Type != payloadIDr {
|
||||
t.Fatalf("initial auth payload order = %#v", payloads)
|
||||
}
|
||||
if got := string(payloads[1].Body[4:]); got != "ims" || payloads[1].Body[0] != 2 {
|
||||
@@ -68,11 +69,25 @@ func TestInitialEAPOnlyAuthCarriesAPNIDrAndNotify(t *testing.T) {
|
||||
}
|
||||
kind, data, err := parseNotify(payloads[2])
|
||||
if err != nil {
|
||||
t.Fatalf("parseNotify() error = %v", err)
|
||||
t.Fatalf("parseNotify(EAP_ONLY_AUTHENTICATION) error = %v", err)
|
||||
}
|
||||
if kind != notifyEAPOnlyAuth || len(data) != 0 {
|
||||
t.Fatalf("notify = %d/%x, want EAP_ONLY_AUTHENTICATION", kind, data)
|
||||
}
|
||||
kind, data, err = parseNotify(payloads[3])
|
||||
if err != nil {
|
||||
t.Fatalf("parseNotify() error = %v", err)
|
||||
}
|
||||
if kind != notifyMOBIKESupported || len(data) != 0 {
|
||||
t.Fatalf("notify = %d/%x, want MOBIKE_SUPPORTED", kind, data)
|
||||
}
|
||||
kind, data, err = parseNotify(payloads[4])
|
||||
if err != nil {
|
||||
t.Fatalf("parseNotify() error = %v", err)
|
||||
}
|
||||
if kind != notifyInitialContact || len(data) != 0 {
|
||||
t.Fatalf("notify = %d/%x, want INITIAL_CONTACT", kind, data)
|
||||
}
|
||||
for _, kind := range []uint8{payloadTSi, payloadTSr} {
|
||||
item, err := onePayload(payloads, kind)
|
||||
if err != nil {
|
||||
@@ -88,6 +103,83 @@ func TestInitialEAPOnlyAuthCarriesAPNIDrAndNotify(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialStandardEAPAuthOmitsEAPOnlyNotify(t *testing.T) {
|
||||
idi := payload{Type: payloadIDi, Body: []byte{3, 0, 0, 0, 'u'}}
|
||||
idr := payload{Type: payloadIDr, Body: []byte{2, 0, 0, 0, 'i', 'm', 's'}}
|
||||
payloads := buildInitialEAPAuth(
|
||||
idi,
|
||||
idr,
|
||||
[]byte{1, 2, 3},
|
||||
dualStackTrafficSelectors(payloadTSi),
|
||||
dualStackTrafficSelectors(payloadTSr),
|
||||
false,
|
||||
)
|
||||
if len(payloads) != 8 || payloads[0].Type != payloadIDi || payloads[1].Type != payloadIDr {
|
||||
t.Fatalf("initial standard EAP payload order = %#v", payloads)
|
||||
}
|
||||
initialContact := 0
|
||||
mobikeSupported := 0
|
||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||
kind, _, err := parseNotify(item)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kind == notifyEAPOnlyAuth {
|
||||
t.Fatal("standard EAP initial request contains EAP_ONLY_AUTHENTICATION")
|
||||
}
|
||||
if kind == notifyInitialContact {
|
||||
initialContact++
|
||||
}
|
||||
if kind == notifyMOBIKESupported {
|
||||
mobikeSupported++
|
||||
}
|
||||
}
|
||||
if initialContact != 1 {
|
||||
t.Fatalf("standard EAP initial request INITIAL_CONTACT count = %d, want 1", initialContact)
|
||||
}
|
||||
if mobikeSupported != 1 {
|
||||
t.Fatalf("standard EAP initial request MOBIKE_SUPPORTED count = %d, want 1", mobikeSupported)
|
||||
}
|
||||
if payloads[2].Type != payloadNotify || payloads[3].Type != payloadNotify {
|
||||
t.Fatalf("standard EAP Android notify order = %#v", payloads[:4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationRequestMatchesAndroidAttributes(t *testing.T) {
|
||||
cp := configurationRequest()
|
||||
if cp.Type != payloadCP || len(cp.Body) < 4 || cp.Body[0] != configRequest {
|
||||
t.Fatalf("configuration request = %#v", cp)
|
||||
}
|
||||
var attributes []uint16
|
||||
for offset := 4; offset < len(cp.Body); {
|
||||
if offset+4 > len(cp.Body) {
|
||||
t.Fatalf("truncated attribute at %d", offset)
|
||||
}
|
||||
kind := uint16(cp.Body[offset])<<8 | uint16(cp.Body[offset+1])
|
||||
length := int(cp.Body[offset+2])<<8 | int(cp.Body[offset+3])
|
||||
if offset+4+length > len(cp.Body) {
|
||||
t.Fatalf("attribute %d exceeds payload", kind)
|
||||
}
|
||||
attributes = append(attributes, kind)
|
||||
offset += 4 + length
|
||||
}
|
||||
want := []uint16{1, 8, 3, 10, 20, 21, configApplicationVersion}
|
||||
if !slices.Equal(attributes, want) {
|
||||
t.Fatalf("configuration attributes = %v, want %v", attributes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) {
|
||||
for _, mnc := range []string{"03", "003"} {
|
||||
if advertiseEAPOnlyAuthentication("262", mnc) {
|
||||
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
|
||||
}
|
||||
}
|
||||
if !advertiseEAPOnlyAuthentication("262", "02") || !advertiseEAPOnlyAuthentication("234", "15") {
|
||||
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponderIDrValidatorsSeparateEPDGAndAPN(t *testing.T) {
|
||||
epdg := payload{
|
||||
Type: payloadIDr,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,6 +16,21 @@ type recordingAKA struct {
|
||||
challenges []vowifi.AKAChallenge
|
||||
}
|
||||
|
||||
type recordingPreferredAKA struct {
|
||||
recordingAKA
|
||||
preference string
|
||||
}
|
||||
|
||||
func (aka *recordingPreferredAKA) AuthenticateWithPreference(
|
||||
ctx context.Context,
|
||||
identity vowifi.SIMIdentity,
|
||||
challenge vowifi.AKAChallenge,
|
||||
preference string,
|
||||
) (vowifi.AKAResult, error) {
|
||||
aka.preference = preference
|
||||
return aka.Authenticate(ctx, identity, challenge)
|
||||
}
|
||||
|
||||
func (aka *recordingAKA) CheckReady(context.Context, vowifi.SIMIdentity) (vowifi.AKAEvidence, error) {
|
||||
return vowifi.AKAEvidence{Ready: true, Application: "usim"}, nil
|
||||
}
|
||||
@@ -60,6 +75,7 @@ func TestAuthenticateAKAMapsNonceToTypedChallenge(t *testing.T) {
|
||||
aka,
|
||||
vowifi.SIMIdentity{IMSI: "001010123456789"},
|
||||
digestChallenge{Nonce: base64.StdEncoding.EncodeToString(nonceBytes)},
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("authenticateAKA() error = %v", err)
|
||||
@@ -93,6 +109,7 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) {
|
||||
aka,
|
||||
vowifi.SIMIdentity{},
|
||||
digestChallenge{Nonce: nonce},
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("authenticateAKA() error = %v", err)
|
||||
@@ -102,6 +119,26 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateAKAUsesPreferredApplicationWhenSupported(t *testing.T) {
|
||||
nonce := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
aka := &recordingPreferredAKA{recordingAKA: recordingAKA{
|
||||
result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4}},
|
||||
}}
|
||||
_, err := authenticateAKA(
|
||||
context.Background(),
|
||||
aka,
|
||||
vowifi.SIMIdentity{IMSI: "310280229187733"},
|
||||
digestChallenge{Nonce: nonce},
|
||||
"isim_strict",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("authenticateAKA() error = %v", err)
|
||||
}
|
||||
if aka.preference != "isim_strict" {
|
||||
t.Fatalf("preference = %q, want isim_strict", aka.preference)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDigestAuthorizationCarriesAUTSWithEmptyResponse(t *testing.T) {
|
||||
authorization := buildDigestAuthorization(
|
||||
digestChallenge{
|
||||
|
||||
+117
-12
@@ -271,13 +271,22 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
|
||||
mnc = "0" + mnc
|
||||
}
|
||||
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
||||
privateDomain := domain
|
||||
publicDomain := domain
|
||||
if vowifi.IsATT310280(identity) {
|
||||
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
||||
// the generic 3GPP PLMN IMS domain.
|
||||
domain = "one.att.net"
|
||||
privateDomain = "private.att.net"
|
||||
publicDomain = "one.att.net"
|
||||
}
|
||||
privateIdentity := config.PrivateIdentity
|
||||
if privateIdentity == "" {
|
||||
privateIdentity = imsi + "@" + domain
|
||||
privateIdentity = imsi + "@" + privateDomain
|
||||
}
|
||||
publicIdentity := config.PublicIdentity
|
||||
if publicIdentity == "" {
|
||||
publicIdentity = "sip:" + imsi + "@" + domain
|
||||
publicIdentity = "sip:" + imsi + "@" + publicDomain
|
||||
}
|
||||
if strings.ContainsAny(privateIdentity+publicIdentity, "\r\n") ||
|
||||
!strings.Contains(privateIdentity, "@") ||
|
||||
@@ -534,15 +543,33 @@ func newSession(
|
||||
refreshCancel()
|
||||
return nil, errors.New("ims: protected local IP address is unavailable")
|
||||
}
|
||||
protectedClientPort := provider.config.ProtectedClientPort
|
||||
protectedServerPort := provider.config.ProtectedServerPort
|
||||
if vowifi.IsATT310280(request.Identity) && protectedServerPort == 0 {
|
||||
protectedServerPort = 6000
|
||||
}
|
||||
if securityEncryptionForIdentity(request.Identity) == "null" {
|
||||
if protectedClientPort == 0 {
|
||||
protectedClientPort = 5062
|
||||
}
|
||||
if protectedServerPort == 0 {
|
||||
protectedServerPort = 5063
|
||||
}
|
||||
}
|
||||
proposal, err := newSecurityProposal(
|
||||
localIP,
|
||||
provider.config.ProtectedClientPort,
|
||||
provider.config.ProtectedServerPort,
|
||||
protectedClientPort,
|
||||
protectedServerPort,
|
||||
)
|
||||
if err != nil {
|
||||
refreshCancel()
|
||||
return nil, err
|
||||
}
|
||||
proposal.encryption = securityEncryptionForIdentity(request.Identity)
|
||||
if vowifi.IsATT310280(request.Identity) {
|
||||
proposal.integrityAlgorithms = []string{"hmac-sha-1-96"}
|
||||
proposal.encryptionAlgorithmsList = []string{"aes-cbc"}
|
||||
}
|
||||
session.securityProposal = proposal
|
||||
protectedTCP, err := net.ListenTCP(
|
||||
"tcp",
|
||||
@@ -567,6 +594,21 @@ func newSession(
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func securityEncryptionForIdentity(identity vowifi.SIMIdentity) string {
|
||||
if usesO2GermanyIMSProfile(identity) {
|
||||
// O2 Germany's P-CSCF advertises the 3GPP integrity-only ESP profile.
|
||||
// Proposing aes-cbc is rejected before the AKA challenge is issued.
|
||||
return "null"
|
||||
}
|
||||
return "aes-cbc"
|
||||
}
|
||||
|
||||
func usesO2GermanyIMSProfile(identity vowifi.SIMIdentity) bool {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
||||
return mcc+mnc == "2623"
|
||||
}
|
||||
|
||||
func (session *Session) abort() {
|
||||
session.refreshCancel()
|
||||
_ = session.conn.Close()
|
||||
@@ -635,6 +677,15 @@ func registrationRejectionError(response *sipResponse, phase string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// P-Debug-Info is carrier-generated but can contain subscriber identifiers.
|
||||
// Surface only a fixed classification for the O2 security-agreement error;
|
||||
// never copy the raw header into logs or API responses.
|
||||
for _, value := range response.values("P-Debug-Info") {
|
||||
if strings.Contains(strings.ToLower(value), "no matched security item") {
|
||||
message += "; carrier detail: no matched IMS security item"
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: %s", ErrRegistrationRejected, message)
|
||||
}
|
||||
|
||||
@@ -697,7 +748,11 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge)
|
||||
preference := ""
|
||||
if vowifi.IsATT310280(session.request.Identity) {
|
||||
preference = "isim_strict"
|
||||
}
|
||||
material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge, preference)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -757,6 +812,10 @@ func (session *Session) buildRegister(
|
||||
authorizationHeader string,
|
||||
authorization string,
|
||||
) ([]byte, error) {
|
||||
att310280 := vowifi.IsATT310280(session.request.Identity)
|
||||
if att310280 {
|
||||
expires = 18400
|
||||
}
|
||||
branch, err := randomHex(12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -775,6 +834,34 @@ func (session *Session) buildRegister(
|
||||
session.instanceID,
|
||||
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
|
||||
)
|
||||
if att310280 {
|
||||
contact = fmt.Sprintf(
|
||||
`<sip:%s@%s;transport=%s>;+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip;`+
|
||||
`+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`,
|
||||
session.identity.user,
|
||||
contactAddress,
|
||||
session.transport,
|
||||
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
|
||||
session.instanceID,
|
||||
)
|
||||
}
|
||||
o2Germany := usesO2GermanyIMSProfile(session.request.Identity)
|
||||
supported := "path, gruu"
|
||||
allow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS"
|
||||
if o2Germany {
|
||||
// Match the complete IMS capability set used by the previously working
|
||||
// VoHive client. O2 validates more of the initial UE security profile
|
||||
// than the other tested carriers do.
|
||||
supported = "path, gruu, outbound, sec-agree, 100rel, timer"
|
||||
allow = "INVITE, ACK, CANCEL, BYE, PRACK, UPDATE, INFO, MESSAGE, OPTIONS"
|
||||
}
|
||||
if att310280 {
|
||||
supported = "path,sec-agree,gruu"
|
||||
}
|
||||
userAgent := strings.TrimSpace(session.provider.config.UserAgent)
|
||||
if att310280 && (userAgent == "" || userAgent == "vocat/1") {
|
||||
userAgent = "SimAdmin VoWiFi"
|
||||
}
|
||||
lines := []string{
|
||||
"REGISTER " + requestURI + " SIP/2.0",
|
||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", transportUpper, local, branch),
|
||||
@@ -786,14 +873,25 @@ func (session *Session) buildRegister(
|
||||
fmt.Sprintf("CSeq: %d REGISTER", cseq),
|
||||
"Contact: " + contact,
|
||||
fmt.Sprintf("Expires: %d", expires),
|
||||
"Supported: path, gruu",
|
||||
"Allow: REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS",
|
||||
"User-Agent: " + session.provider.config.UserAgent,
|
||||
"Supported: " + supported,
|
||||
"Allow: " + allow,
|
||||
"User-Agent: " + userAgent,
|
||||
}
|
||||
if o2Germany {
|
||||
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
|
||||
} else if att310280 {
|
||||
lines = append(lines,
|
||||
"P-Preferred-Identity: <"+session.identity.public+">",
|
||||
`P-Visited-Network-ID: "one.att.net"`,
|
||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
|
||||
"Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0",
|
||||
"Accept-Contact: *;+g.3gpp.smsip",
|
||||
`Accept-Contact: *;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"`,
|
||||
)
|
||||
}
|
||||
if session.securityOffered() {
|
||||
lines = append(
|
||||
lines,
|
||||
"Security-Client: "+session.securityProposal.headerValue(),
|
||||
lines = append(lines,
|
||||
"Security-Client: "+session.securityClientValue(),
|
||||
"Require: sec-agree",
|
||||
"Proxy-Require: sec-agree",
|
||||
)
|
||||
@@ -1194,7 +1292,14 @@ func (session *Session) Close(ctx context.Context) error {
|
||||
session.closeInboundConnections()
|
||||
session.receiveDone.Wait()
|
||||
if session.ipsecHandle != nil {
|
||||
if err := session.ipsecHandle.Close(ctx); err != nil {
|
||||
// XFRM teardown is local and must still run when SIP deregistration has
|
||||
// consumed the caller's deadline. Use a fresh bounded context so a
|
||||
// service restart or Profile switch cannot strand the previous SIM's
|
||||
// transport-mode policies in the kernel.
|
||||
cleanupContext, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
err := session.ipsecHandle.Close(cleanupContext)
|
||||
cleanupCancel()
|
||||
if err != nil {
|
||||
cleanupErrors = append(cleanupErrors, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +379,138 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestO2GermanyInitialRegisterMatchesSupportedIMSProfile(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
identity := vowifi.SIMIdentity{
|
||||
IMSI: "262030123456789",
|
||||
HomeMCC: "262",
|
||||
HomeMNC: "03",
|
||||
}
|
||||
identities, err := deriveIdentities(identity, Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("deriveIdentities() error = %v", err)
|
||||
}
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{
|
||||
SecurityMode: SecurityRequired,
|
||||
UserAgent: "vocat-test",
|
||||
}},
|
||||
request: vowifi.IMSRequest{Identity: identity},
|
||||
identity: identities,
|
||||
endpoint: pcscfEndpoint{host: "pcscf.example", port: 5060},
|
||||
transport: "tcp",
|
||||
conn: client,
|
||||
callID: "o2-test",
|
||||
fromTag: "tag",
|
||||
instanceID: "urn:uuid:test",
|
||||
securityProposal: securityProposal{
|
||||
spiClient: 101,
|
||||
spiServer: 102,
|
||||
portClient: 5062,
|
||||
portServer: 5063,
|
||||
encryption: "null",
|
||||
},
|
||||
}
|
||||
|
||||
packet, err := session.buildRegister(1, 3600, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRegister() error = %v", err)
|
||||
}
|
||||
_, headers, err := parseTestRequest(packet)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTestRequest() error = %v", err)
|
||||
}
|
||||
if got, want := headers["security-client"], "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null;spi-c=0000000101;spi-s=0000000102;port-c=5062;port-s=5063"; got != want {
|
||||
t.Fatalf("Security-Client = %q, want %q", got, want)
|
||||
}
|
||||
if headers["proxy-require"] != "sec-agree" || !strings.Contains(headers["authorization"], "integrity-protected=no") {
|
||||
t.Fatalf("initial O2 headers omitted standardized sec-agree/IMS-AKA fields: %#v", headers)
|
||||
}
|
||||
if got, want := headers["p-preferred-identity"], "<"+identities.public+">"; got != want {
|
||||
t.Fatalf("P-Preferred-Identity = %q, want %q", got, want)
|
||||
}
|
||||
for name, token := range map[string]string{
|
||||
"supported": "sec-agree",
|
||||
"allow": "MESSAGE",
|
||||
} {
|
||||
if !strings.Contains(headers[name], token) {
|
||||
t.Fatalf("%s = %q, want token %q", name, headers[name], token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestATT310280DeriveIdentitiesUsesISIMDomains(t *testing.T) {
|
||||
identities, err := deriveIdentities(vowifi.SIMIdentity{
|
||||
IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280",
|
||||
}, Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("deriveIdentities() error = %v", err)
|
||||
}
|
||||
if identities.domain != "one.att.net" ||
|
||||
identities.private != "[email protected]" ||
|
||||
identities.public != "sip:[email protected]" {
|
||||
t.Fatalf("AT&T identities = %#v", identities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATT310280InitialRegisterMatchesProvisionedProfile(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
identity := vowifi.SIMIdentity{
|
||||
IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280",
|
||||
}
|
||||
identities, err := deriveIdentities(identity, Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{SecurityMode: SecurityRequired, UserAgent: "vocat/1"}},
|
||||
request: vowifi.IMSRequest{Identity: identity},
|
||||
identity: identities,
|
||||
endpoint: pcscfEndpoint{host: "pcscf.example", port: 5060},
|
||||
transport: "tcp",
|
||||
conn: client,
|
||||
callID: "att-test",
|
||||
fromTag: "tag",
|
||||
instanceID: "urn:uuid:test",
|
||||
securityProposal: securityProposal{
|
||||
spiClient: 1546543, spiServer: 1546542,
|
||||
portClient: 32773, portServer: 6000,
|
||||
integrityAlgorithms: []string{"hmac-sha-1-96"},
|
||||
encryptionAlgorithmsList: []string{"aes-cbc"},
|
||||
},
|
||||
}
|
||||
packet, err := session.buildRegister(1, 3600, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRegister() error = %v", err)
|
||||
}
|
||||
request := string(packet)
|
||||
for _, want := range []string{
|
||||
"REGISTER sip:one.att.net SIP/2.0",
|
||||
"Expires: 18400",
|
||||
"Supported: path,sec-agree,gruu",
|
||||
"User-Agent: SimAdmin VoWiFi",
|
||||
`+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip`,
|
||||
"P-Preferred-Identity: <sip:[email protected]>",
|
||||
`P-Visited-Network-ID: "one.att.net"`,
|
||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
|
||||
"Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0",
|
||||
"Accept-Contact: *;+g.3gpp.smsip",
|
||||
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=1546543; spi-s=1546542; port-c=32773; port-s=6000",
|
||||
`username="[email protected]"`,
|
||||
`uri="sip:one.att.net"`,
|
||||
} {
|
||||
if !strings.Contains(request, want) {
|
||||
t.Fatalf("AT&T REGISTER omits %q:\n%s", want, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
|
||||
var callID string
|
||||
for step := 0; step < 3; step++ {
|
||||
|
||||
+212
-47
@@ -12,6 +12,8 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
type SecurityMode string
|
||||
@@ -39,6 +41,12 @@ var (
|
||||
type IPSecSAConfig struct {
|
||||
LocalIP net.IP
|
||||
RemoteIP net.IP
|
||||
// IntegrityAlgorithm is the negotiated 3GPP Security-Server alg value.
|
||||
// Supported values are hmac-md5-96 and hmac-sha-1-96.
|
||||
IntegrityAlgorithm string
|
||||
// EncryptionAlgorithm is the negotiated 3GPP Security-Server ealg value.
|
||||
// Supported values are null, des-ede3-cbc, and aes-cbc.
|
||||
EncryptionAlgorithm string
|
||||
|
||||
UEClientSPI uint32
|
||||
UEServerSPI uint32
|
||||
@@ -63,10 +71,14 @@ type IPSecSAInstaller interface {
|
||||
}
|
||||
|
||||
type securityProposal struct {
|
||||
spiClient uint32
|
||||
spiServer uint32
|
||||
portClient int
|
||||
portServer int
|
||||
spiClient uint32
|
||||
spiServer uint32
|
||||
portClient int
|
||||
portServer int
|
||||
encryption string
|
||||
fallbackEncryption string
|
||||
integrityAlgorithms []string
|
||||
encryptionAlgorithmsList []string
|
||||
}
|
||||
|
||||
func newSecurityProposal(localIP net.IP, configuredClientPort int, configuredServerPort int) (securityProposal, error) {
|
||||
@@ -96,21 +108,98 @@ func newSecurityProposal(localIP net.IP, configuredClientPort int, configuredSer
|
||||
return securityProposal{}, errors.New("ims: protected UE ports must be distinct non-standard SIP ports")
|
||||
}
|
||||
return securityProposal{
|
||||
spiClient: spiClient,
|
||||
spiServer: spiServer,
|
||||
portClient: portClient,
|
||||
portServer: portServer,
|
||||
spiClient: spiClient,
|
||||
spiServer: spiServer,
|
||||
portClient: portClient,
|
||||
portServer: portServer,
|
||||
integrityAlgorithms: []string{"hmac-md5-96", "hmac-sha-1-96"},
|
||||
encryptionAlgorithmsList: []string{"null", "des-ede3-cbc", "aes-cbc"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (proposal securityProposal) headerValue() string {
|
||||
return fmt.Sprintf(
|
||||
"ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=aes-cbc;spi-c=%010d;spi-s=%010d;port-c=%d;port-s=%d",
|
||||
proposal.spiClient,
|
||||
proposal.spiServer,
|
||||
proposal.portClient,
|
||||
proposal.portServer,
|
||||
)
|
||||
// TS 33.203 defines spi-c and spi-s as exactly 10 decimal digits. Keep the
|
||||
// complete mechanism explicit even where ESP/null defaults would permit a
|
||||
// shorter form; this is the interoperable handset/IMS profile.
|
||||
values := make([]string, 0, len(proposal.integrities())*len(proposal.encryptionAlgorithms()))
|
||||
index := 0
|
||||
for _, integrity := range proposal.integrities() {
|
||||
for _, encryption := range proposal.encryptionAlgorithms() {
|
||||
preference := fmt.Sprintf("0.%03d", 999-index)
|
||||
if index == 0 {
|
||||
preference = "1.000"
|
||||
}
|
||||
values = append(values, fmt.Sprintf(
|
||||
"ipsec-3gpp;q=%s;alg=%s;prot=esp;mod=trans;ealg=%s;spi-c=%010d;spi-s=%010d;port-c=%d;port-s=%d",
|
||||
preference,
|
||||
integrity,
|
||||
encryption,
|
||||
proposal.spiClient,
|
||||
proposal.spiServer,
|
||||
proposal.portClient,
|
||||
proposal.portServer,
|
||||
))
|
||||
index++
|
||||
}
|
||||
}
|
||||
return strings.Join(values, ", ")
|
||||
}
|
||||
|
||||
func (session *Session) securityClientValue() string {
|
||||
if vowifi.IsATT310280(session.request.Identity) {
|
||||
return fmt.Sprintf(
|
||||
"ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=%d; spi-s=%d; port-c=%d; port-s=%d",
|
||||
session.securityProposal.spiClient,
|
||||
session.securityProposal.spiServer,
|
||||
session.securityProposal.portClient,
|
||||
session.securityProposal.portServer,
|
||||
)
|
||||
}
|
||||
return session.securityProposal.headerValue()
|
||||
}
|
||||
|
||||
func (proposal securityProposal) encryptionAlgorithm() string {
|
||||
if strings.EqualFold(strings.TrimSpace(proposal.encryption), "null") {
|
||||
return "null"
|
||||
}
|
||||
return "aes-cbc"
|
||||
}
|
||||
|
||||
func (proposal securityProposal) encryptionAlgorithms() []string {
|
||||
if len(proposal.encryptionAlgorithmsList) > 0 {
|
||||
return append([]string(nil), proposal.encryptionAlgorithmsList...)
|
||||
}
|
||||
algorithms := []string{proposal.encryptionAlgorithm()}
|
||||
fallback := strings.ToLower(strings.TrimSpace(proposal.fallbackEncryption))
|
||||
if (fallback == "aes-cbc" || fallback == "null") && fallback != algorithms[0] {
|
||||
algorithms = append(algorithms, fallback)
|
||||
}
|
||||
return algorithms
|
||||
}
|
||||
|
||||
func (proposal securityProposal) integrities() []string {
|
||||
if len(proposal.integrityAlgorithms) > 0 {
|
||||
return append([]string(nil), proposal.integrityAlgorithms...)
|
||||
}
|
||||
return []string{"hmac-sha-1-96"}
|
||||
}
|
||||
|
||||
func (proposal securityProposal) supportsIntegrity(integrity string) bool {
|
||||
for _, offered := range proposal.integrities() {
|
||||
if strings.EqualFold(integrity, offered) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (proposal securityProposal) supportsEncryption(encryption string) bool {
|
||||
for _, offered := range proposal.encryptionAlgorithms() {
|
||||
if strings.EqualFold(encryption, offered) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func randomSPI(exclude uint32) (uint32, error) {
|
||||
@@ -198,10 +287,10 @@ func parseSecurityAgreement(values []string, proposal securityProposal) (securit
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(mechanism.name, "ipsec-3gpp") ||
|
||||
!strings.EqualFold(mechanism.algorithm, "hmac-sha-1-96") ||
|
||||
!proposal.supportsIntegrity(mechanism.algorithm) ||
|
||||
!strings.EqualFold(mechanism.protocol, "esp") ||
|
||||
!strings.EqualFold(mechanism.mode, "trans") ||
|
||||
!strings.EqualFold(mechanism.encryption, "aes-cbc") {
|
||||
!proposal.supportsEncryption(mechanism.encryption) {
|
||||
continue
|
||||
}
|
||||
if mechanism.spiClient == 0 || mechanism.spiServer == 0 ||
|
||||
@@ -349,16 +438,48 @@ func preferenceValue(value string) (int, error) {
|
||||
return numeric, nil
|
||||
}
|
||||
|
||||
func expandIPSecKeys(ck []byte, ik []byte) (encryption []byte, integrity []byte, err error) {
|
||||
func expandIPSecKeys(ck []byte, ik []byte, encryptionAlgorithm, integrityAlgorithm string) (encryption []byte, integrity []byte, err error) {
|
||||
if len(ck) != 16 || len(ik) != 16 {
|
||||
return nil, nil, errors.New("ims: AKA did not return 16-byte CK and IK")
|
||||
}
|
||||
encryption = append([]byte(nil), ck...)
|
||||
integrity = make([]byte, 20)
|
||||
copy(integrity, ik)
|
||||
switch strings.ToLower(strings.TrimSpace(encryptionAlgorithm)) {
|
||||
case "null":
|
||||
encryption = nil
|
||||
case "aes-cbc", "":
|
||||
encryption = append([]byte(nil), ck...)
|
||||
case "des-ede3-cbc":
|
||||
encryption = append(encryption, ck...)
|
||||
encryption = append(encryption, ck[:8]...)
|
||||
for index, value := range encryption {
|
||||
encryption[index] = withOddDESParity(value)
|
||||
}
|
||||
default:
|
||||
return nil, nil, errors.New("ims: unsupported ipsec-3gpp encryption algorithm")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(integrityAlgorithm)) {
|
||||
case "hmac-md5-96":
|
||||
integrity = append([]byte(nil), ik...)
|
||||
case "hmac-sha-1-96", "":
|
||||
integrity = make([]byte, 20)
|
||||
copy(integrity, ik)
|
||||
default:
|
||||
return nil, nil, errors.New("ims: unsupported ipsec-3gpp integrity algorithm")
|
||||
}
|
||||
return encryption, integrity, nil
|
||||
}
|
||||
|
||||
func withOddDESParity(value byte) byte {
|
||||
value &^= 1
|
||||
ones := 0
|
||||
for bits := value; bits != 0; bits >>= 1 {
|
||||
ones += int(bits & 1)
|
||||
}
|
||||
if ones%2 == 0 {
|
||||
value |= 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type xfrmOperation struct {
|
||||
description string
|
||||
arguments []string
|
||||
@@ -382,20 +503,31 @@ func buildXFRMInstallPlan(config IPSecSAConfig) ([]xfrmOperation, error) {
|
||||
{"outbound UE-server to P-CSCF-client state", config.LocalIP, config.RemoteIP, config.PCSCFClientSPI, serverPairReqID(config)},
|
||||
}
|
||||
for _, state := range states {
|
||||
arguments := []string{
|
||||
"xfrm", "state", "add",
|
||||
"src", state.source.String(),
|
||||
"dst", state.destination.String(),
|
||||
"proto", "esp",
|
||||
"spi", fmt.Sprintf("0x%08x", state.spi),
|
||||
"reqid", strconv.FormatUint(uint64(state.reqid), 10),
|
||||
"mode", "transport",
|
||||
"replay-window", "32",
|
||||
"auth-trunc", xfrmIntegrityAlgorithm(config), "0x" + hex.EncodeToString(config.IntegrityKey), "96",
|
||||
}
|
||||
switch ipsecEncryptionAlgorithm(config) {
|
||||
case "aes-cbc":
|
||||
arguments = append(arguments, "enc", "cbc(aes)", "0x"+hex.EncodeToString(config.EncryptionKey))
|
||||
case "des-ede3-cbc":
|
||||
arguments = append(arguments, "enc", "cbc(des3_ede)", "0x"+hex.EncodeToString(config.EncryptionKey))
|
||||
default:
|
||||
// Linux requires an explicit encryption transform for ESP even when
|
||||
// 3GPP negotiates ealg=null. iproute2 must receive a genuinely empty
|
||||
// key argument; the textual value "0x" is rejected by XFRM as EINVAL.
|
||||
arguments = append(arguments, "enc", "cipher_null", "")
|
||||
}
|
||||
operations = append(operations, xfrmOperation{
|
||||
description: state.description,
|
||||
arguments: []string{
|
||||
"xfrm", "state", "add",
|
||||
"src", state.source.String(),
|
||||
"dst", state.destination.String(),
|
||||
"proto", "esp",
|
||||
"spi", fmt.Sprintf("0x%08x", state.spi),
|
||||
"reqid", strconv.FormatUint(uint64(state.reqid), 10),
|
||||
"mode", "transport",
|
||||
"replay-window", "32",
|
||||
"auth-trunc", "hmac(sha1)", "0x" + hex.EncodeToString(config.IntegrityKey), "96",
|
||||
"enc", "cbc(aes)", "0x" + hex.EncodeToString(config.EncryptionKey),
|
||||
},
|
||||
arguments: arguments,
|
||||
})
|
||||
}
|
||||
for _, flow := range xfrmFlows(config) {
|
||||
@@ -588,12 +720,43 @@ func validateIPSecSAConfig(config IPSecSAConfig) error {
|
||||
config.PCSCFClientPort == config.PCSCFServerPort {
|
||||
return errors.New("ims: client and server protected ports must differ")
|
||||
}
|
||||
if len(config.EncryptionKey) != 16 || len(config.IntegrityKey) != 20 {
|
||||
if ipsecEncryptionAlgorithm(config) != "null" && ipsecEncryptionAlgorithm(config) != "aes-cbc" && ipsecEncryptionAlgorithm(config) != "des-ede3-cbc" {
|
||||
return errors.New("ims: unsupported ipsec-3gpp encryption algorithm")
|
||||
}
|
||||
if ipsecIntegrityAlgorithm(config) != "hmac-md5-96" && ipsecIntegrityAlgorithm(config) != "hmac-sha-1-96" {
|
||||
return errors.New("ims: unsupported ipsec-3gpp integrity algorithm")
|
||||
}
|
||||
wantEncryptionKey := map[string]int{"null": 0, "aes-cbc": 16, "des-ede3-cbc": 24}[ipsecEncryptionAlgorithm(config)]
|
||||
wantIntegrityKey := map[string]int{"hmac-md5-96": 16, "hmac-sha-1-96": 20}[ipsecIntegrityAlgorithm(config)]
|
||||
if len(config.EncryptionKey) != wantEncryptionKey || len(config.IntegrityKey) != wantIntegrityKey {
|
||||
return errors.New("ims: ipsec-3gpp key length is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ipsecIntegrityAlgorithm(config IPSecSAConfig) string {
|
||||
algorithm := strings.ToLower(strings.TrimSpace(config.IntegrityAlgorithm))
|
||||
if algorithm == "" {
|
||||
return "hmac-sha-1-96"
|
||||
}
|
||||
return algorithm
|
||||
}
|
||||
|
||||
func xfrmIntegrityAlgorithm(config IPSecSAConfig) string {
|
||||
if ipsecIntegrityAlgorithm(config) == "hmac-md5-96" {
|
||||
return "hmac(md5)"
|
||||
}
|
||||
return "hmac(sha1)"
|
||||
}
|
||||
|
||||
func ipsecEncryptionAlgorithm(config IPSecSAConfig) string {
|
||||
algorithm := strings.ToLower(strings.TrimSpace(config.EncryptionAlgorithm))
|
||||
if algorithm == "" {
|
||||
return "aes-cbc"
|
||||
}
|
||||
return algorithm
|
||||
}
|
||||
|
||||
func cloneIPSecSAConfig(config IPSecSAConfig) IPSecSAConfig {
|
||||
config.LocalIP = append(net.IP(nil), config.LocalIP...)
|
||||
config.RemoteIP = append(net.IP(nil), config.RemoteIP...)
|
||||
@@ -657,7 +820,7 @@ func (session *Session) activateIPSec(
|
||||
if !session.securityOffered() {
|
||||
return ErrIPSecAgreementRequired
|
||||
}
|
||||
encryptionKey, integrityKey, err := expandIPSecKeys(ck, ik)
|
||||
encryptionKey, integrityKey, err := expandIPSecKeys(ck, ik, agreement.selected.encryption, agreement.selected.algorithm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -671,18 +834,20 @@ func (session *Session) activateIPSec(
|
||||
}
|
||||
selected := agreement.selected
|
||||
config := IPSecSAConfig{
|
||||
LocalIP: localIP,
|
||||
RemoteIP: remoteIP,
|
||||
UEClientSPI: session.securityProposal.spiClient,
|
||||
UEServerSPI: session.securityProposal.spiServer,
|
||||
PCSCFClientSPI: selected.spiClient,
|
||||
PCSCFServerSPI: selected.spiServer,
|
||||
UEClientPort: session.securityProposal.portClient,
|
||||
UEServerPort: session.securityProposal.portServer,
|
||||
PCSCFClientPort: selected.portClient,
|
||||
PCSCFServerPort: selected.portServer,
|
||||
EncryptionKey: encryptionKey,
|
||||
IntegrityKey: integrityKey,
|
||||
LocalIP: localIP,
|
||||
RemoteIP: remoteIP,
|
||||
IntegrityAlgorithm: selected.algorithm,
|
||||
EncryptionAlgorithm: selected.encryption,
|
||||
UEClientSPI: session.securityProposal.spiClient,
|
||||
UEServerSPI: session.securityProposal.spiServer,
|
||||
PCSCFClientSPI: selected.spiClient,
|
||||
PCSCFServerSPI: selected.spiServer,
|
||||
UEClientPort: session.securityProposal.portClient,
|
||||
UEServerPort: session.securityProposal.portServer,
|
||||
PCSCFClientPort: selected.portClient,
|
||||
PCSCFServerPort: selected.portServer,
|
||||
EncryptionKey: encryptionKey,
|
||||
IntegrityKey: integrityKey,
|
||||
}
|
||||
handle, err := session.provider.installer.Install(ctx, config)
|
||||
if err != nil {
|
||||
|
||||
@@ -23,8 +23,9 @@ type fakeIPSecInstaller struct {
|
||||
}
|
||||
|
||||
type fakeIPSecHandle struct {
|
||||
mu sync.Mutex
|
||||
closeCount int
|
||||
mu sync.Mutex
|
||||
closeCount int
|
||||
closeContextErr error
|
||||
}
|
||||
|
||||
func (installer *fakeIPSecInstaller) Install(
|
||||
@@ -53,10 +54,11 @@ func (installer *fakeIPSecInstaller) installed() []IPSecSAConfig {
|
||||
return result
|
||||
}
|
||||
|
||||
func (handle *fakeIPSecHandle) Close(context.Context) error {
|
||||
func (handle *fakeIPSecHandle) Close(ctx context.Context) error {
|
||||
handle.mu.Lock()
|
||||
defer handle.mu.Unlock()
|
||||
handle.closeCount++
|
||||
handle.closeContextErr = ctx.Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -66,6 +68,38 @@ func (handle *fakeIPSecHandle) closes() int {
|
||||
return handle.closeCount
|
||||
}
|
||||
|
||||
func (handle *fakeIPSecHandle) contextError() error {
|
||||
handle.mu.Lock()
|
||||
defer handle.mu.Unlock()
|
||||
return handle.closeContextErr
|
||||
}
|
||||
|
||||
func TestSessionCloseCleansIPSecAfterCallerDeadline(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer server.Close()
|
||||
refreshDone := make(chan struct{})
|
||||
close(refreshDone)
|
||||
handle := &fakeIPSecHandle{}
|
||||
session := &Session{
|
||||
conn: client,
|
||||
refreshCancel: func() {},
|
||||
refreshDone: refreshDone,
|
||||
ipsecHandle: handle,
|
||||
calls: make(map[string]*imsCall),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := session.Close(ctx); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
if handle.closes() != 1 {
|
||||
t.Fatalf("IPsec close count = %d, want 1", handle.closes())
|
||||
}
|
||||
if err := handle.contextError(); err != nil {
|
||||
t.Fatalf("IPsec cleanup inherited expired caller context: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderNegotiatesIPSecAndRegistersOverProtectedTCP(t *testing.T) {
|
||||
localIP := net.ParseIP("127.0.0.1")
|
||||
remoteIP := net.ParseIP("127.0.0.2")
|
||||
@@ -386,7 +420,12 @@ func serveProtectedRegistrar(
|
||||
return result, fmt.Errorf("initial sec-agree headers = %#v", headers)
|
||||
}
|
||||
result.securityClient = headers["security-client"]
|
||||
proposal, err := parseSecurityMechanism(result.securityClient)
|
||||
offers := splitHeaderValues([]string{result.securityClient})
|
||||
if len(offers) != 6 {
|
||||
_ = initialConnection.Close()
|
||||
return result, fmt.Errorf("initial Security-Client offers = %d, want 6", len(offers))
|
||||
}
|
||||
proposal, err := parseSecurityMechanism(offers[0])
|
||||
if err != nil {
|
||||
_ = initialConnection.Close()
|
||||
return result, fmt.Errorf("parse initial Security-Client: %w", err)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func TestParseSecurityAgreementSelectsSupportedIPSec(t *testing.T) {
|
||||
@@ -36,6 +38,51 @@ func TestParseSecurityAgreementSelectsSupportedIPSec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestO2GermanySecurityProposalUsesIntegrityOnlyESP(t *testing.T) {
|
||||
identity := vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "03"}
|
||||
if got := securityEncryptionForIdentity(identity); got != "null" {
|
||||
t.Fatalf("O2 security encryption = %q, want null", got)
|
||||
}
|
||||
proposal := securityProposal{
|
||||
spiClient: 1001, spiServer: 1002,
|
||||
portClient: 40666, portServer: 55610,
|
||||
encryption: securityEncryptionForIdentity(identity),
|
||||
}
|
||||
if got, want := proposal.headerValue(), "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null;spi-c=0000001001;spi-s=0000001002;port-c=40666;port-s=55610"; got != want {
|
||||
t.Fatalf("O2 Security-Client = %q, want %q", got, want)
|
||||
}
|
||||
selected := "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;" +
|
||||
"ealg=null;spi-c=2001;spi-s=2002;port-c=50601;port-s=50600"
|
||||
if _, err := parseSecurityAgreement([]string{selected}, proposal); err != nil {
|
||||
t.Fatalf("O2 null Security-Server rejected: %v", err)
|
||||
}
|
||||
|
||||
identity.HomeMNC = "02"
|
||||
if got := securityEncryptionForIdentity(identity); got != "aes-cbc" {
|
||||
t.Fatalf("non-O2 security encryption = %q, want aes-cbc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityAgreementAcceptsO2FallbackEncryption(t *testing.T) {
|
||||
proposal := securityProposal{
|
||||
spiClient: 1001,
|
||||
spiServer: 1002,
|
||||
portClient: 5062,
|
||||
portServer: 5063,
|
||||
encryption: "null",
|
||||
fallbackEncryption: "aes-cbc",
|
||||
}
|
||||
value := "ipsec-3gpp;q=0.5;alg=hmac-sha-1-96;prot=esp;mod=trans;" +
|
||||
"ealg=aes-cbc;spi-c=2001;spi-s=2002;port-c=50601;port-s=50600"
|
||||
agreement, err := parseSecurityAgreement([]string{value}, proposal)
|
||||
if err != nil {
|
||||
t.Fatalf("parseSecurityAgreement(aes-cbc fallback) error = %v", err)
|
||||
}
|
||||
if got := agreement.selected.encryption; got != "aes-cbc" {
|
||||
t.Fatalf("selected encryption = %q, want aes-cbc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSecurityAgreementSkipsIncompleteCarrierAlternatives(t *testing.T) {
|
||||
proposal := securityProposal{
|
||||
spiClient: 1001,
|
||||
@@ -121,7 +168,7 @@ func TestParseSecurityAgreementFailsClosed(t *testing.T) {
|
||||
func TestExpandIPSecKeys(t *testing.T) {
|
||||
ck := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
|
||||
ik := []byte{16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31}
|
||||
encryption, integrity, err := expandIPSecKeys(ck, ik)
|
||||
encryption, integrity, err := expandIPSecKeys(ck, ik, "aes-cbc", "hmac-sha-1-96")
|
||||
if err != nil {
|
||||
t.Fatalf("expandIPSecKeys() error = %v", err)
|
||||
}
|
||||
@@ -139,6 +186,71 @@ func TestExpandIPSecKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandIPSecKeysForAndroidAlgorithmSet(t *testing.T) {
|
||||
ck := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18}
|
||||
ik := []byte{0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38}
|
||||
tripleDES, md5Key, err := expandIPSecKeys(ck, ik, "des-ede3-cbc", "hmac-md5-96")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tripleDES) != 24 || len(md5Key) != 16 {
|
||||
t.Fatalf("3DES/MD5 key lengths = %d/%d", len(tripleDES), len(md5Key))
|
||||
}
|
||||
for _, value := range tripleDES {
|
||||
ones := 0
|
||||
for bits := value; bits != 0; bits >>= 1 {
|
||||
ones += int(bits & 1)
|
||||
}
|
||||
if ones%2 != 1 {
|
||||
t.Fatalf("3DES byte %02x does not have odd parity", value)
|
||||
}
|
||||
}
|
||||
nullKey, sha1Key, err := expandIPSecKeys(ck, ik, "null", "hmac-sha-1-96")
|
||||
if err != nil || len(nullKey) != 0 || len(sha1Key) != 20 {
|
||||
t.Fatalf("null/SHA1 keys = %d/%d, %v", len(nullKey), len(sha1Key), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndroidIMSProposalOffersAllDefaultAlgorithms(t *testing.T) {
|
||||
proposal := securityProposal{
|
||||
spiClient: 1001, spiServer: 1002, portClient: 5062, portServer: 5063,
|
||||
integrityAlgorithms: []string{"hmac-md5-96", "hmac-sha-1-96"},
|
||||
encryptionAlgorithmsList: []string{"null", "des-ede3-cbc", "aes-cbc"},
|
||||
}
|
||||
header := proposal.headerValue()
|
||||
for _, combination := range []string{
|
||||
"alg=hmac-md5-96;prot=esp;mod=trans;ealg=null",
|
||||
"alg=hmac-md5-96;prot=esp;mod=trans;ealg=des-ede3-cbc",
|
||||
"alg=hmac-md5-96;prot=esp;mod=trans;ealg=aes-cbc",
|
||||
"alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null",
|
||||
"alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=des-ede3-cbc",
|
||||
"alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=aes-cbc",
|
||||
} {
|
||||
if !strings.Contains(header, combination) {
|
||||
t.Fatalf("Android IMS Security-Client omitted %q: %s", combination, header)
|
||||
}
|
||||
}
|
||||
if got := len(splitHeaderValues([]string{header})); got != 6 {
|
||||
t.Fatalf("Security-Client mechanism count = %d, want 6", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSecurityProposalUsesAndroidDefaultsForEveryCarrier(t *testing.T) {
|
||||
proposal, err := newSecurityProposal(net.ParseIP("127.0.0.1"), 45062, 45063)
|
||||
if err != nil {
|
||||
t.Fatalf("newSecurityProposal() error = %v", err)
|
||||
}
|
||||
if got, want := proposal.integrities(), []string{"hmac-md5-96", "hmac-sha-1-96"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("integrity algorithms = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := proposal.encryptionAlgorithms(), []string{"null", "des-ede3-cbc", "aes-cbc"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("encryption algorithms = %v, want %v", got, want)
|
||||
}
|
||||
if got := len(splitHeaderValues([]string{proposal.headerValue()})); got != 6 {
|
||||
t.Fatalf("Security-Client mechanism count = %d, want 6", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXFRMPlanContainsFourStatesAndProtocolSpecificPolicies(t *testing.T) {
|
||||
config := testIPSecSAConfig()
|
||||
install, err := buildXFRMInstallPlan(config)
|
||||
@@ -209,6 +321,46 @@ func TestXFRMPlanContainsFourStatesAndProtocolSpecificPolicies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestXFRMPlanSupportsIntegrityOnlyESP(t *testing.T) {
|
||||
config := testIPSecSAConfig()
|
||||
config.EncryptionAlgorithm = "null"
|
||||
config.EncryptionKey = nil
|
||||
install, err := buildXFRMInstallPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("buildXFRMInstallPlan(null) error = %v", err)
|
||||
}
|
||||
for index, operation := range install[:4] {
|
||||
joined := strings.Join(operation.arguments, " ")
|
||||
if !strings.Contains(joined, "auth-trunc hmac(sha1)") {
|
||||
t.Fatalf("null state %d omitted integrity: %v", index, operation.arguments)
|
||||
}
|
||||
if !containsArguments(operation.arguments, "enc", "cipher_null", "") {
|
||||
t.Fatalf("null state %d omitted Linux NULL cipher: %v", index, operation.arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestXFRMPlanSupportsAndroidMD5AndTripleDES(t *testing.T) {
|
||||
config := testIPSecSAConfig()
|
||||
config.IntegrityAlgorithm = "hmac-md5-96"
|
||||
config.EncryptionAlgorithm = "des-ede3-cbc"
|
||||
config.IntegrityKey = []byte(strings.Repeat("\x22", 16))
|
||||
config.EncryptionKey = []byte(strings.Repeat("\x11", 24))
|
||||
|
||||
install, err := buildXFRMInstallPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("buildXFRMInstallPlan() error = %v", err)
|
||||
}
|
||||
for index, operation := range install[:4] {
|
||||
if !containsArguments(operation.arguments, "auth-trunc", "hmac(md5)", "0x"+strings.Repeat("22", 16), "96") {
|
||||
t.Fatalf("state %d omitted HMAC-MD5-96 transform: %v", index, operation.arguments)
|
||||
}
|
||||
if !containsArguments(operation.arguments, "enc", "cbc(des3_ede)", "0x"+strings.Repeat("11", 24)) {
|
||||
t.Fatalf("state %d omitted 3DES-CBC transform: %v", index, operation.arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIPSecSAConfigRejectsDuplicateSPI(t *testing.T) {
|
||||
config := testIPSecSAConfig()
|
||||
config.PCSCFServerSPI = config.UEClientSPI
|
||||
|
||||
@@ -28,21 +28,37 @@ func (resolver ProxyResolver) Resolve(
|
||||
}
|
||||
deviceID := strings.TrimSpace(request.DeviceID)
|
||||
iccid := strings.TrimSpace(request.ICCID)
|
||||
if deviceID == "" || iccid == "" {
|
||||
if deviceID == "" {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
var upstreamID string
|
||||
if iccid != "" {
|
||||
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
|
||||
if err == nil {
|
||||
upstreamID = binding.UpstreamProxyID
|
||||
} else if !errors.Is(err, store.ErrNotFound) {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for ICCID %s: %w", iccid, err)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for ICCID %s: %w", iccid, err)
|
||||
if upstreamID == "" {
|
||||
country, found := device.CountryForMCC(strings.TrimSpace(request.HomeMCC))
|
||||
if !found {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
rule, ruleErr := resolver.Store.CountryRule(ctx, country)
|
||||
if errors.Is(ruleErr, store.ErrNotFound) || (ruleErr == nil && !rule.Enabled) {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
if ruleErr != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy country rule for MCC %s: %w", request.HomeMCC, ruleErr)
|
||||
}
|
||||
upstreamID = rule.UpstreamProxyID
|
||||
}
|
||||
upstream, err := resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID)
|
||||
upstream, err := resolver.Store.UpstreamProxy(ctx, upstreamID)
|
||||
if err != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf(
|
||||
"load upstream proxy %q for device %s: %w",
|
||||
binding.UpstreamProxyID,
|
||||
upstreamID,
|
||||
deviceID,
|
||||
err,
|
||||
)
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestProxyResolverDoesNotLeakBindingToAnotherProfileOnSameDevice(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverDoesNotUseCountryRuleWithoutDeviceBinding(t *testing.T) {
|
||||
func TestProxyResolverUsesCountryRuleWithoutICCIDBinding(t *testing.T) {
|
||||
database := testStore(t)
|
||||
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{
|
||||
ID: "legacy", Name: "Legacy", Addr: "127.0.0.1:1080", Enabled: true,
|
||||
@@ -98,8 +98,42 @@ func TestProxyResolverDoesNotUseCountryRuleWithoutDeviceBinding(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.Mode != vowifi.ProxyModeDirect {
|
||||
t.Fatalf("route = %#v, want direct", route)
|
||||
if route.Mode != vowifi.ProxyModeSOCKS5 || route.ID != "legacy" {
|
||||
t.Fatalf("route = %#v, want MCC country fallback", route)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
|
||||
database := testStore(t)
|
||||
for _, proxy := range []store.UpstreamProxy{
|
||||
{ID: "profile", Name: "Profile", Addr: "127.0.0.1:1080", Enabled: true},
|
||||
{ID: "country", Name: "Country", Addr: "127.0.0.1:1081", Enabled: true},
|
||||
} {
|
||||
if err := database.UpsertUpstreamProxy(context.Background(), proxy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := database.UpsertCountryRule(context.Background(), store.CountryRule{
|
||||
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "country", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
|
||||
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Physical SIM", UpstreamProxyID: "profile",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{
|
||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.ID != "profile" {
|
||||
t.Fatalf("route = %#v, want ICCID binding", route)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Refresh VoCat's offline PLMN name table from Android's carrier database.
|
||||
"""Refresh VoCat's offline carrier table from Android's carrier database.
|
||||
|
||||
The AOSP carrier ID table is maintained for Android's own carrier recognition.
|
||||
Only unconstrained MCC/MNC records are used here: MVNO matches that also require
|
||||
an SPN, IMSI prefix, GID or ICCID prefix must not rename the serving MNO.
|
||||
The compact ``c`` map remains the MCC/MNC-only fallback. The ``r`` list keeps
|
||||
Android's SIM-identity rules (IMSI, ICCID, SPN and GID) so travel eSIMs and
|
||||
MVNOs sharing an MNO's PLMN can be identified without hard-coded exceptions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -125,10 +125,61 @@ def aosp_carriers(text: str) -> dict[str, str]:
|
||||
return carriers
|
||||
|
||||
|
||||
RULE_FIELDS = {
|
||||
"mccmnc_tuple": "m",
|
||||
"imsi_prefix_xpattern": "x",
|
||||
"spn": "s",
|
||||
"gid1": "g1",
|
||||
"gid2": "g2",
|
||||
"iccid_prefix": "i",
|
||||
}
|
||||
|
||||
|
||||
def textproto_strings(block: str, field: str) -> list[str]:
|
||||
return [
|
||||
json.loads(value)
|
||||
for value in re.findall(
|
||||
rf"^\s*{re.escape(field)}:\s*(\"(?:\\.|[^\"\\])*\")",
|
||||
block,
|
||||
re.M,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def aosp_identity_rules(text: str) -> list[dict[str, object]]:
|
||||
"""Return rules VoCat can evaluate using values read directly from a SIM.
|
||||
|
||||
Android combines different fields with AND and repeated values of one field
|
||||
with OR. Rules that also require APN, PNN/PLMN or carrier certificates are
|
||||
omitted until those inputs are available; treating an unavailable input as
|
||||
a wildcard would incorrectly identify subscriptions.
|
||||
"""
|
||||
|
||||
supported = set(RULE_FIELDS)
|
||||
rules: list[dict[str, object]] = []
|
||||
for carrier in braced_blocks(text, "carrier_id"):
|
||||
name = textproto_string(carrier, "carrier_name").strip()
|
||||
if not name:
|
||||
continue
|
||||
for attribute in braced_blocks(carrier, "carrier_attribute"):
|
||||
fields = set(re.findall(r"^\s*([a-zA-Z0-9_]+)\s*:", attribute, re.M))
|
||||
if not fields.issubset(supported) or fields == {"mccmnc_tuple"}:
|
||||
continue
|
||||
rule: dict[str, object] = {"n": name}
|
||||
for source_field, output_field in RULE_FIELDS.items():
|
||||
values = textproto_strings(attribute, source_field)
|
||||
if values:
|
||||
rule[output_field] = values
|
||||
if rule.get("m"):
|
||||
rules.append(rule)
|
||||
return rules
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with urllib.request.urlopen(SOURCE_URL, timeout=30) as response:
|
||||
source = base64.b64decode(response.read()).decode("utf-8")
|
||||
names = aosp_carriers(source)
|
||||
identity_rules = aosp_identity_rules(source)
|
||||
table = json.loads(FRONTEND_TABLE.read_text(encoding="utf-8"))
|
||||
countries: dict[str, str] = table["i"]
|
||||
countries.update({str(mcc): "us" for mcc in range(310, 317)})
|
||||
@@ -149,19 +200,23 @@ def main() -> None:
|
||||
"c": dict(sorted(carriers.items())),
|
||||
"i": dict(sorted(countries.items())),
|
||||
"t": sorted(set(table["t"])),
|
||||
"r": identity_rules,
|
||||
"meta": {
|
||||
"source": "Android Open Source Project carrier_list.textpb",
|
||||
"source_url": SOURCE_URL.removesuffix("?format=TEXT"),
|
||||
"aosp_version": version_match.group(1) if version_match else "unknown",
|
||||
"aosp_generic_records": len(names),
|
||||
"aosp_identity_rules": len(identity_rules),
|
||||
},
|
||||
}
|
||||
encoded = json.dumps(output, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
FRONTEND_TABLE.write_text(encoded, encoding="utf-8", newline="\n")
|
||||
BACKEND_TABLE.write_text(encoded, encoding="utf-8", newline="\n")
|
||||
frontend_encoded = json.dumps(output, ensure_ascii=False, indent=4) + "\n"
|
||||
backend_encoded = json.dumps(output, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
FRONTEND_TABLE.write_text(frontend_encoded, encoding="utf-8", newline="\n")
|
||||
BACKEND_TABLE.write_text(backend_encoded, encoding="utf-8", newline="\n")
|
||||
print(
|
||||
f"updated {len(carriers)} PLMN records "
|
||||
f"({len(names)} generic AOSP records, version {output['meta']['aosp_version']})"
|
||||
f"({len(names)} generic records, {len(identity_rules)} identity rules, "
|
||||
f"version {output['meta']['aosp_version']})"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FieldRow } from "./FieldRow";
|
||||
import { useShowSensitive } from "./shared";
|
||||
import type { DeviceDetail } from "./types";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import { carrierBrandIso } from "../../lib/carrier";
|
||||
import { carrierIso } from "../../lib/carrier";
|
||||
import { CountryFlag } from "../CountryFlag";
|
||||
|
||||
export interface OverviewSimPanelProps {
|
||||
@@ -22,7 +22,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, customPhoneNumber
|
||||
const sensitive = !showSensitive;
|
||||
const activeEsim = (device.activeEsimProfileName || "").trim();
|
||||
const flightOn = device.vowifiActive || modem?.operatingMode === 0 || modem?.operatingMode === 4;
|
||||
const carrierCountryCode = carrierBrandIso(modem?.nativeSpn, modem?.imsi);
|
||||
const carrierCountryCode = String(modem?.homeCarrierCountryCode ?? "").trim() || carrierIso(modem?.imsi);
|
||||
const displayedPhoneNumber = customPhoneNumber?.trim() || device.localPhone || "--";
|
||||
const backendLabel =
|
||||
device.backendMode === "qmi" ? "QMI" : device.backendMode === "mbim" ? "MBIM" : device.backendMode === "at" ? "AT" : "Auto";
|
||||
|
||||
@@ -94,7 +94,7 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {
|
||||
<div className="space-y-1.5 border-t border-gray-100 px-3 pb-2 pt-2 text-sm text-gray-700 dark:border-white/5 dark:text-gray-200">
|
||||
{eapRejected ? (
|
||||
<div className="mb-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800 dark:border-amber-500/25 dark:bg-amber-500/10 dark:text-amber-200">
|
||||
{t("已连接到运营商 ePDG,但运营商拒绝了此 SIM 的 EAP-AKA 鉴权。通常表示该 Profile 未开通 IMS/WiFi Calling;重复重连不会解决,需要换用支持 VoWiFi 的运营商 Profile。")}
|
||||
{t("已连接到运营商 ePDG,但 EAP-AKA 流程被拒绝。可能是初始身份、运营商 IKE/EAP 兼容性或订阅策略问题;请根据错误详情确认失败阶段。")}
|
||||
</div>
|
||||
) : null}
|
||||
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
|
||||
|
||||
@@ -259,20 +259,18 @@ export function simOperatorDisplay(device?: DeviceDetail | null): string {
|
||||
const spn = String(modem?.nativeSpn ?? "").trim();
|
||||
const name = oplPnnName(modem) || firstPnnName(modem?.pnn);
|
||||
const plmn = plmnOf(modem);
|
||||
// EF_SPN is the SIM's customer-facing brand. Do not append the currently
|
||||
// visited PLMN: a roaming Lebara UK SIM on a Chinese network would otherwise
|
||||
// be mislabeled as "Lebara (460xx)". Append the home/authentication PLMN
|
||||
// resolved from IMSI instead, so GigSky on 222-01 renders as
|
||||
// "GigSky (22201)" even while roaming.
|
||||
if (spn) {
|
||||
const home = lookupCarrier(modem?.imsi);
|
||||
return withPlmn(spn, home ? home.mcc + home.mnc : cardPlmnOf(modem));
|
||||
}
|
||||
if (name) return withPlmn(name, plmn);
|
||||
// Home ("original") carrier resolved from the SIM's IMSI via the MCC/MNC table.
|
||||
// Readable even when the modem isn't camped (VoWiFi RF-off / flight mode).
|
||||
// "Original Carrier" means the IMSI home/authentication network. EF_SPN is
|
||||
// only a profile-supplied display brand (travel eSIMs and MVNOs may put their
|
||||
// storefront name there), so it must not override a known home PLMN.
|
||||
const resolvedName = String(modem?.homeCarrierName ?? "").trim();
|
||||
const resolvedPLMN = String(modem?.homeCarrierPlmn ?? "").trim();
|
||||
if (resolvedName) return withPlmn(resolvedName, resolvedPLMN);
|
||||
const carrier = lookupCarrier(modem?.imsi);
|
||||
if (carrier) return withPlmn(carrier.name, carrier.mcc + carrier.mnc);
|
||||
// If the bundled carrier database cannot resolve the home PLMN, retain the
|
||||
// card's own labels as graceful, data-driven fallbacks.
|
||||
if (spn) return withPlmn(spn, cardPlmnOf(modem));
|
||||
if (name) return withPlmn(name, plmn);
|
||||
if (plmn) return plmn;
|
||||
const cardPlmn = cardPlmnOf(modem);
|
||||
if (cardPlmn) return cardPlmn;
|
||||
|
||||
@@ -21,6 +21,10 @@ function profileLabel(profile: { name?: string; serviceProviderName?: string; ic
|
||||
return String(profile.name || profile.serviceProviderName || profile.iccid).trim();
|
||||
}
|
||||
|
||||
function currentDeviceICCID(device: DeviceListItem) {
|
||||
return String(device.modem?.iccid || device.vowifiRuntime?.iccid || "").trim();
|
||||
}
|
||||
|
||||
export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const { open, proxy, proxies, devices, bindings, busy, onAdd, onDelete, onClose } = props;
|
||||
@@ -30,7 +34,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const proxyName = proxy?.name || proxy?.id || "";
|
||||
const deviceKey = devices
|
||||
.map((device) => `${device.id}:${String(device.modem?.iccid || "").trim()}`)
|
||||
.map((device) => `${device.id}:${currentDeviceICCID(device)}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
const current = useMemo(
|
||||
@@ -53,7 +57,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
let active = true;
|
||||
setLoadingProfiles(true);
|
||||
Promise.allSettled(devices.map(async (device) => {
|
||||
const currentICCID = String(device.modem?.iccid || "").trim();
|
||||
const currentICCID = currentDeviceICCID(device);
|
||||
let installed: ProfileProxyCandidate[] = [];
|
||||
try {
|
||||
const data = await api<EsimOverview>(`/devices/${encodeURIComponent(device.id)}/esim`);
|
||||
|
||||
@@ -101,7 +101,7 @@ export function NetworkAccessCard({
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{t("在内置内网网段之外始终放行的 CIDR 或单个 IP(例如 203.0.113.0/24)。")}
|
||||
{t("在内置内网网段之外始终放行的 CIDR 或单个 IP;也允许通知推送访问这些目标地址(例如 198.18.0.0/15)。")}
|
||||
</p>
|
||||
{cidrs.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/30 py-2 text-center text-xs text-gray-400 dark:border-white/10 dark:bg-white/5">
|
||||
|
||||
@@ -47,13 +47,3 @@ export function carrierIso(imsi?: string): string {
|
||||
if (hit) return hit.iso;
|
||||
return data.i[imsiDigits(imsi).slice(0, 3)] ?? "";
|
||||
}
|
||||
|
||||
// carrierBrandIso keeps the normal IMSI country flag for branded/MVNO SIMs.
|
||||
// Lebara UK's Vodafone-NL-hosted 204-04 eSIM is the one known exception: its
|
||||
// customer-facing country is GB even though AKA must continue using 204-04.
|
||||
export function carrierBrandIso(spn?: string, imsi?: string): string {
|
||||
const brand = String(spn ?? "").trim().toLowerCase();
|
||||
const digits = imsiDigits(imsi);
|
||||
if (brand.includes("lebara") && digits.startsWith("20404")) return "gb";
|
||||
return carrierIso(imsi);
|
||||
}
|
||||
|
||||
@@ -352,8 +352,8 @@ export const EN_DICT: Record<string, string> = {
|
||||
"Opening to the public internet greatly expands the attack surface. Use a strong password and switch back to Internal Only as soon as possible.",
|
||||
额外放行网段: "Additional Allowed Ranges",
|
||||
添加网段: "Add Range",
|
||||
"在内置内网网段之外始终放行的 CIDR 或单个 IP(例如 203.0.113.0/24)。":
|
||||
"CIDRs or single IPs always allowed in addition to the built-in internal ranges (e.g. 203.0.113.0/24).",
|
||||
"在内置内网网段之外始终放行的 CIDR 或单个 IP;也允许通知推送访问这些目标地址(例如 198.18.0.0/15)。":
|
||||
"CIDRs or single IPs always allowed in addition to the built-in internal ranges; notification delivery may also access these destinations (e.g. 198.18.0.0/15).",
|
||||
暂无额外放行网段: "No additional allowed ranges",
|
||||
信任代理请求头: "Trust Proxy Headers",
|
||||
"仅在系统位于可信反向代理之后时开启,按 X-Forwarded-For 判定来源;否则客户端可伪造该头绕过内网限制。":
|
||||
@@ -753,8 +753,8 @@ export const EN_DICT: Record<string, string> = {
|
||||
"扫描中...": "Scanning...",
|
||||
"扫描可用网络": "Scan Available Networks",
|
||||
"不可注册": "Unavailable",
|
||||
"已连接到运营商 ePDG,但运营商拒绝了此 SIM 的 EAP-AKA 鉴权。通常表示该 Profile 未开通 IMS/WiFi Calling;重复重连不会解决,需要换用支持 VoWiFi 的运营商 Profile。":
|
||||
"The carrier ePDG was reached, but it rejected EAP-AKA authentication for this SIM. The profile usually has no IMS/Wi-Fi Calling entitlement; reconnecting will not fix it, so use a carrier profile that supports VoWiFi.",
|
||||
"已连接到运营商 ePDG,但 EAP-AKA 流程被拒绝。可能是初始身份、运营商 IKE/EAP 兼容性或订阅策略问题;请根据错误详情确认失败阶段。":
|
||||
"The carrier ePDG was reached, but the EAP-AKA flow was rejected. This can be caused by the initial identity, carrier IKE/EAP interoperability, or subscription policy; check the error details for the failing stage.",
|
||||
"扫描结果只代表模组在当前位置实际收到的运营商信号,不代表模组支持的全部运营商;禁用网络表示当前 SIM 不允许注册。":
|
||||
"Scan results show only networks the modem can currently receive, not every operator the hardware supports. A forbidden network cannot be used by the current SIM.",
|
||||
"扫描网络失败": "Network scan failed",
|
||||
|
||||
+5247
-2
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,9 @@ export interface ModemSummary {
|
||||
cardMcc?: string;
|
||||
cardMnc?: string;
|
||||
cardCountry?: string;
|
||||
homeCarrierName?: string;
|
||||
homeCarrierPlmn?: string;
|
||||
homeCarrierCountryCode?: string;
|
||||
serviceBlocked?: boolean;
|
||||
blockedReason?: string;
|
||||
networkMode: string;
|
||||
|
||||
Reference in New Issue
Block a user