Das ist das Format der Pictansu-Backups. Wir veröffentlichen es, damit du unsere Aussagen prüfen und dein eigenes Backup ohne die App öffnen kannst. Dein Backup bleibt trotzdem sicher: Es beruht auf Standardalgorithmen und deinem Wiederherstellungscode, nicht auf einem geheimen Format. Die technische Beschreibung unten ist auf Englisch.
What a backup contains
A backup is a folder named Pictansu Backup in your Google Drive or iCloud Drive, or the same files in the root of a saved .zip:
Pictansu Backup/
manifest.json
items/<id>.bin the original photo or video, encrypted
items/<id>.thumb.bin its thumbnail, encrypted
items/<id>.motion.bin Live Photo motion, encrypted
Photos, videos, thumbnails, file names, album names, dates and plaintext hashes are all encrypted. <id> is a random 32-character hex string. Your cloud provider can read the unencrypted part of manifest.json described below, the names and sizes of the files, and which items have a Live Photo part.
manifest.json
These fields can be read without a key:
| Field | Meaning |
|---|---|
formatVersion | 1. The app refuses to restore or overwrite a backup with a newer version than it knows. |
app | pictansu |
createdAt, platform | When the backup was made (UTC), and ios or android. |
vaultId | A random hex ID for the vault. Not a secret. |
keyCheck | Base64. The UTF-8 text pictansu-vault encrypted as a PTSU file with the master key, context pictansu-key-check-v1. It tells the app whether a backup belongs to this vault. |
kdf | Argon2id parameters, repeated from the wrapped key for readability. |
wrappedKeys.recovery | The master key, encrypted with a key derived from your recovery code. See “Keys”. |
itemCount, totalBytes | A summary the app shows before you enter your recovery code. |
files[] | path, size and sha256 of each file. The hash is of the encrypted file, so it reveals nothing about the content. |
items | {"alg": "pictansu-ptsu-v1", "context": "pictansu-manifest-items-v1", "data": "<base64>"}. data is the encrypted list described in “Encrypted item list”. |
Encrypted files (PTSU v1)
Every encrypted file uses XChaCha20-Poly1305-IETF from libsodium, one authenticated chunk at a time. The header is 28 bytes, with big-endian integers:
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 4 | magic | ASCII PTSU |
| 4 | 1 | version | 1 |
| 5 | 1 | alg | 1 = XChaCha20-Poly1305-IETF per chunk |
| 6 | 2 | reserved | 0 |
| 8 | 4 | chunkSize C | Plaintext chunk size, u32 (default 1 MiB) |
| 12 | 16 | noncePrefix | Random |
Chunks follow from offset 28. For chunk i, counting from 0:
- Position:
28 + i × (C + 16). Every chunk except the last holds exactly C plaintext bytes (C + 16 encrypted). The last holds 1 to C bytes. An empty file is a single 0-byte chunk (16 encrypted bytes). - Nonce (24 bytes):
noncePrefix ‖ u64be(i) - Additional data:
the 28 header bytes ‖ u64be(i) ‖ final ‖ context.finalis one byte, 1 for the last chunk only.contextis the UTF-8 string below. - Plaintext:
crypto_aead_xchacha20poly1305_ietf_decrypt(chunk, additional data, nonce, key) - A last chunk that doesn't authenticate with
final = 1means the file was cut short. Treat it as a failure.
| File | Context |
|---|---|
items/<id>.bin | pictansu-item-v1:<id> |
items/<id>.thumb.bin | pictansu-thumb-v1:<id> |
items/<id>.motion.bin | pictansu-motion-v1:<id> |
items.data in the manifest | pictansu-manifest-items-v1 |
Keys
All files are encrypted with one random 32-byte master key. The backup stores it only in encrypted form:
{"purpose": "recovery",
"kdf": {"alg": "argon2id13", "opslimit": 3, "memlimit": 67108864, "salt": "<base64, 16 bytes>"},
"wrapped": {"alg": "xchacha20poly1305-ietf", "nonce": "<base64, 24 bytes>", "ciphertext": "<base64>"}}
- Recovery code: 28 Crockford base32 characters (140 bits), shown as 7 groups of 4. Before use, make it uppercase, remove spaces and hyphens, and change O to 0 and I or L to 1.
- Key encryption key: 32 bytes from Argon2id13 (
crypto_pwhash) over the normalized code. Valid ranges: opslimit 2 to 10, memlimit 64 to 512 MiB. - Additional data for the wrapped key: UTF-8
pictansu-key-wrap-v1|recovery|argon2id13;ops=<opslimit>;mem=<memlimit>;salt=<base64 salt>
Your PIN is never in a backup. A 6-digit PIN has only a million possible values, so anyone holding the file could try them all. A 140-bit recovery code can't be guessed that way. This is why restoring a backup needs the recovery code, and why a lost recovery code can't be replaced by anyone.
Encrypted item list
Decrypting items.data gives JSON:
{"albums": [{"id", "name", "createdAt"}],
"items": [{"id", "name", "type": "photo|video", "mime", "size", "sha256",
"createdAt", "importedAt", "addedAt"?, "album", "albumId"?, "durationMs"?,
"width"?, "height"?, "trashedAt"?, "motion"?: {"size", "sha256", "name"?},
"thumb": true|false}],
"devices"?: {"<random device id>": "<last backup, UTC>"},
"tombstones"?: [{"id", "at"}]}
size and sha256 describe the original, unencrypted file. tombstones lists items you permanently deleted, so a restore doesn't bring them back.
Opening a backup without the app
Any libsodium binding can do this. You need the backup and your recovery code.
- Normalize the recovery code to 28 characters, as described in “Keys”.
KEK = crypto_pwhash_argon2id(32, code, salt, opslimit, memlimit)master key = crypto_aead_xchacha20poly1305_ietf_decrypt(wrapped.ciphertext, additional data, wrapped.nonce, KEK)- Base64-decode
items.dataand decrypt it with the chunk rules above, to get the item list. - Decrypt each
items/<id>.binthe same way, using its context, and compare the result with the item'ssha256.
Questions about the format are welcome at admin@slothlab.app.