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:
parent
fc0eef0c31
commit
70b2b0348e
8 changed files with 1942 additions and 35 deletions
1137
Cargo.lock
generated
1137
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
408
proton-bridge/src/crypto.rs
Normal 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}"))
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue