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