# Proton Auth & Crypto Analysis > Prepared for the hermes-proton shared auth plugin design (T1 architecture spec). > Sources: go-proton-api (src), go-srp, gopenpgp v2, hydroxide (emersion), proton-python-client. --- ## 1. SRP-6a Authentication Flow Proton implements **SRP-6a** (Secure Remote Password protocol) for all client-server authentication. There is no OAuth2, OpenID Connect, or any standard web auth layer — SRP is the only path to an API session. ### 1.1 Protocol Overview ``` Client Server │ │ │ POST /auth/v4/info │ │ { username } │ │ ─────────────────────────────► │ │ │ │ { Version, Modulus (signed), │ │ ServerEphemeral, Salt, │ │ SRPSession } │ │ ◄───────────────────────────── │ │ │ │ Compute SRP proofs: │ │ • HashPassword(password, salt, │ │ modulus, version) │ │ • a = random() │ │ • A = g^a mod N │ │ • u = H(A || B) │ │ • S = (B - k·g^x)^(a + u·x) mod N│ │ • M1 = H(A || B || S) │ │ │ │ POST /auth/v4 │ │ { Username, ClientEphemeral (A), │ │ ClientProof (M1), SRPSession } │ │ ─────────────────────────────► │ │ │ │ { ServerProof, UID, │ │ AccessToken, RefreshToken, │ │ UserID, EventID, ExpiresAt } │ │ ◄───────────────────────────── │ │ │ │ Verify ServerProof = M2 │ │ (H(A || M1 || S)) │ │ │ ``` ### 1.2 Password Hashing (Version-Dependent) Proton's SRP uses **bcrypt + SHA-512** for password pre-hashing before the SRP computation. The hash version is negotiated via the `Version` field in `AuthInfo`. | Version | Method | Key Detail | |---------|--------|------------| | **0** | SHA-512(lower(username) + password) → base64 → V1 | Legacy, pre-V3 session | | **1** | MD5(lower(username)) → hex salt → bcrypt(password, salt) → expandHash(result + modulus) | Legacy | | **2** | Same as V1 after stripping `-._` from username | Legacy | | **3** | bcrypt(password, based64(salt + "proton")) → expandHash(result + modulus) | **Current** (majority of users) | | **4** | Same as V3 | Current (same algorithm) | The `expandHash()` function uses 4x SHA-512 (with appended bytes 0,1,2,3) concatenated to produce a 256-byte digest. **Source:** `go-srp/hash.go` — `HashPassword()` dispatches by version. `go-srp/srp.go` — `NewAuth()` calls `HashPassword()` then generates SRP proofs. ### 1.3 Modulus Verification The SRP modulus `N` is signed using Proton's PGP key. Before any SRP computation: 1. Server returns the modulus as a **clearsigned PGP message** (armored + signature) 2. Client verifies the signature against Proton's hardcoded public key 3. Rejects if extra data follows the signature (`ErrDataAfterModulus`) **Source:** `go-srp/srp.go` — `readClearSignedMessage()`, `modulusPubkey` constant. ### 1.4 Constant-Time Arithmetic `go-srp` uses the `saferith` library (`github.com/cronokirby/saferith`) for all modular exponentiation — **constant-time** big integer operations to prevent timing/side-channel attacks. The classic `math/big` is only used for parameter validation (bit-length checks, Lucas primality test on the modulus). ### 1.5 SRP Safety Checks The Python client (`proton/srp/_pysrp.py`) implements SRP-6a safety checks that the Go library inherits via protocol semantics: - `B mod N == 0` → reject (invalid server) - `u == 0` → reject (collision, tiny probability) - `a` is random 32 bytes, clamped to `[2*bitLength, N-1]` ### 1.6 2FA (Two-Factor Authentication) After the SRP `/auth/v4` call, if the account has 2FA enabled, the `Auth` response includes: ```json { "2FA": { "Enabled": 1, "U2F": null, "TOTP": 1 } } ``` The client must then call: ``` POST /auth/v4/2fa { "TwoFactorCode": "123456" } ``` **Two modes:** - **TOTP** — Time-based one-time password (6-digit code). Sends `TwoFactorCode` in the body. - **U2F/FIDO2** — WebAuthn challenge-response. The `U2F` field in `TwoFactor` carries challenge data (interface{} in Go — typed as `U2FData` in practice). After successful 2FA, the response includes the granted `Scope` (e.g. "payments", "locked", "full"). **Source:** - `go-proton-api/auth.go` — `Auth2FA()` → `POST /auth/v4/2fa` - `hydroxide/protonmail/auth.go` — `AuthTOTP()` implementation - `hydroxide/auth/auth.go` — comment: "Two-factor authentication during re-auth is not supported (error message)" **⚠️ Important:** The hydroxide auth cache does **not** support 2FA during re-authentication (token refresh). If the auth refresh fails with a 2FA requirement, it errors out. The go-proton-api manager handles this via the standard 401 → refresh flow with no special 2FA path for refresh. --- ## 2. Session Management ### 2.1 Token Set A successful authentication returns: | Field | Type | Purpose | |-------|------|---------| | `UID` | string | Session identifier, sent as `x-pm-uid` header | | `AccessToken` | string | Bearer token, sent as `Authorization: Bearer ` | | `RefreshToken` | string | Used to obtain new tokens without re-authentication | | `EventID` | string | Cursor for event polling (mailbox changes) | | `UserID` | string | Internal user identifier | | `ExpiresAt` | time.Time | Token expiry | | `Scope` | string | Granted scope (e.g. "full", "payments", "locked") | | `PasswordMode` | int | 1 = single password, 2 = two-password mode | ### 2.2 Token Lifecycle ``` Authentication │ ▼ ┌──────────────────────────────────────┐ │ UID + AccessToken + RefreshToken │ ← Immutable UID, mutable tokens │ (set on client) │ └──────────────────────────────────────┘ │ ▼ API call with AccessToken │ ├── 200 OK ──► continue │ └── 401 Unauthorized ──► trigger auth refresh │ ▼ POST /auth/v4/refresh { GrantType: "refresh_token", RefreshToken: , ResponseType: "token", RedirectURI: "https://protonmail.ch", State: , UID: , AccessToken: } │ ├── 200 OK ──► update AccessToken + RefreshToken │ retry original request │ └── 422/400 ──► deauth (tokens permanently invalid) fire deauth handlers (session lost, must re-login) ``` **Source:** `go-proton-api/client.go` — `doRes()` retry logic, `authRefresh()`. ### 2.3 Auto-Refresh Mechanics The go-proton-api `Client` wraps every API call with automatic token refresh: 1. Every request includes `x-pm-uid` header + `Authorization: Bearer ` 2. On 401 response, `doRes()` calls `authRefresh()` 3. `authRefresh` returns new `AccessToken` + `RefreshToken` 4. If refresh succeeds, the original request is **retried once** 5. If refresh fails with 400/422, `deauthOnce.Do()` fires all registered `deauthHandlers` — the session is dead **Concurrency:** `authLock` (sync.RWMutex) protects token storage. Reads are RLock'd (concurrent), writes are Lock'd (exclusive). This means parallel API calls competing on the same client will share the first refresh result. ### 2.4 Session Persistence The `Client` struct directly holds `uid`, `acc`, `ref` as plain strings in memory. There is no built-in persistence layer — token storage is the **plugin's responsibility**. **hydroxide's approach** (reference): - Encrypts `CachedAuth` (Auth + LoginPassword + MailboxPassword + KeySalts) with NaCl secretbox (XSalsa20-Poly1305) using a 32-byte key - Stores as `map[username]encrypted-blob` in `auth.json` - Decrypts on load, caches bcrypt hash of secret key in memory - On token refresh, re-encrypts and writes back **Source:** `hydroxide/auth/auth.go` — `EncryptAndSave()`, `CachedAuth`. ### 2.5 Session Logout ``` DELETE /auth/v4 ``` - Server-side session invalidation - Client resets `uid`, `acc`, `ref`, `keyRing` to empty/nil Session management endpoints: - `GET /auth/v4/sessions` — list active sessions - `DELETE /auth/v4/sessions` — revoke all sessions - `DELETE /auth/v4/sessions/` — revoke specific session **Source:** `go-proton-api/auth.go` — `AuthDelete()`, `AuthSessions()`, `AuthRevoke()`, `AuthRevokeAll()`. --- ## 3. Token Storage Requirements ### 3.1 What Must Be Persisted | Data | Sensitivity | Size | Required For | |------|-------------|------|-------------| | `UID` | Low (public) | ~36 chars | Session identity | | `AccessToken` | **High** | ~1 KB | API authorization | | `RefreshToken` | **High** | ~1 KB | Token renewal | | `UserID` | Low | ~36 chars | User identification | | `EventID` | Low | ~36 chars | Event polling cursor | | `PasswordMode` | Low | 1 byte | Key derivation strategy | | `LoginPassword` | **Critical** | variable | Key unlocking (single password mode) | | `MailboxPassword` | **Critical** | variable | Key unlocking (two-password mode) | | `KeySalts` | Medium | map[string][]byte | Key derivation | ### 3.2 Security Requirements 1. **Encrypt at rest** — Tokens are bearer credentials. If disk is compromised, the attacker gains full Proton API access. 2. **Per-user encryption key** — Each user's tokens encrypted with a derived key (not a single master key for all users). 3. **No plaintext on disk** — Never write AccessToken or RefreshToken without encryption. 4. **Memory clearing** — After use, zero out sensitive buffers (`ClearPrivateParams()` pattern from gopenpgp). 5. **Anti-replay protection** — The `RefreshToken` is single-use in practice (server invalidates old refresh token on each `/auth/refresh` call). ### 3.3 Recommended Storage Schema ``` ┌─────────────────────────────┐ │ encrypted_tokens.json │ │ { │ │ "user@pm.me": , │ │ "user2@pm.me": │ │ } │ └─────────────────────────────┘ ``` Where each `` decrypts to: ```json { "uid": "...", "access_token": "...", "refresh_token": "...", "user_id": "...", "event_id": "...", "password_mode": 1, "login_password": "", "mailbox_password": "", "key_salts": { "key-id": "" } } ``` **Encryption choice: NaCl secretbox (XSalsa20-Poly1305)** - Used by hydroxide (reference implementation) - 32-byte key, 24-byte random nonce per encryption - Authenticated encryption (integrity + confidentiality) - Available in Python (`PyNaCl`), Go (`golang.org/x/crypto/nacl/secretbox`), and TypeScript (`tweetnacl`) ### 3.4 Key Management for Storage The **session encryption key** (32 bytes) must itself be protected: - **Option A:** Derive from a master password (user provides on plugin init) - **Option B:** Store in Hermes agent's secure keyring (if available) - **Option C:** Environment variable (simplest, least secure) **Recommended: Option A with bcrypt-locked cache** (mirrors hydroxide): 1. User provides a "session key" passphrase once 2. Derive 32-byte NaCl key via argon2id/bcrypt 3. Cache bcrypt hash in memory for fast re-auth 4. Offer "forget session" to clear memory --- ## 4. gopenpgp Crypto Requirements ### 4.1 Library Overview `github.com/ProtonMail/gopenpgp/v2` — the **canonical crypto library** for all Proton clients. Built on top of `github.com/ProtonMail/go-crypto` (Proton's fork of golang.org/x/crypto/openpgp). Key properties: - Supports **RSA** (2048+ bit) and **X25519** (Curve25519 + EdDSA) keys - Default cipher: AES-256, default hash: SHA-256 - Default compression: ZLIB - Session key support for symmetric encryption - Key ring abstraction for multi-key operations ### 4.2 Per-Product Crypto Requirements #### Mail | Operation | gopenpgp API | Frequency | |-----------|-------------|-----------| | **Encrypt message** | `KeyRing.Encrypt(plainMessage, keyRing)` | Per outgoing | | **Decrypt message** | `KeyRing.Decrypt(pgpMessage, keyRing, verifyTime)` | Per incoming | | **Encrypt with password** | `helper.EncryptMessageWithPassword(password, text)` | Outgoing to non-PGP | | **Decrypt with password** | `helper.DecryptMessageWithPassword(password, armor)` | Incoming shared | | **Sign message** | `KeyRing.SignDetached(message, trimNewlines)` | Outgoing | | **Verify signature** | `KeyRing.VerifyDetached(message, sig, verifyTime)` | Incoming | | **Cleartext signed** | `helper.SignCleartextMessageArmored(key, pass, text)` | Outgoing public | | **PGP/MIME encrypt** | `EncryptRFC822(kr, literal)` (from go-proton-api) | Mail sending | | **Get public keys** | `crypto.NewKeyFromArmored(armored)` → `KeyRing.AddKey()` | Key discovery | **Key insight:** If using **Proton Bridge** for Mail, all PGP operations are handled transparently by the Bridge — the skill only deals with IMAP/SMTP. Direct API integration requires the full gopenpgp stack. #### Drive | Operation | gopenpgp API | Frequency | |-----------|-------------|-----------| | **Encrypt block** | `KeyRing.Encrypt(plainData, nil)` | Per upload chunk | | **Decrypt block** | `KeyRing.Decrypt(encData, nil, 0)` | Per download chunk | | **Key packet** | `KeyRing.EncryptSessionKey(sessionKey)` | Upload metadata | | **Decrypt key packet** | `KeyRing.DecryptSessionKey(keyPacket)` | Download metadata | | **Generate session key** | `crypto.GenerateSessionKey()` | Per file upload | | **HMAC verification** | Verify content hash after decryption | Per download | Drive uses a **content key + block key** structure: 1. File gets a random **content key** (session key) 2. Content key encrypted with user's keyring → stored in metadata 3. Each block (chunk) encrypted with AES-256 derived from content key 4. HMAC of each block for integrity verification **Reference:** `rclone protondrive backend` for the most complete third-party implementation of the block encryption scheme. #### Pass | Operation | gopenpgp API | Frequency | |-----------|-------------|-----------| | **Encrypt vault item** | `KeyRing.Encrypt(plainVaultItem, nil)` | Per item save | | **Decrypt vault item** | `KeyRing.Decrypt(encItem, nil, 0)` | Per item read | | **Share item** | `helper.EncryptMessageWithPassword(password, item)` | Sharing | **Note:** Proton Pass *may* be accessible via the official `pass-cli` (Rust) which handles all crypto internally. Direct API access would require implementing the full Pass vault encryption scheme. If doing **direct API** (without pass-cli): - Items are encrypted with AES-256-GCM using a vault-level key - Vault key encrypted with user's keyring - Each vault item = `` + `` ### 4.3 Key Unlocking The key unlocking process follows a specific chain: ``` ┌──────────────────────────────┐ │ User's Login/Mailbox │ │ Password (from auth) │ └──────────┬───────────────────┘ │ ┌──────────▼───────────────────┐ │ Fetch /keys/salts │ │ Returns map[keyID]base64salt │ └──────────┬───────────────────┘ │ ┌──────────▼───────────────────┐ │ Step 1: Unlock User Key │ │ (user key has no Token, │ │ so passphrase is used │ │ directly or derived │ │ via keySalt) │ └──────────┬───────────────────┘ │ ┌──────────▼───────────────────┐ │ Step 2: For each Address Key: │ │ ┌─ If key.Token exists: │ │ │ Decrypt Token using user │ │ │ key ring (PGP decrypt) │ │ │ → get passphrase │ │ │ Verify token signature │ │ └─ Else: use passphrase │ │ directly or with keySalt │ │ │ │ Then: unlock key entity │ │ (decrypt all PrivateKey + │ │ subkey PrivateKey fields) │ └──────────┬───────────────────┘ │ ┌──────────▼───────────────────┐ │ Result: KeyRing with all │ │ decrypted address keys │ │ (openpgp.EntityList) │ └──────────────────────────────┘ ``` **Source:** `go-proton-api/keyring.go` — `Keys.Unlock()`, `Key.Unlock()`, `Key.getPassphraseFromToken()`. `hydroxide/protonmail/auth.go` — `Unlock()`, `unlockKeyRing()`, `unlockPrivateKey()`. --- ## 5. Multi-Address Keyring ### 5.1 Problem Proton accounts can have **multiple addresses** (e.g. `user@pm.me`, `user@protonmail.com`, `custom@domain.com`), each with its own PGP key pair. There may be multiple keys per address (key rotation). ### 5.2 Solution in go-proton-api The `Unlock()` flow (in hydroxide's protonmail/auth.go): 1. `GetCurrentUser()` → get user object 2. Unlock **user keys** first (these are the "master" keys) 3. `ListAddresses()` → get all addresses 4. For each address, unlock its keys using the **user key ring** for token decryption 5. Combine **all unlocked address key rings** into a single `openpgp.EntityList` 6. Store as `client.keyRing` The `Keys.Unlock()` method iterates over **active** keys only (skips inactive/revoked). If any key fails to unlock, it logs a warning and continues — only fails if **no** keys could be unlocked. ### 5.3 Encryption Target Selection When encrypting a message, the client: 1. Fetches recipient's public keys via `GET /core/v4/keys?Email=
` 2. Builds a `KeyRing` from the returned `PublicKey` objects 3. Encrypts to both (a) the recipient's keyring and (b) the sender's keyring (for the sent folder) Self-encryption uses `GetPublicKeys()` on the sender's own addresses, which returns both active and expired keys to ensure old messages remain decryptable. ### 5.4 Implications for Auth Plugin The plugin must: 1. After login, **fetch and unlock all address keys** — this is an async multi-step process 2. Cache the combined `KeyRing` in plugin state 3. On new message notification, determine the correct address key 4. On address change event, re-fetch and re-unlock keys --- ## 6. API Endpoint Reference ### Authentication | Method | Endpoint | Purpose | |--------|----------|---------| | POST | `/auth/v4/info` | Get SRP challenge (modulus, salt, server ephemeral) | | POST | `/auth/v4` | SRP authentication (username, client ephemeral, client proof) | | POST | `/auth/v4/2fa` | Two-factor authentication (TOTP code) | | POST | `/auth/v4/refresh` | Refresh expired tokens | | DELETE | `/auth/v4` | Logout (server-side session invalidation) | | GET | `/auth/v4/sessions` | List active sessions | | DELETE | `/auth/v4/sessions` | Revoke all sessions | | DELETE | `/auth/v4/sessions/:uid` | Revoke specific session | | GET | `/auth/v4/modulus` | Get modulus (for password change) | ### Keys | Method | Endpoint | Purpose | |--------|----------|---------| | GET | `/keys/salts` | Get key salts for passphrase derivation | | GET | `/core/v4/keys?Email=` | Get public keys for an address | | POST | `/core/v4/keys/address` | Create a new address key | | PUT | `/core/v4/keys/:id/primary` | Make key primary | | POST | `/core/v4/keys/:id/delete` | Delete address key | **Source:** `go-proton-api/server/auth.go` (server-side routes) and `go-proton-api/auth.go` (client-side calls). --- ## 7. Implementation Recommendations for Auth Plugin ### 7.1 Architecture ``` ┌──────────────────────────────────────────────────┐ │ Auth Plugin State │ │ │ │ ┌──────────────┐ ┌──────────────────────────┐ │ │ │ Session Mgr │ │ Key Manager │ │ │ │ │ │ │ │ │ │ • UID │ │ • User KeyRing │ │ │ │ • AccessToken│ │ • Address KeyRings │ │ │ │ • RefreshToken│ │ • KeySalts cache │ │ │ │ • Expiry │ │ • Combined EntityList │ │ │ └──────┬───────┘ └──────────┬───────────────┘ │ │ │ │ │ │ │ ┌───────────────┴────────┐ │ │ └─────► Token Store │ │ │ │ (encrypted JSON on │ │ │ │ Hermes skill dir) │ │ │ └──────────────────────┘ │ └──────────────────────────────────────────────────┘ ``` ### 7.2 Login Flow ``` User provides username + password │ ▼ SRP-6a: POST /auth/v4/info → get challenge │ ▼ Compute SRP proofs (go-srp or port) │ ▼ POST /auth/v4 → get Auth (UID, AccessToken, RefreshToken) │ ├── 2FA required? │ └── prompt for TOTP → POST /auth/v4/2fa │ ▼ POST /keys/salts → get key salts │ ▼ GET /core/v4/keys?Email= → get user keys │ ▼ Unlock user key ring (password + salts) │ ▼ ListAddresses() → get all addresses │ ▼ For each address: unlock address keys (via user key ring) │ ▼ Save encrypted tokens to disk Cache key ring in memory ``` ### 7.3 Token Refresh Flow ``` API call returns 401 │ ▼ POST /auth/v4/refresh (with old RefreshToken) │ ├── 200 OK → update AccessToken + RefreshToken │ re-encrypt and save tokens │ retry original API call │ └── 422/400 → deauth fire deauth handlers clear key ring from memory notify user: "Session expired, re-login required" ``` ### 7.4 Language Recommendation | Component | Language | Reason | |-----------|----------|--------| | **Auth HTTP client** | Python (via httpx/aiohttp) | Hermes skills are Python/JS; SRP math can use `srp` package | | **SRP math** | Python (pure) | Port the 5 functions from go-srp (~200 lines) or use `srp._pysrp` | | **Token storage** | Python (cryptography library) | `cryptography.Fernet` (AES-256-GCM) or NaCl secretbox | | **gopenpgp ops** | Go (subprocess) or Python (PGPy) | gopenpgp is Go-only; alternatives: (a) proton-bridge subprocess, (b) PGPy for limited ops, (c) subprocess Go helper | | **Key ring mgmt** | Python + PGPy or subprocess Go | Key generation/management needs Go for full compatibility | **Alternative:** Use the **Proton Bridge** for Mail (handles all PGP transparently) and only need SRP auth for token acquisition, then pass-cli/rclone for the other products which handle their own crypto. ### 7.5 Error Code Reference | Code | Meaning | Action | |------|---------|--------| | 8002 | Wrong password | Re-prompt user | | 9001 | Human verification required | Forward to HV handler | | 10002 | Account deleted | Notify user | | 10003 | Account disabled | Notify user | | 10013 | Invalid refresh token | Full re-login required | | 12087 | 2FA code invalid | Re-prompt TOTP | --- ## 8. Key Risks & Open Questions ### Risks 1. **SRP implementation correctness** — The protocol is unforgiving. One wrong byte order (little-endian vs big-endian in the hash) produces a silent auth failure. Must match Proton's exact implementation. 2. **Refresh token single-use** — The server invalidates the old `RefreshToken` on each `/auth/refresh`. If two parallel requests try to refresh with the same token, one will get `10013` (invalid). The go-proton-api handles this with a mutex; the plugin must also serialize refresh attempts. 3. **Token expiry race** — Token lifetime is server-controlled (ExpiresAt). If the clock skew is too large, a token might appear valid on the client but rejected by the server. Always retry on 401 with refresh. 4. **2FA during re-auth** — Hydroxide explicitly doesn't support this. If a server-side policy change triggers 2FA re-verification during refresh, the session is dead. 5. **Key unlocking cost** — Unlocking 10+ address keys with bcrypt iterations is CPU-intensive (seconds). Cache aggressively, unlock lazily. ### Open Questions for Hannibal (T1) - Should the auth plugin use **Hydroxide's** NaCl secretbox or **Fernet** for token storage? (NaCl is what Proton-adjacent tools use, Fernet is simpler) - Python SRP: use `srp._pysrp` from the abandoned Python client (it works, just unmaintained) or re-implement from go-srp spec? - Key ring: subprocess a small Go binary for gopenpgp, or use PGPy with Proton's fork of go-crypto? - Single-password vs two-password mode detection and handling? --- ## Sources | Repository | File | Key Information | |-----------|------|----------------| | ProtonMail/go-proton-api | `manager_auth.go` | `NewClientWithLogin()` — full SRP login flow | | ProtonMail/go-proton-api | `client.go` | Auto-refresh on 401, deauth handlers | | ProtonMail/go-proton-api | `auth.go` | 2FA, session management endpoints | | ProtonMail/go-proton-api | `keys.go`, `keyring.go` | Key management, unlock chain | | ProtonMail/go-proton-api | `keys_types.go` | PublicKey/KeyList types | | ProtonMail/go-proton-api | `message_encrypt.go` | PGP/MIME encryption | | ProtonMail/go-srp | `srp.go` | SRP protocol implementation, modulus verification | | ProtonMail/go-srp | `hash.go` | Password hashing (versions 0-4), expandHash | | ProtonMail/gopenpgp v2 | `crypto/key.go` | Key operations (lock, unlock, export) | | ProtonMail/gopenpgp v2 | README | API overview for crypto operations | | emersion/hydroxide | `auth/auth.go` | Auth caching, NaCl secretbox storage | | emersion/hydroxide | `protonmail/auth.go` | AuthInfo, Auth, AuthTOTP, AuthRefresh, Unlock | | ProtonMail/proton-python-client | `proton/srp/_pysrp.py` | Python SRP-6a implementation | | ProtonMail/proton-python-client | `proton/api.py` | Session management in Python |