FairAuth

Das Format der Sicherungsdatei ist veröffentlicht, damit du deine Sicherungen auch ohne FairAuth öffnen kannst. Die Beschreibung gibt es auf Englisch und Deutsch.

FairAuth-Sicherungsdatei (Format Version 1)

English

Stand: 2026-09-25. Dieses Format ist offen dokumentiert, damit du deine Codes jederzeit auch ohne FairAuth lesen und in jede andere App mitnehmen kannst. Die Beschreibung ist vollständig: Mit ihr und einer libsodium-Anbindung lässt sich jede FairAuth-Sicherung öffnen. Am Ende steht ein Beispielprogramm.

Herausgeber: BYTEPOTATO UG (haftungsbeschränkt).

Überblick

Eine Sicherungsdatei hat die Endung .fairauth. Sie besteht aus drei Teilen:

  1. einem festen Vorspann von 16 Byte,
  2. einem lesbaren Kopf im JSON-Format mit allen Parametern,
  3. dem verschlüsselten Inhalt als Folge von Nachrichten.

Verschlüsselung in zwei Stufen:

Ohne das Passwort lässt sich die Datei nicht öffnen. Auch BYTEPOTATO UG (haftungsbeschränkt) kann das nicht.

Alle Ganzzahlen sind little-endian. Base64 ist Standard-Base64 nach RFC 4648 Abschnitt 4 mit Auffüllung.

1. Vorspann (16 Byte)

OffsetGrößeInhalt
08ASCII FAIRAUTH
81Containerversion, 1
93reserviert: Schreiber setzen alle drei Byte auf 0, Leser ignorieren sie
124Länge des Kopfes in Byte (u32), 2 bis 65.536

2. Kopf (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>"
  }
}
FeldBedeutung
kdf.versionArgon2-Version 19 (0x13), in libsodium crypto_pwhash_ALG_ARGON2ID13
kdf.opsLimit, kdf.memLimitBytesFairAuth schreibt mindestens 3 und 268.435.456 (OPSLIMIT_MODERATE, MEMLIMIT_MODERATE). Leser akzeptieren opsLimit 2 bis 16 und memLimitBytes 67.108.864 bis 1.073.741.824
keyWrap.ciphertext32 Byte Datenschlüssel plus 16 Byte Tag
payload.headerKopf des secretstream (crypto_secretstream_xchacha20poly1305_HEADERBYTES = 24)

Leser ignorieren unbekannte Felder im Kopf. Vorspann und Kopf enthalten drei Versionsangaben. FairAuth behandelt sie beim Lesen so:

AngabeFairAuth schreibtLeser
Containerversion (Vorspann, Offset 8)11: weiterlesen. Größer als 1: Datei nicht öffnen, sondern auf ein Update hinweisen. 0: keine FairAuth-Sicherung
version (Kopf)11: weiterlesen. Jede andere Ganzzahl: Datei nicht öffnen, sondern auf ein Update hinweisen. Fehlt version, ist es keine Ganzzahl oder ist format nicht fairauth-backup: keine FairAuth-Sicherung
kdf.version (Kopf)19Pflichtfeld, muss genau 19 sein. Jeden anderen Wert und ein fehlendes Feld: Datei als beschädigt ablehnen, ohne Hinweis auf ein Update

Das Feld schema im manifest (Abschnitt 5.1) ist keine dieser Angaben: FairAuth schreibt dort 1 und wertet es beim Lesen nicht aus.

3. Schlüssel ableiten und Datenschlüssel öffnen

  1. Passwort nach Unicode NFC normalisieren und als UTF-8 kodieren. So ergibt dasselbe Passwort auf jedem Gerät und mit jeder Tastatur denselben Schlüssel, auch mit Umlauten, Akzenten oder japanischer Eingabe.
  2. KEK = crypto_pwhash(32, passwort, salt, opsLimit, memLimitBytes, ALG_ARGON2ID13)
  3. Zusätzliche Daten der Schlüsselhülle: AAD_KEY = "FairAuth backup key v1" (ASCII) ‖ salt ‖ u64(opsLimit) ‖ u64(memLimitBytes)
  4. Datenschlüssel = crypto_aead_xchacha20poly1305_ietf_decrypt(ciphertext, AAD_KEY, nonce, KEK)

Schlägt Schritt 4 fehl, ist das Passwort falsch oder der Kopf verändert. Weil die KDF-Parameter in AAD_KEY stecken, lassen sie sich nicht unbemerkt ändern.

Der Datenschlüssel bleibt über mehrere Sicherungen gleich, damit FairAuth automatisch sichern kann, ohne jedes Mal nach dem Passwort zu fragen. Beim Passwortwechsel erzeugt FairAuth einen neuen Datenschlüssel.

