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:
parent
da7dac8301
commit
c332322220
1 changed files with 680 additions and 0 deletions
680
docs/auth-analysis.md
Normal file
680
docs/auth-analysis.md
Normal 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 |
|
||||
Loading…
Add table
Add a link
Reference in a new issue