Skip to content

Recovery key and emergency access for team folders (F9)

Krywerk's own procedure (decision of 2026-10-14): not compatible with the emergency access or the recovery keys of Cryptomator Hub; works with Krywerk Server and Krywerk clients (kw-hub-client, krywerk-hub, krywerk-mount, Krywerk app). Official Cryptomator apps are not affected (they only see normal hub vaults). No Hub/Cryptomator source code was used as a basis; only our own specifications and public standards.

Terms in the user interface: recovery key, emergency access, emergency council (council), emergency approval (approval), emergency request (recovery process).

1. Goals and threat model

  1. Recovery key: the 64-byte master key of a team folder as text that can be typed or printed. Whoever has it can open the team folder without a server, convert it into a password vault or register it again with a (new) server. Intended for total loss of the server and as a sealed deposit with the management.
  2. Emergency access (k of n, four-eyes principle): an emergency council of n people; at least k ≥ 2 must approve so that a person (the requester, who is a council member) gets manager access to a team folder – for example, when all managers have left.

Assumptions: the server is honest but curious (it sees only encrypted data and enforces rules). Individual council members must not be able to achieve anything alone (k ≥ 2). A malicious server can falsify access rights anyway (as in the hub protocol) – but it cannot reconstruct a master key without k shares.

2. Recovery key – text format

  • Bytes: 0x01 (version) ‖ master key (64 B: encKey ‖ macKey) ‖ checksum (5 B) = 70 bytes. Checksum = the first 5 bytes of SHA-256(0x01 ‖ master key).
  • Encoding: Crockford Base32 (alphabet 0123456789ABCDEFGHJKMNPQRSTVWXYZ, MSB first, no padding) → 70 × 8 = 560 bits = 112 characters, in 28 groups of 4 characters, separated by -; displayed in 7 lines of 4 groups.
  • Parsing (tolerant): uppercase; ignore whitespace, line breaks, -, _, .; O→0, I/L→1; other characters = error “invalid character”. Length ≠ 112 → error with the number of characters found. Version ≠ 1 → “unknown version”. Wrong checksum → “typo” (without position).
  • Whether the key belongs to the team folder is shown by verifying the signature of vault.cryptomator (HS256 with the master key) plus jti = vault ID; failure → “does not belong to this team folder”.
  • The key is never stored, logged or sent to the server. It is displayed once on creation; managers can generate it again later (they have the master key).

Example layout (not a real key): 0G4R-…-7Q2M (28 groups).

