#!/usr/bin/env python3 """Vectores de prueba de la firma OIT de las invitaciones V1 (CONTRATO_INVITACIONES_V1 §3.1). python3 generar.py > invitaciones-v1.json Bytes firmados: etiqueta || 0x00 || JCS(cuerpo). Ed25519 en Python puro, sobre el código de referencia del RFC 8032 §6, para no depender de la misma librería que se va a probar. Antes de emitir nada se comprueba contra los vectores del RFC 8032 (TEST 1 y 2) y contra OpenSSL. JCS (RFC 8785) se implementa sólo para el dominio de estos cuerpos: claves y cadenas ASCII y enteros < 2^53. Para ese dominio JCS es exactamente claves ordenadas, sin espacios y los escapes mínimos de JSON; fuera de él, este generador se niega. Las llaves son las semillas PÚBLICAS de los TEST 1 y 2 del RFC 8032: nunca valen para nada real. """ import base64, calendar, hashlib, json, os, subprocess, sys, tempfile # ── Ed25519 (RFC 8032 §6) ──────────────────────────────────────────────────── p = 2**255 - 19 L = 2**252 + 27742317777372353535851937790883648493 d = -121665 * pow(121666, p - 2, p) % p SQRT_M1 = pow(2, (p - 1) // 4, p) IDENTIDAD = (0, 1, 1, 0) def sha512_modl(b): return int.from_bytes(hashlib.sha512(b).digest(), "little") % L def sumar(P, Q): A = (P[1] - P[0]) * (Q[1] - Q[0]) % p B = (P[1] + P[0]) * (Q[1] + Q[0]) % p C = 2 * P[3] * Q[3] * d % p D = 2 * P[2] * Q[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 multiplicar(s, P): Q = IDENTIDAD while s > 0: if s & 1: Q = sumar(Q, P) P = sumar(P, P) s >>= 1 return Q def iguales(P, Q): return (P[0] * Q[2] - Q[0] * P[2]) % p == 0 and (P[1] * Q[2] - Q[1] * P[2]) % p == 0 def recuperar_x(y, signo): if y >= p: return None x2 = (y * y - 1) * pow(d * y * y + 1, p - 2, p) if x2 == 0: return None if signo else 0 x = pow(x2, (p + 3) // 8, p) if (x * x - x2) % p != 0: x = x * SQRT_M1 % p if (x * x - x2) % p != 0: return None if (x & 1) != signo: x = p - x return x gy = 4 * pow(5, p - 2, p) % p gx = recuperar_x(gy, 0) BASE = (gx, gy, 1, gx * gy % p) def comprimir(P): zi = pow(P[2], p - 2, p) x, y = P[0] * zi % p, P[1] * zi % p return int.to_bytes(y | ((x & 1) << 255), 32, "little") def descomprimir(s, laxo=False): """Estricto: rechaza y >= p y el «cero negativo», como el RFC 8032. Laxo: reduce y mod p, como hacen los verificadores permisivos (ZIP-215 y otros).""" y = int.from_bytes(s, "little") signo = y >> 255 y &= (1 << 255) - 1 if laxo: y %= p x = recuperar_x(y, signo) if x is None and laxo and signo: x = recuperar_x(y, 0) if x is None: return None return (x, y, 1, x * y % p) def expandir(semilla): h = hashlib.sha512(semilla).digest() a = int.from_bytes(h[:32], "little") a &= (1 << 254) - 8 a |= 1 << 254 return a, h[32:] def publica(semilla): return comprimir(multiplicar(expandir(semilla)[0], BASE)) def firmar(semilla, msg): a, prefijo = expandir(semilla) A = comprimir(multiplicar(a, BASE)) r = sha512_modl(prefijo + msg) R = comprimir(multiplicar(r, BASE)) h = sha512_modl(R + A + msg) return R + int.to_bytes((r + h * a) % L, 32, "little") def orden_pequeno(P): return iguales(multiplicar(8, P), IDENTIDAD) def verificar(pub, msg, firma, modo): """modo «estricto»: lo que §3.1 exige (codificación canónica de A y R, S < L, A de orden no pequeño). modo «laxo»: sólo la ecuación, con A y S tal como lleguen. Sirve para enseñar qué vectores pasan en un verificador permisivo y por qué hace falta cada comprobación.""" if len(pub) != 32 or len(firma) != 64: return False estricto = modo == "estricto" A = descomprimir(pub, laxo=not estricto) R = descomprimir(firma[:32], laxo=not estricto) if A is None or R is None: return False S = int.from_bytes(firma[32:], "little") if estricto and (S >= L or comprimir(A) != pub or orden_pequeno(A)): return False h = sha512_modl(firma[:32] + pub + msg) return iguales(multiplicar(S % L if not estricto else S, BASE), sumar(R, multiplicar(h, A))) # ── comprobaciones del propio generador ───────────────────────────────────── RFC = [ # RFC 8032 §7.1, TEST 1 y TEST 2 ("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "", "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"), ("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", "72", "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da" "085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"), ] for sk, pk, m, sig in RFC: sk, m = bytes.fromhex(sk), bytes.fromhex(m) assert publica(sk).hex() == pk, "la llave pública no coincide con el RFC 8032" assert firmar(sk, m).hex() == sig, "la firma no coincide con el RFC 8032" assert verificar(bytes.fromhex(pk), m, bytes.fromhex(sig), "estricto") assert not verificar(bytes.fromhex(pk), m + b"x", bytes.fromhex(sig), "estricto") def openssl_firma(semilla, msg): """Segunda implementación independiente: OpenSSL, con la misma semilla (Ed25519 es determinista, así que la firma tiene que salir idéntica byte a byte).""" der = bytes.fromhex("302e020100300506032b657004220420") + semilla with tempfile.TemporaryDirectory() as t: k, mm = os.path.join(t, "k.der"), os.path.join(t, "m") open(k, "wb").write(der) open(mm, "wb").write(msg) return subprocess.run(["openssl", "pkeyutl", "-sign", "-inkey", k, "-keyform", "DER", "-rawin", "-in", mm], capture_output=True, check=True).stdout def openssl_verifica(pub, msg, firma): """Lo que dice un verificador de terceros muy usado. Medido el 2026-10-28 con OpenSSL 3.6.4: ACEPTA F8, F9 y F10, las firmas forjadas sin llave privada.""" with tempfile.TemporaryDirectory() as t: k, mm, ss = (os.path.join(t, n) for n in ("k.der", "m", "s")) open(k, "wb").write(bytes.fromhex("302a300506032b6570032100") + pub) open(mm, "wb").write(msg) open(ss, "wb").write(firma) return subprocess.run(["openssl", "pkeyutl", "-verify", "-pubin", "-inkey", k, "-keyform", "DER", "-rawin", "-in", mm, "-sigfile", ss], capture_output=True).returncode == 0 # ── JCS restringido ───────────────────────────────────────────────────────── def jcs(obj): for k, v in obj.items(): assert isinstance(k, str) and k.isascii(), k if isinstance(v, str): assert v.isascii() and all(0x20 <= ord(c) < 0x7f and c not in '"\\' for c in v), v else: assert isinstance(v, int) and not isinstance(v, bool) and 0 <= v < 2**53, v # con claves ASCII, el orden por unidades UTF-16 de RFC 8785 es el orden de bytes return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() def det(etiqueta, n): """Bytes de prueba deterministas. En producción, nonce y token son ALEATORIOS.""" return hashlib.sha256(b"origin-invitaciones/vectores-de-prueba/" + etiqueta.encode()).digest()[:n] ETQ = {op: f"origin-invitaciones/{op}/v1" for op in ("crear", "canjear", "revocar")} AUD = "originmetaverse.com" TS = calendar.timegm((2026, 10, 28, 12, 0, 0)) LLAVE_A = bytes.fromhex(RFC[0][0]) # quien invita LLAVE_B = bytes.fromhex(RFC[1][0]) # quien canjea PUB_A, PUB_B = publica(LLAVE_A), publica(LLAVE_B) TOKEN = b64u(det("token", 32)) HUELLA = hashlib.sha256(TOKEN.encode()).hexdigest() assert len(TOKEN) == 43 def mensaje(etiqueta, cuerpo): return etiqueta.encode() + b"\x00" + jcs(cuerpo) def cuerpo(pub, nonce, **extra): return {"aud": AUD, "ts": TS, "nonce": b64u(det(nonce, 16)), "pub": b64u(pub), **extra} C_CREAR = cuerpo(PUB_A, "n1", destino="mundo-de-prueba.v1", expiraEn=TS + 7 * 86400) C_CANJEAR = cuerpo(PUB_B, "n2", token=TOKEN) C_REVOCAR = cuerpo(PUB_A, "n3", huella=HUELLA) vectores = [] def caso(id, op, c, firma, llave_cabecera, espera, motivo, reloj=TS, nota=None, transporte=None, firmado_con=None): m = mensaje(ETQ[op], c) v = { "id": id, "operacion": op, "etiqueta": ETQ[op], "cuerpo": c, "jcs": jcs(c).decode(), "transporte_json": transporte if transporte is not None else jcs(c).decode(), "mensaje_hex": m.hex(), "cabeceras": {"Origin-Llave": b64u(llave_cabecera), "Origin-Firma": b64u(firma)}, "reloj_del_servidor": reloj, "espera": espera, "motivo": motivo, "verificador_estricto_acepta_la_firma": verificar(llave_cabecera, m, firma, "estricto"), "verificador_laxo_acepta_la_firma": verificar(llave_cabecera, m, firma, "laxo"), "openssl_acepta_la_firma": openssl_verifica(llave_cabecera, m, firma), } if firmado_con: v["firmado_con"] = firmado_con if nota: v["nota"] = nota vectores.append(v) return v # Válidos for id, op, c, sk, pub in (("V1", "crear", C_CREAR, LLAVE_A, PUB_A), ("V2", "canjear", C_CANJEAR, LLAVE_B, PUB_B), ("V3", "revocar", C_REVOCAR, LLAVE_A, PUB_A)): f = firmar(sk, mensaje(ETQ[op], c)) assert f == openssl_firma(sk, mensaje(ETQ[op], c)), "OpenSSL no da la misma firma" caso(id, op, c, f, pub, "acepta", "petición válida") F_V1 = firmar(LLAVE_A, mensaje(ETQ["crear"], C_CREAR)) # El orden y los espacios del transporte no importan: el servidor re-canonicaliza. caso("V4", "crear", C_CREAR, F_V1, PUB_A, "acepta", "mismo V1 con otro orden de claves y espacios en el transporte: el servidor firma sobre JCS, " "no sobre los bytes recibidos", transporte='{ "pub": "%s", "ts": %d, "nonce": "%s", "aud": "%s", "expiraEn": %d, "destino": "%s" }' % (C_CREAR["pub"], TS, C_CREAR["nonce"], AUD, C_CREAR["expiraEn"], C_CREAR["destino"]), nota="usar como petición nueva; si se manda DESPUÉS de V1, es el caso F2 (nonce repetido)") # Deben fallar caso("F1", "crear", C_CREAR, F_V1, PUB_A, "rechaza", "nonce repetido", nota="enviar V1 y luego exactamente la misma petición: la firma es buena, el par (pub, nonce) " "ya se usó dentro de la ventana") caso("F2", "crear", C_CREAR, F_V1, PUB_A, "rechaza", "ts fuera de la ventana de ±5 min", reloj=TS + 301, nota="mismo V1, con el reloj del servidor 301 s por delante") caso("F3", "crear", C_CREAR, F_V1, PUB_A, "rechaza", "ts fuera de la ventana de ±5 min", reloj=TS - 301, nota="mismo V1, con el reloj del servidor 301 s por detrás (ts del futuro)") # F4: la firma de «crear» puesta sobre un cuerpo de «canjear» f_cruzada = firmar(LLAVE_B, mensaje(ETQ["crear"], C_CANJEAR)) caso("F4", "canjear", C_CANJEAR, f_cruzada, PUB_B, "rechaza", "etiqueta cruzada: la firma se hizo con la etiqueta de crear sobre el cuerpo de canjear", firmado_con=ETQ["crear"]) # F5: Origin-Llave distinta del pub firmado caso("F5", "crear", C_CREAR, F_V1, PUB_B, "rechaza", "Origin-Llave no es igual al pub del cuerpo (debe rechazarse ANTES de verificar)") # F6: S no canónico (S + L): misma firma, otros bytes S = int.from_bytes(F_V1[32:], "little") f_mal = F_V1[:32] + int.to_bytes(S + L, 32, "little") caso("F6", "crear", C_CREAR, f_mal, PUB_A, "rechaza", "S no canónico (S >= L): firma maleable") # F7: firma de otro cuerpo (cambió destino después de firmar) otro = dict(C_CREAR, destino="otro-mundo") caso("F7", "crear", otro, F_V1, PUB_A, "rechaza", "el cuerpo no es el firmado (destino cambiado)") # F8-F10: llaves de orden pequeño con la firma trivial, sin llave privada def forjar(pub_bytes, etiqueta_nonce): A = descomprimir(pub_bytes, laxo=True) sub = [IDENTIDAD] while True: nxt = sumar(sub[-1], A) if iguales(nxt, IDENTIDAD): break sub.append(nxt) for intento in range(64): c = cuerpo(pub_bytes, f"{etiqueta_nonce}-{intento}", destino="mundo-de-prueba.v1", expiraEn=TS + 7 * 86400) m = mensaje(ETQ["crear"], c) for Rp in sub: Rb = comprimir(Rp) h = sha512_modl(Rb + pub_bytes + m) if iguales(sumar(Rp, multiplicar(h, A)), IDENTIDAD): return c, Rb + bytes(32) raise SystemExit("no se pudo forjar") for id, hexpub, nombre in ( ("F8", "01" + "00" * 31, "la identidad (orden 1)"), ("F9", "ec" + "ff" * 30 + "7f", "el punto de orden 2"), ("F10", "ee" + "ff" * 30 + "7f", "una codificación NO canónica de la identidad (y = p + 1)")): pb = bytes.fromhex(hexpub) c, f = forjar(pb, id) v = caso(id, "crear", c, f, pb, "rechaza", f"llave de orden pequeño: {nombre}. Firma trivial R de orden pequeño y S = 0, hecha SIN " "llave privada", nota="la ecuación de verificación SE CUMPLE: sólo la rechaza la comprobación explícita " "de llave fuerte (en la cadena, is_strong_ed25519_key; RV14)") assert v["verificador_laxo_acepta_la_firma"], f"{id}: la forja debería pasar la ecuación" if not v["openssl_acepta_la_firma"]: print(f"aviso: este OpenSSL ya rechaza {id}", file=sys.stderr) assert not v["verificador_estricto_acepta_la_firma"] # F11: clave duplicada en el transporte caso("F11", "crear", C_CREAR, F_V1, PUB_A, "rechaza", "clave duplicada en el JSON recibido. JCS (RFC 8785 §3.1, I-JSON) no la admite, y JSON.parse " "se queda en silencio con la última", transporte=jcs(C_CREAR).decode()[:-1] + ',"destino":"otro-mundo"}') # coherencia final for v in vectores: if v["espera"] == "acepta": assert v["verificador_estricto_acepta_la_firma"], v["id"] for id in ("F4", "F6", "F7"): v = next(x for x in vectores if x["id"] == id) assert not v["verificador_estricto_acepta_la_firma"], id salida = { "version": "origin-invitaciones/vectores/v1", "contrato": "origin-metaverse-web docs/CONTRATO_INVITACIONES_V1.md §3.1 (d5f5f5e)", "autora": "Charon, 2026-10-28", "mensaje_firmado": "utf8(etiqueta) || 0x00 || JCS(cuerpo)", "codificacion": "pub, firma, nonce y token en base64url sin relleno; huella = sha256(token) en hex", "llaves_de_prueba": { "A_quien_invita": {"semilla_hex": LLAVE_A.hex(), "pub_hex": PUB_A.hex(), "pub_b64url": b64u(PUB_A), "origen": "RFC 8032 §7.1 TEST 1 (pública: nunca para nada real)"}, "B_quien_canjea": {"semilla_hex": LLAVE_B.hex(), "pub_hex": PUB_B.hex(), "pub_b64url": b64u(PUB_B), "origen": "RFC 8032 §7.1 TEST 2"}, }, "token_de_prueba": TOKEN, "huella_de_prueba": HUELLA, "comprobado_con": ["RFC 8032 TEST 1 y 2 (llave y firma)", "OpenSSL " + subprocess.run( ["openssl", "version"], capture_output=True, text=True).stdout.split()[1] + " (V1-V3 byte a byte)"], "como_usarlos": [ "Cada vector es una petición: transporte_json es el cuerpo HTTP y cabeceras sus cabeceras.", "Fija el reloj del servidor en reloj_del_servidor y empieza cada vector con el almacén de " "nonces vacío, salvo F1, que va justo después de V1.", "espera = acepta | rechaza. Un test que no sepa reprobar no vale: quita cada comprobación " "y mira que su vector pase a aceptarse.", "verificador_laxo_acepta_la_firma dice qué pasaría con un verificador permisivo: F6 y F8-F10 " "pasan ahí. F8-F10 cumplen la ecuación: OpenSSL 3.6.4 los ACEPTA (openssl_acepta_la_firma). " "Si tu verify con zip215:false tampoco los rechaza, falta la comprobación de llave fuerte: " "decodificar pub, exigir que vuelva a codificarse igual y que no sea de orden pequeño.", ], "supuestos_por_confirmar": [ "expiraEn es un instante absoluto en segundos (ts + 7 días aquí). Si es una duración, se regeneran.", "canjear firma {aud, nonce, pub, token, ts}: pub es la OIT de quien canjea y el token (la " "preimagen) va DENTRO de lo firmado. El §3 punto 3 dice {token, canjeadorOit, firma} y el " "§3.1 dice «la huella del token»: con la huella sola el servidor no puede comprobar la preimagen.", "revocar firma {aud, huella, nonce, pub, ts}, con pub igual a invitadorOit.", ], "vectores": vectores, } json.dump(salida, sys.stdout, ensure_ascii=False, indent=2) print()