Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add support for rfc9440 cert headers #165

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/nanomdm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 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")
Expand Down
9 changes: 7 additions & 2 deletions docs/operations-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

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

Expand Down
49 changes: 49 additions & 0 deletions http/mdm/cert_extract.go
Original file line number Diff line number Diff line change
@@ -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
}
58 changes: 58 additions & 0 deletions http/mdm/cert_extract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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)

cert, err := ExtractRFC9440(certRFC9440)
assertNilError(t, err)
if cert == nil {
t.Error("expected cert")
}
}

func TestQueryEscapedPEM(t *testing.T) {
_, err := ExtractQueryEscapedPEM("")
assertError(t, err)

_, err = ExtractQueryEscapedPEM("%GK") // invalid query escape code
assertError(t, err)

_, err = ExtractQueryEscapedPEM("INVALID")
assertError(t, err)

cert, err := ExtractQueryEscapedPEM(certQueryEscaped)
assertNilError(t, err)
if cert == nil {
t.Error("expected cert")
}
}
49 changes: 31 additions & 18 deletions http/mdm/mdm_cert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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))
}
Expand Down