mirror of
https://github.com/pomerium/pomerium.git
synced 2025-08-02 08:19:23 +02:00
ppl: add new client certificate criterion (#4448)
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.
This commit is contained in:
parent
f7e0b61c03
commit
ac475f4c5d
5 changed files with 400 additions and 27 deletions
164
pkg/policy/criteria/client_certificate.go
Normal file
164
pkg/policy/criteria/client_certificate.go
Normal file
|
@ -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
pkg/policy/criteria/client_certificate_test.go
Normal file
203
pkg/policy/criteria/client_certificate_test.go
Normal file
|
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -43,7 +43,8 @@ type (
|
|||
ID string `json:"id"`
|
||||
}
|
||||
ClientCertificateInfo struct {
|
||||
Presented bool `json:"presented"`
|
||||
Presented bool `json:"presented"`
|
||||
Leaf string `json:"leaf"`
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -7,32 +7,34 @@ type Reason string
|
|||
|
||||
// Well-known reasons.
|
||||
const (
|
||||
ReasonAccept = "accept"
|
||||
ReasonClaimOK = "claim-ok"
|
||||
ReasonClaimUnauthorized = "claim-unauthorized"
|
||||
ReasonClientCertificateRequired = "client-certificate-required"
|
||||
ReasonCORSRequest = "cors-request"
|
||||
ReasonDeviceOK = "device-ok"
|
||||
ReasonDeviceUnauthenticated = "device-unauthenticated"
|
||||
ReasonDeviceUnauthorized = "device-unauthorized"
|
||||
ReasonDomainOK = "domain-ok"
|
||||
ReasonDomainUnauthorized = "domain-unauthorized"
|
||||
ReasonEmailOK = "email-ok"
|
||||
ReasonEmailUnauthorized = "email-unauthorized"
|
||||
ReasonHTTPMethodOK = "http-method-ok"
|
||||
ReasonHTTPMethodUnauthorized = "http-method-unauthorized"
|
||||
ReasonHTTPPathOK = "http-path-ok"
|
||||
ReasonHTTPPathUnauthorized = "http-path-unauthorized"
|
||||
ReasonInvalidClientCertificate = "invalid-client-certificate"
|
||||
ReasonNonCORSRequest = "non-cors-request"
|
||||
ReasonNonPomeriumRoute = "non-pomerium-route"
|
||||
ReasonPomeriumRoute = "pomerium-route"
|
||||
ReasonReject = "reject"
|
||||
ReasonRouteNotFound = "route-not-found"
|
||||
ReasonUserOK = "user-ok"
|
||||
ReasonUserUnauthenticated = "user-unauthenticated" // user needs to log in
|
||||
ReasonUserUnauthorized = "user-unauthorized" // user does not have access
|
||||
ReasonValidClientCertificate = "valid-client-certificate"
|
||||
ReasonAccept = "accept"
|
||||
ReasonClaimOK = "claim-ok"
|
||||
ReasonClaimUnauthorized = "claim-unauthorized"
|
||||
ReasonClientCertificateOK = "client-certificate-ok"
|
||||
ReasonClientCertificateUnauthorized = "client-certificate-unauthorized"
|
||||
ReasonClientCertificateRequired = "client-certificate-required"
|
||||
ReasonCORSRequest = "cors-request"
|
||||
ReasonDeviceOK = "device-ok"
|
||||
ReasonDeviceUnauthenticated = "device-unauthenticated"
|
||||
ReasonDeviceUnauthorized = "device-unauthorized"
|
||||
ReasonDomainOK = "domain-ok"
|
||||
ReasonDomainUnauthorized = "domain-unauthorized"
|
||||
ReasonEmailOK = "email-ok"
|
||||
ReasonEmailUnauthorized = "email-unauthorized"
|
||||
ReasonHTTPMethodOK = "http-method-ok"
|
||||
ReasonHTTPMethodUnauthorized = "http-method-unauthorized"
|
||||
ReasonHTTPPathOK = "http-path-ok"
|
||||
ReasonHTTPPathUnauthorized = "http-path-unauthorized"
|
||||
ReasonInvalidClientCertificate = "invalid-client-certificate"
|
||||
ReasonNonCORSRequest = "non-cors-request"
|
||||
ReasonNonPomeriumRoute = "non-pomerium-route"
|
||||
ReasonPomeriumRoute = "pomerium-route"
|
||||
ReasonReject = "reject"
|
||||
ReasonRouteNotFound = "route-not-found"
|
||||
ReasonUserOK = "user-ok"
|
||||
ReasonUserUnauthenticated = "user-unauthenticated" // user needs to log in
|
||||
ReasonUserUnauthorized = "user-unauthorized" // user does not have access
|
||||
ReasonValidClientCertificate = "valid-client-certificate"
|
||||
)
|
||||
|
||||
// Reasons is a collection of reasons.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue