Technische Dokumentation

Diese Dokumentation beschreibt die technische Architektur des Datenformats für die Verwendung außerhalb der Receipts Space App. Das Design folgt den Prinzipien von “Local First” und “File Over App”.

Highlights

  • Offline-First: Vollständig ohne Server nutzbar
  • Konfliktfrei: CRDT-basierte Synchronisation über beliebige Speichermedien
  • Manipulationssicher: Verkettete Transaktionen mit optionaler Signierung
  • Langlebig: Einfaches, dokumentiertes Format (JSON/JSONL)
  • Portabel: Daten gehören dem Nutzer, nicht der App

Beispiel-Code auf Github

Verzeichnisstruktur

Ein Workspace ist wie folgt aufgebaut:

workspace/
├── info.json                                    → Metadaten
├── transactions/<clientId>/<...>/<idx>.dat      → Änderungslog
└── assets/<clientId>/<...>/<idx>.dat            → Dateianhänge

Workspace-Metadaten (info.json)

FeldBeschreibung
apiVersionFormat-Version, aktuell 3
workspaceTypeImmer receipts2 für Receipts Space
workspaceIdEindeutige ID des Workspaces
createDateErstellungszeitpunkt (Unix-Timestamp in Sekunden)
encryption(optional) Verschlüsselungskonfiguration → Verschlüsselung

Transaktionen

Änderungen werden in einem fortlaufenden Log gespeichert. Jeder Client führt sein eigenes Log, identifiziert durch eine eindeutige clientId. Die Einträge werden fortlaufend nummeriert (0.dat, 1.dat, …) und zur Vermeidung von Dateisystem-Konflikten nach einem Verteilungs-Algorithmus in Unterverzeichnisse aufgeteilt (max. 1000 Dateien pro Ordner).

Konfliktfreie Synchronisation (CRDT)

Jeder Datenbankeintrag enthält:

FeldBeschreibung
_idEindeutige ID des Eintrags
_typeDatentyp (z.B. category, receipt)
_vVersionszähler (Lamport-Clock)

Die Synchronisation nutzt Last-Write-Wins (LWW): Ein Eintrag wird nur übernommen, wenn sein _v größer ist als der vorhandene Wert. Bei gleichem _v entscheidet der Zeitstempel. Dadurch ist die Reihenfolge der Anwendung irrelevant (CRDT).

Dateiformat

Jede Transaktionsdatei besteht aus zwei Teilen, getrennt durch einen Zeilenumbruch:

  1. Header (erste Zeile): JSON-Objekt mit Metadaten
  2. Content (Rest): JSONL-formatierte Änderungen (UTF-8)

Header-Felder:

FeldBeschreibung
sContent-Größe in Bytes
cSHA256-Checksum des Contents (URL-safe Base64, ohne Padding)
tErstellungszeit (Unix-Timestamp in Sekunden)
vFormat-Version, aktuell 1
p(optional) Hash der vorherigen Transaktion → Verkettung
did(optional) Eindeutige Device-ID der Installation

Beispiel:

{"c":"Sg96RWfbFLN6_d3gsG65IJjJVgM9Jw9yFbxSzaYrxv8","did":"8rc37rrax970579qkvra65cke4","p":"za_9iYEZLZtTmdv0MMaV_IwD_tF17Sx-imb31KhkgUY","s":4134,"t":1768137121,"v":1}
{"_id":"47855a70de36449f821d40b45f8c170a","_type":"category","_v":1,"title":"Telekommunikation"}

Device-ID

Die Device-ID did ist eine eindeutige Kennung pro Installation (Gerät oder Benutzer). Sie wird in der ersten Transaktion eines Clients gesetzt. Dieselbe did kann von mehreren Clients gemeinsam verwendet werden, aber jeder Client hat immer nur eine did.

Transaktionsverkettung

Über das optionale Feld p (previous) wird jede Transaktion mit ihrer Vorgängerin verknüpft. Der Wert ist der SHA256-Hash der kompletten vorherigen Datei (Header + Content).

Bei verschlüsselten Workspaces wird der Hash über die verschlüsselte Datei gebildet (IV + Ciphertext + AuthTag), nicht über den Klartext.

┌────────────────────┐     ┌──────────────┐     ┌──────────────┐
│       0.dat        │◄────│    1.dat     │◄────│    2.dat     │
│ p: hash(info.json) │  p  │  p: hash(0)  │  p  │  p: hash(1)  │
└────────────────────┘     └──────────────┘     └──────────────┘

Garantien:

  • Integrität: Manipulation ändert den Hash → Kettenbruch erkennbar
  • Vollständigkeit: Fehlende Transaktionen brechen die Kette
  • Reihenfolge: Chronologie ist kryptografisch gesichert

Verifizierung: Von der letzten Transaktion rückwärts prüfen, ob jeder p-Wert mit dem berechneten Hash der Vorgänger-Datei übereinstimmt.

Dateianhänge (Assets)

Binärdateien werden separat als Assets gespeichert, analog zu Transaktionen mit fortlaufendem Index pro Client.

Asset-URL Format: asset:///<clientId>/<index>/<filename>?s=<size>&t=<mimeType>&d=<checksum>

ParameterBeschreibung
clientIdClient-ID
indexFortlaufender Index
filenameOriginaler Dateiname
sGröße in Bytes
tMIME-Type
dSHA256-Checksum (Base64)

