| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887 |
- package device
- import (
- "context"
- "encoding/hex"
- "errors"
- "fmt"
- "strings"
- "time"
- "vocat/internal/i18n"
- "vocat/internal/modem"
- )
- // 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"
- // 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
- }
- // 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) {
- // 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
- }
- // discoverEuiccAIDs detects eSTK multi-SE cards without changing any profile
- // state. The vendor product applet is selected only as a read-only capability
- // probe; when present, both vendor ISD-R AIDs are tried. Per OpenEUICC's eSTK
- // integration, the generic GSMA AID is not appended after an eSTK SE opens,
- // because it 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 {
- return []string{isdRAID}
- }
- 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 []string{isdRAID}
- }
- return found
- }
- 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.manager.csim(ctx, channel.id, closeAPDU)
- }
- // 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.manager.csim(ctx, channel.id, 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.manager.csim(ctx, channel.id, 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
- }
- channel, err := manager.openEuicc(ctx, id)
- if err != nil {
- return EsimInfo{}, err
- }
- defer channel.close(context.Background())
- payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo
- if err != nil {
- return EsimInfo{}, err
- }
- info := EsimInfo{Profiles: parseProfilesInfo(payload)}
- manager.cacheESIMInfo(id, info)
- return info, nil
- }
- // 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.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) {
- 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) {
- 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)
- _, err := manager.Refresh(ctx, id)
- cancel()
- if err == nil {
- return
- }
- time.Sleep(interval)
- }
- }
- // 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++ {
- 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)
- }
|