Decrypt ProtonMail messages end-to-end via key passphrase fix

The key insight from go-proton-api SaltForKey: ProtonMail uses only the
last 31 chars of the bcrypt output as the key passphrase — not the full
60-char string. One line fix, two days of debugging.

Also adds the full crypto layer (crypto.rs): user key unlock, address key
token decryption, and message body decryption via rpgp. Includes SRP auth,
session caching with locked-scope handling, TOTP, and the ProtonMail API
client for inbox listing and message fetch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Shautvast 2026-02-22 15:55:18 +01:00
parent fc0eef0c31
commit 70b2b0348e
8 changed files with 1942 additions and 35 deletions

1137
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -13,4 +13,10 @@ sha2 = "0.10"
num-bigint = "0.4"
base64 = "0.22"
rand = "0.8"
pwhash = "0.3" # bcrypt with caller-supplied salt
pwhash = "0.3" # bcrypt with caller-supplied salt (used for SRP)
bcrypt = "0.15" # reference bcrypt impl for key passphrase derivation
pgp = { version = "0.14", default-features = false } # rpgp — OpenPGP decrypt
env_logger = "0.11"
aes = "0.8"
cfb-mode = "0.8"
sha1 = "0.10"

View file

@ -128,6 +128,93 @@ struct KeysPayload {
keys: Vec<RecipientKey>,
}
// ── User private keys ─────────────────────────────────────────────────────────
/// One of the user's encrypted private keys.
#[derive(Debug, Clone, Deserialize)]
pub struct UserKey {
#[serde(rename = "ID")]
pub id: String,
/// PGP-armored private key, encrypted with the user's key passphrase.
#[serde(rename = "PrivateKey")]
pub private_key: String,
/// 1 = primary key.
#[serde(rename = "Primary")]
pub primary: u32,
#[serde(rename = "Active")]
pub active: u32,
}
/// Inner User object returned by GET /core/v4/users.
#[derive(Deserialize)]
struct UserObject {
#[serde(rename = "Keys")]
keys: Vec<UserKey>,
}
#[derive(Deserialize)]
struct UserPayload {
#[serde(rename = "User")]
user: UserObject,
}
// ── Key salts ─────────────────────────────────────────────────────────────────
/// Per-key bcrypt salt used to derive the key-unlock passphrase.
#[derive(Debug, Clone, Deserialize)]
pub struct KeySalt {
#[serde(rename = "ID")]
pub id: String,
/// Base64-encoded 16-byte bcrypt salt. Null / absent for keys without a salt.
#[serde(rename = "KeySalt")]
pub key_salt: Option<String>,
}
#[derive(Deserialize)]
struct KeySaltsPayload {
#[serde(rename = "KeySalts")]
key_salts: Vec<KeySalt>,
}
// ── Address keys ─────────────────────────────────────────────────────────────
/// One key attached to an email address.
/// In newer ProtonMail accounts the `token` field holds the address-key
/// passphrase encrypted with the user key — decrypt it first, then use the
/// plaintext token as the passphrase to unlock `private_key`.
/// In older accounts `token` is absent and the key shares the user-key passphrase.
#[derive(Debug, Clone, Deserialize)]
pub struct AddressKey {
#[serde(rename = "ID")]
pub id: String,
#[serde(rename = "PrivateKey")]
pub private_key: String,
/// Encrypted passphrase (new-format accounts). None in old-format accounts.
#[serde(rename = "Token")]
pub token: Option<String>,
#[serde(rename = "Primary")]
pub primary: u32,
#[serde(rename = "Active")]
pub active: u32,
}
/// One of the user's email addresses with its associated keys.
#[derive(Debug, Clone, Deserialize)]
pub struct Address {
#[serde(rename = "ID")]
pub id: String,
#[serde(rename = "Email")]
pub email: String,
#[serde(rename = "Keys")]
pub keys: Vec<AddressKey>,
}
#[derive(Deserialize)]
struct AddressesPayload {
#[serde(rename = "Addresses")]
addresses: Vec<Address>,
}
// ── API client ────────────────────────────────────────────────────────────────
/// Stateless API client. Pass a freshly-validated `Session` to each call.
@ -249,4 +336,84 @@ impl<'a> ApiClient<'a> {
}
Ok(env.data.keys)
}
/// Fetch the user's encrypted private keys via GET /core/v4/users.
/// Keys are embedded in the returned User object.
/// Each `PrivateKey` field is PGP-armored and must be unlocked
/// with the passphrase derived by `crypto::derive_key_passphrase`.
pub async fn get_user_keys(&self) -> Result<Vec<UserKey>, String> {
let [h0, h1] = self.auth_headers();
let env: Envelope<UserPayload> = self
.client
.get(format!("{}/core/v4/users", API_BASE))
.header(h0.0, h0.1)
.header(h1.0, h1.1)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
if env.code != 1000 {
return Err(format!("get_user_keys: API code {}", env.code));
}
Ok(env.data.user.keys)
}
/// Fetch the per-key bcrypt salts used to derive key-unlock passphrases.
/// Fetch per-key bcrypt salts. Returns the raw response text on failure
/// so the caller can log it and fall back to an empty salt list.
pub async fn get_key_salts(&self) -> Result<Vec<KeySalt>, String> {
let [h0, h1] = self.auth_headers();
let resp = self
.client
.get(format!("{}/core/v4/keys/salts", API_BASE))
.header(h0.0, h0.1)
.header(h1.0, h1.1)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("get_key_salts {status}: {body}"));
}
let body = resp.text().await.map_err(|e| e.to_string())?;
println!("key_salts raw: {body}");
let env: Envelope<KeySaltsPayload> =
serde_json::from_str(&body).map_err(|e| format!("key_salts parse: {e}"))?;
if env.code != 1000 {
return Err(format!("get_key_salts: API code {}", env.code));
}
Ok(env.data.key_salts)
}
/// Fetch the user's email addresses and their associated private keys.
/// Messages are encrypted to address keys, not user keys.
pub async fn get_addresses(&self) -> Result<Vec<Address>, String> {
let [h0, h1] = self.auth_headers();
let env: Envelope<AddressesPayload> = self
.client
.get(format!("{}/core/v4/addresses", API_BASE))
.header(h0.0, h0.1)
.header(h1.0, h1.1)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
if env.code != 1000 {
return Err(format!("get_addresses: API code {}", env.code));
}
Ok(env.data.addresses)
}
}

