mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
fixed invalid manifest signature in pgp
This commit is contained in:
1 parent
35d9d8f3dd
commit
ec590fd52b
6 files changed
+356
-3
No files matched your search
Generated
+3
@@ -318,7 +318,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
@@ -1709,6 +1711,7 @@ dependencies = [
|
||||
"base64",
|
||||
"bcrypt",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"getrandom 0.2.17",
|
||||
"hex",
|
||||
|
||||
@@ -28,6 +28,8 @@ aes-gcm = "0.10"
|
||||
hkdf = "0.12"
|
||||
bcrypt = "0.17"
|
||||
pgp = "0.16"
|
||||
# Match the chrono line rPGP pulls in; used to stamp detached-signature subpackets.
|
||||
chrono = "0.4"
|
||||
tracing = "0.1"
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Shared harness for live integration tests against a real Proton account.
|
||||
//!
|
||||
//! Tests are `#[ignore]` by default and run only with `cargo test -- --ignored`.
|
||||
//! When credentials are absent they **skip cleanly** (return `None`) rather than
|
||||
//! fail, so `--ignored` runs stay green in CI without secrets.
|
||||
//!
|
||||
//! Required env / `.env` (repo root):
|
||||
//! `username=`, `password=` (in `.env`)
|
||||
//! `PROTON_TOTP_SECRET=` (base32 2FA secret; env var)
|
||||
//!
|
||||
//! Read-only `live_login` example duplicates the TOTP/dotenv logic; this is the
|
||||
//! authenticated-client factory for the mutating integration suite.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use proton_drive_sdk::ProtonDriveClient;
|
||||
use proton_sdk::config::ProtonClientConfiguration;
|
||||
use proton_sdk::session::ProtonApiSession;
|
||||
use sha1::Sha1;
|
||||
|
||||
// Must follow Proton's required shape (sdk/README.md "Operational requirements").
|
||||
// A malformed channel fails honest-identification → 422 "unusual activity".
|
||||
const APP_VERSION: &str = "external-drive-rust@0.1.0-alpha";
|
||||
const USER_AGENT: &str = "external-drive-rust/0.1.0";
|
||||
|
||||
/// An authenticated Drive client plus the data password it was built with.
|
||||
pub struct LiveClient {
|
||||
pub client: ProtonDriveClient,
|
||||
// Kept alive: the session backs the client's token store.
|
||||
pub _session: ProtonApiSession,
|
||||
}
|
||||
|
||||
/// Build an authenticated `ProtonDriveClient`, or `None` when credentials are
|
||||
/// not configured (skip, don't fail).
|
||||
///
|
||||
/// Call from a test like:
|
||||
/// ```ignore
|
||||
/// let Some(live) = common::live_client().await else { return };
|
||||
/// ```
|
||||
pub async fn live_client() -> Option<LiveClient> {
|
||||
let (username, password) = match read_dotenv() {
|
||||
Ok(creds) => creds,
|
||||
Err(e) => {
|
||||
eprintln!("[skip] no .env credentials: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let totp_secret = match read_totp_secret() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("[skip] PROTON_TOTP_SECRET not set (env or .env)");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let config = ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT);
|
||||
|
||||
let mut session = ProtonApiSession::begin(config, &username, password.as_bytes())
|
||||
.await
|
||||
.expect("SRP login failed");
|
||||
|
||||
if session.is_waiting_for_second_factor() {
|
||||
let code = totp_now(&totp_secret).expect("TOTP compute failed");
|
||||
session
|
||||
.apply_second_factor_code(&code)
|
||||
.await
|
||||
.expect("2FA submission failed");
|
||||
}
|
||||
|
||||
let client = ProtonDriveClient::new(&session, password.into_bytes());
|
||||
Some(LiveClient {
|
||||
client,
|
||||
_session: session,
|
||||
})
|
||||
}
|
||||
|
||||
/// Minimal `.env` reader: `key=value` lines, trims whitespace, ignores `#`.
|
||||
fn read_dotenv() -> Result<(String, String), Box<dyn std::error::Error>> {
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../.env");
|
||||
let text = std::fs::read_to_string(path).map_err(|e| format!("read {path}: {e}"))?;
|
||||
let mut username = None;
|
||||
let mut password = None;
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Some((k, v)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
match k.trim() {
|
||||
"username" => username = Some(v.trim().to_owned()),
|
||||
"password" => password = Some(v.trim().to_owned()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
username.ok_or("username missing in .env")?,
|
||||
password.ok_or("password missing in .env")?,
|
||||
))
|
||||
}
|
||||
|
||||
/// TOTP secret from `PROTON_TOTP_SECRET` env var, falling back to a
|
||||
/// `PROTON_TOTP_SECRET=` line in `.env`.
|
||||
fn read_totp_secret() -> Option<String> {
|
||||
if let Ok(s) = std::env::var("PROTON_TOTP_SECRET") {
|
||||
if !s.trim().is_empty() {
|
||||
return Some(s.trim().to_owned());
|
||||
}
|
||||
}
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../.env");
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
if k.trim() == "PROTON_TOTP_SECRET" {
|
||||
let v = v.trim();
|
||||
if !v.is_empty() {
|
||||
return Some(v.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// RFC 6238 TOTP (SHA-1, 6 digits, 30s step) for the current time.
|
||||
fn totp_now(secret_b32: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let key = base32_decode(secret_b32)?;
|
||||
let counter = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() / 30;
|
||||
|
||||
let mut mac = Hmac::<Sha1>::new_from_slice(&key)?;
|
||||
mac.update(&counter.to_be_bytes());
|
||||
let digest = mac.finalize().into_bytes();
|
||||
|
||||
let offset = (digest[19] & 0x0f) as usize;
|
||||
let bin = ((digest[offset] as u32 & 0x7f) << 24)
|
||||
| ((digest[offset + 1] as u32) << 16)
|
||||
| ((digest[offset + 2] as u32) << 8)
|
||||
| (digest[offset + 3] as u32);
|
||||
Ok(format!("{:06}", bin % 1_000_000))
|
||||
}
|
||||
|
||||
/// RFC 4648 base32 decode (uppercase, padding optional).
|
||||
fn base32_decode(s: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
||||
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let mut buf = 0u64;
|
||||
let mut bits = 0u32;
|
||||
let mut out = Vec::new();
|
||||
for c in s.trim().bytes() {
|
||||
if c == b'=' || c == b' ' {
|
||||
continue;
|
||||
}
|
||||
let up = c.to_ascii_uppercase();
|
||||
let val = ALPHA
|
||||
.iter()
|
||||
.position(|&a| a == up)
|
||||
.ok_or("invalid base32 char")? as u64;
|
||||
buf = (buf << 5) | val;
|
||||
bits += 5;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((buf >> bits) as u8);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A unique-ish suffix for test artifact names, so reruns don't collide.
|
||||
pub fn unique_suffix() -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
format!("{nanos}")
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Live integration: upload → download → byte-equality round-trips.
|
||||
//!
|
||||
//! Skipped by default. Run against the test account with:
|
||||
//! PROTON_TOTP_SECRET=... cargo test -p proton-drive-sdk --test live_upload_download -- --ignored --nocapture
|
||||
//!
|
||||
//! Each test cleans up after itself (trash + delete-from-trash) so the account
|
||||
//! stays reusable across runs.
|
||||
|
||||
mod common;
|
||||
|
||||
use proton_sdk::ids::NodeUid;
|
||||
|
||||
/// Trash then permanently delete the given nodes; best-effort, logs on failure.
|
||||
async fn cleanup(client: &proton_drive_sdk::ProtonDriveClient, uids: &[NodeUid]) {
|
||||
if let Err(e) = client.trash_nodes(uids).await {
|
||||
eprintln!("[cleanup] trash failed: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = client.delete_nodes(uids).await {
|
||||
eprintln!("[cleanup] delete failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Small single-block legacy SEIPDv1 upload, downloaded back, bytes compared.
|
||||
#[tokio::test]
|
||||
#[ignore = "live: needs test-account credentials"]
|
||||
async fn upload_download_small_roundtrip() {
|
||||
let Some(live) = common::live_client().await else {
|
||||
return;
|
||||
};
|
||||
let client = live.client;
|
||||
|
||||
let root = client
|
||||
.get_my_files_folder()
|
||||
.await
|
||||
.expect("get my-files root");
|
||||
|
||||
let name = format!("rt-small-{}.txt", common::unique_suffix());
|
||||
let payload = b"hello proton drive integration test \x00\x01\x02 bytes".to_vec();
|
||||
|
||||
let uid = client
|
||||
.upload_file(&root.uid, &name, "text/plain", &payload)
|
||||
.await
|
||||
.expect("upload_file");
|
||||
|
||||
let got = client.download_file(&uid).await.expect("download_file");
|
||||
assert_eq!(got, payload, "downloaded bytes must match uploaded");
|
||||
|
||||
cleanup(&client, &[uid]).await;
|
||||
}
|
||||
|
||||
/// Multi-block streaming upload (> 4 MiB) exercising `upload_file_from` and the
|
||||
/// paginated revision-block download path.
|
||||
#[tokio::test]
|
||||
#[ignore = "live: needs test-account credentials"]
|
||||
async fn upload_download_multiblock_roundtrip() {
|
||||
let Some(live) = common::live_client().await else {
|
||||
return;
|
||||
};
|
||||
let client = live.client;
|
||||
|
||||
let root = client
|
||||
.get_my_files_folder()
|
||||
.await
|
||||
.expect("get my-files root");
|
||||
|
||||
// 10 MiB of non-trivial, non-repeating-friendly content (3 full 4 MiB blocks).
|
||||
let size = 10 * 1024 * 1024;
|
||||
let mut payload = vec![0u8; size];
|
||||
for (i, b) in payload.iter_mut().enumerate() {
|
||||
*b = (i as u32).wrapping_mul(2_654_435_761) as u8;
|
||||
}
|
||||
|
||||
let name = format!("rt-multi-{}.bin", common::unique_suffix());
|
||||
let reader = std::io::Cursor::new(payload.clone());
|
||||
|
||||
let uid = client
|
||||
.upload_file_from(
|
||||
&root.uid,
|
||||
&name,
|
||||
"application/octet-stream",
|
||||
reader,
|
||||
size as i64,
|
||||
Vec::new(), // thumbnails
|
||||
None, // last_modification_time
|
||||
false, // aead
|
||||
)
|
||||
.await
|
||||
.expect("upload_file_from");
|
||||
|
||||
let got = client.download_file(&uid).await.expect("download_file");
|
||||
assert_eq!(got.len(), payload.len(), "size mismatch");
|
||||
assert_eq!(got, payload, "multi-block bytes must match");
|
||||
|
||||
cleanup(&client, &[uid]).await;
|
||||
}
|
||||
|
||||
/// New revision over an existing file: upload v1, replace with v2, download must
|
||||
/// yield v2.
|
||||
#[tokio::test]
|
||||
#[ignore = "live: needs test-account credentials"]
|
||||
async fn new_revision_roundtrip() {
|
||||
let Some(live) = common::live_client().await else {
|
||||
return;
|
||||
};
|
||||
let client = live.client;
|
||||
|
||||
let root = client
|
||||
.get_my_files_folder()
|
||||
.await
|
||||
.expect("get my-files root");
|
||||
|
||||
let name = format!("rt-rev-{}.txt", common::unique_suffix());
|
||||
let v1 = b"first revision".to_vec();
|
||||
let v2 = b"second revision, longer than the first".to_vec();
|
||||
|
||||
let uid = client
|
||||
.upload_file(&root.uid, &name, "text/plain", &v1)
|
||||
.await
|
||||
.expect("upload v1");
|
||||
|
||||
client
|
||||
.upload_new_revision(&uid, &v2)
|
||||
.await
|
||||
.expect("upload v2");
|
||||
|
||||
let got = client.download_file(&uid).await.expect("download");
|
||||
assert_eq!(got, v2, "active revision must be v2");
|
||||
|
||||
cleanup(&client, &[uid]).await;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ aes-gcm.workspace = true
|
||||
hkdf.workspace = true
|
||||
bcrypt.workspace = true
|
||||
pgp.workspace = true
|
||||
chrono.workspace = true
|
||||
tracing.workspace = true
|
||||
num-bigint.workspace = true
|
||||
num-traits.workspace = true
|
||||
|
||||
@@ -15,8 +15,9 @@ use pgp::composed::{
|
||||
};
|
||||
use pgp::crypto::hash::HashAlgorithm;
|
||||
use pgp::crypto::sym::SymmetricKeyAlgorithm;
|
||||
use pgp::packet::{SignatureConfig, SignatureType};
|
||||
use pgp::types::{CompressionAlgorithm, KeyVersion, Password, PublicKeyTrait};
|
||||
use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData};
|
||||
use pgp::types::{CompressionAlgorithm, KeyDetails, KeyVersion, Password, PublicKeyTrait};
|
||||
use chrono::SubsecRound;
|
||||
use rand::RngCore;
|
||||
|
||||
use super::errors::CryptoError;
|
||||
@@ -244,8 +245,27 @@ pub(crate) fn sign_detached(signer: &PrivateKey, data: &[u8]) -> Result<String,
|
||||
let key = &signer.key().primary_key;
|
||||
let pw = signer.password();
|
||||
|
||||
let config = SignatureConfig::from_key(&mut rng, key, SignatureType::Binary)
|
||||
let mut config = SignatureConfig::from_key(&mut rng, key, SignatureType::Binary)
|
||||
.map_err(|e| CryptoError::Encrypt(format!("signature config: {e}")))?;
|
||||
|
||||
// `from_key` leaves the subpacket lists empty, which produces a signature with
|
||||
// a zero creation time and a zero (missing) issuer key id. GopenPGP — what the
|
||||
// Proton server verifies with — rejects such signatures ("Invalid manifest
|
||||
// signature"). Mirror rPGP's own MessageBuilder: stamp the issuer fingerprint +
|
||||
// creation time in the hashed subpackets and the issuer key id in the unhashed.
|
||||
config.hashed_subpackets = vec![
|
||||
Subpacket::regular(SubpacketData::IssuerFingerprint(key.fingerprint()))
|
||||
.map_err(|e| CryptoError::Encrypt(format!("issuer fingerprint subpacket: {e}")))?,
|
||||
Subpacket::regular(SubpacketData::SignatureCreationTime(
|
||||
chrono::Utc::now().trunc_subsecs(0),
|
||||
))
|
||||
.map_err(|e| CryptoError::Encrypt(format!("creation-time subpacket: {e}")))?,
|
||||
];
|
||||
if key.version() <= KeyVersion::V4 {
|
||||
config.unhashed_subpackets = vec![Subpacket::regular(SubpacketData::Issuer(key.key_id()))
|
||||
.map_err(|e| CryptoError::Encrypt(format!("issuer subpacket: {e}")))?];
|
||||
}
|
||||
|
||||
let signature = config
|
||||
.sign(key, &pw, data)
|
||||
.map_err(|e| CryptoError::Encrypt(format!("detached sign: {e}")))?;
|
||||
@@ -328,6 +348,22 @@ mod tests {
|
||||
.key
|
||||
.verify_detached_signature(&sig, data)
|
||||
.expect("verify detached signature");
|
||||
|
||||
// Regression: the detached signature must carry a non-zero creation time
|
||||
// and an issuer key id. `SignatureConfig::from_key` leaves both empty,
|
||||
// which rPGP self-verify tolerates but GopenPGP (the Proton server)
|
||||
// rejects with "Invalid manifest signature". rPGP self-verify above is not
|
||||
// enough to catch that — assert the subpackets explicitly.
|
||||
use pgp::composed::Deserializable as _;
|
||||
let (parsed, _) = StandaloneSignature::from_string(&sig).expect("parse detached sig");
|
||||
assert!(
|
||||
parsed.signature.created().is_some(),
|
||||
"detached signature missing creation time"
|
||||
);
|
||||
assert!(
|
||||
!parsed.signature.issuer().is_empty(),
|
||||
"detached signature missing issuer key id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user