use std::collections::HashMap;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use crate::api::{ApiClient, ApiErrorCode};
use crate::base64::{base64url_decode, base64url_encode};
use crate::crypto::aes::aes128_ecb_decrypt_block;
use crate::crypto::{
decrypt_key, decrypt_private_key, decrypt_session_id, derive_key_v2, encrypt_key,
make_password_key, make_random_key, make_username_hash, verify_tsid,
};
use crate::error::{MegaError, Result};
use super::core::Session;
use super::device_id::device_id_hash;
#[derive(Debug, Clone, Copy)]
enum UpgradeOutcome {
NotNeeded,
Upgraded,
AlreadyUpgraded,
Failed,
}
impl Session {
pub async fn login(email: &str, password: &str) -> Result<Self> {
Self::login_internal(email, password, None).await
}
pub async fn login_with_proxy(email: &str, password: &str, proxy: &str) -> Result<Self> {
Self::login_internal(email, password, Some(proxy)).await
}
async fn login_internal(email: &str, password: &str, proxy: Option<&str>) -> Result<Self> {
let mut api = match proxy {
Some(p) => ApiClient::with_proxy(p)?,
None => ApiClient::new(),
};
let email_lower = email.to_lowercase();
let pre_login = api
.request(json!({
"a": "us0",
"user": &email_lower
}))
.await?;
let login_variant = pre_login["v"].as_i64().unwrap_or(0);
let (password_key, user_hash) = if login_variant == 2 {
let salt_b64 = pre_login["s"].as_str().ok_or(MegaError::InvalidResponse)?;
let salt = base64url_decode(salt_b64)?;
let derived = derive_key_v2(password, &salt)?;
let password_key: [u8; 16] = derived[..16].try_into().unwrap();
let user_hash = base64url_encode(&derived[16..32]);
(password_key, user_hash)
} else {
let password_key = make_password_key(password);
let user_hash_bytes = make_username_hash(&email_lower, &password_key);
let user_hash = base64url_encode(&user_hash_bytes);
(password_key, user_hash)
};
let sek = make_random_key();
let sek_b64 = base64url_encode(&sek);
let si = device_id_hash();
let mut login_payload = json!({
"a": "us",
"user": &email_lower,
"uh": &user_hash,
"sek": &sek_b64
});
if let Some(si) = si {
login_payload["si"] = Value::String(si);
}
let login_response = api.request(login_payload).await?;
let k_b64 = login_response["k"]
.as_str()
.ok_or(MegaError::InvalidResponse)?;
let master_key = decrypt_key(k_b64, &password_key)?;
let session_key = match login_response.get("sek").and_then(|v| v.as_str()) {
Some(sek_b64) => {
let decoded = base64url_decode(sek_b64)?;
if decoded.len() != 16 {
return Err(MegaError::InvalidResponse);
}
let mut key = [0u8; 16];
key.copy_from_slice(&decoded);
Some(key)
}
None => None,
};
let (session_id, rsa_key) =
if let Some(tsid) = login_response.get("tsid").and_then(|v| v.as_str()) {
if !verify_tsid(tsid, &master_key)? {
return Err(Self::invalid_tsid_error());
}
let sid = tsid.to_string();
api.set_session_id(sid.clone());
(sid, Self::empty_rsa_key())
} else {
let privk_b64 = login_response["privk"]
.as_str()
.ok_or(MegaError::InvalidResponse)?;
let rsa_key = decrypt_private_key(privk_b64, &master_key)?;
let csid_b64 = login_response["csid"]
.as_str()
.ok_or(MegaError::InvalidResponse)?;
let session_id = decrypt_session_id(csid_b64, &rsa_key)?;
api.set_session_id(session_id.clone());
(session_id, rsa_key)
};
let mut upgrade_outcome = UpgradeOutcome::NotNeeded;
if login_variant == 1 {
upgrade_outcome = Self::attempt_account_upgrade(&mut api, password, &master_key)
.await
.unwrap_or(UpgradeOutcome::Failed);
let mut batch = Vec::new();
if matches!(upgrade_outcome, UpgradeOutcome::Upgraded) {
batch.push(json!({
"a": "log",
"e": 99473,
"m": "Account successfully upgraded to v2"
}));
}
batch.push(json!({"a": "uq", "pro": 1, "src": -1, "v": 2}));
let _ = api.request_batch(batch).await;
} else {
api.request_batch(vec![
json!({"a": "stp"}),
json!({"a": "uq", "pro": 1, "src": -1, "v": 2}),
])
.await?;
}
let user_info = api.request(json!({"a": "ug", "v": 1})).await?;
let user_handle = user_info["u"]
.as_str()
.ok_or(MegaError::InvalidResponse)?
.to_string();
let user_email = user_info["email"]
.as_str()
.unwrap_or(&email_lower)
.to_string();
let user_name = user_info["name"].as_str().map(|s| s.to_string());
let scsn = user_info
.get("sn")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let user_attr_cache = Self::collect_user_attrs_from_ug(&user_info);
let user_attr_versions = Self::collect_user_attr_versions_from_ug(&user_info);
let mut session = Session::new_internal(
api,
session_id,
session_key,
master_key,
rsa_key,
user_email,
user_name,
user_handle,
user_attr_cache,
user_attr_versions,
scsn,
);
session.install_default_persistence_runtime()?;
let account_is_v2 = login_variant == 2
|| matches!(
upgrade_outcome,
UpgradeOutcome::Upgraded | UpgradeOutcome::AlreadyUpgraded
);
if account_is_v2 {
let _ = session.initialize_account_keys().await;
}
let _ = session.load_keys_attribute().await;
let _ = session.promote_pending_shares().await;
if session.clear_inuse_flags_for_missing_shares() {
let _ = session.persist_keys_with_retry().await;
}
Ok(session)
}
fn collect_user_attrs_from_ug(user_info: &Value) -> HashMap<String, Vec<u8>> {
let mut cache = HashMap::new();
let Some(obj) = user_info.as_object() else {
return cache;
};
let attrs = [
"^!keys",
"*keyring",
"*~usk",
"*~jscd",
"+puCu255",
"+puEd255",
"+sigCu255",
"+sigPubk",
];
for attr in attrs {
if let Some(av) = obj
.get(attr)
.and_then(|v| v.get("av"))
.and_then(|v| v.as_str())
{
if av.is_empty() {
continue;
}
if let Ok(decoded) = base64url_decode(av) {
cache.insert(attr.to_string(), decoded);
}
}
}
cache
}
fn collect_user_attr_versions_from_ug(user_info: &Value) -> HashMap<String, String> {
let mut versions = HashMap::new();
let Some(obj) = user_info.as_object() else {
return versions;
};
let attrs = [
"^!keys",
"*keyring",
"*~usk",
"*~jscd",
"+puCu255",
"+puEd255",
"+sigCu255",
"+sigPubk",
];
for attr in attrs {
if let Some(v) = obj
.get(attr)
.and_then(|v| v.get("v"))
.and_then(|v| v.as_str())
{
versions.insert(attr.to_string(), v.to_string());
}
}
versions
}
fn invalid_tsid_error() -> MegaError {
MegaError::ApiError {
code: ApiErrorCode::NotExist as i32,
message: ApiErrorCode::NotExist.description().to_string(),
}
}
fn build_upgrade_payload(
password: &str,
master_key: &[u8; 16],
) -> Result<(String, String, String)> {
let client_random = make_random_key();
let mut buffer = b"mega.nz".to_vec();
buffer.resize(200, b'P');
buffer.extend_from_slice(&client_random);
let salt = Sha256::digest(&buffer);
let derived = derive_key_v2(password, salt.as_slice())?;
let password_key: [u8; 16] = derived[..16].try_into().unwrap();
let auth_key = &derived[16..32];
let encrypted_master_key = encrypt_key(master_key, &password_key);
let mut hasher = Sha256::new();
hasher.update(auth_key);
let hashed = hasher.finalize();
let hak = &hashed[..16];
Ok((
base64url_encode(&client_random),
base64url_encode(&encrypted_master_key),
base64url_encode(hak),
))
}
async fn attempt_account_upgrade(
api: &mut ApiClient,
password: &str,
master_key: &[u8; 16],
) -> Result<UpgradeOutcome> {
let (crv, emk, hak) = Self::build_upgrade_payload(password, master_key)?;
let resp = api
.request_batch(vec![
json!({"a": "stp"}),
json!({"a": "avu", "crv": crv, "emk": emk, "hak": hak}),
])
.await?;
let arr = resp.as_array().ok_or(MegaError::InvalidResponse)?;
let avu = arr.get(1).ok_or(MegaError::InvalidResponse)?;
if let Some(code) = avu.as_i64() {
if code == 0 {
return Ok(UpgradeOutcome::Upgraded);
}
if code == -8 {
return Ok(UpgradeOutcome::AlreadyUpgraded);
}
if code < 0 {
return Ok(UpgradeOutcome::Failed);
}
}
Ok(UpgradeOutcome::Upgraded)
}
pub async fn change_password(&mut self, new_password: &str) -> Result<()> {
let salt = make_random_key();
let salt_b64 = base64url_encode(&salt);
let derived = derive_key_v2(new_password, &salt)?;
let password_key: [u8; 16] = derived[..16].try_into().unwrap();
let user_hash = base64url_encode(&derived[16..32]);
let encrypted_master_key = encrypt_key(&self.master_key, &password_key);
let k_b64 = base64url_encode(&encrypted_master_key);
let response = self
.api
.request(json!({
"a": "up",
"k": k_b64,
"uh": user_hash,
"s": salt_b64
}))
.await?;
if let Some(err_code) = response.as_i64()
&& err_code < 0
{
let error_code = crate::api::ApiErrorCode::from(err_code);
return Err(MegaError::ApiError {
code: err_code as i32,
message: error_code.description().to_string(),
});
}
Ok(())
}
pub(super) async fn login_with_session(session_b64: &str, proxy: Option<&str>) -> Result<Self> {
let blob = Session::parse_session_blob(session_b64)?;
let mut api = match proxy {
Some(p) => ApiClient::with_proxy(p)?,
None => ApiClient::new(),
};
let mut session_id = blob.session_id.clone();
api.set_session_id(session_id.clone());
let sek = make_random_key();
let sek_b64 = base64url_encode(&sek);
let mut login_payload = json!({
"a": "us",
"sek": &sek_b64
});
if let Some(si) = device_id_hash() {
login_payload["si"] = Value::String(si);
}
let login_response = api.request(login_payload).await?;
let session_key = match login_response.get("sek").and_then(|v| v.as_str()) {
Some(sek_b64) => {
let decoded = base64url_decode(sek_b64)?;
if decoded.len() != 16 {
return Err(MegaError::InvalidResponse);
}
let mut key = [0u8; 16];
key.copy_from_slice(&decoded);
Some(key)
}
None => None,
};
let mut master_key = blob.master_key;
if blob.master_key_encrypted {
let sek = session_key.ok_or(MegaError::InvalidResponse)?;
master_key = aes128_ecb_decrypt_block(&blob.master_key, &sek);
}
let rsa_key = if let Some(privk_b64) = login_response.get("privk").and_then(|v| v.as_str())
{
decrypt_private_key(privk_b64, &master_key)?
} else {
Self::empty_rsa_key()
};
if let Some(tsid) = login_response.get("tsid").and_then(|v| v.as_str()) {
if !verify_tsid(tsid, &master_key)? {
return Err(Self::invalid_tsid_error());
}
session_id = tsid.to_string();
api.set_session_id(session_id.clone());
}
let user_info = api.request(json!({"a": "ug", "v": 1})).await?;
let user_handle = user_info["u"]
.as_str()
.ok_or(MegaError::InvalidResponse)?
.to_string();
let user_email = user_info["email"].as_str().unwrap_or_default().to_string();
let user_name = user_info["name"].as_str().map(|s| s.to_string());
let scsn = user_info
.get("sn")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let user_attr_cache = Self::collect_user_attrs_from_ug(&user_info);
let user_attr_versions = Self::collect_user_attr_versions_from_ug(&user_info);
let mut session = Session::new_internal(
api,
session_id,
session_key,
master_key,
rsa_key,
user_email,
user_name,
user_handle,
user_attr_cache,
user_attr_versions,
scsn,
);
session.install_default_persistence_runtime()?;
let account_is_v2 = user_info.get("aav").and_then(|v| v.as_i64()) == Some(2);
if account_is_v2 {
let _ = session.initialize_account_keys().await;
}
let _ = session.load_keys_attribute().await;
let _ = session.promote_pending_shares().await;
if session.clear_inuse_flags_for_missing_shares() {
let _ = session.persist_keys_with_retry().await;
}
Ok(session)
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use base64::Engine;
use base64::engine::general_purpose;
use serde_json::{Value, json};
use super::*;
use crate::api::ApiClient;
use crate::crypto::aes::aes128_ecb_encrypt_block;
struct TestDir {
path: PathBuf,
}
impl TestDir {
fn new(label: &str) -> Self {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!(
"megalib-auth-tests-{}-{}-{}",
label,
std::process::id(),
unique
));
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
struct EnvGuard {
home: Option<std::ffi::OsString>,
xdg_state_home: Option<std::ffi::OsString>,
localappdata: Option<std::ffi::OsString>,
}
impl EnvGuard {
fn new(home: &Path) -> Self {
let xdg_state_home = home.join("xdg-state");
let localappdata = home.join("AppData").join("Local");
let guard = Self {
home: std::env::var_os("HOME"),
xdg_state_home: std::env::var_os("XDG_STATE_HOME"),
localappdata: std::env::var_os("LOCALAPPDATA"),
};
unsafe {
std::env::set_var("HOME", home);
std::env::set_var("XDG_STATE_HOME", &xdg_state_home);
std::env::set_var("LOCALAPPDATA", &localappdata);
}
guard
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match &self.home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match &self.xdg_state_home {
Some(value) => std::env::set_var("XDG_STATE_HOME", value),
None => std::env::remove_var("XDG_STATE_HOME"),
}
match &self.localappdata {
Some(value) => std::env::set_var("LOCALAPPDATA", value),
None => std::env::remove_var("LOCALAPPDATA"),
}
}
}
}
struct TestApiServer {
url: String,
actions: Arc<Mutex<Vec<String>>>,
handle: Option<thread::JoinHandle<()>>,
}
impl TestApiServer {
fn start(tsid: String, user_info: Value) -> Self {
let listener = TcpListener::bind("127.0.0.1:0")
.expect("test should be able to bind a local API stub");
listener
.set_nonblocking(true)
.expect("test API listener should accept nonblocking mode");
let url = format!(
"http://{}/cs",
listener.local_addr().expect("listener address")
);
let actions = Arc::new(Mutex::new(Vec::new()));
let recorded_actions = Arc::clone(&actions);
let user_info = user_info.to_string();
let handle = thread::spawn(move || {
let mut handled_requests = 0usize;
let mut idle_deadline = Instant::now() + Duration::from_secs(2);
loop {
match listener.accept() {
Ok((mut stream, _)) => {
stream
.set_nonblocking(false)
.expect("accepted test API stream should switch to blocking mode");
idle_deadline = Instant::now() + Duration::from_secs(2);
let body = read_request_body(&mut stream);
let request: Value = serde_json::from_slice(&body)
.expect("test API stub should receive valid JSON");
let action = request
.as_array()
.and_then(|items| items.first())
.and_then(|item| item.get("a"))
.and_then(Value::as_str)
.expect("test API stub request should include an action")
.to_string();
recorded_actions
.lock()
.expect("recorded actions mutex should not be poisoned")
.push(action.clone());
handled_requests += 1;
let response_body = match action.as_str() {
"us" => format!(r#"[{{"tsid":"{tsid}"}}]"#),
"ug" => format!("[{user_info}]"),
"uga" => "[-9]".to_string(),
other => panic!("unexpected API action in auth test: {other}"),
};
write_response(&mut stream, &response_body);
if handled_requests >= 3 {
break;
}
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
if Instant::now() >= idle_deadline {
assert_eq!(
handled_requests, 3,
"auth test API stub expected 3 requests but saw {handled_requests}"
);
break;
}
thread::sleep(Duration::from_millis(10));
}
Err(err) => panic!("auth test API stub accept failed: {err}"),
}
}
});
Self {
url,
actions,
handle: Some(handle),
}
}
fn actions(&self) -> Vec<String> {
self.actions
.lock()
.expect("recorded actions mutex should not be poisoned")
.clone()
}
}
impl Drop for TestApiServer {
fn drop(&mut self) {
if let Some(handle) = self.handle.take() {
handle
.join()
.expect("auth test API stub thread should finish cleanly");
}
}
}
fn read_request_body(stream: &mut TcpStream) -> Vec<u8> {
let mut buffer = Vec::new();
let mut chunk = [0u8; 1024];
let header_end = loop {
let read = stream
.read(&mut chunk)
.expect("test API stub should be able to read request bytes");
assert!(read > 0, "test API stub received an empty HTTP request");
buffer.extend_from_slice(&chunk[..read]);
if let Some(end) = find_header_end(&buffer) {
break end;
}
};
let headers = std::str::from_utf8(&buffer[..header_end])
.expect("test API stub request headers should be UTF-8");
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
Some(
value
.trim()
.parse::<usize>()
.expect("content-length header should be numeric"),
)
} else {
None
}
})
.unwrap_or(0);
while buffer.len() < header_end + content_length {
let read = stream
.read(&mut chunk)
.expect("test API stub should be able to read remaining body bytes");
assert!(read > 0, "test API stub request body ended unexpectedly");
buffer.extend_from_slice(&chunk[..read]);
}
buffer[header_end..header_end + content_length].to_vec()
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|idx| idx + 4)
}
fn write_response(stream: &mut TcpStream, body: &str) {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("test API stub should be able to write the response");
stream
.flush()
.expect("test API stub should flush the response");
}
fn test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn make_session_blob(master_key: [u8; 16], sid_bytes: &[u8]) -> String {
let mut blob = Vec::with_capacity(16 + sid_bytes.len());
blob.extend_from_slice(&master_key);
blob.extend_from_slice(sid_bytes);
general_purpose::STANDARD.encode(blob)
}
fn make_valid_tsid(master_key: &[u8; 16]) -> String {
let mut sid = vec![0u8; 43];
sid[..16].copy_from_slice(b"0123456789ABCDEF");
sid[16..27].copy_from_slice(b"session-mid");
let mut challenge = [0u8; 16];
challenge.copy_from_slice(&sid[..16]);
let encrypted = aes128_ecb_encrypt_block(&challenge, master_key);
sid[27..].copy_from_slice(&encrypted);
base64url_encode(&sid)
}
fn default_persistence_root_for_home(home: &Path) -> PathBuf {
#[cfg(target_os = "windows")]
{
return home.join("AppData").join("Local").join("megalib");
}
#[cfg(target_os = "macos")]
{
home.join("Library")
.join("Application Support")
.join("megalib")
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
home.join("xdg-state").join("megalib")
}
}
#[test]
fn load_restores_tree_cache_state_through_authenticated_startup_path() {
let _lock = test_lock()
.lock()
.expect("auth test lock should not be poisoned");
let dir = TestDir::new("startup-restore");
let _env = EnvGuard::new(dir.path());
let root = default_persistence_root_for_home(dir.path());
let master_key = [0x41; 16];
let session_blob = make_session_blob(master_key, b"sid-bytes");
let tsid = make_valid_tsid(&master_key);
let session_file = dir.path().join("session.txt");
let mut persisted = Session::test_dummy().install_persistence_runtime_at_for_tests(root);
persisted.user_handle = "account-handle".to_string();
persisted.master_key = master_key;
persisted.scsn = Some("persisted-scsn".to_string());
persisted.user_alert_lsn = Some("persisted-lsn".to_string());
persisted.user_alerts = vec![json!({"id": "persisted-alert"})];
persisted.alerts_catchup_pending = true;
persisted.nodes = vec![
crate::fs::Node {
name: "Root".to_string(),
handle: "root".to_string(),
parent_handle: None,
node_type: crate::fs::NodeType::Root,
size: 0,
timestamp: 0,
key: Vec::new(),
path: Some("/Root".to_string()),
link: None,
file_attr: None,
share_key: None,
share_handle: None,
is_inshare: false,
is_outshare: false,
share_access: None,
},
crate::fs::Node {
name: "Documents".to_string(),
handle: "docs".to_string(),
parent_handle: Some("root".to_string()),
node_type: crate::fs::NodeType::Folder,
size: 0,
timestamp: 1,
key: vec![0x11; 16],
path: Some("/Root/Documents".to_string()),
link: None,
file_attr: None,
share_key: None,
share_handle: None,
is_inshare: false,
is_outshare: true,
share_access: None,
},
];
persisted.pending_nodes = vec![json!({"h": "pending", "p": "docs", "t": 0})];
persisted.outshares.insert(
"docs".to_string(),
std::collections::HashSet::from(["EXP".to_string()]),
);
persisted.pending_outshares.insert(
"docs".to_string(),
std::collections::HashSet::from(["pending-user".to_string()]),
);
persisted
.persist_tree_cache_state()
.expect("test should seed a disk-backed tree/cache snapshot");
fs::write(&session_file, &session_blob).expect("test should write the session blob file");
let server = TestApiServer::start(
tsid,
json!({
"u": "account-handle",
"email": "restored@example.com",
"name": "Restored User",
"sn": "fresh-server-scsn"
}),
);
ApiClient::set_test_api_url_override(Some(server.url.clone()));
let runtime = tokio::runtime::Runtime::new().expect("auth test runtime should build");
let loaded = runtime
.block_on(Session::load(&session_file))
.expect("startup load should succeed")
.expect("startup load should return a session");
ApiClient::set_test_api_url_override(None);
assert_eq!(loaded.scsn.as_deref(), Some("persisted-scsn"));
assert_eq!(loaded.user_alert_lsn.as_deref(), Some("persisted-lsn"));
assert_eq!(loaded.user_alerts.len(), 1);
assert_eq!(loaded.nodes.len(), 2);
assert_eq!(
loaded
.nodes
.iter()
.find(|node| node.handle == "docs")
.and_then(crate::fs::Node::path),
Some("/Root/Documents")
);
assert_eq!(loaded.pending_nodes.len(), 1);
assert_eq!(
loaded.outshares.get("docs"),
Some(&std::collections::HashSet::from(["EXP".to_string()]))
);
assert_eq!(
loaded.pending_outshares.get("docs"),
Some(&std::collections::HashSet::from([
"pending-user".to_string()
]))
);
assert_eq!(server.actions(), vec!["us", "ug", "uga"]);
}
}