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.
Server-side components
Section titled “Server-side components”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.
Server entities
Section titled “Server entities”| 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 |
PiaUser (E2EE columns) |
— | IsE2EEEnabled, UmkVersion, RecoveryWrappedUmkCiphertext, RecoveryKdf*, RecoveryWrapVersion |
Note what isn’t there: no plaintext UMK, no plaintext DEKs, no plaintext per-record payloads.
Endpoints
Section titled “Endpoints”Device endpoints
Section titled “Device endpoints”| 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 |
Recovery endpoints
Section titled “Recovery endpoints”| 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 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.
Onboarding workflows
Section titled “Onboarding workflows”First device
Section titled “First device”- Client generates UMK + ECDH (P-256) + ECDSA (P-256) keypairs.
- Client self-wraps the UMK using its own ECDH agreement key.
- Client generates a recovery code (Base32, 128-bit entropy).
- Client wraps the UMK with the recovery-derived KEK (Argon2id + AES-GCM) and uploads the blob via
/recovery/wrapped-umk. - Client registers the device via
/devices/register. Server creates theServerDevicerow inActivestatus (first device is auto-active).
Additional devices
Section titled “Additional devices”- New device registers as
Pending(uploads public keys). - Server creates an onboarding session — 10-min expiry, fresh 32-byte challenge.
- An existing
Activedevice approves the request: ECDH-wraps the UMK for the new device’s public agreement key and uploads the blob via/devices/approve. - New device fetches its wrapped UMK via
/devices/{deviceId}/wrapped-umk, unwraps locally, and is upgraded toActive.
Recovery activation
Section titled “Recovery activation”- User enters recovery code on a fresh device.
- Client derives KEK via Argon2id using the salt and parameters fetched from the server.
- Client unwraps the UMK from
/recovery/wrapped-umk. - Client proves possession by computing the HMAC and posting it to
/recovery/activate. - Server marks a new device
Active; client self-wraps the UMK for ongoing use.
Security invariants the server enforces
Section titled “Security invariants the server enforces”- 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.