diff --git a/Cargo.lock b/Cargo.lock index 49dcd69..624e0c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -318,7 +318,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-link", ] @@ -1709,6 +1711,7 @@ dependencies = [ "base64", "bcrypt", "bytes", + "chrono", "futures", "getrandom 0.2.17", "hex", diff --git a/Cargo.toml b/Cargo.toml index 3df0c7b..7a63da8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,8 @@ aes-gcm = "0.10" hkdf = "0.12" bcrypt = "0.17" pgp = "0.16" +# Match the chrono line rPGP pulls in; used to stamp detached-signature subpackets. +chrono = "0.4" tracing = "0.1" num-bigint = "0.4" num-traits = "0.2" diff --git a/crates/proton-drive-sdk/tests/common/mod.rs b/crates/proton-drive-sdk/tests/common/mod.rs new file mode 100644 index 0000000..e9f30f4 --- /dev/null +++ b/crates/proton-drive-sdk/tests/common/mod.rs @@ -0,0 +1,180 @@ +//! Shared harness for live integration tests against a real Proton account. +//! +//! Tests are `#[ignore]` by default and run only with `cargo test -- --ignored`. +//! When credentials are absent they **skip cleanly** (return `None`) rather than +//! fail, so `--ignored` runs stay green in CI without secrets. +//! +//! Required env / `.env` (repo root): +//! `username=`, `password=` (in `.env`) +//! `PROTON_TOTP_SECRET=` (base32 2FA secret; env var) +//! +//! Read-only `live_login` example duplicates the TOTP/dotenv logic; this is the +//! authenticated-client factory for the mutating integration suite. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use hmac::{Hmac, Mac}; +use proton_drive_sdk::ProtonDriveClient; +use proton_sdk::config::ProtonClientConfiguration; +use proton_sdk::session::ProtonApiSession; +use sha1::Sha1; + +// Must follow Proton's required shape (sdk/README.md "Operational requirements"). +// A malformed channel fails honest-identification → 422 "unusual activity". +const APP_VERSION: &str = "external-drive-rust@0.1.0-alpha"; +const USER_AGENT: &str = "external-drive-rust/0.1.0"; + +/// An authenticated Drive client plus the data password it was built with. +pub struct LiveClient { + pub client: ProtonDriveClient, + // Kept alive: the session backs the client's token store. + pub _session: ProtonApiSession, +} + +/// Build an authenticated `ProtonDriveClient`, or `None` when credentials are +/// not configured (skip, don't fail). +/// +/// Call from a test like: +/// ```ignore +/// let Some(live) = common::live_client().await else { return }; +/// ``` +pub async fn live_client() -> Option { + let (username, password) = match read_dotenv() { + Ok(creds) => creds, + Err(e) => { + eprintln!("[skip] no .env credentials: {e}"); + return None; + } + }; + let totp_secret = match read_totp_secret() { + Some(s) => s, + None => { + eprintln!("[skip] PROTON_TOTP_SECRET not set (env or .env)"); + return None; + } + }; + + let config = ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT); + + let mut session = ProtonApiSession::begin(config, &username, password.as_bytes()) + .await + .expect("SRP login failed"); + + if session.is_waiting_for_second_factor() { + let code = totp_now(&totp_secret).expect("TOTP compute failed"); + session + .apply_second_factor_code(&code) + .await + .expect("2FA submission failed"); + } + + let client = ProtonDriveClient::new(&session, password.into_bytes()); + Some(LiveClient { + client, + _session: session, + }) +} + +/// Minimal `.env` reader: `key=value` lines, trims whitespace, ignores `#`. +fn read_dotenv() -> Result<(String, String), Box> { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../.env"); + let text = std::fs::read_to_string(path).map_err(|e| format!("read {path}: {e}"))?; + let mut username = None; + let mut password = None; + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((k, v)) = line.split_once('=') else { + continue; + }; + match k.trim() { + "username" => username = Some(v.trim().to_owned()), + "password" => password = Some(v.trim().to_owned()), + _ => {} + } + } + Ok(( + username.ok_or("username missing in .env")?, + password.ok_or("password missing in .env")?, + )) +} + +/// TOTP secret from `PROTON_TOTP_SECRET` env var, falling back to a +/// `PROTON_TOTP_SECRET=` line in `.env`. +fn read_totp_secret() -> Option { + if let Ok(s) = std::env::var("PROTON_TOTP_SECRET") { + if !s.trim().is_empty() { + return Some(s.trim().to_owned()); + } + } + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../.env"); + let text = std::fs::read_to_string(path).ok()?; + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((k, v)) = line.split_once('=') { + if k.trim() == "PROTON_TOTP_SECRET" { + let v = v.trim(); + if !v.is_empty() { + return Some(v.to_owned()); + } + } + } + } + None +} + +/// RFC 6238 TOTP (SHA-1, 6 digits, 30s step) for the current time. +fn totp_now(secret_b32: &str) -> Result> { + let key = base32_decode(secret_b32)?; + let counter = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() / 30; + + let mut mac = Hmac::::new_from_slice(&key)?; + mac.update(&counter.to_be_bytes()); + let digest = mac.finalize().into_bytes(); + + let offset = (digest[19] & 0x0f) as usize; + let bin = ((digest[offset] as u32 & 0x7f) << 24) + | ((digest[offset + 1] as u32) << 16) + | ((digest[offset + 2] as u32) << 8) + | (digest[offset + 3] as u32); + Ok(format!("{:06}", bin % 1_000_000)) +} + +/// RFC 4648 base32 decode (uppercase, padding optional). +fn base32_decode(s: &str) -> Result, Box> { + const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut buf = 0u64; + let mut bits = 0u32; + let mut out = Vec::new(); + for c in s.trim().bytes() { + if c == b'=' || c == b' ' { + continue; + } + let up = c.to_ascii_uppercase(); + let val = ALPHA + .iter() + .position(|&a| a == up) + .ok_or("invalid base32 char")? as u64; + buf = (buf << 5) | val; + bits += 5; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + } + } + Ok(out) +} + +/// A unique-ish suffix for test artifact names, so reruns don't collide. +pub fn unique_suffix() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("{nanos}") +} diff --git a/crates/proton-drive-sdk/tests/live_upload_download.rs b/crates/proton-drive-sdk/tests/live_upload_download.rs new file mode 100644 index 0000000..c36aeae --- /dev/null +++ b/crates/proton-drive-sdk/tests/live_upload_download.rs @@ -0,0 +1,131 @@ +//! Live integration: upload → download → byte-equality round-trips. +//! +//! Skipped by default. Run against the test account with: +//! PROTON_TOTP_SECRET=... cargo test -p proton-drive-sdk --test live_upload_download -- --ignored --nocapture +//! +//! Each test cleans up after itself (trash + delete-from-trash) so the account +//! stays reusable across runs. + +mod common; + +use proton_sdk::ids::NodeUid; + +/// Trash then permanently delete the given nodes; best-effort, logs on failure. +async fn cleanup(client: &proton_drive_sdk::ProtonDriveClient, uids: &[NodeUid]) { + if let Err(e) = client.trash_nodes(uids).await { + eprintln!("[cleanup] trash failed: {e}"); + return; + } + if let Err(e) = client.delete_nodes(uids).await { + eprintln!("[cleanup] delete failed: {e}"); + } +} + +/// Small single-block legacy SEIPDv1 upload, downloaded back, bytes compared. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn upload_download_small_roundtrip() { + let Some(live) = common::live_client().await else { + return; + }; + let client = live.client; + + let root = client + .get_my_files_folder() + .await + .expect("get my-files root"); + + let name = format!("rt-small-{}.txt", common::unique_suffix()); + let payload = b"hello proton drive integration test \x00\x01\x02 bytes".to_vec(); + + let uid = client + .upload_file(&root.uid, &name, "text/plain", &payload) + .await + .expect("upload_file"); + + let got = client.download_file(&uid).await.expect("download_file"); + assert_eq!(got, payload, "downloaded bytes must match uploaded"); + + cleanup(&client, &[uid]).await; +} + +/// Multi-block streaming upload (> 4 MiB) exercising `upload_file_from` and the +/// paginated revision-block download path. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn upload_download_multiblock_roundtrip() { + let Some(live) = common::live_client().await else { + return; + }; + let client = live.client; + + let root = client + .get_my_files_folder() + .await + .expect("get my-files root"); + + // 10 MiB of non-trivial, non-repeating-friendly content (3 full 4 MiB blocks). + let size = 10 * 1024 * 1024; + let mut payload = vec![0u8; size]; + for (i, b) in payload.iter_mut().enumerate() { + *b = (i as u32).wrapping_mul(2_654_435_761) as u8; + } + + let name = format!("rt-multi-{}.bin", common::unique_suffix()); + let reader = std::io::Cursor::new(payload.clone()); + + let uid = client + .upload_file_from( + &root.uid, + &name, + "application/octet-stream", + reader, + size as i64, + Vec::new(), // thumbnails + None, // last_modification_time + false, // aead + ) + .await + .expect("upload_file_from"); + + let got = client.download_file(&uid).await.expect("download_file"); + assert_eq!(got.len(), payload.len(), "size mismatch"); + assert_eq!(got, payload, "multi-block bytes must match"); + + cleanup(&client, &[uid]).await; +} + +/// New revision over an existing file: upload v1, replace with v2, download must +/// yield v2. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn new_revision_roundtrip() { + let Some(live) = common::live_client().await else { + return; + }; + let client = live.client; + + let root = client + .get_my_files_folder() + .await + .expect("get my-files root"); + + let name = format!("rt-rev-{}.txt", common::unique_suffix()); + let v1 = b"first revision".to_vec(); + let v2 = b"second revision, longer than the first".to_vec(); + + let uid = client + .upload_file(&root.uid, &name, "text/plain", &v1) + .await + .expect("upload v1"); + + client + .upload_new_revision(&uid, &v2) + .await + .expect("upload v2"); + + let got = client.download_file(&uid).await.expect("download"); + assert_eq!(got, v2, "active revision must be v2"); + + cleanup(&client, &[uid]).await; +} diff --git a/crates/proton-sdk/Cargo.toml b/crates/proton-sdk/Cargo.toml index 472437d..df2235d 100644 --- a/crates/proton-sdk/Cargo.toml +++ b/crates/proton-sdk/Cargo.toml @@ -25,6 +25,7 @@ aes-gcm.workspace = true hkdf.workspace = true bcrypt.workspace = true pgp.workspace = true +chrono.workspace = true tracing.workspace = true num-bigint.workspace = true num-traits.workspace = true diff --git a/crates/proton-sdk/src/crypto/encrypt.rs b/crates/proton-sdk/src/crypto/encrypt.rs index c15b165..ff3aaa4 100644 --- a/crates/proton-sdk/src/crypto/encrypt.rs +++ b/crates/proton-sdk/src/crypto/encrypt.rs @@ -15,8 +15,9 @@ use pgp::composed::{ }; use pgp::crypto::hash::HashAlgorithm; use pgp::crypto::sym::SymmetricKeyAlgorithm; -use pgp::packet::{SignatureConfig, SignatureType}; -use pgp::types::{CompressionAlgorithm, KeyVersion, Password, PublicKeyTrait}; +use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData}; +use pgp::types::{CompressionAlgorithm, KeyDetails, KeyVersion, Password, PublicKeyTrait}; +use chrono::SubsecRound; use rand::RngCore; use super::errors::CryptoError; @@ -244,8 +245,27 @@ pub(crate) fn sign_detached(signer: &PrivateKey, data: &[u8]) -> Result