| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373 |
- 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 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.
- for prefix, mncLength := range map[string]int{"23415": 2} {
- if strings.HasPrefix(imsi, prefix) {
- return imsi[:3], imsi[3 : 3+mncLength], nil
- }
- }
- return "", "", efErr
- }
- 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()
- 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()
- 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")
- }
- currentMode, err := adapter.readOperatingMode(ctx, deviceID)
- if err != nil {
- return err
- }
- if currentMode != snapshot.OperatingMode {
- if _, err := adapter.execute(
- ctx,
- deviceID,
- fmt.Sprintf("AT+CFUN=%d", snapshot.OperatingMode),
- ); err != nil {
- return fmt.Errorf("restore EC20 operating mode: %w", err)
- }
- }
- currentMode, err = adapter.readOperatingMode(ctx, deviceID)
- if err != nil {
- return err
- }
- if currentMode != snapshot.OperatingMode {
- return fmt.Errorf(
- "vocat: EC20 restore reported CFUN=%d, expected %d",
- currentMode,
- snapshot.OperatingMode,
- )
- }
- 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 {
- 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
- }
|