This commit is contained in:
nvrl committed 2026-06-25 09:42:34 +02:00
commit 808a96cdd1
28 files changed
+8410

No files matched your search

+24
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -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"
+88
View File
@@ -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.
+27
View File
@@ -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
+43
View File
@@ -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 })
}
+535
View File
@@ -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,
}
+42
View File
@@ -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;
+83
View File
@@ -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,
}
}
}
+30
View File
@@ -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
+267
View File
@@ -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}")))
}
+104
View File
@@ -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>,
}
+113
View File
@@ -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))
}
}
+57
View File
@@ -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
}
}
+24
View File
@@ -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>;
+618
View File
@@ -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());
}
}
+76
View File
@@ -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());
}
}
+276
View File
@@ -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);
}
}
+25
View File
@@ -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),
}
+107
View File
@@ -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)
}
+73
View File
@@ -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}")))
}
+437
View File
@@ -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);
}
}
+57
View File
@@ -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)
}
}
+388
View File
@@ -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}/")
}
}
+103
View File
@@ -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)
}
}
+23
View File
@@ -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;
+310
View File
@@ -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
}
}