Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .agents/skills/a2a-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,22 @@ Helpful environment variables for appliance environments that use an internal CA
- `REQUESTS_CA_BUNDLE` for HTTP requests
- `WEBSOCKET_CLIENT_CA_BUNDLE` for SignalR/WebSocket traffic

#### TLS 1.3 (SPP 9.0)

If **async** A2A credential retrieval or `CertificateAuth` login fails on SPP 9.0 with `60094 Authorization is denied` while the **sync** path works, the cause is TLS 1.3 post-handshake authentication. The async `SSLContext` must set `post_handshake_auth = True` (done in `AsyncSafeguardClient._create_ssl_context`); without it the client never answers the server's post-handshake `CertificateRequest`. The sync (`requests`/urllib3) path enables this by default.

To force a TLS version, pass the opt-in `min_tls_version` / `max_tls_version` (`ssl.TLSVersion | None`) to `A2AContext` / `AsyncA2AContext` (also on `SafeguardClient` / `AsyncSafeguardClient` and the `quick_*` classmethods). Examples:

```python
import ssl
# Require TLS 1.3
A2AContext(host, cert, key, min_tls_version=ssl.TLSVersion.TLSv1_3)
# Interim: cap at TLS 1.2
A2AContext(host, cert, key, max_tls_version=ssl.TLSVersion.TLSv1_2)
```

Defaults are `None` (negotiate normally). Cert auth requires HTTP/1.1 — never enable HTTP/2, which disallows the post-handshake `CertificateRequest`.

### Safe debugging rules

- never print or log `password.value` or private-key plaintext in committed samples/tests
Expand Down
18 changes: 18 additions & 0 deletions .agents/skills/api-patterns/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,24 @@ client = SafeguardClient("host", auth=auth, verify=False)

Set these when the appliance uses a certificate signed by an internal CA.

### TLS Version Pinning (opt-in)

`SafeguardClient`, `AsyncSafeguardClient`, `A2AContext`, `AsyncA2AContext`
(and the A2A `quick_*` classmethods) accept optional `min_tls_version` /
`max_tls_version` (`ssl.TLSVersion | None`, default `None` = negotiate).

```python
import ssl
# Require TLS 1.3 (SPP 9.0)
client = SafeguardClient("host", auth=auth, min_tls_version=ssl.TLSVersion.TLSv1_3)
# Interim: cap at TLS 1.2
client = SafeguardClient("host", auth=auth, max_tls_version=ssl.TLSVersion.TLSv1_2)
```

Pins govern the client's request transport (all API, token, and A2A calls).
Async cert/A2A auth enables `post_handshake_auth` so it works over TLS 1.3;
the sync path enables it by default. Keep HTTP/1.1 (no HTTP/2).

## Common Patterns

### Query parameters
Expand Down
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,23 @@ and publishing workflow.
- Prefer CA bundle verification over `verify=False`; use
`REQUESTS_CA_BUNDLE` / `WEBSOCKET_CLIENT_CA_BUNDLE` when needed.

### TLS 1.3

SPP 9.0 enables TLS 1.3. Certificate/A2A auth over TLS 1.3 needs
post-handshake authentication (RFC 8446 §4.6.2): the sync (`requests`/urllib3)
path enables it by default, and the async (`aiohttp`) path enables
`post_handshake_auth` in `AsyncSafeguardClient._create_ssl_context`. Do not
remove it or async cert/A2A auth fails on 9.0 (error 60094).

Both clients and both A2A contexts accept optional, opt-in
`min_tls_version` / `max_tls_version` (`ssl.TLSVersion | None`, default
`None` = negotiate). They govern the client's request transport (all API,
token, and A2A traffic): async applies them in `_create_ssl_context`; sync
mounts a `_TlsVersionAdapter` using urllib3's native
`ssl_minimum_version` / `ssl_maximum_version` only when a pin is set, so the
default path is unchanged. Keep HTTP/1.1 (never enable HTTP/2), since cert
auth's post-handshake CertificateRequest is disallowed under HTTP/2.

## Versioning

