Apache License 2.0
Lizenzumfang: Die Apache License 2.0 gilt ausschließlich für die 17
auf dieser Seite gelisteten Quelldateien, die unverändert auch in www/quellcode.zip
enthalten sind. Die Oberfläche des eigenständigen Offline-Prüfwerkzeugs ist eingeschlossen. Sie gilt nicht für die übrige Benutzeroberfläche, den vollständigen Anmeldeablauf,
Synchronisierung, Speicher- und Serverlogik, Abrechnung oder andere Teile von Private Calendar.
Rechteinhaber der hier veröffentlichten, von Private Calendar stammenden Quelldateien: Private Calendar
(Copyright 2026).
7776cb5a078747494402f36fa8ca3a69224bd92c972231b2986b60a2545d1640
Der vollständige, unveränderte Lizenztext liegt im Archiv als
LICENSE.txt und ist zusätzlich direkt abrufbar. Copyright-, SPDX- und etwaige
Fremdhinweise innerhalb einer Quelldatei bleiben Bestandteil der Veröffentlichung und müssen nach
den jeweils geltenden Bedingungen erhalten bleiben.
Tatsächliche Uploads offline prüfen
Das eigenständige, ebenfalls unter Apache-2.0 veröffentlichte Prüfwerkzeug entschlüsselt einen
kopierten Anfrageinhalt von PUT /api/blocks. Ein Rohdaten-Export liefert dafür die
Konto- und Schlüsselparameter; der geprüfte Kalenderblock stammt aus dem Upload. Nach erfolgreicher
Entschlüsselung kann das Werkzeug ein Byte einer lokalen Chiffratkopie verändern und deren
AES-GCM-Integritätsprüfung testen. Es lädt keine Bibliotheken, stellt keine Netzwerkverbindung her
und speichert keine Eingaben. Der normale Offline-Export-Test bleibt zusätzlich verfügbar.
Offline-Prüfwerkzeug herunterladen Anleitung für beide Tests · Vollständiger Werkzeug-Code
Im ZIP liegt dieselbe Datei unter offline/kalender-pruefen.html. Die Kopie auf
/sicherheit, diese Quelltext-Seite und das ZIP werden aus derselben Werkzeug-Datei erzeugt.
Die Tests betreffen konkrete Kalenderblöcke. Sie schließen einen zusätzlichen Klartext- oder Schlüsselabfluss nicht aus. Der Änderungstest prüft das Offline-Werkzeug, nicht die Fehlerbehandlung aller Clients. Prüfwerkzeug und Vergleichswerte stammen vom selben Betreiber.
Prüfsummen des Browser-Kryptografiebausteins
Der Kryptografiebaustein des Browser-Clients ist die Datei: crypto.js. Genau sie wird an dein Gerät
ausgeliefert, und genau ihr Inhalt steht weiter unten. Dass beides dasselbe ist, musst du uns nicht
glauben – du kannst es nachrechnen. Diese beiden Werte gehören zu der Fassung, die auf dieser Seite
abgedruckt ist:
SHA-256, hexadezimal
Derselbe Wert in Base64 – so steht er als integrity-Angabe im Quelltext der App
Nachrechnen kannst du ihn mit einem Befehl. Die Ausgabe muss Zeichen für Zeichen mit dem hexadezimalen Wert oben übereinstimmen:
curl -s https://my.privatecalendar.de/crypto.js | sha256sum
Was dieser Vergleich zeigt – und was er ausdrücklich nicht zeigt – steht auf der Prüfseite.
Die Dateien
Jede Datei lässt sich einzeln aufklappen und kopieren. Die Android- und die iPhone-App sind verfügbar.
Veröffentlicht sind hier das eigenständige Offline-Prüfwerkzeug und die kryptografischen Quelldateien aus allen drei Clients:
dem Browser (JavaScript), der Android-App (Kotlin) und der iPhone-App (Swift). Kalenderblöcke und
_meta schreiben alle drei ausschließlich das gleiche v3:-Umschlagformat mit
demselben authentifizierten Kopf; die Prüfwerte dafür stehen auf der
Prüfseite und werden in allen drei Testsuiten einzeln nachgerechnet.
Ein Leser für alte v2:- und präfixlose Blöcke ist in jedem Client noch vorhanden – aber
nur als Leser: einen Schreibweg dorthin gibt es nicht mehr.
web/crypto.js — Kryptografie und Ableitung von Authentisierungswerten im Browser
/* SPDX-FileCopyrightText: 2026 Private Calendar
* SPDX-License-Identifier: Apache-2.0
*
* Private Calendar – veröffentlichte kryptografische Bausteine.
*
* Diese Datei enthält die veröffentlichten Bausteine, die Termine ver- und
* entschlüsseln sowie Schlüssel und Authentisierungswerte aus dem Passwort
* ableiten. Der vollständige Anmeldeablauf liegt außerhalb dieser Datei. Sie liegt
* bewusst in einer eigenen, kleinen Datei statt inline in index.html: nur so
* lässt sich der sicherheitsrelevante Teil der Anwendung einzeln lesen,
* einzeln hashen und mit der veröffentlichten Fassung unter /quellcode
* vergleichen. Die Prüfanleitung dazu steht auf /sicherheit.
*
* Die Datei wird als klassisches Skript vor dem übrigen Anwendungscode geladen;
* das 'use strict' unten gilt daher für denselben Geltungsbereich wie dort.
* Zustand, den dieser Code benutzt, aber nicht selbst deklariert (encKey,
* vaultKey, vaultInfo, acct …), stammt aus der Anwendung.
*/
'use strict';
/* ---------- Schlüsselableitung, Konto-Anmeldung, Block- und Freigabe-Verschlüsselung ---------- */
const hexOf = buf => Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join('');
const b64 = buf => {
const bytes = new Uint8Array(buf), chunks = [];
// Ein kompletter großer Kalenderblock als Funktionsargumentliste überschreitet
// die Stack-/Argumentgrenze von V8. Begrenzte Stücke halten das Wire-Format
// identisch und funktionieren auch für mehrmegabytegroße Chiffrate.
for (let i = 0; i < bytes.length; i += 32768)
chunks.push(String.fromCharCode(...bytes.subarray(i, i + 32768)));
return btoa(chunks.join(''));
};
const unb64 = s => Uint8Array.from(atob(s), c => c.charCodeAt(0));
/* Passwort-KDF (OWASP): PBKDF2-HMAC-SHA256 mit 600.000 Iterationen und einem
zufälligen 16-Byte-Salt pro Konto. Ältere Konten nutzten 310.000 Iterationen
und den vorhersehbaren Salt `kalender|<email>`; nach erfolgreicher Anmeldung
migriert der Client sie auf die aktuelle Parameterwahl. */
const PASSWORD_KDF_ITERATIONS = 600000;
const PASSWORD_KDF_ITERATIONS_LEGACY = 310000;
const PASSWORD_KDF_SALT_LEN = 16;
function newPasswordKdfSalt(){
return crypto.getRandomValues(new Uint8Array(PASSWORD_KDF_SALT_LEN));
}
function legacyPasswordKdfSalt(email){
return new TextEncoder().encode('kalender|' + String(email || '').trim().toLowerCase());
}
function passwordKdfSaltBytes(salt){
if (salt instanceof Uint8Array) return salt;
if (salt instanceof ArrayBuffer) return new Uint8Array(salt);
return unb64(String(salt || ''));
}
async function deriveKeys(password, salt, iterations){
const te = new TextEncoder();
const saltBytes = passwordKdfSaltBytes(salt);
const iter = Number(iterations) | 0;
if (saltBytes.length < 8 || iter < 100000) throw new Error('Ungültige Schlüsselableitung');
const base = await crypto.subtle.importKey('raw', te.encode(password), 'PBKDF2', false, ['deriveBits']);
const master = await crypto.subtle.deriveBits({name:'PBKDF2', salt:saltBytes, iterations:iter, hash:'SHA-256'}, base, 256);
const hk = await crypto.subtle.importKey('raw', master, 'HKDF', false, ['deriveBits']);
const encBits = await crypto.subtle.deriveBits({name:'HKDF', hash:'SHA-256', salt:new Uint8Array(32), info:te.encode('enc-v1')}, hk, 256);
const authBits = await crypto.subtle.deriveBits({name:'HKDF', hash:'SHA-256', salt:new Uint8Array(32), info:te.encode('auth-v1')}, hk, 256);
return { encB64: b64(encBits), authPass: hexOf(authBits), saltB64: b64(saltBytes), iterations: iter };
}
/** Aktuelle Standardableitung: frisches Zufallssalt, 600.000 Iterationen. */
async function deriveKeysModern(password, salt){
return deriveKeys(password, salt || newPasswordKdfSalt(), PASSWORD_KDF_ITERATIONS);
}
/** Legacy-Ableitung (E-Mail-Salt, 310.000) – nur Lesen/Migration und veröffentlichte Testwerte. */
async function deriveKeysLegacy(email, password){
return deriveKeys(password, legacyPasswordKdfSalt(email), PASSWORD_KDF_ITERATIONS_LEGACY);
}
async function importEncKey(){
if (encKey || !acct || !acct.enc || !subtleOK) return encKey;
encKey = await crypto.subtle.importKey('raw', unb64(acct.enc), 'AES-GCM', false, ['encrypt', 'decrypt']);
return encKey;
}
/* Ein frischer Hauptschlüssel des Kontos. Gebraucht wird er ausschließlich für die
vollständige Schlüsselerneuerung: ein Passwortwechsel verpackt denselben
Hauptschlüssel nur neu, wer ihn kopiert hat, liest damit weiter mit. Der neue
Schlüssel stammt aus dem Zufallsgenerator des Betriebssystems und nicht aus dem
Passwort — die Kenntnis des Passworts allein sagt danach nichts mehr über ihn. */
function newMasterKey(){ return b64(crypto.getRandomValues(new Uint8Array(32))); }
/* ---------- Wiederherstellungsschlüssel (Recovery Key) ----------
Ein zufälliger, hochentropischer Schlüssel, den der Nutzer bei der Registrierung
sichert. Daraus werden ein Prüfwert (für den Server) und ein Wrap-Schlüssel
abgeleitet, mit dem der Master-Key verschlüsselt beim Server liegt. Ohne diesen
Wiederherstellungsschlüssel und ohne Passwort kann niemand – auch der Server
nicht – entschlüsseln. */
const RK_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function b32enc(bytes){
let bits = 0, val = 0, out = '';
for (const b of bytes){ val = (val << 8) | b; bits += 8; while (bits >= 5){ out += RK_ALPHABET[(val >>> (bits - 5)) & 31]; bits -= 5; } }
if (bits > 0) out += RK_ALPHABET[(val << (5 - bits)) & 31];
return out;
}
function newRecoveryKey(){ return b32enc(crypto.getRandomValues(new Uint8Array(20))); } // 32 Zeichen, 160 Bit
const groupRecoveryKey = s => String(s).replace(/(.{4})(?=.)/g, '$1-'); // XXXX-XXXX-…
const normRecoveryKey = s => String(s || '').toUpperCase().replace(/0/g, 'O').replace(/1/g, 'I').replace(/[^A-Z2-7]/g, '');
async function deriveRecoveryKeys(email, rk){
const te = new TextEncoder();
const base = await crypto.subtle.importKey('raw', te.encode(normRecoveryKey(rk)), 'PBKDF2', false, ['deriveBits']);
const master = await crypto.subtle.deriveBits({name:'PBKDF2', salt:te.encode('kalender-recovery|' + email.trim().toLowerCase()), iterations:310000, hash:'SHA-256'}, base, 256);
const hk = await crypto.subtle.importKey('raw', master, 'HKDF', false, ['deriveBits']);
const wrapBits = await crypto.subtle.deriveBits({name:'HKDF', hash:'SHA-256', salt:new Uint8Array(32), info:te.encode('rec-wrap-v1')}, hk, 256);
const authBits = await crypto.subtle.deriveBits({name:'HKDF', hash:'SHA-256', salt:new Uint8Array(32), info:te.encode('rec-auth-v1')}, hk, 256);
const wrapKey = await crypto.subtle.importKey('raw', wrapBits, 'AES-GCM', false, ['encrypt','decrypt']);
return { wrapKey, recAuth: hexOf(authBits) };
}
/* ---------- Kalenderschlüssel ----------
Jeder Kalender hat einen eigenen AES-Schlüssel. Nur so lässt sich ein einzelner
Kalender teilen, ohne das Master-Passwort preiszugeben. Der Schlüsselbund liegt
im _meta-Block und ist mit dem Master-Schlüssel verschlüsselt. */
let keyring = {}; // calId -> base64(32 Byte)
// Out-of-band verified recipient fingerprints. This map is stored only inside
// the master-key-encrypted _meta block/local vault; the server cannot alter it.
let shareTrust = {}; // normalized email -> 128-bit public-key fingerprint
let calKeyCache = {}; // calId -> CryptoKey
let keyringRefreshNeeded = false;
function newCalKey(){ return b64(crypto.getRandomValues(new Uint8Array(32))); }
function ensureCalKeyMaterial(calId){
if (String(calId).indexOf('s:') === 0) return false;
if (keyring[calId]) return false;
keyring[calId] = newCalKey(); calKeyCache[calId] = null;
calsUpdatedAt = Date.now();
if (typeof markDirtyKey === 'function') markDirtyKey('_|_meta');
return true;
}
async function calCryptoKey(calId){
if (calKeyCache[calId]) return calKeyCache[calId];
const raw = keyring[calId];
if (!raw) return null;
return calKeyCache[calId] = await crypto.subtle.importKey('raw', unb64(raw), 'AES-GCM', false, ['encrypt','decrypt']);
}
async function ensureCalKey(calId){
ensureCalKeyMaterial(calId);
return calCryptoKey(calId);
}
/*
* AES-GCM-Umschlag: genau zwei Segmente `Base64(IV).Base64(Ciphertext||Tag)`.
* Der Parser ist absichtlich unnachgiebig: exakte Segmentzahl, IV = 12 Byte,
* Ciphertext mindestens 16 Byte (GCM-Tag), Base64 nur im kanonischen Alphabet
* mit kanonischem Padding. Nach dem Dekodieren wird neu serialisiert und jede
* Abweichung vom Rohstring abgelehnt — so können Browser, Android und iPhone
* denselben Geheimtext nicht unterschiedlich auslegen.
*/
const B64_STD_RE = /^[A-Za-z0-9+/]+={0,2}$/;
const B64_URL_RE = /^[A-Za-z0-9_-]+$/;
function decodeStrictB64(text){
const s = String(text || '');
if (!s || s.length % 4 !== 0 || !B64_STD_RE.test(s)) throw new Error('Ungültiger Umschlag');
let bytes;
try { bytes = unb64(s); } catch(e){ throw new Error('Ungültiger Umschlag'); }
if (b64(bytes) !== s) throw new Error('Ungültiger Umschlag');
return bytes;
}
function parseAesGcmEnvelope(blob){
const raw = String(blob || '');
const body = raw.startsWith('v2:') ? raw.slice(3) : raw;
const parts = body.split('.');
if (parts.length !== 2) throw new Error('Ungültiger Umschlag');
const iv = decodeStrictB64(parts[0]);
if (iv.length !== 12) throw new Error('Ungültiger Umschlag');
const ct = decodeStrictB64(parts[1]);
if (ct.length < 16) throw new Error('Ungültiger Umschlag');
return {iv, ct};
}
async function encryptWith(key, obj, additionalData){
const iv = crypto.getRandomValues(new Uint8Array(12));
const algorithm = {name:'AES-GCM', iv};
if (additionalData != null) algorithm.additionalData = new TextEncoder().encode(String(additionalData));
const ct = await crypto.subtle.encrypt(algorithm, key, new TextEncoder().encode(JSON.stringify(obj)));
return b64(iv) + '.' + b64(ct);
}
async function decryptWith(key, blob, additionalData){
const {iv, ct} = parseAesGcmEnvelope(blob);
const algorithm = {name:'AES-GCM', iv};
if (additionalData != null) algorithm.additionalData = new TextEncoder().encode(String(additionalData));
const pt = await crypto.subtle.decrypt(algorithm, key, ct);
return JSON.parse(new TextDecoder().decode(pt));
}
/*
* Kalenderobjekte sind ein eigenes, versioniertes Umschlagformat. `v2:` war
* lediglich eine Schlüsselkennzeichnung; der GCM-Tag band weder den Platz des
* Blocks noch seine Serverrevision. Der öffentlich lesbare v3-Kopf ist selbst
* Teil der AAD: eine Änderung daran macht den GCM-Tag ungültig. Er enthält nur
* ohnehin serverseitig bekannte Routingdaten und keinen Kalenderinhalt.
*
* Wire-Form: `v3:` + Base64url(AAD) + `.` + Base64(IV) + `.` + Base64(CT||Tag).
* Genau drei Segmente nach dem Präfix; Kopf und Körper werden kanonisch
* geprüft, bevor der GCM-Tag ausgewertet wird.
*/
const CALENDAR_OBJECT_PROTOCOL = 3;
const CALENDAR_OBJECT_PREFIX = 'v3:';
const objectB64 = bytes => b64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
const objectUnb64 = text => unb64(String(text).replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((String(text).length + 3) % 4));
function decodeStrictB64Url(text){
const s = String(text || '');
if (!s || !B64_URL_RE.test(s)) throw new Error('Ungültiger Umschlag');
let bytes;
try { bytes = objectUnb64(s); } catch(e){ throw new Error('Ungültiger Umschlag'); }
if (objectB64(bytes) !== s) throw new Error('Ungültiger Umschlag');
return bytes;
}
function calendarObjectAad(context){
const c = context || {};
// Array statt eines Objekt-JSONs: Feldreihenfolge ist damit ausdrücklich
// kanonisch und kann nicht von einer JSON-Implementierung abhängen.
return JSON.stringify([
'private-calendar-object-v3', CALENDAR_OBJECT_PROTOCOL,
String(c.accountId || ''), String(c.calendarId || ''), String(c.objectType || ''),
String(c.bucket || ''), String(c.objectId || ''), String(c.keyEpoch || ''),
Number(c.revision || 0), String(c.previousCommitment || ''),
]);
}
function calendarObjectContext(aad){
let v;
try { v = JSON.parse(aad); } catch(e){ throw new Error('Ungültiger Objektkopf'); }
if (!Array.isArray(v) || v.length !== 10 || v[0] !== 'private-calendar-object-v3' || v[1] !== CALENDAR_OBJECT_PROTOCOL ||
!v.slice(2, 8).every(x => typeof x === 'string') || !Number.isSafeInteger(v[8]) || v[8] < 1 || typeof v[9] !== 'string')
throw new Error('Ungültiger Objektkopf');
return {accountId:v[2], calendarId:v[3], objectType:v[4], bucket:v[5], objectId:v[6], keyEpoch:v[7], revision:v[8], previousCommitment:v[9]};
}
function sameCalendarObjectContext(actual, expected){
return actual.accountId === String(expected.accountId || '') &&
actual.calendarId === String(expected.calendarId || '') &&
actual.objectType === String(expected.objectType || '') &&
actual.bucket === String(expected.bucket || '') &&
actual.objectId === String(expected.objectId || '') &&
actual.keyEpoch === String(expected.keyEpoch || '') &&
actual.revision === Number(expected.revision || 0) &&
actual.previousCommitment === String(expected.previousCommitment || '');
}
async function calendarKeyEpoch(rawKeyB64){
const digest = await crypto.subtle.digest('SHA-256', unb64(rawKeyB64));
return objectB64(digest);
}
/* Vorgänger-Commitment ist SHA-256 des vollständigen, kanonischen Wire-Strings.
Weil der Parser nur kanonische Umschläge annimmt, ist der gehashte String durch
die authentifizierte Struktur eindeutig bestimmt — nicht durch eine permissive
Roh-Zerlegung. */
async function calendarBlobCommitment(blob){
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(blob)));
return objectB64(digest);
}
async function encryptCalendarObject(key, object, context){
const aad = calendarObjectAad(context);
return CALENDAR_OBJECT_PREFIX + objectB64(new TextEncoder().encode(aad)) + '.' + await encryptWith(key, object, aad);
}
async function decryptCalendarObject(key, blob, expectedContext){
const value = String(blob || '');
if (!value.startsWith(CALENDAR_OBJECT_PREFIX)){
// Altbestand bleibt ausschließlich lesbar, damit die erste v3-Schreiboperation
// ihn migrieren kann. Neue Umschläge werden nie ohne gebundenen Kontext erzeugt.
return {legacy:true, context:null, value:await decryptWith(key, value)};
}
const rest = value.slice(CALENDAR_OBJECT_PREFIX.length);
const parts = rest.split('.');
if (parts.length !== 3 || !parts[0]) throw new Error('Ungültiger Objektumschlag');
const aad = new TextDecoder().decode(decodeStrictB64Url(parts[0]));
const context = calendarObjectContext(aad);
// JSON-Kopf muss nach dem Parsen bitgleich zur kanonischen Serialisierung sein.
if (calendarObjectAad(context) !== aad) throw new Error('Ungültiger Objektumschlag');
if (!sameCalendarObjectContext(context, expectedContext)){
const failure = new Error('Objektumschlag gehört nicht zu diesem Block');
/* Weicht ausschließlich die Schlüssel-Epoche ab, ist der Block echt und
unversehrt — uns fehlt bloß sein Schlüssel. Nur dieser eine Fall darf in
Quarantäne gehen; jede andere Abweichung bleibt ein harter Fehler, weil sie
einen verschobenen oder untergeschobenen Block bedeuten kann. */
if (sameCalendarObjectContext(context, Object.assign({}, expectedContext, {keyEpoch: context.keyEpoch})))
failure.keyEpochMismatch = context.keyEpoch;
throw failure;
}
return {legacy:false, context, value:await decryptWith(key, parts[1] + '.' + parts[2], aad)};
}
async function resolveLoginKeys(keys, result){
const passwordKeyB64 = keys.encB64;
let masterB64 = passwordKeyB64;
if (result && result.passwordBlob){
const passwordKey = await crypto.subtle.importKey('raw', unb64(passwordKeyB64), 'AES-GCM', false, ['decrypt']);
const wrapped = await decryptWith(passwordKey, result.passwordBlob);
if (!wrapped || typeof wrapped.enc !== 'string' || unb64(wrapped.enc).length !== 32)
throw new Error('Ungültige Schlüsseldaten des Kontos');
masterB64 = wrapped.enc;
}
return Object.assign({}, keys, {encB64:masterB64, passwordKeyB64});
}
async function localPasswordBlob(passwordKeyB64, masterB64){
const key = await crypto.subtle.importKey('raw', unb64(passwordKeyB64), 'AES-GCM', false, ['encrypt']);
return encryptWith(key, {enc:masterB64});
}
async function activateLocalVault(email, server, passwordKeyB64, masterB64, kdf){
if (!idb) throw new Error('Sicherer lokaler Speicher ist nicht verfügbar');
await setVaultMaster(masterB64);
const passwordBlob = await localPasswordBlob(passwordKeyB64, masterB64);
const oldInfo = vaultInfo;
// Ein erfolgreicher Login hebt einen zuvor ausdrücklich gespeicherten
// Abmelde-Zustand auf. `signedOut` fehlt absichtlich im neuen Datensatz.
const saltB64 = kdf && (kdf.saltB64 || kdf.salt) || (vaultInfo && vaultInfo.kdfSalt) || null;
const iters = kdf && kdf.iterations != null ? Number(kdf.iterations)
: (vaultInfo && vaultInfo.kdfIterations != null ? Number(vaultInfo.kdfIterations) : null);
vaultInfo = {version:VAULT_VERSION, email:String(email || '').toLowerCase(), server,
passwordBlob, createdAt:(vaultInfo && vaultInfo.createdAt) || Date.now(),
kdfSalt: saltB64 || undefined, kdfIterations: iters || undefined};
if (acct) acct.enc = masterB64;
vaultMigrating = true;
try { await writeVaultSnapshot(true); }
catch(e){ vaultInfo = oldInfo; if (!oldInfo){ vaultKey = null; vaultMasterB64 = null; } throw e; }
finally { vaultMigrating = false; }
if (typeof migratePlaintextDeviceBackups === 'function') await migratePlaintextDeviceBackups();
}
function vaultPasswordKdfParams(info){
if (info && info.kdfSalt && info.kdfIterations)
return { salt: unb64(info.kdfSalt), iterations: Number(info.kdfIterations) };
// Älterer Tresor ohne gespeicherte Parameter: E-Mail-Salt und 310.000 Runden.
return { salt: legacyPasswordKdfSalt(info && info.email), iterations: PASSWORD_KDF_ITERATIONS_LEGACY };
}
async function unlockLocalVaultWithPassword(password){
if (!vaultInfo || !vaultInfo.passwordBlob) throw new Error('Lokale Schlüsseldaten fehlen');
const kdf = vaultPasswordKdfParams(vaultInfo);
const keys = await deriveKeys(password, kdf.salt, kdf.iterations);
const passwordKey = await crypto.subtle.importKey('raw', unb64(keys.encB64), 'AES-GCM', false, ['decrypt']);
let wrapped;
try { wrapped = await decryptWith(passwordKey, vaultInfo.passwordBlob); }
catch(e){ throw new Error('Passwort ist falsch'); }
if (!wrapped || typeof wrapped.enc !== 'string') throw new Error('Lokale Schlüsseldaten sind beschädigt');
await setVaultMaster(wrapped.enc);
return true;
}
async function migrateLegacyCacheWithPassword(password){
if (vaultInfo) return unlockLocalVaultWithPassword(password);
if (!acct || !acct.enc) throw new Error('Lokaler Kontoschlüssel fehlt');
// Ohne vault_info: erst den vom Server gelieferten KDF-Stand versuchen, sonst Legacy.
let keys;
try {
const params = await api('POST', '/api/login/kdf', {email: acct.email});
keys = await deriveKeys(password, unb64(params.salt), params.iterations);
} catch(e){
keys = await deriveKeysLegacy(acct.email, password);
}
if (keys.encB64 !== acct.enc){
const result = await api('POST', '/api/account/local-vault', {authPass:keys.authPass});
keys = await resolveLoginKeys(keys, result);
} else keys = Object.assign(keys, {passwordKeyB64:keys.encB64});
if (keys.encB64 !== acct.enc) throw new Error('Passwort ist falsch');
await activateLocalVault(acct.email, acct.server, keys.passwordKeyB64 || keys.encB64, acct.enc, keys);
return true;
}
const encryptJSON = obj => encryptWith(encKey, obj);
const decryptJSON = blob => decryptWith(encKey, blob);
/*
* Es gibt absichtlich keine allgemeine Block-Schreibfunktion mehr. Kalender- und
* _meta-Blöcke dürfen ausschließlich über encryptCalendarObject() entstehen,
* denn nur dort ist der vollständige, servergebundene v3-Kontext Teil der AAD.
* Die v2-/präfixlose Behandlung in decryptCalendarObject() ist ein isolierter
* Legacy-Leser für die Migration und darf niemals als neuer Schreibpfad dienen.
*/
/* ---------- Schlüsselpaar für Freigaben (ECDH P-256) ---------- */
let myPriv = null, myPub = null, myFingerprint = null;
const normalizeShareFingerprint = value => {
const compact = String(value || '').toUpperCase().replace(/[^0-9A-F]/g, '');
return compact.length === 32 ? compact : '';
};
const formatShareFingerprint = compact => (String(compact || '').match(/.{1,4}/g) || []).join('-');
async function shareFingerprint(pubB64){
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', unb64(pubB64)));
return [...digest.slice(0, 16)].map(v => v.toString(16).padStart(2, '0')).join('').toUpperCase();
}
function sameBytes(a, b){
a = new Uint8Array(a); b = new Uint8Array(b);
if (a.length !== b.length) return false;
let diff = 0; for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
async function sharingKeypairMatches(privateKey, publicKeyB64){
const probe = await crypto.subtle.generateKey({name:'ECDH', namedCurve:'P-256'}, true, ['deriveBits']);
const publicKey = await crypto.subtle.importKey('spki', unb64(publicKeyB64), {name:'ECDH', namedCurve:'P-256'}, false, []);
const left = await crypto.subtle.deriveBits({name:'ECDH', public:probe.publicKey}, privateKey, 256);
const right = await crypto.subtle.deriveBits({name:'ECDH', public:publicKey}, probe.privateKey, 256);
return sameBytes(left, right);
}
function shareAad(ownerFingerprint, recipientFingerprint, ownerEmail, recipientEmail, calendarId, role){
return ['private-calendar-share-v2', normalizeShareFingerprint(ownerFingerprint), normalizeShareFingerprint(recipientFingerprint),
String(ownerEmail || '').trim().toLowerCase(), String(recipientEmail || '').trim().toLowerCase(),
String(calendarId), role === 'writer' ? 'writer' : 'reader'].join('\n');
}
async function newSharingKeypair(){
const kp = await crypto.subtle.generateKey({name:'ECDH', namedCurve:'P-256'}, true, ['deriveBits']);
return {pub: b64(await crypto.subtle.exportKey('spki', kp.publicKey)),
priv: b64(await crypto.subtle.exportKey('pkcs8', kp.privateKey))};
}
/* Übernimmt ein Schlüsselpaar als das eigene. Der private Schlüssel wird dabei
nicht exportierbar importiert: von hier an kann ihn auch der übrige Anwendungscode
nicht mehr auslesen, sondern nur noch damit rechnen. */
async function adoptSharingKeypair(pub, priv){
myPub = pub;
myPriv = await crypto.subtle.importKey('pkcs8', unb64(priv), {name:'ECDH', namedCurve:'P-256'}, false, ['deriveBits']);
myFingerprint = await shareFingerprint(myPub);
}
async function ensureKeypair(){
if (myPriv) return true;
const r = await api('GET', '/api/keys');
if (r && r.pubkey && r.wrapped_priv){
const pkcs8 = await decryptJSON(r.wrapped_priv); // {p:base64,pub?:base64}
const privateKey = await crypto.subtle.importKey('pkcs8', unb64(pkcs8.p), {name:'ECDH', namedCurve:'P-256'}, false, ['deriveBits']);
if (pkcs8.pub && pkcs8.pub !== r.pubkey) throw new Error('Der Freigabeschlüssel des Servers stimmt nicht mit dem privaten Schlüssel überein');
if (!await sharingKeypairMatches(privateKey, r.pubkey)) throw new Error('Der Freigabeschlüssel des Servers wurde unerwartet ersetzt');
myPub = r.pubkey;
myPriv = privateKey;
myFingerprint = await shareFingerprint(myPub);
return true;
}
const kp = await newSharingKeypair();
const wrapped = await encryptJSON({p: kp.priv, pub: kp.pub});
await api('PUT', '/api/keys', {pubkey: kp.pub, wrapped_priv: wrapped});
await adoptSharingKeypair(kp.pub, kp.priv);
return true;
}
/* Gemeinsames Geheimnis mit dem Gegenüber -> AES-Schlüssel zum Ein-/Auspacken */
async function shareKeyWith(otherPubB64){
const pub = await crypto.subtle.importKey('spki', unb64(otherPubB64), {name:'ECDH', namedCurve:'P-256'}, false, []);
const bits = await crypto.subtle.deriveBits({name:'ECDH', public: pub}, myPriv, 256);
const hk = await crypto.subtle.importKey('raw', bits, 'HKDF', false, ['deriveBits']);
const out = await crypto.subtle.deriveBits({name:'HKDF', hash:'SHA-256', salt:new Uint8Array(32), info:new TextEncoder().encode('share-v1')}, hk, 256);
return crypto.subtle.importKey('raw', out, 'AES-GCM', false, ['encrypt','decrypt']);
}
/* ---------- Buchungslinks ("Freie Zeiten teilen") ----------
Ein Buchungslink hat die Form https://…/t/<Kennzeichen>#k=<Schlüssel>. Das
Kennzeichen ist öffentlich und steht in der Datenbank; der Schlüssel steht
hinter dem Rautezeichen und wird von Browsern grundsätzlich nicht mitgesendet,
erreicht den Server also nicht. Mit ihm ist das Angebot verschlüsselt: Titel,
Dauer und die freien Zeitpunkte. Der Server sieht davon nichts – nur, dass es
einen Link gibt.
Die Antwort läuft in die andere Richtung und ist auch vor allen anderen
geschützt, die denselben Link kennen: Die anfragende Person erzeugt ein
flüchtiges Schlüsselpaar, einigt sich damit auf ein gemeinsames Geheimnis mit
dem Freigabeschlüssel des Eigentümers (derselbe öffentliche Schlüssel wie beim
Teilen von Kalendern) und verschlüsselt Name, Nachricht und gewählten
Zeitpunkt damit. Entschlüsseln kann das nur der Eigentümer. */
const bookingB64url = buf => b64(buf).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
function bookingUnb64url(value){
const s = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
return unb64(s + '='.repeat((4 - s.length % 4) % 4));
}
function newBookingToken(){ return bookingB64url(crypto.getRandomValues(new Uint8Array(16))); }
function newBookingSecret(){ return bookingB64url(crypto.getRandomValues(new Uint8Array(32))); }
async function bookingLinkKey(secret){
const raw = bookingUnb64url(secret);
if (raw.length !== 32) throw new Error('Ungültiger Linkschlüssel');
return crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']);
}
const bookingConfigAad = token => 'private-calendar-booking-config-v1|' + String(token);
const bookingRequestAad = (token, epk, slotTag) =>
['private-calendar-booking-request-v1', String(token), String(epk), String(slotTag)].join('|');
const bookingReplyAad = (token, requestId) =>
['private-calendar-booking-reply-v1', String(token), String(requestId)].join('|');
async function encryptBookingConfig(key, token, config){
return encryptWith(key, config, bookingConfigAad(token));
}
async function decryptBookingConfig(key, token, blob){
return decryptWith(key, blob, bookingConfigAad(token));
}
/* Kurzwert eines Zeitpunkts. Der Server erkennt daran, dass zwei Anfragen
denselben Zeitpunkt meinen, und kann eine Doppelbuchung ablehnen – ohne den
Zeitpunkt zu erfahren, denn ohne den Linkschlüssel ist der Wert nicht
umkehrbar und auch nicht mit einem anderen Link vergleichbar. */
async function bookingSlotTag(secret, token, slotStart){
const key = await crypto.subtle.importKey('raw', bookingUnb64url(secret), {name:'HMAC', hash:'SHA-256'}, false, ['sign']);
const message = new TextEncoder().encode('private-calendar-booking-slot-v1|' + String(token) + '|' + String(slotStart));
return hexOf(await crypto.subtle.sign('HMAC', key, message)).slice(0, 32);
}
async function bookingSharedKey(privateKey, publicKeyB64){
const pub = await crypto.subtle.importKey('spki', unb64(publicKeyB64), {name:'ECDH', namedCurve:'P-256'}, false, []);
const bits = await crypto.subtle.deriveBits({name:'ECDH', public: pub}, privateKey, 256);
const hk = await crypto.subtle.importKey('raw', bits, 'HKDF', false, ['deriveBits']);
const out = await crypto.subtle.deriveBits({name:'HKDF', hash:'SHA-256', salt:new Uint8Array(32),
info:new TextEncoder().encode('booking-request-v1')}, hk, 256);
return crypto.subtle.importKey('raw', out, 'AES-GCM', false, ['encrypt', 'decrypt']);
}
async function encryptBookingRequest(ownerPubB64, token, slotTag, payload){
const pair = await crypto.subtle.generateKey({name:'ECDH', namedCurve:'P-256'}, true, ['deriveBits']);
const epk = b64(await crypto.subtle.exportKey('spki', pair.publicKey));
const key = await bookingSharedKey(pair.privateKey, ownerPubB64);
return {epk, blob: await encryptWith(key, payload, bookingRequestAad(token, epk, slotTag))};
}
async function decryptBookingRequest(privateKey, token, epk, slotTag, blob){
const key = await bookingSharedKey(privateKey, epk);
return decryptWith(key, blob, bookingRequestAad(token, epk, slotTag));
}
/* Zu- oder Absage an die anfragende Person. Sie liegt unter dem Linkschlüssel,
weil das flüchtige Schlüsselpaar der Anfrage nach dem Absenden nicht mehr
existiert. Abrufbar ist sie nur mit der Kennung der Anfrage, die allein die
anfragende Person erhalten hat. */
async function encryptBookingReply(key, token, requestId, reply){
return encryptWith(key, reply, bookingReplyAad(token, requestId));
}
async function decryptBookingReply(key, token, requestId, blob){
return decryptWith(key, blob, bookingReplyAad(token, requestId));
}
/* ---------- Lokaler verschlüsselter Speicher (Vault) ---------- */
async function deriveVaultKey(masterB64){
const raw = unb64(masterB64);
const hk = await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']);
return crypto.subtle.deriveKey({name:'HKDF', hash:'SHA-256', salt:new Uint8Array(32),
info:new TextEncoder().encode('kalender-local-vault-v2')}, hk, {name:'AES-GCM', length:256}, false, ['encrypt','decrypt']);
}
async function setVaultMaster(masterB64){
if (!masterB64 || unb64(masterB64).length !== 32) throw new Error('Ungültiger lokaler Schlüssel');
vaultMasterB64 = masterB64;
vaultKey = await deriveVaultKey(masterB64);
encKey = await crypto.subtle.importKey('raw', unb64(masterB64), 'AES-GCM', false, ['encrypt','decrypt']);
}
async function vaultEncryptRecord(name, value){
if (!vaultKey) throw new Error('Kalender ist gesperrt');
const iv = crypto.getRandomValues(new Uint8Array(12));
const aad = new TextEncoder().encode('kalender-vault-v2|' + name);
const plain = new TextEncoder().encode(JSON.stringify(value));
const ct = await crypto.subtle.encrypt({name:'AES-GCM', iv, additionalData:aad}, vaultKey, plain);
return 'vlt2:' + b64(iv) + '.' + b64(ct);
}
async function vaultDecryptRecord(name, stored){
if (!vaultKey || typeof stored !== 'string' || stored.indexOf('vlt2:') !== 0) throw new Error('Ungültiger lokaler Datensatz');
const {iv, ct} = parseAesGcmEnvelope(stored.slice(5));
const aad = new TextEncoder().encode('kalender-vault-v2|' + name);
const pt = await crypto.subtle.decrypt({name:'AES-GCM', iv, additionalData:aad}, vaultKey, ct);
return JSON.parse(new TextDecoder().decode(pt));
}
/* ---------- Passwortgeschützte Export-Dateien ---------- */
/* Export-KDF: gleiche 600.000 Runden wie die aktuelle Passwort-KDF (OWASP).
Ältere Exporte mit 310.000 bleiben lesbar; die Rundenzahl steht in der Datei. */
const EXPORT_KDF_ITERATIONS = 600000;
const EXPORT_KDF_ITERATIONS_MIN = 100000;
const EXPORT_KDF_ITERATIONS_MAX = 2000000;
async function encryptExportPayload(text, contentType, originalFilename, password){
const te = new TextEncoder();
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const base = await crypto.subtle.importKey('raw', te.encode(password), 'PBKDF2', false, ['deriveKey']);
const key = await crypto.subtle.deriveKey(
{name:'PBKDF2', salt, iterations:EXPORT_KDF_ITERATIONS, hash:'SHA-256'}, base,
{name:'AES-GCM', length:256}, false, ['encrypt']);
const aadText = 'kalender-export-v1|' + contentType;
const ciphertext = await crypto.subtle.encrypt(
{name:'AES-GCM', iv, additionalData:te.encode(aadText)}, key, te.encode(text));
return JSON.stringify({
format:'kalender-export', version:1, encrypted:true,
originalFilename, contentType, contentEncoding:'utf-8',
kdf:{name:'PBKDF2', hash:'SHA-256', iterations:EXPORT_KDF_ITERATIONS, salt:exportB64(salt)},
cipher:{name:'AES-GCM', keyLength:256, iv:exportB64(iv), additionalData:aadText},
ciphertext:exportB64(ciphertext)
}, null, 2);
}
function importUnb64(value){
const input = String(value || '').replace(/\s+/g, '');
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(input) || input.length % 4) throw new Error('Ungültige Base64-Daten');
const padding = input.endsWith('==') ? 2 : input.endsWith('=') ? 1 : 0;
const out = new Uint8Array(input.length / 4 * 3 - padding);
let offset = 0;
// In Blöcken dekodieren, damit große Vollverlaufs-Exporte keinen riesigen
// zusätzlichen Binärstring im Browser anlegen.
for (let i=0; i<input.length; i+=32768){
const binary = atob(input.slice(i, Math.min(input.length, i + 32768)));
for (let j=0; j<binary.length; j++) out[offset++] = binary.charCodeAt(j);
}
return out;
}
function validateExportEnvelope(envelope){
if (!envelope || envelope.format !== 'kalender-export' || envelope.version !== 1 || envelope.encrypted !== true)
throw new Error('Unbekanntes Exportformat');
if (!['application/json','text/calendar'].includes(envelope.contentType)) throw new Error('Unbekannter Exportinhalt');
const iterations = Number(envelope.kdf && envelope.kdf.iterations);
if (!envelope.kdf || envelope.kdf.name !== 'PBKDF2' || envelope.kdf.hash !== 'SHA-256'
|| !Number.isFinite(iterations) || iterations < EXPORT_KDF_ITERATIONS_MIN || iterations > EXPORT_KDF_ITERATIONS_MAX)
throw new Error('Nicht unterstützte Schlüsselableitung');
if (!envelope.cipher || envelope.cipher.name !== 'AES-GCM' || envelope.cipher.keyLength !== 256)
throw new Error('Nicht unterstützte Exportverschlüsselung');
const aad = 'kalender-export-v1|' + envelope.contentType;
if (envelope.cipher.additionalData !== aad) throw new Error('Export-Metadaten wurden verändert');
return {aad, iterations};
}
async function decryptExportPayload(envelope, password){
const {aad, iterations} = validateExportEnvelope(envelope);
const te = new TextEncoder();
const salt = importUnb64(envelope.kdf.salt), iv = importUnb64(envelope.cipher.iv), ciphertext = importUnb64(envelope.ciphertext);
if (salt.length !== 16 || iv.length !== 12 || ciphertext.length < 16) throw new Error('Beschädigte Exportdatei');
const base = await crypto.subtle.importKey('raw', te.encode(password), 'PBKDF2', false, ['deriveKey']);
const key = await crypto.subtle.deriveKey(
{name:'PBKDF2', salt, iterations, hash:'SHA-256'}, base,
{name:'AES-GCM', length:256}, false, ['decrypt']);
const plain = await crypto.subtle.decrypt(
{name:'AES-GCM', iv, additionalData:te.encode(aad)}, key, ciphertext);
return {text:new TextDecoder().decode(plain), contentType:envelope.contentType,
originalFilename:String(envelope.originalFilename || '')};
}android/AppSignature.kt — AppSignature
// SPDX-FileCopyrightText: 2026 Private Calendar
// SPDX-License-Identifier: Apache-2.0
package de.privatecalendar.app.crypto
import android.content.Context
import android.content.pm.PackageManager
import android.content.pm.Signature
import android.os.Build
import java.security.MessageDigest
/**
* Fingerabdruck des Zertifikats, mit dem DIESE Installation signiert ist.
*
* Wozu: Der Nutzer vergleicht den angezeigten Wert mit dem auf der Website
* veröffentlichten. Stimmen beide überein, stammt das installierte Paket vom
* offiziellen Signaturschlüssel – eine umgepackte oder untergeschobene Kopie hat
* zwangsläufig einen anderen Fingerabdruck, weil sie unseren privaten Schlüssel
* nicht besitzt.
*
* Google Play signiert ein hochgeladenes App-Bundle mit dem Play-App-Signing-
* Zertifikat neu. Angezeigt wird deshalb dessen Fingerabdruck und nicht der des
* Upload-Schlüssels; der auf der Website veröffentlichte Vergleichswert ist
* derselbe. Debug-Builds zeigen erwartungsgemäß einen anderen Wert.
*
* Grenze der Aussage: Das belegt die **Herkunft** des Pakets, nicht das Verhalten
* des Codes darin. Dafür bräuchte es offenen Quelltext und reproduzierbare Builds.
*/
object AppSignature {
/** SHA-256 des Signaturzertifikats als Hex in Großbuchstaben, oder null wenn nicht lesbar. */
fun sha256(context: Context): String? = runCatching {
val certificates = signingCertificates(context) ?: return@runCatching null
val first = certificates.firstOrNull() ?: return@runCatching null
MessageDigest.getInstance("SHA-256")
.digest(first.toByteArray())
.joinToString("") { "%02X".format(it.toInt() and 0xFF) }
}.getOrNull()
/** „AB:CD:EF:…“ – paarweise gruppiert, damit ein Vergleich von Hand zumutbar bleibt. */
fun formatted(hex: String): String = hex.chunked(2).joinToString(":")
private fun signingCertificates(context: Context): Array<Signature>? {
val pm = context.packageManager
val name = context.packageName
// GET_SIGNING_CERTIFICATES/SigningInfo gibt es erst ab API 28; minSdk ist 26,
// darunter bleibt nur das abgelöste GET_SIGNATURES.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
@Suppress("DEPRECATION")
return pm.getPackageInfo(name, PackageManager.GET_SIGNATURES).signatures
}
val flags = PackageManager.GET_SIGNING_CERTIFICATES
val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.getPackageInfo(name, PackageManager.PackageInfoFlags.of(flags.toLong()))
} else {
@Suppress("DEPRECATION")
pm.getPackageInfo(name, flags)
}
val signing = info.signingInfo ?: return null
// Bei mehreren Signierern zählt der aktuelle Satz, sonst die Historie (Rotation).
return if (signing.hasMultipleSigners()) signing.apkContentsSigners
else signing.signingCertificateHistory
}
}android/BookingCrypto.kt — BookingCrypto
// SPDX-FileCopyrightText: 2026 Private Calendar
// SPDX-License-Identifier: Apache-2.0
package de.privatecalendar.app.crypto
import java.security.PrivateKey
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Buchungslinks, browserkompatibel — die Gegenstelle ist `www/crypto.js`.
*
* Ein Link ist `/t/<token>#k=<secret>`: 16 zufällige Bytes als öffentliches Kennzeichen,
* 32 als Schlüssel, beide URL-sicher Base64 ohne Auffüllzeichen. Browser senden den Teil
* hinter dem Rautezeichen nicht mit, der Server hält also Chiffrat, das er nicht öffnen
* kann. Das ist der ganze Entwurf, und nichts darf ihn aufweichen.
*
* Drei Umschläge, jeder durch seine AAD an genau seinen Platz gebunden:
* - Angebot (`…-config-v1|<token>`) unter dem Linkschlüssel: freie Zeiten, Titel, Dauer.
* - Anfrage (`…-request-v1|<token>|<epk>|<slotTag>`) unter einem ECDH-Geheimnis aus dem
* flüchtigen Schlüsselpaar der anfragenden Person und dem Freigabeschlüssel des
* Eigentümers — nur der Eigentümer kann sie lesen, auch niemand sonst mit dem Link.
* - Antwort (`…-reply-v1|<token>|<requestId>`) wieder unter dem Linkschlüssel, weil das
* flüchtige Schlüsselpaar der Anfrage danach nicht mehr existiert.
*
* Die AAD-Zeichenketten und die Ableitung sind Wire-Format: eine Abweichung macht jeden
* im Browser erzeugten Link auf dem Telefon unlesbar (und umgekehrt).
*/
object BookingCrypto {
private val zeroSalt = ByteArray(32)
fun newToken(): String = base64Url(ByteArray(16).also(SecureRandom()::nextBytes))
fun newSecret(): String = base64Url(ByteArray(32).also(SecureRandom()::nextBytes))
/** Der Linkschlüssel ist der rohe Wert hinter `#k=`, nicht davon abgeleitet. */
fun linkKey(secret: String): ByteArray {
val raw = decodeBase64Url(secret)
require(raw.size == 32) { "Ungültiger Linkschlüssel" }
return raw
}
fun encryptConfig(linkKey: ByteArray, token: String, configJson: String): String =
CalendarCrypto.encryptEnvelope(linkKey, configJson, additionalData = configAad(token))
fun decryptConfig(linkKey: ByteArray, token: String, blob: String): String =
CalendarCrypto.decryptEnvelope(linkKey, blob, configAad(token))
fun encryptReply(linkKey: ByteArray, token: String, requestId: String, replyJson: String): String =
CalendarCrypto.encryptEnvelope(linkKey, replyJson, additionalData = replyAad(token, requestId))
/**
* Entschlüsselt eine eingegangene Anfrage mit dem eigenen Freigabe-Privatschlüssel
* (derselbe wie beim Teilen von Kalendern) und dem flüchtigen öffentlichen Schlüssel
* der anfragenden Person.
*/
fun decryptRequest(
privateKey: PrivateKey,
token: String,
ephemeralPublicKey: String,
slotTag: String,
blob: String,
): String = CalendarCrypto.decryptEnvelope(
sharedKey(privateKey, ephemeralPublicKey),
blob,
requestAad(token, ephemeralPublicKey, slotTag),
)
/**
* Kurzwert eines Zeitpunkts. Der Server erkennt daran, dass zwei Anfragen denselben
* Zeitpunkt meinen, ohne den Zeitpunkt zu erfahren: ohne den Linkschlüssel ist der
* Wert weder umkehrbar noch mit einem anderen Link vergleichbar.
*/
fun slotTag(secret: String, token: String, slotStart: String): String {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(decodeBase64Url(secret), "HmacSHA256"))
val message = "private-calendar-booking-slot-v1|$token|$slotStart"
return mac.doFinal(message.encodeToByteArray())
.joinToString("") { "%02x".format(it) }
.take(32)
}
private fun sharedKey(privateKey: PrivateKey, ephemeralPublicKey: String): ByteArray {
val secret = SharingCrypto.ecdhSecret(privateKey, ephemeralPublicKey)
return hkdfSha256(secret, "booking-request-v1".encodeToByteArray())
}
private fun configAad(token: String): ByteArray =
"private-calendar-booking-config-v1|$token".encodeToByteArray()
private fun requestAad(token: String, epk: String, slotTag: String): ByteArray =
"private-calendar-booking-request-v1|$token|$epk|$slotTag".encodeToByteArray()
private fun replyAad(token: String, requestId: String): ByteArray =
"private-calendar-booking-reply-v1|$token|$requestId".encodeToByteArray()
private fun base64Url(bytes: ByteArray): String =
Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
private fun decodeBase64Url(value: String): ByteArray {
// Der Browser schneidet die Auffüllzeichen ab; der strikte Java-Dekodierer nicht.
val normalized = value.trim().replace('-', '+').replace('_', '/')
val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4)
return Base64.getDecoder().decode(padded)
}
private fun hkdfSha256(input: ByteArray, info: ByteArray): ByteArray {
val extract = Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(zeroSalt, "HmacSHA256"))
doFinal(input)
}
return Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(extract, "HmacSHA256"))
update(info)
update(1.toByte())
doFinal()
}
}
}android/CalendarCrypto.kt — CalendarCrypto
// SPDX-FileCopyrightText: 2026 Private Calendar
// SPDX-License-Identifier: Apache-2.0
package de.privatecalendar.app.crypto
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
object CalendarCrypto {
/** Current OWASP recommendation for PBKDF2-HMAC-SHA256 (password → enc/auth). */
const val PASSWORD_KDF_ITERATIONS = 600_000
/** Pre-migration accounts used email salt + 310_000 rounds. */
const val PASSWORD_KDF_ITERATIONS_LEGACY = 310_000
/**
* Grenzen für eine Rundenzahl, die vom Server kommt.
*
* `/api/login/kdf` wird vor jeder Anmeldung ungeprüft geglaubt, und PBKDF2 läuft genau so oft,
* wie dort steht. Ohne Obergrenze genügte ein `"iterations": 2000000000`, damit die Anmeldung
* nie zurückkommt und ein Kern dauerhaft heiß läuft. Dieselbe Schranke zieht [ExportCrypto]
* um die Rundenzahl aus einer Exportdatei, und aus demselben Grund.
*/
const val PASSWORD_KDF_ITERATIONS_MIN = 100_000
const val PASSWORD_KDF_ITERATIONS_MAX = 2_000_000
const val PASSWORD_KDF_SALT_LEN = 16
/** Recovery-key derivation keeps the fixed email salt and legacy iteration count. */
private const val RECOVERY_ITERATIONS = PASSWORD_KDF_ITERATIONS_LEGACY
private const val CALENDAR_OBJECT_PREFIX = "v3:"
private const val CALENDAR_OBJECT_PROTOCOL = 3
private val zeroSalt = ByteArray(32)
private val STRICT_B64_STD = Regex("^[A-Za-z0-9+/]+={0,2}$")
private val STRICT_B64_URL = Regex("^[A-Za-z0-9_-]+$")
/** Canonical, authenticated routing context of a synchronized calendar object. */
/**
* Der Block ist authentisch, sein Schlüssel aber unbekannt: die im Kopf
* genannte Epoche passt zu keinem Schlüssel, den dieses Gerät erreichen kann.
* Aus der Epoche (SHA-256) lässt sich der Schlüssel nicht zurückrechnen.
*/
class KeyEpochMismatchException(val keyEpoch: String) :
IllegalArgumentException("Calendar envelope does not belong to this block")
/**
* Der Block gehört nachweislich hierher — Konto, Kalender, Art, Eimer, Objektkennung,
* Schlüsselepoche und Vorgängerbindung stimmen alle —, nur die beglaubigte Revision ist
* eine andere als die, unter der der Server ihn gerade führt.
*
* Das ist die einzige Abweichung, aus der sich noch etwas machen lässt, und sie hat zwei
* mögliche Ursachen: ein Server, der ein echtes älteres Chiffrat unter neuer Nummer anbietet
* (Rückspulversuch), oder eine ältere App-Fassung, die die Serverversion einer Zeile
* hochgesetzt hat, ohne den Umschlag neu zu erzeugen. Beide Fälle sehen gleich aus und
* dürfen deshalb beide nicht ungeprüft angezeigt werden; wie der Aufrufer damit umgeht,
* steht im Repository ([DefaultCalendarRepository.mergeIncoming]).
*/
class RevisionMismatchException(val authenticatedRevision: Long) :
IllegalArgumentException("Calendar envelope carries a different revision")
data class CalendarObjectContext(
val accountId: String,
val calendarId: String,
val objectType: String,
val bucket: String,
val objectId: String,
val keyEpoch: String,
val revision: Long,
val previousCommitment: String,
)
data class DecryptedCalendarObject(
val legacy: Boolean,
val context: CalendarObjectContext?,
val plaintext: String,
)
data class PasswordKeys(
val passwordMaster: ByteArray,
val encryptionKey: ByteArray,
val authPass: String,
val salt: ByteArray,
val iterations: Int,
) {
val saltBase64: String get() = Base64.getEncoder().encodeToString(salt)
}
data class RecoveryKeys(
val recoveryMaster: ByteArray,
val wrapKey: ByteArray,
val recoveryAuth: String,
)
fun normalizeEmail(email: String): String = email.trim().lowercase()
fun randomPasswordKdfSalt(): ByteArray =
ByteArray(PASSWORD_KDF_SALT_LEN).also(SecureRandom()::nextBytes)
fun legacyPasswordKdfSalt(email: String): ByteArray =
"kalender|${normalizeEmail(email)}".encodeToByteArray()
suspend fun passwordKeys(password: String, salt: ByteArray, iterations: Int): PasswordKeys {
require(salt.size >= 8) { "Ungültiges Salt" }
require(iterations in PASSWORD_KDF_ITERATIONS_MIN..PASSWORD_KDF_ITERATIONS_MAX) {
"Ungültige Iterationszahl"
}
val master = pbkdf2(password, salt, iterations)
return PasswordKeys(
passwordMaster = master,
encryptionKey = hkdf(master, zeroSalt, "enc-v1".encodeToByteArray()),
authPass = hkdf(master, zeroSalt, "auth-v1".encodeToByteArray()).toHex(),
salt = salt.copyOf(),
iterations = iterations,
)
}
/** Current standard: random 16-byte salt, 600_000 iterations. */
suspend fun passwordKeysModern(password: String, salt: ByteArray = randomPasswordKdfSalt()): PasswordKeys =
passwordKeys(password, salt, PASSWORD_KDF_ITERATIONS)
/** Legacy email-salt derivation (migration + published test vectors). */
suspend fun passwordKeysLegacy(email: String, password: String): PasswordKeys =
passwordKeys(password, legacyPasswordKdfSalt(email), PASSWORD_KDF_ITERATIONS_LEGACY)
suspend fun passwordKeysFromServer(password: String, saltBase64: String, iterations: Int): PasswordKeys {
val salt = Base64.getDecoder().decode(saltBase64)
require(salt.size >= 8) { "Ungültiges Salt" }
return passwordKeys(password, salt, iterations)
}
fun normalizeRecoveryKey(value: String): String = value.uppercase()
.replace('0', 'O')
.replace('1', 'I')
.filter { it in 'A'..'Z' || it in '2'..'7' }
suspend fun recoveryKeys(email: String, recoveryKey: String): RecoveryKeys {
val master = pbkdf2(
normalizeRecoveryKey(recoveryKey),
"kalender-recovery|${normalizeEmail(email)}".encodeToByteArray(),
RECOVERY_ITERATIONS,
)
return RecoveryKeys(
recoveryMaster = master,
wrapKey = hkdf(master, zeroSalt, "rec-wrap-v1".encodeToByteArray()),
recoveryAuth = hkdf(master, zeroSalt, "rec-auth-v1".encodeToByteArray()).toHex(),
)
}
/** 160 random bits encoded exactly like the browser's one-time recovery key. */
fun randomRecoveryKey(): String {
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
val bytes = ByteArray(20).also(SecureRandom()::nextBytes)
val output = StringBuilder(32)
var bits = 0
var value = 0
bytes.forEach { byte ->
value = (value shl 8) or (byte.toInt() and 0xff)
bits += 8
while (bits >= 5) {
output.append(alphabet[(value ushr (bits - 5)) and 31])
bits -= 5
}
}
if (bits > 0) output.append(alphabet[(value shl (5 - bits)) and 31])
return output.toString()
}
fun groupedRecoveryKey(value: String): String = normalizeRecoveryKey(value).chunked(4).joinToString("-")
/** Stable, collision-resistant client-generated event id. */
fun randomEventId(): String = java.util.UUID.randomUUID().toString()
/** Fresh random 32-byte AES-256 key for a new owned calendar. */
fun randomAesKey(): ByteArray = ByteArray(32).also(SecureRandom()::nextBytes)
/**
* The envelope for key wrappers, the local vault, exports and sharing — everything whose
* place is already fixed by the structure that holds it. Synchronized calendar objects
* deliberately do **not** use it; they use [encryptCalendarObject], which authenticates
* where the object belongs. There is no `v2:` writer any more: that prefix only ever
* marked which key applied and left the ciphertext relocatable.
*/
fun encryptEnvelope(
key: ByteArray,
plaintextJson: String,
additionalData: ByteArray? = null,
): String {
require(key.size == 32) { "AES key must contain 32 bytes" }
val iv = ByteArray(12).also(SecureRandom()::nextBytes)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv))
additionalData?.let(cipher::updateAAD)
val encrypted = cipher.doFinal(plaintextJson.encodeToByteArray())
return "${iv.toBase64()}.${encrypted.toBase64()}"
}
/**
* Opens the plain envelope. A leading `v2:` is stripped so pre-v3 calendar blocks stay
* readable for migration; nothing writes that prefix any more.
*
* Parsing is strict and shared with the browser and iPhone: exactly two segments,
* 12-byte IV, ciphertext at least 16 bytes (GCM tag), standard Base64 only in its
* canonical alphabet and padding. After decoding, the segments are re-encoded and any
* deviation from the raw string is rejected so clients cannot disagree on the same wire
* bytes.
*/
fun decryptEnvelope(key: ByteArray, envelope: String, additionalData: ByteArray? = null): String {
require(key.size == 32) { "AES key must contain 32 bytes" }
val (iv, ciphertext) = parseAesGcmEnvelope(envelope)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv))
additionalData?.let(cipher::updateAAD)
return cipher.doFinal(ciphertext).decodeToString()
}
/**
* The only current calendar-object writer. Its public v3 header is AES-GCM
* AAD, so the server cannot move a valid ciphertext to another account,
* calendar, bucket, revision, or predecessor chain.
*/
fun encryptCalendarObject(key: ByteArray, plaintextJson: String, context: CalendarObjectContext): String {
val aad = calendarObjectAad(context)
val body = encryptEnvelope(key, plaintextJson, additionalData = aad.encodeToByteArray())
return CALENDAR_OBJECT_PREFIX + aad.encodeToByteArray().toBase64Url() + "." + body
}
/** Extracts the v3 context header from a v3 calendar envelope; returns null for legacy envelopes. */
fun calendarObjectHeader(envelope: String): CalendarObjectContext? {
if (!envelope.startsWith(CALENDAR_OBJECT_PREFIX)) return null
return runCatching { splitCalendarObject(envelope).context }.getOrNull()
}
/** Opens v3 with an exact expected context; v2/unprefixed input is legacy-only. */
fun decryptCalendarObject(
key: ByteArray,
envelope: String,
expectedContext: CalendarObjectContext,
): DecryptedCalendarObject {
if (!envelope.startsWith(CALENDAR_OBJECT_PREFIX)) {
return DecryptedCalendarObject(true, null, decryptEnvelope(key, envelope))
}
val split = splitCalendarObject(envelope)
if (split.context != expectedContext) {
/* Weicht ausschließlich die Schlüssel-Epoche ab, ist der Block echt und
unversehrt — uns fehlt bloß sein Schlüssel. Nur dieser Fall darf in
Quarantäne gehen; jede andere Abweichung bleibt ein harter Fehler,
weil sie einen verschobenen Block bedeuten kann. */
if (split.context == expectedContext.copy(keyEpoch = split.context.keyEpoch))
throw KeyEpochMismatchException(split.context.keyEpoch)
/* Weicht ausschließlich die Revision ab, ist der Block ebenfalls echt und steht an
seinem Platz — offen ist nur, welcher Stand er ist. Auch das wird gesondert
gemeldet, damit der Abruf ihn zusammenführen und die Kette neu schließen kann,
statt endgültig stehen zu bleiben. Weichen Epoche und Revision zusammen ab oder
irgendein anderes Feld, bleibt es beim harten Fehler. */
if (split.context == expectedContext.copy(revision = split.context.revision))
throw RevisionMismatchException(split.context.revision)
throw IllegalArgumentException("Calendar envelope does not belong to this block")
}
return DecryptedCalendarObject(
legacy = false,
context = split.context,
plaintext = decryptEnvelope(key, split.body, split.aad.encodeToByteArray()),
)
}
fun calendarKeyEpoch(key: ByteArray): String =
java.security.MessageDigest.getInstance("SHA-256").digest(key).toBase64Url()
/**
* Predecessor commitment: SHA-256 of the complete canonical wire string.
* Because the parser accepts only canonical envelopes, the hashed string is uniquely
* determined by the authenticated structure — not by a permissive raw split.
*/
fun calendarBlobCommitment(blob: String): String =
java.security.MessageDigest.getInstance("SHA-256").digest(blob.encodeToByteArray()).toBase64Url()
private data class SplitCalendarObject(
val context: CalendarObjectContext,
val aad: String,
val body: String,
)
/** `v3:` + Base64url(AAD) + `.` + Base64(IV) + `.` + Base64(CT||Tag) — exactly three segments. */
private fun splitCalendarObject(envelope: String): SplitCalendarObject {
val rest = envelope.removePrefix(CALENDAR_OBJECT_PREFIX)
val parts = rest.split('.')
require(parts.size == 3 && parts[0].isNotEmpty()) { "Invalid v3 calendar envelope" }
val aad = decodeStrictBase64Url(parts[0]).decodeToString()
val context = parseCalendarObjectAad(aad)
// Parsed header must re-serialize to the exact AAD bytes that were authenticated.
require(calendarObjectAad(context) == aad) { "Invalid v3 calendar envelope" }
return SplitCalendarObject(context = context, aad = aad, body = parts[1] + "." + parts[2])
}
private fun parseAesGcmEnvelope(envelope: String): Pair<ByteArray, ByteArray> {
val body = envelope.removePrefix("v2:")
val parts = body.split('.')
require(parts.size == 2) { "Invalid encrypted envelope" }
val iv = decodeStrictBase64(parts[0])
require(iv.size == 12) { "Invalid AES-GCM IV" }
val ciphertext = decodeStrictBase64(parts[1])
require(ciphertext.size >= 16) { "Invalid AES-GCM ciphertext" }
return iv to ciphertext
}
private fun decodeStrictBase64(text: String): ByteArray {
require(text.isNotEmpty() && text.length % 4 == 0 && text.matches(STRICT_B64_STD)) {
"Invalid base64"
}
val decoded = try {
Base64.getDecoder().decode(text)
} catch (_: IllegalArgumentException) {
throw IllegalArgumentException("Invalid base64")
}
require(Base64.getEncoder().encodeToString(decoded) == text) { "Non-canonical base64" }
return decoded
}
private fun decodeStrictBase64Url(text: String): ByteArray {
require(text.isNotEmpty() && text.matches(STRICT_B64_URL)) { "Invalid base64url" }
val decoded = try {
Base64.getUrlDecoder().decode(text)
} catch (_: IllegalArgumentException) {
throw IllegalArgumentException("Invalid base64url")
}
require(decoded.toBase64Url() == text) { "Non-canonical base64url" }
return decoded
}
private fun calendarObjectAad(context: CalendarObjectContext): String {
require(context.revision >= 1) { "Calendar object revision must be positive" }
return Json.encodeToString(JsonArray(listOf(
JsonPrimitive("private-calendar-object-v3"), JsonPrimitive(CALENDAR_OBJECT_PROTOCOL),
JsonPrimitive(context.accountId), JsonPrimitive(context.calendarId), JsonPrimitive(context.objectType),
JsonPrimitive(context.bucket), JsonPrimitive(context.objectId), JsonPrimitive(context.keyEpoch),
JsonPrimitive(context.revision), JsonPrimitive(context.previousCommitment),
)))
}
private fun parseCalendarObjectAad(aad: String): CalendarObjectContext {
val values = Json.parseToJsonElement(aad).jsonArray
require(values.size == 10 && values[0].jsonPrimitive.content == "private-calendar-object-v3" &&
values[1].jsonPrimitive.int == CALENDAR_OBJECT_PROTOCOL) { "Invalid v3 calendar header" }
val revision = values[8].jsonPrimitive.long
require(revision >= 1) { "Invalid v3 calendar revision" }
return CalendarObjectContext(
accountId = values[2].jsonPrimitive.content, calendarId = values[3].jsonPrimitive.content,
objectType = values[4].jsonPrimitive.content, bucket = values[5].jsonPrimitive.content,
objectId = values[6].jsonPrimitive.content, keyEpoch = values[7].jsonPrimitive.content,
revision = revision, previousCommitment = values[9].jsonPrimitive.content,
)
}
private suspend fun pbkdf2(password: String, salt: ByteArray, iterations: Int): ByteArray =
withContext(Dispatchers.Default) {
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
.generateSecret(PBEKeySpec(password.toCharArray(), salt, iterations, 256))
.encoded
}
private fun hkdf(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int = 32): ByteArray {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(salt, "HmacSHA256"))
val prk = mac.doFinal(ikm)
val output = ByteArray(length)
var previous = ByteArray(0)
var offset = 0
var counter = 1
while (offset < length) {
mac.init(SecretKeySpec(prk, "HmacSHA256"))
mac.update(previous)
mac.update(info)
mac.update(counter.toByte())
previous = mac.doFinal()
val count = minOf(previous.size, length - offset)
previous.copyInto(output, offset, 0, count)
offset += count
counter++
}
return output
}
fun ByteArray.toBase64(): String = Base64.getEncoder().encodeToString(this)
private fun ByteArray.toBase64Url(): String = Base64.getUrlEncoder().withoutPadding().encodeToString(this)
fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
}android/ExportCrypto.kt — ExportCrypto
// SPDX-FileCopyrightText: 2026 Private Calendar
// SPDX-License-Identifier: Apache-2.0
package de.privatecalendar.app.crypto
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/** Password-encrypted export envelope shared with the web client (`kalender-export` v1). */
object ExportCrypto {
const val ITERATIONS = 600_000
/**
* Grenzen für die aus der DATEI gelesene Rundenzahl.
*
* Die Datei stammt nicht zwangsläufig von uns – sie kommt aus dem Dateiwähler und kann
* beliebig manipuliert sein. Ohne Obergrenze genügt ein `"iterations": 2000000000`, um die
* Ableitung praktisch nie zurückkehren zu lassen: der Import bliebe hängen und ein Kern
* liefe dauerhaft heiß. Die Untergrenze verhindert das Gegenteil, nämlich ein Archiv, das
* sich als „verschlüsselt" ausgibt, dessen Passwort aber in Sekunden durchprobiert wäre.
*/
private const val MIN_ITERATIONS = 100_000
private const val MAX_ITERATIONS = 2_000_000
private const val KEY_BITS = 256
private const val TAG_BITS = 128
private const val FORMAT = "kalender-export"
private val prettyJson = Json { prettyPrint = true }
private val random = SecureRandom()
fun encrypt(
plaintext: String,
contentType: String,
originalFilename: String,
password: String,
): String {
require(password.isNotEmpty()) { "Exportpasswort fehlt" }
val salt = ByteArray(16).also(random::nextBytes)
val iv = ByteArray(12).also(random::nextBytes)
val additionalData = "kalender-export-v1|$contentType"
val passwordChars = password.toCharArray()
val keySpec = PBEKeySpec(passwordChars, salt, ITERATIONS, KEY_BITS)
val keyBytes = try {
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(keySpec).encoded
} finally {
keySpec.clearPassword()
passwordChars.fill('\u0000')
}
return try {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyBytes, "AES"), GCMParameterSpec(TAG_BITS, iv))
cipher.updateAAD(additionalData.encodeToByteArray())
val ciphertext = cipher.doFinal(plaintext.encodeToByteArray())
val base64 = Base64.getEncoder()
prettyJson.encodeToString(
JsonObject.serializer(),
JsonObject(
mapOf(
"format" to JsonPrimitive(FORMAT),
"version" to JsonPrimitive(1),
"encrypted" to JsonPrimitive(true),
"originalFilename" to JsonPrimitive(originalFilename),
"contentType" to JsonPrimitive(contentType),
"contentEncoding" to JsonPrimitive("utf-8"),
"kdf" to JsonObject(
mapOf(
"name" to JsonPrimitive("PBKDF2"),
"hash" to JsonPrimitive("SHA-256"),
"iterations" to JsonPrimitive(ITERATIONS),
"salt" to JsonPrimitive(base64.encodeToString(salt)),
),
),
"cipher" to JsonObject(
mapOf(
"name" to JsonPrimitive("AES-GCM"),
"keyLength" to JsonPrimitive(KEY_BITS),
"iv" to JsonPrimitive(base64.encodeToString(iv)),
"additionalData" to JsonPrimitive(additionalData),
),
),
"ciphertext" to JsonPrimitive(base64.encodeToString(ciphertext)),
),
),
)
} finally {
keyBytes.fill(0)
salt.fill(0)
iv.fill(0)
}
}
/** The decrypted export: original plaintext plus the declared content type. */
data class DecryptedExport(val plaintext: String, val contentType: String, val originalFilename: String)
/** True when [text] looks like a `kalender-export` password envelope. */
fun isEncryptedExport(text: String): Boolean = runCatching {
val root = Json.parseToJsonElement(text).jsonObject
root["format"]?.jsonPrimitive?.contentOrNull == FORMAT
}.getOrDefault(false)
/**
* Opens an envelope produced by [encrypt] (Android or web client). Throws on a wrong
* password or tampered payload; callers translate that into user feedback.
*/
fun decrypt(text: String, password: String): DecryptedExport {
require(password.isNotEmpty()) { "Passwort fehlt" }
val root = Json.parseToJsonElement(text).jsonObject
require(root["format"]?.jsonPrimitive?.contentOrNull == FORMAT) { "Kein verschlüsselter Kalender-Export" }
val kdf = root["kdf"]?.jsonObject ?: error("KDF-Angaben fehlen")
val cipherInfo = root["cipher"]?.jsonObject ?: error("Cipher-Angaben fehlen")
val decoder = Base64.getDecoder()
val salt = decoder.decode(kdf["salt"]?.jsonPrimitive?.contentOrNull ?: error("Salt fehlt"))
val iterations = kdf["iterations"]?.jsonPrimitive?.intOrNull ?: ITERATIONS
require(iterations in MIN_ITERATIONS..MAX_ITERATIONS) { "Unzulässige Anzahl KDF-Runden" }
val iv = decoder.decode(cipherInfo["iv"]?.jsonPrimitive?.contentOrNull ?: error("IV fehlt"))
val contentType = root["contentType"]?.jsonPrimitive?.contentOrNull ?: "application/json"
val additionalData = cipherInfo["additionalData"]?.jsonPrimitive?.contentOrNull
?: "kalender-export-v1|$contentType"
val ciphertext = decoder.decode(root["ciphertext"]?.jsonPrimitive?.contentOrNull ?: error("Ciphertext fehlt"))
val passwordChars = password.toCharArray()
val keySpec = PBEKeySpec(passwordChars, salt, iterations, KEY_BITS)
val keyBytes = try {
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(keySpec).encoded
} finally {
keySpec.clearPassword()
passwordChars.fill('\u0000')
}
return try {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(keyBytes, "AES"), GCMParameterSpec(TAG_BITS, iv))
cipher.updateAAD(additionalData.encodeToByteArray())
DecryptedExport(
plaintext = cipher.doFinal(ciphertext).decodeToString(),
contentType = contentType,
originalFilename = root["originalFilename"]?.jsonPrimitive?.contentOrNull ?: "export",
)
} finally {
keyBytes.fill(0)
}
}
}android/LocalDatabaseKeys.kt — LocalDatabaseKeys
// SPDX-FileCopyrightText: 2026 Private Calendar
// SPDX-License-Identifier: Apache-2.0
package de.privatecalendar.app.crypto
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Per-installation root material for local database encryption.
*
* The random root is wrapped independently by two [KeystoreSecretStore] instances. Independent
* SQLCipher passphrases are derived for the durable encrypted-block cache and the rebuildable
* search index, so copying either database file never exposes the other one or the root material.
*/
class LocalDatabaseKeys(
private val secrets: SecretStore,
private val recoverySecrets: SecretStore? = null,
) {
private data class RootState(val bytes: ByteArray, val created: Boolean)
private val rootState: RootState by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
val rootWasStored = secrets.contains(ROOT_SECRET_NAME)
val stored = decodedRoot(secrets)
val recoveryWasStored = recoverySecrets?.contains(ROOT_SECRET_NAME) == true
val recovered = recoverySecrets?.let(::decodedRoot)
if (stored != null && recovered != null && !MessageDigest.isEqual(stored, recovered)) {
stored.fill(0)
recovered.fill(0)
throw LocalDatabaseKeyUnavailableException("Lokale Datenbankschlüssel stimmen nicht überein")
}
if (stored != null && recovered != null) recovered.fill(0)
when {
stored != null -> {
RootState(stored, false)
}
recovered != null -> {
// The independent wrapper proves the exact root. Repairing the primary wrapper
// can therefore retain the SQLCipher cache and its dirty rows without guessing or
// replacing key material. If an OEM Keystore remains unavailable, this start can
// still proceed through the recovery alias.
val encoded = Base64.getEncoder().encodeToString(recovered)
runCatching { secrets.restoreFromRecovery(ROOT_SECRET_NAME, encoded) }
RootState(recovered, false)
}
rootWasStored || recoveryWasStored -> {
// Fail closed. Overwriting a temporarily unreadable wrapped root would make the
// SQLCipher cache and its potentially dirty offline rows permanently inaccessible.
throw LocalDatabaseKeyUnavailableException("Lokaler Datenbankschlüssel ist derzeit nicht verfügbar")
}
else -> {
val generated = ByteArray(KEY_BYTES).also(SecureRandom()::nextBytes)
val encoded = Base64.getEncoder().encodeToString(generated)
secrets.put(ROOT_SECRET_NAME, encoded)
// The primary commit above is the authority. Keep startup available if an OEM
// Keystore temporarily refuses creation of the independent recovery alias.
runCatching { recoverySecrets?.put(ROOT_SECRET_NAME, encoded) }
RootState(generated, true)
}
}
}
/** True only when this process had to create new root material. */
val wasCreated: Boolean get() = rootState.created
/**
* SQLCipher raw-key form for the durable block cache.
*
* The derived material is a full-entropy 32-byte HKDF output, not a password. SQLCipher's
* password stretching (PBKDF2-HMAC-SHA512, 256,000 iterations by default) exists to make
* guessing a low-entropy human secret expensive; applied to a uniformly random 256-bit key
* it adds start-up cost without adding strength. The raw-key form passes those bytes
* straight through and is exactly as strong as the key itself.
*/
fun cacheRawKey(): ByteArray = withDerived(CACHE_INFO, ::rawKeySpec)
/**
* The same key in the passphrase form earlier versions used. It is needed only to open a
* database written by such a version once, in order to re-encrypt it under the raw key.
*/
fun cacheLegacyPassphrase(): ByteArray = withDerived(CACHE_INFO, ::legacyPassphrase)
/**
* Raw-key form for the search index. That index is a rebuildable projection of the encrypted
* blocks, so a file left behind in the passphrase form is discarded and built again rather
* than re-encrypted.
*/
fun searchRawKey(): ByteArray = withDerived(SEARCH_INFO, ::rawKeySpec)
/** Derives, hands the raw key to [use], and wipes it before returning [use]'s result. */
private fun withDerived(info: String, use: (ByteArray) -> ByteArray): ByteArray {
val key = derive(info)
return use(key).also { key.fill(0) }
}
/**
* Adds or repairs the independent wrapper after the caller has verified the primary-derived
* passphrase against the existing SQLCipher database. A backup must never make startup less
* available, so a temporarily failing secondary Keystore is retried on the next process start.
*/
fun ensureRecoveryCopy(): Boolean {
val recovery = recoverySecrets ?: return true
val root = rootState.bytes
val existing = decodedRoot(recovery)
if (existing != null) {
val matches = MessageDigest.isEqual(root, existing)
existing.fill(0)
if (!matches) return false
return true
}
val encoded = Base64.getEncoder().encodeToString(root)
return runCatching {
recovery.restoreFromRecovery(ROOT_SECRET_NAME, encoded)
}.isSuccess
}
private fun decodedRoot(store: SecretStore): ByteArray? {
val decoded = store.get(ROOT_SECRET_NAME)?.let { encoded ->
runCatching { Base64.getDecoder().decode(encoded) }.getOrNull()
} ?: return null
if (decoded.size == KEY_BYTES) return decoded
decoded.fill(0)
return null
}
/** HKDF output as SQLCipher's `x'<hex>'` raw-key literal, which suppresses its KDF. */
private fun rawKeySpec(key: ByteArray): ByteArray {
// Built byte by byte rather than through a String: an immutable String would leave a
// copy of the key on the heap that cannot be wiped.
val spec = ByteArray(key.size * 2 + 3)
spec[0] = 'x'.code.toByte()
spec[1] = '\''.code.toByte()
var index = 2
for (byte in key) {
val value = byte.toInt()
spec[index++] = HEX_DIGITS[(value shr 4) and 0xF]
spec[index++] = HEX_DIGITS[value and 0xF]
}
spec[index] = '\''.code.toByte()
return spec
}
private fun legacyPassphrase(key: ByteArray): ByteArray =
Base64.getEncoder().withoutPadding().encode(key)
private fun derive(info: String): ByteArray {
val salt = MessageDigest.getInstance("SHA-256").digest(HKDF_SALT.encodeToByteArray())
val extract = Mac.getInstance(HMAC).apply {
init(SecretKeySpec(salt, HMAC))
}.doFinal(rootState.bytes)
val expanded = Mac.getInstance(HMAC).run {
init(SecretKeySpec(extract, HMAC))
doFinal(info.encodeToByteArray() + byteArrayOf(1)).copyOf(KEY_BYTES)
}
extract.fill(0)
salt.fill(0)
// The raw 32 bytes; both the raw-key spec and the legacy passphrase are shapes of these
// exact bytes, so a database written by either representation stays readable.
return expanded
}
private companion object {
val HEX_DIGITS = "0123456789abcdef".toByteArray(Charsets.US_ASCII)
const val ROOT_SECRET_NAME = "local_database_root_v1"
const val KEY_BYTES = 32
const val HMAC = "HmacSHA256"
const val HKDF_SALT = "private-calendar-local-database-hkdf-v1"
const val CACHE_INFO = "private-calendar-cache-sqlcipher-v1"
const val SEARCH_INFO = "private-calendar-search-sqlcipher-v1"
}
}
class LocalDatabaseKeyUnavailableException(message: String) : IllegalStateException(message)
/** A confirmed integrity failure of the local database; the file is left in place. */
class LocalDatabaseCorruptedException(message: String) : IllegalStateException(message)android/SecretStore.kt — SecretStore
// SPDX-FileCopyrightText: 2026 Private Calendar
// SPDX-License-Identifier: Apache-2.0
package de.privatecalendar.app.crypto
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
interface SecretStore {
fun put(name: String, value: String)
fun get(name: String): String?
fun contains(name: String): Boolean
fun remove(name: String)
/**
* Replaces an unreadable value only after the caller recovered the exact plaintext from an
* independently protected source. Test stores can use a normal write.
*/
fun restoreFromRecovery(name: String, value: String) {
put(name, value)
}
}
class KeystoreSecretStore(
context: Context,
preferencesName: String = DEFAULT_PREFERENCES_NAME,
private val keyAlias: String = DEFAULT_KEY_ALIAS,
) : SecretStore {
private val preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
override fun put(name: String, value: String) {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, key())
val encrypted = cipher.doFinal(value.encodeToByteArray())
val encoded = Base64.getEncoder().encodeToString(cipher.iv + encrypted)
// Secrets that immediately protect durable state (notably the local database root key)
// must be on disk before the caller creates ciphertext that depends on them.
check(preferences.edit().putString(name, encoded).commit()) { "Geräteschlüssel konnte nicht gespeichert werden" }
}
override fun get(name: String): String? {
val encoded = preferences.getString(name, null) ?: return null
return runCatching {
val bytes = Base64.getDecoder().decode(encoded)
require(bytes.size > 28) { "Invalid encrypted preference" }
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(0, 12)))
cipher.doFinal(bytes.copyOfRange(12, bytes.size)).decodeToString()
}.getOrElse {
// Treat an unreadable value as unavailable, but retain it. In particular, a transient
// Android Keystore failure must never masquerade as a missing local-database root and
// cause the durable encrypted offline queue to be replaced.
null
}
}
override fun contains(name: String): Boolean = preferences.contains(name)
override fun remove(name: String) {
// Wie beim Schreiben synchron: Ein Abmelden löscht Sitzungstoken und Hauptschlüssel und
// räumt unmittelbar danach die verschlüsselte lokale Datenbank ab. Mit `apply()` lag die
// Löschung dieser Werte noch in der Warteschlange, während die Daten schon weg waren —
// ein harter Prozessabbruch dazwischen ließ den umschlossenen Hauptschlüssel auf der
// Platte zurück, obwohl die Nutzerin abgemeldet hatte.
//
// Anders als beim Schreiben wird ein fehlgeschlagener Commit hier nicht geworfen: Löschen
// steht an Stellen, die bereits Wirkung hatten (Passwortwechsel, Abmelden), und ein
// Abbruch mittendrin ließe schlechteren Zustand zurück als der nicht entfernte Wert.
preferences.edit().remove(name).commit()
}
override fun restoreFromRecovery(name: String, value: String) {
try {
// A damaged preference value does not require replacing the shared Keystore alias.
put(name, value)
return
} catch (_: Exception) {
// If the alias itself is permanently unreadable, every preference protected by it is
// already unavailable. The database root remains safe in the independent recovery
// store, so atomically clear the unusable wrappers before creating a fresh alias.
}
check(preferences.edit().clear().commit()) {
"Nicht lesbare Geräteschlüssel konnten nicht zurückgesetzt werden"
}
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
if (store.containsAlias(keyAlias)) store.deleteEntry(keyAlias)
// The cached handle refers to the entry just deleted; the next read must fetch the
// replacement rather than keep using something that no longer exists.
synchronized(this) { cachedKey = null }
put(name, value)
}
/**
* Loading the `AndroidKeyStore` provider and looking the alias up is an IPC round trip to
* the platform's keystore service, and it used to happen on every single read and write.
* Startup performs several of those before anything is drawn. The handle refers to key
* material that never leaves the security hardware, so holding it for the life of the
* process changes nothing about where the key lives — only how often we ask for it.
*/
@Volatile private var cachedKey: SecretKey? = null
private fun key(): SecretKey {
cachedKey?.let { return it }
return synchronized(this) {
cachedKey ?: loadOrCreateKey().also { cachedKey = it }
}
}
private fun loadOrCreateKey(): SecretKey {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(store.getKey(keyAlias, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
init(
KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build(),
)
generateKey()
}
}
private companion object {
const val DEFAULT_PREFERENCES_NAME = "device_secrets"
const val DEFAULT_KEY_ALIAS = "private_calendar_device_secrets_v1"
const val TRANSFORMATION = "AES/GCM/NoPadding"
}
}android/SharingCrypto.kt — SharingCrypto
// SPDX-FileCopyrightText: 2026 Private Calendar
// SPDX-License-Identifier: Apache-2.0
package de.privatecalendar.app.crypto
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.PrivateKey
import java.security.PublicKey
import java.security.spec.ECGenParameterSpec
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import java.util.Base64
import javax.crypto.KeyAgreement
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
data class SharingIdentity(
val publicKeyBase64: String,
val privateKeyBase64: String,
val privateKey: PrivateKey,
val fingerprint: String,
)
/** Browser-compatible P-256 ECDH sharing helpers plus authenticated share-v2 envelopes. */
object SharingCrypto {
private const val SHARE_PREFIX = "share-v2:"
fun generateIdentity(): SharingIdentity {
val generator = KeyPairGenerator.getInstance("EC")
generator.initialize(ECGenParameterSpec("secp256r1"))
val pair = generator.generateKeyPair()
val publicBase64 = Base64.getEncoder().encodeToString(pair.public.encoded)
return SharingIdentity(
publicKeyBase64 = publicBase64,
privateKeyBase64 = Base64.getEncoder().encodeToString(pair.private.encoded),
privateKey = pair.private,
fingerprint = fingerprint(publicBase64),
)
}
fun importIdentity(publicKeyBase64: String, privateKeyBase64: String): SharingIdentity {
val privateKey = KeyFactory.getInstance("EC").generatePrivate(
PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyBase64)),
)
val publicKey = importPublicKey(publicKeyBase64)
require(keyPairMatches(privateKey, publicKey)) { "Freigabeschlüsselpaar stimmt nicht überein" }
return SharingIdentity(
publicKeyBase64 = publicKeyBase64,
privateKeyBase64 = privateKeyBase64,
privateKey = privateKey,
fingerprint = fingerprint(publicKeyBase64),
)
}
fun fingerprint(publicKeyBase64: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(Base64.getDecoder().decode(publicKeyBase64))
return digest.copyOfRange(0, 16).joinToString("") { "%02X".format(it) }
.chunked(4).joinToString("-")
}
fun normalizeFingerprint(value: String): String? {
val compact = value.uppercase().filter(Char::isLetterOrDigit)
if (!compact.matches(Regex("^[0-9A-F]{32}$"))) return null
return compact.chunked(4).joinToString("-")
}
fun deriveWrapKey(privateKey: PrivateKey, otherPublicKeyBase64: String): ByteArray =
hkdfSha256(ecdhSecret(privateKey, otherPublicKeyBase64), "share-v1".encodeToByteArray())
/**
* Das rohe ECDH-Geheimnis, noch ohne Verwendungszweck. Freigaben leiten daraus
* `share-v1` ab, Buchungsanfragen `booking-request-v1` ([BookingCrypto]) — dieselbe
* Kurve, getrennte Schlüssel, damit ein Umschlag nicht im anderen Zusammenhang gilt.
*/
fun ecdhSecret(privateKey: PrivateKey, otherPublicKeyBase64: String): ByteArray {
val agreement = KeyAgreement.getInstance("ECDH")
agreement.init(privateKey)
agreement.doPhase(importPublicKey(otherPublicKeyBase64), true)
return agreement.generateSecret()
}
fun encryptShare(
wrapKey: ByteArray,
plaintextJson: String,
ownerFingerprint: String,
recipientFingerprint: String,
ownerEmail: String,
recipientEmail: String,
calendarId: String,
role: String,
): String = SHARE_PREFIX + CalendarCrypto.encryptEnvelope(
wrapKey,
plaintextJson,
additionalData = shareAad(
ownerFingerprint, recipientFingerprint, ownerEmail, recipientEmail, calendarId, role,
),
)
fun decryptShare(
wrapKey: ByteArray,
envelope: String,
ownerFingerprint: String,
recipientFingerprint: String,
ownerEmail: String,
recipientEmail: String,
calendarId: String,
role: String,
): Pair<String, Boolean> {
require(envelope.startsWith(SHARE_PREFIX)) {
"Legacy-Freigabeumschläge ohne share-v2: werden nicht mehr unterstützt"
}
val plaintext = CalendarCrypto.decryptEnvelope(
wrapKey,
envelope.removePrefix(SHARE_PREFIX),
shareAad(ownerFingerprint, recipientFingerprint, ownerEmail, recipientEmail, calendarId, role),
)
return plaintext to true
}
private fun shareAad(
ownerFingerprint: String,
recipientFingerprint: String,
ownerEmail: String,
recipientEmail: String,
calendarId: String,
role: String,
): ByteArray = listOf(
"private-calendar-share-v2",
normalizeFingerprint(ownerFingerprint)?.replace("-", "")
?: error("Ungültiger Eigentümer-Sicherheitscode"),
normalizeFingerprint(recipientFingerprint)?.replace("-", "")
?: error("Ungültiger Empfänger-Sicherheitscode"),
CalendarCrypto.normalizeEmail(ownerEmail),
CalendarCrypto.normalizeEmail(recipientEmail),
calendarId,
role,
).joinToString("\n").encodeToByteArray()
private fun importPublicKey(encoded: String): PublicKey = KeyFactory.getInstance("EC").generatePublic(
X509EncodedKeySpec(Base64.getDecoder().decode(encoded)),
)
/** Proves that a server-returned public key corresponds to the master-key-wrapped private key. */
private fun keyPairMatches(privateKey: PrivateKey, publicKey: PublicKey): Boolean {
val generator = KeyPairGenerator.getInstance("EC")
generator.initialize(ECGenParameterSpec("secp256r1"))
val probe = generator.generateKeyPair()
val left = KeyAgreement.getInstance("ECDH").run {
init(privateKey)
doPhase(probe.public, true)
generateSecret()
}
val right = KeyAgreement.getInstance("ECDH").run {
init(probe.private)
doPhase(publicKey, true)
generateSecret()
}
return MessageDigest.isEqual(left, right)
}
private fun hkdfSha256(input: ByteArray, info: ByteArray): ByteArray {
val salt = ByteArray(32)
val extract = Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(salt, "HmacSHA256"))
doFinal(input)
}
return Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(extract, "HmacSHA256"))
update(info)
update(1.toByte())
doFinal()
}
}
}ios/BiometricLock.swift — BiometricLock
// SPDX-FileCopyrightText: 2026 Private Calendar
// SPDX-License-Identifier: Apache-2.0
import Foundation
import LocalAuthentication
/// App-lock policy, ported from Android's `BiometricUnlock` so both clients relock after the
/// same grace periods and print the same German labels.
enum AppLockPolicy {
static let defaultTimeoutMinutes = 1
static let timeoutChoices = [1, 5, 15, 30, 60, 120]
static func normalizedTimeoutMinutes(_ minutes: Int) -> Int {
timeoutChoices.contains(minutes) ? minutes : defaultTimeoutMinutes
}
static func timeoutSeconds(_ minutes: Int) -> TimeInterval {
TimeInterval(normalizedTimeoutMinutes(minutes) * 60)
}
/// ContinuousClock includes time spent asleep and ignores civil-clock changes.
static func shouldRelockAfterBackground(backgroundedAt: ContinuousClock.Instant, now: ContinuousClock.Instant, timeoutMinutes: Int) -> Bool {
backgroundedAt.duration(to: now) >= .seconds(timeoutSeconds(timeoutMinutes))
}
static func timeoutLabel(_ minutes: Int) -> String {
switch normalizedTimeoutMinutes(minutes) {
case 1: return "1 Minute"
case 60: return "1 Stunde"
case 120: return "2 Stunden"
default: return "\(normalizedTimeoutMinutes(minutes)) Minuten"
}
}
}
enum BiometricError: Error, Equatable {
case unavailable(String)
case cancelled
case lockedOut
case failed(String)
}
/// Face ID / Touch ID gate for the encrypted calendar.
///
/// Deliberately `.deviceOwnerAuthenticationWithBiometrics`, matching Android's
/// `BIOMETRIC_STRONG`: the app lock is a second factor on top of the device passcode, so
/// falling back to that same passcode would make it decorative. If biometrics are unavailable
/// the lock cannot be enabled in the first place, so nobody can be shut out of their calendar.
@MainActor
final class BiometricLock {
/// True when this device can satisfy the lock at all.
static func isAvailable() -> Bool {
let context = LAContext()
var error: NSError?
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
}
/// The German name of what the device offers, for settings copy.
static func biometryName() -> String {
let context = LAContext()
var error: NSError?
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
switch context.biometryType {
case .faceID: return "Face ID"
case .touchID: return "Touch ID"
case .opticID: return "Optic ID"
default: return "Biometrie"
}
}
func authenticate(reason: String = "Kalender entsperren", cancelTitle: String = "Abbrechen") async throws {
let context = LAContext()
context.localizedCancelTitle = cancelTitle
var probe: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &probe) else {
throw BiometricError.unavailable(Self.message(for: probe))
}
do {
let success = try await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason)
guard success else { throw BiometricError.failed("Biometrie wurde nicht erkannt. Bitte versuche es erneut.") }
} catch let error as LAError {
switch error.code {
case .userCancel, .appCancel, .systemCancel:
throw BiometricError.cancelled
case .biometryLockout:
throw BiometricError.lockedOut
default:
throw BiometricError.failed(Self.message(for: error as NSError))
}
}
}
private static func message(for error: NSError?) -> String {
guard let error = error as? LAError else {
return "Biometrische Entsperrung ist nicht verfügbar."
}
switch error.code {
case .biometryNotEnrolled:
return "Für dieses Gerät ist keine Biometrie eingerichtet."
case .biometryNotAvailable:
return "Dieses Gerät bietet keine biometrische Entsperrung."
case .biometryLockout:
return "Biometrie ist vorübergehend gesperrt. Entsperre zuerst dein Gerät."
case .userCancel, .appCancel, .systemCancel:
return "Biometrische Entsperrung wurde abgebrochen."
default:
return "Biometrie wurde nicht erkannt. Bitte versuche es erneut."
}
}
}ios/BookingCrypto.swift — BookingCrypto
// SPDX-FileCopyrightText: 2026 Private Calendar
// SPDX-License-Identifier: Apache-2.0
import Foundation
import CryptoKit
/// Booking links, browser-compatible — the counterpart is `www/crypto.js`.
///
/// A link is `/t/<token>#k=<secret>`: 16 random bytes as the public handle, 32 as the key,
/// both URL-safe Base64 without padding. Browsers never send the fragment, so the server holds
/// ciphertext it cannot open. That is the whole design and nothing may weaken it.
///
/// Three envelopes, each bound to exactly its place by its AAD:
/// - Offer (`…-config-v1|<token>`) under the link key: free slots, title, duration.
/// - Request (`…-request-v1|<token>|<epk>|<slotTag>`) under an ECDH secret from the requester's
/// ephemeral key pair and the owner's sharing key — only the owner can read it, not even
/// someone else holding the link.
/// - Reply (`…-reply-v1|<token>|<requestId>`) again under the link key, because the request's
/// ephemeral key pair no longer exists by then.
///
/// The AAD strings and the derivation are wire format: a deviation makes every link created in
/// the browser unreadable on the phone (and the other way round).
enum BookingCrypto {
private static let zeroSalt = Data(repeating: 0, count: 32)
/// The server compares slot tags as opaque 32-character strings.
static let slotTagLength = 32
static func newToken() -> String { base64Url(CalendarCrypto.random(16)) }
static func newSecret() -> String { base64Url(CalendarCrypto.random(32)) }
/// The link key is the raw value behind `#k=`, not derived from it.
static func linkKey(secret: String) throws -> Data {
guard let raw = decodeBase64Url(secret), raw.count == 32 else { throw CryptoError.invalidKey }
return raw
}
static func encryptConfig(linkKey: Data, token: String, configJson: String) throws -> String {
try CalendarCrypto.encryptEnvelope(key: linkKey, plaintextJson: configJson, additionalData: configAad(token))
}
static func decryptConfig(linkKey: Data, token: String, blob: String) throws -> String {
try CalendarCrypto.decryptEnvelopeString(key: linkKey, envelope: blob, additionalData: configAad(token))
}
static func encryptReply(linkKey: Data, token: String, requestId: String, replyJson: String) throws -> String {
try CalendarCrypto.encryptEnvelope(key: linkKey, plaintextJson: replyJson, additionalData: replyAad(token, requestId))
}
static func decryptReply(linkKey: Data, token: String, requestId: String, blob: String) throws -> String {
try CalendarCrypto.decryptEnvelopeString(key: linkKey, envelope: blob, additionalData: replyAad(token, requestId))
}
/// Decrypts an incoming request with the account's own sharing private key (the same one
/// used for calendar shares) and the requester's ephemeral public key.
static func decryptRequest(
privateKey: P256.KeyAgreement.PrivateKey,
token: String,
ephemeralPublicKey: String,
slotTag: String,
blob: String
) throws -> String {
let key = try sharedKey(privateKey: privateKey, ephemeralPublicKey: ephemeralPublicKey)
return try CalendarCrypto.decryptEnvelopeString(
key: key,
envelope: blob,
additionalData: requestAad(token, ephemeralPublicKey, slotTag)
)
}
/// Short value for a point in time. The server can tell that two requests mean the same
/// slot without learning the slot: without the link key the value is neither reversible
/// nor comparable across links.
static func slotTag(secret: String, token: String, slotStart: String) throws -> String {
guard let key = decodeBase64Url(secret) else { throw CryptoError.invalidKey }
let message = Data("private-calendar-booking-slot-v1|\(token)|\(slotStart)".utf8)
let mac = HMAC<SHA256>.authenticationCode(for: message, using: SymmetricKey(data: key))
return String(Data(mac).hexString.prefix(slotTagLength))
}
private static func sharedKey(privateKey: P256.KeyAgreement.PrivateKey, ephemeralPublicKey: String) throws -> Data {
let secret = try SharingCrypto.ecdhSecret(privateKey: privateKey, otherPublicKeyBase64: ephemeralPublicKey)
return CalendarCrypto.hkdf(input: secret, salt: zeroSalt, info: Data("booking-request-v1".utf8))
}
private static func configAad(_ token: String) -> Data {
Data("private-calendar-booking-config-v1|\(token)".utf8)
}
private static func requestAad(_ token: String, _ epk: String, _ slotTag: String) -> Data {
Data("private-calendar-booking-request-v1|\(token)|\(epk)|\(slotTag)".utf8)
}
private static func replyAad(_ token: String, _ requestId: String) -> Data {
Data("private-calendar-booking-reply-v1|\(token)|\(requestId)".utf8)
}
static func base64Url(_ bytes: Data) -> String {
bytes.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
/// The browser strips the padding; the strict decoder does not. Restore it before decoding.
static func decodeBase64Url(_ value: String) -> Data? {
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let padding = (4 - normalized.count % 4) % 4
return Data(base64Encoded: normalized + String(repeating: "=", count: padding))
}
}ios/CryptoPrimitives.swift — CryptoPrimitives
// SPDX-FileCopyrightText: 2026 Private Calendar
// SPDX-License-Identifier: Apache-2.0
import Foundation
import CryptoKit
enum CryptoError: Error, Equatable {
case malformedCiphertext
case invalidKey
case authenticationFailed
case invalidAccountKeyMaterial
case malformedCalendarObject
case calendarObjectContextMismatch
}
/// Protocol-compatible crypto for the existing zero-knowledge service.
///
/// Every constant here is fixed by `www/crypto.js` and mirrored by Android's `CalendarCrypto`;
/// changing one silently locks users out of their own data, so each is covered by a published
/// test vector.
enum CalendarCrypto {
/// Current OWASP recommendation for PBKDF2-HMAC-SHA256 (password → enc/auth).
static let passwordIterations = 600_000
/// Pre-migration accounts used email salt + 310_000 rounds.
static let passwordIterationsLegacy = 310_000
static let passwordSaltLength = 16
/// Recovery-key derivation keeps the fixed email salt and legacy iteration count.
private static let recoveryIterations = passwordIterationsLegacy
private static let zeroSalt = Data(repeating: 0, count: 32)
/// The recovery key alphabet: Crockford-style base32 without the ambiguous 0/1/8/9.
static let recoveryAlphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
struct PasswordKeys: Sendable, Equatable {
let passwordMaster: Data
/// The key that unwraps `passwordBlob`; equals the master key only when no
/// `passwordBlob` exists (i.e. the password was never changed).
let encryptionKey: Data
let authPass: String
let salt: Data
let iterations: Int
var saltBase64: String { salt.base64EncodedString() }
}
struct RecoveryKeys: Sendable, Equatable {
let recoveryMaster: Data
let wrapKey: Data
let recoveryAuth: String
}
// MARK: - Randomness
/// Cryptographically secure random bytes. `SystemRandomNumberGenerator` is backed by
/// `arc4random_buf` on Apple platforms, which is the same CSPRNG `SecRandomCopyBytes` uses.
static func random(_ count: Int) -> Data {
var generator = SystemRandomNumberGenerator()
var bytes = Data(count: count)
for index in 0..<count { bytes[index] = UInt8.random(in: .min ... .max, using: &generator) }
return bytes
}
/// Fresh random 32-byte AES-256 key for a new owned calendar.
static func randomAesKey() -> Data { random(32) }
/// Stable, collision-resistant client-generated event id.
static func randomEventId() -> String { UUID().uuidString }
/// 160 random bits encoded exactly like the browser's one-time recovery key (32 chars).
static func randomRecoveryKey() -> String {
let bytes = random(20)
var output = ""
var bits = 0
var value = 0
for byte in bytes {
value = (value << 8) | Int(byte)
bits += 8
while bits >= 5 {
output.append(recoveryAlphabet[(value >> (bits - 5)) & 31])
bits -= 5
}
}
if bits > 0 { output.append(recoveryAlphabet[(value << (5 - bits)) & 31]) }
return output
}
/// `XXXX-XXXX-…` grouping used wherever a recovery key is shown to the user, matching
/// Android's `groupedRecoveryKey`. The hyphens are also the only points at which the
/// 32-character key can be broken across lines: ungrouped it is one unbreakable word that no
/// phone-width card can lay out. `normalizeRecoveryKey` strips the separators again, so a key
/// re-entered or pasted in either form validates.
static func groupedRecoveryKey(_ value: String) -> String {
let normalized = Array(normalizeRecoveryKey(value))
return stride(from: 0, to: normalized.count, by: 4)
.map { String(normalized[$0..<min($0 + 4, normalized.count)]) }
.joined(separator: "-")
}
// MARK: - Hashes and derivation
static func sha256(_ input: Data) -> Data { Data(SHA256.hash(data: input)) }
static func hkdf(input: Data, salt: Data, info: Data, count: Int = 32) -> Data {
HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: input),
salt: salt,
info: info,
outputByteCount: count
).withUnsafeBytes { Data($0) }
}
static func normalizeEmail(_ email: String) -> String {
email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
static func randomPasswordKdfSalt() -> Data { random(passwordSaltLength) }
static func legacyPasswordKdfSalt(email: String) -> Data {
Data("kalender|\(normalizeEmail(email))".utf8)
}
static func passwordKeys(password: String, salt: Data, iterations: Int) -> PasswordKeys {
precondition(salt.count >= 8 && iterations >= 100_000)
let master = pbkdf2(
password: Data(password.utf8),
salt: salt,
iterations: iterations,
length: 32
)
return PasswordKeys(
passwordMaster: master,
encryptionKey: hkdf(input: master, salt: zeroSalt, info: Data("enc-v1".utf8)),
authPass: hkdf(input: master, salt: zeroSalt, info: Data("auth-v1".utf8)).hexString,
salt: salt,
iterations: iterations
)
}
/// Current standard: random 16-byte salt, 600_000 iterations.
static func passwordKeysModern(password: String, salt: Data? = nil) -> PasswordKeys {
passwordKeys(password: password, salt: salt ?? randomPasswordKdfSalt(), iterations: passwordIterations)
}
/// Legacy email-salt derivation (migration + published test vectors).
static func passwordKeysLegacy(email: String, password: String) -> PasswordKeys {
passwordKeys(
password: password,
salt: legacyPasswordKdfSalt(email: email),
iterations: passwordIterationsLegacy
)
}
static func passwordKeysFromServer(password: String, saltBase64: String, iterations: Int) throws -> PasswordKeys {
guard let salt = Data(base64Encoded: saltBase64), salt.count >= 8 else {
throw CryptoError.invalidAccountKeyMaterial
}
return passwordKeys(password: password, salt: salt, iterations: iterations)
}
/// Matches `normRecoveryKey` in `www/crypto.js`: uppercase, fold 0→O and 1→I, drop
/// everything outside the base32 alphabet.
static func normalizeRecoveryKey(_ value: String) -> String {
String(value.uppercased().map { char in
switch char {
case "0": return Character("O")
case "1": return Character("I")
default: return char
}
}.filter { ("A"..."Z").contains($0) || ("2"..."7").contains($0) })
}
static func recoveryKeys(email: String, recoveryKey: String) -> RecoveryKeys {
let master = pbkdf2(
password: Data(normalizeRecoveryKey(recoveryKey).utf8),
salt: Data("kalender-recovery|\(normalizeEmail(email))".utf8),
iterations: recoveryIterations,
length: 32
)
return RecoveryKeys(
recoveryMaster: master,
wrapKey: hkdf(input: master, salt: zeroSalt, info: Data("rec-wrap-v1".utf8)),
recoveryAuth: hkdf(input: master, salt: zeroSalt, info: Data("rec-auth-v1".utf8)).hexString
)
}
/// Shared client-side password policy. The server deliberately receives only a verifier and
/// therefore cannot assess password strength itself; keeping this beside the derivation
/// prevents registration, password change and account recovery from drifting apart.
static func passwordPolicyError(_ password: String) -> String? {
// The special-character class spells out JavaScript's `\s`; ICU's `\s` lacks U+000B and U+FEFF.
// UTF-16 units like `password.length` in the browser and Kotlin's `String.length`:
// `count` counts an emoji as one character, so a password could pass here and fail there.
if password.utf16.count < 12 { return "Das Passwort braucht mindestens 12 Zeichen." }
if password.range(of: "[a-zäöüß]", options: .regularExpression) == nil {
return "Das Passwort braucht mindestens einen Kleinbuchstaben."
}
if password.range(of: "[A-ZÄÖÜ]", options: .regularExpression) == nil {
return "Das Passwort braucht mindestens einen Großbuchstaben."
}
if password.range(of: "[0-9]", options: .regularExpression) == nil {
return "Das Passwort braucht mindestens eine Zahl."
}
if password.range(of: "[^A-Za-zÄÖÜäöüß0-9\\t\\n\\u000B\\f\\r \\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000\\uFEFF]", options: .regularExpression) == nil {
return "Das Passwort braucht mindestens ein Sonderzeichen."
}
return nil
}
// MARK: - The wire envelope
/// Browser/Android AES envelope: `Base64(12-byte IV) "." Base64(ciphertext||tag)`.
///
/// This is the envelope for key wrappers, the local vault, exports and sharing — everything
/// whose place is already fixed by the structure that holds it. Synchronized calendar
/// objects deliberately do **not** use it; they use `encryptCalendarObject` below, which
/// authenticates where the object belongs. There is no `v2:` writer any more: that prefix
/// only ever marked which key applied and left the ciphertext relocatable.
static func encryptEnvelope(key: Data, plaintext: Data, additionalData: Data? = nil) throws -> String {
guard key.count == 32 else { throw CryptoError.invalidKey }
let nonce = AES.GCM.Nonce()
let sealed = try AES.GCM.seal(
plaintext,
using: SymmetricKey(data: key),
nonce: nonce,
authenticating: additionalData ?? Data()
)
let body = sealed.ciphertext + sealed.tag
return Data(nonce).base64EncodedString() + "." + body.base64EncodedString()
}
static func encryptEnvelope(key: Data, plaintextJson: String, additionalData: Data? = nil) throws -> String {
try encryptEnvelope(key: key, plaintext: Data(plaintextJson.utf8), additionalData: additionalData)
}
/// Opens the plain envelope. A leading `v2:` is stripped so pre-v3 calendar blocks stay
/// readable for migration; nothing writes that prefix any more.
///
/// Parsing is strict and shared with the browser and Android: exactly two segments,
/// 12-byte IV, ciphertext at least 16 bytes (GCM tag), standard Base64 only in its
/// canonical alphabet and padding. After decoding, the segments are re-encoded and any
/// deviation from the raw string is rejected so clients cannot disagree on the same wire
/// bytes.
static func decryptEnvelope(key: Data, envelope: String, additionalData: Data? = nil) throws -> Data {
guard key.count == 32 else { throw CryptoError.invalidKey }
let parsed = try parseAesGcmEnvelope(envelope)
let ciphertext = parsed.ciphertext.prefix(parsed.ciphertext.count - 16)
let tag = parsed.ciphertext.suffix(16)
do {
let box = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: parsed.iv), ciphertext: ciphertext, tag: tag)
return try AES.GCM.open(box, using: SymmetricKey(data: key), authenticating: additionalData ?? Data())
} catch {
throw CryptoError.authenticationFailed
}
}
static func decryptEnvelopeString(key: Data, envelope: String, additionalData: Data? = nil) throws -> String {
String(decoding: try decryptEnvelope(key: key, envelope: envelope, additionalData: additionalData), as: UTF8.self)
}
/// Moves an opaque envelope to a new local master key without exposing its plaintext beyond
/// this process. Used by account recovery for `_meta` and the wrapped sharing private key.
static func reencryptEnvelope(_ envelope: String?, from oldKey: Data, to newKey: Data) throws -> String? {
guard let envelope, !envelope.isEmpty else { return nil }
return try encryptEnvelope(key: newKey, plaintext: decryptEnvelope(key: oldKey, envelope: envelope))
}
// MARK: - Calendar objects (v3)
//
// Calendar blocks and `_meta` are a separate, versioned envelope. `v2:` was only ever a key
// marker: its GCM tag bound neither the object's place nor its server revision, so a valid
// ciphertext could be replayed into another calendar, bucket or revision. The v3 header is
// itself the AES-GCM AAD — altering it invalidates the tag — and carries only routing data
// the server already knows, never calendar content.
//
// Byte-for-byte the same wire format as `www/crypto.js` and Android's `CalendarCrypto`.
static let calendarObjectProtocol = 3
static let calendarObjectPrefix = "v3:"
/// The canonical, authenticated routing context of one synchronized calendar object.
struct CalendarObjectContext: Sendable, Equatable {
var accountId: String
var calendarId: String
var objectType: String
var bucket: String
var objectId: String
var keyEpoch: String
var revision: Int64
var previousCommitment: String
}
struct DecryptedCalendarObject: Sendable, Equatable {
/// True for a pre-v3 envelope, which is readable for migration only.
var legacy: Bool
var context: CalendarObjectContext?
var plaintext: Data
}
/// The only current calendar-object writer.
static func encryptCalendarObject(key: Data, plaintext: Data, context: CalendarObjectContext) throws -> String {
let aad = try calendarObjectAad(context)
let body = try encryptEnvelope(key: key, plaintext: plaintext, additionalData: Data(aad.utf8))
return calendarObjectPrefix + base64url(Data(aad.utf8)) + "." + body
}
static func encryptCalendarObject(key: Data, plaintextJson: String, context: CalendarObjectContext) throws -> String {
try encryptCalendarObject(key: key, plaintext: Data(plaintextJson.utf8), context: context)
}
static func isCalendarObject(_ envelope: String) -> Bool { envelope.hasPrefix(calendarObjectPrefix) }
/// Reads the public v3 header without the key; nil for a legacy envelope or a malformed head.
static func calendarObjectHeader(_ envelope: String) -> CalendarObjectContext? {
guard isCalendarObject(envelope) else { return nil }
guard let split = try? splitCalendarObject(envelope) else { return nil }
return try? parseCalendarObjectAad(split.aad)
}
/// Rewraps the account metadata during password recovery. Current metadata is a v3
/// calendar object, not a legacy two-part envelope, so recovery must authenticate its
/// complete routing context before writing the next revision under the new master key.
static func reencryptRecoveryMeta(
_ envelope: String?,
accountId: String,
baseVersion: Int64,
from oldKey: Data,
to newKey: Data
) throws -> String? {
guard let envelope else { return nil }
let plaintext: Data
if isCalendarObject(envelope) {
guard let context = calendarObjectHeader(envelope),
context.accountId == accountId,
context.calendarId == "_",
context.objectType == "meta",
context.bucket == "_meta",
context.objectId == "\(accountId)/_meta",
context.keyEpoch == calendarKeyEpoch(oldKey),
context.revision == baseVersion
else { throw CryptoError.calendarObjectContextMismatch }
plaintext = try decryptCalendarObject(
key: oldKey,
envelope: envelope,
expectedContext: context
).plaintext
} else {
plaintext = try decryptEnvelope(key: oldKey, envelope: envelope)
}
let next = CalendarObjectContext(
accountId: accountId,
calendarId: "_",
objectType: "meta",
bucket: "_meta",
objectId: "\(accountId)/_meta",
keyEpoch: calendarKeyEpoch(newKey),
revision: baseVersion + 1,
previousCommitment: calendarPredecessorCommitment(envelope)
)
return try encryptCalendarObject(key: newKey, plaintext: plaintext, context: next)
}
/// Opens a v3 envelope against an exact expected context. A `v2:`/unprefixed input is
/// reported as legacy and opened without any bound context — the migration path, never a
/// writer.
static func decryptCalendarObject(
key: Data,
envelope: String,
expectedContext: CalendarObjectContext
) throws -> DecryptedCalendarObject {
guard isCalendarObject(envelope) else {
return DecryptedCalendarObject(legacy: true, context: nil, plaintext: try decryptEnvelope(key: key, envelope: envelope))
}
let split = try splitCalendarObject(envelope)
let actual = try parseCalendarObjectAad(split.aad)
guard actual == expectedContext else { throw CryptoError.calendarObjectContextMismatch }
return DecryptedCalendarObject(
legacy: false,
context: actual,
plaintext: try decryptEnvelope(key: key, envelope: split.body, additionalData: Data(split.aad.utf8))
)
}
/// Base64url of `SHA-256(raw key)` — changes whenever a calendar is re-keyed.
static func calendarKeyEpoch(_ key: Data) -> String { base64url(sha256(key)) }
/// Base64url of `SHA-256(previous complete envelope)`, the predecessor commitment.
/// Because the parser accepts only canonical envelopes, the hashed string is uniquely
/// determined by the authenticated structure — not by a permissive raw split.
static func calendarBlobCommitment(_ blob: String) -> String { base64url(sha256(Data(blob.utf8))) }
/// Legacy envelopes are readable migration inputs, but do not belong to a v3 chain.
static func calendarPredecessorCommitment(_ blob: String) -> String {
blob.hasPrefix("v3:") ? calendarBlobCommitment(blob) : ""
}
/// `v3:` + Base64url(AAD) + `.` + Base64(IV) + `.` + Base64(CT||Tag) — exactly three segments.
private static func splitCalendarObject(_ envelope: String) throws -> (aad: String, body: String) {
let rest = String(envelope.dropFirst(calendarObjectPrefix.count))
let parts = rest.split(separator: ".", omittingEmptySubsequences: false).map(String.init)
guard parts.count == 3, !parts[0].isEmpty else { throw CryptoError.malformedCalendarObject }
guard let header = decodeStrictBase64Url(parts[0]) else { throw CryptoError.malformedCalendarObject }
let aad = String(decoding: header, as: UTF8.self)
let context = try parseCalendarObjectAad(aad)
// Parsed header must re-serialize to the exact AAD bytes that were authenticated.
guard try calendarObjectAad(context) == aad else { throw CryptoError.malformedCalendarObject }
return (aad, parts[1] + "." + parts[2])
}
private static func parseAesGcmEnvelope(_ envelope: String) throws -> (iv: Data, ciphertext: Data) {
let body = envelope.hasPrefix("v2:") ? String(envelope.dropFirst(3)) : envelope
let parts = body.split(separator: ".", omittingEmptySubsequences: false).map(String.init)
guard parts.count == 2 else { throw CryptoError.malformedCiphertext }
guard let iv = decodeStrictBase64(parts[0]), iv.count == 12 else { throw CryptoError.malformedCiphertext }
guard let ciphertext = decodeStrictBase64(parts[1]), ciphertext.count >= 16 else { throw CryptoError.malformedCiphertext }
return (iv, ciphertext)
}
private static func decodeStrictBase64(_ text: String) -> Data? {
guard !text.isEmpty, text.count % 4 == 0, text.range(of: "^[A-Za-z0-9+/]+={0,2}$", options: .regularExpression) != nil else {
return nil
}
guard let decoded = Data(base64Encoded: text) else { return nil }
guard decoded.base64EncodedString() == text else { return nil }
return decoded
}
private static func decodeStrictBase64Url(_ text: String) -> Data? {
guard !text.isEmpty, text.range(of: "^[A-Za-z0-9_-]+$", options: .regularExpression) != nil else {
return nil
}
guard let decoded = unbase64url(text) else { return nil }
guard base64url(decoded) == text else { return nil }
return decoded
}
/// The AAD is a JSON *array*, not an object: the field order is canonical by construction
/// and cannot depend on how any one JSON implementation orders keys.
private static func calendarObjectAad(_ context: CalendarObjectContext) throws -> String {
guard context.revision >= 1 else { throw CryptoError.malformedCalendarObject }
let fields = [
jsonStringLiteral("private-calendar-object-v3"),
String(calendarObjectProtocol),
jsonStringLiteral(context.accountId),
jsonStringLiteral(context.calendarId),
jsonStringLiteral(context.objectType),
jsonStringLiteral(context.bucket),
jsonStringLiteral(context.objectId),
jsonStringLiteral(context.keyEpoch),
String(context.revision),
jsonStringLiteral(context.previousCommitment),
]
return "[" + fields.joined(separator: ",") + "]"
}
private static func parseCalendarObjectAad(_ aad: String) throws -> CalendarObjectContext {
guard let values = try? CalendarJSON.decode([JSONValue].self, from: Data(aad.utf8)),
values.count == 10,
values[0].stringValue == "private-calendar-object-v3",
values[1].intValue == calendarObjectProtocol,
let accountId = values[2].stringValue,
let calendarId = values[3].stringValue,
let objectType = values[4].stringValue,
let bucket = values[5].stringValue,
let objectId = values[6].stringValue,
let keyEpoch = values[7].stringValue,
case .int(let revision) = values[8], revision >= 1,
let previousCommitment = values[9].stringValue
else { throw CryptoError.malformedCalendarObject }
return CalendarObjectContext(
accountId: accountId, calendarId: calendarId, objectType: objectType, bucket: bucket,
objectId: objectId, keyEpoch: keyEpoch, revision: revision, previousCommitment: previousCommitment
)
}
/// A JSON string literal exactly as `JSON.stringify` writes one: escape `"`, `\` and the
/// control characters, leave every other character as literal UTF-8. Written out rather
/// than delegated because the AAD has to be reproducible byte for byte on every platform,
/// and `objectId` contains a `/`, which some JSON encoders escape and others do not.
private static func jsonStringLiteral(_ value: String) -> String {
var out = "\""
for scalar in value.unicodeScalars {
switch scalar {
case "\"": out += "\\\""
case "\\": out += "\\\\"
case "\u{08}": out += "\\b"
case "\u{0C}": out += "\\f"
case "\n": out += "\\n"
case "\r": out += "\\r"
case "\t": out += "\\t"
default:
if scalar.value < 0x20 { out += String(format: "\\u%04x", scalar.value) }
else { out.unicodeScalars.append(scalar) }
}
}
return out + "\""
}
static func base64url(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
static func unbase64url(_ text: String) -> Data? {
var padded = text.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
padded += String(repeating: "=", count: (4 - padded.count % 4) % 4)
return Data(base64Encoded: padded)
}
// MARK: - Account master key
/// Resolves the account's master key from a login response.
///
/// `passwordBlob` is **not** the raw key: `www/crypto.js` (`localPasswordBlob`) stores
/// `{"enc": "<base64 32 bytes>"}` encrypted with the password-derived key, so that a
/// password change re-wraps the same master key instead of re-encrypting every block.
/// Treating the decrypted bytes as the key — as a naive reading suggests — makes every
/// block undecryptable for any account that has ever changed its password.
/// When no blob exists, the derived key *is* the master key.
static func resolveMasterKey(passwordBlob: String?, encryptionKey: Data) throws -> Data {
guard let passwordBlob, !passwordBlob.isEmpty else { return encryptionKey }
let plaintext = try decryptEnvelope(key: encryptionKey, envelope: passwordBlob)
guard let wrapped = try? CalendarJSON.decode([String: JSONValue].self, from: plaintext),
let encoded = wrapped["enc"]?.stringValue,
let master = Data(base64Encoded: encoded),
master.count == 32
else { throw CryptoError.invalidAccountKeyMaterial }
return master
}
/// Builds the `passwordBlob` for a password change: the master key wrapped with the key
/// derived from the *new* password.
static func makePasswordBlob(masterKey: Data, encryptionKey: Data) throws -> String {
let payload: [String: JSONValue] = ["enc": .string(masterKey.base64EncodedString())]
return try encryptEnvelope(key: encryptionKey, plaintext: CalendarJSON.encode(payload))
}
// MARK: - PBKDF2
/// PBKDF2-HMAC-SHA256. Kept local rather than calling CommonCrypto so the browser's
/// iteration counts and published test vectors can be reproduced exactly, and so the
/// implementation stays visible next to the constants it depends on.
static func pbkdf2(password: Data, salt: Data, iterations: Int, length: Int) -> Data {
precondition(iterations > 0 && length > 0)
var result = Data()
let blocks = Int(ceil(Double(length) / 32.0))
for index in 1...blocks {
var counter = UInt32(index).bigEndian
var block = Data(salt)
withUnsafeBytes(of: &counter) { block.append(contentsOf: $0) }
var u = hmac(key: password, message: block)
var output = u
if iterations > 1 {
for _ in 2...iterations {
u = hmac(key: password, message: u)
output = xor(output, u)
}
}
result.append(output)
}
return result.prefix(length)
}
private static func hmac(key: Data, message: Data) -> Data {
Data(HMAC<SHA256>.authenticationCode(for: message, using: SymmetricKey(data: key)))
}
private static func xor(_ lhs: Data, _ rhs: Data) -> Data { Data(zip(lhs, rhs).map(^)) }
/// Constant-time comparison for values derived from secrets.
static func constantTimeEquals(_ lhs: Data, _ rhs: Data) -> Bool {
guard lhs.count == rhs.count else { return false }
var difference: UInt8 = 0
for (a, b) in zip(lhs, rhs) { difference |= a ^ b }
return difference == 0
}
}
extension Data {
var hexString: String { map { String(format: "%02x", $0) }.joined() }
/// Parses an even-length hex string; nil on any non-hex character.
init?(hexString: String) {
let characters = Array(hexString)
guard characters.count % 2 == 0 else { return nil }
var bytes = Data(capacity: characters.count / 2)
for index in stride(from: 0, to: characters.count, by: 2) {
guard let byte = UInt8(String(characters[index...index + 1]), radix: 16) else { return nil }
bytes.append(byte)
}
self = bytes
}
}ios/ExportCrypto.swift — ExportCrypto
// SPDX-FileCopyrightText: 2026 Private Calendar
// SPDX-License-Identifier: Apache-2.0
import Foundation
import CryptoKit
/// Password-encrypted export envelope shared with the web client (`kalender-export` v1).
enum ExportCrypto {
static let iterations = 600_000
static let format = "kalender-export"
/// Bounds for the iteration count *read from the file*.
///
/// The file does not necessarily come from us — it arrives through the document picker and
/// may be manipulated arbitrarily. Without an upper bound an `"iterations": 2000000000`
/// would make the derivation effectively never return: the import would hang with a core
/// pinned. The lower bound prevents the opposite, an archive that claims to be "encrypted"
/// but whose password could be brute-forced in seconds.
static let minimumIterations = 100_000
static let maximumIterations = 2_000_000
private static let keyBytes = 32
enum ExportError: Error, Equatable {
case missingPassword
case notAnEncryptedExport
case malformed(String)
case iterationsOutOfRange
case wrongPasswordOrTampered
}
/// The decrypted export: original plaintext plus the declared content type.
struct DecryptedExport: Equatable, Sendable {
let plaintext: String
let contentType: String
let originalFilename: String
}
/// The on-disk envelope. Declared as a struct so the key order is fixed and matches the
/// Android/web writers rather than depending on dictionary iteration order.
private struct Envelope: Codable {
struct Kdf: Codable {
var name: String
var hash: String
var iterations: Int
var salt: String
}
struct Cipher: Codable {
var name: String
var keyLength: Int
var iv: String
var additionalData: String
}
var format: String
var version: Int
var encrypted: Bool
var originalFilename: String
var contentType: String
var contentEncoding: String
var kdf: Kdf
var cipher: Cipher
var ciphertext: String
}
static func encrypt(plaintext: String, contentType: String, originalFilename: String, password: String) throws -> String {
guard !password.isEmpty else { throw ExportError.missingPassword }
let salt = CalendarCrypto.random(16)
let iv = CalendarCrypto.random(12)
let additionalData = "kalender-export-v1|\(contentType)"
let key = CalendarCrypto.pbkdf2(password: Data(password.utf8), salt: salt, iterations: iterations, length: keyBytes)
let sealed = try AES.GCM.seal(
Data(plaintext.utf8),
using: SymmetricKey(data: key),
nonce: try AES.GCM.Nonce(data: iv),
authenticating: Data(additionalData.utf8)
)
let ciphertext = sealed.ciphertext + sealed.tag
let envelope = Envelope(
format: format,
version: 1,
encrypted: true,
originalFilename: originalFilename,
contentType: contentType,
contentEncoding: "utf-8",
kdf: .init(name: "PBKDF2", hash: "SHA-256", iterations: iterations, salt: salt.base64EncodedString()),
cipher: .init(name: "AES-GCM", keyLength: keyBytes * 8, iv: iv.base64EncodedString(), additionalData: additionalData),
ciphertext: ciphertext.base64EncodedString()
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]
return String(decoding: try encoder.encode(envelope), as: UTF8.self)
}
/// True when `text` looks like a `kalender-export` password envelope.
static func isEncryptedExport(_ text: String) -> Bool {
guard let data = text.data(using: .utf8),
let root = try? CalendarJSON.decode([String: JSONValue].self, from: data)
else { return false }
return root["format"]?.stringValue == format
}
/// Opens an envelope produced by `encrypt` (iOS, Android or web client). Throws on a wrong
/// password or tampered payload; callers translate that into user feedback.
static func decrypt(_ text: String, password: String) throws -> DecryptedExport {
guard !password.isEmpty else { throw ExportError.missingPassword }
guard let data = text.data(using: .utf8),
let root = try? CalendarJSON.decode([String: JSONValue].self, from: data)
else { throw ExportError.malformed("Datei ist kein gültiges JSON") }
guard root["format"]?.stringValue == format else { throw ExportError.notAnEncryptedExport }
guard let kdf = root["kdf"]?.objectValue else { throw ExportError.malformed("KDF-Angaben fehlen") }
guard let cipherInfo = root["cipher"]?.objectValue else { throw ExportError.malformed("Cipher-Angaben fehlen") }
guard let saltText = kdf["salt"]?.stringValue, let salt = Data(base64Encoded: saltText) else {
throw ExportError.malformed("Salt fehlt")
}
let fileIterations = kdf["iterations"]?.intValue ?? iterations
guard (minimumIterations...maximumIterations).contains(fileIterations) else { throw ExportError.iterationsOutOfRange }
guard let ivText = cipherInfo["iv"]?.stringValue, let iv = Data(base64Encoded: ivText), iv.count == 12 else {
throw ExportError.malformed("IV fehlt")
}
let contentType = root["contentType"]?.stringValue ?? "application/json"
let additionalData = cipherInfo["additionalData"]?.stringValue ?? "kalender-export-v1|\(contentType)"
guard let ciphertextText = root["ciphertext"]?.stringValue,
let combined = Data(base64Encoded: ciphertextText), combined.count >= 16
else { throw ExportError.malformed("Ciphertext fehlt") }
let key = CalendarCrypto.pbkdf2(password: Data(password.utf8), salt: salt, iterations: fileIterations, length: keyBytes)
do {
let box = try AES.GCM.SealedBox(
nonce: try AES.GCM.Nonce(data: iv),
ciphertext: combined.prefix(combined.count - 16),
tag: combined.suffix(16)
)
let plaintext = try AES.GCM.open(box, using: SymmetricKey(data: key), authenticating: Data(additionalData.utf8))
return DecryptedExport(
plaintext: String(decoding: plaintext, as: UTF8.self),
contentType: contentType,
originalFilename: root["originalFilename"]?.stringValue ?? "export"
)
} catch {
throw ExportError.wrongPasswordOrTampered
}
}
}ios/KeychainStore.swift — KeychainStore
// SPDX-FileCopyrightText: 2026 Private Calendar
// SPDX-License-Identifier: Apache-2.0
import Foundation
import Security
enum SecureStoreError: Error, Equatable {
case keychain(OSStatus)
case unavailable
}
/// Device-only Keychain access, the iOS counterpart of Android's `KeystoreSecretStore`.
///
/// Everything is stored with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`: readable to
/// background sync after the first unlock, never included in an iCloud or iTunes backup, and
/// never migrated to a new device. Key material that leaves the device is exactly what this
/// product promises does not exist.
///
/// `service` separates the independent stores (primary vs. recovery), so the two wrappers of
/// the installation root are as unrelated here as the two Keystore aliases are on Android.
struct KeychainStore: Sendable {
let service: String
static let primary = KeychainStore(service: "de.privatecalendar.ios.secrets")
static let recovery = KeychainStore(service: "de.privatecalendar.ios.secrets.recovery")
func read(_ account: String) throws -> Data? {
var query = baseQuery(account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else { throw SecureStoreError.keychain(status) }
return data
}
/// True when a record exists, *without* deciding whether it is readable. The distinction
/// matters: a present-but-unreadable record must fail closed rather than be overwritten.
func contains(_ account: String) throws -> Bool {
var query = baseQuery(account)
query[kSecMatchLimit as String] = kSecMatchLimitOne
let status = SecItemCopyMatching(query as CFDictionary, nil)
if status == errSecItemNotFound { return false }
if status == errSecSuccess || status == errSecInteractionNotAllowed { return true }
throw SecureStoreError.keychain(status)
}
func write(_ data: Data, account: String) throws {
let base = baseQuery(account)
let update = SecItemUpdate(base as CFDictionary, [kSecValueData as String: data] as CFDictionary)
if update == errSecSuccess { return }
guard update == errSecItemNotFound else { throw SecureStoreError.keychain(update) }
var create = base
create[kSecValueData as String] = data
create[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(create as CFDictionary, nil)
guard status == errSecSuccess else { throw SecureStoreError.keychain(status) }
}
func remove(_ account: String) throws {
let status = SecItemDelete(baseQuery(account) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else { throw SecureStoreError.keychain(status) }
}
/// Deletes every record of this store. Used by "Konto abmelden" and by the local wipe.
func removeAll() throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else { throw SecureStoreError.keychain(status) }
}
private func baseQuery(_ account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
}
}ios/LocalDatabaseKeys.swift — LocalDatabaseKeys
// SPDX-FileCopyrightText: 2026 Private Calendar
// SPDX-License-Identifier: Apache-2.0
import Foundation
struct LocalDatabaseKeyUnavailable: Error, Equatable {
let message: String
}
/// Per-installation root material for local database encryption, ported from Android's
/// `LocalDatabaseKeys`.
///
/// The random root is wrapped independently by two Keychain stores. Independent SQLCipher
/// passphrases are derived for the durable encrypted-block cache and for the rebuildable search
/// index, so copying either database file never exposes the other one or the root material.
///
/// The behaviour that matters is failing closed: if a wrapper *exists* but cannot be read right
/// now, this throws rather than generating a fresh root. Overwriting a temporarily unreadable
/// root would make the SQLCipher cache — including its dirty, not-yet-uploaded rows — permanently
/// inaccessible. That is data loss, and it must never be the fallback for a transient error.
final class LocalDatabaseKeys: @unchecked Sendable {
private static let rootSecretName = "local_database_root_v1"
private static let keyBytes = 32
private static let hkdfSalt = "private-calendar-local-database-hkdf-v1"
private static let cacheInfo = "private-calendar-cache-sqlcipher-v1"
private static let searchInfo = "private-calendar-search-sqlcipher-v1"
private let secrets: KeychainStore
private let recoverySecrets: KeychainStore?
private let lock = NSLock()
private var resolved: (bytes: Data, created: Bool)?
init(secrets: KeychainStore = .primary, recoverySecrets: KeychainStore? = .recovery) {
self.secrets = secrets
self.recoverySecrets = recoverySecrets
}
/// True only when this process had to create new root material.
func wasCreated() throws -> Bool { try state().created }
/// ASCII passphrase bytes for the encrypted block cache.
func cachePassphrase() throws -> String { try derive(info: Self.cacheInfo) }
/// ASCII passphrase bytes for the rebuildable search index.
func searchPassphrase() throws -> String { try derive(info: Self.searchInfo) }
/// Adds or repairs the independent wrapper after the caller has verified the primary-derived
/// passphrase against the existing database. A backup must never make startup less available,
/// so a temporarily failing secondary store is retried on the next launch.
@discardableResult
func ensureRecoveryCopy() throws -> Bool {
guard let recoverySecrets else { return true }
let root = try state().bytes
if let existing = decodedRoot(from: recoverySecrets) {
return CalendarCrypto.constantTimeEquals(root, existing)
}
do {
try recoverySecrets.write(root, account: Self.rootSecretName)
return true
} catch {
return false
}
}
/// Destroys both wrappers. Only for an explicit local wipe: every encrypted database on the
/// device becomes unreadable afterwards, which is the point.
func destroy() throws {
lock.lock()
resolved = nil
lock.unlock()
try secrets.remove(Self.rootSecretName)
try recoverySecrets?.remove(Self.rootSecretName)
}
private func state() throws -> (bytes: Data, created: Bool) {
lock.lock()
defer { lock.unlock() }
if let resolved { return resolved }
let rootWasStored = (try? secrets.contains(Self.rootSecretName)) ?? false
let stored = decodedRoot(from: secrets)
let recoveryWasStored = recoverySecrets.flatMap { try? $0.contains(Self.rootSecretName) } ?? false
let recovered = recoverySecrets.flatMap { decodedRoot(from: $0) }
if let stored, let recovered, !CalendarCrypto.constantTimeEquals(stored, recovered) {
throw LocalDatabaseKeyUnavailable(message: "Lokale Datenbankschlüssel stimmen nicht überein")
}
let outcome: (bytes: Data, created: Bool)
if let stored {
outcome = (stored, false)
} else if let recovered {
// The independent wrapper proves the exact root, so the SQLCipher cache and its
// dirty rows survive: repair the primary wrapper instead of guessing or replacing.
try? secrets.write(recovered, account: Self.rootSecretName)
outcome = (recovered, false)
} else if rootWasStored || recoveryWasStored {
throw LocalDatabaseKeyUnavailable(message: "Lokaler Datenbankschlüssel ist derzeit nicht verfügbar")
} else {
let generated = CalendarCrypto.random(Self.keyBytes)
try secrets.write(generated, account: Self.rootSecretName)
// The primary commit above is the authority. Keep startup available if the
// secondary store temporarily refuses creation.
try? recoverySecrets?.write(generated, account: Self.rootSecretName)
outcome = (generated, true)
}
resolved = outcome
return outcome
}
private func decodedRoot(from store: KeychainStore) -> Data? {
guard let data = try? store.read(Self.rootSecretName), data.count == Self.keyBytes else { return nil }
return data
}
/// HKDF-SHA256 over the root with a fixed salt, Base64 without padding.
///
/// Each installation's local database is encrypted under its own device-held root and is
/// never read by another device, so this representation is a local matter and need not
/// match the other platform's.
private func derive(info: String) throws -> String {
let root = try state().bytes
let salt = CalendarCrypto.sha256(Data(Self.hkdfSalt.utf8))
let expanded = CalendarCrypto.hkdf(input: root, salt: salt, info: Data(info.utf8), count: Self.keyBytes)
return expanded.base64EncodedString().replacingOccurrences(of: "=", with: "")
}
}ios/SessionStore.swift — SessionStore
// SPDX-FileCopyrightText: 2026 Private Calendar
// SPDX-License-Identifier: Apache-2.0
import Foundation
/// Everything needed to resume a session without asking for the password again. The master key
/// lives here and nowhere else on disk; it never reaches the server in any form.
struct SessionCredentials: Codable, Sendable, Equatable {
let token: String
let email: String
let masterKey: Data
/// The account's opaque user id. It addresses shared calendars, and it is the outermost
/// field of the authenticated v3 calendar-object header — a session without it cannot write.
var userId: String?
/// Client password KDF salt (Base64). Not secret; required for reauthentication after login.
var passwordKdfSalt: String? = nil
var passwordKdfIterations: Int? = nil
}
/// Keychain-backed session state.
///
/// The trusted-device token is stored separately from the session because it outlives it: it is
/// what lets a 2FA account skip the code on the next sign-in, and clearing a session must not
/// silently cost the user that.
actor SessionStore {
private let sessionAccount = "session-v1"
private let trustedAccount = "trusted-token-v1"
private let store: KeychainStore
init(store: KeychainStore = .primary) { self.store = store }
func load() throws -> SessionCredentials? {
guard let data = try store.read(sessionAccount) else { return nil }
return try? CalendarJSON.decode(SessionCredentials.self, from: data)
}
func save(_ credentials: SessionCredentials) throws {
try store.write(try CalendarJSON.encode(credentials), account: sessionAccount)
}
func save(_ credentials: SessionCredentials, trustedToken: String?) throws {
try save(credentials)
if let trustedToken { try store.write(Data(trustedToken.utf8), account: trustedAccount) }
}
func trustedToken() throws -> String? {
try store.read(trustedAccount).flatMap { String(data: $0, encoding: .utf8) }
}
func setTrustedToken(_ token: String?) throws {
if let token {
try store.write(Data(token.utf8), account: trustedAccount)
} else {
try store.remove(trustedAccount)
}
}
/// Ends the session but keeps the trusted-device token, which is what a plain sign-out
/// should do: the next sign-in still skips the second factor on this device.
func clearSession() throws {
try store.remove(sessionAccount)
}
/// Ends the session *and* forgets this device, for "Gerät nicht mehr vertrauen" and for
/// account deletion.
func clearAll() throws {
try store.remove(sessionAccount)
try store.remove(trustedAccount)
}
}ios/SharingCrypto.swift — SharingCrypto
// SPDX-FileCopyrightText: 2026 Private Calendar
// SPDX-License-Identifier: Apache-2.0
import Foundation
import CryptoKit
struct SharingIdentity: Sendable {
let publicKeyBase64: String
let privateKeyBase64: String
let privateKey: P256.KeyAgreement.PrivateKey
let fingerprint: String
}
enum SharingError: Error, Equatable {
case invalidKeyMaterial
case keyPairMismatch
case invalidFingerprint
}
/// Browser-compatible P-256 ECDH sharing helpers plus authenticated `share-v2` envelopes.
///
/// The encodings are wire format: public keys are X.509 SubjectPublicKeyInfo DER and private
/// keys PKCS#8 DER, both Base64 — exactly what `SubtleCrypto.exportKey('spki'|'pkcs8')` in the
/// browser and `Key.getEncoded()` on Android produce. CryptoKit's `derRepresentation` uses the
/// same two encodings, so no hand-rolled ASN.1 is needed.
enum SharingCrypto {
static let sharePrefix = "share-v2:"
static func generateIdentity() -> SharingIdentity {
let privateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false)
let publicBase64 = privateKey.publicKey.derRepresentation.base64EncodedString()
return SharingIdentity(
publicKeyBase64: publicBase64,
privateKeyBase64: privateKey.derRepresentation.base64EncodedString(),
privateKey: privateKey,
fingerprint: fingerprint(publicKeyBase64: publicBase64)
)
}
static func importIdentity(publicKeyBase64: String, privateKeyBase64: String) throws -> SharingIdentity {
guard let privateDer = Data(base64Encoded: privateKeyBase64),
let privateKey = try? P256.KeyAgreement.PrivateKey(derRepresentation: privateDer)
else { throw SharingError.invalidKeyMaterial }
guard let publicDer = Data(base64Encoded: publicKeyBase64),
let publicKey = try? P256.KeyAgreement.PublicKey(derRepresentation: publicDer)
else { throw SharingError.invalidKeyMaterial }
// Proves that a server-returned public key corresponds to the master-key-wrapped
// private key. Deriving the public key from the private one is a stronger check than
// Android's ECDH probe and needs no throwaway key pair.
guard privateKey.publicKey.rawRepresentation == publicKey.rawRepresentation else {
throw SharingError.keyPairMismatch
}
return SharingIdentity(
publicKeyBase64: publicKeyBase64,
privateKeyBase64: privateKeyBase64,
privateKey: privateKey,
fingerprint: fingerprint(publicKeyBase64: publicKeyBase64)
)
}
/// The plaintext that goes *inside* the master-key envelope holding the sharing private key.
///
/// It is a JSON object, never the bare key: the browser (`ensureKeypair` in `www/crypto.js`)
/// and Android (`ensureSharingIdentity` in its `CalendarRepository`) both store
/// `{"p": <pkcs8 base64>, "pub": <spki base64>}`, and the server hands one and the same
/// wrapped blob to every client of the account. `pub` is the account's own record of which
/// public key belongs to this private one, so a server that later returns a different
/// `pubkey` is caught before a single share is opened.
static func encodeWrappedPrivateKey(_ identity: SharingIdentity) throws -> Data {
try Meta.encode([
"p": .string(identity.privateKeyBase64),
"pub": .string(identity.publicKeyBase64),
])
}
/// Reads that plaintext back and pairs it with the public key the server published.
///
/// Builds of this app before 0.1.10 wrote the bare PKCS#8 base64 instead of the object.
/// `PUT /api/keys` never overwrites a published key pair, so those accounts cannot be
/// migrated to the shared shape and are still accepted here — but only when the plaintext
/// is no JSON object at all, so a real object with a missing `p` stays an error.
static func importIdentity(publicKeyBase64: String, wrappedPlaintext: String) throws -> SharingIdentity {
let payload = Meta.parse(wrappedPlaintext)
if case .string(let embedded)? = payload["pub"], embedded != publicKeyBase64 {
throw SharingError.keyPairMismatch
}
let privateKeyBase64: String
if case .string(let value)? = payload["p"] {
privateKeyBase64 = value
} else if payload.isEmpty {
privateKeyBase64 = wrappedPlaintext.trimmingCharacters(in: .whitespacesAndNewlines)
} else {
throw SharingError.invalidKeyMaterial
}
return try importIdentity(publicKeyBase64: publicKeyBase64, privateKeyBase64: privateKeyBase64)
}
/// First 16 bytes of SHA-256 over the DER public key, uppercase hex in groups of four.
static func fingerprint(publicKeyBase64: String) -> String {
guard let der = Data(base64Encoded: publicKeyBase64) else { return "" }
let digest = CalendarCrypto.sha256(der).prefix(16)
let hex = digest.map { String(format: "%02X", $0) }.joined()
return group(hex)
}
/// Accepts a user-typed security code in any spacing/case; nil when it is not 32 hex digits.
static func normalizeFingerprint(_ value: String) -> String? {
let compact = value.uppercased().filter { $0.isLetter || $0.isNumber }
guard compact.count == 32, compact.allSatisfy({ $0.isHexDigit && !$0.isLowercase }) else { return nil }
return group(compact)
}
private static func group(_ hex: String) -> String {
let characters = Array(hex)
return stride(from: 0, to: characters.count, by: 4)
.map { String(characters[$0..<min($0 + 4, characters.count)]) }
.joined(separator: "-")
}
static func deriveWrapKey(privateKey: P256.KeyAgreement.PrivateKey, otherPublicKeyBase64: String) throws -> Data {
let secret = try ecdhSecret(privateKey: privateKey, otherPublicKeyBase64: otherPublicKeyBase64)
return CalendarCrypto.hkdf(input: secret, salt: Data(repeating: 0, count: 32), info: Data("share-v1".utf8))
}
/// The raw ECDH secret, still without a purpose label. Shares derive `share-v1` from it,
/// booking requests `booking-request-v1` (`BookingCrypto`) — same curve, separate keys, so
/// an envelope from one context never validates in the other.
static func ecdhSecret(privateKey: P256.KeyAgreement.PrivateKey, otherPublicKeyBase64: String) throws -> Data {
guard let der = Data(base64Encoded: otherPublicKeyBase64),
let publicKey = try? P256.KeyAgreement.PublicKey(derRepresentation: der)
else { throw SharingError.invalidKeyMaterial }
let shared = try privateKey.sharedSecretFromKeyAgreement(with: publicKey)
return shared.withUnsafeBytes { Data($0) }
}
static func encryptShare(
wrapKey: Data,
plaintextJson: String,
ownerFingerprint: String,
recipientFingerprint: String,
ownerEmail: String,
recipientEmail: String,
calendarId: String,
role: String
) throws -> String {
let aad = try shareAad(
ownerFingerprint: ownerFingerprint,
recipientFingerprint: recipientFingerprint,
ownerEmail: ownerEmail,
recipientEmail: recipientEmail,
calendarId: calendarId,
role: role
)
return sharePrefix + (try CalendarCrypto.encryptEnvelope(key: wrapKey, plaintextJson: plaintextJson, additionalData: aad))
}
/// Returns the plaintext and whether it came from an authenticated `share-v2` envelope.
/// A legacy unprefixed envelope still opens, but the caller must show it as unverified.
static func decryptShare(
wrapKey: Data,
envelope: String,
ownerFingerprint: String,
recipientFingerprint: String,
ownerEmail: String,
recipientEmail: String,
calendarId: String,
role: String
) throws -> (plaintext: String, authenticated: Bool) {
guard envelope.hasPrefix(sharePrefix) else {
return (try CalendarCrypto.decryptEnvelopeString(key: wrapKey, envelope: envelope), false)
}
let aad = try shareAad(
ownerFingerprint: ownerFingerprint,
recipientFingerprint: recipientFingerprint,
ownerEmail: ownerEmail,
recipientEmail: recipientEmail,
calendarId: calendarId,
role: role
)
let plaintext = try CalendarCrypto.decryptEnvelopeString(
key: wrapKey,
envelope: String(envelope.dropFirst(sharePrefix.count)),
additionalData: aad
)
return (plaintext, true)
}
private static func shareAad(
ownerFingerprint: String,
recipientFingerprint: String,
ownerEmail: String,
recipientEmail: String,
calendarId: String,
role: String
) throws -> Data {
guard let owner = normalizeFingerprint(ownerFingerprint)?.replacingOccurrences(of: "-", with: ""),
let recipient = normalizeFingerprint(recipientFingerprint)?.replacingOccurrences(of: "-", with: "")
else { throw SharingError.invalidFingerprint }
let parts = [
"private-calendar-share-v2",
owner,
recipient,
CalendarCrypto.normalizeEmail(ownerEmail),
CalendarCrypto.normalizeEmail(recipientEmail),
calendarId,
role,
]
return Data(parts.joined(separator: "\n").utf8)
}
}offline/kalender-pruefen.html — Kalenderdaten offline prüfen (eigenständige HTML-Datei)
<!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>Was hier steht – und was nicht
Veröffentlicht sind die oben einzeln aufgeführten Dateien mit kryptografischen Bausteinen und gerätegebundenem Schlüsselspeicher. Dazu gehören Passwort- und Authentisierungswert-Ableitung, Ver- und Entschlüsselung von Kalenderblöcken, verschlüsselte Exportdateien und der kryptografische Schlüsseltausch. Die Lizenz und die Prüfung reichen nicht über diese konkrete Dateimenge hinaus.
Zwei Dinge sagen wir dazu offen. Erstens: Die Prüfsumme oben zeigt, dass die ausgelieferte Datei Byte für Byte diese hier ist – sie zeigt nicht, dass wir allen dieselbe Datei ausliefern. Ein Server kann einer einzelnen Person etwas anderes schicken; dagegen hilft nur ein vollständig offener, reproduzierbar baubarer Build, und den haben wir nicht. Zweitens: Was du ohne jedes Vertrauen in Quelltext prüfen kannst, sind deine eigenen Daten – lade deinen Rohdaten-Export herunter und entschlüssele ihn offline. Dieser Test hängt an keinem Code von uns, sondern nur an deiner Datei und deinem Passwort.