Private Calendar Zum Kalender

Verschlüsselung selbst prüfen

Stand: 29. Oktober 2026

Kalenderinhalte werden auf deinem Gerät Ende-zu-Ende ver- und entschlüsselt und bei der Kalendersynchronisierung nur unlesbar an den Private-Calendar-Server übertragen. Die Android- und die iPhone-App sind verfügbar. Die Android- und die iPhone-App laden externe iCal-Link-Abos direkt und synchronisieren nur Chiffrat. Das musst du uns nicht glauben, und du sollst es auch nicht. Hier kannst du einen tatsächlichen Upload offline entschlüsseln und eine lokale Kopie gezielt verändern. Plane für den ersten Durchlauf am Desktop etwa zehn bis fünfzehn Minuten ein. Danach folgen Herkunfts- und Prüfsummenvergleich sowie die vollständige Spezifikation samt offenen kryptografischen Bausteinen.

Auch importierte und abonnierte Kalender sind Ende-zu-Ende verschlüsselt

Per Datei importierte Kalender: Eine .ics-Datei aus Google, Outlook oder Apple wird auf deinem Gerät gelesen und verschlüsselt, bevor die Termine hochgeladen werden; der Server erhält auch hier nur Chiffrat. Wie du deine Termine exportierst und importierst, zeigt die Anleitung Kalender umziehen.

Per Link abonnierte Kalender: Die Android- und die iPhone-App rufen die vom Nutzer gewählte Quelle direkt über HTTPS ab, verarbeiten sie lokal und verschlüsseln Link und übernommene Einträge vor der Synchronisierung. Der Private-Calendar-Server erhält davon nur Chiffrat. Die gewählte Quelle sieht beim Abruf allerdings die IP-Adresse des jeweiligen mobilen Geräts. Browser können ein Abo anlegen und die verschlüsselte Kopie anzeigen, aktualisieren die Quelle aber nicht selbst.

Vorbereitung: Einen echten Upload sichern

Du brauchst einen Desktop-Browser mit Entwicklerwerkzeugen, einen eigenen Kalender und das Offline-Prüfwerkzeug als HTML-Datei. Speichere es als kalender-pruefen.html. Der vollständige Code steht unten und auf /quellcode; das ZIP dort enthält dieselbe HTML-Datei. Auf dem Smartphone kannst du die Seite lesen; die Aufzeichnung ist hier für den Desktop beschrieben.

  1. Öffne deinen Kalender am Desktop und die Entwicklerwerkzeuge → Netzwerk. Aktiviere bei Bedarf „Protokoll beibehalten“ und filtere nach /api/blocks.
  2. Lege in einem eigenen, beschreibbaren Kalender einen einzelnen Testtermin mit einem unverwechselbaren Titel an, etwa „Verschlüsselungsprobe 20.09.“. Warte auf die Synchronisierung.
  3. Wähle die zugehörige PUT-Anfrage. Kopiere unter „Nutzlast / Payload“ beziehungsweise „Anfrage“ den vollständigen JSON-Quelltext mit {"blocks":[…]} in eine lokale Textdatei. Kopiere nur den Anfrageinhalt, keine Header, Cookies oder Sitzungstoken. Ein Upload kann mehrere Blöcke enthalten.
  4. Lade anschließend den Rohdaten-Export herunter. Er liefert dem Werkzeug Konto-ID, Ableitungsparameter und den verschlüsselten Schlüsselbund. Die Termindaten für Test 1 stammen aus dem kopierten Upload, nicht aus diesem Export.
  5. Schalte dein Gerät offline, öffne die gespeicherte HTML-Datei und wähle darin den Export. Gib Konto-E-Mail und das zum Export gehörende Passwort ein. Füge den gesicherten Anfrageinhalt ein und wähle den Kalenderblock des Testtermins anhand Kalender-ID und Monat.

Empfangene Freigaben werden in diesem Ablauf nicht unterstützt. Für einen weiteren Durchlauf kannst du eine kleine ICS-Datei in einen eigenen Kalender importieren und deren Upload untersuchen. Ein iCal-Link-Abo wird von der Android- oder iPhone-App aktualisiert; sein tatsächlicher Upload muss dort mit zusätzlichen Netzwerkwerkzeugen aufgezeichnet werden. Ein Browser-Download der synchronisierten Kopie ist kein Mitschnitt dieses Uploads.

Zwei Tests mit deinen übertragenen Kalenderdaten

1. Wird mein Termin verschlüsselt übertragen?

Klicke im Offline-Werkzeug auf „Test 1: Upload entschlüsseln“. Prüfe, ob dein Testtermin im entschlüsselten Block erscheint. Das Werkzeug prüft außerdem, ob der authentifizierte v3-Kopf zu Konto, Kalender, Monat, Schlüssel und Uploadrevision passt.

Das zeigt: Der untersuchte Upload enthält verschlüsselte Kalenderdaten, die sich außerhalb der Kalender-App entschlüsseln lassen. Unlesbare Zeichen allein wären dafür kein Nachweis. Ob der Server den Upload angenommen hat, bestätigt das Offline-Werkzeug nicht.

2. Werden veränderte verschlüsselte Daten erkannt?

Nach erfolgreichem Test 1 wird „Test 2: Lokale Kopie verändern“ freigeschaltet. Das Werkzeug entschlüsselt das Original erneut und verändert danach ein Byte der verschlüsselten Nutzdaten in einer Arbeitsspeicher-Kopie. Dateiformat, Länge, Kopf, Initialisierungsvektor und Authentifizierungs-Tag bleiben erhalten.

Das zeigt: Die AES-GCM-Integritätsprüfung erkennt diese Veränderung. Nur ihr gezieltes Scheitern zählt als bestandener Test; ein falsches Passwort, ungültiges JSON oder ein unpassender Schlüssel zählt nicht. Es wird nichts hochgeladen, gespeichert oder im Kalender geändert.

Gemeinsame Grenze: Diese Prüfungen untersuchen konkrete übertragene Kalenderblöcke. Sie beweisen nicht, dass der Client niemals zusätzlich Klartext oder Schlüssel überträgt. Der zweite Test prüft die Integritätsprüfung im offenen Offline-Werkzeug, nicht die Fehlerbehandlung aller Clients. Ohne historische Vorgängerblöcke lässt sich auch die vollständige Versionskette nicht prüfen.

Optional kannst du im selben Werkzeug mit „Export entschlüsseln“ weiterhin den gesamten eigenen Kalenderbestand des Rohdaten-Exports lesen. Das prüft die Exportdatei, nicht einen aufgezeichneten Upload. Bewahre zusätzlich normale JSON- oder ICS-Exporte auf; kein einzelner Export garantiert eine vollständige Wiederherstellung. Schließe das Werkzeug nach der Prüfung. Es stellt keine Netzwerkverbindung her und speichert keine Eingaben.

Vollständigen Code des Offline-Prüfwerkzeugs anzeigen
<!doctype html>
<!-- SPDX-FileCopyrightText: 2026 Private Calendar
     SPDX-License-Identifier: Apache-2.0 -->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; form-action 'none'; base-uri 'none'">
