From 8d8a3cd89af09c1003f2c917a0f071c06fd58784 Mon Sep 17 00:00:00 2001 From: Daniel Kimsey Date: Tue, 9 Aug 2022 22:44:42 -0500 Subject: [PATCH 1/2] Add replacer vars for mTLS connection details. This is based on the effort in caddyhttp's replacer but with added Subject/Issuer details. Also include a minor update to add the cipher_suite replacer for parity. --- modules/l4tls/handler.go | 241 ++++++++++++++++++++++++++++++++++ modules/l4tls/handler_test.go | 137 +++++++++++++++++++ modules/l4tls/matcher.go | 1 + 3 files changed, 379 insertions(+) create mode 100644 modules/l4tls/handler_test.go diff --git a/modules/l4tls/handler.go b/modules/l4tls/handler.go index 7c77412..c4061bf 100644 --- a/modules/l4tls/handler.go +++ b/modules/l4tls/handler.go @@ -15,8 +15,21 @@ package l4tls import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha256" "crypto/tls" + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "encoding/pem" "fmt" + "net" + "net/url" + "strconv" + "strings" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/modules/caddytls" @@ -96,6 +109,9 @@ func (t *Handler) Handle(cx *layer4.Connection, next layer4.Handler) error { connectionState := tlsConn.ConnectionState() appendConnectionState(cx, &connectionState) + repl := cx.Context.Value(layer4.ReplacerCtxKey).(*caddy.Replacer) + addTLSVarsToReplacer(repl, connectionState) + // all future reads/writes will now be decrypted/encrypted // (tlsConn, which wraps cx, is wrapped into a new cx so // that future I/O succeeds... if we use the same cx, it'd @@ -104,6 +120,209 @@ func (t *Handler) Handle(cx *layer4.Connection, next layer4.Handler) error { return next.Handle(cx.Wrap(tlsConn)) } +// FIXME: Should this perhaps be moved instead to caddytls? +func addTLSVarsToReplacer(repl *caddy.Replacer, cs tls.ConnectionState) { + cert := getTLSPeerCert(cs) + if cert == nil { + return + } + repl.Map(func(key string) (interface{}, bool) { + if !strings.HasPrefix(key, "l4.tls.") { + return "", false + } + field := strings.ToLower(key[len("l4.tls."):]) + // subject alternate names (SANs) + if strings.HasPrefix(field, "client.san.") { + field = field[len("client.san."):] + var fieldName string + var fieldValue interface{} + switch { + case strings.HasPrefix(field, "dns_names"): + fieldName = "dns_names" + fieldValue = cert.DNSNames + case strings.HasPrefix(field, "emails"): + fieldName = "emails" + fieldValue = cert.EmailAddresses + case strings.HasPrefix(field, "ips"): + fieldName = "ips" + fieldValue = cert.IPAddresses + case strings.HasPrefix(field, "uris"): + fieldName = "uris" + fieldValue = cert.URIs + default: + return nil, false + } + field = field[len(fieldName):] + + // if no index was specified, return the whole list + if field == "" { + return fieldValue, true + } + if len(field) < 2 || field[0] != '.' { + return nil, false + } + field = field[1:] // trim '.' between field name and index + + // get the numeric index + idx, err := strconv.Atoi(field) + if err != nil || idx < 0 { + return nil, false + } + + // access the indexed element and return it + switch v := fieldValue.(type) { + case []string: + if idx >= len(v) { + return nil, true + } + return v[idx], true + case []net.IP: + if idx >= len(v) { + return nil, true + } + return v[idx], true + case []*url.URL: + if idx >= len(v) { + return nil, true + } + return v[idx], true + } + } + // Break-out the client's Subject + if strings.HasPrefix(field, "client.subject.") { + field = field[len("client.subject."):] + var fieldName string + var fieldValue []string + switch { + case field == "common_name": + // There can only be one. + return cert.Subject.CommonName, true + case strings.HasPrefix(field, "organizational_unit"): + fieldName = "organizational_unit" + fieldValue = cert.Subject.OrganizationalUnit + case strings.HasPrefix(field, "organization"): + fieldName = "organization" + fieldValue = cert.Subject.Organization + case strings.HasPrefix(field, "country"): + fieldName = "country" + fieldValue = cert.Subject.Country + case strings.HasPrefix(field, "locality"): + fieldName = "locality" + fieldValue = cert.Subject.Locality + case strings.HasPrefix(field, "province"): + fieldName = "province" + fieldValue = cert.Subject.Province + default: + return nil, false + } + field = field[len(fieldName):] + + // if no index was specified, return the whole list + if field == "" { + return fieldValue, true + } + if len(field) < 2 || field[0] != '.' { + return nil, false + } + field = field[1:] // trim '.' between field name and index + + // get the numeric index + idx, err := strconv.Atoi(field) + if err != nil || idx < 0 { + return nil, false + } + + // access the indexed element and return it + if idx >= len(fieldValue) { + return nil, true + } + return fieldValue[idx], true + } + // Break-out the issuer's Subject + if strings.HasPrefix(field, "client.issuer.") { + field = field[len("client.issuer."):] + var fieldName string + var fieldValue []string + switch { + case field == "common_name": + // There can only be one. + return cert.Issuer.CommonName, true + case strings.HasPrefix(field, "organizational_unit"): + fieldName = "organizational_unit" + fieldValue = cert.Issuer.OrganizationalUnit + case strings.HasPrefix(field, "organization"): + fieldName = "organization" + fieldValue = cert.Issuer.Organization + case strings.HasPrefix(field, "country"): + fieldName = "country" + fieldValue = cert.Issuer.Country + case strings.HasPrefix(field, "locality"): + fieldName = "locality" + fieldValue = cert.Issuer.Locality + case strings.HasPrefix(field, "province"): + fieldName = "province" + fieldValue = cert.Issuer.Province + default: + return nil, false + } + field = field[len(fieldName):] + + // if no index was specified, return the whole list + if field == "" { + return fieldValue, true + } + if len(field) < 2 || field[0] != '.' { + return nil, false + } + field = field[1:] // trim '.' between field name and index + + // get the numeric index + idx, err := strconv.Atoi(field) + if err != nil || idx < 0 { + return nil, false + } + + // access the indexed element and return it + if idx >= len(fieldValue) { + return nil, true + } + return fieldValue[idx], true + } + // Remaining client mTLS fields + switch field { + case "client.fingerprint": + return fmt.Sprintf("%x", sha256.Sum256(cert.Raw)), true + case "client.public_key", "client.public_key_sha256": + if cert.PublicKey == nil { + return nil, true + } + pubKeyBytes, err := marshalPublicKey(cert.PublicKey) + if err != nil { + return nil, true + } + if strings.HasSuffix(field, "_sha256") { + return fmt.Sprintf("%x", sha256.Sum256(pubKeyBytes)), true + } + return fmt.Sprintf("%x", pubKeyBytes), true + case "client.issuer": + return cert.Issuer, true + case "client.serial": + return cert.SerialNumber, true + case "client.subject": + return cert.Subject, true + case "client.common_name": + return cert.Subject.CommonName, true + case "client.certificate_pem": + block := pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw} + return pem.EncodeToMemory(&block), true + case "client.certificate_der_base64": + return base64.StdEncoding.EncodeToString(cert.Raw), true + default: + return nil, false + } + }) +} + func appendClientHello(cx *layer4.Connection, chi ClientHelloInfo) { var clientHellos []ClientHelloInfo if val := cx.GetVar("tls_client_hellos"); val != nil { @@ -140,6 +359,28 @@ func GetConnectionStates(cx *layer4.Connection) []*tls.ConnectionState { return connectionStates } +// marshalPublicKey returns the byte encoding of pubKey. +func marshalPublicKey(pubKey interface{}) ([]byte, error) { + switch key := pubKey.(type) { + case *rsa.PublicKey: + return asn1.Marshal(key) + case *ecdsa.PublicKey: + return elliptic.Marshal(key.Curve, key.X, key.Y), nil + case ed25519.PublicKey: + return key, nil + } + return nil, fmt.Errorf("unrecognized public key type: %T", pubKey) +} + +// getTLSPeerCert retrieves the first peer certificate from a TLS session. +// Returns nil if no peer cert is in use. +func getTLSPeerCert(cs tls.ConnectionState) *x509.Certificate { + if len(cs.PeerCertificates) == 0 { + return nil + } + return cs.PeerCertificates[0] +} + // Interface guards var ( _ caddy.Provisioner = (*Handler)(nil) diff --git a/modules/l4tls/handler_test.go b/modules/l4tls/handler_test.go new file mode 100644 index 0000000..09c269c --- /dev/null +++ b/modules/l4tls/handler_test.go @@ -0,0 +1,137 @@ +// Copyright 2020 Matthew Holt +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package l4tls + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "testing" + + "github.com/caddyserver/caddy/v2" +) + +func TestTLSVarReplacement(t *testing.T) { + repl := caddy.NewReplacer() + + clientCert := []byte(`-----BEGIN CERTIFICATE----- +MIIB9jCCAV+gAwIBAgIBAjANBgkqhkiG9w0BAQsFADAYMRYwFAYDVQQDDA1DYWRk +eSBUZXN0IENBMB4XDTE4MDcyNDIxMzUwNVoXDTI4MDcyMTIxMzUwNVowHTEbMBkG +A1UEAwwSY2xpZW50LmxvY2FsZG9tYWluMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB +iQKBgQDFDEpzF0ew68teT3xDzcUxVFaTII+jXH1ftHXxxP4BEYBU4q90qzeKFneF +z83I0nC0WAQ45ZwHfhLMYHFzHPdxr6+jkvKPASf0J2v2HDJuTM1bHBbik5Ls5eq+ +fVZDP8o/VHKSBKxNs8Goc2NTsr5b07QTIpkRStQK+RJALk4x9QIDAQABo0swSTAJ +BgNVHRMEAjAAMAsGA1UdDwQEAwIHgDAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8A +AAEwEwYDVR0lBAwwCgYIKwYBBQUHAwIwDQYJKoZIhvcNAQELBQADgYEANSjz2Sk+ +eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV +3Q9fgDkiUod+uIK0IynzIKvw+Cjg+3nx6NQ0IM0zo8c7v398RzB4apbXKZyeeqUH +9fNwfEi+OoXR6s+upSKobCmLGLGi9Na5s5g= +-----END CERTIFICATE-----`) + + block, _ := pem.Decode(clientCert) + if block == nil { + t.Fatalf("failed to decode PEM certificate") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("failed to decode PEM certificate: %v", err) + } + + cs := tls.ConnectionState{ + Version: tls.VersionTLS13, + HandshakeComplete: true, + ServerName: "foo.com", + CipherSuite: tls.TLS_AES_256_GCM_SHA384, + PeerCertificates: []*x509.Certificate{cert}, + NegotiatedProtocol: "h2", + NegotiatedProtocolIsMutual: true, + } + addTLSVarsToReplacer(repl, cs) + + for i, tc := range []struct { + input string + expect string + }{ + // FIXME: These are set during the match phase, but it's unclear how to construct a test that properly + // exercises the entire code-path to build these. + // { + // input: "{l4.tls.cipher_suite}", + // expect: "TLS_AES_256_GCM_SHA384", + // }, + // { + // input: "{l4.tls.server_name}", + // expect: "foo.com", + // }, + // { + // input: "{l4.tls.version}", + // expect: "tls1.3", + // }, + { + input: "{l4.tls.client.fingerprint}", + expect: "9f57b7b497cceacc5459b76ac1c3afedbc12b300e728071f55f84168ff0f7702", + }, + { + input: "{l4.tls.client.issuer}", + expect: "CN=Caddy Test CA", + }, + { + input: "{l4.tls.client.serial}", + expect: "2", + }, + { + input: "{l4.tls.client.subject}", + expect: "CN=client.localdomain", + }, + { + input: "{l4.tls.client.san.dns_names}", + expect: "[localhost]", + }, + { + input: "{l4.tls.client.san.dns_names.0}", + expect: "localhost", + }, + { + input: "{l4.tls.client.san.dns_names.1}", + expect: "", + }, + { + input: "{l4.tls.client.san.ips}", + expect: "[127.0.0.1]", + }, + { + input: "{l4.tls.client.san.ips.0}", + expect: "127.0.0.1", + }, + { + input: "{l4.tls.client.certificate_pem}", + expect: string(clientCert) + "\n", // returned value comes with a newline appended to it + }, + { + input: "{l4.tls.client.subject.common_name}", + expect: "client.localdomain", + }, + { + input: "{l4.tls.client.issuer.common_name}", + expect: "Caddy Test CA", + }, + } { + actual := repl.ReplaceAll(tc.input, "") + if actual != tc.expect { + t.Errorf("Test %d: Expected placeholder %s to be '%s' but got '%s'", + i, tc.input, tc.expect, actual) + } + } +} diff --git a/modules/l4tls/matcher.go b/modules/l4tls/matcher.go index 8c20a59..015a0f5 100644 --- a/modules/l4tls/matcher.go +++ b/modules/l4tls/matcher.go @@ -101,6 +101,7 @@ func (m MatchTLS) Match(cx *layer4.Connection) (bool, error) { repl := cx.Context.Value(layer4.ReplacerCtxKey).(*caddy.Replacer) repl.Set("l4.tls.server_name", chi.ClientHelloInfo.ServerName) repl.Set("l4.tls.version", chi.Version) + repl.Set("l4.tls.cipher_suite", chi.CipherSuites) for _, matcher := range m.matchers { // TODO: even though we have more data than the standard lib's From 68c82e9ca1a27ac4128d3aed6692feb8c6c19299 Mon Sep 17 00:00:00 2001 From: Daniel Kimsey Date: Tue, 9 Aug 2022 22:53:00 -0500 Subject: [PATCH 2/2] fixup: test fallthrough --- modules/l4tls/handler_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/modules/l4tls/handler_test.go b/modules/l4tls/handler_test.go index 09c269c..67ded59 100644 --- a/modules/l4tls/handler_test.go +++ b/modules/l4tls/handler_test.go @@ -119,14 +119,26 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV input: "{l4.tls.client.certificate_pem}", expect: string(clientCert) + "\n", // returned value comes with a newline appended to it }, + { + input: "{l4.tls.client.not_a_valid_key}", + expect: "", + }, { input: "{l4.tls.client.subject.common_name}", expect: "client.localdomain", }, + { + input: "{l4.tls.client.subject.not_a_valid_key}", + expect: "", + }, { input: "{l4.tls.client.issuer.common_name}", expect: "Caddy Test CA", }, + { + input: "{l4.tls.client.issuer.not_a_valid_key}", + expect: "", + }, } { actual := repl.ReplaceAll(tc.input, "") if actual != tc.expect {