TLS and mTLS
With a certificate and its key, Hardcharger™ serves Warp SQL over HTTPS on the same port. Add a
CA, and it also requires every client to present a certificate that chains to it (mutual TLS).
Clients default to TLS: hardcharger://host and jdbc:hardcharger://host are HTTPS unless you
say otherwise.
1. Get a certificate
Section titled “1. Get a certificate”In production, use a certificate from your CA (or ACME) for the name clients connect to. For
this guide, make a throwaway CA and a server certificate for localhost:
# A CA, then a server certificate for localhost signed by it (valid for 30 days).openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 30 \ -subj "/CN=Example CA" -keyout ca.key -out ca.crtopenssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj "/CN=localhost" \ -keyout server.key -out server.csropenssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 30 \ -extfile <(printf 'subjectAltName=DNS:localhost,IP:127.0.0.1') -out server.crtserver.crt may hold the whole chain (leaf first), as most CAs deliver it.
2. Start the server with TLS
Section titled “2. Start the server with TLS”HARDCHARGER_ADMIN_PASSWORD='change-me' hardcharger-server --database my.duckdb \ --tls-cert server.crt --tls-key server.key --health-port 31338--tls-cert and --tls-key go together (HARDCHARGER_TLS_CERT, HARDCHARGER_TLS_KEY, or
tls_cert/tls_key in YAML). The health port stays plaintext, for probes on a private network.
3. Connect
Section titled “3. Connect”Clients verify the certificate against the system’s trust store. A private CA has to be named:
from adbc_driver_hardcharger import dbapi as hardcharger
with hardcharger.connect(uri="hardcharger://localhost", username="hardcharger", password="change-me", tls_root_certs="ca.crt") as conn: with conn.cursor() as cur: cur.execute(operation="SELECT 'over TLS' AS hello") print(cur.fetchone())('over TLS',)tls_root_certs takes a file path or PEM text.
$ HARDCHARGER_PASSWORD='change-me' hardcharger --host localhost --username hardcharger \ --tls --tls-roots ca.crt --csv --command "SELECT 'over TLS' AS hello"helloover TLSProperties properties = new Properties();properties.setProperty("user", "hardcharger");properties.setProperty("password", "change-me");properties.setProperty("tlsRootCerts", "ca.crt");try (Connection connection = DriverManager.getConnection("jdbc:hardcharger://localhost", properties); ResultSet rows = connection.createStatement().executeQuery("SELECT 'over TLS' AS hello")) { rows.next(); System.out.println(rows.getString("hello"));}over TLSJDBC also takes a JKS or PKCS#12 trust store (trustStore, trustStorePassword).
$ curl -s --cacert ca.crt https://localhost:31337/warp | jq -r .server.nameHardchargerMutual TLS
Section titled “Mutual TLS”With --mtls-ca-cert, the server completes a TLS handshake only with clients that present a
certificate signed by that CA. By itself it is an extra lock, not a replacement for signing in:
clients still authenticate with a password or token. To make the certificate the credential, turn
on certificate sign-in.
-
Issue a client certificate from your CA. For this guide:
Terminal window openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj "/CN=joe" \-keyout client.key -out client.csropenssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 30 \-extfile <(printf 'extendedKeyUsage=clientAuth') -out client.crt -
Restart the server with the CA that client certificates must chain to:
Terminal window HARDCHARGER_ADMIN_PASSWORD='change-me' hardcharger-server --database my.duckdb \--tls-cert server.crt --tls-key server.key --mtls-ca-cert ca.crt --health-port 31338 -
Connect with the client certificate and key:
from adbc_driver_hardcharger import dbapi as hardchargerwith hardcharger.connect(uri="hardcharger://localhost", username="hardcharger", password="change-me",tls_root_certs="ca.crt", tls_client_cert="client.crt",tls_client_key="client.key") as conn:with conn.cursor() as cur:cur.execute(operation="SELECT 'mutual TLS' AS hello")print(cur.fetchone())('mutual TLS',)Terminal window $ HARDCHARGER_PASSWORD='change-me' hardcharger --host localhost --username hardcharger --tls \--tls-roots ca.crt --mtls-cert client.crt --mtls-key client.key --csv --command "SELECT 'mutual TLS' AS hello"hellomutual TLSProperties properties = new Properties();properties.setProperty("user", "hardcharger");properties.setProperty("password", "change-me");properties.setProperty("tlsRootCerts", "ca.crt");properties.setProperty("clientCertificate", "client.crt");properties.setProperty("clientKey", "client.key");try (Connection connection = DriverManager.getConnection("jdbc:hardcharger://localhost", properties);ResultSet rows = connection.createStatement().executeQuery("SELECT 'mutual TLS' AS hello")) {rows.next();System.out.println(rows.getString("hello"));}mutual TLSThe key may be PKCS#8, PKCS#1 (RSA) or SEC1 (EC), unencrypted.
Terminal window $ curl -s --cacert ca.crt --cert client.crt --key client.key https://localhost:31337/warp | jq -r .server.nameHardcharger
A client without a certificate doesn’t get past the handshake, and the CLI says why:
$ curl -s --cacert ca.crt https://localhost:31337/warp || echo "refused"refused$ HARDCHARGER_PASSWORD='change-me' hardcharger --host localhost --tls-roots ca.crt --username hardcharger --command "SELECT 1"; echo "exit status $?"Error: The server at https://localhost:31337 refused the connection: it requires a client certificate (--mtls-cert and --mtls-key)exit status 1Signing in with the certificate
Section titled “Signing in with the certificate”With --mtls-certificate-sign-in, a client signs in as its certificate’s identity, with no
password: Warp SQL’s client_certificate grant, --auth-type mtls in the CLI. The certificate
proves who the user is; grants decide what they may do. It is off by default: with
--mtls-ca-cert alone, a certificate only admits the connection, as above.
-
Issue a certificate whose e-mail SAN is the identity (the default identity source):
Terminal window openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj "/CN=Ops" \-keyout ops.key -out ops.csropenssl x509 -req -in ops.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 30 \-extfile <(printf 'extendedKeyUsage=clientAuth\nsubjectAltName=email:ops@example.com') -out ops.crt -
Restart the server with certificate sign-in on, and say which identities are administrators:
Terminal window HARDCHARGER_ADMIN_PASSWORD='change-me' hardcharger-server --database my.duckdb \--tls-cert server.crt --tls-key server.key --mtls-ca-cert ca.crt \--mtls-certificate-sign-in --mtls-admin-identities ops@example.com --health-port 31338 -
Connect with the certificate and no user name or password:
from adbc_driver_hardcharger import dbapi as hardchargerwith hardcharger.connect(uri="hardcharger://localhost", auth_type="client_certificate",tls_root_certs="ca.crt", tls_client_cert="ops.crt",tls_client_key="ops.key") as conn:print(conn.current_user())ops@example.comTerminal window $ hardcharger --host localhost --tls --tls-roots ca.crt --auth-type mtls \--mtls-cert ops.crt --mtls-key ops.key --csv --command "SELECT HARDCHARGER_USER() AS who"whoops@example.comProperties properties = new Properties();properties.setProperty("authType", "client_certificate");properties.setProperty("tlsRootCerts", "ca.crt");properties.setProperty("clientCertificate", "ops.crt");properties.setProperty("clientKey", "ops.key");try (Connection connection = DriverManager.getConnection("jdbc:hardcharger://localhost", properties);ResultSet rows = connection.createStatement().executeQuery("SELECT HARDCHARGER_USER() AS who")) {rows.next();System.out.println(rows.getString("who"));}ops@example.comTerminal window $ curl -s --cacert ca.crt --cert ops.crt --key ops.key -H 'Content-Type: application/json' \-d '{"grant_type": "client_certificate"}' https://localhost:31337/warp/v1/auth/token | jq -r .token_typeBearer
A certificate whose identity is no administrator and, under access control, no user is refused.
client.crt from above has no e-mail SAN at all:
$ curl -s --cacert ca.crt --cert client.crt --key client.key -H 'Content-Type: application/json' \ -d '{"grant_type": "client_certificate"}' https://localhost:31337/warp/v1/auth/token | jq -r .codeunknown_certificate_identityThe access token from a certificate sign-in is bound to that certificate (RFC 8705’s cnf
claim): it works only on connections presenting the same one, so a copied token is no use without
the private key. Here the token goes out over a connection with another certificate:
$ TOKEN=$(curl -s --cacert ca.crt --cert ops.crt --key ops.key -H 'Content-Type: application/json' \ -d '{"grant_type": "client_certificate"}' https://localhost:31337/warp/v1/auth/token | jq -r .access_token)$ curl -s --cacert ca.crt --cert client.crt --key client.key -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' -d '{}' https://localhost:31337/warp/v1/sessions | jq -r .codecertificate_mismatchA session opened with a bound token takes only tokens bound to the same certificate, and a scoped token minted over a bound one carries the binding too. The drivers present the certificate on every connection they open (result partitions, ingest parts).
Options
Section titled “Options”| Option | Default | What it does |
|---|---|---|
--mtls-certificate-sign-in |
off | Offer certificate sign-in at all. Off, or with neither administrator identities nor access control (nobody it could sign in), the grant isn’t listed in discovery and is answered unsupported_grant_type |
--mtls-identity-source |
san_email |
Where the identity comes from: the e-mail SAN, the URI SAN (san_uri, for SPIFFE IDs) or the subject CN (subject_cn). A certificate with none, or several, values of that kind is refused |
--mtls-admin-identities |
none | Identities that sign in as the administrator. Under access control any other identity must be an enabled user (below); without access control, no other identity signs in |
--mtls-require-client-cert |
on | --mtls-require-client-cert=false: clients without a certificate may connect and sign in another way |
--mtls-crl-file |
none | Revocation lists (below) |
--mtls-crl-allow-missing-ca-crls |
off | Accept a CA above the leaf that has no CRL in the file |
--mtls-require-identity-match |
off | A password or token sign-in over a client certificate must be for the certificate’s identity (certificate_identity_mismatch otherwise): the certificate becomes a second factor |
How identities match user names: an e-mail address or CN ignoring ASCII case, as user names compare; a URI identity such as a SPIFFE ID only exactly, case included (its path is case-sensitive). Identities are printable ASCII; a certificate with anything else is refused.
Certificate users under access control
Section titled “Certificate users under access control”With access control on, an identity that isn’t in
--mtls-admin-identities signs in only as an enabled user of the store with that name. It needs no
password; its roles decide what it may do:
CREATE USER "joe@example.com";GRANT ROLE analyst TO USER "joe@example.com";A SPIFFE ID is a user name like any other (CREATE USER "spiffe://example.org/ns/etl/sa/loader",
always system scope, never a tenant’s). A name in --mtls-admin-identities must not also be a
user of the store: certificate sign-in refuses it until one of the two goes.
Revoking a certificate
Section titled “Revoking a certificate”--mtls-crl-file names a PEM file of CRLs, one for every CA of a client’s chain (the root’s too).
Every certificate of the chain is checked, so a revoked intermediate CA revokes what it issued, and
a CA without a CRL is refused unless --mtls-crl-allow-missing-ca-crls. Of several CRLs from one
CA the newest is used; delta CRLs are refused. Revoke client.crt with this guide’s CA:
printf '[ca]\ndefault_ca = CA_default\n[CA_default]\ndatabase = index.txt\ncrlnumber = crlnumber\ndefault_md = sha256\ndefault_crl_days = 7\n' > ca.cnftouch index.txt && echo 1000 > crlnumberopenssl ca -config ca.cnf -keyfile ca.key -cert ca.crt -revoke client.crtopenssl ca -config ca.cnf -keyfile ca.key -cert ca.crt -gencrl -out ca.crlHARDCHARGER_ADMIN_PASSWORD='change-me' hardcharger-server --database my.duckdb \ --tls-cert server.crt --tls-key server.key --mtls-ca-cert ca.crt \ --mtls-certificate-sign-in --mtls-admin-identities ops@example.com \ --mtls-crl-file ca.crl --health-port 31338The revoked certificate no longer gets past the handshake; the others still do:
$ curl -s --cacert ca.crt --cert client.crt --key client.key https://localhost:31337/warp || echo "refused"refused$ curl -s --cacert ca.crt --cert ops.crt --key ops.key https://localhost:31337/warp | jq -r .server.nameHardcharger$ hardcharger --host localhost --tls-roots ca.crt --mtls-cert client.crt --mtls-key client.key \ --auth-type mtls --command "SELECT 1"; echo "exit status $?"Error: The server at https://localhost:31337 refused the client certificate: certificate revokedexit status 1The server looks at the file every 5 seconds and reloads it when it changes (a failed reload is
retried), so revoking needs no restart. Connections already open are checked again then, and when
a CRL passes its nextUpdate: their next request gets 401 certificate_revoked. An expired CRL
refuses every certificate its CA issued, so publish a new one before nextUpdate.
Refusals are audited
Section titled “Refusals are audited”Every certificate sign-in, allowed or refused, is an audit record
(kind: authentication, method: client_certificate, the identity, subject, issuer, serial and
thumbprint), and hardcharger_sessions() shows client_certificate as the session’s
auth_method. Refusals (certificate_mismatch, certificate_identity_mismatch,
certificate_revoked, unknown_certificate_identity, and certificates the TLS handshake refuses)
are security warnings with the user, the token’s id, the certificate presented and the client’s
address. They are rate-limited per source: ten a minute in full, then one summary that counts the
rest. A connection is closed after five failed sign-ins.
TLS must end at Hardcharger for any of this (a TCP or TLS-passthrough load balancer): a proxy that ends TLS keeps the certificate.
From a page on another origin
Section titled “From a page on another origin”A browser presents the user’s client certificate on a cross-origin request only when the page asks
for credentials (credentials: "include", the JavaScript client’s default with
authType: "client_certificate"), and lets the page read the answer only if it allows them. An
origin in cors_origins gets
Access-Control-Allow-Credentials: true with its Access-Control-Allow-Origin, preflights
included; other origins get no CORS headers:
$ curl -s -o /dev/null -D - -X OPTIONS localhost:31337/warp/v1/auth/token \ -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: POST' \ | grep -i '^access-control-allow-\(origin\|credentials\)' | tr -d '\r'Access-Control-Allow-Origin: https://app.example.comAccess-Control-Allow-Credentials: trueA preflight carries no certificate, so a server that refuses handshakes without one fails it.
For browser pages on another origin, make certificates optional at the handshake
(--mtls-require-client-cert=false) and let certificate sign-in do the checking. On the server’s
own origin there is no preflight.
Behind a TLS-terminating proxy
Section titled “Behind a TLS-terminating proxy”If a load balancer terminates TLS, run Hardcharger on plain HTTP on a private network, or re-encrypt to Hardcharger with its own certificate. Client certificates must then be checked by the proxy, since Hardcharger never sees them. See Behind a load balancer.
Rotating a certificate
Section titled “Rotating a certificate”The server reads its certificate and key at startup. To rotate, replace the files and restart (with several replicas behind a load balancer, one at a time; graceful shutdown lets running statements finish).