<title>Private Calendar – Kalenderdaten offline prüfen</title>
<style>
body{font:16px/1.6 system-ui,sans-serif;max-width:850px;margin:32px auto;padding:0 16px;color:#172235;background:#fff}
label{display:block;margin:16px 0 4px;font-weight:600}
input,textarea,select{box-sizing:border-box;width:100%;padding:10px;font:inherit;border:1px solid #8993a3;border-radius:8px}
button{margin:16px 8px 0 0;padding:12px 18px;font:inherit;font-weight:600;border:0;border-radius:8px;background:#1857c4;color:#fff;cursor:pointer}
button:disabled{opacity:.5;cursor:default} :focus-visible{outline:3px solid #b45000;outline-offset:3px}
pre{background:#f1f4f8;padding:14px;border-radius:8px;white-space:pre-wrap;overflow-wrap:anywhere}
.err{color:#a31919} .ok{color:#176028} h1{line-height:1.25} code{overflow-wrap:anywhere}
</style>
</head>
<body>
<label for="language">Sprache / Language</label><select id="language"><option value="de">Deutsch</option><option value="en">English</option></select>
<h1 data-de="Kalenderdaten offline prüfen" data-en="Verify calendar data offline">Kalenderdaten offline prüfen</h1>
<p data-de="Öffne diese gespeicherte Datei im Flugmodus. Sie lädt keine Bibliotheken, stellt keine Netzwerkverbindung her
und speichert weder Passwort noch Daten. Schließe den Tab nach der Prüfung." data-en="Open this saved file in flight mode. It loads no libraries, makes no network connections and stores neither passwords nor data. Close the tab after checking.">Öffne diese gespeicherte Datei im Flugmodus. Sie lädt keine Bibliotheken, stellt keine Netzwerkverbindung her
und speichert weder Passwort noch Daten. Schließe den Tab nach der Prüfung.</p>
<p data-de="Für Test 1 dient der Rohdaten-Export nur zum Öffnen deines Schlüsselbunds.
Geprüft wird der separat kopierte Upload. Verwende einen Testtermin in einem eigenen Kalender." data-en="For Test 1, the raw-data export is used only to unlock your keyring. The separately copied upload is checked. Use a test event in one of your own calendars.">Für Test 1 dient der Rohdaten-Export nur zum Öffnen deines Schlüsselbunds.
Geprüft wird der separat kopierte Upload. Verwende einen Testtermin in einem eigenen Kalender.</p>
<label for="datei" data-de="1. Rohdaten-Export (.json)" data-en="1. Raw-data export (.json)">1. Rohdaten-Export (.json)</label>
<input type="file" id="datei" accept="application/json,.json">
<label for="email" data-de="2. Konto-E-Mail (für ältere Konten erforderlich)" data-en="2. Account email (required for older accounts)">2. Konto-E-Mail (für ältere Konten erforderlich)</label>
<input type="email" id="email" autocomplete="off">
<label for="passwort" data-de="3. Kontopasswort zum Zeitpunkt des Exports" data-en="3. Account password at the time of the export">3. Kontopasswort zum Zeitpunkt des Exports</label>
<input type="password" id="passwort" autocomplete="off">
<label for="upload" data-de="4. Vollständiger Anfrageinhalt von PUT /api/blocks (JSON)" data-en="4. Complete PUT /api/blocks request body (JSON)">4. Vollständiger Anfrageinhalt von PUT /api/blocks (JSON)</label>
<textarea id="upload" rows="6" spellcheck="false" autocomplete="off" placeholder='{"blocks":[…]}'></textarea>
<label for="block" data-de="5. Hochgeladenen Kalenderblock auswählen" data-en="5. Select the uploaded calendar block">5. Hochgeladenen Kalenderblock auswählen</label>
<select id="block"><option value="" data-de="Zuerst den Anfrageinhalt einfügen" data-en="Paste the request body first">Zuerst den Anfrageinhalt einfügen</option></select>
<button id="los" type="button" data-de="Test 1: Upload entschlüsseln" data-en="Test 1: Decrypt upload">Test 1: Upload entschlüsseln</button>
<button id="manipulation" type="button" disabled data-de="Test 2: Lokale Kopie verändern" data-en="Test 2: Modify local copy">Test 2: Lokale Kopie verändern</button>
<button id="export" type="button" data-de="Optional: Export entschlüsseln" data-en="Optional: Decrypt export">Optional: Export entschlüsseln</button>
<pre id="ausgabe" role="status" aria-live="polite"></pre>
<pre id="integritaet" role="status" aria-live="polite"></pre>
<p data-de="Diese Prüfungen untersuchen konkrete Kalenderblöcke. Sie beweisen nicht, dass der Client niemals zusätzlich
Klartext oder Schlüssel überträgt. Der Manipulationstest prüft dieses Werkzeug, nicht die Fehlerbehandlung der App.
Eine Vorgängerkette lässt sich ohne deren historische Blöcke nicht vollständig prüfen." data-en="These checks examine specific calendar blocks. They do not prove that the client never additionally transmits plaintext or keys. The tampering test checks this tool, not the app’s error handling. A predecessor chain cannot be fully checked without its historical blocks.">Diese Prüfungen untersuchen konkrete Kalenderblöcke. Sie beweisen nicht, dass der Client niemals zusätzlich
Klartext oder Schlüssel überträgt. Der Manipulationstest prüft dieses Werkzeug, nicht die Fehlerbehandlung der App.
Eine Vorgängerkette lässt sich ohne deren historische Blöcke nicht vollständig prüfen.</p>
<script>
const TOOL_ENGLISH = {"Private Calendar – Kalenderdaten offline prüfen": "Private Calendar – Verify calendar data offline", "Kalenderdaten offline prüfen": "Verify calendar data offline", "Öffne diese gespeicherte Datei im Flugmodus. Sie lädt keine Bibliotheken, stellt keine Netzwerkverbindung her\nund speichert weder Passwort noch Daten. Schließe den Tab nach der Prüfung.": "Open this saved file in flight mode. It loads no libraries, makes no network connections and stores neither passwords nor data. Close the tab after checking.", "Für Test 1 dient der Rohdaten-Export nur zum Öffnen deines Schlüsselbunds.\nGeprüft wird der separat kopierte Upload. Verwende einen Testtermin in einem eigenen Kalender.": "For Test 1, the raw-data export is used only to unlock your keyring. The separately copied upload is checked. Use a test event in one of your own calendars.", "1. Rohdaten-Export (.json)": "1. Raw-data export (.json)", "2. Konto-E-Mail (für ältere Konten erforderlich)": "2. Account email (required for older accounts)", "3. Kontopasswort zum Zeitpunkt des Exports": "3. Account password at the time of the export", "4. Vollständiger Anfrageinhalt von PUT /api/blocks (JSON)": "4. Complete PUT /api/blocks request body (JSON)", "5. Hochgeladenen Kalenderblock auswählen": "5. Select the uploaded calendar block", "Zuerst den Anfrageinhalt einfügen": "Paste the request body first", "Test 1: Upload entschlüsseln": "Test 1: Decrypt upload", "Test 2: Lokale Kopie verändern": "Test 2: Modify local copy", "Optional: Export entschlüsseln": "Optional: Decrypt export", "Diese Prüfungen untersuchen konkrete Kalenderblöcke. Sie beweisen nicht, dass der Client niemals zusätzlich\nKlartext oder Schlüssel überträgt. Der Manipulationstest prüft dieses Werkzeug, nicht die Fehlerbehandlung der App.\nEine Vorgängerkette lässt sich ohne deren historische Blöcke nicht vollständig prüfen.": "These checks examine specific calendar blocks. They do not prove that the client never additionally transmits plaintext or keys. The tampering test checks this tool, not the app’s error handling. A predecessor chain cannot be fully checked without its historical blocks.", "Ungültiger Umschlag.": "Invalid envelope.", "Ungültiger v3-Umschlag.": "Invalid v3 envelope.", "Ungültiger v3-Objektkopf.": "Invalid v3 object header.", "Der Export enthält keine Konto-ID für die v3-Prüfung.": "The export contains no account ID for the v3 check.", "Der v3-Kopf passt nicht zu Konto, Block, Schlüssel oder Revision im Export.": "The v3 header does not match the account, block, key or revision in the export.", "Ungültiges previousCommitment im v3-Kopf.": "Invalid previousCommitment in the v3 header.", "AES-GCM-Integritätsprüfung fehlgeschlagen.": "AES-GCM integrity check failed.", "Bitte den vollständigen JSON-Anfrageinhalt mit blocks einfügen.": "Paste the complete JSON request body containing blocks.", "Kein Kalenderblock enthalten; bitte den Upload deines Testtermins kopieren.": "No calendar block found; copy the upload of your test event.", "Bitte zuerst den Rohdaten-Export auswählen.": "Select the raw-data export first.", "Ungültiger Rohdaten-Export: blocks oder Konto-ID fehlen.": "Invalid raw-data export: missing blocks or account ID.", "Nicht unterstützte KDF-Parameter im Export.": "Unsupported KDF parameters in the export.", "Der Export enthält keinen Schlüsselbund (_meta).": "The export contains no keyring (_meta).", "Kein passender Kalenderschlüssel im Export. Eigenen Kalender und aktuellen Export verwenden.": "No matching calendar key in the export. Use your own calendar and a current export.", "Bitte einen Kalenderblock auswählen.": "Select a calendar block.", "Dieser Test unterstützt nur eigene Kalender, keine empfangenen Freigaben.": "This test supports only your own calendars, not received shares.", "Ungültige Basisrevision im Upload.": "Invalid base revision in the upload.", "Für den Upload-Test ist ein aktueller v3-Kalenderblock erforderlich.": "The upload test requires a current v3 calendar block.", "Der Block enthält keine Terminliste.": "The block contains no event list.", "(ohne Zeit)": "(no time)", "(ohne Titel)": "(untitled)", "Keine verschlüsselten Nutzdaten vorhanden.": "No encrypted payload found.", "Test 2 bestanden: Original erneut entschlüsselt; ein Byte der lokalen Chiffratkopie verändert; AES-GCM hat die Änderung erkannt. Es wurde nichts hochgeladen oder gespeichert.": "Test 2 passed: the original was decrypted again; one byte of the local ciphertext copy was changed; AES-GCM detected the change. Nothing was uploaded or saved.", "Test 2 NICHT bestanden: Veränderte Daten wurden akzeptiert.": "Test 2 FAILED: modified data was accepted.", "Schlüssel wird berechnet …": "Deriving key …", "Bitte zuerst einen Upload einfügen.": "Paste an upload first.", "Optionaler Export-Test: Exportblöcke entschlüsselt (kein Nachweis eines aufgezeichneten Uploads).\n\n": "Optional export test: export blocks decrypted (not evidence of a recorded upload).\n\n", "Prüfung nicht bestanden: ": "Check failed: ", "\nExport, Passwort, ausgewählten Block und Kontozuordnung prüfen.": "\nCheck the export, password, selected block and account association.", "Test 1: Upload erfolgreich entschlüsselt.": "Test 1: Upload decrypted successfully.", "Prüfe selbst, ob dein Testtermin erscheint. Dies bestätigt nicht die Annahme des Uploads durch den Server.": "Check whether your test event appears. This does not confirm that the server accepted the upload.", "Basisrevision": "Base revision"};
const toolLanguage = document.getElementById('language');
toolLanguage.value = /^en\b/i.test(navigator.language || '') ? 'en' : 'de';
function toolText(text) { return toolLanguage.value === 'en' ? TOOL_ENGLISH[text] || text : text; }
function updateToolLanguage() {
  document.documentElement.lang = toolLanguage.value;
  document.title = toolText('Private Calendar – Kalenderdaten offline prüfen');
  document.querySelectorAll('[data-de]').forEach(node => { node.textContent = node.dataset[toolLanguage.value]; });
}
toolLanguage.addEventListener('change', updateToolLanguage);
updateToolLanguage();

const enc = new TextEncoder(), dec = new TextDecoder();
const B64_STD_RE = /^[A-Za-z0-9+/]+={0,2}$/, B64_URL_RE = /^[A-Za-z0-9_-]+$/;
const zuB64 = bytes => {
  const data = new Uint8Array(bytes), parts = [];
  for (let i = 0; i < data.length; i += 32768) parts.push(String.fromCharCode(...data.subarray(i, i + 32768)));
  return btoa(parts.join(''));
};
const vonB64Roh = s => Uint8Array.from(atob(s), c => c.charCodeAt(0));
// Kanonisches Base64: Alphabet, Padding und Neuserialisierung müssen exakt passen.
function vonB64(s) {
  s = String(s || '');
  if (!s || s.length % 4 !== 0 || !B64_STD_RE.test(s)) throw new Error(toolText('Ungültiger Umschlag.'));
  let bytes; try { bytes = vonB64Roh(s); } catch (_) { throw new Error(toolText('Ungültiger Umschlag.')); }
  if (zuB64(bytes) !== s) throw new Error(toolText('Ungültiger Umschlag.'));
  return bytes;
}
const zuB64Url = bytes => zuB64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
function vonB64Url(s) {
  s = String(s || '');
  if (!s || !B64_URL_RE.test(s)) throw new Error(toolText('Ungültiger Umschlag.'));
  let bytes; try { bytes = vonB64Roh(s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4)); }
  catch (_) { throw new Error(toolText('Ungültiger Umschlag.')); }
  if (zuB64Url(bytes) !== s) throw new Error(toolText('Ungültiger Umschlag.'));
  return bytes;
}

// Schritt 1: Aus Passwort und dem kontoeigenen Salt wird der Schlüssel. Zuerst
// PBKDF2 (600.000 Runden, OWASP-Empfehlung), dann HKDF mit dem Etikett
// "enc-v1" — dasselbe Verfahren erzeugt mit "auth-v1" den Anmeldewert,
// sodass der zum Server geschickte Wert und der Datenschlüssel nichts voneinander
// verraten. Das Salt liefert der Server vor der Anmeldung; es ist pro Konto
// zufällig und nicht geheim. Ältere Konten nutzten noch 310.000 Runden und den
// vorhersehbaren Salt "kalender|<email>"; nach erfolgreicher Passworteingabe
// migriert der Client sie auf die aktuellen Parameter.
async function schluesselAusPasswort(passwort, saltBytes, iterationen) {
  const basis = await crypto.subtle.importKey('raw', enc.encode(passwort), 'PBKDF2', false, ['deriveBits']);
  const master = await crypto.subtle.deriveBits(
    { name: 'PBKDF2', salt: saltBytes, iterations: iterationen || 600000, hash: 'SHA-256' },
    basis, 256);
  const hkdf = await crypto.subtle.importKey('raw', master, 'HKDF', false, ['deriveBits']);
  return new Uint8Array(await crypto.subtle.deriveBits(
    { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(32), info: enc.encode('enc-v1') }, hkdf, 256));
}

// Schritt 2: Aktuelle Kalenderobjekte beginnen mit v3:. Ihr erster Teil ist
// Base64url-kodierte AAD; sie wird vor dem Öffnen gegen den Export geprüft und
// anschließend exakt als AES-GCM-AAD verwendet. v2 und präfixlose Umschläge
// sind ausschließlich alte, lesbare Migrationsdaten.
async function schluesselepoch(schluesselBytes) {
  return zuB64Url(await crypto.subtle.digest('SHA-256', schluesselBytes));
}
function v3Kopf(umschlag) {
  // Genau drei Segmente; Kopf und Körper kanonisch. Kein stilles Ignorieren zusätzlicher Teile.
  const teile = String(umschlag).slice(3).split('.');
  if (teile.length !== 3 || !teile[0]) throw new Error(toolText('Ungültiger v3-Umschlag.'));
  let aad, kopf;
  try { aad = dec.decode(vonB64Url(teile[0])); kopf = JSON.parse(aad); }
  catch (_) { throw new Error(toolText('Ungültiger v3-Objektkopf.')); }
  if (!Array.isArray(kopf) || kopf.length !== 10 || kopf[0] !== 'private-calendar-object-v3' || kopf[1] !== 3 ||
      !kopf.slice(2, 8).every(x => typeof x === 'string') || !Number.isSafeInteger(kopf[8]) || kopf[8] < 1 || typeof kopf[9] !== 'string')
    throw new Error(toolText('Ungültiger v3-Objektkopf.'));
  // JSON-Kopf muss nach dem Parsen bitgleich zur kanonischen Array-Serialisierung sein.
  const kanonisch = JSON.stringify(['private-calendar-object-v3', 3, kopf[2], kopf[3], kopf[4], kopf[5], kopf[6], kopf[7], kopf[8], kopf[9]]);
  if (kanonisch !== aad) throw new Error(toolText('Ungültiger v3-Objektkopf.'));
  const iv = vonB64(teile[1]), daten = vonB64(teile[2]);
  if (iv.length !== 12 || daten.length < 16) throw new Error(toolText('Ungültiger v3-Umschlag.'));
  return { aad, kopf, iv, daten };
}
async function erwarteV3(exportDaten, block, schluesselBytes) {
  const kontoId = String(exportDaten.konto && exportDaten.konto.id || '');
  if (!kontoId) throw new Error(toolText('Der Export enthält keine Konto-ID für die v3-Prüfung.'));
  const meta = block.bucket === '_meta';
  return ['private-calendar-object-v3', 3, kontoId, meta ? '_' : String(block.calendar_id),
    meta ? 'meta' : 'calendar-block', String(block.bucket),
    meta ? kontoId + '/_meta' : kontoId + '/' + String(block.calendar_id),
    await schluesselepoch(schluesselBytes), Number(block.version), null];
}
async function oeffne(schluesselBytes, umschlag, erwartet) {
  let iv, daten, additionalData;
  if (String(umschlag).startsWith('v3:')) {
    const v3 = v3Kopf(umschlag), kopf = v3.kopf;
    if (!erwartet || kopf.slice(0, 9).some((wert, i) => wert !== erwartet[i]))
      throw new Error(toolText('Der v3-Kopf passt nicht zu Konto, Block, Schlüssel oder Revision im Export.'));
    // previousCommitment gehört ebenfalls zur AAD. Der Rohdatenexport enthält
    // nur den aktuellen Stand, nicht dessen historischen Vorgänger; deshalb
    // kann dieses Offline-Werkzeug seinen Wert formatprüfen und anzeigen, aber
    // keine nicht exportierte Vorgängerhülle herbeizaubern.
    if (typeof kopf[9] !== 'string' || (kopf[9] && !/^[A-Za-z0-9_-]{43}$/.test(kopf[9])) )
      throw new Error(toolText('Ungültiges previousCommitment im v3-Kopf.'));
    iv = v3.iv; daten = v3.daten; additionalData = enc.encode(v3.aad);
  } else {
    const roh = String(umschlag).replace(/^v2:/, '');
    const teile = roh.split('.');
    if (teile.length !== 2) throw new Error(toolText('Ungültiger Umschlag.'));
    iv = vonB64(teile[0]); daten = vonB64(teile[1]);
    if (iv.length !== 12 || daten.length < 16) throw new Error(toolText('Ungültiger Umschlag.'));
  }
  const key = await crypto.subtle.importKey('raw', schluesselBytes, 'AES-GCM', false, ['decrypt']);
  const algorithm = { name: 'AES-GCM', iv };
  if (additionalData) algorithm.additionalData = additionalData;
  let klartext;
  try { klartext = await crypto.subtle.decrypt(algorithm, key, daten); }
  catch (e) {
    if (e.name !== 'OperationError') throw e;
    const error = new Error(toolText('AES-GCM-Integritätsprüfung fehlgeschlagen.'));
    error.code = 'GCM_AUTH_FAILED'; throw error;
  }
  return JSON.parse(dec.decode(klartext));
}


const el = id => document.getElementById(id);
let geprueft = null, lauf = 0, beschaeftigt = false;
function zuruecksetzen() {
  lauf++; geprueft = null; el('manipulation').disabled = true;
  el('ausgabe').textContent = ''; el('integritaet').textContent = '';
}
function uploadBloecke() {
  const data = JSON.parse(el('upload').value);
  if (!data || !Array.isArray(data.blocks) || !data.blocks.length)
    throw new Error(toolText('Bitte den vollständigen JSON-Anfrageinhalt mit blocks einfügen.'));
  return data.blocks;
}
el('upload').addEventListener('input', () => {
  zuruecksetzen(); el('block').replaceChildren();
  try {
    uploadBloecke().forEach((b, i) => {
      if (!b || b.bucket === '_meta') return;
      const option = document.createElement('option'); option.value = String(i);
      option.textContent = `${b.calendar_id} / ${b.bucket} · ${toolText('Basisrevision')} ${b.base_version}`;
      el('block').append(option);
    });
    if (!el('block').options.length) throw new Error(toolText('Kein Kalenderblock enthalten; bitte den Upload deines Testtermins kopieren.'));
  } catch (e) { el('ausgabe').className = 'err'; el('ausgabe').textContent = e.message; }
});
for (const id of ['datei', 'email', 'passwort', 'block']) el(id).addEventListener('input', zuruecksetzen);
async function schluesselbund() {
  const file = el('datei').files[0];
  if (!file) throw new Error(toolText('Bitte zuerst den Rohdaten-Export auswählen.'));
  const daten = JSON.parse(await file.text());
  if (!Array.isArray(daten.blocks) || !daten.konto || !daten.konto.id)
    throw new Error(toolText('Ungültiger Rohdaten-Export: blocks oder Konto-ID fehlen.'));
  const salt = daten.kdfSalt ? vonB64(daten.kdfSalt)
    : enc.encode('kalender|' + el('email').value.trim().toLowerCase());
  const iterations = daten.kdfSalt ? Number(daten.kdfIterations || 600000) : 310000;
  if (!Number.isSafeInteger(iterations) || iterations < 100000 || iterations > 2000000)
    throw new Error(toolText('Nicht unterstützte KDF-Parameter im Export.'));
  let master = await schluesselAusPasswort(el('passwort').value, salt, iterations);
  if (daten.passwordBlob) master = vonB64((await oeffne(master, daten.passwordBlob)).enc);
  const block = daten.blocks.find(b => b.bucket === '_meta' && b.calendar_id === '_');
  if (!block) throw new Error(toolText('Der Export enthält keinen Schlüsselbund (_meta).'));
  const meta = await oeffne(master, block.blob,
    String(block.blob).startsWith('v3:') ? await erwarteV3(daten, block, master) : null);
  return { daten, master, meta };
}
function blockSchluessel(bundle, block) {
  if (!/^(v2:|v3:)/.test(String(block.blob))) return bundle.master;
  const key = (bundle.meta.keys || {})[block.calendar_id];
  if (!key) throw new Error(toolText('Kein passender Kalenderschlüssel im Export. Eigenen Kalender und aktuellen Export verwenden.'));
  return vonB64(key);
}
async function uploadPruefen(bundle, block) {
  if (!block || block.bucket === '_meta' || typeof block.calendar_id !== 'string' || typeof block.bucket !== 'string')
    throw new Error(toolText('Bitte einen Kalenderblock auswählen.'));
  if (block.owner_id && block.owner_id !== bundle.daten.konto.id)
    throw new Error(toolText('Dieser Test unterstützt nur eigene Kalender, keine empfangenen Freigaben.'));
  if (!Number.isSafeInteger(block.base_version) || block.base_version < 0 || block.base_version >= Number.MAX_SAFE_INTEGER)
    throw new Error(toolText('Ungültige Basisrevision im Upload.'));
  if (typeof block.blob !== 'string' || !block.blob.startsWith('v3:'))
    throw new Error(toolText('Für den Upload-Test ist ein aktueller v3-Kalenderblock erforderlich.'));
  const key = blockSchluessel(bundle, block);
  const expected = await erwarteV3(bundle.daten, {...block, version: block.base_version + 1}, key);
  const content = await oeffne(key, block.blob, expected);
  if (!content || !Array.isArray(content.items)) throw new Error(toolText('Der Block enthält keine Terminliste.'));
  return { key, blob: block.blob, expected, content };
}
function termine(content) {
  return (content.items || []).map(t => `${t.start || toolText('(ohne Zeit)')}  ${t.title || toolText('(ohne Titel)')}`
    + (t.location ? '  @ ' + t.location : '')).join('\n');
}
async function integritaetsTest(probe) {
  // Kontrollprobe erneut öffnen. Falsche Schlüssel/kaputte Originale sind kein bestandener Test.
  await oeffne(probe.key, probe.blob, probe.expected);
  const parts = probe.blob.slice(3).split('.');
  const data = vonB64(parts[2]);
  if (data.length <= 16) throw new Error(toolText('Keine verschlüsselten Nutzdaten vorhanden.'));
  data[0] ^= 1; // Ein Bit im ersten Nutzdatenbyte; Tag, IV, AAD und Länge bleiben unverändert.
  const changed = 'v3:' + parts[0] + '.' + parts[1] + '.' + zuB64(data);
  v3Kopf(changed); // Gültiges Format sicherstellen, bevor die kryptografische Prüfung beginnt.
  try { await oeffne(probe.key, changed, probe.expected); }
  catch (e) {
    if (e.code === 'GCM_AUTH_FAILED') return toolText('Test 2 bestanden: Original erneut entschlüsselt; ein Byte der lokalen Chiffratkopie verändert; AES-GCM hat die Änderung erkannt. Es wurde nichts hochgeladen oder gespeichert.');
    throw e;
  }
  throw new Error(toolText('Test 2 NICHT bestanden: Veränderte Daten wurden akzeptiert.'));
}
async function ausfuehren(mode) {
  if (beschaeftigt) return;
  zuruecksetzen(); const token = lauf; beschaeftigt = true;
  el('los').disabled = el('export').disabled = true;
  const aus = el('ausgabe'); aus.className = ''; aus.textContent = toolText('Schlüssel wird berechnet …');
  try {
    const bundle = await schluesselbund();
    let result, probe;
    if (mode === 'upload') {
      if (el('block').value === '') throw new Error(toolText('Bitte zuerst einen Upload einfügen.'));
      const block = uploadBloecke()[Number(el('block').value)];
      probe = await uploadPruefen(bundle, block);
      result = `${toolText('Test 1: Upload erfolgreich entschlüsselt.')}\nBlock: ${block.calendar_id}/${block.bucket}\nRevision: ${block.base_version + 1}\n\n${termine(probe.content)}\n\n${toolText('Prüfe selbst, ob dein Testtermin erscheint. Dies bestätigt nicht die Annahme des Uploads durch den Server.')}`;
    } else {
      const lines = [];
      for (const block of bundle.daten.blocks) {
        if (block.bucket === '_meta') continue;
        const key = blockSchluessel(bundle, block);
        const content = await oeffne(key, block.blob,
          String(block.blob).startsWith('v3:') ? await erwarteV3(bundle.daten, block, key) : null);
        lines.push(`[${block.calendar_id}/${block.bucket}]\n${termine(content)}`);
      }
      result = toolText('Optionaler Export-Test: Exportblöcke entschlüsselt (kein Nachweis eines aufgezeichneten Uploads).\n\n') + lines.join('\n\n');
    }
    if (token !== lauf) return;
    aus.className = 'ok'; aus.textContent = result;
    geprueft = probe || null; el('manipulation').disabled = !geprueft;
  } catch (e) {
    if (token !== lauf) return;
    aus.className = 'err'; aus.textContent = toolText('Prüfung nicht bestanden: ') + e.message
      + toolText('\nExport, Passwort, ausgewählten Block und Kontozuordnung prüfen.');
  } finally { beschaeftigt = false; el('los').disabled = el('export').disabled = false; }
}
el('los').onclick = () => ausfuehren('upload');
el('export').onclick = () => ausfuehren('export');
el('manipulation').onclick = async () => {
  if (!geprueft) return;
  const token = lauf, probe = geprueft; el('manipulation').disabled = true;
  try {
    const message = await integritaetsTest(probe);
    if (token !== lauf) return;
    el('integritaet').className = 'ok'; el('integritaet').textContent = message;
  } catch (e) {
    if (token !== lauf) return;
    el('integritaet').className = 'err'; el('integritaet').textContent = toolText('Prüfung nicht bestanden: ') + e.message;
  } finally { if (token === lauf) el('manipulation').disabled = false; }
};

</script>
</body>
</html>
Alternative für Technische: den Export per Kommandozeile holen

So laufen die Daten gar nicht erst durch unsere Oberfläche. Nötig sind zwei Angaben: das Sitzungs-Token, zu finden in den Entwicklerwerkzeugen deines Browsers, und dein authPass, der aus dem Passwort abgeleitete Anmelde-Prüfwert. Wie er entsteht, steht vollständig unter Quellcode in deriveKeys: dieselbe PBKDF2-Ableitung wie oben, danach HKDF mit dem Kennzeichen auth-v1 statt enc-v1. Dein Passwort selbst verlässt dein Gerät auch hier nicht.

curl -X POST \
     -H 'Authorization: Bearer <your-session-token>' \
     -H 'Content-Type: application/json' \
     -d '{"authPass":"<your-authPass>"}' \
     https://my.privatecalendar.de/api/account/export-raw
Rohdaten-Export und Auskunft nach Art. 15 DSGVO

Der Rohdaten-Export ist ein Selbstbedienungs-Export des konto-bezogenen Datenbankbestands. Er ist keine Zusage, dass damit bereits jede beim Betrieb oder bei Dritten anfallende personenbezogene Information im Sinne von Art. 15 DSGVO vollständig erfasst ist.

Für deine Rechte sind drei Dinge zu unterscheiden:

  • Die begleitenden Angaben nach Art. 15 Abs. 1 DSGVO (Zwecke, Empfänger, Speicherdauer und deine Rechte) stehen in der Datenschutzerklärung. Der Export selbst führt unter was_der_server_im_klartext_sieht zusätzlich auf, welche Angaben unverschlüsselt vorliegen.
  • Separate technische Server-Protokolle, zurückgehaltene Zugangsgeheimnisse und Daten beim Zahlungsdienstleister Stripe sind im Selbstbedienungs-Export nicht oder nicht vollständig enthalten. Eine vollständige Auskunft und Kopie nach Art. 15 DSGVO kannst du über den Kontakt anfordern.
  • Eine lesbare Sicherung deiner Kalender kannst du wieder importieren. Du findest sie im Browser unter Einstellungen → Import & Export … → JSON exportieren.

Zwei weitere Grenzen nennen wir ausdrücklich. Erstens sind deine Kalenderinhalte im Rohdatenexport verschlüsselt. Anders geht es nicht, wir besitzen den Klartext schlicht nicht. Genau dafür ist die Anleitung oben da; im Klartext bekommst du sie über den JSON-Export. Zweitens liegen Daten, die nicht in unserer Datenbank stehen, auch nicht im Export: Zahlungsdaten eines Premium-Abos verarbeitet unser Zahlungsdienstleister Stripe, und technische Server-Protokolle entstehen beim Betrieb getrennt davon. Beides ist in der Datenschutzerklärung beschrieben.

Offene Kryptografie. Unabhängig prüfbar.

Zusätzlich: Herkunft der Android-App prüfen

Öffne in der Android-App Einstellungen → Verschlüsselung → Diese Installation prüfen und vergleiche den SHA-256-Fingerabdruck des Signaturzertifikats:

C4:24:CB:08:20:25:BF:7D:A9:2C:C4:F0:A9:07:01:39:88:02:AE:86:E7:06:55:D5:59:A0:F1:C1:BA:18:E4:FA

Die Übereinstimmung ordnet die Installation dem veröffentlichten Zertifikat zu. Sie beweist nicht das Verhalten des Programmcodes.

Die kryptografischen Bausteine von Private Calendar sind Open Source und stehen unter der Apache License 2.0.

Du kannst die hier veröffentlichten Dateien lesen, herunterladen, prüfen, verändern und weitergeben. Sie dokumentieren unter anderem Passwort- und Schlüsselerzeugung, Ende-zu-Ende-Ver- und Entschlüsselung, verschlüsselte Exporte sowie den kryptografischen Schlüsseltausch.

Nicht Teil dieser Open-Source-Veröffentlichung sind die übrige Benutzeroberfläche, der vollständige Anmeldeablauf, Synchronisierung, Server und Abrechnung. Deshalb erlaubt der offene Quelltext eine unabhängige Prüfung der veröffentlichten Sicherheitsbausteine, aber keinen vollständigen Nachbau oder Nachweis des gesamten Produkts. Die Apache License 2.0 gilt nur für die auf /quellcode gelisteten und in www/quellcode.zip enthaltenen Quelldateien.

Open-Source-Kryptografie und Lizenz ansehen

Veröffentlichter Code beantwortet allerdings nur die halbe Frage. Die andere Hälfte lautet: Ist das auch der Code, der gerade bei dir läuft? Damit sich das überhaupt beantworten lässt, liegen die veröffentlichten kryptografischen Browser-Bausteine in einer einzigen kleinen Datei (/crypto.js, rund 600 Zeilen) statt verstreut in der Anwendung. Eine einzelne Datei kann man lesen. Und man kann sie hashen: Eine Prüfsumme ist eine kurze Zahl, die sich ändert, sobald sich auch nur ein Zeichen der Datei ändert.

1. Sieh nach, was dein Browser geladen hat

Öffne den Kalender und lass dir den Seitenquelltext anzeigen (im Browsermenü, meist Seitenquelltext anzeigen). Suche nach crypto.js. Die Zeile sieht so aus:

<script src="/crypto.js?v=…" integrity="sha256-…"></script>

Das integrity ist keine Beschriftung, sondern eine Anweisung an deinen Browser: Er rechnet die Prüfsumme der geladenen Datei selbst nach und führt sie überhaupt nicht aus, wenn sie nicht passt. Diese Prüfung macht dein Browser, nicht unsere Anwendung.

2. Vergleiche ihn mit dem veröffentlichten Wert

Denselben Wert veröffentlichen wir direkt über dem abgedruckten Code: Prüfsummen auf der Quelltext-Seite. Stimmen die beiden überein, dann ist die Datei, die dein Browser gerade ausführt, Zeichen für Zeichen die, die dort zum Nachlesen steht.

Das ist der eigentliche Vergleich. Er ist nur deshalb aussagekräftig, weil der Vergleichswert aus dem veröffentlichten Text stammt. Website, Quelltext-Seite und Datei werden jedoch vom selben Betreiber ausgeliefert; ohne eine unabhängig gespeicherte Prüfsumme ist das kein unabhängiger Beweis gegen eine gezielt gemeinsam veränderte Auslieferung.

3. Rechne die Prüfsumme selbst nach

Wenn du eine Kommandozeile hast, brauchst du uns dafür gar nicht:

curl -s https://my.privatecalendar.de/crypto.js | sha256sum

Die Ausgabe muss dem hexadezimalen Wert auf der Quelltext-Seite entsprechen. Die Berechnung läuft unabhängig von unserem JavaScript; Datei und Vergleichswert kommen ohne eine zuvor extern gesicherte Referenz aber weiterhin aus derselben Veröffentlichungsquelle.

Und jetzt die Grenzen, denn sie sind hier besonders wichtig. Erstens deckt die Prüfsumme die Krypto-Datei ab, nicht die gesamte Anwendung: Sie zeigt nur die Gleichheit der jeweils verglichenen Datei, nicht deren tatsächliches Verhalten und nicht, was der übrige Code der Seite anstellt. Zweitens zeigt ein übereinstimmender Hash nicht, dass alle Menschen dieselbe Datei bekommen: ein Server kann einer einzelnen Person gezielt etwas anderes schicken, mitsamt passender Prüfsumme. Und drittens gilt jede Prüfung im Browser nur für diesen Augenblick, weil die Anwendung bei jedem Aufruf neu geladen wird. Öffentlicher Quellcode und reproduzierbare Builds würden die Prüfung erweitern; zusätzlich müsste die Zuordnung zur tatsächlich ausgelieferten Fassung unabhängig überprüft werden. Einen vollständig offenen, reproduzierbar baubaren Client bieten wir nicht. Die beiden Offline-Tests untersuchen konkrete Datenblöcke, nicht das gesamte Verhalten von Client oder Server.

Stufe 4: Die vollständige Spezifikation

Hier steht das gesamte kryptografische Format, Byte für Byte. Es ist so beschrieben, dass eine unabhängige Person es nachbauen kann, ohne unseren Programmcode zu sehen. Genau das ist der Sinn. Am Ende jedes Abschnitts stehen feste Testwerte: Wer die Rechnung selbst durchführt und dieselben Zahlen erhält, hat die Beschreibung verifiziert.

Kodierungen: Base64, Hex, IV und Prüfsummen
WasWie
Text in Schlüsselableitungen und JSONUTF-8
HexKleinbuchstaben, zwei Zeichen je Byte
Base64Standard nach RFC 4648 mit +, / und =-Auffüllung. Kein Base64-URL, keine Zeilenumbrüche.
AES-Schlüssel32 Byte (AES-256)
AES-GCM-IV12 frische Zufallsbytes bei jeder Verschlüsselung
AES-GCM-Prüfsumme16 Byte, an das Chiffrat angehängt (ciphertext || tag)
Der Umschlag: wie ein verschlüsselter Wert aufgebaut ist

Schlüsselhüllen, lokale Vault-Einträge und Exporte haben diese Form:

standardBase64(12-byte IV) + "." + standardBase64(ciphertext || 16-byte GCM tag)

Der Klartext darin ist kompaktes UTF-8-JSON. Als Pseudocode:

encryptJson(key, object):
    iv         = secureRandom(12)
    plaintext  = UTF8(JSON.stringify(object))
    ciphertext = AES_256_GCM_ENCRYPT(key, iv, plaintext, aad = none)
    return BASE64(iv) + "." + BASE64(ciphertext)

decryptJson(key, envelope):
    strip an optional leading "v2:"
    parts = split(envelope, ".")
    require exactly two segments (never silently ignore extra parts)
    iv, ciphertext = strictBase64(parts[0]), strictBase64(parts[1])
    require len(iv) == 12 and len(ciphertext) >= 16
    require BASE64(iv) == parts[0] and BASE64(ciphertext) == parts[1]  (canonical)
    return JSON.parse(UTF8(AES_256_GCM_DECRYPT(key, iv, ciphertext, aad = none)))

„Strict Base64“ heißt: nur das Alphabet A–Z a–z 0–9 + / mit kanonischer =-Auffüllung, ohne Leerzeichen oder Zeilenumbrüche. Nach dem Dekodieren wird neu serialisiert; jede Abweichung vom Rohsegment wird abgelehnt. Dasselbe gilt für den Base64url-Kopf von v3 (Alphabet A–Z a–z 0–9 - _, ohne Auffüllung). Browser, Android und iPhone teilen diese Regeln, damit ein manipulierter Server keinen Client einen Umschlag entschlüsseln lassen und gleichzeitig den Commitment-Hash ändern kann.

Alte Terminblöcke können ein vorangestelltes v2: tragen. Sie bleiben ausschließlich für die Migration lesbar. Einen Schreibweg dorthin gibt es in keinem Client mehr: Es existiert weder eine allgemeine Blockschreibfunktion noch ein Aufrufer, der v2: oder ein präfixloses Format erzeugen könnte. Sobald ein solcher Block einmal geändert wird, entsteht er als v3 neu, und zwar unter dem Kalenderschlüssel, nicht mehr unter dem Hauptschlüssel.

Kalenderblöcke und _meta verwenden stattdessen (im Browser, in der Android-App und in der iPhone-App gleichermaßen) dieses Format:

"v3:" + base64url(UTF8(contextAAD)) + "." + standardBase64(IV) + "." + standardBase64(ciphertext || tag)

contextAAD = JSON(["private-calendar-object-v3", 3,
  accountId, calendarId, objectType, bucket, objectId,
  keyEpoch, revision, previousCommitment])

Die Array-Reihenfolge ist kanonisch, und der Kopf wird als JSON-Array serialisiert – nicht als Objekt –, damit die Feldreihenfolge nicht von der JSON-Bibliothek einer Plattform abhängt. Der v3-Kopf ist nicht geheim, wird aber als AES-GCM-AAD mitauthentifiziert. Beim Öffnen prüft der Client jedes Feld gegen den angeforderten Block: Konto, Kalender, Objektart, Bucket, Objektkennung, Schlüsselepoche und Revision sind daher nicht in einen anderen gültigen Umschlag umwidmbar.

FeldWert
accountIdDie undurchsichtige Kontokennung des Eigentümers; bei einem freigegebenen Kalender also die des Freigebenden, nicht die eigene.
calendarIdKalenderkennung; für _meta das synthetische _.
objectTypemeta für _meta, sonst calendar-block.
bucket_meta, recur oder der Monat YYYY-MM.
objectIdaccountId + "/" + calendarId; für _meta ausdrücklich accountId + "/_meta", nicht "/_".
keyEpochBase64url von SHA-256(rawKey). Ein neu ausgestellter Kalenderschlüssel ergibt eine neue Epoche.
revisionDie Serverversion, die dieser Umschlag erzeugen soll; ganzzahlig und mindestens 1.
previousCommitmentBase64url von SHA-256(previousEnvelope), oder leer, wenn es keinen bestätigten Vorgänger gibt.

Base64url heißt hier: - und _ statt + und /, ohne =-Auffüllung. Nur der Kopf ist so kodiert; IV und Chiffrat bleiben Standard-Base64.

Gemeinsame Testwerte. Diese Werte werden von allen drei Clients einzeln nachgerechnet: im Browser, in der Android- und in der iPhone-Testsuite. Wer eine eigene Implementierung schreibt, kann sie damit prüfen, ohne einen einzigen Block zu besitzen:

key               = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
keyEpoch          = Yw3NKWbEM2aRElRIu7JbT_QSpJxzLbLIq8G4WBvXEN0

contextAAD = ["private-calendar-object-v3",3,"42","personal","calendar-block",
  "2026-08","42/personal","Yw3NKWbEM2aRElRIu7JbT_QSpJxzLbLIq8G4WBvXEN0",7,""]

SHA-256(UTF8(contextAAD))
  = 5f8fbdcec95e3caf4e2250c71403699a84778ff8d3c963076d044c3285bc8bd1
base64url(UTF8(contextAAD))
  = WyJwcml2YXRlLWNhbGVuZGFyLW9iamVjdC12MyIsMywiNDIiLCJwZXJzb25hbCIsImNhbGVuZGFyLWJs
    b2NrIiwiMjAyNi0wOCIsIjQyL3BlcnNvbmFsIiwiWXczTktXYkVNMmFSRWxSSXU3SmJUX1FTcEp4ekxi
    TElxOEc0V0J2WEVOMCIsNywiIl0

previousCommitment for the predecessor envelope "v3:AAAA.BBBB.CCCC"
  = j9szn_LrXyY5_Ftt42xG6moJD0JDu6kNKmiIFsiRwOs

And for _meta, with master key 0707…07 (32 bytes) and account "acc123":
contextAAD = ["private-calendar-object-v3",3,"acc123","_","meta","_meta",
  "acc123/_meta","S7Bvjk46dxXSAdVz0KpCN2LlXavWGiwCJ4-lbMbSlOA",1,""]
SHA-256(UTF8(contextAAD))
  = 0bd4b14ee5bbf57ff4b508dfab8dd5c755466774b8860e0b495e9fa1c03a9615

Im JSON stehen keine Leerzeichen zwischen den Elementen, und es wird nichts über die JSON-Mindestanforderungen hinaus maskiert; insbesondere bleibt der Schrägstrich in objectId ein / und wird nicht zu \/. Die Zeilenumbrüche in den langen Werten oben sind nur Satz; die echten Werte stehen in einer Zeile.

Der Browser speichert seinen zuletzt bestätigten Kopf (Revision und Umschlag-Hash) je Block im verschlüsselten lokalen Vault. Er erkennt kleinere Revisionen, unterschiedliche Umschläge derselben Revision, falsche direkte Vorgänger und die Rückstufung migrierter Blöcke ins Altformat. Die Android-App prüft die Objektzuordnung und die vom Server angegebene Revision, besitzt aber keinen gleichwertigen getrennten lokalen Sicherheitsanker. Bei iOS bis einschließlich Version 1.1.67 wird die Revision noch aus dem Umschlag übernommen; ein gültiger älterer Umschlag unter einer neu behaupteten Serverrevision wird dadurch nicht unabhängig erkannt. Der vorbereitete, noch nicht veröffentlichte iOS-Quellstand ergänzt die unabhängige Revisionsprüfung und prüft erhaltene lokale Vorgänger, bevor Daten gespeichert werden. Eine fehlgeschlagene Prüfung hält den Abgleich an und lässt lokale Änderungen bestehen.

Und eine prinzipielle Grenze bleibt in jedem Fall: Ein völlig neues Gerät kann ohne unabhängigen Vergleich keinen vom Server vollständig zurückgespielten Gesamthistorienstand erkennen – dagegen hilft nur ein externer Witness bzw. ein Abgleich zwischen Geräten.

Schlüssel aus dem Passwort (PBKDF2 + HKDF) mit Testwerten

Das Passwort wird nicht verändert. Vor der Ableitung holt der Client die KDF-Parameter des Kontos (POST /api/login/kdf): ein zufälliges 16-Byte-Salt (Base64) und die Iterationszahl. Neue Konten und migrierte Konten nutzen 600.000 Iterationen (OWASP-Empfehlung für PBKDF2-HMAC-SHA256). Ältere Konten antworten noch mit dem vorhersehbaren Salt UTF8("kalender|" + normalizedEmail) und 310.000 Runden; nach erfolgreicher Passworteingabe schreibt der Client die aktuellen Parameter (neues Zufallssalt, 600.000 Runden) und den neu verpackten passwordBlob zurück.

passwordMaster = PBKDF2-HMAC-SHA256(
    password   = UTF8(password),
    salt       = 16 random bytes (per account),
    iterations = 600000,
    outputLen  = 32 bytes
)

passwordEncryptionKey = HKDF-SHA256(
    ikm  = passwordMaster,
    salt = 32 zero bytes,
    info = UTF8("enc-v1"),
    len  = 32 bytes
)

authenticationBytes = HKDF-SHA256(
    ikm  = passwordMaster,
    salt = 32 zero bytes,
    info = UTF8("auth-v1"),
    len  = 32 bytes
)

authPass = lowercaseHex(authenticationBytes)   # exactly 64 characters

An den Server geht ausschließlich authPass (plus das nicht geheime Salt bei Registrierung und Migration). Der Server hasht authPass ein zweites Mal mit PBKDF2-HMAC-SHA256 (eigenes zufälliges 16-Byte-Salt, 200.000 Runden), bevor er verglichen wird. Der Datenschlüssel passwordEncryptionKey verlässt dein Gerät nie – er lässt sich aus authPass auch nicht zurückrechnen, weil beide aus derselben Quelle in verschiedene Richtungen abgeleitet werden.

Testwerte zum Nachrechnen (kein echtes Konto), aktuelle Parameter:

password           = Test-Passwort9!
salt (ASCII)       = 0123456789abcdef
salt Base64        = MDEyMzQ1Njc4OWFiY2RlZg==
iterations         = 600000
passwordMaster hex = 22ba7aea17824db321fc98bd9d281803062167a57e16a738df4e87761406eb92
enc key Base64     = NxWoPfdHVMdMPmiSZDx+owwUrRZPu29slNJfatyDuhQ=
authPass           = 5f9d1b1614375be31109b66f129f4ac0cd7d499e1a31f1c28cf125a8f27bc374

Legacy-Testwerte (E-Mail-Salt, 310.000 Runden, nur Migration/Leseverständnis):

email              = android@example.com
password           = Test-Passwort9!
passwordMaster hex = aa97169e3a1f5477dabf1d29e070da0d57984ae57274afaa841d65f274e2a9ac
enc key Base64     = iL9E7OJe5piFzdL7glvuGbniOmHO4J9Si3sRxqGq1jA=
authPass           = 514d76da23af2cee1a99ac7ab34ba822333e93a2152619786e190ba516859af1
E-Mail-Adresse sicher ändern und Wiederherstellungsschlüssel behalten

Im Browser unter Konto → Sicherheit → E-Mail-Adresse ändern, in den Apps unter Einstellungen → Konto & Sicherheit → Sicherheit → E-Mail-Adresse ändern kannst du die Konto-Adresse ändern. Du bestätigst dein Passwort, deinen bisherigen Wiederherstellungsschlüssel und bei aktiver 2FA einen frischen Authenticator-Code. Zwei unabhängig erzeugte zwölfstellige Codes gehen getrennt an die bisherige und die neue Adresse. Erst beide Codes schließen die Änderung ab; sie gelten 15 Minuten und nur für die anfordernde Sitzung. Fehlversuche und erneute Anforderungen sind begrenzt. Fehlt der Zugriff auf eines der Postfächer, kann dieser Weg nicht abgeschlossen werden.

Die Schlüsselableitung für die Wiederherstellung enthält die E-Mail-Adresse als Salt. Dein Gerät verpackt deshalb denselben zufälligen Hauptschlüssel mit dem bisherigen Wiederherstellungsschlüssel und der neuen Adresse neu. Der bereits gesicherte Wiederherstellungsschlüssel bleibt gültig; verwende ihn künftig mit der neuen Adresse. Passwort, Hauptschlüssel, Kalenderschlüssel und private Freigabeschlüssel bleiben erhalten. Die eigentlichen Schlüssel verlassen dein Gerät nicht.

Der Server prüft beide Postfächer und den unveränderten Sicherheitsstand unmittelbar vor der gemeinsamen Transaktion. Er widerruft alle bisherigen Sitzungen, vertrauenswürdigen Geräte und ausstehenden konto- oder vertragsbezogenen Bestätigungscodes. Die bestätigende Sitzung erhält ein Ersatz-Token. Beide Adressen bekommen eine Sicherheitsbestätigung; fehlgeschlagene Bestätigungen werden begrenzt nachgesendet. Kalender, Freigaben und Tarif bleiben an dieselbe Konto-Kennung gebunden. Aktualisiere deine anderen Geräte und synchronisiere dort ausstehende Änderungen vor der Adressänderung.

Hauptschlüssel und passwordBlob

Seit dem 23. September 2026 erzeugt jeder Client bei der Registrierung einen zufälligen Hauptschlüssel und speichert ihn nur mit dem abgeleiteten passwordEncryptionKey verpackt als passwordBlob. So taugt ein verschlüsselter Kalenderblock allein nicht als Prüfstein, um das Passwort offline zu erraten. Bei älteren Konten ohne passwordBlob ist zunächst der abgeleitete Schlüssel der Hauptschlüssel. Browser, Android- und iPhone-App verlangen vor der weiteren Nutzung ein Sicherheits-Update auf einen zufälligen Hauptschlüssel. Dabei musst du einen neuen Wiederherstellungsschlüssel sichern und erneut eingeben; der bisherige wird nach erfolgreicher Umstellung ungültig. Die Kalenderblöcke bleiben dabei unverändert.

Bei einem späteren Passwortwechsel werden die Termine nicht neu verschlüsselt; stattdessen wird der unveränderte Hauptschlüssel mit dem neuen Passwortschlüssel verpackt:

passwordBlob = encryptJson(passwordEncryptionKey, {"enc": "<master key, 32 bytes, Base64>"})

Ein Client entschlüsselt passwordBlob mit dem abgeleiteten Schlüssel, prüft, dass enc genau 32 Byte ergibt, und benutzt diesen Wert als Hauptschlüssel. Fehlt passwordBlob, ist der abgeleitete Schlüssel selbst der Hauptschlüssel.

Ein Passwortwechsel beendet andere Sitzungen und vertrauenswürdige Geräte, ändert aber keine Schlüssel-Epochen der Kalenderblöcke. Wer den alten Hauptschlüssel oder einen Kalenderschlüssel schon kopiert hat, liest damit weiter – dafür gibt es die vollständige Schlüsselerneuerung (nächster Abschnitt).

Vollständige Schlüsselerneuerung und Rotation nach Freigabeentzug

Im Browser unter Konto → Sicherheit liegen getrennte Aktionen – keine davon ersetzt die andere:

  • Passwort ändern: neuer Passwort-abgeleiteter Wrap-Schlüssel, unveränderter Hauptschlüssel (siehe oben).
  • Angemeldete Sitzungen: einzelne oder alle anderen Sitzungen abmelden (Bearer-Token ungültig).
  • Alle Schlüssel erneuern (zu finden unter „Im Ernstfall" als Verdacht auf gestohlenen Schlüssel). Notfall: neuer zufälliger Hauptschlüssel, neuer Kalenderschlüssel je eigenem Kalender (neue keyEpoch je Block), neuer Wiederherstellungsschlüssel, optional neues Freigabe-Schlüsselpaar; sämtliche eigenen Blöcke werden neu verschlüsselt und hochgeladen. Andere Sitzungen und vertrauenswürdige Geräte enden mit demselben Server-Schritt.
  • Kalenderschlüssel nach Freigabeentzug: läuft automatisch beim Entziehen des Zugriffs auf einen eigenen Kalender: der Server entfernt die Freigabe, danach erzeugt der Eigentümer-Client eine neue Kalenderschlüssel-Epoche, schreibt den vollen Verlauf und die Tombstones neu und verpackt den neuen Schlüssel nur noch für verbleibende Mitglieder.

Die vollständige Erneuerung (Browser) folgt einer festen Reihenfolge, die in rekeyAllKeys und POST /api/account/rekey steckt:

  1. Vollverlauf laden: fehlende Blöcke blieben sonst unter dem alten Schlüssel auf dem Server.
  2. Alle eigenen Kalenderblöcke unter den neuen Kalenderschlüsseln hochladen (PUT /api/blocks).
  3. Erst dann in einer Server-Transaktion: neuer passwordBlob, neuer recoveryBlob/recovery_hash, optional neues wrapped_priv/pubkey, neuer _meta-Schlüsselbund (mit metaBaseVersion als optimistischer Sperre), Löschen aller Sitzungen und vertrauten Geräte, Ausgabe einer neuen Sitzung.
  4. Verbleibende Freigaben erhalten neue share-v2:-Umschläge mit den neuen Kalenderschlüsseln.

Was ein Angreifer vor der Erneuerung kopiert hat, bleibt bei ihm – entwertet wird die künftige Nutzung gegen diesen Server, nicht die Vergangenheit. Offline gebliebene Clients mit dem alten Schlüsselstand können den alten Zustand nicht mehr hochladen: ihre Sitzung ist tot, und ein Widerspruch auf _meta (falsche Basisversion) wird abgelehnt.

Die Android-App rotiert den Kalenderschlüssel ebenfalls nach dem Entzug einer eigenen Freigabe (rotateCalendarKeyAfterRevocation). Die iPhone-App entzieht den Serverzugriff, rotiert dabei aber den Kalenderschlüssel nicht. Für eine Schlüsselrotation nach einem Entzug auf dem iPhone nutze die oben beschriebene Erneuerung im Browser. Die vollständige Konto-Erneuerung ist die Notfallfunktion im Browser; native Clients folgen dem neuen Schlüsselstand über die normale Synchronisierung, sobald der Browser sie abgeschlossen hat.

Nachvollziehbarkeit: newMasterKey() und die Ableitung des Wiederherstellungsschlüssels stehen in der veröffentlichten crypto.js unter /quellcode. Die Orchestrierung der Blöcke und der Server-Endpunkt liegen im Anwendungscode bzw. im Server – die Wirkung (neue Epochen, Session-Widerruf, Recovery-Hülle) ist oben beschrieben und am API-Verhalten prüfbar.

Kalenderschlüssel und der _meta-Block

Jeder eigene Kalender hat einen eigenen, zufälligen 32-Byte-Schlüssel. Nur dadurch lässt sich ein einzelner Kalender teilen, ohne das Passwort oder die übrigen Kalender preiszugeben. Schlüsselbund und Kalenderliste liegen im Block _meta, verschlüsselt mit dem Hauptschlüssel:

{
  "calendars": [ /* own calendar objects: name, colour, ... */ ],
  "keys":      { "calendarId": "<32-byte key as Base64>" },
  "updatedAt": 1780000000000
}
Wiederherstellungsschlüssel mit Testwerten

Bei der Registrierung entstehen 20 Zufallsbytes, Base32-kodiert mit dem Alphabet ABCDEFGHIJKLMNOPQRSTUVWXYZ234567, 32 Zeichen, in Vierergruppen dargestellt. Bei der Eingabe wird großgeschrieben, 0 zu O und 1 zu I übersetzt und alles außer A–Z und 2–7 entfernt.

recoveryMaster = PBKDF2-HMAC-SHA256(
    password   = UTF8(normalizedRecoveryKey),
    salt       = UTF8("kalender-recovery|" + normalizedEmail),
    iterations = 310000,
    outputLen  = 32 bytes
)

recoveryWrapKey   = HKDF-SHA256(recoveryMaster, 32 zero bytes, "rec-wrap-v1", 32)
recoveryAuthBytes = HKDF-SHA256(recoveryMaster, 32 zero bytes, "rec-auth-v1", 32)
recoveryAuth      = lowercaseHex(recoveryAuthBytes)
recoveryBlob      = encryptJson(recoveryWrapKey, {"enc": "<master key Base64>"})

Der Server hasht recoveryAuth ein weiteres Mal mit eigenem Salt. Er kann recoveryBlob nicht entschlüsseln.

Testwerte (gleiche E-Mail wie oben):

normalized key            = ABCDEFGHIJKLMNOPQRSTUVWXYZ234567
recoveryMaster hex        = d0ecb59611302709af5458c9faebdd3ec355331d887b9902d792701e58df8d49
recoveryWrapKey Base64    = B5oK1RHSqam7qPFeQB7aFE4vQNaFftLoFftewp7UCXc=
recoveryAuth              = 9152893083f662b696de880202922ae2315c3c9d3f5c2526d5ef94538fb31efa
Teilen: ECDH-Schlüsseltausch und Sicherheitscode

Jedes Konto besitzt ein P-256-ECDH-Schlüsselpaar. Der öffentliche Teil liegt offen beim Server, der private nur mit dem Hauptschlüssel verpackt. Beim Teilen wird der Kalenderschlüssel für die Gegenseite verschlüsselt – der Server sieht ihn nie:

sharedSecret = ECDH-P256(ownPrivateKey, peerPublicKey)   # 32 bytes
shareWrapKey = HKDF-SHA256(sharedSecret, 32 zero bytes, UTF8("share-v1"), 32)

aad = "private-calendar-share-v2" \n
      ownerFingerprint   \n  recipientFingerprint \n
      ownerEmail         \n  recipientEmail       \n
      calendarId         \n  role ("reader" | "writer")

wrapped_key = "share-v2:" + encryptJson(shareWrapKey, {
    "v": 2, "name": ..., "color": ..., "key": "<calendar key Base64>",
    "ownerFingerprint": ..., "recipientFingerprint": ...
}, aad)

Der achtgruppige Sicherheitscode, den ihr vergleicht, sind die ersten 16 Byte von SHA-256(publicKey) in Hex-Großbuchstaben. Die zusätzlich authentifizierten Daten (AAD) binden den Umschlag an beide Fingerabdrücke, beide E-Mail-Adressen, die Kalender-ID und die Rolle – ein untergeschobener oder umgewidmeter Umschlag lässt sich damit nicht entschlüsseln.

Bekannte Schlüsselkennungen werden bei aktiven Kalenderfreigaben auf jedem Gerät geprüft. Ein abweichender Schlüssel wird nicht automatisch übernommen. Eine erneute Einladung mit geänderter Identität erfordert den über einen anderen Kanal verglichenen neuen Sicherheitscode. Bei der ersten Einladung ohne bekannte Schlüsselkennung bleibt der Codevergleich optional; ohne Vergleich wird nur die zuerst angenommene Kennung für spätere Kontrollen gemerkt. Das ersetzt keine unabhängige Prüfung beim ersten Kontakt.

Den eigenen öffentlichen Code zeigt jeder Client unter Einstellungen → Mein Sicherheitscode an und kann ihn kopieren; der private Schlüssel wird dort nie angezeigt. Kennt die einladende Person den Code noch nicht, kann sie zunächst eine reine Anforderungsmail senden. Diese erstellt keine Freigabe und enthält keinen Schlüssel. Die eingeladene Person schickt ihren lokal angezeigten Code über einen unabhängigen, möglichst Ende-zu-Ende-verschlüsselten Chat zurück. Erst nach diesem Abgleich verpackt der Client den Kalenderschlüssel und erstellt die eigentliche Einladung.

Buchungs- und Verfügbarkeitslinks: Schlüssel im Adressanker

Ein Buchungslink lautet /t/<token>#k=<key>. Das Kennzeichen sind 16 zufällige Byte, der Schlüssel 32, beide URL-sicher Base64. Was hinter dem Rautezeichen steht, senden Browser nicht an den Server; er erhält also nur das Kennzeichen und einen Umschlag, den er nicht öffnen kann:

offer = encryptJson(linkKey, {title, duration, timeZone, freeSlots, publicKey},
                    aad = "private-calendar-booking-config-v1|<token>")

Im optionalen Verfügbarkeitsmodus enthält derselbe verschlüsselte Umschlag nur die freigegebenen Zeitfenster und zusammengefassten belegten Zeiträume. Termintitel, Beschreibungen, Orte und Kalendernamen werden nicht veröffentlicht. Der Server kennt den Linkmodus, damit er neue Buchungsanfragen für Verfügbarkeitsseiten abweisen kann; Zeitfenster und Belegtzeiten kann er nicht lesen. Der Linkschlüssel und die Verschlüsselung bleiben unverändert.

Die Antwort läuft in die Gegenrichtung und ist auch vor allen anderen geschützt, die denselben Link haben. Der Browser der anfragenden Person erzeugt dafür ein flüchtiges P-256-Schlüsselpaar und einigt sich damit auf ein Geheimnis mit dem Freigabeschlüssel des Kontos:

sharedSecret = ECDH-P256(ephemeralPrivateKey, accountPublicKey)
requestKey   = HKDF-SHA256(sharedSecret, 32 zero bytes, UTF8("booking-request-v1"), 32)

request    = encryptJson(requestKey, {slotTime, name, email, message},
                         aad = "private-calendar-booking-request-v1|<token>|<ephemeralPublicKey>|<shortValue>")
shortValue = HMAC-SHA256(linkKey, "private-calendar-booking-slot-v1|<token>|<slotTime>")[0..15]

Bei einer Anfrage ohne Kalendereinladung ist der Kurzwert das Einzige, was der Server über den gewählten Zeitpunkt erfährt: Er erkennt daran zwei Anfragen auf denselben Termin und lehnt die zweite ab, kann daraus aber weder den Zeitpunkt zurückrechnen noch ihn einem anderen Link zuordnen. Weil er zugleich in den authentifizierten Daten der Anfrage steht, fällt ein nachträglich vertauschter Kurzwert beim Entschlüsseln auf.

E-Mail ist eine gesonderte Wahl: Allgemeine Benachrichtigungen enthalten keine Termindetails. Nur wenn die anfragende Person zusätzlich ausdrücklich eine Kalendereinladung wählt, werden Titel, Datum, Uhrzeit, Dauer und Zeitzone für diese E-Mail lesbar an Server und E-Mail-Dienst übermittelt. Gastgeber, Ort, Nachricht und die Kontoadresse der anbietenden Person sind nicht enthalten. Vollständige bestätigte Details stehen sonst ausschließlich in der verschlüsselten Antwort; die Kalenderdatei wird nach dem Entschlüsseln im Browser erzeugt. Der Schlüssel ist nicht im E-Mail-Link enthalten: Dafür muss die anfragende Person ihren vollständigen Statuslink speichern.

Was diese Prüfungen zeigen und was nicht

Ein Prüfverfahren, das mehr verspricht als es hält, ist weniger wert als gar keins. Deshalb hier auch die Grenzen.

Nachvollziehbar wird: Die untersuchten Upload- beziehungsweise Exportblöcke lassen sich mit dem dokumentierten Verfahren und deinen Zugangsdaten offline entschlüsseln. Der gezielte Änderungstest zeigt die Integritätsprüfung für die veränderte lokale Kopie. Der Signaturfingerabdruck ordnet die Android-Installation dem veröffentlichten Zertifikat zu. Eine Prüfsumme kann die Gleichheit der jeweils verglichenen Krypto-Dateien zeigen. Das Format ist dokumentiert, sodass eine unabhängige Implementierung möglich ist.

Nicht gezeigt wird, dass wir allen dieselbe Fassung ausliefern, und auch nicht, dass der übrige Programmcode rund um die Verschlüsselung genau das tut, was hier beschrieben ist. Dafür müsste der vollständige Client offen und reproduzierbar baubar sein. Das ist heute nicht der Fall.

Zwischen den nativen Apps und dem Browser besteht dabei ein wichtiger Unterschied. Die Android-App wird über Google Play, die iPhone-App über den App Store installiert. Ihre Fassung steht ab der Installation fest – unser Server kann sie nicht nachträglich austauschen; eine neue Fassung wird als App-Update über den jeweiligen Store verteilt. Im Browser wird die Anwendung dagegen bei jedem Aufruf neu vom Server geladen; eine Prüfung dort gilt immer nur für diesen einen Moment. Die installierte App-Fassung bleibt deshalb bis zum nächsten App-Update unverändert. Die Offline-Prüfungen sind davon unberührt: Sie prüfen konkrete Daten, nicht den gesamten Code, und sind damit von dieser Einschränkung unabhängig.

Unabhängig davon gilt: Solange dein Kalender entsperrt ist, muss dein Gerät die Inhalte anzeigen. Schadsoftware auf dem Gerät selbst könnte sie mitlesen. Das kann kein Verschlüsselungsverfahren verhindern. Halte dein Gerät aktuell und sperre es.

Premium-Widgets auf dem Startbildschirm

Widgets ändern die Ende-zu-Ende-Verschlüsselung der Synchronisierung nicht: Inhalte werden weiterhin ausschließlich auf dem Gerät entschlüsselt. Für die gewählte Anzeige erhält das Betriebssystem jedoch lesbare Widget-Ansichten. Eine biometrisch gesperrte App versteckt bereits angezeigte Widget-Inhalte nicht automatisch. Auf dem iPhone wird die Widget-Anzeige bei Gerätesperre durch den Datenschutz des Systems geschützt; eine sekundengenaue Entfernung zwischengespeicherter Ansichten wird nicht zugesagt.

Du wählst je Widget zwischen belegten Zeiten und Termintiteln. Belegte Zeiten erscheinen als graue Blöcke ohne Titel, wie in der Verfügbarkeitsanzeige. Sichtbare Inhalte sind außerhalb der biometrischen App-Sperre lesbar und werden vom Betriebssystem beziehungsweise Startbildschirm verwaltet. Standardmäßig werden belegte Zeiten ohne Titel angezeigt.

Widgets zeigen den zuletzt lokal verfügbaren Stand. Änderungen auf anderen Geräten werden nach dem nächsten erfolgreichen Abgleich sichtbar. Das Betriebssystem kann Aktualisierungen verzögern oder verhindern; Offline-Nutzung, Energiesparen und erzwungenes Beenden begrenzen die Aktualität. Die Anzeige nennt den letzten erfolgreichen Abgleich. Widgets ersetzen keine verlässliche Fristenkontrolle.

Was nicht Ende-zu-Ende verschlüsselt ist

Freiwillige Fehlerberichte und Funktionswünsche sind für den Betreiber und den bestehenden Maildienst lesbar. Selbst ausgewählte Bilder und Videos werden verschlüsselt auf dem Server gespeichert und sind 72 Stunden über einen vertraulichen Download-Link in der Betreiber-Mail abrufbar. Der Abruf erfordert zusätzlich ein Download-Passwort des Betreibers; der Link allein genügt nicht. Das ist keine Ende-zu-Ende-Verschlüsselung; der Server kann die Dateien entschlüsseln. Das Feedbackformular übernimmt keine Kalenderinhalte automatisch und ist von der Ende-zu-Ende-verschlüsselten Kalendersynchronisierung getrennt.

Vollständigkeit gehört dazu. Der Server sieht im Klartext: deine E-Mail-Adresse, die Kalender-IDs und Monatsbezeichner deiner Blöcke, die Zeitpunkte deiner Änderungen, mit wem du Kalender teilst, sowie Anmeldezeitpunkte samt IP-Adresse. Bei Premium kommen Abo- und Rechnungsdaten hinzu. Diese Angaben stehen ebenfalls in deinem Rohdaten-Export – nachlesbar, nicht nur behauptet.

Fragen dazu?

Wenn du etwas nachprüfen möchtest, das hier nicht steht, schreib uns: Kontakt. Fehler in dieser Beschreibung sind uns wichtiger als Lob.