x509kit 0.3.2__tar.gz → 0.4.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 (40) hide show
  1. {x509kit-0.3.2/src/x509kit.egg-info → x509kit-0.4.0}/PKG-INFO +1 -1
  2. x509kit-0.4.0/examples/13_acme_server.py +211 -0
  3. x509kit-0.4.0/examples/14_acme_client.py +167 -0
  4. {x509kit-0.3.2 → x509kit-0.4.0}/pyproject.toml +1 -1
  5. {x509kit-0.3.2 → x509kit-0.4.0}/src/x509kit/__init__.py +9 -0
  6. x509kit-0.4.0/src/x509kit/acme.py +538 -0
  7. {x509kit-0.3.2 → x509kit-0.4.0}/src/x509kit/x509.py +1 -1
  8. {x509kit-0.3.2 → x509kit-0.4.0/src/x509kit.egg-info}/PKG-INFO +1 -1
  9. {x509kit-0.3.2 → x509kit-0.4.0}/src/x509kit.egg-info/SOURCES.txt +3 -0
  10. {x509kit-0.3.2 → x509kit-0.4.0}/test_all.py +17 -21
  11. {x509kit-0.3.2 → x509kit-0.4.0}/CHANGELOG.md +0 -0
  12. {x509kit-0.3.2 → x509kit-0.4.0}/LICENSE +0 -0
  13. {x509kit-0.3.2 → x509kit-0.4.0}/MANIFEST.in +0 -0
  14. {x509kit-0.3.2 → x509kit-0.4.0}/README.md +0 -0
  15. {x509kit-0.3.2 → x509kit-0.4.0}/SECURITY.md +0 -0
  16. {x509kit-0.3.2 → x509kit-0.4.0}/check_dispatcher.py +0 -0
  17. {x509kit-0.3.2 → x509kit-0.4.0}/check_docs.py +0 -0
  18. {x509kit-0.3.2 → x509kit-0.4.0}/check_no_hidden.py +0 -0
  19. {x509kit-0.3.2 → x509kit-0.4.0}/compat_test.py +0 -0
  20. {x509kit-0.3.2 → x509kit-0.4.0}/examples/01_root_ca.py +0 -0
  21. {x509kit-0.3.2 → x509kit-0.4.0}/examples/02_issue_from_csr.py +0 -0
  22. {x509kit-0.3.2 → x509kit-0.4.0}/examples/03_self_signed_server.py +0 -0
  23. {x509kit-0.3.2 → x509kit-0.4.0}/examples/04_mtls_server.py +0 -0
  24. {x509kit-0.3.2 → x509kit-0.4.0}/examples/05_crl_revocation.py +0 -0
  25. {x509kit-0.3.2 → x509kit-0.4.0}/examples/06_ocsp_responder.py +0 -0
  26. {x509kit-0.3.2 → x509kit-0.4.0}/examples/07_pkcs12_export.py +0 -0
  27. {x509kit-0.3.2 → x509kit-0.4.0}/examples/08_parse_real_cert.py +0 -0
  28. {x509kit-0.3.2 → x509kit-0.4.0}/examples/09_encrypted_key.py +0 -0
  29. {x509kit-0.3.2 → x509kit-0.4.0}/examples/10_verify_chain.py +0 -0
  30. {x509kit-0.3.2 → x509kit-0.4.0}/examples/11_name_canonicalization.py +0 -0
  31. {x509kit-0.3.2 → x509kit-0.4.0}/examples/12_extensions.py +0 -0
  32. {x509kit-0.3.2 → x509kit-0.4.0}/examples/README.md +0 -0
  33. {x509kit-0.3.2 → x509kit-0.4.0}/flask/test_live_ca.py +0 -0
  34. {x509kit-0.3.2 → x509kit-0.4.0}/flask/test_mtls.py +0 -0
  35. {x509kit-0.3.2 → x509kit-0.4.0}/fuzz_x509.py +0 -0
  36. {x509kit-0.3.2 → x509kit-0.4.0}/setup.cfg +0 -0
  37. {x509kit-0.3.2 → x509kit-0.4.0}/src/x509kit/py.typed +0 -0
  38. {x509kit-0.3.2 → x509kit-0.4.0}/src/x509kit.egg-info/dependency_links.txt +0 -0
  39. {x509kit-0.3.2 → x509kit-0.4.0}/src/x509kit.egg-info/requires.txt +0 -0
  40. {x509kit-0.3.2 → x509kit-0.4.0}/src/x509kit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: x509kit
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Pure-Python X.509 / PKCS#10 / CRL / OCSP / PKCS#12 toolkit — stdlib only
5
5
  Author-email: ArtProsto <artemkamirn@mail.ru>
6
6
  License: MIT
