Architecture overview
Ce contenu n’est pas encore disponible dans votre langue.
Pia Server is an ASP.NET Core 10 Minimal-API service. There are no MVC controllers — endpoints are grouped into modules (auth, sync, AI proxy, devices, recovery) and each module exposes a MapXxxEndpoints() extension method that Program.cs calls.
Module map
Section titled “Module map”| Module | File | Purpose |
|---|---|---|
| AuthEndpoints | Auth/AuthEndpoints.cs | OAuth login, refresh, logout, /auth/me |
| SyncEndpoints | Sync/SyncEndpoints.cs | Cursor-based pull, conflict-aware push, status, audit, quota |
| AiProxyEndpoints | AiProxy/AiProxyEndpoints.cs | Forward client requests to upstream AI providers |
| DeviceEndpoints | E2EE/DeviceEndpoints.cs | Register / approve / revoke E2EE devices, exchange wrapped UMK |
| RecoveryEndpoints | E2EE/RecoveryEndpoints.cs | Store / retrieve recovery-wrapped UMK, activate via recovery code |
Health check at GET /health.
Middleware order
Section titled “Middleware order”UseHsts → UseHttpsRedirection → UseAuthentication → UseAuthorization → UseRateLimiter → EndpointsService registration
Section titled “Service registration”The DI container in Program.cs registers:
| Service | Lifetime | Notes |
|---|---|---|
PiaDbContext | Scoped | EF Core, provider chosen by config |
JwtService | Singleton | HS256, issuer pia-server, audience pia-client |
UserService | Scoped | User CRUD, find-or-create on OAuth callback |
EncryptionService | Singleton | AES-256-GCM with per-user keys via HKDF |
AiProxyService | Scoped | Routes per-mode AI requests to upstream providers |
ConflictResolver | Scoped | Last-write-wins sync conflict resolution |
QuotaService | Scoped | Enforces per-user object caps before push |
SyncService | Scoped | Sync orchestration |
OnboardingSessionService | Singleton | In-memory E2EE pairing sessions (10-min expiry) |
RefreshTokenCleanupService | Hosted | Background prune of expired refresh tokens |
OnboardingSessionService is in-memory by design — pairing sessions are short-lived and don’t need to survive a restart. If you run the server in a load-balanced deployment, route all /api/e2ee/* calls for a given device pairing to the same node, or replace the implementation with a Redis-backed one.
Request flow at a glance
Section titled “Request flow at a glance”A typical authenticated sync request looks like this:
- Caddy terminates TLS and forwards to
pia-server. - The rate limiter applies the
syncpolicy (per-user budget). UseAuthenticationvalidates the JWT and populatesHttpContext.User.- The endpoint handler receives the
SyncPushRequestbody. QuotaService.CheckQuotaAsyncrejects with409 quota_exceededif any cap would be violated.ConflictResolverreconciles per-recordUpdatedAttimestamps.- EF Core writes the changes inside a single transaction.
- The endpoint returns a
SyncPushResponsewith server-assigned IDs and any conflict metadata.
E2EE traffic adds a parallel side-channel: the server stores wrapped UMK material via DeviceEndpoints/RecoveryEndpoints, but never sees plaintext keys.
Configuration
Section titled “Configuration”Configuration is layered (appsettings.json → appsettings.{Environment}.json → environment variables → command-line args). The key sections are:
| Section | Purpose |
|---|---|
Database | Provider (sqlite / postgresql) and connection string |
Jwt | Issuer, audience, signing key, lifetimes |
OAuth | Per-provider client IDs and secrets |
Ai | Default provider + per-mode overrides |
Encryption | Master key (must come from env in production) |
License | Licence file path |
See Configuration for the full reference.
Hardening notes
Section titled “Hardening notes”A few non-obvious hardening choices are worth knowing about so you don’t accidentally undo them in a refactor:
- Email enumeration:
/auth/login/localis timing-padded (300 ms floor) and runs a dummy hash on the unknown-email branch. The error body is identical for “unknown email” and “wrong password”. Don’t introduce branches that return earlier than the floor. /auth/forgot-passwordalways returns 200, regardless of whether the email is registered. Reset tokens are minted only for real users; the response is identical either way./auth/registerintentionally returns409 email_existsfor duplicates (UX trade-off accepted), with rate limiting in front.- OAuth callback collisions: when an OAuth provider brings an email that already exists under a different provider, the server returns
409 email_exists— the same response as/auth/register. This leaks existence but cannot be probed anonymously.