FairAuth

The format of the backup file is published, so you can open your backups without FairAuth. The description is available in English and German.

FairAuth backup file (format version 1)

Deutsch

This is a translation of the German original (BACKUP_FORMAT.md); if they differ, the German text applies.

As of 2026-09-25. This format is openly documented so that you can read your codes at any time, even without FairAuth, and take them to any other app. The description is complete: with it and a libsodium binding, you can open any FairAuth backup. An example program is at the end.

Publisher: BYTEPOTATO UG (haftungsbeschränkt).

Overview

A backup file has the extension .fairauth. It consists of three parts:

  1. a fixed preamble of 16 bytes,
  2. a readable header in JSON format with all parameters,
  3. the encrypted content as a sequence of messages.

Encryption in two stages:

Without the password, the file cannot be opened. Not even BYTEPOTATO UG (haftungsbeschränkt) can do that.

All integers are little-endian. Base64 is standard Base64 as defined in RFC 4648 section 4, with padding.

1. Preamble (16 bytes)

OffsetSizeContent
08ASCII FAIRAUTH
81Container version, 1
93reserved: writers set all three bytes to 0, readers ignore them
124Length of the header in bytes (u32), 2 to 65,536

2. Header (JSON, UTF-8)

{
  "format": "fairauth-backup",
  "version": 1,
  "created": "2026-09-25T10:15:00Z",
  "kdf": {
    "algorithm": "argon2id",
    "version": 19,
    "opsLimit": 3,
    "memLimitBytes": 268435456,
    "salt": "<Base64, 16 Byte>"
  },
  "keyWrap": {
    "algorithm": "xchacha20poly1305-ietf",
    "nonce": "<Base64, 24 Byte>",
    "ciphertext": "<Base64, 48 Byte>"
  },
  "payload": {
    "algorithm": "secretstream-xchacha20poly1305",
    "header": "<Base64, 24 Byte>"
  }
}
FieldMeaning
kdf.versionArgon2 version 19 (0x13), in libsodium crypto_pwhash_ALG_ARGON2ID13
kdf.opsLimit, kdf.memLimitBytesFairAuth writes at least 3 and 268,435,456 (OPSLIMIT_MODERATE, MEMLIMIT_MODERATE). Readers accept opsLimit 2 to 16 and memLimitBytes 67,108,864 to 1,073,741,824
keyWrap.ciphertext32-byte data key plus 16-byte tag
payload.headerHeader of the secretstream (crypto_secretstream_xchacha20poly1305_HEADERBYTES = 24)

Readers ignore unknown fields in the header. The preamble and the header contain three version fields. FairAuth treats them as follows when reading:

FieldFairAuth writesReaders
Container version (preamble, offset 8)11: continue. Greater than 1: do not open the file, but point the user to an update. 0: not a FairAuth backup
version (header)11: continue. Any other integer: do not open the file, but point the user to an update. If version is missing or not an integer, or format is not fairauth-backup: not a FairAuth backup
kdf.version (header)19Required, must be exactly 19. Any other value or a missing field: reject the file as damaged, without pointing to an update

The field schema in the manifest (section 5.1) is not one of these: FairAuth writes 1 there and does not evaluate it when reading.

3. Deriving the key and opening the data key

  1. Normalize the password to Unicode NFC and encode it as UTF-8. This way the same password yields the same key on every device and with every keyboard, including with umlauts, accents or Japanese input.
  2. KEK = crypto_pwhash(32, password, salt, opsLimit, memLimitBytes, ALG_ARGON2ID13)
  3. Additional data for the key wrap: AAD_KEY = "FairAuth backup key v1" (ASCII) ‖ salt ‖ u64(opsLimit) ‖ u64(memLimitBytes)
  4. data_key = crypto_aead_xchacha20poly1305_ietf_decrypt(ciphertext, AAD_KEY, nonce, KEK)

If step 4 fails, the password is wrong or the header has been modified. Because the KDF parameters are part of AAD_KEY, they cannot be changed without this being detected.

The data key stays the same across multiple backups, so that FairAuth can back up automatically without asking for the password every time. When the password is changed, FairAuth generates a new data key.

4. Decrypting the content

Directly after the header, frames follow up to the end of the file:

SizeContent
4Length n of the frame (u32), 17 to 8,388,625
nsecretstream ciphertext of one message

n counts only the ciphertext, not the 4 bytes of the length field. The ciphertext is 17 bytes longer than the decrypted message (crypto_secretstream_xchacha20poly1305_ABYTES: 1 byte for the encrypted tag and 16 bytes of authentication code). A decrypted message is therefore at most 8 MiB (8,388,608 bytes), and a frame at most 8 MiB + 17 bytes (8,388,625 bytes).