View file

@ -61,6 +61,10 @@ struct AuthResponse {
/// Integer bitmask: 0 = none, 1 = TOTP, 2 = FIDO2.
#[serde(rename = "TwoFactor")]
two_factor: u32,
/// e.g. "full" or "locked". "locked" means key-sensitive endpoints
/// (like /core/v4/keys/salts) return 403 until we call the unlock endpoint.
#[serde(rename = "Scope", default)]
scope: String,
}
#[derive(Serialize)]
@ -102,6 +106,11 @@ pub struct Session {
pub refresh_token: String,
/// Unix timestamp at which the access token expires.
pub expires_at: u64,
/// Space-separated OAuth scopes granted by the server.
/// The `locked` scope is required for /core/v4/keys/salts.
/// Refreshed tokens lose `locked`; only fresh SRP+TOTP logins grant it.
#[serde(default)]
pub scope: String,
}
impl Session {
@ -113,6 +122,11 @@ impl Session {
now + REFRESH_MARGIN_SECS >= self.expires_at
}
/// Returns true if this session has the `locked` scope needed for key salts.
pub fn has_locked_scope(&self) -> bool {
self.scope.split_whitespace().any(|s| s == "locked")
}
pub fn save(&self) -> Result<(), String> {
let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
fs::write(SESSION_FILE, json).map_err(|e| e.to_string())
@ -122,6 +136,10 @@ impl Session {
let json = fs::read_to_string(SESSION_FILE).ok()?;
serde_json::from_str(&json).ok()
}
pub fn delete() {
let _ = fs::remove_file(SESSION_FILE);
}
}
fn expires_at_from_now(expires_in: u64) -> u64 {
@ -148,21 +166,33 @@ pub fn build_client() -> Result<Client, String> {
}
/// Return a valid session, refreshing or re-logging in as needed.
///
/// Requires the `locked` scope (needed for /core/v4/keys/salts).
/// Refreshed tokens lose that scope, so we always fall back to a full
/// SRP+TOTP login when it is absent.
pub async fn authenticate(client: &Client, config: &ProtonConfig) -> Result<Session, String> {
if let Some(session) = Session::load() {
if !session.is_expired() {
if !session.has_locked_scope() {
println!("Cached session missing 'locked' scope — re-authenticating…");
} else if !session.is_expired() {
println!("Using cached session for {}", config.username);
return Ok(session);
}
println!("Access token expired, refreshing…");
match refresh(client, &session).await {
Ok(refreshed) => {
refreshed.save()?;
println!("Session refreshed for {}", config.username);
return Ok(refreshed);
}
Err(e) => {
println!("Refresh failed ({}), falling back to full login", e);
} else {
println!("Access token expired, refreshing…");
match refresh(client, &session).await {
Ok(refreshed) => {
// Refreshed tokens don't carry the locked scope; save the
// new tokens but only use this session if it has the scope.
if refreshed.has_locked_scope() {
refreshed.save()?;
println!("Session refreshed for {}", config.username);
return Ok(refreshed);
}
println!("Refreshed token lacks 'locked' scope — falling back to full login");
}
Err(e) => {
println!("Refresh failed ({}), falling back to full login", e);
}
}
}
}
@ -194,6 +224,7 @@ async fn refresh(client: &Client, session: &Session) -> Result<Session, String>
access_token: resp.access_token,
refresh_token: resp.refresh_token,
expires_at: expires_at_from_now(resp.expires_in),
scope: String::new(), // refreshed tokens don't carry the locked scope
})
}
@ -254,6 +285,7 @@ async fn login(client: &Client, config: &ProtonConfig) -> Result<Session, String
access_token: auth.access_token.clone(),
refresh_token: auth.refresh_token.clone(),
expires_at: expires_at_from_now(auth.expires_in),
scope: auth.scope.clone(),
};
// Step 4: handle TOTP if enabled
@ -261,6 +293,12 @@ async fn login(client: &Client, config: &ProtonConfig) -> Result<Session, String
submit_totp(client, &session).await?;
}
let has_full = auth.scope.split_whitespace().any(|s| s == "full");
println!("Auth scope tokens: {}", auth.scope);
if !has_full {
println!("Warning: token does not have 'full' scope — some endpoints may return 403.");
}
session.save()?;
println!("Authenticated as {}", config.username);
Ok(session)

