#!/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']}")