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
28 KiB
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:
- Server returns the modulus as a clearsigned PGP message (armored + signature)
- Client verifies the signature against Proton's hardcoded public key
- 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)ais 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:
{
"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
TwoFactorCodein the body. - U2F/FIDO2 — WebAuthn challenge-response. The
U2Ffield inTwoFactorcarries challenge data (interface{} in Go — typed asU2FDatain 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/2fahydroxide/protonmail/auth.go—AuthTOTP()implementationhydroxide/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:
- Every request includes
x-pm-uidheader +Authorization: Bearer <acc> - On 401 response,
doRes()callsauthRefresh() authRefreshreturns newAccessToken+RefreshToken- If refresh succeeds, the original request is retried once
- If refresh fails with 400/422,
deauthOnce.Do()fires all registereddeauthHandlers— 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-blobinauth.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,keyRingto empty/nil
Session management endpoints:
GET /auth/v4/sessions— list active sessionsDELETE /auth/v4/sessions— revoke all sessionsDELETE /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
- Encrypt at rest — Tokens are bearer credentials. If disk is compromised, the attacker gains full Proton API access.
- Per-user encryption key — Each user's tokens encrypted with a derived key (not a single master key for all users).
- No plaintext on disk — Never write AccessToken or RefreshToken without encryption.
- Memory clearing — After use, zero out sensitive buffers (
ClearPrivateParams()pattern from gopenpgp). - Anti-replay protection — The
RefreshTokenis single-use in practice (server invalidates old refresh token on each/auth/refreshcall).
3.3 Recommended Storage Schema
┌─────────────────────────────┐
│ encrypted_tokens.json │
│ { │
│ "user@pm.me": <NaCl box>, │
│ "user2@pm.me": <NaCl box> │
│ } │
└─────────────────────────────┘
Where each <NaCl box> decrypts to:
{
"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):
- User provides a "session key" passphrase once
- Derive 32-byte NaCl key via argon2id/bcrypt
- Cache bcrypt hash in memory for fast re-auth
- 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
| 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:
- File gets a random content key (session key)
- Content key encrypted with user's keyring → stored in metadata
- Each block (chunk) encrypted with AES-256 derived from content key
- 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):
GetCurrentUser()→ get user object- Unlock user keys first (these are the "master" keys)
ListAddresses()→ get all addresses- For each address, unlock its keys using the user key ring for token decryption
- Combine all unlocked address key rings into a single
openpgp.EntityList - 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:
- Fetches recipient's public keys via
GET /core/v4/keys?Email=<address> - Builds a
KeyRingfrom the returnedPublicKeyobjects - 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:
- After login, fetch and unlock all address keys — this is an async multi-step process
- Cache the combined
KeyRingin plugin state - On new message notification, determine the correct address key
- 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
-
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.
-
Refresh token single-use — The server invalidates the old
RefreshTokenon each/auth/refresh. If two parallel requests try to refresh with the same token, one will get10013(invalid). The go-proton-api handles this with a mutex; the plugin must also serialize refresh attempts. -
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.
-
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.
-
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._pysrpfrom 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 |