View file

@ -15,6 +15,11 @@ pub struct ProtonConfig {
pub password: String,
/// Mailbox password — only needed for two-password mode accounts.
pub mailbox_password: Option<String>,
/// Override the derived user-key passphrase directly (for debugging).
/// Set this to the output of:
/// node -e "const b=require('bcryptjs'); console.log(b.hashSync(PASSWORD, '\$2y\$10\$' + KEY_SALT_B64))"
/// where KEY_SALT_B64 is the keySalt from /core/v4/keys/salts for your primary user key.
pub user_key_passphrase: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]

408
proton-bridge/src/crypto.rs Normal file
View file

@ -0,0 +1,408 @@
/// Crypto layer (Step 4).
///
/// Responsibilities:
/// 1. Derive the key-unlock passphrase from the user's password + per-key salt.
/// 2. Parse and hold the unlocked PGP private key.
/// 3. Decrypt message bodies (OpenPGP, asymmetric).
///
/// Algorithm notes (from go-crypto / gopenpgp):
/// - Key passphrase = bcrypt($2y$, cost=10, keySalt, password).
/// Unlike SRP there is NO "proton" suffix on the salt and NO expandHash.
/// The full 60-char bcrypt output string IS the passphrase.
/// - Message bodies are standard OpenPGP public-key encrypted messages.
/// rpgp handles the PKESK → session key → literal data layers automatically.
use std::io::Cursor;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use pgp::types::PublicKeyTrait;
use pgp::{Deserializable, Message, SignedSecretKey};
use crate::srp::bcrypt_base64_encode;
// ── Key passphrase derivation ─────────────────────────────────────────────────
/// Derive the passphrase used to unlock a user private key.
///
/// Matches ProtonMail's `srp.MailboxPassword` + `SaltForKey` exactly:
/// 1. Decode `key_salt_b64` (standard base64) → 16 raw bytes.
/// 2. bcrypt($2y$, cost=10, salt=raw_16_bytes, password+\0) → 60-char string.
/// 3. Return only the last 31 chars (the hash portion; strip `$2y$10$<22-salt>`).
///
/// `key_salt_b64` is the base64-encoded 16-byte salt from `GET /core/v4/keys/salts`.
/// If the salt is empty (legacy keys without a salt), the raw password is returned.
pub fn derive_key_passphrase(password: &str, key_salt_b64: &str) -> Result<String, String> {
if key_salt_b64.is_empty() {
return Ok(password.to_string());
}
let salt_bytes = B64.decode(key_salt_b64).map_err(|e| e.to_string())?;
let salt: [u8; 16] = salt_bytes
.try_into()
.map_err(|_| "key salt must be exactly 16 bytes".to_string())?;
let full = bcrypt::hash_with_salt(password, 10, salt)
.map_err(|e| format!("bcrypt error: {e}"))?
.format_for_version(bcrypt::Version::TwoY);
// ProtonMail uses only the last 31 chars — the hash portion after the
// 29-char prefix "$2y$10$<22-char-bcrypt-salt>".
// See go-proton-api SaltForKey: `saltedKeyPass[len(saltedKeyPass)-31:]`
Ok(full[full.len() - 31..].to_string())
}
/// Replicates the exact bcryptjs salt-decoding behaviour used by ProtonMail's web client.
///
/// ProtonMail JS calls `bcryptjs.hashSync(password, "$2y$10$" + keySalt_b64)`.
/// bcryptjs decodes the 22 chars after `"$2y$10$"` using its own 64-char alphabet
/// (`./A-Za-z0-9`). Crucially, bcryptjs's `base64_decode` BREAKs out of its loop
/// when c1 or c2 of any 4-char group is invalid (64/255) — it does NOT skip/filter.
///
/// Example: salt `/+FQehpJut/ngT45Nv/YhQ==`
/// c1='/' (valid=1), c2='+' (invalid=255) → BREAK after 0 bytes → 16-byte zero salt.
///
/// Standard base64 decoding (what `derive_key_passphrase` does) can produce
/// completely different bytes for the same salt string, causing an AES-key mismatch.
pub fn derive_key_passphrase_js(password: &str, key_salt_b64: &str) -> Result<String, String> {
if key_salt_b64.is_empty() {
return Ok(password.to_string());
}
// bcrypt alphabet — same index ordering bcryptjs uses in its decode table.
const ALPHA: &[u8] = b"./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let mut table = [255u8; 256];
for (i, &c) in ALPHA.iter().enumerate() {
table[c as usize] = i as u8;
}
// bcryptjs reads the first 22 chars after "$2y$10$", which are the first 22
// chars of key_salt_b64 (standard base64 of 16 bytes = 22 data chars + "==").
let chars: Vec<u8> = key_salt_b64.bytes().take(22).collect();
// Replicate bcryptjs base64_decode exactly: BREAK on any invalid char.
// Any byte not written stays 0 (bcryptjs initialises its salt array to zeroes).
let mut salt = [0u8; 16];
let mut pos = 0usize;
let mut i = 0usize;
while pos < 16 && i + 1 < chars.len() {
let c1 = table[chars[i] as usize] as u32;
let c2 = table[chars[i + 1] as usize] as u32;
if c1 == 255 || c2 == 255 { break; } // bcryptjs breaks on invalid c1 or c2
if pos < 16 { salt[pos] = ((c1 << 2) | ((c2 & 0x30) >> 4)) as u8; pos += 1; }
if i + 2 >= chars.len() { break; }
let c3 = table[chars[i + 2] as usize] as u32;
if c3 == 255 { break; } // bcryptjs breaks on invalid c3
if pos < 16 { salt[pos] = (((c2 & 0x0f) << 4) | ((c3 & 0x3c) >> 2)) as u8; pos += 1; }
if i + 3 >= chars.len() { break; }
let c4 = table[chars[i + 3] as usize] as u32;
if c4 == 255 { break; } // bcryptjs breaks on invalid c4
if pos < 16 { salt[pos] = (((c3 & 0x03) << 6) | c4) as u8; pos += 1; }
i += 4;
}
eprintln!(" [bcrypt-js] effective salt bytes: {}",
salt.iter().map(|b| format!("{b:02x}")).collect::<String>());
let parts = bcrypt::hash_with_salt(password, 10, salt)
.map_err(|e| format!("bcrypt-js decode error: {e}"))?;
Ok(parts.format_for_version(bcrypt::Version::TwoY))
}
/// Same derivation using pwhash (kept for comparison / fallback).
pub fn derive_key_passphrase_pwhash(password: &str, key_salt_b64: &str) -> Result<String, String> {
if key_salt_b64.is_empty() {
return Ok(password.to_string());
}
let salt_bytes = B64.decode(key_salt_b64).map_err(|e| e.to_string())?;
let encoded_salt = bcrypt_base64_encode(&salt_bytes);
let hashed = pwhash::bcrypt::hash_with(
pwhash::bcrypt::BcryptSetup {
salt: Some(&encoded_salt),
cost: Some(10),
variant: Some(pwhash::bcrypt::BcryptVariant::V2y),
},
password,
)
.map_err(|e| format!("bcrypt error (pwhash): {e}"))?;
Ok(hashed.to_string())
}
// ── Private key management ────────────────────────────────────────────────────
/// A parsed, ready-to-use private key.
pub struct PrivateKey {
inner: SignedSecretKey,
passphrase: String,
}
impl PrivateKey {
/// Parse the PGP-armored private key and verify its structure.
/// The `passphrase` is kept to supply during decryption (rpgp unlocks
/// subkeys on-the-fly when decrypting each message).
pub fn unlock(armored: &str, passphrase: &str) -> Result<Self, String> {
let (key, _headers) =
SignedSecretKey::from_armor_single(Cursor::new(armored.as_bytes()))
.map_err(|e| format!("parse private key: {e}"))?;
key.verify().map_err(|e| format!("invalid private key: {e}"))?;
Ok(Self {
inner: key,
passphrase: passphrase.to_string(),
})
}
/// Return primary key ID + all subkey IDs as hex strings.
pub fn key_ids(&self) -> Vec<String> {
let mut ids = vec![format!("{:?}", self.inner.key_id())];
for sk in &self.inner.secret_subkeys {
ids.push(format!("{:?}", sk.key_id()));
}
ids
}
pub fn passphrase_suffix(&self) -> String {
self.passphrase.chars().rev().take(8).collect::<String>()
.chars().rev().collect()
}
}
/// Print the key IDs of every key in the pool for diagnostics.
pub fn dump_key_pool(pool: &[PrivateKey]) {
for (i, k) in pool.iter().enumerate() {
eprintln!(" [pool] key[{i}]: {:?} (pp: ...{})",
k.key_ids(), k.passphrase_suffix());
}
}
/// Print the PKESK recipient key IDs of a PGP-armored message.
pub fn dump_message_pkesk(armored: &str) {
let Ok((msg, _)) = Message::from_armor_single(Cursor::new(armored.as_bytes())) else {
eprintln!(" [body-diag] failed to parse message armor");
return;
};
if let pgp::Message::Encrypted { esk, .. } = &msg {
for e in esk {
if let pgp::composed::message::Esk::PublicKeyEncryptedSessionKey(pkesk) = e {
let kid = pkesk.id().map(|k| format!("{k:?}")).unwrap_or("(anon)".into());
let ver = pkesk.version();
eprintln!(" [body-diag] PKESK version={ver:?} recipient={kid}");
}
}
} else {
eprintln!(" [body-diag] message is not Encrypted variant");
}
}
// ── Message decryption ────────────────────────────────────────────────────────
/// Decrypt an address key's `Token` field using the user's private key.
///
/// In newer ProtonMail accounts, each address key's passphrase is stored as a
/// PGP-encrypted token (encrypted to the user key). Decrypting it gives the
/// raw passphrase bytes used to unlock the address key.
pub fn decrypt_token(encrypted_token: &str, user_key: &PrivateKey) -> Result<String, String> {
let (msg, _) = Message::from_armor_single(Cursor::new(encrypted_token.as_bytes()))
.map_err(|e| format!("parse token: {e}"))?;
// Diagnostic: print key IDs, algorithms, and S2K params.
eprintln!(" [token diag] user primary key ID: {:?} algo={:?} sha1_cksum={}",
user_key.inner.key_id(), user_key.inner.algorithm(),
user_key.inner.primary_key.has_sha1_checksum());
for sk in &user_key.inner.secret_subkeys {
eprintln!(" [token diag] user subkey ID: {:?} algo={:?} sha1_cksum={} s2k={:?}",
sk.key_id(), sk.algorithm(),
sk.key.has_sha1_checksum(),
sk.key.secret_params());
}
if let Message::Encrypted { esk, .. } = &msg {
for e in esk {
if let pgp::composed::message::Esk::PublicKeyEncryptedSessionKey(pkesk) = e {
let kid = pkesk.id().map(|k| format!("{k:?}")).unwrap_or("(anon)".into());
let algo = pkesk.algorithm().map(|a| format!("{a:?}")).unwrap_or("?".into());
let ver = pkesk.version();
eprintln!(" [token diag] PKESK version={ver:?} algo={algo} recipient={kid}");
}
}
}
let passphrase = user_key.passphrase.clone();
let (decrypted, _) = msg
.decrypt(|| passphrase, &[&user_key.inner])
.map_err(|e| format!("decrypt token: {e}"))?;
let bytes = decrypted
.get_content()
.map_err(|e| format!("get token content: {e}"))?
.ok_or_else(|| "empty token".to_string())?;
String::from_utf8(bytes).map_err(|e| format!("token utf8: {e}"))
}
// ── Passphrase diagnostic ─────────────────────────────────────────────────────
/// Independently test passphrase candidates against every ECDH subkey's encrypted material.
///
/// Uses rpgp's own S2K `derive_key` (so we trust that part), then runs AES-256-CFB decrypt
/// and SHA-1 checksum verification ourselves — bypassing rpgp's unlock path — so that we
/// can isolate whether the problem is the passphrase or something inside rpgp.
///
/// Prints a result line for every (subkey × candidate) combination.
/// Also runs a manual S2K to detect if rpgp's derive_key uses the raw count byte or decoded count.
pub fn diagnose_subkey_passphrase(key: &PrivateKey, candidates: &[(&str, &str)]) {
use pgp::types::{S2kParams, SecretParams, StringToKey};
use cfb_mode::cipher::{AsyncStreamCipher, KeyIvInit};
use cfb_mode::Decryptor;
use aes::Aes256;
use sha1::Sha1;
use sha2::Sha256;
// Manual SHA-256-based OpenPGP S2K (RFC 4880 §3.7.1.3).
// Returns 32 bytes; caller slices to key_size.
fn manual_s2k_sha256(passphrase: &str, s2k_salt: &[u8; 8], count: u64) -> [u8; 32] {
use sha2::Digest;
let body: Vec<u8> = s2k_salt.iter().chain(passphrase.as_bytes()).copied().collect();
let mut hasher = Sha256::new();
let mut total = 0u64;
while total < count {
let rem = (count - total) as usize;
if rem >= body.len() {
hasher.update(&body);
total += body.len() as u64;
} else {
hasher.update(&body[..rem]);
total = count;
}
}
hasher.finalize().into()
}
fn check_sha1(enc_data: &[u8], aes_key: &[u8], iv: &[u8]) -> (bool, String) {
use sha1::Digest;
let mut pt = enc_data.to_vec();
let Ok(d) = Decryptor::<Aes256>::new_from_slices(aes_key, iv) else {
return (false, "cfb-err".into());
};
d.decrypt(&mut pt);
let preview: String = pt.iter().take(6).map(|b| format!("{b:02x}")).collect();
if pt.len() < 20 { return (false, preview); }
let split = pt.len() - 20;
let actual: [u8; 20] = Sha1::digest(&pt[..split]).into();
let ok = actual.as_slice() == &pt[split..];
(ok, preview)
}
for sk in &key.inner.secret_subkeys {
eprintln!(" [diag] subkey {:?}", sk.key_id());
match sk.key.secret_params() {
SecretParams::Plain(_) => eprintln!(" [diag] subkey is unencrypted"),
SecretParams::Encrypted(enc) => {
let enc_data = enc.data();
let s2k_params = enc.string_to_key_params();
if let S2kParams::Cfb { sym_alg, s2k, iv } = s2k_params {
let key_size = sym_alg.key_size();
eprintln!(
" [diag] enc_data ({} bytes): {}",
enc_data.len(),
enc_data.iter().map(|b| format!("{b:02x}")).collect::<String>()
);
eprintln!(
" [diag] iv: {}",
iv.iter().map(|b| format!("{b:02x}")).collect::<String>()
);
// Extract raw S2K params for manual comparison.
let raw_params: Option<([u8; 8], u8)> =
if let StringToKey::IteratedAndSalted { salt, count, .. } = s2k {
Some((*salt, *count))
} else { None };
for (label, passphrase) in candidates {
// Path A: rpgp's derive_key
let (rpgp_ok, rpgp_preview, rpgp_key4) = match s2k.derive_key(passphrase, key_size) {
Ok(aes_key) => {
let k4: String = aes_key.iter().take(4)
.map(|b| format!("{b:02x}")).collect();
let (ok, preview) = check_sha1(enc_data, &aes_key, iv);
(ok, preview, k4)
}
Err(e) => {
eprintln!(" [diag] {label} → S2K error: {e}");
continue;
}
};
// Path B+C: manual S2K with decoded count (65536) and raw count (96)
if let Some((s2k_salt, count_byte)) = raw_params {
let decoded_count = (16u64 + (count_byte as u64 & 15))
<< ((count_byte as u64 >> 4) + 6);
let raw_count = count_byte as u64;
let key_dec = manual_s2k_sha256(passphrase, &s2k_salt, decoded_count);
let key_raw_c = manual_s2k_sha256(passphrase, &s2k_salt, raw_count);
let k4_dec: String = key_dec.iter().take(4)
.map(|b| format!("{b:02x}")).collect();
let k4_raw: String = key_raw_c.iter().take(4)
.map(|b| format!("{b:02x}")).collect();
let (ok_dec, _) = check_sha1(enc_data, &key_dec[..key_size], iv);
let (ok_raw, _) = check_sha1(enc_data, &key_raw_c[..key_size], iv);
let keys_agree = rpgp_key4 == k4_dec;
let sha1_str = if rpgp_ok { "OK ✓" } else { "FAIL" };
eprintln!(
" [diag] {label} → SHA-1 {sha1_str} (pt:{rpgp_preview}) \
rpgp={rpgp_key4} man65k={k4_dec}(ok:{ok_dec}) \
man96={k4_raw}(ok:{ok_raw}) agree:{keys_agree}"
);
if rpgp_ok || ok_dec || ok_raw {
eprintln!(" [diag] *** CORRECT PASSPHRASE: {label} ***");
}
} else {
let sha1_str = if rpgp_ok { "OK ✓" } else { "FAIL" };
eprintln!(" [diag] {label} → SHA-1 {sha1_str} (pt:{rpgp_preview})");
if rpgp_ok { eprintln!(" [diag] *** CORRECT PASSPHRASE: {label} ***"); }
}
}
} else {
eprintln!(" [diag] non-CFB S2K params: {s2k_params:?}");
}
}
}
}
}
/// Decrypt a ProtonMail message body.
///
/// `encrypted_body` is the PGP-armored ciphertext from `Message.body`.
/// `keys` is every candidate private key (user key + all address keys).
/// rpgp picks the one whose key ID matches the PKESK packet in the message.
/// Returns the plaintext (HTML or plain text; consult `Message.mime_type`).
pub fn decrypt_body(encrypted_body: &str, keys: &[&PrivateKey]) -> Result<String, String> {
let (msg, _headers) =
Message::from_armor_single(Cursor::new(encrypted_body.as_bytes()))
.map_err(|e| format!("parse PGP message: {e}"))?;
// Try each key until one succeeds (the right one matches the PKESK key ID).
let mut last_err = String::from("no keys provided");
for key in keys {
let passphrase = key.passphrase.clone();
match msg.decrypt(|| passphrase, &[&key.inner]) {
Ok((decrypted, _)) => {
let bytes = decrypted
.get_content()
.map_err(|e| format!("get content: {e}"))?
.ok_or_else(|| "decrypted message has no content".to_string())?;
return String::from_utf8(bytes).map_err(|e| format!("utf8 decode: {e}"));
}
Err(e) => last_err = e.to_string(),
}
}
Err(format!("decrypt: {last_err}"))
}