@@ -0,0 +1,211 @@
1
+ """13 - Минимальный ACME-сервер.
2
+
3
+ Run: python examples/13_acme_server.py [--auto-verify] [--port 14000]
4
+
5
+ --auto-verify — не делать реальный http-01 fetch (для локальных тестов).
6
+ В продакшене НЕ использовать.
7
+
8
+ Endpoints:
9
+ GET /directory — метаданные сервера
10
+ HEAD /new-nonce — свежий nonce
11
+ POST /new-account — создать аккаунт
12
+ POST /new-order — создать order
13
+ POST /authz/<id> — получить authz
14
+ POST /challenge/<id> — активировать challenge
15
+ POST /finalize/<id> — финализировать order (CSR)
16
+ POST /cert/<id> — скачать сертификат
17
+ """
18
+ import argparse
19
+ import json
20
+ import sys
21
+ import threading
22
+ from http.server import HTTPServer, BaseHTTPRequestHandler
23
+
24
+ from x509kit import CertificateBuilder, CACert, Ed25519Keys
25
+ from x509kit.acme import (
26
+ ACMEServer, ACMEError, b64u, verify_jws,
27
+ )
28
+
29
+
30
+ def build_ca():
31
+ key = Ed25519Keys.generate()
32
+ ca = (CertificateBuilder(is_ca=True)
33
+ .add_subject({"C": "XX", "O": "ACME Demo", "CN": "ACME Demo Root"})
34
+ .add_public_key(key)
35
+ .add_serial_random().add_validity(days=3650)
36
+ .add_basic_constraints(path_len=0)
37
+ .add_key_usage("keyCertSign", "cRLSign")
38
+ .self_signed().sign(key))
39
+ return ca, key
40
+
41
+
42
+ def make_handler(acme: ACMEServer):
43
+ class Handler(BaseHTTPRequestHandler):
44
+ server_ref = acme
45
+
46
+ def log_message(self, fmt, *args):
47
+ sys.stderr.write(f"[acme] {fmt % args}\n")
48
+
49
+ # ---- helpers ----
50
+ def _send(self, status: int, body: bytes, ctype: str,
51
+ extra_headers=None):
52
+ self.send_response(status)
53
+ self.send_header("Content-Type", ctype)
54
+ self.send_header("Content-Length", str(len(body)))
55
+ self.send_header("Replay-Nonce", self.server_ref.new_nonce())
56
+ # RFC 8555 §7.1: многие клиенты (в т.ч. certbot) требуют rel="up"
57
+ self.send_header("Link",
58
+ f'<{self.server_ref.base_url}/directory>;rel="up"')
59
+ for k, v in (extra_headers or {}).items():
60
+ self.send_header(k, v)
61
+ self.end_headers()
62
+ self.wfile.write(body)
63
+
64
+ def _json(self, status, obj, extra_headers=None):
65
+ body = json.dumps(obj, separators=(",", ":")).encode()
66
+ self._send(status, body, "application/json", extra_headers)
67
+
68
+ def _problem(self, err: ACMEError):
69
+ from x509kit.acme import _acme_problem
70
+ body = json.dumps(_acme_problem(err), separators=(",", ":")).encode()
71
+ self._send(err.status, body, "application/problem+json")
72
+
73
+ def _read_jws(self):
74
+ n = int(self.headers.get("Content-Length", "0"))
75
+ raw = self.rfile.read(n)
76
+ try:
77
+ return json.loads(raw.decode("utf-8"))
78
+ except Exception as e:
79
+ raise ACMEError("malformed", f"not JSON: {e}")
80
+
81
+ def _verify(self, jws, url):
82
+ try:
83
+ return verify_jws(
84
+ jws,
85
+ expected_url=url,
86
+ nonce_checker=self.server_ref.consume_nonce,
87
+ key_resolver=self.server_ref.account_key,
88
+ )
89
+ except Exception as e:
90
+ # Convert nonce/signature errors to ACME problem
91
+ msg = str(e)
92
+ kind = "badNonce" if "nonce" in msg.lower() else "malformed"
93
+ raise ACMEError(kind, msg, 400)
94
+
95
+ # ---- HTTP verbs ----
96
+ def do_GET(self):
97
+ path = self.path.split("?", 1)[0]
98
+ if path == "/directory":
99
+ self._json(200, self.server_ref.directory())
100
+ else:
101
+ self._problem(ACMEError("malformed", f"GET {path}", 404))
102
+
103
+ def do_HEAD(self):
104
+ path = self.path.split("?", 1)[0]
105
+ if path == "/new-nonce":
106
+ self.send_response(200)
107
+ self.send_header("Replay-Nonce", self.server_ref.new_nonce())
108
+ self.send_header("Cache-Control", "no-store")
109
+ self.end_headers()
110
+ else:
111
+ self.send_response(404)
112
+ self.end_headers()
113
+
114
+ def do_POST(self):
115
+ path = self.path.split("?", 1)[0]
116
+ url = self.server_ref.base_url + path
117
+
118
+ try:
119
+ jws = self._read_jws()
120
+ result = self._verify(jws, url)
121
+ self._dispatch(path, result)
122
+ except ACMEError as e:
123
+ self._problem(e)
124
+ except Exception as e:
125
+ self._problem(ACMEError("serverInternal", str(e), 500))
126
+
127
+ def _dispatch(self, path, result):
128
+ S = self.server_ref
129
+
130
+ if path == "/new-account":
131
+ kid, created = S.new_account(result.jwk, result.payload)
132
+ view = S.account_view(kid)
133
+ status = 201 if created else 200
134
+ self._json(status, view, {"Location": kid})
135
+ return
136
+
137
+ if path == "/new-order":
138
+ if result.kid is None:
139
+ raise ACMEError("malformed", "kid required", 400)
140
+ view = S.new_order(result.kid, result.payload)
141
+ order_url = f"{S.base_url}/order/{view['id']}"
142
+ self._json(201, view, {"Location": order_url})
143
+ return
144
+
145
+ if path.startswith("/order/"):
146
+ order_id = path[len("/order/"):]
147
+ if not order_id:
148
+ raise ACMEError("malformed",
149
+ "order URL is missing id", 404)
150
+ self._json(200, S.get_order(order_id))
151
+ return
152
+
153
+ if path.startswith("/authz/"):
154
+ authz_id = path[len("/authz/"):]
155
+ self._json(200, S.get_authz(authz_id))
156
+ return
157
+
158
+ if path.startswith("/challenge/"):
159
+ chal_id = path[len("/challenge/"):]
160
+ # payload обычно {}
161
+ self._json(200, S.trigger_challenge(chal_id))
162
+ return
163
+
164
+ if path.startswith("/finalize/"):
165
+ order_id = path[len("/finalize/"):]
166
+ self._json(200, S.finalize(order_id, result.payload))
167
+ return
168
+
169
+ if path.startswith("/cert/"):
170
+ cert_id = path[len("/cert/"):]
171
+ pem = S.get_certificate(cert_id)
172
+ self._send(200, pem.encode(),
173
+ "application/pem-certificate-chain")
174
+ return
175
+
176
+ raise ACMEError("malformed", f"unknown path: {path}", 404)
177
+
178
+ return Handler
179
+
180
+
181
+ def main():
182
+ p = argparse.ArgumentParser()
183
+ p.add_argument("--port", type=int, default=14000)
184
+ p.add_argument("--host", default="127.0.0.1")
185
+ p.add_argument("--auto-verify", action="store_true",
186
+ help="challenge valid без реальной проверки (только для тестов!)")
187
+ args = p.parse_args()
188
+
189
+ ca, ca_key = build_ca()
190
+ base_url = f"http://{args.host}:{args.port}"
191
+ acme = ACMEServer(ca, ca_key, base_url,
192
+ cert_days=90,
193
+ auto_verify=args.auto_verify)
194
+
195
+ Handler = make_handler(acme)
196
+ srv = HTTPServer((args.host, args.port), Handler)
197
+
198
+ print(f"ACME server: {base_url}/directory")
199
+ print(f"Directory: {base_url}/directory")
200
+ print(f"auto_verify: {args.auto_verify}")
201
+ print("Ctrl+C to stop.")
202
+ print()
203
+ print("Test with: python examples/14_acme_client.py")
204
+ try:
205
+ srv.serve_forever()
206
+ except KeyboardInterrupt:
207
+ print("\nStopped.")
208
+
209
+
210
+ if __name__ == "__main__":
211
+ main()
@@ -0,0 +1,167 @@
1
+ """14 - Минимальный ACME-клиент для локального сервера.
2
+
3
+ Run (в другом терминале, пока сервер работает):
4
+ python examples/14_acme_client.py
5
+
6
+ Создаёт аккаунт Ed25519, заказывает сертификат для "example.local",
7
+ проходит http-01 challenge (auto_verify), финализирует и скачивает cert.
8
+ """
9
+ import json
10
+ import sys
11
+ import urllib.request
12
+ from base64 import urlsafe_b64decode, urlsafe_b64encode
13
+
14
+ from x509kit import CSRBuilder, Ed25519Keys
15
+
16
+
17
+ def b64u(data):
18
+ if isinstance(data, str):
19
+ data = data.encode()
20
+ return urlsafe_b64encode(data).rstrip(b"=").decode()
21
+
22
+
23
+ def http(method, url, body=None, headers=None):
24
+ req = urllib.request.Request(url, method=method)
25
+ if body is not None:
26
+ req.data = body if isinstance(body, bytes) else body.encode()
27
+ for k, v in (headers or {}).items():
28
+ req.add_header(k, v)
29
+ with urllib.request.urlopen(req) as r:
30
+ data = r.read()
31
+ return r.status, dict(r.headers), data
32
+
33
+
34
+ def jws_body(payload: dict, protected_extra: dict, key):
35
+ protected = dict(protected_extra)
36
+ protected_b64 = b64u(json.dumps(protected, separators=(",", ":")))
37
+ payload_b64 = b64u(json.dumps(payload, separators=(",", ":")))
38
+ signing_input = (protected_b64 + "." + payload_b64).encode()
39
+ sig = key.sign(signing_input)
40
+ return json.dumps({
41
+ "protected": protected_b64,
42
+ "payload": payload_b64,
43
+ "signature": b64u(sig),
44
+ }, separators=(",", ":")).encode()
45
+
46
+
47
+ def main():
48
+ BASE = "http://127.0.0.1:14000"
49
+
50
+ # --- 1. Directory ---
51
+ print("[1] GET /directory")
52
+ _, _, body = http("GET", f"{BASE}/directory")
53
+ directory = json.loads(body)
54
+ print(f" newAccount: {directory['newAccount']}")
55
+
56
+ # --- 2. new-nonce ---
57
+ print("[2] HEAD /new-nonce")
58
+ _, hdrs, _ = http("HEAD", directory["newNonce"])
59
+ nonce = hdrs["Replay-Nonce"]
60
+ print(f" nonce: {nonce[:16]}…")
61
+
62
+ # --- 3. Ключ аккаунта ---
63
+ account_key = Ed25519Keys.generate()
64
+ jwk = {
65
+ "kty": "OKP",
66
+ "crv": "Ed25519",
67
+ "x": b64u(account_key.pub),
68
+ }
69
+
70
+ # --- 4. new-account ---
71
+ print("[3] POST /new-account")
72
+ body = jws_body(
73
+ {"termsOfServiceAgreed": True, "contact": []},
74
+ {"alg": "EdDSA", "jwk": jwk, "nonce": nonce,
75
+ "url": directory["newAccount"]},
76
+ account_key,
77
+ )
78
+ status, hdrs, _ = http("POST", directory["newAccount"], body,
79
+ {"Content-Type": "application/jose+json"})
80
+ kid = hdrs["Location"]
81
+ nonce = hdrs["Replay-Nonce"]
82
+ print(f" kid: {kid}")
83
+
84
+ # --- 5. new-order ---
85
+ print("[4] POST /new-order")
86
+ order_payload = {
87
+ "identifiers": [{"type": "dns", "value": "example.local"}],
88
+ }
89
+ body = jws_body(
90
+ order_payload,
91
+ {"alg": "EdDSA", "kid": kid, "nonce": nonce,
92
+ "url": directory["newOrder"]},
93
+ account_key,
94
+ )
95
+ _, hdrs, raw = http("POST", directory["newOrder"], body,
96
+ {"Content-Type": "application/jose+json"})
97
+ nonce = hdrs["Replay-Nonce"]
98
+ order = json.loads(raw)
99
+ print(f" status: {order['status']}")
100
+ authz_url = order["authorizations"][0]
101
+
102
+ # --- 6. authz ---
103
+ print("[5] POST /authz/<id> (POST-as-GET)")
104
+ body = jws_body({}, {"alg": "EdDSA", "kid": kid, "nonce": nonce,
105
+ "url": authz_url}, account_key)
106
+ _, hdrs, raw = http("POST", authz_url, body,
107
+ {"Content-Type": "application/jose+json"})
108
+ nonce = hdrs["Replay-Nonce"]
109
+ authz = json.loads(raw)
110
+ chal_url = authz["challenges"][0]["url"]
111
+ print(f" authz status: {authz['status']}")
112
+ print(f" challenge: {authz['challenges'][0]['type']}")
113
+
114
+ # --- 7. Trigger challenge ---
115
+ print("[6] POST /challenge/<id>")
116
+ body = jws_body({}, {"alg": "EdDSA", "kid": kid, "nonce": nonce,
117
+ "url": chal_url}, account_key)
118
+ _, hdrs, raw = http("POST", chal_url, body,
119
+ {"Content-Type": "application/jose+json"})
120
+ nonce = hdrs["Replay-Nonce"]
121
+ chal = json.loads(raw)
122
+ print(f" challenge status: {chal['status']}")
123
+ if chal["status"] != "valid":
124
+ print(f" error: {chal.get('error', {}).get('detail', '?')}")
125
+ sys.exit(1)
126
+
127
+ # --- 8. Finalize ---
128
+ print("[7] POST /finalize/<order>")
129
+ leaf_key = Ed25519Keys.generate()
130
+ csr = (CSRBuilder()
131
+ .add_subject({"CN": "example.local"})
132
+ .add_public_key(leaf_key)
133
+ .add_san_dns("example.local")
134
+ .sign(leaf_key))
135
+ finalize_payload = {"csr": b64u(csr.der)}
136
+ body = jws_body(
137
+ finalize_payload,
138
+ {"alg": "EdDSA", "kid": kid, "nonce": nonce,
139
+ "url": order["finalize"]},
140
+ account_key,
141
+ )
142
+ _, hdrs, raw = http("POST", order["finalize"], body,
143
+ {"Content-Type": "application/jose+json"})
144
+ nonce = hdrs["Replay-Nonce"]
145
+ order2 = json.loads(raw)
146
+ cert_url = order2["certificate"]
147
+ print(f" order status: {order2['status']}")
148
+ print(f" certificate: {cert_url}")
149
+
150
+ # --- 9. Download certificate ---
151
+ print("[8] POST /cert/<id> (POST-as-GET)")
152
+ body = jws_body({}, {"alg": "EdDSA", "kid": kid, "nonce": nonce,
153
+ "url": cert_url}, account_key)
154
+ _, _, raw = http("POST", cert_url, body,
155
+ {"Content-Type": "application/jose+json"})
156
+ pem = raw.decode()
157
+ print()
158
+ print("=== Certificate (first 400 chars) ===")
159
+ print(pem[:400])
160
+
161
+ open("acme_leaf.pem", "w").write(pem)
162
+ print()
163
+ print("Saved: acme_leaf.pem")
164
+
165
+
166
+ if __name__ == "__main__":
167
+ main()
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "x509kit"
7
- version = "0.3.2"
7
+ version = "0.4.0"
8
8
  description = "Pure-Python X.509 / PKCS#10 / CRL / OCSP / PKCS#12 toolkit — stdlib only"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -318,7 +318,16 @@ from .x509 import (
318
318
  __version__,
319
319
  )
