mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
- 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.
1019 lines
33 KiB
Go
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)
|
|
}
|