| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 |
- package device
- import (
- "bytes"
- "context"
- "crypto/tls"
- "encoding/base64"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "strings"
- "time"
- )
- // es9pClient speaks SGP.22 ES9+ — JSON over HTTPS — to one SM-DP+. It is the
- // network half of the LPA download flow: the host authenticates nothing itself
- // (the eUICC does all certificate verification on-card); it only shuttles the
- // base64 DER blobs between the SM-DP+ and the eUICC.
- //
- // The wire contract mirrors lpac's euicc/es9p.c: every request is a POST to
- // https://<smdp>/gsma/rsp2/es9plus/<function> with a fixed header set, binary
- // fields base64-encoded, and the reply envelope carries the outcome in
- // header.functionExecutionStatus (with statusCodeData.message holding the
- // human-readable failure, e.g. "The matchingID is not found").
- type es9pClient struct {
- smdp string
- http *http.Client
- }
- func newES9PClient(smdp string) *es9pClient {
- // The eUICC — not the host — is the root of trust for RSP: during
- // AuthenticateServer the card verifies the SM-DP+'s CERT.DPauth.SIG against
- // its embedded CI root, so a rogue/TLS-MitM server cannot forge a signature
- // the card will accept. The host TLS layer is transport only, and a minimal
- // embedded box may ship no CA bundle (this is exactly what broke on the test
- // machine), so we don't anchor host TLS to system roots. InsecureSkipVerify
- // is safe here specifically because the card does the authoritative check.
- transport := &http.Transport{
- TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // eUICC is the RSP trust anchor
- }
- return &es9pClient{
- smdp: strings.TrimSpace(smdp),
- http: &http.Client{Timeout: 90 * time.Second, Transport: transport},
- }
- }
- // es9pError is a failed ES9+ functionExecutionStatus. Message is the SM-DP+'s
- // own explanation (surfaced verbatim, as the reference implementation does).
- type es9pError struct {
- Function string
- Status string
- Message string
- SubjectCode string
- ReasonCode string
- }
- func (e *es9pError) Error() string {
- if e.Message != "" {
- return e.Message
- }
- if mapped := es9pErrorMessage(e.SubjectCode, e.ReasonCode); mapped != "" {
- return mapped
- }
- if e.Status != "" {
- return fmt.Sprintf("SM-DP+ %s failed (%s)", e.Function, e.Status)
- }
- return fmt.Sprintf("SM-DP+ %s failed", e.Function)
- }
- // es9pStatusCodeData mirrors header.functionExecutionStatus.statusCodeData.
- type es9pStatusCodeData struct {
- ReasonCode string `json:"reasonCode"`
- SubjectCode string `json:"subjectCode"`
- SubjectIdentifier string `json:"subjectIdentifier"`
- Message string `json:"message"`
- }
- // call POSTs one ES9+ function and returns the parsed top-level fields. Failure
- // is decided the way lpac decides it: a non-success execution status, or a
- // missing required output field, yields an es9pError carrying the SM-DP+ message.
- func (c *es9pClient) call(ctx context.Context, function string, request map[string]string, requiredOut ...string) (map[string]json.RawMessage, error) {
- url := "https://" + c.smdp + "/gsma/rsp2/es9plus/" + function
- body, err := json.Marshal(request)
- if err != nil {
- return nil, err
- }
- httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
- if err != nil {
- return nil, err
- }
- httpReq.Header.Set("Content-Type", "application/json")
- httpReq.Header.Set("User-Agent", "gsma-rsp-lpad")
- httpReq.Header.Set("X-Admin-Protocol", "gsma/rsp/v2.2.2")
- resp, err := c.http.Do(httpReq)
- if err != nil {
- return nil, fmt.Errorf("es9p %s: %w", function, err)
- }
- defer resp.Body.Close()
- data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
- if err != nil {
- return nil, fmt.Errorf("es9p %s: read response: %w", function, err)
- }
- var root map[string]json.RawMessage
- if err := json.Unmarshal(data, &root); err != nil {
- return nil, fmt.Errorf("es9p %s: invalid JSON (HTTP %d): %w", function, resp.StatusCode, err)
- }
- var header struct {
- FunctionExecutionStatus struct {
- Status string `json:"status"`
- StatusCodeData *es9pStatusCodeData `json:"statusCodeData"`
- } `json:"functionExecutionStatus"`
- }
- if raw, ok := root["header"]; ok {
- _ = json.Unmarshal(raw, &header)
- }
- fes := header.FunctionExecutionStatus
- // A non-success execution status is an outright failure.
- switch fes.Status {
- case "", "Executed-Success", "Executed-WithWarning":
- // proceed
- default:
- return nil, es9pErrFromStatus(function, fes.Status, fes.StatusCodeData)
- }
- // Success means the expected output fields are present at the top level.
- for _, key := range requiredOut {
- if _, ok := root[key]; !ok {
- return nil, es9pErrFromStatus(function, fes.Status, fes.StatusCodeData)
- }
- }
- return root, nil
- }
- func es9pErrFromStatus(function, status string, scd *es9pStatusCodeData) error {
- err := &es9pError{Function: function, Status: status}
- if scd != nil {
- err.Message = scd.Message
- err.SubjectCode = scd.SubjectCode
- err.ReasonCode = scd.ReasonCode
- }
- return err
- }
- // es9pErrorMessage maps an SGP.22 (subjectCode, reasonCode) pair to a
- // human-readable failure when the SM-DP+ omits statusCodeData.message. Table
- // mirrors lpac's euicc/es9p_errors.c.
- var es9pErrorTable = map[[2]string]string{
- {"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile",
- {"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid",
- {"8.1.1", "2.2"}: "EID is missing in the context of this order",
- {"8.1.1", "3.1"}: "a different EID is already associated with this ICCID",
- {"8.1.1", "3.8"}: "EID doesn't match the expected value",
- {"8.1.2", "6.1"}: "EUM Certificate is invalid",
- {"8.1.2", "6.3"}: "EUM Certificate has expired",
- {"8.1.3", "6.1"}: "eUICC Certificate is invalid",
- {"8.1.3", "6.3"}: "eUICC Certificate has expired",
- {"8.2", "1.2"}: "Profile has not yet been released",
- {"8.2", "3.7"}: "BPP is not available for a new binding",
- {"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type",
- {"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device",
- {"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID",
- {"8.2.6", "3.3"}: "Conflicting MatchingID value",
- {"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused",
- {"8.2.7", "2.2"}: "Confirmation Code is missing",
- {"8.2.7", "3.8"}: "Confirmation Code is refused",
- {"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded",
- {"8.8.1", "3.8"}: "Invalid SM-DP+ Address",
- {"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC",
- {"8.8.5", "4.1"}: "The Download order has expired",
- {"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded",
- {"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown",
- {"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.",
- }
- func es9pErrorMessage(subjectCode, reasonCode string) string {
- return es9pErrorTable[[2]string{subjectCode, reasonCode}]
- }
- // es9pString extracts a plain string field.
- func es9pString(root map[string]json.RawMessage, key string) (string, error) {
- raw, ok := root[key]
- if !ok {
- return "", fmt.Errorf("es9p: response missing %s", key)
- }
- var value string
- if err := json.Unmarshal(raw, &value); err != nil {
- return "", fmt.Errorf("es9p: decode %s: %w", key, err)
- }
- return value, nil
- }
- // es9pB64 extracts and base64-decodes a binary field.
- func es9pB64(root map[string]json.RawMessage, key string) ([]byte, error) {
- value, err := es9pString(root, key)
- if err != nil {
- return nil, err
- }
- return es9pBase64Decode(value)
- }
- func es9pBase64Decode(value string) ([]byte, error) {
- value = strings.TrimSpace(value)
- if decoded, err := base64.StdEncoding.DecodeString(value); err == nil {
- return decoded, nil
- }
- return base64.RawStdEncoding.DecodeString(value)
- }
- func es9pBase64Encode(value []byte) string {
- return base64.StdEncoding.EncodeToString(value)
- }
- // es9pInitiateResult carries the server's half of mutual authentication.
- type es9pInitiateResult struct {
- TransactionID string
- ServerSigned1 []byte
- ServerSignature1 []byte
- EuiccCiPKIDToBeUsed []byte
- ServerCertificate []byte
- }
- func (c *es9pClient) initiateAuthentication(ctx context.Context, euiccChallenge, euiccInfo1 []byte) (*es9pInitiateResult, error) {
- root, err := c.call(ctx, "initiateAuthentication", map[string]string{
- "smdpAddress": c.smdp,
- "euiccChallenge": es9pBase64Encode(euiccChallenge),
- "euiccInfo1": es9pBase64Encode(euiccInfo1),
- }, "transactionId", "serverSigned1", "serverSignature1", "euiccCiPKIdToBeUsed", "serverCertificate")
- if err != nil {
- return nil, err
- }
- result := &es9pInitiateResult{}
- if result.TransactionID, err = es9pString(root, "transactionId"); err != nil {
- return nil, err
- }
- if result.ServerSigned1, err = es9pB64(root, "serverSigned1"); err != nil {
- return nil, err
- }
- if result.ServerSignature1, err = es9pB64(root, "serverSignature1"); err != nil {
- return nil, err
- }
- if result.EuiccCiPKIDToBeUsed, err = es9pB64(root, "euiccCiPKIdToBeUsed"); err != nil {
- return nil, err
- }
- if result.ServerCertificate, err = es9pB64(root, "serverCertificate"); err != nil {
- return nil, err
- }
- return result, nil
- }
- // es9pAuthenticateResult carries the profile metadata and the SM-DP+ download
- // authorization needed for PrepareDownload.
- type es9pAuthenticateResult struct {
- TransactionID string
- ProfileMetadata []byte
- SmdpSigned2 []byte
- SmdpSignature2 []byte
- SmdpCertificate []byte
- }
- func (c *es9pClient) authenticateClient(ctx context.Context, transactionID string, authenticateServerResponse []byte) (*es9pAuthenticateResult, error) {
- root, err := c.call(ctx, "authenticateClient", map[string]string{
- "transactionId": transactionID,
- "authenticateServerResponse": es9pBase64Encode(authenticateServerResponse),
- }, "profileMetadata", "smdpSigned2", "smdpSignature2", "smdpCertificate")
- if err != nil {
- return nil, err
- }
- result := &es9pAuthenticateResult{TransactionID: transactionID}
- if result.ProfileMetadata, err = es9pB64(root, "profileMetadata"); err != nil {
- return nil, err
- }
- if result.SmdpSigned2, err = es9pB64(root, "smdpSigned2"); err != nil {
- return nil, err
- }
- if result.SmdpSignature2, err = es9pB64(root, "smdpSignature2"); err != nil {
- return nil, err
- }
- if result.SmdpCertificate, err = es9pB64(root, "smdpCertificate"); err != nil {
- return nil, err
- }
- return result, nil
- }
- func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID string, prepareDownloadResponse []byte) ([]byte, error) {
- root, err := c.call(ctx, "getBoundProfilePackage", map[string]string{
- "transactionId": transactionID,
- "prepareDownloadResponse": es9pBase64Encode(prepareDownloadResponse),
- }, "boundProfilePackage")
- if err != nil {
- return nil, err
- }
- return es9pB64(root, "boundProfilePackage")
- }
- // handleNotification delivers a pending notification (a ProfileInstallationResult
- // for the download case). It is best-effort: the profile is already installed, so
- // a notification failure is reported by the caller as a warning, not a failure.
- func (c *es9pClient) handleNotification(ctx context.Context, pendingNotification []byte) error {
- _, err := c.call(ctx, "handleNotification", map[string]string{
- "pendingNotification": es9pBase64Encode(pendingNotification),
- })
- return err
- }
- // cancelSession aborts an in-flight download so the SM-DP+ releases the
- // transaction. Best-effort cleanup on error/abort paths.
- func (c *es9pClient) cancelSession(ctx context.Context, transactionID string, cancelSessionResponse []byte) error {
- _, err := c.call(ctx, "cancelSession", map[string]string{
- "transactionId": transactionID,
- "cancelSessionResponse": es9pBase64Encode(cancelSessionResponse),
- })
- return err
- }
|