Skip to content

Commit d7eb85f

Browse files
add support for rfc9440 cert headers (#165)
* add support for rfc9440 certificate headers * added back TLS in switch * added back TLS in switch (2nd) * fix test error assert * check for non-nil cert
1 parent 637eb0e commit d7eb85f

File tree

5 files changed

+146
-21
lines changed

5 files changed

+146
-21
lines changed

cmd/nanomdm/main.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func main() {
6565
flRootsPath = flag.String("ca", "", "path to PEM CA cert(s)")
6666
flIntsPath = flag.String("intermediate", "", "path to PEM intermediate cert(s)")
6767
flWebhook = flag.String("webhook-url", "", "URL to send requests to")
68-
flCertHeader = flag.String("cert-header", "", "HTTP header containing URL-escaped TLS client certificate")
68+
flCertHeader = flag.String("cert-header", "", "HTTP header containing TLS client certificate")
6969
flDebug = flag.Bool("debug", false, "log debug messages")
7070
flDump = flag.Bool("dump", false, "dump MDM requests and responses to stdout")
7171
flDisableMDM = flag.Bool("disable-mdm", false, "disable MDM HTTP endpoint")

docs/operations-guide.md

+7-2
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,16 @@ NanoMDM validates that the device identity certificate is issued from specific C
4040

4141
### -cert-header string
4242

43-
* HTTP header containing URL-escaped TLS client certificate
43+
* HTTP header containing TLS client certificate
4444

4545
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.
4646

47-
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.
47+
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.
48+
49+
[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).
50+
51+
> [!NOTE]
52+
> NanoMDM v0.7.0 and below do not support RFC 9440 header parsing, only URL query-escaped PEM certificates.
4853
4954
### -checkin
5055

http/mdm/cert_extract.go

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package mdm
2+
3+
import (
4+
"crypto/x509"
5+
"encoding/base64"
6+
"errors"
7+
"fmt"
8+
"net/url"
9+
10+
"github.com/micromdm/nanomdm/cryptoutil"
11+
)
12+
13+
// ExtractRFC9440 attempts to parse a certificate out of an RFC 9440-style header value.
14+
// RFC 9440 is, basically, the base64-encoded DER certificate surrounded by colons.
15+
func ExtractRFC9440(headerValue string) (*x509.Certificate, error) {
16+
if len(headerValue) < 3 {
17+
return nil, errors.New("header too short")
18+
}
19+
if headerValue[0] != ':' || headerValue[len(headerValue)-1] != ':' {
20+
return nil, errors.New("invalid prefix or suffix")
21+
}
22+
certBytes, err := base64.StdEncoding.DecodeString(headerValue[1 : len(headerValue)-1])
23+
if err != nil {
24+
return nil, fmt.Errorf("decoding base64: %w", err)
25+
}
26+
cert, err := x509.ParseCertificate(certBytes)
27+
if err != nil {
28+
return nil, fmt.Errorf("parse certificate: %w", err)
29+
}
30+
return cert, nil
31+
}
32+
33+
// ExtractQueryEscapedPEM parses a PEM certificate from a URL query-escaped header value.
34+
// This is ostensibly to support Nginx' $ssl_client_escaped_cert in a `proxy_set_header` directive.
35+
func ExtractQueryEscapedPEM(headerValue string) (*x509.Certificate, error) {
36+
if len(headerValue) < 1 {
37+
return nil, errors.New("header too short")
38+
}
39+
certPEM, err := url.QueryUnescape(headerValue)
40+
if err != nil {
41+
return nil, fmt.Errorf("query unescape: %w", err)
42+
43+
}
44+
cert, err := cryptoutil.DecodePEMCertificate([]byte(certPEM))
45+
if err != nil {
46+
return nil, fmt.Errorf("decode certificate: %w", err)
47+
}
48+
return cert, nil
49+
}

http/mdm/cert_extract_test.go

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package mdm
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func assertError(t *testing.T, err error) {
8+
t.Helper()
9+
if err == nil {
10+
t.Error("expected error")
11+
}
12+
}
13+
14+
func assertNilError(t *testing.T, err error) {
15+
t.Helper()
16+
if err != nil {
17+
t.Errorf("unexpected error: %v", err)
18+
}
19+
}
20+
21+
const (
22+
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"
23+
certRFC9440 = ":MIIC1TCCAb2gAwIBAgIJAOOl7VQeisl5MA0GCSqGSIb3DQEBCwUAMBoxGDAWBgNVBAMMD21kbS5leGFtcGxlLm9yZzAeFw0yNTAxMzAxOTA3NDhaFw0yNjAxMzAxOTA3NDhaMBoxGDAWBgNVBAMMD21kbS5leGFtcGxlLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMMuJRNUCmgdKs6W+dVna8ftPokGsm7xN7xGG+HcAs41I2ImgcrbXG35/b9OWlG3/FxAJuXwWaajcRVcfdXHeBwinsdiywzxWDjaL30tjCaA4/gIHCamXEmpnxdC/G41GNSYMAjM6Qo1hUeLuvdKtGskTIsY0Bn12+X9VvgFK/w55XCqdNXWZtNJm+6xnJn2lWo+MQ1pCGT9o2vkCt7IXz5VeCFFsRAFs58cUUIvH/Nu1VL2wOUON2qbms0VnLF0oLvFwZG1u25TSzMOMJTM2s0HjjnP5Ef/mx4QvLEXYuwvH04lK2LP3iQvO0dYRildZ3Te5fAcgHgqNeqk8S3gg3ECAwEAAaMeMBwwGgYDVR0RBBMwEYIPbWRtLmV4YW1wbGUub3JnMA0GCSqGSIb3DQEBCwUAA4IBAQAVuu9eLtd609JBMHIcFUA1h0MvnPZ7bJQCYjIvh7CIwl7SBlFiaQ3gIahelAR5pqdOxpqoYZdjgkns4qH4GH6NDORoVl7WPPIpT4s9cD+zaEzMrc1ZmzPwEksBl89yfkB5QH0kXhe4jpSxtcYOwGQ7BOJDDqhqiI47NnTF5Xsy53OocauXVXSdDYfHNxAokijKMWEQRnGs2Gjc5jF/se/ojXko3pCP71Q4lGFRo/yqGUmwZ8Ul/3Bm/H4nk/rvcYbcXToIpDuE4ioXhsGZD/tfDKSGd4QyEL5sBb/8ULuC+y1nolRY7zZTc3eUVEJUM7li4JHB2s5rKGNh7rtCvJQw:"
24+
)
25+
26+
func TestExtractRFC9440(t *testing.T) {
27+
_, err := ExtractRFC9440("")
28+
assertError(t, err)
29+
30+
_, err = ExtractRFC9440(":")
31+
assertError(t, err)
32+
33+
_, err = ExtractRFC9440(":INVALID:")
34+
assertError(t, err)
35+
36+
cert, err := ExtractRFC9440(certRFC9440)
37+
assertNilError(t, err)
38+
if cert == nil {
39+
t.Error("expected cert")
40+
}
41+
}
42+
43+
func TestQueryEscapedPEM(t *testing.T) {
44+
_, err := ExtractQueryEscapedPEM("")
45+
assertError(t, err)
46+
47+
_, err = ExtractQueryEscapedPEM("%GK") // invalid query escape code
48+
assertError(t, err)
49+
50+
_, err = ExtractQueryEscapedPEM("INVALID")
51+
assertError(t, err)
52+
53+
cert, err := ExtractQueryEscapedPEM(certQueryEscaped)
54+
assertNilError(t, err)
55+
if cert == nil {
56+
t.Error("expected cert")
57+
}
58+
}

http/mdm/mdm_cert.go

+31-18
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ package mdm
33
import (
44
"context"
55
"crypto/x509"
6+
"fmt"
67
"net/http"
7-
"net/url"
8+
"strings"
89

9-
"github.com/micromdm/nanomdm/cryptoutil"
1010
mdmhttp "github.com/micromdm/nanomdm/http"
1111
"github.com/micromdm/nanomdm/storage"
1212

@@ -19,33 +19,46 @@ type contextKeyCert struct{}
1919
var contextEnrollmentID struct{}
2020

2121
// CertExtractPEMHeaderMiddleware extracts the MDM enrollment identity
22-
// certificate from the request into the HTTP request context. It looks
23-
// at the request header which should be a URL-encoded PEM certificate.
22+
// certificate from an HTTP header of the request and places the
23+
// parsed certificate onto the HTTP request context. See [GetCert].
2424
//
25-
// This is ostensibly to support Nginx' $ssl_client_escaped_cert in a
26-
// proxy_set_header directive. Though any reverse proxy setting a
27-
// similar header could be used, of course.
25+
// The format of the header is parsed as RFC 9440 if it begins with
26+
// a colon, otherwise a URL query-escaped PEM certificate is assumed.
2827
func CertExtractPEMHeaderMiddleware(next http.Handler, header string, logger log.Logger) http.HandlerFunc {
2928
return func(w http.ResponseWriter, r *http.Request) {
30-
logger := ctxlog.Logger(r.Context(), logger)
31-
escapedCert := r.Header.Get(header)
32-
if escapedCert == "" {
33-
logger.Debug("msg", "empty header", "header", header)
29+
logger := ctxlog.Logger(r.Context(), logger).With("header", header)
30+
31+
headerValue := r.Header.Get(header)
32+
if headerValue == "" {
33+
logger.Debug("msg", "empty header")
3434
next.ServeHTTP(w, r)
3535
return
3636
}
37-
pemCert, err := url.QueryUnescape(escapedCert)
37+
38+
var cert *x509.Certificate
39+
var err error
40+
if strings.HasPrefix(headerValue, ":") {
41+
cert, err = ExtractRFC9440(headerValue)
42+
if err != nil {
43+
err = fmt.Errorf("rfc9440: %w", err)
44+
}
45+
} else {
46+
cert, err = ExtractQueryEscapedPEM(headerValue)
47+
if err != nil {
48+
err = fmt.Errorf("query escaped: %w", err)
49+
}
50+
}
3851
if err != nil {
39-
logger.Info("msg", "unescaping header", "header", header, "err", err)
40-
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
52+
logger.Info("msg", "cert extract", "err", err)
53+
next.ServeHTTP(w, r)
4154
return
4255
}
43-
cert, err := cryptoutil.DecodePEMCertificate([]byte(pemCert))
44-
if err != nil {
45-
logger.Info("msg", "decoding cert", "header", header, "err", err)
46-
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
56+
if cert == nil {
57+
logger.Debug("msg", "empty certificate")
58+
next.ServeHTTP(w, r)
4759
return
4860
}
61+
4962
ctx := context.WithValue(r.Context(), contextKeyCert{}, cert)
5063
next.ServeHTTP(w, r.WithContext(ctx))
5164
}

0 commit comments

Comments
 (0)