Zum Inhalt springen

E2EE architecture

Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.

The server is intentionally blind to plaintext when E2EE is enabled. It stores ciphertext payloads, wrapped key material, and metadata needed to route those bytes — nothing more. All encryption and decryption happens on the client.

For the user-facing model (key hierarchy, recovery codes, formats), see End-to-end encryption. This page focuses on the server side.

All E2EE code lives under src/Pia.Server/E2EE/.

File Responsibility
DeviceEndpoints.cs REST API for device register, list, approve, revoke; get/set wrapped UMK
RecoveryEndpoints.cs Store / retrieve recovery-wrapped UMK, activate via recovery code
OnboardingSessionService.cs In-memory session store for device pairing — 10-minute expiry, 32-byte random server challenges

OnboardingSessionService is registered as Singleton and is in-memory by design: pairing sessions are short-lived and don’t need to survive a restart. In a load-balanced deployment, route all /api/e2ee/* traffic for a given pairing to one node, or replace the implementation with a shared store.

Entity PK What it holds
ServerDevice DeviceId Public agreement key, public signing key, device fingerprint, status (Pending / Active / Revoked)
ServerWrappedUmk (UserId, DeviceId) UMK wrapped for one specific device via ECDH-derived KEK, with the approving device’s signature
PiaUser (E2EE columns) — IsE2EEEnabled, UmkVersion, RecoveryWrappedUmkCiphertext, RecoveryKdf*, RecoveryWrapVersion

Note what isn’t there: no plaintext UMK, no plaintext DEKs, no plaintext per-record payloads.

Method Path Purpose
POST /api/e2ee/devices/register Register a new device as Pending (stores its public keys)
GET /api/e2ee/devices List the user’s devices
GET /api/e2ee/devices/{deviceId}/status Poll one device’s status while it waits for approval
POST /api/e2ee/devices/approve Existing device wraps UMK for a target device
POST /api/e2ee/devices/{deviceId}/revoke Revoke a device
GET /api/e2ee/devices/{deviceId}/wrapped-umk Fetch the UMK wrapped for that device
POST /api/e2ee/devices/{deviceId}/wrapped-umk Store a UMK wrapped for that device
Method Path Purpose
POST /api/e2ee/recovery/wrapped-umk Store recovery-wrapped UMK (Argon2id-derived KEK)
GET /api/e2ee/recovery/wrapped-umk Retrieve it
POST /api/e2ee/recovery/activate Activate a pending device with the UMK unwrapped from the recovery code

/recovery/activate takes the UMK wrapped for the new device together with a proof of possession derived from the UMK, and accepts them only for the caller’s own pending device inside a live onboarding session. The UMK never leaves the device in plaintext during this exchange.

  1. Client generates UMK + ECDH (P-256) + ECDSA (P-256) keypairs.
  2. Client self-wraps the UMK using its own ECDH agreement key.
  3. Client generates a recovery code (Base32, 128-bit entropy).
  4. Client wraps the UMK with the recovery-derived KEK (Argon2id + AES-GCM) and uploads the blob via /recovery/wrapped-umk.
  5. Client registers the device via /devices/register. Server creates the ServerDevice row in Active status (first device is auto-active).
  1. New device registers as Pending (uploads public keys).
  2. Server creates an onboarding session — 10-min expiry, fresh 32-byte challenge.
  3. An existing Active device approves the request: ECDH-wraps the UMK for the new device’s public agreement key, signs the handover with its ECDSA key and uploads both via /devices/approve.
  4. Server verifies the signature against the approver’s stored SigningPublicKey (ECDSA P-256 over SHA-256, IEEE P1363) and answers 400 without writing anything when it is missing or does not verify. It stores signature, signature version and onboarding session with the ServerWrappedUmk and marks the new device Active.
  5. New device fetches its wrapped UMK and the signature via /devices/{deviceId}/wrapped-umk and unwraps locally.
  1. User enters recovery code on a fresh device.
  2. Client derives KEK via Argon2id using the salt and parameters fetched from the server.
  3. Client unwraps the UMK from /recovery/wrapped-umk.
  4. Client computes the proof of possession from the UMK and posts it, with the UMK wrapped for this device, to /recovery/activate.
  5. Server marks a new device Active; client self-wraps the UMK for ongoing use.
  • The UMK is never accepted in plaintext on any endpoint — only as wrapped blobs.
  • All wrapped blobs include AAD that binds them to a context (pia-umk-wrap-v1:{targetDeviceId} for device wraps, pia-dek-wrap-v1:{entityType}:{entityId} for record DEKs). A blob from one context can’t be replayed in another.
  • An approval activates a device only with a valid signature of the approving device. Version 1 covers the onboarding session, the target device and its agreement key; version 2 adds the wrapped UMK and its HKDF salt. DeviceApprovalSignature in Pia.Shared builds both, so server and client check the same bytes. Storing a new wrap via POST /devices/{deviceId}/wrapped-umk drops the stored signature.
  • Onboarding sessions are bound to one account and one device and expire after 10 minutes.
  • Device fingerprint = SHA-256(AgreementPublicKey) truncated to 64 bits, displayed as XXXX-XXXX-XXXX-XXXX. Used in the admin Devices view and in the desktop’s pairing UI.