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.
Files changed (76) hide show
  1. {sslcontext-2.0.0 → sslcontext-2.1.0}/CHANGELOG.md +12 -1
  2. {sslcontext-2.0.0 → sslcontext-2.1.0}/PKG-INFO +1 -1
  3. {sslcontext-2.0.0 → sslcontext-2.1.0}/pyproject.toml +1 -1
  4. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/AGENTS.md +20 -10
  5. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_asn1.py +14 -0
  6. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_load.py +20 -9
  7. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_api.py +1 -0
  8. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_objects.py +5 -0
  9. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_pin.py +30 -5
  10. sslcontext-2.1.0/tests/data/client-ec.der +0 -0
  11. sslcontext-2.1.0/tests/data/client-ec.enc.der +0 -0
  12. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_sources.py +120 -0
  13. {sslcontext-2.0.0 → sslcontext-2.1.0}/.gitignore +0 -0
  14. {sslcontext-2.0.0 → sslcontext-2.1.0}/LICENSE +0 -0
  15. {sslcontext-2.0.0 → sslcontext-2.1.0}/README.md +0 -0
  16. {sslcontext-2.0.0 → sslcontext-2.1.0}/docs/api/reference.md +0 -0
  17. {sslcontext-2.0.0 → sslcontext-2.1.0}/docs/changelog.md +0 -0
  18. {sslcontext-2.0.0 → sslcontext-2.1.0}/docs/header.md +0 -0
  19. {sslcontext-2.0.0 → sslcontext-2.1.0}/docs/index.md +0 -0
  20. {sslcontext-2.0.0 → sslcontext-2.1.0}/mkdocs.yml +0 -0
  21. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/__init__.py +0 -0
  22. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/__main__.py +0 -0
  23. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_context.py +0 -0
  24. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_identity.py +0 -0
  25. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/__init__.py +0 -0
  26. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_certcb.py +0 -0
  27. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_ctx.py +0 -0
  28. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_engine.py +0 -0
  29. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_errors.py +0 -0
  30. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_keys.py +0 -0
  31. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_loader.py +0 -0
  32. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_params.py +0 -0
  33. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_provider.py +0 -0
  34. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_openssl/_signing.py +0 -0
  35. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_policy.py +0 -0
  36. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_select.py +0 -0
  37. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_sources.py +0 -0
  38. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/_system.py +0 -0
  39. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/backends/__init__.py +0 -0
  40. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/backends/_cng.py +0 -0
  41. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/backends/_cryptoki.py +0 -0
  42. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/backends/_keychain.py +0 -0
  43. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/backends/_macos.py +0 -0
  44. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/backends/_pkcs11.py +0 -0
  45. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/cli/__init__.py +0 -0
  46. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/cli/_common.py +0 -0
  47. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/cli/_keystore.py +0 -0
  48. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/cli/_probe.py +0 -0
  49. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/cli/_root.py +0 -0
  50. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/exceptions.py +0 -0
  51. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/issuing.py +0 -0
  52. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/py.typed +0 -0
  53. {sslcontext-2.0.0 → sslcontext-2.1.0}/src/sslcontext/testing.py +0 -0
  54. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/conftest.py +0 -0
  55. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/ca.pem +0 -0
  56. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/client-ec.enc.key +0 -0
  57. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/client-ec.key +0 -0
  58. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/client-ec.pem +0 -0
  59. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/client-rsa.key +0 -0
  60. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/client-rsa.p12 +0 -0
  61. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/client-rsa.pem +0 -0
  62. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/server.key +0 -0
  63. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/data/server.pem +0 -0
  64. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/integration/start_virtual_card.sh +0 -0
  65. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/integration/test_cng.py +0 -0
  66. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/integration/test_keychain.py +0 -0
  67. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/integration/test_pkcs11.py +0 -0
  68. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_backends.py +0 -0
  69. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_cli.py +0 -0
  70. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_context.py +0 -0
  71. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_issuing.py +0 -0
  72. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_load.py +0 -0
  73. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_package.py +0 -0
  74. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_pin.py +0 -0
  75. {sslcontext-2.0.0 → sslcontext-2.1.0}/tests/test_select.py +0 -0
  76. {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.0.0...HEAD
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.0.0
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.0.0"
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. `key` is
220
- a PEM or DER private key (or a `cryptography` key); omitted, it is read from
221
- the first certificate source (a combined PEM). `password` decrypts an
222
- encrypted key; see [PINs and passwords](#pins-and-passwords-sslcontext).
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: a value, or a callable
350
- that is called only if the key turns out to be encrypted (returning `None`
351
- fails with `CredentialError`). OpenSSL's terminal passphrase prompt is never
352
- used.
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 OpenSSL cannot be driven: LibreSSL, OpenSSL < 1.1.1, a static `_ssl` that hides its symbols, or an unrecognised `SSLContext` layout |
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 ._pin import PasswordSource, resolve_password
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 callable(password):
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). ``key`` is a PEM or DER private key, likewise, or a
112
- ``cryptography`` private key; when omitted it is read from the first
113
- certificate source, which then holds both. ``password`` (a value, or a
114
- callable asked only if the key turns out encrypted) decrypts an encrypted
115
- key. Nothing is written to disk.
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 callable(password):
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[str, bytes, Callable[[], Union[str, bytes, None]], None]
67
- """A password, a callable returning one (or ``None`` to decline), or ``None``."""
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
- raise TypeError(f"a password must be str or bytes, not {type(source).__name__}")
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
+ )
@@ -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