hermes-proton/docs/auth-analysis.md
Amy Allen c332322220
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
2026-06-08 18:29:58 +02:00

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.goHashPassword() dispatches by version. go-srp/srp.goNewAuth() 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.goreadClearSignedMessage(), 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:

{
  "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.goAuth2FA()POST /auth/v4/2fa
  • hydroxide/protonmail/auth.goAuthTOTP() 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.godoRes() 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.goEncryptAndSave(), 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.goAuthDelete(), 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).
┌─────────────────────────────┐
│ 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):

  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.goKeys.Unlock(), Key.Unlock(), Key.getPassphraseFromToken(). hydroxide/protonmail/auth.goUnlock(), 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