pybrws 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pybrws-0.1.0/LICENSE +21 -0
- pybrws-0.1.0/PKG-INFO +86 -0
- pybrws-0.1.0/README.md +46 -0
- pybrws-0.1.0/pybrws/__init__.py +3 -0
- pybrws-0.1.0/pybrws/__main__.py +3 -0
- pybrws-0.1.0/pybrws/certs.py +275 -0
- pybrws-0.1.0/pybrws/cli.py +303 -0
- pybrws-0.1.0/pybrws/client.py +303 -0
- pybrws-0.1.0/pybrws/cookies.py +234 -0
- pybrws-0.1.0/pybrws/data/__init__.py +21 -0
- pybrws-0.1.0/pybrws/data/chrome-1.hex +61 -0
- pybrws-0.1.0/pybrws/data/reference-firefox-157-peet.json +47 -0
- pybrws-0.1.0/pybrws/data/reference-firefox-157-windows-fresh.hex +60 -0
- pybrws-0.1.0/pybrws/differ.py +138 -0
- pybrws-0.1.0/pybrws/emitter.py +366 -0
- pybrws-0.1.0/pybrws/h3.py +341 -0
- pybrws-0.1.0/pybrws/handshake.py +814 -0
- pybrws-0.1.0/pybrws/headers.py +171 -0
- pybrws-0.1.0/pybrws/hexio.py +58 -0
- pybrws-0.1.0/pybrws/hosts.py +49 -0
- pybrws-0.1.0/pybrws/http1.py +268 -0
- pybrws-0.1.0/pybrws/http2.py +761 -0
- pybrws-0.1.0/pybrws/keyexchange.py +107 -0
- pybrws-0.1.0/pybrws/keyschedule.py +185 -0
- pybrws-0.1.0/pybrws/message.py +348 -0
- pybrws-0.1.0/pybrws/model.py +125 -0
- pybrws-0.1.0/pybrws/parser.py +123 -0
- pybrws-0.1.0/pybrws/pool.py +159 -0
- pybrws-0.1.0/pybrws/profiles/__init__.py +166 -0
- pybrws-0.1.0/pybrws/profiles/chrome155.py +579 -0
- pybrws-0.1.0/pybrws/profiles/firefox157.py +318 -0
- pybrws-0.1.0/pybrws/profiles/firefox157_quic.py +311 -0
- pybrws-0.1.0/pybrws/proxies.py +277 -0
- pybrws-0.1.0/pybrws/proxy.py +407 -0
- pybrws-0.1.0/pybrws/quic.py +454 -0
- pybrws-0.1.0/pybrws/quic_impersonate.py +510 -0
- pybrws-0.1.0/pybrws/records.py +161 -0
- pybrws-0.1.0/pybrws/session.py +305 -0
- pybrws-0.1.0/pybrws/tickets.py +122 -0
- pybrws-0.1.0/pybrws/tls12.py +374 -0
- pybrws-0.1.0/pybrws/tools/__init__.py +7 -0
- pybrws-0.1.0/pybrws/tools/_selfsigned.py +93 -0
- pybrws-0.1.0/pybrws/tools/ab_test.py +496 -0
- pybrws-0.1.0/pybrws/tools/catch_h2.py +382 -0
- pybrws-0.1.0/pybrws/tools/catch_hello.py +218 -0
- pybrws-0.1.0/pybrws/tools/catch_quic.py +231 -0
- pybrws-0.1.0/pybrws/tools/catch_request.py +288 -0
- pybrws-0.1.0/pybrws/tools/catch_resumed.py +285 -0
- pybrws-0.1.0/pybrws/tools/detect_waf.py +165 -0
- pybrws-0.1.0/pybrws/tools/isolate.py +117 -0
- pybrws-0.1.0/pybrws/tools/ja4.py +205 -0
- pybrws-0.1.0/pybrws/tools/probe_fingerprint.py +199 -0
- pybrws-0.1.0/pybrws/tools/read_quic.py +458 -0
- pybrws-0.1.0/pybrws/tools/verify.py +664 -0
- pybrws-0.1.0/pybrws/wire.py +27 -0
- pybrws-0.1.0/pybrws.egg-info/PKG-INFO +86 -0
- pybrws-0.1.0/pybrws.egg-info/SOURCES.txt +114 -0
- pybrws-0.1.0/pybrws.egg-info/dependency_links.txt +1 -0
- pybrws-0.1.0/pybrws.egg-info/entry_points.txt +2 -0
- pybrws-0.1.0/pybrws.egg-info/requires.txt +27 -0
- pybrws-0.1.0/pybrws.egg-info/top_level.txt +1 -0
- pybrws-0.1.0/pyproject.toml +68 -0
- pybrws-0.1.0/setup.cfg +4 -0
- pybrws-0.1.0/tests/test_ab_tool.py +229 -0
- pybrws-0.1.0/tests/test_certs.py +185 -0
- pybrws-0.1.0/tests/test_chrome_profile.py +556 -0
- pybrws-0.1.0/tests/test_cli.py +243 -0
- pybrws-0.1.0/tests/test_cli_subcommands.py +101 -0
- pybrws-0.1.0/tests/test_cookie_captures.py +97 -0
- pybrws-0.1.0/tests/test_cookie_slot.py +123 -0
- pybrws-0.1.0/tests/test_cookies.py +174 -0
- pybrws-0.1.0/tests/test_cookies_redirects.py +470 -0
- pybrws-0.1.0/tests/test_decoding_limits.py +325 -0
- pybrws-0.1.0/tests/test_differ.py +35 -0
- pybrws-0.1.0/tests/test_docs_config.py +117 -0
- pybrws-0.1.0/tests/test_emitter.py +263 -0
- pybrws-0.1.0/tests/test_fidelity_fixes.py +60 -0
- pybrws-0.1.0/tests/test_h2_profiles.py +94 -0
- pybrws-0.1.0/tests/test_h3.py +178 -0
- pybrws-0.1.0/tests/test_headers.py +117 -0
- pybrws-0.1.0/tests/test_http1.py +239 -0
- pybrws-0.1.0/tests/test_http1_hostile.py +130 -0
- pybrws-0.1.0/tests/test_http2.py +446 -0
- pybrws-0.1.0/tests/test_http2_hostile.py +391 -0
- pybrws-0.1.0/tests/test_keyexchange.py +103 -0
- pybrws-0.1.0/tests/test_keyschedule.py +130 -0
- pybrws-0.1.0/tests/test_method_articles.py +117 -0
- pybrws-0.1.0/tests/test_packaged_data.py +39 -0
- pybrws-0.1.0/tests/test_packaging.py +157 -0
- pybrws-0.1.0/tests/test_parser.py +62 -0
- pybrws-0.1.0/tests/test_plumbing.py +47 -0
- pybrws-0.1.0/tests/test_pool.py +178 -0
- pybrws-0.1.0/tests/test_post_profiles.py +177 -0
- pybrws-0.1.0/tests/test_profile_loader.py +109 -0
- pybrws-0.1.0/tests/test_proxies.py +276 -0
- pybrws-0.1.0/tests/test_proxy.py +176 -0
- pybrws-0.1.0/tests/test_published_text.py +95 -0
- pybrws-0.1.0/tests/test_quic.py +336 -0
- pybrws-0.1.0/tests/test_quic_hostile.py +533 -0
- pybrws-0.1.0/tests/test_quic_impersonate.py +327 -0
- pybrws-0.1.0/tests/test_quic_profile.py +243 -0
- pybrws-0.1.0/tests/test_read_quic.py +331 -0
- pybrws-0.1.0/tests/test_records.py +142 -0
- pybrws-0.1.0/tests/test_reference_pages.py +90 -0
- pybrws-0.1.0/tests/test_resumed.py +166 -0
- pybrws-0.1.0/tests/test_retry.py +141 -0
- pybrws-0.1.0/tests/test_review_followups.py +458 -0
- pybrws-0.1.0/tests/test_server_auth.py +83 -0
- pybrws-0.1.0/tests/test_session.py +192 -0
- pybrws-0.1.0/tests/test_session_proxy_hostile.py +457 -0
- pybrws-0.1.0/tests/test_streaming.py +263 -0
- pybrws-0.1.0/tests/test_tickets.py +208 -0
- pybrws-0.1.0/tests/test_tls12.py +395 -0
- pybrws-0.1.0/tests/test_tls_hostile.py +614 -0
- pybrws-0.1.0/tests/test_tool_package.py +174 -0
- pybrws-0.1.0/tests/test_verify_capture.py +887 -0
pybrws-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ernesto Pérez Costa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pybrws-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pybrws
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An HTTPS client that sends what Firefox 157 and Chrome 155 send over TLS and HTTP/2, and what Firefox 157 sends over HTTP/3
|
|
5
|
+
Author: Ernesto
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/epcosta17/pybrws
|
|
8
|
+
Project-URL: Documentation, https://pybrws.readthedocs.io/
|
|
9
|
+
Project-URL: Source, https://github.com/epcosta17/pybrws
|
|
10
|
+
Project-URL: Issues, https://github.com/epcosta17/pybrws/issues
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: cryptography>=50
|
|
21
|
+
Requires-Dist: hpack
|
|
22
|
+
Requires-Dist: brotli
|
|
23
|
+
Requires-Dist: publicsuffixlist
|
|
24
|
+
Requires-Dist: idna
|
|
25
|
+
Requires-Dist: certifi
|
|
26
|
+
Requires-Dist: zstandard; python_version < "3.14"
|
|
27
|
+
Provides-Extra: client
|
|
28
|
+
Provides-Extra: zstd
|
|
29
|
+
Provides-Extra: http3
|
|
30
|
+
Requires-Dist: aioquic<2,>=1.3; extra == "http3"
|
|
31
|
+
Provides-Extra: measure
|
|
32
|
+
Requires-Dist: httpx[http2]<1,>=0.28; extra == "measure"
|
|
33
|
+
Provides-Extra: docs
|
|
34
|
+
Requires-Dist: zensical<0.2,>=0.1.1; extra == "docs"
|
|
35
|
+
Requires-Dist: mkdocstrings-python; extra == "docs"
|
|
36
|
+
Provides-Extra: dev
|
|
37
|
+
Requires-Dist: aioquic<2,>=1.3; extra == "dev"
|
|
38
|
+
Requires-Dist: httpx[http2]<1,>=0.28; extra == "dev"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# pybrws
|
|
42
|
+
|
|
43
|
+
Python + browser: an HTTPS client that sends what a real browser sends
|
|
44
|
+
|
|
45
|
+
pybrws is written from scratch in Python. It sends what Firefox 157 and Chrome 155 send on the wire: the TLS ClientHello, the HTTP/2 start and the headers. It also sends Firefox's QUIC hello over HTTP/3. It is a learning and measurement tool that you can install, and it began as a way to see how browsers look on the wire.
|
|
46
|
+
|
|
47
|
+
It is not a replacement for [curl_cffi](https://pypi.org/project/curl-cffi/). For production use, use curl_cffi. pybrws makes no promise that any site will accept its requests.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
pip install pybrws
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Python 3.11 or newer. Two optional extras:
|
|
56
|
+
|
|
57
|
+
- `pip install "pybrws[http3]"` adds aioquic, for HTTP/3.
|
|
58
|
+
- `pip install "pybrws[measure]"` adds httpx, for the A/B controls of `pybrws ab`.
|
|
59
|
+
|
|
60
|
+
## Use it from Python
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from pybrws.client import get
|
|
64
|
+
from pybrws.profiles import chrome155
|
|
65
|
+
|
|
66
|
+
response = get("https://example.com/", profile=chrome155)
|
|
67
|
+
print(response.status, response.header("content-type"))
|
|
68
|
+
print(response.text()[:200])
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The default profile is `firefox157`. `post` and `request` work the same way. HTTP/3 is `pybrws.h3.get(url)`, Firefox only.
|
|
72
|
+
|
|
73
|
+
## Use it from the command line
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
pybrws https://example.com/
|
|
77
|
+
pybrws verify --local
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The first fetches a page like curl. The second compares the default profile's ClientHello with the browser capture shipped in the package, offline, and prints "same string as the capture". Other subcommands: `capture`, `read-quic`, `ja4`, `verify`, `probe`, `ab`, `isolate`, `waf`.
|
|
81
|
+
|
|
82
|
+
## More
|
|
83
|
+
|
|
84
|
+
- Documentation: https://pybrws.readthedocs.io/
|
|
85
|
+
- Source: https://github.com/epcosta17/pybrws
|
|
86
|
+
- Licence: [MIT](https://github.com/epcosta17/pybrws/blob/master/LICENSE)
|
pybrws-0.1.0/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# pybrws
|
|
2
|
+
|
|
3
|
+
Python + browser: an HTTPS client that sends what a real browser sends
|
|
4
|
+
|
|
5
|
+
pybrws is written from scratch in Python. It sends what Firefox 157 and Chrome 155 send on the wire: the TLS ClientHello, the HTTP/2 start and the headers. It also sends Firefox's QUIC hello over HTTP/3. It is a learning and measurement tool that you can install, and it began as a way to see how browsers look on the wire.
|
|
6
|
+
|
|
7
|
+
It is not a replacement for [curl_cffi](https://pypi.org/project/curl-cffi/). For production use, use curl_cffi. pybrws makes no promise that any site will accept its requests.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pip install pybrws
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Python 3.11 or newer. Two optional extras:
|
|
16
|
+
|
|
17
|
+
- `pip install "pybrws[http3]"` adds aioquic, for HTTP/3.
|
|
18
|
+
- `pip install "pybrws[measure]"` adds httpx, for the A/B controls of `pybrws ab`.
|
|
19
|
+
|
|
20
|
+
## Use it from Python
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from pybrws.client import get
|
|
24
|
+
from pybrws.profiles import chrome155
|
|
25
|
+
|
|
26
|
+
response = get("https://example.com/", profile=chrome155)
|
|
27
|
+
print(response.status, response.header("content-type"))
|
|
28
|
+
print(response.text()[:200])
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The default profile is `firefox157`. `post` and `request` work the same way. HTTP/3 is `pybrws.h3.get(url)`, Firefox only.
|
|
32
|
+
|
|
33
|
+
## Use it from the command line
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
pybrws https://example.com/
|
|
37
|
+
pybrws verify --local
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The first fetches a page like curl. The second compares the default profile's ClientHello with the browser capture shipped in the package, offline, and prints "same string as the capture". Other subcommands: `capture`, `read-quic`, `ja4`, `verify`, `probe`, `ab`, `isolate`, `waf`.
|
|
41
|
+
|
|
42
|
+
## More
|
|
43
|
+
|
|
44
|
+
- Documentation: https://pybrws.readthedocs.io/
|
|
45
|
+
- Source: https://github.com/epcosta17/pybrws
|
|
46
|
+
- Licence: [MIT](https://github.com/epcosta17/pybrws/blob/master/LICENSE)
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Certificate handling: parsing, decompression, chain and signature checks.
|
|
2
|
+
|
|
3
|
+
Path validation is delegated to `cryptography.x509.verification`. Hand-rolling
|
|
4
|
+
it would mean reimplementing name constraints, policy handling and revocation
|
|
5
|
+
semantics, which is the kind of code that is wrong in ways nobody notices
|
|
6
|
+
until it matters.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ipaddress
|
|
12
|
+
import ssl
|
|
13
|
+
import warnings
|
|
14
|
+
|
|
15
|
+
from cryptography import x509
|
|
16
|
+
from cryptography.exceptions import InvalidSignature
|
|
17
|
+
from cryptography.hazmat.primitives import hashes
|
|
18
|
+
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa
|
|
19
|
+
from cryptography.x509.verification import PolicyBuilder, Store
|
|
20
|
+
|
|
21
|
+
from . import hosts
|
|
22
|
+
from .parser import _Reader
|
|
23
|
+
|
|
24
|
+
# RFC 8879
|
|
25
|
+
COMPRESSION_ZLIB = 1
|
|
26
|
+
COMPRESSION_BROTLI = 2
|
|
27
|
+
COMPRESSION_ZSTD = 3
|
|
28
|
+
|
|
29
|
+
# The string a TLS 1.3 server signs, RFC 8446 section 4.4.3.
|
|
30
|
+
SIGNATURE_CONTEXT = b" " * 64 + b"TLS 1.3, server CertificateVerify" + b"\x00"
|
|
31
|
+
|
|
32
|
+
RSA_PSS = {
|
|
33
|
+
0x0804: hashes.SHA256,
|
|
34
|
+
0x0805: hashes.SHA384,
|
|
35
|
+
0x0806: hashes.SHA512,
|
|
36
|
+
}
|
|
37
|
+
ECDSA = {
|
|
38
|
+
0x0403: hashes.SHA256,
|
|
39
|
+
0x0503: hashes.SHA384,
|
|
40
|
+
0x0603: hashes.SHA512,
|
|
41
|
+
}
|
|
42
|
+
RSA_PKCS1 = {
|
|
43
|
+
0x0401: hashes.SHA256,
|
|
44
|
+
0x0501: hashes.SHA384,
|
|
45
|
+
0x0601: hashes.SHA512,
|
|
46
|
+
}
|
|
47
|
+
ED25519 = 0x0807
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CertificateError(Exception):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def decompress_certificate(message: bytes) -> bytes:
|
|
55
|
+
"""Turn an RFC 8879 CompressedCertificate body into a Certificate body.
|
|
56
|
+
|
|
57
|
+
The output is bounded by the declared length *while* decompressing.
|
|
58
|
+
Checking it afterwards would be too late: this message arrives before the
|
|
59
|
+
server has proved anything, and a kilobyte of brotli can expand to a
|
|
60
|
+
gigabyte. RFC 8879 section 4 asks for exactly this bound. The declared
|
|
61
|
+
length is a uint24, so its own ceiling of 2**24 - 1 holds by construction.
|
|
62
|
+
"""
|
|
63
|
+
reader = _Reader(message, "CompressedCertificate")
|
|
64
|
+
algorithm = reader.uint(2)
|
|
65
|
+
expected = reader.uint(3)
|
|
66
|
+
compressed = reader.vector(3)
|
|
67
|
+
# One byte past the declared length is enough to know it was a lie.
|
|
68
|
+
limit = expected + 1
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
if algorithm == COMPRESSION_ZLIB:
|
|
72
|
+
import zlib
|
|
73
|
+
|
|
74
|
+
plain = zlib.decompressobj().decompress(compressed, limit)
|
|
75
|
+
elif algorithm == COMPRESSION_BROTLI:
|
|
76
|
+
import brotli
|
|
77
|
+
|
|
78
|
+
# Bounded to within one internal block of the limit, which is
|
|
79
|
+
# enough: the length check below rejects anything past it.
|
|
80
|
+
plain = brotli.Decompressor().process(
|
|
81
|
+
compressed, output_buffer_limit=limit)
|
|
82
|
+
elif algorithm == COMPRESSION_ZSTD:
|
|
83
|
+
try:
|
|
84
|
+
from compression import zstd
|
|
85
|
+
except ImportError:
|
|
86
|
+
import io
|
|
87
|
+
|
|
88
|
+
import zstandard
|
|
89
|
+
|
|
90
|
+
with zstandard.ZstdDecompressor().stream_reader(
|
|
91
|
+
io.BytesIO(compressed)) as source:
|
|
92
|
+
plain = source.read(limit)
|
|
93
|
+
else:
|
|
94
|
+
plain = zstd.ZstdDecompressor().decompress(
|
|
95
|
+
compressed, max_length=limit)
|
|
96
|
+
else:
|
|
97
|
+
raise CertificateError(
|
|
98
|
+
"unknown certificate compression " + str(algorithm))
|
|
99
|
+
except CertificateError:
|
|
100
|
+
raise
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
raise CertificateError(
|
|
103
|
+
"certificate decompression failed: " + str(exc)) from exc
|
|
104
|
+
|
|
105
|
+
if len(plain) != expected:
|
|
106
|
+
raise CertificateError(
|
|
107
|
+
"decompressed to %d bytes, the server said %d" % (len(plain), expected)
|
|
108
|
+
)
|
|
109
|
+
return plain
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_certificate(body: bytes) -> list[bytes]:
|
|
113
|
+
"""Return the DER certificates in a Certificate message body, leaf first."""
|
|
114
|
+
reader = _Reader(body, "Certificate")
|
|
115
|
+
reader.vector(1) # certificate_request_context, empty for a server
|
|
116
|
+
entries = _Reader(reader.vector(3), "certificate_list")
|
|
117
|
+
chain = []
|
|
118
|
+
while entries.remaining:
|
|
119
|
+
chain.append(entries.vector(3))
|
|
120
|
+
entries.vector(2) # per-certificate extensions
|
|
121
|
+
if not chain:
|
|
122
|
+
raise CertificateError("the server sent an empty certificate list")
|
|
123
|
+
return chain
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse_anchors(ders) -> list:
|
|
127
|
+
anchors = []
|
|
128
|
+
for der in ders:
|
|
129
|
+
try:
|
|
130
|
+
anchors.append(x509.load_der_x509_certificate(der))
|
|
131
|
+
except Exception:
|
|
132
|
+
# Bundles carry the occasional malformed anchor. One
|
|
133
|
+
# unparseable certificate should not cost the other few hundred.
|
|
134
|
+
continue
|
|
135
|
+
return anchors
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _platform_roots() -> list[bytes]:
|
|
139
|
+
context = ssl.create_default_context()
|
|
140
|
+
return list(context.get_ca_certs(binary_form=True))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _mozilla_roots() -> list[bytes]:
|
|
144
|
+
"""The DER of each certificate in the certifi bundle, which is Mozilla's
|
|
145
|
+
root program. A bundle that cannot be read counts as having none."""
|
|
146
|
+
import certifi
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
with open(certifi.where(), encoding="ascii") as bundle:
|
|
150
|
+
text = bundle.read()
|
|
151
|
+
except (OSError, UnicodeDecodeError):
|
|
152
|
+
return []
|
|
153
|
+
begin, end = "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----"
|
|
154
|
+
ders = []
|
|
155
|
+
for block in text.split(end)[:-1]:
|
|
156
|
+
# The bundle puts a comment above each certificate.
|
|
157
|
+
_, found, body = block.partition(begin)
|
|
158
|
+
if not found:
|
|
159
|
+
continue
|
|
160
|
+
try:
|
|
161
|
+
ders.append(ssl.PEM_cert_to_DER_cert(begin + body + end + "\n"))
|
|
162
|
+
except ValueError:
|
|
163
|
+
continue
|
|
164
|
+
return ders
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def system_store() -> Store:
|
|
168
|
+
"""The platform's trust anchors alone, as Python's ssl module sees them.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
CertificateError: if the platform has none.
|
|
172
|
+
"""
|
|
173
|
+
warnings.filterwarnings("ignore", message="Parsed a serial number")
|
|
174
|
+
anchors = _parse_anchors(_platform_roots())
|
|
175
|
+
if not anchors:
|
|
176
|
+
raise CertificateError("no trust anchors available from the system store")
|
|
177
|
+
return Store(anchors)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def default_store() -> Store:
|
|
181
|
+
"""The roots a connection is verified against by default: Mozilla's
|
|
182
|
+
(the certifi bundle) plus whatever the platform adds, an enterprise CA
|
|
183
|
+
for example. That is close to Firefox's mix: Firefox adds only the roots
|
|
184
|
+
an administrator or user installed on the system, not the platform's
|
|
185
|
+
whole root program.
|
|
186
|
+
|
|
187
|
+
A certificate that is in both counts once. A source that cannot be read
|
|
188
|
+
counts as having none.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
CertificateError: if both sources yield no anchors.
|
|
192
|
+
"""
|
|
193
|
+
warnings.filterwarnings("ignore", message="Parsed a serial number")
|
|
194
|
+
seen = set()
|
|
195
|
+
unique = []
|
|
196
|
+
for der in _mozilla_roots() + _platform_roots():
|
|
197
|
+
if der not in seen:
|
|
198
|
+
seen.add(der)
|
|
199
|
+
unique.append(der)
|
|
200
|
+
anchors = _parse_anchors(unique)
|
|
201
|
+
if not anchors:
|
|
202
|
+
raise CertificateError("no trust anchors available from certifi or the system store")
|
|
203
|
+
return Store(anchors)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def verify_chain(chain: list[bytes], hostname: str, store: Store | None = None):
|
|
207
|
+
"""Validate the chain for `hostname`, returning the leaf certificate.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
CertificateError: if the chain does not validate.
|
|
211
|
+
"""
|
|
212
|
+
certificates = [x509.load_der_x509_certificate(der) for der in chain]
|
|
213
|
+
leaf, intermediates = certificates[0], certificates[1:]
|
|
214
|
+
if hosts.is_ip_literal(hostname):
|
|
215
|
+
# An address is matched against an IP address SAN, never a DNS one.
|
|
216
|
+
subject = x509.IPAddress(ipaddress.ip_address(hostname.strip("[]")))
|
|
217
|
+
else:
|
|
218
|
+
subject = x509.DNSName(hostname)
|
|
219
|
+
verifier = (
|
|
220
|
+
PolicyBuilder()
|
|
221
|
+
.store(store if store is not None else default_store())
|
|
222
|
+
.build_server_verifier(subject)
|
|
223
|
+
)
|
|
224
|
+
try:
|
|
225
|
+
verifier.verify(leaf, intermediates)
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
raise CertificateError("certificate chain rejected: " + str(exc)) from exc
|
|
228
|
+
return leaf
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def verify_signature(leaf, scheme: int, signature: bytes, transcript_hash: bytes) -> None:
|
|
232
|
+
"""Check the CertificateVerify signature over the handshake transcript.
|
|
233
|
+
|
|
234
|
+
This is what proves the peer holds the key in the certificate, rather
|
|
235
|
+
than merely having a copy of someone else's chain.
|
|
236
|
+
"""
|
|
237
|
+
signed = SIGNATURE_CONTEXT + transcript_hash
|
|
238
|
+
key = leaf.public_key()
|
|
239
|
+
try:
|
|
240
|
+
if scheme in RSA_PSS:
|
|
241
|
+
algorithm = RSA_PSS[scheme]()
|
|
242
|
+
key.verify(signature, signed,
|
|
243
|
+
padding.PSS(mgf=padding.MGF1(algorithm),
|
|
244
|
+
salt_length=algorithm.digest_size),
|
|
245
|
+
algorithm)
|
|
246
|
+
elif scheme in ECDSA:
|
|
247
|
+
key.verify(signature, signed, ec.ECDSA(ECDSA[scheme]()))
|
|
248
|
+
elif scheme == ED25519:
|
|
249
|
+
key.verify(signature, signed)
|
|
250
|
+
elif scheme in RSA_PKCS1:
|
|
251
|
+
# Not permitted for TLS 1.3 CertificateVerify, but accepting it
|
|
252
|
+
# silently would hide a server doing the wrong thing.
|
|
253
|
+
raise CertificateError(
|
|
254
|
+
"server signed with rsa_pkcs1, which TLS 1.3 forbids here"
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
raise CertificateError("unsupported signature scheme 0x%04x" % scheme)
|
|
258
|
+
except InvalidSignature as exc:
|
|
259
|
+
raise CertificateError("CertificateVerify signature is invalid") from exc
|
|
260
|
+
except CertificateError:
|
|
261
|
+
raise
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
raise CertificateError("CertificateVerify could not be checked: "
|
|
264
|
+
+ str(exc)) from exc
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def expected_key_type(leaf) -> str:
|
|
268
|
+
key = leaf.public_key()
|
|
269
|
+
if isinstance(key, rsa.RSAPublicKey):
|
|
270
|
+
return "rsa"
|
|
271
|
+
if isinstance(key, ec.EllipticCurvePublicKey):
|
|
272
|
+
return "ecdsa"
|
|
273
|
+
if isinstance(key, ed25519.Ed25519PublicKey):
|
|
274
|
+
return "ed25519"
|
|
275
|
+
return type(key).__name__
|