Skip to content

Commit

Permalink
ppl: add new client certificate criterion (#4448)
Browse files Browse the repository at this point in the history
Add a new client_certificate criterion that accepts a "Certificate
Matcher" object. Start with two certificate match conditions:
fingerprint and SPKI hash, each of which can accept either a single
string or an array of strings.

Add new "client-certificate-ok" and "client-certificate-unauthorized"
reason strings.
  • Loading branch information
kenjenkins committed Aug 9, 2023
1 parent f7e0b61 commit ac475f4
Show file tree
Hide file tree
Showing 5 changed files with 400 additions and 27 deletions.
164 changes: 164 additions & 0 deletions pkg/policy/criteria/client_certificate.go
@@ -0,0 +1,164 @@
package criteria

import (
"encoding/base64"
"errors"
"fmt"
"regexp"
"strings"

"github.com/open-policy-agent/opa/ast"

"github.com/pomerium/pomerium/pkg/policy/generator"
"github.com/pomerium/pomerium/pkg/policy/parser"
)

var clientCertificateBaseBody = ast.MustParseBody(`
cert := crypto.x509.parse_certificates(trim_space(input.http.client_certificate.leaf))[0]
fingerprint := crypto.sha256(base64.decode(cert.Raw))
spki_hash := base64.encode(hex.decode(
crypto.sha256(base64.decode(cert.RawSubjectPublicKeyInfo))))
`)

type clientCertificateCriterion struct {
g *Generator
}

func (clientCertificateCriterion) DataType() generator.CriterionDataType {
return CriterionDataTypeCertificateMatcher
}

func (clientCertificateCriterion) Name() string {
return "client_certificate"
}

func (c clientCertificateCriterion) GenerateRule(
_ string, data parser.Value,
) (*ast.Rule, []*ast.Rule, error) {
body := append(ast.Body(nil), clientCertificateBaseBody...)

obj, ok := data.(parser.Object)
if !ok {
return nil, nil, fmt.Errorf("expected object for certificate matcher, got: %T", data)
}

for k, v := range obj {
var err error

switch k {
case "fingerprint":
err = addCertFingerprintCondition(&body, v)
case "spki_hash":
err = addCertSPKIHashCondition(&body, v)
default:
err = fmt.Errorf("unsupported certificate matcher condition: %s", k)
}

if err != nil {
return nil, nil, err
}
}

rule := NewCriterionRule(c.g, c.Name(),
ReasonClientCertificateOK, ReasonClientCertificateUnauthorized,
body)

return rule, nil, nil
}

func addCertFingerprintCondition(body *ast.Body, data parser.Value) error {
var pa parser.Array
switch v := data.(type) {
case parser.Array:
pa = v
case parser.String:
pa = parser.Array{data}
default:
return errors.New("certificate fingerprint condition expects a string or array of strings")
}

ra := ast.NewArray()
for _, v := range pa {
f, err := canonicalCertFingerprint(v)
if err != nil {
return err
}
ra = ra.Append(ast.NewTerm(f))
}

*body = append(*body,
ast.Assign.Expr(ast.VarTerm("allowed_fingerprints"), ast.NewTerm(ra)),
ast.Equal.Expr(ast.VarTerm("fingerprint"), ast.VarTerm("allowed_fingerprints[_]")))
return nil
}

// The long certificate fingerprint format is 32 uppercase hex-encoded bytes
// separated by colons.
var longCertFingerprintRE = regexp.MustCompile("^[0-9A-F]{2}(:[0-9A-F]{2}){31}$")

// The short certificate fingerprint format is 32 lowercase hex-encoded bytes.
var shortCertFingerprintRE = regexp.MustCompile("^[0-9a-f]{64}$")

// canonicalCertFingeprint converts a single fingerprint value into the format
// that our Rego logic generates.
func canonicalCertFingerprint(data parser.Value) (ast.Value, error) {
s, ok := data.(parser.String)
if !ok {
return nil, fmt.Errorf("certificate fingerprint must be a string (was %v)", data)
}

f := string(s)
if f == "" {
return nil, errors.New("certificate fingerprint must not be empty")
} else if shortCertFingerprintRE.MatchString(f) {
return ast.String(f), nil
} else if longCertFingerprintRE.MatchString(f) {
f = strings.ToLower(strings.ReplaceAll(f, ":", ""))
return ast.String(f), nil
}
return nil, fmt.Errorf("unsupported certificate fingerprint format (%s)", f)
}

func addCertSPKIHashCondition(body *ast.Body, data parser.Value) error {
var pa parser.Array
switch v := data.(type) {
case parser.Array:
pa = v
case parser.String:
pa = parser.Array{data}
default:
return errors.New("certificate SPKI hash condition expects a string or array of strings")
}

ra := ast.NewArray()
for _, v := range pa {
s, ok := v.(parser.String)
if !ok {
return fmt.Errorf("certificate SPKI hash must be a string (was %v)", v)
}

h := string(s)
if h == "" {
return errors.New("certificate SPKI hash must not be empty")
} else if b, err := base64.StdEncoding.DecodeString(h); err != nil || len(b) != 32 {
return fmt.Errorf("certificate SPKI hash must be a base64-encoded SHA-256 hash "+
"(was %s)", h)
}

ra = ra.Append(ast.NewTerm(ast.String(h)))
}

*body = append(*body,
ast.Assign.Expr(ast.VarTerm("allowed_spki_hashes"), ast.NewTerm(ra)),
ast.Equal.Expr(ast.VarTerm("spki_hash"), ast.VarTerm("allowed_spki_hashes[_]")))
return nil
}

// ClientCertificate returns a Criterion on a client certificate.
func ClientCertificate(generator *Generator) Criterion {
return clientCertificateCriterion{g: generator}
}

func init() {
Register(ClientCertificate)
}
203 changes: 203 additions & 0 deletions pkg/policy/criteria/client_certificate_test.go
@@ -0,0 +1,203 @@
package criteria

import (
"strings"
"testing"

"github.com/open-policy-agent/opa/ast"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/pomerium/pomerium/pkg/policy/parser"
)

const testCert = `
-----BEGIN CERTIFICATE-----
MIIBYTCCAQigAwIBAgICEAEwCgYIKoZIzj0EAwIwGjEYMBYGA1UEAxMPVHJ1c3Rl
ZCBSb290IENBMCAYDzAwMDEwMTAxMDAwMDAwWhcNMzMwNzMxMTUzMzE5WjAeMRww
GgYDVQQDExN0cnVzdGVkIGNsaWVudCBjZXJ0MFkwEwYHKoZIzj0CAQYIKoZIzj0D
AQcDQgAEfAYP3ZwiKJgk9zXpR/CMHYlAxjweJaMJihIS2FTA5gb0xBcTEe5AGpNF
CHWPk4YCB25VeHg9GmY9Q1+qDD1hdqM4MDYwEwYDVR0lBAwwCgYIKwYBBQUHAwIw
HwYDVR0jBBgwFoAUXep6D8FTP6+5ZdR/HjP3pYfmxkwwCgYIKoZIzj0EAwIDRwAw
RAIgProROtxpvKS/qjrjonSvacnhdU0JwoXj2DgYvF/qjrUCIAXlHkdEzyXmTLuu
/YxuOibV35vlaIzj21GRj4pYmVR1
-----END CERTIFICATE-----`

func TestClientCertificate(t *testing.T) {
t.Parallel()

cases := []struct {
label string
policy string
cert string
expected A
}{
{"no certificate",
`allow:
or:
- client_certificate:
fingerprint: 17859273e8a980631d367b2d5a6a6635412b0f22835f69e47b3f65624546a704`,
"",
A{false, A{ReasonClientCertificateUnauthorized}, M{}},
},
{"no fingerprint match",
`allow:
or:
- client_certificate:
fingerprint: df6ff72fe9116521268f6f2dd4966f51df479883fe7037b39f75916ac3049d1a`,
testCert,
A{false, A{ReasonClientCertificateUnauthorized}, M{}},
},
{"fingerprint match",
`allow:
or:
- client_certificate:
fingerprint: 17859273e8a980631d367b2d5a6a6635412b0f22835f69e47b3f65624546a704`,
testCert,
A{true, A{ReasonClientCertificateOK}, M{}},
},
{"fingerprint list match",
`allow:
or:
- client_certificate:
fingerprint:
- 17859273e8a980631d367b2d5a6a6635412b0f22835f69e47b3f65624546a704
- df6ff72fe9116521268f6f2dd4966f51df479883fe7037b39f75916ac3049d1a`,
testCert,
A{true, A{ReasonClientCertificateOK}, M{}},
},
{"spki hash match",
`allow:
or:
- client_certificate:
spki_hash: FsDbM0rUYIiL3V339eIKqiz6HPSB+Pz2WeAWhqlqh8U=`,
testCert,
A{true, A{ReasonClientCertificateOK}, M{}},
},
{"spki hash list match",
`allow:
or:
- client_certificate:
spki_hash:
- FsDbM0rUYIiL3V339eIKqiz6HPSB+Pz2WeAWhqlqh8U=
- NvqYIYSbgK2vCJpQhObf77vv+bQWtc5ek5RIOwPiC9A=`,
testCert,
A{true, A{ReasonClientCertificateOK}, M{}},
},
}

for i := range cases {
c := cases[i]
t.Run(c.label, func(t *testing.T) {
t.Parallel()

input := Input{
HTTP: InputHTTP{
ClientCertificate: ClientCertificateInfo{
Leaf: c.cert,
},
},
}
res, err := evaluate(t, c.policy, nil, input)
require.NoError(t, err)
assert.Equal(t, c.expected, res["allow"])
})
}
}

func TestCanonicalCertFingerprint(t *testing.T) {
t.Parallel()

cases := []struct {
label string
input string
output string
err string
}{
{"object",
`{}`, "", "certificate fingerprint must be a string (was {})",
},
{"empty",
`""`, "", "certificate fingerprint must not be empty",
},
{"SHA-1 fingerprint",
`"B1:E6:A2:DC:DD:6B:87:A4:9B:C5:7C:3B:7C:7F:1C:74:9A:DB:88:36"`,
"", "unsupported certificate fingerprint format (B1:E6:A2:DC:DD:6B:87:A4:9B:C5:7C:3B:7C:7F:1C:74:9A:DB:88:36)",
},
{"uppercase short",
`"DF6FF72FE9116521268F6F2DD4966F51DF479883FE7037B39F75916AC3049D1A"`,
"", "unsupported certificate fingerprint format (DF6FF72FE9116521268F6F2DD4966F51DF479883FE7037B39F75916AC3049D1A)",
},
{"valid short",
`"df6ff72fe9116521268f6f2dd4966f51df479883fe7037b39f75916ac3049d1a"`,
"df6ff72fe9116521268f6f2dd4966f51df479883fe7037b39f75916ac3049d1a", "",
},
{"lowercase long",
`"df:6f:f7:2f:e9:11:65:21:26:8f:6f:2d:d4:96:6f:51:df:47:98:83:fe:70:37:b3:9f:75:91:6a:c3:04:9d:1a"`,
"", "unsupported certificate fingerprint format (df:6f:f7:2f:e9:11:65:21:26:8f:6f:2d:d4:96:6f:51:df:47:98:83:fe:70:37:b3:9f:75:91:6a:c3:04:9d:1a)",
},
{"valid long",
`"DF:6F:F7:2F:E9:11:65:21:26:8F:6F:2D:D4:96:6F:51:DF:47:98:83:FE:70:37:B3:9F:75:91:6A:C3:04:9D:1A"`,
"df6ff72fe9116521268f6f2dd4966f51df479883fe7037b39f75916ac3049d1a", "",
},
}

for i := range cases {
c := cases[i]
t.Run(c.label, func(t *testing.T) {
t.Parallel()

value, err := parser.ParseValue(strings.NewReader(c.input))
require.NoError(t, err)

f, err := canonicalCertFingerprint(value)
if c.err == "" {
require.NoError(t, err)
assert.Equal(t, ast.String(c.output), f)
} else {
assert.Equal(t, c.err, err.Error())
}
})
}
}

func TestSPKIHashFormatErrors(t *testing.T) {
t.Parallel()

cases := []struct {
label string
input string
err string
}{
{"object",
`{}`, "certificate SPKI hash condition expects a string or array of strings",
},
{"not base64",
`"not%valid%base64%data"`, "certificate SPKI hash must be a base64-encoded SHA-256 hash (was not%valid%base64%data)",
},
{"SHA-1 hash",
`"VYby3BAoHawLLtsyckwo5Q=="`, "certificate SPKI hash must be a base64-encoded SHA-256 hash (was VYby3BAoHawLLtsyckwo5Q==)",
},
{"valid",
`"FsDbM0rUYIiL3V339eIKqiz6HPSB+Pz2WeAWhqlqh8U="`, "",
},
}

for i := range cases {
c := cases[i]
t.Run(c.label, func(t *testing.T) {
t.Parallel()

value, err := parser.ParseValue(strings.NewReader(c.input))
require.NoError(t, err)

var body ast.Body
err = addCertSPKIHashCondition(&body, value)
if c.err == "" {
assert.NoError(t, err)
} else {
assert.Equal(t, c.err, err.Error())
}
})
}
}
3 changes: 3 additions & 0 deletions pkg/policy/criteria/criteria.go
Expand Up @@ -45,6 +45,9 @@ func Register(criterionConstructor CriterionConstructor) {
}

const (
// CriterionDataTypeCertificateMatcher indicates the expected data type is
// a certificate matcher.
CriterionDataTypeCertificateMatcher CriterionDataType = "certificate_matcher"
// CriterionDataTypeStringListMatcher indicates the expected data type is a string list matcher.
CriterionDataTypeStringListMatcher CriterionDataType = "string_list_matcher"
// CriterionDataTypeStringMatcher indicates the expected data type is a string matcher.
Expand Down
3 changes: 2 additions & 1 deletion pkg/policy/criteria/criteria_test.go
Expand Up @@ -43,7 +43,8 @@ type (
ID string `json:"id"`
}
ClientCertificateInfo struct {
Presented bool `json:"presented"`
Presented bool `json:"presented"`
Leaf string `json:"leaf"`
}
)

Expand Down

0 comments on commit ac475f4

Please sign in to comment.