Skip to content

Fix 2014 - #2015

Merged
oberstet merged 7 commits into
crossbario:masterfrom
oberstet:fix_2014
Oct 20, 2026
Merged

oberstet merged 7 commits into
crossbario:masterfrom
oberstet:fix_2014

Conversation

@oberstet

Copy link
Copy Markdown
Contributor

fixes #2014

Start the 26.10.2 development cycle; crossbario#2014 (extract_peer_certificate() with
pyOpenSSL >= 26.2.0) ships in autobahn 26.10.2, which Crossbar.io 26.10.1
will depend on.

Expected CI result: GREEN (version change only).

Note: This work was completed with AI assistance (Claude Code).
…s and channel binding

New tests over real TLS connections (loopback, certificates created per test:
a CA, a server certificate for localhost, client certificates client_0 and
client_1 as in crossbar-examples authentication/tls), on Twisted and asyncio,
over WebSocket and RawSocket, on TLS 1.2 and TLS 1.3. The router side is a
minimal FakeRouterSession (autobahn/test/tlsutil.py) that authenticates like
Crossbar.io: authmethod "tls" by the SHA1 fingerprint of the client
certificate (PendingAuthTLS), and WAMP-cryptosign with channel binding
"tls-unique" (PendingAuthCryptosign, but denying instead of falling back
when the router has no channel ID).

Twisted (autobahn/twisted/test/test_tx_tls.py), 21 tests:

- extract_peer_certificate() on a client certificate with SAN, key usage and
  EKU, field by field against values computed from the DER: FAILS
  (AttributeError: 'X509' object has no attribute 'get_extension', removed
  in pyOpenSSL 26.2.0)
- extract_peer_certificate() on a certificate with a duplicate extension
  must not raise: FAILS (same)
- authmethod "tls" accepted (default, TLS 1.2, TLS 1.3) and an unknown client
  certificate denied for that reason, WebSocket and RawSocket: FAIL, the
  router sees no client certificate (peer_cert is only read in
  connectionMade(), before the TLS handshake)
- WAMP-cryptosign with channel binding over RawSocket (4 tests): FAIL, the
  signature does not match the router's channel ID. The Twisted RawSocket
  protocols never set is_server, so the client side takes the server's
  TLS Finished message for its tls-unique channel ID
- passing: no client certificate denied (WebSocket, RawSocket), cryptosign
  with channel binding over WebSocket (4 tests), no peer certificate

asyncio (autobahn/asyncio/test/test_aio_tls.py), 26 tests:

- authmethod "tls" accepted / unknown certificate denied (8 tests): FAIL,
  asyncio never fills peer_cert
- WAMP-cryptosign with channel binding without a client certificate (6
  tests): FAIL, a TLS server connection without a client certificate is not
  is_secure and has no channel ID (is_secure is derived from the peer
  certificate)
- passing: channel binding with a client certificate (6), no client
  certificate denied (2)

Expected CI result: RED. Twisted trial fails in exactly these 14 tests
(locally cpy311: 349 ok, 12 failures, 2 errors; same on pypy311), asyncio
pytest in exactly these 14 tests (320 passed, 14 failed); ruff and ty 0.0.89
clean.

Note: This work was completed with AI assistance (Claude Code).
patch_modules() rebound sys.modules to a copy and "restored" it to another
copy (since 073d33d, 2015), so after the first test of
test_tx_choosereactor the sys.modules dict was no longer the one the import
system uses. Any module first imported lazily later in the same trial process
then fails. On GitHub CI (all CPython jobs) this hit the new crossbario#2014 tests:
pyOpenSSL's X509.has_expired() calls datetime.strptime(), whose lazy import
of _strptime raised "KeyError: '_strptime'", which hid the actual failure.
Locally _strptime happened to be imported earlier, so it did not show.

Reproduced locally by running test_tx_choosereactor before
test_tx_tls.ExtractPeerCertificateTests in one trial process (2x
KeyError: '_strptime'); with mock.patch.dict(sys.modules), which patches and
restores the dict in place, the same run fails with the expected
AttributeError ('X509' object has no attribute 'get_extension').

Correction to the previous (red) commit message cf8edbc: the asyncio test
module has 22 tests (14 fail, 8 pass), not 26.

Expected CI result: still RED, in the same 14 Twisted tests, now with the
expected errors on CPython too (locally cpy311: 349 ok, 12 failures,
2 errors, no _strptime errors); test_tx_choosereactor passes.

Note: This work was completed with AI assistance (Claude Code).
… tests fail

In the "Unit Tests" matrix jobs the asyncio step was skipped whenever the
Twisted step failed, so the red run of crossbario#2014 showed only the 14 Twisted
failures and never the 14 asyncio ones. The asyncio step now runs unless the
job was cancelled (if: !cancelled()), so both backends always report.

