Library package format, version 1
Status: draft until app version 1.0 ships. Reference implementation: Packages/KuriosaKit/Sources/KuriosaStore. Reference files: Fixtures/golden/.
1. Package layout
A library is a directory holding manifest.json. On the device its name ends in .kuriosa; in a shared folder it is named Kuriosa Library or whatever the person renames it to (section 9):
<Name>.kuriosa/
manifest.json UTF-8 JSON, plaintext
catalog.db.enc KEF1 sealed SQLite database, context "catalog"
media/<id>.enc KEF1 sealed media file, context "media:<id>"
media/<id>.thumb.enc optional KEF1 sealed thumbnail, context "thumb:<id>"
media/<id>.cutout.enc optional KEF1 sealed cut-out of a photo, context "cutout:<id>"
media/<id>.cutout-thumb.enc thumbnail of the cut-out, context "cutout-thumb:<id>"
Photos are JPEG without metadata (see decision 0012); documents are PDF (redrawn without metadata) or JPEG; thumbnails are JPEG, 640 px on the long side. Thumbnails are derived data: readers may ignore them and writers may regenerate them.
A cut-out (decision 0016) is the photo's subject on a transparent background as PNG without metadata, at most 2,048 px on the long side with an even margin of 6 % of the subject's longer side; its thumbnail is PNG, 640 px. The media record says whether a cut-out exists and whether it is shown (catalog-schema.md). The original file never changes; a new cut-out replaces the cut-out files atomically.
Rotation and crop (decision 0021) are not files: they are columns of the media record, applied when a photo is drawn (catalog-schema.md, "Media edits"). Thumbnails and cut-outs are therefore always stored unrotated and uncropped.
A collection's own cover picture (catalog schema version 12, collection.cover_picture_id) is a photo file with its thumbnail but without a media record; it is carried in backups like every file in media/.
<id> is a lowercase UUID string. Media files are immutable. Writers replace files atomically (write to a temporary file, then rename).
A media file is in use while its media row is not deleted and belongs to an item that is not deleted, or while a collection that is not deleted names it in cover_picture_id. Other files of media/ named <id>.… are kept only so undo can bring them back; "Delete for good" (decision 0032) removes them with their media rows. Files with other names are left alone.
2. manifest.json
{
"createdAt" : "2026-09-21T14:13:20Z",
"formatVersion" : 1,
"keySlots" : [
{
"algorithm" : "hkdf-sha256",
"kind" : "recoveryKey",
"salt" : "<base64, 16 bytes>",
"wrappedKey" : "<base64, 60 bytes>"
}
],
"libraryID" : "<lowercase uuid>"
}
formatVersion: readers must refuse versions higher than they support.createdAt: ISO 8601, UTC, whole seconds.- Binary values are standard Base64 with padding.
- Readers ignore unknown keys.
3. Key slots
The data key DK is 32 random bytes. Each slot stores DK encrypted with a key-encryption key KEK:
kind | algorithm | KEK derivation |
|---|---|---|
recoveryKey | hkdf-sha256 | HKDF-SHA256(IKM = recovery key bytes (20), salt = salt, info = UTF-8 kuriosa.keyslot.v1, L = 32) |
password | pbkdf2-sha256 | PBKDF2-HMAC-SHA256(P = UTF-8 of the NFC-normalized password, S = salt, c = iterations, dkLen = 32) |
wrappedKey = AES-256-GCM(KEK, nonce = 12 random bytes, plaintext = DK, AAD = UTF-8 kuriosa.keyslot.v1:<kind>:<ownerID>), stored as nonce (12) || ciphertext (32) || tag (16). For a library, ownerID is the libraryID. The default PBKDF2 iteration count is 600,000.
4. Recovery key display form
20 random bytes encoded as 32 characters of Crockford Base32 (0123456789ABCDEFGHJKMNPQRSTVWXYZ, most significant bits first), shown as eight groups of four separated by -. Parsers ignore case, whitespace, and hyphens, map O→0 and I,L→1, and require exactly 32 characters.
5. KEF1 sealed file
All integers big endian.
| Offset | Length | Content |
|---|---|---|
| 0 | 4 | ASCII KEF1 |
| 4 | 4 | chunk size C (1 ≤ C ≤ 64 MiB; writers use 1 MiB) |
| 8 | 8 | random nonce prefix P |
| 16 | … | records: ciphertext_i || tag_i (16) |
- Plaintext is split into chunks of
Cbytes; the last chunk may be shorter. Empty plaintext gives exactly one final chunk of 0 bytes. - Chunk
i(from 0) is AES-256-GCM with keyDK, nonceP || UInt32(i), and AADheader (bytes 0–15) || UTF-8(context) || F, whereF=0x01for the last chunk, else0x00. - Reading: a record is final if the remaining bytes are ≤
C + 16. Any authentication failure means wrong key, wrong context, truncation, or tampering.
6. Catalog
After decryption, catalog.db.enc is a standard SQLite 3 database file. Its schema is in catalog-schema.md; the version is PRAGMA user_version.
7. Backups
A backup is one file with the whole package, sealed with the same data key: backup-format.md.
8. Device key
On iOS the data key is stored in the Keychain (generic password, service app.kuriosa.library-key, account = libraryID). With the app lock on it is protected by SecAccessControl(.userPresence) and kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly; otherwise kSecAttrAccessibleWhenUnlockedThisDeviceOnly. It is never synced and not part of the package (decision 0013).
9. A library in a shared folder (decision 0035)
A library can live in a folder several devices share (iCloud Drive or another provider). The package keeps this format; Kuriosa names it Kuriosa Library (Kuriosa Library 2, … when the name is taken), without an ending, so file browsers show a plain folder name (issue #57). Packages made earlier are named Kuriosa Library.kuriosa. Rules for writers:
- Finding: a package is any folder with a readable
manifest.json, whatever its name: the chosen folder itself or one directly inside it. With several, the one with the device's library ID comes first, thenKuriosa Library, thenKuriosa Library.kuriosa, then the first by name. A device that no longer finds the package where it was (renamed by the person or by another device) looks for its library ID in the chosen folder and goes on there. - Renaming once: a device whose package inside the chosen folder is still named
Kuriosa Library.kuriosarenames it toKuriosa Libraryonce, with a coordinated move, when it is in step with the folder and the plain name is free. The chosen folder itself is never renamed. Writers copying a package into a folder write the media files first, thencatalog.db.enc, thenmanifest.json, so readers never find a package without its catalog. - Working copy: each device keeps its own copy of
manifest.jsonandcatalog.db.encand saves there. Media files are read and written in the shared package only; they are immutable and named by ID, so devices never write the same media file (a new cut-out, which replaces its files, is the exception: the last writer wins). All access to the shared package uses the platform's file coordination (iOS:NSFileCoordinator), which also makes the provider download a file before it is read. - Writing the catalog: a device writes its catalog into the package only after combining the package's current catalog with its own (section 10), and only if the package's catalog is still the one it combined: under one coordinated write it compares the SHA-256 of the file's bytes with the one it read, and writes only when they match. Otherwise it reads and combines again.
- When: a device should write a saved change soon and read a changed catalog as soon as the platform reports it, with a regular check as a safety net. Kuriosa writes half a second after a save, reads when a file presenter on
catalog.db.encreports a change (issue #89), and checks every 2 to 4 seconds while it is open. - Other copies: when two devices wrote at the same time, iCloud keeps one version as the file and the other as an unresolved conflict version (
NSFileVersion); other providers leave a copy next to it whose name starts withcatalogand ends with.enc(catalog.db 2.enc,catalog.db (conflicted copy).enc). Readers combine every such copy that opens with the library's key and contextcatalog, write the result, and then remove the copies (conflict versions are marked resolved). - Manifest: a device that makes a new recovery key writes the manifest into the package too; the others take a manifest of the same
libraryIDwith other key slots when they read the package. - Deleting media files that the catalog does not know (no
mediarow, never named in the history) is not allowed in a shared package: they can be another device's new photos.
10. Combining two copies of a catalog
Catalog.merge (KuriosaStore) is the reference. Both copies are brought to the current schema; a copy written by a newer schema is refused. The result is the same whichever copy is merged into which, and merging again changes nothing.
- Records (
item,collection,location,media,relation,item_set,item_code) are matched byid. For a record both copies have and that differs, every attribute path of its history form (title,fields.<key>, …, see catalog-schema.md,history.changes) takes the copy's value whose own history changed that path last (byoccurred_at, then the entry'sid).item.eventsanditem.remindersare combined per entryid,item.tagIDsanditem_set.itemIDsper ID. With no history for a path, the record's laterupdated_atdecides, then the larger JSON text of the value.created_attakes the earlier andupdated_atthe later time. - Deletion:
deleted_atfollows the same rule, except that a record deleted in one copy stays alive when the other copy changed it after the deletion (its last history entry is later); the merge then records arestoreentry. A record only one copy has is added, unless the other copy removed it: physically removed records by adeletehistory entry later than the record's last other entry, photos deleted for good and own export templates by alibrary_metakeyremoved:<id>. - Tags are matched by
id, then by name (case-insensitive); of two tags with one name, the smalleridstays and the pieces' tags follow it. Templates and export templates take the row with the laterupdated_at(then higherversion, then the JSON text).library_meta:next_item_numbertakes the larger value,removed:*keys are joined,wishlist_template_idfollows the later template,link_previewstakes the later change (the larger value, which starts with its time), other keys take the smaller value.historyandbackup_eventrows are joined byid. - Link previews (
link_preview, version 16) are matched byitem_idandurl; of two, the laterloaded_at(then the largerid) stays; a row of a piece the copy does not have is left out. Then the mergedlink_previewsswitch decides: while it is off no row stays, while it is on only rows loaded at or after the time it was turned on. Nothing is recorded in the history. - Repairs, recorded in the history with actor
merge, decided alike on every device:- two pieces with one
number: the piece created first keeps it, the other gets the next free number (next_item_numberrule of version 13); the entry records the old and new number; - a deleted collection or place that holds a piece, sub-collection, or sub-place that is not deleted is restored;
- a loop of collections or places: the member with the largest
idmoves to the top level; - a loop of
part_oflinks: the link created last (then the largerid) is removed; - the same link twice (a
mounted_onlink also the other way round): the one created first stays; the same code (item_code.payload) for two pieces: the link created last stays; - a piece whose journal got entries from both copies takes the status its journal leads to.
- two pieces with one