Files
VoCat/internal/device/esim_download.go
T
MengMengCode 3cc73f1885 feat: implement eSIM notification handling and email message construction
- Add eSIM notification management in `esim_notifications.go` with functions to retrieve, list, and remove notifications.
- Implement parsing logic for pending notifications and notification metadata.
- Create tests for eSIM notification parsing and request handling in `esim_notifications_test.go`.
- Introduce email message construction in `email_message.go` to securely format and send emails.
- Add tests for email message encoding and validation in `email_message_test.go`.
- Enhance the CardPolicyAPN component to manage APN configurations, including adding, editing, and deleting custom APNs.
- Implement UI for displaying and managing APN settings with appropriate validation and user feedback.
2026-08-11 18:58:32 +08:00

326 lines
11 KiB
Go

package device
import (
"context"
"errors"
"strings"
"time"
)
// 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, err := newES9PClient(ctx, smdp)
if err != nil {
return nil, err
}
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
}
report("notify", "正在向运营商发送下载通知...", 90)
iccid, installErr := installationResult(installResponse)
warning := ""
notification, notificationErr := parsePendingNotification(installResponse)
if notificationErr == nil {
// Loading the final BPP segment is the commit point. Finish the operator
// acknowledgement even if the browser closes its SSE connection now.
notifyContext, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
notificationErr = channel.deliverNotification(notifyContext, notification)
cancelNotify()
}
if notificationErr != nil {
warning = "Profile 安装结果已保留在 eUICC,但向运营商上报失败,可在当前通知列表中重发"
}
// Error installation results must be reported too. Return the card-side
// installation failure only after making that best-effort ES9+ attempt.
if installErr != nil {
return nil, installErr
}
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()
var lastErr error
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
lastErr = err
continue
}
info, err := readEsimChipInfo(ctx, channel, aid)
channel.close(context.Background())
if err != nil {
lastErr = err
continue
}
return &info, nil
}
if lastErr != nil {
return nil, lastErr
}
return nil, ErrNoEUICC
}
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
}