From dc6aac1b1a50b4e7faad0a1b5298aec8c511f5be Mon Sep 17 00:00:00 2001 From: Jesse Peterson Date: Thu, 30 Jan 2025 11:52:29 -0800 Subject: [PATCH 1/5] add support for rfc9440 certificate headers --- cmd/nanomdm/main.go | 2 +- docs/operations-guide.md | 9 +++++-- http/mdm/cert_extract.go | 49 ++++++++++++++++++++++++++++++++++ http/mdm/cert_extract_test.go | 50 +++++++++++++++++++++++++++++++++++ http/mdm/mdm_cert.go | 49 +++++++++++++++++++++------------- 5 files changed, 138 insertions(+), 21 deletions(-) create mode 100644 http/mdm/cert_extract.go create mode 100644 http/mdm/cert_extract_test.go diff --git a/cmd/nanomdm/main.go b/cmd/nanomdm/main.go index 301d907..4172233 100644 --- a/cmd/nanomdm/main.go +++ b/cmd/nanomdm/main.go @@ -65,7 +65,7 @@ func main() { flRootsPath = flag.String("ca", "", "path to PEM CA cert(s)") flIntsPath = flag.String("intermediate", "", "path to PEM intermediate cert(s)") flWebhook = flag.String("webhook-url", "", "URL to send requests to") - flCertHeader = flag.String("cert-header", "", "HTTP header containing URL-escaped TLS client certificate") + flCertHeader = flag.String("cert-header", "", "HTTP header containing client certificate") flDebug = flag.Bool("debug", false, "log debug messages") flDump = flag.Bool("dump", false, "dump MDM requests and responses to stdout") flDisableMDM = flag.Bool("disable-mdm", false, "disable MDM HTTP endpoint") diff --git a/docs/operations-guide.md b/docs/operations-guide.md index 4dfada5..ddfeb0f 100644 --- a/docs/operations-guide.md +++ b/docs/operations-guide.md @@ -40,11 +40,16 @@ NanoMDM validates that the device identity certificate is issued from specific C ### -cert-header string -* HTTP header containing URL-escaped TLS client certificate +* HTTP header containing client certificate By default NanoMDM tries to extract the device identity certificate from the HTTP request by decoding the "Mdm-Signature" header. See ["Pass an Identity Certificate Through a Proxy" section of this documentation for details](https://developer.apple.com/documentation/devicemanagement/implementing_device_management/managing_certificates_for_mdm_servers_and_devices). This corresponds to the `SignMessage` key being set to true in the enrollment profile. -With the `-cert-header` switch you can specify the name of an HTTP header that is passed to NanoMDM to read the client identity certificate. This is ostensibly to support Nginx' [$ssl_client_escaped_cert](http://nginx.org/en/docs/http/ngx_http_ssl_module.html) in a [proxy_set_header](http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_set_header) directive. Though any reverse proxy setting a similar header could be used, of course. The `SignMessage` key in the enrollment profile should be set appropriately. +With the `-cert-header` switch you can specify the name of an HTTP header that is passed to NanoMDM to instead read the client identity certificate from. The format of the header is parsed as RFC 9440 if it begins with a colon, otherwise a URL query-escaped PEM certificate is assumed. + +[RFC 9440](https://datatracker.ietf.org/doc/rfc9440/) specifies a Base-64 encoded DER certificate surrounded by colons. The URL query-escaped PEM certificate is ostensibly to support Nginx' [$ssl_client_escaped_cert](http://nginx.org/en/docs/http/ngx_http_ssl_module.html) in a [proxy_set_header](http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_set_header) directive. Though any reverse proxy setting similar headers can be used, of course. Again the `SignMessage` key in the enrollment profile should be set appropriately (i.e. to false or not set, if you're using this switch). + +> [!NOTE] +> NanoMDM v0.7.0 and below do not support RFC 9440 header parsing, only URL query-escaped PEM certificates. ### -checkin diff --git a/http/mdm/cert_extract.go b/http/mdm/cert_extract.go new file mode 100644 index 0000000..b9af819 --- /dev/null +++ b/http/mdm/cert_extract.go @@ -0,0 +1,49 @@ +package mdm + +import ( + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "net/url" + + "github.com/micromdm/nanomdm/cryptoutil" +) + +// ExtractRFC9440 attempts to parse a certificate out of an RFC 9440-style header value. +// RFC 9440 is, basically, the base64-encoded DER certificate surrounded by colons. +func ExtractRFC9440(headerValue string) (*x509.Certificate, error) { + if len(headerValue) < 3 { + return nil, errors.New("header too short") + } + if headerValue[0] != ':' || headerValue[len(headerValue)-1] != ':' { + return nil, errors.New("invalid prefix or suffix") + } + certBytes, err := base64.StdEncoding.DecodeString(headerValue[1 : len(headerValue)-1]) + if err != nil { + return nil, fmt.Errorf("decoding base64: %w", err) + } + cert, err := x509.ParseCertificate(certBytes) + if err != nil { + return nil, fmt.Errorf("parse certificate: %w", err) + } + return cert, nil +} + +// ExtractQueryEscapedPEM parses a PEM certificate from a URL query-escaped header value. +// This is ostensibly to support Nginx' $ssl_client_escaped_cert in a `proxy_set_header` directive. +func ExtractQueryEscapedPEM(headerValue string) (*x509.Certificate, error) { + if len(headerValue) < 1 { + return nil, errors.New("header too short") + } + certPEM, err := url.QueryUnescape(headerValue) + if err != nil { + return nil, fmt.Errorf("query unescape: %w", err) + + } + cert, err := cryptoutil.DecodePEMCertificate([]byte(certPEM)) + if err != nil { + return nil, fmt.Errorf("decode certificate: %w", err) + } + return cert, nil +} diff --git a/http/mdm/cert_extract_test.go b/http/mdm/cert_extract_test.go new file mode 100644 index 0000000..b470645 --- /dev/null +++ b/http/mdm/cert_extract_test.go @@ -0,0 +1,50 @@ +package mdm + +import "testing" + +func assertError(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Error("expected error") + } +} + +func assertNilError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +const ( + certQueryEscaped = "-----BEGIN+CERTIFICATE-----%0AMIIC1TCCAb2gAwIBAgIJAOOl7VQeisl5MA0GCSqGSIb3DQEBCwUAMBoxGDAWBgNV%0ABAMMD21kbS5leGFtcGxlLm9yZzAeFw0yNTAxMzAxOTA3NDhaFw0yNjAxMzAxOTA3%0ANDhaMBoxGDAWBgNVBAMMD21kbS5leGFtcGxlLm9yZzCCASIwDQYJKoZIhvcNAQEB%0ABQADggEPADCCAQoCggEBAMMuJRNUCmgdKs6W%2BdVna8ftPokGsm7xN7xGG%2BHcAs41%0AI2ImgcrbXG35%2Fb9OWlG3%2FFxAJuXwWaajcRVcfdXHeBwinsdiywzxWDjaL30tjCaA%0A4%2FgIHCamXEmpnxdC%2FG41GNSYMAjM6Qo1hUeLuvdKtGskTIsY0Bn12%2BX9VvgFK%2Fw5%0A5XCqdNXWZtNJm%2B6xnJn2lWo%2BMQ1pCGT9o2vkCt7IXz5VeCFFsRAFs58cUUIvH%2FNu%0A1VL2wOUON2qbms0VnLF0oLvFwZG1u25TSzMOMJTM2s0HjjnP5Ef%2Fmx4QvLEXYuwv%0AH04lK2LP3iQvO0dYRildZ3Te5fAcgHgqNeqk8S3gg3ECAwEAAaMeMBwwGgYDVR0R%0ABBMwEYIPbWRtLmV4YW1wbGUub3JnMA0GCSqGSIb3DQEBCwUAA4IBAQAVuu9eLtd6%0A09JBMHIcFUA1h0MvnPZ7bJQCYjIvh7CIwl7SBlFiaQ3gIahelAR5pqdOxpqoYZdj%0Agkns4qH4GH6NDORoVl7WPPIpT4s9cD%2BzaEzMrc1ZmzPwEksBl89yfkB5QH0kXhe4%0AjpSxtcYOwGQ7BOJDDqhqiI47NnTF5Xsy53OocauXVXSdDYfHNxAokijKMWEQRnGs%0A2Gjc5jF%2Fse%2FojXko3pCP71Q4lGFRo%2FyqGUmwZ8Ul%2F3Bm%2FH4nk%2FrvcYbcXToIpDuE%0A4ioXhsGZD%2FtfDKSGd4QyEL5sBb%2F8ULuC%2By1nolRY7zZTc3eUVEJUM7li4JHB2s5r%0AKGNh7rtCvJQw%0A-----END+CERTIFICATE-----%0A" + certRFC9440 = ":MIIC1TCCAb2gAwIBAgIJAOOl7VQeisl5MA0GCSqGSIb3DQEBCwUAMBoxGDAWBgNVBAMMD21kbS5leGFtcGxlLm9yZzAeFw0yNTAxMzAxOTA3NDhaFw0yNjAxMzAxOTA3NDhaMBoxGDAWBgNVBAMMD21kbS5leGFtcGxlLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMMuJRNUCmgdKs6W+dVna8ftPokGsm7xN7xGG+HcAs41I2ImgcrbXG35/b9OWlG3/FxAJuXwWaajcRVcfdXHeBwinsdiywzxWDjaL30tjCaA4/gIHCamXEmpnxdC/G41GNSYMAjM6Qo1hUeLuvdKtGskTIsY0Bn12+X9VvgFK/w55XCqdNXWZtNJm+6xnJn2lWo+MQ1pCGT9o2vkCt7IXz5VeCFFsRAFs58cUUIvH/Nu1VL2wOUON2qbms0VnLF0oLvFwZG1u25TSzMOMJTM2s0HjjnP5Ef/mx4QvLEXYuwvH04lK2LP3iQvO0dYRildZ3Te5fAcgHgqNeqk8S3gg3ECAwEAAaMeMBwwGgYDVR0RBBMwEYIPbWRtLmV4YW1wbGUub3JnMA0GCSqGSIb3DQEBCwUAA4IBAQAVuu9eLtd609JBMHIcFUA1h0MvnPZ7bJQCYjIvh7CIwl7SBlFiaQ3gIahelAR5pqdOxpqoYZdjgkns4qH4GH6NDORoVl7WPPIpT4s9cD+zaEzMrc1ZmzPwEksBl89yfkB5QH0kXhe4jpSxtcYOwGQ7BOJDDqhqiI47NnTF5Xsy53OocauXVXSdDYfHNxAokijKMWEQRnGs2Gjc5jF/se/ojXko3pCP71Q4lGFRo/yqGUmwZ8Ul/3Bm/H4nk/rvcYbcXToIpDuE4ioXhsGZD/tfDKSGd4QyEL5sBb/8ULuC+y1nolRY7zZTc3eUVEJUM7li4JHB2s5rKGNh7rtCvJQw:" +) + +func TestExtractRFC9440(t *testing.T) { + _, err := ExtractRFC9440("") + assertError(t, err) + + _, err = ExtractRFC9440(":") + assertError(t, err) + + _, err = ExtractRFC9440(":INVALID:") + assertError(t, err) + + _, err = ExtractRFC9440(certRFC9440) + assertNilError(t, err) +} + +func TestQueryEscapedPEM(t *testing.T) { + _, err := ExtractQueryEscapedPEM("") + assertError(t, err) + + _, err = ExtractQueryEscapedPEM("%GK") // invalid query escape code + assertError(t, err) + + _, err = ExtractQueryEscapedPEM("INVALID") + assertNilError(t, err) + + _, err = ExtractQueryEscapedPEM(certQueryEscaped) + assertNilError(t, err) +} diff --git a/http/mdm/mdm_cert.go b/http/mdm/mdm_cert.go index 2a35c47..1ff43d0 100644 --- a/http/mdm/mdm_cert.go +++ b/http/mdm/mdm_cert.go @@ -3,10 +3,10 @@ package mdm import ( "context" "crypto/x509" + "fmt" "net/http" - "net/url" + "strings" - "github.com/micromdm/nanomdm/cryptoutil" mdmhttp "github.com/micromdm/nanomdm/http" "github.com/micromdm/nanomdm/storage" @@ -19,33 +19,46 @@ type contextKeyCert struct{} var contextEnrollmentID struct{} // CertExtractPEMHeaderMiddleware extracts the MDM enrollment identity -// certificate from the request into the HTTP request context. It looks -// at the request header which should be a URL-encoded PEM certificate. +// certificate from an HTTP header of the request and places the +// parsed certificate onto the HTTP request context. See [GetCert]. // -// This is ostensibly to support Nginx' $ssl_client_escaped_cert in a -// proxy_set_header directive. Though any reverse proxy setting a -// similar header could be used, of course. +// The format of the header is parsed as RFC 9440 if it begins with +// a colon, otherwise a URL query-escaped PEM certificate is assumed. func CertExtractPEMHeaderMiddleware(next http.Handler, header string, logger log.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - logger := ctxlog.Logger(r.Context(), logger) - escapedCert := r.Header.Get(header) - if escapedCert == "" { - logger.Debug("msg", "empty header", "header", header) + logger := ctxlog.Logger(r.Context(), logger).With("header", header) + + headerValue := r.Header.Get(header) + if headerValue == "" { + logger.Debug("msg", "empty header") next.ServeHTTP(w, r) return } - pemCert, err := url.QueryUnescape(escapedCert) + + var cert *x509.Certificate + var err error + if strings.HasPrefix(headerValue, ":") { + cert, err = ExtractRFC9440(headerValue) + if err != nil { + err = fmt.Errorf("rfc9440: %w", err) + } + } else { + cert, err = ExtractQueryEscapedPEM(headerValue) + if err != nil { + err = fmt.Errorf("query escaped: %w", err) + } + } if err != nil { - logger.Info("msg", "unescaping header", "header", header, "err", err) - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + logger.Info("msg", "cert extract", "err", err) + next.ServeHTTP(w, r) return } - cert, err := cryptoutil.DecodePEMCertificate([]byte(pemCert)) - if err != nil { - logger.Info("msg", "decoding cert", "header", header, "err", err) - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + if cert == nil { + logger.Debug("msg", "empty certificate") + next.ServeHTTP(w, r) return } + ctx := context.WithValue(r.Context(), contextKeyCert{}, cert) next.ServeHTTP(w, r.WithContext(ctx)) } From 587099a9e98f51e9fa8cfa174beb791a750aef02 Mon Sep 17 00:00:00 2001 From: Jesse Peterson Date: Thu, 30 Jan 2025 11:53:29 -0800 Subject: [PATCH 2/5] added back TLS in switch --- cmd/nanomdm/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/nanomdm/main.go b/cmd/nanomdm/main.go index 4172233..476ce19 100644 --- a/cmd/nanomdm/main.go +++ b/cmd/nanomdm/main.go @@ -65,7 +65,7 @@ func main() { flRootsPath = flag.String("ca", "", "path to PEM CA cert(s)") flIntsPath = flag.String("intermediate", "", "path to PEM intermediate cert(s)") flWebhook = flag.String("webhook-url", "", "URL to send requests to") - flCertHeader = flag.String("cert-header", "", "HTTP header containing client certificate") + flCertHeader = flag.String("cert-header", "", "HTTP header containing TLS client certificate") flDebug = flag.Bool("debug", false, "log debug messages") flDump = flag.Bool("dump", false, "dump MDM requests and responses to stdout") flDisableMDM = flag.Bool("disable-mdm", false, "disable MDM HTTP endpoint") From 01db39171f0757393488df642cdd3a4bb7beca8c Mon Sep 17 00:00:00 2001 From: Jesse Peterson Date: Thu, 30 Jan 2025 11:54:14 -0800 Subject: [PATCH 3/5] added back TLS in switch (2nd) --- docs/operations-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/operations-guide.md b/docs/operations-guide.md index ddfeb0f..74f8398 100644 --- a/docs/operations-guide.md +++ b/docs/operations-guide.md @@ -40,7 +40,7 @@ NanoMDM validates that the device identity certificate is issued from specific C ### -cert-header string -* HTTP header containing client certificate +* HTTP header containing TLS client certificate By default NanoMDM tries to extract the device identity certificate from the HTTP request by decoding the "Mdm-Signature" header. See ["Pass an Identity Certificate Through a Proxy" section of this documentation for details](https://developer.apple.com/documentation/devicemanagement/implementing_device_management/managing_certificates_for_mdm_servers_and_devices). This corresponds to the `SignMessage` key being set to true in the enrollment profile. From 0fcd3c8fa1d6393639ad13011db01afbedf08851 Mon Sep 17 00:00:00 2001 From: Jesse Peterson Date: Thu, 30 Jan 2025 11:57:31 -0800 Subject: [PATCH 4/5] fix test error assert --- http/mdm/cert_extract_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/http/mdm/cert_extract_test.go b/http/mdm/cert_extract_test.go index b470645..6485402 100644 --- a/http/mdm/cert_extract_test.go +++ b/http/mdm/cert_extract_test.go @@ -43,7 +43,7 @@ func TestQueryEscapedPEM(t *testing.T) { assertError(t, err) _, err = ExtractQueryEscapedPEM("INVALID") - assertNilError(t, err) + assertError(t, err) _, err = ExtractQueryEscapedPEM(certQueryEscaped) assertNilError(t, err) From 561ad3215cbed0d2b634cd9f759efcb9bd6f3b33 Mon Sep 17 00:00:00 2001 From: Jesse Peterson Date: Thu, 30 Jan 2025 12:02:05 -0800 Subject: [PATCH 5/5] check for non-nil cert --- http/mdm/cert_extract_test.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/http/mdm/cert_extract_test.go b/http/mdm/cert_extract_test.go index 6485402..9319166 100644 --- a/http/mdm/cert_extract_test.go +++ b/http/mdm/cert_extract_test.go @@ -1,6 +1,8 @@ package mdm -import "testing" +import ( + "testing" +) func assertError(t *testing.T, err error) { t.Helper() @@ -31,8 +33,11 @@ func TestExtractRFC9440(t *testing.T) { _, err = ExtractRFC9440(":INVALID:") assertError(t, err) - _, err = ExtractRFC9440(certRFC9440) + cert, err := ExtractRFC9440(certRFC9440) assertNilError(t, err) + if cert == nil { + t.Error("expected cert") + } } func TestQueryEscapedPEM(t *testing.T) { @@ -45,6 +50,9 @@ func TestQueryEscapedPEM(t *testing.T) { _, err = ExtractQueryEscapedPEM("INVALID") assertError(t, err) - _, err = ExtractQueryEscapedPEM(certQueryEscaped) + cert, err := ExtractQueryEscapedPEM(certQueryEscaped) assertNilError(t, err) + if cert == nil { + t.Error("expected cert") + } }