#!/usr/bin/env python3 """vsp-publish — assina e registra no Verifica a versão publicada de um site (VSP1). Uso típico no fim do deploy (a chave vem do cofre do painel, como uma API key): VSP_KEY=vsp1_... python3 vsp-publish.py --domain exemplo.com.br --dir ./build \ [--base /] [--page /login --page /conta] [--out /var/www/site/.well-known/vsp.json] --dir pasta com os arquivos exatamente como estão no ar (modo exato) --base caminho da pasta no site (padrão /) --page página dinâmica em modo código (pode repetir); lida do site no ar --out onde gravar o vsp.json (padrão: /.well-known/vsp.json) Só usa a biblioteca padrão do Python 3.8+. Se o pacote `cryptography` existir, assina com ele; senão, com uma implementação Ed25519 (RFC 8032) em Python puro. """ import argparse import base64 import hashlib import json import os import sys import time import urllib.error import urllib.request API = "https://johnnydevit.cloud/verifica/api" PKCS8_PREFIX = bytes.fromhex("302e020100300506032b657004220420") MAX_ITEMS = 500 # --- Ed25519 (RFC 8032, seção 6) para quando `cryptography` não estiver instalado. _P = 2 ** 255 - 19 _D = -121665 * pow(121666, _P - 2, _P) % _P _Q = 2 ** 252 + 27742317777372353535851937790883648493 def _add(a, b): A = (a[1] - a[0]) * (b[1] - b[0]) % _P B = (a[1] + a[0]) * (b[1] + b[0]) % _P C = 2 * a[3] * b[3] * _D % _P D = 2 * a[2] * b[2] % _P E, F, G, H = B - A, D - C, D + C, B + A return (E * F % _P, G * H % _P, F * G % _P, E * H % _P) def _mul(s, pt): acc = (0, 1, 1, 0) while s: if s & 1: acc = _add(acc, pt) pt = _add(pt, pt) s >>= 1 return acc def _recover_x(y, sign): x2 = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P) % _P x = pow(x2, (_P + 3) // 8, _P) if (x * x - x2) % _P: x = x * pow(2, (_P - 1) // 4, _P) % _P if (x & 1) != sign: x = _P - x return x _GY = 4 * pow(5, _P - 2, _P) % _P _GX = _recover_x(_GY, 0) _G = (_GX, _GY, 1, _GX * _GY % _P) def _compress(pt): zi = pow(pt[2], _P - 2, _P) x, y = pt[0] * zi % _P, pt[1] * zi % _P return (y | ((x & 1) << 255)).to_bytes(32, "little") def _pure_sign(seed: bytes, msg: bytes): h = hashlib.sha512(seed).digest() a = int.from_bytes(h[:32], "little") & ((1 << 254) - 8) | (1 << 254) pub = _compress(_mul(a, _G)) r = int.from_bytes(hashlib.sha512(h[32:] + msg).digest(), "little") % _Q R = _compress(_mul(r, _G)) k = int.from_bytes(hashlib.sha512(R + pub + msg).digest(), "little") % _Q return R + ((r + k * a) % _Q).to_bytes(32, "little"), pub def keypair(seed: bytes): """→ (função de assinatura, chave pública crua de 32 bytes).""" try: from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey sk = Ed25519PrivateKey.from_private_bytes(seed) pub = sk.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) return sk.sign, pub except ImportError: pub = _pure_sign(seed, b"")[1] return (lambda m: _pure_sign(seed, m)[0]), pub def load_seed(args) -> bytes: if args.key_file: with open(args.key_file) as f: der = base64.b64decode(json.load(f)["private_key_pkcs8"]) else: raw = os.environ.get("VSP_KEY", "").strip() if not raw.startswith("vsp1_"): sys.exit("defina VSP_KEY (copie a chave no painel do Verifica) ou use --key-file") b = raw[5:].replace("-", "+").replace("_", "/") der = base64.b64decode(b + "=" * (-len(b) % 4)) if len(der) != 48 or not der.startswith(PKCS8_PREFIX): sys.exit("chave em formato inesperado (esperado Ed25519 PKCS#8)") return der[16:] def call(path, body=None): req = urllib.request.Request(args.api + path, json.dumps(body).encode() if body is not None else None, {"content-type": "application/json", "user-agent": "vsp-publish/1"}) try: with urllib.request.urlopen(req, timeout=120) as r: return json.load(r) except urllib.error.HTTPError as e: try: msg = json.load(e).get("error") except Exception: msg = None sys.exit(f"{path}: HTTP {e.code} {msg or ''}".strip()) def collect(directory: str, base: str) -> dict: files = {} base = "/" + base.strip("/") + "/" if base.strip("/") else "/" for root, dirs, names in os.walk(directory): dirs[:] = sorted(d for d in dirs if not d.startswith(".") or d == ".well-known") for n in sorted(names): if n.startswith("."): continue full = os.path.join(root, n) rel = os.path.relpath(full, directory).replace(os.sep, "/") path = base + rel if path.endswith("/.well-known/vsp.json"): continue with open(full, "rb") as f: files[path] = hashlib.sha256(f.read()).hexdigest() return files if __name__ == "__main__": ap = argparse.ArgumentParser(description="Assina e registra a versão do site no Verifica (VSP1).") ap.add_argument("--domain", required=True) ap.add_argument("--dir", help="pasta publicada (modo exato)") ap.add_argument("--base", default="/") ap.add_argument("--page", action="append", default=[], help="página em modo código (repetível)") ap.add_argument("--out", help="onde gravar o vsp.json") ap.add_argument("--key-file", help="arquivo vsp-chave-*.json (em vez de VSP_KEY)") ap.add_argument("--api", default=API) args = ap.parse_args() domain = args.domain.strip().lower() if not args.dir and not args.page: sys.exit("informe --dir e/ou --page") sign, pub = keypair(load_seed(args)) pub_b64 = base64.b64encode(pub).decode() site = call("/site/" + domain) if site["key"]["public_key"] != pub_b64: sys.exit("esta chave não é a registrada para o domínio (troque o TXT e registre a chave nova no painel)") files = collect(args.dir, args.base) if args.dir else {} pages = {} for pg in args.page: pg = pg if pg.startswith("/") else "/" + pg out = call("/surface", {"domain": domain, "path": pg}) pages[out["path"]] = out["surface"] files.update(out["assets"]) for pg in pages: # página em modo código vence o arquivo de mesmo caminho files.pop(pg, None) if pg.endswith("/"): files.pop(pg + "index.html", None) if not 1 <= len(files) + len(pages) <= MAX_ITEMS: sys.exit(f"a versão precisa ter de 1 a {MAX_ITEMS} arquivos e páginas (tem {len(files) + len(pages)})") prev = site["versions"][0]["manifest_sha"] if site["versions"] else None body = {"v": "VSP1", "domain": domain, "issued_at": int(time.time()), "prev": prev, "files": dict(sorted(files.items()))} if pages: body["pages"] = dict(sorted(pages.items())) manifest = json.dumps(body, separators=(",", ":"), ensure_ascii=False) res = call("/publish", {"domain": domain, "manifest": manifest, "sig": base64.b64encode(sign(manifest.encode())).decode()}) out_path = args.out or (os.path.join(args.dir, ".well-known", "vsp.json") if args.dir else "vsp.json") os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True) with open(out_path, "w") as f: json.dump(res["vsp_json"], f, indent=2, ensure_ascii=False) print(f"versão {res['seq']} registrada no bloco #{res['block']} ({len(files)} arquivos, {len(pages)} páginas)") print(f"vsp.json gravado em {out_path} — publique em https://{domain}/.well-known/vsp.json") print(f"verificação: {res['verify_url']}")