`pyproject.toml` holds the base semantic version. CI stamps tagged releases and
Expand Down
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,82 @@ adapting a sample for production, remove `verify=False` and configure trust via
`REQUESTS_CA_BUNDLE` (and `WEBSOCKET_CLIENT_CA_BUNDLE` if you use SignalR) or
pass an explicit CA bundle path to `verify`.

> **TLS 1.3 / SPP 9.0:** if you are connecting to SPP 9.0 (which enables
> TLS 1.3), see [TLS 1.3 and SPP 9.0](#tls-13-and-spp-90) below — especially
> if you build your own `aiohttp`/`ssl` context for certificate or A2A auth.

## TLS 1.3 and SPP 9.0

Starting with **SPP 9.0**, the appliance enables **TLS 1.3**. PySafeguard
`8.2.0` and later negotiate TLS 1.3 automatically — for most applications there
is **nothing to change**. Certificate-based login and A2A credential retrieval
continue to work over TLS 1.3 on both the sync and async clients.

### What changed under the hood

TLS 1.3 moves client-certificate authentication to a **post-handshake**
exchange (RFC 8446 §4.6.2). Instead of the client presenting its certificate
during the initial handshake, the server sends a `CertificateRequest` *after*
the handshake completes, and the client must answer it. Python only answers
that request when the underlying `ssl.SSLContext` has
`post_handshake_auth = True`.

- **Sync client (`requests` / `urllib3`):** already enabled by default, so it
was never affected.
- **Async client (`aiohttp`):** PySafeguard now sets `post_handshake_auth`
explicitly on the SSL context it builds for certificate/A2A auth. Before
`8.2.0`, async certificate and A2A auth failed against SPP 9.0 with
`60094 Authorization is denied`.

### Pinning the TLS version (opt-in)

`SafeguardClient`, `AsyncSafeguardClient`, `A2AContext`, `AsyncA2AContext`
(and the A2A `quick_*` classmethods) accept optional `min_tls_version` and
`max_tls_version` arguments (`ssl.TLSVersion | None`, default `None` =
negotiate normally):

```python
import ssl

# Require TLS 1.3 (e.g. to enforce it against SPP 9.0)
client = SafeguardClient("host", auth=auth, min_tls_version=ssl.TLSVersion.TLSv1_3)

# Interim: cap the connection at TLS 1.2
client = SafeguardClient("host", auth=auth, max_tls_version=ssl.TLSVersion.TLSv1_2)

# Also available on the A2A contexts
with A2AContext("host", "cert.pem", "key.pem",
min_tls_version=ssl.TLSVersion.TLSv1_3) as ctx:
password = ctx.retrieve_password(api_key)
```

The pins govern the client's request transport (all API, token, and A2A
traffic). Leaving them at `None` lets the platform negotiate the highest
mutually supported version, which is the recommended default.

### Python-specific gotchas

- **Post-handshake auth is required for cert/A2A auth on TLS 1.3.** If you
build your **own** `aiohttp`/`ssl` context instead of letting PySafeguard
create it (for example, a custom `AsyncSafeguardClient` subclass or a
hand-rolled A2A call), you **must** set `ssl_ctx.post_handshake_auth = True`
or cert/A2A auth will fail on SPP 9.0 with error `60094`.
- **Keep HTTP/1.1 — do not enable HTTP/2.** The post-handshake
`CertificateRequest` is disallowed under HTTP/2, so certificate auth breaks
over HTTP/2. Both `requests` and `aiohttp` default to HTTP/1.1; PySafeguard
relies on that and does not enable HTTP/2.
- **Your Python `ssl` must be built against OpenSSL 1.1.1 or newer** for
TLS 1.3 support. This is true of all supported CPython builds (3.10+), but
can bite on old or custom OpenSSL builds; check with
`ssl.HAS_TLSv1_3`.
- **Use the `ssl.TLSVersion` enum**, not integers or strings, for
`min_tls_version` / `max_tls_version` (e.g. `ssl.TLSVersion.TLSv1_3`).
- **A version pin forces a real SSL context even with `verify=False`.** On the
async client, `verify=False` normally skips building an SSL context entirely;
setting `min_tls_version`/`max_tls_version` makes PySafeguard build one anyway
so the floor/ceiling can be applied. Certificate verification stays disabled —
only the TLS version bounds are added.