4. Inhalt entschlüsseln

Direkt nach dem Kopf folgen Rahmen bis zum Dateiende:

GrößeInhalt
4Länge n des Rahmens (u32), 17 bis 8.388.625
nsecretstream-Chiffrat einer Nachricht

n zählt nur das Chiffrat, nicht die 4 Byte der Längenangabe. Das Chiffrat ist 17 Byte länger als die entschlüsselte Nachricht (crypto_secretstream_xchacha20poly1305_ABYTES: 1 Byte für das verschlüsselte Tag und 16 Byte Authentifizierungscode). Eine entschlüsselte Nachricht hat also höchstens 8 MiB (8.388.608 Byte), ein Rahmen höchstens 8 MiB + 17 Byte (8.388.625 Byte).

Alle Rahmen werden mit demselben Zustand aus crypto_secretstream_xchacha20poly1305_init_pull(payload.header, Datenschlüssel) geöffnet. Zusätzliche Daten jeder Nachricht:

AD = SHA-256(Vorspann ‖ Kopf)

Gehasht werden die ersten 16 + Kopflänge Byte der Datei (Kopflänge ist der Wert an Offset 12), also Byte 0 bis einschließlich Byte 15 + Kopflänge: Vorspann und Kopf, genau so, wie sie in der Datei stehen. Im Beispiel in Abschnitt 9 ist das data[:head_end]. AD sind die 32 Byte des Hashwerts selbst, nicht seine Hex-Darstellung. So ist der Kopf mit dem Inhalt verbunden. Die letzte Nachricht trägt das Tag TAG_FINAL (0x03), alle anderen TAG_MESSAGE (0x00). Mit dem Rahmen, der TAG_FINAL trägt, muss die Datei enden: Folgen danach noch Bytes, wird die Datei abgelehnt. Fehlt TAG_FINAL, ist die Datei unvollständig und wird ebenfalls abgelehnt. secretstream erkennt außerdem vertauschte, fehlende oder doppelte Rahmen.

5. Nachrichten

Jede Nachricht ist ein JSON-Objekt in UTF-8 mit dem Feld type. Die erste Nachricht ist immer manifest, die letzte immer end. Dazwischen stehen tag, smartFolder, image und entry in beliebiger Reihenfolge. Leser überspringen unbekannte Typen und unbekannte Felder.

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
}
FeldWerte
otp.kindtotp, hotp, steam
otp.secretBase32 nach RFC 4648, Großbuchstaben, ohne =
otp.algorithmSHA1, SHA256, SHA512
otp.digits6 bis 8, bei steam 5
otp.periodSekunden, 10 bis 120 (totp, steam)
otp.counternur hotp, nächster zu verwendender Zähler
otpauthderselbe Eintrag als otpauth://-Link, zur einfachen Übernahme in andere Apps. Bei Abweichungen gilt otp
icon.kindmonogram, image (dann imageID), symbol (dann symbol, ein SF-Symbol-Name)
icon.accentColorGanzzahl 0 bis 11, Index in die zwölf Akzentfarben von FairAuth. FairAuth schreibt das Feld bei jeder Art von icon. Beim Lesen begrenzt FairAuth den Wert auf 0 bis 255 und rechnet für die Anzeige modulo 12; fehlt er, gilt 0
migrationnone, pending (noch nicht umgezogen), done
trashedZeitpunkt, zu dem der Eintrag in den Papierkorb kam, sonst null

Steam-Einträge erzeugen fünf Zeichen aus dem Alphabet 23456789BCDFGHJKMNPQRTVWXY; ihr otpauth-Link trägt issuer=Steam und zusätzlich encoder=steam.

5.3 image

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

format ist png, heic oder jpeg, höchstens 512 × 512 Pixel, ohne Metadaten (kein EXIF, keine Ortsangaben).

shape ist circle (rund) oder roundedSquare (Quadrat mit abgerundeten Ecken). Jeden anderen Wert und ein fehlendes Feld liest FairAuth als circle.

5.4 tag und 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"}}

color ist wie icon.accentColor eine Ganzzahl von 0 bis 11 (Index in die zwölf Akzentfarben) und wird beim Lesen genauso behandelt. FairAuth leitet die Farbe eines Tags aus seinem Namen ab und bietet keine Auswahl an; beim Wiederherstellen übernimmt es deshalb nur den Namen und bestimmt die Farbe neu, das Ergebnis ist dieselbe Farbe. Andere Programme dürfen color verwenden.

