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.
- {x509kit-0.3.2/src/x509kit.egg-info → x509kit-0.4.0}/PKG-INFO +1 -1
- x509kit-0.4.0/examples/13_acme_server.py +211 -0
- x509kit-0.4.0/examples/14_acme_client.py +167 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/pyproject.toml +1 -1
- {x509kit-0.3.2 → x509kit-0.4.0}/src/x509kit/__init__.py +9 -0
- x509kit-0.4.0/src/x509kit/acme.py +538 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/src/x509kit/x509.py +1 -1
- {x509kit-0.3.2 → x509kit-0.4.0/src/x509kit.egg-info}/PKG-INFO +1 -1
- {x509kit-0.3.2 → x509kit-0.4.0}/src/x509kit.egg-info/SOURCES.txt +3 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/test_all.py +17 -21
- {x509kit-0.3.2 → x509kit-0.4.0}/CHANGELOG.md +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/LICENSE +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/MANIFEST.in +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/README.md +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/SECURITY.md +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/check_dispatcher.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/check_docs.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/check_no_hidden.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/compat_test.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/examples/01_root_ca.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/examples/02_issue_from_csr.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/examples/03_self_signed_server.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/examples/04_mtls_server.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/examples/05_crl_revocation.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/examples/06_ocsp_responder.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/examples/07_pkcs12_export.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/examples/08_parse_real_cert.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/examples/09_encrypted_key.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/examples/10_verify_chain.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/examples/11_name_canonicalization.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/examples/12_extensions.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/examples/README.md +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/flask/test_live_ca.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/flask/test_mtls.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/fuzz_x509.py +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/setup.cfg +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/src/x509kit/py.typed +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/src/x509kit.egg-info/dependency_links.txt +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/src/x509kit.egg-info/requires.txt +0 -0
- {x509kit-0.3.2 → x509kit-0.4.0}/src/x509kit.egg-info/top_level.txt +0 -0
|
@@ -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()
|
|
@@ -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
|
|
@@ -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
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
|
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
|