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 |
POST | /api/e2ee/devices/approve | Existing device wraps UMK for a target device |
POST | /api/e2ee/devices/revoke | Revoke a device |
GET | /api/e2ee/devices/wrapped-umk/{deviceId} | Fetch the UMK wrapped for this device |
PUT | /api/e2ee/devices/wrapped-umk | Store a UMK wrapped for a 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/wrapped-umk/{deviceId}, 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.