sslcontext 2.0.0__tar.gz → 2.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.
- {sslcontext-2.0.0 → sslcontext-2.1.0}/CHANGELOG.md +12 -1
- {sslcontext-2.0.0 → sslcontext-2.1.0}/PKG-INFO +1 -1
- {sslcontext-2.0.0 → sslcontext-2.1.0}/pyproject.toml +1 -1
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/AGENTS.md +20 -10
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_asn1.py +14 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_load.py +20 -9
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_api.py +1 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_objects.py +5 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_pin.py +30 -5
- sslcontext-2.1.0/tests/data/client-ec.der +0 -0
- sslcontext-2.1.0/tests/data/client-ec.enc.der +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_sources.py +120 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/.gitignore +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/LICENSE +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/README.md +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/docs/api/reference.md +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/docs/changelog.md +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/docs/header.md +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/docs/index.md +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/mkdocs.yml +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/__init__.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/__main__.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_context.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_identity.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/__init__.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_certcb.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_ctx.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_engine.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_errors.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_keys.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_loader.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_params.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_provider.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_signing.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_policy.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_select.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_sources.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_system.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/backends/__init__.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/backends/_cng.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/backends/_cryptoki.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/backends/_keychain.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/backends/_macos.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/backends/_pkcs11.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/cli/__init__.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/cli/_common.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/cli/_keystore.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/cli/_probe.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/cli/_root.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/exceptions.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/issuing.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/py.typed +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/testing.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/conftest.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/ca.pem +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/client-ec.enc.key +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/client-ec.key +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/client-ec.pem +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/client-rsa.key +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/client-rsa.p12 +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/client-rsa.pem +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/server.key +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/server.pem +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/integration/start_virtual_card.sh +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/integration/test_cng.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/integration/test_keychain.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/integration/test_pkcs11.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_backends.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_cli.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_context.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_issuing.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_load.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_package.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_pin.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_select.py +0 -0
- {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_signer.py +0 -0
|
@@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
9
9
|
|
|
10
10
|
Nothing yet.
|
|
11
11
|
|
|
12
|
+
## [2.1.0] - 2026-10-19
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- A PKCS#12 bundle (`.p12`/`.pfx`) is accepted wherever a certificate is
|
|
17
|
+
(`certificate=`, `load_pem()`), decrypted with `password=`; encrypted DER
|
|
18
|
+
(PKCS#8) private keys are decrypted with `password=` as PEM ones are.
|
|
19
|
+
- A password may be `bytes`, or a file holding it (a path object, or anything
|
|
20
|
+
with `open()` or `read()`; its first line), read only when needed.
|
|
21
|
+
|
|
12
22
|
## [2.0.0] - 2026-10-19
|
|
13
23
|
|
|
14
24
|
### Added
|
|
@@ -82,5 +92,6 @@ Nothing yet.
|
|
|
82
92
|
`openssl-engine` and `openssl-engine-capi` are superseded: OpenSSL 4 removed
|
|
83
93
|
the ENGINE API they were built on.
|
|
84
94
|
|
|
85
|
-
[Unreleased]: https://github.com/jose-pr/sslcontext/compare/v2.
|
|
95
|
+
[Unreleased]: https://github.com/jose-pr/sslcontext/compare/v2.1.0...HEAD
|
|
96
|
+
[2.1.0]: https://github.com/jose-pr/sslcontext/compare/v2.0.0...v2.1.0
|
|
86
97
|
[2.0.0]: https://github.com/jose-pr/sslcontext/releases/tag/v2.0.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sslcontext
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Client certificates from OS keystores (Windows CNG, macOS Keychain, PKCS#11 smart cards) in a standard ssl.SSLContext, chosen as the browser would
|
|
5
5
|
Project-URL: Homepage, https://github.com/jose-pr/sslcontext/
|
|
6
6
|
Project-URL: Documentation, https://jose-pr.github.io/sslcontext/
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sslcontext"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.1.0"
|
|
8
8
|
authors = [{ name = "Jose A." }]
|
|
9
9
|
description = "Client certificates from OS keystores (Windows CNG, macOS Keychain, PKCS#11 smart cards) in a standard ssl.SSLContext, chosen as the browser would"
|
|
10
10
|
readme = "README.md"
|
|
@@ -216,10 +216,17 @@ def load_pem(
|
|
|
216
216
|
|
|
217
217
|
Makes `certificate` (+ chain) and `key` the context's identity; nothing is
|
|
218
218
|
written to disk. `certificate` is the leaf then its chain: PEM holding them
|
|
219
|
-
in order, one DER certificate, or a list of sources in that order
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
219
|
+
in order, one DER certificate, or a list of sources in that order; or one
|
|
220
|
+
PKCS#12 bundle (`.p12`/`.pfx`, recognised by its DER shape, whatever the file
|
|
221
|
+
is called), loaded as `load_pkcs12` loads it, with `password` and without
|
|
222
|
+
`key` (a `key`, or a bundle in a list, is a `ValueError`). `key` is a PEM or
|
|
223
|
+
DER private key (DER: PKCS#8, encrypted or not, or the traditional RSA/EC
|
|
224
|
+
form) or a `cryptography` key; omitted, it is read from the first
|
|
225
|
+
certificate source (a combined PEM). `password` decrypts an encrypted key or
|
|
226
|
+
bundle; see [PINs and passwords](#pins-and-passwords-sslcontext).
|
|
227
|
+
`create_context(certificate=..., password=...)` and `system_context` take the
|
|
228
|
+
same, so `create_context(certificate="client.p12", password="...")` is an
|
|
229
|
+
mTLS context.
|
|
223
230
|
|
|
224
231
|
```python
|
|
225
232
|
def load_pkcs12(
|
|
@@ -330,7 +337,7 @@ class PinRequest:
|
|
|
330
337
|
|
|
331
338
|
PinCallback = Callable[[PinRequest], str | Prompt | None]
|
|
332
339
|
PinSource = str | Prompt | PinCallback | None
|
|
333
|
-
PasswordSource = str | bytes | Callable[[], str | bytes | None] | None
|
|
340
|
+
PasswordSource = str | bytes | os.PathLike | <has open()> | <has read()> | Callable[[], str | bytes | None] | None
|
|
334
341
|
```
|
|
335
342
|
|
|
336
343
|
A backend's `pin=` takes a `PinSource`: the PIN; `KEYSTORE_PROMPT`; a
|
|
@@ -346,10 +353,13 @@ handshake (or the listing) and propagates from `identities()`. A callback
|
|
|
346
353
|
returning anything else raises `TypeError` (the message never includes the
|
|
347
354
|
value). `PinRequest` never holds a PIN.
|
|
348
355
|
|
|
349
|
-
Loaders take a `PasswordSource` for encrypted keys
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
356
|
+
Loaders take a `PasswordSource` for encrypted keys and PKCS#12 bundles: the
|
|
357
|
+
password as `str` (UTF-8) or `bytes` (a `str` is always the password itself,
|
|
358
|
+
never a path); a file holding it (`os.PathLike`, or an object with `open()`
|
|
359
|
+
or `read()`: its first line, without the line ending, as OpenSSL's `-passin
|
|
360
|
+
file:` reads it); or a callable. A file or callable is consulted only if the
|
|
361
|
+
key turns out to be encrypted (a callable returning `None` fails with
|
|
362
|
+
`CredentialError`). OpenSSL's terminal passphrase prompt is never used.
|
|
353
363
|
|
|
354
364
|
```python
|
|
355
365
|
def ask(request: sslcontext.PinRequest):
|
|
@@ -591,7 +601,7 @@ All in `sslcontext.exceptions`, re-exported from the root.
|
|
|
591
601
|
| Class | Bases | Raised when |
|
|
592
602
|
| --- | --- | --- |
|
|
593
603
|
| `SSLContextError` | `Exception` | base of everything below |
|
|
594
|
-
| `UnsupportedOpenSSLError` | `SSLContextError` | the interpreter's
|
|
604
|
+
| `UnsupportedOpenSSLError` | `SSLContextError` | the interpreter's TLS library cannot be driven: OpenSSL < 1.1.1, a static `_ssl` that hides its symbols, or an unrecognised `SSLContext` or LibreSSL structure layout |
|
|
595
605
|
| `CredentialError` | `SSLContextError`, `ValueError` | malformed certificate/key, wrong password, key/certificate mismatch |
|
|
596
606
|
| `IdentityNotFoundError` | `SSLContextError`, `LookupError` | no keystore entry matched a query |
|
|
597
607
|
| `KeystoreError` | `SSLContextError`, `OSError` | the keystore refused or failed an operation |
|
|
@@ -9,6 +9,7 @@ __all__ = [
|
|
|
9
9
|
"DIGEST_INFO_PREFIX",
|
|
10
10
|
"certificate_usages",
|
|
11
11
|
"ecdsa_der",
|
|
12
|
+
"is_pkcs12",
|
|
12
13
|
]
|
|
13
14
|
|
|
14
15
|
# RFC 8017 section 9.2, note 1: DER of DigestInfo up to the digest bytes.
|
|
@@ -102,3 +103,16 @@ def certificate_usages(der: bytes) -> tuple:
|
|
|
102
103
|
octets = der[bits_start + 1 : bits_end] # after the unused-bits count
|
|
103
104
|
usage = sum(octet << (8 * i) for i, octet in enumerate(octets[:2]))
|
|
104
105
|
return extended, usage
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_pkcs12(data: bytes) -> bool:
|
|
109
|
+
"""Whether DER ``data`` is a PKCS#12 bundle: ``SEQUENCE { INTEGER 3, ... }``
|
|
110
|
+
(a certificate's SEQUENCE starts with another SEQUENCE)."""
|
|
111
|
+
try:
|
|
112
|
+
tag, start, _ = _element(data, 0)
|
|
113
|
+
if tag != 0x30:
|
|
114
|
+
return False
|
|
115
|
+
inner, inner_start, inner_end = _element(data, start)
|
|
116
|
+
except (IndexError, ValueError):
|
|
117
|
+
return False
|
|
118
|
+
return inner == 0x02 and data[inner_start:inner_end] == b"\x03"
|
|
@@ -12,7 +12,8 @@ from ._openssl import _keys
|
|
|
12
12
|
from ._openssl._errors import drain_errors
|
|
13
13
|
from ._openssl._objects import read_certificates, read_pkcs12, read_private_key
|
|
14
14
|
from ._openssl._signing import make_private_key
|
|
15
|
-
from .
|
|
15
|
+
from ._asn1 import is_pkcs12
|
|
16
|
+
from ._pin import PasswordSource, deferred, resolve_password
|
|
16
17
|
from ._sources import CertificatesLike, KeyLike, read_source, read_sources
|
|
17
18
|
from .exceptions import CredentialError
|
|
18
19
|
|
|
@@ -38,7 +39,7 @@ def read_key_with(lib: OpenSSL, data: bytes, password: PasswordSource) -> int:
|
|
|
38
39
|
A callable is not called for an unencrypted key, so it never prompts
|
|
39
40
|
needlessly; when it gives nothing, an encrypted key fails at once.
|
|
40
41
|
"""
|
|
41
|
-
if not
|
|
42
|
+
if not deferred(password):
|
|
42
43
|
return read_private_key(lib, data, resolve_password(password))
|
|
43
44
|
try:
|
|
44
45
|
return read_private_key(lib, data, None)
|
|
@@ -108,18 +109,28 @@ def load_pem(
|
|
|
108
109
|
holding them in order, one DER certificate, or a list of sources (each
|
|
109
110
|
PEM or DER) in that order, in any form :data:`CertificatesLike` names
|
|
110
111
|
(text, bytes, paths, objects with ``open()``, ``cryptography``
|
|
111
|
-
certificates).
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
certificates). It may instead be one PKCS#12 bundle (``.p12``/``.pfx``),
|
|
113
|
+
loaded as :func:`load_pkcs12` loads it, with no ``key``. ``key`` is a
|
|
114
|
+
PEM or DER private key (DER: PKCS#8, encrypted or not, or traditional),
|
|
115
|
+
likewise, or a ``cryptography`` private key; when omitted it is read from
|
|
116
|
+
the first certificate source, which then holds both. ``password`` (a
|
|
117
|
+
value, or a callable asked only if the key or bundle turns out
|
|
118
|
+
encrypted) decrypts it. Nothing is written to disk.
|
|
116
119
|
|
|
117
120
|
Raises :class:`CredentialError` for malformed data, a wrong password, or a
|
|
118
121
|
key that does not match the certificate; ``OSError`` when a named file
|
|
119
122
|
cannot be read.
|
|
120
123
|
"""
|
|
121
|
-
lib = openssl()
|
|
122
124
|
blobs = read_sources(certificate, "certificate")
|
|
125
|
+
if any(is_pkcs12(blob) for blob in blobs):
|
|
126
|
+
if len(blobs) != 1 or key is not None:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
"a PKCS#12 bundle holds its own key and chain: pass it alone, "
|
|
129
|
+
"without key"
|
|
130
|
+
)
|
|
131
|
+
load_pkcs12(context, blobs[0], password)
|
|
132
|
+
return
|
|
133
|
+
lib = openssl()
|
|
123
134
|
key_data = blobs[0] if key is None else read_source(key, "key")
|
|
124
135
|
certificates: list = []
|
|
125
136
|
try:
|
|
@@ -148,7 +159,7 @@ def load_pkcs12(
|
|
|
148
159
|
"""
|
|
149
160
|
data = read_source(data, "data")
|
|
150
161
|
lib = openssl()
|
|
151
|
-
if
|
|
162
|
+
if deferred(password):
|
|
152
163
|
try:
|
|
153
164
|
key, certificate, chain = read_pkcs12(lib, bytes(data), None)
|
|
154
165
|
except CredentialError:
|
|
@@ -44,6 +44,7 @@ _SIGNATURES: dict[str, tuple[str, Any, tuple[Any, ...]]] = {
|
|
|
44
44
|
"PEM_read_bio_PrivateKey": ("crypto", _P, (_P, _P, _P, c_char_p)),
|
|
45
45
|
"d2i_X509_bio": ("crypto", _P, (_P, _P)),
|
|
46
46
|
"d2i_PrivateKey_bio": ("crypto", _P, (_P, _P)),
|
|
47
|
+
"d2i_PKCS8PrivateKey_bio": ("crypto", _P, (_P, _P, _P, c_char_p)),
|
|
47
48
|
"d2i_PKCS12_bio": ("crypto", _P, (_P, _P)),
|
|
48
49
|
"i2d_X509": ("crypto", c_int, (_P, _PP)),
|
|
49
50
|
"CRYPTO_free": ("crypto", None, (_P, c_char_p, c_int)),
|
|
@@ -75,6 +75,11 @@ def read_private_key(lib: OpenSSL, data: bytes, password: bytes | None) -> int:
|
|
|
75
75
|
key = lib.PEM_read_bio_PrivateKey(bio, None, None, password or b"")
|
|
76
76
|
else:
|
|
77
77
|
key = lib.d2i_PrivateKey_bio(bio, None)
|
|
78
|
+
if not key and password is not None and _PEM_ANY not in data:
|
|
79
|
+
# DER has no header saying it is encrypted: try encrypted PKCS#8.
|
|
80
|
+
lib.ERR_clear_error()
|
|
81
|
+
with _MemoryBIO(lib, data) as bio:
|
|
82
|
+
key = lib.d2i_PKCS8PrivateKey_bio(bio, None, None, password)
|
|
78
83
|
if not key:
|
|
79
84
|
raise CredentialError(f"cannot read the private key: {drain_errors(lib)}")
|
|
80
85
|
return key
|
|
@@ -9,8 +9,9 @@ dialog, a PIN pad) is used only when the caller returns or passes
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import enum
|
|
12
|
+
import os
|
|
12
13
|
from dataclasses import dataclass
|
|
13
|
-
from typing import Callable, Optional, Union
|
|
14
|
+
from typing import Any, Callable, Optional, Union
|
|
14
15
|
|
|
15
16
|
__all__ = [
|
|
16
17
|
"KEYSTORE_PROMPT",
|
|
@@ -63,8 +64,12 @@ PinSource = Union[str, Prompt, PinCallback, None]
|
|
|
63
64
|
"""A PIN, :data:`KEYSTORE_PROMPT`, a :data:`PinCallback`, or ``None`` (no PIN:
|
|
64
65
|
fail instead of asking anyone)."""
|
|
65
66
|
|
|
66
|
-
PasswordSource = Union[
|
|
67
|
-
""
|
|
67
|
+
PasswordSource = Union[
|
|
68
|
+
str, bytes, "os.PathLike[str]", Any, Callable[[], Union[str, bytes, None]], None
|
|
69
|
+
]
|
|
70
|
+
"""A password (``str`` or ``bytes``), a file holding it (``os.PathLike``, or
|
|
71
|
+
an object with ``open()`` or ``read()``: its first line, without the line
|
|
72
|
+
ending), a callable returning one (or ``None`` to decline), or ``None``."""
|
|
68
73
|
|
|
69
74
|
|
|
70
75
|
def resolve_pin(source: PinSource, request: PinRequest) -> Union[str, Prompt, None]:
|
|
@@ -90,12 +95,32 @@ def resolve_pin(source: PinSource, request: PinRequest) -> Union[str, Prompt, No
|
|
|
90
95
|
return answer
|
|
91
96
|
|
|
92
97
|
|
|
98
|
+
def _is_file(source: Any) -> bool:
|
|
99
|
+
return isinstance(source, os.PathLike) or any(
|
|
100
|
+
callable(getattr(source, name, None)) for name in ("open", "read")
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def deferred(source: PasswordSource) -> bool:
|
|
105
|
+
"""Whether ``source`` is consulted only when a password turns out needed."""
|
|
106
|
+
return callable(source) or _is_file(source)
|
|
107
|
+
|
|
108
|
+
|
|
93
109
|
def resolve_password(source: PasswordSource) -> Optional[bytes]:
|
|
94
110
|
"""The password ``source`` gives, as bytes; ``None`` means none."""
|
|
95
|
-
if callable(source):
|
|
111
|
+
if callable(source) and not _is_file(source):
|
|
96
112
|
source = source()
|
|
97
113
|
if source is None or isinstance(source, bytes):
|
|
98
114
|
return source
|
|
115
|
+
if isinstance(source, (bytearray, memoryview)):
|
|
116
|
+
return bytes(source)
|
|
99
117
|
if isinstance(source, str):
|
|
100
118
|
return source.encode("utf-8")
|
|
101
|
-
|
|
119
|
+
if _is_file(source):
|
|
120
|
+
from ._sources import read_source
|
|
121
|
+
|
|
122
|
+
line = read_source(source, "password").split(b"\n", 1)[0]
|
|
123
|
+
return line[:-1] if line.endswith(b"\r") else line
|
|
124
|
+
raise TypeError(
|
|
125
|
+
f"a password must be str, bytes, a file or a callable, not {type(source).__name__}"
|
|
126
|
+
)
|
|
Binary file
|
|
Binary file
|
|
@@ -124,3 +124,123 @@ def test_wrong_uses_are_refused():
|
|
|
124
124
|
sslcontext.create_context(ca=[])
|
|
125
125
|
with pytest.raises(TypeError, match="not int"):
|
|
126
126
|
sslcontext.create_context(ca=42)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.mark.parametrize(
|
|
130
|
+
"bundle",
|
|
131
|
+
[DATA / "client-rsa.p12", str(DATA / "client-rsa.p12"), data("client-rsa.p12")],
|
|
132
|
+
ids=["pathlike", "str-path", "bytes"],
|
|
133
|
+
)
|
|
134
|
+
def test_a_pkcs12_bundle_is_a_certificate(tls_server, bundle):
|
|
135
|
+
context = sslcontext.create_context(ca=CA, certificate=bundle, password="test")
|
|
136
|
+
assert tls_server.handshake(context).client_cn == "client-rsa"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_a_pkcs12_password_callback_and_the_system_context(tls_server):
|
|
140
|
+
asked = []
|
|
141
|
+
|
|
142
|
+
def password():
|
|
143
|
+
asked.append(1)
|
|
144
|
+
return "test"
|
|
145
|
+
|
|
146
|
+
context = sslcontext.system_context(
|
|
147
|
+
ca=CA, certificate=DATA / "client-rsa.p12", password=password
|
|
148
|
+
)
|
|
149
|
+
assert tls_server.handshake(context).client_cn == "client-rsa"
|
|
150
|
+
assert asked == [1]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_pkcs12_misuse_is_refused():
|
|
154
|
+
with pytest.raises(sslcontext.CredentialError, match="wrong password"):
|
|
155
|
+
sslcontext.create_context(certificate=DATA / "client-rsa.p12", password="no")
|
|
156
|
+
with pytest.raises(ValueError, match="PKCS#12"):
|
|
157
|
+
sslcontext.create_context(
|
|
158
|
+
certificate=DATA / "client-rsa.p12", key=DATA / "client-rsa.key"
|
|
159
|
+
)
|
|
160
|
+
with pytest.raises(ValueError, match="PKCS#12"):
|
|
161
|
+
sslcontext.create_context(
|
|
162
|
+
certificate=[DATA / "client-rsa.p12", DATA / "ca.pem"], password="test"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_an_encrypted_der_key_takes_the_password(tls_server):
|
|
167
|
+
context = sslcontext.create_context(
|
|
168
|
+
ca=CA,
|
|
169
|
+
certificate=DATA / "client-ec.pem",
|
|
170
|
+
key=DATA / "client-ec.enc.der",
|
|
171
|
+
password="test",
|
|
172
|
+
)
|
|
173
|
+
assert tls_server.handshake(context).client_cn == "client-ec"
|
|
174
|
+
for wrong in (None, "no"):
|
|
175
|
+
with pytest.raises(sslcontext.CredentialError):
|
|
176
|
+
sslcontext.create_context(
|
|
177
|
+
certificate=DATA / "client-ec.pem",
|
|
178
|
+
key=DATA / "client-ec.enc.der",
|
|
179
|
+
password=wrong,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_a_plain_der_key_never_asks_for_a_password(tls_server):
|
|
184
|
+
context = sslcontext.create_context(
|
|
185
|
+
ca=CA,
|
|
186
|
+
certificate=DATA / "client-ec.pem",
|
|
187
|
+
key=DATA / "client-ec.der",
|
|
188
|
+
password=lambda: pytest.fail("asked for a password"),
|
|
189
|
+
)
|
|
190
|
+
assert tls_server.handshake(context).client_cn == "client-ec"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class _Reader:
|
|
194
|
+
def __init__(self, data):
|
|
195
|
+
self.data = data
|
|
196
|
+
self.reads = 0
|
|
197
|
+
|
|
198
|
+
def read(self):
|
|
199
|
+
self.reads += 1
|
|
200
|
+
return self.data
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@pytest.mark.parametrize(
|
|
204
|
+
"make",
|
|
205
|
+
[
|
|
206
|
+
lambda tmp: tmp / "pw.txt",
|
|
207
|
+
lambda tmp: str(tmp / "pw.txt"),
|
|
208
|
+
lambda tmp: _Opener(tmp / "pw.txt"),
|
|
209
|
+
lambda tmp: _Reader(b"test\r\nsecond line ignored\n"),
|
|
210
|
+
lambda tmp: b"test",
|
|
211
|
+
lambda tmp: bytearray(b"test"),
|
|
212
|
+
],
|
|
213
|
+
ids=["pathlike", "str-is-the-password", "opener", "reader", "bytes", "bytearray"],
|
|
214
|
+
)
|
|
215
|
+
def test_passwords_from_values_and_files(tls_server, tmp_path, make):
|
|
216
|
+
(tmp_path / "pw.txt").write_bytes(b"test\n")
|
|
217
|
+
source = make(tmp_path)
|
|
218
|
+
if isinstance(source, str): # a str is always the password itself
|
|
219
|
+
with pytest.raises(sslcontext.CredentialError):
|
|
220
|
+
sslcontext.create_context(
|
|
221
|
+
certificate=DATA / "client-rsa.p12", password=source
|
|
222
|
+
)
|
|
223
|
+
return
|
|
224
|
+
for certificate, key in (
|
|
225
|
+
(DATA / "client-rsa.p12", None),
|
|
226
|
+
(DATA / "client-ec.pem", DATA / "client-ec.enc.der"),
|
|
227
|
+
(DATA / "client-ec.pem", DATA / "client-ec.enc.key"),
|
|
228
|
+
):
|
|
229
|
+
context = sslcontext.create_context(
|
|
230
|
+
ca=CA, certificate=certificate, key=key, password=source
|
|
231
|
+
)
|
|
232
|
+
assert tls_server.handshake(context).client_cn in ("client-rsa", "client-ec")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_a_password_file_is_read_only_when_needed():
|
|
236
|
+
reader = _Reader(b"test")
|
|
237
|
+
sslcontext.create_context(
|
|
238
|
+
certificate=DATA / "client-ec.pem", key=DATA / "client-ec.key", password=reader
|
|
239
|
+
)
|
|
240
|
+
assert reader.reads == 0
|
|
241
|
+
sslcontext.create_context(
|
|
242
|
+
certificate=DATA / "client-ec.pem",
|
|
243
|
+
key=DATA / "client-ec.enc.key",
|
|
244
|
+
password=reader,
|
|
245
|
+
)
|
|
246
|
+
assert reader.reads == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|