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

expand client cert search capabilities #380

Merged
merged 3 commits into from
Jan 11, 2024
Merged
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
9 changes: 1 addition & 8 deletions api/tunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import (
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"

"github.com/pomerium/cli/certstore"
pb "github.com/pomerium/cli/proto"
"github.com/pomerium/cli/tcptunnel"
)
Expand Down Expand Up @@ -103,13 +102,7 @@ func getTLSConfig(conn *pb.Connection) (*tls.Config, error) {
}
cfg.Certificates = append(cfg.Certificates, cert)
}
if cn := conn.GetClientCertIssuerCn(); cn != "" {
cert, err := certstore.LoadCert(cn)
if err != nil {
return nil, fmt.Errorf("loading client cert: %w", err)
}
cfg.Certificates = append(cfg.Certificates, *cert)
}
// TODO: add option corresponding to --client-cert-from-store

if len(conn.GetCaCert()) == 0 {
return cfg, nil
Expand Down
134 changes: 134 additions & 0 deletions certstore/certstore.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,137 @@
// Package certstore handles loading client certificates and private keys from
// an OS-specific certificate store.
package certstore

import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"fmt"
"strings"
)

var errNotSupported = errors.New("this build of pomerium-cli does not support this feature")

// GetClientCertificateFunc returns a function suitable for use as a
// [tls.Config.GetClientCertificate] callback. This function searches for a
// client certificate in the system trust store according to the list of
// acceptable CA names from the Certificate Request message, with optional
// additional filter conditions based on the Issuer name and/or the Subject
// name in the end-entity certificate.
//
// Filter conditions should be of the form "attribute=value", e.g. "CN=my cert
// name". Each condition may include at most one attribute/value pair. Only
// attributes corresponding to named fields of [pkix.Name] may be used
// (attribute keys are compared case-insensitively). These attributes are:
// - commonName (CN)
// - countryName (C)
// - localityName (L)
// - organizationName (O)
// - organizationalUnitName (OU)
// - postalCode
// - serialNumber
// - stateOrProvinceName (ST)
// - streetAddress (STREET)
//
// Names containing multiple values for the same attribute are not supported.
func GetClientCertificateFunc(
issuerFilter, subjectFilter string,
) (func(*tls.CertificateRequestInfo) (*tls.Certificate, error), error) {
if !IsCertstoreSupported {
return nil, errNotSupported
}

f, err := filterCallback(issuerFilter, subjectFilter)
if err != nil {
return nil, err
}

return func(cri *tls.CertificateRequestInfo) (*tls.Certificate, error) {
return loadCert(cri.AcceptableCAs, f)
}, nil
}

func filterCallback(issuerFilter, subjectFilter string) (func(*x509.Certificate) bool, error) {
issuerAttr, issuerValue, err := parseFilterCondition(issuerFilter)
if err != nil {
return nil, err
}
subjectAttr, subjectValue, err := parseFilterCondition(subjectFilter)
if err != nil {
return nil, err
}

return func(cert *x509.Certificate) bool {
if issuerAttr != "" {
v, err := attributeLookup(&cert.Issuer, issuerAttr)
if err != nil || v != issuerValue {
return false
}
}
if subjectAttr != "" {
v, err := attributeLookup(&cert.Subject, subjectAttr)
if err != nil || v != subjectValue {
return false
}
}
return true
}, nil
}

func parseFilterCondition(f string) (attr, value string, err error) {
if f == "" {
return
}

var ok bool
attr, value, ok = strings.Cut(f, "=")
if !ok {
err = fmt.Errorf("expected filter format attr=value, but was %q", f)
return
}

attr = strings.ToLower(attr)

// Make sure the attribute name is one we support.
_, err = attributeLookup(&pkix.Name{}, attr)
return
}

