Index | Thread | Search

From:
Persistent Oscillator <persistentoscillator@gmail.com>
Subject:
Relayd client ca support: optionality and filtering
To:
tech@openbsd.org
Date:
Thu, 31 Oct 2024 17:35:26 +0100

Download raw body.

Thread
Hi,

Following up on the recent commit adding client ca verification support
to relayd, I wanted to ask whether there would be interest in the
following additional patch. It does two things:

- It allows client ca verification to be either mandatory or optional,
  integrating code submitted to this list by Markus Läll in December
  2021 [1].
- It adds a "client ca" test verifying whether a client certificate was
  indeed provided, integrating the test into the usual relayd filtering
  rules. This expands the use case of client certificates, allowing for
  more fine-grained checks. In addition to a straightforward check
  whether a client CA was provided by the client and accepted by relayd,
  it can verify the hash, the subject, the issuer or the name of the
  client certificate.

The test allows, for example, to:

pass request quick client ca header "Host" value "secure.example.com"
block request header "Host" value "secure.example.com"

The filter could be more nuanced (eg based on the hash of the specific
client certificate when relayd accepts multiple client certificates).
One could also forward clients who provided a client cert to a different
backend, etc.

Kind regards,

-- PO

[1]: https://www.mail-archive.com/tech@openbsd.org/msg67360.html

