Criptografía ECDSA e Integridad del Documento
El ecosistema Object Interactive Media (OIM) implementa criptografía asimétrica para certificar la autenticidad, la autoría y la integridad a nivel de byte de cada documento .oim y .ovm. Esta página detalla la especificación técnica de los algoritmos criptográficos, las normas de serialización canónica, la estructura binaria de las firmas, la distribución de claves públicas y los procedimientos de verificación.
1. Arquitectura Criptográfica General
Cada binario .oim empaquetado incorpora una firma digital generada mediante el algoritmo de firma digital de curva elíptica (ECDSA) en los metadatos de su contenedor. Este sello proporciona tres garantías nucleares:
- Integridad del Documento: Detecta cualquier alteración en los slots interactivos, diccionarios de idiomas, coordenadas, animaciones, geometrías vectoriales o estilos visuales.
- Enlace Criptográfico de Medios: Vincula matemáticamente los metadatos de la Capa +1 · Dinámica con los píxeles de la imagen horneada (Capa −1) mediante el algoritmo SHA-256.
- Gobierno de Reedición: Hace cumplir el ciclo de vida del archivo (
public,author_only,sealed), impidiendo sobreescrituras o manipulaciones no autorizadas.
┌────────────────────────────────────────────────────────────────────────┐
│ Contenedor .oim (PNG) │
│ ┌───────────────────────────┐ ┌───────────────────────────────┐ │
│ │ Capa −1: Horneada │ │ Capa +1: Dinámica (metadatos) │ │
│ │ (Raster Sin Pérdida) │ │ (Chunk zTXt: "OIM_Data") │ │
│ └─────────────┬─────────────┘ └───────────────┬───────────────┘ │
│ │ SHA-256 │ │
│ ▼ ▼ │
│ [image_hash] ──────────────────► [Serialización Canónica] │
│ │ │
│ ▼ │
│ Firma ECDSA NIST P-256 │
│ (Formato Crudo IEEE P1363) │
└────────────────────────────────────────────────────────────────────────┘2. Primitivas Criptográficas
2.1. Parámetros de la Curva: NIST P-256 (secp256r1)
OIM utiliza de forma estandarizada la curva elíptica NIST P-256 (definida en FIPS 186-4, ANSI X9.62 y SECG SEC 2; OID 1.2.840.10045.3.1.7), comúnmente llamada prime256v1.
La curva opera sobre el cuerpo finito primo $\mathbb{F}_p$ siguiendo la ecuación corta de Weierstrass: $$y^2 \equiv x^3 + a x + b \pmod{p}$$
| Parámetro | Notación | Valor Hexadecimal |
|---|---|---|
| Primo del Cuerpo | $p$ | 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF |
| Coeficiente $a$ | $a$ | 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC ($p - 3$) |
| Coeficiente $b$ | $b$ | 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B |
| Punto Base $G_x$ | $G_x$ | 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296 |
| Punto Base $G_y$ | $G_y$ | 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5 |
| Orden $n$ | $n$ | 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 |
| Cofactor | $h$ | 0x01 |
2.2. Función Resumen: SHA-256
La función de hash empleada es SHA-256 (FIPS 180-4), que produce un resumen criptográfico de 256 bits sobre la secuencia binaria canónica UTF-8.
3. Formato de Firma: Estructura Cruda IEEE P1363
Una firma digital ECDSA se compone matemáticamente de un par de números enteros $(r, s)$ módulo $n$. Existen dos formatos principales de serialización:
- Secuencia ASN.1 DER (
RFC 3279): Formato de longitud variable (70 a 72 bytes) con etiquetas TLV y relleno de signo en caso de enteros positivos con bit alto activo. - Formato Crudo IEEE P1363: Estructura de longitud fija de exactamente 64 bytes que concatena $r$ y $s$ como enteros grandes sin signo en orden big-endian.
3.1. Estructura Binaria IEEE P1363
OIM estandariza exclusivamente el formato IEEE P1363:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
+ Coordenada r (32 bytes) +
| Entero Big-Endian Sin Signo |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
+ Coordenada s (32 bytes) +
| Entero Big-Endian Sin Signo |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- Bytes 0 al 31 (32 bytes): Coordenada $r$, rellenada con ceros a la izquierda si ocupa menos de 32 bytes.
- Bytes 32 al 63 (32 bytes): Coordenada $s$, rellenada con ceros a la izquierda si ocupa menos de 32 bytes.
- Cadena Base64: Los 64 bytes crudos se codifican en Base64 estándar (cadena de 88 caracteres terminada en
=) y se almacenan enmetadata.signature.
¿Por qué IEEE P1363?
La API nativa del navegador Web Cryptography API (window.crypto.subtle.verify) exige estrictamente el formato IEEE P1363 para firmas ECDSA. Utilizar este formato directamente evita incluir librerías pesadas de análisis ASN.1 en el bundle del cliente.
4. Serialización Canónica Recursiva (serialize_canonical)
Dado que la especificación de JSON no impone un orden para las claves de los objetos y los entornos de ejecución formatean los números de coma flotante con variaciones sutiles (Python frente a los motores JavaScript V8 o SpiderMonkey), la verificación criptográfica exige una normalización previa determinista.
4.1. Reglas de Normalización
El algoritmo de serialización canónica aplica las siguientes transformaciones en orden:
Exclusión de Campos Dinámicos de Cabecera: Se eliminan los campos que no forman parte de la carga útil original:
"signature": Evita dependencias circulares."key_version": Identificador dinámico de clave de empaquetado."timestamp": Marca temporal de empaquetado.
Exclusión de Datos Personales (
creator_user_id):- El identificador
creator_user_idse descarta antes de generar los bytes canónicos y nunca se escribe en el archivo final. - Cumplimiento de RGPD: La identidad del autor es un dato personal. Sellarla de manera permanente en un archivo binario distribuido impediría atender el Derecho de Supresión («Derecho al Olvido»). La autoría reside en el registro revocable del backend, no grabada en los bytes del archivo.
- El identificador
Normalización Numérica (
clean_floats):- Flotantes Enteros: Todo valor de coma flotante que represente matemáticamente un entero (por ejemplo,
50.0) se convierte a entero (50). - Flotantes Fraccionarios: Cualquier número con decimales se redondea a un máximo de 6 posiciones (
round(val, 6)). - Esto suprime las discrepancias en representaciones IEEE 754 entre diferentes lenguajes.
- Flotantes Enteros: Todo valor de coma flotante que represente matemáticamente un entero (por ejemplo,
Ordenación Recursiva de Claves:
- Todas las claves de los diccionarios u objetos en todos los niveles de profundidad se ordenan lexicográficamente según su valor ASCII ascendente.
- El orden de los elementos dentro de arrays se conserva intacto, pero los objetos contenidos en arrays ordenan sus claves de forma recursiva.
Formateo Compacto y Codificación UTF-8:
- Delimitadores sin espacios: coma
,y dos puntos:sin caracteres en blanco adyacentes. - Los caracteres no pertenecientes al alfabeto ASCII se codifican directamente como octetos UTF-8 (
ensure_ascii=False), nunca mediante secuencias de escape\uXXXX.
- Delimitadores sin espacios: coma
4.2. Implementación de Referencia (TypeScript y Python)
export function serializeCanonical(data: Record<string, any>): Uint8Array {
function sortKeys(obj: any): any {
if (Array.isArray(obj)) {
return obj.map(sortKeys);
} else if (obj !== null && typeof obj === 'object') {
const sorted: Record<string, any> = {};
const keys = Object.keys(obj).sort();
for (const k of keys) {
sorted[k] = sortKeys(obj[k]);
}
return sorted;
}
return obj;
}
function cleanFloats(obj: any): any {
if (Array.isArray(obj)) {
return obj.map(cleanFloats);
} else if (obj !== null && typeof obj === 'object') {
const cleaned: Record<string, any> = {};
for (const [k, v] of Object.entries(obj)) {
cleaned[k] = cleanFloats(v);
}
return cleaned;
} else if (typeof obj === 'number') {
if (Number.isInteger(obj)) return obj;
return Number(obj.toFixed(6));
}
return obj;
}
const cleaned: Record<string, any> = {};
const excluded = new Set(["signature", "key_version", "timestamp", "creator_user_id"]);
const keys = Object.keys(data).filter(k => !excluded.has(k)).sort();
for (const k of keys) {
cleaned[k] = sortKeys(cleanFloats(data[k]));
}
return new TextEncoder().encode(JSON.stringify(cleaned));
}import json
def clean_floats(obj):
if isinstance(obj, dict):
return {k: clean_floats(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [clean_floats(x) for x in obj]
elif isinstance(obj, float):
if obj.is_integer():
return int(obj)
return round(obj, 6)
return obj
def serialize_canonical(data: dict) -> bytes:
excluded = {"signature", "key_version", "timestamp", "creator_user_id"}
cleaned = {k: v for k, v in data.items() if k not in excluded}
cleaned = clean_floats(cleaned)
return json.dumps(
cleaned,
sort_keys=True,
separators=(',', ':'),
ensure_ascii=False
).encode('utf-8')5. Directorio de Claves Públicas y JWK (RFC 7517)
Las claves públicas de verificación se publican en formato JSON Web Key (JWK) según la especificación RFC 7517.
5.1. Endpoint Público de Claves
GET https://api.oim.media/v1/media/keysRespuesta JSON (200 OK):
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "5Qavr4M_npVognquI5k5D9K8wcXBXM9Z0mZHjqyehAs",
"y": "oh7rz5Q7SlZT_ms2buyOw0bPX77QsXimc0X92Ymj2_o",
"kid": "v1"
}
]
}5.2. Esquema de Campos JWK
kty(Tipo de Clave): Estrictamente"EC".crv(Curva): Estrictamente"P-256".x: Coordenada afín X en entero sin signo de 32 bytes big-endian, codificada en Base64URL sin relleno (=).y: Coordenada afín Y en entero sin signo de 32 bytes big-endian, codificada en Base64URL sin relleno (=).kid(Identificador de Clave): Cadena de versión correspondiente al campometadata.key_versiondel archivo (por ejemplo,"v1","v2").
5.3. Resolución en 4 Niveles en <oim-player>
El reproductor oficial implementa una estrategia de caché multinivel para permitir verificación fuera de línea y alta disponibilidad:
- Nivel 1 — Clave Fija de Respaldo: La clave raíz de producción actual (
kid: "v1") está compilada en el reproductor para verificación inmediata sin latencia. - Nivel 2 — Caché en Memoria: Las claves consultadas durante la sesión se mantienen en la tabla en memoria
_cachedKeys. - Nivel 3 — Almacenamiento en IndexedDB: Las claves descargadas del servidor se persisten en la base de datos IndexedDB
oim-player-keys(almacénkeys, con keyPathkid), permitiendo verificar archivos firmados con claves rotadas sin conexión a internet. - Nivel 4 — Consulta a la Red: Si un archivo requiere un
kiddesconocido o falla contra la clave fija, el reproductor consultaGET /v1/media/keys.
6. Permisos de Reedición y Ciclo de Vida del Documento
Cada documento .oim declara sus permisos de reedición. Estos se interpretan en los editores visuales y se validan en la API de empaquetado.
6.1. Los Tres Estados de Reedición
| Estado | Declaración en Metadatos | Candado en Registro | Descripción | Distintivo en el Reproductor |
|---|---|---|---|---|
author_only (Defecto) | reedit: "author_only"immutable_lock: false | immutable_lock: false | Solo el autor original autenticado puede empaquetar revisiones bajo este oim_id. Intentos por terceros devuelven 403 Forbidden. | verified (Sello Verde) |
public | reedit: "public"immutable_lock: false | immutable_lock: false | Recurso comunitario abierto. Cualquier usuario puede abrir, editar y reexportar sin comprobación de autoría en el registro. | verified_authorless (Sello Abierto Comunitario) |
sealed | reedit: "sealed"immutable_lock: true | immutable_lock: true | Binario finalizado permanentemente. La reedición queda deshabilitada para todos los usuarios, incluido el autor original. Es irreversible. | verified (Documento Sellado) |
6.1.1. El sello de la imagen
La firma cubre los metadatos y también la imagen base. Al empaquetar, el backend calcula el SHA-256 de los datos de imagen del PNG final (los chunks IDAT concatenados) y lo firma dentro de los metadatos como image_hash, con image_hash_alg: "sha256-idat". El reproductor recalcula ese hash sobre el archivo que recibe y lo compara.
| Resultado | Estado | ¿Se muestra? |
|---|---|---|
| Firma válida y la imagen casa con el sello | verified / verified_authorless | Sí |
| Firma válida, archivo sin sello (anterior al 11 sept 2026) | image_unsealed | Sí, con aviso |
| Firma válida y la imagen no casa | image_mismatch | No |
| Firma inválida | invalid_signature | No |
Firmado con una clave pasada su fecha verify_until (reloj del dispositivo) | expired_key | Solo la imagen base |
Un archivo alterado no se muestra: ni su imagen ni sus capas. La imagen base se pinta antes de verificar, para que cargue rápido, y se retira si el veredicto es de alteración.
Solo se exige el sello que declara image_hash_alg. En junio de 2026 un cliente enviaba su propio image_hash y el backend lo firmaba sin comprobarlo (hay archivos con el SHA-256 de una entrada vacía), así que un image_hash sin algoritmo no prueba nada y se trata como image_unsealed.
Recomprimir un PNG sin tocar un píxel también cambia sus IDAT: para el sello, ese archivo ya no es el que salió de OIM.
6.2. Reglas de Seguridad por Defecto (Fail-Closed)
- Caída ante Valor Desconocido: Si
reeditcontiene una cadena desconocida o no válida (por ejemplo,"libre"o"PUBLICO"), cae estrictamente a"author_only". Nunca cae a"public". Un dato corrupto o manipulado jamás puede relajar la seguridad. - Nombres heredados (hasta 2.0.0): los archivos firmados antes de 1.23.0 llevan
reedicioncon los valores en españolpublico,solo_autorysellado. Se siguen leyendo, comopublic,author_onlyysealed; si vienen ambos, ganareedit. Los documentos escritos desde 1.23.0 llevan soloreedit. - Prioridad del Candado Inmutable: Si
immutable_lock: trueestá presente, prevalece sobre cualquier valor dereedit(incluso si declarareedit: "public"). El documento se procesa siempre como"sealed". - Exclusión de Consulta de Registro: Los documentos declarados
publicomiten la consulta de autoría en el registro, impidiendo que un tercero se apropie del identificador en la base de datos.
7. Unión Criptográfica de Ambas Capas (image_hash)
Una debilidad habitual en formatos de medios enriquecidos es el riesgo de manipular los píxeles de fondo manteniendo intactos los vectores interactivos, o sustituir los textos sin alterar la imagen. OIM anula esta posibilidad mediante una unión criptográfica bidireccional:
- Hash de la imagen horneada (Capa −1): Durante el empaquetado, se calcula el resumen SHA-256 de los píxeles de la imagen horneada y se guarda en los metadatos:json
{ "image_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "slots": [] } - Cobertura de la Firma: Al serializarse canónicamente,
image_hashqueda cubierto por la firma ECDSA. - Comprobación de Integridad:
- Si se modifican los píxeles de la imagen horneada (Capa −1), el hash calculado diferirá de
image_hash. - Si se intenta alterar
image_hashen los metadatos para que coincida con la imagen manipulada, la firma ECDSA fallará de inmediato.
- Si se modifican los píxeles de la imagen horneada (Capa −1), el hash calculado diferirá de
8. Ejemplos Completos de Verificación
8.1. Navegador Web (API Nativa Web Crypto)
import { serializeCanonical } from '@oim-media/core';
async function verifyOimDocument(metadata: any): Promise<boolean> {
if (!metadata.signature || !metadata.key_version) {
console.warn('El documento no está firmado.');
return false;
}
// 1. Obtener claves públicas
const res = await fetch('https://api.oim.media/v1/media/keys');
const { keys } = await res.json();
const jwk = keys.find((k: any) => k.kid === metadata.key_version);
if (!jwk) {
throw new Error(`Clave pública no encontrada para la versión: ${metadata.key_version}`);
}
// 2. Importar clave JWK en Web Crypto API
const publicKey = await window.crypto.subtle.importKey(
"jwk",
jwk,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["verify"]
);
// 3. Decodificar firma cruda IEEE P1363 (64 bytes)
const rawSignature = Uint8Array.from(atob(metadata.signature), c => c.charCodeAt(0));
// 4. Calcular bytes canónicos
const canonicalBytes = serializeCanonical(metadata);
// 5. Verificar firma criptográfica
const isValid = await window.crypto.subtle.verify(
{ name: "ECDSA", hash: { name: "SHA-256" } },
publicKey,
rawSignature,
canonicalBytes
);
return isValid;
}8.2. Script de Verificación en Python 3
import base64
import requests
from cryptography.hazmat.primitives.asymmetric import ec, utils
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
from core.utils import serialize_canonical
def verify_oim_metadata(metadata: dict) -> bool:
signature_b64 = metadata.get("signature")
key_version = metadata.get("key_version")
if not signature_b64 or not key_version:
return False
# 1. Obtener claves públicas
keys_resp = requests.get("https://api.oim.media/v1/media/keys").json()
jwk = next((k for k in keys_resp.get("keys", []) if k.get("kid") == key_version), None)
if not jwk:
raise ValueError(f"Versión de clave {key_version} no encontrada en el directorio")
# 2. Decodificar coordenadas Base64URL a EllipticCurvePublicKey
def b64url_decode(s: str) -> bytes:
s += '=' * (-len(s) % 4)
return base64.urlsafe_b64decode(s)
x_bytes = b64url_decode(jwk["x"])
y_bytes = b64url_decode(jwk["y"])
public_numbers = ec.EllipticCurvePublicNumbers(
x=int.from_bytes(x_bytes, byteorder='big'),
y=int.from_bytes(y_bytes, byteorder='big'),
curve=ec.SECP256R1()
)
public_key = public_numbers.public_key()
# 3. Decodificar firma cruda IEEE P1363 (64 bytes) a (r, s)
raw_sig = base64.b64decode(signature_b64)
if len(raw_sig) != 64:
raise ValueError("Longitud de firma inválida: debe ser de 64 bytes IEEE P1363")
r = int.from_bytes(raw_sig[:32], byteorder='big')
s = int.from_bytes(raw_sig[32:], byteorder='big')
der_signature = utils.encode_dss_signature(r, s)
# 4. Obtener secuencia canónica normalizada
canonical_bytes = serialize_canonical(metadata)
# 5. Comprobar firma asimétrica
try:
public_key.verify(
der_signature,
canonical_bytes,
ec.ECDSA(hashes.SHA256())
)
return True
except Exception:
return False