Support TLS authentication using SAN URI (#3078)

Closes https://github.com/valkey-io/valkey/issues/3077

### Overview 
URI in SAN is used to represent client identities in modern mTLS
deployments where CN may be empty or deprecated. See the
https://github.com/valkey-io/valkey/issues/3077#issuecomment-3775615304
for more details.
When `tls-auth-clients-user URI` is configured, during the TLS
handshake, the server iterates through the URIs in the client
certificate and authenticates the client as the first enabled user whose
name matches one of those URIs.

### Implementation
- Introduced a new value `URI` for `tls-auth-clients-user`
- Added new function `getCertSanUri` that:
- Extracts URI entries from the certificate's SAN extension
- Checks each URI against existing Valkey users
- Returns the first URI that matches an enabled user
- Renamed `getCertFieldByName` → `getCertSubjectFieldByName` for clarity
- Modified `tlsGetPeerUsername` to support both CN and URI
authentication modes

### Example behavior
Common setup
``` 
# client certificate X509v3 Subject Alternative Name
URI:urn:valkey:user:first, URI:urn:valkey:user:second

# valkey.conf
tls-auth-clients-user URI
hide-user-data-from-log no
```
Use case 1: multiple enabled users
```
user urn:valkey:user:first on >clientpass allcommands allkeys
user urn:valkey:user:second on >clientpass allcommands allkeys

39762:M 26 Jan 2026 22:06:25.122 - TLS: Auto-authenticated client as urn:valkey:user:first
```
Use case 2: first URI disabled, second enabled
```
user urn:valkey:user:first off >clientpass allcommands allkeys
user urn:valkey:user:second on >clientpass allcommands allkeys

39792:M 26 Jan 2026 22:07:08.006 - TLS: Auto-authenticated client as urn:valkey:user:second
```
Use case 3: all matching users disabled or no matching user
```
user urn:valkey:user:first off >clientpass allcommands allkeys
user urn:valkey:user:second off >clientpass allcommands allkeys

39812:M 26 Jan 2026 22:07:34.174 * TLS: No matching user found in certificate SAN URI fields
127.0.0.1:6379> acl whoami
"default"
127.0.0.1:6379> acl log
1)  1) "count"
    2) (integer) 1
    3) "reason"
    4) "tls-cert"
    5) "context"
    6) "toplevel"
    7) "object"
    8) ""
    9) "username"
   10) "urn:valkey:user:second"
   11) "age-seconds"
   12) "17.381"
   13) "client-info"
   14) "id=3 addr=127.0.0.1:57236 laddr=127.0.0.1:6379 fd=8 name= age=0 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=16384 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=NULL user=default redir=-1 resp=2 lib-name= lib-ver= tot-net-in=0 tot-net-out=0 tot-cmds=0"
   15) "entry-id"
   16) (integer) 0
   17) "timestamp-created"
   18) (integer) 1771963041866
   19) "timestamp-last-updated"
   20) (integer) 1771963041866
127.0.0.1:6379>
```

---------

Signed-off-by: Yang Zhao <zymy701@gmail.com>
This commit is contained in:
Yang Zhao
2026-03-03 05:38:04 -08:00
committed by GitHub
parent c21689ab4d
commit 7e110ae2b6
8 changed files with 185 additions and 52 deletions
+2 -2
View File
@@ -126,9 +126,9 @@ configEnum tls_auth_clients_enum[] = {
configEnum tls_client_auth_user_enum[] = {
{"CN", TLS_CLIENT_FIELD_CN},
{"URI", TLS_CLIENT_FIELD_URI},
{"off", TLS_CLIENT_FIELD_OFF},
{NULL, 0} // terminator
};
{NULL, 0}};
configEnum oom_score_adj_enum[] = {
{"no", OOM_SCORE_ADJ_NO},
+9 -6
View File
@@ -48,6 +48,7 @@
#define NET_HOST_PORT_STR_LEN (NET_HOST_STR_LEN + 32) /* Must be enough for hostname:port */
struct aeEventLoop;
struct user;
typedef struct connection connection;
typedef struct connListener connListener;
@@ -147,8 +148,9 @@ typedef struct ConnectionType {
/* TLS specified methods */
sds (*get_peer_cert)(struct connection *conn);
/* Get peer username based on connection type */
sds (*get_peer_username)(connection *conn);
/* Get peer user based on connection type. If cert_username is non-NULL,
* it is set to the extracted certificate field value. */
struct user *(*get_peer_user)(connection *conn, sds *cert_username);
/* Miscellaneous */
int (*connIntegrityChecked)(void); // return 1 if connection type has built-in integrity checks
@@ -424,10 +426,11 @@ static inline sds connGetPeerCert(connection *conn) {
return NULL;
}
/* Get Peer username based on connection type */
static inline sds connGetPeerUsername(connection *conn) {
if (conn->type && conn->type->get_peer_username) {
return conn->type->get_peer_username(conn);
/* Get peer user based on connection type. If cert_username is non-NULL,
* it is set to the extracted certificate field value. */
static inline struct user *connGetPeerUser(connection *conn, sds *cert_username) {
if (conn->type && conn->type->get_peer_user) {
return conn->type->get_peer_user(conn, cert_username);
}
return NULL;
}
+11 -13
View File
@@ -1772,20 +1772,18 @@ void clientAcceptHandler(connection *conn) {
}
}
/* Auto-authenticate from cert_user field if set */
sds username = connGetPeerUsername(conn);
if (username != NULL) {
user *u = ACLGetUserByName(username, sdslen(username));
if (u && (u->flags & USER_FLAG_ENABLED)) {
clientSetUser(c, u, 1);
moduleNotifyUserChanged(c);
serverLog(LL_VERBOSE, "TLS: Auto-authenticated client as %s",
server.hide_user_data_from_log ? "*redacted*" : u->name);
} else {
addACLLogEntry(c, ACL_INVALID_TLS_CERT_AUTH, ACL_LOG_CTX_TOPLEVEL, 0, username, NULL);
}
sdsfree(username);
/* Auto-authenticate from cert user field if set */
sds cert_username = NULL;
user *u = connGetPeerUser(conn, &cert_username);
if (u) {
clientSetUser(c, u, 1);
moduleNotifyUserChanged(c);
serverLog(LL_VERBOSE, "TLS: Auto-authenticated client as %s",
server.hide_user_data_from_log ? "*redacted*" : u->name);
} else if (cert_username) {
addACLLogEntry(c, ACL_INVALID_TLS_CERT_AUTH, ACL_LOG_CTX_TOPLEVEL, 0, cert_username, NULL);
}
sdsfree(cert_username);
server.stat_numconnections++;
moduleFireServerEvent(VALKEYMODULE_EVENT_CLIENT_CHANGE, VALKEYMODULE_SUBEVENT_CLIENT_CHANGE_CONNECTED, c);
+3 -2
View File
@@ -554,9 +554,10 @@ typedef enum {
#define TLS_CLIENT_AUTH_YES 1
#define TLS_CLIENT_AUTH_OPTIONAL 2
/* TLS Client Certfiicate Authentication */
/* TLS Client Certificate Authentication */
#define TLS_CLIENT_FIELD_OFF 0
#define TLS_CLIENT_FIELD_CN 1
#define TLS_CLIENT_FIELD_URI 2
/* Sanitize dump payload */
#define SANITIZE_DUMP_NO 0
@@ -1068,7 +1069,7 @@ typedef struct readyList {
#define SELECTOR_FLAG_ALLDBS (1 << 4) /* Allow all databases */
typedef struct {
typedef struct user {
sds name; /* The username as an SDS string. */
uint32_t flags; /* See USER_FLAG_* */
list *passwords; /* A list of SDS valid passwords for this user. */
+77 -20
View File
@@ -43,6 +43,7 @@
#include <openssl/conf.h>
#include <openssl/ssl.h>
#include <openssl/x509.h>
#include <openssl/x509v3.h>
#include <openssl/err.h>
#include <openssl/rand.h>
#include <openssl/pem.h>
@@ -1277,7 +1278,7 @@ static void updateSSLState(connection *conn_) {
updatePendingData(conn);
}
static int getCertFieldByName(X509 *cert, const char *field, char *out, size_t outlen) {
static int getCertSubjectFieldByName(X509 *cert, const char *field, char *out, size_t outlen) {
if (!cert || !field || !out) return 0;
int nid = -1;
@@ -1296,35 +1297,91 @@ static int getCertFieldByName(X509 *cert, const char *field, char *out, size_t o
return X509_NAME_get_text_by_NID(subject, nid, out, outlen) > 0;
}
sds tlsGetPeerUsername(connection *conn_) {
/* Extract URI from Subject Alternative Name extension and return the first
* enabled Valkey user that matches a URI. Returns NULL if no match found.
* If cert_username is non-NULL, it is set to the last URI checked. */
static user *getValidUserFromCertSanUri(X509 *cert, sds *cert_username) {
if (!cert) return NULL;
GENERAL_NAMES *san_names = X509_get_ext_d2i(cert, NID_subject_alt_name, NULL, NULL);
if (!san_names) return NULL;
user *result = NULL;
int num_names = sk_GENERAL_NAME_num(san_names);
for (int i = 0; i < num_names; i++) {
GENERAL_NAME *name = sk_GENERAL_NAME_value(san_names, i);
if (name->type == GEN_URI) {
ASN1_STRING *uri_asn1 = name->d.uniformResourceIdentifier;
const unsigned char *uri_data = ASN1_STRING_get0_data(uri_asn1);
int uri_len = ASN1_STRING_length(uri_asn1);
if (!uri_data || uri_len <= 0 || memchr(uri_data, '\0', uri_len)) {
serverLog(LL_DEBUG, "TLS: Invalid or malformed SAN URI in certificate");
continue;
}
if (cert_username) {
sdsfree(*cert_username);
*cert_username = sdsnewlen(uri_data, uri_len);
}
user *u = ACLGetUserByName((const char *)uri_data, uri_len);
if (u && (u->flags & USER_FLAG_ENABLED)) {
result = u;
break;
}
}
}
GENERAL_NAMES_free(san_names);
return result;
}
user *tlsGetPeerUser(connection *conn_, sds *cert_username) {
tls_connection *conn = (tls_connection *)conn_;
if (!conn || !SSL_is_init_finished(conn->ssl)) return NULL;
/* Find the corresponding field name from the enum mapping */
const char *field = NULL;
switch (server.tls_ctx_config.client_auth_user) {
case TLS_CLIENT_FIELD_CN:
field = "CN";
break;
default:
long verify_result = SSL_get_verify_result(conn->ssl);
if (verify_result != X509_V_OK) {
serverLog(LL_DEBUG, "TLS: Client certificate verification failed: %s",
X509_verify_cert_error_string(verify_result));
return NULL;
}
if (!field) return NULL;
X509 *cert = SSL_get_peer_certificate(conn->ssl);
X509 *cert = SSL_get0_peer_certificate(conn->ssl);
if (!cert) return NULL;
char field_value[256];
sds result = NULL;
user *result = NULL;
if (getCertFieldByName(cert, field, field_value, sizeof(field_value))) {
result = sdsnew(field_value);
} else {
serverLog(LL_NOTICE, "TLS: Failed to extract field '%s' from certificate", field);
switch (server.tls_ctx_config.client_auth_user) {
case TLS_CLIENT_FIELD_URI:
result = getValidUserFromCertSanUri(cert, cert_username);
if (!result) {
serverLog(LL_VERBOSE, "TLS: No matching user found in certificate SAN URI fields");
}
break;
case TLS_CLIENT_FIELD_CN: {
char field_value[256];
if (getCertSubjectFieldByName(cert, "CN", field_value, sizeof(field_value))) {
if (cert_username) *cert_username = sdsnew(field_value);
result = ACLGetUserByName(field_value, strlen(field_value));
if (!result || !(result->flags & USER_FLAG_ENABLED)) {
serverLog(LL_VERBOSE, "TLS: No matching user found for certificate CN '%s'", field_value);
result = NULL;
}
} else {
serverLog(LL_DEBUG, "TLS: Failed to extract CN in certificate subject");
}
break;
}
default:
break;
}
X509_free(cert);
return result;
}
@@ -1860,7 +1917,7 @@ static ConnectionType CT_TLS = {
/* TLS specified methods */
.get_peer_cert = connTLSGetPeerCert,
.get_peer_username = tlsGetPeerUsername,
.get_peer_user = tlsGetPeerUser,
/* Miscellaneous */
.connIntegrityChecked = connTLSIsIntegrityChecked,
+64
View File
@@ -174,6 +174,70 @@ start_server {tags {"tls"}} {
$s close
}
test {TLS: Auto-authenticate using tls-auth-clients-user (URI)} {
# Enable the feature to auto-authenticate based on URI
r CONFIG SET tls-auth-clients-user URI
# Create users matching the URI in the client certificate
r ACL SETUSER {urn:valkey:user:first} on >clientpass allcommands
r ACL SETUSER {urn:valkey:user:second} on >clientpass allcommands
# With feature on, client should be auto-authenticated using the URI from SAN
# Verify that the authenticated user matches the first URI
set s [valkey_client]
assert_equal "urn:valkey:user:first" [$s ACL WHOAMI]
$s close
# Turn off the first user
r ACL SETUSER {urn:valkey:user:first} off
# Verify that the authenticated user matches the second URI
set s [valkey_client]
assert_equal "urn:valkey:user:second" [$s ACL WHOAMI]
$s close
# Turn off the second user
r ACL SETUSER {urn:valkey:user:second} off
# Verify that the authenticated user matches the default
set s [valkey_client]
assert_equal "default" [$s ACL WHOAMI]
$s close
# Delete all users
r ACL DELUSER {urn:valkey:user:first} {urn:valkey:user:second}
# Verify that the authenticated user matches the default
set s [valkey_client]
assert_equal "default" [$s ACL WHOAMI]
$s close
# Restore
r CONFIG SET tls-auth-clients-user off
}
test {TLS: Verify CN and URI modes are mutually exclusive} {
# Create both CN and URI users
r ACL SETUSER {Client-only} on >clientpass allcommands allkeys
r ACL SETUSER {urn:valkey:user:first} on >clientpass allcommands allkeys
# Set to CN mode
r CONFIG SET tls-auth-clients-user CN
set s [valkey_client]
assert_equal "Client-only" [$s ACL WHOAMI]
$s close
# Set to URI mode
r CONFIG SET tls-auth-clients-user URI
set s [valkey_client]
assert_equal "urn:valkey:user:first" [$s ACL WHOAMI]
$s close
# Clean up
r ACL DELUSER {Client-only} {urn:valkey:user:first}
r CONFIG SET tls-auth-clients-user off
}
test {TLS: Auto-reload detects changes} {
if {$::tls_module} {
# Auto-reload requires built-in TLS
+1
View File
@@ -56,6 +56,7 @@ nsCertType = server
[ client_cert ]
keyUsage = digitalSignature, keyEncipherment
nsCertType = client
subjectAltName = URI:urn:valkey:user:first, URI:urn:valkey:user:second
_END_
generate_cert server "Server-only" "-extfile tests/tls/openssl.cnf -extensions server_cert"
+18 -9
View File
@@ -256,18 +256,27 @@ tcp-keepalive 300
# tls-auth-clients optional
# Automatically authenticate TLS clients as Valkey users based on their
# certificates.
# certificate field values.
#
# If set to a field like "CN", the server will extract the corresponding field
# from the client's TLS certificate and attempt to find a Valkey user with the
# same name. If a matching user is found, the client is automatically
# authenticated as that user during the TLS handshake. If no matching user is
# found, the client is connected as the unauthenticated default user. Set to
# "off" to disable automatic user authentication via certificate fields.
# If set to a field like "CN" or "URI", the server will extract the corresponding
# value from the client's TLS certificate and attempt to find a matching Valkey
# user with the same name. If a matching user is found, the client is
# automatically authenticated as that user during the TLS handshake. If no
# matching user is found, the client is connected as the unauthenticated default
# user. Set to "off" to disable automatic user authentication via certificate
# fields.
#
# Supported values: CN, off. Default: off.
# Notes on options:
# CN - Extract the Common Name from the certificate's Subject field (single-valued).
# URI - Extract the URIs from the certificate's Subject Alternative Name extension.
# If multiple URI entries exist, the first URI that matches an enabled
# Valkey user is used for authentication.
# off - Disable automatic authentication from certificate fields.
#
# tls-auth-clients-user CN
# Default value: off.
#
# Example:
# tls-auth-clients-user URI
# By default, a replica does not attempt to establish a TLS connection
# with its primary.