320
320
 
321
+ from .acme import (
322
+ ACMEServer, ACMEError,
323
+ b64u, b64u_decode,
324
+ jwk_to_key, jwk_thumbprint, verify_jws,
325
+ )
326
+
321
327
  __all__ = [
328
+ "ACMEServer", "ACMEError",
329
+ "b64u", "b64u_decode",
330
+ "jwk_to_key", "jwk_thumbprint", "verify_jws",
322
331
  "ASN1",
323
332
  # Ключи
324
333
  "RSAKeys", "Ed25519Keys", "ECDSAKeys", "EC_CURVES",
@@ -0,0 +1,538 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ x509kit.acme — минимальный ACME-сервер (RFC 8555) поверх x509kit.
4
+
5
+ Поддерживает:
6
+ directory, new-nonce, new-account (jwk), new-order, authz,
7
+ http-01 challenge, finalize (CSR → cert), cert download.
8
+
9
+ Не поддерживает:
10
+ DNS-01, TLS-ALPN-01, wildcard, external account binding,
11
+ key rollover, revocation.
12
+
13
+ Состояние — в памяти. Перезапуск → всё потеряно.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import hashlib
20
+ import json
21
+ import secrets
22
+ import time
23
+ import urllib.request
24
+ from datetime import datetime, timedelta, timezone
25
+
26
+ from .x509 import (
27
+ ASN1, CertificateBuilder, CACert, CSR, der_int, der_seq,
28
+ Ed25519Keys, ECDSAKeys, RSAKeys, X509Error,
29
+ )
30
+
31
+
32
+ # ======================================================================
33
+ # Base64url (RFC 4648 §5)
34
+ # ======================================================================
35
+
36
+ def b64u(data) -> str:
37
+ if isinstance(data, str):
38
+ data = data.encode("utf-8")
39
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
40
+
41
+
42
+ def b64u_decode(s) -> bytes:
43
+ if isinstance(s, bytes):
44
+ s = s.decode("ascii")
45
+ pad = "=" * (-len(s) % 4)
46
+ return base64.urlsafe_b64decode(s + pad)
47
+
48
+
49
+ # ======================================================================
50
+ # JWK (RFC 7517) и thumbprint (RFC 7638)
51
+ # ======================================================================
52
+
53
+ def jwk_to_key(jwk: dict):
54
+ """JWK dict → публичный ключ x509kit."""
55
+ kty = jwk.get("kty")
56
+ if kty == "OKP" and jwk.get("crv") == "Ed25519":
57
+ return Ed25519Keys(pub=b64u_decode(jwk["x"]))
58
+ if kty == "EC":
59
+ crv = jwk.get("crv")
60
+ curve_oid = {"P-256": "1.2.840.10045.3.1.7",
61
+ "P-384": "1.3.132.0.34",
62
+ "P-521": "1.3.132.0.35"}.get(crv)
63
+ if curve_oid is None:
64
+ raise X509Error(f"unsupported EC curve: {crv}")
65
+ x = int.from_bytes(b64u_decode(jwk["x"]), "big")
66
+ y = int.from_bytes(b64u_decode(jwk["y"]), "big")
67
+ return ECDSAKeys(curve_oid, point=(x, y))
68
+ if kty == "RSA":
69
+ n = int.from_bytes(b64u_decode(jwk["n"]), "big")
70
+ e = int.from_bytes(b64u_decode(jwk["e"]), "big")
71
+ return RSAKeys(n=n, e=e)
72
+ raise X509Error(f"unsupported JWK kty={kty}")
73
+
74
+
75
+ def jwk_thumbprint(jwk: dict) -> str:
76
+ """RFC 7638 thumbprint (base64url SHA-256)."""
77
+ kty = jwk["kty"]
78
+ if kty == "OKP":
79
+ canonical = {"crv": jwk["crv"], "kty": kty, "x": jwk["x"]}
80
+ elif kty == "EC":
81
+ canonical = {"crv": jwk["crv"], "kty": kty,
82
+ "x": jwk["x"], "y": jwk["y"]}
83
+ elif kty == "RSA":
84
+ canonical = {"e": jwk["e"], "kty": kty, "n": jwk["n"]}
85
+ else:
86
+ raise X509Error(f"unsupported kty: {kty}")
87
+ js = json.dumps(canonical, separators=(",", ":"), sort_keys=True)
88
+ return b64u(hashlib.sha256(js.encode("utf-8")).digest())
89
+
90
+
91
+ # ======================================================================
92
+ # JWS verification (RFC 7515)
93
+ # ======================================================================
94
+
95
+ def _ecdsa_raw_to_der(raw: bytes) -> bytes:
96
+ """64 байта r||s → DER SEQUENCE."""
97
+ if len(raw) % 2 != 0:
98
+ raise X509Error(f"bad ECDSA sig length: {len(raw)}")
99
+ half = len(raw) // 2
100
+ r = int.from_bytes(raw[:half], "big")
101
+ s = int.from_bytes(raw[half:], "big")
102
+ return der_seq(der_int(r), der_int(s))
103
+
104
+
105
+ class JWSResult:
106
+ __slots__ = ("protected", "payload", "key", "jwk", "kid",
107
+ "url", "nonce", "alg")
108
+
109
+ def __init__(self, protected, payload, key, jwk, kid, url, nonce, alg):
110
+ self.protected = protected
111
+ self.payload = payload
112
+ self.key = key
113
+ self.jwk = jwk
114
+ self.kid = kid
115
+ self.url = url
116
+ self.nonce = nonce
117
+ self.alg = alg
118
+
119
+
120
+ def verify_jws(jws: dict, *, expected_url: str,
121
+ nonce_checker=None, key_resolver=None) -> JWSResult:
122
+ """Проверить JWS. Все ошибки → X509Error.
123
+
124
+ jws = {"protected": b64u, "payload": b64u, "signature": b64u}
125
+ """
126
+ try:
127
+ protected_b64 = jws["protected"]
128
+ payload_b64 = jws.get("payload", "")
129
+ signature_b64 = jws["signature"]
130
+ except KeyError as e:
131
+ raise X509Error(f"JWS missing field: {e}")
132
+
133
+ try:
134
+ protected = json.loads(b64u_decode(protected_b64))
135
+ except Exception as e:
136
+ raise X509Error(f"JWS protected header is not valid JSON: {e}")
137
+
138
+ try:
139
+ payload = json.loads(b64u_decode(payload_b64)) if payload_b64 else {}
140
+ except Exception as e:
141
+ raise X509Error(f"JWS payload is not valid JSON: {e}")
142
+
143
+ # --- URL ---
144
+ url = protected.get("url")
145
+ if url != expected_url:
146
+ raise X509Error(f"JWS url mismatch: {url!r} != {expected_url!r}")
147
+
148
+ # --- nonce ---
149
+ nonce = protected.get("nonce")
150
+ if not nonce:
151
+ raise X509Error("JWS missing nonce")
152
+ if nonce_checker is not None and not nonce_checker(nonce):
153
+ raise X509Error(f"JWS bad or reused nonce: {nonce!r}")
154
+
155
+ # --- key: jwk (для new-account) или kid (для остальных) ---
156
+ has_jwk = "jwk" in protected
157
+ has_kid = "kid" in protected
158
+ if has_jwk and has_kid:
159
+ raise X509Error("JWS must have jwk OR kid, not both")
160
+ if not has_jwk and not has_kid:
161
+ raise X509Error("JWS must have either jwk or kid")
162
+
163
+ if has_jwk:
164
+ jwk = protected["jwk"]
165
+ key = jwk_to_key(jwk)
166
+ kid = None
167
+ else:
168
+ kid = protected["kid"]
169
+ if key_resolver is None:
170
+ raise X509Error("JWS uses kid, but no key resolver provided")
171
+ key = key_resolver(kid)
172
+ jwk = None
173
+
174
+ # --- signature ---
175
+ alg = protected.get("alg")
176
+ signing_input = (protected_b64 + "." + payload_b64).encode("ascii")
177
+ try:
178
+ signature = b64u_decode(signature_b64)
179
+ except Exception as e:
180
+ raise X509Error(f"JWS signature is not valid base64url: {e}")
181
+
182
+ if alg == "EdDSA":
183
+ if not isinstance(key, Ed25519Keys):
184
+ raise X509Error("alg=EdDSA but key is not Ed25519")
185
+ ok = key.verify(signing_input, signature)
186
+ elif alg in ("ES256", "ES384", "ES512"):
187
+ if not isinstance(key, ECDSAKeys):
188
+ raise X509Error(f"alg={alg} but key is not ECDSA")
189
+ hash_name = {"ES256": "sha256", "ES384": "sha384", "ES512": "sha512"}[alg]
190
+ try:
191
+ der_sig = _ecdsa_raw_to_der(signature)
192
+ except X509Error:
193
+ raise
194
+ ok = key.verify(signing_input, der_sig, hash_name)
195
+ elif alg == "RS256":
196
+ if not isinstance(key, RSAKeys):
197
+ raise X509Error("alg=RS256 but key is not RSA")
198
+ ok = key.verify_pkcs1v15(signing_input, signature, "sha256")
199
+ else:
200
+ raise X509Error(f"unsupported JWS alg: {alg}")
201
+
202
+ if not ok:
203
+ raise X509Error("JWS signature invalid")
204
+
205
+ return JWSResult(protected, payload, key, jwk, kid, url, nonce, alg)
206
+
207
+
208
+ # ======================================================================
209
+ # ACME errors
210
+ # ======================================================================
211
+
212
+ class ACMEError(X509Error):
213
+ """Ошибка ACME с типом (urn:ietf:params:acme:error:...) и HTTP-кодом."""
214
+
215
+ def __init__(self, kind: str, detail: str, status: int = 400):
216
+ super().__init__(f"{kind}: {detail}")
217
+ self.kind = kind
218
+ self.detail = detail
219
+ self.status = status
220
+
221
+
222
+ def _acme_problem(err: ACMEError) -> dict:
223
+ return {
224
+ "type": f"urn:ietf:params:acme:error:{err.kind}",
225
+ "detail": err.detail,
226
+ }
227
+
228
+
229
+ # ======================================================================
230
+ # ACME Server
231
+ # ======================================================================
232
+
233
+ class ACMEServer:
234
+ """Минимальный ACME-сервер с http-01 challenge.
235
+
236
+ ca — CACert (CA:TRUE)
237
+ ca_key — приватный ключ CA (RSA/Ed25519/ECDSA)
238
+ base_url — базовый URL, например "http://localhost:14000"
239
+ chain — список промежуточных сертификатов (опционально)
240
+ """
241
+
242
+ def __init__(self, ca: CACert, ca_key, base_url: str, *,
243
+ chain=None, cert_days: int = 90, auto_verify: bool = False):
244
+ if not ca_key.is_private:
245
+ raise X509Error("ca_key must be a private key")
246
+ if ca.public_key.to_public_pem() != ca_key.to_public_pem():
247
+ raise X509Error("ca_key does not match ca.public_key")
248
+
249
+ self.ca = ca
250
+ self.ca_key = ca_key
251
+ self.base_url = base_url.rstrip("/")
252
+ self.chain = list(chain or [])
253
+ self.cert_days = cert_days
254
+ self.auto_verify = auto_verify
255
+
256
+ self.nonces: set[str] = set()
257
+ self.accounts: dict[str, dict] = {} # kid -> {jwk, key, ...}
258
+ self.orders: dict[str, dict] = {} # order_id
259
+ self.authzs: dict[str, dict] = {} # authz_id
260
+ self.chals: dict[str, dict] = {} # chal_id
261
+ self.certs: dict[str, dict] = {} # cert_id
262
+
263
+ # ---- nonces ----------------------------------------------------
264
+ def new_nonce(self) -> str:
265
+ n = b64u(secrets.token_bytes(16))
266
+ self.nonces.add(n)
267
+ return n
268
+
269
+ def consume_nonce(self, n: str) -> bool:
270
+ if n in self.nonces:
271
+ self.nonces.discard(n)
272
+ return True
273
+ return False
274
+
275
+ # ---- directory -------------------------------------------------
276
+ def directory(self) -> dict:
277
+ return {
278
+ "newNonce": f"{self.base_url}/new-nonce",
279
+ "newAccount": f"{self.base_url}/new-account",
280
+ "newOrder": f"{self.base_url}/new-order",
281
+ "revokeCert": f"{self.base_url}/revoke-cert",
282
+ "keyChange": f"{self.base_url}/key-change",
283
+ "meta": {
284
+ "termsOfService": f"{self.base_url}/tos",
285
+ "website": self.base_url,
286
+ },
287
+ }
288
+
289
+ # ---- accounts --------------------------------------------------
290
+ def new_account(self, jwk: dict, payload: dict):
291
+ thumbprint = jwk_thumbprint(jwk)
292
+ kid = f"{self.base_url}/acct/{thumbprint}"
293
+
294
+ if kid in self.accounts:
295
+ return kid, False
296
+
297
+ if not payload.get("termsOfServiceAgreed", False):
298
+ raise ACMEError("malformed", "termsOfServiceAgreed must be true")
299
+
300
+ self.accounts[kid] = {
301
+ "jwk": jwk,
302
+ "key": jwk_to_key(jwk),
303
+ "contact": payload.get("contact", []),
304
+ "created": time.time(),
305
+ }
306
+ return kid, True
307
+
308
+ def account_key(self, kid: str):
309
+ acc = self.accounts.get(kid)
310
+ if acc is None:
311
+ raise ACMEError("accountDoesNotExist", f"unknown kid: {kid}", 400)
312
+ return acc["key"]
313
+
314
+ def account_view(self, kid: str) -> dict:
315
+ acc = self.accounts[kid]
316
+ return {
317
+ "status": "valid",
318
+ "contact": acc["contact"],
319
+ "createdAt": datetime.fromtimestamp(
320
+ acc["created"], tz=timezone.utc,
321
+ ).strftime("%Y-%m-%dT%H:%M:%SZ"),
322
+ }
323
+
324
+ # ---- orders ----------------------------------------------------
325
+ def new_order(self, kid: str, payload: dict) -> dict:
326
+ idents = payload.get("identifiers") or []
327
+ if not idents:
328
+ raise ACMEError("malformed", "no identifiers in order")
329
+ for i in idents:
330
+ if i.get("type") != "dns":
331
+ raise ACMEError("unsupportedIdentifier",
332
+ f"only 'dns' supported, got {i.get('type')}")
333
+
334
+ order_id = b64u(secrets.token_bytes(16))
335
+ authz_ids = [self._new_authz(kid, ident) for ident in idents]
336
+
337
+ expires = datetime.now(timezone.utc) + timedelta(days=7)
338
+ self.orders[order_id] = {
339
+ "id": order_id,
340
+ "account": kid,
341
+ "status": "pending",
342
+ "identifiers": idents,
343
+ "authz_ids": authz_ids,
344
+ "expires": expires.isoformat(),
345
+ "finalize_url": f"{self.base_url}/finalize/{order_id}",
346
+ "cert_id": None,
347
+ }
348
+ return self._order_view(order_id)
349
+
350
+ def _order_view(self, order_id: str) -> dict:
351
+ order = self.orders[order_id]
352
+ self._refresh_order(order)
353
+ view = {
354
+ "id": order_id,
355
+ "status": order["status"],
356
+ "expires": order["expires"],
357
+ "identifiers": order["identifiers"],
358
+ "authorizations": [
359
+ f"{self.base_url}/authz/{a}" for a in order["authz_ids"]
360
+ ],
361
+ "finalize": f"{self.base_url}/finalize/{order_id}",
362
+ }
363
+ if order["cert_id"]:
364
+ view["certificate"] = f"{self.base_url}/cert/{order['cert_id']}"
365
+ return view
366
+
367
+ def _refresh_order(self, order: dict) -> None:
368
+ if order["status"] not in ("pending", "ready"):
369
+ return
370
+ all_valid = True
371
+ any_invalid = False
372
+ for a in order["authz_ids"]:
373
+ st = self.authzs[a]["status"]
374
+ if st == "invalid":
375
+ any_invalid = True
376
+ break
377
+ if st != "valid":
378
+ all_valid = False
379
+ if any_invalid:
380
+ order["status"] = "invalid"
381
+ elif all_valid:
382
+ order["status"] = "ready"
383
+
384
+ def get_order(self, order_id: str) -> dict:
385
+ if order_id not in self.orders:
386
+ raise ACMEError("orderNotReady", f"unknown order: {order_id}", 404)
387
+ return self._order_view(order_id)
388
+
389
+ # ---- authz / challenge ----------------------------------------
390
+ def _new_authz(self, kid: str, ident: dict) -> str:
391
+ authz_id = b64u(secrets.token_bytes(16))
392
+ chal_id = b64u(secrets.token_bytes(16))
393
+ token = b64u(secrets.token_bytes(32))
394
+
395
+ jwk = self.accounts[kid]["jwk"]
396
+ key_auth = f"{token}.{jwk_thumbprint(jwk)}"
397
+
398
+ self.authzs[authz_id] = {
399
+ "id": authz_id, "account": kid,
400
+ "identifier": ident,
401
+ "status": "pending",
402
+ "challenge_id": chal_id,
403
+ }
404
+ self.chals[chal_id] = {
405
+ "id": chal_id, "authz": authz_id,
406
+ "type": "http-01", "token": token,
407
+ "key_auth": key_auth, "status": "pending",
408
+ "error": None,
409
+ }
410
+ return authz_id
411
+
412
+ def get_authz(self, authz_id: str) -> dict:
413
+ a = self.authzs.get(authz_id)
414
+ if a is None:
415
+ raise ACMEError("malformed", f"unknown authz: {authz_id}", 404)
416
+ c = self.chals[a["challenge_id"]]
417
+ return {
418
+ "status": a["status"],
419
+ "expires": (datetime.now(timezone.utc)
420
+ + timedelta(days=7)).isoformat(),
421
+ "identifier": a["identifier"],
422
+ "challenges": [self._chal_view(c)],
423
+ }
424
+
425
+ def _chal_view(self, c: dict) -> dict:
426
+ v = {
427
+ "type": c["type"],
428
+ "url": f"{self.base_url}/challenge/{c['id']}",
429
+ "status": c["status"],
430
+ "token": c["token"],
431
+ }
432
+ if c["error"]:
433
+ v["error"] = {
434
+ "type": "urn:ietf:params:acme:error:unauthorized",
435
+ "detail": c["error"],
436
+ }
437
+ return v
438
+
439
+ def trigger_challenge(self, chal_id: str, *, fetcher=None) -> dict:
440
+ c = self.chals.get(chal_id)
441
+ if c is None:
442
+ raise ACMEError("malformed", f"unknown challenge: {chal_id}", 404)
443
+ if c["status"] == "valid":
444
+ return self._chal_view(c)
445
+
446
+ authz = self.authzs[c["authz"]]
447
+ domain = authz["identifier"]["value"]
448
+ path = f"/.well-known/acme-challenge/{c['token']}"
449
+
450
+ if self.auto_verify:
451
+ c["status"] = "valid"
452
+ authz["status"] = "valid"
453
+ return self._chal_view(c)
454
+
455
+ fetch = fetcher or self._default_fetcher
456
+ try:
457
+ body = fetch(f"http://{domain}{path}")
458
+ except Exception as e:
459
+ c["error"] = f"fetch failed: {e}"
460
+ c["status"] = "invalid"
461
+ authz["status"] = "invalid"
462
+ return self._chal_view(c)
463
+
464
+ if body.strip() != c["key_auth"]:
465
+ c["error"] = "key authorization mismatch"
466
+ c["status"] = "invalid"
467
+ authz["status"] = "invalid"
468
+ return self._chal_view(c)
469
+
470
+ c["status"] = "valid"
471
+ authz["status"] = "valid"
472
+ return self._chal_view(c)
473
+
474
+ @staticmethod
475
+ def _default_fetcher(url: str) -> str:
476
+ with urllib.request.urlopen(url, timeout=10) as r:
477
+ return r.read().decode("utf-8", "replace")
478
+
479
+ # ---- finalize --------------------------------------------------
480
+ def finalize(self, order_id: str, payload: dict) -> dict:
481
+ if order_id not in self.orders:
482
+ raise ACMEError("orderNotReady", f"unknown order: {order_id}", 404)
483
+ order = self.orders[order_id]
484
+ self._refresh_order(order)
485
+ if order["status"] != "ready":
486
+ raise ACMEError("orderNotReady",
487
+ f"order status is {order['status']}, expected ready",
488
+ 403)
489
+
490
+ csr_b64 = payload.get("csr")
491
+ if not csr_b64:
492
+ raise ACMEError("malformed", "finalize payload missing csr")
493
+ csr_der = b64u_decode(csr_b64)
494
+
495
+ try:
496
+ csr = CSR(csr_der)
497
+ except X509Error as e:
498
+ raise ACMEError("badCSR", f"cannot parse CSR: {e}")
499
+ if not csr.verify_signature():
500
+ raise ACMEError("badCSR", "CSR self-signature invalid")
501
+
502
+ csr_names = set(csr.dns_names)
503
+ for ident in order["identifiers"]:
504
+ if ident["value"] not in csr_names:
505
+ raise ACMEError("badCSR",
506
+ f"CSR missing identifier {ident['value']!r}")
507
+
508
+ leaf = (CertificateBuilder.from_csr(csr)
509
+ .add_issuer(self.ca)
510
+ .add_serial_random()
511
+ .add_validity(days=self.cert_days)
512
+ .sign(self.ca_key))
513
+
514
+ cert_id = b64u(secrets.token_bytes(16))
515
+ # RFC 8555 §7.4.2: сервер ОБЯЗАН отдать fullchain — leaf + issuer(s).
516
+ # Certbot требует минимум 2 сертификата. Если промежуточных нет,
517
+ # включаем сам CA как последний элемент chain.
518
+ chain_der = [c.der for c in self.chain]
519
+ if not chain_der:
520
+ chain_der = [self.ca.der]
521
+ self.certs[cert_id] = {
522
+ "der": leaf.der,
523
+ "chain_der": chain_der,
524
+ "order": order_id,
525
+ }
526
+ order["status"] = "valid"
527
+ order["cert_id"] = cert_id
528
+ return self._order_view(order_id)
529
+
530
+ def get_certificate(self, cert_id: str) -> str:
531
+ c = self.certs.get(cert_id)
532
+ if c is None:
533
+ raise ACMEError("malformed", f"unknown cert: {cert_id}", 404)
534
+ from .x509 import Cert as _C
535
+ pem = _C(c["der"]).to_pem()
536
+ for d in c["chain_der"]:
537
+ pem += _C(d).to_pem()
538
+ return pem
@@ -44,7 +44,7 @@ import socket
44
44
  import unicodedata
45
45
  import warnings as _warnings
46
46
 
47
- __version__ = "0.3.2"
47
+ __version__ = "0.4.0"
48
48
 
49
49
 
50
50
  # ======================================================================
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: x509kit
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Pure-Python X.509 / PKCS#10 / CRL / OCSP / PKCS#12 toolkit — stdlib only
5
5
  Author-email: ArtProsto <artemkamirn@mail.ru>
6
6
  License: MIT
@@ -22,10 +22,13 @@ examples/09_encrypted_key.py
22
22
  examples/10_verify_chain.py
23
23
  examples/11_name_canonicalization.py
24
24
  examples/12_extensions.py
25
+ examples/13_acme_server.py
26
+ examples/14_acme_client.py
25
27
  examples/README.md
26
28
  flask/test_live_ca.py
27
29
  flask/test_mtls.py
28
30
  src/x509kit/__init__.py
31
+ src/x509kit/acme.py
29
32
  src/x509kit/py.typed
30
33
  src/x509kit/x509.py
31
34
  src/x509kit.egg-info/PKG-INFO
@@ -1060,27 +1060,23 @@ def test_cert_info():
1060
1060
 
1061
1061
  info = cert.info()
1062
1062
 
1063
- checks = [
1064
- ("начинается с 'Certificate:'", info.startswith("Certificate:")),
1065
- ("есть 'Version: 3'", "Version: 3" in info),
1066
- ("есть 'Serial Number:'", "Serial Number:" in info),
1067
- ("есть 'Signature Algorithm:'", "Signature Algorithm:" in info),
1068
- ("есть 'Issuer:'", "Issuer:" in info),
1069
- ("есть 'Validity'", "Validity" in info),
1070
- ("есть 'Not Before'", "Not Before:" in info),
1071
- ("есть 'Not After'", "Not After :" in info),
1072
- ("есть 'Subject:'", "Subject:" in info),
1073
- ("есть 'Subject Public Key Info:'",
1074
- "Subject Public Key Info:" in info),
1075
- ("есть 'Public Key Algorithm: ED25519'",
1076
- "Public Key Algorithm: ED25519" in info),
1077
- ("есть 'X509v3 extensions:'", "X509v3 extensions:" in info),
1078
- ("есть 'X509v3 Subject Alternative Name'",
1079
- "X509v3 Subject Alternative Name" in info),
1080
- ("есть SAN содержимое", "info.example" in info),
1081
- ]
1082
- for label, ok in checks:
1083
- T.expect(label, ok)
1063
+ T.expect("начинается с 'Certificate:'", info.startswith("Certificate:"))
1064
+ T.expect("есть 'Version: 3'", "Version: 3" in info)
1065
+ T.expect("есть 'Serial Number:'", "Serial Number:" in info)
1066
+ T.expect("есть 'Signature Algorithm:'", "Signature Algorithm:" in info)
1067
+ T.expect("есть 'Issuer:'", "Issuer:" in info)
1068
+ T.expect("есть 'Validity'", "Validity" in info)
1069
+ T.expect("есть 'Not Before'", "Not Before:" in info)
1070
+ T.expect("есть 'Not After'", "Not After :" in info)
1071
+ T.expect("есть 'Subject:'", "Subject:" in info)
1072
+ T.expect("есть 'Subject Public Key Info:'",
1073
+ "Subject Public Key Info:" in info)
1074
+ T.expect("есть 'Public Key Algorithm: ED25519'",
1075
+ "Public Key Algorithm: ED25519" in info)
1076
+ T.expect("есть 'X509v3 extensions:'", "X509v3 extensions:" in info)
1077
+ T.expect("есть 'X509v3 Subject Alternative Name'",
1078
+ "X509v3 Subject Alternative Name" in info)
1079
+ T.expect("есть SAN содержимое", "info.example" in info)
1084
1080
 
1085
1081
 
1086
1082
  def test_pkcs12_shortcuts():
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