esim.go 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887
  1. package device
  2. import (
  3. "context"
  4. "encoding/hex"
  5. "errors"
  6. "fmt"
  7. "strings"
  8. "time"
  9. "vocat/internal/i18n"
  10. "vocat/internal/modem"
  11. )
  12. // eUICC / eSIM (LPA, SGP.22) access over the modem's AT+CSIM APDU passthrough.
  13. //
  14. // The Quectel EC20's AT+CCHO logical-channel command is non-functional on the
  15. // deployed firmware, so — like lpac's `at_csim` backend — we drive MANAGE
  16. // CHANNEL / SELECT / STORE DATA manually over AT+CSIM. The logical channel is
  17. // separate from the modem's own basic channel, so reading and switching
  18. // profiles does not disturb the modem's network registration.
  19. //
  20. // Verified against a live eUICC: channel open/select/STORE DATA/close all
  21. // succeed and GetProfilesInfo returns every profile with no authentication.
  22. // isdRAID is the standard ISD-R AID that hosts the LPA functions (ES10).
  23. const isdRAID = "A0000005591010FFFFFFFF8900000100"
  24. // eSTK multi-SE products expose each eUICC storage through its own vendor
  25. // ISD-R AID. The standard GSMA AID aliases one of them, so probing only that
  26. // AID silently hides the second storage.
  27. const (
  28. estkProductAID = "A06573746B6D65FFFFFFFFFFFF6D6774"
  29. estkSE0AID = "A06573746B6D65FFFF4953442D522030"
  30. estkSE1AID = "A06573746B6D65FFFF4953442D522031"
  31. )
  32. func targetEuiccAID(aidHex string) string {
  33. aidHex = strings.ToUpper(strings.TrimSpace(aidHex))
  34. if aidHex == "" {
  35. return isdRAID
  36. }
  37. return aidHex
  38. }
  39. var (
  40. errNoLogicalChannel = errors.New("esim: modem could not open a logical channel")
  41. errNoEUICC = errors.New("esim: no eUICC (ISD-R) found on the inserted card")
  42. errESIMSW = errors.New("esim: eUICC returned an error status word")
  43. errESIMRecovering = errors.New("esim: profile-switch recovery is in progress")
  44. errEUICCChannelStuck = errors.New("esim: eUICC APDU channel is unavailable until the modem restarts")
  45. )
  46. // ErrNoEUICC is returned when the inserted card exposes no eUICC ISD-R, so the
  47. // HTTP layer can render the empty state instead of an error.
  48. var ErrNoEUICC = errNoEUICC
  49. // ErrEUICCChannelStuck means the modem kept rejecting MANAGE CHANNEL or
  50. // SELECT ISD-R with the EC20's non-descriptive +CME ERROR: 0 after retries.
  51. // This is observed after SIM hot-swap and requires a modem restart; repeating
  52. // the same profile-list request cannot reset the baseband's UIM/APDU state.
  53. var ErrEUICCChannelStuck = errEUICCChannelStuck
  54. // EsimProfile is one eUICC profile decoded from GetProfilesInfo.
  55. type EsimProfile struct {
  56. ICCID string `json:"iccid"`
  57. AID string `json:"aidHex"`
  58. ServiceProvider string `json:"serviceProviderName,omitempty"`
  59. Name string `json:"name,omitempty"`
  60. Nickname string `json:"nickname,omitempty"`
  61. State int `json:"state"` // 0 = disabled, 1 = enabled
  62. StateText string `json:"stateText"`
  63. Class string `json:"classText,omitempty"`
  64. }
  65. // EsimInfo is the decoded profile list plus chip metadata for one eUICC.
  66. type EsimInfo struct {
  67. EID string `json:"eid,omitempty"`
  68. AID string `json:"aidHex,omitempty"`
  69. Profiles []EsimProfile `json:"profiles"`
  70. }
  71. // EsimInventoryEntry is one independently addressable eUICC storage together
  72. // with its profile list and production metadata.
  73. type EsimInventoryEntry struct {
  74. Info EsimInfo
  75. Chip EsimChipInfo
  76. }
  77. // EnabledProfile returns the currently enabled profile, or nil.
  78. func (info *EsimInfo) EnabledProfile() *EsimProfile {
  79. for index := range info.Profiles {
  80. if info.Profiles[index].State == 1 {
  81. return &info.Profiles[index]
  82. }
  83. }
  84. return nil
  85. }
  86. // decodeICCID converts a GSM BCD (nibble-swapped) ICCID to its digit string.
  87. func decodeICCID(raw []byte) string {
  88. var builder strings.Builder
  89. for _, b := range raw {
  90. lo, hi := b&0x0F, b>>4
  91. if lo <= 9 {
  92. builder.WriteByte(byte('0' + lo))
  93. }
  94. if hi <= 9 {
  95. builder.WriteByte(byte('0' + hi))
  96. }
  97. }
  98. return builder.String()
  99. }
  100. // encodeFixedDigitBCD converts decimal digits to GSM BCD (nibble-swapped) and
  101. // pads every unused nibble with F up to the requested fixed field size.
  102. func encodeFixedDigitBCD(digits string, octets int, label string) ([]byte, error) {
  103. digits = strings.TrimSpace(digits)
  104. if digits == "" {
  105. return nil, fmt.Errorf("esim: empty %s", label)
  106. }
  107. if octets <= 0 || len(digits) > octets*2 {
  108. return nil, fmt.Errorf("esim: %s exceeds its %d-byte field", label, octets)
  109. }
  110. out := make([]byte, octets)
  111. for index := range out {
  112. out[index] = 0xFF
  113. }
  114. for index := 0; index < len(digits); index += 2 {
  115. lo := digits[index]
  116. if lo < '0' || lo > '9' {
  117. return nil, fmt.Errorf("esim: invalid %s digit %q", label, lo)
  118. }
  119. // hiNibble is the high nibble value; a trailing odd digit pads with 0xF.
  120. hiNibble := byte(0xF)
  121. if index+1 < len(digits) {
  122. hi := digits[index+1]
  123. if hi < '0' || hi > '9' {
  124. return nil, fmt.Errorf("esim: invalid %s digit %q", label, hi)
  125. }
  126. hiNibble = hi - '0'
  127. }
  128. out[index/2] = hiNibble<<4 | (lo - '0')
  129. }
  130. return out, nil
  131. }
  132. // SGP.22 defines Iccid as the 10-octet EF-ICCID representation even when the
  133. // printed identifier contains only 18 or 19 digits.
  134. func encodeICCID(digits string) ([]byte, error) {
  135. return encodeFixedDigitBCD(digits, 10, "ICCID")
  136. }
  137. func buildEnableProfileRequest(iccid string) ([]byte, error) {
  138. bcd, err := encodeICCID(iccid)
  139. if err != nil {
  140. return nil, err
  141. }
  142. profileID := derConstruct(0xA0, derEncode(0x5A, bcd))
  143. return derConstruct(0xBF31, profileID, derEncode(0x81, []byte{0xFF})), nil
  144. }
  145. // parseCSIM extracts the payload and status word from an AT+CSIM response.
  146. func parseCSIM(response modem.Response) ([]byte, int, error) {
  147. value := valueAfterPrefix(response, "+CSIM:")
  148. if value == "" {
  149. return nil, 0, errors.New("esim: modem did not return a +CSIM result")
  150. }
  151. parts := csvValues(value)
  152. if len(parts) < 2 {
  153. return nil, 0, fmt.Errorf("esim: malformed +CSIM result %q", value)
  154. }
  155. hexData := strings.Trim(parts[1], `"`)
  156. if len(hexData) < 4 {
  157. return nil, 0, fmt.Errorf("esim: short +CSIM data %q", hexData)
  158. }
  159. raw, err := hex.DecodeString(hexData)
  160. if err != nil {
  161. return nil, 0, fmt.Errorf("esim: decode +CSIM data: %w", err)
  162. }
  163. sw := int(raw[len(raw)-2])<<8 | int(raw[len(raw)-1])
  164. return raw[:len(raw)-2], sw, nil
  165. }
  166. // euiccChannel is an open logical channel to the eUICC's ISD-R.
  167. type euiccChannel struct {
  168. manager *Manager
  169. id string
  170. channel int
  171. }
  172. // csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage
  173. // makes the eUICC decrypt/write sizeable SCP03t segments on-card, which can exceed
  174. // the modem's default 3s command timeout, so eSIM APDUs get a longer budget.
  175. const csimAPDUTimeout = 30 * time.Second
  176. // csim sends one raw APDU over AT+CSIM and returns payload + status word.
  177. func (manager *Manager) csim(ctx context.Context, id string, apdu []byte) ([]byte, int, error) {
  178. command := fmt.Sprintf("AT+CSIM=%d,\"%s\"", len(apdu)*2, strings.ToUpper(hex.EncodeToString(apdu)))
  179. state, err := manager.lookup(id)
  180. if err != nil {
  181. return nil, 0, err
  182. }
  183. state.opMu.Lock()
  184. defer state.opMu.Unlock()
  185. if err := manager.validateActive(id, state); err != nil {
  186. return nil, 0, err
  187. }
  188. client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
  189. if err != nil {
  190. return nil, 0, err
  191. }
  192. // Give each eUICC APDU its own generous deadline (withTimeout preserves an
  193. // existing one, so a shorter caller deadline still wins).
  194. apduCtx, cancel := context.WithTimeout(ctx, csimAPDUTimeout)
  195. defer cancel()
  196. response, err := manager.command(apduCtx, client, command)
  197. if err != nil {
  198. return nil, 0, err
  199. }
  200. return parseCSIM(response)
  201. }
  202. // openEuicc opens a logical channel and selects the ISD-R AID on it.
  203. func (manager *Manager) openEuicc(ctx context.Context, id string) (*euiccChannel, error) {
  204. return manager.openEuiccAID(ctx, id, isdRAID)
  205. }
  206. func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) {
  207. if manager.esimRecoveryActive(id) {
  208. return nil, errESIMRecovering
  209. }
  210. var lastErr error
  211. for attempt := 0; attempt < 3; attempt++ {
  212. channel, err := manager.openEuiccOnceAID(ctx, id, aidHex)
  213. if err == nil {
  214. return channel, nil
  215. }
  216. lastErr = err
  217. if !isTransientEuiccCME(err) {
  218. return nil, err
  219. }
  220. if attempt == 2 {
  221. return nil, fmt.Errorf("%w: %v", ErrEUICCChannelStuck, err)
  222. }
  223. delay := time.Duration(attempt+1) * 250 * time.Millisecond
  224. select {
  225. case <-ctx.Done():
  226. return nil, ctx.Err()
  227. case <-time.After(delay):
  228. }
  229. }
  230. return nil, lastErr
  231. }
  232. func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccChannel, error) {
  233. return manager.openEuiccOnceAID(ctx, id, isdRAID)
  234. }
  235. func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) {
  236. // MANAGE CHANNEL (open): 00 70 00 00 01 -> "<channel> 90 00". This EC20
  237. // firmware requires the explicit one-byte expected length: Le=00 opens a
  238. // channel but then rejects SELECT ISD-R at the AT+CSIM layer.
  239. payload, sw, err := manager.csim(ctx, id, []byte{0x00, 0x70, 0x00, 0x00, 0x01})
  240. if err != nil {
  241. return nil, err
  242. }
  243. if sw != 0x9000 || len(payload) != 1 {
  244. return nil, errNoLogicalChannel
  245. }
  246. channel := &euiccChannel{manager: manager, id: id, channel: int(payload[0])}
  247. // SELECT ISD-R by AID on the logical channel: CLA=channel, INS=A4, P1=04.
  248. aidHex = strings.ToUpper(strings.TrimSpace(aidHex))
  249. aid, err := hex.DecodeString(aidHex)
  250. if err != nil || len(aid) == 0 || len(aid) > 255 {
  251. channel.close(context.Background())
  252. return nil, fmt.Errorf("esim: invalid ISD-R AID %q", aidHex)
  253. }
  254. selectAID := append([]byte{byte(channel.channel), 0xA4, 0x04, 0x00, byte(len(aid))}, aid...)
  255. _, sw, err = manager.csim(ctx, id, selectAID)
  256. if err != nil {
  257. channel.close(context.Background())
  258. return nil, err
  259. }
  260. if sw>>8 == 0x61 {
  261. // Drain the select FCP the card is holding with a proper GET RESPONSE
  262. // (CLA=0x80|channel, INS=0xC0). transmit() injects the channel into the
  263. // CLA low nibble, so the first byte here stays 0x80.
  264. _, sw, _ = channel.transmit(ctx, []byte{0x80, 0xC0, 0x00, 0x00, byte(sw & 0xFF)}, 0x80)
  265. }
  266. if sw != 0x9000 {
  267. channel.close(context.Background())
  268. return nil, errNoEUICC
  269. }
  270. return channel, nil
  271. }
  272. // discoverEuiccAIDs detects eSTK multi-SE cards without changing any profile
  273. // state. The vendor product applet is selected only as a read-only capability
  274. // probe; when present, both vendor ISD-R AIDs are tried. Per OpenEUICC's eSTK
  275. // integration, the generic GSMA AID is not appended after an eSTK SE opens,
  276. // because it aliases one of the same storages.
  277. func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []string {
  278. product, err := manager.openEuiccAID(ctx, id, estkProductAID)
  279. if err != nil {
  280. return []string{isdRAID}
  281. }
  282. product.close(context.Background())
  283. var found []string
  284. for _, aid := range []string{estkSE0AID, estkSE1AID} {
  285. channel, err := manager.openEuiccAID(ctx, id, aid)
  286. if err != nil {
  287. continue
  288. }
  289. channel.close(context.Background())
  290. found = append(found, aid)
  291. }
  292. if len(found) == 0 {
  293. return []string{isdRAID}
  294. }
  295. return found
  296. }
  297. func isTransientEuiccCME(err error) bool {
  298. var commandErr *modem.CommandError
  299. return errors.As(err, &commandErr) &&
  300. strings.EqualFold(strings.TrimSpace(commandErr.Final), "+CME ERROR: 0")
  301. }
  302. // close releases the logical channel (MANAGE CHANNEL close).
  303. func (channel *euiccChannel) close(ctx context.Context) {
  304. closeAPDU := []byte{0x00, 0x70, 0x80, byte(channel.channel), 0x00}
  305. _, _, _ = channel.manager.csim(ctx, channel.id, closeAPDU)
  306. }
  307. // transmit sends one APDU on the logical channel (CLA high nibble from insClass,
  308. // channel number in the low nibble), following 61xx "more data" continuations,
  309. // and returns the assembled payload.
  310. func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass byte) ([]byte, int, error) {
  311. apdu[0] = (apdu[0] & 0xF0) | byte(channel.channel)
  312. payload, sw, err := channel.manager.csim(ctx, channel.id, apdu)
  313. if err != nil {
  314. return nil, 0, err
  315. }
  316. assembled := append([]byte(nil), payload...)
  317. guard := 0
  318. for sw>>8 == 0x61 && guard < 24 {
  319. guard++
  320. getResponse := []byte{0x80 | byte(channel.channel), 0xC0, 0x00, 0x00, byte(sw & 0xFF)}
  321. frag, nextSW, err := channel.manager.csim(ctx, channel.id, getResponse)
  322. if err != nil {
  323. return nil, 0, err
  324. }
  325. assembled = append(assembled, frag...)
  326. sw = nextSW
  327. }
  328. return assembled, sw, nil
  329. }
  330. // es10 runs one ES10 command: it wraps the DER request body in one or more
  331. // chained STORE DATA APDUs (see storeDataChained) and returns the assembled
  332. // response body. Small requests produce a single P1=0x91/P2=0x00 block, exactly
  333. // as before; larger ones (AuthenticateServer, LoadBoundProfilePackage, …) are
  334. // split across continuation blocks.
  335. func (channel *euiccChannel) es10(ctx context.Context, derRequest []byte) ([]byte, error) {
  336. return channel.storeDataChained(ctx, derRequest)
  337. }
  338. // derNode is one decoded BER-TLV element (long-form tags and lengths handled).
  339. type derNode struct {
  340. tag int
  341. value []byte
  342. children []*derNode
  343. }
  344. // derParse decodes a sequence of BER-TLV elements. Constructed elements have
  345. // their value recursively decoded into children.
  346. func derParse(data []byte) []*derNode {
  347. var nodes []*derNode
  348. index := 0
  349. for index < len(data) {
  350. node, next, ok := derDecodeOne(data, index)
  351. if !ok {
  352. break
  353. }
  354. nodes = append(nodes, node)
  355. index = next
  356. }
  357. return nodes
  358. }
  359. func derDecodeOne(data []byte, start int) (*derNode, int, bool) {
  360. index := start
  361. if index >= len(data) {
  362. return nil, 0, false
  363. }
  364. first := data[index]
  365. index++
  366. constructed := first&0x20 != 0
  367. tag := int(first)
  368. if first&0x1F == 0x1F { // long-form tag: keep the full tag bytes (e.g. 9F70, BF2D)
  369. for index < len(data) {
  370. b := data[index]
  371. index++
  372. tag = tag<<8 | int(b)
  373. if b&0x80 == 0 {
  374. break
  375. }
  376. }
  377. }
  378. if index >= len(data) {
  379. return nil, 0, false
  380. }
  381. lengthByte := data[index]
  382. index++
  383. length := 0
  384. if lengthByte&0x80 == 0 {
  385. length = int(lengthByte)
  386. } else {
  387. count := int(lengthByte & 0x7F)
  388. if count == 0 || count > 4 || index+count > len(data) {
  389. return nil, 0, false
  390. }
  391. for i := 0; i < count; i++ {
  392. length = length<<8 | int(data[index])
  393. index++
  394. }
  395. }
  396. if index+length > len(data) {
  397. return nil, 0, false
  398. }
  399. value := data[index : index+length]
  400. node := &derNode{tag: tag, value: value}
  401. if constructed {
  402. node.children = derParse(value)
  403. }
  404. return node, index + length, true
  405. }
  406. // derValue returns the raw value of the first node with tag.
  407. func derValue(nodes []*derNode, tag int) []byte {
  408. for _, node := range nodes {
  409. if node.tag == tag {
  410. return node.value
  411. }
  412. }
  413. return nil
  414. }
  415. // derFindAll recursively collects every node with the given tag. Icons live in
  416. // primitive (non-constructed) leaves, so their bytes are never descended into.
  417. func derFindAll(nodes []*derNode, tag int) []*derNode {
  418. var found []*derNode
  419. for _, node := range nodes {
  420. if node.tag == tag {
  421. found = append(found, node)
  422. }
  423. found = append(found, derFindAll(node.children, tag)...)
  424. }
  425. return found
  426. }
  427. // parseProfilesInfo decodes a GetProfilesInfo response body into profiles. The
  428. // ProfileInfo records (tag E3) are collected wherever they sit (some cards use
  429. // a BF3D root, others echo BF2D, with an optional A0 list wrapper).
  430. func parseProfilesInfo(payload []byte) []EsimProfile {
  431. records := derFindAll(derParse(payload), 0xE3)
  432. var profiles []EsimProfile
  433. seenICCID := make(map[string]struct{})
  434. for _, record := range records {
  435. fields := record.children
  436. profile := EsimProfile{
  437. ServiceProvider: string(derValue(fields, 0x91)),
  438. Name: string(derValue(fields, 0x92)),
  439. Nickname: string(derValue(fields, 0x90)),
  440. }
  441. if iccid := derValue(fields, 0x5A); iccid != nil {
  442. profile.ICCID = decodeICCID(iccid)
  443. }
  444. // E3 is reused by constructed metadata inside some eUICC 4.x profile
  445. // records. Recursive discovery is needed for cards that wrap the real
  446. // ProfileInfo list, but those nested E3 nodes are not profiles and carry
  447. // no ICCID. Never expose an entry that cannot be safely addressed by the
  448. // ES10c profile operations; also collapse duplicate ICCIDs defensively.
  449. if !validProfileICCID(profile.ICCID) {
  450. continue
  451. }
  452. if _, exists := seenICCID[profile.ICCID]; exists {
  453. continue
  454. }
  455. seenICCID[profile.ICCID] = struct{}{}
  456. if aid := derValue(fields, 0x4F); aid != nil {
  457. profile.AID = strings.ToUpper(hex.EncodeToString(aid))
  458. }
  459. if state := derValue(fields, 0x9F70); len(state) == 1 {
  460. profile.State = int(state[0])
  461. }
  462. profile.StateText = i18n.T("已禁用")
  463. if profile.State == 1 {
  464. profile.StateText = i18n.T("已启用")
  465. }
  466. if class := derValue(fields, 0x95); len(class) == 1 {
  467. profile.Class = map[int]string{0: "test", 1: "provisioning", 2: "operational"}[int(class[0])]
  468. }
  469. profiles = append(profiles, profile)
  470. }
  471. return profiles
  472. }
  473. func validProfileICCID(iccid string) bool {
  474. if len(iccid) < 18 || len(iccid) > 20 || !strings.HasPrefix(iccid, "89") {
  475. return false
  476. }
  477. for _, character := range iccid {
  478. if character < '0' || character > '9' {
  479. return false
  480. }
  481. }
  482. return true
  483. }
  484. // ESIMListProfiles reads the eUICC profile list via ES10c GetProfilesInfo.
  485. func (manager *Manager) ESIMListProfiles(ctx context.Context, id string) (EsimInfo, error) {
  486. manager.esimMu.Lock()
  487. defer manager.esimMu.Unlock()
  488. if manager.esimRecoveryActive(id) {
  489. if cached, ok := manager.cachedESIMInfo(id); ok {
  490. return cached, nil
  491. }
  492. return EsimInfo{}, errESIMRecovering
  493. }
  494. channel, err := manager.openEuicc(ctx, id)
  495. if err != nil {
  496. return EsimInfo{}, err
  497. }
  498. defer channel.close(context.Background())
  499. payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo
  500. if err != nil {
  501. return EsimInfo{}, err
  502. }
  503. info := EsimInfo{Profiles: parseProfilesInfo(payload)}
  504. manager.cacheESIMInfo(id, info)
  505. return info, nil
  506. }
  507. // ESIMSwitchProfile enables one profile by ICCID via ES10c EnableProfile.
  508. func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid string, aidHex string) error {
  509. iccid = strings.TrimSpace(iccid)
  510. if iccid == "" {
  511. return errors.New("esim: an ICCID is required")
  512. }
  513. der, err := buildEnableProfileRequest(iccid)
  514. if err != nil {
  515. return err
  516. }
  517. manager.esimMu.Lock()
  518. if err := manager.waitForESIMRecovery(ctx, id); err != nil {
  519. manager.esimMu.Unlock()
  520. return err
  521. }
  522. channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex))
  523. if err != nil {
  524. manager.esimMu.Unlock()
  525. return err
  526. }
  527. // EnableProfile request (SGP.22 ES10c, per lpac):
  528. // BF31 { A0 { 5A <iccid bcd> } 81 01 FF } (refresh = yes)
  529. // The profileIdentifier is an explicitly-tagged [0] CHOICE, so the ICCID
  530. // element (5A) must be wrapped in A0 — omitting that wrapper makes the eUICC
  531. // reject the command with result 0x7F (undefined error). refreshFlag (81)
  532. // stays a sibling of A0, directly under BF31.
  533. // EnableProfile is a non-idempotent commit. Once its APDU starts, a browser
  534. // disconnect or reverse-proxy timeout must not cancel it halfway through and
  535. // skip the modem reset, otherwise EC20 remains in SIM failure (+CME 13).
  536. commitContext, cancelCommit := context.WithTimeout(context.WithoutCancel(ctx), csimAPDUTimeout)
  537. payload, err := channel.es10(commitContext, der)
  538. cancelCommit()
  539. // Release the logical channel before any reset: openEuicc's csim holds
  540. // opMu only for the duration of each APDU, so by here the lock is free.
  541. closeContext, cancelClose := context.WithTimeout(context.Background(), csimAPDUTimeout)
  542. channel.close(closeContext)
  543. cancelClose()
  544. if err != nil {
  545. // The card may have committed immediately before the transport error. A
  546. // detached reset is safe in either case and prevents an uncertain switch
  547. // from leaving the modem's SIM cache unusable.
  548. manager.startProfileSwitchRecovery(id)
  549. manager.esimMu.Unlock()
  550. return err
  551. }
  552. // A transport SW 9000 only means the APDU reached the eUICC. The real outcome
  553. // is the EnableProfile result code (tag 80) inside the BF31 response — honour
  554. // it so a rejected switch is surfaced instead of reported as "switched".
  555. result, ok := enableProfileResult(payload)
  556. if !ok {
  557. manager.startProfileSwitchRecovery(id)
  558. manager.esimMu.Unlock()
  559. return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload)))
  560. }
  561. if err := enableProfileResponseError(byte(result), payload); err != nil {
  562. manager.esimMu.Unlock()
  563. return err
  564. }
  565. manager.markCachedProfileEnabled(id, iccid)
  566. // The eUICC accepted the target profile. Reset and repopulate the modem in
  567. // a detached recovery so it survives an HTTP disconnect, but keep this API
  568. // call pending until the live modem ICCID proves that the switch took effect.
  569. manager.startProfileSwitchRecovery(id)
  570. manager.esimMu.Unlock()
  571. verifyContext, cancelVerify := context.WithTimeout(context.WithoutCancel(ctx), profileSwitchVerificationTimeout(manager))
  572. defer cancelVerify()
  573. if err := manager.waitForESIMRecovery(verifyContext, id); err != nil {
  574. return err
  575. }
  576. return manager.verifySwitchedICCID(verifyContext, id, iccid)
  577. }
  578. func (manager *Manager) startProfileSwitchRecovery(id string) {
  579. done := make(chan struct{})
  580. manager.esimRecoveryMu.Lock()
  581. if manager.esimRecoveries == nil {
  582. manager.esimRecoveries = make(map[string]chan struct{})
  583. }
  584. if manager.esimRecoveries[id] != nil {
  585. manager.esimRecoveryMu.Unlock()
  586. return
  587. }
  588. manager.esimRecoveries[id] = done
  589. manager.esimRecoveryMu.Unlock()
  590. go func() {
  591. manager.recoverAfterProfileSwitch(id)
  592. manager.esimRecoveryMu.Lock()
  593. if manager.esimRecoveries[id] == done {
  594. delete(manager.esimRecoveries, id)
  595. close(done)
  596. }
  597. manager.esimRecoveryMu.Unlock()
  598. }()
  599. }
  600. func (manager *Manager) waitForESIMRecovery(ctx context.Context, id string) error {
  601. manager.esimRecoveryMu.Lock()
  602. done := manager.esimRecoveries[id]
  603. manager.esimRecoveryMu.Unlock()
  604. if done == nil {
  605. return nil
  606. }
  607. select {
  608. case <-done:
  609. return nil
  610. case <-ctx.Done():
  611. return fmt.Errorf("esim: wait for profile-switch recovery: %w", ctx.Err())
  612. }
  613. }
  614. func (manager *Manager) esimRecoveryActive(id string) bool {
  615. manager.esimRecoveryMu.Lock()
  616. active := manager.esimRecoveries[id] != nil
  617. manager.esimRecoveryMu.Unlock()
  618. return active
  619. }
  620. func cloneESIMInfo(info EsimInfo) EsimInfo {
  621. info.Profiles = append([]EsimProfile(nil), info.Profiles...)
  622. return info
  623. }
  624. func (manager *Manager) cachedESIMInfo(id string) (EsimInfo, bool) {
  625. manager.esimCacheMu.RLock()
  626. info, ok := manager.esimCache[id]
  627. manager.esimCacheMu.RUnlock()
  628. return cloneESIMInfo(info), ok
  629. }
  630. func (manager *Manager) cacheESIMInfo(id string, info EsimInfo) {
  631. manager.esimCacheMu.Lock()
  632. manager.esimCache[id] = cloneESIMInfo(info)
  633. manager.esimCacheMu.Unlock()
  634. }
  635. func (manager *Manager) markCachedProfileEnabled(id, iccid string) {
  636. manager.esimCacheMu.Lock()
  637. info, ok := manager.esimCache[id]
  638. if ok {
  639. for index := range info.Profiles {
  640. if info.Profiles[index].ICCID == iccid {
  641. info.Profiles[index].State = 1
  642. info.Profiles[index].StateText = i18n.T("已启用")
  643. } else {
  644. info.Profiles[index].State = 0
  645. info.Profiles[index].StateText = i18n.T("已禁用")
  646. }
  647. }
  648. manager.esimCache[id] = info
  649. }
  650. manager.esimCacheMu.Unlock()
  651. }
  652. func (manager *Manager) markCachedProfileDisabled(id, iccid string) {
  653. manager.esimCacheMu.Lock()
  654. info, ok := manager.esimCache[id]
  655. if ok {
  656. for index := range info.Profiles {
  657. if info.Profiles[index].ICCID == iccid {
  658. info.Profiles[index].State = 0
  659. info.Profiles[index].StateText = i18n.T("已禁用")
  660. break
  661. }
  662. }
  663. manager.esimCache[id] = info
  664. }
  665. manager.esimCacheMu.Unlock()
  666. }
  667. func (manager *Manager) removeCachedProfile(id, iccid string) {
  668. manager.esimCacheMu.Lock()
  669. info, ok := manager.esimCache[id]
  670. if ok {
  671. profiles := info.Profiles[:0]
  672. for _, profile := range info.Profiles {
  673. if profile.ICCID != iccid {
  674. profiles = append(profiles, profile)
  675. }
  676. }
  677. info.Profiles = profiles
  678. manager.esimCache[id] = info
  679. }
  680. manager.esimCacheMu.Unlock()
  681. }
  682. func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
  683. manager.esimCacheMu.Lock()
  684. info, ok := manager.esimCache[id]
  685. if ok {
  686. for index := range info.Profiles {
  687. if info.Profiles[index].ICCID == iccid {
  688. info.Profiles[index].Nickname = nickname
  689. break
  690. }
  691. }
  692. manager.esimCache[id] = info
  693. }
  694. manager.esimCacheMu.Unlock()
  695. }
  696. // recoverAfterProfileSwitch owns the post-commit reset independently of the
  697. // initiating HTTP request. EC20 commonly drops the AT port while processing
  698. // CFUN=1,1, so the reset error is intentionally followed by discovery retries.
  699. func (manager *Manager) recoverAfterProfileSwitch(id string) {
  700. resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
  701. _ = manager.rebootForProfileSwitch(resetContext, id)
  702. cancelReset()
  703. manager.refreshAfterProfileSwitch(id)
  704. }
  705. // refreshAfterProfileSwitch repopulates the device snapshot in the background
  706. // after an eSIM profile switch + modem reboot. /overview only serves the cached
  707. // snapshot, and nothing else live-reads post-switch, so without this the card
  708. // stays on "--" forever. The EC20 takes ~10-15s to come back from AT+CFUN=1,1,
  709. // so we delay first, then retry with backoff. Transport errors during the
  710. // reboot window are fine — Fix 1 discards the poisoned client and reopens on
  711. // the next attempt. All errors are swallowed: this is best-effort self-healing
  712. // and setResult already records the last failure for the UI.
  713. func (manager *Manager) refreshAfterProfileSwitch(id string) {
  714. const (
  715. settle = 8 * time.Second
  716. interval = 4 * time.Second
  717. attempts = 6
  718. )
  719. time.Sleep(settle)
  720. for attempt := 0; attempt < attempts; attempt++ {
  721. ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
  722. _, err := manager.Refresh(ctx, id)
  723. cancel()
  724. if err == nil {
  725. return
  726. }
  727. time.Sleep(interval)
  728. }
  729. }
  730. // enableProfileResult extracts the EnableProfile result code (tag 80) from the
  731. // ES10c response body. ok is false when no result code is present.
  732. func enableProfileResult(payload []byte) (int, bool) {
  733. for _, node := range derFindAll(derParse(payload), 0x80) {
  734. if len(node.value) > 0 {
  735. return int(node.value[0]), true
  736. }
  737. }
  738. return 0, false
  739. }
  740. var (
  741. ErrESIMEnableProfileNotFound = errors.New("esim: profile to enable was not found on the selected eUICC")
  742. ErrESIMProfileNotDisabled = errors.New("esim: profile is not currently disabled")
  743. ErrESIMEnableDisallowedPolicy = errors.New("esim: profile switch is not allowed by the active profile policy")
  744. ErrESIMWrongProfileReenabling = errors.New("esim: profile cannot be re-enabled from the current profile state")
  745. ErrESIMEnableCATBusy = errors.New("esim: card application toolkit is busy; retry enabling later")
  746. ErrESIMEnableUndefined = errors.New("esim: eUICC returned undefinedError while enabling this profile; the card did not provide a more specific reason")
  747. )
  748. // enableProfileResponseError maps the complete SGP.22 EnableProfileResult
  749. // enumeration. In particular, 0x7F is undefinedError: it is a definite card
  750. // rejection, but it does not prove that the subscription itself is unusable.
  751. func enableProfileResponseError(result byte, payload []byte) error {
  752. raw := strings.ToUpper(hex.EncodeToString(payload))
  753. wrap := func(cause error) error {
  754. return fmt.Errorf("%w (result=0x%02X, raw %s)", cause, result, raw)
  755. }
  756. switch result {
  757. case 0:
  758. return nil
  759. case 1:
  760. return wrap(ErrESIMEnableProfileNotFound)
  761. case 2:
  762. return wrap(ErrESIMProfileNotDisabled)
  763. case 3:
  764. return wrap(ErrESIMEnableDisallowedPolicy)
  765. case 4:
  766. return wrap(ErrESIMWrongProfileReenabling)
  767. case 5:
  768. return wrap(ErrESIMEnableCATBusy)
  769. case 0x7F:
  770. return wrap(ErrESIMEnableUndefined)
  771. default:
  772. return fmt.Errorf("esim: eUICC rejected EnableProfile, result=0x%02X (raw %s)", result, raw)
  773. }
  774. }
  775. func profileSwitchVerificationTimeout(manager *Manager) time.Duration {
  776. // A slow EC20 can spend one long command timeout resetting, then several
  777. // snapshot attempts reopening its USB serial port. Keep the HTTP operation
  778. // alive for that recovery, with a practical floor for unusually slow hosts.
  779. timeout := manager.longTimeout*2 + 90*time.Second
  780. if timeout < 2*time.Minute {
  781. return 2 * time.Minute
  782. }
  783. return timeout
  784. }
  785. // verifySwitchedICCID performs a fresh baseband read after recovery. An ES10c
  786. // result of zero only means the eUICC accepted the operation; the state change
  787. // is finalized by REFRESH/reset. The UI must not report success until the modem
  788. // is actually exposing the requested ICCID.
  789. func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error {
  790. expected = strings.TrimSpace(expected)
  791. const attempts = 6
  792. var lastICCID string
  793. var lastErr error
  794. for attempt := 0; attempt < attempts; attempt++ {
  795. for _, command := range []string{"AT+CCID", "AT+QCCID"} {
  796. commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
  797. response, err := manager.ExecuteAT(commandContext, id, command)
  798. cancel()
  799. if err != nil {
  800. lastErr = err
  801. continue
  802. }
  803. live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
  804. if live == "" {
  805. lastErr = errors.New("modem response contained no valid ICCID")
  806. continue
  807. }
  808. lastICCID = live
  809. if live == expected {
  810. return nil
  811. }
  812. lastErr = fmt.Errorf("modem still reports ICCID %s", live)
  813. break
  814. }
  815. if attempt+1 < attempts {
  816. select {
  817. case <-time.After(2 * time.Second):
  818. case <-ctx.Done():
  819. return fmt.Errorf("esim: verify enabled profile %s: %w", expected, ctx.Err())
  820. }
  821. }
  822. }
  823. if lastICCID != "" {
  824. return fmt.Errorf("esim: EnableProfile was accepted but target ICCID %s did not become active after modem recovery (current ICCID %s)", expected, lastICCID)
  825. }
  826. return fmt.Errorf("esim: EnableProfile was accepted but target ICCID %s could not be verified after modem recovery: %w", expected, lastErr)
  827. }