All frames are opened with the same state from crypto_secretstream_xchacha20poly1305_init_pull(payload.header, data_key). Additional data for every message:

AD = SHA-256(preamble ‖ header)

The hash covers the first 16 + header length bytes of the file (the header length is the value at offset 12), that is bytes 0 through 15 + header length inclusive: the preamble and the header exactly as they appear in the file. In the example in section 9, this is data[:head_end]. AD is the 32-byte hash value itself, not its hex representation. This binds the header to the content. The last message carries the tag TAG_FINAL (0x03), all others TAG_MESSAGE (0x00). The file must end with the frame that carries TAG_FINAL: if any bytes follow it, the file is rejected. If TAG_FINAL is missing, the file is incomplete and is rejected as well. secretstream also detects frames that are reordered, missing or duplicated.

5. Messages

Each message is a JSON object in UTF-8 with the field type. The first message is always manifest, the last one always end. In between are tag, smartFolder, image and entry in any order. Readers skip unknown types and unknown fields.

5.1 manifest

{"type": "manifest", "schema": 1, "app": "FairAuth", "appVersion": "1.0 (1)",
 "entries": 42, "images": 3, "tags": 5, "smartFolders": 1}

5.2 entry

{
  "type": "entry",
  "id": "5B0C2E4A-2B7B-4C4F-9E37-2E6F4C1D8A10",
  "issuer": "Example",
  "account": "anna@example.com",
  "otp": {"kind": "totp", "secret": "JBSWY3DPEHPK3PXP",
          "algorithm": "SHA1", "digits": 6, "period": 30},
  "otpauth": "otpauth://totp/Example:anna%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30",
  "icon": {"kind": "monogram", "monogram": "EX", "accentColor": 3},
  "tags": ["0E3F…"],
  "favorite": false,
  "pinned": false,
  "note": "",
  "domains": ["example.com"],
  "backupCodesSaved": true,
  "archived": false,
  "migration": "none",
  "sortKey": "a0",
  "created": "2026-09-20T08:00:00Z",
  "modified": "2026-09-25T10:00:00Z",
  "trashed": null
}
FieldValues
otp.kindtotp, hotp, steam
otp.secretBase32 as defined in RFC 4648, uppercase letters, without =
otp.algorithmSHA1, SHA256, SHA512
otp.digits6 to 8, 5 for steam
otp.periodSeconds, 10 to 120 (totp, steam)
otp.counterhotp only, next counter to be used
otpauththe same entry as an otpauth:// link, for easy transfer to other apps. If the two differ, otp applies
icon.kindmonogram, image (then imageID), symbol (then symbol, an SF Symbol name)
icon.accentColorInteger 0 to 11, index into FairAuth’s twelve accent colors. FairAuth writes the field for every kind of icon. When reading, FairAuth limits the value to 0 to 255 and takes it modulo 12 for display; if it is missing, 0 applies
migrationnone, pending (not yet moved), done
trashedTime at which the entry was moved to the trash, otherwise null

Steam entries generate five characters from the alphabet 23456789BCDFGHJKMNPQRTVWXY; their otpauth link carries issuer=Steam and additionally encoder=steam.

5.3 image

{"type": "image", "id": "…", "format": "png", "width": 512, "height": 512,
 "shape": "circle", "data": "<Base64>"}

format is png, heic or jpeg, at most 512 Ă— 512 pixels, without metadata (no EXIF, no location data).

shape is circle (round) or roundedSquare (square with rounded corners). FairAuth reads any other value and a missing field as circle.

5.4 tag and smartFolder

{"type": "tag", "id": "…", "name": "Arbeit", "color": 3, "sortKey": "a0"}
{"type": "smartFolder", "id": "…", "name": "Ohne Backup-Codes", "symbol": "tray",
 "sortKey": "a1", "filter": {"backupCodesMissing": true, "tagIDs": [], "tagMode": "any"}}

Like icon.accentColor, color is an integer from 0 to 11 (index into the twelve accent colors) and is treated the same way when reading. FairAuth derives a tag’s color from its name and offers no choice; when restoring, it therefore takes only the name and works out the color again, which gives the same color. Other programs may use color.

filter has the following ten keys; FairAuth always writes all of them (the example above is shortened). An entry belongs to the Smart Folder if it meets all conditions. If a key is missing or has a value other than those listed, the default applies when reading. If filter is missing entirely, all defaults apply.

KeyValuesDefault
tagIDsList of tag IDs (id of a tag message). Empty means: no condition[]
tagModeany (at least one of the tags), all (all tags)any
favoritesOnlytrue (favorites only), falsefalse
kindsList of totp, hotp, steam. Empty means: all kinds[]
algorithmsList of SHA1, SHA256, SHA512. Empty means: all algorithms[]
imageany (all), with (only with a custom image), without (only without a custom image)any
archivedhide (hide archived entries), only (archived entries only), include (include archived entries)hide
migrationPendingtrue (only entries with migration = pending), falsefalse
backupCodesMissingtrue (only entries with backupCodesSaved = false), falsefalse
queryText. FairAuth reads at most 256 bytes of UTF-8 and keeps it, but does not filter by it""