filter hat die folgenden zehn Schlüssel; FairAuth schreibt immer alle (das Beispiel oben ist gekürzt). Ein Eintrag gehört zum Smart Folder, wenn er alle Bedingungen erfüllt. Fehlt ein Schlüssel oder hat er einen anderen als die genannten Werte, gilt beim Lesen die Vorgabe. Fehlt filter ganz, gelten alle Vorgaben.

SchlüsselWerteVorgabe
tagIDsListe von Tag-IDs (id einer tag-Nachricht). Leer heißt: keine Bedingung[]
tagModeany (mindestens eines der Tags), all (alle Tags)any
favoritesOnlytrue (nur Favoriten), falsefalse
kindsListe aus totp, hotp, steam. Leer heißt: alle Arten[]
algorithmsListe aus SHA1, SHA256, SHA512. Leer heißt: alle Algorithmen[]
imageany (alle), with (nur mit eigenem Bild), without (nur ohne eigenes Bild)any
archivedhide (archivierte ausblenden), only (nur archivierte), include (archivierte einschließen)hide
migrationPendingtrue (nur Einträge mit migration = pending), falsefalse
backupCodesMissingtrue (nur Einträge mit backupCodesSaved = false), falsefalse
queryText. FairAuth liest höchstens 256 Byte UTF-8 und übernimmt ihn, filtert aber nicht danach""

Ungültige IDs in tagIDs und unbekannte Werte in kinds und algorithms überspringt FairAuth beim Lesen.

5.5 end

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

FairAuth prüft beim Lesen genau zwei Zahlen, beide aus end: entries muss gleich der Zahl der gelesenen entry-Nachrichten sein, images gleich der Zahl der gelesenen image-Nachrichten. Fehlt eines der beiden Felder oder stimmt eine Zahl nicht, wird die Datei abgelehnt. tag- und smartFolder-Nachrichten zählt end nicht.

Die Zahlen im manifest (Abschnitt 5.1) sind nur eine Vorabangabe: FairAuth prüft sie beim Lesen nicht und liest aus dem manifest nur appVersion. In Dateien von FairAuth entsprechen entries, tags und smartFolders der Zahl der geschriebenen Nachrichten. images zählt dagegen die verschiedenen Bilder, auf die Einträge verweisen. Kann FairAuth eines davon beim Sichern nicht laden, fehlt seine image-Nachricht, und end.images ist kleiner als manifest.images.

6. Grenzen für Leser

GrößeGrenze
Kopf64 KiB
Nachricht, entschlüsselt8 MiB (8.388.608 Byte)
Rahmen (n in Abschnitt 4)8 MiB + 17 Byte (8.388.625 Byte)
Nachrichten50.000
Einträge20.000
Datei512 MiB

FairAuth zählt beim Lesen die Rahmen samt ihrer Längenangaben gegen die Dateigrenze und bricht beim Überschreiten ab. Beim Schreiben prüft es dieselben Grenzen vorher und bricht ab, bevor es eine Datei erzeugt, die ein Leser ablehnen würde: eine Sicherung, die erst beim Wiederherstellen scheitert, fällt zu spät auf. Namen, Konten, Notizen, Tags und Smart-Ordner kürzt FairAuth beim Lesen auf die Grenzen des Tresors, in Byte UTF-8 und an einer Zeichengrenze (VAULT_FORMAT.md, Abschnitt 3).

7. Klartext-Export

Nach erneuter Anmeldung, zwei Bestätigungen und einem deutlichen Hinweis exportiert FairAuth auch unverschlüsselt:

Eine unverschlüsselte Datei kann jeder lesen, der sie in die Hände bekommt.

8. Dateinamen und Versionen

FairAuth schreibt FairAuth-Backup-JJJJ-MM-TTTHH-MM-SSZ.fairauth (UTC) in den gewählten Ordner, zuerst als temporäre Datei, dann umbenannt. Es behält die fünf neuesten Dateien mit diesem Namensschema und entfernt ältere. Andere Dateien im Ordner fasst es nicht an.

9. Beispiel: Sicherung ohne FairAuth öffnen (Python)

Voraussetzung: Python 3 und PyNaCl 1.5 oder neuer (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"])

Das Programm gibt jeden Eintrag als otpauth://-Link aus. Diese Links lassen sich in nahezu jede Authenticator-App übernehmen.

Zwei Testvektoren liegen bei: backup-testvector-de.fairauth (Passwort mit Umlauten) und backup-testvector-ja.fairauth (japanisches Passwort). Passwörter und erwartete Ausgabe stehen in backup-testvector.txt. Beide Dateien sind mit diesem Programm und zusätzlich mit der Argon2-Referenzimplementierung geprüft.