Files
VoCat/internal/device/esim.go
T
MengMengCode 22487dbb1f Add PC/SC backend support for USB SIM readers
- Implemented a new `unsupportedBackend` in `backend_stub.go` to handle unsupported platforms.
- Created a `Service` struct in `service.go` to manage interactions with smart cards, including session management and identity reading.
- Added methods for reading identity, checking readiness, and authenticating with USIM applications.
- Introduced a `scriptedCard` for testing purposes in `service_test.go` to simulate card responses.
- Defined necessary types and error handling in `types.go` for better clarity and usability.
- Developed a `PCSCAdapter` in `vowifi/pcsc_adapter.go` to integrate PC/SC service with Wi-Fi calling functionalities.
- Added a new SVG icon for USB SIM readers in `public/sim-reader.svg`.
- Implemented tests to ensure correct functionality and error handling in various scenarios.
2026-08-12 02:33:32 +08:00

1019 lines
33 KiB
Go

package device
import (
"context"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"vocat/internal/i18n"
"vocat/internal/modem"
"vocat/internal/pcsc"
)
// eUICC / eSIM (LPA, SGP.22) access over the modem's AT+CSIM APDU passthrough.
//
// The Quectel EC20's AT+CCHO logical-channel command is non-functional on the
// deployed firmware, so — like lpac's `at_csim` backend — we drive MANAGE
// CHANNEL / SELECT / STORE DATA manually over AT+CSIM. The logical channel is
// separate from the modem's own basic channel, so reading and switching
// profiles does not disturb the modem's network registration.
//
// Verified against a live eUICC: channel open/select/STORE DATA/close all
// succeed and GetProfilesInfo returns every profile with no authentication.
// isdRAID is the standard ISD-R AID that hosts the LPA functions (ES10).
const isdRAID = "A0000005591010FFFFFFFF8900000100"
// xesimISDRAID is the alternate ISD-R application exposed by XeSIM cards.
// It implements the same ES10 interface, but is not selectable through the
// standard ...0100 AID. Selecting it is a read-only capability probe; profile
// state is never changed during discovery.
const xesimISDRAID = "A0000005591010FFFFFFFF8900000177"
// eSTK multi-SE products expose each eUICC storage through its own vendor
// ISD-R AID. The standard GSMA AID aliases one of them, so probing only that
// AID silently hides the second storage.
const (
estkProductAID = "A06573746B6D65FFFFFFFFFFFF6D6774"
estkSE0AID = "A06573746B6D65FFFF4953442D522030"
estkSE1AID = "A06573746B6D65FFFF4953442D522031"
)
func targetEuiccAID(aidHex string) string {
aidHex = strings.ToUpper(strings.TrimSpace(aidHex))
if aidHex == "" {
return isdRAID
}
return aidHex
}
var (
errNoLogicalChannel = errors.New("esim: modem could not open a logical channel")
errNoEUICC = errors.New("esim: no eUICC (ISD-R) found on the inserted card")
errESIMSW = errors.New("esim: eUICC returned an error status word")
errESIMRecovering = errors.New("esim: profile-switch recovery is in progress")
errEUICCChannelStuck = errors.New("esim: eUICC APDU channel is unavailable until the modem restarts")
)
// ErrNoEUICC is returned when the inserted card exposes no eUICC ISD-R, so the
// HTTP layer can render the empty state instead of an error.
var ErrNoEUICC = errNoEUICC
// ErrEUICCChannelStuck means the modem kept rejecting MANAGE CHANNEL or
// SELECT ISD-R with the EC20's non-descriptive +CME ERROR: 0 after retries.
// This is observed after SIM hot-swap and requires a modem restart; repeating
// the same profile-list request cannot reset the baseband's UIM/APDU state.
var ErrEUICCChannelStuck = errEUICCChannelStuck
// EsimProfile is one eUICC profile decoded from GetProfilesInfo.
type EsimProfile struct {
ICCID string `json:"iccid"`
AID string `json:"aidHex"`
ServiceProvider string `json:"serviceProviderName,omitempty"`
Name string `json:"name,omitempty"`
Nickname string `json:"nickname,omitempty"`
State int `json:"state"` // 0 = disabled, 1 = enabled
StateText string `json:"stateText"`
Class string `json:"classText,omitempty"`
}
// EsimInfo is the decoded profile list plus chip metadata for one eUICC.
type EsimInfo struct {
EID string `json:"eid,omitempty"`
AID string `json:"aidHex,omitempty"`
Profiles []EsimProfile `json:"profiles"`
}
// EsimInventoryEntry is one independently addressable eUICC storage together
// with its profile list and production metadata.
type EsimInventoryEntry struct {
Info EsimInfo
Chip EsimChipInfo
}
// EnabledProfile returns the currently enabled profile, or nil.
func (info *EsimInfo) EnabledProfile() *EsimProfile {
for index := range info.Profiles {
if info.Profiles[index].State == 1 {
return &info.Profiles[index]
}
}
return nil
}
// decodeICCID converts a GSM BCD (nibble-swapped) ICCID to its digit string.
func decodeICCID(raw []byte) string {
var builder strings.Builder
for _, b := range raw {
lo, hi := b&0x0F, b>>4
if lo <= 9 {
builder.WriteByte(byte('0' + lo))
}
if hi <= 9 {
builder.WriteByte(byte('0' + hi))
}
}
return builder.String()
}
// encodeFixedDigitBCD converts decimal digits to GSM BCD (nibble-swapped) and
// pads every unused nibble with F up to the requested fixed field size.
func encodeFixedDigitBCD(digits string, octets int, label string) ([]byte, error) {
digits = strings.TrimSpace(digits)
if digits == "" {
return nil, fmt.Errorf("esim: empty %s", label)
}
if octets <= 0 || len(digits) > octets*2 {
return nil, fmt.Errorf("esim: %s exceeds its %d-byte field", label, octets)
}
out := make([]byte, octets)
for index := range out {
out[index] = 0xFF
}
for index := 0; index < len(digits); index += 2 {
lo := digits[index]
if lo < '0' || lo > '9' {
return nil, fmt.Errorf("esim: invalid %s digit %q", label, lo)
}
// hiNibble is the high nibble value; a trailing odd digit pads with 0xF.
hiNibble := byte(0xF)
if index+1 < len(digits) {
hi := digits[index+1]
if hi < '0' || hi > '9' {
return nil, fmt.Errorf("esim: invalid %s digit %q", label, hi)
}
hiNibble = hi - '0'
}
out[index/2] = hiNibble<<4 | (lo - '0')
}
return out, nil
}
// SGP.22 defines Iccid as the 10-octet EF-ICCID representation even when the
// printed identifier contains only 18 or 19 digits.
func encodeICCID(digits string) ([]byte, error) {
return encodeFixedDigitBCD(digits, 10, "ICCID")
}
func buildEnableProfileRequest(iccid string) ([]byte, error) {
bcd, err := encodeICCID(iccid)
if err != nil {
return nil, err
}
profileID := derConstruct(0xA0, derEncode(0x5A, bcd))
return derConstruct(0xBF31, profileID, derEncode(0x81, []byte{0xFF})), nil
}
// parseCSIM extracts the payload and status word from an AT+CSIM response.
func parseCSIM(response modem.Response) ([]byte, int, error) {
value := valueAfterPrefix(response, "+CSIM:")
if value == "" {
return nil, 0, errors.New("esim: modem did not return a +CSIM result")
}
parts := csvValues(value)
if len(parts) < 2 {
return nil, 0, fmt.Errorf("esim: malformed +CSIM result %q", value)
}
hexData := strings.Trim(parts[1], `"`)
if len(hexData) < 4 {
return nil, 0, fmt.Errorf("esim: short +CSIM data %q", hexData)
}
raw, err := hex.DecodeString(hexData)
if err != nil {
return nil, 0, fmt.Errorf("esim: decode +CSIM data: %w", err)
}
sw := int(raw[len(raw)-2])<<8 | int(raw[len(raw)-1])
return raw[:len(raw)-2], sw, nil
}
// euiccChannel is an open logical channel to the eUICC's ISD-R.
type euiccChannel struct {
manager *Manager
id string
channel int
pcscSession *pcsc.Session
resetOnClose bool
}
// csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage
// makes the eUICC decrypt/write sizeable SCP03t segments on-card, which can exceed
// the modem's default 3s command timeout, so eSIM APDUs get a longer budget.
const csimAPDUTimeout = 30 * time.Second
// csim sends one raw APDU over AT+CSIM and returns payload + status word.
func (manager *Manager) csim(ctx context.Context, id string, apdu []byte) ([]byte, int, error) {
command := fmt.Sprintf("AT+CSIM=%d,\"%s\"", len(apdu)*2, strings.ToUpper(hex.EncodeToString(apdu)))
state, err := manager.lookup(id)
if err != nil {
return nil, 0, err
}
state.opMu.Lock()
defer state.opMu.Unlock()
if err := manager.validateActive(id, state); err != nil {
return nil, 0, err
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
return nil, 0, err
}
// Give each eUICC APDU its own generous deadline (withTimeout preserves an
// existing one, so a shorter caller deadline still wins).
apduCtx, cancel := context.WithTimeout(ctx, csimAPDUTimeout)
defer cancel()
response, err := manager.command(apduCtx, client, command)
if err != nil {
return nil, 0, err
}
return parseCSIM(response)
}
// openEuicc opens a logical channel and selects the ISD-R AID on it.
func (manager *Manager) openEuicc(ctx context.Context, id string) (*euiccChannel, error) {
return manager.openEuiccAID(ctx, id, isdRAID)
}
func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) {
if manager.esimRecoveryActive(id) {
return nil, errESIMRecovering
}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
channel, err := manager.openEuiccOnceAID(ctx, id, aidHex)
if err == nil {
return channel, nil
}
lastErr = err
if !isTransientEuiccCME(err) {
return nil, err
}
if attempt == 2 {
return nil, fmt.Errorf("%w: %v", ErrEUICCChannelStuck, err)
}
delay := time.Duration(attempt+1) * 250 * time.Millisecond
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
return nil, lastErr
}
func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccChannel, error) {
return manager.openEuiccOnceAID(ctx, id, isdRAID)
}
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 -> "<channel> 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.
payload, sw, err := manager.csim(ctx, id, []byte{0x00, 0x70, 0x00, 0x00, 0x01})
if err != nil {
return nil, err
}
if sw != 0x9000 || len(payload) != 1 {
return nil, errNoLogicalChannel
}
channel := &euiccChannel{manager: manager, id: id, channel: int(payload[0])}
// SELECT ISD-R by AID on the logical channel: CLA=channel, INS=A4, P1=04.
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...)
_, sw, err = manager.csim(ctx, id, selectAID)
if err != nil {
channel.close(context.Background())
return nil, err
}
if sw>>8 == 0x61 {
// Drain the select FCP the card is holding with a proper GET RESPONSE
// (CLA=0x80|channel, INS=0xC0). transmit() injects the channel into the
// CLA low nibble, so the first byte here stays 0x80.
_, sw, _ = channel.transmit(ctx, []byte{0x80, 0xC0, 0x00, 0x00, byte(sw & 0xFF)}, 0x80)
}
if sw != 0x9000 {
channel.close(context.Background())
return nil, errNoEUICC
}
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
// OpenEUICC's eSTK integration, generic AIDs are not appended after an eSTK SE
// opens, because the standard AID aliases one of the same storages.
func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []string {
product, err := manager.openEuiccAID(ctx, id, estkProductAID)
if err == nil {
product.close(context.Background())
var found []string
for _, aid := range []string{estkSE0AID, estkSE1AID} {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
continue
}
channel.close(context.Background())
found = append(found, aid)
}
if len(found) > 0 {
return found
}
}
var found []string
for _, aid := range []string{isdRAID, xesimISDRAID} {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
continue
}
channel.close(context.Background())
found = append(found, aid)
}
if len(found) > 0 {
return found
}
// Preserve the old error path for a physical SIM with no eUICC. The caller
// retries the standard AID once and returns ErrNoEUICC to the HTTP layer.
return []string{isdRAID}
}
func isTransientEuiccCME(err error) bool {
var commandErr *modem.CommandError
return errors.As(err, &commandErr) &&
strings.EqualFold(strings.TrimSpace(commandErr.Final), "+CME ERROR: 0")
}
// 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.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,
// channel number in the low nibble), following 61xx "more data" continuations,
// 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.exchange(ctx, apdu)
if err != nil {
return nil, 0, err
}
assembled := append([]byte(nil), payload...)
guard := 0
for sw>>8 == 0x61 && guard < 24 {
guard++
getResponse := []byte{0x80 | byte(channel.channel), 0xC0, 0x00, 0x00, byte(sw & 0xFF)}
frag, nextSW, err := channel.exchange(ctx, getResponse)
if err != nil {
return nil, 0, err
}
assembled = append(assembled, frag...)
sw = nextSW
}
return assembled, sw, nil
}
// es10 runs one ES10 command: it wraps the DER request body in one or more
// chained STORE DATA APDUs (see storeDataChained) and returns the assembled
// response body. Small requests produce a single P1=0x91/P2=0x00 block, exactly
// as before; larger ones (AuthenticateServer, LoadBoundProfilePackage, …) are
// split across continuation blocks.
func (channel *euiccChannel) es10(ctx context.Context, derRequest []byte) ([]byte, error) {
return channel.storeDataChained(ctx, derRequest)
}
// derNode is one decoded BER-TLV element (long-form tags and lengths handled).
type derNode struct {
tag int
value []byte
children []*derNode
}
// derParse decodes a sequence of BER-TLV elements. Constructed elements have
// their value recursively decoded into children.
func derParse(data []byte) []*derNode {
var nodes []*derNode
index := 0
for index < len(data) {
node, next, ok := derDecodeOne(data, index)
if !ok {
break
}
nodes = append(nodes, node)
index = next
}
return nodes
}
func derDecodeOne(data []byte, start int) (*derNode, int, bool) {
index := start
if index >= len(data) {
return nil, 0, false
}
first := data[index]
index++
constructed := first&0x20 != 0
tag := int(first)
if first&0x1F == 0x1F { // long-form tag: keep the full tag bytes (e.g. 9F70, BF2D)
for index < len(data) {
b := data[index]
index++
tag = tag<<8 | int(b)
if b&0x80 == 0 {
break
}
}
}
if index >= len(data) {
return nil, 0, false
}
lengthByte := data[index]
index++
length := 0
if lengthByte&0x80 == 0 {
length = int(lengthByte)
} else {
count := int(lengthByte & 0x7F)
if count == 0 || count > 4 || index+count > len(data) {
return nil, 0, false
}
for i := 0; i < count; i++ {
length = length<<8 | int(data[index])
index++
}
}
if index+length > len(data) {
return nil, 0, false
}
value := data[index : index+length]
node := &derNode{tag: tag, value: value}
if constructed {
node.children = derParse(value)
}
return node, index + length, true
}
// derValue returns the raw value of the first node with tag.
func derValue(nodes []*derNode, tag int) []byte {
for _, node := range nodes {
if node.tag == tag {
return node.value
}
}
return nil
}
// derFindAll recursively collects every node with the given tag. Icons live in
// primitive (non-constructed) leaves, so their bytes are never descended into.
func derFindAll(nodes []*derNode, tag int) []*derNode {
var found []*derNode
for _, node := range nodes {
if node.tag == tag {
found = append(found, node)
}
found = append(found, derFindAll(node.children, tag)...)
}
return found
}
// parseProfilesInfo decodes a GetProfilesInfo response body into profiles. The
// ProfileInfo records (tag E3) are collected wherever they sit (some cards use
// a BF3D root, others echo BF2D, with an optional A0 list wrapper).
func parseProfilesInfo(payload []byte) []EsimProfile {
records := derFindAll(derParse(payload), 0xE3)
var profiles []EsimProfile
seenICCID := make(map[string]struct{})
for _, record := range records {
fields := record.children
profile := EsimProfile{
ServiceProvider: string(derValue(fields, 0x91)),
Name: string(derValue(fields, 0x92)),
Nickname: string(derValue(fields, 0x90)),
}
if iccid := derValue(fields, 0x5A); iccid != nil {
profile.ICCID = decodeICCID(iccid)
}
// E3 is reused by constructed metadata inside some eUICC 4.x profile
// records. Recursive discovery is needed for cards that wrap the real
// ProfileInfo list, but those nested E3 nodes are not profiles and carry
// no ICCID. Never expose an entry that cannot be safely addressed by the
// ES10c profile operations; also collapse duplicate ICCIDs defensively.
if !validProfileICCID(profile.ICCID) {
continue
}
if _, exists := seenICCID[profile.ICCID]; exists {
continue
}
seenICCID[profile.ICCID] = struct{}{}
if aid := derValue(fields, 0x4F); aid != nil {
profile.AID = strings.ToUpper(hex.EncodeToString(aid))
}
if state := derValue(fields, 0x9F70); len(state) == 1 {
profile.State = int(state[0])
}
profile.StateText = i18n.T("已禁用")
if profile.State == 1 {
profile.StateText = i18n.T("已启用")
}
if class := derValue(fields, 0x95); len(class) == 1 {
profile.Class = map[int]string{0: "test", 1: "provisioning", 2: "operational"}[int(class[0])]
}
profiles = append(profiles, profile)
}
return profiles
}
func validProfileICCID(iccid string) bool {
if len(iccid) < 18 || len(iccid) > 20 || !strings.HasPrefix(iccid, "89") {
return false
}
for _, character := range iccid {
if character < '0' || character > '9' {
return false
}
}
return true
}
// ESIMListProfiles reads the eUICC profile list via ES10c GetProfilesInfo.
func (manager *Manager) ESIMListProfiles(ctx context.Context, id string) (EsimInfo, error) {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
if manager.esimRecoveryActive(id) {
if cached, ok := manager.cachedESIMInfo(id); ok {
return cached, nil
}
return EsimInfo{}, errESIMRecovering
}
var lastErr error
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
lastErr = err
continue
}
payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo
channel.close(context.Background())
if err != nil {
lastErr = err
continue
}
info := EsimInfo{AID: aid, Profiles: parseProfilesInfo(payload)}
manager.cacheESIMInfo(id, info)
return info, nil
}
if lastErr != nil {
return EsimInfo{}, lastErr
}
return EsimInfo{}, ErrNoEUICC
}
// ESIMSwitchProfile enables one profile by ICCID via ES10c EnableProfile.
func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid string, aidHex string) error {
iccid = strings.TrimSpace(iccid)
if iccid == "" {
return errors.New("esim: an ICCID is required")
}
der, err := buildEnableProfileRequest(iccid)
if err != nil {
return err
}
manager.esimMu.Lock()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
manager.esimMu.Unlock()
return err
}
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex))
if err != nil {
manager.esimMu.Unlock()
return err
}
// EnableProfile request (SGP.22 ES10c, per lpac):
// BF31 { A0 { 5A <iccid bcd> } 81 01 FF } (refresh = yes)
// The profileIdentifier is an explicitly-tagged [0] CHOICE, so the ICCID
// element (5A) must be wrapped in A0 — omitting that wrapper makes the eUICC
// reject the command with result 0x7F (undefined error). refreshFlag (81)
// stays a sibling of A0, directly under BF31.
// EnableProfile is a non-idempotent commit. Once its APDU starts, a browser
// disconnect or reverse-proxy timeout must not cancel it halfway through and
// skip the modem reset, otherwise EC20 remains in SIM failure (+CME 13).
commitContext, cancelCommit := context.WithTimeout(context.WithoutCancel(ctx), csimAPDUTimeout)
payload, err := channel.es10(commitContext, der)
cancelCommit()
// 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 {
// The card may have committed immediately before the transport error. A
// detached reset is safe in either case and prevents an uncertain switch
// from leaving the modem's SIM cache unusable.
manager.startProfileSwitchRecovery(id)
manager.esimMu.Unlock()
return err
}
// A transport SW 9000 only means the APDU reached the eUICC. The real outcome
// is the EnableProfile result code (tag 80) inside the BF31 response — honour
// it so a rejected switch is surfaced instead of reported as "switched".
result, ok := enableProfileResult(payload)
if !ok {
manager.startProfileSwitchRecovery(id)
manager.esimMu.Unlock()
return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
if err := enableProfileResponseError(byte(result), payload); err != nil {
manager.esimMu.Unlock()
return err
}
manager.markCachedProfileEnabled(id, iccid)
// The eUICC accepted the target profile. Reset and repopulate the modem in
// a detached recovery so it survives an HTTP disconnect, but keep this API
// call pending until the live modem ICCID proves that the switch took effect.
manager.startProfileSwitchRecovery(id)
manager.esimMu.Unlock()
verifyContext, cancelVerify := context.WithTimeout(context.WithoutCancel(ctx), profileSwitchVerificationTimeout(manager))
defer cancelVerify()
if err := manager.waitForESIMRecovery(verifyContext, id); err != nil {
return err
}
return manager.verifySwitchedICCID(verifyContext, id, iccid)
}
func (manager *Manager) startProfileSwitchRecovery(id string) {
done := make(chan struct{})
manager.esimRecoveryMu.Lock()
if manager.esimRecoveries == nil {
manager.esimRecoveries = make(map[string]chan struct{})
}
if manager.esimRecoveries[id] != nil {
manager.esimRecoveryMu.Unlock()
return
}
manager.esimRecoveries[id] = done
manager.esimRecoveryMu.Unlock()
go func() {
manager.recoverAfterProfileSwitch(id)
manager.esimRecoveryMu.Lock()
if manager.esimRecoveries[id] == done {
delete(manager.esimRecoveries, id)
close(done)
}
manager.esimRecoveryMu.Unlock()
}()
}
func (manager *Manager) waitForESIMRecovery(ctx context.Context, id string) error {
manager.esimRecoveryMu.Lock()
done := manager.esimRecoveries[id]
manager.esimRecoveryMu.Unlock()
if done == nil {
return nil
}
select {
case <-done:
return nil
case <-ctx.Done():
return fmt.Errorf("esim: wait for profile-switch recovery: %w", ctx.Err())
}
}
func (manager *Manager) esimRecoveryActive(id string) bool {
manager.esimRecoveryMu.Lock()
active := manager.esimRecoveries[id] != nil
manager.esimRecoveryMu.Unlock()
return active
}
func cloneESIMInfo(info EsimInfo) EsimInfo {
info.Profiles = append([]EsimProfile(nil), info.Profiles...)
return info
}
func (manager *Manager) cachedESIMInfo(id string) (EsimInfo, bool) {
manager.esimCacheMu.RLock()
info, ok := manager.esimCache[id]
manager.esimCacheMu.RUnlock()
return cloneESIMInfo(info), ok
}
func (manager *Manager) cacheESIMInfo(id string, info EsimInfo) {
manager.esimCacheMu.Lock()
manager.esimCache[id] = cloneESIMInfo(info)
manager.esimCacheMu.Unlock()
}
func (manager *Manager) markCachedProfileEnabled(id, iccid string) {
manager.esimCacheMu.Lock()
info, ok := manager.esimCache[id]
if ok {
for index := range info.Profiles {
if info.Profiles[index].ICCID == iccid {
info.Profiles[index].State = 1
info.Profiles[index].StateText = i18n.T("已启用")
} else {
info.Profiles[index].State = 0
info.Profiles[index].StateText = i18n.T("已禁用")
}
}
manager.esimCache[id] = info
}
manager.esimCacheMu.Unlock()
}
func (manager *Manager) markCachedProfileDisabled(id, iccid string) {
manager.esimCacheMu.Lock()
info, ok := manager.esimCache[id]
if ok {
for index := range info.Profiles {
if info.Profiles[index].ICCID == iccid {
info.Profiles[index].State = 0
info.Profiles[index].StateText = i18n.T("已禁用")
break
}
}
manager.esimCache[id] = info
}
manager.esimCacheMu.Unlock()
}
func (manager *Manager) removeCachedProfile(id, iccid string) {
manager.esimCacheMu.Lock()
info, ok := manager.esimCache[id]
if ok {
profiles := info.Profiles[:0]
for _, profile := range info.Profiles {
if profile.ICCID != iccid {
profiles = append(profiles, profile)
}
}
info.Profiles = profiles
manager.esimCache[id] = info
}
manager.esimCacheMu.Unlock()
}
func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
manager.esimCacheMu.Lock()
info, ok := manager.esimCache[id]
if ok {
for index := range info.Profiles {
if info.Profiles[index].ICCID == iccid {
info.Profiles[index].Nickname = nickname
break
}
}
manager.esimCache[id] = info
}
manager.esimCacheMu.Unlock()
}
// recoverAfterProfileSwitch owns the post-commit reset independently of the
// 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) {
if !manager.isPCSCDevice(id) {
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
_ = manager.rebootForProfileSwitch(resetContext, id)
cancelReset()
}
manager.refreshAfterProfileSwitch(id)
}
// refreshAfterProfileSwitch repopulates the device snapshot in the background
// after an eSIM profile switch + modem reboot. /overview only serves the cached
// snapshot, and nothing else live-reads post-switch, so without this the card
// stays on "--" forever. The EC20 takes ~10-15s to come back from AT+CFUN=1,1,
// so we delay first, then retry with backoff. Transport errors during the
// reboot window are fine — Fix 1 discards the poisoned client and reopens on
// 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
attempts = 6
)
time.Sleep(settle)
for attempt := 0; attempt < attempts; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
_, _ = manager.Discover(ctx)
_, flightErr := manager.SetFlight(ctx, id, true)
var err error
if flightErr == nil {
_, err = manager.Refresh(ctx, id)
} else {
err = flightErr
}
cancel()
if err == nil {
return
}
time.Sleep(interval)
}
}
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) {
for _, node := range derFindAll(derParse(payload), 0x80) {
if len(node.value) > 0 {
return int(node.value[0]), true
}
}
return 0, false
}
var (
ErrESIMEnableProfileNotFound = errors.New("esim: profile to enable was not found on the selected eUICC")
ErrESIMProfileNotDisabled = errors.New("esim: profile is not currently disabled")
ErrESIMEnableDisallowedPolicy = errors.New("esim: profile switch is not allowed by the active profile policy")
ErrESIMWrongProfileReenabling = errors.New("esim: profile cannot be re-enabled from the current profile state")
ErrESIMEnableCATBusy = errors.New("esim: card application toolkit is busy; retry enabling later")
ErrESIMEnableUndefined = errors.New("esim: eUICC returned undefinedError while enabling this profile; the card did not provide a more specific reason")
)
// enableProfileResponseError maps the complete SGP.22 EnableProfileResult
// enumeration. In particular, 0x7F is undefinedError: it is a definite card
// rejection, but it does not prove that the subscription itself is unusable.
func enableProfileResponseError(result byte, payload []byte) error {
raw := strings.ToUpper(hex.EncodeToString(payload))
wrap := func(cause error) error {
return fmt.Errorf("%w (result=0x%02X, raw %s)", cause, result, raw)
}
switch result {
case 0:
return nil
case 1:
return wrap(ErrESIMEnableProfileNotFound)
case 2:
return wrap(ErrESIMProfileNotDisabled)
case 3:
return wrap(ErrESIMEnableDisallowedPolicy)
case 4:
return wrap(ErrESIMWrongProfileReenabling)
case 5:
return wrap(ErrESIMEnableCATBusy)
case 0x7F:
return wrap(ErrESIMEnableUndefined)
default:
return fmt.Errorf("esim: eUICC rejected EnableProfile, result=0x%02X (raw %s)", result, raw)
}
}
func profileSwitchVerificationTimeout(manager *Manager) time.Duration {
// A slow EC20 can spend one long command timeout resetting, then several
// snapshot attempts reopening its USB serial port. Keep the HTTP operation
// alive for that recovery, with a practical floor for unusually slow hosts.
timeout := manager.longTimeout*2 + 90*time.Second
if timeout < 2*time.Minute {
return 2 * time.Minute
}
return timeout
}
// verifySwitchedICCID performs a fresh baseband read after recovery. An ES10c
// result of zero only means the eUICC accepted the operation; the state change
// is finalized by REFRESH/reset. The UI must not report success until the modem
// is actually exposing the requested ICCID.
func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error {
expected = strings.TrimSpace(expected)
const attempts = 6
var lastICCID string
var lastErr error
for attempt := 0; attempt < attempts; attempt++ {
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)
}
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
}
}
if attempt+1 < attempts {
select {
case <-time.After(2 * time.Second):
case <-ctx.Done():
return fmt.Errorf("esim: verify enabled profile %s: %w", expected, ctx.Err())
}
}
}
if lastICCID != "" {
return fmt.Errorf("esim: EnableProfile was accepted but target ICCID %s did not become active after modem recovery (current ICCID %s)", expected, lastICCID)
}
return fmt.Errorf("esim: EnableProfile was accepted but target ICCID %s could not be verified after modem recovery: %w", expected, lastErr)
}