When reading, FairAuth skips invalid IDs in tagIDs and unknown values in kinds and algorithms.

5.5 end

{"type": "end", "entries": 42, "images": 3}

When reading, FairAuth checks exactly two counts, both from end: entries must equal the number of entry messages read, and images the number of image messages read. If either field is missing or a count does not match, the file is rejected. end does not count tag and smartFolder messages.

The counts in the manifest (section 5.1) are only an advance indication: FairAuth does not check them when reading and reads only appVersion from the manifest. In files written by FairAuth, entries, tags and smartFolders equal the number of messages written. images, by contrast, counts the distinct images that entries refer to. If FairAuth cannot load one of them while backing up, its image message is missing, and end.images is smaller than manifest.images.

6. Limits for readers

QuantityLimit
Header64 KiB
Message, decrypted8 MiB (8,388,608 bytes)
Frame (n in section 4)8 MiB + 17 bytes (8,388,625 bytes)
Messages50,000
Entries20,000
File512 MiB

When reading, FairAuth counts the frames and their length fields against the file limit and stops when it is exceeded. When writing, it checks the same limits first and stops before it creates a file a reader would reject: a backup that fails only when restoring is found out too late. When reading, FairAuth cuts names, accounts, notes, tags and smart folders to the vault’s limits, in bytes of UTF-8 and at a character boundary (VAULT_FORMAT.md, section 3).

7. Plain-text export

After re-authentication, two confirmations and a clear warning, FairAuth also exports without encryption:

An unencrypted file can be read by anyone who gets hold of it.

8. File names and versions

FairAuth writes FairAuth-Backup-YYYY-MM-DDTHH-MM-SSZ.fairauth (UTC) to the chosen folder, first as a temporary file, which is then renamed. It keeps the five newest files with this naming scheme and removes older ones. It does not touch other files in the folder.

9. Example: opening a backup without FairAuth (Python)

Requirements: Python 3 and PyNaCl 1.5 or later (pip install pynacl).

import base64, hashlib, json, struct, sys, unicodedata
from nacl import bindings, pwhash

def open_backup(path: str, password: str) -> list:
    data = open(path, "rb").read()
    if data[:8] != b"FAIRAUTH" or data[8] != 1:
        raise ValueError("keine FairAuth-Sicherung der Version 1")
    head_len = struct.unpack_from("<I", data, 12)[0]
    head_end = 16 + head_len
    head = json.loads(data[16:head_end])

    kdf, wrap = head["kdf"], head["keyWrap"]
    salt = base64.b64decode(kdf["salt"])
    secret = unicodedata.normalize("NFC", password).encode("utf-8")
    kek = pwhash.argon2id.kdf(32, secret, salt,
                              opslimit=kdf["opsLimit"], memlimit=kdf["memLimitBytes"])
    aad = b"FairAuth backup key v1" + salt + struct.pack("<QQ", kdf["opsLimit"], kdf["memLimitBytes"])
    data_key = bindings.crypto_aead_xchacha20poly1305_ietf_decrypt(
        base64.b64decode(wrap["ciphertext"]), aad, base64.b64decode(wrap["nonce"]), kek)

    ad = hashlib.sha256(data[:head_end]).digest()
    state = bindings.crypto_secretstream_xchacha20poly1305_state()
    bindings.crypto_secretstream_xchacha20poly1305_init_pull(
        state, base64.b64decode(head["payload"]["header"]), data_key)

    items, pos, final = [], head_end, False
    while pos < len(data):
        size = struct.unpack_from("<I", data, pos)[0]
        pos += 4
        message, tag = bindings.crypto_secretstream_xchacha20poly1305_pull(
            state, data[pos:pos + size], ad)
        pos += size
        items.append(json.loads(message))
        if tag == bindings.crypto_secretstream_xchacha20poly1305_TAG_FINAL:
            final = True
            break
    if not final or pos != len(data):
        raise ValueError("Datei unvollständig oder verändert")
    return items

if __name__ == "__main__":
    for item in open_backup(sys.argv[1], sys.argv[2]):
        if item["type"] == "entry":
            print(item["otpauth"])

The program prints each entry as an otpauth:// link. These links can be imported into almost any authenticator app.

Two test vectors are included: backup-testvector-de.fairauth (password with umlauts) and backup-testvector-ja.fairauth (Japanese password). The passwords and the expected output are in backup-testvector.txt. Both files have been checked with this program and additionally with the Argon2 reference implementation.