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