implemented missing drive endpoints

This commit is contained in:
nvrl committed 2026-06-26 16:01:35 +02:00
1 parent 4c3058c618
commit 8eb7b8f56f
6 files changed
+243 -9

No files matched your search

+8 -1
View File
@@ -20,7 +20,7 @@ use tokio::sync::Mutex;
use crate::crypto::{self, PrivateKey, PublicKey};
use crate::error::{ProtonError, Result};
use crate::http::ApiHttpClient;
use crate::ids::AddressId;
use crate::ids::{AddressId, AddressKeyId};
use crate::session::ProtonApiSession;
use dtos::{
@@ -37,6 +37,8 @@ pub struct Address {
pub status: i32,
/// Index, within the decrypted key list, of the primary key.
pub primary_key_index: usize,
/// Id of the address's primary key (the `AddressKeyID` for write requests).
pub primary_key_id: AddressKeyId,
}
/// Resolves account keys needed to decrypt Drive metadata.
@@ -263,6 +265,7 @@ impl AccountClient {
let mut keys = Vec::new();
let mut primary_key_index = None;
let mut primary_key_id = None;
for key_dto in &dto.keys {
if !key_dto.is_active() {
@@ -295,6 +298,7 @@ impl AccountClient {
Ok(key) => {
if key_dto.is_primary() && primary_key_index.is_none() {
primary_key_index = Some(keys.len());
primary_key_id = Some(key_dto.id.clone());
}
keys.push(key);
}
@@ -306,6 +310,8 @@ impl AccountClient {
let primary_key_index = primary_key_index
.ok_or_else(|| ProtonError::invalid_operation(format!("address {} has no primary key", dto.id)))?;
let primary_key_id = primary_key_id
.ok_or_else(|| ProtonError::invalid_operation(format!("address {} has no primary key", dto.id)))?;
let address = Address {
id: dto.id.clone(),
@@ -313,6 +319,7 @@ impl AccountClient {
order: dto.order,
status: dto.status,
primary_key_index,
primary_key_id,
};
Ok((address, keys))
+4 -1
View File
@@ -16,7 +16,10 @@ mod verify;
pub use content::ContentKey;
pub use derive::derive_key_passphrase;
pub use encrypt::{generate_node_hash_key, generate_node_key, GeneratedNodeKey};
pub use encrypt::{
build_volume_creation_material, generate_node_hash_key, generate_node_key, GeneratedNodeKey,
VolumeCreationMaterial,
};
pub use errors::CryptoError;
pub use keys::{decrypt_armored_with_keys, PrivateKey};
pub use srp::{generate_proofs, SrpProofs, DEFAULT_BIT_LENGTH};
+115
View File
@@ -146,6 +146,76 @@ pub fn generate_node_hash_key(node_key: &PrivateKey) -> Result<String, CryptoErr
encrypt_and_sign(node_key.key(), Some(node_key), &bytes, false, false)
}
/// The encrypted/locked material for a new volume's root share + root folder.
///
/// Mirrors the output of C# `VolumeOperations.GetCreationRequest`: a fresh root
/// share key and root folder key (each Ed25519 + X25519, locked with a random
/// passphrase), plus the passphrases/name/hash-key encrypted into the request
/// shape the `volumes` endpoint expects.
pub struct VolumeCreationMaterial {
/// Locked root share key (armored secret key) — `ShareKey`.
pub share_key_armored: String,
/// Share passphrase encrypted (encrypt-only) to the address key — `SharePassphrase`.
pub share_passphrase: String,
/// Detached signature over the share passphrase by the address key.
pub share_passphrase_signature: String,
/// Root folder name encrypted + inline-signed to the share key — `FolderName`.
pub folder_name: String,
/// Locked root folder key (armored secret key) — `FolderKey`.
pub folder_key_armored: String,
/// Folder passphrase encrypted (encrypt-only) to the share key — `FolderPassphrase`.
pub folder_passphrase: String,
/// Detached signature over the folder passphrase by the address key.
pub folder_passphrase_signature: String,
/// Folder hash key encrypted + inline-signed to the folder key — `FolderHashKey`.
pub folder_hash_key: String,
}
/// Build the crypto material for creating a volume's root share and folder.
///
/// Mirrors C# `VolumeOperations.GetCreationRequest`:
/// - root share key + folder key are generated and locked with random passphrases;
/// - the **share** passphrase is encrypted (encrypt-only) to `address_key` with a
/// detached signature by the address key (it is the address key that unwraps it);
/// - the **folder** passphrase is encrypted (encrypt-only) to the share key with a
/// detached signature by the address key;
/// - the folder **name** (`"root"`) is encrypted + inline-signed to the share key;
/// - the folder **hash key** (32 random bytes) is encrypted + inline-signed to the
/// folder key. All inline/detached signatures are made by `address_key`.
pub fn build_volume_creation_material(
address_key: &PrivateKey,
root_folder_name: &str,
) -> Result<VolumeCreationMaterial, CryptoError> {
let share = generate_node_key()?;
let folder = generate_node_key()?;
let share_passphrase = address_key.encrypt(&share.passphrase)?;
let share_passphrase_signature = address_key.sign_detached(&share.passphrase)?;
let folder_name = share
.key
.encrypt_and_sign(address_key, root_folder_name.as_bytes(), true, false)?;
let folder_passphrase = share.key.encrypt(&folder.passphrase)?;
let folder_passphrase_signature = address_key.sign_detached(&folder.passphrase)?;
let mut hash_key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut hash_key);
let folder_hash_key = folder
.key
.encrypt_and_sign(address_key, &hash_key, false, false)?;
Ok(VolumeCreationMaterial {
share_key_armored: share.locked_armored,
share_passphrase,
share_passphrase_signature,
folder_name,
folder_key_armored: folder.locked_armored,
folder_passphrase,
folder_passphrase_signature,
folder_hash_key,
})
}
/// 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`).
@@ -260,6 +330,51 @@ mod tests {
.expect("verify detached signature");
}
#[test]
fn volume_creation_material_round_trips() {
// Stand-in for the account's primary address key.
let address_key = generate_node_key().expect("generate address key");
let material =
build_volume_creation_material(&address_key.key, "root").expect("build material");
// The share passphrase is encrypted to the address key and unlocks the
// root share key; its detached signature verifies against the address key.
let share_pp = address_key
.key
.decrypt_armored_message(&material.share_passphrase)
.expect("decrypt share passphrase");
address_key
.key
.verify_detached_signature(&material.share_passphrase_signature, &share_pp)
.expect("verify share passphrase signature");
let share_key = PrivateKey::from_armored(&material.share_key_armored, &share_pp)
.expect("unlock share key");
// The folder passphrase is encrypted to the share key and unlocks the
// root folder key; its detached signature verifies against the address key.
let folder_pp = share_key
.decrypt_armored_message(&material.folder_passphrase)
.expect("decrypt folder passphrase");
address_key
.key
.verify_detached_signature(&material.folder_passphrase_signature, &folder_pp)
.expect("verify folder passphrase signature");
let folder_key = PrivateKey::from_armored(&material.folder_key_armored, &folder_pp)
.expect("unlock folder key");
// The folder name is encrypted to the share key; the hash key to the folder key.
let name = share_key
.decrypt_armored_message(&material.folder_name)
.expect("decrypt folder name");
assert_eq!(name, b"root");
let hash_key = folder_key
.decrypt_armored_message(&material.folder_hash_key)
.expect("decrypt folder hash key");
assert_eq!(hash_key.len(), 32);
}
#[test]
fn node_hash_key_round_trips_under_node_key() {
// The folder hash key is 32 random bytes encrypted to the folder's own
+25 -1
View File
@@ -39,6 +39,12 @@ pub struct Tokens {
#[derive(Clone)]
pub struct ApiHttpClient {
inner: Arc<Inner>,
/// Extra path segment prepended to every request path (after `base_url`,
/// before the per-call `path`). Mirrors C# `session.GetHttpClient(baseRoute)`
/// — the Drive client targets `…/drive/` while account/auth calls stay at the
/// root. Empty by default. Lives on the outer struct (not `Inner`) so clones
/// can carry different prefixes while sharing one token/telemetry store.
route_prefix: Arc<str>,
}
struct Inner {
@@ -77,9 +83,22 @@ impl ApiHttpClient {
tokens: Mutex::new(tokens),
telemetry: std::sync::Mutex::new(NoopTelemetry::shared()),
}),
route_prefix: Arc::from(""),
})
}
/// Derive a clone that prepends `route` to every request path, sharing this
/// client's token store, telemetry sink and connection pool. Mirrors C#
/// `session.GetHttpClient(baseRoute)`: the Drive client passes `"drive/"` so
/// its routes resolve under `…/drive/` while auth/account calls (and token
/// refresh) stay at the root. `route` should end in `/`.
pub fn with_base_route(&self, route: impl Into<Arc<str>>) -> Self {
Self {
inner: Arc::clone(&self.inner),
route_prefix: route.into(),
}
}
/// 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()
@@ -274,7 +293,12 @@ impl ApiHttpClient {
body: Option<&B>,
access_token: &str,
) -> Result<reqwest::Response> {
let url = format!("{}{}", self.inner.base_url, path.trim_start_matches('/'));
let url = format!(
"{}{}{}",
self.inner.base_url,
self.route_prefix,
path.trim_start_matches('/')
);
send_retrying(&self.inner.config.retry_policy, || {
let mut request = self
.inner