diff --git a/regress/usr.sbin/relayd/Relayd.pm b/regress/usr.sbin/relayd/Relayd.pm
index 2a6aa926d16..6c8ddad08a9 100644
--- a/regress/usr.sbin/relayd/Relayd.pm
+++ b/regress/usr.sbin/relayd/Relayd.pm
@@ -88,6 +88,9 @@ sub new {
 	if ($self->{verifyclient}) {
 		print $fh "\n\ttls client ca client-ca.crt";
 	}
+	if ($self->{verifyclientoptional}) {
+		print $fh "\n\ttls client ca client-ca.crt optional";
+	}
 	# substitute variables in config file
 	foreach (@protocol) {
 		s/(\$[a-z]+)/$1/eeg;
diff --git a/regress/usr.sbin/relayd/args-https-filter-clientca-fail.pl b/regress/usr.sbin/relayd/args-https-filter-clientca-fail.pl
new file mode 100644
index 00000000000..cef405e53a1
--- /dev/null
+++ b/regress/usr.sbin/relayd/args-https-filter-clientca-fail.pl
@@ -0,0 +1,35 @@
+# test clientca filter over http relay
+
+use strict;
+use warnings;
+
+our %args = (
+    client => {
+	ssl => 1,
+	func => \&http_client,
+	httpnok => 1,
+	nocheck => 1,
+	loggrep => qr/HTTP\/1\.0 403 Forbidden/,
+    },
+    relayd => {
+	forwardssl => 1,
+	listenssl => 1,
+	verifyclientoptional => 1,
+	protocol => [ "http",
+	    "return error",
+	    "match request tag NOCLIENTCA",
+	    "pass request quick client ca tag HASCLIENTCA",
+	    "block",
+	],
+	loggrep => qr/NOCLIENTCA.*403 Forbidden/,
+    },
+    server => {
+	func => \&http_server,
+	ssl => 1,
+	noserver => 1,
+	nocheck => 1,
+    },
+    LEN => 427,
+);
+
+1;
diff --git a/regress/usr.sbin/relayd/args-https-filter-clientca-hash.pl b/regress/usr.sbin/relayd/args-https-filter-clientca-hash.pl
new file mode 100644
index 00000000000..86263f075f3
--- /dev/null
+++ b/regress/usr.sbin/relayd/args-https-filter-clientca-hash.pl
@@ -0,0 +1,36 @@
+# test clientca hash filter over http relay
+
+use strict;
+use warnings;
+
+my $sha = qx(openssl x509 -outform der -in client.crt|sha256|tr -d '\n');
+
+our %args = (
+    client => {
+	ssl => 1,
+	offertlscert => 1,
+	func => \&http_client,
+	loggrep => 'Issuer.*/OU=relayd/',
+    },
+    relayd => {
+	forwardssl => 1,
+	listenssl => 1,
+	verifyclientoptional => 1,
+	protocol => [ "http",
+	    "return error",
+	    "match request tag NOCLIENTCA",
+	    "pass request quick client ca clhash \"SHA256:$sha\" \\
+	    	tag HASCLIENTCAHASH",
+	    "block",
+	],
+	loggrep => qr/HASCLIENTCAHASH.*GET/,
+    },
+    server => {
+	func => \&http_server,
+	ssl => 1,
+    },
+    LEN => 251,
+    md5 => "bc3a3f39af35fe5b1687903da2b00c7f",
+);
+
+1;
diff --git a/regress/usr.sbin/relayd/args-https-filter-clientca-issuer.pl b/regress/usr.sbin/relayd/args-https-filter-clientca-issuer.pl
new file mode 100644
index 00000000000..029b03adf16
--- /dev/null
+++ b/regress/usr.sbin/relayd/args-https-filter-clientca-issuer.pl
@@ -0,0 +1,35 @@
+# test clientca hash filter over http relay
+
+use strict;
+use warnings;
+
+our %args = (
+    client => {
+	ssl => 1,
+	offertlscert => 1,
+	func => \&http_client,
+	loggrep => 'Issuer.*/OU=relayd/',
+    },
+    relayd => {
+	forwardssl => 1,
+	listenssl => 1,
+	verifyclientoptional => 1,
+	protocol => [ "http",
+	    "return error",
+	    "match request tag NOCLIENTCA",
+	    "pass request quick client ca issuer \\
+	    	\"\/L=OpenBSD\/O=relayd-regress\/OU=client-ca\/CN=root\"\\
+	       	tag HASCLIENTCAISSUER",
+	    "block",
+	],
+	loggrep => qr/HASCLIENTCAISSUER.*GET/,
+    },
+    server => {
+	func => \&http_server,
+	ssl => 1,
+    },
+    LEN => 251,
+    md5 => "bc3a3f39af35fe5b1687903da2b00c7f",
+);
+
+1;
diff --git a/regress/usr.sbin/relayd/args-https-filter-clientca-name.pl b/regress/usr.sbin/relayd/args-https-filter-clientca-name.pl
new file mode 100644
index 00000000000..83eff0bcd77
--- /dev/null
+++ b/regress/usr.sbin/relayd/args-https-filter-clientca-name.pl
@@ -0,0 +1,34 @@
+# test clientca hash filter over http relay
+
+use strict;
+use warnings;
+
+our %args = (
+    client => {
+	ssl => 1,
+	offertlscert => 1,
+	func => \&http_client,
+	loggrep => 'Issuer.*/OU=relayd/',
+    },
+    relayd => {
+	forwardssl => 1,
+	listenssl => 1,
+	verifyclientoptional => 1,
+	protocol => [ "http",
+	    "return error",
+	    "match request tag NOCLIENTCA",
+	    "pass request quick client ca name \"localhost\" \\
+	    	tag HASCLIENTCANAME",
+	    "block",
+	],
+	loggrep => qr/HASCLIENTCANAME.*GET/,
+    },
+    server => {
+	func => \&http_server,
+	ssl => 1,
+    },
+    LEN => 251,
+    md5 => "bc3a3f39af35fe5b1687903da2b00c7f",
+);
+
+1;
diff --git a/regress/usr.sbin/relayd/args-https-filter-clientca-subj.pl b/regress/usr.sbin/relayd/args-https-filter-clientca-subj.pl
new file mode 100644
index 00000000000..7fbbfabbd6c
--- /dev/null
+++ b/regress/usr.sbin/relayd/args-https-filter-clientca-subj.pl
@@ -0,0 +1,35 @@
+# test clientca hash filter over http relay
+
+use strict;
+use warnings;
+
+our %args = (
+    client => {
+	ssl => 1,
+	offertlscert => 1,
+	func => \&http_client,
+	loggrep => 'Issuer.*/OU=relayd/',
+    },
+    relayd => {
+	forwardssl => 1,
+	listenssl => 1,
+	verifyclientoptional => 1,
+	protocol => [ "http",
+	    "return error",
+	    "match request tag NOCLIENTCA",
+	    "pass request quick client ca subject \\
+	    	\"\/L=OpenBSD\/O=relayd-regress\/OU=client\/CN=localhost\" \\
+		tag HASCLIENTCASUBJ",
+	    "block",
+	],
+	loggrep => qr/HASCLIENTCASUBJ.*GET/,
+    },
+    server => {
+	func => \&http_server,
+	ssl => 1,
+    },
+    LEN => 251,
+    md5 => "bc3a3f39af35fe5b1687903da2b00c7f",
+);
+
+1;
diff --git a/regress/usr.sbin/relayd/args-https-filter-clientca.pl b/regress/usr.sbin/relayd/args-https-filter-clientca.pl
new file mode 100644
index 00000000000..3bc2ee7f201
--- /dev/null
+++ b/regress/usr.sbin/relayd/args-https-filter-clientca.pl
@@ -0,0 +1,33 @@
+# test clientca filter over http relay
+
+use strict;
+use warnings;
+
+our %args = (
+    client => {
+	ssl => 1,
+	offertlscert => 1,
+	func => \&http_client,
+	loggrep => 'Issuer.*/OU=relayd/',
+    },
+    relayd => {
+	forwardssl => 1,
+	listenssl => 1,
+	verifyclientoptional => 1,
+	protocol => [ "http",
+	    "return error",
+	    "match request tag NOCLIENTCA",
+	    "pass request quick client ca tag HASCLIENTCA",
+	    "block",
+	],
+	loggrep => qr/HASCLIENTCA.*GET/,
+    },
+    server => {
+	func => \&http_server,
+	ssl => 1,
+    },
+    LEN => 251,
+    md5 => "bc3a3f39af35fe5b1687903da2b00c7f",
+);
+
+1;
diff --git a/usr.sbin/relayd/parse.y b/usr.sbin/relayd/parse.y
index fcdfb8e92e3..e8671cb0b63 100644
--- a/usr.sbin/relayd/parse.y
+++ b/usr.sbin/relayd/parse.y
@@ -179,7 +179,7 @@ typedef struct {
 %token	TIMEOUT TLS TO ROUTER RTLABEL TRANSPARENT URL WITH TTL RTABLE
 %token	MATCH PARAMS RANDOM LEASTSTATES SRCHASH KEY CERTIFICATE PASSWORD ECDHE
 %token	EDH TICKETS CONNECTION CONNECTIONS CONTEXT ERRORS STATE CHANGES CHECKS
-%token	WEBSOCKETS PFLOG CLIENT
+%token	WEBSOCKETS PFLOG CLIENT OPTIONAL 
 %token	<v.string>	STRING
 %token  <v.number>	NUMBER
 %type	<v.string>	context hostname interface table value path
@@ -188,6 +188,7 @@ typedef struct {
 %type	<v.number>	opttls opttlsclient
 %type	<v.number>	redirect_proto relay_proto match pflog
 %type	<v.number>	action ruleaf key_option
+%type	<v.number>	clientcaopt 
 %type	<v.port>	port
 %type	<v.host>	host
 %type	<v.addr>	address rulesrc ruledst addrprefix
@@ -235,6 +236,10 @@ opttlsclient	: /*empty*/	{ $$ = 0; }
 		| WITH TLS	{ $$ = 1; }
 		;
 
+clientcaopt	: /*empty*/	{ $$ = 0; }
+		| OPTIONAL	{ $$ = 1; }
+		;
+
 http_type	: HTTP		{ $$ = 0; }
 		| STRING	{
 			if (strcmp("https", $1) == 0) {
@@ -1351,7 +1356,7 @@ tlsflags	: SESSION TICKETS { proto->tickets = 1; }
 			name->name = $2;
 			TAILQ_INSERT_TAIL(&proto->tlscerts, name, entry);
 		}
-		| CLIENT CA STRING		{
+		| CLIENT CA STRING clientcaopt		{
 			if (strlcpy(proto->tlsclientca, $3,
 			    sizeof(proto->tlsclientca)) >=
 			    sizeof(proto->tlsclientca)) {
@@ -1359,6 +1364,9 @@ tlsflags	: SESSION TICKETS { proto->tickets = 1; }
 				free($3);
 				YYERROR;
 			}
+			if ($4) {
+				proto->tlsflags |= TLSFLAG_CLIENT_OPTIONAL;
+			}
 			free($3);
 		}
 		| NO flag			{ proto->tlsflags &= ~($2); }
@@ -1498,6 +1506,31 @@ ruleopts	: METHOD STRING					{
 			rule->rule_method = id;
 			free($2);
 		}
+		| CLIENT CA STRING STRING 		{ 
+			keytype = KEY_TYPE_CLCACHK;
+			rule->rule_kv[keytype].kv_type = keytype;
+			if (strcasecmp("clhash", $3) == 0) {
+				rule->rule_kv[keytype].kv_key = strdup($3);
+			} else if (strcasecmp("issuer", $3) == 0) {
+				rule->rule_kv[keytype].kv_key = strdup($3);
+			} else if (strcasecmp("name", $3) == 0) {
+				rule->rule_kv[keytype].kv_key = strdup($3);
+			} else if (strcasecmp("subject", $3) == 0) {
+				rule->rule_kv[keytype].kv_key = strdup($3);
+			} else {
+				yyerror("invalid client ca key: %s", $3);
+				YYERROR;
+			}
+			rule->rule_kv[keytype].kv_value = strdup($4);
+			free($3);
+			free($4);
+		}
+		| CLIENT CA					{
+			keytype = KEY_TYPE_CLCACHK;
+			rule->rule_kv[keytype].kv_type = keytype;
+			rule->rule_kv[keytype].kv_key = strdup("*");
+			rule->rule_kv[keytype].kv_value = strdup("*");
+		}
 		| COOKIE key_option STRING value		{
 			keytype = KEY_TYPE_COOKIE;
 			rule->rule_kv[keytype].kv_key = strdup($3);
@@ -2468,6 +2501,7 @@ lookup(char *s)
 		{ "nodelay",		NODELAY },
 		{ "nothing",		NOTHING },
 		{ "on",			ON },
+		{ "optional",		OPTIONAL },
 		{ "params",		PARAMS },
 		{ "parent",		PARENT },
 		{ "pass",		PASS },
diff --git a/usr.sbin/relayd/relay.c b/usr.sbin/relayd/relay.c
index 6d0970802c5..9dc06969624 100644
--- a/usr.sbin/relayd/relay.c
+++ b/usr.sbin/relayd/relay.c
@@ -179,6 +179,9 @@ relay_ruledebug(struct relay_rule *rule)
 			continue;
 
 		switch (kv->kv_type) {
+		case KEY_TYPE_CLCACHK:
+			fprintf(stderr, "client ca ");
+			break;
 		case KEY_TYPE_COOKIE:
 			fprintf(stderr, "cookie ");
 			break;
@@ -2266,7 +2269,10 @@ relay_tls_ctx_create(struct relay *rlay)
 			}
 			purge_key(&buf, len);
 
-			tls_config_verify_client(tls_cfg);
+			if (rlay->rl_proto->tlsflags & TLSFLAG_CLIENT_OPTIONAL)
+				tls_config_verify_client_optional(tls_cfg);
+			else
+				tls_config_verify_client(tls_cfg);
 		}
 		rlay->rl_tls_client_ca_fd = -1;
 
diff --git a/usr.sbin/relayd/relay_http.c b/usr.sbin/relayd/relay_http.c
index 0c511d0b9eb..051e33d1f83 100644
--- a/usr.sbin/relayd/relay_http.c
+++ b/usr.sbin/relayd/relay_http.c
@@ -71,6 +71,8 @@ int		 relay_httpurl_test(struct ctl_relay_event *,
 		    struct relay_rule *, struct kvlist *);
 int		 relay_httpcookie_test(struct ctl_relay_event *,
 		    struct relay_rule *, struct kvlist *);
+int		 relay_clientca_test(struct ctl_relay_event *,
+		    struct relay_rule *, struct kvlist *);
 int		 relay_apply_actions(struct ctl_relay_event *, struct kvlist *,
 		    struct relay_table *);
 int		 relay_match_actions(struct ctl_relay_event *,
@@ -1718,6 +1720,41 @@ relay_match_actions(struct ctl_relay_event *cre, struct relay_rule *rule,
 	return (0);
 }
 
+int
+relay_clientca_test(struct ctl_relay_event *cre, struct relay_rule *rule,
+    struct kvlist *actions)
+{
+	struct kv		*kv = &rule->rule_kv[KEY_TYPE_CLCACHK];
+	struct kv		*match = NULL;
+
+	if (kv->kv_type != KEY_TYPE_CLCACHK)
+		return (0);
+
+	if (tls_peer_cert_provided(cre->tls) != 1) 
+		return (-1);
+
+	if(strcasecmp("clhash", kv->kv_key) == 0) {
+		if(tls_peer_cert_hash(cre->tls) == NULL || \
+			strcasecmp(tls_peer_cert_hash(cre->tls), kv->kv_value) != 0) 
+			return (-1);
+	} else if(strcasecmp("issuer", kv->kv_key) == 0) {
+		if(tls_peer_cert_issuer(cre->tls) == NULL || \
+			strcasecmp(tls_peer_cert_issuer(cre->tls), kv->kv_value) != 0) 
+			return (-1);
+	} else if(strcasecmp("subject", kv->kv_key) == 0) {
+		if(tls_peer_cert_subject(cre->tls) == NULL || \
+			strcasecmp(tls_peer_cert_subject(cre->tls), kv->kv_value) != 0) 
+			return (-1);
+	} else if(strcasecmp("name", kv->kv_key) == 0) {
+		if(!tls_peer_cert_contains_name(cre->tls, kv->kv_value)) 
+			return (-1);
+	}
+
+	relay_match(actions, kv, match, NULL); 
+
+	return (0);
+}
+
 int
 relay_apply_actions(struct ctl_relay_event *cre, struct kvlist *actions,
     struct relay_table *tbl)
@@ -1994,6 +2031,8 @@ relay_test(struct protocol *proto, struct ctl_relay_event *cre)
 			RELAY_GET_SKIP_STEP(RULE_SKIP_METHOD);
 		else if (r->rule_tagged && con->se_tag != r->rule_tagged)
 			RELAY_GET_NEXT_STEP;
+		else if ((relay_clientca_test(cre, r, &matches)) != 0)
+			RELAY_GET_NEXT_STEP;
 		else if (relay_httpheader_test(cre, r, &matches) != 0)
 			RELAY_GET_NEXT_STEP;
 		else if ((res = relay_httpquery_test(cre, r, &matches)) != 0)
diff --git a/usr.sbin/relayd/relayd.conf.5 b/usr.sbin/relayd/relayd.conf.5
index 8372875b853..a5b8ff314b7 100644
--- a/usr.sbin/relayd/relayd.conf.5
+++ b/usr.sbin/relayd/relayd.conf.5
@@ -954,9 +954,12 @@ will be used (strong crypto cipher suites without anonymous DH).
 See the CIPHERS section of
 .Xr openssl 1
 for information about TLS cipher suites and preference lists.
-.It Ic client ca Ar path
+.It Ic client ca Ar path Op optional
 Require TLS client certificates that can be verified against the CA
 certificates in the specified file.
+If the 
+.Ic optional
+keyword is present, the certificate is verified only if presented.
 .It Ic client-renegotiation
 Allow client-initiated renegotiation.
 To mitigate a potential DoS risk,
@@ -1213,6 +1216,27 @@ and can be either
 or
 .Ic VERSION-CONTROL .
 .It Xo
+.It Ic client ca Op Ar option value
+Verify if the client provided a valid certificate.
+Valid options are:
+.Bl -tag -width Ds
+.It Ic clhash Ar string
+Verify if the hash of the client certificate corresponds to the specified hash strring.
+The hash string for a certificate in file
+.Ar mycert.crt
+can be generated using the commands:
+.Bd -literal -offset indent
+h=$(openssl x509 -outform der -in mycert.crt | sha256)
+printf "SHA256:${h}\\n"
+.Ed
+.It Ic issuer Ar string
+Verify if the issuer of the client certificate corresponds to the specified issuer string.
+.It Ic name Ar string
+Verify if the SAN or CN of the client certificate contains the specified name string.
+.It Ic subject Ar string
+Verify if the subject of the client certificate corresponds to the provided subject string. 
+.El
+.It Xo
 .Ar type option
 .Oo Oo Ic digest Oc
 .Pq Ar key Ns | Ns Ic file Ar path
diff --git a/usr.sbin/relayd/relayd.h b/usr.sbin/relayd/relayd.h
index 3b5c3987f93..c1381813677 100644
--- a/usr.sbin/relayd/relayd.h
+++ b/usr.sbin/relayd/relayd.h
@@ -299,6 +299,7 @@ enum key_type {
 	KEY_TYPE_NONE		= 0,
 	KEY_TYPE_COOKIE,
 	KEY_TYPE_HEADER,
+	KEY_TYPE_CLCACHK,
 	KEY_TYPE_PATH,
 	KEY_TYPE_QUERY,
 	KEY_TYPE_URL,
@@ -702,6 +703,7 @@ TAILQ_HEAD(relay_rules, relay_rule);
 #define TLSFLAG_VERSION				0x1f
 #define TLSFLAG_CIPHER_SERVER_PREF		0x20
 #define TLSFLAG_CLIENT_RENEG			0x40
+#define TLSFLAG_CLIENT_OPTIONAL			0x80
 #define TLSFLAG_DEFAULT				\
 	(TLSFLAG_TLSV1_2|TLSFLAG_TLSV1_3|TLSFLAG_CIPHER_SERVER_PREF)