Imported and subscribed calendars are also end-to-end encrypted
Calendars imported from files: An .ics file from Google, Outlook or Apple is read and encrypted on your device before events are uploaded. The server receives only ciphertext. See Switch calendars for export and import instructions.
Calendars subscribed via link: The Android and iPhone apps retrieve the user-selected source directly over HTTPS, process it locally and encrypt the link and imported entries before synchronization. The Private Calendar server receives only ciphertext. The selected source does see the mobile device’s IP address during retrieval. Browsers can create a subscription and display the encrypted copy, but do not update the source themselves.
Preparation: capture a real upload
You need a desktop browser with developer tools, a calendar you own and the offline verification tool as an HTML file. Save it as kalender-pruefen.html. The complete code appears below and on the source page; its ZIP contains the same HTML file. You can read this page on a smartphone, but the recording instructions use a desktop.
- Open your calendar on a desktop and select Developer Tools → Network. If needed, enable “Preserve log” and filter for
/api/blocks. - Create a single test event in a writable calendar you own, using a distinctive title such as “Encryption sample 20 September”. Wait for synchronization.
- Select the corresponding PUT request. Under “Payload” or “Request”, copy the complete JSON source containing
{"blocks":[…]}into a local text file. Copy only the request body, without headers, cookies or session tokens. An upload may contain multiple blocks. - Then download the raw-data export. It supplies the account identifier, derivation parameters and encrypted keyring to the tool. The event data for Test 1 comes from the copied upload, not this export.
- Switch your device offline, open the saved HTML file and select the raw-data export in it (the tool’s interface is in German). Enter your account email and the password associated with the export. Paste the saved request content and select the calendar block of the test appointment based on calendar ID and month.
Received shares are not supported in this flow. For another run, import a small ICS file into your own calendar and inspect its upload. An iCal subscription is updated by the Android or iPhone app; record its actual upload there using network tools. A browser download of the synchronized copy is not a recording of that upload.
Two tests with your transferred calendar data
1. Will my appointment be transmitted encrypted?
In the offline tool, click “Test 1: Upload entschlüsseln” (decrypt upload). Check that your test event appears in the decrypted block. The tool also verifies that the authenticated v3 header matches the account, calendar, month, key and upload revision.
What this shows: The inspected upload contains encrypted calendar data that can be decrypted outside the calendar app. Unreadable characters alone would not prove this. The offline tool does not confirm whether the server accepted the upload.
2. Is changed encrypted data detected?
After Test 1 succeeds, “Test 2: Lokale Kopie verändern” (change local copy) becomes available. The tool decrypts the original again, then changes one byte of the encrypted payload in a copy held in memory. The file format, length, header, initialization vector and authentication tag remain unchanged.
What this shows: The AES-GCM integrity check detects this modification. Only its deliberate authentication failure counts as a successful test; an incorrect password, invalid JSON or an unsuitable key does not count. Nothing is uploaded, saved or changed in your calendar.
Shared limitation: These checks examine specific transferred calendar blocks. They do not prove that the client never additionally transmits plaintext or keys. The second test checks the integrity check in the open offline tool, not the error handling of all clients. Without historical predecessor blocks, the complete version chain cannot be checked.
You can also use “Optional: Decrypt export” in the same tool to read your entire calendar collection from the raw-data export. This checks the export file, not a recorded upload. Keep normal JSON or ICS exports as well; no single export guarantees complete recovery. Close the tool after checking. It makes no network requests and stores no input.
View full code of offline testing tool
<!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>
Technical alternative: retrieve the export using the command line
This bypasses our user interface. You need the session token from your browser’s developer tools and authPass, the sign-in verification value derived from your password. The source code documents its derivation in deriveKeys: the same PBKDF2 derivation as above, followed by HKDF with the identifier auth-v1 instead of enc-v1. Your password never leaves your device here either.
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
Raw data export and information in accordance with Art. 15 GDPR
The raw-data export is a self-service export of your account-related database records. It does not promise a complete record of all personal information processed during operation or by third parties within the meaning of Article 15 GDPR.
Three things have to be distinguished when it comes to your rights:
- The accompanying information required by Article 15(1) GDPR — purposes, recipients, retention periods and your rights — is set out in the privacy policy. The export also lists under
was_der_server_im_klartext_siehtwhich information is available unencrypted. - Separate technical server logs, withheld access secrets and data held by the payment provider Stripe are not included, or not fully included, in the self-service export. You can request complete information and a copy under Article 15 GDPR through the contact address.
- You can re-import a readable backup of your calendars. In the browser, go to Settings → Import & Export … → Export JSON.
Two further limitations apply. First, your calendar content in the raw-data export is encrypted; we do not have the plaintext. The instructions above explain how to read it, and a normal JSON export provides it in plain text. Second, data outside our database is not included in this export: Stripe processes Premium payment data, and technical server logs are created separately during operation. Both are described in the privacy policy.
Open cryptography. Independently verifiable.
Additionally: Check the origin of the Android app
In the Android app, open Settings → Encryption → Check this installation and compare the SHA-256 fingerprint of the signing certificate:
The match associates the installation with the published certificate. It does not prove the behaviour of the program code.
The cryptographic building blocks of Private Calendar are open source and are available under the Apache License 2.0.
You may read, download, inspect, modify and redistribute the files published here. They document password and key generation, end-to-end encryption and decryption, encrypted exports and cryptographic key exchange.
The rest of the user interface, the complete sign-in flow, synchronization, server and billing are outside this open-source release. The published source permits independent verification of these security components, but does not provide a complete reproduction or proof of the entire product. The Apache License 2.0 applies only to the source files listed on the source page and included in www/quellcode.zip.
View open source cryptography and licence
Published code answers only half the question. The other half is whether it is the code you are actually running. To make this verifiable, the browser’s published cryptographic components are contained in a single small file (/crypto.js, around 600 lines), rather than scattered throughout the application. You can read that file and calculate its hash: a checksum changes when even one character of the file changes.
1. Check what your browser has loaded
Open the calendar and view the page source code (in the browser menu, usually
View page source). Search for crypto.js. The line looks like this:
<script src="/crypto.js?v=…" integrity="sha256-…"></script>
The integrity attribute instructs your browser to calculate the loaded file’s checksum and refuse to execute it if the value does not match. The browser performs this check independently of our application.
2. Compare it to the published value
We publish the same value directly above the printed code: Checksums on the source code page. If they match, the file your browser is running is character-for-character identical to the one available for you to read.
This is the actual comparison. It is meaningful because the reference value comes from the published text. However, the website, source page and file are all served by the same operator. Without an independently saved checksum, this is not independent evidence against a deliberately coordinated change to all of them.
3. Calculate the checksum yourself
If you have a command line, you don't need us at all:
curl -s https://my.privatecalendar.de/crypto.js | sha256sum
The output must match the hexadecimal value on the source page. This calculation runs independently of our JavaScript; however, without an externally saved reference, the file and reference value still come from the same publisher.
The limitations are explained next, because they matter particularly here. First, the checksum covers the cryptography file, not the entire application: it shows only that the compared files are identical, not how they behave or what the rest of the page’s code does. Second, a matching hash does not prove that everyone receives the same file: a server could send a different file to a particular person, together with a matching checksum. Third, each browser check applies only at that moment, because the application is loaded again on every visit. Public source code and reproducible builds would extend verification; their correspondence with the version actually delivered would also need independent verification. We do not offer a fully open client with reproducible builds. The two offline tests examine specific data blocks, not all client or server behaviour.
Level 4: The full specification
This documents the entire cryptographic format, byte for byte, so that an independent person can implement it without seeing our application code. Each section ends with fixed test values: calculating the same values independently verifies the description.
Encodings: Base64, Hex, IV and checksums
| What | How |
|---|---|
| Text in key derivations and JSON | UTF-8 |
| Hex | Lowercase letters, two characters per byte |
| Base64 | Standard RFC 4648 using +, / and = padding. No Base64url and no line breaks. |
| AES key | 32 bytes (AES-256) |
| AES-GCM-IV | Twelve fresh random bytes for each encryption operation |
| AES-GCM authentication tag | 16 bytes appended to the ciphertext (ciphertext || tag) |
The envelope: how an encrypted value is structured
Key wrappers, local vault records and exports use this format:
standardBase64(12-byte IV) + "." + standardBase64(ciphertext || 16-byte GCM tag)
The plaintext in it is compact UTF-8 JSON. As 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” means using only the alphabet A–Z a–z 0–9 + / with canonical = padding and no spaces or line breaks. The decoded data is re-serialized; any difference from the raw segment is rejected. The same applies to the v3 Base64url header (alphabet A–Z a–z 0–9 - _, without padding). Browser, Android and iPhone share these rules so that a manipulated server cannot make a client decrypt an envelope while changing its commitment hash.
Old event blocks may carry the prefix v2:. They remain readable only for migration. No client can write that format any more: there is neither a general block-writing function nor a caller capable of producing v2: or a format without a prefix. Once changed, such a block is recreated as v3 under the calendar key rather than the master key.
Calendar blocks and _meta instead use the following format in the browser, Android and iPhone apps:
"v3:" + base64url(UTF8(contextAAD)) + "." + standardBase64(IV) + "." + standardBase64(ciphertext || tag)
contextAAD = JSON(["private-calendar-object-v3", 3,
accountId, calendarId, objectType, bucket, objectId,
keyEpoch, revision, previousCommitment])
The array order is canonical. The header is serialized as a JSON array, rather than an object, so that field order does not depend on a platform’s JSON library. The v3 header is not secret, but is authenticated as AES-GCM additional authenticated data (AAD). When opening an envelope, the client checks every field against the requested block. Its account, calendar, object type, bucket, object identifier, key epoch and revision therefore cannot be reassigned to another valid envelope.
| Field | Value |
|---|---|
accountId | The owner’s opaque account identifier; for a shared calendar, this is the sharing person’s identifier rather than your own. |
calendarId | Calendar identifier; for _meta, the synthetic identifier _. |
objectType | meta for _meta, otherwise calendar-block. |
bucket | _meta, recur or the month YYYY-MM. |
objectId | accountId + "/" + calendarId; for _meta, specifically accountId + "/_meta", not "/_". |
keyEpoch | Base64url of SHA-256(rawKey). Issuing a new calendar key creates a new epoch. |
revision | The server revision this envelope is intended to create; an integer of at least 1. |
previousCommitment | Base64url of SHA-256(previousEnvelope), or empty if there is no confirmed predecessor. |
Base64url uses - and _ instead of + and /, without = padding. Only the header uses this encoding; the IV and ciphertext use standard Base64.
Common test values. All three clients independently recalculate these values: the browser, Android and iPhone test suites. Anyone writing their own implementation can verify it without having a single block:
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
There are no spaces between JSON elements, and escaping is limited to what JSON requires. In particular, the slash in objectId remains / rather than \/. Line breaks in the long values above are for display only; the actual values occupy one line.
The browser stores its last confirmed header (revision and envelope hash) for each block in the encrypted local vault. It detects lower revisions, different envelopes at the same revision, incorrect immediate predecessors and downgrades of migrated blocks to a legacy format. The Android app checks the object context and the revision reported by the server, but has no equivalent separate local security anchor. In iOS up to and including version 1.1.67, the revision is still taken from the envelope; a genuine older envelope presented with a newly claimed server revision is therefore not independently detected. The prepared, unreleased iOS source adds independent revision checks and checks retained local predecessors before storing data. A failed check stops synchronisation and preserves local changes.
One fundamental limitation remains: without an independent comparison, a completely new device cannot verify that the server has restored its entire history. This requires an external witness or a comparison between devices.
Key from the password (PBKDF2 + HKDF) with test values
Your password is not modified. Before derivation, the client retrieves the account’s KDF parameters (POST /api/login/kdf): a random 16-byte salt (Base64) and the iteration count. New and migrated accounts use 600,000 iterations (the OWASP recommendation for PBKDF2-HMAC-SHA256). Older accounts still return the predictable salt UTF8("kalender|" + normalizedEmail) and 310,000 rounds. After successful password entry, the client writes back the current parameters — a new random salt and 600,000 rounds — and the re-wrapped passwordBlob.
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
Only authPass is sent to the server, along with the non-secret salt during registration and migration. The server hashes authPass a second time using PBKDF2-HMAC-SHA256 with its own random 16-byte salt and 200,000 rounds before comparison. The data key passwordEncryptionKey never leaves your device and cannot be recovered from authPass, because the two are derived from the same source in different directions.
Test values for recalculation (not a real account), current parameters:
password = Test-Passwort9!
salt (ASCII) = 0123456789abcdef
salt Base64 = MDEyMzQ1Njc4OWFiY2RlZg==
iterations = 600000
passwordMaster hex = 22ba7aea17824db321fc98bd9d281803062167a57e16a738df4e87761406eb92
enc key Base64 = NxWoPfdHVMdMPmiSZDx+owwUrRZPu29slNJfatyDuhQ=
authPass = 5f9d1b1614375be31109b66f129f4ac0cd7d499e1a31f1c28cf125a8f27bc374
Legacy test values (email salt, 310,000 rounds; migration and reading only):
email = android@example.com
password = Test-Passwort9!
passwordMaster hex = aa97169e3a1f5477dabf1d29e070da0d57984ae57274afaa841d65f274e2a9ac
enc key Base64 = iL9E7OJe5piFzdL7glvuGbniOmHO4J9Si3sRxqGq1jA=
authPass = 514d76da23af2cee1a99ac7ab34ba822333e93a2152619786e190ba516859af1
Safely change email address and keep recovery key
In the browser under Account → Security → Change email address, or in the apps under Settings → Account & security → Security → Change email address, you can change your account email address. Confirm your password, your existing recovery key and, if 2FA is enabled, a fresh authenticator code. Two independently generated twelve-digit codes are sent separately to the old and new addresses. Both codes are required to complete the change; they are valid for 15 minutes and only for the requesting session. Failed attempts and repeated requests are limited. If you cannot access either mailbox, you cannot complete this process.
The recovery key derivation contains the email address as a salt. Your device will therefore repackage the same random master key with the previous recovery key and the new address. The already backed up recovery key remains valid; use it with the new address in the future. Password, master key, calendar key and private sharing key are retained. The actual keys do not leave your device.
The server checks both mailboxes and the unchanged security level immediately before the joint transaction. It revokes all previous sessions, trusted devices, and outstanding account or contract-related verification codes. The confirming session receives a replacement token. Both addresses receive a security confirmation; failed confirmations are forwarded to a limited extent. Calendar, shares and plan remain tied to the same account ID. Update your other devices and sync pending changes there before changing the address.
Master key and passwordBlob
Since 23 September 2026, every client generates a random master key during registration and stores it only wrapped by the derived passwordEncryptionKey as passwordBlob. An encrypted calendar block alone therefore cannot be used to test password guesses offline. For older accounts without passwordBlob, the derived key initially serves as the master key. Before further use, the browser, Android and iPhone apps require a security update to a random master key. Save the new recovery key and enter it again to confirm your copy; the previous key becomes invalid after successful conversion. Calendar blocks remain unchanged.
Changing your password later does not re-encrypt events; instead, the unchanged master key is wrapped by the new password-derived key:
passwordBlob = encryptJson(passwordEncryptionKey, {"enc": "<master key, 32 bytes, Base64>"})
A client decrypts passwordBlob using the derived key, checks that enc yields exactly 32 bytes, and uses that value as the master key. If passwordBlob is absent, the derived key itself serves as the master key.
Changing your password revokes other sessions and trusted devices but does not change the key epochs of calendar blocks. Anyone who has already copied the old master key or a calendar key can still read those data. Full key regeneration addresses this (see the next section).
Full key regeneration and rotation after revoking a share
In the browser, under Account → Security, there are separate actions – none replaces the others:
- Change password: new password-derived wrapping key, unchanged master key (see above).
- Signed-in sessions: sign out individual sessions or all other sessions by revoking their bearer tokens.
- Regenerate all keys is the emergency action under “In an emergency”, labelled Suspected stolen key. It creates a new random master key, a new calendar key for each calendar you own (a new
keyEpochper block), a new recovery key and, optionally, a new sharing key pair. All your own blocks are re-encrypted and uploaded. Other sessions and trusted devices are revoked in the same server operation. - Calendar key after revoking a share: runs automatically when access to one of your own calendars is revoked. The server removes the share; the owner’s client then creates a new calendar key epoch, re-encrypts the full history and tombstones, and wraps the new key only for the remaining members.
Full key regeneration in the browser follows the fixed sequence implemented in rekeyAllKeys and POST /api/account/rekey:
- Load full history: missing blocks would otherwise remain on the server under the old key.
- Upload all your own calendar blocks under the new calendar keys
(
PUT /api/blocks). - Only then, in a single server transaction: a new
passwordBlob, newrecoveryBlob/recovery_hash, optionally newwrapped_priv/pubkey, a new_metakeyring (withmetaBaseVersionas the optimistic lock), revocation of all sessions and trusted devices, and issuance of a new session. - Remaining shares receive new
share-v2:envelopes containing the new calendar keys.
Anything an attacker copied before regeneration remains in their possession. Future access to this server is revoked; previously copied data cannot be taken back. Offline clients with the old keys can no longer upload their old state: their session is revoked, and a conflicting update to _meta (incorrect base version) is rejected.
The Android app also rotates the calendar key after revoking a share of your own calendar (rotateCalendarKeyAfterRevocation). The iPhone app revokes server access but does not rotate the calendar key. To rotate keys after revoking a share on iPhone, use the browser’s key regeneration described above. Full account key regeneration is an emergency function in the browser. Once it is complete, native clients obtain the new keys through normal synchronization.
Verification: newMasterKey() and the recovery key derivation are in the published crypto.js at /quellcode. Block orchestration and the server endpoint are part of the application and server code. Their effects — new epochs, session revocation and a recovery wrapper — are described above and can be checked through the API’s behaviour.
Calendar key and the _meta block
Each calendar you own has its own random 32-byte key. This lets you share individual calendars without revealing your password or other calendars. The keyring and calendar list are stored in the _meta block, encrypted with the master key:
{
"calendars": [ /* own calendar objects: name, colour, ... */ ],
"keys": { "calendarId": "<32-byte key as Base64>" },
"updatedAt": 1780000000000
}
Recovery key with test values
Registration creates twenty random bytes, encoded as 32 Base32 characters using the alphabet ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 and displayed in groups of four. Input is converted to uppercase, 0 is mapped to O and 1 to I, and everything except A–Z and 2–7 is removed.
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>"})
The server hashes recoveryAuth again using its own salt. It cannot decrypt recoveryBlob.
Test values (same email as above):
normalized key = ABCDEFGHIJKLMNOPQRSTUVWXYZ234567
recoveryMaster hex = d0ecb59611302709af5458c9faebdd3ec355331d887b9902d792701e58df8d49
recoveryWrapKey Base64 = B5oK1RHSqam7qPFeQB7aFE4vQNaFftLoFftewp7UCXc=
recoveryAuth = 9152893083f662b696de880202922ae2315c3c9d3f5c2526d5ef94538fb31efa
Sharing: ECDH key exchange and security code
Each account has a P-256 ECDH key pair. The public key is available on the server; the private key is stored only wrapped by the master key. When sharing, the calendar key is encrypted for the recipient — the server never sees it:
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)
The eight-group security code you compare consists of the first sixteen bytes of SHA-256(publicKey) in uppercase hexadecimal. Additional authenticated data (AAD) binds the envelope to both fingerprints, both email addresses, the calendar identifier and the role. A substituted or repurposed envelope therefore cannot be decrypted.
Known key fingerprints are checked on each device for active calendar shares. A different key is never adopted automatically. A new invitation with a changed identity requires the new safety code compared over another channel. For a first invitation without a known fingerprint, comparison remains optional; without comparison, only the initially accepted fingerprint is retained for later checks. This does not replace independent verification on first contact.
Each client displays its public code under Settings → My security code and lets you copy it; the private key is never displayed there. If the inviting person does not yet have the code, they can send a request email first. This creates no share and contains no key. The invited person sends their locally displayed code back through an independent channel, preferably an end-to-end encrypted chat. Only after this comparison does the client wrap the calendar key and create the actual invitation.
Booking and availability links: key in the URL fragment
A booking link has the form /t/<token>#k=<key>. The identifier contains sixteen random bytes and the key 32, both encoded in URL-safe Base64. Browsers do not send the part after the hash sign to the server, so it receives only the identifier and an envelope it cannot decrypt:
offer = encryptJson(linkKey, {title, duration, timeZone, freeSlots, publicKey},
aad = "private-calendar-booking-config-v1|<token>")
In optional availability mode, the same encrypted envelope contains only the shared time slots and summarized occupied time periods. Event titles, descriptions, locations and calendar names will not be published. The server knows the link mode so that it can reject new booking requests for availability pages; the server cannot read time windows and busy times. The link key and encryption remain unchanged.
The response travels in the opposite direction and is protected even from other people who have the same link. The requester’s browser generates an ephemeral P-256 key pair and derives a shared secret with the account’s sharing key:
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]
For a request without a calendar invitation, the short value is all the server learns about the selected time: it can identify two requests for the same slot and reject the second, but cannot recover the time or associate it with another link. The short value is also part of the request’s authenticated data, so replacing it afterwards is detected during decryption.
Email is a separate choice: General notifications contain no appointment details. Only when the requester also explicitly chooses a calendar invitation are the title, date, time, duration and time zone sent in readable form to the server and email service for that email. The host, location, message and calendar owner’s account address are excluded. Otherwise, the full confirmed details appear only in the encrypted response; the calendar file is generated in the browser after decryption. The email link contains no key: the requester must save their complete status link.
What these tests show and what they don't
A verification procedure that promises more than it demonstrates is less useful than none at all. Its limitations are therefore explained here too.
What these checks establish: The upload or export blocks being checked can be decrypted offline using the documented procedure and your credentials. The deliberate modification test demonstrates the integrity check on the altered local copy. The signature fingerprint associates the Android installation with the published certificate. A checksum can show that the compared cryptography files are identical. The format is documented to allow an independent implementation.
These checks do not prove that everyone receives the same version, or that the surrounding application code behaves exactly as described here. That would require the complete client to be open and reproducible, which it currently is not.
Native apps and the browser differ here. The Android app is installed through Google Play and the iPhone app through the App Store. Their version is fixed at installation: our server cannot replace it afterwards. New versions arrive as updates through the relevant store. The browser loads the application from the server on every visit, so each check applies only at that moment. The installed app version remains unchanged until the next app update. Offline checks are unaffected: they examine specific data, not the entire code.
While your calendar is unlocked, your device must be able to display its content. Malware on the device itself could read it; encryption cannot prevent this. Keep your device updated and locked.
Premium widgets on the Home Screen
Widgets do not change end-to-end encrypted sync: content is still decrypted exclusively on the device. However, the operating system receives readable widget views for the selected display. Locking the app with biometrics does not automatically hide content already displayed in widgets. On iPhone, system data protection safeguards the widget display when the device is locked; cached views are not guaranteed to disappear at an exact second.
For each widget, choose between busy times and appointment titles. Busy times appear as grey blocks without titles, matching the availability display. Visible content can be read outside the biometric app lock and is managed by the operating system or launcher. Busy times without titles are shown by default.
Widgets show the latest locally available data. Changes on other devices appear after the next successful sync. The operating system may delay or prevent updates; being offline, power saving and force-stopping limit freshness. The display shows the last successful sync. Widgets do not replace reliable deadline monitoring.
Which data is not end-to-end encrypted
Voluntary bug reports and feature requests can be read by the operator and the existing email provider. Images and videos you select yourself are stored encrypted on the server and are available for 72 hours through a confidential download link in the operator’s email. Downloading also requires an operator password; the link alone is not enough. This is not end-to-end encryption; the server can decrypt the files. The feedback form does not automatically include calendar content and is separate from end-to-end encrypted calendar synchronisation.
Completeness matters too. The server sees the following in plain text: your email address, calendar identifiers and the month identifiers of your blocks, modification times, who you share calendars with, and sign-in times including IP addresses. Premium adds subscription and billing data. This information is also included in your raw-data export in readable form.
Questions about this?
If you want to check something that isn't listed here, write to us: Contact. We would rather hear about errors in this description than receive praise.