mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
updated readme + example
This commit is contained in:
1 parent
5d4e20d4c2
commit
b0eeb8cf0f
2 files changed
+190
-27
No files matched your search
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
A pure-Rust reimplementation of the [Proton Drive SDK](https://github.com/ProtonDriveApps/sdk).
|
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
|
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.
|
> Unofficial, third-party project. Not affiliated with or endorsed by Proton.
|
||||||
> If you build on it, follow the operational requirements in the upstream SDK
|
> If you build on it, follow the operational requirements in the upstream SDK
|
||||||
@@ -10,18 +11,37 @@ and does the OpenPGP crypto directly.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Milestone 1 — session + read operations.** Implemented:
|
**Milestone 2 — authenticated read/write.** Implemented:
|
||||||
|
|
||||||
- Core: typed IDs, API response envelope, config, reqwest HTTP client with
|
- **Core / HTTP**: typed IDs, API response envelope, config, reqwest client with
|
||||||
`x-pm-uid` / `x-pm-appversion` / bearer auth and transparent 401 token refresh.
|
`x-pm-uid` / `x-pm-appversion` / bearer auth, transparent 401 refresh,
|
||||||
- Session: `resume` from existing tokens, `end`.
|
retry/backoff (408/429/502/503/504 + transient transport, `Retry-After` +
|
||||||
- Account: user keys, address keys, and Proton's bcrypt key-passphrase
|
jitter), and per-request telemetry.
|
||||||
derivation (the mailbox password is required even for read — see below).
|
- **Session**: SRP-6a password login (`begin`), TOTP 2FA
|
||||||
- Drive read: `get_my_files_folder`, `get_node`, `enumerate_folder_children`,
|
(`apply_second_factor_code`), scope refresh, `resume` from tokens, `end`.
|
||||||
with share and node (link) decryption and recursive parent-key resolution.
|
- **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,
|
Not yet implemented: cross-volume move (needs `NewShareID` + re-signing),
|
||||||
events, trash, photos, signature-verification enforcement, persistent caching.
|
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
|
## 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-sdk` | Core account/session/crypto (`Proton.Sdk` analogue) |
|
||||||
| `crates/proton-drive-sdk` | High-level Drive client (`Proton.Drive.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
|
## The mailbox-password requirement
|
||||||
|
|
||||||
@@ -43,28 +63,26 @@ mailbox password + key salt --bcrypt--> key passphrase
|
|||||||
-> decrypt share/node passphrase -> unlock node key -> decrypt names
|
-> decrypt share/node passphrase -> unlock node key -> decrypt names
|
||||||
```
|
```
|
||||||
|
|
||||||
`ProtonApiSession::resume` takes the tokens; `ProtonDriveClient::new` takes the
|
`ProtonApiSession::begin` / `resume` handle the tokens; `ProtonDriveClient::new`
|
||||||
mailbox password.
|
takes the mailbox password.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
SRP login (with 2FA), then read the my-files root:
|
||||||
|
|
||||||
```rust,no_run
|
```rust,no_run
|
||||||
use proton_sdk::config::ProtonClientConfiguration;
|
use proton_sdk::config::ProtonClientConfiguration;
|
||||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
use proton_sdk::session::ProtonApiSession;
|
||||||
use proton_drive_sdk::ProtonDriveClient;
|
use proton_drive_sdk::ProtonDriveClient;
|
||||||
|
|
||||||
# async fn run() -> proton_sdk::error::Result<()> {
|
# async fn run() -> proton_sdk::error::Result<()> {
|
||||||
let config = ProtonClientConfiguration::new("external-drive-myapp@0.1.0-alpha");
|
let config = ProtonClientConfiguration::new("external-drive-myapp@0.1.0-alpha");
|
||||||
let session = ProtonApiSession::resume(config, ResumeParameters {
|
|
||||||
session_id: "uid".into(),
|
let mut session =
|
||||||
username: "user@proton.me".into(),
|
ProtonApiSession::begin(config, "user@proton.me", b"mailbox-password").await?;
|
||||||
user_id: "user-id".into(),
|
if session.is_waiting_for_second_factor() {
|
||||||
access_token: "access".into(),
|
session.apply_second_factor_code("123456").await?;
|
||||||
refresh_token: "refresh".into(),
|
}
|
||||||
scopes: vec![],
|
|
||||||
is_waiting_for_second_factor_code: false,
|
|
||||||
password_mode: PasswordMode::Single,
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let drive = ProtonDriveClient::new(&session, b"mailbox-password".to_vec());
|
let drive = ProtonDriveClient::new(&session, b"mailbox-password".to_vec());
|
||||||
let root = drive.get_my_files_folder().await?;
|
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
|
## Build & test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build
|
cargo build # 0 warnings / 0 errors
|
||||||
cargo test
|
cargo test # offline crypto + derivation round-trip tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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<String, Box<dyn std::error::Error>> {
|
||||||
|
let key = base32_decode(secret_b32)?;
|
||||||
|
let counter = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() / 30;
|
||||||
|
|
||||||
|
let mut mac = Hmac::<Sha1>::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<Vec<u8>, Box<dyn std::error::Error>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user