docs(vectores): la firma OIT de las invitaciones V1, con los casos que deben fallar

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>
This commit is contained in:
CharonandClaude Opus 5.5 committed 2026-10-28 04:27:14 -05:00
1 parent e7b9c0c8bf
commit abe680e853
3 files changed
+893

No files matched your search

+385
View File
@@ -0,0 +1,385 @@
#!/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()