mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
public link consume, revision history, path helpers
Public links (public_link.rs, ported from the TS SDK — the C# SDK has no
equivalent): ProtonDrivePublicLinkClient lets a visitor with no Proton
account open someone else's share link. `info` reads pre-auth metadata,
`open` runs the SRP handshake against drive/urls/{token}/info + /auth and
unlocks the share key (bcrypt SharePasswordSalt -> key password -> the
SKESK'd SharePassphrase -> ShareKey). The read surface then reuses the
ordinary link/children/revision endpoints. Signature verification is
deliberately absent: an anonymous visitor cannot read core/v4/keys/all,
matching the TS SDK. Supporting primitives: http::get_unauthenticated and
crypto::decrypt_armored_with_password.
Revision history: enumerate_revisions / get_revision / restore_revision /
delete_revision, plus open_revision_at / download_revision{,_to} to read a
specific revision. Drafts are filtered on the wire value, since
RevisionState::from_raw maps 0 to Active. Three server behaviors found
live and worked around: ?NoBlockUrls=true still emits Blocks with null
BareURL, so get_revision deserializes into a DTO without that field; the
restore POST needs an empty object body, not null; and restore is async
(HTTP 202, non-1000 envelope) so the new active revision is not live on
return.
Path and hierarchy helpers: get_node_hierarchy, get_node_path,
get_node_by_path, create_folder_path. Names are encrypted and there is no
name-query endpoint, so lookup walks and decrypts one directory level per
segment.
Fix a silent-corruption path in block-size resolution. RevisionReader
accumulates block sizes to derive where each block starts in the
plaintext, so a vector that overstates any block but the last shifts every
block after it and the reader splices at the wrong offsets — full-length
reads of the wrong bytes, no error raised. resolve_block_sizes previously
guessed all-full blocks when both Common.BlockSizes and Common.Size were
unusable. It now errors for multi-block files and only assumes a full
block when there is exactly one (which cannot be misaligned). Regression
test in revision.rs::padded_block_sizes_shift_later_blocks.
Recover from stale upload drafts. An upload interrupted after the draft
node was created but before it was sealed leaves an unsealed draft, and
every later attempt at that name returns 422 AlreadyExists forever. Draft
creation now deletes the conflicting node and retries when the conflict is
an unsealed draft; by default only our own client's, with
upload_file_replacing_draft_from opting into any client's to recover
across a daemon restart (which rotates our client uid). A committed file
of that name is never touched.
Live tests for the public-link round trip, custom-password links, revision
history, and the path helpers.
This commit is contained in:
1 parent
f75ecb04b6
commit
24a99e6b9a
12 files changed
+2366
-73
No files matched your search
@@ -24,6 +24,7 @@ pub use encrypt::{
|
||||
};
|
||||
pub use errors::CryptoError;
|
||||
pub use keys::{PrivateKey, decrypt_armored_with_keys};
|
||||
pub use messages::decrypt_armored_with_password;
|
||||
pub use srp::{DEFAULT_BIT_LENGTH, SrpProofs, SrpVerifier, generate_proofs, generate_verifier};
|
||||
pub use verify::{PublicKey, VerificationKeyRing, VerificationStatus, verify_detached};
|
||||
|
||||
|
||||
@@ -41,6 +41,28 @@ pub fn decrypt_binary(
|
||||
read_plaintext(decrypted)
|
||||
}
|
||||
|
||||
/// Decrypt an armored, **symmetrically** encrypted message (SKESK + SEIPD).
|
||||
///
|
||||
/// The counterpart of [`ContentKey::encrypt_with_password`](super::ContentKey::encrypt_with_password):
|
||||
/// no PGP key is involved, only a passphrase. Proton uses this for a public
|
||||
/// share link's `SharePassphrase`, where the passphrase is the bcrypt-derived
|
||||
/// key password (see [`derive_key_passphrase`](super::derive_key_passphrase))
|
||||
/// rather than the raw link password. The S2K parameters travel in the packet,
|
||||
/// so they need not be agreed on out of band.
|
||||
pub fn decrypt_armored_with_password(
|
||||
armored: &str,
|
||||
password: &[u8],
|
||||
) -> Result<Vec<u8>, CryptoError> {
|
||||
let (message, _headers) =
|
||||
Message::from_string(armored).map_err(|e| CryptoError::Parse(format!("message: {e}")))?;
|
||||
|
||||
let decrypted = message
|
||||
.decrypt_with_password(&Password::from(password))
|
||||
.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
|
||||
@@ -71,3 +93,51 @@ pub(crate) fn read_plaintext(mut message: Message<'_>) -> Result<Vec<u8>, Crypto
|
||||
.as_data_vec()
|
||||
.map_err(|e| CryptoError::Decrypt(format!("read literal: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::decrypt_armored_with_password;
|
||||
use crate::crypto::derive_key_passphrase;
|
||||
use pgp::composed::MessageBuilder;
|
||||
use pgp::crypto::sym::SymmetricKeyAlgorithm;
|
||||
use pgp::types::{Password, StringToKey};
|
||||
|
||||
/// Build the shape Proton's `SharePassphrase` has: an armored, symmetrically
|
||||
/// encrypted message whose passphrase is the bcrypt-derived key password.
|
||||
fn encrypt_armored_with_password(plaintext: &[u8], password: &[u8]) -> String {
|
||||
let mut rng = rand_08::thread_rng();
|
||||
let s2k = StringToKey::new_default(&mut rng);
|
||||
let mut builder = MessageBuilder::from_bytes(String::new(), plaintext.to_vec())
|
||||
.seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256);
|
||||
builder
|
||||
.encrypt_with_password(s2k, &Password::from(password))
|
||||
.expect("encrypt with password");
|
||||
builder
|
||||
.to_armored_string(&mut rng, Default::default())
|
||||
.expect("armor")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_encrypted_message_round_trips() {
|
||||
let password = b"link-password-and-custom".as_slice();
|
||||
let salt = [7u8; 16];
|
||||
let key_password = derive_key_passphrase(password, &salt).expect("derive");
|
||||
|
||||
let armored = encrypt_armored_with_password(b"the share passphrase", &key_password);
|
||||
let plaintext = decrypt_armored_with_password(&armored, &key_password).expect("decrypt");
|
||||
|
||||
assert_eq!(plaintext, b"the share passphrase");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wrong_password_does_not_decrypt() {
|
||||
// A wrong *link* password derives a different key password, which is how
|
||||
// a bad custom password surfaces if it ever gets past the SRP handshake.
|
||||
let salt = [7u8; 16];
|
||||
let right = derive_key_passphrase(b"correct", &salt).expect("derive");
|
||||
let wrong = derive_key_passphrase(b"incorrect", &salt).expect("derive");
|
||||
|
||||
let armored = encrypt_armored_with_password(b"secret", &right);
|
||||
assert!(decrypt_armored_with_password(&armored, &wrong).is_err());
|
||||
}
|
||||
}
|
||||
@@ -444,6 +444,38 @@ struct SessionRefreshResponse {
|
||||
/// 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`.
|
||||
/// Issue a GET with no session, for endpoints reachable before authentication.
|
||||
///
|
||||
/// The public-link flow needs this: `drive/urls/{token}/info` opens the SRP
|
||||
/// handshake and is by definition callable by a visitor who has no Proton
|
||||
/// session at all.
|
||||
pub async fn get_unauthenticated<T: DeserializeOwned>(
|
||||
config: &ProtonClientConfiguration,
|
||||
path: &str,
|
||||
) -> Result<T> {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(config.request_timeout)
|
||||
.gzip(true)
|
||||
.build()?;
|
||||
|
||||
let base_url = ensure_trailing_slash(&config.base_url);
|
||||
let url = format!("{}{}", base_url, path.trim_start_matches('/'));
|
||||
|
||||
let response = send_retrying(&config.retry_policy, || {
|
||||
let mut request = http
|
||||
.get(&url)
|
||||
.header(APP_VERSION_HEADER, &config.app_version)
|
||||
.header(reqwest::header::ACCEPT, API_CONTENT_TYPE);
|
||||
if !config.user_agent.is_empty() {
|
||||
request = request.header(reqwest::header::USER_AGENT, &config.user_agent);
|
||||
}
|
||||
request
|
||||
})
|
||||
.await?;
|
||||
|
||||
parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post_unauthenticated<B: Serialize, T: DeserializeOwned>(
|
||||
config: &ProtonClientConfiguration,
|
||||
path: &str,
|
||||
|
||||
Reference in new issue
Block a user