Skip to content

E2EE architecture

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/.

FileResponsibility
DeviceEndpoints.csREST API for device register, list, approve, revoke; get/set wrapped UMK
RecoveryEndpoints.csStore / retrieve recovery-wrapped UMK, activate via recovery code
OnboardingSessionService.csIn-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.

EntityPKWhat it holds
ServerDeviceDeviceIdPublic agreement key, public signing key, device fingerprint, status (Pending / Active / Revoked)
ServerWrappedUmk(UserId, DeviceId)UMK wrapped for one specific device via ECDH-derived KEK
PiaUser (E2EE columns)IsE2EEEnabled, UmkVersion, RecoveryWrappedUmkCiphertext, RecoveryKdf*, RecoveryWrapVersion

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

MethodPathPurpose
POST/api/e2ee/devices/registerRegister a new device as Pending (stores its public keys)
GET/api/e2ee/devicesList the user’s devices
POST/api/e2ee/devices/approveExisting device wraps UMK for a target device
POST/api/e2ee/devices/revokeRevoke a device
GET/api/e2ee/devices/wrapped-umk/{deviceId}Fetch the UMK wrapped for this device
PUT/api/e2ee/devices/wrapped-umkStore a UMK wrapped for a device
MethodPathPurpose
POST/api/e2ee/recovery/wrapped-umkStore recovery-wrapped UMK (Argon2id-derived KEK)
GET/api/e2ee/recovery/wrapped-umkRetrieve it
POST/api/e2ee/recovery/activateActivate a device by proving possession of the UMK derived from the recovery code

/recovery/activate requires an HMAC-SHA256 proof: HMAC(HKDF(UMK, "activation"), serverChallenge). The server compares the proof against its own computation; 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 and uploads the blob via /devices/approve.
  4. New device fetches its wrapped UMK via /devices/wrapped-umk/{deviceId}, unwraps locally, and is upgraded to Active.
  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 proves possession by computing the HMAC and posting it 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.
  • Onboarding sessions use 32-byte random challenges 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.