diff --git a/internal/device/certs/gsma-rsp2-root-ci1.pem b/internal/device/certs/gsma-rsp2-root-ci1.pem new file mode 100644 index 0000000..d9b7113 --- /dev/null +++ b/internal/device/certs/gsma-rsp2-root-ci1.pem @@ -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----- diff --git a/internal/device/es9p.go b/internal/device/es9p.go index ec0d878..cf77037 100644 --- a/internal/device/es9p.go +++ b/internal/device/es9p.go @@ -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 { diff --git a/internal/device/es9p_test.go b/internal/device/es9p_test.go index 9999cfa..5d939e9 100644 --- a/internal/device/es9p_test.go +++ b/internal/device/es9p_test.go @@ -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() diff --git a/internal/netguard/public.go b/internal/netguard/public.go index 4b1a50a..71707c9 100644 --- a/internal/netguard/public.go +++ b/internal/netguard/public.go @@ -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{