Trust GSMA RSP2 Root CI1 for ES9+ TLS (#66)

Production SM-DP+ endpoints may use the GSMA RSP2 Root CI1, which is not present in the Alpine system CA bundle. Add the verified root to the ES9+ client's trust pool while preserving the existing public-destination and TLS validation rules.\n\nConstraint: ES9+ TLS must trust the GSMA RSP2 Root CI1 used by production SM-DP+ services.\nRejected: Disable certificate verification or trust the leaf certificate | both weaken server authentication and break rotation.\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep the additional root scoped to ES9+; update it only from a verified GSMA certificate source.\nTested: go test ./...\nNot-tested: Clean-container live download against every SM-DP+ provider.

Co-authored-by: Meng Meng <[email protected]>
This commit is contained in:
MarkXiu
2026-08-19 23:45:50 +08:00
committed by GitHub
co-authored by Meng Meng
parent 489a6dc10c
commit c66fe06def
4 changed files with 83 additions and 1 deletions
@@ -0,0 +1,15 @@
-----BEGIN CERTIFICATE-----
MIICSTCCAe+gAwIBAgIQbmhWeneg7nyF7hg5Y9+qejAKBggqhkjOPQQDAjBEMRgw
FgYDVQQKEw9HU00gQXNzb2NpYXRpb24xKDAmBgNVBAMTH0dTTSBBc3NvY2lhdGlv
biAtIFJTUDIgUm9vdCBDSTEwIBcNMTcwMjIyMDAwMDAwWhgPMjA1MjAyMjEyMzU5
NTlaMEQxGDAWBgNVBAoTD0dTTSBBc3NvY2lhdGlvbjEoMCYGA1UEAxMfR1NNIEFz
c29jaWF0aW9uIC0gUlNQMiBSb290IENJMTBZMBMGByqGSM49AgEGCCqGSM49AwEH
A0IABJ1qutL0HCMX52GJ6/jeibsAqZfULWj/X10p/Min6seZN+hf5llovbCNuB2n
unLz+O8UD0SUCBUVo8e6n9X1TuajgcAwgb0wDgYDVR0PAQH/BAQDAgEGMA8GA1Ud
EwEB/wQFMAMBAf8wEwYDVR0RBAwwCogIKwYBBAGC6WAwFwYDVR0gAQH/BA0wCzAJ
BgdngRIBAgEAME0GA1UdHwRGMEQwQqBAoD6GPGh0dHA6Ly9nc21hLWNybC5zeW1h
dXRoLmNvbS9vZmZsaW5lY2EvZ3NtYS1yc3AyLXJvb3QtY2kxLmNybDAdBgNVHQ4E
FgQUgTcPUSXQsdQI1MOyMubSXnlb6/swCgYIKoZIzj0EAwIDSAAwRQIgIJdYsOMF
WziPK7l8nh5mu0qiRiVf25oa9ullG/OIASwCIQDqCmDrYf+GziHXBOiwJwnBaeBO
aFsiLzIEOaUuZwdNUw==
-----END CERTIFICATE-----
+32 -1
View File
@@ -3,6 +3,7 @@ package device
import (
"bytes"
"context"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
@@ -14,9 +15,24 @@ import (
"strings"
"time"
_ "embed"
"vocat/internal/netguard"
)
// GSM Association RSP2 Root CI1; SHA-256 fingerprint:
// 5E:3E:91:FD:45:43:27:C3:AF:5D:32:A7:A7:3B:BC:59:FE:43:AA:7D:85:FD:32:D5:DB:44:42:3F:80:A5:6B:B3.
//
//go:embed certs/gsma-rsp2-root-ci1.pem
var gsmaRSP2RootCI1PEM []byte
var gsmaRSP2RootCI1SHA256 = [32]byte{
0x5e, 0x3e, 0x91, 0xfd, 0x45, 0x43, 0x27, 0xc3,
0xaf, 0x5d, 0x32, 0xa7, 0xa7, 0x3b, 0xbc, 0x59,
0xfe, 0x43, 0xaa, 0x7d, 0x85, 0xfd, 0x32, 0xd5,
0xdb, 0x44, 0x42, 0x3f, 0x80, 0xa5, 0x6b, 0xb3,
}
// 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
@@ -50,13 +66,28 @@ func newES9PClient(ctx context.Context, smdp string) (*es9pClient, error) {
if err != nil {
return nil, fmt.Errorf("esim: unsafe SM-DP+ address: %w", err)
}
roots, err := es9pRootCAs()
if err != nil {
return nil, err
}
return &es9pClient{
smdp: validated.Host,
endpoint: validated,
http: netguard.NewPublicHTTPClient(90*time.Second, true),
http: netguard.NewPublicHTTPClientWithRootCAs(90*time.Second, true, roots),
}, nil
}
func es9pRootCAs() (*x509.CertPool, error) {
roots, err := x509.SystemCertPool()
if err != nil || roots == nil {
roots = x509.NewCertPool()
}
if !roots.AppendCertsFromPEM(gsmaRSP2RootCI1PEM) {
return nil, errors.New("esim: load GSMA RSP2 Root CI1 certificate")
}
return roots, nil
}
// es9pError is a failed ES9+ functionExecutionStatus. Message is the SM-DP+'s
// own explanation (surfaced verbatim, as the reference implementation does).
type es9pError struct {
+27
View File
@@ -3,8 +3,11 @@ package device
import (
"bytes"
"context"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"net/http"
"net/http/httptest"
"net/url"
@@ -12,6 +15,30 @@ import (
"testing"
)
func TestES9PRootCAsIncludeGSMARSP2RootCI1(t *testing.T) {
roots, err := es9pRootCAs()
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode(gsmaRSP2RootCI1PEM)
if block == nil {
t.Fatal("GSMA Root CI1 PEM did not decode")
}
certificate, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatal(err)
}
if actual := sha256.Sum256(certificate.Raw); actual != gsmaRSP2RootCI1SHA256 {
t.Fatalf("GSMA root SHA-256 = %X, want %X", actual, gsmaRSP2RootCI1SHA256)
}
if certificate.Subject.CommonName != "GSM Association - RSP2 Root CI1" || !certificate.IsCA {
t.Fatalf("unexpected GSMA root certificate: subject=%q ca=%v", certificate.Subject.CommonName, certificate.IsCA)
}
if _, err := certificate.Verify(x509.VerifyOptions{Roots: roots}); err != nil {
t.Fatalf("GSMA root is not trusted by the ES9+ pool: %v", err)
}
}
// newTestES9P routes an es9pClient at a throwaway TLS server.
func newTestES9P(t *testing.T, handler http.HandlerFunc) *es9pClient {
t.Helper()
+9
View File
@@ -3,6 +3,7 @@ package netguard
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
@@ -48,6 +49,13 @@ func ValidatePublicURL(ctx context.Context, raw string, requireHTTPS bool) (*url
// rejects private/special-use destinations at dial time, and validates every
// redirect before following it.
func NewPublicHTTPClient(timeout time.Duration, requireHTTPS bool) *http.Client {
return NewPublicHTTPClientWithRootCAs(timeout, requireHTTPS, nil)
}
// NewPublicHTTPClientWithRootCAs creates the same guarded client while using
// the supplied trust pool for protocols whose standards define additional
// public roots beyond the host operating system's CA bundle.
func NewPublicHTTPClientWithRootCAs(timeout time.Duration, requireHTTPS bool, roots *x509.CertPool) *http.Client {
if timeout <= 0 {
timeout = 30 * time.Second
}
@@ -60,6 +68,7 @@ func NewPublicHTTPClient(timeout time.Duration, requireHTTPS bool) *http.Client
ExpectContinueTimeout: time.Second,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
RootCAs: roots,
},
}
return &http.Client{