Cryptomator Hub – Client- und Server-Spezifikation (Clean-Room-Beschreibung)¶
Stand: 2026-10-13. Grundlage für Phase F (Krywerk als Hub-Client) und für einen eigenen, Hub-kompatiblen Server in Rust. Diese Datei beschreibt ausschließlich Verhalten, Formate, Endpunkte, Algorithmen und Parameter. Sie enthält bewusst keinen kopierten Quellcode (Hub ist AGPL-3.0, Cryptomator-Desktop GPL-3.0). Wer den Server implementiert, soll ausschließlich mit diesem Dokument arbeiten.
Markierungen: - (ungeprüft) – nicht im Code verifiziert, nur plausibel oder aus Doku/Namen abgeleitet. - (beobachtet) – am lokalen Test-Stack (Hub 2.1.2, Keycloak 26.7.5) per HTTP nachgeprüft.
0. Quellen und Versionen¶
| Kürzel | Repository | Stand |
|---|---|---|
| HUB | cryptomator/hub, Tag 2.1.2 (Commit 2887647a) – entspricht dem Test-Stack (/api/version → hubVersion 2.1.2) |
maßgeblich |
| HUB-dev | cryptomator/hub, Branch develop (Commit d7e72f38, Version 2.2.0-SNAPSHOT, apiLevel 5, UVF) |
nur für Abschnitt 13/14 |
| DESK | cryptomator/cryptomator, develop (Commit 6c03aa9c, 1.20.0-SNAPSHOT; Hub-Teil identisch mit 1.19.x ab 1.19.1 (ungeprüft im Detail)) |
maßgeblich für Client |
| T2C | coffeelibs/tiny-oauth2-client Tag 0.8.1 (vom Desktop benutzte OAuth-Bibliothek) |
OIDC-Details |
| CL / CFS | cryptomator/cryptolib (0af261a), cryptomator/cryptofs (9eb7e59) |
Masterkey, vault.cryptomator |
| IOS / AND | cryptomator/ios (047f538), cryptomator/android (0d12781) |
nur überflogen |
Pfadabkürzungen: HUB-API = backend/src/main/java/org/cryptomator/hub/api/,
HUB-FE = frontend/src/, DESK-HUB = src/main/java/org/cryptomator/ui/keyloading/hub/.
1. Überblick und Glossar¶
Hub ist ein Schlüsselverteilungsdienst: Er speichert nur verschlüsselte Schlüssel (JWEs) und Zugriffsrechte. Alle Kryptographie (Masterkey erzeugen, für Nutzer verschlüsseln, entschlüsseln) findet in Clients statt (Browser-Web-UI, Desktop, Mobile). Der Server signiert für Format-8-Tresore nichts.
| Begriff | Bedeutung |
|---|---|
| Vault Key / Masterkey | 64 Byte Zufall = Format-8-Masterkey: Bytes 0–31 = Verschlüsselungsschlüssel (encKey), Bytes 32–63 = MAC-Schlüssel (macKey) (CL Masterkey-Konstruktor; HUB-FE common/crypto.ts VaultKeys.hashDirectoryId bestätigt die Reihenfolge). Gleichzeitig HMAC-Schlüssel für die Signatur von vault.cryptomator. |
| User Key Pair | Pro Nutzer zwei P-384-Schlüsselpaare: ECDH (zum Empfangen von Vault Keys) und ECDSA (Web-of-Trust-Signaturen, seit Hub 1.4). Öffentliche Teile liegen im Klartext auf dem Server, private nur verschlüsselt. (HUB-FE crypto.ts UserKeys) |
| Device Key Pair | P-384-ECDH-Schlüsselpaar pro Gerät (Desktop, Mobile, Browser). Der private User-ECDH-Key wird je Gerät mit ECDH-ES auf den Device Key verschlüsselt. |
| Account Key / Setup Code | Geheimnis des Nutzers (Web-UI erzeugt eine zufällige UUID als String, HUB-FE components/InitialSetup.vue fetchData). Schützt die privaten User Keys per PBES2. In der UI „Account Key“, im API-Feld setupCode. |
| Recovery Key | Masterkey + 2 Byte Prüfsumme, als 44 Wörter kodiert (Abschnitt 11). Nur für Wiederherstellung durch den Owner. |
| Admin Password (legacy) | Hub 1.0–1.2: Vault-Masterkey per PBKDF2-Passwort auf dem Server gespeichert; nur noch für „Claim Ownership“-Migration (Abschnitt 12). |
| Vault ID | UUID (zufällig vom Ersteller erzeugt). Steht als jti im Payload und als letztes Pfadsegment der kid von vault.cryptomator. |
| Hub ID | UUID der Hub-Instanz (settings.hub_id, GET /api/settings → hubId); bindet die Lizenz. Für Clients irrelevant. |
| apiLevel | Ganzzahl in GET /api/config; Clients wählen danach den Unlock-Ablauf (Abschnitt 9). Hub 2.1.2 liefert 4 (HUB-API ConfigResource.getConfig; beobachtet). |
| Access Token (Hub-Sprech) | Nicht das OAuth-Token, sondern der pro (Nutzer, Vault) gespeicherte JWE mit dem Vault Key. Tabelle access_token, Spalte vault_masterkey. |
| Bearer Token | OAuth2/OIDC Access Token von Keycloak, für alle authentifizierten API-Aufrufe. |
Schlüsselhierarchie (Format 8):
Setup Code ──PBES2──► privateKeys-JWE (auf Server) ──► User-ECDH-Privkey (+ ECDSA-Privkey)
Device-Privkey ──ECDH-ES──► userPrivateKey-JWE (pro Gerät, auf Server) ──► User-ECDH-Privkey
User-ECDH-Privkey ──ECDH-ES──► Access-Token-JWE (pro Nutzer+Vault, auf Server) ──► 64-Byte-Masterkey
Masterkey ──HMAC-SHA256──► Signatur von vault.cryptomator (liegt nur im Tresor, nicht auf dem Server)
2. vault.cryptomator eines Hub-Tresors¶
Erzeugt im Browser (HUB-FE common/vaultconfig.ts VaultConfig.create, common/crypto.ts
VaultKeys.createVaultConfig). JWT in Compact Serialization, alle drei Teile base64url ohne Padding.
Header (JSON, Feldreihenfolge wie erzeugt: kid, typ, alg, hub):
| Feld | Wert |
|---|---|
kid |
"hub+" + absoluteApiBaseUrl + "vaults/" + vaultId, z. B. hub+https://hub.example.com/api/vaults/3f1c… (absoluteApiBaseUrl endet auf /api/) |
typ |
"jwt" (klein geschrieben – anders als bei normalen Format-8-Tresoren) |
alg |
"HS256" |
hub |
Objekt HubConfig, siehe unten |
hub-Objekt (HUB-FE crypto.ts Interface VaultConfigHeaderHub; DESK-HUB HubConfig):
| Feld | Inhalt bei Erzeugung | Desktop-Nutzung |
|---|---|---|
clientId |
keycloakClientIdCryptomator aus /api/config (Standard "cryptomator") |
OAuth client_id |
authEndpoint |
keycloakAuthEndpoint aus /api/config |
Authorization Endpoint |
tokenEndpoint |
keycloakTokenEndpoint aus /api/config |
Token Endpoint |
authSuccessUrl |
<origin>/app/unlock-success?vault=<vaultId> |
Browser-Redirect nach Login; Desktop hängt &device=<deviceId> an |
authErrorUrl |
<origin>/app/unlock-error?vault=<vaultId> |
dito bei Fehler |
apiBaseUrl |
<origin>/api/ |
Basis aller API-Aufrufe (nullable; fehlt in Hub-1.0-Tresoren) |
devicesResourceUrl |
<origin>/api/devices/ (veraltet) |
Fallback: API-Basis = devicesResourceUrl + "/.." normalisiert |
Unbekannte Felder werden vom Desktop ignoriert. Payload: jti = Vault-ID (UUID-String),
format = 8, cipherCombo = "SIV_GCM", shorteningThreshold = 220. Kein iat/exp.
Signatur: HMAC-SHA256 über b64url(header) "." b64url(payload) mit dem vollen 64-Byte-Masterkey als
HMAC-Schlüssel (identisch zu normalen Format-8-Tresoren). Der Server kennt den Masterkey nie und kann die
Datei weder erzeugen noch prüfen. Desktop/cryptofs akzeptieren HS256/HS384/HS512 (CFS
VaultConfig.UnverifiedVaultConfig.initAlgorithm).
Parsing im Desktop (CFS VaultConfig.decode, getKeyId, getHeader; DESK-HUB
HubKeyLoadingStrategy, HubKeyLoadingModule.provideHubConfig, ReceiveKeyController.extractVaultId):
1. JWT ungeprüft dekodieren; kid als URI parsen.
2. Key-Loading-Strategie nach URI-Schema: hub+http und hub+https → Hub-Strategie (Präfix hub+
wird geprüft). Andere Schemata (masterkeyfile:) → normale Passwort-Strategie.
3. Vault-ID = Teil des URI-Pfads nach dem letzten /. Host/Pfad der kid werden sonst nicht benutzt;
alle URLs kommen aus dem Header-Objekt hub.
4. Masterkey per Hub holen (Abschnitt 6), danach erst die HMAC-Signatur prüfen. Folge: Der Header ist bis
dahin nicht authentisch – deshalb die Host-Vertrauensprüfung (Abschnitt 6.1, Sicherheitsfix
GHSA-34rf-rwr3-7g43, Desktop 1.19.1).
ZIP-Vorlage (HUB-FE vaultconfig.ts VaultConfig.exportTemplate; Download "<Tresorname>.zip"):
vault.cryptomator (obiges JWT) und ein leerer Ordner d/<XX>/<YYYY…30> für das Stammverzeichnis
(dirIdHash("") wie Format 8). Kein masterkey.cryptomator, keine dirid.c9r. Der Nutzer entpackt das
ZIP in den Cloud-Speicher.
3. Kryptographische Formate¶
3.1 Kurven und Schlüsselkodierungen¶
| Objekt | Kurve | Kodierung | Transport |
|---|---|---|---|
| User ECDH Public Key | P-384 | SPKI DER | Base64 Standard mit Padding (ecdhPublicKey, legacy publicKey) |
| User ECDSA Public Key | P-384 | SPKI DER | Base64 Standard (ecdsaPublicKey) |
| Device Public Key | P-384 | SPKI DER | Base64 Standard (publicKey im Device). Hub-1.x-Legacy: base64url ohne Padding |
| Private Keys (in JWE-Payloads) | P-384 | PKCS#8 DER | Base64 Standard |
| Masterkey (in JWE-Payload) | – | 64 Byte roh | Base64 Standard |
| JWE/JWS/JWT-Teile | – | – | base64url ohne Padding |
Server-Validierung nur per Regex (HUB validation/*): OnlyBase64Chars = Zeichen [+/A-Za-z0-9]
gefolgt von beliebig vielen =; ValidJWE = fünf durch Punkte getrennte base64url-Teile (2. und 3. und 5.
dürfen leer sein); ValidId = Zeichen aus [-_.a-zA-Z0-9] (auch leer); ValidJWS analog dreiteilig
(ungeprüft im Detail).
3.2 JWE-Variante A: ECDH-ES + A256GCM (Direct Key Agreement)¶
Verwendung: Vault Key → Nutzer, User Key → Gerät, Setup Code → Nutzer selbst, Legacy Vault Key → Gerät.
(HUB-FE common/jwe.ts JWEBuilder.ecdhEs, ECDH_ES.deriveContentKey, ConcatKDF.kdf; DESK-HUB
JWEHelper.encryptKey über Nimbus JOSE.)
- Protected Header:
alg = "ECDH-ES",enc = "A256GCM",epk= ephemerer P-384-Public-Key als JWK (kty "EC",crv "P-384",x,y; WebCrypto-Exporte enthalten zusätzlichext/key_ops– Leser müssen unbekannte JWK-Felder tolerieren (ungeprüft, aber funktioniert offensichtlich mit Nimbus)). Web-UI setztapuundapvauf base64url des leeren Strings (also""); Desktop (Nimbus) lässt sie weg. Fehlend und leer sind kryptographisch gleichwertig. - Encrypted Key: leer (zweiter Teil der Compact Serialization ist leer).
- Z = ECDH(ephemer, Empfänger) = x-Koordinate, 48 Byte.
- CEK = ConcatKDF-SHA-256 (NIST SP 800-56A, RFC 7518 §4.6.2):
CEK = SHA256( be32(1) ‖ Z ‖ OtherInfo )[0..32]mitOtherInfo = be32(len("A256GCM")) ‖ "A256GCM" ‖ be32(len(apu)) ‖ apu ‖ be32(len(apv)) ‖ apv ‖ be32(256)(apu/apv = base64url-dekodierte Bytes, hier jeweils Länge 0). Eine Runde genügt (256 Bit). - Inhalt: AES-256-GCM, IV 12 Byte zufällig, Tag 128 Bit, AAD = ASCII-Bytes des base64url-kodierten Protected Headers. Klartext = UTF-8-JSON.
3.3 JWE-Variante B: PBES2-HS512+A256KW + A256GCM¶
Verwendung: private User Keys unter dem Setup Code (privateKeys). (HUB-FE jwe.ts JWEBuilder.pbes2,
PBES2.deriveWrappingKey; DESK-HUB JWEHelper.decryptUserKey(jwe, setupCode).)
- Header:
alg = "PBES2-HS512+A256KW",enc = "A256GCM",p2s= base64url(16 Zufallsbytes),p2c= 1 000 000 (Standard der Web-UI,PBES2.DEFAULT_ITERATION_COUNT),apu/apv="". - KEK = PBKDF2-HMAC-SHA512(Passwort = UTF-8(Setup Code), Salt = UTF-8("PBES2-HS512+A256KW") ‖ 0x00 ‖ p2s-Bytes, Iterationen = p2c, Länge 32 Byte).
- CEK = 32 Byte zufällig; Encrypted Key = AES Key Wrap (RFC 3394) des CEK mit KEK → 40 Byte.
- Inhalt wie oben (AES-256-GCM, AAD = Header-b64url).
- Falscher Setup Code ⇒ Key-Unwrap scheitert (Integritätsprüfung von AES-KW). Desktop zeigt dann
„ungültiger Setup Code“ (DESK-HUB
RegisterDeviceController.setupFailed).
3.4 Payload-Formen¶
| JWE | Variante | Payload-JSON |
|---|---|---|
| Access Token (Vault Key für Nutzer) | A | {"key": "<Base64 der 64 Masterkey-Bytes>"} |
privateKeys (User Keys unter Setup Code) |
B | {"key": "<B64 PKCS8 ECDH>", "ecdhPrivateKey": "<B64 PKCS8 ECDH>", "ecdsaPrivateKey": "<B64 PKCS8 ECDSA>"} – key ist redundant „für Abwärtskompatibilität“ |
userPrivateKey eines Geräts (von Web-UI erzeugt) |
A | wie privateKeys (drei Felder) |
userPrivateKey eines Geräts (vom Desktop erzeugt) |
A | nur {"key": "<B64 PKCS8 ECDH>"} (DESK-HUB JWEHelper.encryptUserKey) |
setupCode (Setup Code für den Nutzer selbst) |
A, Empfänger = eigener User-ECDH-Key | {"setupCode": "<Setup Code>"} |
| Legacy Access Token (Hub 1.x, an Gerät) | A, Empfänger = Device Key | {"key": "<Base64 Masterkey>"} |
Wichtig für Kompatibilität: Desktop (und laut Feldnamen auch iOS/Android) liest bei User Keys
ausschließlich das Feld key (DESK-HUB JWEHelper.readKey mit Feldname key). Jeder Erzeuger von
privateKeys/userPrivateKey muss daher key befüllen. Browser lesen ecdhPrivateKey und fallen auf key
zurück (HUB-FE UserKeys.createFromJwe). Base64 hier ist Standard-Alphabet mit Padding (Java
Base64.getDecoder würde base64url ablehnen).
4. OIDC-Login des Desktop-Clients¶
(DESK-HUB AuthFlowTask.call, AuthFlowController; T2C AuthorizationCodeGrant, PKCE,
http/RedirectTarget, util/RandomUtil.)
- Flow: Authorization Code mit PKCE, öffentlicher Client ohne Secret.
client_id=hub.clientIdaus dem Vault-Header (Standardcryptomator).- Scopes: keine (
scope-Parameter wird weggelassen). Keycloak liefert dann Standard-Scopes. - PKCE:
code_verifier= base64url (ohne Padding) von 33 Zufallsbytes (44 Zeichen; angefragt „43“, auf Vielfaches von 3 Bytes aufgerundet);code_challenge= base64url(SHA-256(ASCII(verifier))),code_challenge_method = S256. - state: base64url von 12 Zufallsbytes (16 Zeichen); wird bei Rückkehr exakt verglichen.
- Redirect:
http://127.0.0.1:<Port>/<Pfad>– Host ist die Loopback-IP (nichtlocalhost, RFC 8252 §8.3), Port vom Betriebssystem zugewiesen (Port 0), Pfad =/+ 16 zufällige base64url-Zeichen. Der lokale Server akzeptiert nur exakt diesen Pfad; prüftstate; liestcodeodererror. - Antwort an den Browser: HTTP-Redirect auf
authSuccessUrl + "&device=" + deviceIdbzw.authErrorUrl + "&device=" + deviceId(der Hub zeigt dort eine „Sie können zurück zur App“-Seite). - Authorization-URL: bestehende Query des
authEndpointbleibt erhalten, ergänzt umresponse_type=code,client_id,state,code_challenge,code_challenge_method,redirect_uri. - Token Request: POST an
tokenEndpoint, form-encoded:grant_type=authorization_code,client_id,code_verifier,code,redirect_uri. Timeout 10 s. Erwartet HTTP 200; verwendet nuraccess_tokenaus der JSON-Antwort. Refresh Token wird nicht genutzt; jedes Entsperren loggt neu ein (Browser-SSO-Session macht das meist unsichtbar). - Keycloak-Client
cryptomator(HUBbackend/src/main/resources/cryptomator-realm.json,deploy/compose/prod/compose.yaml):publicClient: true, Attributpkce.code.challenge.method = S256,redirectUris:http://127.0.0.1/*,org.cryptomator.ios:/hub/auth,org.cryptomator.android:/hub/auth. Keycloak erlaubt bei Loopback-IP beliebige Ports (RFC 8252) (ungeprüft für andere IdPs). - Mobile: iOS AppAuth mit Redirect
org.cryptomator.ios:/hub/auth,scopes: nil(IOSCryptomatorHubAuthenticator+HubAuthenticating.swift); Androidorg.cryptomator.android:/hub/auth(ANDCryptoConstants.HUB_REDIRECT_URL). - Alle API-Aufrufe: Header
Authorization: Bearer <access_token>.
5. Geräteschlüssel und Geräteregistrierung (Desktop)¶
Erzeugung/Speicherung (DESK common/settings/DeviceKey.loadOrCreate): Ein P-384-Schlüsselpaar pro
Installation (nicht pro Hub/Tresor), gespeichert als PKCS#12-Datei (cryptomator.p12Path, z. B.
~/.config/Cryptomator/key.p12, macOS ~/Library/Application Support/Cryptomator/key.p12). Das
PKCS#12-Passwort ist eine zufällige UUID im System-Schlüsselbund unter dem Eintrag
cryptomator-device-p12. Fehlt Datei oder Passwort, wird neu erzeugt (⇒ neues Gerät). Ohne
Schlüsselbund-Unterstützung bricht der Desktop mit „kein Schlüsselbund“ ab.
Device ID (DESK-HUB HubKeyLoadingModule.provideDeviceId; identisch im Browser HUB-FE
BrowserKeys.id): deviceId = HEX_UPPER( SHA-256( SPKI-DER(devicePublicKey) ) ) → 64 Zeichen.
Registrierung (DESK-HUB RegisterDeviceController.register), ausgelöst durch 404 bei
GET /api/devices/{deviceId}:
1. UI fragt Gerätename (Vorbelegung: Hostname) und Setup Code (Account Key).
2. GET /api/users/me (Bearer). Erwartet 200. Liest publicKey (= User-ECDH-SPKI, Base64) und
privateKey (= privateKeys-JWE) – die Legacy-Feldnamen.
3. Best-Effort-Migration von Hub-1.x-Geräten (Abschnitt 9.2): GET /api/devices/{deviceId}/legacy-access-tokens.
4. privateKey-JWE mit Setup Code entschlüsseln (Variante B) → User-ECDH-Privkey (PKCS#8 aus Feld key).
5. User-Privkey per Variante A für den eigenen Device-Public-Key verschlüsseln, Payload {"key": …}.
6. PUT /api/devices/{deviceId}, Content-Type: application/json, Body:
{"id": deviceId, "name": <Name>, "publicKey": <Base64 SPKI Device>, "type": "DESKTOP",
"userPrivateKey": <JWE>, "creationTime": <ISO-8601 Instant, z. B. "2026-10-13T12:00:00.123Z">}.
7. 201 → Erfolgsseite, danach automatisch erneuter Schlüsselempfang (DESK-HUB
RegisterSuccessController.complete). 409 → „Gerät existiert bereits (bei anderem Nutzer)“. Sonst Fehler.
Falscher Setup Code → Feld als ungültig markiert.
iOS/Android registrieren identisch mit type: "MOBILE" (IOS CryptomatorHubAuthenticator.registerDevice;
AND HubRepositoryImpl). Browser: type: "BROWSER", Device Key im IndexedDB unter der User-ID.
6. Unlock-Ablauf des Desktops (apiLevel ≥ 1)¶
6.1 Host-Vertrauensprüfung (vor jedem Netzwerkzugriff)¶
(DESK-HUB CheckHostTrustController.initialize, isConsistentHubConfig.) „Authority“ = schema://host[:port].
1. Konsistenz: Authority von apiBaseUrl (bzw. aus devicesResourceUrl abgeleitet), devicesResourceUrl,
authSuccessUrl, authErrorUrl müssen gleich der API-Authority sein; tokenEndpoint muss die Authority
des authEndpoint haben. Sonst Abbruch („nicht vertrauenswürdiger Host“).
2. API- und Auth-Host enden beide auf .cryptomator.cloud und kein HTTP → vertraut.
3. Beide Authorities in System-Property cryptomator.hub.allowedHosts (kommagetrennt) → vertraut.
4. HTTP (nicht HTTPS) und nicht beide Hosts exakt localhost → abgelehnt.
5. cryptomator.hub.enableTrustOnFirstUse=true (in allen offiziellen Installern gesetzt, DESK dist/*) und
beide Authorities bereits in den Einstellungen trustedHosts → vertraut; sonst Nutzer fragen (TOFU) und
bei Zustimmung speichern.
6. Sonst ablehnen.
Konsequenz für einen eigenen Server: HTTPS (außer localhost), Erfolgs-/Fehlerseiten auf demselben Origin
wie die API, Token- und Auth-Endpoint auf demselben Origin; Nutzer bestätigen den Host beim ersten Mal.
6.2 Anfragen der Reihe nach¶
(DESK-HUB ReceiveKeyController; HTTP/1.1, Timeout je 10 s; URLs = API-Basis + relativer Pfad.)
| # | Anfrage | Header | Erwartung / Reaktion |
|---|---|---|---|
| 0 | OIDC-Login (Abschnitt 4) | – | Bearer Token |
| 1 | GET users/me?withDevices=false |
Bearer | 200 → Feld name wird als Dateisystem-Besitzername gemerkt. Jeder andere Status ⇒ Fehler |
| 2 | GET config |
ohne Auth | 200; apiLevel ≥ 1 → Schritt 3, sonst Legacy (9.1) |
| 3 | GET devices/{deviceId} |
Bearer | 200 → userPrivateKey (JWE) und name lesen. 404 → Registrierung (Abschnitt 5). Sonst Fehler |
| 4 | GET vaults/{vaultId}/access-token |
Bearer, Hub-Device-ID: <deviceId> |
siehe Tabelle 6.3 |
| 5 | lokal | – | userPrivateKey mit Device-Privkey entschlüsseln (A) → User-Privkey; Access-Token mit User-Privkey entschlüsseln (A) → key → 64-Byte-Masterkey; danach vault.cryptomator-HMAC prüfen |
Mobile Apps fragen zuerst access-token, danach devices/{id} (IOS CryptomatorHubAuthenticator), Ergebnis
identisch. iOS verlangt apiLevel ≥ 2 (Konstante minimumHubVersion = 2), Android ≥ 1 (AND
UnlockHubVault.HUB_MINIMUM_VERSION).
6.3 Statuscodes von GET /api/vaults/{vaultId}/access-token¶
(HUB-API VaultResource.unlock; Reihenfolge der Prüfungen auf dem Server, soweit bestimmbar.)
| Status | Bedingung (Server) | Desktop-Reaktion |
|---|---|---|
| 401 | Kein/ungültiges Bearer Token | Fehler |
| 403 | Nutzer hat effektiv weder Rolle MEMBER noch OWNER; auch bei unbekannter Vault-ID (Filter-Standard „FORBIDDEN bei fehlendem Vault“, beobachtet) | „Gerät/Nutzer nicht autorisiert“ (UnauthorizedDeviceController) |
| 402 | Lizenz abgelaufen (ActiveLicense-Filter) oder Lizenz noch nicht eingerichtet (globaler Setup-Filter) oder mehr sitzplatzbelegende Nutzer mit Access Token als Lizenz-Sitze |
„Lizenz ungültig/überschritten“ (InvalidLicenseController) |
| 410 | Vault archiviert und Query evenIfArchived nicht true |
Fenster schließen, Dialog „Tresor archiviert“ |
| 449 | Nutzer ist (effektiv) Mitglied, hat aber noch keinen ecdhPublicKey (Account nicht eingerichtet) (beobachtet; Nichtmitglied ohne Account → 403) |
„Account einrichten“; Button öffnet <Web-App>/profile mit Web-App = API-Basis + ../app/ |
| 403 | Mitglied, aber noch kein Access Token für (Nutzer, Vault) hinterlegt (Text „Access to this device not granted“) | wie 403 oben |
| 200 | Access Token vorhanden; Body = JWE als text/plain |
entsperren |
Server-Seiteneffekte bei 200: Audit-Event VaultKeyRetrieved (Ergebnis SUCCESS/UNAUTHORIZED, IP, Device-ID);
wenn Hub-Device-ID zu einem Gerät des Nutzers gehört: lastAccessTime und lastIpAddress des Geräts
aktualisieren (unbekannte IDs werden ignoriert). Zusätzliche Antwort-Header bei vorhandener Lizenz:
Hub-iOS-License (JWT) plus Hub-Subscription-State: ACTIVE, sowie Hub-Android-License (JWT) (Abschnitt 15).
Die Filter-Reihenfolge zwischen 402 (Lizenz) und 403 (Rolle) ist nicht festgelegt (beide gleiche
Priorität) (ungeprüft). 404 ist dokumentiert, wird aber praktisch nicht erreicht.
7. Datenmodell des Servers (Hub 2.1.2)¶
(HUB entities/*, Flyway-Migrationen backend/src/main/resources/org/cryptomator/hub/flyway/V*.sql.)
| Entität | Felder | Bemerkungen |
|---|---|---|
| Authority | id (String, PK = Keycloak-ID), type (USER/GROUP), name, pictureUrl? |
Oberklasse für User und Group |
| User | + email?, firstName?, lastName?, language?, enabled (bool), ecdhPublicKey?, ecdsaPublicKey?, privateKeys? (JWE B), setupCode? (JWE A) |
ecdhPublicKey == null ⇔ Account nicht eingerichtet |
| Group | Mitglieder = Authorities (User oder Gruppen; verschachtelt) | |
| EffectiveGroupMembership | (group_id, member_id, intermediate_group_ids[]) |
Transitive Hülle; Tabelle, bei Sync gepflegt |
| Device | id (PK, 64 Hex), owner (User), name, type (BROWSER/DESKTOP/MOBILE), publickey, userPrivateKeys (JWE A), creationTime, lastAccessTime?, lastIpAddress? |
|
| Vault | id (UUID), name, description?, creationTime, archived, requiredEmergencyKeyShares (int), emergencyKeyShares (Map UserID→JWE), Legacy: salt?, iterations?, masterkey?, authPublicKey?, authPrivateKey? |
Server speichert kein vault.cryptomator |
| VaultAccess | (vault_id, authority_id) PK, role (OWNER/MEMBER) |
Direkte Mitgliedschaft von User oder Gruppe |
| EffectiveVaultAccess (View) | (vault_id, authority_id, role) |
Vereinigung: direkte VaultAccess-Einträge ∪ (VaultAccess einer Gruppe × alle effektiven Mitglieder dieser Gruppe). Ein Nutzer kann mehrere Rollen effektiv haben |
| AccessToken | (user_id, vault_id) PK, vault_masterkey (JWE A) |
Nur gültig, solange auch EffectiveVaultAccess besteht (Unlock-Abfrage joint beide). Entfernen eines Mitglieds löscht den Token nicht |
| Settings (eine Zeile) | hubId, licenseKey?, wotMaxDepth, wotIdVerifyLen, enableEmergencyAccess, defaultRequiredEmergencyKeyShares, defaultMinMembers, allowChoosingEmergencyCouncil, emergencyCouncilMemberIds |
|
| WotEntry / EffectiveWot | wot: (user_id = signiert, signer_id) PK, signature (JWS, opak); effective_wot (View): (trusting_user_id, trusted_user_id, signature_chain[]) |
Web of Trust, Abschnitt 16a; keine Löschung bei Reset |
| LegacyDevice / LegacyAccessToken | Hub-1.x-Geräte und pro-Gerät-Tokens | veraltet |
| EmergencyRecoveryProcess | Notfall-Wiederherstellung | Abschnitt 16 |
| AuditEvent + Unterarten | siehe Abschnitt 16 |
Benutzer-Synchronisation: Nutzer und Gruppen stammen aus Keycloak.
(1) HUB keycloak/KeycloakAuthorityPuller.sync läuft periodisch (hub.keycloak.syncer-period, dev 1 min),
liest alle Keycloak-Nutzer/-Gruppen über einen Service-Account-Client (cryptomatorhub-system, Client
Credentials) und gleicht Tabellen inkl. Gruppenmitgliedschaften ab (anlegen, aktualisieren, löschen).
(2) Die Web-UI ruft nach dem Login PUT /api/users/me ohne Body auf (HUB-FE router/index.ts, Query
sync_me), wodurch der Nutzer sofort angelegt wird (Name aus dem Token, picture, email).
Ein Nutzer ohne DB-Zeile bekommt bei GET /users/me 404 – der Desktop scheitert dann mit „unerwarteter
Antwort“. Empfehlung für den eigenen Server: Nutzer beim ersten gültigen Token automatisch anlegen.
Seat-Zählung (nur für Lizenz, HUB entities/EffectiveVaultAccess Named Queries): „sitzplatzbelegend“ =
aktivierter Nutzer mit effektivem Zugriff auf mindestens einen nicht archivierten Vault. Ohne Lizenzlogik
entfällt das vollständig.
8. Authentifizierung und Autorisierung auf dem Server¶
- Quarkus OIDC als
service(nur Bearer-Prüfung),quarkus.oidc.client-id=cryptomatorhub, Auth-Server = Keycloak-Realm (HUBapplication.properties). Signaturprüfung über JWKS des Realms, Issuer aus der Discovery (Quarkus-Standard, ungeprüft im Detail). Audience wird nicht geprüft (beobachtet: Token mitaud=account,azp=krywerk-e2ewird akzeptiert). Tokens des Clientscryptomatorfunktionieren daher. - Rollen = Keycloak-Realm-Rollen aus Claim
realm_access.roles(Quarkus-Keycloak-Standard; Realm-Mapperrealm_access.rolesincryptomator-realm.json) (Mapping-Pfad ungeprüft, Verhalten beobachtet). Rollen (HUBkeycloak/RealmRole):user(alle normalen Endpunkte),create-vaults(neuen Vault anlegen),admin(Verwaltung). Fehlende Rolle → 403; fehlendes Token → 401. (beobachtet 2026-10-13, Black Box) Enthält das Access Token einen Claimgroups, nimmt Hub 2.1.2 diesen stattrealm_access.rolesals Rollenliste: Nach Hinzufügen eines Keycloak-Mappers „Group Membership“ mit Claimgroupsbekamen Nutzer mit Gruppe 403 aufGET /users/me, Nutzer ohne Gruppe (Claim fehlt) weiter - Ein eigener Gruppen-Claim für Dritte (z. B. Krywerk Server) muss im selben Realm daher anders heißen. Keycloak lässt den Gruppen-Claim bei Nutzern ohne Gruppe ganz weg (beobachtet).
- Identität:
sub= User-ID; Anzeigename = Quarkus-Principal-Name (typischpreferred_username, ungeprüft). - Vault-Rollen-Filter (HUB
filters/VaultRoleFilter.filter): Für Methoden mit verlangter Vault-Rolle: Vault existiert → (a) Bypass, wenn der Nutzer eine angegebene Realm-Rolle hat (z. B.adminbeiGET /vaults/{id}), (b) Bypass für Notfall-Rat-Mitglieder, wo erlaubt, (c) sonst muss eine der effektiven Rollen des Nutzers in der verlangten Menge liegen, sonst 403. Vault existiert nicht → je nach Endpunkt 403 (Standard), 404, durchlassen oder „Realm-Rolle verlangt“ (create-vaultsbeim Anlegen). - Lizenz-Filter: (1) global (HUB
filters/LicenseSetupFilter): solange keine Lizenz installiert ist, 402 für alle Endpunkte außerGET /config,GET/PUT /users/me, Lizenz-Trial/-Refresh, Billing. (2)@ActiveLicense(filters/ActiveLicenseFilter): 402 bei abgelaufener Lizenz (access-token, legacy keys, addUser, addGroup). Ein eigener Server ohne Lizenzmodell gibt nie 402 zurück. - Server-eigene Kryptographie: keine für Format 8. Er verifiziert nur (a) Lizenz-JWTs (ES512, fest
eingebauter Public Key + x5c-Kette, HUB
license/LicenseVerifierProducer,X5cCheckingJWTVerifier) und (b) Claim-Ownership-Beweise (ES384, Abschnitt 12). JWEs werden nur per Regex auf Form geprüft und unverändert gespeichert/ausgeliefert.
9. API-Level, Hub 1.x vs. 2.x, Legacy-Geräte¶
apiLevel-Bedeutung (Kommentar in HUB-dev ConfigResource): 0 = initial (Hub 1.0–1.2); 1 = neuer Unlock-Flow
über /devices/{id} mit User Keys (Hub 1.3.0); 2 = fehlender Response-Header in 1.3.0 nachgereicht;
3 = HTTP 402 bei überschrittenen Sitzen (1.3.4); 4 = Hub 1.4.0 (ECDSA-Key/WoT, gilt bis 2.1.x);
5 = UVF (/vaults/{id}/uvf/*, Access Tokens können UVF-Member-Key enthalten; erst HUB-dev).
Für einen Format-8-Server ist 4 die richtige Angabe.
9.1 Legacy-Unlock (apiLevel 0, Hub 1.0–1.2)¶
(DESK-HUB ReceiveKeyController.requestLegacyAccessToken, LegacyRegisterDeviceController; HUB-API
VaultResource.legacyUnlock.)
- GET vaults/{vaultId}/keys/{deviceId} (Bearer). 200 → Body = JWE (Variante A) mit Masterkey direkt an den
Device Key verschlüsselt ({"key": …}); 402/403/410 wie oben; 404 → Legacy-Registrierung.
- Legacy-Registrierung: PUT <devicesResourceUrl><deviceId>, Body {"id", "name", "publicKey"} mit
publicKey = base64url ohne Padding des SPKI; erwartet 201 oder 409. Danach musste ein Vault-Owner im
Browser dem Gerät Zugriff gewähren.
- Hub 2.1.2 bedient keys/{deviceId} noch aus legacy_access_token: 404 bei unbekanntem Vault (beobachtet),
403 wenn für (Vault, Gerät, Nutzer) kein Legacy-Token existiert.
9.2 Migration von Legacy-Geräten (beim Registrieren unter Hub ≥ 1.3)¶
(DESK-HUB RegisterDeviceController.migrateLegacyDevices; HUB-API DeviceResource.getLegacyAccessTokens,
UsersResource.updateMyAccessTokens.)
1. GET devices/{deviceId}/legacy-access-tokens → 200 mit JSON-Objekt { "<vaultId>": "<JWE an Device>", … }
(beobachtet: {}). Nicht-200 oder leer ⇒ überspringen.
2. Jeden JWE mit Device-Privkey entschlüsseln, Masterkey neu für den User-ECDH-Public-Key verschlüsseln.
3. POST users/me/access-tokens mit { "<vaultId>": "<neuer JWE>", … } → 200 (unbekannte Vaults werden
übersprungen). Fehler werden nur geloggt.
4. Beim PUT /devices/{id} löscht der Server ein gleichnamiges Legacy-Gerät.
10. REST-API-Referenz (Hub 2.1.2, Präfix /api)¶
Konventionen: JSON (UTF-8); Zeitstempel ISO-8601-Instant mit Millisekunden (…T…Z); null-Felder werden
in Antworten meist weggelassen (JsonInclude NON_NULL, beobachtet bei users/me). Rolle „user“ bedeutet
Realm-Rolle user. „Owner“ = effektive Vault-Rolle OWNER (über Gruppe genügt).
10.1 Config, Version, Settings¶
| Methode + Pfad | Auth | Antwort |
|---|---|---|
GET /config |
keine | 200 ConfigDto (unten) |
GET /version |
keine (beobachtet) | {"hubVersion": "2.1.2", "keycloakVersion": "26.7.5"} |
GET /settings |
user | {"hubId", "wotMaxDepth" (0–9, Standard 3), "wotIdVerifyLen" (≥ 0, Standard 2), "enableEmergencyAccess", "defaultRequiredEmergencyKeyShares", "defaultMinMembers", "allowChoosingEmergencyCouncil", "emergencyCouncilMemberIds": [..]} |
PUT /settings |
admin | Body wie oben → 204; 400 ungültig |
ConfigDto (HUB-API ConfigResource.ConfigDto): keycloakUrl, keycloakRealm, keycloakClientIdHub
(cryptomatorhub, Web-UI), keycloakClientIdCryptomator (cryptomator, Apps), keycloakAuthEndpoint,
keycloakTokenEndpoint (öffentliche URLs, interne Realm-URL-Präfixe werden ersetzt), serverTime,
apiLevel (4), entitlements {seats, showTrialHint, auditLogRetentionDays, emergencyAccessEnabled,
keycloakAccessEnabled, iosLicense?, androidLicense?, desktopLicense?}, billingUrl,
licenseApiUrl, licenseSetupRequired.
Von Clients tatsächlich genutzt: Desktop/iOS/Android nur apiLevel. Die Web-UI nutzt
keycloakClientIdCryptomator, keycloakAuthEndpoint, keycloakTokenEndpoint zum Bauen des
Vault-Headers sowie Keycloak-URL/Realm/Hub-Client-ID für ihren eigenen Login. Die Apps beziehen alle
URLs aus dem Vault-Header, nie aus /config.
10.2 Nutzer¶
| Methode + Pfad | Auth | Request | Antwort / Status |
|---|---|---|---|
GET /users/me?withDevices=<bool> |
user (auch im Setup-Modus) | – | 200 UserDto; devices nur bei withDevices=true, sonst []; 404 wenn keine DB-Zeile |
PUT /users/me |
user (auch im Setup-Modus) | optional UserDto |
201. Legt Nutzer an falls nötig; setzt immer name/pictureUrl/email aus dem Token. Mit Body: übernimmt setupCode, ecdhPublicKey, ecdsaPublicKey, privateKeys, language; aktualisiert vorhandene Geräte aus devices (nur type, name, publicKey, userPrivateKey; legt keine an, löscht keine). Audit UserSetupCodeChanged / UserKeysChanged bei Änderung |
POST /users/me/access-tokens |
user | { "<vaultId>": "<JWE>" } |
200; speichert eigene Access Tokens (unbekannte Vaults übersprungen) – keine Rollenprüfung! |
POST /users/me/reset |
user | – | 204; löscht User Keys, Setup Code, alle Geräte, alle Access Tokens, Notfall-Anteile des Nutzers |
GET /users/me-with-legacy-devices-and-access |
user | – | UserDto mit Legacy-Geräten (veraltet) |
PUT /users/trusted/{userId} |
user | Body = kompakte JWS (ES384) als text/plain |
204; speichert/überschreibt die Signatur (aufrufender Nutzer → {userId}) ohne jede Prüfung; Audit SignedWotId. Details 16a.2 |
GET /users/trusted, GET /users/trusted/{userId} |
user | – | [{ "trustedUserId", "signatureChain": [JWS…] }] bzw. einzelnes Objekt; je Ziel nur die kürzeste Kette (≤ wotMaxDepth+1 Signaturen), Reihenfolge vom Aufrufer zum Ziel; 404 ohne ausreichend kurze Kette. Details 16a.2 |
GET /users, GET/PUT/DELETE /users/{id}, PUT /users/{id}/enabled, POST /users |
admin | – | Nutzerverwaltung (schreibt auch nach Keycloak; für Clients irrelevant) |
UserDto (HUB-API UserDto) – Ausgabe: id, type = "USER", name, pictureUrl?, email,
firstName?, lastName?, language?, enabled, devices [DeviceDto], ecdhPublicKey?,
publicKey? (Alias von ecdhPublicKey), ecdsaPublicKey?, privateKeys?, privateKey? (Alias von
privateKeys), setupCode?. Eingabe akzeptiert jeweils neuen oder alten Namen (neuer gewinnt).
Die Aliase publicKey/privateKey sind Pflicht: Desktop, iOS und Android lesen nur diese
(DESK-HUB RegisterDeviceController.UserDto, IOS UserDto, AND HubRepositoryImpl).
Öffentliche Kurzform (justPublicInfo, z. B. in users-requiring-access-grant): ohne privateKeys,
setupCode, Geräte.
10.3 Geräte¶
| Methode + Pfad | Auth | Request | Antwort / Status |
|---|---|---|---|
PUT /devices/{deviceId} |
user | DeviceDto (name nicht leer, ohne HTML; publicKey Base64; userPrivateKey JWE; type optional, Standard DESKTOP) |
201 (auch bei Update eines eigenen Geräts: dann name, publicKey, userPrivateKey überschrieben; creationTime serverseitig). 409 bei DB-Konflikt (ID gehört anderem Nutzer). Löscht gleichnamiges Legacy-Gerät. Audit DeviceRegistered |
GET /devices/{deviceId} |
user | – | 200 DeviceDto; 404 wenn nicht vorhanden oder fremd |
DELETE /devices/{deviceId} |
user | – | 204; 404 wenn nicht vorhanden/fremd. Audit DeviceRemoved |
GET /devices/{deviceId}/legacy-access-tokens |
user | – | 200 {vaultId: JWE} |
DELETE /devices/{deviceId}/legacy-device |
user | – | 204/404 |
GET /devices?ids=…, GET /devices/legacy-devices?ids=…, GET /devices/has-legacy-devices |
admin | – | Listen / bool |
DeviceDto: id, name, type (BROWSER|DESKTOP|MOBILE), publicKey, userPrivateKey
(Singular, historisch), owner (User-ID), creationTime, lastIpAddress?, lastAccessTime?,
legacyDevice (bool). Geräteliste des Nutzers: GET /users/me?withDevices=true.
10.4 Vaults¶
| Methode + Pfad | Auth / Vault-Rolle | Request | Antwort / Status |
|---|---|---|---|
PUT /vaults/{vaultId} |
user; existiert → OWNER (oder Notfall-Rat); neu → Realm-Rolle create-vaults |
VaultDto (id, name (nicht leer), description?, archived (ignoriert), requiredEmergencyKeyShares (≥0), emergencyKeyShares (Map), creationTime ignoriert) |
201 + VaultDto bei Neuanlage (Ersteller wird automatisch OWNER; Audit VaultCreated, VaultMemberAdded), 200 bei Update (nur Name, Beschreibung, Notfall-Anteile). 402 Sitze. 403 fehlende Rolle (beobachtet 201 mit create-vaults) |
GET /vaults/{vaultId} |
MEMBER/OWNER oder Realm admin; fehlend → 404 |
– | VaultDto |
DELETE /vaults/{vaultId} |
OWNER | – | 204 (beobachtet); kaskadiert Zugriffe/Tokens (ungeprüft im Detail) |
PUT /vaults/{vaultId}/archived |
OWNER oder admin; fehlend → 404 |
Body text/plain true/false |
200 VaultDto; 402 beim Entarchivieren über Sitzlimit |
GET /vaults/accessible?role=<OWNER\|MEMBER> |
user | – | [VaultDto] mit effektivem Zugriff (optional nach Rolle gefiltert) |
GET /vaults/recoverable |
user | – | Vaults, in deren Notfall-Rat der Nutzer ist |
GET /vaults/some?ids=…, GET /vaults/all |
admin | – | [VaultDto] |
GET /vaults/{vaultId}/members |
OWNER (oder Rat) | – | [MemberDto] direkte Mitglieder; deaktivierte Nutzer ausgeblendet |
PUT /vaults/{vaultId}/members |
OWNER (oder Rat) | { "<authorityId>": "OWNER"\|"MEMBER", … } (nicht leer) |
204; ersetzt die gesamte direkte Mitgliederliste (unbekannte IDs ignoriert; keine gültigen → 400); 402 Sitze; 404 Vault fehlt |
PUT /vaults/{vaultId}/users/{userId}?role=MEMBER\|OWNER |
OWNER (oder Rat) | – | 201 neu, 200 Rolle geändert; 404 Nutzer unbekannt; 402 |
PUT /vaults/{vaultId}/groups/{groupId}?role=… |
OWNER | – | 201/200; 404 Gruppe; 402 |
DELETE /vaults/{vaultId}/authority/{authorityId} |
OWNER (oder Rat) | – | 204; 404 wenn keine direkte Mitgliedschaft. Access Token bleibt gespeichert, ist aber ohne Mitgliedschaft wirkungslos |
GET /vaults/{vaultId}/users-requiring-access-grant |
OWNER (oder Rat) | – | [UserDto (öffentlich)]: aktivierte Nutzer mit effektivem Zugriff, eingerichtetem ecdhPublicKey und ohne Access Token |
POST /vaults/{vaultId}/access-tokens |
OWNER (oder Rat) | { "<userId>": "<JWE>", … } (nicht leer) |
200; legt an/überschreibt; 404 unbekannter Nutzer; 402 Sitze. Audit VaultAccessGranted. Server prüft nicht, ob der Nutzer Mitglied ist |
GET /vaults/{vaultId}/access-token?evenIfArchived=<bool> |
MEMBER/OWNER | Header Hub-Device-ID optional |
siehe 6.3 |
GET /vaults/{vaultId}/keys/{deviceId} |
user | – | Legacy (9.1) |
POST /vaults/{vaultId}/claim-ownership |
user | form proof=<JWS> |
Legacy (12) |
VaultDto: id, name, creationTime, description?, archived, requiredEmergencyKeyShares,
emergencyKeyShares (Map UserID→JWE), Legacy masterkey?, iterations?, salt?, authPublicKey?,
authPrivateKey?. MemberDto: id, type (USER|GROUP), name, pictureUrl?, ecdhPublicKey?,
ecdsaPublicKey?, vaultRole (beobachtet: {"id","type":"USER","name","vaultRole":"OWNER"}),
memberSize? (Gruppen).
10.5 Authorities und Gruppen¶
| Methode + Pfad | Auth | Antwort |
|---|---|---|
GET /authorities/search?query=<Text>&withMemberSize=<bool> |
user | [AuthorityDto] (Nutzer und Gruppen nach Namen; deaktivierte Nutzer ausgeblendet). Nutzer erscheinen in UserDto-Form, Gruppen {id, type:"GROUP", name, memberSize?} (beobachtet) |
GET /authorities?ids=… |
user | [AuthorityDto] |
GET /groups, GET /groups/{id}, GET /groups/{id}/effective-members, POST /groups, PUT/DELETE /groups/{id}, POST/DELETE /groups/{id}/members/{userId} |
admin | Gruppenverwaltung (Hub 2.x verwaltet Keycloak-Gruppen) |
10.6 Lizenz, Billing, Audit, Notfall¶
GET /license/user-info (user) → {licensedSeats, usedSeats, expiresAt, gracePeriodEndsAt};
PUT /license/trial, POST /license/refresh, GET /billing, PUT /billing/token (admin) – nur für das
Hub-Lizenzmodell. GET /auditlog (admin, Parameter Start-/Enddatum, Reihenfolge, Seitengröße 1–100, Typ).
/emergency-access/* siehe Abschnitt 16.
11. Tresor-Erstellung über die Web-UI (Format 8)¶
(HUB-FE components/CreateVault.vue initialize, createVault, downloadVaultTemplate; common/crypto.ts.)
1. Browser erzeugt 64 Byte Masterkey (VaultKeys.create) und zeigt sofort den Recovery Key an.
2. Optional: Notfall-Anteile erzeugen (Secret Sharing des Masterkeys, JWE pro Ratsmitglied) (Details 16).
3. vaultId = zufällige UUID (crypto.randomUUID).
4. vault.cryptomator bauen und signieren (Abschnitt 2), Stammordner-Hash berechnen.
5. Owner-Access-Token = JWE A mit {"key": Base64(Masterkey)} an den eigenen ecdhPublicKey.
6. PUT /api/vaults/{vaultId} mit {id, name, creationTime, description, archived:false,
requiredEmergencyKeyShares, emergencyKeyShares} → Server macht Ersteller zum OWNER.
7. POST /api/vaults/{vaultId}/access-tokens mit { "<eigene User-ID>": <JWE> }.
8. Nutzer lädt das ZIP herunter. Das vault.cryptomator wird nicht hochgeladen.
Mitglieder hinzufügen und Zugriff gewähren (HUB-FE components/VaultDetails.vue,
GrantPermissionDialog.giveUsersAccess): Owner fügt Nutzer/Gruppen hinzu (PUT …/users/{id} bzw.
…/groups/{id} bzw. PUT …/members). Danach liefert users-requiring-access-grant die Nutzer, die einen
Token brauchen. Owner holt seinen eigenen Token (GET …/access-token?evenIfArchived=true mit
Hub-Device-ID des Browsers), entschlüsselt den Masterkey, verschlüsselt ihn je Nutzer an dessen
ecdhPublicKey und schickt alle per POST …/access-tokens. Nutzer ohne eingerichteten Account
(ecdhPublicKey fehlt) werden übersprungen und später nachgeholt. Optional prüft die Web-UI vorher die
Web-of-Trust-Kette (ES384-JWS mit Payload aus ecdhPublicKey/ecdsaPublicKey; iss = Signierer, sub =
signierter Nutzer, iat stehen im JWS-Header; HUB-FE common/wot.ts) und zeigt ein Vertrauens-Schild;
die Prüfung ist rein informativ und blockiert die Vergabe nicht (Abschnitt 16a).
Recovery Key (HUB-FE VaultKeys.createRecoveryKey/recover, common/util.ts WordEncoder,
Wortliste common/4096words_en.ts): crc = CRC32(masterkey) (Standard-CRC-32); Daten = 64 Byte Masterkey
‖ crc & 0xFF ‖ (crc >> 8) & 0xFF = 66 Byte. Je 3 Byte → zwei 12-Bit-Indizes
(i1 = b1·16 + (b2 >> 4), i2 = (b2 & 0x0F)·256 + b3) in die 4096-Wörter-Liste → 44 Wörter, durch
Leerzeichen getrennt (Trennzeichen ungeprüft). Wiederherstellung: dekodieren, Länge 66 und Prüfsumme prüfen.
Verwendung: (a) „Tresor wiederherstellen“ beim Erstellen – erzeugt mit demselben Masterkey einen neuen
Hub-Vault mit neuer ID (neues vault.cryptomator nötig); (b) Owner-Dialog „Recover“ – Masterkey aus
Recovery Key, Access Token für sich selbst neu hochladen (RecoverVaultDialog).
Kann ein Drittclient (Krywerk) Tresore über die API anlegen? Ja. Der Server sieht nie Klartextschlüssel
und kein vault.cryptomator. Krywerk braucht: Realm-Rolle create-vaults, eingerichteten Account
(eigener ecdhPublicKey), die Werte keycloakClientIdCryptomator/keycloakAuthEndpoint/
keycloakTokenEndpoint aus /api/config und die öffentliche Basis-URL für den Header. Dann Schritte 1–8
lokal nachbilden. Das Format der Erfolgs-/Fehler-URLs (/app/unlock-success?vault=…) muss zum Server passen.
12. Legacy: Admin-Passwort und Claim Ownership¶
Hub 1.0–1.2 speicherte pro Vault: salt (Base64), iterations, masterkey = Base64(Nonce 12 Byte ‖
AES-GCM-Wrap des Masterkeys), authPublicKey (SPKI ECDSA P-384), authPrivateKey (Base64, Nonce ‖ GCM-Wrap
PKCS#8). KEK = PBKDF2-HMAC-SHA256(Admin-Passwort, salt, iterations, 256 Bit) (HUB-FE
VaultKeys.decryptWithAdminPassword). Übernahme: Browser entpackt mit dem Passwort, signiert ein ES384-JWT
mit Claims sub = eigene User-ID, vaultId (klein geschrieben), nbf, exp, sendet es als Formfeld
proof an POST /vaults/{id}/claim-ownership. Server prüft mit authPublicKey (30 s Toleranz), macht den
Nutzer zum OWNER und löscht alle Legacy-Felder (HUB-API VaultResource.claimOwnership). 400 falscher Beweis,
404 Vault fehlt, 409 bereits übernommen. Für Krywerk und den eigenen Server optional.
13. Universal Vault Format (UVF) – nur Erkennung¶
UVF gibt es erst in HUB-dev (apiLevel 5). Erkennung: Im Tresor liegt vault.uvf statt (bzw. ohne)
vault.cryptomator. vault.uvf ist ein JWE in JSON-Serialisierung mit crit: ["uvf.spec.version"],
uvf.spec.version: 1 und org.cryptomator.hub.canonical = <apiBase>/vaults/<id>/uvf/vault.uvf; Empfänger
org.cryptomator.hub.memberkey (A256KW) und org.cryptomator.hub.recoverykey.<JWK-Thumbprint> (ECDH-ES)
(HUB-dev common/universalVaultFormat.ts). Server-Endpunkte /vaults/{id}/uvf/vault.uvf und
/uvf/jwks.json. Krywerk unterstützt UVF vorerst nicht: bei vault.uvf mit klarer Meldung abbrechen;
der eigene Server meldet apiLevel 4.
14. Unterschiede, die ein Client kennen muss (Kurzfassung)¶
- apiLevel 0: pro-Gerät-Tokens (
keys/{deviceId}), keine User Keys. ≥1: User Keys +/devices/{id}+access-token. ≥3: 402 bei Sitzüberschreitung. 4: ECDSA-User-Key, WoT. 5: UVF. - Feldnamen: neue (
ecdhPublicKey,privateKeys) und alte (publicKey,privateKey) parallel. userPrivateKey-Payload hat je nach Erzeuger 1 oder 3 Felder; immerkeylesen.- Hub 2.x verwaltet Nutzer/Gruppen über Keycloak-Admin-API; für den Unlock irrelevant.
15. Lizenzdurchsetzung (neutral beschrieben)¶
- Server-seitig: Lizenz = ES512-JWT von
api.cryptomator.org, geprüft gegen eingebauten Public Key plus Zertifikatskette (CN „Hub License Issuer CA (Prod)“), gebunden an die Hub ID; Entitlements bestimmen Sitze, Audit-Aufbewahrung, Notfallzugriff. Durchsetzung nur über HTTP 402 (Abschnitt 8). - Desktop: prüft keine Hub-Lizenz; reagiert nur auf 402 mit einem Hinweisdialog. (Die
Desktop-Supporter-Zertifikate (
common/LicenseChecker) sind davon unabhängig.) - iOS: wertet Header
Hub-iOS-Licenseaus – ein ES512-JWT (aud„Cryptomator for iOS“, beobachtet im Test-Stack), das die App selbst gegen einen eingebauten Schlüssel prüft; fehlt er, Fallback aufHub-Subscription-State(ACTIVE/sonst inaktiv). Inaktiv bedeutet vermutlich eingeschränkte Funktionen (z. B. nur Lesen) (ungeprüft). Android analog mitHub-Android-License(ANDHubRepositoryImpl.resolveSubscriptionState).desktopLicense(aud „Cryptomator“) wird in/configgeliefert, aber im Desktop-Code nicht ausgewertet (beobachtet: nirgends referenziert). - Für den eigenen Server: keine 402, keine Lizenz-Header. Mobile Apps behandeln Hub-Tresore dann als „ohne aktives Abo“ – es werden keine Lizenzen nachgebildet.
16. Audit-Log und Notfallzugriff (Kurzüberblick)¶
Audit-Ereignisse (HUB entities/events/*): DeviceRegistered, DeviceRemoved, VaultCreated, VaultUpdated,
VaultMemberAdded/Removed/Updated, VaultAccessGranted, VaultKeyRetrieved (mit Ergebnis, IP, Device-ID),
VaultOwnershipClaimed, UserKeysChange, UserSetupCodeChange, UserAccountReset, SignedWotId,
SettingWotUpdate, EmergencyAccessSetup, EmergencyAccessSettingsUpdated, EmergencyAccessRecovery
Started/Approved/Completed/Aborted. Abruf GET /auditlog (admin, lizenzabhängig). Für Kompatibilität nicht
nötig; Device-lastAccessTime/lastIpAddress aber schon (UI-Anzeige).
Notfallzugriff: Masterkey wird bei der Erstellung in n Anteile für Ratsmitglieder geteilt
(emergencyKeyShares = UserID→JWE, Schwelle requiredEmergencyKeyShares). Wiederherstellung über
Prozesse PUT /emergency-access/{processId} (Felder id, vaultId, type, details, requiredKeyShares
≥2, processPublicKey, recoveredKeyShares {UserID: {processPrivateKey JWE, unrecoveredKeyShare JWE,
…}}), POST …/recovered-key-shares, DELETE …/complete, DELETE …/abort, GET /emergency-access,
GET /emergency-access/{vaultId}. Ratsmitglieder dürfen dann Owner-Endpunkte nutzen (Bypass in 8).
Algorithmus der Anteilsbildung nicht untersucht (ungeprüft) – für einen minimalen Server weglassen.
16a. Web of Trust (Hub ≥ 1.4)¶
Zweck: Ein Owner soll vor dem Verteilen eines Vault Keys erkennen können, ob der vom Server gelieferte
ecdhPublicKey eines Mitglieds wirklich diesem Mitglied gehört (Schutz gegen einen Server, der fremde
Schlüssel unterschiebt). Dazu signieren Nutzer gegenseitig die öffentlichen Schlüssel anderer Nutzer,
nachdem sie deren Fingerprint außerhalb von Hub abgeglichen haben. Der Server speichert die Signaturen
opak und berechnet nur Ketten; jede kryptographische Prüfung geschieht im Client.
Der Cryptomator-Desktop nutzt WoT nicht –
weder die Endpunkte noch den ECDSA-Schlüssel (DESK develop: keine Treffer für die Endpunkte oder
ecdsaPublicKey). iOS/Android wurden dafür nicht geprüft (ungeprüft, vermutlich ebenso). WoT ist reine
Web-UI-Funktion.
16a.1 Signaturformat („A vertraut B“)¶
(HUB-FE common/wot.ts sign, common/jwt.ts)
- Kompakte JWS-Serialisierung
B64URL(Header) "." B64URL(Payload) "." B64URL(Signatur), alle drei Teile Base64url ohne Padding. Header und Payload sind UTF-8-JSON (so, wieJSON.stringifysie erzeugt: ohne Leerzeichen). - Algorithmus ES384: ECDSA über P-384 mit SHA-384 über die ASCII-Bytes von
B64URL(Header) "." B64URL(Payload); Signatur im JOSE-Format r ‖ s (je 48 Byte, zusammen 96 Byte, kein DER). - Signierschlüssel: der ECDSA-Privatschlüssel von A (aus A's
privateKeys-JWE, FeldecdsaPrivateKey, im Browser über den Geräteschlüssel des Browsers entschlüsselt). - Header – enthält die Metadaten (nicht der Payload!), Feldreihenfolge beim Erzeugen:
alg="ES384",typ="JWT",b64=true(JSON-Boolean),iss= Hub-User-ID von A (Signierer),sub= Hub-User-ID von B (signierter Nutzer),iat= Unix-Zeit in Sekunden (Ganzzahl). Keinkid, keinjwk, keincrit.b64: trueist der RFC-7797-Standardfall und ändert nichts an der Kodierung. - Payload – genau zwei Felder in dieser Reihenfolge:
ecdhPublicKey,ecdsaPublicKey. Werte sind die unveränderten Strings aus B'sUserDto(Base64 Standard mit Padding über SPKI-DER, siehe 3.1). Es werden keine Claims in den Payload geschrieben. - Der Client signiert nur, wenn B beide Schlüssel besitzt; sonst Fehler.
Beispiel (erfundene, gekürzte Werte):
Header: {"alg":"ES384","typ":"JWT","b64":true,"iss":"3f2a…-alice","sub":"9c41…-bob","iat":1760000000}
Payload: {"ecdhPublicKey":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE…","ecdsaPublicKey":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE…"}
JWS: eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCIsImI2NCI6dHJ1ZSwiaXNzIjoi….eyJlY2RoUHVibGljS2V5Ijoi….<128 Zeichen Signatur>
Hinweise für eine eigene Implementierung: Beim Prüfen immer über die empfangenen kodierten Teile rechnen, nie neu serialisieren. Beim Erzeugen die obige Feldreihenfolge einhalten, damit die Web-UI und Audit-Ansicht identische Ergebnisse sehen (Pflicht ist sie nicht, da die Web-UI nur die Signatur und den Payload prüft).
16a.2 Serverseite¶
Tabelle wot (HUB entities/WotEntry, Migration V16__WoT.sql): Primärschlüssel
(user_id = signierter Nutzer, signer_id = Signierer), Spalte signature (Text, die JWS unverändert).
Beide IDs sind Fremdschlüssel auf die Nutzertabelle mit Löschkaskade (Nutzer gelöscht → seine Einträge
als Signierer und als Signierter verschwinden). Pro Paar (Signierer, Signierter) gibt es höchstens eine
Signatur; ein erneutes Signieren überschreibt die alte.
PUT /api/users/trusted/{userId} (HUB-API UsersResource, Rolle user):
- Request-Body: die kompakte JWS als Klartext, Content-Type: text/plain (kein JSON, keine
Anführungszeichen). Body darf nicht fehlen.
- Signierer = sub des Bearer Tokens (aufrufender Nutzer), Signierter = {userId} aus dem Pfad.
- Keine Validierung: Der Server parst die JWS nicht, prüft weder Signatur noch iss/sub/Payload
und auch nicht, ob {userId} Schlüssel hat. Beliebiger Text wird gespeichert (die Server-Tests speichern
z. B. Freitext). Selbstsignatur ({userId} = eigener Nutzer) wird nicht abgewiesen.
- Antwort 204 ohne Body. Fehlerfälle nicht ausdrücklich behandelt: aufrufender Nutzer ohne DB-Zeile
bzw. unbekannte {userId} (Fremdschlüssel) führen zu einem Serverfehler (vermutlich 500, ungeprüft).
- Audit-Ereignis SignedWotId mit userId (signiert), signerId, signerKey = der zum Zeitpunkt des
Aufrufs gespeicherte ecdsaPublicKey des Signierers, signature = die JWS.
- Die Web-UI ruft danach sofort GET /users/trusted/{userId} auf, um die neue Kette zu erhalten.
Ketten-Berechnung (View effective_wot, HUB entities/EffectiveWot): rekursive Suche über wot:
- Start: jeder Eintrag (Signierer S → Signierter U) ist eine Kette der Tiefe 0 mit der Signaturliste
[sig(S→U)] und der Signiererliste [S].
- Erweiterung: Kette (T … → U, Tiefe d) wird um einen Eintrag (U → V) verlängert, falls d < wotMaxDepth
und V nicht in der bisherigen Signiererliste vorkommt (Schleifenschutz). Neue Signaturliste =
alte Liste + sig(U→V), Signiererliste + U, Tiefe d+1.
- Ergebnis je Paar (vertrauender Nutzer T = erster Signierer, vertrauter Nutzer V): nur die kürzeste
Kette (geringste Tiefe). Bei mehreren gleich kurzen Ketten ist die Auswahl nicht festgelegt.
- Kettenlänge = Tiefe + 1, also höchstens wotMaxDepth + 1 Signaturen. wotMaxDepth = 0 heißt: nur
direkte Signaturen.
- Reihenfolge der signatureChain: vom anfragenden Nutzer zum Ziel. Element 0 ist von T signiert
(über den ersten Zwischennutzer bzw. direkt über das Ziel), das letzte Element signiert die Schlüssel
des Ziels.
- Eine Selbstsignatur (T → T) erscheint als Paar (T, T) mit Kette der Länge 1; ansonsten taucht T nie als
eigenes Ziel auf.
GET /api/users/trusted (Rolle user, kein Caching): 200, JSON-Array aller Paare mit T = aufrufender
Nutzer: [{"trustedUserId": "<id>", "signatureChain": ["<JWS>", …]}, …]. Leeres Array, wenn keine.
GET /api/users/trusted/{userId} (Rolle user, kein Caching): 200 mit einem Objekt
{"trustedUserId", "signatureChain"} für T = aufrufender Nutzer, V = {userId}; 404, wenn keine
Kette mit höchstens wotMaxDepth + 1 Signaturen existiert (auch für unbekannte IDs).
Beispiel: A signiert B, B signiert C, wotMaxDepth = 3. A erhält
[{"trustedUserId":"B","signatureChain":["<A→B>"]},{"trustedUserId":"C","signatureChain":["<A→B>","<B→C>"]}].
16a.3 Prüfung im Client (Web-UI)¶
(HUB-FE common/wot.ts verify, components/TrustDetails.vue)
Eingaben: signatureChain (vom Server), der Ziel-Nutzer mit seinen aktuellen ecdhPublicKey und
ecdsaPublicKey (aus der jeweiligen Nutzer-/Mitgliederliste) und der eigene ECDSA-Public-Key. Den
eigenen Schlüssel nimmt die Web-UI aus dem lokal entschlüsselten Schlüsselpaar (nicht aus dem vom Server
gelieferten ecdsaPublicKey – sonst könnte der Server die Prüfung aushebeln).
Algorithmus:
1. vertrauter_schlüssel := eigener ECDSA-Public-Key.
2. Für jedes Element jws_i der Kette in Reihenfolge:
a. Header dekodieren; alg muss ES384 sein, sonst Fehler.
b. ES384-Signatur von jws_i mit vertrauter_schlüssel prüfen; ungültig → Fehler.
c. Payload dekodieren (JSON mit ecdhPublicKey, ecdsaPublicKey).
d. Ist jws_i nicht das letzte Element: vertrauter_schlüssel := der im Payload stehende
ecdsaPublicKey (Base64 → SPKI-DER → P-384-ECDSA-Schlüssel) und weiter mit dem nächsten Element.
(Der Schlüssel des Zwischennutzers kommt also aus der signierten Nutzlast, nicht vom Server.)
e. Ist jws_i das letzte Element: Beide Payload-Felder müssen als Strings exakt gleich den aktuellen
Schlüsseln des Ziel-Nutzers sein; sonst Fehler.
3. Kein Fehler → Kette gültig.
Nicht geprüft werden: iss, sub, typ, b64, iat (weder Alter noch Zukunft), Kettenlänge gegen
wotMaxDepth (das begrenzt nur der Server) und ob die Signierer der Zwischenglieder zu den Nutzer-IDs
passen. Eine leere Kette wird nicht gesondert behandelt (der Server liefert keine).
Empfehlung für Krywerk: zusätzlich sub des letzten Glieds = Ziel-ID und iss von Glied 0 = eigene ID
prüfen – das ist strenger als Hub, aber mit Hub-erzeugten Signaturen verträglich.
Vertrauensstufe (nur Anzeige, TrustDetails):
- Ziel = man selbst → Stufe 0, angezeigt als „Trust Level 100 %“.
- Gültige Kette der Länge n → Stufe n, angezeigt als Prozentwert 1/n (100 %, 50 %, 33 %, …), grünes
Schild („Verified“).
- Keine Kette (404 bzw. nicht in der Liste), Ziel ohne beide Schlüssel, oder Prüfung fehlgeschlagen →
Stufe −1 „Unverified“, rotes Schild.
- Hat das Ziel noch keinen Account eingerichtet, erscheint der Hinweis, dass der Nutzer nicht eingerichtet
ist; Signieren ist dann nicht möglich. Für alle anderen (außer sich selbst) gibt es die Schaltfläche
„Check Identity…“, auch wenn bereits eine Kette besteht (direktes Signieren verkürzt die Kette auf 1).
16a.4 Fingerprint und Identitätsprüfung¶
(HUB-FE common/wot.ts computeFingerprint, common/crypto.ts JWK-Thumbprint,
components/UserkeyFingerprint.vue, components/SignUserKeysDialog.vue)
Fingerprint eines Nutzers:
1. Für ecdhPublicKey und ecdsaPublicKey je den JWK-Thumbprint nach RFC 7638 bilden: SHA-256 über
die UTF-8-Bytes von {"crv":"P-384","kty":"EC","x":"<x>","y":"<y>"} (genau diese Reihenfolge, keine
Leerzeichen), x/y = 48-Byte-Big-Endian-Koordinaten in Base64url ohne Padding. Beide Schlüssel
ergeben trotz verschiedener Verwendung denselben Thumbprint-Aufbau (kein alg/use enthalten).
2. Thumbprint(ECDH) ‖ Thumbprint(ECDSA) = 64 Byte.
3. SHA-256 darüber → 32 Byte → 64 Hex-Zeichen in Großbuchstaben.
4. Anzeige: Blöcke zu 8 Zeichen, durch je ein Leerzeichen getrennt (8 Blöcke).
Der eigene Fingerprint steht im Nutzerprofil („User Key Fingerprint“); er wird über einen anderen Kanal an den Prüfenden weitergegeben.
Dialog „Verify {Name}'s Identity“ (A prüft B):
- A tippt die ersten wotIdVerifyLen Zeichen von B's Fingerprint ein. Sobald die Eingabe mindestens
wotIdVerifyLen Zeichen lang ist und der erwartete Fingerprint (ohne Leerzeichen, Großbuchstaben) mit
der Eingabe beginnt (Vergleich exakt, d. h. groß geschrieben und ohne Leerzeichen), füllt die UI den
vollständigen formatierten Fingerprint ein. Bestätigen ist erst möglich, wenn die Eingabe ohne
Leerzeichen dem vollständigen Fingerprint entspricht (also nach dem Autovervollständigen oder bei
vollständiger manueller Eingabe).
- wotIdVerifyLen = 0: Feld ist vorbefüllt und schreibgeschützt; A bestätigt nur noch.
- Ohne geladene Einstellung gilt im Dialog vorübergehend 64 (alle Zeichen).
- Bestätigen → Signatur nach 16a.1 erzeugen → PUT /users/trusted/{B} → GET /users/trusted/{B} →
Anzeige aktualisieren.
16a.5 Einstellungen¶
(Settings, HUB-API SettingsResource, HUB-FE components/AdminSettings.vue)
| Feld | Standard | Bereich Server | Bereich Admin-UI | Bedeutung |
|---|---|---|---|---|
wotMaxDepth |
3 | 0–9 (Validierung + DB-Constraint) | 0–9 | Maximale Zahl zusätzlicher Glieder; Ketten haben höchstens wotMaxDepth + 1 Signaturen |
wotIdVerifyLen |
2 | ≥ 0 (keine Obergrenze) | 0–64 | Zahl der Fingerprint-Zeichen, die beim Prüfen eingetippt werden müssen |
Lesen über GET /settings (jeder Nutzer), Ändern über PUT /settings (admin, kompletter Body). Ändert
sich einer der beiden Werte, entsteht das Audit-Ereignis SettingWotUpdate (updatedBy,
wotMaxDepth, wotIdVerifyLen). Eine Änderung von wotMaxDepth wirkt sofort auf alle Abfragen, da die
Ketten bei jeder Abfrage neu berechnet werden.
16a.6 Verwendung in der Web-UI¶
- Vault-Details (Mitgliederliste), Zugriff gewähren (
GrantPermissionDialog, Liste der Nutzer, die einen Token bekommen) und Auswahl des Notfall-Rats (Admin-Einstellungen Notfallzugriff, Einrichtung/Dialog Notfallzugriff überMultiUserSelectInputGroup): neben jedem Nutzer (nicht bei Gruppen) das Schild aus 16a.3 mit Popover und „Check Identity…“. Die Liste der Ketten kommt jeweils ausGET /users/trusted. - Rein informativ: Hub blockiert nichts. Der Owner kann Zugriff auch an „Unverified“-Nutzer gewähren, Notfall-Rat-Mitglieder können ungeprüft sein. Es gibt keine zusätzliche Warnung außer dem roten Schild.
- Audit-Log (
AuditLogDetailsSignedWotId): prüft die gespeicherte JWS mit dem im Ereignis gespeichertensignerKeyund klassifiziert: „noch gültig“ (Signierer-Schlüssel unverändert und Fingerprint der signierten Schlüssel = aktueller Fingerprint des Ziels), „Schlüssel des Signierers geändert“, „Schlüssel des Ziels geändert“ oder „ungültig“ (Signatur fehlerhaft).
16a.7 Schlüsselwechsel und Konto-Reset¶
POST /users/me/resetund neue Schlüssel überPUT /users/melöschen keinewot-Einträge – weder die vom Nutzer erstellten noch die über ihn. Es gibt keine serverseitige Invalidierung.- Faktische Wirkung durch die Client-Prüfung:
- Signaturen über den Nutzer passen nicht mehr zu seinen neuen Schlüsseln (Schritt 2e) → er wird für alle anderen „Unverified“, bis ihn jemand neu signiert (überschreibt die alte Zeile).
- Ketten, die der Nutzer selbst als Erster signiert hat, scheitern für ihn in Schritt 2b, weil sein neuer ECDSA-Schlüssel die alten Signaturen nicht bestätigt → er muss andere neu signieren.
- Glieder, die der Nutzer als Zwischenglied mit seinem alten Schlüssel signiert hat, bleiben für
Dritte gültig, solange das vorherige Glied seinen alten
ecdsaPublicKeyenthält (der Prüfschlüssel kommt aus der Nutzlast). Signiert er danach neu, ersetzt die neue Signatur (neuer Schlüssel) die alte Zeile, und Ketten über ihn scheitern bis zur Neusignierung seiner Schlüssel durch den Vorgänger. - Da der Server nur die kürzeste Kette liefert, kann eine ungültige kurze Kette eine gültige längere verdecken; die Web-UI fragt keine Alternativen ab.
16a.8 Umsetzung in Krywerk (Empfehlung)¶
- Server: Tabelle wie oben,
PUTspeichert opak (optional zusätzlich Signatur gegen den aktuellenecdsaPublicKeydes Aufrufers prüfen und 400 liefern – verträglich mit der Web-UI, aber strenger als Hub); Ketten per Breitensuche mit Schleifenschutz und Tiefenlimit, kürzeste Kette je Ziel, Reihenfolge vom Anfragenden zum Ziel; 404 bei fehlender Kette; Audit optional. - Client: Signieren nach 16a.1 (ES384, Header mit
iss/sub/iat, Payload = exakte Schlüsselstrings), Prüfen nach 16a.3 mit dem lokal gehaltenen eigenen ECDSA-Public-Key, Fingerprint nach 16a.4 (Testvektor: Fingerprint aus der Profilseite der Web-UI mit eigener Berechnung vergleichen).
17. Minimaler kompatibler Server¶
17a. Offizieller Desktop entsperrt und registriert Geräte¶
Neben einem OIDC-Provider (öffentlicher Client mit PKCE S256, Redirect http://127.0.0.1:*/*, Realm-Rollen
im Token oder eigenes Rollenmodell) braucht es:
| Endpunkt | Pflichtverhalten |
|---|---|
GET /api/config (ohne Auth) |
200, JSON mit mindestens apiLevel: 4 (Desktop braucht ≥1, iOS ≥2) |
GET /api/users/me (+ Query withDevices tolerieren) |
200 mit name, publicKey, privateKey (Aliase!), setupCode, id; Nutzer bei Bedarf automatisch anlegen |
GET /api/devices/{id} |
200 {name, userPrivateKey, …} für eigenes Gerät, sonst 404 |
PUT /api/devices/{id} |
Body speichern, 201; 409 wenn die ID einem anderen Nutzer gehört |
GET /api/vaults/{id}/access-token |
403/410/449/200 gemäß 6.3; Hub-Device-ID für lastAccess auswerten |
GET /api/devices/{id}/legacy-access-tokens |
optional; 200 {} oder 404 (Desktop überspringt) |
<origin>/app/unlock-success, <origin>/app/unlock-error, <origin>/app/profile |
beliebige HTML-Seiten auf demselben Origin wie die API |
17b. Krywerk statt Web-UI (Account, Tresore, Mitglieder)¶
| Zweck | Endpunkte |
|---|---|
| Account einrichten / Setup Code ändern | PUT /api/users/me mit ecdhPublicKey, ecdsaPublicKey, privateKeys (B, Felder key+ecdhPrivateKey+ecdsaPrivateKey), setupCode (A an sich selbst) |
| Account zurücksetzen | POST /api/users/me/reset |
| Geräte auflisten/entfernen | GET /api/users/me?withDevices=true, DELETE /api/devices/{id} |
| Tresor anlegen | PUT /api/vaults/{id} (Rolle create-vaults), POST /api/vaults/{id}/access-tokens |
| Tresore auflisten/ansehen | GET /api/vaults/accessible, GET /api/vaults/{id} |
| Mitglieder | GET /api/authorities/search, GET /api/vaults/{id}/members, PUT /api/vaults/{id}/users/{uid}?role=, PUT /api/vaults/{id}/groups/{gid}?role=, DELETE /api/vaults/{id}/authority/{aid} (alternativ PUT /api/vaults/{id}/members) |
| Zugriff gewähren | GET /api/vaults/{id}/users-requiring-access-grant, GET /api/vaults/{id}/access-token?evenIfArchived=true, POST /api/vaults/{id}/access-tokens |
| Archivieren / Löschen | PUT /api/vaults/{id}/archived, DELETE /api/vaults/{id} |
Nicht nötig: Lizenz, Billing, Audit-Abruf, WoT (optional, Abschnitt 16a), Notfallzugriff, Legacy-Endpunkte, Admin-Verwaltung (Nutzer/Gruppen können direkt aus dem IdP synchronisiert werden).
Serverseitige Invarianten: (1) Ersteller wird OWNER; (2) effektiver Zugriff = direkt ∪ über (verschachtelte)
Gruppen; (3) Access Token zählt nur bei effektivem Zugriff; (4) 449 (nur bei effektiver Mitgliedschaft, sonst 403) vor Token-Prüfung, wenn
ecdhPublicKey fehlt; (5) archivierte Vaults → 410 (außer evenIfArchived=true), bleiben aber für
Owner verwaltbar; (6) Geräte gehören genau einem Nutzer, fremde Geräte sind „404“; (7) alle Schlüsselfelder
opak speichern, nur Form validieren; (8) Owner-only-Endpunkte prüfen die effektive Rolle.
18. Hinweise für die Rust-Implementierung¶
Primitive (alle reine Rust-Crates, kein OpenSSL nötig):
| Zweck | Vorschlag |
|---|---|
| P-384 ECDH, Schlüsselgenerierung, SPKI/PKCS#8-DER | p384 (Features ecdh, ecdsa, pkcs8, pem), elliptic-curve (jwk für epk) |
| ES384 (WoT, Claim-Proof) | p384::ecdsa (JWS-Signatur im Format r‖s, 96 Byte) |
| Concat KDF | selbst implementieren (eine SHA-256-Runde, sha2) – trivial und gut testbar |
| AES-256-GCM | aes-gcm |
| PBES2 | pbkdf2 + hmac + sha2 (SHA-512), aes-kw (RFC 3394) |
| Base64 | base64 mit STANDARD (Schlüssel/Payloads) und URL_SAFE_NO_PAD (JOSE) |
| HS256 vault.cryptomator | bereits in Krywerk vorhanden (hmac/sha2) |
| OIDC-Client (Desktop-Flow) | oauth2 (PKCE, Code-Austausch) + kleiner Loopback-Server auf 127.0.0.1:0 (tokio/hyper oder blockierend tiny_http); Browser öffnen mit open/webbrowser |
| Token-Prüfung (Server) | jsonwebtoken mit JWKS-Cache (RS256/ES256 je nach IdP) oder openidconnect |
| Recovery Key | crc32fast; Wortliste nicht aus dem Hub-Repo kopieren, sondern als Daten mit Lizenzklärung beziehen (offene Frage) |
| Device-Key-Speicher | keyring (System-Schlüsselbund) für Passwort/Schlüssel, Datei mit eigenem Format (kein PKCS#12 nötig) |
| Geheimnisse | zeroize/secrecy für Masterkey, Privkeys, Setup Code, Tokens |
Fertige JOSE-Crates (josekit braucht OpenSSL; jose-jwe-Unterstützung für ECDH-ES/PBES2 lückenhaft) –
eigene schmale Implementierung der zwei Varianten ist vermutlich robuster. Testvektoren: Hub-JWEs aus dem
Test-Stack erzeugen und gegen Krywerk entschlüsseln (und umgekehrt mit dem offiziellen Desktop).
Sicherheit: Bearer Tokens, Setup Code, Masterkey, private Schlüssel und entschlüsselte Payloads niemals
loggen (auch nicht in Debug-Ausgaben oder Fehlermeldungen; HTTP-Clients ohne Header-Logging). Host-
Vertrauensprüfung wie 6.1 übernehmen (Header vor Masterkey-Erhalt nicht authentisch). Nach Erhalt des
Masterkeys die vault.cryptomator-Signatur zwingend prüfen und jti mit der Vault-ID aus der kid
vergleichen (der Desktop tut Letzteres nicht explizit, ungeprüft). p2c beim Entschlüsseln nach oben
begrenzen (DoS), aber ≥ 1 000 000 zulassen. Nur HTTPS außer für localhost.
19. Offene Fragen / Unsicherheiten¶
- Genaues Quarkus-Mapping von Rollen (
realm_access.rolesvs.groups) und Issuer-Prüfung – Verhalten beobachtet, Konfiguration nicht vollständig nachvollzogen. Audience-Prüfung findet nicht statt. Ein vorhandener Claimgroupsersetzt die Rollen (beobachtet, siehe §8). - Filter-Reihenfolge 402 vs. 403 bei
access-token; im Test-Stack (mit Lizenz) kam für fremde Vault-ID 403. - Ob Nimbus (Desktop) eine Obergrenze für
p2chat (ungeprüft); Hub nutzt 1 000 000. - iOS/Android nur überflogen: genaue Bedeutung von „inaktivem Abo“, vollständige Statuscode-Behandlung.
- Lizenz/Bezugsquelle der 4096-Wörter-Liste für Recovery Keys (Datei liegt im AGPL-Repo) – vor Übernahme klären.
- Exakte Löschkaskaden beim
DELETE /vaults/{id}(Access Tokens, Notfall-Anteile). - Ob Mitglied-Entfernung den Access Token löschen sollte (Hub tut es nicht; Schlüssel ist ohnehin kompromittiert, Rotation gibt es in Format 8 nicht).
- Recovery-Key-Trennzeichen und Normalisierung (Groß/Klein, Mehrfach-Leerzeichen) beim Dekodieren.
- Notfallzugriff (Secret-Sharing-Verfahren) nicht untersucht.
- Hub ≥ 2.2 (UVF, apiLevel 5) noch unveröffentlicht; Verhalten älterer Desktop-Versionen gegenüber einem Server mit apiLevel 5 nicht geprüft.
- WoT: Statuscode von
PUT /users/trusted/{userId}bei unbekannter Ziel-ID bzw. Aufrufer ohne DB-Zeile (vermutlich 500, nicht beobachtet); Auswahl unter gleich kurzen Ketten ist nicht deterministisch.
Hinweis zum Test-Stack: Beim Prüfen wurde versehentlich ein Test-Vault (11111111-…) über
PUT /api/vaults/{id} angelegt und sofort per DELETE wieder entfernt (204). Inzwischen ist im Test-Stack
eine (Trial-)Lizenz aktiv (licenseSetupRequired: false, 5 Sitze).