// attributeLookup returns a single attribute value from a pkix.Name struct.
// Multi-valued RDNs are not supported. Attributes and abbreviations are
// defined in RFC 2256 § 5. Only the named fields of pkix.Name are supported.
func attributeLookup(name *pkix.Name, attr string) (string, error) {
switch attr {
case "commonname", "cn":
return name.CommonName, nil
case "countryname", "c":
return flatten(name.Country)
case "localityname", "l":
return flatten(name.Locality)
case "organizationalunitname", "ou":
return flatten(name.OrganizationalUnit)
case "organizationname", "o":
return flatten(name.Organization)
case "postalcode":
return flatten(name.PostalCode)
case "serialnumber":
return name.SerialNumber, nil
case "stateorprovincename", "st":
return flatten(name.Province)
case "streetaddress", "street":
return flatten(name.StreetAddress)
default:
return "", fmt.Errorf("unsupported attribute %q", attr)
}
}

func flatten(s []string) (string, error) {
if len(s) > 1 {
return "", fmt.Errorf("multi-valued attributes are not supported")
}
if len(s) == 0 {
return "", nil
}
return s[0], nil
}
9 changes: 7 additions & 2 deletions certstore/certstore_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package certstore

import (
"crypto/tls"
"crypto/x509"

"github.com/pomerium/cli/third_party/ecpsigner/darwin/keychain"
"github.com/pomerium/cli/version"
Expand All @@ -15,8 +16,12 @@ func init() {
version.Features = append(version.Features, "keychain")
}

func LoadCert(issuer string) (*tls.Certificate, error) {
cred, err := keychain.Cred(issuer)
// loadCert searches the macOS Keychain for a client certificate, according to
// a list of acceptable CA Distinguished Names and an additional filter.
func loadCert(
acceptableCAs [][]byte, filterCallback func(*x509.Certificate) bool,
) (*tls.Certificate, error) {
cred, err := keychain.Cred(acceptableCAs, filterCallback)
if err != nil {
return nil, err
}
Expand Down
8 changes: 5 additions & 3 deletions certstore/certstore_stub.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ package certstore

import (
"crypto/tls"
"errors"
"crypto/x509"
)

var IsCertstoreSupported = false

func LoadCert(issuer string) (*tls.Certificate, error) {
return nil, errors.New("this build of pomerium-cli does not support this feature")
// loadCert is a stub that always returns an error, for builds where this
// feature is not supported.
func loadCert([][]byte, func(*x509.Certificate) bool) (*tls.Certificate, error) {
return nil, errNotSupported
}
129 changes: 129 additions & 0 deletions certstore/certstore_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package certstore

import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// Issuer: C=US, O=Pomerium, OU=Engineering, CN=Test Root CA
// Subject: C=US, ST=California, L=Los Angeles, O=Pomerium, CN=Test Certificate
const testCertPEM = `-----BEGIN CERTIFICATE-----
MIIB7jCCAZOgAwIBAgICIAAwCgYIKoZIzj0EAwIwTTELMAkGA1UEBhMCVVMxETAP
BgNVBAoTCFBvbWVyaXVtMRQwEgYDVQQLEwtFbmdpbmVlcmluZzEVMBMGA1UEAxMM
VGVzdCBSb290IENBMCIYDzAwMDEwMTAxMDAwMDAwWhgPMDAwMTAxMDEwMDAwMDBa
MGYxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRQwEgYDVQQHEwtM
b3MgQW5nZWxlczERMA8GA1UEChMIUG9tZXJpdW0xGTAXBgNVBAMTEFRlc3QgQ2Vy
dGlmaWNhdGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASwx43T5tT/gvl0MjOZ
pRMvDs2L6HqcN4vNmsbJRk/sTQrD0xVd4kzZc8mW7Q0/3WfE6QwbqkEyvxaPJ0iA
8Xhvo0YwRDATBgNVHSUEDDAKBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB8GA1Ud
IwQYMBaAFFGNnzA+46PbyMi0anD7+kfQInDQMAoGCCqGSM49BAMCA0kAMEYCIQCc
31d4ncyipKvvF/sDAb43lAcwXHh3d+J68RoGDEBaAwIhAM8zV4cVob9hkh6oxb61
q/MkLGpAvT+8J0K+JmvvCfTe
-----END CERTIFICATE-----`

func TestFilterCallback(t *testing.T) {
p, _ := pem.Decode([]byte(testCertPEM))
cert, err := x509.ParseCertificate(p.Bytes)
require.NoError(t, err)

cases := []struct {
label string
issuerFilter string
subjectFilter string
match bool
}{
{"no filter", "", "", true},
{"issuer CN match", "CN=Test Root CA", "", true},
{"issuer CN no match", "CN=Test Certificate", "", false},
{"subject ST match", "", "ST=California", true},
{"subject ST no match", "", "ST=New York", false},
{"issuer and subject match", "CN=Test Root CA", "CN=Test Certificate", true},
{"issuer and subject swapped", "CN=Test Certificate", "CN=Test Root CA", false},
{"full attribute names", "organizationName=Pomerium", "localityName=Los Angeles", true},
{"case insensitive attribute names", "o=Pomerium", "LOCALITYNAME=Los Angeles", true},
{"case sensitive values", "o=pomerium", "l=los angeles", false},
}
for i := range cases {
c := &cases[i]
t.Run(c.label, func(t *testing.T) {
f, err := filterCallback(c.issuerFilter, c.subjectFilter)
require.NoError(t, err)
assert.Equal(t, c.match, f(cert))
})
}
}

func TestParseFilterCondition(t *testing.T) {
cases := []struct {
label string
input string
attr string
value string
errMsg string
}{
{"empty", "", "", "", ""},
{"invalid", "foo", "foo", "", `expected filter format attr=value, but was "foo"`},
{"unknown", "foo=bar", "foo", "bar", `unsupported attribute "foo"`},
{"ok", "cn=some name", "cn", "some name", ""},
}
for i := range cases {
c := &cases[i]
t.Run(c.label, func(t *testing.T) {
attr, value, err := parseFilterCondition(c.input)
assert.Equal(t, c.attr, attr)
assert.Equal(t, c.value, value)
if c.errMsg == "" {
assert.NoError(t, err)
} else {
assert.Equal(t, c.errMsg, err.Error())
}
})
}
}

func TestAttributeLookup(t *testing.T) {
name := &pkix.Name{
Country: []string{"Italia"},
Organization: []string{"Pomerium"},
OrganizationalUnit: []string{"Engineering"},
Locality: []string{"Tivoli"},
Province: []string{"Roma"},
StreetAddress: []string{"Via Esempio 123"},
PostalCode: []string{"12345"},
SerialNumber: "67890",
CommonName: "common name",
}

cases := []struct {
attr string
value string
}{
{"c", "Italia"},
{"countryname", "Italia"},
{"o", "Pomerium"},
{"organizationname", "Pomerium"},
{"ou", "Engineering"},
{"organizationalunitname", "Engineering"},
{"l", "Tivoli"},
{"localityname", "Tivoli"},
{"st", "Roma"},
{"stateorprovincename", "Roma"},
{"street", "Via Esempio 123"},
{"streetaddress", "Via Esempio 123"},
{"postalcode", "12345"},
{"serialnumber", "67890"},
}
for i := range cases {
c := &cases[i]
t.Run(c.attr, func(t *testing.T) {
value, err := attributeLookup(name, c.attr)
require.NoError(t, err)
assert.Equal(t, c.value, value)
})
}
}
12 changes: 9 additions & 3 deletions certstore/certstore_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package certstore

import (
"crypto/tls"
"crypto/x509"

"github.com/pomerium/cli/third_party/ecpsigner/windows/ncrypt"
"github.com/pomerium/cli/version"
Expand All @@ -15,13 +16,18 @@ func init() {
version.Features = append(version.Features, "ncrypt")
}

func LoadCert(issuer string) (*tls.Certificate, error) {
// loadCert searches the Windows trust store for a client certificate,
// according to a list of acceptable CA Distinguished Names and an additional
// filter.
func loadCert(
acceptableCAs [][]byte, filterCallback func(*x509.Certificate) bool,
) (*tls.Certificate, error) {
// Try the MY store in both the CURRENT_USER and LOCAL_MACHINE locations.
cred, err := ncrypt.Cred(issuer, "MY", "current_user")
cred, err := ncrypt.Cred(acceptableCAs, filterCallback, "MY", "current_user")
if err == nil {
return toTLSCertificate(cred), nil
}
cred, err = ncrypt.Cred(issuer, "MY", "local_machine")
cred, err = ncrypt.Cred(acceptableCAs, filterCallback, "MY", "local_machine")
if err == nil {
return toTLSCertificate(cred), nil
}
Expand Down
Loading
Loading