## Getting Started

> **Note:** Recent versions of Safeguard have Resource Owner Grant (ROG)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "poetry.core.masonry.api"
[project]
name = "pysafeguard"
description = "One Identity Safeguard Python Package"
version = "8.1.0"
version = "8.2.0"
readme = { file = "README.md", content-type = "text/markdown" }
keywords = ["safeguard", "oneidentity"]
license = "Apache"
Expand Down
43 changes: 40 additions & 3 deletions src/pysafeguard/a2a.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

from __future__ import annotations

import ssl
import typing
from typing import TYPE_CHECKING
from types import TracebackType
Expand All @@ -56,6 +57,10 @@ class A2AContext:
:param key_file: Path to the certificate private key.
:param verify: TLS verification — ``True``, ``False``, or a CA bundle path.
:param api_version: API version (default ``"v4"``).
:param min_tls_version: Optional minimum TLS version to negotiate (e.g.
``ssl.TLSVersion.TLSv1_3``). ``None`` (default) negotiates normally.
:param max_tls_version: Optional maximum TLS version to negotiate (e.g.
``ssl.TLSVersion.TLSv1_2``). ``None`` (default) negotiates normally.
"""

def __init__(
Expand All @@ -66,6 +71,8 @@ def __init__(
*,
verify: bool | str = True,
api_version: LiteralString = "v4",
min_tls_version: ssl.TLSVersion | None = None,
max_tls_version: ssl.TLSVersion | None = None,
) -> None:
if not cert_file or not key_file:
raise ValueError("cert_file and key_file are required for A2A context")
Expand All @@ -75,7 +82,13 @@ def __init__(
self._verify = verify
self._api_version = api_version

self._conn = SafeguardClient(host, verify=verify, api_version=api_version)
self._conn = SafeguardClient(
host,
verify=verify,
api_version=api_version,
min_tls_version=min_tls_version,
max_tls_version=max_tls_version,
)
self._user_authenticated = False

# -- lifecycle -----------------------------------------------------------
Expand Down Expand Up @@ -281,6 +294,8 @@ def quick_retrieve_password(
*,
verify: bool | str = True,
api_version: LiteralString = "v4",
min_tls_version: ssl.TLSVersion | None = None,
max_tls_version: ssl.TLSVersion | None = None,
) -> HiddenString:
"""One-shot password retrieval without creating a context.

