E2EE architecture
Ce contenu n’est pas encore disponible dans votre langue.
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, 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.
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 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.
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, signs the handover with its ECDSA key and uploads both via/devices/approve. - Server verifies the signature against the approver’s stored
SigningPublicKey(ECDSA P-256 over SHA-256, IEEE P1363) and answers400without writing anything when it is missing or does not verify. It stores signature, signature version and onboarding session with theServerWrappedUmkand marks the new deviceActive. - New device fetches its wrapped UMK and the signature via
/devices/{deviceId}/wrapped-umkand unwraps locally.
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 computes the proof of possession from the UMK and posts it, with the UMK wrapped for this device, 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. - 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.
DeviceApprovalSignatureinPia.Sharedbuilds both, so server and client check the same bytes. Storing a new wrap viaPOST /devices/{deviceId}/wrapped-umkdrops 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.