"""emerSSL: проверка клиентского сертификата по блокчейну Emercoin и разбор InfoCard. Перенос логики PHP echo-скриптов Олега (emerSSL-Nginx.php, emerSSL-InfoCard-Nginx.php; исходники лежат на сайте как *.php.txt). Сертификат приходит от Caddy в заголовке X-Client-Cert (DER в base64); сервис слушает только 127.0.0.1, подделать заголовок снаружи нельзя. Проверка: key = "ssl:" + серийный номер в hex, дополненный нулями слева до 16 знаков (номер короче 16 знаков, т.е. с ведущим нулём, — отказ, как в оригинале); NVS-значение, первая строка: "<алгоритм>=<отпечаток>" — сравниваем с хэшем DER сертификата. InfoCard: UID в subject = "info::<пароль>"; значение info: — gzip, зашифрованный `openssl enc -aes-256-cbc -salt` с ключом из пароля по EVP_BytesToKey(MD5) (старый OpenSSL). """ import base64 import gzip import hashlib import os import re import time import zlib import httpx from cryptography import x509 from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.x509.oid import NameOID _ATTR_NAMES = {NameOID.EMAIL_ADDRESS: "emailAddress", NameOID.USER_ID: "UID"} RPC_URLS = {"main": os.environ.get("EMC_RPC_MAIN", ""), "test": os.environ.get("EMC_RPC_TEST", "")} INFOCARD_LIMIT = 20 # глубина цепочки Import, как в оригинале INFOCARD_CACHE_TTL = 600 # 10 минут, как в оригинале INFOCARD_MAX_BYTES = 256 * 1024 _client = httpx.AsyncClient(timeout=10) _infocard_cache: dict[tuple[str, str, str], tuple[float, str | None, str | None]] = {} class NVSError(Exception): pass async def name_show(net: str, name: str, valuetype: str | None = None) -> dict: """name_show через RPC ноды. Параметры фиксированы: третий (filepath) пишет файл на ноде.""" url = RPC_URLS.get(net) if not url: raise NVSError(f"network {net!r} is not configured") params = [name] + ([valuetype] if valuetype else []) try: r = await _client.post(url, json={"jsonrpc": "1.0", "id": "emerssl", "method": "name_show", "params": params}) body = r.json() except (httpx.HTTPError, ValueError) as e: raise NVSError(f"node RPC unavailable: {e.__class__.__name__}") from e if body.get("error"): raise NVSError(str(body["error"].get("message", body["error"]))) return body["result"] # ---------------------------------------------------------------- certificate def load_cert(header: str | None) -> tuple[bytes, x509.Certificate] | None: if not header: return None try: der = base64.b64decode(header, validate=True) return der, x509.load_der_x509_certificate(der) except (ValueError, TypeError): return None def cert_info(der: bytes, cert: x509.Certificate) -> dict: return { "serial": format(cert.serial_number, "X"), "subject": [[_ATTR_NAMES.get(a.oid, a.rfc4514_attribute_name), str(a.value)] for a in cert.subject], "issuer": cert.issuer.rfc4514_string(), "not_after": cert.not_valid_after_utc.strftime("%Y-%m-%d"), "sha256": hashlib.sha256(der).hexdigest(), } def cert_uid(cert: x509.Certificate) -> str | None: attrs = cert.subject.get_attributes_for_oid(NameOID.USER_ID) return str(attrs[0].value) if attrs else None async def validate(net: str, der: bytes, cert: x509.Certificate) -> dict: """Возвращает {"ok", "result", "key", "address"}; result — как emerssl_validate() в PHP: "$" при успехе, иначе текст ошибки.""" key = format(cert.serial_number, "x").rjust(16, "0") out = {"ok": False, "key": "ssl:" + key, "address": None} if key[0] == "0": return out | {"result": "Wrong serial number - must not start from zero"} try: nvs = await name_show(net, out["key"]) except NVSError: return out | {"result": f"Cannot extract from NVS key={out['key']}"} if nvs.get("expires_in", 0) <= 0: return out | {"result": "NVS record expired, and is not trustable"} first = (nvs.get("value") or "").splitlines()[0:1] algo, _, emc_fp = (first[0] if first else "").partition("=") try: crt_fp = hashlib.new(algo.strip().lower(), der).hexdigest() except ValueError: return out | {"result": f"Unsupported hash algorithm in NVS record: {algo.strip()!r}"} if emc_fp.strip().lower() != crt_fp: return out | {"result": "False certificate provided"} return out | {"ok": True, "address": nvs.get("address"), "result": "$" + str(nvs.get("address"))} # ------------------------------------------------------------------- InfoCard def openssl_decrypt(blob: bytes, password: str) -> bytes: """`openssl enc -d -aes-256-cbc -md md5 -pass pass:` для блока "Salted__".""" if blob[:8] != b"Salted__": raise ValueError("value is not an OpenSSL salted blob") salt, ct = blob[8:16], blob[16:] material, prev = b"", b"" while len(material) < 48: prev = hashlib.md5(prev + password.encode() + salt).digest() material += prev dec = Cipher(algorithms.AES(material[:32]), modes.CBC(material[32:48])).decryptor() padded = dec.update(ct) + dec.finalize() unpad = padding.PKCS7(128).unpadder() return unpad.update(padded) + unpad.finalize() def gunzip_limited(data: bytes) -> bytes: d = zlib.decompressobj(16 + zlib.MAX_WBITS) out = d.decompress(data, INFOCARD_MAX_BYTES) if d.unconsumed_tail: raise ValueError("InfoCard too large") return out _LINE = re.compile(r"^(\S+)?(\s+)(.+)?") _KEY = re.compile(r"([+]?)([^+]+)([+]?)") class InfoCard: """Сборка InfoCard с цепочкой Import и квалификаторами +Key / Key+ (как emerssl_infocard).""" def __init__(self, net: str): self.net = net self.limit = INFOCARD_LIMIT self.card: dict[str, list[str]] = {} async def load(self, ref: str) -> str: """'$' при успехе, иначе текст ошибки.""" self.limit -= 1 if self.limit < 0: return "Too long InfoCard reference chain" parts = re.sub(r"[^0-9A-Za-z_:]", "", ref).split(":") if parts[0] != "info": return f"Unsupported InfoCard service type: {parts[0]}" if len(parts) < 3: return "Wrong InfoCard link format - missing password" text, err = await self._fetch(parts[1], parts[2]) if err: return err await self._parse(text) return "$" async def _fetch(self, key: str, password: str) -> tuple[str | None, str | None]: ck = (self.net, key, password) hit = _infocard_cache.get(ck) if hit and time.time() - hit[0] < INFOCARD_CACHE_TTL: return hit[1], hit[2] text = err = None try: nvs = await name_show(self.net, "info:" + key, "base64") if nvs.get("expires_in", 0) <= 0: err = "NVS record expired, and is not trustable" else: raw = openssl_decrypt(base64.b64decode(nvs["value"]), password) text = gunzip_limited(raw).decode("utf-8", "replace") except NVSError: err = f"Unable fetch from NVS value for key=info:{key}" except (ValueError, gzip.BadGzipFile, zlib.error): err = f"Unable to decrypt InfoCard info:{key} (wrong password?)" _infocard_cache[ck] = (time.time(), text, err) return text, err async def _parse(self, text: str) -> None: k = old_k = "" loc: list[str] = [] for line in text.splitlines(keepends=True): m = _LINE.match(line) if not m: continue if m.group(1): k = m.group(1) v = "" if m.group(3) is not None: v = m.group(3).replace("\\#", "\0") # \# — экранированная решётка v = re.sub(r"\s*#.*", "", v) # остальное после # — комментарий v = v.replace("\0", "#").rstrip() if k and v: if k != old_k: await self._merge(old_k, loc) loc = [] loc.append(v) old_k = k await self._merge(old_k, loc) async def _merge(self, k: str, loc: list[str]) -> None: if not k: return if k == "Import": for ref in loc: await self.load(ref) return m = _KEY.match(k) if not m or not m.group(2): return before, name, after = bool(m.group(1)), m.group(2), bool(m.group(3)) if not before and not after: self.card[name] = list(loc) elif before and not after: # +Key: список родителя перед текущим self.card[name] = self.card.get(name, []) + loc elif after and not before: # Key+: родитель после текущего self.card[name] = loc + self.card.get(name, [])