mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
- Implemented a new `unsupportedBackend` in `backend_stub.go` to handle unsupported platforms. - Created a `Service` struct in `service.go` to manage interactions with smart cards, including session management and identity reading. - Added methods for reading identity, checking readiness, and authenticating with USIM applications. - Introduced a `scriptedCard` for testing purposes in `service_test.go` to simulate card responses. - Defined necessary types and error handling in `types.go` for better clarity and usability. - Developed a `PCSCAdapter` in `vowifi/pcsc_adapter.go` to integrate PC/SC service with Wi-Fi calling functionalities. - Added a new SVG icon for USB SIM readers in `public/sim-reader.svg`. - Implemented tests to ensure correct functionality and error handling in various scenarios.
608 lines
21 KiB
Go
608 lines
21 KiB
Go
package device
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"crypto/x509"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// eSIM profile download (SGP.22 §3, "写卡"). This orchestrates the LPA download
|
||
// flow over the modem's AT+CSIM eUICC channel (ES10b/ES10c) plus an ES9+ HTTPS
|
||
// client (es9p.go). The host performs no credential cryptography — the eUICC
|
||
// verifies the SM-DP+ certificate against its embedded CI root and unwraps the
|
||
// SCP03t-protected package on-card; the host only relays DER blobs between the
|
||
// SM-DP+ and the card.
|
||
//
|
||
// The wire formats here were verified byte-for-byte against lpac
|
||
// (euicc/es10b.c, es10c.c, es9p.c) and exercised against a live eUICC.
|
||
|
||
// es10SegmentMSS caps each STORE DATA block. 120 matches lpac's es10x_mss and
|
||
// keeps every AT+CSIM command small enough for both the modem's buffer and the
|
||
// session's 512-byte command limit (120 APDU bytes ≈ 270 AT chars).
|
||
const es10SegmentMSS = 120
|
||
|
||
// storeDataChained sends one ES10 request body as one or more chained STORE
|
||
// DATA blocks (CLA=80, INS=E2). Blocks use P1=0x11 while more follow and 0x91 on
|
||
// the last, with a per-command block counter in P2 — exactly lpac's
|
||
// es10x_command_iter. The eUICC's streamed responses (drained via 61xx in
|
||
// transmit) are concatenated and returned.
|
||
func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest []byte) ([]byte, error) {
|
||
var assembled []byte
|
||
sequence := byte(0)
|
||
for offset := 0; offset < len(derRequest); {
|
||
size := len(derRequest) - offset
|
||
last := true
|
||
if size > es10SegmentMSS {
|
||
size = es10SegmentMSS
|
||
last = false
|
||
}
|
||
p1 := byte(0x91)
|
||
if !last {
|
||
p1 = 0x11
|
||
}
|
||
// A block is at most es10SegmentMSS bytes, so short-form Lc always fits.
|
||
apdu := []byte{0x80, 0xE2, p1, sequence, byte(size)}
|
||
apdu = append(apdu, derRequest[offset:offset+size]...)
|
||
apdu = append(apdu, 0x00) // Le
|
||
payload, sw, err := channel.transmit(ctx, apdu, 0x80)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !es10StatusOK(sw) {
|
||
return nil, fmt.Errorf("%w: SW=%04X", errESIMSW, sw)
|
||
}
|
||
assembled = append(assembled, payload...)
|
||
offset += size
|
||
sequence++
|
||
}
|
||
return assembled, nil
|
||
}
|
||
|
||
// 91xx is a successful UICC result with a proactive SIM Toolkit command
|
||
// pending. EnableProfile commonly returns it on direct PC/SC transports because
|
||
// the requested refresh is delivered to the terminal rather than consumed by
|
||
// modem firmware. Resetting the card after the operation applies that refresh.
|
||
func es10StatusOK(sw int) bool {
|
||
return sw == 0x9000 || sw>>8 == 0x91
|
||
}
|
||
|
||
// getEUICCChallenge (ES10c, BF2E) returns the eUICC challenge bytes.
|
||
func (channel *euiccChannel) getEUICCChallenge(ctx context.Context) ([]byte, error) {
|
||
payload, err := channel.es10(ctx, []byte{0xBF, 0x2E, 0x00})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
challenge := derFindValue(payload, 0x80)
|
||
if len(challenge) == 0 {
|
||
return nil, errors.New("esim: eUICC returned no challenge")
|
||
}
|
||
return challenge, nil
|
||
}
|
||
|
||
// getEUICCInfo1 (ES10c, BF20) returns the raw EuiccInfo1 TLV (tag included) —
|
||
// this is exactly the base64'd euiccInfo1 that ES9+ InitiateAuthentication wants.
|
||
func (channel *euiccChannel) getEUICCInfo1(ctx context.Context) ([]byte, error) {
|
||
return channel.es10(ctx, []byte{0xBF, 0x20, 0x00})
|
||
}
|
||
|
||
// getEUICCInfo2 (ES10c, BF22) returns the raw EuiccInfo2 TLV (tag included),
|
||
// used for the chip header (EID, free NVRAM, trusted CI list).
|
||
func (channel *euiccChannel) getEUICCInfo2(ctx context.Context) ([]byte, error) {
|
||
return channel.es10(ctx, []byte{0xBF, 0x22, 0x00})
|
||
}
|
||
|
||
// getEuiccConfiguredAddresses (ES10a, BF3C) returns the default SM-DP+ address
|
||
// (tag 0x80) and the Root SM-DS address (tag 0x81). These live in their own
|
||
// command, separate from EUICCInfo2.
|
||
func (channel *euiccChannel) getEuiccConfiguredAddresses(ctx context.Context) (defaultSmdp, rootDs string) {
|
||
payload, err := channel.es10(ctx, []byte{0xBF, 0x3C, 0x00})
|
||
if err != nil {
|
||
return "", ""
|
||
}
|
||
if root := derFindAll(derParse(payload), 0xBF3C); len(root) > 0 {
|
||
children := derParse(root[0].value)
|
||
if v := derValue(children, 0x80); len(v) > 0 {
|
||
defaultSmdp = string(v)
|
||
}
|
||
if v := derValue(children, 0x81); len(v) > 0 {
|
||
rootDs = string(v)
|
||
}
|
||
}
|
||
return defaultSmdp, rootDs
|
||
}
|
||
|
||
// euiccFirmwareVersion extracts euiccFirmwareVer (BF22 → 0x83) and renders it as
|
||
// a dotted version. EUICCInfo2 stores the firmware version as three binary bytes
|
||
// (major.minor.patch), NOT ASCII — this matches lpac's _versiontype2str
|
||
// ("%d.%d.%d"), so a card returning 0x19 0x04 0x00 renders as "25.4.0".
|
||
func euiccFirmwareVersion(euiccInfo2 []byte) string {
|
||
v := derFindValue(euiccInfo2, 0x83)
|
||
if len(v) != 3 {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf("%d.%d.%d", v[0], v[1], v[2])
|
||
}
|
||
|
||
// euiccSAS extracts sasAccreditationNumber (BF22 → 0x0C) as a string.
|
||
func euiccSAS(euiccInfo2 []byte) string {
|
||
if v := derFindValue(euiccInfo2, 0x0C); len(v) > 0 {
|
||
return strings.TrimSpace(string(v))
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// getEID (ES10c GetEuiccData, BF3E requesting tag 5A) returns the eUICC's EID
|
||
// as 32 uppercase hex digits.
|
||
func (channel *euiccChannel) getEID(ctx context.Context) (string, error) {
|
||
request := derConstruct(0xBF3E, derEncode(0x5C, []byte{0x5A}))
|
||
payload, err := channel.es10(ctx, request)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
eid := derFindValue(payload, 0x5A)
|
||
if len(eid) == 0 {
|
||
return "", errors.New("esim: eUICC returned no EID")
|
||
}
|
||
return strings.ToUpper(hex.EncodeToString(eid)), nil
|
||
}
|
||
|
||
// euiccTrustedCIs extracts the euiccCiPKIdListForVerification (BF22 → 0xA9) as
|
||
// uppercase hex key identifiers — the root CIs this eUICC will verify against.
|
||
func euiccTrustedCIs(euiccInfo2 []byte) []string {
|
||
var out []string
|
||
for _, list := range derFindAll(derParse(euiccInfo2), 0xA9) {
|
||
for _, node := range derParse(list.value) {
|
||
if len(node.value) > 0 {
|
||
out = append(out, strings.ToUpper(hex.EncodeToString(node.value)))
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ciKeyNameTable maps the SubjectKeyIdentifier (SHA-1 of the CI public key) of
|
||
// each GSMA-published RSP root CI to the friendly name the eSIM ecosystem uses
|
||
// (the same labels VoHive shows under 证书). Only the production and test roots
|
||
// the card population actually carries are listed; an unknown ID renders as its
|
||
// hex so the field is never silently empty.
|
||
var ciKeyNameTable = map[string]string{
|
||
"81370F5125D0B1D408D4C3B232E6D25E795BEBFB": "GSM Association - RSP2 Root CI1",
|
||
"4DE04679565824D8B0F9A8DE54A24E0EC20D6E2D": "GSM Association - RSP2 Root CI2",
|
||
"2C0F9A60BC975B2D8CDBF1273F6DEB07BF2695AF": "GSM Association - RSP2 Root CI3",
|
||
"84660C5F8824FA8023D730ECB1F5F33A2EA78A6B": "GSM Association - RSP2 Root CI3 (EUMet)",
|
||
"DBF1DFA0D9B6AB4D6F5D9D1F4D7B6F5D9D1F4D7B": "GSM Association - TEST Root CI1",
|
||
"1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F": "GSM Association - TEST Root CI2",
|
||
}
|
||
|
||
// ciKeyFriendlyName renders one hex CI key ID as the friendly CI name, falling
|
||
// back to the raw hex when no entry is known.
|
||
func ciKeyFriendlyName(hexID string) string {
|
||
hexID = strings.ToUpper(hexID)
|
||
if name, ok := ciKeyNameTable[hexID]; ok {
|
||
return name
|
||
}
|
||
return hexID
|
||
}
|
||
|
||
// eumManufacturerForEID derives the eUICC manufacturer from the EID's EUM
|
||
// issuer identifier. Per GSMA SGP.02, the EID is 32 BCD digits: nibble 0 is the
|
||
// EID version and nibbles 1-4 (eid[1:5]) carry the four-digit EUM issuer code —
|
||
// the same code VoHive surfaces as 生产商.
|
||
var eumManufacturerTable = map[string]string{
|
||
"5840": "WatchData Technologies Ltd.",
|
||
"4990": "G+D Mobile Security GmbH",
|
||
"3590": "Thales DIS France SAS",
|
||
"3592": "Thales DIS France SAS",
|
||
"4901": "Idemia France SAS",
|
||
"4040": "Gemalto AG",
|
||
"8901": "Hutopt Technology (Shanghai) Co., Ltd.",
|
||
// Observed on firmware 4.2.0 together with SAS-UP certificate
|
||
// ED-ZI-UP-0826, which GSMA issued to Eastcompeace's Zhuhai site.
|
||
"9086": "Eastcompeace Technology Co., Ltd.",
|
||
}
|
||
|
||
// Some newer EIDs use the eight-digit issuer prefix published in the GSMA EUM
|
||
// registry rather than matching the older four-digit extraction above.
|
||
var eidManufacturerPrefixTable = map[string]string{
|
||
"89033023": "Thales DIS France SAS",
|
||
}
|
||
|
||
// eumManufacturerForEID returns the manufacturer name for the EID's EUM code, or
|
||
// "" when the issuer is not in the table.
|
||
func eumManufacturerForEID(eid string) string {
|
||
eid = strings.ToUpper(strings.TrimSpace(eid))
|
||
if len(eid) >= 8 {
|
||
if manufacturer, ok := eidManufacturerPrefixTable[eid[:8]]; ok {
|
||
return manufacturer
|
||
}
|
||
}
|
||
if len(eid) < 5 {
|
||
return ""
|
||
}
|
||
return eumManufacturerTable[eid[1:5]]
|
||
}
|
||
|
||
// cancelSession (ES10b, BF41) aborts an open download transaction on-card and
|
||
// returns the CancelSessionResponse to relay to ES9+ cancelSession. reason 0x00
|
||
// is endUserRejection (the generic abort). Best-effort cleanup only.
|
||
func (channel *euiccChannel) cancelSession(ctx context.Context, transactionID []byte, reason byte) ([]byte, error) {
|
||
request := derConstruct(0xBF41,
|
||
derEncode(0x80, transactionID),
|
||
derEncode(0x81, []byte{reason}),
|
||
)
|
||
return channel.es10(ctx, request)
|
||
}
|
||
|
||
// euiccFreeNVRAM extracts extCardResource.freeNonVolatileMemory (BF22 → 0x84 →
|
||
// 0x82) from a EuiccInfo2 TLV. extCardResource (0x84) is BER primitive-encoded,
|
||
// so its children are read from its raw value, not .children. ok is false when
|
||
// the field is absent.
|
||
func euiccFreeNVRAM(euiccInfo2 []byte) (int, bool) {
|
||
for _, res := range derFindAll(derParse(euiccInfo2), 0x84) {
|
||
if value := derFindValue(res.value, 0x82); len(value) > 0 {
|
||
n := 0
|
||
for _, b := range value {
|
||
n = n<<8 | int(b)
|
||
}
|
||
return n, true
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// authenticateServer (ES10b, BF38) presents the SM-DP+'s credentials to the
|
||
// eUICC, which verifies the certificate chain against its embedded CI root. The
|
||
// whole card response is the AuthenticateServerResponse relayed to ES9+
|
||
// AuthenticateClient. matchingId/imei are optional ctxParams1 inputs.
|
||
func (channel *euiccChannel) authenticateServer(ctx context.Context, init *es9pInitiateResult, matchingID, imei string) ([]byte, error) {
|
||
// deviceInfo (A1): tac (80, 4 BCD bytes), deviceCapabilities (A1, empty),
|
||
// optional imei (82, BCD). With no IMEI, lpac uses a fixed default TAC.
|
||
tac := []byte{0x35, 0x29, 0x06, 0x11}
|
||
var imeiField []byte
|
||
if digits := onlyDigits(imei); len(digits) >= 8 {
|
||
if bcd, err := encodeFixedDigitBCD(digits, 8, "IMEI"); err == nil {
|
||
tac = bcd[:4]
|
||
imeiField = derEncode(0x82, bcd)
|
||
}
|
||
}
|
||
deviceInfo := derConstruct(0xA1, derEncode(0x80, tac), derEncode(0xA1, nil))
|
||
if imeiField != nil {
|
||
deviceInfo = derConstruct(0xA1, derEncode(0x80, tac), derEncode(0xA1, nil), imeiField)
|
||
}
|
||
|
||
// ctxParams1 (A0): optional matchingId (80) then deviceInfo.
|
||
var ctxChildren [][]byte
|
||
if matchingID != "" {
|
||
ctxChildren = append(ctxChildren, derEncode(0x80, []byte(matchingID)))
|
||
}
|
||
ctxChildren = append(ctxChildren, deviceInfo)
|
||
ctxParams1 := derConstruct(0xA0, ctxChildren...)
|
||
|
||
// The four server blobs arrive as complete TLVs (30/5F37/04/30). unwrapDER +
|
||
// re-encode normalizes them whether they come wrapped or bare, so the request
|
||
// is always well-formed.
|
||
request := derConstruct(0xBF38,
|
||
derEncode(0x30, unwrapDER(init.ServerSigned1, 0x30)),
|
||
derEncode(0x5F37, unwrapDER(init.ServerSignature1, 0x5F37)),
|
||
derEncode(0x04, unwrapDER(init.EuiccCiPKIDToBeUsed, 0x04)),
|
||
derEncode(0x30, unwrapDER(init.ServerCertificate, 0x30)),
|
||
ctxParams1,
|
||
)
|
||
response, err := channel.es10(ctx, request)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := authenticateServerResultError(response); err != nil {
|
||
return nil, fmt.Errorf("%w; selected CI=%X; %s", err,
|
||
unwrapDER(init.EuiccCiPKIDToBeUsed, 0x04), describeDPAuthCertificate(init.ServerCertificate))
|
||
}
|
||
return response, nil
|
||
}
|
||
|
||
func describeDPAuthCertificate(blob []byte) string {
|
||
certificate, err := x509.ParseCertificate(blob)
|
||
if err != nil {
|
||
return fmt.Sprintf("CERT.DPauth could not be parsed as X.509: %v", err)
|
||
}
|
||
return fmt.Sprintf(
|
||
"CERT.DPauth subject=%q issuer=%q valid=%s..%s SKI=%X AKI=%X signature=%s",
|
||
certificate.Subject.String(), certificate.Issuer.String(),
|
||
certificate.NotBefore.UTC().Format("2006-01-02T15:04:05Z"),
|
||
certificate.NotAfter.UTC().Format("2006-01-02T15:04:05Z"),
|
||
certificate.SubjectKeyId, certificate.AuthorityKeyId, certificate.SignatureAlgorithm,
|
||
)
|
||
}
|
||
|
||
// esimAuthenticateError is the AuthenticateErrorCode returned by the eUICC in
|
||
// an ES10b AuthenticateServerResponse error choice (BF38/A1). Keeping the card's
|
||
// code here prevents an SM-DP+ from collapsing every cause into the unhelpful
|
||
// "eUICC reported an authentication error" message.
|
||
type esimAuthenticateError struct {
|
||
Code int
|
||
}
|
||
|
||
func (err *esimAuthenticateError) Error() string {
|
||
reasons := map[int]string{
|
||
1: "invalid server certificate",
|
||
2: "invalid server signature",
|
||
3: "unsupported elliptic curve",
|
||
4: "no matching RSP session context",
|
||
5: "invalid certificate OID",
|
||
6: "eUICC challenge mismatch",
|
||
7: "CI public key is unknown to the eUICC",
|
||
8: "transaction ID error",
|
||
9: "required certificate revocation list is missing",
|
||
10: "invalid certificate revocation-list signature",
|
||
11: "server certificate has been revoked",
|
||
12: "invalid certificate or revocation-list time",
|
||
13: "invalid certificate or revocation-list configuration",
|
||
14: "invalid ICCID",
|
||
127: "undefined authentication error",
|
||
}
|
||
reason := reasons[err.Code]
|
||
if reason == "" {
|
||
reason = "unknown authentication error"
|
||
}
|
||
return fmt.Sprintf("eSIM: eUICC AuthenticateServer failed: %s (code %d)", reason, err.Code)
|
||
}
|
||
|
||
func authenticateServerResultError(response []byte) error {
|
||
var outer *derNode
|
||
for _, node := range derParse(response) {
|
||
if node.tag == 0xBF38 {
|
||
outer = node
|
||
break
|
||
}
|
||
}
|
||
if outer == nil || len(outer.children) == 0 || outer.children[0].tag != 0xA1 {
|
||
return nil
|
||
}
|
||
codeBytes := derFindValue(outer.children[0].value, 0x02)
|
||
if len(codeBytes) == 0 {
|
||
return &esimAuthenticateError{Code: -1}
|
||
}
|
||
code := 0
|
||
for _, value := range codeBytes {
|
||
code = code<<8 | int(value)
|
||
}
|
||
return &esimAuthenticateError{Code: code}
|
||
}
|
||
|
||
// prepareDownload (ES10b, BF21) authorizes the download on-card, including the
|
||
// confirmation-code hash when the SM-DP+ flags it required. The whole card
|
||
// response is the PrepareDownloadResponse relayed to ES9+ GetBoundProfilePackage.
|
||
func (channel *euiccChannel) prepareDownload(ctx context.Context, auth *es9pAuthenticateResult, confirmationCode string) ([]byte, error) {
|
||
// transactionId (0x80) and ccRequiredFlag (0x01) live inside smdpSigned2 (a
|
||
// SEQUENCE), so read them recursively from the raw blob.
|
||
transactionID := derFindValue(auth.SmdpSigned2, 0x80)
|
||
ccRequired := false
|
||
if flag := derFindValue(auth.SmdpSigned2, 0x01); len(flag) > 0 {
|
||
for _, b := range flag {
|
||
if b != 0 {
|
||
ccRequired = true
|
||
}
|
||
}
|
||
}
|
||
|
||
var hashField []byte
|
||
if ccRequired {
|
||
if confirmationCode == "" {
|
||
return nil, errors.New("esim: this profile requires a confirmation code")
|
||
}
|
||
// hashCc = SHA256( SHA256(cc) || transactionId )
|
||
first := sha256.Sum256([]byte(confirmationCode))
|
||
second := sha256.New()
|
||
second.Write(first[:])
|
||
second.Write(transactionID)
|
||
hashField = derEncode(0x04, second.Sum(nil))
|
||
}
|
||
|
||
children := [][]byte{
|
||
derEncode(0x30, unwrapDER(auth.SmdpSigned2, 0x30)),
|
||
derEncode(0x5F37, unwrapDER(auth.SmdpSignature2, 0x5F37)),
|
||
}
|
||
if hashField != nil {
|
||
children = append(children, hashField)
|
||
}
|
||
children = append(children, derEncode(0x30, unwrapDER(auth.SmdpCertificate, 0x30)))
|
||
return channel.es10(ctx, derConstruct(0xBF21, children...))
|
||
}
|
||
|
||
// unwrapDER returns the inner value of a single-element TLV when the blob is
|
||
// already wrapped in the expected tag, else the blob unchanged. SM-DP+ blobs
|
||
// sometimes arrive as bare values and sometimes as full TLVs; this normalizes so
|
||
// we never double-wrap.
|
||
func unwrapDER(blob []byte, tag int) []byte {
|
||
got, headerLen, totalLen, err := derElementAt(blob, 0)
|
||
if err == nil && got == tag && totalLen == len(blob) {
|
||
return blob[headerLen:]
|
||
}
|
||
return blob
|
||
}
|
||
|
||
// loadBoundProfilePackage (ES10b) streams the BoundProfilePackage into the eUICC.
|
||
// The package is sliced at TLV boundaries the way lpac does — [BF36 header +
|
||
// BF23], A0 whole, A1/A3 header then each child, A2 whole — and each slice is
|
||
// sent as a chained STORE DATA. Only the final slice returns data: the
|
||
// ProfileInstallationResult (BF37). progress is invoked per slice.
|
||
func (channel *euiccChannel) loadBoundProfilePackage(ctx context.Context, bpp []byte, progress func(done, total int)) ([]byte, error) {
|
||
segments, err := segmentBoundProfilePackage(bpp)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var lastResponse []byte
|
||
for index, segment := range segments {
|
||
response, err := channel.storeDataChained(ctx, segment)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(response) > 0 {
|
||
lastResponse = response
|
||
}
|
||
if progress != nil {
|
||
progress(index+1, len(segments))
|
||
}
|
||
}
|
||
if len(lastResponse) == 0 {
|
||
return nil, errors.New("esim: eUICC returned no installation result")
|
||
}
|
||
return lastResponse, nil
|
||
}
|
||
|
||
// segmentBoundProfilePackage splits a BoundProfilePackage (the BF36 element)
|
||
// into the TLV-aligned slices lpac uses for LoadBoundProfilePackage.
|
||
func segmentBoundProfilePackage(bpp []byte) ([][]byte, error) {
|
||
// Locate the BF36 (BoundProfilePackage) element at the top level.
|
||
offset := 0
|
||
bf36Start, bf36Header, bf36Total := -1, 0, 0
|
||
for offset < len(bpp) {
|
||
tag, headerLen, totalLen, err := derElementAt(bpp, offset)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if tag == 0xBF36 {
|
||
bf36Start, bf36Header, bf36Total = offset, headerLen, totalLen
|
||
break
|
||
}
|
||
offset += totalLen
|
||
}
|
||
if bf36Start < 0 {
|
||
return nil, errors.New("esim: BoundProfilePackage (BF36) not found")
|
||
}
|
||
valueStart := bf36Start + bf36Header
|
||
valueEnd := bf36Start + bf36Total
|
||
|
||
var segments [][]byte
|
||
cursor := valueStart
|
||
// First slice: BF36 header through the end of the first child (BF23,
|
||
// initialiseSecureChannelRequest) so the secure channel is set up first.
|
||
_, _, firstTotal, err := derElementAt(bpp, cursor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
segments = append(segments, bpp[bf36Start:cursor+firstTotal])
|
||
cursor += firstTotal
|
||
|
||
for cursor < valueEnd {
|
||
tag, headerLen, totalLen, err := derElementAt(bpp, cursor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
switch tag {
|
||
case 0xA1, 0xA3: // sequenceOf88 / sequenceOf86: header, then each child
|
||
segments = append(segments, bpp[cursor:cursor+headerLen])
|
||
child := cursor + headerLen
|
||
childEnd := cursor + totalLen
|
||
for child < childEnd {
|
||
_, _, childTotal, err := derElementAt(bpp, child)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
segments = append(segments, bpp[child:child+childTotal])
|
||
child += childTotal
|
||
}
|
||
default: // A0 / A2 (and anything unexpected): send whole
|
||
segments = append(segments, bpp[cursor:cursor+totalLen])
|
||
}
|
||
cursor += totalLen
|
||
}
|
||
return segments, nil
|
||
}
|
||
|
||
// esimInstallError is a card-side ProfileInstallationResult ErrorResult: the
|
||
// package was received but the eUICC refused to install it.
|
||
type esimInstallError struct {
|
||
CommandID int
|
||
ErrorReason int
|
||
}
|
||
|
||
func (e *esimInstallError) Error() string {
|
||
if reason, ok := es10bErrorReasons[e.ErrorReason]; ok {
|
||
return "esim: eUICC 拒绝安装 Profile:" + reason
|
||
}
|
||
return fmt.Sprintf("esim: eUICC 拒绝安装 Profile (reason %d, command %d)", e.ErrorReason, e.CommandID)
|
||
}
|
||
|
||
// es10bErrorReasons maps the ProfileInstallationResult errorReason to text.
|
||
// Values mirror lpac's enum es10b_error_reason.
|
||
var es10bErrorReasons = map[int]string{
|
||
1: "输入值不正确",
|
||
2: "签名无效",
|
||
3: "transactionId 无效",
|
||
4: "不支持的 CRT 值",
|
||
5: "不支持的远程操作类型",
|
||
6: "不支持的 Profile 类别",
|
||
7: "SCP03t 结构错误",
|
||
8: "SCP03t 安全错误",
|
||
9: "该 Profile (ICCID) 已存在于 eUICC",
|
||
10: "eUICC 剩余空间不足",
|
||
11: "安装被中断",
|
||
12: "Profile 元素处理错误",
|
||
13: "数据不匹配",
|
||
14: "测试 Profile 的 NAA 密钥无效",
|
||
15: "Profile 策略规则 (PPR) 不允许",
|
||
127: "未知错误",
|
||
}
|
||
|
||
// installationResult decodes the ProfileInstallationResult (BF37 → BF27 →
|
||
// BF2F NotificationMetadata + A2 finalResult[A0 success | A1 error]). It returns
|
||
// the new profile's ICCID on success, or a typed *esimInstallError on ErrorResult.
|
||
func installationResult(payload []byte) (string, error) {
|
||
roots := derParse(payload)
|
||
result := derFindAll(roots, 0xBF37)
|
||
if len(result) == 0 {
|
||
return "", fmt.Errorf("esim: no ProfileInstallationResult (BF37) in %s", strings.ToUpper(hex.EncodeToString(payload)))
|
||
}
|
||
data := derFindAll(result[0].children, 0xBF27)
|
||
if len(data) == 0 {
|
||
return "", errors.New("esim: no ProfileInstallationResultData (BF27)")
|
||
}
|
||
iccid := ""
|
||
for _, node := range derFindAll(data[0].children, 0x5A) {
|
||
iccid = decodeICCID(node.value)
|
||
break
|
||
}
|
||
finalResult := firstChild(data[0].children, 0xA2)
|
||
if finalResult == nil {
|
||
return "", errors.New("esim: ProfileInstallationResultData missing finalResult (A2)")
|
||
}
|
||
if errNode := firstChild(finalResult.children, 0xA1); errNode != nil {
|
||
installErr := &esimInstallError{CommandID: -1, ErrorReason: -1}
|
||
if v := derValue(errNode.children, 0x80); len(v) > 0 {
|
||
installErr.CommandID = int(v[0])
|
||
}
|
||
if v := derValue(errNode.children, 0x81); len(v) > 0 {
|
||
installErr.ErrorReason = int(v[0])
|
||
}
|
||
return "", installErr
|
||
}
|
||
if firstChild(finalResult.children, 0xA0) == nil {
|
||
return "", errors.New("esim: unexpected ProfileInstallationResult finalResult")
|
||
}
|
||
return iccid, nil
|
||
}
|
||
|
||
// firstChild returns the first direct child with the given tag, or nil.
|
||
func firstChild(nodes []*derNode, tag int) *derNode {
|
||
for _, node := range nodes {
|
||
if node.tag == tag {
|
||
return node
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func onlyDigits(value string) string {
|
||
var builder strings.Builder
|
||
for _, r := range value {
|
||
if r >= '0' && r <= '9' {
|
||
builder.WriteRune(r)
|
||
}
|
||
}
|
||
return builder.String()
|
||
}
|