diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index 2d7eff7..d6958bc 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -27,6 +27,7 @@ import ( "vocat/internal/extensions" "vocat/internal/httpsmode" "vocat/internal/loghub" + "vocat/internal/pcsc" "vocat/internal/server" "vocat/internal/store" "vocat/internal/update" @@ -184,7 +185,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { return err } - deviceManager, err := device.NewManager(device.Options{}) + cardReaders := pcsc.New() + deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders}) if err != nil { return fmt.Errorf("create device manager: %w", err) } @@ -220,6 +222,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { logger, database, deviceManager, + cardReaders, ) if err != nil { return fmt.Errorf("configure VoWiFi runtime: %w", err) @@ -362,6 +365,12 @@ func configureDeviceBackends( if mapErr != nil { continue } + if config.DeviceType == store.DeviceTypeUSBSIMReader { + if err := manager.SetSIMPin(entry.ID, config.SIMPIN); err != nil { + logger.Warn("configure USB SIM reader", "device_id", config.ID, "error", err) + } + continue + } if err := manager.SetBackend(entry.ID, config.DeviceBackend); err != nil { logger.Warn("configure device backend", "device_id", config.ID, "backend", config.DeviceBackend, "error", err) } @@ -384,6 +393,9 @@ func restoreDefaultCellularRadios( } mapper := integration.ATMapper{Store: database, Devices: manager} for _, config := range configs { + if config.DeviceType == store.DeviceTypeUSBSIMReader { + continue + } if config.VoWiFiEnabled { continue } @@ -431,6 +443,9 @@ func restoreConfiguredCellularData( } mapper := integration.ATMapper{Store: database, Devices: manager} for _, config := range configs { + if config.DeviceType == store.DeviceTypeUSBSIMReader { + continue + } if !config.NetworkEnabled || config.VoWiFiEnabled { continue } @@ -482,6 +497,9 @@ func disableAllDeveloperCellularData( } mapper := integration.ATMapper{Store: database, Devices: manager} for _, config := range configs { + if config.DeviceType == store.DeviceTypeUSBSIMReader { + continue + } entry, err := mapper.Get(config.ID) if err != nil { continue @@ -536,12 +554,13 @@ func configureVoWiFiRuntime( logger *slog.Logger, database *store.Store, deviceManager *device.Manager, + cardReaders *pcsc.Service, ) (*vowifiruntime.Manager, error) { mapper := integration.ATMapper{ Store: database, Devices: deviceManager, } - adapter, err := vowifi.NewEC20Adapter(mapper, vowifi.EC20AdapterOptions{ + ec20Adapter, err := vowifi.NewEC20Adapter(mapper, vowifi.EC20AdapterOptions{ // The test deployment is deliberately non-cellular. VoWiFi teardown // may restore CFUN, but it must never reactivate a PDP context. RestoreCellularData: false, @@ -556,6 +575,16 @@ func configureVoWiFiRuntime( if err != nil { return nil, err } + pcscAdapter, err := vowifi.NewPCSCAdapter(cardReaders, func(ctx context.Context, deviceID string) (pcsc.Selector, string, error) { + config, resolveErr := database.Device(ctx, strings.TrimSpace(deviceID)) + if resolveErr != nil { + return pcsc.Selector{}, "", resolveErr + } + return pcsc.Selector{USBPath: config.USBPath, ReaderName: config.ControlDevice}, config.SIMPIN, nil + }) + if err != nil { + return nil, err + } projector := integration.StateProjector{ Store: database, Devices: mapper, @@ -568,6 +597,10 @@ func configureVoWiFiRuntime( if err != nil { return nil, fmt.Errorf("load device %q VoWiFi config: %w", deviceID, err) } + adapter := vowifiDeviceAdapter(ec20Adapter) + if deviceConfig.DeviceType == store.DeviceTypeUSBSIMReader { + adapter = pcscAdapter + } return newVoWiFiOrchestrator(deviceConfig, database, adapter) }, }) @@ -601,10 +634,16 @@ func configureVoWiFiRuntime( return manager, nil } +type vowifiDeviceAdapter interface { + vowifi.SIMIdentityReader + vowifi.AKAProvider + vowifi.RadioController +} + func newVoWiFiOrchestrator( deviceConfig store.Device, database *store.Store, - adapter *vowifi.EC20Adapter, + adapter vowifiDeviceAdapter, ) (*vowifi.Orchestrator, error) { apn := deviceConfig.APN if apn == "" { @@ -734,9 +773,18 @@ func provisionDiscoveredDevices( candidate := discovered.Candidate backend := "at" control := candidate.ATPort.OpenPath() + deviceType := store.DeviceTypePCIeEC20EC25 + esimTransport := backend if candidate.QMIControl != "" { backend = "qmi" control = candidate.QMIControl + esimTransport = backend + } + if candidate.HardwareKind == pcsc.HardwareKind { + backend = "pcsc" + control = candidate.ReaderName + deviceType = store.DeviceTypeUSBSIMReader + esimTransport = "pcsc" } name := candidate.Product if name == "" || strings.EqualFold(name, "Android") { @@ -745,6 +793,7 @@ func provisionDiscoveredDevices( if err := database.UpsertDevice(ctx, store.Device{ ID: discovered.ID, Name: name, + DeviceType: deviceType, Interface: candidate.NetworkInterface, ControlDevice: control, ATPort: candidate.ATPort.OpenPath(), @@ -755,7 +804,7 @@ func provisionDiscoveredDevices( StopBits: 1, Parity: "none", DeviceBackend: backend, - ESIMTransport: backend, + ESIMTransport: esimTransport, NetworkEnabled: false, SMSEnabled: true, VoWiFiEnabled: true, @@ -931,6 +980,7 @@ func reconcileCardPolicies( manager *device.Manager, vowifiManager *vowifiruntime.Manager, ) { + observedCards := make(map[string]string) reconcile := func() { policies, policyListErr := database.ListCardPolicies(ctx) if policyListErr == nil { @@ -953,12 +1003,26 @@ func reconcileCardPolicies( for _, config := range configs { entry, mapErr := mapper.Get(config.ID) if mapErr != nil || entry.Snapshot == nil { + if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" { + if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" { + _, _ = vowifiManager.RequestReconnect(config.ID) + } + observedCards[config.ID] = "missing" + } continue } iccid := strings.TrimSpace(entry.Snapshot.ICCID) if iccid == "" { + if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" { + if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" { + _, _ = vowifiManager.RequestReconnect(config.ID) + } + observedCards[config.ID] = "missing" + } continue } + previousObserved := observedCards[config.ID] + observedCards[config.ID] = iccid policy, policyErr := database.CardPolicy(ctx, iccid) if policyErr != nil { continue @@ -1001,6 +1065,8 @@ func reconcileCardPolicies( _, _ = vowifiManager.RequestEnabled(config.ID, true) case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid): _, _ = vowifiManager.RequestReconnect(config.ID) + case config.DeviceType == store.DeviceTypeUSBSIMReader && previousObserved == "missing": + _, _ = vowifiManager.RequestReconnect(config.ID) } continue } diff --git a/go.mod b/go.mod index f36fc27..6f78428 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module vocat go 1.25.0 require ( + github.com/ElMostafaIdrassi/goscard v1.0.0 github.com/coder/websocket v1.8.15 go.bug.st/serial v1.6.4 golang.org/x/crypto v0.41.0 @@ -14,6 +15,7 @@ require ( require ( github.com/creack/goselect v0.1.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.8.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect diff --git a/go.sum b/go.sum index f7cb006..b7efee2 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/ElMostafaIdrassi/goscard v1.0.0 h1:RDG5QrqrQBUoi5MkzM4zILdYf8qDn62daYZszqvdgx0= +github.com/ElMostafaIdrassi/goscard v1.0.0/go.mod h1:uGOakQe2fFlW2cVlr9cv6x07uelrf0j0aKPbR7jGgfg= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= @@ -6,6 +8,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -18,8 +22,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= diff --git a/internal/device/controls.go b/internal/device/controls.go index 6cb75b1..c1a5c1f 100644 --- a/internal/device/controls.go +++ b/internal/device/controls.go @@ -274,6 +274,9 @@ func (manager *Manager) SetFlight( if err := manager.validateActive(id, state); err != nil { return FlightResult{}, err } + if manager.candidateFor(state).HardwareKind == "pcsc" { + return FlightResult{PreviousMode: 4, CurrentMode: 4, FlightMode: true, RadioOff: true}, nil + } client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) if err != nil { manager.setResult(id, state, nil, err) diff --git a/internal/device/esim.go b/internal/device/esim.go index 438c43a..84a430e 100644 --- a/internal/device/esim.go +++ b/internal/device/esim.go @@ -10,6 +10,7 @@ import ( "vocat/internal/i18n" "vocat/internal/modem" + "vocat/internal/pcsc" ) // eUICC / eSIM (LPA, SGP.22) access over the modem's AT+CSIM APDU passthrough. @@ -190,9 +191,11 @@ func parseCSIM(response modem.Response) ([]byte, int, error) { // euiccChannel is an open logical channel to the eUICC's ISD-R. type euiccChannel struct { - manager *Manager - id string - channel int + manager *Manager + id string + channel int + pcscSession *pcsc.Session + resetOnClose bool } // csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage @@ -264,6 +267,14 @@ func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccCha } func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) { + state, lookupErr := manager.lookup(id) + if lookupErr != nil { + return nil, lookupErr + } + candidate := manager.candidateFor(state) + if candidate.HardwareKind == pcsc.HardwareKind { + return manager.openPCSCEuiccOnceAID(ctx, id, candidate, aidHex) + } // MANAGE CHANNEL (open): 00 70 00 00 01 -> " 90 00". This EC20 // firmware requires the explicit one-byte expected length: Le=00 opens a // channel but then rejects SELECT ISD-R at the AT+CSIM layer. @@ -302,6 +313,37 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) return channel, nil } +func (manager *Manager) openPCSCEuiccOnceAID(ctx context.Context, id string, candidate modem.Candidate, aidHex string) (*euiccChannel, error) { + session, err := manager.cardReaders.OpenSession(ctx, pcsc.Selector{ + USBPath: candidate.USBPath, ReaderName: candidate.ReaderName, + }) + if err != nil { + return nil, err + } + payload, sw, err := session.Transmit(ctx, []byte{0x00, 0x70, 0x00, 0x00, 0x01}) + if err != nil || sw != 0x9000 || len(payload) != 1 { + session.Close() + if err != nil { + return nil, fmt.Errorf("esim: PC/SC MANAGE CHANNEL: %w", err) + } + return nil, errNoLogicalChannel + } + channel := &euiccChannel{manager: manager, id: id, channel: int(payload[0]), pcscSession: session} + aidHex = strings.ToUpper(strings.TrimSpace(aidHex)) + aid, err := hex.DecodeString(aidHex) + if err != nil || len(aid) == 0 || len(aid) > 255 { + channel.close(context.Background()) + return nil, fmt.Errorf("esim: invalid ISD-R AID %q", aidHex) + } + selectAID := append([]byte{byte(channel.channel), 0xA4, 0x04, 0x00, byte(len(aid))}, aid...) + _, selectSW, err := channel.transmit(ctx, selectAID, 0x00) + if err != nil || selectSW != 0x9000 { + channel.close(context.Background()) + return nil, errNoEUICC + } + return channel, nil +} + // discoverEuiccAIDs detects eSTK multi-SE and alternate-ISD-R cards without // changing any profile state. The vendor product applet and candidate ISD-R // applications are selected only as read-only capability probes. Per @@ -352,7 +394,23 @@ func isTransientEuiccCME(err error) bool { // close releases the logical channel (MANAGE CHANNEL close). func (channel *euiccChannel) close(ctx context.Context) { closeAPDU := []byte{0x00, 0x70, 0x80, byte(channel.channel), 0x00} - _, _, _ = channel.manager.csim(ctx, channel.id, closeAPDU) + _, _, _ = channel.exchange(ctx, closeAPDU) + if channel.pcscSession != nil { + if channel.resetOnClose { + _ = channel.pcscSession.CloseWithReset() + } else { + _ = channel.pcscSession.Close() + } + channel.pcscSession = nil + } +} + +func (channel *euiccChannel) exchange(ctx context.Context, apdu []byte) ([]byte, int, error) { + if channel.pcscSession != nil { + payload, sw, err := channel.pcscSession.Transmit(ctx, apdu) + return payload, int(sw), err + } + return channel.manager.csim(ctx, channel.id, apdu) } // transmit sends one APDU on the logical channel (CLA high nibble from insClass, @@ -360,7 +418,7 @@ func (channel *euiccChannel) close(ctx context.Context) { // and returns the assembled payload. func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass byte) ([]byte, int, error) { apdu[0] = (apdu[0] & 0xF0) | byte(channel.channel) - payload, sw, err := channel.manager.csim(ctx, channel.id, apdu) + payload, sw, err := channel.exchange(ctx, apdu) if err != nil { return nil, 0, err } @@ -369,7 +427,7 @@ func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass for sw>>8 == 0x61 && guard < 24 { guard++ getResponse := []byte{0x80 | byte(channel.channel), 0xC0, 0x00, 0x00, byte(sw & 0xFF)} - frag, nextSW, err := channel.manager.csim(ctx, channel.id, getResponse) + frag, nextSW, err := channel.exchange(ctx, getResponse) if err != nil { return nil, 0, err } @@ -610,6 +668,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid // Release the logical channel before any reset: openEuicc's csim holds // opMu only for the duration of each APDU, so by here the lock is free. closeContext, cancelClose := context.WithTimeout(context.Background(), csimAPDUTimeout) + channel.resetOnClose = channel.pcscSession != nil channel.close(closeContext) cancelClose() if err != nil { @@ -780,9 +839,11 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) { // initiating HTTP request. EC20 commonly drops the AT port while processing // CFUN=1,1, so the reset error is intentionally followed by discovery retries. func (manager *Manager) recoverAfterProfileSwitch(id string) { - resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout) - _ = manager.rebootForProfileSwitch(resetContext, id) - cancelReset() + if !manager.isPCSCDevice(id) { + resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout) + _ = manager.rebootForProfileSwitch(resetContext, id) + cancelReset() + } manager.refreshAfterProfileSwitch(id) } @@ -795,6 +856,20 @@ func (manager *Manager) recoverAfterProfileSwitch(id string) { // the next attempt. All errors are swallowed: this is best-effort self-healing // and setResult already records the last failure for the UI. func (manager *Manager) refreshAfterProfileSwitch(id string) { + if manager.isPCSCDevice(id) { + time.Sleep(750 * time.Millisecond) + for attempt := 0; attempt < 10; attempt++ { + ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4) + _, _ = manager.Discover(ctx) + _, err := manager.Refresh(ctx, id) + cancel() + if err == nil { + return + } + time.Sleep(time.Second) + } + return + } const ( settle = 8 * time.Second interval = 4 * time.Second @@ -819,6 +894,14 @@ func (manager *Manager) refreshAfterProfileSwitch(id string) { } } +func (manager *Manager) isPCSCDevice(id string) bool { + state, err := manager.lookup(id) + if err != nil { + return false + } + return manager.candidateFor(state).HardwareKind == pcsc.HardwareKind +} + // enableProfileResult extracts the EnableProfile result code (tag 80) from the // ES10c response body. ok is false when no result code is present. func enableProfileResult(payload []byte) (int, bool) { @@ -888,25 +971,37 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st var lastICCID string var lastErr error for attempt := 0; attempt < attempts; attempt++ { - for _, command := range []string{"AT+CCID", "AT+QCCID"} { - commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout) - response, err := manager.ExecuteAT(commandContext, id, command) - cancel() - if err != nil { - lastErr = err - continue + if manager.isPCSCDevice(id) { + snapshot, err := manager.Refresh(ctx, id) + if err == nil { + lastICCID = strings.TrimSpace(snapshot.ICCID) + if lastICCID == expected { + return nil + } + err = fmt.Errorf("reader still reports ICCID %s", lastICCID) } - live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22) - if live == "" { - lastErr = errors.New("modem response contained no valid ICCID") - continue + lastErr = err + } else { + for _, command := range []string{"AT+CCID", "AT+QCCID"} { + commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout) + response, err := manager.ExecuteAT(commandContext, id, command) + cancel() + if err != nil { + lastErr = err + continue + } + live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22) + if live == "" { + lastErr = errors.New("modem response contained no valid ICCID") + continue + } + lastICCID = live + if live == expected { + return nil + } + lastErr = fmt.Errorf("modem still reports ICCID %s", live) + break } - lastICCID = live - if live == expected { - return nil - } - lastErr = fmt.Errorf("modem still reports ICCID %s", live) - break } if attempt+1 < attempts { select { diff --git a/internal/device/esim_lpa.go b/internal/device/esim_lpa.go index f72bdea..ebc2a2c 100644 --- a/internal/device/esim_lpa.go +++ b/internal/device/esim_lpa.go @@ -52,7 +52,7 @@ func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest [] if err != nil { return nil, err } - if sw != 0x9000 { + if !es10StatusOK(sw) { return nil, fmt.Errorf("%w: SW=%04X", errESIMSW, sw) } assembled = append(assembled, payload...) @@ -62,6 +62,14 @@ func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest [] return assembled, nil } +// 91xx is a successful UICC result with a proactive SIM Toolkit command +// pending. EnableProfile commonly returns it on direct PC/SC transports because +// the requested refresh is delivered to the terminal rather than consumed by +// modem firmware. Resetting the card after the operation applies that refresh. +func es10StatusOK(sw int) bool { + return sw == 0x9000 || sw>>8 == 0x91 +} + // getEUICCChallenge (ES10c, BF2E) returns the eUICC challenge bytes. func (channel *euiccChannel) getEUICCChallenge(ctx context.Context) ([]byte, error) { payload, err := channel.es10(ctx, []byte{0xBF, 0x2E, 0x00}) diff --git a/internal/device/esim_lpa_test.go b/internal/device/esim_lpa_test.go index 629b269..190892f 100644 --- a/internal/device/esim_lpa_test.go +++ b/internal/device/esim_lpa_test.go @@ -155,3 +155,16 @@ func TestEuiccFreeNVRAM(t *testing.T) { t.Fatalf("expected ok=false when extCardResource absent") } } + +func TestES10StatusAcceptsProactiveRefresh(t *testing.T) { + for _, status := range []int{0x9000, 0x9100, 0x910B, 0x91FF} { + if !es10StatusOK(status) { + t.Fatalf("status %04X should be successful", status) + } + } + for _, status := range []int{0x6A82, 0x6985, 0x9200} { + if es10StatusOK(status) { + t.Fatalf("status %04X should fail", status) + } + } +} diff --git a/internal/device/manager.go b/internal/device/manager.go index 5c460b7..4263f3f 100644 --- a/internal/device/manager.go +++ b/internal/device/manager.go @@ -10,6 +10,7 @@ import ( "time" "vocat/internal/modem" + "vocat/internal/pcsc" ) type Options struct { @@ -19,6 +20,7 @@ type Options struct { LongTimeout time.Duration SMSTimeout time.Duration ScanTimeout time.Duration + CardReaders *pcsc.Service } type Manager struct { @@ -34,6 +36,7 @@ type Manager struct { longTimeout time.Duration smsTimeout time.Duration scanTimeout time.Duration + cardReaders *pcsc.Service started bool devices map[string]*managedDevice ussdSessions map[string]ussdSession @@ -59,6 +62,7 @@ type managedDevice struct { discovered bool preFlightMode *int resetClientOnLock bool + simPIN string } func NewManager(options Options) (*Manager, error) { @@ -82,6 +86,9 @@ func NewManager(options Options) (*Manager, error) { // AT+COPS=? can take well over a minute while the modem sweeps every band. options.ScanTimeout = 150 * time.Second } + if options.CardReaders == nil { + options.CardReaders = pcsc.New() + } return &Manager{ discoverer: options.Discoverer, opener: options.Opener, @@ -89,6 +96,7 @@ func NewManager(options Options) (*Manager, error) { longTimeout: options.LongTimeout, smsTimeout: options.SMSTimeout, scanTimeout: options.ScanTimeout, + cardReaders: options.CardReaders, devices: make(map[string]*managedDevice), ussdSessions: make(map[string]ussdSession), esimRecoveries: make(map[string]chan struct{}), @@ -146,9 +154,23 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) { if ctx == nil { ctx = context.Background() } - candidates, err := manager.discoverer.Discover(ctx) - if err != nil { - return nil, err + candidates, modemErr := manager.discoverer.Discover(ctx) + readers, readerErr := manager.cardReaders.Readers(ctx) + if readerErr == nil { + for _, reader := range readers { + candidates = append(candidates, modem.Candidate{ + ID: pcsc.DeviceID(reader), HardwareKind: pcsc.HardwareKind, + ReaderName: reader.Name, USBPath: reader.USBPath, + VendorID: reader.VendorID, ProductID: reader.ProductID, + Manufacturer: reader.Manufacturer, Product: reader.Product, + }) + } + } + if modemErr != nil && readerErr != nil && !errors.Is(readerErr, pcsc.ErrUnsupported) && !errors.Is(readerErr, pcsc.ErrUnavailable) { + return nil, errors.Join(modemErr, readerErr) + } + if modemErr != nil && len(candidates) == 0 { + return nil, modemErr } seen := make(map[string]struct{}, len(candidates)) @@ -360,6 +382,9 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error return Snapshot{}, err } candidate := manager.candidateFor(state) + if candidate.HardwareKind == pcsc.HardwareKind { + return manager.refreshCardReader(ctx, id, state, candidate) + } backend := manager.backendFor(state) client, err := manager.clientLocked(ctx, state, candidate) if err != nil { @@ -375,11 +400,59 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error return snapshot, err } +func (manager *Manager) refreshCardReader(ctx context.Context, id string, state *managedDevice, candidate modem.Candidate) (Snapshot, error) { + result := Snapshot{ + DeviceID: id, Port: candidate.ReaderName, Responsive: true, + Manufacturer: candidate.Manufacturer, Model: candidate.Product, + AccessTech: "Wi-Fi", RegistrationSource: "pcsc", OperatingMode: 4, + ModeKnown: true, FlightMode: true, RadioOff: true, UpdatedAt: time.Now().UTC(), + } + previousICCID := state.lastICCID + card, err := manager.cardReaders.Snapshot(ctx, pcsc.Selector{USBPath: candidate.USBPath, ReaderName: candidate.ReaderName}, state.simPIN) + if err != nil { + switch { + case errors.Is(err, pcsc.ErrNoCard): + result.SIMStatus = "" + err = nil + case errors.Is(err, pcsc.ErrPINRequired), errors.Is(err, pcsc.ErrPINTriesLow), errors.Is(err, pcsc.ErrPINRejected): + result.SIMStatus = "SIM PIN" + result.Warnings = []string{err.Error()} + err = nil + default: + manager.setResult(id, state, &result, err) + return result, err + } + } else { + result.SIMStatus = "READY" + result.SIMReady = true + result.ICCID = card.Identity.ICCID + result.IMSI = card.Identity.IMSI + result.SPN = card.Identity.SPN + result.SIMChanged = previousICCID != "" && !strings.EqualFold(previousICCID, result.ICCID) + state.lastICCID = result.ICCID + } + manager.setResult(id, state, &result, err) + return result, err +} + +// SetSIMPin updates the in-memory PIN used for protected USIM files and AKA. +// It is deliberately never retained in runtime snapshots or logs. +func (manager *Manager) SetSIMPin(id, pin string) error { + manager.mu.Lock() + defer manager.mu.Unlock() + state := manager.devices[id] + if state == nil || !state.discovered { + return ErrNotFound + } + state.simPIN = strings.TrimSpace(pin) + return nil +} + // SetBackend selects which control plane supplies registration and data state. // AT remains available in either mode for UICC, RF, SMS, voice and diagnostics. func (manager *Manager) SetBackend(id, backend string) error { backend = strings.ToLower(strings.TrimSpace(backend)) - if backend != "at" && backend != "qmi" { + if backend != "at" && backend != "qmi" && backend != "pcsc" { return fmt.Errorf("unsupported device backend %q", backend) } manager.mu.Lock() diff --git a/internal/device/manager_test.go b/internal/device/manager_test.go index b1a6361..b69ece5 100644 --- a/internal/device/manager_test.go +++ b/internal/device/manager_test.go @@ -6,8 +6,45 @@ import ( "testing" "vocat/internal/modem" + "vocat/internal/pcsc" ) +type testPCSCBackend struct{ readers []pcsc.Reader } + +func (backend testPCSCBackend) Readers(context.Context) ([]pcsc.Reader, error) { + return append([]pcsc.Reader(nil), backend.readers...), nil +} +func (testPCSCBackend) Open(context.Context, pcsc.Selector) (pcsc.Card, error) { + return nil, pcsc.ErrNoCard +} + +func TestManagerDiscoversWiFiCallingOnlyReaderWithoutATPort(t *testing.T) { + manager, err := NewManager(Options{ + Discoverer: staticDiscoverer{}, Opener: &staticOpener{}, + CardReaders: pcsc.NewWithBackend(testPCSCBackend{readers: []pcsc.Reader{{ + Name: "Alcor Link AK9563 00 00", USBPath: "1-3", Product: "AK9563", + }}}), + }) + if err != nil { + t.Fatal(err) + } + if err := manager.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = manager.Stop(context.Background()) }) + items := manager.List() + if len(items) != 1 || items[0].Candidate.HardwareKind != pcsc.HardwareKind || items[0].Candidate.HasATPort() { + t.Fatalf("discovered readers = %#v", items) + } + snapshot, err := manager.Refresh(context.Background(), items[0].ID) + if err != nil { + t.Fatal(err) + } + if !snapshot.Responsive || snapshot.SIMReady || snapshot.SIMStatus != "" || !snapshot.FlightMode { + t.Fatalf("reader snapshot = %#v", snapshot) + } +} + func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) { client := &transcriptClient{steps: []clientStep{ { diff --git a/internal/device/types.go b/internal/device/types.go index 2148272..6dbcfb7 100644 --- a/internal/device/types.go +++ b/internal/device/types.go @@ -11,6 +11,7 @@ var ( ErrNotStarted = errors.New("device manager is not started") ErrNotFound = errors.New("device not found") ErrNoATPort = errors.New("device has no usable AT port") + ErrUnsupportedCapability = errors.New("device does not support this capability") ErrSMSPromptUnsupported = errors.New("device AT client does not support SMS prompt mode") ErrSMSInvalidRecipient = errors.New("invalid SMS recipient") ErrSMSEmpty = errors.New("SMS text is empty") diff --git a/internal/modem/types.go b/internal/modem/types.go index 5f32e9d..5a50aa5 100644 --- a/internal/modem/types.go +++ b/internal/modem/types.go @@ -44,6 +44,8 @@ func (p Port) OpenPath() string { } type Candidate struct { + HardwareKind string `json:"hardwareKind,omitempty"` + ReaderName string `json:"readerName,omitempty"` ID string `json:"id"` VendorID string `json:"vendorId"` ProductID string `json:"productId"` diff --git a/internal/pcsc/backend_linux.go b/internal/pcsc/backend_linux.go new file mode 100644 index 0000000..97c955d --- /dev/null +++ b/internal/pcsc/backend_linux.go @@ -0,0 +1,272 @@ +//go:build linux && (amd64 || arm64) + +package pcsc + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + + "github.com/ElMostafaIdrassi/goscard" +) + +type nativeBackend struct { + initializeOnce sync.Once + initializeErr error +} + +func newNativeBackend() Backend { return &nativeBackend{} } + +func (backend *nativeBackend) initialize() error { + backend.initializeOnce.Do(func() { + if err := goscard.Initialize(goscard.NewDefaultLogger(goscard.LogLevelNone)); err != nil { + backend.initializeErr = fmt.Errorf("%w: pcsc-lite client library could not be loaded", ErrUnavailable) + } + }) + return backend.initializeErr +} + +func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := backend.initialize(); err != nil { + return nil, err + } + cardContext, _, err := goscard.NewContext(goscard.SCardScopeSystem, nil, nil) + if err != nil { + return nil, fmt.Errorf("%w: pcscd is not reachable", ErrUnavailable) + } + defer cardContext.Release() + names, _, err := cardContext.ListReaders(nil) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "no readers") { + return []Reader{}, nil + } + return nil, fmt.Errorf("pcsc: list readers: %w", err) + } + presentNames, atrs, _, _ := cardContext.ListReadersWithCardPresent(nil) + present := make(map[string]string, len(presentNames)) + for index, name := range presentNames { + atr := "" + if index < len(atrs) { + atr = atrs[index] + } + present[name] = atr + } + readers := make([]Reader, 0, len(names)) + for _, name := range names { + reader := Reader{Name: name} + reader.ATR, reader.CardPresent = present[name] + if path, ok := backend.readerUSBPath(cardContext, name); ok { + reader.USBPath = path + reader.VendorID = readSysfsText(path, "idVendor") + reader.ProductID = readSysfsText(path, "idProduct") + reader.Manufacturer = readSysfsText(path, "manufacturer") + reader.Product = readSysfsText(path, "product") + } else { + reader.USBPath = "pcsc:" + name + } + if reader.Product == "" { + reader.Product = strings.TrimSpace(strings.TrimSuffix(name, " 00 00")) + } + readers = append(readers, reader) + } + return readers, nil +} + +func (backend *nativeBackend) readerUSBPath(cardContext goscard.Context, name string) (string, bool) { + card, _, err := cardContext.Connect(name, goscard.SCardShareDirect, goscard.SCardProtocolT0|goscard.SCardProtocolT1) + if err != nil { + return "", false + } + defer card.Disconnect(goscard.SCardLeaveCard) + attribute, _, err := card.GetAttrib(goscard.SCardAttrChannelID) + if err != nil || len(attribute) < 4 { + return "", false + } + channel := binary.LittleEndian.Uint32(attribute[:4]) + if channel>>16 != 0x0020 { + return "", false + } + bus, device := int((channel>>8)&0xFF), int(channel&0xFF) + entries, err := os.ReadDir("/sys/bus/usb/devices") + if err != nil { + return "", false + } + for _, entry := range entries { + if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { + continue + } + path := filepath.Join("/sys/bus/usb/devices", entry.Name()) + entryBus, busErr := readSysfsInt(path, "busnum") + entryDevice, deviceErr := readSysfsInt(path, "devnum") + if busErr == nil && deviceErr == nil && entryBus == bus && entryDevice == device { + return entry.Name(), true + } + } + return "", false +} + +func (backend *nativeBackend) Open(ctx context.Context, selector Selector) (Card, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + readers, err := backend.Readers(ctx) + if err != nil { + return nil, err + } + reader, ok := matchReader(readers, selector) + if !ok { + return nil, ErrReaderNotFound + } + if !reader.CardPresent { + return nil, ErrNoCard + } + cardContext, _, err := goscard.NewContext(goscard.SCardScopeSystem, nil, nil) + if err != nil { + return nil, fmt.Errorf("%w: create context", ErrUnavailable) + } + card, _, err := cardContext.Connect(reader.Name, goscard.SCardShareShared, goscard.SCardProtocolT0|goscard.SCardProtocolT1) + if err != nil { + cardContext.Release() + return nil, fmt.Errorf("pcsc: connect reader: %w", err) + } + if _, err := card.BeginTransaction(); err != nil { + card.Disconnect(goscard.SCardLeaveCard) + cardContext.Release() + return nil, fmt.Errorf("pcsc: begin card transaction: %w", err) + } + return &nativeCard{context: &cardContext, card: &card}, nil +} + +type nativeCard struct { + context *goscard.Context + card *goscard.Card + closed bool +} + +func (card *nativeCard) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) { + if card == nil || card.card == nil || card.closed { + return nil, 0, errors.New("pcsc: card session is closed") + } + if err := ctx.Err(); err != nil { + return nil, 0, err + } + return card.transmit(ctx, append([]byte(nil), command...), 0) +} + +// TransmitRaw performs exactly one APDU exchange. Stateful eUICC callers need +// to observe 61xx themselves because GET RESPONSE must target their logical +// channel rather than the basic channel. +func (card *nativeCard) TransmitRaw(ctx context.Context, command []byte) ([]byte, uint16, error) { + if card == nil || card.card == nil || card.closed { + return nil, 0, errors.New("pcsc: card session is closed") + } + if err := ctx.Err(); err != nil { + return nil, 0, err + } + pci := goscard.SCardIoRequestT0 + if card.card.ActiveProtocol() == goscard.SCardProtocolT1 { + pci = goscard.SCardIoRequestT1 + } + response, _, err := card.card.Transmit(&pci, append([]byte(nil), command...), nil) + if err != nil { + return nil, 0, err + } + if len(response) < 2 { + return nil, 0, errors.New("pcsc: APDU response omitted its status word") + } + if err := ctx.Err(); err != nil { + return nil, 0, err + } + last := len(response) - 2 + return append([]byte(nil), response[:last]...), uint16(response[last])<<8 | uint16(response[last+1]), nil +} + +func (card *nativeCard) transmit(ctx context.Context, command []byte, depth int) ([]byte, uint16, error) { + if depth > 8 { + return nil, 0, errors.New("pcsc: too many APDU continuations") + } + pci := goscard.SCardIoRequestT0 + if card.card.ActiveProtocol() == goscard.SCardProtocolT1 { + pci = goscard.SCardIoRequestT1 + } + response, _, err := card.card.Transmit(&pci, command, nil) + if err != nil { + return nil, 0, err + } + if len(response) < 2 { + return nil, 0, errors.New("pcsc: APDU response omitted its status word") + } + data := append([]byte(nil), response[:len(response)-2]...) + sw1, sw2 := response[len(response)-2], response[len(response)-1] + if sw1 == 0x6C && len(command) >= 5 { + retry := append([]byte(nil), command...) + retry[len(retry)-1] = sw2 + return card.transmit(ctx, retry, depth+1) + } + if sw1 == 0x61 || sw1 == 0x9F { + more, sw, err := card.transmit(ctx, []byte{0x00, 0xC0, 0x00, 0x00, sw2}, depth+1) + if err != nil { + return nil, 0, err + } + return append(data, more...), sw, nil + } + if err := ctx.Err(); err != nil { + return nil, 0, err + } + return data, uint16(sw1)<<8 | uint16(sw2), nil +} + +func (card *nativeCard) Close() error { + return card.close(goscard.SCardLeaveCard) +} + +func (card *nativeCard) CloseWithReset() error { + return card.close(goscard.SCardResetCard) +} + +func (card *nativeCard) close(disposition goscard.SCardDisposition) error { + if card == nil || card.closed { + return nil + } + card.closed = true + var result []error + if card.card != nil { + if _, err := card.card.EndTransaction(disposition); err != nil { + result = append(result, err) + } + if _, err := card.card.Disconnect(disposition); err != nil { + result = append(result, err) + } + } + if card.context != nil { + if _, err := card.context.Release(); err != nil { + result = append(result, err) + } + } + return errors.Join(result...) +} + +func readSysfsText(usbPath, name string) string { + value, err := os.ReadFile(filepath.Join("/sys/bus/usb/devices", usbPath, name)) + if err != nil { + return "" + } + return strings.TrimSpace(string(value)) +} + +func readSysfsInt(path, name string) (int, error) { + value, err := os.ReadFile(filepath.Join(path, name)) + if err != nil { + return 0, err + } + return strconv.Atoi(strings.TrimSpace(string(value))) +} diff --git a/internal/pcsc/backend_stub.go b/internal/pcsc/backend_stub.go new file mode 100644 index 0000000..cdb0d5a --- /dev/null +++ b/internal/pcsc/backend_stub.go @@ -0,0 +1,17 @@ +//go:build !linux || (!amd64 && !arm64) + +package pcsc + +import "context" + +type unsupportedBackend struct{} + +func newNativeBackend() Backend { return unsupportedBackend{} } + +func (unsupportedBackend) Readers(context.Context) ([]Reader, error) { + return nil, ErrUnsupported +} + +func (unsupportedBackend) Open(context.Context, Selector) (Card, error) { + return nil, ErrUnsupported +} diff --git a/internal/pcsc/service.go b/internal/pcsc/service.go new file mode 100644 index 0000000..1af74d0 --- /dev/null +++ b/internal/pcsc/service.go @@ -0,0 +1,610 @@ +package pcsc + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" +) + +const usimAIDPrefix = "A0000000871002" + +type Service struct { + mu sync.Mutex + backend Backend +} + +// Session is an exclusive connection to one smart card. It is used by eUICC +// operations which must keep the same PC/SC transaction and logical channel +// alive across a sequence of APDUs. +type Session struct { + service *Service + card Card + closed bool +} + +func New() *Service { + return &Service{backend: newNativeBackend()} +} + +func NewWithBackend(backend Backend) *Service { + return &Service{backend: backend} +} + +func DeviceID(reader Reader) string { + identity := strings.TrimSpace(reader.USBPath) + if identity == "" { + identity = strings.TrimSpace(reader.Name) + } + sum := sha256.Sum256([]byte(identity)) + return "reader-" + hex.EncodeToString(sum[:8]) +} + +func (service *Service) Readers(ctx context.Context) ([]Reader, error) { + if service == nil || service.backend == nil { + return nil, ErrUnavailable + } + service.mu.Lock() + defer service.mu.Unlock() + return service.backend.Readers(ctx) +} + +// OpenSession opens one card and holds the service lock until Close. Callers +// must close the returned session; this prevents AKA/identity reads from +// interleaving with a stateful ES10 transaction. +func (service *Service) OpenSession(ctx context.Context, selector Selector) (*Session, error) { + if service == nil || service.backend == nil { + return nil, ErrUnavailable + } + if err := selector.validate(); err != nil { + return nil, err + } + service.mu.Lock() + card, err := service.backend.Open(ctx, selector) + if err != nil { + service.mu.Unlock() + return nil, err + } + return &Session{service: service, card: card}, nil +} + +// Transmit sends one raw APDU. Unlike the ordinary SIM helpers, it leaves +// 61xx continuation handling to the eUICC logical-channel implementation so +// GET RESPONSE uses the correct channel CLA. +func (session *Session) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) { + if session == nil || session.card == nil || session.closed { + return nil, 0, errors.New("pcsc: card session is closed") + } + if raw, ok := session.card.(interface { + TransmitRaw(context.Context, []byte) ([]byte, uint16, error) + }); ok { + return raw.TransmitRaw(ctx, command) + } + return session.card.Transmit(ctx, command) +} + +func (session *Session) Close() error { + return session.close(false) +} + +// CloseWithReset resets the card while releasing the PC/SC connection. eUICC +// EnableProfile requires this refresh boundary before the newly enabled USIM +// application and ICCID become visible to subsequent callers. +func (session *Session) CloseWithReset() error { + return session.close(true) +} + +func (session *Session) close(reset bool) error { + if session == nil || session.closed { + return nil + } + session.closed = true + var err error + if resetter, ok := session.card.(interface{ CloseWithReset() error }); reset && ok { + err = resetter.CloseWithReset() + } else { + err = session.card.Close() + } + session.service.mu.Unlock() + return err +} + +func (service *Service) Snapshot(ctx context.Context, selector Selector, pin string) (Snapshot, error) { + readers, err := service.Readers(ctx) + if err != nil { + return Snapshot{}, err + } + reader, ok := matchReader(readers, selector) + if !ok { + return Snapshot{}, ErrReaderNotFound + } + result := Snapshot{Reader: reader} + if !reader.CardPresent { + return result, ErrNoCard + } + identity, err := service.ReadIdentity(ctx, selector, pin) + result.Identity = identity + return result, err +} + +func (service *Service) ReadIdentity(ctx context.Context, selector Selector, pin string) (Identity, error) { + if service == nil || service.backend == nil { + return Identity{}, ErrUnavailable + } + if err := selector.validate(); err != nil { + return Identity{}, err + } + service.mu.Lock() + defer service.mu.Unlock() + card, err := service.backend.Open(ctx, selector) + if err != nil { + return Identity{}, err + } + defer card.Close() + return readIdentity(ctx, card, pin) +} + +func (service *Service) CheckReady( + ctx context.Context, + selector Selector, + expectedICCID string, + pin string, +) (string, error) { + if service == nil || service.backend == nil { + return "", ErrUnavailable + } + service.mu.Lock() + defer service.mu.Unlock() + card, err := service.backend.Open(ctx, selector) + if err != nil { + return "", err + } + defer card.Close() + iccid, err := readICCID(ctx, card) + if err != nil { + return "", err + } + if expected := strings.TrimSpace(expectedICCID); expected != "" && !strings.EqualFold(expected, iccid) { + return "", ErrCardChanged + } + aid, err := selectUSIM(ctx, card) + if err != nil { + return "", err + } + if err := verifyPIN(ctx, card, pin); err != nil { + return "", err + } + return strings.ToUpper(hex.EncodeToString(aid)), nil +} + +func (service *Service) Authenticate( + ctx context.Context, + selector Selector, + expectedICCID string, + pin string, + challenge AKAChallenge, +) (AKAResult, error) { + if service == nil || service.backend == nil { + return AKAResult{}, ErrUnavailable + } + service.mu.Lock() + defer service.mu.Unlock() + card, err := service.backend.Open(ctx, selector) + if err != nil { + return AKAResult{}, err + } + defer card.Close() + iccid, err := readICCID(ctx, card) + if err != nil { + return AKAResult{}, err + } + if expected := strings.TrimSpace(expectedICCID); expected != "" && !strings.EqualFold(expected, iccid) { + return AKAResult{}, ErrCardChanged + } + if _, err := selectUSIM(ctx, card); err != nil { + return AKAResult{}, err + } + if err := verifyPIN(ctx, card, pin); err != nil { + return AKAResult{}, err + } + 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) + data, sw, err := card.Transmit(ctx, apdu) + if err != nil { + return AKAResult{}, errors.New("pcsc: USIM authentication transport failed") + } + if sw == 0x9862 { + return AKAResult{}, ErrAKARejected + } + if sw != 0x9000 { + return AKAResult{}, fmt.Errorf("pcsc: USIM authentication failed with status %04X", sw) + } + return parseAKAResponse(data) +} + +func matchReader(readers []Reader, selector Selector) (Reader, bool) { + path := strings.TrimSpace(selector.USBPath) + name := strings.TrimSpace(selector.ReaderName) + for _, reader := range readers { + if path != "" && reader.USBPath == path { + return reader, true + } + } + for _, reader := range readers { + if name != "" && reader.Name == name { + return reader, true + } + } + return Reader{}, false +} + +func readIdentity(ctx context.Context, card Card, pin string) (Identity, error) { + identity := Identity{PINTries: -1} + iccid, err := readICCID(ctx, card) + if err != nil { + return identity, err + } + identity.ICCID = iccid + aid, err := selectUSIM(ctx, card) + if err != nil { + return identity, err + } + identity.USIMAID = append([]byte(nil), aid...) + if err := verifyPIN(ctx, card, pin); err != nil { + identity.PINRequired = errors.Is(err, ErrPINRequired) || errors.Is(err, ErrPINTriesLow) + var pinErr *PINError + if errors.As(err, &pinErr) { + identity.PINTries = pinErr.Tries + } + return identity, err + } + if err := selectFile(ctx, card, []byte{0x6F, 0x07}); err != nil { + return identity, fmt.Errorf("pcsc: select EF_IMSI: %w", err) + } + imsiData, err := readBinary(ctx, card, 9) + if err != nil { + return identity, fmt.Errorf("pcsc: read EF_IMSI: %w", err) + } + identity.IMSI, err = decodeIMSI(imsiData) + if err != nil { + return identity, err + } + if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil { + if selectErr = selectFile(ctx, card, []byte{0x6F, 0xAD}); selectErr == nil { + if data, readErr := readBinary(ctx, card, 4); readErr == nil && len(data) >= 4 { + length := int(data[3] & 0x0f) + if length == 2 || length == 3 { + identity.MNCLength = length + } + } + } + } + if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil { + identity.SPN = readSPN(ctx, card) + } + if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil { + identity.SMSC = readSMSC(ctx, card) + } + return identity, nil +} + +func readICCID(ctx context.Context, card Card) (string, error) { + if err := selectMF(ctx, card); err != nil { + return "", err + } + if err := selectFile(ctx, card, []byte{0x2F, 0xE2}); err != nil { + return "", fmt.Errorf("pcsc: select EF_ICCID: %w", err) + } + data, err := readBinary(ctx, card, 10) + if err != nil { + return "", fmt.Errorf("pcsc: read EF_ICCID: %w", err) + } + value := decodeSwappedBCD(data, false) + if len(value) < 18 || len(value) > 22 { + return "", errors.New("pcsc: card returned an invalid ICCID") + } + return value, nil +} + +func selectUSIM(ctx context.Context, card Card) ([]byte, error) { + if err := selectMF(ctx, card); err != nil { + return nil, err + } + if err := selectFile(ctx, card, []byte{0x2F, 0x00}); err != nil { + return nil, fmt.Errorf("pcsc: select EF_DIR: %w", err) + } + var usimAID []byte + for record := 1; record <= 32; record++ { + data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB2, byte(record), 0x04, 0x00}) + if err != nil { + return nil, err + } + if sw == 0x6A83 || sw == 0x9402 { + break + } + if sw != 0x9000 { + continue + } + aid := findTLV(data, 0x4F) + if len(aid) == 0 { + continue + } + if strings.HasPrefix(strings.ToUpper(hex.EncodeToString(aid)), usimAIDPrefix) { + usimAID = append([]byte(nil), aid...) + break + } + } + if len(usimAID) == 0 { + return nil, ErrUSIMUnavailable + } + if _, err := selectApplication(ctx, card, usimAID); err != nil { + return nil, err + } + return usimAID, nil +} + +func selectMF(ctx context.Context, card Card) error { + _, sw, err := card.Transmit(ctx, []byte{0x00, 0xA4, 0x00, 0x04, 0x02, 0x3F, 0x00, 0x00}) + return requireStatus("select MF", sw, err) +} + +func selectFile(ctx context.Context, card Card, fileID []byte) error { + if len(fileID) != 2 { + return errors.New("pcsc: invalid file identifier") + } + apdu := []byte{0x00, 0xA4, 0x00, 0x04, 0x02, fileID[0], fileID[1], 0x00} + _, sw, err := card.Transmit(ctx, apdu) + return requireStatus("select file", sw, err) +} + +func selectApplication(ctx context.Context, card Card, aid []byte) ([]byte, error) { + if len(aid) == 0 || len(aid) > 32 { + return nil, errors.New("pcsc: invalid USIM AID") + } + apdu := []byte{0x00, 0xA4, 0x04, 0x04, byte(len(aid))} + apdu = append(apdu, aid...) + apdu = append(apdu, 0x00) + data, sw, err := card.Transmit(ctx, apdu) + if err := requireStatus("select USIM application", sw, err); err != nil { + return nil, err + } + return data, nil +} + +func readBinary(ctx context.Context, card Card, length int) ([]byte, error) { + if length <= 0 || length > 256 { + return nil, errors.New("pcsc: invalid binary read length") + } + le := byte(length) + if length == 256 { + le = 0 + } + data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB0, 0x00, 0x00, le}) + if err := requireStatus("read binary", sw, err); err != nil { + return nil, err + } + return data, nil +} + +func verifyPIN(ctx context.Context, card Card, pin string) error { + pin = strings.TrimSpace(pin) + if pin == "" { + return nil + } + if len(pin) < 4 || len(pin) > 8 || !decimalDigits(pin) { + return errors.New("pcsc: SIM PIN must contain 4 to 8 digits") + } + _, sw, err := card.Transmit(ctx, []byte{0x00, 0x20, 0x00, 0x01, 0x00}) + if err != nil { + return errors.New("pcsc: SIM PIN status check failed") + } + if sw == 0x9000 { + return nil + } + tries := -1 + if sw&0xFFF0 == 0x63C0 { + tries = int(sw & 0x000F) + if tries <= 2 { + return &PINError{Kind: ErrPINTriesLow, Tries: tries} + } + } + body := bytes.Repeat([]byte{0xFF}, 8) + copy(body, []byte(pin)) + apdu := append([]byte{0x00, 0x20, 0x00, 0x01, 0x08}, body...) + _, sw, err = card.Transmit(ctx, apdu) + if err != nil { + return errors.New("pcsc: SIM PIN verification transport failed") + } + if sw == 0x9000 { + return nil + } + if sw&0xFFF0 == 0x63C0 { + return &PINError{Kind: ErrPINRejected, Tries: int(sw & 0x000F)} + } + return ErrPINRejected +} + +func requireStatus(operation string, sw uint16, err error) error { + if err != nil { + return fmt.Errorf("pcsc: %s transport failed", operation) + } + if sw == 0x9000 { + return nil + } + if sw == 0x6982 || sw == 0x9804 { + return &PINError{Kind: ErrPINRequired, Tries: -1} + } + return fmt.Errorf("pcsc: %s failed with status %04X", operation, sw) +} + +func decodeSwappedBCD(value []byte, dropFirstNibble bool) string { + var result strings.Builder + for _, octet := range value { + for _, nibble := range []byte{octet & 0x0F, octet >> 4} { + if dropFirstNibble { + dropFirstNibble = false + continue + } + if nibble == 0x0F { + return result.String() + } + if nibble > 9 { + return "" + } + result.WriteByte('0' + nibble) + } + } + return result.String() +} + +func decodeIMSI(data []byte) (string, error) { + if len(data) < 2 { + return "", errors.New("pcsc: EF_IMSI is too short") + } + length := int(data[0]) + if length <= 0 || length > len(data)-1 { + return "", errors.New("pcsc: EF_IMSI has an invalid length") + } + value := decodeSwappedBCD(data[1:1+length], true) + if len(value) < 10 || len(value) > 18 || !decimalDigits(value) { + return "", errors.New("pcsc: card returned an invalid IMSI") + } + return value, nil +} + +func decimalDigits(value string) bool { + if value == "" { + return false + } + for _, character := range value { + if character < '0' || character > '9' { + return false + } + } + return true +} + +func findTLV(data []byte, wanted byte) []byte { + for len(data) >= 2 { + tag := data[0] + data = data[1:] + length, consumed, ok := decodeTLVLength(data) + if !ok || consumed+length > len(data) { + return nil + } + value := data[consumed : consumed+length] + if tag == wanted { + return append([]byte(nil), value...) + } + if tag&0x20 != 0 { + if nested := findTLV(value, wanted); len(nested) > 0 { + return nested + } + } + data = data[consumed+length:] + } + return nil +} + +func decodeTLVLength(data []byte) (length, consumed int, ok bool) { + if len(data) == 0 { + return 0, 0, false + } + if data[0]&0x80 == 0 { + return int(data[0]), 1, true + } + count := int(data[0] & 0x7F) + if count < 1 || count > 2 || len(data) < 1+count { + return 0, 0, false + } + length = 0 + for _, octet := range data[1 : 1+count] { + length = length<<8 | int(octet) + } + return length, 1 + count, true +} + +func parseAKAResponse(data []byte) (AKAResult, error) { + if len(data) < 2 { + return AKAResult{}, errors.New("pcsc: USIM returned a short AKA response") + } + switch data[0] { + case 0xDB: + res, rest, ok := takeLV(data[1:]) + if !ok || len(res) < 4 || len(res) > 16 { + return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA RES") + } + ck, rest, ok := takeLV(rest) + if !ok || len(ck) != 16 { + return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA CK") + } + ik, rest, ok := takeLV(rest) + if !ok || len(ik) != 16 { + return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA IK") + } + if len(rest) > 0 { + kc, tail, valid := takeLV(rest) + if !valid || len(kc) != 8 || len(tail) != 0 { + return AKAResult{}, errors.New("pcsc: USIM returned invalid trailing AKA material") + } + } + return AKAResult{RES: append([]byte(nil), res...), CK: append([]byte(nil), ck...), IK: append([]byte(nil), ik...)}, nil + case 0xDC: + auts, tail, ok := takeLV(data[1:]) + if !ok || len(auts) != 14 || len(tail) != 0 { + return AKAResult{}, errors.New("pcsc: USIM returned invalid AKA synchronization evidence") + } + return AKAResult{AUTS: append([]byte(nil), auts...), SynchronizationFailure: true}, nil + default: + return AKAResult{}, errors.New("pcsc: USIM returned an unsupported AKA response") + } +} + +func takeLV(data []byte) (value, rest []byte, ok bool) { + if len(data) == 0 || int(data[0]) > len(data)-1 { + return nil, data, false + } + length := int(data[0]) + return data[1 : 1+length], data[1+length:], true +} + +func readSPN(ctx context.Context, card Card) string { + if err := selectFile(ctx, card, []byte{0x6F, 0x46}); err != nil { + return "" + } + data, err := readBinary(ctx, card, 17) + if err != nil || len(data) < 2 { + return "" + } + return strings.TrimSpace(strings.TrimRight(string(data[1:]), "\x00\xFF")) +} + +func readSMSC(ctx context.Context, card Card) string { + if err := selectFile(ctx, card, []byte{0x6F, 0x42}); err != nil { + return "" + } + data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB2, 0x01, 0x04, 0x00}) + if err != nil || sw != 0x9000 || len(data) < 15 { + return "" + } + sca := data[len(data)-15 : len(data)-3] + if len(sca) < 2 || sca[0] < 2 || int(sca[0]) > len(sca)-1 { + return "" + } + digits := decodeSwappedBCD(sca[2:1+int(sca[0])], false) + if !decimalDigits(digits) { + return "" + } + if sca[1]&0x70 == 0x10 { + return "+" + digits + } + return digits +} diff --git a/internal/pcsc/service_test.go b/internal/pcsc/service_test.go new file mode 100644 index 0000000..0a99488 --- /dev/null +++ b/internal/pcsc/service_test.go @@ -0,0 +1,84 @@ +package pcsc + +import ( + "bytes" + "context" + "errors" + "testing" +) + +type scriptedReply struct { + data []byte + sw uint16 +} + +type scriptedCard struct { + replies []scriptedReply + calls [][]byte +} + +func (card *scriptedCard) Transmit(_ context.Context, command []byte) ([]byte, uint16, error) { + card.calls = append(card.calls, append([]byte(nil), command...)) + if len(card.replies) == 0 { + return nil, 0, errors.New("unexpected APDU") + } + reply := card.replies[0] + card.replies = card.replies[1:] + return append([]byte(nil), reply.data...), reply.sw, nil +} + +func (*scriptedCard) Close() error { return nil } + +func TestDecodeIdentifiers(t *testing.T) { + if got := decodeSwappedBCD([]byte{0x98, 0x10, 0x32, 0x54, 0xF6}, false); got != "890123456" { + t.Fatalf("ICCID BCD = %q", got) + } + imsi, err := decodeIMSI([]byte{0x08, 0x19, 0x32, 0x54, 0x76, 0x98, 0x10, 0x32, 0x54}) + if err != nil { + t.Fatal(err) + } + if imsi != "123456789012345" { + t.Fatalf("IMSI = %q", imsi) + } +} + +func TestVerifyPINRefusesLowAttemptCount(t *testing.T) { + card := &scriptedCard{replies: []scriptedReply{{sw: 0x63C2}}} + err := verifyPIN(context.Background(), card, "1234") + if !errors.Is(err, ErrPINTriesLow) { + t.Fatalf("error = %v", err) + } + if len(card.calls) != 1 { + t.Fatalf("APDU calls = %d, PIN must not be submitted", len(card.calls)) + } +} + +func TestParseAKAResponse(t *testing.T) { + data := []byte{0xDB, 0x08, 1, 2, 3, 4, 5, 6, 7, 8, 0x10} + data = append(data, bytes.Repeat([]byte{0xAA}, 16)...) + data = append(data, 0x10) + data = append(data, bytes.Repeat([]byte{0xBB}, 16)...) + result, err := parseAKAResponse(data) + if err != nil { + t.Fatal(err) + } + if len(result.RES) != 8 || len(result.CK) != 16 || len(result.IK) != 16 || result.SynchronizationFailure { + t.Fatalf("unexpected AKA result: %#v", result) + } + + syncResult, err := parseAKAResponse(append([]byte{0xDC, 0x0E}, bytes.Repeat([]byte{0xCC}, 14)...)) + if err != nil { + t.Fatal(err) + } + if !syncResult.SynchronizationFailure || len(syncResult.AUTS) != 14 { + t.Fatalf("unexpected sync result: %#v", syncResult) + } +} + +func TestDeviceIDUsesStableUSBPath(t *testing.T) { + a := DeviceID(Reader{Name: "reader 00 00", USBPath: "1-3"}) + b := DeviceID(Reader{Name: "renamed reader", USBPath: "1-3"}) + if a != b || a == "" { + t.Fatalf("device IDs = %q, %q", a, b) + } +} diff --git a/internal/pcsc/types.go b/internal/pcsc/types.go new file mode 100644 index 0000000..500ba66 --- /dev/null +++ b/internal/pcsc/types.go @@ -0,0 +1,107 @@ +package pcsc + +import ( + "context" + "errors" + "fmt" + "strings" +) + +const HardwareKind = "pcsc" + +var ( + ErrUnsupported = errors.New("pcsc: platform is not supported") + ErrUnavailable = errors.New("pcsc: service is unavailable") + ErrReaderNotFound = errors.New("pcsc: reader not found") + ErrNoCard = errors.New("pcsc: no card is inserted") + ErrPINRequired = errors.New("pcsc: SIM PIN is required") + ErrPINTriesLow = errors.New("pcsc: refusing PIN verification because too few attempts remain") + ErrPINRejected = errors.New("pcsc: SIM PIN was rejected") + ErrUSIMUnavailable = errors.New("pcsc: no usable USIM application was found") + ErrCardChanged = errors.New("pcsc: card identity changed during authentication") + ErrAKARejected = errors.New("pcsc: USIM rejected the network authentication token") +) + +type Reader struct { + Name string + USBPath string + VendorID string + ProductID string + Manufacturer string + Product string + CardPresent bool + ATR string +} + +type Selector struct { + USBPath string + ReaderName string +} + +func (selector Selector) validate() error { + if strings.TrimSpace(selector.USBPath) == "" && strings.TrimSpace(selector.ReaderName) == "" { + return errors.New("pcsc: reader selector is empty") + } + return nil +} + +type Identity struct { + ICCID string + IMSI string + MNCLength int + USIMAID []byte + SMSC string + SPN string + PINRequired bool + PINTries int +} + +type Snapshot struct { + Reader Reader + Identity Identity +} + +type AKAChallenge struct { + RAND [16]byte + AUTN [16]byte +} + +type AKAResult struct { + RES []byte + CK []byte + IK []byte + AUTS []byte + SynchronizationFailure bool +} + +type PINError struct { + Kind error + Tries int +} + +func (err *PINError) Error() string { + if err == nil { + return "pcsc: SIM PIN error" + } + if err.Tries >= 0 { + return fmt.Sprintf("%v (%d attempts remain)", err.Kind, err.Tries) + } + return err.Kind.Error() +} + +func (err *PINError) Unwrap() error { + if err == nil { + return nil + } + return err.Kind +} + +type Card interface { + Transmit(context.Context, []byte) ([]byte, uint16, error) + Close() error +} + +type Backend interface { + Readers(context.Context) ([]Reader, error) + Open(context.Context, Selector) (Card, error) +} diff --git a/internal/server/automatic_tasks.go b/internal/server/automatic_tasks.go index 6654c0a..e9d6b13 100644 --- a/internal/server/automatic_tasks.go +++ b/internal/server/automatic_tasks.go @@ -231,6 +231,9 @@ func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.Auto if err != nil { return store.Device{}, device.Device{}, "", fmt.Errorf("read device: %w", err) } + if err := validateAutomaticTaskDeviceCapabilities(config, task.TaskType, task.Environment); err != nil { + return store.Device{}, device.Device{}, "", err + } entry, physicalID, present := s.physicalForConfig(config) if !present || entry.Snapshot == nil { return store.Device{}, device.Device{}, "", errors.New("configured device is offline") @@ -511,6 +514,25 @@ func (s *Server) restoreAutomaticTaskEnvironment(physicalID string, snapshot aut if err := s.store.UpsertDevice(cleanupContext, config); err != nil { restoreErrors = append(restoreErrors, fmt.Errorf("persist device policy: %w", err)) } + if config.DeviceType == store.DeviceTypeUSBSIMReader { + if s.vowifi == nil { + return errors.Join(append(restoreErrors, errors.New("VoWiFi runtime is unavailable"))...) + } + state, stateErr := s.vowifi.State(config.ID) + if policy.VoWiFiEnabled { + if stateErr == nil && state.Enabled { + _, stateErr = s.vowifi.RequestReconnect(config.ID) + } else { + _, stateErr = s.vowifi.RequestEnabled(config.ID, true) + } + } else if stateErr == nil && (state.Enabled || state.Active) { + _, stateErr = s.vowifi.RequestEnabled(config.ID, false) + } + if stateErr != nil { + restoreErrors = append(restoreErrors, fmt.Errorf("restore reader VoWiFi: %w", stateErr)) + } + return errors.Join(restoreErrors...) + } if policy.VoWiFiEnabled { if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil { @@ -710,6 +732,15 @@ func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Reques s.writeStoreError(w, err) return } + config, err := s.store.Device(r.Context(), task.DeviceID) + if err != nil { + s.writeStoreError(w, err) + return + } + if err := validateAutomaticTaskDeviceCapabilities(config, task.TaskType, task.Environment); err != nil { + writeError(w, http.StatusConflict, "wifi_calling_only_device", err.Error()) + return + } run, err := s.store.QueueAutomaticTaskNow(r.Context(), task) if err != nil { s.writeStoreError(w, err) @@ -745,7 +776,8 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic if request.Name == "" || request.DeviceID == "" || request.ProfileICCID == "" { return store.AutomaticTask{}, errors.New("name, device, and eSIM profile are required") } - if _, err := s.store.Device(r.Context(), request.DeviceID); err != nil { + selectedDevice, err := s.store.Device(r.Context(), request.DeviceID) + if err != nil { return store.AutomaticTask{}, errors.New("selected device does not exist") } if request.Environment != "vowifi" && request.Environment != "cellular" { @@ -757,6 +789,9 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic if request.TaskType == "public_ip" && request.Environment != "cellular" { return store.AutomaticTask{}, errors.New("public IP tasks must use cellular direct mode") } + if err := validateAutomaticTaskDeviceCapabilities(selectedDevice, request.TaskType, request.Environment); err != nil { + return store.AutomaticTask{}, err + } if request.IntervalDays < 1 || request.IntervalDays > 365 || request.RetryCount < 0 || request.RetryCount > 10 { return store.AutomaticTask{}, errors.New("interval_days must be 1-365 and retry_count must be 0-10") } @@ -796,6 +831,19 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic return task, nil } +func validateAutomaticTaskDeviceCapabilities(config store.Device, taskType, environment string) error { + if config.DeviceType != store.DeviceTypeUSBSIMReader { + return nil + } + if environment != "vowifi" { + return errors.New("USB SIM reader tasks must use the VoWiFi environment") + } + if taskType != "sms" && taskType != "call" { + return errors.New("USB SIM readers support only VoWiFi SMS and call tasks") + } + return nil +} + func nextAutomaticRun(date, clock string, intervalDays int, now time.Time) (time.Time, error) { location := now.Location() start, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(date)+" "+strings.TrimSpace(clock), location) diff --git a/internal/server/automatic_tasks_test.go b/internal/server/automatic_tasks_test.go index 9d48971..230fa69 100644 --- a/internal/server/automatic_tasks_test.go +++ b/internal/server/automatic_tasks_test.go @@ -3,6 +3,8 @@ package server import ( "testing" "time" + + "vocat/internal/store" ) func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) { @@ -18,6 +20,25 @@ func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) { } } +func TestUSBSIMReaderAutomaticTasksRequireVoWiFi(t *testing.T) { + reader := store.Device{DeviceType: store.DeviceTypeUSBSIMReader} + for _, test := range []struct { + taskType string + environment string + wantError bool + }{ + {taskType: "sms", environment: "vowifi"}, + {taskType: "call", environment: "vowifi"}, + {taskType: "sms", environment: "cellular", wantError: true}, + {taskType: "public_ip", environment: "cellular", wantError: true}, + } { + err := validateAutomaticTaskDeviceCapabilities(reader, test.taskType, test.environment) + if (err != nil) != test.wantError { + t.Errorf("type=%s environment=%s error=%v", test.taskType, test.environment, err) + } + } +} + func TestAutomaticSMSRetrySafetyPreventsDuplicateSubmission(t *testing.T) { unsafe := []byte(`{"data":{"parts_attempted":1,"parts_accepted":1,"retry_safe":false}}`) if automaticSMSRetrySafe(unsafe) { diff --git a/internal/server/device_api.go b/internal/server/device_api.go index 1af05b8..9361ce0 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -66,6 +66,7 @@ type deviceConfigPayload struct { USBPath string `json:"usb_path"` AudioDevice string `json:"audio_device"` ModemIMEI string `json:"modem_imei"` + SIMPIN string `json:"sim_pin"` APN string `json:"apn"` ProxyPort int `json:"proxy_port"` BaudRate int `json:"baud_rate"` @@ -97,6 +98,7 @@ func (payload deviceConfigPayload) toStoreDevice() store.Device { USBPath: strings.TrimSpace(payload.USBPath), AudioDevice: strings.TrimSpace(payload.AudioDevice), ModemIMEI: strings.TrimSpace(payload.ModemIMEI), + SIMPIN: strings.TrimSpace(payload.SIMPIN), APN: strings.TrimSpace(payload.APN), ProxyPort: payload.ProxyPort, BaudRate: payload.BaudRate, @@ -246,6 +248,12 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool { config.NetworkEnabled = false } fillConfigFromPhysical(&config, *selected) + if pinSetter, ok := s.devices.(interface{ SetSIMPin(string, string) error }); ok { + if err := pinSetter.SetSIMPin(selected.ID, config.SIMPIN); err != nil { + s.writeDeviceError(w, err) + return true + } + } if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok { if err := selector.SetBackend(selected.ID, config.DeviceBackend); err != nil { s.writeDeviceError(w, err) @@ -363,6 +371,8 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request) } } result = append(result, map[string]any{ + "hardware_kind": candidate.HardwareKind, + "reader_name": candidate.ReaderName, "discovery_key": entry.ID, "control_path": controlPath, "net_interface": candidate.NetworkInterface, @@ -374,10 +384,10 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request) "at_port": candidate.ATPort.OpenPath(), "imei": snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMEI }), "mode": backendMode(candidate), - "network_capable": candidate.NetworkInterface != "" || candidate.QMIControl != "", + "network_capable": candidate.HardwareKind != "pcsc" && (candidate.NetworkInterface != "" || candidate.QMIControl != ""), "configured": configuredID != "", "configured_id": configuredID, - "degraded": !candidate.HasATPort(), + "degraded": candidate.HardwareKind != "pcsc" && !candidate.HasATPort(), }) } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"devices": result}}) @@ -459,7 +469,16 @@ func (s *Server) handleDevicePath( if next.Name == id && strings.TrimSpace(payload.Name) == "" { next.Name = config.Name } + if next.SIMPIN == "" || next.SIMPIN == store.SecretMask { + next.SIMPIN = config.SIMPIN + } if _, physicalID, present := s.physicalForConfig(next); present { + if pinSetter, ok := s.devices.(interface{ SetSIMPin(string, string) error }); ok { + if err := pinSetter.SetSIMPin(physicalID, next.SIMPIN); err != nil { + s.writeDeviceError(w, err) + return true + } + } if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok { if err := selector.SetBackend(physicalID, next.DeviceBackend); err != nil { s.writeDeviceError(w, err) @@ -482,6 +501,16 @@ func (s *Server) handleDevicePath( } entry, physicalID, physicalPresent := s.physicalForConfig(config) + if config.DeviceType == store.DeviceTypeUSBSIMReader && len(tail) > 0 { + operation := strings.Join(tail, "/") + unsupported := tail[0] == "network" || tail[0] == "operator_selection" || + operation == "actions/at" || operation == "actions/ussd" || operation == "actions/ussd/continue" || + operation == "actions/ussd/cancel" || operation == "actions/reboot" || operation == "usbnet-mode" + if unsupported { + writeError(w, http.StatusConflict, "wifi_calling_only_device", "USB SIM readers support WiFi Calling, IMS SMS and calls only") + return true + } + } if len(tail) > 0 && tail[0] == "esim" { return s.handleESIM(w, r, tail[1:], physicalID, physicalPresent, config.ID) } @@ -1803,6 +1832,10 @@ func deviceStatus(entry device.Device) map[string]any { } func storedDeviceConfig(config store.Device) map[string]any { + simPIN := "" + if strings.TrimSpace(config.SIMPIN) != "" { + simPIN = store.SecretMask + } return map[string]any{ "id": config.ID, "name": config.Name, @@ -1813,6 +1846,7 @@ func storedDeviceConfig(config store.Device) map[string]any { "usb_path": config.USBPath, "audio_device": config.AudioDevice, "modem_imei": config.ModemIMEI, + "sim_pin": simPIN, "apn": config.APN, "proxy_port": config.ProxyPort, "baud_rate": config.BaudRate, @@ -1832,6 +1866,17 @@ func storedDeviceConfig(config store.Device) map[string]any { func fillConfigFromPhysical(config *store.Device, entry device.Device) { candidate := entry.Candidate + if candidate.HardwareKind == "pcsc" { + config.DeviceType = store.DeviceTypeUSBSIMReader + config.ControlDevice = candidate.ReaderName + config.ATPort = "" + config.Interface = "" + config.DeviceBackend = "pcsc" + config.ESIMTransport = "pcsc" + config.NetworkEnabled = false + config.SMSEnabled = true + config.VoWiFiEnabled = true + } if config.Interface == "" { config.Interface = candidate.NetworkInterface } @@ -1914,13 +1959,29 @@ func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) m "reg_status": snapshot.RegistrationStatus, "reg_status_text": registrationText(snapshot), "ps_attached": snapshot.PSAttached, - "sim_inserted": snapshot.SIMStatus != "", + "sim_inserted": snapshotHasSIM(snapshot), "operating_mode": snapshot.OperatingMode, "phone_number": phone, "phone_number_source": phoneSource, } } +func snapshotHasSIM(snapshot *device.Snapshot) bool { + if snapshot == nil { + return false + } + if snapshot.SIMReady || strings.TrimSpace(snapshot.ICCID) != "" || strings.TrimSpace(snapshot.IMSI) != "" { + return true + } + switch strings.ToLower(strings.TrimSpace(snapshot.SIMStatus)) { + case "", "unknown", "not_inserted", "not inserted", "absent": + return false + default: + // PIN/PUK and other explicit UICC states prove that a card is present. + return true + } +} + func idleVoWiFiRuntime(id string, snapshot *device.Snapshot) map[string]any { iccid := "" imsi := "" @@ -1970,6 +2031,9 @@ func deviceName(entry device.Device) string { } func backendMode(candidate modem.Candidate) string { + if candidate.HardwareKind == "pcsc" { + return "pcsc" + } if candidate.QMIControl != "" { return "qmi" } diff --git a/internal/server/device_summary_test.go b/internal/server/device_summary_test.go index dcbe56a..bf52e3c 100644 --- a/internal/server/device_summary_test.go +++ b/internal/server/device_summary_test.go @@ -115,3 +115,24 @@ func TestConfiguredDeviceSummaryMarksIdleRuntimeAsNotInUse(t *testing.T) { t.Fatalf("summary = %#v", got) } } + +func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) { + for _, snapshot := range []*device.Snapshot{ + {IMEI: "867123456789012"}, + {IMEI: "867123456789012", SIMStatus: "unknown"}, + {IMEI: "867123456789012", SIMStatus: "not_inserted"}, + } { + if snapshotHasSIM(snapshot) { + t.Fatalf("snapshot was reported with a SIM: %#v", snapshot) + } + } + for _, snapshot := range []*device.Snapshot{ + {SIMStatus: "pin_required"}, + {ICCID: "89441000400128014257"}, + {SIMReady: true}, + } { + if !snapshotHasSIM(snapshot) { + t.Fatalf("snapshot was reported without a SIM: %#v", snapshot) + } + } +} diff --git a/internal/server/export_proxy_api.go b/internal/server/export_proxy_api.go index 19cae8d..ae3d125 100644 --- a/internal/server/export_proxy_api.go +++ b/internal/server/export_proxy_api.go @@ -1,11 +1,13 @@ package server import ( + "context" "errors" "net/http" "strings" "vocat/internal/exportproxy" + "vocat/internal/store" ) func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool { @@ -33,6 +35,9 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return true } + if s.rejectUnsupportedExportProxyDevice(w, r.Context(), config.DeviceID) { + return true + } created, err := s.exportProxy.Create(r.Context(), config) if err != nil { s.writeExportProxyError(w, err) @@ -71,6 +76,9 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return true } + if s.rejectUnsupportedExportProxyDevice(w, r.Context(), config.DeviceID) { + return true + } updated, err := s.exportProxy.Update(r.Context(), id, config) if err != nil { s.writeExportProxyError(w, err) @@ -90,6 +98,19 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle return true } +func (s *Server) rejectUnsupportedExportProxyDevice(w http.ResponseWriter, ctx context.Context, deviceID string) bool { + config, err := s.store.Device(ctx, strings.TrimSpace(deviceID)) + if err != nil { + s.writeStoreError(w, err) + return true + } + if config.DeviceType == store.DeviceTypeUSBSIMReader { + writeError(w, http.StatusConflict, "wifi_calling_only_device", "USB SIM readers cannot export cellular data as a proxy") + return true + } + return false +} + func (s *Server) writeExportProxyError(w http.ResponseWriter, err error) { switch { case errors.Is(err, exportproxy.ErrDisabled): diff --git a/internal/server/export_proxy_reader_test.go b/internal/server/export_proxy_reader_test.go new file mode 100644 index 0000000..d898345 --- /dev/null +++ b/internal/server/export_proxy_reader_test.go @@ -0,0 +1,31 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "vocat/internal/store" +) + +func TestExportProxyRejectsUSBSIMReader(t *testing.T) { + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.UpsertDevice(context.Background(), store.Device{ + ID: "reader-1", Name: "USB SIM Reader", DeviceType: store.DeviceTypeUSBSIMReader, + }); err != nil { + t.Fatal(err) + } + server := &Server{store: database} + response := httptest.NewRecorder() + if !server.rejectUnsupportedExportProxyDevice(response, context.Background(), "reader-1") { + t.Fatal("reader was accepted as an export-proxy device") + } + if response.Code != http.StatusConflict { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } +} diff --git a/internal/server/proxy_binding_test.go b/internal/server/proxy_binding_test.go index 3dd42e4..a558c8c 100644 --- a/internal/server/proxy_binding_test.go +++ b/internal/server/proxy_binding_test.go @@ -93,3 +93,28 @@ func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) { t.Fatalf("binding after rejected rebind = %+v, %v", binding, err) } } + +func TestProfileProxyBindingSupportsPCSCReader(t *testing.T) { + server, database, controller := newProfileBindingTestServer(t) + readerICCID := "89104100000028106378" + if err := database.UpsertDevice(context.Background(), store.Device{ + ID: "reader-1", Name: "USB SIM Reader", DeviceType: store.DeviceTypeUSBSIMReader, + }); err != nil { + t.Fatal(err) + } + controller.state = vowifi.State{DeviceID: "reader-1", ICCID: readerICCID, Enabled: true} + response := profileBindingRequest(t, server, http.MethodPost, `{ + "upstream_proxy_id":"route-1", + "bindings":[{"device_id":"reader-1","iccid":"89104100000028106378","profile_name":"Reader Profile"}] + }`) + if response.Code != http.StatusOK { + t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String()) + } + binding, err := database.DeviceProxyBinding(context.Background(), readerICCID) + if err != nil || binding.DeviceID != "reader-1" || binding.UpstreamProxyID != "route-1" { + t.Fatalf("reader binding = %+v, %v", binding, err) + } + if controller.reconnects != 1 { + t.Fatalf("reader reconnects = %d, want 1", controller.reconnects) + } +} diff --git a/internal/server/sms_api.go b/internal/server/sms_api.go index 139c8aa..5379b98 100644 --- a/internal/server/sms_api.go +++ b/internal/server/sms_api.go @@ -546,6 +546,13 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) { if onlyDevice != "" && config.ID != onlyDevice { continue } + // A PC/SC USB reader has no modem storage or AT command channel. Its + // messages are delivered by the active VoWiFi IMS session, so attempting + // an AT+CMGL catch-up scan would only poison the reader's health state + // with ErrNoATPort. + if !supportsModemSMSStorage(config) { + continue + } // Do not queue CMGL traffic on the same serial actor while VoWiFi is // reading the SIM or running AKA. Once the session is stable, resume the // SM/ME scan as a catch-up path: an SMS submitted while the card was @@ -659,6 +666,10 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) { } } +func supportsModemSMSStorage(config store.Device) bool { + return store.NormalizeDeviceType(config.DeviceType) != store.DeviceTypeUSBSIMReader +} + func shouldDeferModemSMSSync(state vowifi.State, stateErr error) bool { if stateErr != nil || !state.Enabled { return false diff --git a/internal/server/sms_api_test.go b/internal/server/sms_api_test.go index b74da2e..bfbd80c 100644 --- a/internal/server/sms_api_test.go +++ b/internal/server/sms_api_test.go @@ -108,6 +108,15 @@ func TestNormalizeSMSDeviceFilter(t *testing.T) { } } +func TestSupportsModemSMSStorageRejectsUSBReader(t *testing.T) { + if supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypeUSBSIMReader}) { + t.Fatal("USB SIM reader must not be polled with modem SMS AT commands") + } + if !supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}) { + t.Fatal("cellular modem should retain modem SMS storage synchronization") + } +} + func TestSMSSendOutcome(t *testing.T) { tests := []struct { name string diff --git a/internal/store/devices.go b/internal/store/devices.go index a497582..0a6e070 100644 --- a/internal/store/devices.go +++ b/internal/store/devices.go @@ -17,6 +17,7 @@ const ( DeviceTypeWiFi410 = "wifi_410" DeviceTypeDJI4G = "dji_4g" DeviceTypePCIeEC20EC25 = "pcie_ec20_ec25" + DeviceTypeUSBSIMReader = "usb_sim_reader" ) // NormalizeDeviceType returns a stable persisted device type identifier. @@ -27,6 +28,8 @@ func NormalizeDeviceType(value string) string { return DeviceTypeWiFi410 case DeviceTypeDJI4G: return DeviceTypeDJI4G + case DeviceTypeUSBSIMReader: + return DeviceTypeUSBSIMReader case "", DeviceTypePCIeEC20EC25: return DeviceTypePCIeEC20EC25 default: @@ -132,16 +135,29 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err value.DeviceBackend = "at" } value.DeviceBackend = strings.ToLower(strings.TrimSpace(value.DeviceBackend)) - if value.DeviceBackend != "at" && value.DeviceBackend != "qmi" { + if value.DeviceBackend != "at" && value.DeviceBackend != "qmi" && value.DeviceBackend != "pcsc" { return fmt.Errorf("unsupported device backend %q", value.DeviceBackend) } if value.ESIMTransport == "" { value.ESIMTransport = "at" } value.ESIMTransport = strings.ToLower(strings.TrimSpace(value.ESIMTransport)) - if value.ESIMTransport != "at" && value.ESIMTransport != "qmi" { + if value.ESIMTransport != "at" && value.ESIMTransport != "qmi" && value.ESIMTransport != "pcsc" && value.ESIMTransport != "none" { return fmt.Errorf("unsupported eSIM transport %q", value.ESIMTransport) } + value.SIMPIN = strings.TrimSpace(value.SIMPIN) + if value.SIMPIN != "" { + if len(value.SIMPIN) < 4 || len(value.SIMPIN) > 8 || strings.Trim(value.SIMPIN, "0123456789") != "" { + return errors.New("SIM PIN must contain 4 to 8 digits") + } + } + if value.DeviceType == DeviceTypeUSBSIMReader { + value.DeviceBackend = "pcsc" + value.ESIMTransport = "pcsc" + value.NetworkEnabled = false + value.SMSEnabled = true + value.VoWiFiEnabled = true + } extra, err := normalizeJSONObject(value.Extra) if err != nil { return fmt.Errorf("normalize device extra data: %w", err) @@ -159,12 +175,12 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err _, err = executor.ExecContext(ctx, ` INSERT INTO devices ( id, name, device_type, interface, control_device, at_port, usb_path, - audio_device, modem_imei, apn, proxy_port, baud_rate, + audio_device, modem_imei, sim_pin, apn, proxy_port, baud_rate, data_bits, stop_bits, parity, device_backend, esim_transport, qmi_use_proxy, qmi_proxy_path, qmi_proxy_executable, network_enabled, sms_enabled, vowifi_enabled, extra_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, device_type = excluded.device_type, @@ -174,6 +190,7 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err usb_path = excluded.usb_path, audio_device = excluded.audio_device, modem_imei = excluded.modem_imei, + sim_pin = excluded.sim_pin, apn = excluded.apn, proxy_port = excluded.proxy_port, baud_rate = excluded.baud_rate, @@ -192,7 +209,7 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err updated_at = excluded.updated_at `, value.ID, value.Name, value.DeviceType, value.Interface, value.ControlDevice, value.ATPort, - value.USBPath, value.AudioDevice, value.ModemIMEI, value.APN, + value.USBPath, value.AudioDevice, value.ModemIMEI, value.SIMPIN, value.APN, value.ProxyPort, value.BaudRate, value.DataBits, value.StopBits, value.Parity, value.DeviceBackend, value.ESIMTransport, boolInt(value.QMIUseProxy), value.QMIProxyPath, value.QMIProxyExecutable, @@ -282,7 +299,7 @@ func (s *Store) DeleteDevice(ctx context.Context, id string) error { const deviceSelect = ` SELECT id, name, device_type, interface, control_device, at_port, usb_path, - audio_device, modem_imei, apn, proxy_port, baud_rate, data_bits, + audio_device, modem_imei, sim_pin, apn, proxy_port, baud_rate, data_bits, stop_bits, parity, device_backend, esim_transport, qmi_use_proxy, qmi_proxy_path, qmi_proxy_executable, network_enabled, sms_enabled, vowifi_enabled, extra_json, created_at, updated_at @@ -295,7 +312,7 @@ func scanDevice(row rowScanner) (Device, error) { var createdAt, updatedAt int64 err := row.Scan( &value.ID, &value.Name, &value.DeviceType, &value.Interface, &value.ControlDevice, - &value.ATPort, &value.USBPath, &value.AudioDevice, &value.ModemIMEI, + &value.ATPort, &value.USBPath, &value.AudioDevice, &value.ModemIMEI, &value.SIMPIN, &value.APN, &value.ProxyPort, &value.BaudRate, &value.DataBits, &value.StopBits, &value.Parity, &value.DeviceBackend, &value.ESIMTransport, &qmiUseProxy, &value.QMIProxyPath, diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index a9e1bf8..e7129a8 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -366,6 +366,31 @@ func TestDeviceStateRoundTripAndCascade(t *testing.T) { } } +func TestUSBSIMReaderConfigurationIsWiFiCallingOnly(t *testing.T) { + ctx := context.Background() + database := openTestStore(t, ":memory:") + err := database.UpsertDevice(ctx, Device{ + ID: "reader-1", Name: "USB SIM", DeviceType: DeviceTypeUSBSIMReader, + USBPath: "1-3", ControlDevice: "Reader 00 00", SIMPIN: "1234", + DeviceBackend: "at", ESIMTransport: "at", NetworkEnabled: true, + }) + if err != nil { + t.Fatal(err) + } + got, err := database.Device(ctx, "reader-1") + if err != nil { + t.Fatal(err) + } + if got.DeviceBackend != "pcsc" || got.ESIMTransport != "pcsc" || got.NetworkEnabled || !got.SMSEnabled || !got.VoWiFiEnabled || got.SIMPIN != "1234" { + t.Fatalf("reader config = %+v", got) + } + bad := got + bad.SIMPIN = "12x4" + if err := database.UpsertDevice(ctx, bad); err == nil { + t.Fatal("non-numeric SIM PIN was accepted") + } +} + func TestSMSPersistenceAndDerivedThreads(t *testing.T) { ctx := context.Background() database := openTestStore(t, ":memory:") diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 005a622..cd0ca99 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -260,6 +260,10 @@ func migrationStatements(version int) []string { `ALTER TABLE card_policies ADD COLUMN custom_phone_number TEXT NOT NULL DEFAULT ''`, } + case 16: + return []string{ + `ALTER TABLE devices ADD COLUMN sim_pin TEXT NOT NULL DEFAULT ''`, + } default: return nil } diff --git a/internal/store/models.go b/internal/store/models.go index 8756e9f..b4c5f21 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -24,6 +24,7 @@ type Device struct { USBPath string AudioDevice string ModemIMEI string + SIMPIN string APN string ProxyPort int BaudRate int diff --git a/internal/store/store.go b/internal/store/store.go index ec863ad..c165bda 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -13,7 +13,7 @@ import ( _ "modernc.org/sqlite" ) -const schemaVersion = 15 +const schemaVersion = 16 var ErrNotFound = errors.New("store: not found") @@ -122,7 +122,8 @@ func migrate(ctx context.Context, db *sql.DB) error { // migration are still safe and must be applied. 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 == 14 && strings.Contains(statement, "ADD COLUMN")) || + (nextVersion == 16 && strings.Contains(statement, "ADD COLUMN sim_pin")) if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { continue } diff --git a/internal/vowifi/pcsc_adapter.go b/internal/vowifi/pcsc_adapter.go new file mode 100644 index 0000000..4709109 --- /dev/null +++ b/internal/vowifi/pcsc_adapter.go @@ -0,0 +1,124 @@ +package vowifi + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + "vocat/internal/pcsc" +) + +type PCSCBindingResolver func(context.Context, string) (pcsc.Selector, string, error) + +// PCSCAdapter uses a directly attached USB smart-card reader as the UICC for +// Wi-Fi Calling. It deliberately exposes no cellular-radio behaviour. +type PCSCAdapter struct { + service *pcsc.Service + resolve PCSCBindingResolver + mu sync.RWMutex + bindings map[string]string +} + +var ( + _ SIMIdentityReader = (*PCSCAdapter)(nil) + _ SMSCenterReader = (*PCSCAdapter)(nil) + _ AKAProvider = (*PCSCAdapter)(nil) + _ RadioController = (*PCSCAdapter)(nil) +) + +func NewPCSCAdapter(service *pcsc.Service, resolver PCSCBindingResolver) (*PCSCAdapter, error) { + if service == nil || resolver == nil { + return nil, errors.New("vocat: PC/SC service and reader resolver are required") + } + return &PCSCAdapter{service: service, resolve: resolver, bindings: make(map[string]string)}, nil +} + +func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (SIMIdentity, error) { + selector, pin, err := adapter.resolve(ctx, strings.TrimSpace(deviceID)) + if err != nil { + return SIMIdentity{}, err + } + identity, err := adapter.service.ReadIdentity(ctx, selector, pin) + if err != nil { + return SIMIdentity{}, fmt.Errorf("read USB SIM identity: %w", err) + } + if len(identity.IMSI) < 5 { + return SIMIdentity{}, errors.New("vocat: USB SIM reader returned an invalid IMSI") + } + adapter.mu.Lock() + adapter.bindings[identity.ICCID] = strings.TrimSpace(deviceID) + adapter.mu.Unlock() + 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 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{ + ICCID: identity.ICCID, IMSI: identity.IMSI, + HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength], + SMSC: identity.SMSC, + }, nil +} + +func (adapter *PCSCAdapter) ReadSMSCenter(ctx context.Context, deviceID string) (string, error) { + selector, pin, err := adapter.resolve(ctx, strings.TrimSpace(deviceID)) + if err != nil { + return "", err + } + identity, err := adapter.service.ReadIdentity(ctx, selector, pin) + if err != nil { + return "", err + } + if strings.TrimSpace(identity.SMSC) == "" { + return "", errors.New("vocat: USB SIM does not expose a service-centre address") + } + return identity.SMSC, nil +} + +func (adapter *PCSCAdapter) CheckReady(ctx context.Context, identity SIMIdentity) (AKAEvidence, error) { + selector, pin, err := adapter.resolve(ctx, adapter.deviceID(identity)) + if err != nil { + return AKAEvidence{}, err + } + aid, err := adapter.service.CheckReady(ctx, selector, identity.ICCID, pin) + if err != nil { + return AKAEvidence{}, fmt.Errorf("check USB SIM AKA application: %w", err) + } + return AKAEvidence{Ready: true, Application: aid}, nil +} + +func (adapter *PCSCAdapter) deviceID(identity SIMIdentity) string { + adapter.mu.RLock() + deviceID := adapter.bindings[identity.ICCID] + adapter.mu.RUnlock() + return deviceID +} + +func (adapter *PCSCAdapter) Authenticate(ctx context.Context, identity SIMIdentity, challenge AKAChallenge) (AKAResult, error) { + selector, pin, err := adapter.resolve(ctx, adapter.deviceID(identity)) + if err != nil { + return AKAResult{}, err + } + result, err := adapter.service.Authenticate(ctx, selector, identity.ICCID, pin, pcsc.AKAChallenge(challenge)) + if err != nil { + if errors.Is(err, pcsc.ErrAKARejected) { + return AKAResult{}, errors.Join(ErrEC20AKAMACFailure, err) + } + return AKAResult{}, fmt.Errorf("authenticate with USB SIM: %w", err) + } + return AKAResult(result), nil +} + +func (*PCSCAdapter) Snapshot(context.Context, string) (RadioSnapshot, error) { + return RadioSnapshot{OperatingMode: 4, PureAirplanePolicy: true}, nil +} +func (*PCSCAdapter) StopCellularData(context.Context, string) error { return nil } +func (*PCSCAdapter) EnterVoWiFiRFOff(context.Context, string) error { return nil } +func (*PCSCAdapter) Restore(context.Context, string, RadioSnapshot) error { return nil } diff --git a/web/public/sim-reader.svg b/web/public/sim-reader.svg new file mode 100644 index 0000000..ac9e794 --- /dev/null +++ b/web/public/sim-reader.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/web/src/components/devices/CardPolicyPanel.tsx b/web/src/components/devices/CardPolicyPanel.tsx index cd4a450..373573c 100644 --- a/web/src/components/devices/CardPolicyPanel.tsx +++ b/web/src/components/devices/CardPolicyPanel.tsx @@ -15,9 +15,10 @@ export interface CardPolicyPanelProps { policy: CardPolicy | null; deviceOnline: boolean; onPolicyChanged: () => void | Promise; + wifiCallingOnly?: boolean; } -export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged }: CardPolicyPanelProps) { +export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged, wifiCallingOnly = false }: CardPolicyPanelProps) { const { t } = useI18n(); const operable = deviceOnline && !!iccid; const currentPolicy = policy?.iccid === iccid ? policy : null; @@ -66,7 +67,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
{t("卡策略")}
-
{t("VoWiFi / 飞行模式 开关跟着 SIM 卡走,切换即时生效")}
+
{wifiCallingOnly ? t("USB SIM 读卡器仅用于 WiFi Calling,策略跟随 ICCID 保存") : t("VoWiFi / 飞行模式 开关跟着 SIM 卡走,切换即时生效")}
{!iccid ? ( @@ -119,7 +120,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
- - + /> : null}
- + /> : null} ) : null} diff --git a/web/src/components/devices/DeviceAddDialog.tsx b/web/src/components/devices/DeviceAddDialog.tsx index c17d236..f991a4e 100644 --- a/web/src/components/devices/DeviceAddDialog.tsx +++ b/web/src/components/devices/DeviceAddDialog.tsx @@ -30,7 +30,7 @@ function isQmiMode(d?: DiscoveredDevice | null): boolean { } function modeLabel(d?: DiscoveredDevice | null): string { const m = String(d?.mode || "unknown").toLowerCase(); - return m === "qmi" ? "QMI" : m === "mbim" ? "MBIM" : m === "ecm" ? "ECM" : m === "rndis" ? "RNDIS" : m === "ncm" ? "NCM" : "UNKNOWN"; + return m === "pcsc" ? "PC/SC" : m === "qmi" ? "QMI" : m === "mbim" ? "MBIM" : m === "ecm" ? "ECM" : m === "rndis" ? "RNDIS" : m === "ncm" ? "NCM" : "UNKNOWN"; } function Field({ label, children }: { label: ReactNode; children: ReactNode }) { @@ -47,6 +47,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) { const { addSelected, addConfig } = props; const fixedQmi = isQmiControl(addSelected?.controlPath || addConfig?.controlDevice); const isMbim = String(addSelected?.mode || "").toLowerCase() === "mbim"; + const isReader = addSelected?.hardwareKind === "pcsc" || String(addSelected?.mode || "").toLowerCase() === "pcsc"; useEffect(() => { if (fixedQmi && addConfig.deviceBackend !== "qmi") props.onConfigChange({ ...addConfig, deviceBackend: "qmi" }); @@ -57,7 +58,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isMbim]); - const backendOptions = [ + const backendOptions = isReader ? [{ value: "pcsc", label: "PC/SC" }] : [ ...(isMbim ? [] : [ @@ -161,6 +162,9 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) { + {isReader ? + set({ simPin: e.target.value })} maxLength={8} inputMode="numeric" placeholder={t("仅在 SIM 启用 PIN 时填写")} /> + : null}
{t("设备后端模式")}
@@ -173,7 +177,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) { onChange={(v) => set({ deviceBackend: v })} className="w-[110px]" placeholder="AT" - disabled={fixedQmi || isMbim} + disabled={fixedQmi || isMbim || isReader} options={backendOptions} />
diff --git a/web/src/components/devices/DeviceConfigTab.tsx b/web/src/components/devices/DeviceConfigTab.tsx index 84a0331..ec2e71a 100644 --- a/web/src/components/devices/DeviceConfigTab.tsx +++ b/web/src/components/devices/DeviceConfigTab.tsx @@ -34,13 +34,14 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on const usbPath = deviceStatus?.usbPath || editConfig?.usbPath; const isQmi = isQmiControl(controlDevice); const isMbim = String(editConfig?.deviceBackend || "").toLowerCase() === "mbim"; + const isReader = editConfig?.deviceType === "usb_sim_reader"; useEffect(() => { if (isQmi && editConfig && editConfig.deviceBackend !== "qmi") onEditConfig({ ...editConfig, deviceBackend: "qmi" }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isQmi]); - const backendOptions = [ + const backendOptions = isReader ? [{ value: "pcsc", label: "PC/SC" }] : [ ...(isMbim ? [] : [ @@ -106,6 +107,9 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on + {isReader ? + onEditConfig({ ...editConfig, simPin: e.target.value })} maxLength={8} inputMode="numeric" placeholder={t("留空表示不修改;仅在 SIM 启用 PIN 时填写")} /> + : null}
@@ -123,7 +127,7 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on onChange={(v) => onEditConfig({ ...editConfig, deviceBackend: v as DeviceConfig["deviceBackend"] })} className="w-[120px]" placeholder="AT" - disabled={isQmi || isMbim} + disabled={isQmi || isMbim || isReader} options={backendOptions} />
diff --git a/web/src/components/devices/DeviceDetailHeader.tsx b/web/src/components/devices/DeviceDetailHeader.tsx index 542395b..beaff11 100644 --- a/web/src/components/devices/DeviceDetailHeader.tsx +++ b/web/src/components/devices/DeviceDetailHeader.tsx @@ -15,6 +15,7 @@ export interface DeviceDetailHeaderProps { onReconnectVowifi: () => void; onRebootModem: () => void; onOpenSms: () => void; + wifiCallingOnly?: boolean; } export function DeviceDetailHeader(props: DeviceDetailHeaderProps) { @@ -42,7 +43,7 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) { - ) : device.developerEnabled ? ( + ) : device.developerEnabled && !props.wifiCallingOnly ? (
) : null} - + : null} diff --git a/web/src/components/devices/DeviceOverviewTab.tsx b/web/src/components/devices/DeviceOverviewTab.tsx index 440ea68..1e9ea3f 100644 --- a/web/src/components/devices/DeviceOverviewTab.tsx +++ b/web/src/components/devices/DeviceOverviewTab.tsx @@ -26,12 +26,13 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) { const { t } = useI18n(); const [operatorOpen, setOperatorOpen] = useState(false); const { device } = props; + const wifiCallingOnly = device.deviceType === "usb_sim_reader"; return (
-
+
{t("运行状态")}
- {isVoWiFiInUse(device) ? ( + {isVoWiFiInUse(device) && !(device.modem?.imei && device.modem?.simInserted === false) ? ( ) : ( setOperatorOpen(true)} /> @@ -44,16 +45,16 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) { e911Starting={props.e911Starting} onSetupE911={props.onSetupE911} /> - + /> : null}
{device.developerEnabled && device.networkEnabled && device.id ? : null} - {device?.id ? ( + {device?.id && !wifiCallingOnly ? ( = { + "USB SIM 读卡器(仅 WiFi Calling)": "USB SIM Reader (WiFi Calling only)", + "仅在 SIM 启用 PIN 时填写": "Only enter this when SIM PIN is enabled", + "留空表示不修改;仅在 SIM 启用 PIN 时填写": "Leave blank to keep unchanged; only enter this when SIM PIN is enabled", + "USB SIM 读卡器仅用于 WiFi Calling,策略跟随 ICCID 保存": "The USB SIM reader is for WiFi Calling only; policy is saved per ICCID", // Cellular APN profiles. "蜂窝 APN": "Cellular APN", "APN 列表和启用状态跟随当前 ICCID/Profile 保存": "APN profiles and the active selection are saved per ICCID/Profile", @@ -765,7 +769,9 @@ export const EN_DICT: Record = { "暂无设备": "No devices", "最后原因": "Last Reason", "最后成功:": "Last success: ", - "未检测到 eUICC": "No eUICC detected", + "未检测到 eUICC": "No eUICC detected", + "SIM卡未插入": "No SIM card inserted", + "USB SIM读卡器仅支持VoWiFi短信和通话任务": "USB SIM readers support VoWiFi SMS and call tasks only", "未生效": "Not in effect", "未知": "Unknown", "未知日期": "Unknown date", diff --git a/web/src/pages/AutomaticTasksPage.tsx b/web/src/pages/AutomaticTasksPage.tsx index 8f08fb4..dc145c8 100644 --- a/web/src/pages/AutomaticTasksPage.tsx +++ b/web/src/pages/AutomaticTasksPage.tsx @@ -251,7 +251,7 @@ export default function AutomaticTasksPage() { function edit(task?: AutomaticTask) { const deviceId = task?.deviceId || devices[0]?.id || ""; - const next = task ? { + let next = task ? { id: task.id, name: task.name, enabled: task.enabled, @@ -269,13 +269,21 @@ export default function AutomaticTasksPage() { message: task.payload?.message || "", durationSeconds: task.payload?.durationSeconds || 30, } : emptyForm(deviceId); + if (devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader") { + next = { ...next, taskType: next.taskType === "public_ip" ? "sms" : next.taskType, environment: "vowifi" }; + } setForm(next); setOpen(true); void loadProfiles(deviceId, next.profileIccid); } function chooseDevice(deviceId: string) { - setForm((current) => ({ ...current, deviceId, profileIccid: "", profileAid: "" })); + const reader = devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader"; + setForm((current) => ({ + ...current, deviceId, profileIccid: "", profileAid: "", + taskType: reader && current.taskType === "public_ip" ? "sms" : current.taskType, + environment: reader ? "vowifi" : current.environment, + })); void loadProfiles(deviceId); } @@ -285,6 +293,7 @@ export default function AutomaticTasksPage() { } function chooseTaskType(taskType: TaskType) { + if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && taskType === "public_ip") return; setForm((current) => ({ ...current, taskType, @@ -296,6 +305,9 @@ export default function AutomaticTasksPage() { if (!form.name.trim()) return message.warning(t("请输入任务名称")); if (!form.deviceId) return message.warning(t("请选择设备")); if (!form.profileIccid) return message.warning(t("请选择 eSIM Profile")); + if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && (form.environment !== "vowifi" || form.taskType === "public_ip")) { + return message.warning(t("USB SIM读卡器仅支持VoWiFi短信和通话任务")); + } if (form.taskType !== "public_ip" && !form.phone.trim()) return message.warning(t("请输入号码")); if (form.taskType === "sms" && !form.message.trim()) return message.warning(t("请输入短信内容")); setSaving(true); @@ -377,6 +389,15 @@ export default function AutomaticTasksPage() { const taskTypeLabel = (value: TaskType) => ({ sms: t("发送短信"), call: t("拨打电话并自动挂断"), public_ip: t("获取漫游公网 IP") })[value]; const environmentLabel = (value: TaskEnvironment) => value === "vowifi" ? "VoWiFi" : t("基站直连"); + const selectedTaskDeviceIsReader = deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader"; + const taskTypeOptions = [ + { value: "sms", label: t("发送短信") }, + { value: "call", label: t("拨打电话并自动挂断") }, + ...(!selectedTaskDeviceIsReader ? [{ value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }] : []), + ]; + const environmentOptions = selectedTaskDeviceIsReader + ? [{ value: "vowifi", label: "VoWiFi" }] + : [{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }]; return (
@@ -473,8 +494,9 @@ export default function AutomaticTasksPage() {
setForm({ ...form, name: event.target.value })} placeholder={t("例如:每日短信保活")} />
({ value: profile.iccid, label: profile.label }))} />
-
setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip"} options={[{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }]} />
+
setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip" || selectedTaskDeviceIsReader} options={environmentOptions} />
+ {selectedTaskDeviceIsReader ?
{t("USB SIM读卡器仅支持VoWiFi短信和通话任务")}
: null} {form.taskType !== "public_ip" ?
setForm({ ...form, phone: event.target.value })} placeholder="+447700900123" />
: null} {form.taskType === "call" ?
setForm({ ...form, durationSeconds: Number(event.target.value) })} />
: null} diff --git a/web/src/pages/DevicesPage.tsx b/web/src/pages/DevicesPage.tsx index ed1e245..58fe996 100644 --- a/web/src/pages/DevicesPage.tsx +++ b/web/src/pages/DevicesPage.tsx @@ -32,6 +32,7 @@ const EMPTY_ADD: AddDeviceForm = { atPort: "", controlDevice: "", deviceBackend: "at", + simPin: "", }; export default function DevicesPage() { @@ -373,7 +374,8 @@ export default function DevicesPage() { setAddSelected(d); setAddConfig((prev) => { const mode = String(d.mode || "").toLowerCase(); - const backend = mode === "mbim" ? "mbim" : isQmiControl(d.controlPath) || (mode === "qmi" && d.controlPath) ? "qmi" : "at"; + const isReader = d.hardwareKind === "pcsc" || mode === "pcsc"; + const backend = isReader ? "pcsc" : mode === "mbim" ? "mbim" : isQmiControl(d.controlPath) || (mode === "qmi" && d.controlPath) ? "qmi" : "at"; return { ...prev, interface: d.netInterface || "", @@ -382,6 +384,8 @@ export default function DevicesPage() { modemImei: d.imei || "", usbPath: d.usbPath || "", deviceBackend: backend, + deviceType: isReader ? "usb_sim_reader" : prev.deviceType, + esimTransport: isReader ? "pcsc" : backend, }; }); }, []); @@ -574,6 +578,10 @@ export default function DevicesPage() { const unconfiguredDiscovered = useMemo(() => discovered.filter((d) => !d.configured), [discovered]); const detailOnline = isDeviceOnline(detail); + const isReader = detail?.deviceType === "usb_sim_reader"; + useEffect(() => { + if (isReader && ["at", "ussd"].includes(activeTab)) setActiveTab("overview"); + }, [isReader, activeTab]); const addAtLimit = deviceLimit > 0 && list.length >= deviceLimit; const tabItems = [ { key: "overview", label: t("概览") }, @@ -582,7 +590,7 @@ export default function DevicesPage() { { key: "ussd", label: t("USSD") }, { key: "config", label: t("配置") }, { key: "card", label: t("卡策略") }, - ]; + ].filter((tab) => !isReader || !["at", "ussd"].includes(tab.key)); const overviewNode = detail ? (
@@ -673,6 +681,7 @@ export default function DevicesPage() { onReconnectVowifi={handleReconnectVoWiFi} onRebootModem={handleRebootModem} onOpenSms={handleOpenSms} + wifiCallingOnly={isReader} />
@@ -697,7 +706,7 @@ export default function DevicesPage() { ) : null} {activeTab === "card" ? ( - + ) : null}
diff --git a/web/src/types.ts b/web/src/types.ts index 360c513..b0fa6ac 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,6 +1,6 @@ export type ApiStatus = "ok" | "error"; -export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25"; +export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25" | "usb_sim_reader"; export interface Session { authenticated: boolean; @@ -167,6 +167,8 @@ export interface DeviceStatus { } export interface DiscoveredDevice { + hardwareKind?: string; + readerName?: string; discoveryKey: string; controlPath: string; netInterface: string; @@ -196,14 +198,15 @@ export interface DeviceConfig { usbPath: string; audioDevice?: string; modemImei?: string; + simPin?: string; apn: string; proxyPort: number; baudRate: number; dataBits: number; stopBits: number; parity: string; - deviceBackend: "at" | "qmi"; - esimTransport: "at" | "qmi"; + deviceBackend: "at" | "qmi" | "pcsc"; + esimTransport: "at" | "qmi" | "pcsc" | "none"; qmiUseProxy: boolean; qmiProxyPath?: string; qmiProxyExecutable?: string;