The Ikimotion file container and .iki projects
Ikimotion’s own files share one container (crate crates/ikifmt): .iki project files and
.ikm protected models (The .ikm protected model). This page describes the byte
layout, the algorithms, what the format protects against and what it does not.
Byte layout
All integers are little-endian. Every length is checked against a limit before anything is allocated, and a reader never panics on malformed input (the test suite feeds it random bytes, random chunk sequences, every truncation and every single-bit flip).
file = magic(8) major(u16) minor(u16) chunk* SIGN
chunk = type(4 ASCII) length(u32) payload(length)
| Magic | Kind |
|---|---|
89 49 4B 49 0D 0A 1A 0A (\x89IKI\r\n\x1a\n) |
.iki project |
89 49 4B 4D 0D 0A 1A 0A (\x89IKM\r\n\x1a\n) |
.ikm model |
Like PNG’s, the magic catches text-mode transfers and keeps the two kinds from opening as each other. The version is 1.0.
Chunks appear in this order: HEAD, KEYS (only when encrypted), MANI, the DATA chunks,
SIGN (last; nothing may follow). A chunk type whose first letter is lowercase is
ancillary: a reader that does not know it skips it (it is still covered by the signature).
An unknown chunk with an uppercase first letter is refused.
| Chunk | Limit | Payload |
|---|---|---|
HEAD |
4 KiB | cipher u8 (0 none, 1 XChaCha20-Poly1305), hash u8 (1 SHA-256), signature u8 (1 Ed25519), reserved u8, chunk_size u32 (4 KiB to 16 MiB, default 1 MiB), file_id [16] (random per file), created_ms u64. Readers ignore bytes after these fields (newer minor versions append). |
KEYS |
64 KiB | count u16 (1 to 64), then per slot type u8, length u16, body. Unknown slot types are kept and skipped. |
MANI |
8 MiB | UTF-8 JSON manifest (encrypted with the content when the file is). |
DATA |
chunk size + 1/1024 + 89 bytes | entry u32, index u32, flags u8 (bit 0: the entry’s last chunk), then the chunk (encrypted when the file is). |
SIGN |
64 KiB | algorithm u8 (1), public_key [32], info_length u32, signer JSON, signature [64]. |
Key slots
| Type | Body |
|---|---|
| 1 password | kdf u8 (1 = Argon2id v1.3), m_kib u32, t u32, p u32, salt [16], nonce [24], wrapped [48] |
| 2 account | account_id (u16 length + UTF-8), blob (u16 length + bytes, opaque to clients) |
| 3 recipient | recipient [32] (X25519 public key), ephemeral [32], nonce [24], wrapped [48] |
| 4 license | service, license_id, master_id (each u16 length + UTF-8); no key material |
Manifest
{
"entries": [
{ "path": "document.snapshot", "type": "application/x-ikimotion-snapshot", "size": 18234,
"encoding": "deflate", "sha256": "…" },
{ "path": "assets/<sha256>", "type": "image/png", "size": 40211, "encoding": "stored",
"sha256": "…", "meta": { "kind": "image", "width": 2048, "height": 2048 } }
],
"meta": { "format": "ikimotion-project", "version": 1, "name": "Ibuki",
"savedAt": "2026-10-18T12:00:00.000Z", "canvas": { "width": 2048, "height": 2048 },
"app": "Ikimotion Studio 0.5.0" }
}
Entry ids are list positions. size is the decoded size; sha256 the decoded bytes’ hash
(required when the file is not encrypted). Encodings: stored, deflate (each chunk
raw-deflated on its own) and rgba8 (straight-alpha pixels, whole rows per chunk, deflated;
width and height set). An entry has ceil(size / chunk_size) chunks (at least one); an
rgba8 entry has ceil(height / rows) chunks with rows = max(1, chunk_size / (width * 4)).
Inflating stops one byte past the expected size, so a chunk cannot expand without bound.
Unknown manifest fields are ignored.
Streaming
Chunks are independent: a reader decodes and checks one chunk at a time (Reader::feed /
Reader::next), a writer emits as it goes (Writer::take), and the license service rewrites a
model chunk by chunk. Memory stays around one chunk whatever the file size.
Algorithms
| Purpose | Choice |
|---|---|
| Content encryption | XChaCha20-Poly1305 with a random 256-bit content key, sub-key HKDF-SHA256(content key, salt = file id, “ikifmt-content-v1”). Chunk n (manifest 0, DATA 1, 2, …) uses nonce `file_id |
| Password slots | Argon2id v1.3, 64 MiB, 3 passes, 1 lane (RFC 9106’s second recommendation with one lane, since WebAssembly runs single-threaded), random 16-byte salt; the derived key wraps the content key with XChaCha20-Poly1305. Readers refuse costs above 256 MiB, 10 passes or 4 lanes. |
| Account slots | The key service wraps the content key with XChaCha20-Poly1305 under HKDF-SHA256(service root, “account-kek-v1” + account id); the associated data names the account. Only that account, signed in, gets it back (POST /api/keys/unwrap). |
| Recipient slots, key release | X25519 (ephemeral-static), HKDF-SHA256 over the shared secret (salt = both public keys), XChaCha20-Poly1305. Low-order points are refused. |
| Signature | Ed25519, strict verification, over `“IKIFMT-SIGNATURE-V1\0” |
| Key certificates | The key service signs `“IKIMOTION-KEY-CERTIFICATE-V1\0” |
XChaCha20-Poly1305 was chosen over AES-GCM because it is constant-time in software (no AES instructions in WebAssembly), and its 192-bit nonces leave room for the file id in every nonce.
.iki projects in the app
- Saving (Export › Project file (.iki)…, File › Save project to file (.iki)…, the Share panel, or Save to a file in Save as / Duplicate) writes the document snapshot and every asset, signed with this browser’s key. When you are signed in, the key is registered with your account and the file carries the service’s certificate, so it names you. Protection is optional: a password, or your account.
- Opening (Import › Project file (.iki)… or a drop) first shows who signed the file: a verified account (with “revoked” when that account revoked the key), an unverified device key, or Modified after it was signed when the signature does not match. A password file asks for the password; an account file opens for its account only. The project opens as a new project; nothing in the file runs as code.
.rigprojbundles from earlier versions still open (read only); the app no longer writes them.
See Saving and opening project files.
Threat model
What the format guarantees:
- Integrity: any change to any byte of a signed file is detected (the signature fails, or an authenticated chunk does not decrypt). Truncation is detected (no signature).
- Attribution: with a certificate, the signer’s account is checked against the key service’s key. A device key without a certificate says only “this key signed it”.
- Confidentiality of encrypted files: without the password or the account, the manifest and the content are unreadable. Argon2id makes each password guess cost 64 MiB and a few hundred milliseconds.
What it does not do:
- Anyone can re-sign a modified unencrypted file with their own key. The app then shows that key (an unknown device key, or a different account), never the original author: check that the signer is who you expect.
- File names, sizes, the signer and the key slots (including the account id of an account slot) are visible without the key. Chunk sizes reveal the rough size of each asset.
- A weak password can be guessed offline; there is no way to recover a forgotten one.
- Account slots depend on the key service: the server holds the wrapping keys, so a compromised service could unwrap account-protected files whose bytes it also obtains.
- The browser’s private signing key is non-extractable (WebCrypto) where Ed25519 is available; script running in the app’s origin can still use it to sign while it runs.