package device import ( "context" "errors" "strings" ) // EsimDownloadParams are the SPA download form fields, mapped from the // snake_case query params by the HTTP layer. type EsimDownloadParams struct { SMDP string MatchingID string ConfirmationCode string AIDHex string IMEI string } // EsimProgress is one download step emitted to the SSE stream. type EsimProgress struct { Step string Msg string Pct int } // EsimDownloadResult reports a completed install. type EsimDownloadResult struct { ICCID string SpaceDelta int64 // bytes consumed (positive) Warning string } // ESIMDownloadProfile downloads and installs one eSIM profile (SGP.22 §3): // challenge/info → ES9+ InitiateAuthentication → ES10b AuthenticateServer → // ES9+ AuthenticateClient → ES10b PrepareDownload → ES9+ GetBoundProfilePackage // → ES10b LoadBoundProfilePackage → ES9+ HandleNotification. progress is invoked // with the SPA's expected step/pct sequence. The whole run holds the device's // eSIM lock so a concurrent list/switch cannot disturb the card mid-install. func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, params EsimDownloadParams, progress func(EsimProgress)) (*EsimDownloadResult, error) { smdp := strings.TrimSpace(params.SMDP) if smdp == "" { return nil, errors.New("esim: SM-DP+ 地址不能为空") } report := func(step, msg string, pct int) { if progress != nil { progress(EsimProgress{Step: step, Msg: msg, Pct: pct}) } } manager.esimMu.Lock() defer manager.esimMu.Unlock() report("preflight", "正在检查 eUICC 剩余空间...", 10) channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(params.AIDHex)) if err != nil { return nil, err } defer channel.close(context.Background()) // Free NVRAM before/after drives both the preflight check and space_delta. freeBefore := 0 if info2, err := channel.getEUICCInfo2(ctx); err == nil { if n, ok := euiccFreeNVRAM(info2); ok { freeBefore = n } } challenge, err := channel.getEUICCChallenge(ctx) if err != nil { return nil, err } info1, err := channel.getEUICCInfo1(ctx) if err != nil { return nil, err } client := newES9PClient(smdp) report("auth_client", "正在向 SM-DP+ 进行客户端身份认证...", 30) init, err := client.initiateAuthentication(ctx, challenge, info1) if err != nil { return nil, err } transactionID := init.TransactionID transactionIDBytes := derFindValue(init.ServerSigned1, 0x80) // Best-effort session cleanup if anything fails after the transaction opens // (card-side CancelSession BF41, then server-side ES9+ cancelSession). finished := false defer func() { if !finished && len(transactionIDBytes) > 0 { if cancelResp, cerr := channel.cancelSession(context.Background(), transactionIDBytes, 0x00); cerr == nil { _ = client.cancelSession(context.Background(), transactionID, cancelResp) } } }() authResponse, err := channel.authenticateServer(ctx, init, params.MatchingID, params.IMEI) if err != nil { return nil, err } // A "cert not trusted"/"matchingID refused"/"EID mismatch" failure surfaces // here, from the SM-DP+'s functionExecutionStatus. auth, err := client.authenticateClient(ctx, transactionID, authResponse) if err != nil { return nil, err } report("download", "正在获取 Profile 数据包...", 55) prepareResponse, err := channel.prepareDownload(ctx, auth, params.ConfirmationCode) if err != nil { return nil, err } bpp, err := client.getBoundProfilePackage(ctx, transactionID, prepareResponse) if err != nil { return nil, err } report("install", "正在将 Profile 写入 eUICC...", 80) installResponse, err := channel.loadBoundProfilePackage(ctx, bpp, func(done, total int) { if total > 0 { report("install", "正在将 Profile 写入 eUICC...", 80+done*8/total) } }) if err != nil { return nil, err } iccid, err := installationResult(installResponse) if err != nil { return nil, err } report("notify", "正在向运营商发送下载通知...", 90) warning := "" if err := client.handleNotification(ctx, installResponse); err != nil { warning = "Profile 已安装,但下载通知发送失败" } freeAfter := freeBefore if info2, err := channel.getEUICCInfo2(ctx); err == nil { if n, ok := euiccFreeNVRAM(info2); ok { freeAfter = n } } spaceDelta := freeBefore - freeAfter if spaceDelta <= 0 { spaceDelta = len(bpp) // fall back to the package size when NVRAM unreadable } // The HTTP layer owns the final "done" event (it attaches space_delta/warning). finished = true return &EsimDownloadResult{ICCID: iccid, SpaceDelta: int64(spaceDelta), Warning: warning}, nil } // ESIMDownloadErrorCode maps a download failure to a stable SPA error code. // Keep the matching deliberately tolerant because some SM-DP+ implementations // return only a free-form statusCodeData.message. func ESIMDownloadErrorCode(err error) string { var authenticateErr *esimAuthenticateError if errors.As(err, &authenticateErr) { return "euicc_authentication_failed" } var es9pErr *es9pError if errors.As(err, &es9pErr) { switch { case es9pErr.SubjectCode == "8.1" && es9pErr.ReasonCode == "4.8": return "euicc_insufficient_memory" case es9pErr.SubjectCode == "8.8.4" && es9pErr.ReasonCode == "3.7": return "euicc_ci_incompatible" case es9pErr.SubjectCode == "8.2.6" && es9pErr.ReasonCode == "3.8": return "activation_code_refused" case es9pErr.SubjectCode == "8.2.5" && es9pErr.ReasonCode == "3.7": return "profile_pool_empty" } } var installErr *esimInstallError if errors.As(err, &installErr) && installErr.ErrorReason == 10 { return "euicc_insufficient_memory" } lower := strings.ToLower(err.Error()) if strings.Contains(lower, "insufficient") || strings.Contains(lower, "空间不足") { return "euicc_insufficient_memory" } if strings.Contains(lower, "cert.dpauth") && (strings.Contains(lower, "root ca") || strings.Contains(lower, "public key supported by the euicc")) { return "euicc_ci_incompatible" } if strings.Contains(lower, "campaign resource pool is empty") || strings.Contains(lower, "no more profile available") { return "profile_pool_empty" } if strings.Contains(lower, "matchingid") && strings.Contains(lower, "refused") || lower == "refused" { return "activation_code_refused" } return "download_failed" } // EsimChipInfo describes the eUICC for the SPA's eSIM chip header. type EsimChipInfo struct { EID string AID string FreeNvramBytes int HasFreeNvram bool TrustedCIs []string // raw hex SubjectKeyIdentifiers Certificates []string // friendly CI names (证书) FirmwareVer string // euiccFirmwareVer (固件) Manufacturer string // EUM issuer → 生产商 DefaultSmdpAddress string // ES10a default SM-DP+ RootDsAddress string // ES10a Root SM-DS SAS string // sasAccreditationNumber } // ESIMChipInfo reads the eUICC's EID, EUICCInfo2, and configured addresses for // the chip header. It takes the eSIM lock like the other card ops. func (manager *Manager) ESIMChipInfo(ctx context.Context, id string) (*EsimChipInfo, error) { manager.esimMu.Lock() defer manager.esimMu.Unlock() channel, err := manager.openEuicc(ctx, id) if err != nil { return nil, err } defer channel.close(context.Background()) info, err := readEsimChipInfo(ctx, channel, isdRAID) if err != nil { return nil, err } return &info, nil } func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string) (EsimChipInfo, error) { info := EsimChipInfo{AID: aidHex} if eid, err := channel.getEID(ctx); err == nil { info.EID = eid info.Manufacturer = eumManufacturerForEID(eid) } if info2, err := channel.getEUICCInfo2(ctx); err == nil { if n, ok := euiccFreeNVRAM(info2); ok { info.FreeNvramBytes = n info.HasFreeNvram = true } info.TrustedCIs = euiccTrustedCIs(info2) info.FirmwareVer = euiccFirmwareVersion(info2) info.SAS = euiccSAS(info2) for _, hexID := range info.TrustedCIs { info.Certificates = append(info.Certificates, ciKeyFriendlyName(hexID)) } } if def, root := channel.getEuiccConfiguredAddresses(ctx); def != "" || root != "" { info.DefaultSmdpAddress = def info.RootDsAddress = root } // Report whatever we read (even partial); only a channel-open failure above // is fatal. A wholly-empty result means the eUICC exposed nothing usable. if info.EID == "" && !info.HasFreeNvram && len(info.TrustedCIs) == 0 { return EsimChipInfo{}, errors.New("esim: eUICC did not report chip info") } return info, nil } // ESIMInventory reads every independently addressable eUICC storage exposed by // the inserted card. It is entirely read-only: only SELECT, GetProfilesInfo, // GetEuiccData, GetEuiccInfo2 and GetEuiccConfiguredAddresses are issued. func (manager *Manager) ESIMInventory(ctx context.Context, id string) ([]EsimInventoryEntry, error) { manager.esimMu.Lock() defer manager.esimMu.Unlock() if manager.esimRecoveryActive(id) { return nil, errESIMRecovering } aids := manager.discoverEuiccAIDs(ctx, id) entries := make([]EsimInventoryEntry, 0, len(aids)) var lastErr error for _, aid := range aids { channel, err := manager.openEuiccAID(ctx, id, aid) if err != nil { lastErr = err continue } profilePayload, profileErr := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) chip, chipErr := readEsimChipInfo(ctx, channel, aid) channel.close(context.Background()) if profileErr != nil { lastErr = profileErr continue } if chipErr != nil { lastErr = chipErr continue } info := EsimInfo{EID: chip.EID, AID: aid, Profiles: parseProfilesInfo(profilePayload)} entries = append(entries, EsimInventoryEntry{Info: info, Chip: chip}) } if len(entries) == 0 { if lastErr != nil { return nil, lastErr } return nil, ErrNoEUICC } return entries, nil }