Pictansu

Pictansu

Backup format

This is the format of Pictansu backups, published so you can check our claims and open your own backup without the app. Your backup stays safe with this page public: it relies on standard algorithms and your recovery code, not on a secret format.

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:

FieldMeaning
formatVersion1. The app refuses to restore or overwrite a backup with a newer version than it knows.
apppictansu
createdAt, platformWhen the backup was made (UTC), and ios or android.
vaultIdA random hex ID for the vault. Not a secret.
keyCheckBase64. 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.
kdfArgon2id parameters, repeated from the wrapped key for readability.
wrappedKeys.recoveryThe master key, encrypted with a key derived from your recovery code. See “Keys”.
itemCount, totalBytesA 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:

OffsetSizeFieldValue
04magicASCII PTSU
41version1
51alg1 = XChaCha20-Poly1305-IETF per chunk
62reserved0
84chunkSize CPlaintext chunk size, u32 (default 1 MiB)
1216noncePrefixRandom

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. final is one byte, 1 for the last chunk only. context is 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 = 1 means the file was cut short. Treat it as a failure.
FileContext
items/<id>.binpictansu-item-v1:<id>
items/<id>.thumb.binpictansu-thumb-v1:<id>
items/<id>.motion.binpictansu-motion-v1:<id>
items.data in the manifestpictansu-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.

  1. Normalize the recovery code to 28 characters, as described in “Keys”.
  2. KEK = crypto_pwhash_argon2id(32, code, salt, opslimit, memlimit)
  3. master key = crypto_aead_xchacha20poly1305_ietf_decrypt(wrapped.ciphertext, additional data, wrapped.nonce, KEK)
  4. Base64-decode items.data and decrypt it with the chunk rules above, to get the item list.
  5. Decrypt each items/<id>.bin the same way, using its context, and compare the result with the item's sha256.

Questions about the format are welcome at admin@slothlab.app.