ECDSA Cryptography & Document Integrity
The Object Interactive Media (OIM) ecosystem employs asymmetric cryptography to guarantee the authenticity, author intent, and byte-level integrity of every .oim and .ovm document. This document provides the authoritative technical specification for the cryptographic algorithms, canonical serialization rules, signature encoding, public key distribution, and verification procedures.
1. Cryptographic Architecture Overview
Every packaged .oim binary embeds an Elliptic Curve Digital Signature Algorithm (ECDSA) signature directly within its container metadata. This cryptographic seal establishes three core guarantees:
- Document Integrity: Detects any alteration to interactive slots, localized text dictionaries, coordinates, animations, vector shapes, or visual styles.
- Base Media Binding: Cryptographically bonds the Dynamic Layer (+1) metadata to the baked image (Layer −1) via SHA-256 hashing.
- Re-edition Enforcement: Cryptographically underpins the document lifecycle (
public,author_only,sealed), preventing unauthorized tampering or overwrites.
┌────────────────────────────────────────────────────────────────────────┐
│ .oim Container (PNG) │
│ ┌───────────────────────────┐ ┌───────────────────────────────┐ │
│ │ Layer −1: Baked Pixels │ │ Layer +1: Dynamic Metadata │ │
│ │ (Lossless Raster Data) │ │ (zTXt chunk: "OIM_Data") │ │
│ └─────────────┬─────────────┘ └───────────────┬───────────────┘ │
│ │ SHA-256 │ │
│ ▼ ▼ │
│ [image_hash] ──────────────────► [Canonical Serialization] │
│ │ │
│ ▼ │
│ ECDSA NIST P-256 Signature │
│ (IEEE P1363 Raw 64 Bytes) │
└────────────────────────────────────────────────────────────────────────┘2. Cryptographic Primitives
2.1. Curve Parameters: NIST P-256 (secp256r1)
OIM mandates the NIST P-256 elliptic curve (standardized in FIPS 186-4, ANSI X9.62, and SECG SEC 2; OID 1.2.840.10045.3.1.7).
The curve is defined over the prime field $\mathbb{F}_p$ by the short Weierstrass equation: $$y^2 \equiv x^3 + a x + b \pmod{p}$$
| Parameter | Notation | Hexadecimal Value |
|---|---|---|
| Field Prime | $p$ | 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF |
| Coefficient $a$ | $a$ | 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC ($p - 3$) |
| Coefficient $b$ | $b$ | 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B |
| Base Point $G_x$ | $G_x$ | 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296 |
| Base Point $G_y$ | $G_y$ | 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5 |
| Order $n$ | $n$ | 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 |
| Cofactor | $h$ | 0x01 |
2.2. Digest Function: SHA-256
The message digest algorithm is SHA-256 (FIPS 180-4). A 256-bit hash is computed over the canonical UTF-8 bytes of the document metadata.
3. Signature Encoding: IEEE P1363 Raw Format
ECDSA signatures mathematically consist of a pair of large integers $(r, s)$ modulo $n$. In software implementations, two primary serialization standards exist:
- ASN.1 DER Sequence (
RFC 3279): Variable-length encoding (typically 70–72 bytes) wrapping $r$ and $s$ with length tags and sign-bit zero padding. - IEEE P1363 Raw Format: Fixed-length 64-byte binary structure consisting of $r$ and $s$ concatenated as fixed-width, big-endian unsigned integers.
3.1. IEEE P1363 Byte Structure
OIM standardizes exclusively on 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
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
+ r-coordinate (32 bytes) +
| Big-Endian Unsigned Integer |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
+ s-coordinate (32 bytes) +
| Big-Endian Unsigned Integer |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- Byte 0 to 31 (32 bytes): The $r$ coordinate, zero-padded on the left if less than 32 bytes.
- Byte 32 to 63 (32 bytes): The $s$ coordinate, zero-padded on the left if less than 32 bytes.
- Base64 String: The 64 raw bytes are encoded as an 88-character Base64 string ending with
=, stored inmetadata.signature.
Why IEEE P1363?
The W3C Web Cryptography API (window.crypto.subtle.verify) natively expects IEEE P1363 raw signatures for ECDSA. Emitting IEEE P1363 directly avoids requiring heavy ASN.1 parsing libraries in the browser runtime.
4. Canonical Recursive Serialization (serialize_canonical)
Because JSON does not enforce key ordering and floating-point representations differ across runtimes (e.g. Python vs. JavaScript V8), digital signatures require a strict, deterministic normalization process.
4.1. Normalization Rules
The canonical serialization algorithm executes the following steps in sequence:
Exclude Dynamic & Ephemeral Envelope Fields: The following top-level keys are excluded from the signed payload:
"signature": Prevents circular dependency."key_version": Ephemeral key identifier envelope."timestamp": Packaging timestamp envelope.
Exclude Personal Identifiers (
creator_user_id):creator_user_idis explicitly stripped before signing and omitted from the published file.- GDPR Compliance: Author identity is personal data. Immutably sealing personal IDs into a distributed binary violates the GDPR Right to Erasure ("Right to be Forgotten"). Authorship is tracked in the revocable backend registry, not sealed inside the immutable binary.
Numerical Normalization (
clean_floats):- Integer Floats: Any floating-point value that represents an integer (e.g.
50.0,100.00) is converted to an integer primitive (50,100). - Fractional Floats: Any non-integer float is rounded to 6 decimal places (
round(val, 6)). - This eliminates serialization discrepancies across IEEE 754 float formatters.
- Integer Floats: Any floating-point value that represents an integer (e.g.
Recursive Key Sorting:
- All object keys at every level of the hierarchy are sorted lexicographically in ascending ASCII order.
- Array element order is strictly preserved; however, objects nested within arrays have their keys recursively sorted.
Compact Formatting & UTF-8 Encoding:
- Delimiters use zero whitespace: comma
,and colon:without surrounding spaces. - Characters outside ASCII are encoded as direct UTF-8 octets (
ensure_ascii=False), never as\uXXXXescape sequences.
- Delimiters use zero whitespace: comma
4.2. Reference Implementation (TypeScript & 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. Public Key Distribution & RFC 7517 JWK
Verification public keys are distributed as standard JSON Web Key (JWK) sets conforming to RFC 7517.
5.1. Key Directory Endpoint
GET https://api.oim.media/v1/media/keysResponse Format (200 OK):
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "5Qavr4M_npVognquI5k5D9K8wcXBXM9Z0mZHjqyehAs",
"y": "oh7rz5Q7SlZT_ms2buyOw0bPX77QsXimc0X92Ymj2_o",
"kid": "v1"
}
]
}5.2. JWK Field Schema
kty(Key Type): Strictly"EC".crv(Curve): Strictly"P-256".x: Unsigned 32-byte big-endian affine X-coordinate, Base64URL-encoded (unpadded).y: Unsigned 32-byte big-endian affine Y-coordinate, Base64URL-encoded (unpadded).kid(Key Identifier): Version string matchingmetadata.key_versionin.oimfiles (e.g."v1","v2").
5.3. Multi-Tier Resolution Strategy in <oim-player>
The OIM Web Player implements a 4-tier caching architecture to guarantee offline verification and resilience against network failures:
- Tier 1 — Pinned Fallback: The current production root key (
kid: "v1") is bundled directly in the player runtime for zero-latency startup. - Tier 2 — Memory Cache: Keys resolved during runtime are stored in an in-memory table
_cachedKeys. - Tier 3 — IndexedDB Storage: Keys fetched from the server are persisted in the browser IndexedDB database
oim-player-keys(storekeys, keyPathkid). Rotated keys remain verifiable offline across browser sessions. - Tier 4 — Network Query: If a document specifies an unknown
kidor verification against a pinned key fails, the player queries/v1/media/keys.
6. Document Re-Edition Permissions & Lifecycle
Every .oim document declares its re-edition governance policy. These permissions are evaluated both client-side by visual tools and enforced server-side by the packaging API.
6.1. The Three Re-Edition States
| State | Declaration in Metadata | Backend Registry Lock | Description | Player Verification Badge |
|---|---|---|---|---|
author_only (Default) | reedit: "author_only"immutable_lock: false | immutable_lock: false | Only the authenticated creator account may package updates under this oim_id. Any attempt by another user is rejected with 403 Forbidden. | verified (Green Seal) |
public | reedit: "public"immutable_lock: false | immutable_lock: false | Open community template/asset. Any user can open, modify, and re-export under the same ID without ownership checks. | verified_authorless (Open Community Seal) |
sealed | reedit: "sealed"immutable_lock: true | immutable_lock: true | Permanently finalized binary. Re-edition is permanently disabled for all users, including the original author. Irreversible. | verified (Sealed Document) |
6.1.1. The image seal
The signature covers the metadata and the base image too. When packaging, the backend computes the SHA-256 of the final PNG's image data (the concatenated IDAT chunks) and signs it inside the metadata as image_hash, with image_hash_alg: "sha256-idat". The player recomputes that hash over the file it receives and compares.
| Outcome | Status | Shown? |
|---|---|---|
| Valid signature, image matches the seal | verified / verified_authorless | Yes |
| Valid signature, no seal (files before 11 Sept 2026) | image_unsealed | Yes, with a notice |
| Valid signature, image does not match | image_mismatch | No |
| Invalid signature | invalid_signature | No |
Signed with a key past its verify_until date (device clock) | expired_key | Base image only |
An altered file is not shown: neither its image nor its layers. The base image is painted before verification, for fast loading, and removed if the verdict is tampering.
Only seals that declare image_hash_alg are enforced. In June 2026 a client sent its own image_hash and the backend signed it unchecked (some files carry the SHA-256 of empty input), so an image_hash without an algorithm proves nothing and is treated as image_unsealed.
Recompressing a PNG without changing a pixel also changes its IDAT: for the seal, that is no longer the file OIM produced.
6.2. Fail-Closed Fallback Rules
To prevent manipulated or corrupted metadata from weakening document permissions:
- Unknown String Fallback: If
reeditcontains an unrecognized string (e.g."open","PUBLIC"), it falls back strictly to"author_only". It never falls back to"public". - Legacy names (until 2.0.0): files signed before 1.23.0 carry
reedicionwith the Spanish valuespublico,solo_autorandsellado. They are still read, aspublic,author_onlyandsealed;reeditwins when both are present. Documents written from 1.23.0 carryreeditonly. - Precedence of
immutable_lock: Ifimmutable_lock: trueis present, it overrides any value inreedit(even ifreedit: "public"). The document is treated strictly as"sealed". - Registry Bypass: Documents marked
publicbypass ownership registry checks (registryQuery(permiso)returnsfalse), preventing malicious actors from locking community assets.
7. Dual-Layer Cryptographic Binding (image_hash)
A primary vulnerability in media-embedding formats is the risk of an attacker altering base image pixels while retaining interactive overlays, or vice-versa. OIM eliminates this vulnerability through mutual cryptographic binding:
- Baked Image Hash (Layer −1): At packaging time, the SHA-256 digest of the raw baked raster image is computed and embedded in the metadata:json
{ "image_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "slots": [] } - Signature Coverage: Because
image_hashis an included field inserialize_canonical, the ECDSA signature cryptographically commits to the exact pixel contents of the baked image (Layer −1). - Verification Enforcement:
- Modifying the baked raster pixels (Layer −1) causes the computed hash to diverge from
image_hash. - Modifying
image_hashin the metadata causes the ECDSA signature verification to fail.
- Modifying the baked raster pixels (Layer −1) causes the computed hash to diverge from
8. Complete Verification Examples
8.1. Web Browser (Native Web Crypto API)
import { serializeCanonical } from '@oim-media/core';
async function verifyOimDocument(metadata: any): Promise<boolean> {
if (!metadata.signature || !metadata.key_version) {
console.warn('Document is unsigned.');
return false;
}
// 1. Fetch public keys
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(`Public key not found for kid: ${metadata.key_version}`);
}
// 2. Import JWK into Web Crypto API
const publicKey = await window.crypto.subtle.importKey(
"jwk",
jwk,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["verify"]
);
// 3. Decode IEEE P1363 64-byte signature
const rawSignature = Uint8Array.from(atob(metadata.signature), c => c.charCodeAt(0));
// 4. Compute canonical bytes
const canonicalBytes = serializeCanonical(metadata);
// 5. Verify signature
const isValid = await window.crypto.subtle.verify(
{ name: "ECDSA", hash: { name: "SHA-256" } },
publicKey,
rawSignature,
canonicalBytes
);
return isValid;
}8.2. Python Verification Script
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
# Import your canonical serialization implementation
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. Retrieve public keys
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"Key version {key_version} not found in directory")
# 2. Decode Base64URL coordinates to 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. Decode IEEE P1363 raw signature (64 bytes) to (r, s)
raw_sig = base64.b64decode(signature_b64)
if len(raw_sig) != 64:
raise ValueError("Invalid signature length: must be 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. Serialize canonical payload
canonical_bytes = serialize_canonical(metadata)
# 5. Verify ECDSA signature
try:
public_key.verify(
der_signature,
canonical_bytes,
ec.ECDSA(hashes.SHA256())
)
return True
except Exception:
return False