Skip to content

feat: add mTLS client certificate authentication to REST generator#1681

Merged
jmartin-tech merged 4 commits into
NVIDIA:mainfrom
JakeBx:feature/mtls-client-auth
Apr 29, 2026
Merged

feat: add mTLS client certificate authentication to REST generator#1681
jmartin-tech merged 4 commits into
NVIDIA:mainfrom
JakeBx:feature/mtls-client-auth

Conversation

@JakeBx
Copy link
Copy Markdown
Contributor

@JakeBx JakeBx commented Apr 11, 2026

Add mutual TLS (mTLS) client certificate support to RestGenerator, enabling authentication with endpoints that require client certificates.

mTLS is a hard requirement in regulated industries (financial services, healthcare, and government environments) where endpoints mandate mutual authentication as part of their security posture. Without this, garak cannot be used as part of a compliant LLM security testing pipeline against these targets.

Major providers are already shipping mTLS support: OpenAI's Mutual TLS Beta Program enables client certificate authentication on /v1/chat/completions today. Without this change, garak users in regulated environments cannot run scans against these endpoints.

New configuration options:

  • client_cert: path to PEM-encoded client certificate
  • client_key: path to client private key (optional if cert contains key)
  • client_key_passphrase_env_var: env var name holding encrypted key passphrase

Implementation details:

  • Custom _MtlsAdapter (HTTPAdapter subclass) injects pre-configured SSLContext into urllib3 connection pools for both direct and proxied connections
  • Session-based request dispatch when mTLS is active; falls back to stateless requests.method() calls otherwise
  • Passphrase read from environment variable (never from config files) and cleared from memory immediately after loading into SSLContext

Security hardening:

  • HTTPS URI validation: rejects http:// URIs when client_cert is set to prevent silent security downgrade
  • Session cleanup via __del__ to close connection pools

Tests:

  • 9 new mTLS unit tests covering construction, validation, error paths, passphrase loading, CA bundle wiring, _call_model session dispatch, and http:// URI rejection
  • All 21 tests pass (12 existing + 9 new)

Documentation:

  • Updated garak.generators.rest.rst with mTLS configuration reference, usage examples for cert+key, combined PEM, and encrypted key scenarios

Verification

Configuration

{
  "rest": {
    "RestGenerator": {
      "uri": "https://your-mtls-endpoint.example.com/v1/generate",
      "client_cert": "/path/to/client.pem",
      "client_key": "/path/to/client.key",
      "client_key_passphrase_env_var": "GARAK_CLIENT_KEY_PASS"
    }
  }
}

For a combined cert+key PEM (no separate key file):

{
  "rest": {
    "RestGenerator": {
      "uri": "https://your-mtls-endpoint.example.com/v1/generate",
      "client_cert": "/path/to/combined.pem"
    }
  }
}

CLI invocation

export GARAK_CLIENT_KEY_PASS="<passphrase>"
python -m garak \
  --model_type rest \
  --model_name RestGenerator \
  --generator_option_file mtls_config.json \
  -p knownbadsignatures

Tests

python -m pytest tests/generators/test_rest.py -v

All cases pass.

