Para Irhis y el servicio de invitaciones de originmetaverse.com (CONTRATO_INVITACIONES_V1 §3.1, origin-metaverse-web d5f5f5e). Ella escribe sus tests antes que el servicio. - Firma sobre etiqueta || 0x00 || JCS(cuerpo). - 4 vectores que deben aceptarse y 11 que deben rechazarse: nonce repetido, ts fuera de ventana, etiqueta cruzada, llave de cabecera distinta, S no canonico, cuerpo cambiado, tres llaves de orden pequeno con firma forjada sin llave privada, y clave JSON duplicada. - Ed25519 en Python puro (referencia del RFC 8032), comprobado con los TEST 1 y 2 del RFC y con OpenSSL byte a byte. Salida determinista. Medido: OpenSSL 3.6.4 ACEPTA las tres firmas forjadas (F8-F10). La verificacion sola no basta; hace falta la comprobacion de llave fuerte, como is_strong_ed25519_key en la cadena (RV14). Contrafactual del generador: sin la comprobacion de orden pequeno, la de S < L o la etiqueta, se niega a emitir. Las comprobaciones de codificacion canonica se solapan con la de orden pequeno en F10 y ningun vector puede separarlas (ver LEEME). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
386 lines
17 KiB
Python
386 lines
17 KiB
Python
#!/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()
|