3. Recovery without a server (client, local)

  • Open: read vault.cryptomator, derive the master key from the recovery key, check the signature + jti, open the vault with the master key (as after unlocking via the server; krywerk-mount --recovery-key).
  • Convert into a password vault: write masterkey.cryptomator with a new password (scrypt as in kw-format), re-sign vault.cryptomator with kid = masterkeyfile:masterkey.cryptomator, jti = previous vault ID, otherwise the same fields (format 8, SIV_GCM, shortening threshold 220). The old file stays as vault.cryptomator.hub-<UTC time> (to revert: rename it). Afterwards every Cryptomator app opens the folder with the password. Crash-safe order: first masterkey.cryptomator (temporary + rename), then back up the old configuration, then write the new configuration.
  • Register again with a server (“server lost”): a signed-in session with the (new) server; create a vault with the same ID (PUT /api/vaults/{id}, role create-vaults; if the ID already exists and you are not OWNER → error), re-sign vault.cryptomator with a new hub header (endpoints from the server's /api/config) (old file kept as vault.cryptomator.hub-<time>), create your own access token. Members must then be added and granted access again.

4. Emergency access – cryptography

  • Shamir secret sharing over GF(2⁸) (reduction polynomial x⁸+x⁴+x³+x+1 = 0x11B), byte by byte over the 64 bytes of the master key; a polynomial of degree k−1 per byte with random coefficients (OS randomness); share i has x = i (1…n), y = 64 bytes. Reconstruction: Lagrange interpolation at x = 0 with exactly k (or more) distinct x. Limits: 2 ≤ k ≤ n ≤ 32.
  • Share payload (JSON, UTF-8): {"krywerkShare":1,"vaultId":"<id>","x":<1..n>,"k":<k>,"y":"<Base64 std, 64 B>"}. Readers check krywerkShare == 1, vaultId = expected ID, k = expected threshold, y 64 bytes.
  • Storage: for each council member, the payload as a JWE ECDH-ES + A256GCM (like access tokens, kw-jose variant A) to their user ECDH key (ecdhPublicKey from users/me or the search). Members without a set-up account cannot join the council.
  • Verification value (so that the server only completes genuine recoveries): proof = Base64url-without-padding( HMAC-SHA256( key = macKey (master key bytes 32..64), msg = UTF-8("krywerk-notfall-v1:" ‖ vaultId) ) ); the server stores only verifier = Base64url-without-padding( SHA-256( UTF-8(proof) ) ).
  • Request: the requester generates a P-384 key pair for the request (process key). Public part: SPKI Base64 (std) processPublicKey; private part as the JWE payload {"key":"<PKCS#8 Base64 std>"}, encrypted (ECDH-ES) to the requester's own user ECDH key → processPrivateKey (so it survives app restarts and device changes).
  • Approval: a council member decrypts their share (own user key), checks vaultId/k, and re-encrypts the same share payload (ECDH-ES) to processPublicKey.
  • Completion: the requester decrypts processPrivateKey, uses it to decrypt all submitted shares, checks them (same vaultId/k, distinct x), reconstructs the master key, checks it against vault.cryptomator (if the folder is available locally) and against the verification value (own calculation), and sends proof plus an access token (master key to their own user ECDH key).

5. Server API (Krywerk-specific, prefix /api/krywerk)

All endpoints require a valid token with the normal user role (like the hub endpoints). Responses are JSON; timestamps ISO 8601 UTC with milliseconds. Errors: 400 (form), 403 (no permission), 404 (vault/request unknown – also for outsiders who are not allowed to see the request: 404 instead of 403 if they are neither OWNER nor council member), 409 (state conflict). The hub fields requiredEmergencyKeyShares/emergencyKeyShares stay untouched (always 0 / {}); the Krywerk data lives in separate tables.

Method + path Who Behavior
PUT /api/krywerk/vaults/{vaultId}/emergency OWNER of the vault Body {"requiredShares":k,"shares":{"<userId>":"<JWE>",…},"verifier":"<b64url>"}. Checks: vault exists (otherwise 404), k ≥ max(2, emergency.min_required_shares), k ≤ n ≤ 32, all userIds are users with ecdhPublicKey (otherwise 400 with a list), JWEs have five parts, verifier is Base64url with 43 characters. Completely replaces an existing setup; open requests for this vault are aborted. Event SETUP. → 200 status object (see below)
DELETE /api/krywerk/vaults/{vaultId}/emergency OWNER Remove the setup (shares, verification value), abort open requests, event REMOVED → 204
GET /api/krywerk/vaults/{vaultId}/emergency OWNER or council Status object: {"vaultId","configured":bool,"requiredShares":k,"council":[{"id","name","email"}],"myShare":"<JWE>"? (council only),"processes":[request short form…]} (open ones and the last 20 completed/aborted ones)
GET /api/krywerk/emergency everyone Overview for the caller: {"council":[{"vaultId","vaultName","requiredShares","councilSize","openProcesses":[request short form…]}],"owned":[{"vaultId","vaultName","configured":bool,"openProcesses":[…]}]} – vaults whose council they sit on, and vaults they manage
POST /api/krywerk/emergency/processes Council of the vault Body {"vaultId","reason","processPublicKey","processPrivateKey"}; reason 1–1000 characters without control characters except line breaks; only one open request per vault (otherwise 409); vault without setup → 409. Event STARTED → 201 request (long form)
GET /api/krywerk/emergency/processes/{processId} Council or OWNER of the vault Request (long form); for the requester only, additionally processPrivateKey and recoveredShares {userId: JWE}
POST /api/krywerk/emergency/processes/{processId}/approvals Council Body {"share":"<JWE>"}; open only (otherwise 409), once per person (409). Event APPROVED → 200 request
POST /api/krywerk/emergency/processes/{processId}/complete Requester Body {"proof","accessToken"}; open (409), approvals ≥ k (409), SHA-256(proof) = verifier (otherwise 403, event PROOF_FAILED). Then: the requester becomes OWNER of the vault (direct entry, an existing role becomes OWNER), access token stored/replaced, status COMPLETED, event COMPLETED → 200 request
DELETE /api/krywerk/emergency/processes/{processId} Requester, council or OWNER open → ABORTED, event ABORTED → 200 request; not open → 409

Request short form: {"id","vaultId","vaultName","requester":{"id","name"},"reason","requiredShares","approvals":<count>,"status":"OPEN|COMPLETED|ABORTED|EXPIRED","creationTime","completionTime"?}. Long form: additionally "processPublicKey", "approvedBy":[{"id","name","time"}], "events":[{"time","actor":{"id","name"}?,"action"}] (actions: SETUP, REMOVED, STARTED, APPROVED, PROOF_FAILED, COMPLETED, ABORTED, EXPIRED; only the events of the request, or for the status object those of the vault).

Expiry: open requests older than emergency.process_max_age_days (default 14) count as EXPIRED (checked and stored when reading or before every change, event EXPIRED).

Deletion cascades: deleting a vault → setup, requests and events are gone. Resetting an account (users/me/reset) → the person's own shares stay stored (they can then no longer be decrypted; k of n tolerates that).

Configuration (server): emergency.min_required_shares (KRYWERK_SERVER_EMERGENCY_MIN_SHARES, default 2, values < 2 are treated as 2), emergency.process_max_age_days (KRYWERK_SERVER_EMERGENCY_MAX_AGE_DAYS, default 14).

6. Client (kw-hub-client, krywerk-hub, krywerk-mount, Krywerk app)

  • kw_hub_client::recovery: encoding/decoding the recovery key, opening, converting, registering again.
  • kw_hub_client::shamir: splitting/combining (tests: all k-subsets reconstruct, k−1 shares reveal nothing verifiable, duplicate x rejected).
  • HubSession: recovery_key(vault), emergency_status(vault_id), emergency_setup(vault, k, council), emergency_remove(vault_id), emergency_overview(), emergency_start(vault_id, reason), emergency_approve(process_id), emergency_complete(process_id, vault: Option<&VaultRef>), emergency_abort(process_id).
  • krywerk-hub (the subcommands are German): wiederherstellungsschluessel <ordner> (show recovery key), wiederherstellen <ordner> [--passwort-tresor | --server URL] (recover), notfall einrichten <ordner> --schwelle K <wer…> (set up emergency access), notfall status [<ordner>], notfall entfernen <ordner> (remove), notfall beantragen <ordner> --grund TEXT (request), notfall zustimmen <antrag-id> (approve), notfall abschliessen <antrag-id> [<ordner>] (complete), notfall abbrechen <antrag-id> (abort). The recovery key is read without echo (or from KRYWERK_RECOVERY_KEY for tests); it is shown only on stdout, never in logs.
  • krywerk-mount <team-folder> --recovery-key: mount without a server.
  • Krywerk app: when creating a team folder, show the recovery key once (copy, save, print, “I have stored it safely” as with the unlock code); “Manage”: show recovery key, set up/remove emergency access; “Emergency access” card: view requests, approve, complete; open requests are counted in the system tray (menu bar on macOS).