docs: Proton SRP-6a auth analysis + gopenpgp crypto requirements

Deep-dive analysis covering:
- SRP-6a protocol flow with password hashing versions 0-4
- Session management (AccessToken, RefreshToken, UID lifecycle)
- 2FA (TOTP + U2F) support
- Token storage requirements with NaCl secretbox recommendation
- gopenpgp crypto operations per product (Mail, Drive, Pass)
- Multi-address keyring management
- API endpoint reference
- Implementation recommendations for auth plugin
- Key risks and open questions for T1 architecture design

Sources: go-proton-api, go-srp, gopenpgp v2, hydroxide, proton-python-client
This commit is contained in:
Amy Allen 2026-06-08 18:29:58 +02:00
parent da7dac8301
commit c332322220
Signed by: amy
GPG key ID: 2BECB80850B5EC4B

680
docs/auth-analysis.md Normal file
View file

@ -0,0 +1,680 @@
# 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 <token>` |
| `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: <ref>,
ResponseType: "token",
RedirectURI: "https://protonmail.ch",
State: <random 32 bytes>,
UID: <uid>,
AccessToken: <acc> }
├── 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 <acc>`
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/<uid>` — 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": <NaCl box>, │
│ "user2@pm.me": <NaCl box>
│ } │
└─────────────────────────────┘
```
Where each `<NaCl box>` decrypts to:
```json
{
"uid": "...",
"access_token": "...",
"refresh_token": "...",
"user_id": "...",
"event_id": "...",
"password_mode": 1,
"login_password": "<encrypted or empty>",
"mailbox_password": "<encrypted or empty>",
"key_salts": { "key-id": "<base64 salt>" }
}
```
**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 = `<encrypted blob>` + `<encrypted content key packet>`
### 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=<address>`
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=<addr>` | 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=<self> → 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 |