From b0eeb8cf0f4b811e0d4aaae1de897735dc6c8f72 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 25 Jun 2026 17:26:02 +0200 Subject: [PATCH] updated readme + example --- README.md | 83 +++++++---- .../proton-drive-sdk/examples/live_login.rs | 134 ++++++++++++++++++ 2 files changed, 190 insertions(+), 27 deletions(-) create mode 100644 crates/proton-drive-sdk/examples/live_login.rs diff --git a/README.md b/README.md index 219244f..64a5d44 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ A pure-Rust reimplementation of the [Proton Drive SDK](https://github.com/ProtonDriveApps/sdk). No dependency on the official NativeAOT core — it talks to the Proton Drive API -and does the OpenPGP crypto directly. +and does the OpenPGP crypto directly. The canonical reference is the upstream +**C#** implementation; behavior is matched to it where the protocol is unclear. > Unofficial, third-party project. Not affiliated with or endorsed by Proton. > If you build on it, follow the operational requirements in the upstream SDK @@ -10,18 +11,37 @@ and does the OpenPGP crypto directly. ## Status -**Milestone 1 — session + read operations.** Implemented: +**Milestone 2 — authenticated read/write.** Implemented: -- Core: typed IDs, API response envelope, config, reqwest HTTP client with - `x-pm-uid` / `x-pm-appversion` / bearer auth and transparent 401 token refresh. -- Session: `resume` from existing tokens, `end`. -- Account: user keys, address keys, and Proton's bcrypt key-passphrase - derivation (the mailbox password is required even for read — see below). -- Drive read: `get_my_files_folder`, `get_node`, `enumerate_folder_children`, - with share and node (link) decryption and recursive parent-key resolution. +- **Core / HTTP**: typed IDs, API response envelope, config, reqwest client with + `x-pm-uid` / `x-pm-appversion` / bearer auth, transparent 401 refresh, + retry/backoff (408/429/502/503/504 + transient transport, `Retry-After` + + jitter), and per-request telemetry. +- **Session**: SRP-6a password login (`begin`), TOTP 2FA + (`apply_second_factor_code`), scope refresh, `resume` from tokens, `end`. +- **Account**: user keys, address keys, bcrypt key-passphrase derivation, + public-key resolution for authorship verification (`core/v4/keys/all`). +- **Drive read**: `get_my_files_folder`, `get_node`, `enumerate_folder_children`, + `enumerate_nodes`, `enumerate_trash`, with share/node decryption and recursive + parent-key resolution. +- **Download**: `download_file` / `download_file_to` (streaming), thumbnails, + content-manifest integrity + non-fatal signature verification. +- **Upload**: `upload_file` / `upload_file_from` (streaming `Read`), new + revisions, caller-supplied thumbnails, and AEAD blocks (SEIPDv2 / AES-256-GCM) + alongside legacy SEIPDv1. +- **Folder / node ops**: create, rename, trash, restore, delete, empty-trash, + and same-volume `move_node`. +- **Events**: `main_volume_id`, `latest_event_id`, `enumerate_events`. +- **Photos**: `ProtonPhotosClient` — timeline, photo get/download, upload. +- **Caching**: pluggable entity cache (`with_entity_cache`); secrets stay + in-memory. +- **Telemetry**: pluggable `Telemetry` observer (`with_telemetry`). -Not yet implemented: SRP password login (`begin`), uploads/downloads, sharing, -events, trash, photos, signature-verification enforcement, persistent caching. +Not yet implemented: cross-volume move (needs `NewShareID` + re-signing), +sharing, signature-verification *enforcement* (it is non-fatal metadata). + +Crypto paths have offline round-trip tests; the write/move paths still need live +validation against a real account. ## Workspace @@ -30,7 +50,7 @@ events, trash, photos, signature-verification enforcement, persistent caching. | `crates/proton-sdk` | Core account/session/crypto (`Proton.Sdk` analogue) | | `crates/proton-drive-sdk` | High-level Drive client (`Proton.Drive.Sdk` analogue) | -OpenPGP is provided by the [`pgp`](https://crates.io/crates/pgp) crate (rPGP). +OpenPGP is provided by the [`pgp`](https://crates.io/crates/pgp) crate (rPGP 0.16). ## The mailbox-password requirement @@ -43,28 +63,26 @@ mailbox password + key salt --bcrypt--> key passphrase -> decrypt share/node passphrase -> unlock node key -> decrypt names ``` -`ProtonApiSession::resume` takes the tokens; `ProtonDriveClient::new` takes the -mailbox password. +`ProtonApiSession::begin` / `resume` handle the tokens; `ProtonDriveClient::new` +takes the mailbox password. ## Usage +SRP login (with 2FA), then read the my-files root: + ```rust,no_run use proton_sdk::config::ProtonClientConfiguration; -use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; +use proton_sdk::session::ProtonApiSession; use proton_drive_sdk::ProtonDriveClient; # async fn run() -> proton_sdk::error::Result<()> { let config = ProtonClientConfiguration::new("external-drive-myapp@0.1.0-alpha"); -let session = ProtonApiSession::resume(config, ResumeParameters { - session_id: "uid".into(), - username: "user@proton.me".into(), - user_id: "user-id".into(), - access_token: "access".into(), - refresh_token: "refresh".into(), - scopes: vec![], - is_waiting_for_second_factor_code: false, - password_mode: PasswordMode::Single, -})?; + +let mut session = + ProtonApiSession::begin(config, "user@proton.me", b"mailbox-password").await?; +if session.is_waiting_for_second_factor() { + session.apply_second_factor_code("123456").await?; +} let drive = ProtonDriveClient::new(&session, b"mailbox-password".to_vec()); let root = drive.get_my_files_folder().await?; @@ -75,11 +93,22 @@ for child in drive.enumerate_folder_children(&root.uid).await? { # } ``` +Already have tokens? Use `ProtonApiSession::resume(config, ResumeParameters {..})` +instead of `begin`. + +A live smoke test lives in `crates/proton-drive-sdk/examples/live_login.rs` +(reads `username` / `password` from a repo-root `.env`, TOTP from +`PROTON_TOTP_SECRET`): + +```bash +PROTON_TOTP_SECRET=... cargo run -p proton-drive-sdk --example live_login +``` + ## Build & test ```bash -cargo build -cargo test +cargo build # 0 warnings / 0 errors +cargo test # offline crypto + derivation round-trip tests ``` ## License diff --git a/crates/proton-drive-sdk/examples/live_login.rs b/crates/proton-drive-sdk/examples/live_login.rs new file mode 100644 index 0000000..fc69a6e --- /dev/null +++ b/crates/proton-drive-sdk/examples/live_login.rs @@ -0,0 +1,134 @@ +//! Live smoke test against a real Proton account. +//! +//! Reads credentials from `.env` (repo root): `username=`, `password=`. +//! The TOTP secret comes from the `PROTON_TOTP_SECRET` env var (base32). +//! +//! Run: `PROTON_TOTP_SECRET=... cargo run -p proton-drive-sdk --example live_login` +//! +//! Flow: SRP `begin` → `apply_second_factor_code` (computed TOTP) → construct +//! `ProtonDriveClient` with the data password → `get_my_files_folder` → +//! enumerate its children. Read-only; mutates nothing server-side. + +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; + +const APP_VERSION: &str = "external-drive-rust@0.1.0-dev"; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let (username, password) = read_dotenv()?; + let totp_secret = std::env::var("PROTON_TOTP_SECRET") + .map_err(|_| "PROTON_TOTP_SECRET not set (base32 2FA secret)")?; + + let config = ProtonClientConfiguration::new(APP_VERSION); + + eprintln!("[*] SRP login as {username} ..."); + let mut session = ProtonApiSession::begin(config, &username, password.as_bytes()).await?; + eprintln!( + "[+] authenticated. user_id={} waiting_2fa={} scopes={:?}", + session.user_id().as_str(), + session.is_waiting_for_second_factor(), + session.scopes(), + ); + + if session.is_waiting_for_second_factor() { + let code = totp_now(&totp_secret)?; + eprintln!("[*] submitting 2FA code {code} ..."); + session.apply_second_factor_code(&code).await?; + eprintln!("[+] 2FA accepted. scopes={:?}", session.scopes()); + } + + let client = ProtonDriveClient::new(&session, password.into_bytes()); + + eprintln!("[*] fetching my-files root ..."); + let root = client.get_my_files_folder().await?; + eprintln!( + "[+] root: name={:?} kind={:?} uid={}", + root.name, + root.kind, + root.uid, + ); + + eprintln!("[*] enumerating children ..."); + let children = client.enumerate_folder_children(&root.uid).await?; + eprintln!("[+] {} children:", children.len()); + for c in &children { + eprintln!(" - {:?} [{:?}] {}", c.name, c.kind, c.uid); + } + + eprintln!("[*] done."); + Ok(()) +} + +/// 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")?, + )) +} + +/// 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) +}