Expected CI result: RED. Unit Tests jobs: Twisted fails in the 14 crossbario#2014
tests (12 failures, 2 errors), and asyncio now runs and fails in the 14
crossbario#2014 tests (locally cpy311: 320 passed, 14 failed). ruff and ty 0.0.89
clean.

Note: This work was completed with AI assistance (Claude Code).
…e asyncio

tls-unique (RFC 5929) is the first TLS Finished message of the handshake, in
a full handshake the client's; Python's ssl module, which autobahn uses on
asyncio, returns exactly that. Checked on one connection (TLS 1.2 and 1.3):
Twisted's transport_channel_id() gives sha256(server Finished) when
is_server is set correctly (WebSocket), and with is_server=None (RawSocket)
sha256(client Finished) on the server but sha256(server Finished) on the
client. So WAMP-cryptosign channel binding fails between a Twisted router
(Crossbar.io) and asyncio clients over WebSocket, and between Twisted
RawSocket peers; it works Twisted<->Twisted over WebSocket (both sides use
the server's Finished) and Crossbar.io RawSocket<->asyncio (both use the
client's).

New tests in TlsUniqueTests (autobahn/twisted/test/test_tx_tls.py), 5 FAIL:

- a Twisted TLS server and a Python ssl client, TLS 1.2 and TLS 1.3: the
  server's channel ID must equal the client's
- a Twisted TLS client and a Python ssl server, TLS 1.2 and TLS 1.3: same
- the WAMP WebSocket and RawSocket server / client protocols set is_server
  (RawSocket: None)

Expected CI result: RED. Twisted trial now fails in 19 crossbario#2014 tests
(locally cpy311: 349 ok, 17 failures, 2 errors), asyncio unchanged in the
14 crossbario#2014 tests (320 passed, 14 failed); ruff and ty 0.0.89 clean.

Note: This work was completed with AI assistance (Claude Code).
…ed and asyncio

Peer certificate:

- new autobahn.util.peer_certificate_details(): the peer_cert dict, read
  with cryptography (pyOpenSSL removed X509.get_extension in 26.2.0 and
  deprecates the rest of its X509 API). Same keys and formats as before;
  extensions[].value: a subject / issuer alternative name as OpenSSL prints
  it (DNS:localhost, IP Address:..), other extensions as cryptography prints
  them; signature_algorithm "ed25519" instead of "ED25519". Never raises on
  certificate content: unreadable extensions / subject / issuer are empty
- Twisted extract_peer_certificate() uses it (to_cryptography(); hash from
  pyOpenSSL's subject_name_hash(), which is not deprecated)
- Twisted WebSocket (_onOpen) and RawSocket (_on_handshake_complete) read
  peer_cert after the TLS handshake, next to the channel ID; it was read in
  connectionMade(), before the handshake, and so was always None
- asyncio: is_secure from the SSL object (was from "peercert", so a server
  without client certificate was not secure and had no channel ID), and new
  autobahn.asyncio.util.extract_peer_certificate() fills peer_cert (was
  always None)

tls-unique (RFC 5929, as the user decided):

- Twisted transport_channel_id() uses the first Finished message of the
  handshake (the client's): server: get_peer_finished(), client:
  get_finished(), as CPython's ssl module does. It used the server's
- Twisted RawSocket server / client protocols set is_server (was None, so
  the client side took the wrong message); asyncio WebSocket protocols too
- PyPy workaround: PyPy's ssl get_channel_binding("tls-unique") misses the
  negation CPython has (reused ^ socket_type instead of ^ !socket_type) and
  returns the server's Finished message; its tls_unique_cb() is correct.
  New autobahn.util.tls_unique() uses that on PyPy; asyncio uses
  tls_unique(), and TlsUniqueTests compare Twisted against it, so they pass
  on PyPy too (they failed there against the plain ssl module, as on the
  pypy311 job of the previous red push). PyPy issue drafted.

Changelog: 26.10.2 section, with the extension value format, the tls-unique
change (Twisted peers on <= 26.10.1 and >= 26.10.2 no longer agree over
WebSocket when they use channel binding) and the PyPy workaround.

This is substantially new code (peer_certificate_details, tls_unique and
the asyncio extraction) written by AI on request; it needs human review.

Expected CI result: GREEN. Locally: Twisted trial cpy311 368 ok / pypy311
367 ok, asyncio pytest cpy311 334 passed / pypy311 333 passed, all crossbario#2014
tests pass on both; import smoke test 35 passed; ruff and ty 0.0.89 clean;
docs build ok, no new warnings.

Note: This work was completed with AI assistance (Claude Code).
@oberstet
oberstet merged commit c0aa8c1 into crossbario:master Oct 20, 2026
53 checks passed
@oberstet
oberstet deleted the fix_2014 branch October 20, 2026 21:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] extract_peer_certificate() raises AttributeError with pyOpenSSL >= 26.2.0 when the peer certificate has extensions

1 participant