mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
init
This commit is contained in:
commit
808a96cdd1
28 files changed
+8410
No files matched your search
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
debug
|
||||||
|
target
|
||||||
|
|
||||||
|
# These are backup files generated by rustfmt
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
# Generated by cargo mutants
|
||||||
|
# Contains mutation testing data
|
||||||
|
**/mutants.out*/
|
||||||
|
|
||||||
|
# rustc will dump stack traces when hitting an internal compiler error to PWD
|
||||||
|
rustc-ice-*.txt
|
||||||
|
|
||||||
|
# RustRover
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
Generated
+3126
File diff suppressed because it is too large.
Load diff
+34
@@ -0,0 +1,34 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["crates/proton-sdk", "crates/proton-drive-sdk"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/ProtonDriveApps/sdk"
|
||||||
|
rust-version = "1.80"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "multipart"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
serde_repr = "0.1"
|
||||||
|
thiserror = "2"
|
||||||
|
async-trait = "0.1"
|
||||||
|
futures = "0.3"
|
||||||
|
bytes = "1"
|
||||||
|
base64 = "0.22"
|
||||||
|
hex = "0.4"
|
||||||
|
hmac = "0.12"
|
||||||
|
sha1 = "0.10"
|
||||||
|
sha2 = "0.10"
|
||||||
|
bcrypt = "0.17"
|
||||||
|
pgp = "0.16"
|
||||||
|
tracing = "0.1"
|
||||||
|
num-bigint = "0.4"
|
||||||
|
num-traits = "0.2"
|
||||||
|
getrandom = "0.2"
|
||||||
|
# rPGP re-exports rand_core 0.6 traits; match its 0.8 line for keygen/encryption RNG.
|
||||||
|
rand = "0.8"
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# proton-sdk-rs
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
> Unofficial, third-party project. Not affiliated with or endorsed by Proton.
|
||||||
|
> If you build on it, follow the operational requirements in the upstream SDK
|
||||||
|
> README (honest `x-pm-appversion`, event-based sync, no Proton branding, etc.).
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Milestone 1 — session + read operations.** 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.
|
||||||
|
|
||||||
|
Not yet implemented: SRP password login (`begin`), uploads/downloads, sharing,
|
||||||
|
events, trash, photos, signature-verification enforcement, persistent caching.
|
||||||
|
|
||||||
|
## Workspace
|
||||||
|
|
||||||
|
| Crate | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `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).
|
||||||
|
|
||||||
|
## The mailbox-password requirement
|
||||||
|
|
||||||
|
Proton's key model is layered, so **read-only decryption still needs the user's
|
||||||
|
mailbox (data) password**, not just session tokens:
|
||||||
|
|
||||||
|
```
|
||||||
|
mailbox password + key salt --bcrypt--> key passphrase
|
||||||
|
-> unlock user keys -> decrypt address-key token -> unlock address key
|
||||||
|
-> decrypt share/node passphrase -> unlock node key -> decrypt names
|
||||||
|
```
|
||||||
|
|
||||||
|
`ProtonApiSession::resume` takes the tokens; `ProtonDriveClient::new` takes the
|
||||||
|
mailbox password.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use proton_sdk::config::ProtonClientConfiguration;
|
||||||
|
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||||
|
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 drive = ProtonDriveClient::new(&session, b"mailbox-password".to_vec());
|
||||||
|
let root = drive.get_my_files_folder().await?;
|
||||||
|
for child in drive.enumerate_folder_children(&root.uid).await? {
|
||||||
|
println!("{} ({:?})", child.name, child.kind);
|
||||||
|
}
|
||||||
|
# Ok(())
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build & test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT, matching upstream. The MIT license covers this source only; use of Proton's
|
||||||
|
hosted services remains subject to Proton's terms.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "proton-drive-sdk"
|
||||||
|
description = "High-level Proton Drive client for Rust"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
proton-sdk = { path = "../proton-sdk" }
|
||||||
|
tokio.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
serde_repr.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
hmac.workspace = true
|
||||||
|
sha1.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
pgp.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
|||||||
|
//! Drive-specific decryption: shares and link (node) keys/names.
|
||||||
|
//!
|
||||||
|
//! Mirrors `ShareCrypto` and the link half of `NodeCrypto` from the C# SDK.
|
||||||
|
//! Signature verification is treated as best-effort (not enforced) for this
|
||||||
|
//! read milestone.
|
||||||
|
|
||||||
|
use proton_sdk::account::AccountClient;
|
||||||
|
use proton_sdk::crypto::{decrypt_armored_with_keys, PrivateKey};
|
||||||
|
use proton_sdk::error::{ProtonError, Result};
|
||||||
|
|
||||||
|
use crate::dtos::{LinkDto, ShareDto};
|
||||||
|
|
||||||
|
/// Decrypt a share key: decrypt the share passphrase with the membership
|
||||||
|
/// address keys, then unlock the share's secret key with it.
|
||||||
|
pub async fn decrypt_share_key(account: &AccountClient, share: &ShareDto) -> Result<PrivateKey> {
|
||||||
|
let address_keys = account.address_private_keys(&share.address_id).await?;
|
||||||
|
|
||||||
|
let passphrase = decrypt_armored_with_keys(&share.passphrase, &address_keys)?;
|
||||||
|
|
||||||
|
let key = PrivateKey::from_armored(&share.key, &passphrase)?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link decrypted with its parent key.
|
||||||
|
pub struct DecryptedLink {
|
||||||
|
/// The node's own private key (unlocked).
|
||||||
|
pub node_key: PrivateKey,
|
||||||
|
/// The decrypted node name.
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a link's passphrase and name using the parent node/share key, and
|
||||||
|
/// unlock the node's key.
|
||||||
|
pub fn decrypt_link(parent_key: &PrivateKey, link: &LinkDto) -> Result<DecryptedLink> {
|
||||||
|
let passphrase = parent_key.decrypt_armored_message(&link.passphrase)?;
|
||||||
|
let node_key = PrivateKey::from_armored(&link.key, &passphrase)?;
|
||||||
|
|
||||||
|
let name_bytes = parent_key.decrypt_armored_message(&link.name)?;
|
||||||
|
let name = String::from_utf8(name_bytes)
|
||||||
|
.map_err(|e| ProtonError::invalid_operation(format!("node name is not valid UTF-8: {e}")))?;
|
||||||
|
|
||||||
|
Ok(DecryptedLink { node_key, name })
|
||||||
|
}
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
//! Serde DTOs for the Drive read endpoints (shares, volumes, links, folders).
|
||||||
|
//!
|
||||||
|
//! Some fields are deserialized for wire-format fidelity / forthcoming
|
||||||
|
//! milestones (uploads, signature verification) but not yet read.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use proton_sdk::ids::{AddressId, LinkId, ShareId, VolumeId};
|
||||||
|
|
||||||
|
/// `GET v2/shares/my-files`
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct MyFilesShareResponse {
|
||||||
|
#[serde(rename = "Volume")]
|
||||||
|
pub volume: ShareVolumeDto,
|
||||||
|
#[serde(rename = "Share")]
|
||||||
|
pub share: ShareDto,
|
||||||
|
#[serde(rename = "Link")]
|
||||||
|
pub link: LinkDetailsDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ShareVolumeDto {
|
||||||
|
#[serde(rename = "VolumeID")]
|
||||||
|
pub id: VolumeId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ShareDto {
|
||||||
|
#[serde(rename = "ShareID")]
|
||||||
|
pub id: ShareId,
|
||||||
|
#[serde(rename = "Key")]
|
||||||
|
pub key: String,
|
||||||
|
#[serde(rename = "Passphrase")]
|
||||||
|
pub passphrase: String,
|
||||||
|
#[serde(rename = "PassphraseSignature")]
|
||||||
|
pub passphrase_signature: Option<String>,
|
||||||
|
#[serde(rename = "AddressID")]
|
||||||
|
pub address_id: AddressId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST v2/volumes/{vid}/links` request body.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LinkDetailsRequest<'a> {
|
||||||
|
#[serde(rename = "LinkIDs")]
|
||||||
|
pub link_ids: &'a [LinkId],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST v2/volumes/{vid}/links` response.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LinkDetailsResponse {
|
||||||
|
#[serde(rename = "Links")]
|
||||||
|
pub links: Vec<LinkDetailsDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LinkDetailsDto {
|
||||||
|
#[serde(rename = "Link")]
|
||||||
|
pub link: LinkDto,
|
||||||
|
#[serde(rename = "Folder")]
|
||||||
|
pub folder: Option<FolderDto>,
|
||||||
|
#[serde(rename = "File")]
|
||||||
|
pub file: Option<FileDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LinkDto {
|
||||||
|
#[serde(rename = "LinkID")]
|
||||||
|
pub id: LinkId,
|
||||||
|
#[serde(rename = "Type")]
|
||||||
|
pub link_type: i32,
|
||||||
|
#[serde(rename = "ParentLinkID")]
|
||||||
|
pub parent_id: Option<LinkId>,
|
||||||
|
#[serde(rename = "State")]
|
||||||
|
pub state: i32,
|
||||||
|
#[serde(rename = "CreateTime")]
|
||||||
|
pub creation_time: i64,
|
||||||
|
#[serde(rename = "ModifyTime")]
|
||||||
|
pub modification_time: i64,
|
||||||
|
#[serde(rename = "Trashed")]
|
||||||
|
pub trash_time: Option<i64>,
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "NodeKey")]
|
||||||
|
pub key: String,
|
||||||
|
#[serde(rename = "NodePassphrase")]
|
||||||
|
pub passphrase: String,
|
||||||
|
#[serde(rename = "NodePassphraseSignature")]
|
||||||
|
pub passphrase_signature: Option<String>,
|
||||||
|
#[serde(rename = "SignatureEmail")]
|
||||||
|
pub signature_email: Option<String>,
|
||||||
|
#[serde(rename = "NameSignatureEmail")]
|
||||||
|
pub name_signature_email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinkDto {
|
||||||
|
pub fn parsed_type(&self) -> LinkType {
|
||||||
|
LinkType::from_raw(self.link_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_trashed(&self) -> bool {
|
||||||
|
self.state == LinkState::Trashed as i32 || self.trash_time.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct FolderDto {
|
||||||
|
#[serde(rename = "NodeHashKey")]
|
||||||
|
pub hash_key: String,
|
||||||
|
#[serde(rename = "XAttr")]
|
||||||
|
pub extended_attributes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct FileDto {
|
||||||
|
#[serde(rename = "MediaType")]
|
||||||
|
pub media_type: String,
|
||||||
|
#[serde(rename = "TotalEncryptedSize")]
|
||||||
|
pub total_size_on_storage: i64,
|
||||||
|
/// Base64 PKESK packet for the file's content key, addressed to the node key.
|
||||||
|
#[serde(rename = "ContentKeyPacket")]
|
||||||
|
pub content_key_packet: Option<String>,
|
||||||
|
/// Detached signature over the content key (best-effort verification).
|
||||||
|
#[serde(rename = "ContentKeyPacketSignature")]
|
||||||
|
pub content_key_signature: Option<String>,
|
||||||
|
#[serde(rename = "ActiveRevision")]
|
||||||
|
pub active_revision: Option<ActiveRevisionDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ActiveRevisionDto {
|
||||||
|
#[serde(rename = "RevisionID")]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "CreateTime")]
|
||||||
|
pub creation_time: i64,
|
||||||
|
#[serde(rename = "EncryptedSize")]
|
||||||
|
pub encrypted_size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET v2/volumes/{vid}/files/{lid}/revisions/{rid}` — one page of a revision's
|
||||||
|
/// block listing.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RevisionResponse {
|
||||||
|
#[serde(rename = "Revision")]
|
||||||
|
pub revision: RevisionDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RevisionDto {
|
||||||
|
#[serde(rename = "ID")]
|
||||||
|
pub id: String,
|
||||||
|
/// Detached signature over the content manifest (thumbnail + block digests).
|
||||||
|
#[serde(rename = "ManifestSignature")]
|
||||||
|
pub manifest_signature: Option<String>,
|
||||||
|
/// Email of the signer; empty/absent means the node key signed.
|
||||||
|
#[serde(rename = "SignatureEmail")]
|
||||||
|
pub signature_email: Option<String>,
|
||||||
|
#[serde(rename = "XAttr")]
|
||||||
|
pub extended_attributes: Option<String>,
|
||||||
|
#[serde(rename = "Thumbnails", default)]
|
||||||
|
pub thumbnails: Vec<ThumbnailDto>,
|
||||||
|
#[serde(rename = "Blocks", default)]
|
||||||
|
pub blocks: Vec<BlockDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One content block of a revision.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct BlockDto {
|
||||||
|
#[serde(rename = "Index")]
|
||||||
|
pub index: i32,
|
||||||
|
/// Absolute URL on block storage.
|
||||||
|
#[serde(rename = "BareURL")]
|
||||||
|
pub bare_url: String,
|
||||||
|
/// Per-block storage authorization token (`pm-storage-token` header).
|
||||||
|
#[serde(rename = "Token")]
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ThumbnailDto {
|
||||||
|
#[serde(rename = "Type")]
|
||||||
|
pub thumbnail_type: i32,
|
||||||
|
/// Base64 SHA-256 digest of the thumbnail's ciphertext (manifest input).
|
||||||
|
#[serde(rename = "Hash")]
|
||||||
|
pub hash_digest: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET v2/volumes/{vid}/folders/{lid}/children`
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct FolderChildrenResponse {
|
||||||
|
#[serde(rename = "LinkIDs")]
|
||||||
|
pub link_ids: Vec<LinkId>,
|
||||||
|
#[serde(rename = "AnchorID")]
|
||||||
|
pub anchor_id: Option<LinkId>,
|
||||||
|
#[serde(rename = "More")]
|
||||||
|
pub more_results_exist: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive link (node) type.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LinkType {
|
||||||
|
Folder,
|
||||||
|
File,
|
||||||
|
Album,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinkType {
|
||||||
|
fn from_raw(raw: i32) -> Self {
|
||||||
|
match raw {
|
||||||
|
1 => Self::Folder,
|
||||||
|
2 => Self::File,
|
||||||
|
3 => Self::Album,
|
||||||
|
_ => Self::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive link state.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LinkState {
|
||||||
|
Draft = 0,
|
||||||
|
Active = 1,
|
||||||
|
Trashed = 2,
|
||||||
|
Deleted = 3,
|
||||||
|
Restoring = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Upload (write) DTOs --------------------------------------------------
|
||||||
|
|
||||||
|
/// `POST v2/volumes/{vid}/files` — create a new file draft.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `FileCreationRequest` (+ its `NodeCreationRequest` base). All
|
||||||
|
/// encrypted/armored fields are produced client-side; `Hash` is the lowercase
|
||||||
|
/// hex name HMAC, `ContentKeyPacket` is base64.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct FileCreationRequest {
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "Hash")]
|
||||||
|
pub name_hash: String,
|
||||||
|
#[serde(rename = "ParentLinkID")]
|
||||||
|
pub parent_link_id: LinkId,
|
||||||
|
#[serde(rename = "NodePassphrase")]
|
||||||
|
pub passphrase: String,
|
||||||
|
#[serde(rename = "NodePassphraseSignature")]
|
||||||
|
pub passphrase_signature: String,
|
||||||
|
#[serde(rename = "NodeKey")]
|
||||||
|
pub key: String,
|
||||||
|
#[serde(rename = "MIMEType")]
|
||||||
|
pub media_type: String,
|
||||||
|
#[serde(rename = "ContentKeyPacket")]
|
||||||
|
pub content_key_packet: String,
|
||||||
|
#[serde(rename = "ContentKeyPacketSignature")]
|
||||||
|
pub content_key_signature: String,
|
||||||
|
#[serde(rename = "SignatureAddress")]
|
||||||
|
pub signature_address: String,
|
||||||
|
#[serde(rename = "ClientUID", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub client_uid: Option<String>,
|
||||||
|
#[serde(rename = "IntendedUploadSize")]
|
||||||
|
pub intended_upload_size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST v2/volumes/{vid}/files` response.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct FileCreationResponse {
|
||||||
|
#[serde(rename = "File")]
|
||||||
|
pub file: FileCreationIdentifiers,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct FileCreationIdentifiers {
|
||||||
|
#[serde(rename = "ID")]
|
||||||
|
pub link_id: LinkId,
|
||||||
|
#[serde(rename = "RevisionID")]
|
||||||
|
pub revision_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST v2/volumes/{vid}/folders` — create a new folder.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `FolderCreationRequest` (+ its `NodeCreationRequest` base). Like
|
||||||
|
/// [`FileCreationRequest`] but with a `NodeHashKey` (the folder's child-name
|
||||||
|
/// HMAC key, encrypted to its own node key) instead of any content-key fields.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct FolderCreationRequest {
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "Hash")]
|
||||||
|
pub name_hash: String,
|
||||||
|
#[serde(rename = "ParentLinkID")]
|
||||||
|
pub parent_link_id: LinkId,
|
||||||
|
#[serde(rename = "NodePassphrase")]
|
||||||
|
pub passphrase: String,
|
||||||
|
#[serde(rename = "NodePassphraseSignature")]
|
||||||
|
pub passphrase_signature: String,
|
||||||
|
#[serde(rename = "NodeKey")]
|
||||||
|
pub key: String,
|
||||||
|
#[serde(rename = "NodeHashKey")]
|
||||||
|
pub node_hash_key: String,
|
||||||
|
/// Folder create uses `SignatureEmail` (file create uses `SignatureAddress`).
|
||||||
|
#[serde(rename = "SignatureEmail")]
|
||||||
|
pub signature_email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST v2/volumes/{vid}/folders` response.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct FolderCreationResponse {
|
||||||
|
#[serde(rename = "Folder")]
|
||||||
|
pub folder: FolderCreationIdentifiers,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct FolderCreationIdentifiers {
|
||||||
|
#[serde(rename = "ID")]
|
||||||
|
pub link_id: LinkId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUT v2/volumes/{vid}/links/{lid}/rename` — rename a node.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `RenameLinkRequest`. `Hash`/`OriginalHash` are lowercase-hex name
|
||||||
|
/// HMACs (new and current). `MIMEType` is always present: the media type for a
|
||||||
|
/// file, `null` for a folder.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RenameLinkRequest {
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "Hash")]
|
||||||
|
pub name_hash: String,
|
||||||
|
#[serde(rename = "NameSignatureEmail")]
|
||||||
|
pub name_signature_email: String,
|
||||||
|
#[serde(rename = "MIMEType")]
|
||||||
|
pub media_type: Option<String>,
|
||||||
|
#[serde(rename = "OriginalHash")]
|
||||||
|
pub original_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUT v2/volumes/{vid}/links/{lid}/move` — move a node to a new parent.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `MoveLinkRequest`. `NodePassphrase` is the node passphrase
|
||||||
|
/// rewrapped (session-key re-encrypted) to the destination parent key; the
|
||||||
|
/// secret is unchanged, so `NodePassphraseSignature` is carried over untouched.
|
||||||
|
/// `Name` is re-encrypted + signed to the destination parent. `Hash` is the new
|
||||||
|
/// name hash under the destination's hash key; `OriginalHash` the current hash
|
||||||
|
/// under the source parent's. Same-volume moves only (no `NewShareID`).
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MoveLinkRequest {
|
||||||
|
#[serde(rename = "ParentLinkID")]
|
||||||
|
pub parent_link_id: LinkId,
|
||||||
|
#[serde(rename = "NodePassphrase")]
|
||||||
|
pub passphrase: String,
|
||||||
|
#[serde(rename = "NodePassphraseSignature", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub passphrase_signature: Option<String>,
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "NameSignatureEmail")]
|
||||||
|
pub name_signature_email: String,
|
||||||
|
#[serde(rename = "Hash")]
|
||||||
|
pub name_hash: String,
|
||||||
|
#[serde(rename = "OriginalHash")]
|
||||||
|
pub original_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `{ LinkIDs: [...] }` — batch link-id body for trash / restore / delete.
|
||||||
|
/// Mirrors C# `MultipleLinksNullaryRequest`.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MultipleLinksRequest<'a> {
|
||||||
|
#[serde(rename = "LinkIDs")]
|
||||||
|
pub link_ids: &'a [LinkId],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate response for batch link operations: a per-link result list. The
|
||||||
|
/// top-level envelope is `1001 MultipleResponses`; the real per-link status is
|
||||||
|
/// in each [`LinkIdResponsePair`]. Mirrors C# `AggregateApiResponse<LinkIdResponsePair>`.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AggregateLinksResponse {
|
||||||
|
#[serde(rename = "Responses", default)]
|
||||||
|
pub responses: Vec<LinkIdResponsePair>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LinkIdResponsePair {
|
||||||
|
#[serde(rename = "LinkID")]
|
||||||
|
pub link_id: LinkId,
|
||||||
|
#[serde(rename = "Response")]
|
||||||
|
pub response: proton_sdk::api::ApiResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST v2/volumes/{vid}/files/{lid}/revisions` — create a new revision on an
|
||||||
|
/// existing file. Mirrors C# `RevisionCreationRequest`.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RevisionCreationRequest {
|
||||||
|
/// The currently active revision this draft supersedes.
|
||||||
|
#[serde(rename = "CurrentRevisionID")]
|
||||||
|
pub current_revision_id: String,
|
||||||
|
#[serde(rename = "ClientUID", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub client_uid: Option<String>,
|
||||||
|
#[serde(rename = "IntendedUploadSize")]
|
||||||
|
pub intended_upload_size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST v2/volumes/{vid}/files/{lid}/revisions` response.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RevisionCreationResponse {
|
||||||
|
#[serde(rename = "Revision")]
|
||||||
|
pub revision: RevisionCreationIdentity,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RevisionCreationIdentity {
|
||||||
|
#[serde(rename = "ID")]
|
||||||
|
pub revision_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET v2/volumes/{vid}/links/{lid}/revisions/{rid}/verification`.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct BlockVerificationInputResponse {
|
||||||
|
/// Base64 verification code XORed with the block ciphertext prefix.
|
||||||
|
#[serde(rename = "VerificationCode")]
|
||||||
|
pub verification_code: String,
|
||||||
|
/// Base64 content key packet (re-encrypted to the node key) for the check.
|
||||||
|
#[serde(rename = "ContentKeyPacket")]
|
||||||
|
pub content_key_packet: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST blocks` — request upload targets for content/thumbnail blocks.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `BlockUploadPreparationRequest`.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct BlockUploadPreparationRequest {
|
||||||
|
#[serde(rename = "AddressID")]
|
||||||
|
pub address_id: AddressId,
|
||||||
|
#[serde(rename = "VolumeID")]
|
||||||
|
pub volume_id: VolumeId,
|
||||||
|
#[serde(rename = "LinkID")]
|
||||||
|
pub link_id: LinkId,
|
||||||
|
#[serde(rename = "RevisionID")]
|
||||||
|
pub revision_id: String,
|
||||||
|
#[serde(rename = "BlockList")]
|
||||||
|
pub blocks: Vec<BlockCreationRequest>,
|
||||||
|
#[serde(rename = "ThumbnailList")]
|
||||||
|
pub thumbnails: Vec<ThumbnailCreationRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct BlockCreationRequest {
|
||||||
|
#[serde(rename = "Index")]
|
||||||
|
pub index: i32,
|
||||||
|
#[serde(rename = "Size")]
|
||||||
|
pub size: i32,
|
||||||
|
/// Armored PGP message: the block's detached signature, encrypted to the
|
||||||
|
/// node key.
|
||||||
|
#[serde(rename = "EncSignature")]
|
||||||
|
pub encrypted_signature: String,
|
||||||
|
/// Base64 SHA-256 of the block ciphertext.
|
||||||
|
#[serde(rename = "Hash")]
|
||||||
|
pub hash: String,
|
||||||
|
#[serde(rename = "Verifier")]
|
||||||
|
pub verifier: BlockVerifier,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct BlockVerifier {
|
||||||
|
/// Base64 verification token (`code XOR ciphertext_prefix`).
|
||||||
|
#[serde(rename = "Token")]
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thumbnail creation entry in a block-upload preparation request. Mirrors C#
|
||||||
|
/// `ThumbnailCreationRequest` (`Size`, `Type`, base64 ciphertext `Hash`).
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ThumbnailCreationRequest {
|
||||||
|
#[serde(rename = "Size")]
|
||||||
|
pub size: i32,
|
||||||
|
#[serde(rename = "Type")]
|
||||||
|
pub thumbnail_type: i32,
|
||||||
|
#[serde(rename = "Hash")]
|
||||||
|
pub hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST blocks` response.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct BlockUploadPreparationResponse {
|
||||||
|
#[serde(rename = "UploadLinks")]
|
||||||
|
pub upload_targets: Vec<BlockUploadTarget>,
|
||||||
|
#[serde(rename = "ThumbnailLinks", default)]
|
||||||
|
pub thumbnail_upload_targets: Vec<BlockUploadTarget>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct BlockUploadTarget {
|
||||||
|
#[serde(rename = "BareURL")]
|
||||||
|
pub bare_url: String,
|
||||||
|
#[serde(rename = "Token")]
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUT v2/volumes/{vid}/files/{lid}/revisions/{rid}` — seal the revision.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `RevisionUpdateRequest`.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RevisionUpdateRequest {
|
||||||
|
#[serde(rename = "ManifestSignature")]
|
||||||
|
pub manifest_signature: String,
|
||||||
|
#[serde(rename = "SignatureAddress")]
|
||||||
|
pub signature_address: String,
|
||||||
|
#[serde(rename = "ChecksumVerified")]
|
||||||
|
pub checksum_verified: bool,
|
||||||
|
#[serde(rename = "XAttr", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub extended_attributes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decrypted `XAttr` JSON payload for a revision (encrypted to the node key
|
||||||
|
/// before upload). Mirrors C# `ExtendedAttributes` / `CommonExtendedAttributes`.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ExtendedAttributes {
|
||||||
|
#[serde(rename = "Common")]
|
||||||
|
pub common: CommonExtendedAttributes,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CommonExtendedAttributes {
|
||||||
|
#[serde(rename = "Size")]
|
||||||
|
pub size: i64,
|
||||||
|
#[serde(rename = "BlockSizes")]
|
||||||
|
pub block_sizes: Vec<i32>,
|
||||||
|
#[serde(rename = "Digests")]
|
||||||
|
pub digests: FileContentDigests,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct FileContentDigests {
|
||||||
|
/// Lowercase hex SHA-1 of the full plaintext.
|
||||||
|
#[serde(rename = "SHA1")]
|
||||||
|
pub sha1: String,
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
//! High-level Proton Drive client (Rust port).
|
||||||
|
//!
|
||||||
|
//! Built on top of [`proton_sdk`]. Milestone 1 covers session-backed read
|
||||||
|
//! operations: resolving the My Files folder, fetching node metadata and
|
||||||
|
//! enumerating folder children.
|
||||||
|
//!
|
||||||
|
//! ```no_run
|
||||||
|
//! # async fn run() -> proton_sdk::error::Result<()> {
|
||||||
|
//! use proton_sdk::config::ProtonClientConfiguration;
|
||||||
|
//! use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||||
|
//! use proton_drive_sdk::ProtonDriveClient;
|
||||||
|
//!
|
||||||
|
//! 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 drive = ProtonDriveClient::new(&session, b"mailbox-password".to_vec());
|
||||||
|
//! let root = drive.get_my_files_folder().await?;
|
||||||
|
//! for child in drive.enumerate_folder_children(&root.uid).await? {
|
||||||
|
//! println!("{} ({:?})", child.name, child.kind);
|
||||||
|
//! }
|
||||||
|
//! # Ok(())
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
mod client;
|
||||||
|
mod crypto;
|
||||||
|
mod dtos;
|
||||||
|
mod node;
|
||||||
|
|
||||||
|
pub use client::ProtonDriveClient;
|
||||||
|
pub use node::{Node, NodeKind, Thumbnail, ThumbnailType};
|
||||||
|
|
||||||
|
pub use proton_sdk;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//! Public, decrypted node model returned to callers.
|
||||||
|
|
||||||
|
use proton_sdk::ids::NodeUid;
|
||||||
|
|
||||||
|
/// A decrypted Drive node (folder or file).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Node {
|
||||||
|
pub uid: NodeUid,
|
||||||
|
pub parent_uid: Option<NodeUid>,
|
||||||
|
pub kind: NodeKind,
|
||||||
|
/// Decrypted node name.
|
||||||
|
pub name: String,
|
||||||
|
/// Creation time, epoch seconds.
|
||||||
|
pub creation_time: i64,
|
||||||
|
/// Last modification time, epoch seconds.
|
||||||
|
pub modification_time: i64,
|
||||||
|
pub trashed: bool,
|
||||||
|
/// Email address that signed the node, if present.
|
||||||
|
pub signature_email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folder- or file-specific node data.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum NodeKind {
|
||||||
|
Folder,
|
||||||
|
File {
|
||||||
|
media_type: String,
|
||||||
|
/// Total encrypted size on cloud storage, in bytes.
|
||||||
|
total_size_on_storage: i64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Node {
|
||||||
|
pub fn is_folder(&self) -> bool {
|
||||||
|
matches!(self.kind, NodeKind::Folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_file(&self) -> bool {
|
||||||
|
matches!(self.kind, NodeKind::File { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kind of a thumbnail. Mirrors C# `Proton.Drive.Sdk.Nodes.ThumbnailType`.
|
||||||
|
///
|
||||||
|
/// The discriminant is the wire `Type` value sent to the API and the key the
|
||||||
|
/// download path sorts by when building the content manifest.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
#[repr(i32)]
|
||||||
|
pub enum ThumbnailType {
|
||||||
|
/// The small thumbnail (`Type = 1`).
|
||||||
|
Thumbnail = 1,
|
||||||
|
/// The larger preview image (`Type = 2`).
|
||||||
|
Preview = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThumbnailType {
|
||||||
|
/// The wire `Type` discriminant.
|
||||||
|
pub fn as_i32(self) -> i32 {
|
||||||
|
self as i32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A caller-supplied, already-rendered thumbnail to attach to an upload.
|
||||||
|
///
|
||||||
|
/// The SDK does not generate thumbnails — the caller renders the image bytes
|
||||||
|
/// (matching the C# SDK, where the platform supplies the bitmap). Mirrors C#
|
||||||
|
/// `Proton.Drive.Sdk.Nodes.Thumbnail`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Thumbnail {
|
||||||
|
pub thumbnail_type: ThumbnailType,
|
||||||
|
/// The rendered image bytes (e.g. JPEG/WebP). Must be non-empty.
|
||||||
|
pub content: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Thumbnail {
|
||||||
|
/// Create a thumbnail from rendered image bytes.
|
||||||
|
pub fn new(thumbnail_type: ThumbnailType, content: Vec<u8>) -> Self {
|
||||||
|
Self {
|
||||||
|
thumbnail_type,
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
name = "proton-sdk"
|
||||||
|
description = "Core Proton account/session/crypto primitives for the Rust Proton SDK"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
serde_repr.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
hmac.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
bcrypt.workspace = true
|
||||||
|
pgp.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
num-bigint.workspace = true
|
||||||
|
num-traits.workspace = true
|
||||||
|
getrandom.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
//! Account client: user, addresses and their decrypted private keys.
|
||||||
|
//!
|
||||||
|
//! Mirrors the parts of `ProtonAccountClient` / `AddressOperations` that the
|
||||||
|
//! Drive read path needs. The unlock chain is:
|
||||||
|
//!
|
||||||
|
//! 1. mailbox password + per-key salt → bcrypt-derived passphrase
|
||||||
|
//! 2. passphrase → unlock **user keys**
|
||||||
|
//! 3. user keys → decrypt an **address key token** → unlock the address key
|
||||||
|
//!
|
||||||
|
//! (Some address keys have no token and instead reuse an account-key passphrase
|
||||||
|
//! derived in step 1.) Decrypted keys are cached for the lifetime of the client.
|
||||||
|
|
||||||
|
mod dtos;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::crypto::{self, PrivateKey};
|
||||||
|
use crate::error::{ProtonError, Result};
|
||||||
|
use crate::http::ApiHttpClient;
|
||||||
|
use crate::ids::AddressId;
|
||||||
|
use crate::session::ProtonApiSession;
|
||||||
|
|
||||||
|
use dtos::{AddressDto, AddressListResponse, KeySaltListResponse, UserResponse};
|
||||||
|
|
||||||
|
/// Public view of an email address attached to the account.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Address {
|
||||||
|
pub id: AddressId,
|
||||||
|
pub email: String,
|
||||||
|
pub order: i32,
|
||||||
|
pub status: i32,
|
||||||
|
/// Index, within the decrypted key list, of the primary key.
|
||||||
|
pub primary_key_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves account keys needed to decrypt Drive metadata.
|
||||||
|
///
|
||||||
|
/// Construction takes the mailbox (data) password because, per Proton's key
|
||||||
|
/// model, it is required even for read-only decryption.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AccountClient {
|
||||||
|
inner: Arc<Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Inner {
|
||||||
|
http: ApiHttpClient,
|
||||||
|
mailbox_password: Vec<u8>,
|
||||||
|
cache: Mutex<Cache>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Cache {
|
||||||
|
/// key id → passphrase derived from the mailbox password and key salts.
|
||||||
|
key_passphrases: Option<HashMap<String, Vec<u8>>>,
|
||||||
|
user_keys: Option<Vec<PrivateKey>>,
|
||||||
|
addresses: Option<Vec<Address>>,
|
||||||
|
address_keys: HashMap<AddressId, Vec<PrivateKey>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountClient {
|
||||||
|
pub fn new(session: &ProtonApiSession, mailbox_password: impl Into<Vec<u8>>) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(Inner {
|
||||||
|
http: session.http().clone(),
|
||||||
|
mailbox_password: mailbox_password.into(),
|
||||||
|
cache: Mutex::new(Cache::default()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All addresses on the account, ordered by their `Order` field.
|
||||||
|
pub async fn addresses(&self) -> Result<Vec<Address>> {
|
||||||
|
{
|
||||||
|
let cache = self.inner.cache.lock().await;
|
||||||
|
if let Some(addresses) = &cache.addresses {
|
||||||
|
return Ok(addresses.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure user keys (and thus the key passphrases) are loaded first.
|
||||||
|
self.user_keys().await?;
|
||||||
|
|
||||||
|
let response: AddressListResponse = self.inner.http.get("core/v4/addresses").await?;
|
||||||
|
let mut addresses = Vec::with_capacity(response.addresses.len());
|
||||||
|
for dto in &response.addresses {
|
||||||
|
let (address, keys) = self.decrypt_address(dto).await?;
|
||||||
|
self.inner
|
||||||
|
.cache
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.address_keys
|
||||||
|
.insert(address.id.clone(), keys);
|
||||||
|
addresses.push(address);
|
||||||
|
}
|
||||||
|
addresses.sort_by_key(|a| a.order);
|
||||||
|
|
||||||
|
self.inner.cache.lock().await.addresses = Some(addresses.clone());
|
||||||
|
Ok(addresses)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default (lowest-ordered) address.
|
||||||
|
pub async fn default_address(&self) -> Result<Address> {
|
||||||
|
self.addresses()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| ProtonError::invalid_operation("user has no address"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypted private keys for a given address.
|
||||||
|
pub async fn address_private_keys(&self, address_id: &AddressId) -> Result<Vec<PrivateKey>> {
|
||||||
|
{
|
||||||
|
let cache = self.inner.cache.lock().await;
|
||||||
|
if let Some(keys) = cache.address_keys.get(address_id) {
|
||||||
|
return Ok(keys.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading all addresses populates the per-address key cache.
|
||||||
|
self.addresses().await?;
|
||||||
|
|
||||||
|
let cache = self.inner.cache.lock().await;
|
||||||
|
cache
|
||||||
|
.address_keys
|
||||||
|
.get(address_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProtonError::invalid_operation(format!("no keys for address {address_id}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypted user (account) keys.
|
||||||
|
async fn user_keys(&self) -> Result<Vec<PrivateKey>> {
|
||||||
|
{
|
||||||
|
let cache = self.inner.cache.lock().await;
|
||||||
|
if let Some(keys) = &cache.user_keys {
|
||||||
|
return Ok(keys.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let passphrases = self.key_passphrases().await?;
|
||||||
|
let response: UserResponse = self.inner.http.get("core/v4/users").await?;
|
||||||
|
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for key_dto in &response.user.keys {
|
||||||
|
if !key_dto.is_active() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(passphrase) = passphrases.get(key_dto.id.as_str()) else {
|
||||||
|
tracing::warn!(key_id = %key_dto.id, "no passphrase for user key");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match PrivateKey::from_armored(&key_dto.private_key, passphrase) {
|
||||||
|
Ok(key) => keys.push(key),
|
||||||
|
Err(e) => tracing::warn!(key_id = %key_dto.id, error = %e, "failed to unlock user key"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if keys.is_empty() {
|
||||||
|
return Err(ProtonError::invalid_operation(
|
||||||
|
"no active user key could be unlocked",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.inner.cache.lock().await.user_keys = Some(keys.clone());
|
||||||
|
Ok(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive (and cache) the per-key passphrases from the mailbox password and
|
||||||
|
/// the account's key salts.
|
||||||
|
async fn key_passphrases(&self) -> Result<HashMap<String, Vec<u8>>> {
|
||||||
|
{
|
||||||
|
let cache = self.inner.cache.lock().await;
|
||||||
|
if let Some(passphrases) = &cache.key_passphrases {
|
||||||
|
return Ok(passphrases.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: KeySaltListResponse = self.inner.http.get("core/v4/keys/salts").await?;
|
||||||
|
let mut passphrases = HashMap::new();
|
||||||
|
for salt in &response.key_salts {
|
||||||
|
let Some(salt_b64) = &salt.value else { continue };
|
||||||
|
if salt_b64.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let salt_bytes = decode_base64(salt_b64)?;
|
||||||
|
let passphrase =
|
||||||
|
crypto::derive_key_passphrase(&self.inner.mailbox_password, &salt_bytes)?;
|
||||||
|
passphrases.insert(salt.key_id.clone(), passphrase);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.inner.cache.lock().await.key_passphrases = Some(passphrases.clone());
|
||||||
|
Ok(passphrases)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the public [`Address`] and unlock all of its active private keys.
|
||||||
|
async fn decrypt_address(&self, dto: &AddressDto) -> Result<(Address, Vec<PrivateKey>)> {
|
||||||
|
let user_keys = self.user_keys().await?;
|
||||||
|
let passphrases = self.key_passphrases().await?;
|
||||||
|
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
let mut primary_key_index = None;
|
||||||
|
|
||||||
|
for key_dto in &dto.keys {
|
||||||
|
if !key_dto.is_active() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two ways to obtain the passphrase that unlocks an address key:
|
||||||
|
// decrypt its token with the user keys, or fall back to an
|
||||||
|
// account-key passphrase derived from the mailbox password.
|
||||||
|
let passphrase = match (&key_dto.token, &key_dto.signature) {
|
||||||
|
(Some(token), Some(_signature)) => {
|
||||||
|
match crypto::decrypt_armored_with_keys(token, &user_keys) {
|
||||||
|
Ok(passphrase) => passphrase,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(key_id = %key_dto.id, error = %e, "failed to decrypt address key token");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => match passphrases.get(key_dto.id.as_str()) {
|
||||||
|
Some(passphrase) => passphrase.clone(),
|
||||||
|
None => {
|
||||||
|
tracing::warn!(key_id = %key_dto.id, "no passphrase for address key");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
match PrivateKey::from_armored(&key_dto.private_key, &passphrase) {
|
||||||
|
Ok(key) => {
|
||||||
|
if key_dto.is_primary() && primary_key_index.is_none() {
|
||||||
|
primary_key_index = Some(keys.len());
|
||||||
|
}
|
||||||
|
keys.push(key);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(key_id = %key_dto.id, error = %e, "failed to unlock address key");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let primary_key_index = primary_key_index
|
||||||
|
.ok_or_else(|| ProtonError::invalid_operation(format!("address {} has no primary key", dto.id)))?;
|
||||||
|
|
||||||
|
let address = Address {
|
||||||
|
id: dto.id.clone(),
|
||||||
|
email: dto.email.clone(),
|
||||||
|
order: dto.order,
|
||||||
|
status: dto.status,
|
||||||
|
primary_key_index,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((address, keys))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_base64(value: &str) -> Result<Vec<u8>> {
|
||||||
|
use base64::Engine;
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(value)
|
||||||
|
.map_err(|e| ProtonError::invalid_operation(format!("invalid base64 salt: {e}")))
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
//! Serde DTOs for the account/user/address/key-salt endpoints.
|
||||||
|
//!
|
||||||
|
//! Proton encodes booleans as integers (`0`/`1`), so flag fields are kept as
|
||||||
|
//! `i32` with `is_*` accessors rather than relying on bool deserialization.
|
||||||
|
//!
|
||||||
|
//! A few fields are kept for wire-format fidelity but not yet read.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::ids::{AddressId, AddressKeyId, UserId, UserKeyId};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct UserResponse {
|
||||||
|
#[serde(rename = "User")]
|
||||||
|
pub user: UserDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct UserDto {
|
||||||
|
#[serde(rename = "ID")]
|
||||||
|
pub id: UserId,
|
||||||
|
#[serde(rename = "Keys")]
|
||||||
|
pub keys: Vec<UserKeyDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct UserKeyDto {
|
||||||
|
#[serde(rename = "ID")]
|
||||||
|
pub id: UserKeyId,
|
||||||
|
#[serde(rename = "PrivateKey")]
|
||||||
|
pub private_key: String,
|
||||||
|
#[serde(rename = "Primary")]
|
||||||
|
pub primary: i32,
|
||||||
|
#[serde(rename = "Active")]
|
||||||
|
pub active: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserKeyDto {
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.active != 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct AddressListResponse {
|
||||||
|
#[serde(rename = "Addresses")]
|
||||||
|
pub addresses: Vec<AddressDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct AddressDto {
|
||||||
|
#[serde(rename = "ID")]
|
||||||
|
pub id: AddressId,
|
||||||
|
#[serde(rename = "Email")]
|
||||||
|
pub email: String,
|
||||||
|
#[serde(rename = "Status")]
|
||||||
|
pub status: i32,
|
||||||
|
#[serde(rename = "Order")]
|
||||||
|
pub order: i32,
|
||||||
|
#[serde(rename = "Keys")]
|
||||||
|
pub keys: Vec<AddressKeyDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct AddressKeyDto {
|
||||||
|
#[serde(rename = "ID")]
|
||||||
|
pub id: AddressKeyId,
|
||||||
|
#[serde(rename = "PrivateKey")]
|
||||||
|
pub private_key: String,
|
||||||
|
#[serde(rename = "Token")]
|
||||||
|
pub token: Option<String>,
|
||||||
|
#[serde(rename = "Signature")]
|
||||||
|
pub signature: Option<String>,
|
||||||
|
#[serde(rename = "Primary")]
|
||||||
|
pub primary: i32,
|
||||||
|
#[serde(rename = "Active")]
|
||||||
|
pub active: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AddressKeyDto {
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.active != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_primary(&self) -> bool {
|
||||||
|
self.primary != 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct KeySaltListResponse {
|
||||||
|
#[serde(rename = "KeySalts")]
|
||||||
|
pub key_salts: Vec<KeySalt>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct KeySalt {
|
||||||
|
#[serde(rename = "ID")]
|
||||||
|
pub key_id: String,
|
||||||
|
/// Base64-encoded 16-byte salt; `null` for keys without a salt.
|
||||||
|
#[serde(rename = "KeySalt")]
|
||||||
|
pub value: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
//! API response envelope and response codes shared across all Proton endpoints.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Deserializer};
|
||||||
|
|
||||||
|
/// The common envelope every Proton API JSON response embeds.
|
||||||
|
///
|
||||||
|
/// Successful responses carry `Code == 1000` plus their endpoint-specific
|
||||||
|
/// fields; failures carry a non-success code and an `Error` message.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ApiResponse {
|
||||||
|
#[serde(rename = "Code")]
|
||||||
|
pub code: ResponseCode,
|
||||||
|
|
||||||
|
#[serde(rename = "Error", default)]
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiResponse {
|
||||||
|
pub fn is_success(&self) -> bool {
|
||||||
|
matches!(self.code, ResponseCode::Success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application-level response codes returned in the `Code` field.
|
||||||
|
///
|
||||||
|
/// Mirrors `Proton.Sdk.Api.ResponseCode`. Unknown / future codes deserialize to
|
||||||
|
/// [`ResponseCode::Unknown`] via [`ResponseCode::from_raw`] rather than failing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[repr(i64)]
|
||||||
|
pub enum ResponseCode {
|
||||||
|
Unknown = 0,
|
||||||
|
|
||||||
|
Unauthorized = 401,
|
||||||
|
Forbidden = 403,
|
||||||
|
RequestTimeout = 408,
|
||||||
|
|
||||||
|
Success = 1000,
|
||||||
|
MultipleResponses = 1001,
|
||||||
|
InvalidRequirements = 2000,
|
||||||
|
InvalidValue = 2001,
|
||||||
|
NotEnoughPermissions = 2011,
|
||||||
|
NotEnoughPermissionsToGrantPermissions = 2026,
|
||||||
|
InvalidEncryptedIdFormat = 2061,
|
||||||
|
AlreadyExists = 2500,
|
||||||
|
DoesNotExist = 2501,
|
||||||
|
Timeout = 2503,
|
||||||
|
IncompatibleState = 2511,
|
||||||
|
InvalidApp = 5002,
|
||||||
|
OutdatedApp = 5003,
|
||||||
|
Offline = 7001,
|
||||||
|
IncorrectLoginCredentials = 8002,
|
||||||
|
AccountDeleted = 10_002,
|
||||||
|
AccountDisabled = 10_003,
|
||||||
|
InvalidRefreshToken = 10_013,
|
||||||
|
NoActiveSubscription = 22_110,
|
||||||
|
AddressMissing = 33_102,
|
||||||
|
DomainExternal = 33_103,
|
||||||
|
ProtonDriveUnknown = 200_000,
|
||||||
|
InsufficientQuota = 200_001,
|
||||||
|
InsufficientSpace = 200_002,
|
||||||
|
InsufficientVolumeQuota = 200_100,
|
||||||
|
TooManyChildren = 200_300,
|
||||||
|
NestingTooDeep = 200_301,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseCode {
|
||||||
|
/// Map a raw integer code to a known variant, falling back to [`Self::Unknown`].
|
||||||
|
pub fn from_raw(raw: i64) -> Self {
|
||||||
|
match raw {
|
||||||
|
401 => Self::Unauthorized,
|
||||||
|
403 => Self::Forbidden,
|
||||||
|
408 => Self::RequestTimeout,
|
||||||
|
1000 => Self::Success,
|
||||||
|
1001 => Self::MultipleResponses,
|
||||||
|
2000 => Self::InvalidRequirements,
|
||||||
|
2001 => Self::InvalidValue,
|
||||||
|
2011 => Self::NotEnoughPermissions,
|
||||||
|
2026 => Self::NotEnoughPermissionsToGrantPermissions,
|
||||||
|
2061 => Self::InvalidEncryptedIdFormat,
|
||||||
|
2500 => Self::AlreadyExists,
|
||||||
|
2501 => Self::DoesNotExist,
|
||||||
|
2503 => Self::Timeout,
|
||||||
|
2511 => Self::IncompatibleState,
|
||||||
|
5002 => Self::InvalidApp,
|
||||||
|
5003 => Self::OutdatedApp,
|
||||||
|
7001 => Self::Offline,
|
||||||
|
8002 => Self::IncorrectLoginCredentials,
|
||||||
|
10_002 => Self::AccountDeleted,
|
||||||
|
10_003 => Self::AccountDisabled,
|
||||||
|
10_013 => Self::InvalidRefreshToken,
|
||||||
|
22_110 => Self::NoActiveSubscription,
|
||||||
|
33_102 => Self::AddressMissing,
|
||||||
|
33_103 => Self::DomainExternal,
|
||||||
|
200_000 => Self::ProtonDriveUnknown,
|
||||||
|
200_001 => Self::InsufficientQuota,
|
||||||
|
200_002 => Self::InsufficientSpace,
|
||||||
|
200_100 => Self::InsufficientVolumeQuota,
|
||||||
|
200_300 => Self::TooManyChildren,
|
||||||
|
200_301 => Self::NestingTooDeep,
|
||||||
|
_ => Self::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for ResponseCode {
|
||||||
|
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let raw = i64::deserialize(deserializer)?;
|
||||||
|
Ok(ResponseCode::from_raw(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//! Client configuration and defaults.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Default base URL for the Proton Drive API.
|
||||||
|
pub const DEFAULT_BASE_URL: &str = "https://drive-api.proton.me/";
|
||||||
|
|
||||||
|
/// Default redirect URI sent with token refresh requests.
|
||||||
|
pub const DEFAULT_REFRESH_REDIRECT_URI: &str = "https://proton.me";
|
||||||
|
|
||||||
|
/// Default per-request timeout, matching `ProtonApiDefaults.DefaultTimeoutSeconds`.
|
||||||
|
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Content type used for the `Accept` header on API requests.
|
||||||
|
pub const API_CONTENT_TYPE: &str = "application/vnd.protonmail.api+json";
|
||||||
|
|
||||||
|
/// Configuration for a Proton client session.
|
||||||
|
///
|
||||||
|
/// `app_version` is mandatory and must honour the operational requirements in
|
||||||
|
/// the SDK README (`external-drive-{name}@{semver}-{channel}`), sent as the
|
||||||
|
/// `x-pm-appversion` header.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProtonClientConfiguration {
|
||||||
|
pub base_url: String,
|
||||||
|
pub app_version: String,
|
||||||
|
pub user_agent: String,
|
||||||
|
pub refresh_redirect_uri: String,
|
||||||
|
pub request_timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtonClientConfiguration {
|
||||||
|
/// Create a configuration with the given app version and otherwise defaults.
|
||||||
|
pub fn new(app_version: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
base_url: DEFAULT_BASE_URL.to_owned(),
|
||||||
|
app_version: app_version.into(),
|
||||||
|
user_agent: String::new(),
|
||||||
|
refresh_redirect_uri: DEFAULT_REFRESH_REDIRECT_URI.to_owned(),
|
||||||
|
request_timeout: DEFAULT_TIMEOUT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||||
|
self.base_url = base_url.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
|
||||||
|
self.user_agent = user_agent.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
|
||||||
|
self.request_timeout = timeout;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//! PGP cryptography primitives backed by rPGP.
|
||||||
|
//!
|
||||||
|
//! Proton's key model is layered: an address key (unlocked from the account
|
||||||
|
//! passphrase) decrypts a share/node *passphrase*, which in turn unlocks the
|
||||||
|
//! locked node key, which decrypts names, hash keys and content keys. This
|
||||||
|
//! module exposes the small set of operations that chain implements.
|
||||||
|
|
||||||
|
mod content;
|
||||||
|
mod derive;
|
||||||
|
mod encrypt;
|
||||||
|
mod errors;
|
||||||
|
mod keys;
|
||||||
|
mod messages;
|
||||||
|
mod srp;
|
||||||
|
|
||||||
|
pub use content::ContentKey;
|
||||||
|
pub use derive::derive_key_passphrase;
|
||||||
|
pub use encrypt::{generate_node_hash_key, generate_node_key, GeneratedNodeKey};
|
||||||
|
pub use errors::CryptoError;
|
||||||
|
pub use keys::{decrypt_armored_with_keys, PrivateKey};
|
||||||
|
pub use srp::{generate_proofs, SrpProofs, DEFAULT_BIT_LENGTH};
|
||||||
|
|
||||||
|
/// Result alias for crypto operations.
|
||||||
|
pub type CryptoResult<T> = std::result::Result<T, CryptoError>;
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
//! File content-key decryption and block (session-key) decryption.
|
||||||
|
//!
|
||||||
|
//! Proton Drive encrypts every block of a file with a single symmetric *content
|
||||||
|
//! key*. The content key itself is delivered as a PGP public-key-encrypted
|
||||||
|
//! session-key (PKESK) packet addressed to the node key. Mirrors the C#
|
||||||
|
//! `NodeCrypto.DecryptContentKey` (`nodeKey.DecryptSessionKey(contentKeyPacket)`)
|
||||||
|
//! followed by `PgpSessionKey.OpenDecryptingStream` for each block.
|
||||||
|
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
use pgp::armor::Dearmor;
|
||||||
|
use pgp::composed::{MessageBuilder, PlainSessionKey};
|
||||||
|
use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
|
||||||
|
use pgp::crypto::hash::HashAlgorithm;
|
||||||
|
use pgp::crypto::sym::SymmetricKeyAlgorithm;
|
||||||
|
use pgp::packet::{
|
||||||
|
LiteralData, Packet, PacketParser, PacketTrait, PublicKeyEncryptedSessionKey,
|
||||||
|
SymEncryptedProtectedData,
|
||||||
|
};
|
||||||
|
use pgp::types::{EskType, PkeskVersion, PublicKeyTrait};
|
||||||
|
|
||||||
|
use super::encrypt::{recipient_encryption_key, RecipientOp};
|
||||||
|
use super::errors::CryptoError;
|
||||||
|
use super::keys::PrivateKey;
|
||||||
|
|
||||||
|
/// The AEAD algorithm Proton uses for SEIPDv2 content blocks: AES-256-GCM.
|
||||||
|
/// Encrypted v6 session keys carry no algorithm info, so it is fixed here (the
|
||||||
|
/// official clients hardcode `aes256` + `gcm` likewise).
|
||||||
|
const AEAD_ALGO: AeadAlgorithm = AeadAlgorithm::Gcm;
|
||||||
|
/// AEAD streaming chunk size: 128 KiB (C# `PgpAeadStreamingChunkLength`).
|
||||||
|
const AEAD_CHUNK_SIZE: ChunkSize = ChunkSize::C128KiB;
|
||||||
|
|
||||||
|
/// A decrypted file content key (a symmetric PGP session key).
|
||||||
|
///
|
||||||
|
/// Decrypts the file's data blocks; each block is a PGP message encrypted to
|
||||||
|
/// this session key, carrying no ESK of its own (the session key is supplied
|
||||||
|
/// externally, here). A key may be *legacy* (a V3/V4 session key sealing SEIPDv1
|
||||||
|
/// blocks) or *AEAD* (a V6 session key sealing SEIPDv2 / AES-256-GCM blocks); the
|
||||||
|
/// underlying [`PlainSessionKey`] variant records which.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ContentKey {
|
||||||
|
session_key: PlainSessionKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContentKey {
|
||||||
|
/// Generate a fresh AES-256 content key for a new file (legacy SEIPDv1 path).
|
||||||
|
///
|
||||||
|
/// Mirrors C# `PgpSessionKey.Generate()`.
|
||||||
|
pub fn generate() -> Self {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let sym_alg = SymmetricKeyAlgorithm::AES256;
|
||||||
|
let key = sym_alg.new_session_key(&mut rng).to_vec();
|
||||||
|
ContentKey {
|
||||||
|
session_key: PlainSessionKey::V3_4 { sym_alg, key },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh AES-256 content key for the AEAD path (SEIPDv2 blocks).
|
||||||
|
///
|
||||||
|
/// The key is held as a V6 session key, which is delivered in the file's
|
||||||
|
/// content key packet as a v6 PKESK. Mirrors C#
|
||||||
|
/// `PgpSessionKey.GenerateForAead()`.
|
||||||
|
pub fn generate_aead() -> Self {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let key = SymmetricKeyAlgorithm::AES256.new_session_key(&mut rng).to_vec();
|
||||||
|
ContentKey {
|
||||||
|
session_key: PlainSessionKey::V6 { key },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this content key seals blocks with AEAD (SEIPDv2) rather than the
|
||||||
|
/// legacy SEIPDv1 path. Determined by the session-key version (a V6 session
|
||||||
|
/// key is the AEAD case). Mirrors C# `PgpSessionKey.IsAead()`.
|
||||||
|
pub fn is_aead(&self) -> bool {
|
||||||
|
matches!(self.session_key, PlainSessionKey::V6 { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt one file block: wrap `plaintext` in a literal-data packet and seal
|
||||||
|
/// it under this content key — as a SEIPDv1 (AES-256-CFB + MDC) packet for a
|
||||||
|
/// legacy key, or a SEIPDv2 (AES-256-GCM, 128 KiB chunks) packet for an AEAD
|
||||||
|
/// key. The result is a bare data packet with no ESK, matching Proton's block
|
||||||
|
/// format (the session key travels in the file's content key packet).
|
||||||
|
pub fn encrypt_block(&self, plaintext: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let literal = LiteralData::from_bytes(bytes::Bytes::new(), plaintext.to_vec().into())
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("literal packet: {e}")))?;
|
||||||
|
let mut inner = Vec::new();
|
||||||
|
literal
|
||||||
|
.to_writer_with_header(&mut inner)
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("serialize literal: {e}")))?;
|
||||||
|
|
||||||
|
self.seal_seipd(&inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt and inline-sign a thumbnail block under this content key.
|
||||||
|
///
|
||||||
|
/// Unlike a content block (which carries a *detached* signature uploaded
|
||||||
|
/// separately), a thumbnail block is encrypt-and-signed in one pass: the
|
||||||
|
/// signature is embedded in the SEIPD payload. Mirrors C#
|
||||||
|
/// `BlockUploader.UploadThumbnailAsync`
|
||||||
|
/// (`ContentKey.OpenEncryptingAndSigningReadStream` with no detached
|
||||||
|
/// signature stream). Like [`encrypt_block`](Self::encrypt_block) the result
|
||||||
|
/// is a bare SEIPDv1 packet with no ESK — the session key travels in the
|
||||||
|
/// file's content key packet.
|
||||||
|
pub fn encrypt_thumbnail(
|
||||||
|
&self,
|
||||||
|
signer: &PrivateKey,
|
||||||
|
plaintext: &[u8],
|
||||||
|
) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
|
||||||
|
// Build a signed (but unencrypted) message: one-pass signature, literal
|
||||||
|
// data, then the signature packet. `seipd_*` would generate its own
|
||||||
|
// session key, so we leave the builder unencrypted and seal the bytes
|
||||||
|
// ourselves with our content key below.
|
||||||
|
let mut builder = MessageBuilder::from_bytes(bytes::Bytes::new(), plaintext.to_vec());
|
||||||
|
builder.sign(&signer.key().primary_key, signer.password(), HashAlgorithm::Sha256);
|
||||||
|
let inner = builder
|
||||||
|
.to_vec(&mut rng)
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("sign thumbnail: {e}")))?;
|
||||||
|
|
||||||
|
self.seal_seipd(&inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seal an already-serialized inner payload as a bare SEIPD packet under this
|
||||||
|
/// content key: SEIPDv1 for a legacy key, SEIPDv2 (AES-256-GCM) for an AEAD
|
||||||
|
/// key. Shared by [`encrypt_block`](Self::encrypt_block) and
|
||||||
|
/// [`encrypt_thumbnail`](Self::encrypt_thumbnail).
|
||||||
|
fn seal_seipd(&self, inner: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let (sym_alg, key) = self.session_parts()?;
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
|
||||||
|
let seipd = if self.is_aead() {
|
||||||
|
SymEncryptedProtectedData::encrypt_seipdv2(
|
||||||
|
&mut rng,
|
||||||
|
sym_alg,
|
||||||
|
AEAD_ALGO,
|
||||||
|
AEAD_CHUNK_SIZE,
|
||||||
|
key,
|
||||||
|
inner,
|
||||||
|
)
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("encrypt seipdv2: {e}")))?
|
||||||
|
} else {
|
||||||
|
SymEncryptedProtectedData::encrypt_seipdv1(&mut rng, sym_alg, key, inner)
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("encrypt seipdv1: {e}")))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
seipd
|
||||||
|
.to_writer_with_header(&mut out)
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("serialize seipd: {e}")))?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt this content key to `node_key` as a v3 PKESK packet — the file's
|
||||||
|
/// `ContentKeyPacket`. Mirrors C# `nodeKey.EncryptSessionKey(contentKey)`.
|
||||||
|
pub fn to_packet(&self, node_key: &PrivateKey) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let (sym_alg, key) = self.session_parts()?;
|
||||||
|
|
||||||
|
// An AEAD content key is a v6 session key (no algorithm byte); a legacy
|
||||||
|
// key is a v3 PKESK carrying the symmetric algorithm.
|
||||||
|
let packet = if self.is_aead() {
|
||||||
|
recipient_encryption_key(node_key.key(), ContentKeyPacketV6Op { key })?
|
||||||
|
} else {
|
||||||
|
recipient_encryption_key(node_key.key(), ContentKeyPacketOp { sym_alg, key })?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
packet
|
||||||
|
.to_writer_with_header(&mut out)
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("serialize content key packet: {e}")))?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The raw symmetric key bytes of this content key.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `PgpSessionKey.Export()`; used to produce the file's
|
||||||
|
/// `ContentKeyPacketSignature` (the node key signs these bytes).
|
||||||
|
pub fn export(&self) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let (_sym_alg, key) = self.session_parts()?;
|
||||||
|
Ok(key.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Symmetric algorithm and raw key material for this content key. A V6 (AEAD)
|
||||||
|
/// session key carries no algorithm, so AES-256 is assumed — matching the
|
||||||
|
/// official clients, which hardcode it for v6 session keys.
|
||||||
|
fn session_parts(&self) -> Result<(SymmetricKeyAlgorithm, &[u8]), CryptoError> {
|
||||||
|
match &self.session_key {
|
||||||
|
PlainSessionKey::V3_4 { sym_alg, key } => Ok((*sym_alg, key)),
|
||||||
|
PlainSessionKey::V6 { key } => Ok((SymmetricKeyAlgorithm::AES256, key)),
|
||||||
|
_ => Err(CryptoError::Encrypt(
|
||||||
|
"content key is not a V3/V4 or V6 session key".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a single file block with this content key.
|
||||||
|
///
|
||||||
|
/// A block body is a bare SEIPD packet with no ESK of its own (the session
|
||||||
|
/// key arrives via the file's content key packet). rPGP's `Message` parser
|
||||||
|
/// rejects a message that *starts* with a SEIPD packet, so we parse packets
|
||||||
|
/// directly, locate the SEIPD (tolerating a leading PKESK, e.g. a full
|
||||||
|
/// round-trip message), and decrypt it with the supplied session key.
|
||||||
|
pub fn decrypt_block(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let (sym_alg, key) = self.session_parts()?;
|
||||||
|
|
||||||
|
let mut parser = PacketParser::new(Cursor::new(ciphertext));
|
||||||
|
let seipd = loop {
|
||||||
|
let packet = parser
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| CryptoError::Parse("block has no SEIPD packet".into()))?
|
||||||
|
.map_err(|e| CryptoError::Parse(format!("block packet: {e}")))?;
|
||||||
|
match packet {
|
||||||
|
Packet::SymEncryptedProtectedData(data) => break data,
|
||||||
|
// Skip a content-key PKESK if the caller passed a full message.
|
||||||
|
Packet::PublicKeyEncryptedSessionKey(_) => continue,
|
||||||
|
other => {
|
||||||
|
return Err(CryptoError::Parse(format!(
|
||||||
|
"unexpected block packet: {:?}",
|
||||||
|
other.tag()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let inner = seipd
|
||||||
|
.decrypt(key, Some(sym_alg))
|
||||||
|
.map_err(|e| CryptoError::Decrypt(format!("block: {e}")))?;
|
||||||
|
|
||||||
|
// The decrypted payload is a serialized literal-data packet.
|
||||||
|
let mut inner_parser = PacketParser::new(Cursor::new(&inner));
|
||||||
|
let literal = inner_parser
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| CryptoError::Parse("block payload is empty".into()))?
|
||||||
|
.map_err(|e| CryptoError::Parse(format!("block payload packet: {e}")))?;
|
||||||
|
match literal {
|
||||||
|
Packet::LiteralData(data) => Ok(data.data().to_vec()),
|
||||||
|
other => Err(CryptoError::Parse(format!(
|
||||||
|
"block payload is not literal data: {:?}",
|
||||||
|
other.tag()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PrivateKey {
|
||||||
|
/// Decrypt a content-key packet — a bare PKESK addressed to this node key.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `nodeKey.DecryptSessionKey(contentKeyPacket.Span)`. The PKESK
|
||||||
|
/// may target either the node's primary key or an encryption subkey, so both
|
||||||
|
/// are tried (the underlying NativeAOT lib does this matching for us).
|
||||||
|
pub fn decrypt_content_key(&self, packet_bytes: &[u8]) -> Result<ContentKey, CryptoError> {
|
||||||
|
let mut parser = PacketParser::new(Cursor::new(packet_bytes));
|
||||||
|
let packet = parser
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| CryptoError::Parse("empty content key packet".into()))?
|
||||||
|
.map_err(|e| CryptoError::Parse(format!("content key packet: {e}")))?;
|
||||||
|
|
||||||
|
let Packet::PublicKeyEncryptedSessionKey(pkesk) = packet else {
|
||||||
|
return Err(CryptoError::Parse(
|
||||||
|
"content key packet is not a PKESK".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let values = pkesk
|
||||||
|
.values()
|
||||||
|
.map_err(|e| CryptoError::Parse(format!("content key PKESK values: {e}")))?;
|
||||||
|
let esk_type = match pkesk.version() {
|
||||||
|
PkeskVersion::V6 => EskType::V6,
|
||||||
|
_ => EskType::V3_4,
|
||||||
|
};
|
||||||
|
|
||||||
|
// `decrypt_session_key` returns `Result<Result<_>>`: outer = passphrase
|
||||||
|
// fit, inner = the decryption result. Try the primary key, then any
|
||||||
|
// encryption subkeys (Proton node keys may carry one).
|
||||||
|
let pw = self.password();
|
||||||
|
let key = self.key();
|
||||||
|
|
||||||
|
let mut last_err: CryptoError;
|
||||||
|
match key.decrypt_session_key(&pw, values, esk_type) {
|
||||||
|
Ok(Ok(session_key)) => return Ok(ContentKey { session_key }),
|
||||||
|
Ok(Err(e)) | Err(e) => last_err = CryptoError::Decrypt(format!("content key: {e}")),
|
||||||
|
}
|
||||||
|
for subkey in &key.secret_subkeys {
|
||||||
|
match subkey.decrypt_session_key(&pw, values, esk_type) {
|
||||||
|
Ok(Ok(session_key)) => return Ok(ContentKey { session_key }),
|
||||||
|
Ok(Err(e)) | Err(e) => {
|
||||||
|
last_err = CryptoError::Decrypt(format!("content key (subkey): {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last_err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PrivateKey {
|
||||||
|
/// Re-encrypt the key packet of `armored_message` — a PGP message currently
|
||||||
|
/// addressed to this key — to `destination`, preserving the encrypted data
|
||||||
|
/// packet (and thus the plaintext and any *detached* signature over it).
|
||||||
|
///
|
||||||
|
/// This is the session-key rewrap a node **move** performs on a node
|
||||||
|
/// passphrase: the secret stays identical, only its recipient changes (old
|
||||||
|
/// parent → new parent), so the locked node key still unlocks and the node's
|
||||||
|
/// `NodePassphraseSignature` need not be reissued. Mirrors C# move's
|
||||||
|
/// `destinationKey.EncryptSessionKey(currentKey.DecryptSessionKey(passphrase))`.
|
||||||
|
pub fn rewrap_message_to(
|
||||||
|
&self,
|
||||||
|
armored_message: &str,
|
||||||
|
destination: &PrivateKey,
|
||||||
|
) -> Result<String, CryptoError> {
|
||||||
|
// De-armor the message to raw packet bytes (PKESK followed by SEIPD).
|
||||||
|
let mut raw = Vec::new();
|
||||||
|
let mut dearmor = Dearmor::new(Cursor::new(armored_message.as_bytes()));
|
||||||
|
std::io::Read::read_to_end(&mut dearmor, &mut raw)
|
||||||
|
.map_err(|e| CryptoError::Parse(format!("de-armor message: {e}")))?;
|
||||||
|
|
||||||
|
// Recover the session key from the leading PKESK (decrypt_content_key
|
||||||
|
// reads only that packet) and serialize the original SEIPD body to
|
||||||
|
// preserve the ciphertext — and thus the plaintext and its signature.
|
||||||
|
let session = self.decrypt_content_key(&raw)?;
|
||||||
|
let body = serialize_seipd(&raw)?;
|
||||||
|
|
||||||
|
// Re-encrypt the session key to the destination and reattach the body.
|
||||||
|
let mut message = session.to_packet(destination)?;
|
||||||
|
message.extend_from_slice(&body);
|
||||||
|
|
||||||
|
// Re-armor as a PGP MESSAGE block.
|
||||||
|
let mut out = Vec::new();
|
||||||
|
pgp::armor::write(
|
||||||
|
&RawPackets(&message),
|
||||||
|
pgp::armor::BlockType::Message,
|
||||||
|
&mut out,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("armor message: {e}")))?;
|
||||||
|
String::from_utf8(out)
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("armored message is not utf8: {e}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize the SEIPD (data) packet of a de-armored message, header included.
|
||||||
|
/// Used by [`PrivateKey::rewrap_message_to`] to carry the original ciphertext
|
||||||
|
/// over to the re-encrypted message unchanged.
|
||||||
|
fn serialize_seipd(raw: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let mut parser = PacketParser::new(Cursor::new(raw));
|
||||||
|
loop {
|
||||||
|
let packet = parser
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| CryptoError::Parse("message has no SEIPD packet".into()))?
|
||||||
|
.map_err(|e| CryptoError::Parse(format!("message packet: {e}")))?;
|
||||||
|
if let Packet::SymEncryptedProtectedData(data) = packet {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
data.to_writer_with_header(&mut out)
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("serialize seipd: {e}")))?;
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `Serialize` wrapper over already-serialized PGP packet bytes, so they can
|
||||||
|
/// be re-armored via [`pgp::armor::write`] without rebuilding a `Message`.
|
||||||
|
struct RawPackets<'a>(&'a [u8]);
|
||||||
|
|
||||||
|
impl pgp::ser::Serialize for RawPackets<'_> {
|
||||||
|
fn to_writer<W: std::io::Write>(&self, writer: &mut W) -> pgp::errors::Result<()> {
|
||||||
|
writer.write_all(self.0)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_len(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt a content key to a recipient as a v3 PKESK (`from_session_key_v3`).
|
||||||
|
struct ContentKeyPacketOp<'a> {
|
||||||
|
sym_alg: SymmetricKeyAlgorithm,
|
||||||
|
key: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecipientOp for ContentKeyPacketOp<'_> {
|
||||||
|
type Out = PublicKeyEncryptedSessionKey;
|
||||||
|
|
||||||
|
fn run(self, pubkey: &impl PublicKeyTrait) -> pgp::errors::Result<Self::Out> {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
PublicKeyEncryptedSessionKey::from_session_key_v3(&mut rng, self.key, self.sym_alg, pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt an AEAD content key to a recipient as a v6 PKESK
|
||||||
|
/// (`from_session_key_v6`). A v6 session key carries no algorithm byte.
|
||||||
|
struct ContentKeyPacketV6Op<'a> {
|
||||||
|
key: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecipientOp for ContentKeyPacketV6Op<'_> {
|
||||||
|
type Out = PublicKeyEncryptedSessionKey;
|
||||||
|
|
||||||
|
fn run(self, pubkey: &impl PublicKeyTrait) -> pgp::errors::Result<Self::Out> {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
PublicKeyEncryptedSessionKey::from_session_key_v6(&mut rng, self.key, pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pgp::composed::{
|
||||||
|
KeyType, MessageBuilder, SecretKeyParamsBuilder, SignedSecretKey,
|
||||||
|
};
|
||||||
|
use pgp::crypto::sym::SymmetricKeyAlgorithm;
|
||||||
|
|
||||||
|
/// Generate a passphrase-locked RSA key able to both sign and encrypt — a
|
||||||
|
/// stand-in for a Proton node key (single primary, no subkey).
|
||||||
|
fn generate_node_key(passphrase: &str) -> SignedSecretKey {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let params = SecretKeyParamsBuilder::default()
|
||||||
|
.key_type(KeyType::Rsa(2048))
|
||||||
|
.can_sign(true)
|
||||||
|
.can_certify(true)
|
||||||
|
.can_encrypt(true)
|
||||||
|
.primary_user_id("node <node@proton.test>".into())
|
||||||
|
.passphrase(Some(passphrase.to_owned()))
|
||||||
|
.build()
|
||||||
|
.expect("key params");
|
||||||
|
params
|
||||||
|
.generate(&mut rng)
|
||||||
|
.expect("generate")
|
||||||
|
.sign(&mut rng, &passphrase.into())
|
||||||
|
.expect("sign")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_key_packet_and_block_round_trip() {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let signed = generate_node_key("pw");
|
||||||
|
let armored = signed
|
||||||
|
.to_armored_string(None.into())
|
||||||
|
.expect("armor secret key");
|
||||||
|
|
||||||
|
// Encrypt a "block" to the node key: the resulting message carries a
|
||||||
|
// PKESK (the content key packet) followed by the SEIPD (the block body).
|
||||||
|
let plaintext = b"the quick brown fox jumps over 13 lazy dogs".to_vec();
|
||||||
|
let mut builder = MessageBuilder::from_bytes(String::new(), plaintext.clone())
|
||||||
|
.seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256);
|
||||||
|
builder
|
||||||
|
.encrypt_to_key(&mut rng, &signed.primary_key.public_key())
|
||||||
|
.expect("encrypt to key");
|
||||||
|
let message_bytes = builder.to_vec(&mut rng).expect("serialize message");
|
||||||
|
|
||||||
|
let key = PrivateKey::from_armored(&armored, b"pw").expect("parse node key");
|
||||||
|
|
||||||
|
// `decrypt_content_key` consumes only the leading PKESK packet; the same
|
||||||
|
// bytes then decrypt as a block once the content key is recovered.
|
||||||
|
let content_key = key
|
||||||
|
.decrypt_content_key(&message_bytes)
|
||||||
|
.expect("decrypt content key");
|
||||||
|
let decrypted = content_key
|
||||||
|
.decrypt_block(&message_bytes)
|
||||||
|
.expect("decrypt block");
|
||||||
|
|
||||||
|
assert_eq!(decrypted, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_content_key_round_trip() {
|
||||||
|
// Upload path: generate a content key, seal it to the node key as a
|
||||||
|
// content-key packet, then recover it and round-trip a block.
|
||||||
|
let signed = generate_node_key("pw");
|
||||||
|
let armored = signed.to_armored_string(None.into()).expect("armor");
|
||||||
|
let node_key = PrivateKey::from_armored(&armored, b"pw").expect("parse node key");
|
||||||
|
|
||||||
|
let content_key = ContentKey::generate();
|
||||||
|
let packet = content_key.to_packet(&node_key).expect("content key packet");
|
||||||
|
|
||||||
|
let recovered = node_key
|
||||||
|
.decrypt_content_key(&packet)
|
||||||
|
.expect("decrypt content key");
|
||||||
|
|
||||||
|
let plaintext = b"upload round trip block".to_vec();
|
||||||
|
let block = content_key.encrypt_block(&plaintext).expect("encrypt block");
|
||||||
|
let decrypted = recovered.decrypt_block(&block).expect("decrypt block");
|
||||||
|
|
||||||
|
assert_eq!(decrypted, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_key_round_trip_through_x25519_node_key() {
|
||||||
|
// The real Proton case: an Ed25519 signing primary with an X25519
|
||||||
|
// encryption subkey. `to_packet` must target the subkey and
|
||||||
|
// `decrypt_content_key` must recover it.
|
||||||
|
let node = super::super::encrypt::generate_node_key().expect("generate node key");
|
||||||
|
let content_key = ContentKey::generate();
|
||||||
|
|
||||||
|
let packet = content_key.to_packet(&node.key).expect("content key packet");
|
||||||
|
let recovered = node.key.decrypt_content_key(&packet).expect("decrypt");
|
||||||
|
|
||||||
|
let plaintext = b"x25519 block".to_vec();
|
||||||
|
let block = content_key.encrypt_block(&plaintext).expect("encrypt block");
|
||||||
|
assert_eq!(recovered.decrypt_block(&block).expect("decrypt"), plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thumbnail_encrypt_inline_signed_round_trip() {
|
||||||
|
// A thumbnail block is encrypt-and-inline-signed under the content key.
|
||||||
|
// It must decrypt back to the plaintext via the same session key, and —
|
||||||
|
// unlike a content block — carry its signature inside the SEIPD payload.
|
||||||
|
let signer = super::super::encrypt::generate_node_key().expect("generate signer");
|
||||||
|
let content_key = ContentKey::generate();
|
||||||
|
|
||||||
|
let plaintext = b"thumbnail image bytes".to_vec();
|
||||||
|
let block = content_key
|
||||||
|
.encrypt_thumbnail(&signer.key, &plaintext)
|
||||||
|
.expect("encrypt thumbnail");
|
||||||
|
|
||||||
|
// The bare SEIPD decrypts under the content key; its payload begins with
|
||||||
|
// a one-pass signature packet (not bare literal data), so `decrypt_block`
|
||||||
|
// would reject it — confirm the inline-signed structure directly.
|
||||||
|
let (sym_alg, key) = content_key.session_parts().expect("session parts");
|
||||||
|
let mut parser = PacketParser::new(Cursor::new(&block));
|
||||||
|
let seipd = match parser.next().expect("packet").expect("seipd") {
|
||||||
|
Packet::SymEncryptedProtectedData(d) => d,
|
||||||
|
other => panic!("expected SEIPD, got {:?}", other.tag()),
|
||||||
|
};
|
||||||
|
let inner = seipd.decrypt(key, Some(sym_alg)).expect("decrypt thumbnail");
|
||||||
|
|
||||||
|
let mut inner_parser = PacketParser::new(Cursor::new(&inner));
|
||||||
|
let first = inner_parser.next().expect("inner packet").expect("inner");
|
||||||
|
assert!(
|
||||||
|
matches!(first, Packet::OnePassSignature(_)),
|
||||||
|
"thumbnail payload should be inline-signed, got {:?}",
|
||||||
|
first.tag()
|
||||||
|
);
|
||||||
|
// The literal data follows the one-pass signature.
|
||||||
|
let literal = inner_parser.next().expect("literal packet").expect("literal");
|
||||||
|
match literal {
|
||||||
|
Packet::LiteralData(data) => assert_eq!(data.data(), plaintext.as_slice()),
|
||||||
|
other => panic!("expected literal data, got {:?}", other.tag()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aead_content_key_round_trip_through_x25519_node_key() {
|
||||||
|
// AEAD upload path: generate an AEAD content key (V6 session key), seal
|
||||||
|
// it to the node key as a v6 PKESK, recover it, and round-trip a SEIPDv2
|
||||||
|
// block. The recovered key must still report as AEAD.
|
||||||
|
let node = super::super::encrypt::generate_node_key().expect("generate node key");
|
||||||
|
let content_key = ContentKey::generate_aead();
|
||||||
|
assert!(content_key.is_aead());
|
||||||
|
|
||||||
|
let packet = content_key.to_packet(&node.key).expect("content key packet");
|
||||||
|
let recovered = node.key.decrypt_content_key(&packet).expect("decrypt");
|
||||||
|
assert!(recovered.is_aead(), "recovered key should be AEAD");
|
||||||
|
|
||||||
|
// A block larger than one AEAD chunk (128 KiB) exercises chunked GCM.
|
||||||
|
let plaintext = vec![0xABu8; (1 << 17) + 1234];
|
||||||
|
let block = content_key.encrypt_block(&plaintext).expect("encrypt block");
|
||||||
|
assert_eq!(
|
||||||
|
recovered.decrypt_block(&block).expect("decrypt block"),
|
||||||
|
plaintext
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rewrap_message_to_moves_passphrase_between_parents() {
|
||||||
|
// Move: a node passphrase encrypted (+signed) to the old parent must be
|
||||||
|
// rewrapped to the new parent without changing the plaintext, so the new
|
||||||
|
// parent recovers the exact same secret and the detached signature over
|
||||||
|
// it still verifies.
|
||||||
|
let old_parent = super::super::encrypt::generate_node_key().expect("old parent");
|
||||||
|
let new_parent = super::super::encrypt::generate_node_key().expect("new parent");
|
||||||
|
let signer = super::super::encrypt::generate_node_key().expect("signer");
|
||||||
|
|
||||||
|
let passphrase = b"node-key locking passphrase bytes".to_vec();
|
||||||
|
let armored = old_parent
|
||||||
|
.key
|
||||||
|
.encrypt_and_sign(&signer.key, &passphrase, false, false)
|
||||||
|
.expect("encrypt passphrase to old parent");
|
||||||
|
// The move keeps the detached NodePassphraseSignature, so sign separately.
|
||||||
|
let signature = signer.key.sign_detached(&passphrase).expect("sign passphrase");
|
||||||
|
|
||||||
|
// The new parent cannot read the old-parent message yet.
|
||||||
|
assert!(new_parent.key.decrypt_armored_message(&armored).is_err());
|
||||||
|
|
||||||
|
let rewrapped = old_parent
|
||||||
|
.key
|
||||||
|
.rewrap_message_to(&armored, &new_parent.key)
|
||||||
|
.expect("rewrap to new parent");
|
||||||
|
|
||||||
|
// New parent recovers the identical plaintext; old signature still holds.
|
||||||
|
let recovered = new_parent
|
||||||
|
.key
|
||||||
|
.decrypt_armored_message(&rewrapped)
|
||||||
|
.expect("new parent decrypt");
|
||||||
|
assert_eq!(recovered, passphrase);
|
||||||
|
signer
|
||||||
|
.key
|
||||||
|
.verify_detached_signature(&signature, &recovered)
|
||||||
|
.expect("passphrase signature still verifies");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_pkesk_content_key_packet() {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let key = PrivateKey::from_armored(
|
||||||
|
&generate_node_key("pw").to_armored_string(None.into()).unwrap(),
|
||||||
|
b"pw",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// An unencrypted message begins with a literal-data packet, not a PKESK.
|
||||||
|
let bytes = MessageBuilder::from_bytes(String::new(), b"hi".to_vec())
|
||||||
|
.to_vec(&mut rng)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(key.decrypt_content_key(&bytes).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//! Proton key-passphrase derivation.
|
||||||
|
//!
|
||||||
|
//! Proton derives the passphrase that unlocks a user/address key from the
|
||||||
|
//! mailbox password and a per-key salt using bcrypt: `bcrypt(password, salt)`
|
||||||
|
//! then dropping the first 29 characters (the `$2y$10$` prefix plus the 22-char
|
||||||
|
//! encoded salt), leaving the 31-char hash. Mirrors `DeriveSecretFromPassword`
|
||||||
|
//! in the C# SDK (which calls `SrpClient.HashPassword`).
|
||||||
|
|
||||||
|
use bcrypt::HashParts;
|
||||||
|
|
||||||
|
use super::errors::CryptoError;
|
||||||
|
|
||||||
|
const BCRYPT_COST: u32 = 10;
|
||||||
|
const BCRYPT_PREFIX_LEN: usize = 29;
|
||||||
|
|
||||||
|
/// Derive the unlocking passphrase for a key from the mailbox password and the
|
||||||
|
/// key's 16-byte salt.
|
||||||
|
pub fn derive_key_passphrase(password: &[u8], salt: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
if salt.len() != 16 {
|
||||||
|
return Err(CryptoError::Unlock(format!(
|
||||||
|
"key salt must be 16 bytes, got {}",
|
||||||
|
salt.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut salt_buf = [0u8; 16];
|
||||||
|
salt_buf.copy_from_slice(salt);
|
||||||
|
|
||||||
|
let parts: HashParts = bcrypt::hash_with_salt(password, BCRYPT_COST, salt_buf)
|
||||||
|
.map_err(|e| CryptoError::Unlock(format!("bcrypt: {e}")))?;
|
||||||
|
|
||||||
|
// `$2b$10$<22 char salt><31 char hash>` — drop everything up to and
|
||||||
|
// including the encoded salt to obtain the passphrase bytes.
|
||||||
|
let hash = parts.to_string();
|
||||||
|
let passphrase = hash
|
||||||
|
.get(BCRYPT_PREFIX_LEN..)
|
||||||
|
.ok_or_else(|| CryptoError::Unlock("bcrypt hash shorter than expected".into()))?;
|
||||||
|
|
||||||
|
Ok(passphrase.as_bytes().to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SALT: [u8; 16] = [
|
||||||
|
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||||
|
0x10,
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derives_31_byte_passphrase() {
|
||||||
|
let passphrase = derive_key_passphrase(b"hunter2", &SALT).unwrap();
|
||||||
|
// The bcrypt hash portion after the `$2b$10$<22-char salt>` prefix.
|
||||||
|
assert_eq!(passphrase.len(), 31);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derivation_is_deterministic() {
|
||||||
|
let a = derive_key_passphrase(b"hunter2", &SALT).unwrap();
|
||||||
|
let b = derive_key_passphrase(b"hunter2", &SALT).unwrap();
|
||||||
|
assert_eq!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_password_yields_different_passphrase() {
|
||||||
|
let a = derive_key_passphrase(b"hunter2", &SALT).unwrap();
|
||||||
|
let b = derive_key_passphrase(b"hunter3", &SALT).unwrap();
|
||||||
|
assert_ne!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_wrong_salt_length() {
|
||||||
|
assert!(derive_key_passphrase(b"hunter2", &[0u8; 8]).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
//! Encryption / signing primitives for the upload (write) path.
|
||||||
|
//!
|
||||||
|
//! Mirrors the encrypt-and-sign helpers the C# SDK builds on top of the
|
||||||
|
//! NativeAOT core (`PgpPrivateKey.Encrypt*`, `EncryptSessionKey`,
|
||||||
|
//! `SignDetached`). All operations target Proton keys whose primary is a
|
||||||
|
//! signing key with a separate encryption subkey, so recipient selection must
|
||||||
|
//! pick the encryption-capable (sub)key.
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use base64::Engine;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use pgp::composed::{
|
||||||
|
ArmorOptions, KeyType, MessageBuilder, SecretKeyParamsBuilder, SignedSecretKey,
|
||||||
|
StandaloneSignature, SubkeyParamsBuilder,
|
||||||
|
};
|
||||||
|
use pgp::crypto::hash::HashAlgorithm;
|
||||||
|
use pgp::crypto::sym::SymmetricKeyAlgorithm;
|
||||||
|
use pgp::packet::{SignatureConfig, SignatureType};
|
||||||
|
use pgp::types::{CompressionAlgorithm, KeyVersion, Password, PublicKeyTrait};
|
||||||
|
use rand::RngCore;
|
||||||
|
|
||||||
|
use super::errors::CryptoError;
|
||||||
|
use super::keys::PrivateKey;
|
||||||
|
|
||||||
|
/// A generic operation run against a recipient's encryption-capable public key.
|
||||||
|
///
|
||||||
|
/// `dyn PublicKeyTrait` is not itself `PublicKeyTrait` (and the packet
|
||||||
|
/// constructors take `&impl PublicKeyTrait`, a `Sized` generic), so a plain
|
||||||
|
/// trait-object closure cannot carry the selected key. This trait is the
|
||||||
|
/// generic-callback workaround: [`recipient_encryption_key`] picks the concrete
|
||||||
|
/// key type and dispatches into `run` monomorphized for that type.
|
||||||
|
pub(crate) trait RecipientOp {
|
||||||
|
/// The value produced by the operation.
|
||||||
|
type Out;
|
||||||
|
/// Run the operation against the selected public key.
|
||||||
|
fn run(self, pubkey: &impl PublicKeyTrait) -> pgp::errors::Result<Self::Out>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `op` against the encryption-capable public key of `key`.
|
||||||
|
///
|
||||||
|
/// Proton keys are typically an Ed25519 signing primary plus an X25519
|
||||||
|
/// encryption subkey, so the primary is used only when it is itself an
|
||||||
|
/// encryption key, otherwise the first encryption-capable subkey is selected.
|
||||||
|
pub(crate) fn recipient_encryption_key<Op: RecipientOp>(
|
||||||
|
key: &SignedSecretKey,
|
||||||
|
op: Op,
|
||||||
|
) -> Result<Op::Out, CryptoError> {
|
||||||
|
let primary = key.primary_key.public_key();
|
||||||
|
if primary.is_encryption_key() {
|
||||||
|
return op
|
||||||
|
.run(primary)
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("encrypt to primary key: {e}")));
|
||||||
|
}
|
||||||
|
for sub in &key.secret_subkeys {
|
||||||
|
let pubsub = sub.public_key();
|
||||||
|
if pubsub.is_encryption_key() {
|
||||||
|
return op
|
||||||
|
.run(&pubsub)
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("encrypt to subkey: {e}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(CryptoError::Encrypt(
|
||||||
|
"key has no encryption-capable (sub)key".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A freshly generated, passphrase-locked node key.
|
||||||
|
pub struct GeneratedNodeKey {
|
||||||
|
/// The unlocked node key, ready to sign and decrypt.
|
||||||
|
pub key: PrivateKey,
|
||||||
|
/// The armored, passphrase-locked secret key (the `NodeKey` request field).
|
||||||
|
pub locked_armored: String,
|
||||||
|
/// The random passphrase that locks the key, base64 of 32 random bytes.
|
||||||
|
/// Mirrors C# `CryptoGenerator.GeneratePassphrase`.
|
||||||
|
pub passphrase: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a new Proton node key: an Ed25519 signing primary plus an X25519
|
||||||
|
/// encryption subkey, locked with a random base64 passphrase.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `NodeOperations.GetCommonCreationParameters` key generation
|
||||||
|
/// (`PgpPrivateKey.Generate("Drive key", "no-reply@proton.me", Default)` then
|
||||||
|
/// `key.Lock(passphrase)`).
|
||||||
|
pub fn generate_node_key() -> Result<GeneratedNodeKey, CryptoError> {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let passphrase = generate_passphrase();
|
||||||
|
let pw_string = String::from_utf8(passphrase.clone())
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("passphrase is not ascii: {e}")))?;
|
||||||
|
|
||||||
|
let subkey = SubkeyParamsBuilder::default()
|
||||||
|
.version(KeyVersion::V4)
|
||||||
|
.key_type(KeyType::X25519)
|
||||||
|
.can_encrypt(true)
|
||||||
|
.passphrase(Some(pw_string.clone()))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("node subkey params: {e}")))?;
|
||||||
|
|
||||||
|
let params = SecretKeyParamsBuilder::default()
|
||||||
|
.version(KeyVersion::V4)
|
||||||
|
.key_type(KeyType::Ed25519)
|
||||||
|
.can_certify(true)
|
||||||
|
.can_sign(true)
|
||||||
|
.primary_user_id("Drive key <no-reply@proton.me>".into())
|
||||||
|
.passphrase(Some(pw_string))
|
||||||
|
.subkey(subkey)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("node key params: {e}")))?;
|
||||||
|
|
||||||
|
let secret = params
|
||||||
|
.generate(&mut rng)
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("generate node key: {e}")))?;
|
||||||
|
let signed = secret
|
||||||
|
.sign(&mut rng, &Password::from(passphrase.as_slice()))
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("sign node key: {e}")))?;
|
||||||
|
let locked_armored = signed
|
||||||
|
.to_armored_string(None.into())
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("armor node key: {e}")))?;
|
||||||
|
|
||||||
|
let key = PrivateKey::from_armored(&locked_armored, &passphrase)?;
|
||||||
|
|
||||||
|
Ok(GeneratedNodeKey {
|
||||||
|
key,
|
||||||
|
locked_armored,
|
||||||
|
passphrase,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 32 random bytes, base64-encoded (the locking passphrase format).
|
||||||
|
fn generate_passphrase() -> Vec<u8> {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
rand::thread_rng().fill_bytes(&mut bytes);
|
||||||
|
BASE64.encode(bytes).into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a folder's node hash key: 32 random bytes encrypted to **and**
|
||||||
|
/// signed by the folder's own `node_key`.
|
||||||
|
///
|
||||||
|
/// This is the HMAC-SHA256 key used to hash child names under the folder; the
|
||||||
|
/// read path recovers it via `decrypt_armored_message` with the folder node key
|
||||||
|
/// (see drive `parent_hash_key`). Mirrors C# `FolderOperations.CreateAsync`
|
||||||
|
/// (`key.EncryptAndSign(hashKey, key)` — the node key is both recipient and
|
||||||
|
/// signer) with `CryptoGenerator.GenerateFolderHashKey` (32 bytes).
|
||||||
|
pub fn generate_node_hash_key(node_key: &PrivateKey) -> Result<String, CryptoError> {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
rand::thread_rng().fill_bytes(&mut bytes);
|
||||||
|
encrypt_and_sign(node_key.key(), Some(node_key), &bytes, false, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt `data` to `recipient`, optionally inline-signing with `signer`, and
|
||||||
|
/// return the armored PGP message. Mirrors C# `PgpEncrypter.EncryptAndSign`
|
||||||
|
/// (and the encrypt-only path when `signer` is `None`).
|
||||||
|
pub(crate) fn encrypt_and_sign(
|
||||||
|
recipient: &SignedSecretKey,
|
||||||
|
signer: Option<&PrivateKey>,
|
||||||
|
data: &[u8],
|
||||||
|
text: bool,
|
||||||
|
compress: bool,
|
||||||
|
) -> Result<String, CryptoError> {
|
||||||
|
recipient_encryption_key(
|
||||||
|
recipient,
|
||||||
|
EncryptSignOp {
|
||||||
|
data: data.to_vec(),
|
||||||
|
signer,
|
||||||
|
text,
|
||||||
|
compress,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produce an armored detached signature over `data` by `signer`'s primary
|
||||||
|
/// (signing) key. Mirrors C# `PgpPrivateKey.Sign` / `SignDetached`.
|
||||||
|
pub(crate) fn sign_detached(signer: &PrivateKey, data: &[u8]) -> Result<String, CryptoError> {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let key = &signer.key().primary_key;
|
||||||
|
let pw = signer.password();
|
||||||
|
|
||||||
|
let config = SignatureConfig::from_key(&mut rng, key, SignatureType::Binary)
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("signature config: {e}")))?;
|
||||||
|
let signature = config
|
||||||
|
.sign(key, &pw, data)
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("detached sign: {e}")))?;
|
||||||
|
|
||||||
|
StandaloneSignature::new(signature)
|
||||||
|
.to_armored_string(ArmorOptions::default())
|
||||||
|
.map_err(|e| CryptoError::Encrypt(format!("armor signature: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inline encrypt-and-sign against a selected recipient public key.
|
||||||
|
struct EncryptSignOp<'a> {
|
||||||
|
data: Vec<u8>,
|
||||||
|
signer: Option<&'a PrivateKey>,
|
||||||
|
text: bool,
|
||||||
|
compress: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecipientOp for EncryptSignOp<'_> {
|
||||||
|
type Out = String;
|
||||||
|
|
||||||
|
fn run(self, pubkey: &impl PublicKeyTrait) -> pgp::errors::Result<String> {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let mut builder = MessageBuilder::from_bytes(Bytes::new(), self.data)
|
||||||
|
.seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256);
|
||||||
|
|
||||||
|
if self.compress {
|
||||||
|
builder.compression(CompressionAlgorithm::ZLIB);
|
||||||
|
}
|
||||||
|
if self.text {
|
||||||
|
builder.sign_text();
|
||||||
|
}
|
||||||
|
if let Some(signer) = self.signer {
|
||||||
|
builder.sign(
|
||||||
|
&signer.key().primary_key,
|
||||||
|
signer.password(),
|
||||||
|
HashAlgorithm::Sha256,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.encrypt_to_key(&mut rng, pubkey)?;
|
||||||
|
builder.to_armored_string(&mut rng, ArmorOptions::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_node_key_encrypt_sign_round_trip() {
|
||||||
|
// A generated node key (Ed25519 primary + X25519 subkey) must be able to
|
||||||
|
// act as both a recipient (encrypt to its subkey) and a signer.
|
||||||
|
let node = generate_node_key().expect("generate node key");
|
||||||
|
let signer = generate_node_key().expect("generate signer key");
|
||||||
|
|
||||||
|
// Encrypt + inline-sign a payload to the node key, then decrypt it back.
|
||||||
|
let plaintext = b"extended attributes payload".to_vec();
|
||||||
|
let armored = node
|
||||||
|
.key
|
||||||
|
.encrypt_and_sign(&signer.key, &plaintext, false, true)
|
||||||
|
.expect("encrypt and sign");
|
||||||
|
let decrypted = node
|
||||||
|
.key
|
||||||
|
.decrypt_armored_message(&armored)
|
||||||
|
.expect("decrypt");
|
||||||
|
assert_eq!(decrypted, plaintext);
|
||||||
|
|
||||||
|
// Encrypt-only (no signature) must also round-trip.
|
||||||
|
let armored_only = node.key.encrypt(&plaintext).expect("encrypt only");
|
||||||
|
let decrypted_only = node
|
||||||
|
.key
|
||||||
|
.decrypt_armored_message(&armored_only)
|
||||||
|
.expect("decrypt only");
|
||||||
|
assert_eq!(decrypted_only, plaintext);
|
||||||
|
|
||||||
|
// A detached signature by the signer verifies against the signer key.
|
||||||
|
let data = b"manifest bytes";
|
||||||
|
let sig = signer.key.sign_detached(data).expect("detached sign");
|
||||||
|
signer
|
||||||
|
.key
|
||||||
|
.verify_detached_signature(&sig, data)
|
||||||
|
.expect("verify detached signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn node_hash_key_round_trips_under_node_key() {
|
||||||
|
// The folder hash key is 32 random bytes encrypted to the folder's own
|
||||||
|
// node key; the read path recovers it with that same node key.
|
||||||
|
let node = generate_node_key().expect("generate node key");
|
||||||
|
|
||||||
|
let armored = generate_node_hash_key(&node.key).expect("generate hash key");
|
||||||
|
let hash_key = node
|
||||||
|
.key
|
||||||
|
.decrypt_armored_message(&armored)
|
||||||
|
.expect("decrypt hash key");
|
||||||
|
assert_eq!(hash_key.len(), 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
//! Crypto error type.
|
||||||
|
|
||||||
|
/// Failure of a PGP operation.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum CryptoError {
|
||||||
|
/// An armored or binary key/message failed to parse.
|
||||||
|
#[error("failed to parse PGP data: {0}")]
|
||||||
|
Parse(String),
|
||||||
|
|
||||||
|
/// A locked private key could not be unlocked with the supplied passphrase.
|
||||||
|
#[error("failed to unlock private key: {0}")]
|
||||||
|
Unlock(String),
|
||||||
|
|
||||||
|
/// Decryption of a message or session key failed.
|
||||||
|
#[error("failed to decrypt: {0}")]
|
||||||
|
Decrypt(String),
|
||||||
|
|
||||||
|
/// Encryption, signing or key generation failed.
|
||||||
|
#[error("failed to encrypt: {0}")]
|
||||||
|
Encrypt(String),
|
||||||
|
|
||||||
|
/// Signature verification failed (when verification is enforced).
|
||||||
|
#[error("signature verification failed: {0}")]
|
||||||
|
Verification(String),
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! PGP secret-key parsing and the layered Proton decryption chain.
|
||||||
|
//!
|
||||||
|
//! rPGP 0.16 decrypts directly with a `(key, password)` pair rather than a
|
||||||
|
//! distinct pre-unlock step, so a [`PrivateKey`] simply pairs a parsed
|
||||||
|
//! [`SignedSecretKey`] with the passphrase that unlocks it.
|
||||||
|
|
||||||
|
use pgp::composed::{Deserializable, SignedSecretKey, StandaloneSignature};
|
||||||
|
use pgp::types::Password;
|
||||||
|
|
||||||
|
use super::errors::CryptoError;
|
||||||
|
use super::messages;
|
||||||
|
|
||||||
|
/// A Proton private key: a parsed secret key plus the passphrase that unlocks it.
|
||||||
|
///
|
||||||
|
/// `Password` is not `Clone`, so we retain the raw passphrase bytes and build a
|
||||||
|
/// `Password` on demand for each operation.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PrivateKey {
|
||||||
|
key: SignedSecretKey,
|
||||||
|
passphrase: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PrivateKey {
|
||||||
|
/// Parse an armored secret key and validate that `passphrase` unlocks it.
|
||||||
|
pub fn from_armored(armored: &str, passphrase: &[u8]) -> Result<Self, CryptoError> {
|
||||||
|
let (key, _headers) = SignedSecretKey::from_string(armored)
|
||||||
|
.map_err(|e| CryptoError::Parse(format!("secret key: {e}")))?;
|
||||||
|
|
||||||
|
// Confirm the passphrase is correct up front, mirroring the C# `Unlock`.
|
||||||
|
// `unlock` returns `Result<Result<T>>`: the outer layer reports whether
|
||||||
|
// the secret params could be decrypted (i.e. the passphrase fit), the
|
||||||
|
// inner layer is our (trivial) closure result.
|
||||||
|
let password = password_from_bytes(passphrase);
|
||||||
|
key.unlock(&password, |_, _| Ok(()))
|
||||||
|
.map_err(|e| CryptoError::Unlock(e.to_string()))?
|
||||||
|
.map_err(|e| CryptoError::Unlock(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
key,
|
||||||
|
passphrase: passphrase.to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt an armored PGP message addressed to this key.
|
||||||
|
pub fn decrypt_armored_message(&self, armored: &str) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
messages::decrypt_armored(armored, &self.key, &self.password())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a binary (de-armored) PGP message addressed to this key.
|
||||||
|
pub fn decrypt_binary_message(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
messages::decrypt_binary(ciphertext, &self.key, &self.password())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify an armored detached signature over `data` against this key's
|
||||||
|
/// public key. Returns `Ok(())` only on a good signature.
|
||||||
|
pub fn verify_detached_signature(&self, armored_sig: &str, data: &[u8]) -> Result<(), CryptoError> {
|
||||||
|
let (sig, _headers) = StandaloneSignature::from_string(armored_sig)
|
||||||
|
.map_err(|e| CryptoError::Parse(format!("detached signature: {e}")))?;
|
||||||
|
sig.verify(&self.key.signed_public_key(), data)
|
||||||
|
.map_err(|e| CryptoError::Verification(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt `data` to this key and inline-sign it with `signer`, returning
|
||||||
|
/// the armored PGP message. Used for node names and extended attributes.
|
||||||
|
pub fn encrypt_and_sign(
|
||||||
|
&self,
|
||||||
|
signer: &PrivateKey,
|
||||||
|
data: &[u8],
|
||||||
|
text: bool,
|
||||||
|
compress: bool,
|
||||||
|
) -> Result<String, CryptoError> {
|
||||||
|
super::encrypt::encrypt_and_sign(&self.key, Some(signer), data, text, compress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt `data` to this key without signing, returning the armored PGP
|
||||||
|
/// message. Used for node passphrases and encrypted block signatures.
|
||||||
|
pub fn encrypt(&self, data: &[u8]) -> Result<String, CryptoError> {
|
||||||
|
super::encrypt::encrypt_and_sign(&self.key, None, data, false, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produce an armored detached signature over `data` with this key's
|
||||||
|
/// primary (signing) key.
|
||||||
|
pub fn sign_detached(&self, data: &[u8]) -> Result<String, CryptoError> {
|
||||||
|
super::encrypt::sign_detached(self, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn key(&self) -> &SignedSecretKey {
|
||||||
|
&self.key
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn password(&self) -> Password {
|
||||||
|
password_from_bytes(&self.passphrase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt an armored message, trying each key in `keys` until one succeeds.
|
||||||
|
pub fn decrypt_armored_with_keys(armored: &str, keys: &[PrivateKey]) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let owned: Vec<(&SignedSecretKey, Password)> =
|
||||||
|
keys.iter().map(|k| (k.key(), k.password())).collect();
|
||||||
|
let refs: Vec<(&SignedSecretKey, &Password)> =
|
||||||
|
owned.iter().map(|(k, p)| (*k, p)).collect();
|
||||||
|
messages::decrypt_armored_any(armored, &refs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn password_from_bytes(bytes: &[u8]) -> Password {
|
||||||
|
Password::from(bytes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//! PGP message decryption helpers.
|
||||||
|
|
||||||
|
use pgp::composed::{Message, SignedSecretKey};
|
||||||
|
use pgp::types::Password;
|
||||||
|
|
||||||
|
use super::errors::CryptoError;
|
||||||
|
|
||||||
|
/// Decrypt an armored PGP message with `key`/`key_pw`, returning plaintext.
|
||||||
|
///
|
||||||
|
/// Signature verification is *not* enforced here: Proton treats authorship
|
||||||
|
/// verification as a separate, non-fatal concern, so the plaintext is returned
|
||||||
|
/// even when a signature is absent or unverified.
|
||||||
|
pub fn decrypt_armored(
|
||||||
|
armored: &str,
|
||||||
|
key: &SignedSecretKey,
|
||||||
|
key_pw: &Password,
|
||||||
|
) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let (message, _headers) = Message::from_string(armored)
|
||||||
|
.map_err(|e| CryptoError::Parse(format!("message: {e}")))?;
|
||||||
|
|
||||||
|
let decrypted = message
|
||||||
|
.decrypt(key_pw, key)
|
||||||
|
.map_err(|e| CryptoError::Decrypt(e.to_string()))?;
|
||||||
|
|
||||||
|
read_plaintext(decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a binary (already de-armored) PGP message.
|
||||||
|
pub fn decrypt_binary(
|
||||||
|
ciphertext: &[u8],
|
||||||
|
key: &SignedSecretKey,
|
||||||
|
key_pw: &Password,
|
||||||
|
) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let message = Message::from_bytes(ciphertext)
|
||||||
|
.map_err(|e| CryptoError::Parse(format!("message: {e}")))?;
|
||||||
|
|
||||||
|
let decrypted = message
|
||||||
|
.decrypt(key_pw, key)
|
||||||
|
.map_err(|e| CryptoError::Decrypt(e.to_string()))?;
|
||||||
|
|
||||||
|
read_plaintext(decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt an armored message, trying each candidate key until one succeeds.
|
||||||
|
///
|
||||||
|
/// Proton key tokens are encrypted to one of several user keys; we don't know
|
||||||
|
/// which a priori, so we attempt them in turn.
|
||||||
|
pub fn decrypt_armored_any(
|
||||||
|
armored: &str,
|
||||||
|
keys: &[(&SignedSecretKey, &Password)],
|
||||||
|
) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let mut last_err = CryptoError::Decrypt("no candidate keys".into());
|
||||||
|
for (key, pw) in keys {
|
||||||
|
match decrypt_armored(armored, key, pw) {
|
||||||
|
Ok(plaintext) => return Ok(plaintext),
|
||||||
|
Err(e) => last_err = e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain a decrypted message to bytes, transparently decompressing if needed.
|
||||||
|
pub(crate) fn read_plaintext(mut message: Message<'_>) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
while message.is_compressed() {
|
||||||
|
message = message
|
||||||
|
.decompress()
|
||||||
|
.map_err(|e| CryptoError::Decrypt(format!("decompress: {e}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
message
|
||||||
|
.as_data_vec()
|
||||||
|
.map_err(|e| CryptoError::Decrypt(format!("read literal: {e}")))
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
//! Proton SRP password-login proofs.
|
||||||
|
//!
|
||||||
|
//! Pure-Rust port of `ProtonMail/go-srp` (the algorithm behind the C# SDK's
|
||||||
|
//! `Proton.Cryptography.Srp.SrpClient`, which is an external NativeAOT lib). The
|
||||||
|
//! flow: hash the password (bcrypt + `expandHash`), then run Proton's SRP-6a
|
||||||
|
//! variant to produce the client ephemeral, the client proof to send, and the
|
||||||
|
//! expected server proof to check against the `auth/v4` response.
|
||||||
|
//!
|
||||||
|
//! All wire integers are little-endian, fixed to `bit_length / 8` bytes (256 for
|
||||||
|
//! the 2048-bit group). The modulus is delivered as a PGP cleartext-signed
|
||||||
|
//! message; we verify the signature against Proton's embedded SRP modulus key
|
||||||
|
//! before trusting it.
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use base64::Engine;
|
||||||
|
use num_bigint::BigUint;
|
||||||
|
use num_traits::One;
|
||||||
|
use pgp::composed::{CleartextSignedMessage, Deserializable, SignedPublicKey};
|
||||||
|
use sha2::{Digest, Sha512};
|
||||||
|
|
||||||
|
use super::errors::CryptoError;
|
||||||
|
|
||||||
|
/// The default SRP group size used by Proton.
|
||||||
|
pub const DEFAULT_BIT_LENGTH: usize = 2048;
|
||||||
|
|
||||||
|
/// Proton's public key for verifying the SRP modulus signature (`proton@srp.modulus`).
|
||||||
|
const MODULUS_PUBKEY: &str = "-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
|
xjMEXAHLgxYJKwYBBAHaRw8BAQdAFurWXXwjTemqjD7CXjXVyKf0of7n9Ctm
|
||||||
|
L8v9enkzggHNEnByb3RvbkBzcnAubW9kdWx1c8J3BBAWCgApBQJcAcuDBgsJ
|
||||||
|
BwgDAgkQNQWFxOlRjyYEFQgKAgMWAgECGQECGwMCHgEAAPGRAP9sauJsW12U
|
||||||
|
MnTQUZpsbJb53d0Wv55mZIIiJL2XulpWPQD/V6NglBd96lZKBmInSXX/kXat
|
||||||
|
Sv+y0io+LR8i2+jV+AbOOARcAcuDEgorBgEEAZdVAQUBAQdAeJHUz1c9+KfE
|
||||||
|
kSIgcBRE3WuXC4oj5a2/U3oASExGDW4DAQgHwmEEGBYIABMFAlwBy4MJEDUF
|
||||||
|
hcTpUY8mAhsMAAD/XQD8DxNI6E78meodQI+wLsrKLeHn32iLvUqJbVDhfWSU
|
||||||
|
WO4BAMcm1u02t4VKw++ttECPt+HUgPUq5pqQWe5Q2cW4TMsE
|
||||||
|
=Y4Mw
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----";
|
||||||
|
|
||||||
|
/// The result of running the client side of the SRP handshake.
|
||||||
|
pub struct SrpProofs {
|
||||||
|
/// `A` — the client ephemeral, sent to the server (`ClientEphemeral`).
|
||||||
|
pub client_ephemeral: Vec<u8>,
|
||||||
|
/// `M1` — proof sent to the server (`ClientProof`).
|
||||||
|
pub client_proof: Vec<u8>,
|
||||||
|
/// `M2` — proof we expect back from the server (`ServerProof`).
|
||||||
|
pub expected_server_proof: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the cleartext-signed modulus against Proton's key and return the raw
|
||||||
|
/// (base64-decoded) modulus bytes. Mirrors go-srp `readClearSignedMessage`.
|
||||||
|
fn verify_and_decode_modulus(signed_modulus: &str) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let (verification_key, _) = SignedPublicKey::from_string(MODULUS_PUBKEY)
|
||||||
|
.map_err(|e| CryptoError::Parse(format!("modulus pubkey: {e}")))?;
|
||||||
|
|
||||||
|
let (message, _headers) = CleartextSignedMessage::from_string(signed_modulus)
|
||||||
|
.map_err(|e| CryptoError::Parse(format!("signed modulus: {e}")))?;
|
||||||
|
|
||||||
|
message
|
||||||
|
.verify(&verification_key)
|
||||||
|
.map_err(|e| CryptoError::Verification(format!("modulus signature: {e}")))?;
|
||||||
|
|
||||||
|
BASE64
|
||||||
|
.decode(message.text().trim())
|
||||||
|
.map_err(|e| CryptoError::Parse(format!("modulus base64: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `expandHash`: SHA-512 of `data || i` for `i` in `0..4`, concatenated (256 bytes).
|
||||||
|
fn expand_hash(data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(256);
|
||||||
|
for i in 0u8..4 {
|
||||||
|
let mut hasher = Sha512::new();
|
||||||
|
hasher.update(data);
|
||||||
|
hasher.update([i]);
|
||||||
|
out.extend_from_slice(&hasher.finalize());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash the password for auth versions 3/4: `expandHash(bcrypt(pw, salt+"proton") || modulus)`.
|
||||||
|
///
|
||||||
|
/// `salt` is the raw (decoded) login salt; `modulus` is the raw modulus bytes.
|
||||||
|
fn hash_password_v3(
|
||||||
|
password: &[u8],
|
||||||
|
salt: &[u8],
|
||||||
|
modulus: &[u8],
|
||||||
|
) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
// Proton appends the literal "proton" to the 10-byte salt, yielding bcrypt's
|
||||||
|
// required 16-byte salt.
|
||||||
|
let mut salt_buf = [0u8; 16];
|
||||||
|
if salt.len() + 6 != 16 {
|
||||||
|
return Err(CryptoError::Unlock(format!(
|
||||||
|
"SRP login salt must be 10 bytes, got {}",
|
||||||
|
salt.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
salt_buf[..salt.len()].copy_from_slice(salt);
|
||||||
|
salt_buf[salt.len()..].copy_from_slice(b"proton");
|
||||||
|
|
||||||
|
let parts = bcrypt::hash_with_salt(password, 10, salt_buf)
|
||||||
|
.map_err(|e| CryptoError::Unlock(format!("bcrypt: {e}")))?;
|
||||||
|
|
||||||
|
// go-srp hashes the full `$2y$10$<salt><hash>` bcrypt string. The `bcrypt`
|
||||||
|
// crate emits a `$2b$` variant tag; rebuild with `$2y$` so the byte input to
|
||||||
|
// expandHash matches the reference exactly. (`2b`/`2y` produce identical hash
|
||||||
|
// bytes; only the embedded tag differs.)
|
||||||
|
let s = parts.to_string();
|
||||||
|
let fields: Vec<&str> = s.split('$').collect();
|
||||||
|
// fields = ["", "2b", "10", "<22-char salt><31-char hash>"]
|
||||||
|
let crypted = match fields.as_slice() {
|
||||||
|
[_, _variant, cost, tail] => format!("$2y${cost}${tail}"),
|
||||||
|
_ => return Err(CryptoError::Unlock("unexpected bcrypt output".into())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut input = crypted.into_bytes();
|
||||||
|
input.extend_from_slice(modulus);
|
||||||
|
Ok(expand_hash(&input))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert little-endian fixed-width bytes to a `BigUint`.
|
||||||
|
fn from_le(bytes: &[u8]) -> BigUint {
|
||||||
|
BigUint::from_bytes_le(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a `BigUint` to little-endian bytes, zero-padded to `byte_len`.
|
||||||
|
fn to_le_fixed(n: &BigUint, byte_len: usize) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let mut v = n.to_bytes_le();
|
||||||
|
if v.len() > byte_len {
|
||||||
|
return Err(CryptoError::Decrypt("SRP value exceeds group size".into()));
|
||||||
|
}
|
||||||
|
v.resize(byte_len, 0);
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the modulus and server ephemeral (go-srp `checkParams`).
|
||||||
|
///
|
||||||
|
/// We re-check the cheap structural properties and run the single Lucas-test
|
||||||
|
/// exponentiation; the full safe-prime primality test is skipped because the
|
||||||
|
/// modulus signature has already been verified against Proton's key.
|
||||||
|
fn check_params(bit_length: usize, n: &BigUint, b: &BigUint) -> Result<(), CryptoError> {
|
||||||
|
if n.bits() as usize != bit_length {
|
||||||
|
return Err(CryptoError::Verification("SRP modulus has wrong size".into()));
|
||||||
|
}
|
||||||
|
// 2 generates the whole group only when N ≡ 3 (mod 8).
|
||||||
|
if (n % 8u32) != BigUint::from(3u32) {
|
||||||
|
return Err(CryptoError::Verification("SRP modulus is not 3 mod 8".into()));
|
||||||
|
}
|
||||||
|
let n_minus_1 = n - 1u32;
|
||||||
|
if *b <= BigUint::one() || *b >= n_minus_1 {
|
||||||
|
return Err(CryptoError::Verification(
|
||||||
|
"SRP server ephemeral out of bounds".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Lucas test (base 2): 2^((N-1)/2) ≡ -1 (mod N) proves primality and that 2
|
||||||
|
// is a generator of the full group.
|
||||||
|
let half = &n_minus_1 >> 1;
|
||||||
|
if BigUint::from(2u32).modpow(&half, n) != n_minus_1 {
|
||||||
|
return Err(CryptoError::Verification("SRP modulus is not prime".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a random client secret in the valid range, plus the client
|
||||||
|
/// ephemeral `A = g^a mod N` and scrambling parameter `u`.
|
||||||
|
fn generate_ephemeral(
|
||||||
|
bit_length: usize,
|
||||||
|
n: &BigUint,
|
||||||
|
n_minus_1: &BigUint,
|
||||||
|
server_ephemeral: &[u8],
|
||||||
|
) -> Result<(BigUint, Vec<u8>, BigUint), CryptoError> {
|
||||||
|
let byte_len = bit_length / 8;
|
||||||
|
let lower_bound = BigUint::from((bit_length * 2) as u64);
|
||||||
|
let g = BigUint::from(2u32);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut buf = vec![0u8; byte_len];
|
||||||
|
getrandom::getrandom(&mut buf)
|
||||||
|
.map_err(|e| CryptoError::Decrypt(format!("rng: {e}")))?;
|
||||||
|
let secret = from_le(&buf) % n_minus_1;
|
||||||
|
if secret <= lower_bound || secret >= *n_minus_1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let a = g.modpow(&secret, n);
|
||||||
|
let a_bytes = to_le_fixed(&a, byte_len)?;
|
||||||
|
|
||||||
|
let mut su = a_bytes.clone();
|
||||||
|
su.extend_from_slice(server_ephemeral);
|
||||||
|
let scrambling = from_le(&expand_hash(&su));
|
||||||
|
if scrambling.bits() == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok((secret, a_bytes, scrambling));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the client side of the Proton SRP handshake.
|
||||||
|
///
|
||||||
|
/// * `version` — auth version from `auth/v4/info` (3/4 supported).
|
||||||
|
/// * `password` — the user's login password.
|
||||||
|
/// * `salt` — raw (decoded) login salt.
|
||||||
|
/// * `signed_modulus` — the cleartext-signed modulus string.
|
||||||
|
/// * `server_ephemeral` — raw (decoded) server ephemeral `B`.
|
||||||
|
pub fn generate_proofs(
|
||||||
|
version: i32,
|
||||||
|
password: &[u8],
|
||||||
|
salt: &[u8],
|
||||||
|
signed_modulus: &str,
|
||||||
|
server_ephemeral: &[u8],
|
||||||
|
bit_length: usize,
|
||||||
|
) -> Result<SrpProofs, CryptoError> {
|
||||||
|
if version < 3 {
|
||||||
|
return Err(CryptoError::Unlock(format!(
|
||||||
|
"unsupported SRP auth version {version}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let byte_len = bit_length / 8;
|
||||||
|
let modulus = verify_and_decode_modulus(signed_modulus)?;
|
||||||
|
let hashed_password = hash_password_v3(password, salt, &modulus)?;
|
||||||
|
|
||||||
|
let n = from_le(&modulus);
|
||||||
|
let b = from_le(server_ephemeral);
|
||||||
|
check_params(bit_length, &n, &b)?;
|
||||||
|
|
||||||
|
let n_minus_1 = &n - 1u32;
|
||||||
|
let x = from_le(&hashed_password);
|
||||||
|
let g = BigUint::from(2u32);
|
||||||
|
|
||||||
|
// Multiplier k = expandHash(g || N) mod N, bounded.
|
||||||
|
let mut k_input = to_le_fixed(&g, byte_len)?;
|
||||||
|
k_input.extend_from_slice(&to_le_fixed(&n, byte_len)?);
|
||||||
|
let k = from_le(&expand_hash(&k_input)) % &n;
|
||||||
|
if k <= BigUint::one() || k >= n_minus_1 {
|
||||||
|
return Err(CryptoError::Verification("SRP multiplier out of bounds".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (secret, a_bytes, scrambling) =
|
||||||
|
generate_ephemeral(bit_length, &n, &n_minus_1, server_ephemeral)?;
|
||||||
|
|
||||||
|
finish_proofs(
|
||||||
|
&n,
|
||||||
|
&n_minus_1,
|
||||||
|
byte_len,
|
||||||
|
&k,
|
||||||
|
&x,
|
||||||
|
server_ephemeral,
|
||||||
|
&secret,
|
||||||
|
&a_bytes,
|
||||||
|
&scrambling,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assemble the shared secret and proofs from a chosen client secret and
|
||||||
|
/// scrambling parameter. Separated out so the math is testable without the RNG.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn finish_proofs(
|
||||||
|
n: &BigUint,
|
||||||
|
n_minus_1: &BigUint,
|
||||||
|
byte_len: usize,
|
||||||
|
k: &BigUint,
|
||||||
|
x: &BigUint,
|
||||||
|
server_ephemeral: &[u8],
|
||||||
|
secret: &BigUint,
|
||||||
|
a_bytes: &[u8],
|
||||||
|
scrambling: &BigUint,
|
||||||
|
) -> Result<SrpProofs, CryptoError> {
|
||||||
|
let g = BigUint::from(2u32);
|
||||||
|
|
||||||
|
// base = (B - k * g^x) mod N
|
||||||
|
let b = from_le(server_ephemeral);
|
||||||
|
let gx = g.modpow(x, n);
|
||||||
|
let kgx = (k * &gx) % n;
|
||||||
|
let base = (&b + n - kgx) % n;
|
||||||
|
|
||||||
|
// exponent = (u * x + a) mod (N-1)
|
||||||
|
let exponent = (scrambling * x + secret) % n_minus_1;
|
||||||
|
|
||||||
|
let shared = base.modpow(&exponent, n);
|
||||||
|
let shared_bytes = to_le_fixed(&shared, byte_len)?;
|
||||||
|
|
||||||
|
// M1 = expandHash(A || B || S); M2 = expandHash(A || M1 || S)
|
||||||
|
let mut cp = a_bytes.to_vec();
|
||||||
|
cp.extend_from_slice(server_ephemeral);
|
||||||
|
cp.extend_from_slice(&shared_bytes);
|
||||||
|
let client_proof = expand_hash(&cp);
|
||||||
|
|
||||||
|
let mut sp = a_bytes.to_vec();
|
||||||
|
sp.extend_from_slice(&client_proof);
|
||||||
|
sp.extend_from_slice(&shared_bytes);
|
||||||
|
let expected_server_proof = expand_hash(&sp);
|
||||||
|
|
||||||
|
Ok(SrpProofs {
|
||||||
|
client_ephemeral: a_bytes.to_vec(),
|
||||||
|
client_proof,
|
||||||
|
expected_server_proof,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// Vectors from ProtonMail/go-srp `srp_test.go`. The modulus is signed by the
|
||||||
|
// production modulus key, so it exercises our real verification path.
|
||||||
|
const TEST_MODULUS_B64: &str = "W2z5HBi8RvsfYzZTS7qBaUxxPhsfHJFZpu3Kd6s1JafNrCCH9rfvPLrfuqocxWPgWDH2R8neK7PkNvjxto9TStuY5z7jAzWRvFWN9cQhAKkdWgy0JY6ywVn22+HFpF4cYesHrqFIKUPDMSSIlWjBVmEJZ/MusD44ZT29xcPrOqeZvwtCffKtGAIjLYPZIEbZKnDM1Dm3q2K/xS5h+xdhjnndhsrkwm9U9oyA2wxzSXFL+pdfj2fOdRwuR5nW0J2NFrq3kJjkRmpO/Genq1UW+TEknIWAb6VzJJJA244K/H8cnSx2+nSNZO3bbo6Ys228ruV9A8m6DhxmS+bihN3ttQ==";
|
||||||
|
const TEST_MODULUS_CLEARSIGN: &str = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nW2z5HBi8RvsfYzZTS7qBaUxxPhsfHJFZpu3Kd6s1JafNrCCH9rfvPLrfuqocxWPgWDH2R8neK7PkNvjxto9TStuY5z7jAzWRvFWN9cQhAKkdWgy0JY6ywVn22+HFpF4cYesHrqFIKUPDMSSIlWjBVmEJZ/MusD44ZT29xcPrOqeZvwtCffKtGAIjLYPZIEbZKnDM1Dm3q2K/xS5h+xdhjnndhsrkwm9U9oyA2wxzSXFL+pdfj2fOdRwuR5nW0J2NFrq3kJjkRmpO/Genq1UW+TEknIWAb6VzJJJA244K/H8cnSx2+nSNZO3bbo6Ys228ruV9A8m6DhxmS+bihN3ttQ==\n-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwl4EARYIABAFAlwB1j0JEDUFhcTpUY8mAAD8CgEAnsFnF4cF0uSHKkXa1GIa\nGO86yMV4zDZEZcDSJo0fgr8A/AlupGN9EdHlsrZLmTA1vhIx+rOgxdEff28N\nkvNM7qIK\n=q6vu\n-----END PGP SIGNATURE-----";
|
||||||
|
const TEST_SALT_B64: &str = "yKlc5/CvObfoiw==";
|
||||||
|
const TEST_USERNAME: &str = "jakubqa";
|
||||||
|
const TEST_PASSWORD: &[u8] = b"abc123";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_hash_is_256_bytes() {
|
||||||
|
assert_eq!(expand_hash(b"abc").len(), 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modulus_signature_verifies_and_decodes() {
|
||||||
|
let decoded = verify_and_decode_modulus(TEST_MODULUS_CLEARSIGN).expect("verify modulus");
|
||||||
|
let expected = BASE64.decode(TEST_MODULUS_B64).unwrap();
|
||||||
|
assert_eq!(decoded, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_tampered_modulus_signature() {
|
||||||
|
// Flip a byte deep in the base64 payload so the signature no longer matches.
|
||||||
|
let tampered = TEST_MODULUS_CLEARSIGN.replacen("W2z5", "X2z5", 1);
|
||||||
|
assert!(verify_and_decode_modulus(&tampered).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full SRP-6a round trip: compute the server side in-test (the server holds
|
||||||
|
// the verifier v = g^x) and confirm our client proofs agree with what the
|
||||||
|
// server would derive. Proves the base/exponent/shared-secret math without
|
||||||
|
// depending on a non-reproducible RNG sequence.
|
||||||
|
#[test]
|
||||||
|
fn client_server_round_trip() {
|
||||||
|
let _ = TEST_USERNAME;
|
||||||
|
let bit_length = DEFAULT_BIT_LENGTH;
|
||||||
|
let byte_len = bit_length / 8;
|
||||||
|
|
||||||
|
let modulus = verify_and_decode_modulus(TEST_MODULUS_CLEARSIGN).unwrap();
|
||||||
|
let salt = BASE64.decode(TEST_SALT_B64).unwrap();
|
||||||
|
let hashed = hash_password_v3(TEST_PASSWORD, &salt, &modulus).unwrap();
|
||||||
|
|
||||||
|
let n = from_le(&modulus);
|
||||||
|
let n_minus_1 = &n - 1u32;
|
||||||
|
let x = from_le(&hashed);
|
||||||
|
let g = BigUint::from(2u32);
|
||||||
|
|
||||||
|
// Multiplier k (same as production path).
|
||||||
|
let mut k_input = to_le_fixed(&g, byte_len).unwrap();
|
||||||
|
k_input.extend_from_slice(&to_le_fixed(&n, byte_len).unwrap());
|
||||||
|
let k = from_le(&expand_hash(&k_input)) % &n;
|
||||||
|
|
||||||
|
// Server: verifier and ephemeral B = (k*v + g^b) mod N.
|
||||||
|
let v = g.modpow(&x, &n);
|
||||||
|
let server_secret = BigUint::from(0x5eed_1234_u64);
|
||||||
|
let b_pub = (&k * &v + g.modpow(&server_secret, &n)) % &n;
|
||||||
|
let server_ephemeral = to_le_fixed(&b_pub, byte_len).unwrap();
|
||||||
|
|
||||||
|
// Client: chosen secret a, ephemeral A, scrambling u.
|
||||||
|
let client_secret = BigUint::from(0xabcd_ef01_u64);
|
||||||
|
let a_pub = g.modpow(&client_secret, &n);
|
||||||
|
let a_bytes = to_le_fixed(&a_pub, byte_len).unwrap();
|
||||||
|
let mut u_input = a_bytes.clone();
|
||||||
|
u_input.extend_from_slice(&server_ephemeral);
|
||||||
|
let u = from_le(&expand_hash(&u_input));
|
||||||
|
|
||||||
|
let proofs = finish_proofs(
|
||||||
|
&n,
|
||||||
|
&n_minus_1,
|
||||||
|
byte_len,
|
||||||
|
&k,
|
||||||
|
&x,
|
||||||
|
&server_ephemeral,
|
||||||
|
&client_secret,
|
||||||
|
&a_bytes,
|
||||||
|
&u,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Server-side shared secret: S = (A * v^u)^b mod N.
|
||||||
|
let server_shared = (&a_pub * v.modpow(&u, &n) % &n).modpow(&server_secret, &n);
|
||||||
|
let server_shared_bytes = to_le_fixed(&server_shared, byte_len).unwrap();
|
||||||
|
|
||||||
|
let mut cp = a_bytes.clone();
|
||||||
|
cp.extend_from_slice(&server_ephemeral);
|
||||||
|
cp.extend_from_slice(&server_shared_bytes);
|
||||||
|
let server_view_client_proof = expand_hash(&cp);
|
||||||
|
|
||||||
|
let mut sp = a_bytes.clone();
|
||||||
|
sp.extend_from_slice(&server_view_client_proof);
|
||||||
|
sp.extend_from_slice(&server_shared_bytes);
|
||||||
|
let server_proof = expand_hash(&sp);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
proofs.client_proof, server_view_client_proof,
|
||||||
|
"client proof must match server's derivation"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
proofs.expected_server_proof, server_proof,
|
||||||
|
"expected server proof must match server's M2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_proofs_runs_end_to_end() {
|
||||||
|
// Exercises the full RNG path against the real (valid, safe-prime) modulus.
|
||||||
|
let salt = BASE64.decode(TEST_SALT_B64).unwrap();
|
||||||
|
let modulus = verify_and_decode_modulus(TEST_MODULUS_CLEARSIGN).unwrap();
|
||||||
|
// Build a server ephemeral that passes checkParams (1 < B < N-1).
|
||||||
|
let n = from_le(&modulus);
|
||||||
|
let x = from_le(&hash_password_v3(TEST_PASSWORD, &salt, &modulus).unwrap());
|
||||||
|
let g = BigUint::from(2u32);
|
||||||
|
let byte_len = DEFAULT_BIT_LENGTH / 8;
|
||||||
|
let mut k_input = to_le_fixed(&g, byte_len).unwrap();
|
||||||
|
k_input.extend_from_slice(&to_le_fixed(&n, byte_len).unwrap());
|
||||||
|
let k = from_le(&expand_hash(&k_input)) % &n;
|
||||||
|
let v = g.modpow(&x, &n);
|
||||||
|
let b_pub = (&k * &v + g.modpow(&BigUint::from(7u32), &n)) % &n;
|
||||||
|
let server_ephemeral = to_le_fixed(&b_pub, byte_len).unwrap();
|
||||||
|
|
||||||
|
let proofs = generate_proofs(
|
||||||
|
4,
|
||||||
|
TEST_PASSWORD,
|
||||||
|
&salt,
|
||||||
|
TEST_MODULUS_CLEARSIGN,
|
||||||
|
&server_ephemeral,
|
||||||
|
DEFAULT_BIT_LENGTH,
|
||||||
|
)
|
||||||
|
.expect("generate proofs");
|
||||||
|
assert_eq!(proofs.client_ephemeral.len(), byte_len);
|
||||||
|
assert_eq!(proofs.client_proof.len(), 256);
|
||||||
|
assert_eq!(proofs.expected_server_proof.len(), 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//! Error types for the core SDK.
|
||||||
|
|
||||||
|
use crate::api::ResponseCode;
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, ProtonError>;
|
||||||
|
|
||||||
|
/// Top-level error for the core SDK.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ProtonError {
|
||||||
|
/// The API returned a non-success response envelope or HTTP status.
|
||||||
|
#[error(transparent)]
|
||||||
|
Api(#[from] ProtonApiError),
|
||||||
|
|
||||||
|
/// Transport-level failure (DNS, TLS, timeout, connection reset).
|
||||||
|
#[error("HTTP transport error: {0}")]
|
||||||
|
Transport(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
/// Failed to (de)serialize a request or response body.
|
||||||
|
#[error("serialization error: {0}")]
|
||||||
|
Serialization(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
/// A cryptographic operation failed.
|
||||||
|
#[error("cryptography error: {0}")]
|
||||||
|
Crypto(#[from] crate::crypto::CryptoError),
|
||||||
|
|
||||||
|
/// The SDK was used in a way that violates an invariant.
|
||||||
|
#[error("invalid operation: {0}")]
|
||||||
|
InvalidOperation(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtonError {
|
||||||
|
pub fn invalid_operation(message: impl Into<String>) -> Self {
|
||||||
|
Self::InvalidOperation(message.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An error reported by the Proton API in its response envelope.
|
||||||
|
#[derive(Debug, Clone, thiserror::Error)]
|
||||||
|
#[error("proton api error {code:?} (http {http_status}): {message}")]
|
||||||
|
pub struct ProtonApiError {
|
||||||
|
/// Application-level response code from the `Code` field.
|
||||||
|
pub code: ResponseCode,
|
||||||
|
/// HTTP status code of the response.
|
||||||
|
pub http_status: u16,
|
||||||
|
/// Human-readable message from the `Error` field, if present.
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtonApiError {
|
||||||
|
pub fn is_unauthorized(&self) -> bool {
|
||||||
|
self.http_status == 401
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_invalid_refresh_token(&self) -> bool {
|
||||||
|
matches!(self.code, ResponseCode::InvalidRefreshToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
//! HTTP plumbing: header injection, the Proton response envelope, bearer-token
|
||||||
|
//! authentication and transparent 401 refresh.
|
||||||
|
//!
|
||||||
|
//! Mirrors `HttpApiCallBuilder`, `AuthorizationHandler` and `TokenCredential`
|
||||||
|
//! from the C# SDK, collapsed into a single reqwest-based client since Rust has
|
||||||
|
//! no `DelegatingHandler` pipeline.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use reqwest::{Method, StatusCode};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::api::{ApiResponse, ResponseCode};
|
||||||
|
use crate::config::{ProtonClientConfiguration, API_CONTENT_TYPE};
|
||||||
|
use crate::error::{ProtonApiError, ProtonError, Result};
|
||||||
|
use crate::ids::SessionId;
|
||||||
|
|
||||||
|
const SESSION_ID_HEADER: &str = "x-pm-uid";
|
||||||
|
const APP_VERSION_HEADER: &str = "x-pm-appversion";
|
||||||
|
const STORAGE_TOKEN_HEADER: &str = "pm-storage-token";
|
||||||
|
|
||||||
|
/// The mutable authentication tokens for a session, shared between every
|
||||||
|
/// request and the refresh path.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Tokens {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reqwest-backed client bound to a single authenticated session.
|
||||||
|
///
|
||||||
|
/// Cloning is cheap (everything is reference-counted) and shares the same token
|
||||||
|
/// state, so a refresh triggered by one request is visible to all others.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ApiHttpClient {
|
||||||
|
inner: Arc<Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Inner {
|
||||||
|
http: reqwest::Client,
|
||||||
|
base_url: String,
|
||||||
|
config: ProtonClientConfiguration,
|
||||||
|
session_id: SessionId,
|
||||||
|
tokens: Mutex<Tokens>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiHttpClient {
|
||||||
|
/// Build a client for an authenticated session.
|
||||||
|
pub fn new(
|
||||||
|
config: ProtonClientConfiguration,
|
||||||
|
session_id: SessionId,
|
||||||
|
tokens: Tokens,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(config.request_timeout)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let base_url = ensure_trailing_slash(&config.base_url);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
inner: Arc::new(Inner {
|
||||||
|
http,
|
||||||
|
base_url,
|
||||||
|
config,
|
||||||
|
session_id,
|
||||||
|
tokens: Mutex::new(tokens),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the current tokens (e.g. to persist for a later `resume`).
|
||||||
|
pub async fn current_tokens(&self) -> Tokens {
|
||||||
|
self.inner.tokens.lock().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET {url}` against block storage, returning the raw (still-encrypted)
|
||||||
|
/// blob bytes.
|
||||||
|
///
|
||||||
|
/// Block storage lives on a different host from the API: the URL is
|
||||||
|
/// absolute and authorization is a per-block `pm-storage-token` header
|
||||||
|
/// rather than the session bearer. Mirrors C# `StorageApiClient
|
||||||
|
/// .GetBlobStreamAsync`. A successful response is raw binary; an error
|
||||||
|
/// response is the usual JSON envelope.
|
||||||
|
pub async fn get_storage_blob(&self, url: &str, token: &str) -> Result<Vec<u8>> {
|
||||||
|
let mut request = self
|
||||||
|
.inner
|
||||||
|
.http
|
||||||
|
.get(url)
|
||||||
|
.header(STORAGE_TOKEN_HEADER, token);
|
||||||
|
|
||||||
|
if !self.inner.config.user_agent.is_empty() {
|
||||||
|
request = request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
|
||||||
|
// Success bodies are raw block bytes (not JSON); only error responses
|
||||||
|
// carry the envelope.
|
||||||
|
if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes) {
|
||||||
|
if !envelope.is_success() {
|
||||||
|
return Err(api_error(status, &bytes));
|
||||||
|
}
|
||||||
|
} else if !status.is_success() {
|
||||||
|
return Err(api_error(status, &bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST {url}` a block blob to storage as `multipart/form-data`.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `StorageApiClient.UploadBlobAsync`: a single `Block` part
|
||||||
|
/// (filename `blob`, `application/octet-stream`) on the storage host,
|
||||||
|
/// authorized by the per-block `pm-storage-token` header rather than the
|
||||||
|
/// session bearer. The response is the usual JSON envelope.
|
||||||
|
pub async fn post_storage_blob(&self, url: &str, token: &str, blob: Vec<u8>) -> Result<()> {
|
||||||
|
let part = reqwest::multipart::Part::bytes(blob)
|
||||||
|
.file_name("blob")
|
||||||
|
.mime_str("application/octet-stream")
|
||||||
|
.map_err(ProtonError::from)?;
|
||||||
|
let form = reqwest::multipart::Form::new().part("Block", part);
|
||||||
|
|
||||||
|
let mut request = self
|
||||||
|
.inner
|
||||||
|
.http
|
||||||
|
.post(url)
|
||||||
|
.header(STORAGE_TOKEN_HEADER, token)
|
||||||
|
.multipart(form);
|
||||||
|
|
||||||
|
if !self.inner.config.user_agent.is_empty() {
|
||||||
|
request = request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
|
||||||
|
if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes) {
|
||||||
|
if !envelope.is_success() {
|
||||||
|
return Err(api_error(status, &bytes));
|
||||||
|
}
|
||||||
|
} else if !status.is_success() {
|
||||||
|
return Err(api_error(status, &bytes));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_id(&self) -> &SessionId {
|
||||||
|
&self.inner.session_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET {path}` returning a typed success body.
|
||||||
|
pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
|
||||||
|
self.send::<(), T>(Method::GET, path, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST {path}` with a JSON body, returning a typed success body.
|
||||||
|
pub async fn post<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> Result<T> {
|
||||||
|
self.send::<B, T>(Method::POST, path, Some(body)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUT {path}` with a JSON body, returning a typed success body.
|
||||||
|
pub async fn put<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> Result<T> {
|
||||||
|
self.send::<B, T>(Method::PUT, path, Some(body)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE {path}` returning a typed success body.
|
||||||
|
pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
|
||||||
|
self.send::<(), T>(Method::DELETE, path, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send<B: Serialize, T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<&B>,
|
||||||
|
) -> Result<T> {
|
||||||
|
let access_token = self.inner.tokens.lock().await.access_token.clone();
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.send_with_token(method.clone(), path, body, &access_token)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let response = if response.status() == StatusCode::UNAUTHORIZED {
|
||||||
|
self.handle_unauthorized(method, path, body, response, access_token)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
response
|
||||||
|
};
|
||||||
|
|
||||||
|
parse_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_unauthorized<B: Serialize>(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<&B>,
|
||||||
|
response: reqwest::Response,
|
||||||
|
rejected_access_token: String,
|
||||||
|
) -> Result<reqwest::Response> {
|
||||||
|
// Don't bother refreshing for terminal account states.
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes) {
|
||||||
|
if matches!(
|
||||||
|
envelope.code,
|
||||||
|
ResponseCode::AccountDeleted | ResponseCode::AccountDisabled
|
||||||
|
) {
|
||||||
|
return Err(api_error(StatusCode::UNAUTHORIZED, &bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let access_token = self.refresh_access_token(&rejected_access_token).await?;
|
||||||
|
self.send_with_token(method, path, body, &access_token).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_with_token<B: Serialize>(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<&B>,
|
||||||
|
access_token: &str,
|
||||||
|
) -> Result<reqwest::Response> {
|
||||||
|
let url = format!("{}{}", self.inner.base_url, path.trim_start_matches('/'));
|
||||||
|
let mut request = self
|
||||||
|
.inner
|
||||||
|
.http
|
||||||
|
.request(method, url)
|
||||||
|
.header(SESSION_ID_HEADER, self.inner.session_id.as_str())
|
||||||
|
.header(APP_VERSION_HEADER, &self.inner.config.app_version)
|
||||||
|
.header(reqwest::header::ACCEPT, API_CONTENT_TYPE)
|
||||||
|
.bearer_auth(access_token);
|
||||||
|
|
||||||
|
if !self.inner.config.user_agent.is_empty() {
|
||||||
|
request = request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(body) = body {
|
||||||
|
request = request.json(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(request.send().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the session tokens, deduplicating concurrent refreshes: if the
|
||||||
|
/// in-memory access token already differs from the rejected one, another
|
||||||
|
/// task refreshed first and we reuse its result.
|
||||||
|
async fn refresh_access_token(&self, rejected_access_token: &str) -> Result<String> {
|
||||||
|
let mut guard = self.inner.tokens.lock().await;
|
||||||
|
|
||||||
|
if guard.access_token != rejected_access_token {
|
||||||
|
return Ok(guard.access_token.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshed = self.request_refresh(&guard.refresh_token).await?;
|
||||||
|
*guard = refreshed.clone();
|
||||||
|
Ok(refreshed.access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_refresh(&self, refresh_token: &str) -> Result<Tokens> {
|
||||||
|
let url = format!("{}auth/v4/refresh", self.inner.base_url);
|
||||||
|
let body = SessionRefreshRequest {
|
||||||
|
response_type: "token",
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token,
|
||||||
|
redirect_uri: &self.inner.config.refresh_redirect_uri,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The refresh call carries the session id but, deliberately, no bearer
|
||||||
|
// token (the access token is the thing being replaced).
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.http
|
||||||
|
.post(url)
|
||||||
|
.header(SESSION_ID_HEADER, self.inner.session_id.as_str())
|
||||||
|
.header(APP_VERSION_HEADER, &self.inner.config.app_version)
|
||||||
|
.header(reqwest::header::ACCEPT, API_CONTENT_TYPE)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let refreshed: SessionRefreshResponse = parse_response(response).await?;
|
||||||
|
Ok(Tokens {
|
||||||
|
access_token: refreshed.access_token,
|
||||||
|
refresh_token: refreshed.refresh_token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SessionRefreshRequest<'a> {
|
||||||
|
#[serde(rename = "ResponseType")]
|
||||||
|
response_type: &'a str,
|
||||||
|
#[serde(rename = "GrantType")]
|
||||||
|
grant_type: &'a str,
|
||||||
|
#[serde(rename = "RefreshToken")]
|
||||||
|
refresh_token: &'a str,
|
||||||
|
#[serde(rename = "RedirectURI")]
|
||||||
|
redirect_uri: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct SessionRefreshResponse {
|
||||||
|
#[serde(rename = "AccessToken")]
|
||||||
|
access_token: String,
|
||||||
|
#[serde(rename = "RefreshToken")]
|
||||||
|
refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST {path}` without a session: no `x-pm-uid` and no bearer token.
|
||||||
|
///
|
||||||
|
/// Used by the SRP login flow (`auth/v4/info`, `auth/v4`), which runs before a
|
||||||
|
/// session exists. Mirrors the C# SDK's `BeginAsync`, which issues these calls
|
||||||
|
/// on a session-less `HttpClient`.
|
||||||
|
pub async fn post_unauthenticated<B: Serialize, T: DeserializeOwned>(
|
||||||
|
config: &ProtonClientConfiguration,
|
||||||
|
path: &str,
|
||||||
|
body: &B,
|
||||||
|
) -> Result<T> {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(config.request_timeout)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let base_url = ensure_trailing_slash(&config.base_url);
|
||||||
|
let url = format!("{}{}", base_url, path.trim_start_matches('/'));
|
||||||
|
|
||||||
|
let mut request = http
|
||||||
|
.post(url)
|
||||||
|
.header(APP_VERSION_HEADER, &config.app_version)
|
||||||
|
.header(reqwest::header::ACCEPT, API_CONTENT_TYPE)
|
||||||
|
.json(body);
|
||||||
|
|
||||||
|
if !config.user_agent.is_empty() {
|
||||||
|
request = request.header(reqwest::header::USER_AGENT, &config.user_agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_response(request.send().await?).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a response body, enforce the Proton success envelope, and deserialize
|
||||||
|
/// the typed success payload.
|
||||||
|
async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> Result<T> {
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
|
||||||
|
// Every Proton response embeds the envelope; a missing/non-success code or a
|
||||||
|
// non-2xx HTTP status is an API error. `MultipleResponses` (1001) is a batch
|
||||||
|
// multi-status, not a failure: the real per-item codes live in the body, so
|
||||||
|
// the caller (e.g. trash/restore/delete) inspects them itself.
|
||||||
|
if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes) {
|
||||||
|
if !envelope.is_success() && envelope.code != ResponseCode::MultipleResponses {
|
||||||
|
return Err(api_error(status, &bytes));
|
||||||
|
}
|
||||||
|
} else if !status.is_success() {
|
||||||
|
return Err(api_error(status, &bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::from_slice::<T>(&bytes)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_error(status: StatusCode, bytes: &[u8]) -> ProtonError {
|
||||||
|
let envelope = serde_json::from_slice::<ApiResponse>(bytes).ok();
|
||||||
|
let code = envelope
|
||||||
|
.as_ref()
|
||||||
|
.map(|e| e.code)
|
||||||
|
.unwrap_or(ResponseCode::Unknown);
|
||||||
|
let message = envelope
|
||||||
|
.and_then(|e| e.error_message)
|
||||||
|
.unwrap_or_else(|| status.canonical_reason().unwrap_or("unknown error").to_owned());
|
||||||
|
|
||||||
|
ProtonError::Api(ProtonApiError {
|
||||||
|
code,
|
||||||
|
http_status: status.as_u16(),
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_trailing_slash(url: &str) -> String {
|
||||||
|
if url.ends_with('/') {
|
||||||
|
url.to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{url}/")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! Strongly-typed string identifiers used across the SDK.
|
||||||
|
//!
|
||||||
|
//! Proton API identifiers are opaque strings. We wrap them in newtypes so the
|
||||||
|
//! type system prevents mixing e.g. a [`ShareId`] with a [`LinkId`], mirroring
|
||||||
|
//! the dedicated ID types in the C# SDK.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
macro_rules! string_id {
|
||||||
|
($(#[$meta:meta])* $name:ident) => {
|
||||||
|
$(#[$meta])*
|
||||||
|
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct $name(pub String);
|
||||||
|
|
||||||
|
impl $name {
|
||||||
|
pub fn new(value: impl Into<String>) -> Self {
|
||||||
|
Self(value.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for $name {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for $name {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, concat!(stringify!($name), "({:?})"), self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for $name {
|
||||||
|
fn from(value: String) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for $name {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
Self(value.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
string_id!(
|
||||||
|
/// Identifies an authenticated API session (`x-pm-uid`).
|
||||||
|
SessionId
|
||||||
|
);
|
||||||
|
string_id!(
|
||||||
|
/// Identifies a user account.
|
||||||
|
UserId
|
||||||
|
);
|
||||||
|
string_id!(
|
||||||
|
/// Identifies a user-level encryption key.
|
||||||
|
UserKeyId
|
||||||
|
);
|
||||||
|
string_id!(
|
||||||
|
/// Identifies an email address attached to an account.
|
||||||
|
AddressId
|
||||||
|
);
|
||||||
|
string_id!(
|
||||||
|
/// Identifies an address-level encryption key.
|
||||||
|
AddressKeyId
|
||||||
|
);
|
||||||
|
string_id!(
|
||||||
|
/// Identifies a Drive volume.
|
||||||
|
VolumeId
|
||||||
|
);
|
||||||
|
string_id!(
|
||||||
|
/// Identifies a Drive share.
|
||||||
|
ShareId
|
||||||
|
);
|
||||||
|
string_id!(
|
||||||
|
/// Identifies a Drive link (node) within a volume.
|
||||||
|
LinkId
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Globally addresses a Drive node: a [`LinkId`] qualified by its [`VolumeId`].
|
||||||
|
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
|
||||||
|
pub struct NodeUid {
|
||||||
|
pub volume_id: VolumeId,
|
||||||
|
pub link_id: LinkId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NodeUid {
|
||||||
|
pub fn new(volume_id: VolumeId, link_id: LinkId) -> Self {
|
||||||
|
Self { volume_id, link_id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for NodeUid {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}~{}", self.volume_id, self.link_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//! Core Proton account/session/crypto primitives for the Rust Proton SDK.
|
||||||
|
//!
|
||||||
|
//! Pure-Rust reimplementation of the foundational `Proton.Sdk` layer of the
|
||||||
|
//! official [Proton Drive SDK](https://github.com/ProtonDriveApps/sdk). This
|
||||||
|
//! crate has no dependency on the native NativeAOT core; it talks to the Proton
|
||||||
|
//! API directly.
|
||||||
|
//!
|
||||||
|
//! Scope (matching the official SDK): Drive business-logic foundations only.
|
||||||
|
//! Login/SRP is provided behind [`session::ProtonApiSession::begin`] but read
|
||||||
|
//! workflows can be driven entirely through
|
||||||
|
//! [`session::ProtonApiSession::resume`] with pre-obtained tokens.
|
||||||
|
|
||||||
|
pub mod account;
|
||||||
|
pub mod api;
|
||||||
|
pub mod config;
|
||||||
|
pub mod crypto;
|
||||||
|
pub mod error;
|
||||||
|
pub mod http;
|
||||||
|
pub mod ids;
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
|
pub use error::{ProtonApiError, ProtonError, Result};
|
||||||
|
pub use session::ProtonApiSession;
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
//! Authenticated API session.
|
||||||
|
//!
|
||||||
|
//! Mirrors `Proton.Sdk.ProtonApiSession`. [`ProtonApiSession::resume`] rebuilds
|
||||||
|
//! a session from previously obtained tokens — the path used by all read
|
||||||
|
//! workflows. [`ProtonApiSession::begin`] (SRP password login) is intentionally
|
||||||
|
//! deferred, matching the official SDK's stance that login flows live outside
|
||||||
|
//! the core Drive scope.
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use base64::Engine;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::config::ProtonClientConfiguration;
|
||||||
|
use crate::crypto;
|
||||||
|
use crate::error::{ProtonError, Result};
|
||||||
|
use crate::http::{self, ApiHttpClient, Tokens};
|
||||||
|
use crate::ids::{SessionId, UserId};
|
||||||
|
|
||||||
|
/// Whether the account uses a single password or a separate data password.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PasswordMode {
|
||||||
|
Single,
|
||||||
|
Dual,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PasswordMode {
|
||||||
|
/// Map the wire value (`1` = single, `2` = dual); anything else defaults to dual.
|
||||||
|
fn from_wire(value: i32) -> Self {
|
||||||
|
match value {
|
||||||
|
1 => PasswordMode::Single,
|
||||||
|
_ => PasswordMode::Dual,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters required to resume a session from persisted credentials.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ResumeParameters {
|
||||||
|
pub session_id: SessionId,
|
||||||
|
pub username: String,
|
||||||
|
pub user_id: UserId,
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
pub is_waiting_for_second_factor_code: bool,
|
||||||
|
pub password_mode: PasswordMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An authenticated Proton API session.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ProtonApiSession {
|
||||||
|
http: ApiHttpClient,
|
||||||
|
session_id: SessionId,
|
||||||
|
username: String,
|
||||||
|
user_id: UserId,
|
||||||
|
scopes: Vec<String>,
|
||||||
|
password_mode: PasswordMode,
|
||||||
|
is_waiting_for_second_factor: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtonApiSession {
|
||||||
|
/// Resume a session from previously obtained tokens.
|
||||||
|
pub fn resume(config: ProtonClientConfiguration, params: ResumeParameters) -> Result<Self> {
|
||||||
|
let tokens = Tokens {
|
||||||
|
access_token: params.access_token,
|
||||||
|
refresh_token: params.refresh_token,
|
||||||
|
};
|
||||||
|
let http = ApiHttpClient::new(config, params.session_id.clone(), tokens)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
http,
|
||||||
|
session_id: params.session_id,
|
||||||
|
username: params.username,
|
||||||
|
user_id: params.user_id,
|
||||||
|
scopes: params.scopes,
|
||||||
|
password_mode: params.password_mode,
|
||||||
|
is_waiting_for_second_factor: params.is_waiting_for_second_factor_code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SRP password login.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `ProtonApiSession.BeginAsync`: initiate an SRP session
|
||||||
|
/// (`auth/v4/info`), run the client handshake, then authenticate
|
||||||
|
/// (`auth/v4`). The returned session is ready for read/write workflows once
|
||||||
|
/// the data password is applied (see CLAUDE.md key chain); if the account
|
||||||
|
/// requires a second factor, [`is_waiting_for_second_factor`] is set and the
|
||||||
|
/// caller must complete 2FA before authorized scopes are granted.
|
||||||
|
///
|
||||||
|
/// Both API calls are unauthenticated (no session id / bearer), matching the
|
||||||
|
/// reference SDK.
|
||||||
|
pub async fn begin(
|
||||||
|
config: ProtonClientConfiguration,
|
||||||
|
username: &str,
|
||||||
|
password: &[u8],
|
||||||
|
) -> Result<Self> {
|
||||||
|
let init: SessionInitiationResponse = http::post_unauthenticated(
|
||||||
|
&config,
|
||||||
|
"auth/v4/info",
|
||||||
|
&SessionInitiationRequest { username },
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let salt = BASE64
|
||||||
|
.decode(init.salt.trim())
|
||||||
|
.map_err(|e| ProtonError::invalid_operation(format!("decode SRP salt: {e}")))?;
|
||||||
|
let server_ephemeral = BASE64
|
||||||
|
.decode(init.server_ephemeral.trim())
|
||||||
|
.map_err(|e| ProtonError::invalid_operation(format!("decode server ephemeral: {e}")))?;
|
||||||
|
|
||||||
|
let proofs = crypto::generate_proofs(
|
||||||
|
init.version,
|
||||||
|
password,
|
||||||
|
&salt,
|
||||||
|
&init.modulus,
|
||||||
|
&server_ephemeral,
|
||||||
|
crypto::DEFAULT_BIT_LENGTH,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let auth: AuthenticationResponse = http::post_unauthenticated(
|
||||||
|
&config,
|
||||||
|
"auth/v4",
|
||||||
|
&AuthenticationRequest {
|
||||||
|
username,
|
||||||
|
client_ephemeral: BASE64.encode(&proofs.client_ephemeral),
|
||||||
|
client_proof: BASE64.encode(&proofs.client_proof),
|
||||||
|
srp_session: init.srp_session,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Reject a forged server: the server must prove it holds the verifier.
|
||||||
|
let server_proof = BASE64
|
||||||
|
.decode(auth.server_proof.trim())
|
||||||
|
.map_err(|e| ProtonError::invalid_operation(format!("decode server proof: {e}")))?;
|
||||||
|
if server_proof != proofs.expected_server_proof {
|
||||||
|
return Err(ProtonError::invalid_operation(
|
||||||
|
"SRP server proof mismatch — server failed authentication",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens = Tokens {
|
||||||
|
access_token: auth.access_token,
|
||||||
|
refresh_token: auth.refresh_token,
|
||||||
|
};
|
||||||
|
let http = ApiHttpClient::new(config, auth.session_id.clone(), tokens)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
http,
|
||||||
|
session_id: auth.session_id,
|
||||||
|
username: username.to_owned(),
|
||||||
|
user_id: auth.user_id,
|
||||||
|
scopes: auth.scopes,
|
||||||
|
password_mode: PasswordMode::from_wire(auth.password_mode),
|
||||||
|
is_waiting_for_second_factor: auth
|
||||||
|
.second_factor
|
||||||
|
.map(|f| f.is_enabled())
|
||||||
|
.unwrap_or(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submit a second-factor code (`POST auth/v4/2fa`).
|
||||||
|
///
|
||||||
|
/// Mirrors C# `ProtonApiSession.ApplySecondFactorCodeAsync`: on success the
|
||||||
|
/// server returns the now-elevated scopes; clear the waiting flag and adopt
|
||||||
|
/// them. Must be called on a session whose [`is_waiting_for_second_factor`]
|
||||||
|
/// is set (typically right after [`begin`], before the session is shared
|
||||||
|
/// with a Drive client).
|
||||||
|
pub async fn apply_second_factor_code(&mut self, code: &str) -> Result<()> {
|
||||||
|
let response: ScopesResponse = self
|
||||||
|
.http
|
||||||
|
.post("auth/v4/2fa", &SecondFactorValidationRequest { code })
|
||||||
|
.await?;
|
||||||
|
self.is_waiting_for_second_factor = false;
|
||||||
|
self.scopes = response.scopes;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the session's authorized scopes (`GET auth/v4/scopes`).
|
||||||
|
///
|
||||||
|
/// Mirrors C# `ProtonApiSession.RefreshScopesAsync`.
|
||||||
|
pub async fn refresh_scopes(&mut self) -> Result<()> {
|
||||||
|
let response: ScopesResponse = self.http.get("auth/v4/scopes").await?;
|
||||||
|
self.scopes = response.scopes;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End the session server-side (`DELETE auth/v4`).
|
||||||
|
pub async fn end(&self) -> Result<()> {
|
||||||
|
let _: crate::api::ApiResponse = self.http.delete("auth/v4").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn http(&self) -> &ApiHttpClient {
|
||||||
|
&self.http
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_id(&self) -> &SessionId {
|
||||||
|
&self.session_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user_id(&self) -> &UserId {
|
||||||
|
&self.user_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn username(&self) -> &str {
|
||||||
|
&self.username
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scopes(&self) -> &[String] {
|
||||||
|
&self.scopes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn password_mode(&self) -> PasswordMode {
|
||||||
|
self.password_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the account still needs a second-factor code before its scopes
|
||||||
|
/// are fully authorized.
|
||||||
|
pub fn is_waiting_for_second_factor(&self) -> bool {
|
||||||
|
self.is_waiting_for_second_factor
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot current tokens for persistence (they may have rotated on refresh).
|
||||||
|
pub async fn current_tokens(&self) -> Tokens {
|
||||||
|
self.http.current_tokens().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SessionInitiationRequest<'a> {
|
||||||
|
#[serde(rename = "Username")]
|
||||||
|
username: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SessionInitiationResponse {
|
||||||
|
#[serde(rename = "Version")]
|
||||||
|
version: i32,
|
||||||
|
/// Cleartext-signed modulus (verified before use).
|
||||||
|
#[serde(rename = "Modulus")]
|
||||||
|
modulus: String,
|
||||||
|
/// Base64 server ephemeral `B`.
|
||||||
|
#[serde(rename = "ServerEphemeral")]
|
||||||
|
server_ephemeral: String,
|
||||||
|
/// Base64 login salt.
|
||||||
|
#[serde(rename = "Salt")]
|
||||||
|
salt: String,
|
||||||
|
#[serde(rename = "SRPSession")]
|
||||||
|
srp_session: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AuthenticationRequest<'a> {
|
||||||
|
#[serde(rename = "Username")]
|
||||||
|
username: &'a str,
|
||||||
|
/// Base64 client ephemeral `A`.
|
||||||
|
#[serde(rename = "ClientEphemeral")]
|
||||||
|
client_ephemeral: String,
|
||||||
|
/// Base64 client proof `M1`.
|
||||||
|
#[serde(rename = "ClientProof")]
|
||||||
|
client_proof: String,
|
||||||
|
#[serde(rename = "SRPSession")]
|
||||||
|
srp_session: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AuthenticationResponse {
|
||||||
|
#[serde(rename = "UID")]
|
||||||
|
session_id: SessionId,
|
||||||
|
#[serde(rename = "UserID")]
|
||||||
|
user_id: UserId,
|
||||||
|
/// Base64 server proof `M2`.
|
||||||
|
#[serde(rename = "ServerProof")]
|
||||||
|
server_proof: String,
|
||||||
|
#[serde(rename = "AccessToken")]
|
||||||
|
access_token: String,
|
||||||
|
#[serde(rename = "RefreshToken")]
|
||||||
|
refresh_token: String,
|
||||||
|
#[serde(rename = "Scopes", default)]
|
||||||
|
scopes: Vec<String>,
|
||||||
|
#[serde(rename = "PasswordMode")]
|
||||||
|
password_mode: i32,
|
||||||
|
#[serde(rename = "2FA")]
|
||||||
|
second_factor: Option<SecondFactorInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SecondFactorValidationRequest<'a> {
|
||||||
|
#[serde(rename = "TwoFactorCode")]
|
||||||
|
code: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ScopesResponse {
|
||||||
|
#[serde(rename = "Scopes", default)]
|
||||||
|
scopes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SecondFactorInfo {
|
||||||
|
#[serde(rename = "Enabled", default)]
|
||||||
|
enabled: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecondFactorInfo {
|
||||||
|
fn is_enabled(&self) -> bool {
|
||||||
|
self.enabled != 0
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user