Test Covers
test_rest_mtls_no_cert_direct_path Non-mTLS path unaffected; verify kwarg still passed
test_rest_mtls_both_cert_and_key Separate cert + key files → session created
test_rest_mtls_cert_only Combined PEM → session created
test_rest_mtls_key_without_cert_raises Key supplied without cert → BadGeneratorException
test_rest_mtls_nonexistent_cert_raises Missing file → BadGeneratorException
test_rest_mtls_passphrase_loads_from_env Passphrase loaded from env var; cleared after use
test_rest_mtls_verify_ssl_ca_path CA bundle path wired into SSLContext at construction
test_rest_mtls_call_model_uses_session _call_model routes through _mtls_session.request
test_rest_mtls_http_uri_raises http:// URI with mTLS → BadGeneratorException
  • Verify the thing does what it should — smoke tested against an nginx mTLS endpoint with a self-signed CA; mutual handshake completes and responses parse correctly
  • Verify the thing does not do what it should not — non-mTLS generators instantiate normally with no change to behaviour; misuse cases (key without cert, missing files, http://) all raise BadGeneratorException with clear messages
  • Document the thing and how it works — docstring parameter table and mTLS configuration examples added to docs/source/garak.generators.rest.rst

Happy to iterate on the approach if the implementation direction needs adjusting

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 11, 2026

DCO Assistant Lite bot All contributors have signed the DCO ✍️ ✅

@JakeBx
Copy link
Copy Markdown
Contributor Author

JakeBx commented Apr 11, 2026

I have read the DCO Document and I hereby sign the DCO

@JakeBx JakeBx marked this pull request as ready for review April 11, 2026 10:00
Copy link
Copy Markdown
Collaborator

@jmartin-tech jmartin-tech left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This look really useful, I have requested a few tweaks based on static code review.

It may take a bit to configure a testing env for final acceptance. If you happen to have details to share for setup of a container or even a light testing application that and the configuration example for garak that could be used to validate this that would be appreciated.

Comment thread garak/generators/rest.py Outdated
Comment on lines +172 to +180
# load passphrase from env var if specified
self.client_key_passphrase = None
if self.client_key_passphrase_env_var is not None:
self.client_key_passphrase = os.getenv(self.client_key_passphrase_env_var)
if self.client_key_passphrase is None:
raise BadGeneratorException(
f"client_key_passphrase_env_var '{self.client_key_passphrase_env_var}' "
"is set but the environment variable is not defined"
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer items pulled from env vars override _validate_env_var() and call the super() to supply default support:

def _validate_env_var(self):
    super()._validate_env_var()
    if self.client_key_passphrase is None and self.client_key_passphrase_env_var is not None:
        self.client_key_passphrase = os.getenv(self.client_key_passphrase_env_var, default=None)
        if self.client_key_passphrase is None:
            raise BadGeneratorException(
                f"client_key_passphrase_env_var '{self.client_key_passphrase_env_var}' "
                "is set but the environment variable is not defined"
            )

Note that _validate_env_var() is called as the last stop of the earlier call to self._load_config(config_root) moving this validation to an earlier stage in this object's intialization.

Comment thread garak/generators/rest.py Outdated
Comment thread garak/generators/rest.py Outdated
Comment thread garak/generators/rest.py Outdated
github-actions Bot added a commit that referenced this pull request Apr 15, 2026
@JakeBx
Copy link
Copy Markdown
Contributor Author

JakeBx commented Apr 15, 2026

Pushed the fixes addressing all four review items:

  • Passphrase loading moved to _validate_env_var() with super() call
  • Comment updated to reference _load_unsafe()
  • _unsafe_attributes trimmed to ["_mtls_session"] only
  • Session creation extracted to _load_unsafe() for multiprocessing support

New in this revision:

  • Added _load_unsafe() method that reconstructs the mTLS session after pickle roundtrip
    (called from __setstate__ via Configurable)
  • Added 10 new unit tests including test_rest_mtls_pickle_roundtrip that exercises
    __getstate____setstate__ → adapter verification with real certs
  • Added test_rest_mtls_integration.py — 10 self-contained integration tests
    (marked @pytest.mark.integration) that spin up a threaded mTLS HTTPS server
    with generated certs, covering cert+key, combined PEM, ECDSA certs, encrypted
    key with passphrase, missing cert rejection, pickle roundtrip with live handshake

Testing scaffolding for manual validation:
https://github.com/JakeBx/mtls-testing
(Docker nginx mTLS proxy + garak config example — same setup I used for smoke testing)

All 32 tests pass: python -m pytest tests/generators/test_rest.py tests/generators/test_rest_mtls_integration.py -v

Add mutual TLS (mTLS) client certificate support to RestGenerator,
enabling authentication with endpoints that require client certificates.

New configuration options:
- client_cert: path to PEM-encoded client certificate
- client_key: path to client private key (optional if cert contains key)
- client_key_passphrase_env_var: env var name holding encrypted key passphrase

Implementation details:
- Custom _MtlsAdapter (HTTPAdapter subclass) injects pre-configured
  SSLContext into urllib3 connection pools for both direct and proxied
  connections
- Session-based request dispatch when mTLS is active; falls back to
  stateless requests.method() calls otherwise
- Passphrase read from environment variable (never from config files)
  and cleared from memory immediately after loading into SSLContext
- _unsafe_attributes prevents passphrase and session from leaking
  into pickled/serialized state via Configurable.__getstate__()

Security hardening:
- HTTPS URI validation: rejects http:// URIs when client_cert is set
  to prevent silent security downgrade
- Session cleanup via __del__ to close connection pools

Tests:
- 9 new mTLS unit tests covering construction, validation, error paths,
  passphrase loading, CA bundle wiring, _call_model session dispatch,
  and http:// URI rejection
- All 21 tests pass (12 existing + 9 new)

Documentation:
- Updated garak.generators.rest.rst with mTLS configuration reference,
  usage examples for cert+key, combined PEM, and encrypted key scenarios

Signed-off-by: JakeBx <jacob.j.lee@live.com>
@JakeBx JakeBx force-pushed the feature/mtls-client-auth branch from 47d9e6b to 3adcbf7 Compare April 17, 2026 09:22
JakeBx added 3 commits April 17, 2026 19:35
- Move passphrase env var loading to _validate_env_var() with super() call
- Extract mTLS session creation to _load_unsafe() for multiprocessing support
- Trim _unsafe_attributes to only ['_mtls_session']
- Update comment to reference _load_unsafe()
- Add pickle roundtrip unit test with real certs
- Add self-contained mTLS integration test suite (10 tests)

Signed-off-by: JakeBx <jacob.j.lee@live.com>
Signed-off-by: JakeBx <jacob.j.lee@live.com>
Python 3.13 OpenSSL enforces RFC 5280 strictly and rejects certificates
missing the Authority Key Identifier extension. Add SubjectKeyIdentifier and
AuthorityKeyIdentifier to all generated certs in the mtls_certs fixture (CA,
server, client, ECDSA client) to fix 6 failing integration tests on Python 3.13.

Signed-off-by: JakeBx <jacob.j.lee@live.com>
@JakeBx JakeBx force-pushed the feature/mtls-client-auth branch from 3adcbf7 to 680509f Compare April 17, 2026 09:40
@JakeBx JakeBx requested a review from jmartin-tech April 17, 2026 09:41
Copy link
Copy Markdown
Collaborator

@jmartin-tech jmartin-tech left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Testing looks good, thanks for a great capability add!

proxy-1  | 172.19.0.1 - - [29/Apr/2026:20:59:28 +0000] "POST /openrouter HTTP/1.1" 200 903 "-" "garak/0.14.2.pre1 (LLM vulnerability scanner https://garak.ai)" ssl_client_s_dn="CN=garak-client,OU=Garak,O=JakeBx,L=Sydney,ST=NSW,C=AU" ssl_client_verify="SUCCESS"
proxy-1  | 172.19.0.1 - - [29/Apr/2026:20:59:30 +0000] "POST /openrouter HTTP/1.1" 200 1265 "-" "garak/0.14.2.pre1 (LLM vulnerability scanner https://garak.ai)" ssl_client_s_dn="CN=garak-client,OU=Garak,O=JakeBx,L=Sydney,ST=NSW,C=AU" ssl_client_verify="SUCCESS"
proxy-1  | 172.19.0.1 - - [29/Apr/2026:20:59:32 +0000] "POST /openrouter HTTP/1.1" 200 901 "-" "garak/0.14.2.pre1 (LLM vulnerability scanner https://garak.ai)" ssl_client_s_dn="CN=garak-client,OU=Garak,O=JakeBx,L=Sydney,ST=NSW,C=AU" ssl_client_verify="SUCCESS"
proxy-1  | 172.19.0.1 - - [29/Apr/2026:20:59:34 +0000] "POST /openrouter HTTP/1.1" 200 1133 "-" "garak/0.14.2.pre1 (LLM vulnerability scanner https://garak.ai)" ssl_client_s_dn="CN=garak-client,OU=Garak,O=JakeBx,L=Sydney,ST=NSW,C=AU" ssl_client_verify="SUCCESS"
proxy-1  | 172.19.0.1 - - [29/Apr/2026:21:02:28 +0000] "POST /openrouter HTTP/1.1" 200 1305 "-" "garak/0.14.2.pre1 (LLM vulnerability scanner https://garak.ai)" ssl_client_s_dn="CN=garak-client-encrypted,OU=Garak,O=JakeBx,L=Sydney,ST=NSW,C=AU" ssl_client_verify="SUCCESS"
proxy-1  | 172.19.0.1 - - [29/Apr/2026:21:02:31 +0000] "POST /openrouter HTTP/1.1" 200 2098 "-" "garak/0.14.2.pre1 (LLM vulnerability scanner https://garak.ai)" ssl_client_s_dn="CN=garak-client-encrypted,OU=Garak,O=JakeBx,L=Sydney,ST=NSW,C=AU" ssl_client_verify="SUCCESS"
proxy-1  | 172.19.0.1 - - [29/Apr/2026:21:02:35 +0000] "POST /openrouter HTTP/1.1" 200 2368 "-" "garak/0.14.2.pre1 (LLM vulnerability scanner https://garak.ai)" ssl_client_s_dn="CN=garak-client-encrypted,OU=Garak,O=JakeBx,L=Sydney,ST=NSW,C=AU" ssl_client_verify="SUCCESS"

@jmartin-tech jmartin-tech merged commit 48a864f into NVIDIA:main Apr 29, 2026
16 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 29, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants