Skip to content

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:

  1. Integridad del Documento: Detecta cualquier alteración en los slots interactivos, diccionarios de idiomas, coordenadas, animaciones, geometrías vectoriales o estilos visuales.
  2. 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.
  3. Gobierno de Reedición: Hace cumplir el ciclo de vida del archivo (public, author_only, sealed), impidiendo sobreescrituras o manipulaciones no autorizadas.
text
┌────────────────────────────────────────────────────────────────────────┐
│                        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ámetroNotaciónValor 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:

  1. 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.
  2. 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:

text
 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 en metadata.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:

  1. 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.
  2. Exclusión de Datos Personales (creator_user_id):

    • El identificador creator_user_id se 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.
  3. 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.
  4. 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.
  5. 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.

4.2. Implementación de Referencia (TypeScript y Python) ​

typescript
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));
}
python
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 ​

http
GET https://api.oim.media/v1/media/keys

Respuesta JSON (200 OK):

json
{
  "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 campo metadata.key_version del 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:

  1. 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.
  2. Nivel 2 — Caché en Memoria: Las claves consultadas durante la sesión se mantienen en la tabla en memoria _cachedKeys.
  3. Nivel 3 — Almacenamiento en IndexedDB: Las claves descargadas del servidor se persisten en la base de datos IndexedDB oim-player-keys (almacén keys, con keyPath kid), permitiendo verificar archivos firmados con claves rotadas sin conexión a internet.
  4. Nivel 4 — Consulta a la Red: Si un archivo requiere un kid desconocido o falla contra la clave fija, el reproductor consulta GET /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 ​

EstadoDeclaración en MetadatosCandado en RegistroDescripciónDistintivo en el Reproductor
author_only (Defecto)reedit: "author_only"
immutable_lock: false
immutable_lock: falseSolo el autor original autenticado puede empaquetar revisiones bajo este oim_id. Intentos por terceros devuelven 403 Forbidden.verified (Sello Verde)
publicreedit: "public"
immutable_lock: false
immutable_lock: falseRecurso comunitario abierto. Cualquier usuario puede abrir, editar y reexportar sin comprobación de autoría en el registro.verified_authorless (Sello Abierto Comunitario)
sealedreedit: "sealed"
immutable_lock: true
immutable_lock: trueBinario 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.

ResultadoEstado¿Se muestra?
Firma válida y la imagen casa con el selloverified / verified_authorlessSí
Firma válida, archivo sin sello (anterior al 11 sept 2026)image_unsealedSí, con aviso
Firma válida y la imagen no casaimage_mismatchNo
Firma inválidainvalid_signatureNo
Firmado con una clave pasada su fecha verify_until (reloj del dispositivo)expired_keySolo 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 reedit contiene 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 reedicion con los valores en español publico, solo_autor y sellado. Se siguen leyendo, como public, author_only y sealed; si vienen ambos, gana reedit. Los documentos escritos desde 1.23.0 llevan solo reedit.
  • Prioridad del Candado Inmutable: Si immutable_lock: true está presente, prevalece sobre cualquier valor de reedit (incluso si declara reedit: "public"). El documento se procesa siempre como "sealed".
  • Exclusión de Consulta de Registro: Los documentos declarados public omiten 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:

  1. 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": []
    }
  2. Cobertura de la Firma: Al serializarse canónicamente, image_hash queda cubierto por la firma ECDSA.
  3. 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_hash en los metadatos para que coincida con la imagen manipulada, la firma ECDSA fallará de inmediato.

8. Ejemplos Completos de Verificación ​

8.1. Navegador Web (API Nativa Web Crypto) ​

typescript
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 ​

python
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