fix(rust): Fix SRP authentication implementation
Fixed issues preventing successful authentication: - Corrected Argon2 memory limit handling (API sends bytes, not KB) - Replaced Blake2b with crypto_kdf_derive_from_key for login subkey derivation - Fixed serde field names to match API expectations (srpUserID, sessionID) - Added non-interactive mode for CLI testing - Added support for ENTE_ENDPOINT environment variable The implementation now matches the web client's key derivation exactly, enabling successful authentication with both local and production servers. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
23
rust/Cargo.lock
generated
23
rust/Cargo.lock
generated
@@ -712,6 +712,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"srp",
|
"srp",
|
||||||
|
"srp6",
|
||||||
"tempdir",
|
"tempdir",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.16",
|
"thiserror 2.0.16",
|
||||||
@@ -1098,6 +1099,12 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex-literal"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bcaaec4551594c969335c98c903c1397853d4198408ea609190f420500f6be71"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hkdf"
|
name = "hkdf"
|
||||||
version = "0.12.4"
|
version = "0.12.4"
|
||||||
@@ -1756,6 +1763,7 @@ dependencies = [
|
|||||||
"num-integer",
|
"num-integer",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2886,6 +2894,21 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "srp6"
|
||||||
|
version = "1.0.0-beta.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "279b6afb40ed1f544789f35d689a0b5fadb2f404de70102cdd8918fdce25e47f"
|
||||||
|
dependencies = [
|
||||||
|
"hex-literal",
|
||||||
|
"num-bigint",
|
||||||
|
"num-traits",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"serde",
|
||||||
|
"sha2",
|
||||||
|
"thiserror 2.0.16",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ zeroize = { version = "1.8", features = ["derive"] }
|
|||||||
|
|
||||||
# SRP authentication
|
# SRP authentication
|
||||||
srp = "0.6"
|
srp = "0.6"
|
||||||
|
srp6 = "1.0.0-beta.1"
|
||||||
num-bigint = { version = "0.4", features = ["rand"] }
|
num-bigint = { version = "0.4", features = ["rand"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
urlencoding = "2.1"
|
urlencoding = "2.1"
|
||||||
|
|||||||
@@ -6,119 +6,9 @@ use crate::api::models::{
|
|||||||
use crate::crypto::{derive_argon_key, derive_login_key};
|
use crate::crypto::{derive_argon_key, derive_login_key};
|
||||||
use crate::models::error::Result;
|
use crate::models::error::Result;
|
||||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||||
use num_bigint::{BigUint, RandBigInt};
|
use sha2::Sha256;
|
||||||
use sha2::{Digest, Sha256};
|
use srp::client::SrpClient;
|
||||||
|
use srp::groups::G_4096;
|
||||||
/// Simple SRP-6a client implementation matching Go's behavior
|
|
||||||
struct SimpleSrpClient {
|
|
||||||
n: BigUint, // Safe prime
|
|
||||||
g: BigUint, // Generator
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SimpleSrpClient {
|
|
||||||
fn new() -> Self {
|
|
||||||
// SRP-4096 group parameters (same as used in Go client)
|
|
||||||
let n_hex = "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF6955817183995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E208E24FA074E5AB3143DB5BFCE0FD108E4B82D120A92108011A723C12A787E6D788719A10BDBA5B2699C327186AF4E23C1A946834B6150BDA2583E9CA2AD44CE8DBBBC2DB04DE8EF92E8EFC141FBECAA6287C59474E6BC05D99B2964FA090C3A2233BA186515BE7ED1F612970CEE2D7AFB81BDD762170481CD0069127D5B05AA993B4EA988D8FDDC186FFB7DC90A6C08F4DF435C93402849236C3FAB4D27C7026C1D4DCB2602646DEC9751E763DBA37BDF8FF9406AD9E530EE5DB382F413001AEB06A53ED9027D831179727B0865A8918DA3EDBEBCF9B14ED44CE6CBACED4BB1BDB7F1447E6CC254B332051512BD7AF426FB8F401378CD2BF5983CA01C64B92ECF032EA15D1721D03F482D7CE6E74FEF6D55E702F46980C82B5A84031900B1C9E59E7C97FBEC7E8F323A97A7E36CC88BE0F1D45B7FF585AC54BD407B22B4154AACC8F6D7EBF48E1D814CC5ED20F8037E0A79715EEF29BE32806A1D58BB7C5DA76F550AA3D8A1FBFF0EB19CCB1A313D55CDA56C9EC2EF29632387FE8D76E3C0468043E8F663F4860EE12BF2D5B0B7474D6E694F91E6DBE115974A3926F12FEE5E438777CB6A932DF8CD8BEC4D073B931BA3BC832B68D9DD300741FA7BF8AFC47ED2576F6936BA424663AAB639C5AE4F5683423B4742BF1C978238F16CBE39D652DE3FDB8BEFC848AD922222E04A4037C0713EB57A81A23F0C73473FC646CEA306B4BCBC8862F8385DDFA9D4B7FA2C087E879683303ED5BDD3A062B3CF5B3A278A66D2A13F83F44F82DDF310EE074AB6A364597E899A0255DC164F31CC50846851DF9AB48195DED7EA1B1D510BD7EE74D73FAF36BC31ECFA268359046F4EB879F924009438B481C6CD7889A002ED5EE382BC9190DA6FC026E479558E4475677E9AA9E3050E2765694DFC81F56E880B96E7160C980DD98EDD3DFFFFFFFFFFFFFFFFF";
|
|
||||||
let g_hex = "02";
|
|
||||||
|
|
||||||
Self {
|
|
||||||
n: BigUint::parse_bytes(n_hex.as_bytes(), 16).unwrap(),
|
|
||||||
g: BigUint::parse_bytes(g_hex.as_bytes(), 16).unwrap(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_keys(&self) -> (BigUint, Vec<u8>) {
|
|
||||||
let mut rng = rand::thread_rng();
|
|
||||||
let client_secret = rng.gen_biguint_below(&self.n);
|
|
||||||
let client_public = self.g.modpow(&client_secret, &self.n);
|
|
||||||
let client_public_bytes = pad_to_n_bytes(client_public.to_bytes_be(), 512); // 4096 bits = 512 bytes
|
|
||||||
(client_secret, client_public_bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_proof(
|
|
||||||
&self,
|
|
||||||
identity: &[u8],
|
|
||||||
password: &[u8],
|
|
||||||
salt: &[u8],
|
|
||||||
client_secret: &BigUint,
|
|
||||||
client_public: &[u8],
|
|
||||||
server_public: &[u8],
|
|
||||||
) -> crate::models::error::Result<Vec<u8>> {
|
|
||||||
// H(N) XOR H(g)
|
|
||||||
let h_n = Sha256::digest(&self.n.to_bytes_be());
|
|
||||||
let h_g = Sha256::digest(&self.g.to_bytes_be());
|
|
||||||
let h_xor: Vec<u8> = h_n.iter().zip(h_g.iter()).map(|(a, b)| a ^ b).collect();
|
|
||||||
|
|
||||||
// H(identity)
|
|
||||||
let h_identity = Sha256::digest(identity);
|
|
||||||
|
|
||||||
// Compute shared key
|
|
||||||
let k = compute_k(&self.n, &self.g);
|
|
||||||
let u = compute_u(client_public, server_public);
|
|
||||||
let x = compute_x(identity, password, salt);
|
|
||||||
|
|
||||||
let server_b = BigUint::from_bytes_be(server_public);
|
|
||||||
let v = self.g.modpow(&x, &self.n);
|
|
||||||
let kv = (&k * &v) % &self.n;
|
|
||||||
let diff = if server_b >= kv {
|
|
||||||
&server_b - &kv
|
|
||||||
} else {
|
|
||||||
&self.n - (&kv - &server_b)
|
|
||||||
};
|
|
||||||
let ux = &u * &x;
|
|
||||||
let aux = client_secret + &ux;
|
|
||||||
let session_key = diff.modpow(&aux, &self.n);
|
|
||||||
let session_key_hash = Sha256::digest(&session_key.to_bytes_be());
|
|
||||||
|
|
||||||
// M1 = H(H(N) XOR H(g) | H(identity) | salt | A | B | session_key)
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(&h_xor);
|
|
||||||
hasher.update(&h_identity);
|
|
||||||
hasher.update(salt);
|
|
||||||
hasher.update(client_public);
|
|
||||||
hasher.update(server_public);
|
|
||||||
hasher.update(&session_key_hash);
|
|
||||||
|
|
||||||
Ok(hasher.finalize().to_vec())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pad_to_n_bytes(bytes: Vec<u8>, n: usize) -> Vec<u8> {
|
|
||||||
if bytes.len() >= n {
|
|
||||||
bytes
|
|
||||||
} else {
|
|
||||||
let mut padded = vec![0u8; n - bytes.len()];
|
|
||||||
padded.extend(bytes);
|
|
||||||
padded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_k(n: &BigUint, g: &BigUint) -> BigUint {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(&n.to_bytes_be());
|
|
||||||
hasher.update(&pad_to_n_bytes(g.to_bytes_be(), 512));
|
|
||||||
BigUint::from_bytes_be(&hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_u(client_public: &[u8], server_public: &[u8]) -> BigUint {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(client_public);
|
|
||||||
hasher.update(server_public);
|
|
||||||
BigUint::from_bytes_be(&hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_x(identity: &[u8], password: &[u8], salt: &[u8]) -> BigUint {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(identity);
|
|
||||||
hasher.update(b":");
|
|
||||||
hasher.update(password);
|
|
||||||
let h1 = hasher.finalize();
|
|
||||||
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(salt);
|
|
||||||
hasher.update(&h1);
|
|
||||||
BigUint::from_bytes_be(&hasher.finalize())
|
|
||||||
}
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// SRP authentication implementation for Ente API
|
/// SRP authentication implementation for Ente API
|
||||||
@@ -167,6 +57,8 @@ impl<'a> AuthClient<'a> {
|
|||||||
srp_m1: STANDARD.encode(client_proof),
|
srp_m1: STANDARD.encode(client_proof),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
log::debug!("Sending verify-session request: {:?}", request);
|
||||||
|
|
||||||
self.api
|
self.api
|
||||||
.post("/users/srp/verify-session", &request, None)
|
.post("/users/srp/verify-session", &request, None)
|
||||||
.await
|
.await
|
||||||
@@ -178,6 +70,8 @@ impl<'a> AuthClient<'a> {
|
|||||||
email: &str,
|
email: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<(AuthResponse, Vec<u8>)> {
|
) -> Result<(AuthResponse, Vec<u8>)> {
|
||||||
|
use rand::RngCore;
|
||||||
|
|
||||||
// Step 1: Get SRP attributes
|
// Step 1: Get SRP attributes
|
||||||
let srp_attrs = self.get_srp_attributes(email).await?;
|
let srp_attrs = self.get_srp_attributes(email).await?;
|
||||||
|
|
||||||
@@ -188,43 +82,68 @@ impl<'a> AuthClient<'a> {
|
|||||||
srp_attrs.mem_limit as u32,
|
srp_attrs.mem_limit as u32,
|
||||||
srp_attrs.ops_limit as u32,
|
srp_attrs.ops_limit as u32,
|
||||||
)?;
|
)?;
|
||||||
|
log::debug!("KEK (hex): {}", hex::encode(&key_enc_key));
|
||||||
|
|
||||||
// Step 3: Derive login key
|
// Step 3: Derive login key
|
||||||
let login_key = derive_login_key(&key_enc_key)?;
|
let login_key = derive_login_key(&key_enc_key)?;
|
||||||
|
log::debug!("loginSubKey (base64): {}", STANDARD.encode(&login_key));
|
||||||
|
log::debug!("loginSubKey (hex): {}", hex::encode(&login_key));
|
||||||
|
|
||||||
// Step 4: Initialize SRP client using the same approach as Go
|
// Step 4: Initialize SRP client
|
||||||
let srp_salt = STANDARD.decode(&srp_attrs.srp_salt)?;
|
let srp_salt = STANDARD.decode(&srp_attrs.srp_salt)?;
|
||||||
let identity = srp_attrs.srp_user_id.to_string();
|
// Use the UUID string directly as bytes (matching TypeScript's Buffer.from(srpUserID))
|
||||||
|
let identity = srp_attrs.srp_user_id.to_string().into_bytes();
|
||||||
|
|
||||||
// Create SRP client (matching Go's srp.NewClient)
|
// Create SRP client with 4096-bit group (matching Go's srp.GetParams(4096))
|
||||||
let srp_client = SimpleSrpClient::new();
|
let client = SrpClient::<Sha256>::new(&G_4096);
|
||||||
let (client_secret, client_public) = srp_client.generate_keys();
|
|
||||||
|
// Generate random ephemeral private key
|
||||||
|
let mut a = vec![0u8; 64];
|
||||||
|
rand::thread_rng().fill_bytes(&mut a);
|
||||||
|
|
||||||
|
// Compute public ephemeral
|
||||||
|
let a_pub = client.compute_public_ephemeral(&a);
|
||||||
|
|
||||||
// Step 5: Create SRP session
|
// Step 5: Create SRP session
|
||||||
|
log::debug!("Creating SRP session...");
|
||||||
let session = self
|
let session = self
|
||||||
.create_srp_session(&srp_attrs.srp_user_id, &client_public)
|
.create_srp_session(&srp_attrs.srp_user_id, &a_pub)
|
||||||
.await?;
|
.await?;
|
||||||
|
log::debug!("Session created successfully: {}", session.session_id);
|
||||||
|
|
||||||
|
// Add a small delay to avoid potential rate limiting
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
// Step 6: Process server's public key and generate proof
|
// Step 6: Process server's public key and generate proof
|
||||||
let server_public = STANDARD.decode(&session.srp_b)?;
|
let server_public = STANDARD.decode(&session.srp_b)?;
|
||||||
let client_proof = srp_client.compute_proof(
|
|
||||||
identity.as_bytes(),
|
// Process the server's response and generate client proof
|
||||||
&login_key,
|
// The srp crate expects: a, username, password, salt, b_pub
|
||||||
&srp_salt,
|
// But Ente uses the login_key (derived from password) as the password for SRP
|
||||||
&client_secret,
|
let verifier = client
|
||||||
&client_public,
|
.process_reply(&a, &identity, &login_key, &srp_salt, &server_public)
|
||||||
&server_public,
|
.map_err(|e| {
|
||||||
)?;
|
crate::models::error::Error::AuthenticationFailed(format!(
|
||||||
|
"SRP client process failed: {:?}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
// Step 7: Verify session with proof
|
// Step 7: Verify session with proof
|
||||||
|
let proof = verifier.proof();
|
||||||
|
|
||||||
let auth_response = self
|
let auth_response = self
|
||||||
.verify_srp_session(&srp_attrs.srp_user_id, &session.session_id, &client_proof)
|
.verify_srp_session(&srp_attrs.srp_user_id, &session.session_id, &proof)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// TODO: Verify server proof if provided
|
// TODO: Verify server proof if provided
|
||||||
// if let Some(srp_m2) = &auth_response.srp_m2 {
|
// if let Some(srp_m2) = &auth_response.srp_m2 {
|
||||||
// let server_proof = STANDARD.decode(srp_m2)?;
|
// let server_proof = STANDARD.decode(srp_m2)?;
|
||||||
// // Verify server proof
|
// verifier.verify_server(&server_proof).map_err(|_| {
|
||||||
|
// crate::models::error::Error::AuthenticationFailed(
|
||||||
|
// "Server proof verification failed".to_string()
|
||||||
|
// )
|
||||||
|
// })?;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
Ok((auth_response, key_enc_key))
|
Ok((auth_response, key_enc_key))
|
||||||
|
|||||||
@@ -156,11 +156,27 @@ impl ApiClient {
|
|||||||
let response = self.execute_with_retry(request).await?;
|
let response = self.execute_with_retry(request).await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
let error_text = response
|
let error_text = response
|
||||||
.text()
|
.text()
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
return Err(Error::Generic(format!("API error: {}", error_text)));
|
|
||||||
|
// Try to parse as JSON to get error details
|
||||||
|
if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_text) {
|
||||||
|
log::error!(
|
||||||
|
"API error: status={}, body={}",
|
||||||
|
status,
|
||||||
|
serde_json::to_string_pretty(&error_json).unwrap_or(error_text.clone())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::error!("API error: status={}, body={}", status, error_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(Error::Generic(format!(
|
||||||
|
"API error ({}): {}",
|
||||||
|
status, error_text
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(response.json().await?)
|
Ok(response.json().await?)
|
||||||
@@ -173,17 +189,43 @@ impl ApiClient {
|
|||||||
B: Serialize,
|
B: Serialize,
|
||||||
{
|
{
|
||||||
let url = format!("{}{}", self.base_url, path);
|
let url = format!("{}{}", self.base_url, path);
|
||||||
|
|
||||||
|
// Debug log the JSON being sent
|
||||||
|
if path.contains("verify-session") {
|
||||||
|
log::debug!(
|
||||||
|
"POST {} with JSON: {}",
|
||||||
|
url,
|
||||||
|
serde_json::to_string_pretty(body)?
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let request = self.client.post(&url).json(body);
|
let request = self.client.post(&url).json(body);
|
||||||
let request = self.build_request(request, account_id);
|
let request = self.build_request(request, account_id);
|
||||||
|
|
||||||
let response = self.execute_with_retry(request).await?;
|
let response = self.execute_with_retry(request).await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
let error_text = response
|
let error_text = response
|
||||||
.text()
|
.text()
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
return Err(Error::Generic(format!("API error: {}", error_text)));
|
|
||||||
|
// Try to parse as JSON to get error details
|
||||||
|
if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_text) {
|
||||||
|
log::error!(
|
||||||
|
"API error: status={}, body={}",
|
||||||
|
status,
|
||||||
|
serde_json::to_string_pretty(&error_json).unwrap_or(error_text.clone())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::error!("API error: status={}, body={}", status, error_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(Error::Generic(format!(
|
||||||
|
"API error ({}): {}",
|
||||||
|
status, error_text
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(response.json().await?)
|
Ok(response.json().await?)
|
||||||
@@ -202,11 +244,27 @@ impl ApiClient {
|
|||||||
let response = self.execute_with_retry(request).await?;
|
let response = self.execute_with_retry(request).await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
let error_text = response
|
let error_text = response
|
||||||
.text()
|
.text()
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
return Err(Error::Generic(format!("API error: {}", error_text)));
|
|
||||||
|
// Try to parse as JSON to get error details
|
||||||
|
if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_text) {
|
||||||
|
log::error!(
|
||||||
|
"API error: status={}, body={}",
|
||||||
|
status,
|
||||||
|
serde_json::to_string_pretty(&error_json).unwrap_or(error_text.clone())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::error!("API error: status={}, body={}", status, error_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(Error::Generic(format!(
|
||||||
|
"API error ({}): {}",
|
||||||
|
status, error_text
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(response.json().await?)
|
Ok(response.json().await?)
|
||||||
@@ -221,11 +279,27 @@ impl ApiClient {
|
|||||||
let response = self.execute_with_retry(request).await?;
|
let response = self.execute_with_retry(request).await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
let error_text = response
|
let error_text = response
|
||||||
.text()
|
.text()
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
return Err(Error::Generic(format!("API error: {}", error_text)));
|
|
||||||
|
// Try to parse as JSON to get error details
|
||||||
|
if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_text) {
|
||||||
|
log::error!(
|
||||||
|
"API error: status={}, body={}",
|
||||||
|
status,
|
||||||
|
serde_json::to_string_pretty(&error_json).unwrap_or(error_text.clone())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::error!("API error: status={}, body={}", status, error_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(Error::Generic(format!(
|
||||||
|
"API error ({}): {}",
|
||||||
|
status, error_text
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ use uuid::Uuid;
|
|||||||
// ========== Authentication Models ==========
|
// ========== Authentication Models ==========
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct SrpAttributes {
|
pub struct SrpAttributes {
|
||||||
|
#[serde(rename = "srpUserID")]
|
||||||
pub srp_user_id: Uuid,
|
pub srp_user_id: Uuid,
|
||||||
|
#[serde(rename = "srpSalt")]
|
||||||
pub srp_salt: String,
|
pub srp_salt: String,
|
||||||
|
#[serde(rename = "memLimit")]
|
||||||
pub mem_limit: i32,
|
pub mem_limit: i32,
|
||||||
|
#[serde(rename = "opsLimit")]
|
||||||
pub ops_limit: i32,
|
pub ops_limit: i32,
|
||||||
|
#[serde(rename = "kekSalt")]
|
||||||
pub kek_salt: String,
|
pub kek_salt: String,
|
||||||
|
#[serde(rename = "isEmailMFAEnabled")]
|
||||||
pub is_email_mfa_enabled: bool,
|
pub is_email_mfa_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,24 +26,28 @@ pub struct GetSrpAttributesResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct CreateSrpSessionRequest {
|
pub struct CreateSrpSessionRequest {
|
||||||
|
#[serde(rename = "srpUserID")]
|
||||||
pub srp_user_id: String,
|
pub srp_user_id: String,
|
||||||
|
#[serde(rename = "srpA")]
|
||||||
pub srp_a: String,
|
pub srp_a: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct CreateSrpSessionResponse {
|
pub struct CreateSrpSessionResponse {
|
||||||
|
#[serde(rename = "sessionID")]
|
||||||
pub session_id: Uuid,
|
pub session_id: Uuid,
|
||||||
|
#[serde(rename = "srpB")]
|
||||||
pub srp_b: String,
|
pub srp_b: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct VerifySrpSessionRequest {
|
pub struct VerifySrpSessionRequest {
|
||||||
|
#[serde(rename = "srpUserID")]
|
||||||
pub srp_user_id: String,
|
pub srp_user_id: String,
|
||||||
|
#[serde(rename = "sessionID")]
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
|
#[serde(rename = "srpM1")]
|
||||||
pub srp_m1: String,
|
pub srp_m1: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,23 @@ pub enum AccountSubcommands {
|
|||||||
List,
|
List,
|
||||||
|
|
||||||
/// Login into existing account
|
/// Login into existing account
|
||||||
Add,
|
Add {
|
||||||
|
/// Email address (optional - will prompt if not provided)
|
||||||
|
#[arg(long)]
|
||||||
|
email: Option<String>,
|
||||||
|
|
||||||
|
/// Password (optional - will prompt if not provided)
|
||||||
|
#[arg(long)]
|
||||||
|
password: Option<String>,
|
||||||
|
|
||||||
|
/// Specify the app (photos, locker, auth)
|
||||||
|
#[arg(long, default_value = "photos")]
|
||||||
|
app: String,
|
||||||
|
|
||||||
|
/// Export directory path
|
||||||
|
#[arg(long)]
|
||||||
|
export_dir: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Update an existing account's export directory
|
/// Update an existing account's export directory
|
||||||
Update {
|
Update {
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ use std::path::PathBuf;
|
|||||||
pub async fn handle_account_command(cmd: AccountCommand, storage: &Storage) -> Result<()> {
|
pub async fn handle_account_command(cmd: AccountCommand, storage: &Storage) -> Result<()> {
|
||||||
match cmd.command {
|
match cmd.command {
|
||||||
AccountSubcommands::List => list_accounts(storage).await,
|
AccountSubcommands::List => list_accounts(storage).await,
|
||||||
AccountSubcommands::Add => add_account(storage).await,
|
AccountSubcommands::Add {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
app,
|
||||||
|
export_dir,
|
||||||
|
} => add_account(storage, email, password, app, export_dir).await,
|
||||||
AccountSubcommands::Update { email, dir, app } => {
|
AccountSubcommands::Update { email, dir, app } => {
|
||||||
update_account(storage, &email, &dir, &app).await
|
update_account(storage, &email, &dir, &app).await
|
||||||
}
|
}
|
||||||
@@ -46,29 +51,52 @@ async fn list_accounts(storage: &Storage) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn add_account(storage: &Storage) -> Result<()> {
|
async fn add_account(
|
||||||
|
storage: &Storage,
|
||||||
|
email_arg: Option<String>,
|
||||||
|
password_arg: Option<String>,
|
||||||
|
app_arg: String,
|
||||||
|
export_dir_arg: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
println!("\n=== Add Ente Account ===\n");
|
println!("\n=== Add Ente Account ===\n");
|
||||||
|
|
||||||
// Get email
|
// Get email (from arg or prompt)
|
||||||
let email: String = Input::new()
|
let email = if let Some(email) = email_arg {
|
||||||
.with_prompt("Enter your email address")
|
email
|
||||||
.interact_text()
|
} else {
|
||||||
.map_err(|e| crate::models::error::Error::InvalidInput(e.to_string()))?;
|
Input::new()
|
||||||
|
.with_prompt("Enter your email address")
|
||||||
|
.interact_text()
|
||||||
|
.map_err(|e| crate::models::error::Error::InvalidInput(e.to_string()))?
|
||||||
|
};
|
||||||
|
|
||||||
// Get app type
|
// Parse app type
|
||||||
let apps = vec!["photos", "locker", "auth"];
|
let app = match app_arg.to_lowercase().as_str() {
|
||||||
let app_index = Select::new()
|
|
||||||
.with_prompt("Select the Ente app")
|
|
||||||
.items(&apps)
|
|
||||||
.default(0)
|
|
||||||
.interact()
|
|
||||||
.map_err(|e| crate::models::error::Error::InvalidInput(e.to_string()))?;
|
|
||||||
|
|
||||||
let app = match apps[app_index] {
|
|
||||||
"photos" => App::Photos,
|
"photos" => App::Photos,
|
||||||
"locker" => App::Locker,
|
"locker" => App::Locker,
|
||||||
"auth" => App::Auth,
|
"auth" => App::Auth,
|
||||||
_ => unreachable!(),
|
_ => {
|
||||||
|
// If invalid app provided via CLI, use interactive selection
|
||||||
|
if password_arg.is_some() {
|
||||||
|
return Err(crate::models::error::Error::InvalidInput(format!(
|
||||||
|
"Invalid app: {}. Must be one of: photos, locker, auth",
|
||||||
|
app_arg
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let apps = vec!["photos", "locker", "auth"];
|
||||||
|
let app_index = Select::new()
|
||||||
|
.with_prompt("Select the Ente app")
|
||||||
|
.items(&apps)
|
||||||
|
.default(0)
|
||||||
|
.interact()
|
||||||
|
.map_err(|e| crate::models::error::Error::InvalidInput(e.to_string()))?;
|
||||||
|
match apps[app_index] {
|
||||||
|
"photos" => App::Photos,
|
||||||
|
"locker" => App::Locker,
|
||||||
|
"auth" => App::Auth,
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if account already exists
|
// Check if account already exists
|
||||||
@@ -80,18 +108,26 @@ async fn add_account(storage: &Storage) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get password
|
// Get password (from arg or prompt)
|
||||||
let password = Password::new()
|
let password = if let Some(password) = password_arg {
|
||||||
.with_prompt("Enter your password")
|
password
|
||||||
.interact()
|
} else {
|
||||||
.map_err(|e| crate::models::error::Error::InvalidInput(e.to_string()))?;
|
Password::new()
|
||||||
|
.with_prompt("Enter your password")
|
||||||
|
.interact()
|
||||||
|
.map_err(|e| crate::models::error::Error::InvalidInput(e.to_string()))?
|
||||||
|
};
|
||||||
|
|
||||||
// Get export directory
|
// Get export directory (from arg or prompt)
|
||||||
let export_dir: String = Input::new()
|
let export_dir = if let Some(dir) = export_dir_arg {
|
||||||
.with_prompt("Enter export directory path")
|
dir
|
||||||
.default(format!("./exports/{}", email))
|
} else {
|
||||||
.interact_text()
|
Input::new()
|
||||||
.map_err(|e| crate::models::error::Error::InvalidInput(e.to_string()))?;
|
.with_prompt("Enter export directory path")
|
||||||
|
.default(format!("./exports/{}", email))
|
||||||
|
.interact_text()
|
||||||
|
.map_err(|e| crate::models::error::Error::InvalidInput(e.to_string()))?
|
||||||
|
};
|
||||||
|
|
||||||
// Validate export directory
|
// Validate export directory
|
||||||
let export_path = PathBuf::from(&export_dir);
|
let export_path = PathBuf::from(&export_dir);
|
||||||
@@ -100,8 +136,12 @@ async fn add_account(storage: &Storage) -> Result<()> {
|
|||||||
std::fs::create_dir_all(&export_path).map_err(|e| crate::models::error::Error::Io(e))?;
|
std::fs::create_dir_all(&export_path).map_err(|e| crate::models::error::Error::Io(e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize API client
|
// Initialize API client (use ENTE_ENDPOINT env var if set, otherwise default to production)
|
||||||
let api_client = ApiClient::new(None)?;
|
let api_endpoint = std::env::var("ENTE_ENDPOINT").ok();
|
||||||
|
if let Some(ref endpoint) = api_endpoint {
|
||||||
|
log::debug!("Using custom API endpoint: {}", endpoint);
|
||||||
|
}
|
||||||
|
let api_client = ApiClient::new(api_endpoint)?;
|
||||||
let auth_client = AuthClient::new(&api_client);
|
let auth_client = AuthClient::new(&api_client);
|
||||||
|
|
||||||
println!("\nAuthenticating with Ente servers...");
|
println!("\nAuthenticating with Ente servers...");
|
||||||
|
|||||||
@@ -27,17 +27,20 @@ pub fn derive_argon_key(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut key = vec![0u8; 32]; // 32 bytes output
|
let mut key = vec![0u8; sodium::crypto_secretbox_KEYBYTES as usize]; // 32 bytes output
|
||||||
|
|
||||||
|
// Convert password to bytes (matching JS sodium.from_string)
|
||||||
|
let password_bytes = password.as_bytes();
|
||||||
|
|
||||||
let result = unsafe {
|
let result = unsafe {
|
||||||
sodium::crypto_pwhash(
|
sodium::crypto_pwhash(
|
||||||
key.as_mut_ptr(),
|
key.as_mut_ptr(),
|
||||||
key.len() as u64,
|
key.len() as u64,
|
||||||
password.as_ptr() as *const std::ffi::c_char,
|
password_bytes.as_ptr() as *const std::ffi::c_char,
|
||||||
password.len() as u64,
|
password_bytes.len() as u64,
|
||||||
salt_bytes.as_ptr(),
|
salt_bytes.as_ptr(),
|
||||||
ops_limit as u64,
|
ops_limit as u64,
|
||||||
mem_limit as usize * 1024, // Convert from KB to bytes
|
mem_limit as usize, // API sends bytes, libsodium-sys expects bytes
|
||||||
sodium::crypto_pwhash_ALG_ARGON2ID13 as i32,
|
sodium::crypto_pwhash_ALG_ARGON2ID13 as i32,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,61 +6,30 @@ const LOGIN_SUB_KEY_ID: u64 = 1;
|
|||||||
const LOGIN_SUB_KEY_CONTEXT: &[u8] = b"loginctx";
|
const LOGIN_SUB_KEY_CONTEXT: &[u8] = b"loginctx";
|
||||||
|
|
||||||
/// Derive login key from key encryption key
|
/// Derive login key from key encryption key
|
||||||
/// This matches the Go implementation's DeriveLoginKey function
|
/// This matches the web implementation's deriveSRPLoginSubKey function
|
||||||
pub fn derive_login_key(key_enc_key: &[u8]) -> Result<Vec<u8>> {
|
pub fn derive_login_key(key_enc_key: &[u8]) -> Result<Vec<u8>> {
|
||||||
let sub_key = derive_sub_key(
|
// Derive 32 bytes using crypto_kdf_derive_from_key
|
||||||
key_enc_key,
|
let mut sub_key = vec![0u8; LOGIN_SUB_KEY_LEN];
|
||||||
LOGIN_SUB_KEY_CONTEXT,
|
|
||||||
LOGIN_SUB_KEY_ID,
|
|
||||||
LOGIN_SUB_KEY_LEN,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Return the first 16 bytes of the derived key
|
// Ensure context is exactly 8 bytes (crypto_kdf_CONTEXTBYTES)
|
||||||
Ok(sub_key[..16].to_vec())
|
let mut context = [0u8; sodium::crypto_kdf_CONTEXTBYTES as usize];
|
||||||
}
|
let context_len = LOGIN_SUB_KEY_CONTEXT.len().min(context.len());
|
||||||
|
context[..context_len].copy_from_slice(&LOGIN_SUB_KEY_CONTEXT[..context_len]);
|
||||||
/// Derive a subkey using Blake2b (matching Go's deriveSubKey)
|
|
||||||
fn derive_sub_key(
|
|
||||||
master_key: &[u8],
|
|
||||||
context: &[u8],
|
|
||||||
sub_key_id: u64,
|
|
||||||
sub_key_length: usize,
|
|
||||||
) -> Result<Vec<u8>> {
|
|
||||||
const CRYPTO_KDF_BLAKE2B_BYTES_MIN: usize = 16;
|
|
||||||
const CRYPTO_KDF_BLAKE2B_BYTES_MAX: usize = 64;
|
|
||||||
|
|
||||||
if !(CRYPTO_KDF_BLAKE2B_BYTES_MIN..=CRYPTO_KDF_BLAKE2B_BYTES_MAX).contains(&sub_key_length) {
|
|
||||||
return Err(Error::Crypto("subKeyLength out of bounds".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pad the context to 16 bytes (PERSONALBYTES)
|
|
||||||
let mut ctx_padded = vec![0u8; sodium::crypto_generichash_blake2b_PERSONALBYTES as usize];
|
|
||||||
let context_len = context.len().min(ctx_padded.len());
|
|
||||||
ctx_padded[..context_len].copy_from_slice(&context[..context_len]);
|
|
||||||
|
|
||||||
// Convert subKeyID to byte slice and pad to 16 bytes (SALTBYTES)
|
|
||||||
let mut salt = vec![0u8; sodium::crypto_generichash_blake2b_SALTBYTES as usize];
|
|
||||||
salt[..8].copy_from_slice(&sub_key_id.to_le_bytes());
|
|
||||||
|
|
||||||
// Create output buffer
|
|
||||||
let mut out = vec![0u8; sub_key_length];
|
|
||||||
|
|
||||||
let result = unsafe {
|
let result = unsafe {
|
||||||
sodium::crypto_generichash_blake2b_salt_personal(
|
sodium::crypto_kdf_derive_from_key(
|
||||||
out.as_mut_ptr(),
|
sub_key.as_mut_ptr(),
|
||||||
sub_key_length,
|
LOGIN_SUB_KEY_LEN,
|
||||||
std::ptr::null(), // No input data, just using key, salt, and personalization
|
LOGIN_SUB_KEY_ID,
|
||||||
0,
|
context.as_ptr(),
|
||||||
master_key.as_ptr(),
|
key_enc_key.as_ptr(),
|
||||||
master_key.len(),
|
|
||||||
salt.as_ptr(),
|
|
||||||
ctx_padded.as_ptr(),
|
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
if result != 0 {
|
if result != 0 {
|
||||||
return Err(Error::Crypto("Failed to derive subkey with Blake2b".into()));
|
return Err(Error::Crypto("Failed to derive login subkey".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(out)
|
// Return the first 16 bytes of the derived key (matching web implementation)
|
||||||
|
Ok(sub_key[..16].to_vec())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user