Files
VoCat/internal/vowifi/ec20_adapter.go
T

1409 lines
36 KiB
Go

package vowifi
import (
"context"
"encoding/csv"
"encoding/hex"
"errors"
"fmt"
"io"
"sort"
"strconv"
"strings"
"sync"
"time"
"vocat/internal/modem"
)
var (
ErrEC20SIMNotReady = errors.New("vocat: EC20 SIM is not ready")
ErrEC20MNCUnavailable = errors.New("vocat: EC20 SIM does not expose an explicit MNC length")
ErrEC20ApplicationAbsent = errors.New("vocat: EC20 has no usable USIM or ISIM application")
ErrEC20IdentityChanged = errors.New("vocat: EC20 SIM identity changed during authentication")
ErrEC20AKACommand = errors.New("vocat: EC20 USIM AUTHENTICATE command failed")
ErrEC20AKAResponse = errors.New("vocat: EC20 returned an invalid USIM AUTHENTICATE response")
ErrEC20AKAMACFailure = errors.New("vocat: EC20 USIM rejected the network authentication token")
)
const (
usimAIDPrefix = "A0000000871002"
isimAIDPrefix = "A0000000871004"
efADDecimal = 28589 // 0x6FAD
channelCleanupTimeout = 3 * time.Second
)
// EC20ATExecutor is deliberately the same narrow shape as
// device.Manager.ExecuteAT. Production code passes the device manager directly;
// tests can use an evidence transcript without opening any serial device.
type EC20ATExecutor interface {
ExecuteAT(context.Context, string, string) (modem.Response, error)
}
// EC20SensitiveATExecutor is implemented by device.Manager so an APDU carrying
// RAND and AUTN is not retained as a device error. The fallback exists only for
// small deterministic test executors.
type EC20SensitiveATExecutor interface {
ExecuteSensitiveAT(context.Context, string, string) (modem.Response, error)
}
type EC20UICCLocker interface {
LockUICC()
UnlockUICC()
}
type EC20AdapterOptions struct {
// PureAirplanePolicy reports the independent user policy. The adapter only
// changes the transactional CFUN projection used by VoWiFi and never
// changes this policy.
PureAirplanePolicy func(deviceID string) bool
// HomePLMN supplies an explicit operator configuration when EF_AD omits
// the MNC length. The returned MCC/MNC must exactly prefix the live IMSI.
HomePLMN func(deviceID, iccid, imsi string) (mcc, mnc string, ok bool)
// RestoreCellularData permits reactivating PDP contexts that were active
// before the VoWiFi transaction. It is deliberately false by default:
// VoWiFi must never start billable cellular data unless an operator has
// explicitly opted in to that separate behavior.
RestoreCellularData bool
}
// EC20Adapter implements SIMIdentityReader, AKAProvider, and RadioController
// using standardized AT commands on the AT port selected by device.Manager.
// It never discovers or opens serial ports itself, so ttyUSB0 diagnostic cannot
// be selected accidentally.
type EC20Adapter struct {
executor EC20ATExecutor
options EC20AdapterOptions
apduMu sync.Mutex
mu sync.Mutex
bindings map[string]ec20SIMBinding
checkpoints map[string]ec20RadioCheckpoint
}
type ec20SIMBinding struct {
deviceID string
iccid string
imsi string
aid string
application string
basicChannel bool
}
type ec20RadioCheckpoint struct {
activeCIDs []int
}
var (
_ SIMIdentityReader = (*EC20Adapter)(nil)
_ AKAProvider = (*EC20Adapter)(nil)
_ RadioController = (*EC20Adapter)(nil)
)
func NewEC20Adapter(
executor EC20ATExecutor,
options EC20AdapterOptions,
) (*EC20Adapter, error) {
if executor == nil {
return nil, errors.New("vocat: EC20 AT executor is required")
}
return &EC20Adapter{
executor: executor,
options: options,
bindings: make(map[string]ec20SIMBinding),
checkpoints: make(map[string]ec20RadioCheckpoint),
}, nil
}
func (adapter *EC20Adapter) ReadIdentity(
ctx context.Context,
deviceID string,
) (SIMIdentity, error) {
deviceID = strings.TrimSpace(deviceID)
if deviceID == "" {
return SIMIdentity{}, errors.New("vocat: EC20 device ID is required")
}
pin, err := adapter.execute(ctx, deviceID, "AT+CPIN?")
if err != nil {
return SIMIdentity{}, fmt.Errorf("read EC20 SIM state: %w", err)
}
if !responseContainsValue(pin, "+CPIN:", "READY") {
return SIMIdentity{}, ErrEC20SIMNotReady
}
imsiResponse, err := adapter.execute(ctx, deviceID, "AT+CIMI")
if err != nil {
return SIMIdentity{}, fmt.Errorf("read EC20 IMSI: %w", err)
}
imsi := digitIdentifier(imsiResponse, []string{"+CIMI:"}, 10, 18)
if imsi == "" {
return SIMIdentity{}, errors.New("vocat: EC20 returned no valid IMSI")
}
iccid, err := adapter.readICCID(ctx, deviceID)
if err != nil {
return SIMIdentity{}, err
}
imeiResponse, err := adapter.execute(ctx, deviceID, "AT+CGSN")
if err != nil {
return SIMIdentity{}, fmt.Errorf("read EC20 IMEI: %w", err)
}
imei := digitIdentifier(imeiResponse, []string{"+CGSN:", "+GSN:"}, 14, 17)
if imei == "" {
return SIMIdentity{}, errors.New("vocat: EC20 returned no valid IMEI")
}
homeMCC, homeMNC, err := adapter.readHomePLMN(
ctx,
deviceID,
iccid,
imsi,
)
if err != nil {
return SIMIdentity{}, err
}
identity := SIMIdentity{
ICCID: iccid,
IMSI: imsi,
IMEI: imei,
HomeMCC: homeMCC,
HomeMNC: homeMNC,
}
adapter.mu.Lock()
adapter.bindings[iccid] = ec20SIMBinding{
deviceID: deviceID,
iccid: iccid,
imsi: imsi,
}
adapter.mu.Unlock()
return identity, nil
}
// ReadSMSCenter returns the service-centre address configured by the SIM.
// AT+CSCA? is read-only and remains available while cellular RF is disabled
// for a Wi-Fi Calling session.
func (adapter *EC20Adapter) ReadSMSCenter(ctx context.Context, deviceID string) (string, error) {
response, err := adapter.execute(ctx, strings.TrimSpace(deviceID), "AT+CSCA?")
if err != nil {
return "", fmt.Errorf("read EC20 SMS service centre: %w", err)
}
fields := parseCSV(valueAfterATPrefix(response, "+CSCA:"))
if len(fields) == 0 {
return "", errors.New("vocat: EC20 returned no SMS service-centre address")
}
value := strings.Trim(strings.TrimSpace(fields[0]), `"`)
digits := strings.TrimPrefix(value, "+")
if !validDigits(digits, 3, 20) {
return "", errors.New("vocat: EC20 returned an invalid SMS service-centre address")
}
return value, nil
}
func (adapter *EC20Adapter) readHomePLMN(
ctx context.Context,
deviceID string,
iccid string,
imsi string,
) (string, string, error) {
mncLength, efErr := adapter.readExplicitMNCLength(ctx, deviceID)
if efErr == nil {
if len(imsi) < 3+mncLength {
return "", "", errors.New(
"vocat: IMSI is shorter than the EF_AD home PLMN",
)
}
return imsi[:3], imsi[3 : 3+mncLength], nil
}
if adapter.options.HomePLMN != nil {
mcc, mnc, ok := adapter.options.HomePLMN(deviceID, iccid, imsi)
mcc = strings.TrimSpace(mcc)
mnc = strings.TrimSpace(mnc)
if ok && validConfiguredHomePLMN(imsi, mcc, mnc) {
return mcc, mnc, nil
}
}
// Exact assigned HPLMN prefixes are data, not an MNC-length heuristic. The
// target Vodafone UK SIM is 234/15. Unknown assignments remain fail-closed.
if mcc, mnc, ok := assignedHomePLMN(imsi); ok {
return mcc, mnc, nil
}
return "", "", efErr
}
func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) {
assignments := []struct {
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.
}
for _, assignment := range assignments {
if strings.HasPrefix(imsi, assignment.prefix) {
return imsi[:3], imsi[3 : 3+assignment.mncLength], true
}
}
return "", "", false
}
func validConfiguredHomePLMN(imsi, mcc, mnc string) bool {
if !validDigits(mcc, 3, 3) || !validDigits(mnc, 2, 3) {
return false
}
return strings.HasPrefix(imsi, mcc+mnc)
}
func (adapter *EC20Adapter) readExplicitMNCLength(
ctx context.Context,
deviceID string,
) (int, error) {
commands := []string{
fmt.Sprintf("AT+CRSM=176,%d,0,0,4", efADDecimal),
fmt.Sprintf("AT+CRSM=176,%d,0,0,0", efADDecimal),
}
var lastErr error
for _, command := range commands {
response, err := adapter.execute(ctx, deviceID, command)
if err != nil {
lastErr = err
continue
}
data, err := parseCRSMData(response)
if err != nil {
lastErr = err
continue
}
if len(data) < 4 {
lastErr = ErrEC20MNCUnavailable
continue
}
length := int(data[3] & 0x0f)
if length == 2 || length == 3 {
return length, nil
}
lastErr = ErrEC20MNCUnavailable
}
if lastErr == nil {
lastErr = ErrEC20MNCUnavailable
}
return 0, fmt.Errorf("%w: %v", ErrEC20MNCUnavailable, lastErr)
}
func (adapter *EC20Adapter) readICCID(
ctx context.Context,
deviceID string,
) (string, error) {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
response, err := adapter.execute(ctx, deviceID, command)
if err != nil {
lastErr = err
continue
}
value := iccidIdentifier(
response,
[]string{"+CCID:", "+QCCID:"},
18,
22,
)
if value != "" {
return value, nil
}
lastErr = errors.New("response contained no valid ICCID")
}
if attempt < 2 {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
}
return "", fmt.Errorf("read EC20 ICCID: %w", lastErr)
}
func (adapter *EC20Adapter) CheckReady(
ctx context.Context,
identity SIMIdentity,
) (AKAEvidence, error) {
binding, err := adapter.bindingFor(identity)
if err != nil {
return AKAEvidence{}, err
}
if err := adapter.verifyLiveICCID(ctx, binding); err != nil {
return AKAEvidence{}, err
}
// CCHO/CGLA/GET RESPONSE/CCHC is one UICC transaction. Serialize it
// across the adapter so periodic device refreshes or another AKA exchange
// cannot insert an APDU between a 61xx response and GET RESPONSE.
adapter.apduMu.Lock()
defer adapter.apduMu.Unlock()
if locker, ok := adapter.executor.(EC20UICCLocker); ok {
locker.LockUICC()
defer locker.UnlockUICC()
}
aid, application, err := adapter.discoverAKAApplication(ctx, binding.deviceID)
if err != nil {
return AKAEvidence{}, err
}
channel, err := adapter.openLogicalChannel(ctx, binding.deviceID, aid)
basicChannel := false
if err == nil {
if err := adapter.closeLogicalChannelWithCleanup(
binding.deviceID,
channel,
); err != nil {
return AKAEvidence{}, err
}
} else {
// Several EC20 firmware branches reject CCHO/CGLA even though their
// basic-channel CSIM implementation is standards compliant.
if err := adapter.selectBasicApplication(
ctx,
binding.deviceID,
aid,
); err != nil {
return AKAEvidence{}, errors.Join(
ErrEC20ApplicationAbsent,
err,
)
}
basicChannel = true
}
binding.aid = aid
binding.application = application
binding.basicChannel = basicChannel
adapter.mu.Lock()
adapter.bindings[binding.iccid] = binding
adapter.mu.Unlock()
return AKAEvidence{Ready: true, Application: application}, nil
}
func (adapter *EC20Adapter) Authenticate(
ctx context.Context,
identity SIMIdentity,
challenge AKAChallenge,
) (AKAResult, error) {
binding, err := adapter.bindingFor(identity)
if err != nil {
return AKAResult{}, err
}
if binding.aid == "" {
if _, err := adapter.CheckReady(ctx, identity); err != nil {
return AKAResult{}, err
}
binding, err = adapter.bindingFor(identity)
if err != nil {
return AKAResult{}, err
}
}
if err := adapter.verifyLiveICCID(ctx, binding); err != nil {
return AKAResult{}, err
}
adapter.apduMu.Lock()
defer adapter.apduMu.Unlock()
if locker, ok := adapter.executor.(EC20UICCLocker); ok {
locker.LockUICC()
defer locker.UnlockUICC()
}
apdu := buildUSIMAuthenticateAPDU(challenge)
var raw []byte
if binding.basicChannel {
if err := adapter.selectBasicApplication(
ctx,
binding.deviceID,
binding.aid,
); err != nil {
return AKAResult{}, err
}
raw, err = adapter.transmitBasicAPDU(
ctx,
binding.deviceID,
apdu,
true,
)
if err != nil {
return AKAResult{}, ErrEC20AKACommand
}
} else {
channel, err := adapter.openLogicalChannel(
ctx,
binding.deviceID,
binding.aid,
)
if err != nil {
return AKAResult{}, err
}
var commandErr error
raw, commandErr = adapter.transmitLogicalAPDU(
ctx,
binding.deviceID,
channel,
apdu,
true,
)
closeErr := adapter.closeLogicalChannelWithCleanup(
binding.deviceID,
channel,
)
if commandErr != nil {
if closeErr != nil {
return AKAResult{}, errors.Join(commandErr, closeErr)
}
return AKAResult{}, commandErr
}
if closeErr != nil {
return AKAResult{}, closeErr
}
}
return parseUSIMAuthenticateResponse(raw)
}
func buildUSIMAuthenticateAPDU(challenge AKAChallenge) []byte {
// TS 31.102 AUTHENTICATE, 3G security context (P2=0x81):
// Lc=34, then LV(RAND) and LV(AUTN), followed by Le.
apdu := make([]byte, 0, 40)
apdu = append(apdu, 0x00, 0x88, 0x00, 0x81, 0x22, 0x10)
apdu = append(apdu, challenge.RAND[:]...)
apdu = append(apdu, 0x10)
apdu = append(apdu, challenge.AUTN[:]...)
apdu = append(apdu, 0x00)
return apdu
}
func parseUSIMAuthenticateResponse(raw []byte) (AKAResult, error) {
if len(raw) < 2 {
return AKAResult{}, fmt.Errorf(
"%w: response length %d has no status word",
ErrEC20AKAResponse,
len(raw),
)
}
status := uint16(raw[len(raw)-2])<<8 | uint16(raw[len(raw)-1])
body := raw[:len(raw)-2]
if status != 0x9000 {
switch status {
case 0x9862:
return AKAResult{}, ErrEC20AKAMACFailure
default:
return AKAResult{}, fmt.Errorf(
"%w: status word %04X",
ErrEC20AKAResponse,
status,
)
}
}
if len(body) < 2 {
return AKAResult{}, fmt.Errorf(
"%w: response body length %d is too short",
ErrEC20AKAResponse,
len(body),
)
}
tag := body[0]
value := body[1:]
switch tag {
case 0xdb:
res, rest, ok := takeLV(value)
if !ok {
return AKAResult{}, fmt.Errorf(
"%w: malformed RES field in %d-byte success value",
ErrEC20AKAResponse,
len(value),
)
}
if len(res) < 4 || len(res) > 16 {
return AKAResult{}, fmt.Errorf(
"%w: invalid RES length %d",
ErrEC20AKAResponse,
len(res),
)
}
ck, rest, ok := takeLV(rest)
if !ok || len(ck) != 16 {
return AKAResult{}, fmt.Errorf(
"%w: invalid CK length %d",
ErrEC20AKAResponse,
len(ck),
)
}
ik, rest, ok := takeLV(rest)
if !ok || len(ik) != 16 {
return AKAResult{}, fmt.Errorf(
"%w: invalid IK length %d",
ErrEC20AKAResponse,
len(ik),
)
}
// Kc is present on many USIMs. It is not needed by EAP-AKA, but when
// present its LV still has to be structurally valid.
if len(rest) > 0 {
kc, tail, ok := takeLV(rest)
if !ok || len(kc) != 8 || len(tail) != 0 {
return AKAResult{}, fmt.Errorf(
"%w: invalid optional Kc length %d with %d trailing bytes",
ErrEC20AKAResponse,
len(kc),
len(tail),
)
}
}
return AKAResult{
RES: append([]byte(nil), res...),
CK: append([]byte(nil), ck...),
IK: append([]byte(nil), ik...),
}, nil
case 0xdc:
auts, tail, ok := takeLV(value)
if !ok || len(auts) != 14 || len(tail) != 0 {
return AKAResult{}, fmt.Errorf(
"%w: invalid AUTS length %d with %d trailing bytes",
ErrEC20AKAResponse,
len(auts),
len(tail),
)
}
return AKAResult{
AUTS: append([]byte(nil), auts...),
SynchronizationFailure: true,
}, nil
default:
return AKAResult{}, fmt.Errorf(
"%w: unsupported response tag %02X with %d value bytes",
ErrEC20AKAResponse,
tag,
len(value),
)
}
}
func takeLV(value []byte) (field, rest []byte, ok bool) {
if len(value) == 0 {
return nil, value, false
}
length := int(value[0])
if length > len(value)-1 {
return nil, value, false
}
return value[1 : 1+length], value[1+length:], true
}
func (adapter *EC20Adapter) Snapshot(
ctx context.Context,
deviceID string,
) (RadioSnapshot, error) {
mode, err := adapter.readOperatingMode(ctx, deviceID)
if err != nil {
return RadioSnapshot{}, err
}
active, err := adapter.readActiveCIDs(ctx, deviceID)
if err != nil {
return RadioSnapshot{}, err
}
adapter.mu.Lock()
adapter.checkpoints[deviceID] = ec20RadioCheckpoint{
activeCIDs: append([]int(nil), active...),
}
adapter.mu.Unlock()
purePolicy := false
if adapter.options.PureAirplanePolicy != nil {
purePolicy = adapter.options.PureAirplanePolicy(deviceID)
}
return RadioSnapshot{
CellularDataEnabled: len(active) > 0,
OperatingMode: mode,
PureAirplanePolicy: purePolicy,
}, nil
}
func (adapter *EC20Adapter) StopCellularData(
ctx context.Context,
deviceID string,
) error {
active, err := adapter.readActiveCIDs(ctx, deviceID)
if err != nil {
return err
}
for _, cid := range active {
if _, err := adapter.execute(
ctx,
deviceID,
fmt.Sprintf("AT+CGACT=0,%d", cid),
); err != nil {
return fmt.Errorf("deactivate EC20 PDP context %d: %w", cid, err)
}
}
remaining, err := adapter.readActiveCIDs(ctx, deviceID)
if err != nil {
return err
}
if len(remaining) != 0 {
return errors.New("vocat: EC20 cellular data remained active")
}
return nil
}
func (adapter *EC20Adapter) EnterVoWiFiRFOff(
ctx context.Context,
deviceID string,
) error {
mode, err := adapter.readOperatingMode(ctx, deviceID)
if err != nil {
return err
}
if mode != 4 {
if _, err := adapter.execute(ctx, deviceID, "AT+CFUN=4"); err != nil {
return fmt.Errorf("enter EC20 RF-off mode: %w", err)
}
}
mode, err = adapter.readOperatingMode(ctx, deviceID)
if err != nil {
return err
}
if mode != 4 {
return fmt.Errorf("vocat: EC20 reported CFUN=%d after RF-off request", mode)
}
return nil
}
func (adapter *EC20Adapter) Restore(
ctx context.Context,
deviceID string,
snapshot RadioSnapshot,
) error {
if snapshot.OperatingMode < 0 {
return errors.New("vocat: invalid EC20 radio snapshot")
}
targetMode := snapshot.OperatingMode
if snapshot.PureAirplanePolicy {
// VoWiFi teardown is intentionally fail-closed. Even if the modem was in
// CFUN=1 before setup, disabling VoWiFi must leave RF off until the user
// explicitly turns airplane mode off through the separate control.
targetMode = 4
}
currentMode, err := adapter.readOperatingMode(ctx, deviceID)
if err != nil {
return err
}
if currentMode != targetMode {
if _, err := adapter.execute(
ctx,
deviceID,
fmt.Sprintf("AT+CFUN=%d", targetMode),
); err != nil {
return fmt.Errorf("restore EC20 operating mode: %w", err)
}
}
currentMode, err = adapter.readOperatingMode(ctx, deviceID)
if err != nil {
return err
}
if currentMode != targetMode {
return fmt.Errorf(
"vocat: EC20 restore reported CFUN=%d, expected %d",
currentMode,
targetMode,
)
}
adapter.mu.Lock()
checkpoint, found := adapter.checkpoints[deviceID]
adapter.mu.Unlock()
if !found {
if snapshot.CellularDataEnabled {
return errors.New("vocat: EC20 PDP restore evidence is unavailable")
}
checkpoint.activeCIDs = nil
}
if (snapshot.OperatingMode == 0 || snapshot.OperatingMode == 4) &&
len(checkpoint.activeCIDs) > 0 {
return errors.New("vocat: EC20 snapshot has active data in RF-off mode")
}
desiredCIDs := checkpoint.activeCIDs
if !adapter.options.RestoreCellularData || targetMode == 0 || targetMode == 4 {
desiredCIDs = nil
}
if err := adapter.reconcileActiveCIDs(
ctx,
deviceID,
desiredCIDs,
); err != nil {
return err
}
adapter.mu.Lock()
delete(adapter.checkpoints, deviceID)
adapter.mu.Unlock()
return nil
}
func (adapter *EC20Adapter) reconcileActiveCIDs(
ctx context.Context,
deviceID string,
desired []int,
) error {
current, err := adapter.readActiveCIDs(ctx, deviceID)
if err != nil {
return err
}
desiredSet := integerSet(desired)
currentSet := integerSet(current)
for _, cid := range current {
if _, wanted := desiredSet[cid]; wanted {
continue
}
if _, err := adapter.execute(
ctx,
deviceID,
fmt.Sprintf("AT+CGACT=0,%d", cid),
); err != nil {
return fmt.Errorf("restore EC20 PDP context %d: %w", cid, err)
}
}
for _, cid := range desired {
if _, active := currentSet[cid]; active {
continue
}
if _, err := adapter.execute(
ctx,
deviceID,
fmt.Sprintf("AT+CGACT=1,%d", cid),
); err != nil {
return fmt.Errorf("restore EC20 PDP context %d: %w", cid, err)
}
}
verified, err := adapter.readActiveCIDs(ctx, deviceID)
if err != nil {
return err
}
if !sameIntegers(verified, desired) {
return fmt.Errorf(
"vocat: EC20 PDP restore mismatch: active contexts %v",
verified,
)
}
return nil
}
func (adapter *EC20Adapter) readOperatingMode(
ctx context.Context,
deviceID string,
) (int, error) {
response, err := adapter.execute(ctx, deviceID, "AT+CFUN?")
if err != nil {
return 0, fmt.Errorf("read EC20 operating mode: %w", err)
}
value := valueAfterATPrefix(response, "+CFUN:")
fields := parseCSV(value)
if len(fields) == 0 {
return 0, errors.New("vocat: EC20 returned no CFUN mode")
}
mode, err := strconv.Atoi(fields[0])
if err != nil || mode < 0 {
return 0, errors.New("vocat: EC20 returned an invalid CFUN mode")
}
return mode, nil
}
func (adapter *EC20Adapter) readActiveCIDs(
ctx context.Context,
deviceID string,
) ([]int, error) {
response, err := adapter.execute(ctx, deviceID, "AT+CGACT?")
if err != nil {
return nil, fmt.Errorf("read EC20 PDP contexts: %w", err)
}
var active []int
for _, line := range response.Lines {
line = strings.TrimSpace(line)
if !strings.HasPrefix(strings.ToUpper(line), "+CGACT:") {
continue
}
fields := parseCSV(strings.TrimSpace(line[len("+CGACT:"):]))
if len(fields) < 2 {
return nil, errors.New("vocat: EC20 returned an invalid CGACT record")
}
cid, cidErr := strconv.Atoi(fields[0])
state, stateErr := strconv.Atoi(fields[1])
if cidErr != nil || stateErr != nil || cid <= 0 || (state != 0 && state != 1) {
return nil, errors.New("vocat: EC20 returned an invalid CGACT record")
}
if state == 1 {
active = append(active, cid)
}
}
sort.Ints(active)
return uniqueIntegers(active), nil
}
func (adapter *EC20Adapter) bindingFor(
identity SIMIdentity,
) (ec20SIMBinding, error) {
iccid := strings.TrimSpace(identity.ICCID)
adapter.mu.Lock()
binding, ok := adapter.bindings[iccid]
adapter.mu.Unlock()
if !ok || iccid == "" {
return ec20SIMBinding{}, errors.New("vocat: EC20 SIM identity is not bound to a device")
}
if strings.TrimSpace(identity.IMSI) != binding.imsi {
return ec20SIMBinding{}, ErrEC20IdentityChanged
}
return binding, nil
}
func (adapter *EC20Adapter) verifyLiveICCID(
ctx context.Context,
binding ec20SIMBinding,
) error {
iccid, err := adapter.readICCID(ctx, binding.deviceID)
if err != nil {
return err
}
if iccid != binding.iccid {
return ErrEC20IdentityChanged
}
return nil
}
func (adapter *EC20Adapter) discoverAKAApplication(
ctx context.Context,
deviceID string,
) (aid string, application string, err error) {
response, cuadErr := adapter.execute(ctx, deviceID, "AT+CUAD")
if cuadErr == nil {
data, parseErr := parseCUADData(response)
if parseErr == nil {
aids := collectApplicationAIDs(data)
for _, candidate := range aids {
if strings.HasPrefix(candidate, usimAIDPrefix) {
return candidate, "USIM", nil
}
}
for _, candidate := range aids {
if strings.HasPrefix(candidate, isimAIDPrefix) {
return candidate, "ISIM", nil
}
}
if len(aids) > 0 {
return "", "", ErrEC20ApplicationAbsent
}
}
}
// AT+CUAD is optional on older EC20 firmware. CCHO still provides a
// standards-based, evidence-bearing probe of the assigned USIM AID.
return usimAIDPrefix, "USIM", nil
}
func (adapter *EC20Adapter) openLogicalChannel(
ctx context.Context,
deviceID string,
aid string,
) (int, error) {
response, err := adapter.execute(
ctx,
deviceID,
fmt.Sprintf(`AT+CCHO="%s"`, aid),
)
if err != nil {
return 0, fmt.Errorf("%w: open application", ErrEC20ApplicationAbsent)
}
value := valueAfterATPrefix(response, "+CCHO:")
if value == "" {
for _, line := range response.Lines {
line = strings.TrimSpace(line)
if _, parseErr := strconv.Atoi(line); parseErr == nil {
value = line
break
}
}
}
channel, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || channel < 1 || channel > 19 {
return 0, errors.New("vocat: EC20 returned an invalid logical channel")
}
return channel, nil
}
func (adapter *EC20Adapter) closeLogicalChannel(
ctx context.Context,
deviceID string,
channel int,
) error {
if _, err := adapter.execute(
ctx,
deviceID,
fmt.Sprintf("AT+CCHC=%d", channel),
); err != nil {
return fmt.Errorf("close EC20 logical channel: %w", err)
}
return nil
}
func (adapter *EC20Adapter) closeLogicalChannelWithCleanup(
deviceID string,
channel int,
) error {
ctx, cancel := context.WithTimeout(
context.Background(),
channelCleanupTimeout,
)
defer cancel()
return adapter.closeLogicalChannel(ctx, deviceID, channel)
}
func (adapter *EC20Adapter) selectBasicApplication(
ctx context.Context,
deviceID string,
aid string,
) error {
aidBytes, err := hex.DecodeString(aid)
if err != nil || len(aidBytes) == 0 || len(aidBytes) > 255 {
return errors.New("vocat: invalid USIM application identifier")
}
// SELECT by DF name, request the first/only matching application and FCP.
apdu := []byte{0x00, 0xa4, 0x04, 0x04, byte(len(aidBytes))}
apdu = append(apdu, aidBytes...)
raw, err := adapter.transmitBasicAPDU(ctx, deviceID, apdu, false)
if err != nil {
return fmt.Errorf("select EC20 basic-channel application: %w", err)
}
_, status, err := splitAPDUStatus(raw)
if err != nil {
return err
}
if status != 0x9000 {
return fmt.Errorf(
"vocat: EC20 basic-channel SELECT returned %04X",
status,
)
}
return nil
}
func (adapter *EC20Adapter) transmitBasicAPDU(
ctx context.Context,
deviceID string,
apdu []byte,
sensitive bool,
) ([]byte, error) {
if len(apdu) == 0 || len(apdu) > 261 {
return nil, errors.New("vocat: invalid EC20 APDU length")
}
var collected []byte
current := append([]byte(nil), apdu...)
for exchange := 0; exchange < 4; exchange++ {
command := fmt.Sprintf(
`AT+CSIM=%d,"%s"`,
len(current)*2,
strings.ToUpper(hex.EncodeToString(current)),
)
var response modem.Response
var err error
if sensitive {
response, err = adapter.executeSensitive(ctx, deviceID, command)
} else {
response, err = adapter.execute(ctx, deviceID, command)
}
if err != nil {
return nil, errors.New("vocat: EC20 CSIM exchange failed")
}
raw, err := parseCSIMData(response)
if err != nil {
return nil, err
}
body, status, err := splitAPDUStatus(raw)
if err != nil {
return nil, err
}
collected = append(collected, body...)
sw1 := byte(status >> 8)
if sw1 != 0x61 && sw1 != 0x9f {
collected = append(collected, byte(status>>8), byte(status))
return collected, nil
}
// GET RESPONSE on the same basic channel. Le=0 means 256 bytes.
current = []byte{0x00, 0xc0, 0x00, 0x00, byte(status)}
}
return nil, errors.New("vocat: EC20 APDU response chaining exceeded limit")
}
func (adapter *EC20Adapter) transmitLogicalAPDU(
ctx context.Context,
deviceID string,
channel int,
apdu []byte,
sensitive bool,
) ([]byte, error) {
if channel < 1 || channel > 19 || len(apdu) == 0 || len(apdu) > 261 {
return nil, ErrEC20AKACommand
}
var collected []byte
current := append([]byte(nil), apdu...)
for exchange := 0; exchange < 4; exchange++ {
command := fmt.Sprintf(
`AT+CGLA=%d,%d,"%s"`,
channel,
len(current)*2,
strings.ToUpper(hex.EncodeToString(current)),
)
var response modem.Response
var err error
if sensitive {
response, err = adapter.executeSensitive(ctx, deviceID, command)
} else {
response, err = adapter.execute(ctx, deviceID, command)
}
if err != nil {
return nil, errors.Join(ErrEC20AKACommand, err)
}
raw, err := parseCGLAData(response)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrEC20AKAResponse, err)
}
body, status, err := splitAPDUStatus(raw)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrEC20AKAResponse, err)
}
collected = append(collected, body...)
sw1 := byte(status >> 8)
if sw1 != 0x61 && sw1 != 0x9f {
collected = append(collected, byte(status>>8), byte(status))
return collected, nil
}
// CGLA carries the logical-channel identifier separately, so GET
// RESPONSE retains the same interindustry CLA used by AUTHENTICATE.
// Le=0 means 256 bytes when SW2 is zero.
current = []byte{0x00, 0xc0, 0x00, 0x00, byte(status)}
}
return nil, fmt.Errorf(
"%w: logical-channel response chaining exceeded limit",
ErrEC20AKAResponse,
)
}
func splitAPDUStatus(raw []byte) ([]byte, uint16, error) {
if len(raw) < 2 {
return nil, 0, errors.New("vocat: EC20 APDU has no status word")
}
status := uint16(raw[len(raw)-2])<<8 | uint16(raw[len(raw)-1])
return raw[:len(raw)-2], status, nil
}
func (adapter *EC20Adapter) execute(
ctx context.Context,
deviceID string,
command string,
) (modem.Response, error) {
return adapter.executor.ExecuteAT(ctx, deviceID, command)
}
func (adapter *EC20Adapter) executeSensitive(
ctx context.Context,
deviceID string,
command string,
) (modem.Response, error) {
if executor, ok := adapter.executor.(EC20SensitiveATExecutor); ok {
return executor.ExecuteSensitiveAT(ctx, deviceID, command)
}
return adapter.executor.ExecuteAT(ctx, deviceID, command)
}
func parseCRSMData(response modem.Response) ([]byte, error) {
value := valueAfterATPrefix(response, "+CRSM:")
fields := parseCSV(value)
if len(fields) < 2 {
return nil, errors.New("invalid CRSM response")
}
sw1, err1 := strconv.Atoi(fields[0])
sw2, err2 := strconv.Atoi(fields[1])
if err1 != nil || err2 != nil {
return nil, errors.New("invalid CRSM status")
}
if sw1 != 0x90 && sw1 != 0x91 && sw1 != 0x9f {
return nil, fmt.Errorf("CRSM status %02X%02X", sw1, sw2)
}
if len(fields) < 3 {
return nil, errors.New("CRSM response has no data")
}
data, err := hex.DecodeString(strings.Trim(fields[2], `"`))
if err != nil {
return nil, errors.New("CRSM response data is not hexadecimal")
}
return data, nil
}
func parseCUADData(response modem.Response) ([]byte, error) {
fields := parseCSV(valueAfterATPrefix(response, "+CUAD:"))
if len(fields) == 0 {
return nil, errors.New("CUAD response has no data")
}
value := fields[len(fields)-1]
data, err := hex.DecodeString(strings.Trim(value, `"`))
if err != nil || len(data) == 0 {
return nil, errors.New("CUAD response data is invalid")
}
if len(data) >= 2 && data[len(data)-2] == 0x90 && data[len(data)-1] == 0x00 {
data = data[:len(data)-2]
}
return data, nil
}
func collectApplicationAIDs(data []byte) []string {
var result []string
var walk func([]byte)
walk = func(value []byte) {
for len(value) > 0 {
tag, constructed, body, consumed, err := decodeBERTLV(value)
if err != nil || consumed == 0 {
return
}
if len(tag) == 1 && tag[0] == 0x4f && len(body) > 0 {
result = append(result, strings.ToUpper(hex.EncodeToString(body)))
}
if constructed {
walk(body)
}
value = value[consumed:]
}
}
walk(data)
return result
}
func decodeBERTLV(data []byte) (
tag []byte,
constructed bool,
value []byte,
consumed int,
err error,
) {
if len(data) < 2 {
return nil, false, nil, 0, errors.New("short BER-TLV")
}
tagLength := 1
if data[0]&0x1f == 0x1f {
for {
if tagLength >= len(data) {
return nil, false, nil, 0, errors.New("short BER tag")
}
last := data[tagLength]&0x80 == 0
tagLength++
if last {
break
}
if tagLength > 4 {
return nil, false, nil, 0, errors.New("oversized BER tag")
}
}
}
body, bodyConsumed, err := decodeBERTLVValue(data[tagLength:])
if err != nil {
return nil, false, nil, 0, err
}
return data[:tagLength],
data[0]&0x20 != 0,
body,
tagLength + bodyConsumed,
nil
}
func decodeBERTLVValue(data []byte) ([]byte, int, error) {
if len(data) == 0 {
return nil, 0, errors.New("missing BER length")
}
length := int(data[0])
lengthOctets := 1
if data[0]&0x80 != 0 {
count := int(data[0] & 0x7f)
if count == 0 || count > 2 || len(data) < 1+count {
return nil, 0, errors.New("invalid BER length")
}
length = 0
for _, octet := range data[1 : 1+count] {
length = length<<8 | int(octet)
}
lengthOctets += count
}
if length > len(data)-lengthOctets {
return nil, 0, errors.New("short BER value")
}
return data[lengthOctets : lengthOctets+length],
lengthOctets + length,
nil
}
func parseCGLAData(response modem.Response) ([]byte, error) {
return parseATAPDUData(response, "+CGLA:")
}
func parseCSIMData(response modem.Response) ([]byte, error) {
return parseATAPDUData(response, "+CSIM:")
}
func parseATAPDUData(response modem.Response, prefix string) ([]byte, error) {
fields := parseCSV(valueAfterATPrefix(response, prefix))
if len(fields) < 2 {
return nil, errors.New("EC20 response has no APDU")
}
declared, err := strconv.Atoi(fields[0])
if err != nil || declared < 0 {
return nil, errors.New("EC20 response has invalid APDU length")
}
encoded := strings.Trim(fields[1], `" `)
data, err := hex.DecodeString(encoded)
if err != nil {
return nil, errors.New("EC20 APDU response is not hexadecimal")
}
if declared != len(encoded) && declared != len(data) {
return nil, errors.New("EC20 APDU response length mismatch")
}
return data, nil
}
func responseContainsValue(
response modem.Response,
prefix string,
expected string,
) bool {
return strings.EqualFold(
strings.Trim(strings.TrimSpace(valueAfterATPrefix(response, prefix)), `"`),
expected,
)
}
func valueAfterATPrefix(response modem.Response, prefix string) string {
for _, line := range response.Lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(strings.ToUpper(line), strings.ToUpper(prefix)) {
return strings.TrimSpace(line[len(prefix):])
}
}
return ""
}
func digitIdentifier(
response modem.Response,
prefixes []string,
minimum int,
maximum int,
) string {
for _, prefix := range prefixes {
value := strings.Trim(valueAfterATPrefix(response, prefix), `" `)
if validDigits(value, minimum, maximum) {
return value
}
}
for _, line := range response.Lines {
value := strings.TrimSpace(line)
if validDigits(value, minimum, maximum) {
return value
}
}
return ""
}
func iccidIdentifier(
response modem.Response,
prefixes []string,
minimum int,
maximum int,
) string {
normalize := func(value string) string {
value = strings.Trim(value, `" `)
value = strings.TrimRight(value, "Ff")
if validDigits(value, minimum, maximum) {
return value
}
return ""
}
for _, prefix := range prefixes {
if value := normalize(valueAfterATPrefix(response, prefix)); value != "" {
return value
}
}
for _, line := range response.Lines {
if value := normalize(strings.TrimSpace(line)); value != "" {
return value
}
}
return ""
}
func validDigits(value string, minimum int, maximum int) bool {
if len(value) < minimum || len(value) > maximum {
return false
}
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
return true
}
func parseCSV(value string) []string {
reader := csv.NewReader(strings.NewReader(value))
reader.TrimLeadingSpace = true
reader.LazyQuotes = true
record, err := reader.Read()
if err != nil && err != io.EOF {
return nil
}
for index := range record {
record[index] = strings.TrimSpace(record[index])
}
return record
}
func integerSet(values []int) map[int]struct{} {
result := make(map[int]struct{}, len(values))
for _, value := range values {
result[value] = struct{}{}
}
return result
}
func uniqueIntegers(values []int) []int {
if len(values) < 2 {
return values
}
result := values[:1]
for _, value := range values[1:] {
if value != result[len(result)-1] {
result = append(result, value)
}
}
return result
}
func sameIntegers(left, right []int) bool {
left = append([]int(nil), left...)
right = append([]int(nil), right...)
sort.Ints(left)
sort.Ints(right)
left = uniqueIntegers(left)
right = uniqueIntegers(right)
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}