# -*- coding: utf-8 -*- r""" Firma del manifiesto de actualización (RSA-2048, PKCS#1 v1.5, SHA-256) sin dependencias. La clave privada vive fuera del proyecto, en %USERPROFILE%\.labstream\organizador-firma.json, y solo la usa empaquetar.py al publicar. La clave pública va incrustada en actualizador.py: un .exe instalado solo acepta manifiestos firmados con ella, así que aunque alguien pudiera cambiar el archivo en el servidor, no podría hacer que la app instale un paquete que no salió de aquí. py -3.12 firma.py generar crea la clave (si no existe) y muestra la pública para pegarla py -3.12 firma.py probar firma y verifica un texto de prueba """ import os import sys import json import hashlib import secrets RUTA_PRIVADA = os.path.join(os.path.expanduser("~"), ".labstream", "organizador-firma.json") BITS = 2048 E = 65537 # DigestInfo de SHA-256 (RFC 8017, apéndice B.1) _DI_SHA256 = bytes.fromhex("3031300d060960864801650304020105000420") def _es_primo(n, rondas=40): if n < 2: return False for p in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37): if n % p == 0: return n == p d, r = n - 1, 0 while d % 2 == 0: d //= 2 r += 1 for _ in range(rondas): a = secrets.randbelow(n - 3) + 2 x = pow(a, d, n) if x in (1, n - 1): continue for _ in range(r - 1): x = pow(x, 2, n) if x == n - 1: break else: return False return True def _primo(bits): while True: c = secrets.randbits(bits) | (1 << (bits - 1)) | 1 if _es_primo(c): return c def generar(ruta=RUTA_PRIVADA): """Crea la pareja de claves y guarda la privada en `ruta`. Devuelve la pública {n, e}.""" if os.path.exists(ruta): raise FileExistsError(f"ya hay una clave en {ruta}; no se sobrescribe") while True: p, q = _primo(BITS // 2), _primo(BITS // 2) n = p * q phi = (p - 1) * (q - 1) if p != q and n.bit_length() == BITS and phi % E: break d = pow(E, -1, phi) os.makedirs(os.path.dirname(ruta), exist_ok=True) with open(ruta, "w", encoding="utf-8") as fh: json.dump({"n": n, "e": E, "d": d}, fh) try: os.chmod(ruta, 0o600) except OSError: pass return {"n": n, "e": E} def cargar_privada(ruta=RUTA_PRIVADA): with open(ruta, encoding="utf-8") as fh: return json.load(fh) def _em(mensaje, k): """EMSA-PKCS1-v1_5: 00 01 FF..FF 00 DigestInfo || hash""" t = _DI_SHA256 + hashlib.sha256(mensaje).digest() return b"\x00\x01" + b"\xff" * (k - len(t) - 3) + b"\x00" + t def firmar(mensaje, priv): """Devuelve la firma en hex del mensaje (bytes).""" k = (priv["n"].bit_length() + 7) // 8 m = int.from_bytes(_em(mensaje, k), "big") return pow(m, priv["d"], priv["n"]).to_bytes(k, "big").hex() def verificar(mensaje, firma_hex, pub): """True si la firma corresponde al mensaje y a la clave pública {n, e}.""" try: k = (pub["n"].bit_length() + 7) // 8 s = bytes.fromhex(firma_hex) if len(s) != k: return False m = pow(int.from_bytes(s, "big"), pub["e"], pub["n"]).to_bytes(k, "big") return secrets.compare_digest(m, _em(mensaje, k)) except Exception: # noqa return False def texto_manifiesto(m): """Lo que se firma: los datos del manifiesto que deciden qué se instala. Cambiar cualquiera invalida la firma.""" app = m.get("app") or {} return "\n".join(["organilab-manifiesto-v1", str(m.get("version", "")), str(app.get("archivo", "")), str(app.get("sha256", "")).lower(), str(app.get("size", ""))]).encode("utf-8") if __name__ == "__main__": orden = sys.argv[1] if len(sys.argv) > 1 else "" if orden == "generar": if os.path.exists(RUTA_PRIVADA): pub = {k: cargar_privada()[k] for k in ("n", "e")} print("ya existía la clave en", RUTA_PRIVADA) else: print("generando clave RSA-2048 (unos segundos)…") pub = generar() print("clave privada guardada en", RUTA_PRIVADA, "— haz copia de seguridad: sin ella no se pueden publicar versiones nuevas") print("\nCLAVE_PUBLICA para actualizador.py:") print(f'CLAVE_PUBLICA = {{"e": {pub["e"]}, "n": {pub["n"]}}}') elif orden == "probar": priv = cargar_privada() pub = {"n": priv["n"], "e": priv["e"]} f = firmar(b"hola", priv) print("firma ok:", verificar(b"hola", f, pub), "· alterada:", verificar(b"holA", f, pub)) else: print(__doc__)