mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-15 04:13:42 +08:00
Merge remote-tracking branch 'origin/master' into feat/admin-credentials-pr-limit
# Conflicts: # cmd/vocat/menu.go # internal/auth/service.go
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -271,13 +271,22 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
|
||||
mnc = "0" + mnc
|
||||
}
|
||||
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
||||
privateDomain := domain
|
||||
publicDomain := domain
|
||||
if vowifi.IsATT310280(identity) {
|
||||
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
||||
// the generic 3GPP PLMN IMS domain.
|
||||
domain = "one.att.net"
|
||||
privateDomain = "private.att.net"
|
||||
publicDomain = "one.att.net"
|
||||
}
|
||||
privateIdentity := config.PrivateIdentity
|
||||
if privateIdentity == "" {
|
||||
privateIdentity = imsi + "@" + domain
|
||||
privateIdentity = imsi + "@" + privateDomain
|
||||
}
|
||||
publicIdentity := config.PublicIdentity
|
||||
if publicIdentity == "" {
|
||||
publicIdentity = "sip:" + imsi + "@" + domain
|
||||
publicIdentity = "sip:" + imsi + "@" + publicDomain
|
||||
}
|
||||
if strings.ContainsAny(privateIdentity+publicIdentity, "\r\n") ||
|
||||
!strings.Contains(privateIdentity, "@") ||
|
||||
@@ -536,6 +545,9 @@ func newSession(
|
||||
}
|
||||
protectedClientPort := provider.config.ProtectedClientPort
|
||||
protectedServerPort := provider.config.ProtectedServerPort
|
||||
if vowifi.IsATT310280(request.Identity) && protectedServerPort == 0 {
|
||||
protectedServerPort = 6000
|
||||
}
|
||||
if securityEncryptionForIdentity(request.Identity) == "null" {
|
||||
if protectedClientPort == 0 {
|
||||
protectedClientPort = 5062
|
||||
@@ -554,6 +566,10 @@ func newSession(
|
||||
return nil, err
|
||||
}
|
||||
proposal.encryption = securityEncryptionForIdentity(request.Identity)
|
||||
if vowifi.IsATT310280(request.Identity) {
|
||||
proposal.integrityAlgorithms = []string{"hmac-sha-1-96"}
|
||||
proposal.encryptionAlgorithmsList = []string{"aes-cbc"}
|
||||
}
|
||||
session.securityProposal = proposal
|
||||
protectedTCP, err := net.ListenTCP(
|
||||
"tcp",
|
||||
@@ -732,7 +748,11 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge)
|
||||
preference := ""
|
||||
if vowifi.IsATT310280(session.request.Identity) {
|
||||
preference = "isim_strict"
|
||||
}
|
||||
material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge, preference)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -792,6 +812,10 @@ func (session *Session) buildRegister(
|
||||
authorizationHeader string,
|
||||
authorization string,
|
||||
) ([]byte, error) {
|
||||
att310280 := vowifi.IsATT310280(session.request.Identity)
|
||||
if att310280 {
|
||||
expires = 18400
|
||||
}
|
||||
branch, err := randomHex(12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -810,6 +834,17 @@ func (session *Session) buildRegister(
|
||||
session.instanceID,
|
||||
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
|
||||
)
|
||||
if att310280 {
|
||||
contact = fmt.Sprintf(
|
||||
`<sip:%s@%s;transport=%s>;+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip;`+
|
||||
`+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`,
|
||||
session.identity.user,
|
||||
contactAddress,
|
||||
session.transport,
|
||||
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
|
||||
session.instanceID,
|
||||
)
|
||||
}
|
||||
o2Germany := usesO2GermanyIMSProfile(session.request.Identity)
|
||||
supported := "path, gruu"
|
||||
allow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS"
|
||||
@@ -820,6 +855,13 @@ func (session *Session) buildRegister(
|
||||
supported = "path, gruu, outbound, sec-agree, 100rel, timer"
|
||||
allow = "INVITE, ACK, CANCEL, BYE, PRACK, UPDATE, INFO, MESSAGE, OPTIONS"
|
||||
}
|
||||
if att310280 {
|
||||
supported = "path,sec-agree,gruu"
|
||||
}
|
||||
userAgent := strings.TrimSpace(session.provider.config.UserAgent)
|
||||
if att310280 && (userAgent == "" || userAgent == "vocat/1") {
|
||||
userAgent = "SimAdmin VoWiFi"
|
||||
}
|
||||
lines := []string{
|
||||
"REGISTER " + requestURI + " SIP/2.0",
|
||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", transportUpper, local, branch),
|
||||
@@ -833,14 +875,23 @@ func (session *Session) buildRegister(
|
||||
fmt.Sprintf("Expires: %d", expires),
|
||||
"Supported: " + supported,
|
||||
"Allow: " + allow,
|
||||
"User-Agent: " + session.provider.config.UserAgent,
|
||||
"User-Agent: " + userAgent,
|
||||
}
|
||||
if o2Germany {
|
||||
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
|
||||
} else if att310280 {
|
||||
lines = append(lines,
|
||||
"P-Preferred-Identity: <"+session.identity.public+">",
|
||||
`P-Visited-Network-ID: "one.att.net"`,
|
||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
|
||||
"Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0",
|
||||
"Accept-Contact: *;+g.3gpp.smsip",
|
||||
`Accept-Contact: *;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"`,
|
||||
)
|
||||
}
|
||||
if session.securityOffered() {
|
||||
lines = append(lines,
|
||||
"Security-Client: "+session.securityProposal.headerValue(),
|
||||
"Security-Client: "+session.securityClientValue(),
|
||||
"Require: sec-agree",
|
||||
"Proxy-Require: sec-agree",
|
||||
)
|
||||
|
||||
@@ -442,6 +442,75 @@ func TestO2GermanyInitialRegisterMatchesSupportedIMSProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestATT310280DeriveIdentitiesUsesISIMDomains(t *testing.T) {
|
||||
identities, err := deriveIdentities(vowifi.SIMIdentity{
|
||||
IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280",
|
||||
}, Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("deriveIdentities() error = %v", err)
|
||||
}
|
||||
if identities.domain != "one.att.net" ||
|
||||
identities.private != "[email protected]" ||
|
||||
identities.public != "sip:[email protected]" {
|
||||
t.Fatalf("AT&T identities = %#v", identities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATT310280InitialRegisterMatchesProvisionedProfile(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
identity := vowifi.SIMIdentity{
|
||||
IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280",
|
||||
}
|
||||
identities, err := deriveIdentities(identity, Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{SecurityMode: SecurityRequired, UserAgent: "vocat/1"}},
|
||||
request: vowifi.IMSRequest{Identity: identity},
|
||||
identity: identities,
|
||||
endpoint: pcscfEndpoint{host: "pcscf.example", port: 5060},
|
||||
transport: "tcp",
|
||||
conn: client,
|
||||
callID: "att-test",
|
||||
fromTag: "tag",
|
||||
instanceID: "urn:uuid:test",
|
||||
securityProposal: securityProposal{
|
||||
spiClient: 1546543, spiServer: 1546542,
|
||||
portClient: 32773, portServer: 6000,
|
||||
integrityAlgorithms: []string{"hmac-sha-1-96"},
|
||||
encryptionAlgorithmsList: []string{"aes-cbc"},
|
||||
},
|
||||
}
|
||||
packet, err := session.buildRegister(1, 3600, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRegister() error = %v", err)
|
||||
}
|
||||
request := string(packet)
|
||||
for _, want := range []string{
|
||||
"REGISTER sip:one.att.net SIP/2.0",
|
||||
"Expires: 18400",
|
||||
"Supported: path,sec-agree,gruu",
|
||||
"User-Agent: SimAdmin VoWiFi",
|
||||
`+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip`,
|
||||
"P-Preferred-Identity: <sip:[email protected]>",
|
||||
`P-Visited-Network-ID: "one.att.net"`,
|
||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
|
||||
"Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0",
|
||||
"Accept-Contact: *;+g.3gpp.smsip",
|
||||
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=1546543; spi-s=1546542; port-c=32773; port-s=6000",
|
||||
`username="[email protected]"`,
|
||||
`uri="sip:one.att.net"`,
|
||||
} {
|
||||
if !strings.Contains(request, want) {
|
||||
t.Fatalf("AT&T REGISTER omits %q:\n%s", want, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
|
||||
var callID string
|
||||
for step := 0; step < 3; step++ {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
type SecurityMode string
|
||||
@@ -143,6 +145,19 @@ func (proposal securityProposal) headerValue() string {
|
||||
return strings.Join(values, ", ")
|
||||
}
|
||||
|
||||
func (session *Session) securityClientValue() string {
|
||||
if vowifi.IsATT310280(session.request.Identity) {
|
||||
return fmt.Sprintf(
|
||||
"ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=%d; spi-s=%d; port-c=%d; port-s=%d",
|
||||
session.securityProposal.spiClient,
|
||||
session.securityProposal.spiServer,
|
||||
session.securityProposal.portClient,
|
||||
session.securityProposal.portServer,
|
||||
)
|
||||
}
|
||||
return session.securityProposal.headerValue()
|
||||
}
|
||||
|
||||
func (proposal securityProposal) encryptionAlgorithm() string {
|
||||
if strings.EqualFold(strings.TrimSpace(proposal.encryption), "null") {
|
||||
return "null"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user