Expand All @@ -290,9 +305,19 @@ def quick_retrieve_password(
:param key_file: Path to certificate key.
:param verify: TLS verification setting.
:param api_version: API version.
:param min_tls_version: Optional minimum TLS version to negotiate.
:param max_tls_version: Optional maximum TLS version to negotiate.
:returns: The password wrapped in a :class:`~pysafeguard.HiddenString`.
"""
with cls(host, cert_file, key_file, verify=verify, api_version=api_version) as ctx:
with cls(
host,
cert_file,
key_file,
verify=verify,
api_version=api_version,
min_tls_version=min_tls_version,
max_tls_version=max_tls_version,
) as ctx:
return ctx.retrieve_password(api_key)

@classmethod
Expand All @@ -306,6 +331,8 @@ def quick_retrieve_private_key(
key_format: SshKeyFormat = SshKeyFormat.OPENSSH,
verify: bool | str = True,
api_version: LiteralString = "v4",
min_tls_version: ssl.TLSVersion | None = None,
max_tls_version: ssl.TLSVersion | None = None,
) -> HiddenString:
"""One-shot private key retrieval without creating a context.

Expand All @@ -316,9 +343,19 @@ def quick_retrieve_private_key(
:param key_format: Key format (default :attr:`SshKeyFormat.OPENSSH`).
:param verify: TLS verification setting.
:param api_version: API version.
:param min_tls_version: Optional minimum TLS version to negotiate.
:param max_tls_version: Optional maximum TLS version to negotiate.
:returns: The private key wrapped in a :class:`~pysafeguard.HiddenString`.
"""
with cls(host, cert_file, key_file, verify=verify, api_version=api_version) as ctx:
with cls(
host,
cert_file,
key_file,
verify=verify,
api_version=api_version,
min_tls_version=min_tls_version,
max_tls_version=max_tls_version,
) as ctx:
return ctx.retrieve_private_key(api_key, key_format=key_format)

# -- Internal helpers ----------------------------------------------------
Expand Down
43 changes: 40 additions & 3 deletions src/pysafeguard/async_a2a.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from __future__ import annotations

import ssl
import typing
from typing import TYPE_CHECKING

Expand All @@ -37,6 +38,10 @@ class AsyncA2AContext:
:param key_file: Path to the certificate private key.
:param verify: TLS verification — ``True``, ``False``, or a CA bundle path.
:param api_version: API version (default ``"v4"``).
:param min_tls_version: Optional minimum TLS version to negotiate (e.g.
``ssl.TLSVersion.TLSv1_3``). ``None`` (default) negotiates normally.
:param max_tls_version: Optional maximum TLS version to negotiate (e.g.
``ssl.TLSVersion.TLSv1_2``). ``None`` (default) negotiates normally.
"""

def __init__(
Expand All @@ -47,6 +52,8 @@ def __init__(
*,
verify: bool | str = True,
api_version: LiteralString = "v4",
min_tls_version: ssl.TLSVersion | None = None,
max_tls_version: ssl.TLSVersion | None = None,
) -> None:
if not cert_file or not key_file:
raise ValueError("cert_file and key_file are required for A2A context")
Expand All @@ -56,7 +63,13 @@ def __init__(
self._verify = verify
self._api_version = api_version

self._conn = AsyncSafeguardClient(host, verify=verify, api_version=api_version)
self._conn = AsyncSafeguardClient(
host,
verify=verify,
api_version=api_version,
min_tls_version=min_tls_version,
max_tls_version=max_tls_version,
)
self._user_authenticated = False

# -- lifecycle -----------------------------------------------------------
Expand Down Expand Up @@ -255,6 +268,8 @@ async def quick_retrieve_password(
*,
verify: bool | str = True,
api_version: LiteralString = "v4",
min_tls_version: ssl.TLSVersion | None = None,
max_tls_version: ssl.TLSVersion | None = None,
) -> HiddenString:
"""One-shot async password retrieval without creating a context.

Expand All @@ -264,9 +279,19 @@ async def quick_retrieve_password(
:param key_file: Path to certificate key.
:param verify: TLS verification setting.
:param api_version: API version.
:param min_tls_version: Optional minimum TLS version to negotiate.
:param max_tls_version: Optional maximum TLS version to negotiate.
:returns: The password wrapped in a :class:`~pysafeguard.HiddenString`.
"""
async with cls(host, cert_file, key_file, verify=verify, api_version=api_version) as ctx:
async with cls(
host,
cert_file,
key_file,
verify=verify,
api_version=api_version,
min_tls_version=min_tls_version,
max_tls_version=max_tls_version,
) as ctx:
return await ctx.retrieve_password(api_key)

@classmethod
Expand All @@ -280,6 +305,8 @@ async def quick_retrieve_private_key(
key_format: SshKeyFormat = SshKeyFormat.OPENSSH,
verify: bool | str = True,
api_version: LiteralString = "v4",
min_tls_version: ssl.TLSVersion | None = None,
max_tls_version: ssl.TLSVersion | None = None,
) -> HiddenString:
"""One-shot async private key retrieval without creating a context.

Expand All @@ -290,9 +317,19 @@ async def quick_retrieve_private_key(
:param key_format: Key format (default :attr:`SshKeyFormat.OPENSSH`).
:param verify: TLS verification setting.
:param api_version: API version.
:param min_tls_version: Optional minimum TLS version to negotiate.
:param max_tls_version: Optional maximum TLS version to negotiate.
:returns: The private key wrapped in a :class:`~pysafeguard.HiddenString`.
"""
async with cls(host, cert_file, key_file, verify=verify, api_version=api_version) as ctx:
async with cls(
host,
cert_file,
key_file,
verify=verify,
api_version=api_version,
min_tls_version=min_tls_version,
max_tls_version=max_tls_version,
) as ctx:
return await ctx.retrieve_private_key(api_key, key_format=key_format)

# -- Internal helpers ----------------------------------------------------
Expand Down
Loading
Loading