View file

@ -1,12 +1,14 @@
mod api;
mod auth;
mod config;
mod crypto;
mod srp;
use api::{ApiClient, LABEL_INBOX};
#[tokio::main]
async fn main() {
env_logger::init();
let config = match config::Config::load() {
Ok(c) => c,
Err(e) => { eprintln!("Failed to load bridge.toml: {}", e); std::process::exit(1); }
@ -23,20 +25,184 @@ async fn main() {
};
println!("Session UID: {}", session.uid);
// Step 3 smoke-test: list the first page of inbox messages.
let api = ApiClient::new(&client, &session);
match api.list_messages(LABEL_INBOX, 0, 10).await {
Ok((messages, total)) => {
println!("\nInbox ({total} total):");
for m in &messages {
println!(
" [{:>5}] {:50} — {}",
if m.unread == 1 { "UNREAD" } else { " read" },
m.subject,
m.sender.address,
);
// ── Step 3: list inbox ────────────────────────────────────────────────────
let (messages, total) = match api.list_messages(LABEL_INBOX, 0, 10).await {
Ok(r) => r,
Err(e) => { eprintln!("list_messages failed: {}", e); std::process::exit(1); }
};
println!("\nInbox ({total} total):");
for m in &messages {
println!(
" [{}] {:50} — {}",
if m.unread == 1 { "UNREAD" } else { " read" },
m.subject,
m.sender.address,
);
}
// ── Step 4: decrypt first message body ───────────────────────────────────
let first = match messages.first() {
Some(m) => m,
None => { println!("\nInbox is empty — nothing to decrypt."); return; }
};
// Fetch user keys, address keys, and bcrypt salts in parallel.
let (user_keys_res, addresses_res, key_salts_res) = tokio::join!(
api.get_user_keys(),
api.get_addresses(),
api.get_key_salts(),
);
let user_keys = user_keys_res.unwrap_or_else(|e| { eprintln!("get_user_keys: {e}"); vec![] });
let addresses = addresses_res.unwrap_or_else(|e| { eprintln!("get_addresses: {e}"); vec![] });
// key_salts requires the 'locked' OAuth scope. The server may revoke it even
// when our cached session still claims to have it. If we get 403, purge the
// session so the next run does a full SRP+TOTP login that re-grants 'locked'.
let key_salts = match key_salts_res {
Ok(salts) => salts,
Err(e) => {
eprintln!("get_key_salts: {e}");
if e.contains("403") {
auth::Session::delete();
eprintln!("Session 'locked' scope expired server-side — session.json deleted.");
eprintln!("Re-run; you will be prompted for your TOTP code to regain 'locked' scope.");
std::process::exit(1);
}
vec![]
}
};
// In two-password mode the mailbox password unlocks the keys.
let password = config.proton.mailbox_password
.as_deref()
.unwrap_or(&config.proton.password);
// Derive passphrase for a key ID via bcrypt(password, keySalt).
// Falls back to raw password when salt is absent.
let passphrase_for = |key_id: &str| -> String {
let salt = key_salts.iter()
.find(|s| s.id == key_id)
.and_then(|s| s.key_salt.as_deref())
.unwrap_or("");
crypto::derive_key_passphrase(password, salt)
.unwrap_or_else(|_| password.to_string())
};
// Phase 1: unlock user keys — try multiple passphrase candidates so we can
// identify which derivation is correct.
let mut user_private_keys: Vec<(String, crypto::PrivateKey)> = Vec::new();
for uk in &user_keys {
if uk.active == 1 {
let salt = key_salts.iter()
.find(|s| s.id == uk.id)
.and_then(|s| s.key_salt.as_deref())
.unwrap_or("");
// Derive key passphrase per ProtonMail's go-proton-api SaltForKey:
// bcrypt($2y$, cost=10, raw_salt_16_bytes, password+\0)[last 31 chars]
eprintln!(" [key-salt] raw API value: {:?} (len={})", salt, salt.len());
let pp = crypto::derive_key_passphrase(password, salt)
.unwrap_or_else(|_| password.to_string());
eprintln!(" [pp] len={} last8=...{}", pp.len(),
pp.chars().rev().take(8).collect::<String>().chars().rev().collect::<String>());
let mut candidates: Vec<(&str, String)> = vec![
("bcrypt-31", pp),
("raw-password", password.to_string()),
];
// Manual override — set user_key_passphrase in bridge.toml to bypass derivation.
if let Some(ref override_pp) = config.proton.user_key_passphrase {
eprintln!(" [override] using user_key_passphrase from config");
candidates.push(("config-override", override_pp.clone()));
}
// Use the first candidate to store the parsed key structure.
// (unlock() only parses the key; actual passphrase is tested during decrypt.)
let first_pp = candidates[0].1.clone();
match crypto::PrivateKey::unlock(&uk.private_key, &first_pp) {
Ok(k) => {
println!("user key {} parsed OK", &uk.id[..8]);
// Run the independent S2K+CFB+SHA1 diagnostic for all candidates.
let diag_candidates: Vec<(&str, &str)> = candidates
.iter()
.map(|(l, p)| (*l, p.as_str()))
.collect();
crypto::diagnose_subkey_passphrase(&k, &diag_candidates);
// Store all candidate passphrases so token decrypt can try each.
for (label, pp) in &candidates {
match crypto::PrivateKey::unlock(&uk.private_key, pp) {
Ok(k2) => user_private_keys.push((label.to_string(), k2)),
Err(e) => eprintln!("user key {} [{}] FAILED: {}", &uk.id[..8], label, e),
}
}
}
Err(e) => eprintln!("user key {} parse FAILED: {}", &uk.id[..8], e),
}
}
Err(e) => eprintln!("list_messages failed: {}", e),
}
// Phase 2: unlock address keys.
// Newer accounts: the address key's passphrase is stored as a PGP-encrypted
// Token (encrypted to the user key). Decrypt the token, then unlock.
// Older accounts: address keys share the same bcrypt passphrase as user keys.
let mut key_pool: Vec<crypto::PrivateKey> = Vec::new();
for addr in &addresses {
for ak in &addr.keys {
if ak.active == 1 {
let pp = if let Some(token) = &ak.token {
let mut token_pp = None;
for (label, uk) in &user_private_keys {
match crypto::decrypt_token(token, uk) {
Ok(p) => {
println!("token decrypted using [{}] passphrase!", label);
token_pp = Some(p);
break;
}
Err(e) => eprintln!("decrypt_token [{}] ({}): {e}", label, addr.email),
}
}
token_pp.unwrap_or_else(|| passphrase_for(&ak.id))
} else {
passphrase_for(&ak.id)
};
match crypto::PrivateKey::unlock(&ak.private_key, &pp) {
Ok(k) => {
println!("address key {} ({}) unlocked", &ak.id[..8], addr.email);
key_pool.push(k);
}
Err(e) => eprintln!("address key {} ({}): {e}", &ak.id[..8], addr.email),
}
}
}
}
// Include user keys as fallback (messages may be encrypted to the user key).
key_pool.extend(user_private_keys.into_iter().map(|(_, k)| k));
println!("\n{} key(s) in pool ({} address(es))", key_pool.len(), addresses.len());
// Fetch and decrypt the first message.
let full_msg = match api.get_message(&first.id).await {
Ok(m) => m,
Err(e) => { eprintln!("get_message failed: {}", e); std::process::exit(1); }
};
crypto::dump_key_pool(&key_pool);
crypto::dump_message_pkesk(&full_msg.body);
let key_refs: Vec<&crypto::PrivateKey> = key_pool.iter().collect();
match crypto::decrypt_body(&full_msg.body, &key_refs) {
Ok(plaintext) => {
println!("\n── Decrypted body ({}) ──────────────────", full_msg.mime_type);
let preview: String = plaintext.chars().take(2000).collect();
println!("{preview}");
if plaintext.len() > 2000 {
println!("… ({} chars total)", plaintext.len());
}
}
Err(e) => eprintln!("decrypt_body failed: {}", e),
}
}

View file

@ -177,7 +177,7 @@ fn hash_password(
}
/// Encode bytes using bcrypt's own base64 alphabet.
fn bcrypt_base64_encode(input: &[u8]) -> String {
pub(crate) fn bcrypt_base64_encode(input: &[u8]) -> String {
const ALPHABET: &[u8] = b"./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let mut out = String::new();
let mut i = 0;