diff --git a/docs/auth-analysis.md b/docs/auth-analysis.md new file mode 100644 index 0000000..aa2e877 --- /dev/null +++ b/docs/auth-analysis.md @@ -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 ` | +| `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 |