Beispiel: asset:///1EH7BEtuL9xOz5aTpEyI4K/466/rechnung.pdf?s=6284&t=application%2Fpdf&d=JAd0HmXcSIVVdYMmDBjfVZeTvAyXQ94GmjA6CwSwOYU

Die Metadaten sind Teil der URL, wodurch die Integrität beim Laden geprüft werden kann.

Datentypen

TypFormatBeispiel
ID / Referenz_id des Zielobjekts"category": "abc123"
DatumInteger YYYYMMDD20241201 (1. Dez 2024)
BetragFließkommazahl1.23
TimestampUnix-Sekunden1732311704

Bei den einzelnen Datenfeldern sind einige Besonderheiten zu beachten:

  • Der Dokumententyp ist in doctype abgelegt. Aktuell existiert nur ein Dokumententyp mit der ID d0c5d0c5d0c5d0c5d0c5d0c5d0c5d0c5. Ist dieser nicht gesetzt, wird anhand des Wertes in credit entschieden, ob es sich um eine Einnahme (true) oder eine Ausgabe (false) handelt.
  • Kontakt (contact) und Kategorie (category) werden durch die ID referenziert.
  • Die Schlagwörter in tags sind als Dictionary abgelegt, wobei die Keys die Referenz / ID darstellen und die Values anzeigen, ob der Wert gesetzt (truthy) oder gelöscht (falsy) wurde.
  • Die Steuersumme wird in tax abgelegt. Die Steuer-Details werden in taxDetails abgelegt. Auch hier wird ein Dictionary genutzt. Die Keys stellen den Steuersatz als String dar (z. B. "19.0") und die Values den Betrag.
  • Der Abschluss, der im revisionssicheren Modus auch zum Schreibschutz führt, ist in confirmed abgelegt.

Verschlüsselung (optional)

Für sensible Daten werden Transaktionen und Assets vollständig verschlüsselt (Header + Content). Die Verschlüsselung wird workspace-weit in info.json konfiguriert – ein Workspace ist entweder komplett verschlüsselt oder gar nicht.

Konfiguration in info.json

{
  "apiVersion": 3,
  "workspaceType": "receipts2",
  "workspaceId": "...",
  "createDate": 1732311704,
  "encryption": {
    "algorithm": "aes-256-gcm",
    "kdf": "pbkdf2",
    "kdfHash": "sha256",
    "kdfIterations": 100000,
    "salt": "base64-random-salt-16bytes",
    "verify": "base64-encrypted-test-string"
  }
}
FeldBeschreibung
algorithmVerschlüsselungsalgorithmus: aes-256-gcm
kdfSchlüsselableitungsfunktion: pbkdf2
kdfHashHash-Funktion für PBKDF2: sha256
kdfIterationsAnzahl Iterationen (min. 100.000 empfohlen)
saltZufälliger Salt (Base64, min. 16 Bytes)
verifyVerschlüsselter Teststring zur Passwort-Prüfung

Schlüsselableitung

Passwort + Salt → PBKDF2-SHA256 (kdfIterations) → Workspace-Key (256 Bit)

Passwort-Verifikation

Das Feld verify enthält den String receipts2 verschlüsselt mit dem Workspace-Key:

verify = [IV (12B)][Ciphertext + AuthTag (16B)]

Prüfung: Entschlüsselung von verify → Ergebnis = receipts2? → Passwort korrekt ✓

Dateistruktur (verschlüsselt)

Transaktionen und Assets werden vollständig verschlüsselt:

[IV (12B)][Ciphertext + AuthTag (16B)]

Ciphertext = AES-GCM(Header + "\n" + Content)

Verschlüsselungsablauf (AES-256-GCM)

Verschlüsseln:

  1. Erstelle normale Transaktionsdatei (Header + Content)
  2. Generiere zufällige IV (12 Bytes)
  3. Verschlüssele: encrypted = AES-GCM(key, iv, plaintext)
  4. Speichere: [IV (12B)][encrypted] (enthält Ciphertext + AuthTag)

Entschlüsseln:

  1. Extrahiere IV (erste 12 Bytes)
  2. Extrahiere encrypted (Rest, enthält Ciphertext + AuthTag)
  3. Entschlüssele: plaintext = AES-GCM-Decrypt(key, iv, encrypted)
  4. Parse Header (erste Zeile) und Content (Rest)
  5. Prüfe Checksum c gegen Content

Sicherheitshinweise

AspektEmpfehlung
AlgorithmusAES-256-GCM (authentifizierte Verschlüsselung)
IVNiemals wiederverwenden, immer zufällig generieren (12 Bytes)
SchlüsselableitungPBKDF2 mit min. 100.000 Iterationen
SaltEinmalig pro Workspace generieren, in info.json speichern
Passwort-PrüfungAES-GCM AuthTag schlägt bei falschem Passwort fehl

Einschränkungen

Bei vollständiger Verschlüsselung:

  • Sync erfordert Passwort: Ohne Passwort sind keine Metadaten lesbar
  • Keine Kettenverifizierung ohne Passwort: p liegt im verschlüsselten Header
  • Dateiname bleibt sichtbar: Nur der Inhalt ist geschützt

Hinweis

Bei aktivierter Verschlüsselung ist die gesamte Transaktion ohne das korrekte Passwort nicht lesbar. Die Integritätsprüfung über c erfolgt nach der Entschlüsselung.

Beispiel

Github