mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
feature (devices+sharing) + upgraded rand and aeds
This commit is contained in:
1 parent
78567bf00e
commit
64f977edb3
19 files changed
+2830
-93
No files matched your search
Generated
+155
-41
@@ -15,10 +15,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"crypto-common 0.1.7",
|
||||
"crypto-common 0.1.6",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"inout 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
@@ -30,17 +40,42 @@ dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
|
||||
dependencies = [
|
||||
"cipher 0.5.2",
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"aead 0.5.2",
|
||||
"aes 0.8.4",
|
||||
"cipher 0.4.4",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"ctr 0.9.2",
|
||||
"ghash 0.5.1",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028"
|
||||
dependencies = [
|
||||
"aead 0.6.1",
|
||||
"aes 0.9.1",
|
||||
"cipher 0.5.2",
|
||||
"ctr 0.10.1",
|
||||
"ghash 0.6.0",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -50,7 +85,7 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes 0.8.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -269,9 +304,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.0"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
@@ -340,6 +375,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -359,7 +405,7 @@ version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"crypto-common 0.1.6",
|
||||
"inout 0.1.4",
|
||||
]
|
||||
|
||||
@@ -369,6 +415,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"crypto-common 0.2.2",
|
||||
"inout 0.2.2",
|
||||
]
|
||||
@@ -446,6 +493,12 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -484,9 +537,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
@@ -499,7 +552,9 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"hybrid-array",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -511,6 +566,15 @@ dependencies = [
|
||||
"cipher 0.4.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
|
||||
dependencies = [
|
||||
"cipher 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
@@ -690,7 +754,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.7",
|
||||
"crypto-common 0.1.6",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -745,10 +809,10 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aead 0.5.2",
|
||||
"cipher 0.4.4",
|
||||
"cmac",
|
||||
"ctr",
|
||||
"ctr 0.9.2",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -966,9 +1030,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
version = "0.14.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
@@ -1011,6 +1075,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1020,7 +1085,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
"polyval 0.6.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
|
||||
dependencies = [
|
||||
"polyval 0.7.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1505,9 +1579,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
@@ -1557,9 +1631,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
@@ -1639,9 +1713,9 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aead 0.5.2",
|
||||
"cipher 0.4.4",
|
||||
"ctr",
|
||||
"ctr 0.9.2",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -1733,9 +1807,9 @@ version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"aead 0.5.2",
|
||||
"aes 0.8.4",
|
||||
"aes-gcm 0.10.3",
|
||||
"aes-kw",
|
||||
"argon2",
|
||||
"base64",
|
||||
@@ -1833,7 +1907,18 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
"universal-hash 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b20f20e954175de5f463f67781b35583397d916b1d148738923711b2ad16bee8"
|
||||
dependencies = [
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.0",
|
||||
"universal-hash 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1874,12 +1959,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures",
|
||||
"getrandom 0.4.3",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"pgp",
|
||||
@@ -1897,22 +1983,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aes-gcm 0.11.0",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"bcrypt",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"getrandom 0.2.17",
|
||||
"getrandom 0.4.3",
|
||||
"hex",
|
||||
"hkdf 0.13.0",
|
||||
"hmac 0.13.0",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"pgp",
|
||||
"rand 0.10.2",
|
||||
"rand 0.8.6",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -1954,7 +2041,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"rand 0.9.5",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -2020,14 +2107,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
@@ -2066,6 +2164,12 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "replace_with"
|
||||
version = "0.1.8"
|
||||
@@ -2259,9 +2363,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
@@ -2841,10 +2945,20 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"crypto-common 0.1.6",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -3363,18 +3477,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.52"
|
||||
version = "0.8.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.52"
|
||||
version = "0.8.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
||||
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+8
-5
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/proton-sdk", "crates/proton-drive-rs"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/narrrl/proton-sdk-rs"
|
||||
@@ -28,16 +28,19 @@ hex = "0.4"
|
||||
hmac = "0.13"
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
aes-gcm = "0.10"
|
||||
aes-gcm = "0.11"
|
||||
hkdf = "0.13"
|
||||
bcrypt = "0.19"
|
||||
pgp = "0.20"
|
||||
# Match the chrono line rPGP pulls in; used to stamp detached-signature subpackets.
|
||||
chrono = "0.4"
|
||||
tracing = "0.1"
|
||||
num-bigint = "0.4"
|
||||
num-bigint = "0.5"
|
||||
num-traits = "0.2"
|
||||
getrandom = "0.2"
|
||||
getrandom = "0.4"
|
||||
# General-purpose RNG (HTTP retry jitter, etc.).
|
||||
rand = "0.10"
|
||||
# rPGP (pgp 0.20) still depends on rand 0.8 and passes its CryptoRng/Rng traits
|
||||
# across its public API, so the RNG we hand to keygen/encryption must be rand 0.8.
|
||||
rand = "0.8"
|
||||
# Kept under an alias alongside rand 0.10; crypto/ uses `rand_08`, the rest `rand`.
|
||||
rand_08 = { package = "rand", version = "0.8" }
|
||||
@@ -14,7 +14,7 @@ readme = "../../README.md"
|
||||
documentation = "https://docs.rs/proton-drive-rs"
|
||||
|
||||
[dependencies]
|
||||
proton-sdk = { path = "../proton-sdk", version = "0.1.5" }
|
||||
proton-sdk = { path = "../proton-sdk", version = "0.1.6" }
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
@@ -25,6 +25,7 @@ async-trait.workspace = true
|
||||
futures.workspace = true
|
||||
bytes.workspace = true
|
||||
base64.workspace = true
|
||||
getrandom.workspace = true
|
||||
hex.workspace = true
|
||||
hmac.workspace = true
|
||||
sha1.workspace = true
|
||||
|
||||
@@ -13,8 +13,10 @@ use proton_sdk::account::{AccountClient, KeySalt};
|
||||
use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository};
|
||||
use proton_sdk::crypto::PrivateKey;
|
||||
use proton_sdk::crypto::{
|
||||
ContentKey, VerificationKeyRing, VerificationStatus, build_volume_creation_material,
|
||||
generate_node_hash_key, generate_node_key, generate_node_key_aead, verify_detached,
|
||||
ContentKey, DEFAULT_BIT_LENGTH, VerificationKeyRing, VerificationStatus, accept_invitation,
|
||||
build_standard_share_material, build_volume_creation_material, decrypt_armored_with_keys,
|
||||
derive_key_passphrase, encrypt_external_invitation, encrypt_invitation, generate_node_hash_key,
|
||||
generate_node_key, generate_node_key_aead, generate_verifier, verify_detached,
|
||||
};
|
||||
use proton_sdk::error::{ProtonError, Result};
|
||||
use proton_sdk::http::ApiHttpClient;
|
||||
@@ -35,25 +37,35 @@ use crate::crypto::{
|
||||
};
|
||||
use crate::devices::{Device, DeviceMetadata, DeviceType};
|
||||
use crate::dtos::{
|
||||
AggregateLinksResponse, BlockCreationRequest, BlockDto, BlockUploadPreparationRequest,
|
||||
BlockUploadPreparationResponse, BlockVerificationInputResponse, BlockVerifier,
|
||||
CommonExtendedAttributes, DeviceCreationDeviceDto, DeviceCreationLinkDto,
|
||||
DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse,
|
||||
DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes, FileContentDigests,
|
||||
FileCreationRequest, FileCreationResponse, FolderChildrenResponse, FolderCreationRequest,
|
||||
FolderCreationResponse, LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest,
|
||||
LinkDetailsResponse, LinkDto, LinkType, MoveLinkRequest, MoveMultipleLinksItem,
|
||||
MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse,
|
||||
AcceptInvitationRequest, AggregateLinksResponse, BlockCreationRequest, BlockDto,
|
||||
BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockVerificationInputResponse,
|
||||
BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, CommonExtendedAttributes,
|
||||
CreateBookmarkRequest, CreatePublicLinkRequest, CreatePublicLinkResponse, CreateShareRequest,
|
||||
CreateShareResponse, DeviceCreationDeviceDto, DeviceCreationLinkDto, DeviceCreationRequest,
|
||||
DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse, DeviceUpdateRequest,
|
||||
DeviceUpdateShareDto, ExtendedAttributes, ExternalInvitationDto, ExternalInvitationResponseDto,
|
||||
ExternalInvitationsResponse, FileContentDigests, FileCreationRequest, FileCreationResponse,
|
||||
FolderChildrenResponse, FolderCreationRequest, FolderCreationResponse,
|
||||
InvitationDetailsResponse, InvitationsListResponse, InviteEmailDetailsDto,
|
||||
InviteExternalUserRequest, InviteExternalUserResponse, InviteProtonUserInvitationDto,
|
||||
InviteProtonUserRequest, InviteProtonUserResponse, LatestVolumeEventResponse, LinkDetailsDto,
|
||||
LinkDetailsRequest, LinkDetailsResponse, LinkDto, LinkType, ModulusResponse, MoveLinkRequest,
|
||||
MoveMultipleLinksItem, MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse,
|
||||
NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotosAttributesDto,
|
||||
RenameLinkRequest, RevisionCreationRequest, RevisionCreationResponse, RevisionDto,
|
||||
RevisionResponse, RevisionUpdateRequest, ShareResponse, ShareTargetType, SharedWithMeResponse,
|
||||
RevisionResponse, RevisionUpdateRequest, ShareInvitationDto, ShareInvitationsResponse,
|
||||
ShareMembersResponse, ShareResponse, ShareTargetType, ShareUrlsResponse, SharedWithMeResponse,
|
||||
ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto,
|
||||
TimelinePhotoListResponse, VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse,
|
||||
VolumeTrashResponse,
|
||||
TimelinePhotoListResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto,
|
||||
VolumeEventListResponse, VolumeTrashResponse,
|
||||
};
|
||||
use crate::events::{DriveEvent, DriveEventScopeId};
|
||||
use crate::node::{FileThumbnail, Node, NodeKind, RevisionState, Thumbnail, ThumbnailType};
|
||||
use crate::photos::{PhotoUploadMetadata, PhotosTimelineItem};
|
||||
use crate::sharing::{
|
||||
Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole,
|
||||
PublicLink, ShareInvitation, ShareMember,
|
||||
};
|
||||
|
||||
/// Content blocks are 4 MiB of plaintext each (C# `RevisionWriter.DefaultBlockSize`).
|
||||
const DEFAULT_BLOCK_SIZE: usize = 1 << 22;
|
||||
@@ -209,6 +221,13 @@ impl ProtonDriveClient {
|
||||
&self.account
|
||||
}
|
||||
|
||||
/// Total account storage usage (`MaxSpace`/`UsedSpace`), account-wide across
|
||||
/// all Proton products. Convenience passthrough to
|
||||
/// [`AccountClient::quota`](proton_sdk::account::AccountClient::quota).
|
||||
pub async fn quota(&self) -> Result<proton_sdk::account::Quota> {
|
||||
self.account.quota().await
|
||||
}
|
||||
|
||||
/// Attach a telemetry observer to receive a
|
||||
/// [`TelemetryEvent`](proton_sdk::telemetry::TelemetryEvent) for each
|
||||
/// instrumented operation (transfers, navigation, mutations) plus a
|
||||
@@ -1625,6 +1644,777 @@ impl ProtonDriveClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Share a node with one or more Proton users by email.
|
||||
///
|
||||
/// Creates a standard share on the node if it is not shared yet, then invites
|
||||
/// each user at the given [`MemberRole`]. Ported from the TypeScript SDK's
|
||||
/// `shareNode` (the C# public SDK has no share-creation surface): the share
|
||||
/// key is generated and bound to the node + owning address, and each invite
|
||||
/// carries the share session key encrypted to the invitee's public key plus a
|
||||
/// context-bound inviter signature over it.
|
||||
///
|
||||
/// `email_message` is an optional note included in the invitation emails.
|
||||
/// Returns the freshly created invitations. Invitees already invited or
|
||||
/// already members are skipped (this does not update their role).
|
||||
pub async fn share_node(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
invitees: &[(String, MemberRole)],
|
||||
email_message: Option<&str>,
|
||||
) -> Result<Vec<ShareInvitation>> {
|
||||
let mut timer = self.telemetry.start("share_node");
|
||||
|
||||
let (share_id, share_session_key, inviter_email, inviter_key) =
|
||||
self.ensure_node_share(uid).await?;
|
||||
|
||||
// Skip anyone who is already invited or already a member.
|
||||
let existing_invites = self.list_share_invitations_inner(&share_id).await?;
|
||||
let existing_members = self.list_share_members_inner(&share_id).await?;
|
||||
let already: std::collections::HashSet<String> = existing_invites
|
||||
.iter()
|
||||
.map(|i| i.invitee_email.to_lowercase())
|
||||
.chain(existing_members.iter().map(|m| m.email.to_lowercase()))
|
||||
.collect();
|
||||
|
||||
let mut created = Vec::new();
|
||||
for (email, role) in invitees {
|
||||
if already.contains(&email.to_lowercase()) {
|
||||
tracing::info!(%email, "skipping already-invited/member for share");
|
||||
continue;
|
||||
}
|
||||
let permissions = role.to_permissions().ok_or_else(|| {
|
||||
ProtonError::invalid_operation("cannot invite a user with the inherited role")
|
||||
})?;
|
||||
|
||||
let invitee_keys = self.account.public_keys(email).await;
|
||||
let invitee_pubkey = invitee_keys.first().ok_or_else(|| {
|
||||
ProtonError::invalid_operation(format!("no public key for invitee {email}"))
|
||||
})?;
|
||||
|
||||
let (key_packet, key_packet_signature) =
|
||||
encrypt_invitation(&share_session_key, &inviter_key, invitee_pubkey)?;
|
||||
|
||||
let request = InviteProtonUserRequest {
|
||||
invitation: InviteProtonUserInvitationDto {
|
||||
inviter_email: inviter_email.clone(),
|
||||
invitee_email: email.clone(),
|
||||
permissions,
|
||||
key_packet,
|
||||
key_packet_signature,
|
||||
external_invitation_id: None,
|
||||
},
|
||||
email_details: InviteEmailDetailsDto {
|
||||
message: email_message.map(str::to_string),
|
||||
item_name: None,
|
||||
},
|
||||
};
|
||||
|
||||
let path = format!("v2/shares/{share_id}/invitations");
|
||||
let response: InviteProtonUserResponse = self.http.post(&path, &request).await?;
|
||||
created.push(invitation_from_dto(&share_id, response.invitation));
|
||||
}
|
||||
|
||||
timer.success();
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
/// List the members (accepted invitations) of the share on a node.
|
||||
///
|
||||
/// Returns an empty list when the node is not shared.
|
||||
pub async fn list_share_members(&self, uid: &NodeUid) -> Result<Vec<ShareMember>> {
|
||||
let mut timer = self.telemetry.start("list_share_members");
|
||||
let members = match self.node_share_id(uid).await? {
|
||||
Some(share_id) => self.list_share_members_inner(&share_id).await?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
timer.success();
|
||||
Ok(members)
|
||||
}
|
||||
|
||||
/// List the pending Proton-user invitations of the share on a node.
|
||||
///
|
||||
/// Returns an empty list when the node is not shared.
|
||||
pub async fn list_share_invitations(&self, uid: &NodeUid) -> Result<Vec<ShareInvitation>> {
|
||||
let mut timer = self.telemetry.start("list_share_invitations");
|
||||
let invitations = match self.node_share_id(uid).await? {
|
||||
Some(share_id) => self.list_share_invitations_inner(&share_id).await?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
timer.success();
|
||||
Ok(invitations)
|
||||
}
|
||||
|
||||
/// Change a member's role (`PUT v2/shares/{sid}/members/{mid}`).
|
||||
pub async fn update_member_role(&self, member: &ShareMember, role: MemberRole) -> Result<()> {
|
||||
let permissions = role.to_permissions().ok_or_else(|| {
|
||||
ProtonError::invalid_operation("cannot set a member to the inherited role")
|
||||
})?;
|
||||
let path = format!(
|
||||
"v2/shares/{}/members/{}",
|
||||
member.share_id, member.membership_id
|
||||
);
|
||||
let _: proton_sdk::api::ApiResponse = self
|
||||
.http
|
||||
.put(&path, &UpdatePermissionsRequest { permissions })
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a member from a share, revoking their access
|
||||
/// (`DELETE v2/shares/{sid}/members/{mid}`).
|
||||
pub async fn remove_member(&self, member: &ShareMember) -> Result<()> {
|
||||
self.remove_share_member(&member.share_id, &member.membership_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Change a pending invitation's role
|
||||
/// (`PUT v2/shares/{sid}/invitations/{iid}`).
|
||||
pub async fn update_invitation_role(
|
||||
&self,
|
||||
invitation: &ShareInvitation,
|
||||
role: MemberRole,
|
||||
) -> Result<()> {
|
||||
let permissions = role.to_permissions().ok_or_else(|| {
|
||||
ProtonError::invalid_operation("cannot set an invitation to the inherited role")
|
||||
})?;
|
||||
let path = format!(
|
||||
"v2/shares/{}/invitations/{}",
|
||||
invitation.share_id, invitation.invitation_id
|
||||
);
|
||||
let _: proton_sdk::api::ApiResponse = self
|
||||
.http
|
||||
.put(&path, &UpdatePermissionsRequest { permissions })
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke a pending invitation
|
||||
/// (`DELETE v2/shares/{sid}/invitations/{iid}`).
|
||||
pub async fn delete_invitation(&self, invitation: &ShareInvitation) -> Result<()> {
|
||||
let path = format!(
|
||||
"v2/shares/{}/invitations/{}",
|
||||
invitation.share_id, invitation.invitation_id
|
||||
);
|
||||
let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a public share link on a node, returning the shareable URL.
|
||||
///
|
||||
/// Ported from the TypeScript SDK's public-link flow: a random 12-character
|
||||
/// link password (optionally suffixed with `custom_password`) protects the
|
||||
/// share. The share session key is wrapped with the SRP-salted passphrase
|
||||
/// (`SharePassphraseKeyPacket`), the password is stored encrypted to the
|
||||
/// owner's address key, and an SRP verifier lets the server authenticate
|
||||
/// visitors without learning the password. `role` must be Viewer or Editor
|
||||
/// (public links cannot grant Admin). `expiration_time` is an optional Unix
|
||||
/// timestamp. The returned [`PublicLink::url`] carries the secret fragment.
|
||||
pub async fn create_public_link(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
role: MemberRole,
|
||||
custom_password: Option<&str>,
|
||||
expiration_time: Option<i64>,
|
||||
) -> Result<PublicLink> {
|
||||
let mut timer = self.telemetry.start("create_public_link");
|
||||
|
||||
let permissions = match role {
|
||||
MemberRole::Viewer | MemberRole::Editor => role.to_permissions().unwrap(),
|
||||
_ => {
|
||||
return Err(ProtonError::invalid_operation(
|
||||
"a public link can only grant the Viewer or Editor role",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let (share_id, share_session_key, creator_email, address_key) =
|
||||
self.ensure_node_share(uid).await?;
|
||||
|
||||
// The URL fragment is the generated password; the encryption password is
|
||||
// the generated password with any custom password appended.
|
||||
let generated = generate_public_link_password();
|
||||
let full_password = match custom_password {
|
||||
Some(custom) if !custom.is_empty() => format!("{generated}{custom}"),
|
||||
_ => generated.clone(),
|
||||
};
|
||||
|
||||
// Wrap the share session key with the SRP-salted passphrase, and store the
|
||||
// password encrypted to the owner's address key.
|
||||
let key_salt = generate_key_salt();
|
||||
let salted_passphrase = derive_key_passphrase(full_password.as_bytes(), &key_salt)?;
|
||||
let share_passphrase_key_packet =
|
||||
base64_encode(share_session_key.encrypt_with_password(&salted_passphrase)?);
|
||||
let armored_password = address_key.encrypt(full_password.as_bytes())?;
|
||||
|
||||
// SRP verifier over the link password, against a fresh signed modulus.
|
||||
let modulus = self.fetch_srp_modulus().await?;
|
||||
let verifier = generate_verifier(
|
||||
full_password.as_bytes(),
|
||||
&modulus.modulus,
|
||||
DEFAULT_BIT_LENGTH,
|
||||
)?;
|
||||
|
||||
let flags = if custom_password.map(|p| !p.is_empty()).unwrap_or(false) {
|
||||
3 // random + custom password
|
||||
} else {
|
||||
2 // random password only
|
||||
};
|
||||
|
||||
let request = CreatePublicLinkRequest {
|
||||
creator_email,
|
||||
permissions,
|
||||
flags,
|
||||
expiration_time,
|
||||
share_password_salt: base64_encode(key_salt),
|
||||
share_passphrase_key_packet,
|
||||
password: armored_password,
|
||||
url_password_salt: verifier.salt,
|
||||
srp_verifier: verifier.verifier,
|
||||
srp_modulus_id: modulus.modulus_id,
|
||||
max_accesses: 0,
|
||||
};
|
||||
|
||||
let path = format!("shares/{share_id}/urls");
|
||||
let response: CreatePublicLinkResponse = self.http.post(&path, &request).await?;
|
||||
|
||||
timer.success();
|
||||
Ok(PublicLink {
|
||||
share_id,
|
||||
public_link_id: response.share_url.share_url_id,
|
||||
url: Some(format!("{}#{generated}", response.share_url.public_url)),
|
||||
role,
|
||||
creation_time: now_epoch_seconds(),
|
||||
expiration_time,
|
||||
has_custom_password: flags == 3,
|
||||
})
|
||||
}
|
||||
|
||||
/// The public link on a node, if one exists (metadata only — the secret URL
|
||||
/// fragment is not reconstructed).
|
||||
pub async fn get_public_link(&self, uid: &NodeUid) -> Result<Option<PublicLink>> {
|
||||
let mut timer = self.telemetry.start("get_public_link");
|
||||
let link = match self.node_share_id(uid).await? {
|
||||
Some(share_id) => {
|
||||
let path = format!("shares/{share_id}/urls");
|
||||
let response: ShareUrlsResponse = self.http.get(&path).await?;
|
||||
response
|
||||
.share_urls
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|dto| PublicLink {
|
||||
share_id,
|
||||
public_link_id: dto.share_url_id,
|
||||
url: None,
|
||||
role: MemberRole::from_permissions(dto.permissions),
|
||||
creation_time: dto.create_time,
|
||||
expiration_time: dto.expiration_time,
|
||||
has_custom_password: dto.flags == 3,
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
timer.success();
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Remove a public link, revoking access via the URL
|
||||
/// (`DELETE shares/{sid}/urls/{urlID}`).
|
||||
pub async fn remove_public_link(&self, link: &PublicLink) -> Result<()> {
|
||||
let path = format!("shares/{}/urls/{}", link.share_id, link.public_link_id);
|
||||
let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List invitations addressed to the current user (shared *with* me),
|
||||
/// pending accept or reject.
|
||||
///
|
||||
/// Pages `GET v2/shares/invitations`, then fetches and decrypts each
|
||||
/// invitation's detail so the caller has the shared item's name. A single
|
||||
/// invitation that fails to load is logged and skipped rather than failing
|
||||
/// the whole listing. Mirrors JS `iterateInvitations`.
|
||||
pub async fn list_incoming_invitations(&self) -> Result<Vec<IncomingInvitation>> {
|
||||
let mut timer = self.telemetry.start("list_incoming_invitations");
|
||||
let mut ids = Vec::new();
|
||||
let mut anchor: Option<String> = None;
|
||||
loop {
|
||||
let path = match &anchor {
|
||||
Some(a) => format!("v2/shares/invitations?AnchorID={a}"),
|
||||
None => "v2/shares/invitations".to_string(),
|
||||
};
|
||||
let page: InvitationsListResponse = self.http.get(&path).await?;
|
||||
for item in page.invitations {
|
||||
ids.push(item.invitation_id);
|
||||
}
|
||||
anchor = page.anchor_id.filter(|a| !a.is_empty());
|
||||
if !page.more || anchor.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut invitations = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
match self.incoming_invitation_detail(&id).await {
|
||||
Ok(inv) => invitations.push(inv),
|
||||
Err(e) => tracing::warn!(invitation = %id, "failed to load invitation: {e}"),
|
||||
}
|
||||
}
|
||||
timer.success();
|
||||
Ok(invitations)
|
||||
}
|
||||
|
||||
/// Accept an invitation addressed to us, gaining access to the shared node.
|
||||
///
|
||||
/// Fetches the invitation, decrypts its key packet with our address keys to
|
||||
/// recover the share session key, signs the key under the member context, and
|
||||
/// posts the signature. Mirrors JS `acceptInvitation`.
|
||||
pub async fn accept_invitation(&self, invitation_id: &str) -> Result<()> {
|
||||
let mut timer = self.telemetry.start("accept_invitation");
|
||||
let detail = self.get_incoming_invitation(invitation_id).await?;
|
||||
|
||||
let (keys, signing_key) = self
|
||||
.own_address_keys(&detail.invitation.invitee_email)
|
||||
.await?;
|
||||
let signature = accept_invitation(&detail.invitation.key_packet, &keys, &signing_key)?;
|
||||
|
||||
let path = format!("v2/shares/invitations/{invitation_id}/accept");
|
||||
let _: proton_sdk::api::ApiResponse = self
|
||||
.http
|
||||
.post(
|
||||
&path,
|
||||
&AcceptInvitationRequest {
|
||||
session_key_signature: signature,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
timer.success();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reject an invitation addressed to us, declining access.
|
||||
pub async fn reject_invitation(&self, invitation_id: &str) -> Result<()> {
|
||||
let path = format!("v2/shares/invitations/{invitation_id}/reject");
|
||||
let _: proton_sdk::api::ApiResponse = self.http.post(&path, &serde_json::json!({})).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Invite a mixed set of email addresses to a node's share, routing each to
|
||||
/// the right flow: addresses that resolve to a Proton account get a normal
|
||||
/// (encrypted-key-packet) invitation via [`share_node`](Self::share_node);
|
||||
/// the rest get an [external invitation](Self::invite_external_users). Returns
|
||||
/// `(proton_invited, external_invited)` counts. Already-invited or existing
|
||||
/// members are skipped by the underlying calls.
|
||||
pub async fn invite_users(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
invitees: &[(String, MemberRole)],
|
||||
email_message: Option<&str>,
|
||||
) -> Result<(usize, usize)> {
|
||||
let mut proton: Vec<(String, MemberRole)> = Vec::new();
|
||||
let mut external: Vec<(String, MemberRole)> = Vec::new();
|
||||
for (email, role) in invitees {
|
||||
// A Proton account exposes at least one public key; an address with
|
||||
// none is external and cannot receive an encrypted key packet.
|
||||
if self.account.public_keys(email).await.is_empty() {
|
||||
external.push((email.clone(), *role));
|
||||
} else {
|
||||
proton.push((email.clone(), *role));
|
||||
}
|
||||
}
|
||||
|
||||
let proton_invited = if proton.is_empty() {
|
||||
0
|
||||
} else {
|
||||
self.share_node(uid, &proton, email_message).await?.len()
|
||||
};
|
||||
let external_invited = if external.is_empty() {
|
||||
0
|
||||
} else {
|
||||
self.invite_external_users(uid, &external, email_message)
|
||||
.await?
|
||||
.len()
|
||||
};
|
||||
Ok((proton_invited, external_invited))
|
||||
}
|
||||
|
||||
/// Invite non-Proton (external) email addresses to a node's share.
|
||||
///
|
||||
/// The invitees have no Proton keys, so each gets an external invitation: the
|
||||
/// inviter signs `"{email}|{base64(share session key)}"` under the external
|
||||
/// context. When an invitee later signs up, the server converts the pending
|
||||
/// invitation to a normal membership. Skips anyone already invited (Proton or
|
||||
/// external) or already a member. Returns the freshly created invitations.
|
||||
pub async fn invite_external_users(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
invitees: &[(String, MemberRole)],
|
||||
email_message: Option<&str>,
|
||||
) -> Result<Vec<ExternalInvitation>> {
|
||||
let mut timer = self.telemetry.start("invite_external_users");
|
||||
|
||||
let (share_id, share_session_key, _inviter_email, inviter_key) =
|
||||
self.ensure_node_share(uid).await?;
|
||||
let (address_id, _, _) = self.membership_address().await?;
|
||||
|
||||
let already: std::collections::HashSet<String> = self
|
||||
.list_external_invitations_inner(&share_id)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|i| i.invitee_email.to_lowercase())
|
||||
.chain(
|
||||
self.list_share_invitations_inner(&share_id)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|i| i.invitee_email.to_lowercase()),
|
||||
)
|
||||
.chain(
|
||||
self.list_share_members_inner(&share_id)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|m| m.email.to_lowercase()),
|
||||
)
|
||||
.collect();
|
||||
|
||||
let mut created = Vec::new();
|
||||
for (email, role) in invitees {
|
||||
if already.contains(&email.to_lowercase()) {
|
||||
tracing::info!(%email, "skipping already-invited/member for external share");
|
||||
continue;
|
||||
}
|
||||
let permissions = role.to_permissions().ok_or_else(|| {
|
||||
ProtonError::invalid_operation("cannot invite a user with the inherited role")
|
||||
})?;
|
||||
let signature = encrypt_external_invitation(&share_session_key, &inviter_key, email)?;
|
||||
|
||||
let request = InviteExternalUserRequest {
|
||||
external_invitation: ExternalInvitationDto {
|
||||
inviter_address_id: address_id.clone(),
|
||||
invitee_email: email.clone(),
|
||||
permissions,
|
||||
external_invitation_signature: signature,
|
||||
},
|
||||
email_details: InviteEmailDetailsDto {
|
||||
message: email_message.map(str::to_string),
|
||||
item_name: None,
|
||||
},
|
||||
};
|
||||
let path = format!("v2/shares/{share_id}/external-invitations");
|
||||
let response: InviteExternalUserResponse = self.http.post(&path, &request).await?;
|
||||
created.push(external_invitation_from_dto(
|
||||
&share_id,
|
||||
response.external_invitation,
|
||||
));
|
||||
}
|
||||
|
||||
timer.success();
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
/// List the pending external (non-Proton) invitations on a node's share.
|
||||
/// Returns an empty list when the node is not shared.
|
||||
pub async fn list_external_invitations(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
) -> Result<Vec<ExternalInvitation>> {
|
||||
let mut timer = self.telemetry.start("list_external_invitations");
|
||||
let invitations = match self.node_share_id(uid).await? {
|
||||
Some(share_id) => self.list_external_invitations_inner(&share_id).await?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
timer.success();
|
||||
Ok(invitations)
|
||||
}
|
||||
|
||||
/// Revoke a pending external invitation
|
||||
/// (`DELETE v2/shares/{sid}/external-invitations/{iid}`).
|
||||
pub async fn delete_external_invitation(&self, invitation: &ExternalInvitation) -> Result<()> {
|
||||
let path = format!(
|
||||
"v2/shares/{}/external-invitations/{}",
|
||||
invitation.share_id, invitation.invitation_id
|
||||
);
|
||||
let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- bookmarks ---------------------------------------------------------
|
||||
|
||||
/// List the public links the user has saved to their account
|
||||
/// (`GET v2/shared-bookmarks`). The URL is always recovered; the item name is
|
||||
/// only present when the SRP-derived share key could be decrypted (not yet).
|
||||
pub async fn list_bookmarks(&self) -> Result<Vec<Bookmark>> {
|
||||
let mut timer = self.telemetry.start("list_bookmarks");
|
||||
let response: BookmarksResponse = self.http.get("v2/shared-bookmarks").await?;
|
||||
|
||||
// Bookmark URL passwords are encrypted to our My Files share address key.
|
||||
let (_, email, _) = self.membership_address().await?;
|
||||
let (address_keys, _) = self.own_address_keys(&email).await?;
|
||||
|
||||
let mut bookmarks = Vec::with_capacity(response.bookmarks.len());
|
||||
for dto in response.bookmarks {
|
||||
let Some(enc) = dto.encrypted_url_password.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(bytes) = decrypt_armored_with_keys(enc, &address_keys) else {
|
||||
continue;
|
||||
};
|
||||
// The stored secret is the URL password with any custom password
|
||||
// appended; the URL only needs the leading generated portion, but the
|
||||
// API does not tell us the split, so the fragment is the whole string.
|
||||
let url_password = String::from_utf8_lossy(&bytes).into_owned();
|
||||
|
||||
bookmarks.push(Bookmark {
|
||||
url: format!(
|
||||
"https://drive.proton.me/urls/{}#{url_password}",
|
||||
dto.token.token
|
||||
),
|
||||
token: dto.token.token,
|
||||
// Name decryption needs the SRP-derived share key, not yet ported;
|
||||
// the URL is enough to open the link.
|
||||
node_name: None,
|
||||
is_folder: dto.token.link_type == 1,
|
||||
creation_time: dto.create_time,
|
||||
});
|
||||
}
|
||||
timer.success();
|
||||
Ok(bookmarks)
|
||||
}
|
||||
|
||||
/// Save a public link to the account as a bookmark
|
||||
/// (`POST v2/urls/{token}/bookmark`). `url` is the full public URL including
|
||||
/// the `#password` fragment; `custom_password` is required only when the link
|
||||
/// is additionally password-protected.
|
||||
pub async fn create_bookmark(&self, url: &str, custom_password: Option<&str>) -> Result<()> {
|
||||
let mut timer = self.telemetry.start("create_bookmark");
|
||||
let (token, url_password) = parse_public_link_url(url)?;
|
||||
|
||||
let (address_id, email, _) = self.membership_address().await?;
|
||||
let (_, address_key) = self.own_address_keys(&email).await?;
|
||||
let address_key_id = self.address_primary_key_id(&address_id).await?;
|
||||
|
||||
// The stored secret is the URL password concatenated with any custom
|
||||
// password, encrypted + signed to our own address key.
|
||||
let concatenated = format!("{url_password}{}", custom_password.unwrap_or(""));
|
||||
let encrypted_url_password =
|
||||
address_key.encrypt_and_sign(&address_key, concatenated.as_bytes(), false, false)?;
|
||||
|
||||
let request = CreateBookmarkRequest {
|
||||
bookmark_share_url: BookmarkShareUrlDto {
|
||||
encrypted_url_password,
|
||||
address_id,
|
||||
address_key_id,
|
||||
},
|
||||
};
|
||||
let path = format!("v2/urls/{token}/bookmark");
|
||||
let _: proton_sdk::api::ApiResponse = self.http.post(&path, &request).await?;
|
||||
timer.success();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a saved bookmark (`DELETE v2/urls/{token}/bookmark`).
|
||||
pub async fn delete_bookmark(&self, token: &str) -> Result<()> {
|
||||
let path = format!("v2/urls/{token}/bookmark");
|
||||
let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- sharing internals -------------------------------------------------
|
||||
|
||||
/// Fetch a single incoming invitation's encrypted detail by id.
|
||||
async fn get_incoming_invitation(
|
||||
&self,
|
||||
invitation_id: &str,
|
||||
) -> Result<InvitationDetailsResponse> {
|
||||
let path = format!("v2/shares/invitations/{invitation_id}");
|
||||
self.http.get(&path).await
|
||||
}
|
||||
|
||||
/// Fetch and decrypt a single incoming invitation into its public form.
|
||||
async fn incoming_invitation_detail(&self, invitation_id: &str) -> Result<IncomingInvitation> {
|
||||
let detail = self.get_incoming_invitation(invitation_id).await?;
|
||||
let node_uid = NodeUid::new(
|
||||
VolumeId::from(detail.share.volume_id.clone()),
|
||||
LinkId::from(detail.link.link_id.clone()),
|
||||
);
|
||||
let node_name = self.decrypt_invitation_name(&detail).await;
|
||||
|
||||
Ok(IncomingInvitation {
|
||||
invitation_id: detail.invitation.invitation_id,
|
||||
inviter_email: detail.invitation.inviter_email,
|
||||
invitee_email: detail.invitation.invitee_email,
|
||||
role: MemberRole::from_permissions(detail.invitation.permissions),
|
||||
invitation_time: detail.invitation.create_time,
|
||||
node_uid,
|
||||
node_name,
|
||||
is_folder: detail.link.link_type == 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypt the shared item's name from an invitation, using the invitee's own
|
||||
/// keys to unlock the share key. A failure yields `None` — the invitation is
|
||||
/// still actionable (accept/reject) without a readable name.
|
||||
async fn decrypt_invitation_name(&self, detail: &InvitationDetailsResponse) -> Option<String> {
|
||||
let (keys, _) = self
|
||||
.own_address_keys(&detail.invitation.invitee_email)
|
||||
.await
|
||||
.ok()?;
|
||||
let passphrase = keys
|
||||
.iter()
|
||||
.find_map(|k| k.decrypt_armored_message(&detail.share.passphrase).ok())?;
|
||||
let share_key = PrivateKey::from_armored(&detail.share.share_key, &passphrase).ok()?;
|
||||
let name_bytes = share_key.decrypt_armored_message(&detail.link.name).ok()?;
|
||||
String::from_utf8(name_bytes).ok()
|
||||
}
|
||||
|
||||
/// The current user's own address keys for `email`: all its private keys (for
|
||||
/// decryption) and the primary signing key.
|
||||
async fn own_address_keys(&self, email: &str) -> Result<(Vec<PrivateKey>, PrivateKey)> {
|
||||
let email_lc = email.to_lowercase();
|
||||
let address = self
|
||||
.account
|
||||
.addresses()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|a| a.email.to_lowercase() == email_lc)
|
||||
.ok_or_else(|| ProtonError::invalid_operation(format!("no own address for {email}")))?;
|
||||
let keys = self.account.address_private_keys(&address.id).await?;
|
||||
let signing_key = keys
|
||||
.get(address.primary_key_index)
|
||||
.cloned()
|
||||
.ok_or_else(|| ProtonError::invalid_operation("address has no primary key"))?;
|
||||
Ok((keys, signing_key))
|
||||
}
|
||||
|
||||
async fn list_external_invitations_inner(
|
||||
&self,
|
||||
share_id: &ShareId,
|
||||
) -> Result<Vec<ExternalInvitation>> {
|
||||
let path = format!("v2/shares/{share_id}/external-invitations");
|
||||
let response: ExternalInvitationsResponse = self.http.get(&path).await?;
|
||||
Ok(response
|
||||
.external_invitations
|
||||
.into_iter()
|
||||
.map(|i| external_invitation_from_dto(share_id, i))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Fetch a fresh signed SRP modulus (`GET auth/v4/modulus`, root route).
|
||||
async fn fetch_srp_modulus(&self) -> Result<ModulusResponse> {
|
||||
// The modulus endpoint lives at the API root, not under `drive/`, and is
|
||||
// a GET (the server rejects POST with 405 "Allow: GET").
|
||||
let root = self.http.with_base_route("");
|
||||
root.get("auth/v4/modulus").await
|
||||
}
|
||||
|
||||
/// The id of the standard share on a node, if it is shared.
|
||||
async fn node_share_id(&self, uid: &NodeUid) -> Result<Option<ShareId>> {
|
||||
let details = self
|
||||
.get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id))
|
||||
.await?;
|
||||
Ok(details
|
||||
.links
|
||||
.into_iter()
|
||||
.find(|detail| detail.link.id == uid.link_id)
|
||||
.and_then(|detail| detail.sharing)
|
||||
.map(|sharing| sharing.share_id))
|
||||
}
|
||||
|
||||
/// Get-or-create the standard share on a node, returning its id, passphrase
|
||||
/// session key (for inviting), and the owning address's email + signing key.
|
||||
async fn ensure_node_share(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
) -> Result<(ShareId, ContentKey, String, PrivateKey)> {
|
||||
let details = self
|
||||
.get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id))
|
||||
.await?;
|
||||
let detail = details
|
||||
.links
|
||||
.into_iter()
|
||||
.find(|detail| detail.link.id == uid.link_id)
|
||||
.ok_or_else(|| ProtonError::invalid_operation("node not found"))?;
|
||||
let link = &detail.link;
|
||||
|
||||
let parent_key = self.resolve_parent_key(&uid.volume_id, link).await?;
|
||||
let node_key = decrypt_link(&parent_key, link)?.node_key;
|
||||
|
||||
let (address_id, inviter_email, address_key) = self.membership_address().await?;
|
||||
|
||||
if let Some(sharing) = detail.sharing {
|
||||
// Already shared: recover the existing share's passphrase session key
|
||||
// (the passphrase is encrypted to the node key) so we can invite.
|
||||
let path = format!("shares/{}", sharing.share_id);
|
||||
let response: ShareResponse = self.http.get(&path).await?;
|
||||
let share_session_key =
|
||||
node_key.recover_message_session_key(&response.share.passphrase)?;
|
||||
return Ok((
|
||||
sharing.share_id,
|
||||
share_session_key,
|
||||
inviter_email,
|
||||
address_key,
|
||||
));
|
||||
}
|
||||
|
||||
// Not shared yet: lift the node's passphrase + name session keys, build a
|
||||
// fresh share bound to the node + owning address, and create it.
|
||||
let node_passphrase_sk = parent_key.recover_message_session_key(&link.passphrase)?;
|
||||
let node_name_sk = parent_key.recover_message_session_key(&link.name)?;
|
||||
let material = build_standard_share_material(
|
||||
&node_key,
|
||||
&node_passphrase_sk,
|
||||
&node_name_sk,
|
||||
&address_key,
|
||||
)?;
|
||||
|
||||
let request = CreateShareRequest {
|
||||
root_link_id: uid.link_id.clone(),
|
||||
address_id,
|
||||
name: "New Share".to_string(),
|
||||
share_key: material.share_key_armored,
|
||||
share_passphrase: material.share_passphrase,
|
||||
share_passphrase_signature: material.share_passphrase_signature,
|
||||
passphrase_key_packet: material.passphrase_key_packet,
|
||||
name_key_packet: material.name_key_packet,
|
||||
};
|
||||
let path = format!("volumes/{}/shares", uid.volume_id);
|
||||
let response: CreateShareResponse = self.http.post(&path, &request).await?;
|
||||
|
||||
Ok((
|
||||
response.share.id,
|
||||
material.share_session_key,
|
||||
inviter_email,
|
||||
address_key,
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_share_members_inner(&self, share_id: &ShareId) -> Result<Vec<ShareMember>> {
|
||||
let path = format!("v2/shares/{share_id}/members");
|
||||
let response: ShareMembersResponse = self.http.get(&path).await?;
|
||||
Ok(response
|
||||
.members
|
||||
.into_iter()
|
||||
.map(|m| ShareMember {
|
||||
share_id: share_id.clone(),
|
||||
membership_id: m.member_id,
|
||||
email: m.email,
|
||||
added_by_email: m.inviter_email,
|
||||
role: MemberRole::from_permissions(m.permissions),
|
||||
invitation_time: m.create_time,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_share_invitations_inner(
|
||||
&self,
|
||||
share_id: &ShareId,
|
||||
) -> Result<Vec<ShareInvitation>> {
|
||||
let path = format!("v2/shares/{share_id}/invitations");
|
||||
let response: ShareInvitationsResponse = self.http.get(&path).await?;
|
||||
Ok(response
|
||||
.invitations
|
||||
.into_iter()
|
||||
.map(|i| invitation_from_dto(share_id, i))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The account's registered devices, with their root-folder names decrypted.
|
||||
///
|
||||
/// Mirrors C# `DeviceOperations.EnumerateDevicesAsync` (`GET devices` then
|
||||
@@ -1731,8 +2521,28 @@ impl ProtonDriveClient {
|
||||
.resolve_membership_address(membership_address_id)
|
||||
.await?;
|
||||
|
||||
// Proton's rename endpoint keeps the link's existing name *key packet* and
|
||||
// replaces only the *data packet*, so the new name must be encrypted under
|
||||
// the **same** session key as the current name (otherwise the retained key
|
||||
// packet no longer decrypts the data packet). Recover that session key from
|
||||
// the current root-folder name and reuse it. Mirrors C#
|
||||
// `DeviceOperations.RenameRootFolderAsync` (`GetNodeMetadata` →
|
||||
// `NameSessionKey` → `DeviceCrypto.GetRenameRequest`).
|
||||
let uid = &device.root_folder_uid;
|
||||
let details = self
|
||||
.get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id))
|
||||
.await?;
|
||||
let current_name = details
|
||||
.links
|
||||
.into_iter()
|
||||
.find(|detail| detail.link.id == uid.link_id)
|
||||
.map(|detail| detail.link.name)
|
||||
.ok_or_else(|| {
|
||||
ProtonError::invalid_operation(format!("device root folder {uid} not found"))
|
||||
})?;
|
||||
let name_session_key = share_key.recover_message_session_key(¤t_name)?;
|
||||
let encrypted_name =
|
||||
share_key.encrypt_and_sign(&signing_key, name.as_bytes(), true, false)?;
|
||||
name_session_key.encrypt_and_sign_text_to(&share_key, &signing_key, name.as_bytes())?;
|
||||
let request = RenameLinkRequest {
|
||||
name: encrypted_name,
|
||||
name_hash: String::new(),
|
||||
@@ -3089,6 +3899,80 @@ fn is_my_files_missing(error: &ProtonError) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// base64-encode bytes with the standard alphabet.
|
||||
fn base64_encode(bytes: impl AsRef<[u8]>) -> String {
|
||||
BASE64.encode(bytes)
|
||||
}
|
||||
|
||||
/// The alphabet for generated public-link passwords (JS `generatePublicLinkPassword`).
|
||||
const PUBLIC_LINK_PASSWORD_CHARSET: &[u8] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
/// Generated public-link password length (JS `PUBLIC_LINK_GENERATED_PASSWORD_LENGTH`).
|
||||
const PUBLIC_LINK_PASSWORD_LEN: usize = 12;
|
||||
|
||||
/// Generate a random 12-character alphanumeric public-link password.
|
||||
fn generate_public_link_password() -> String {
|
||||
let mut raw = [0u8; PUBLIC_LINK_PASSWORD_LEN];
|
||||
getrandom::fill(&mut raw).expect("system RNG");
|
||||
raw.iter()
|
||||
.map(|b| {
|
||||
PUBLIC_LINK_PASSWORD_CHARSET[*b as usize % PUBLIC_LINK_PASSWORD_CHARSET.len()] as char
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate a fresh 16-byte key salt (Proton `generateKeySalt`).
|
||||
fn generate_key_salt() -> [u8; 16] {
|
||||
let mut salt = [0u8; 16];
|
||||
getrandom::fill(&mut salt).expect("system RNG");
|
||||
salt
|
||||
}
|
||||
|
||||
/// Build a [`ShareInvitation`] model from its wire DTO.
|
||||
fn invitation_from_dto(share_id: &ShareId, dto: ShareInvitationDto) -> ShareInvitation {
|
||||
ShareInvitation {
|
||||
share_id: share_id.clone(),
|
||||
invitation_id: dto.invitation_id,
|
||||
invitee_email: dto.invitee_email,
|
||||
inviter_email: dto.inviter_email,
|
||||
role: MemberRole::from_permissions(dto.permissions),
|
||||
invitation_time: dto.create_time,
|
||||
}
|
||||
}
|
||||
|
||||
fn external_invitation_from_dto(
|
||||
share_id: &ShareId,
|
||||
dto: ExternalInvitationResponseDto,
|
||||
) -> ExternalInvitation {
|
||||
ExternalInvitation {
|
||||
share_id: share_id.clone(),
|
||||
invitation_id: dto.external_invitation_id,
|
||||
invitee_email: dto.invitee_email,
|
||||
inviter_email: dto.inviter_email,
|
||||
role: MemberRole::from_permissions(dto.permissions),
|
||||
invitation_time: dto.create_time,
|
||||
state: ExternalInvitationState::from_raw(dto.state),
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a public-link URL (`https://drive.proton.me/urls/{token}#{password}`)
|
||||
/// into its token and password. Mirrors JS `getTokenAndPasswordFromUrl`: the
|
||||
/// token is the last path segment, the password the fragment after `#`.
|
||||
fn parse_public_link_url(url: &str) -> Result<(String, String)> {
|
||||
let invalid = || ProtonError::invalid_operation("invalid public link url");
|
||||
let (before_hash, password) = url.split_once('#').ok_or_else(invalid)?;
|
||||
let token = before_hash
|
||||
.trim_end_matches('/')
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or_else(invalid)?;
|
||||
if password.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok((token.to_string(), password.to_string()))
|
||||
}
|
||||
|
||||
/// The current Unix epoch, in seconds.
|
||||
fn now_epoch_seconds() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
|
||||
@@ -1119,3 +1119,417 @@ mod tests {
|
||||
assert!(ShareTargetType::from_raw(6).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sharing: creating shares, inviting Proton users, members + invitations.
|
||||
// Ported from the TypeScript SDK (`internal/sharing/apiService.ts`); the C#
|
||||
// public SDK does not expose share creation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST volumes/{volumeID}/shares` — create a standard share on a node.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateShareRequest {
|
||||
#[serde(rename = "RootLinkID")]
|
||||
pub root_link_id: LinkId,
|
||||
#[serde(rename = "AddressID")]
|
||||
pub address_id: AddressId,
|
||||
#[serde(rename = "Name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "ShareKey")]
|
||||
pub share_key: String,
|
||||
#[serde(rename = "SharePassphrase")]
|
||||
pub share_passphrase: String,
|
||||
#[serde(rename = "SharePassphraseSignature")]
|
||||
pub share_passphrase_signature: String,
|
||||
#[serde(rename = "PassphraseKeyPacket")]
|
||||
pub passphrase_key_packet: String,
|
||||
#[serde(rename = "NameKeyPacket")]
|
||||
pub name_key_packet: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateShareResponse {
|
||||
#[serde(rename = "Share")]
|
||||
pub share: CreatedShareDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatedShareDto {
|
||||
#[serde(rename = "ID")]
|
||||
pub id: ShareId,
|
||||
#[serde(rename = "EditorsCanShare", default)]
|
||||
pub editors_can_share: bool,
|
||||
}
|
||||
|
||||
/// `POST v2/shares/{shareID}/invitations` — invite a Proton user.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InviteProtonUserRequest {
|
||||
#[serde(rename = "Invitation")]
|
||||
pub invitation: InviteProtonUserInvitationDto,
|
||||
#[serde(rename = "EmailDetails")]
|
||||
pub email_details: InviteEmailDetailsDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InviteProtonUserInvitationDto {
|
||||
#[serde(rename = "InviterEmail")]
|
||||
pub inviter_email: String,
|
||||
#[serde(rename = "InviteeEmail")]
|
||||
pub invitee_email: String,
|
||||
#[serde(rename = "Permissions")]
|
||||
pub permissions: i32,
|
||||
#[serde(rename = "KeyPacket")]
|
||||
pub key_packet: String,
|
||||
#[serde(rename = "KeyPacketSignature")]
|
||||
pub key_packet_signature: String,
|
||||
#[serde(
|
||||
rename = "ExternalInvitationID",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub external_invitation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InviteEmailDetailsDto {
|
||||
#[serde(rename = "Message", skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
#[serde(rename = "ItemName", skip_serializing_if = "Option::is_none")]
|
||||
pub item_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InviteProtonUserResponse {
|
||||
#[serde(rename = "Invitation")]
|
||||
pub invitation: ShareInvitationDto,
|
||||
}
|
||||
|
||||
/// `GET v2/shares/{shareID}/invitations`
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ShareInvitationsResponse {
|
||||
#[serde(rename = "Invitations", default)]
|
||||
pub invitations: Vec<ShareInvitationDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ShareInvitationDto {
|
||||
#[serde(rename = "InvitationID")]
|
||||
pub invitation_id: String,
|
||||
#[serde(rename = "InviterEmail")]
|
||||
pub inviter_email: String,
|
||||
#[serde(rename = "InviteeEmail")]
|
||||
pub invitee_email: String,
|
||||
#[serde(rename = "Permissions", default)]
|
||||
pub permissions: Option<i32>,
|
||||
#[serde(rename = "CreateTime", default)]
|
||||
pub create_time: i64,
|
||||
}
|
||||
|
||||
/// `GET v2/shares/{shareID}/members`
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ShareMembersResponse {
|
||||
#[serde(rename = "Members", default)]
|
||||
pub members: Vec<ShareMemberDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ShareMemberDto {
|
||||
#[serde(rename = "MemberID")]
|
||||
pub member_id: ShareMembershipId,
|
||||
#[serde(rename = "InviterEmail")]
|
||||
pub inviter_email: String,
|
||||
#[serde(rename = "Email")]
|
||||
pub email: String,
|
||||
#[serde(rename = "Permissions", default)]
|
||||
pub permissions: Option<i32>,
|
||||
#[serde(rename = "CreateTime", default)]
|
||||
pub create_time: i64,
|
||||
}
|
||||
|
||||
/// `PUT v2/shares/{shareID}/members/{memberID}` and the invitation equivalent.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UpdatePermissionsRequest {
|
||||
#[serde(rename = "Permissions")]
|
||||
pub permissions: i32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Incoming invitations: invitations addressed to the current user, and
|
||||
// accept/reject. `GET v2/shares/invitations` lists them; the detail, accept and
|
||||
// reject routes are keyed by invitation id alone.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET v2/shares/invitations` — the invitations where we are the invitee.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InvitationsListResponse {
|
||||
#[serde(rename = "Invitations", default)]
|
||||
pub invitations: Vec<InvitationListItemDto>,
|
||||
#[serde(rename = "More", default)]
|
||||
pub more: bool,
|
||||
#[serde(rename = "AnchorID", default)]
|
||||
pub anchor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InvitationListItemDto {
|
||||
#[serde(rename = "InvitationID")]
|
||||
pub invitation_id: String,
|
||||
#[serde(rename = "ShareID", default)]
|
||||
pub share_id: String,
|
||||
#[serde(rename = "ShareTargetType", default)]
|
||||
pub share_target_type: i32,
|
||||
}
|
||||
|
||||
/// `GET v2/shares/invitations/{invitationID}` — the encrypted invitation, the
|
||||
/// share crypto needed to decrypt it, and the shared node's link.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InvitationDetailsResponse {
|
||||
#[serde(rename = "Invitation")]
|
||||
pub invitation: IncomingInvitationDto,
|
||||
#[serde(rename = "Share")]
|
||||
pub share: InvitationShareDto,
|
||||
#[serde(rename = "Link")]
|
||||
pub link: InvitationLinkDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct IncomingInvitationDto {
|
||||
#[serde(rename = "InvitationID")]
|
||||
pub invitation_id: String,
|
||||
#[serde(rename = "InviterEmail")]
|
||||
pub inviter_email: String,
|
||||
#[serde(rename = "InviteeEmail")]
|
||||
pub invitee_email: String,
|
||||
#[serde(rename = "KeyPacket")]
|
||||
pub key_packet: String,
|
||||
#[serde(rename = "KeyPacketSignature", default)]
|
||||
pub key_packet_signature: String,
|
||||
#[serde(rename = "Permissions", default)]
|
||||
pub permissions: Option<i32>,
|
||||
#[serde(rename = "CreateTime", default)]
|
||||
pub create_time: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InvitationShareDto {
|
||||
#[serde(rename = "ShareKey")]
|
||||
pub share_key: String,
|
||||
#[serde(rename = "Passphrase")]
|
||||
pub passphrase: String,
|
||||
#[serde(rename = "CreatorEmail", default)]
|
||||
pub creator_email: String,
|
||||
#[serde(rename = "VolumeID")]
|
||||
pub volume_id: String,
|
||||
#[serde(rename = "ShareTargetType", default)]
|
||||
pub share_target_type: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InvitationLinkDto {
|
||||
#[serde(rename = "LinkID")]
|
||||
pub link_id: String,
|
||||
#[serde(rename = "Type", default)]
|
||||
pub link_type: i32,
|
||||
#[serde(rename = "MIMEType", default)]
|
||||
pub mime_type: Option<String>,
|
||||
#[serde(rename = "Name")]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// `POST v2/shares/invitations/{invitationID}/accept`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AcceptInvitationRequest {
|
||||
#[serde(rename = "SessionKeySignature")]
|
||||
pub session_key_signature: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External (non-Proton) invitations. Same share-invitation model, but the
|
||||
// invitee has no Proton key, so instead of a key packet the inviter carries a
|
||||
// detached signature over the invitee email + share session key.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST v2/shares/{shareID}/external-invitations`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InviteExternalUserRequest {
|
||||
#[serde(rename = "ExternalInvitation")]
|
||||
pub external_invitation: ExternalInvitationDto,
|
||||
#[serde(rename = "EmailDetails")]
|
||||
pub email_details: InviteEmailDetailsDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExternalInvitationDto {
|
||||
#[serde(rename = "InviterAddressID")]
|
||||
pub inviter_address_id: AddressId,
|
||||
#[serde(rename = "InviteeEmail")]
|
||||
pub invitee_email: String,
|
||||
#[serde(rename = "Permissions")]
|
||||
pub permissions: i32,
|
||||
#[serde(rename = "ExternalInvitationSignature")]
|
||||
pub external_invitation_signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InviteExternalUserResponse {
|
||||
#[serde(rename = "ExternalInvitation")]
|
||||
pub external_invitation: ExternalInvitationResponseDto,
|
||||
}
|
||||
|
||||
/// `GET v2/shares/{shareID}/external-invitations`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExternalInvitationsResponse {
|
||||
#[serde(rename = "ExternalInvitations", default)]
|
||||
pub external_invitations: Vec<ExternalInvitationResponseDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExternalInvitationResponseDto {
|
||||
#[serde(rename = "ExternalInvitationID")]
|
||||
pub external_invitation_id: String,
|
||||
#[serde(rename = "InviterEmail")]
|
||||
pub inviter_email: String,
|
||||
#[serde(rename = "InviteeEmail")]
|
||||
pub invitee_email: String,
|
||||
#[serde(rename = "Permissions", default)]
|
||||
pub permissions: Option<i32>,
|
||||
#[serde(rename = "CreateTime", default)]
|
||||
pub create_time: i64,
|
||||
/// `1` = pending (invitee has no Proton account yet), else user-registered.
|
||||
#[serde(rename = "State", default)]
|
||||
pub state: i32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bookmarks: public links the user has saved to their account.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET v2/shared-bookmarks`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BookmarksResponse {
|
||||
#[serde(rename = "Bookmarks", default)]
|
||||
pub bookmarks: Vec<BookmarkDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BookmarkDto {
|
||||
#[serde(rename = "Token")]
|
||||
pub token: BookmarkTokenDto,
|
||||
#[serde(rename = "CreateTime", default)]
|
||||
pub create_time: i64,
|
||||
#[serde(rename = "EncryptedUrlPassword", default)]
|
||||
pub encrypted_url_password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BookmarkTokenDto {
|
||||
#[serde(rename = "Token")]
|
||||
pub token: String,
|
||||
#[serde(rename = "ShareKey")]
|
||||
pub share_key: String,
|
||||
#[serde(rename = "SharePassphrase")]
|
||||
pub share_passphrase: String,
|
||||
#[serde(rename = "SharePasswordSalt")]
|
||||
pub share_password_salt: String,
|
||||
#[serde(rename = "LinkType", default)]
|
||||
pub link_type: i32,
|
||||
#[serde(rename = "MIMEType", default)]
|
||||
pub mime_type: Option<String>,
|
||||
#[serde(rename = "Name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "NodeKey", default)]
|
||||
pub node_key: String,
|
||||
#[serde(rename = "NodePassphrase", default)]
|
||||
pub node_passphrase: String,
|
||||
#[serde(rename = "ContentKeyPacket", default)]
|
||||
pub content_key_packet: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST v2/urls/{token}/bookmark`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateBookmarkRequest {
|
||||
#[serde(rename = "BookmarkShareURL")]
|
||||
pub bookmark_share_url: BookmarkShareUrlDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BookmarkShareUrlDto {
|
||||
#[serde(rename = "EncryptedUrlPassword")]
|
||||
pub encrypted_url_password: String,
|
||||
#[serde(rename = "AddressID")]
|
||||
pub address_id: AddressId,
|
||||
#[serde(rename = "AddressKeyID")]
|
||||
pub address_key_id: AddressKeyId,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public share links (`shares/{shareID}/urls`) + SRP modulus fetch.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST auth/v4/modulus` (root route) — a fresh signed SRP modulus.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ModulusResponse {
|
||||
#[serde(rename = "Modulus")]
|
||||
pub modulus: String,
|
||||
#[serde(rename = "ModulusID")]
|
||||
pub modulus_id: String,
|
||||
}
|
||||
|
||||
/// `POST shares/{shareID}/urls` — create a public link.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreatePublicLinkRequest {
|
||||
#[serde(rename = "CreatorEmail")]
|
||||
pub creator_email: String,
|
||||
#[serde(rename = "Permissions")]
|
||||
pub permissions: i32,
|
||||
#[serde(rename = "Flags")]
|
||||
pub flags: i32,
|
||||
#[serde(rename = "ExpirationTime", skip_serializing_if = "Option::is_none")]
|
||||
pub expiration_time: Option<i64>,
|
||||
#[serde(rename = "SharePasswordSalt")]
|
||||
pub share_password_salt: String,
|
||||
#[serde(rename = "SharePassphraseKeyPacket")]
|
||||
pub share_passphrase_key_packet: String,
|
||||
#[serde(rename = "Password")]
|
||||
pub password: String,
|
||||
#[serde(rename = "UrlPasswordSalt")]
|
||||
pub url_password_salt: String,
|
||||
#[serde(rename = "SRPVerifier")]
|
||||
pub srp_verifier: String,
|
||||
#[serde(rename = "SRPModulusID")]
|
||||
pub srp_modulus_id: String,
|
||||
#[serde(rename = "MaxAccesses")]
|
||||
pub max_accesses: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatePublicLinkResponse {
|
||||
#[serde(rename = "ShareURL")]
|
||||
pub share_url: ShareUrlDto,
|
||||
}
|
||||
|
||||
/// `GET shares/{shareID}/urls`
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ShareUrlsResponse {
|
||||
#[serde(rename = "ShareURLs", default)]
|
||||
pub share_urls: Vec<ShareUrlDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ShareUrlDto {
|
||||
#[serde(rename = "ShareURLID")]
|
||||
pub share_url_id: String,
|
||||
#[serde(rename = "ShareID", default)]
|
||||
pub share_id: Option<ShareId>,
|
||||
#[serde(rename = "PublicUrl", default)]
|
||||
pub public_url: String,
|
||||
#[serde(rename = "CreateTime", default)]
|
||||
pub create_time: i64,
|
||||
#[serde(rename = "ExpirationTime", default)]
|
||||
pub expiration_time: Option<i64>,
|
||||
#[serde(rename = "Permissions", default)]
|
||||
pub permissions: Option<i32>,
|
||||
#[serde(rename = "Flags", default)]
|
||||
pub flags: i32,
|
||||
#[serde(rename = "NumAccesses", default)]
|
||||
pub num_accesses: i64,
|
||||
}
|
||||
@@ -41,6 +41,7 @@ mod dtos;
|
||||
mod events;
|
||||
mod node;
|
||||
mod photos;
|
||||
mod sharing;
|
||||
|
||||
pub use cache::{CachedNodeInfo, DriveEntityCache};
|
||||
pub use client::ProtonDriveClient;
|
||||
@@ -50,5 +51,9 @@ pub use node::{Node, NodeKind, RevisionState, Thumbnail, ThumbnailType};
|
||||
pub use photos::{PhotoTag, PhotoUploadMetadata, PhotosTimelineItem, ProtonPhotosClient};
|
||||
pub use proton_sdk::account::KeySalt;
|
||||
pub use proton_sdk::cache::{CacheRepository, EncryptedCacheRepository, InMemoryCacheRepository};
|
||||
pub use sharing::{
|
||||
Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole,
|
||||
PublicLink, ShareInvitation, ShareMember,
|
||||
};
|
||||
|
||||
pub use proton_sdk;
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Sharing model: member roles, members and pending invitations.
|
||||
//!
|
||||
//! Covers *creating* shares — inviting Proton users to a node and (see
|
||||
//! [`crate::client`]) managing public links — which the C# public SDK does not
|
||||
//! expose; the behavior is ported from the TypeScript SDK (`internal/sharing`).
|
||||
//! Reading shared-with-me nodes and leaving them lives on the client already.
|
||||
|
||||
use proton_sdk::ids::{NodeUid, ShareId, ShareMembershipId};
|
||||
|
||||
/// A sharing permission role. The wire form is a permissions bitmask
|
||||
/// (`Read = 4`, `Write = 2`, `Admin = 16`); Proton only uses three combinations.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MemberRole {
|
||||
/// Read-only (`4`).
|
||||
Viewer,
|
||||
/// Read + write (`6`).
|
||||
Editor,
|
||||
/// Read + write + admin (`22`).
|
||||
Admin,
|
||||
/// No explicit permissions on the membership; access is inherited. Only ever
|
||||
/// *read* back from the API — it cannot be sent when inviting.
|
||||
Inherited,
|
||||
}
|
||||
|
||||
impl MemberRole {
|
||||
/// The permissions bitmask for this role, or `None` for [`MemberRole::Inherited`]
|
||||
/// (which has no wire representation when inviting). Mirrors JS
|
||||
/// `memberRoleToPermission`.
|
||||
pub fn to_permissions(self) -> Option<i32> {
|
||||
match self {
|
||||
MemberRole::Viewer => Some(4),
|
||||
MemberRole::Editor => Some(6),
|
||||
MemberRole::Admin => Some(22),
|
||||
MemberRole::Inherited => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a permissions bitmask back to a role. Mirrors JS
|
||||
/// `permissionsToMemberRole`: an absent value is inherited; an unknown value
|
||||
/// degrades to [`MemberRole::Viewer`] (the holder can at least read).
|
||||
pub fn from_permissions(permissions: Option<i32>) -> Self {
|
||||
match permissions {
|
||||
None => MemberRole::Inherited,
|
||||
Some(4) => MemberRole::Viewer,
|
||||
Some(6) => MemberRole::Editor,
|
||||
Some(22) => MemberRole::Admin,
|
||||
Some(_) => MemberRole::Viewer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A member of a share (an invitation that has been accepted).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShareMember {
|
||||
/// The share this membership belongs to.
|
||||
pub share_id: ShareId,
|
||||
/// The membership id (used to update the role or remove the member).
|
||||
pub membership_id: ShareMembershipId,
|
||||
/// The member's email address.
|
||||
pub email: String,
|
||||
/// The email of the user who added this member.
|
||||
pub added_by_email: String,
|
||||
/// The member's role.
|
||||
pub role: MemberRole,
|
||||
/// When the member was invited (Unix seconds).
|
||||
pub invitation_time: i64,
|
||||
}
|
||||
|
||||
/// A public share link on a node.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PublicLink {
|
||||
/// The share this link belongs to.
|
||||
pub share_id: ShareId,
|
||||
/// The public-link id (`ShareURLID`; used to remove the link).
|
||||
pub public_link_id: String,
|
||||
/// The full shareable URL, including the `#password` fragment when known
|
||||
/// (present on creation; `None` when only listed without decrypting).
|
||||
pub url: Option<String>,
|
||||
/// The role granted to anyone with the link (Viewer or Editor).
|
||||
pub role: MemberRole,
|
||||
/// When the link was created (Unix seconds).
|
||||
pub creation_time: i64,
|
||||
/// When the link expires (Unix seconds), if an expiry was set.
|
||||
pub expiration_time: Option<i64>,
|
||||
/// Whether the link is additionally protected by a custom password.
|
||||
pub has_custom_password: bool,
|
||||
}
|
||||
|
||||
/// A pending invitation to a Proton user (not yet accepted).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShareInvitation {
|
||||
/// The share this invitation belongs to.
|
||||
pub share_id: ShareId,
|
||||
/// The invitation id (used to delete or update the invitation).
|
||||
pub invitation_id: String,
|
||||
/// The invitee's email address.
|
||||
pub invitee_email: String,
|
||||
/// The email of the inviter.
|
||||
pub inviter_email: String,
|
||||
/// The offered role.
|
||||
pub role: MemberRole,
|
||||
/// When the invitation was created (Unix seconds).
|
||||
pub invitation_time: i64,
|
||||
}
|
||||
|
||||
/// State of an external (non-Proton) invitation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExternalInvitationState {
|
||||
/// The invitee has not yet created a Proton account.
|
||||
Pending,
|
||||
/// The invitee has registered; the invitation can be converted to a member.
|
||||
UserRegistered,
|
||||
}
|
||||
|
||||
impl ExternalInvitationState {
|
||||
/// Map the API's `State` number (`1` = pending, else user-registered).
|
||||
pub fn from_raw(state: i32) -> Self {
|
||||
match state {
|
||||
1 => Self::Pending,
|
||||
_ => Self::UserRegistered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pending invitation to a non-Proton email address. The invitee must sign up
|
||||
/// before the share can be finalized; until then it lives as an external invite.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExternalInvitation {
|
||||
/// The share this invitation belongs to.
|
||||
pub share_id: ShareId,
|
||||
/// The external-invitation id (used to delete or update it).
|
||||
pub invitation_id: String,
|
||||
/// The invitee's email address.
|
||||
pub invitee_email: String,
|
||||
/// The email of the inviter.
|
||||
pub inviter_email: String,
|
||||
/// The offered role.
|
||||
pub role: MemberRole,
|
||||
/// When the invitation was created (Unix seconds).
|
||||
pub invitation_time: i64,
|
||||
/// Whether the invitee has registered yet.
|
||||
pub state: ExternalInvitationState,
|
||||
}
|
||||
|
||||
/// An invitation addressed to the current user (shared *with* me), pending
|
||||
/// accept or reject. The [`invitation_id`](Self::invitation_id) is the handle for
|
||||
/// [`accept_invitation`](crate::ProtonDriveClient::accept_invitation) and
|
||||
/// [`reject_invitation`](crate::ProtonDriveClient::reject_invitation).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingInvitation {
|
||||
/// The invitation id — the handle for accept/reject.
|
||||
pub invitation_id: String,
|
||||
/// The email of the user who shared the item.
|
||||
pub inviter_email: String,
|
||||
/// The address the invitation was sent to (one of ours).
|
||||
pub invitee_email: String,
|
||||
/// The offered role.
|
||||
pub role: MemberRole,
|
||||
/// When the invitation was created (Unix seconds).
|
||||
pub invitation_time: i64,
|
||||
/// The shared node's uid (once accepted it appears in shared-with-me).
|
||||
pub node_uid: NodeUid,
|
||||
/// The shared item's decrypted name, when it could be decrypted.
|
||||
pub node_name: Option<String>,
|
||||
/// Whether the shared item is a folder.
|
||||
pub is_folder: bool,
|
||||
}
|
||||
|
||||
/// A public link the user has saved to their account ("bookmark"). Opening it
|
||||
/// visits the public URL; there is no local copy.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Bookmark {
|
||||
/// The bookmark's token — the handle for
|
||||
/// [`delete_bookmark`](crate::ProtonDriveClient::delete_bookmark).
|
||||
pub token: String,
|
||||
/// The full public URL, including the `#password` fragment.
|
||||
pub url: String,
|
||||
/// The bookmarked item's decrypted name, when it could be decrypted.
|
||||
pub node_name: Option<String>,
|
||||
/// Whether the bookmarked item is a folder.
|
||||
pub is_folder: bool,
|
||||
/// When the bookmark was created (Unix seconds).
|
||||
pub creation_time: i64,
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Live integration: device registration and the account quota read surface,
|
||||
//! against a real Proton account.
|
||||
//!
|
||||
//! Skipped by default. Run against the test account with:
|
||||
//! cargo test -p proton-drive-rs --test live_devices -- --ignored --nocapture
|
||||
//!
|
||||
//! The device test registers a throwaway device and deletes it at the end, so
|
||||
//! the account stays reusable across runs.
|
||||
|
||||
mod common;
|
||||
|
||||
use proton_drive_rs::{DeviceType, ProtonDriveClient};
|
||||
use proton_sdk::ids::DeviceUid;
|
||||
|
||||
/// Delete the device; best-effort, logs on failure.
|
||||
async fn cleanup(client: &ProtonDriveClient, uid: &DeviceUid) {
|
||||
if let Err(e) = client.delete_device(uid).await {
|
||||
eprintln!("[cleanup] delete_device failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// create → enumerate (find + name decrypts) → rename → delete (gone).
|
||||
#[tokio::test]
|
||||
#[ignore = "live: needs test-account credentials"]
|
||||
async fn device_create_enumerate_rename_delete() {
|
||||
let Some(live) = common::live_client().await else {
|
||||
return;
|
||||
};
|
||||
let client = &live.client;
|
||||
|
||||
let name = format!("dev-{}", common::unique_suffix());
|
||||
|
||||
// create
|
||||
let device = client
|
||||
.create_device(&name, DeviceType::Linux)
|
||||
.await
|
||||
.expect("create_device");
|
||||
assert_eq!(
|
||||
device.device_type,
|
||||
DeviceType::Linux,
|
||||
"type must round-trip"
|
||||
);
|
||||
assert_eq!(
|
||||
device.name.as_deref().ok(),
|
||||
Some(name.as_str()),
|
||||
"created device name must match"
|
||||
);
|
||||
let uid = device.uid.clone();
|
||||
|
||||
// enumerate: the new device must appear, and its name must decrypt to what
|
||||
// we set (this exercises the device-share-key name decryption path, which
|
||||
// the offline tests can't reach).
|
||||
let listed = client.enumerate_devices().await.expect("enumerate_devices");
|
||||
let found = listed
|
||||
.iter()
|
||||
.find(|d| d.uid == uid)
|
||||
.expect("the created device must appear in the enumeration");
|
||||
assert_eq!(
|
||||
found.name.as_deref().ok(),
|
||||
Some(name.as_str()),
|
||||
"enumerated device name must decrypt to the created name"
|
||||
);
|
||||
assert_eq!(
|
||||
found.device_type,
|
||||
DeviceType::Linux,
|
||||
"enumerated device type must match"
|
||||
);
|
||||
|
||||
// rename
|
||||
let renamed = format!("{name}-renamed");
|
||||
client
|
||||
.rename_device(&uid, &renamed)
|
||||
.await
|
||||
.expect("rename_device");
|
||||
let after = client
|
||||
.enumerate_devices()
|
||||
.await
|
||||
.expect("enumerate_devices (after rename)");
|
||||
let found = after
|
||||
.iter()
|
||||
.find(|d| d.uid == uid)
|
||||
.expect("device must still be present after rename");
|
||||
assert_eq!(
|
||||
found.name.as_deref().ok(),
|
||||
Some(renamed.as_str()),
|
||||
"rename must take effect and re-decrypt"
|
||||
);
|
||||
|
||||
// delete
|
||||
cleanup(client, &uid).await;
|
||||
let gone = client
|
||||
.enumerate_devices()
|
||||
.await
|
||||
.expect("enumerate_devices (after delete)");
|
||||
assert!(
|
||||
!gone.iter().any(|d| d.uid == uid),
|
||||
"device must be gone after deletion"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account quota (read-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The account quota must read back with sane values.
|
||||
#[tokio::test]
|
||||
#[ignore = "live: needs test-account credentials"]
|
||||
async fn quota_reads() {
|
||||
let Some(live) = common::live_client().await else {
|
||||
return;
|
||||
};
|
||||
let client = &live.client;
|
||||
|
||||
let quota = client.quota().await.expect("quota");
|
||||
eprintln!(
|
||||
"[info] quota: used {} / max {} bytes ({:.1}% used)",
|
||||
quota.used_space,
|
||||
quota.max_space,
|
||||
quota.used_fraction() * 100.0
|
||||
);
|
||||
|
||||
assert!(quota.max_space > 0, "a real account has non-zero max space");
|
||||
assert!(quota.used_space >= 0, "used space cannot be negative");
|
||||
assert!(
|
||||
quota.used_space <= quota.max_space,
|
||||
"used space cannot exceed max space"
|
||||
);
|
||||
assert!(
|
||||
(0.0..=1.0).contains("a.used_fraction()),
|
||||
"used fraction must be a probability"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Live integration: sharing — public links, standard-share creation, and the
|
||||
//! read-only shared-with-me / incoming-invitation surfaces, against a real
|
||||
//! Proton account.
|
||||
//!
|
||||
//! Skipped by default. Run against the test account with:
|
||||
//! cargo test -p proton-drive-rs --test live_sharing -- --ignored --nocapture
|
||||
//!
|
||||
//! Each test creates its own throwaway folder and deletes it at the end
|
||||
//! (deleting the node tears down any share created on it), so the account stays
|
||||
//! reusable across runs.
|
||||
//!
|
||||
//! What is *not* covered here: inviting a real Proton user (`share_node` with a
|
||||
//! non-empty invitee list) and accept/reject flows. Those need a *second*
|
||||
//! account, and inviting a stranger would send them a real email — out of scope
|
||||
//! for a single-account CI harness. We still exercise share *creation* (the
|
||||
//! crypto + `POST shares`) by calling `share_node` with an empty invitee list,
|
||||
//! which provisions the standard share without emailing anyone.
|
||||
|
||||
mod common;
|
||||
|
||||
use proton_drive_rs::{MemberRole, ProtonDriveClient};
|
||||
use proton_sdk::ids::NodeUid;
|
||||
|
||||
/// Trash then permanently delete the given node; best-effort, logs on failure.
|
||||
async fn cleanup(client: &ProtonDriveClient, uid: &NodeUid) {
|
||||
if let Err(e) = client.trash_nodes(std::slice::from_ref(uid)).await {
|
||||
eprintln!("[cleanup] trash failed: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = client.delete_nodes(std::slice::from_ref(uid)).await {
|
||||
eprintln!("[cleanup] delete failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a throwaway folder under My Files, returning its uid.
|
||||
async fn scratch_folder(client: &ProtonDriveClient, tag: &str) -> NodeUid {
|
||||
let root = client
|
||||
.get_my_files_folder()
|
||||
.await
|
||||
.expect("get my-files root");
|
||||
let name = format!("{tag}-{}", common::unique_suffix());
|
||||
client
|
||||
.create_folder(&root.uid, &name, None)
|
||||
.await
|
||||
.expect("create scratch folder")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Full public-link lifecycle on a folder: create → get → remove.
|
||||
#[tokio::test]
|
||||
#[ignore = "live: needs test-account credentials"]
|
||||
async fn public_link_create_get_remove() {
|
||||
let Some(live) = common::live_client().await else {
|
||||
return;
|
||||
};
|
||||
let client = &live.client;
|
||||
|
||||
let folder = scratch_folder(client, "publink").await;
|
||||
|
||||
// No public link yet.
|
||||
assert!(
|
||||
client
|
||||
.get_public_link(&folder)
|
||||
.await
|
||||
.expect("get_public_link (before)")
|
||||
.is_none(),
|
||||
"a fresh folder must have no public link"
|
||||
);
|
||||
|
||||
// Create a viewer link (no custom password, no expiry).
|
||||
let link = client
|
||||
.create_public_link(&folder, MemberRole::Viewer, None, None)
|
||||
.await
|
||||
.expect("create_public_link");
|
||||
|
||||
assert_eq!(link.role, MemberRole::Viewer, "role must round-trip");
|
||||
assert!(
|
||||
!link.has_custom_password,
|
||||
"no custom password was requested"
|
||||
);
|
||||
let url = link.url.as_deref().expect("created link carries its URL");
|
||||
assert!(
|
||||
url.contains('#'),
|
||||
"the URL must carry the secret password fragment: {url}"
|
||||
);
|
||||
|
||||
// Now it must be discoverable via get_public_link.
|
||||
let fetched = client
|
||||
.get_public_link(&folder)
|
||||
.await
|
||||
.expect("get_public_link (after)")
|
||||
.expect("public link must exist after creation");
|
||||
assert_eq!(
|
||||
fetched.public_link_id, link.public_link_id,
|
||||
"get must return the link we just created"
|
||||
);
|
||||
assert_eq!(fetched.role, MemberRole::Viewer, "fetched role must match");
|
||||
|
||||
// Remove it and confirm it is gone.
|
||||
client
|
||||
.remove_public_link(&link)
|
||||
.await
|
||||
.expect("remove_public_link");
|
||||
assert!(
|
||||
client
|
||||
.get_public_link(&folder)
|
||||
.await
|
||||
.expect("get_public_link (after remove)")
|
||||
.is_none(),
|
||||
"public link must be gone after removal"
|
||||
);
|
||||
|
||||
cleanup(client, &folder).await;
|
||||
}
|
||||
|
||||
/// A public link with a custom password reports `has_custom_password` and still
|
||||
/// round-trips through get/remove.
|
||||
#[tokio::test]
|
||||
#[ignore = "live: needs test-account credentials"]
|
||||
async fn public_link_with_custom_password() {
|
||||
let Some(live) = common::live_client().await else {
|
||||
return;
|
||||
};
|
||||
let client = &live.client;
|
||||
|
||||
let folder = scratch_folder(client, "publink-pw").await;
|
||||
|
||||
let link = client
|
||||
.create_public_link(&folder, MemberRole::Editor, Some("s3cr3t!"), None)
|
||||
.await
|
||||
.expect("create_public_link with custom password");
|
||||
|
||||
assert_eq!(link.role, MemberRole::Editor, "editor role must round-trip");
|
||||
assert!(
|
||||
link.has_custom_password,
|
||||
"a custom password must set has_custom_password"
|
||||
);
|
||||
|
||||
let fetched = client
|
||||
.get_public_link(&folder)
|
||||
.await
|
||||
.expect("get_public_link")
|
||||
.expect("link must exist");
|
||||
assert!(
|
||||
fetched.has_custom_password,
|
||||
"the listed link must also report the custom password"
|
||||
);
|
||||
|
||||
client
|
||||
.remove_public_link(&link)
|
||||
.await
|
||||
.expect("remove_public_link");
|
||||
|
||||
cleanup(client, &folder).await;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standard share creation (no real invitees)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `share_node` with an empty invitee list provisions the standard share (the
|
||||
/// crypto + `POST shares`) without emailing anyone; the members and pending
|
||||
/// invitations then read back empty.
|
||||
#[tokio::test]
|
||||
#[ignore = "live: needs test-account credentials"]
|
||||
async fn share_node_creates_share_and_lists_are_empty() {
|
||||
let Some(live) = common::live_client().await else {
|
||||
return;
|
||||
};
|
||||
let client = &live.client;
|
||||
|
||||
let folder = scratch_folder(client, "share").await;
|
||||
|
||||
// Not shared yet: both listings are empty.
|
||||
assert!(
|
||||
client
|
||||
.list_share_members(&folder)
|
||||
.await
|
||||
.expect("list_share_members (before)")
|
||||
.is_empty(),
|
||||
"an unshared folder has no members"
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.list_share_invitations(&folder)
|
||||
.await
|
||||
.expect("list_share_invitations (before)")
|
||||
.is_empty(),
|
||||
"an unshared folder has no invitations"
|
||||
);
|
||||
|
||||
// Provision the share without inviting anyone.
|
||||
let created = client
|
||||
.share_node(&folder, &[], None)
|
||||
.await
|
||||
.expect("share_node with no invitees");
|
||||
assert!(
|
||||
created.is_empty(),
|
||||
"no invitees means no invitations created: {created:?}"
|
||||
);
|
||||
|
||||
// The share now exists, but with no members and no pending invitations.
|
||||
assert!(
|
||||
client
|
||||
.list_share_members(&folder)
|
||||
.await
|
||||
.expect("list_share_members (after)")
|
||||
.is_empty(),
|
||||
"no one was invited, so there are no members"
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.list_share_invitations(&folder)
|
||||
.await
|
||||
.expect("list_share_invitations (after)")
|
||||
.is_empty(),
|
||||
"no one was invited, so there are no pending invitations"
|
||||
);
|
||||
|
||||
cleanup(client, &folder).await;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only shared-with-me / incoming invitations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The shared-with-me and incoming-invitation read surfaces must resolve without
|
||||
/// error even when there is nothing shared with this account (they typically
|
||||
/// return empty on the throwaway test account).
|
||||
#[tokio::test]
|
||||
#[ignore = "live: needs test-account credentials"]
|
||||
async fn shared_with_me_and_incoming_invitations_read() {
|
||||
let Some(live) = common::live_client().await else {
|
||||
return;
|
||||
};
|
||||
let client = &live.client;
|
||||
|
||||
let shared = client
|
||||
.enumerate_shared_with_me_node_uids()
|
||||
.await
|
||||
.expect("enumerate_shared_with_me_node_uids must not error");
|
||||
eprintln!("[info] shared-with-me count: {}", shared.len());
|
||||
|
||||
let incoming = client
|
||||
.list_incoming_invitations()
|
||||
.await
|
||||
.expect("list_incoming_invitations must not error");
|
||||
eprintln!("[info] incoming invitations count: {}", incoming.len());
|
||||
}
|
||||
@@ -37,6 +37,7 @@ num-bigint.workspace = true
|
||||
num-traits.workspace = true
|
||||
getrandom.workspace = true
|
||||
rand.workspace = true
|
||||
rand_08.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# A one-shot loopback HTTP server backs the telemetry-instrumentation test;
|
||||
|
||||
@@ -43,6 +43,31 @@ pub struct Address {
|
||||
pub primary_key_id: AddressKeyId,
|
||||
}
|
||||
|
||||
/// Total account storage usage, in bytes (all Proton products, not Drive-only).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Quota {
|
||||
/// Total storage available to the account.
|
||||
pub max_space: i64,
|
||||
/// Storage currently used.
|
||||
pub used_space: i64,
|
||||
}
|
||||
|
||||
impl Quota {
|
||||
/// Bytes still free (never negative).
|
||||
pub fn available(&self) -> i64 {
|
||||
(self.max_space - self.used_space).max(0)
|
||||
}
|
||||
|
||||
/// Fraction of the quota in use, `0.0..=1.0` (0 when `max_space` is 0).
|
||||
pub fn used_fraction(&self) -> f64 {
|
||||
if self.max_space <= 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.used_space as f64 / self.max_space as f64).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves account keys needed to decrypt Drive metadata.
|
||||
///
|
||||
/// Construction takes the mailbox (data) password because, per Proton's key
|
||||
@@ -238,6 +263,18 @@ impl AccountClient {
|
||||
keys
|
||||
}
|
||||
|
||||
/// Total account storage usage (`core/v4/users`).
|
||||
///
|
||||
/// Account-wide `MaxSpace`/`UsedSpace` across all Proton products, not the
|
||||
/// Drive-only quota. Not cached — reflects live usage on each call.
|
||||
pub async fn quota(&self) -> Result<Quota> {
|
||||
let response: UserResponse = self.inner.http.get("core/v4/users").await?;
|
||||
Ok(Quota {
|
||||
max_space: response.user.max_space,
|
||||
used_space: response.user.used_space,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypted user (account) keys.
|
||||
async fn user_keys(&self) -> Result<Vec<PrivateKey>> {
|
||||
{
|
||||
|
||||
@@ -20,6 +20,12 @@ pub(super) struct UserResponse {
|
||||
pub(super) struct UserDto {
|
||||
#[serde(rename = "ID")]
|
||||
pub id: UserId,
|
||||
/// Total account storage in bytes (all Proton products).
|
||||
#[serde(rename = "MaxSpace")]
|
||||
pub max_space: i64,
|
||||
/// Used account storage in bytes.
|
||||
#[serde(rename = "UsedSpace")]
|
||||
pub used_space: i64,
|
||||
#[serde(rename = "Keys")]
|
||||
pub keys: Vec<UserKeyDto>,
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ impl EncryptedCacheRepository {
|
||||
|
||||
fn encrypt(&self, entry_key: &str, plaintext: &str) -> Result<String> {
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
getrandom::getrandom(&mut salt)
|
||||
getrandom::fill(&mut salt)
|
||||
.map_err(|e| ProtonError::invalid_operation(format!("cache salt: {e}")))?;
|
||||
let (key, nonce) = self.derive(&salt, entry_key)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
||||
@@ -241,7 +241,7 @@ impl EncryptedCacheRepository {
|
||||
// `ciphertext || tag` — matching the C# `[salt][ciphertext][tag]` layout.
|
||||
let sealed = cipher
|
||||
.encrypt(
|
||||
Nonce::from_slice(&nonce),
|
||||
&Nonce::from(nonce),
|
||||
Payload {
|
||||
msg: plaintext.as_bytes(),
|
||||
aad: &[],
|
||||
@@ -268,7 +268,7 @@ impl EncryptedCacheRepository {
|
||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
||||
.map_err(|e| ProtonError::invalid_operation(format!("cache cipher: {e}")))?;
|
||||
match cipher.decrypt(
|
||||
Nonce::from_slice(&nonce),
|
||||
&Nonce::from(nonce),
|
||||
Payload {
|
||||
msg: sealed,
|
||||
aad: &[],
|
||||
|
||||
@@ -17,12 +17,14 @@ mod verify;
|
||||
pub use content::ContentKey;
|
||||
pub use derive::derive_key_passphrase;
|
||||
pub use encrypt::{
|
||||
GeneratedNodeKey, VolumeCreationMaterial, build_volume_creation_material,
|
||||
generate_node_hash_key, generate_node_key, generate_node_key_aead,
|
||||
GeneratedNodeKey, SHARING_EXTERNAL_INVITATION_CONTEXT, SHARING_INVITER_CONTEXT,
|
||||
SHARING_MEMBER_CONTEXT, StandardShareMaterial, VolumeCreationMaterial, accept_invitation,
|
||||
build_standard_share_material, build_volume_creation_material, encrypt_external_invitation,
|
||||
encrypt_invitation, generate_node_hash_key, generate_node_key, generate_node_key_aead,
|
||||
};
|
||||
pub use errors::CryptoError;
|
||||
pub use keys::{PrivateKey, decrypt_armored_with_keys};
|
||||
pub use srp::{DEFAULT_BIT_LENGTH, SrpProofs, generate_proofs};
|
||||
pub use srp::{DEFAULT_BIT_LENGTH, SrpProofs, SrpVerifier, generate_proofs, generate_verifier};
|
||||
pub use verify::{PublicKey, VerificationKeyRing, VerificationStatus, verify_detached};
|
||||
|
||||
/// Result alias for crypto operations.
|
||||
|
||||
@@ -15,13 +15,16 @@ use pgp::crypto::hash::HashAlgorithm;
|
||||
use pgp::crypto::sym::SymmetricKeyAlgorithm;
|
||||
use pgp::packet::{
|
||||
LiteralData, Packet, PacketParser, PacketTrait, PublicKeyEncryptedSessionKey,
|
||||
SymEncryptedProtectedData,
|
||||
SymEncryptedProtectedData, SymKeyEncryptedSessionKey,
|
||||
};
|
||||
use pgp::types::{
|
||||
DecryptionKey, EncryptionKey, EskType, Password, PkeskVersion, Seipdv1ReadMode, StringToKey,
|
||||
};
|
||||
use pgp::types::{DecryptionKey, EncryptionKey, EskType, PkeskVersion, Seipdv1ReadMode};
|
||||
|
||||
use super::encrypt::{RecipientOp, recipient_encryption_key};
|
||||
use super::encrypt::{RecipientOp, recipient_encryption_key, recipient_encryption_key_public};
|
||||
use super::errors::CryptoError;
|
||||
use super::keys::PrivateKey;
|
||||
use super::verify::PublicKey;
|
||||
|
||||
/// 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
|
||||
@@ -47,7 +50,7 @@ impl ContentKey {
|
||||
///
|
||||
/// Mirrors C# `PgpSessionKey.Generate()`.
|
||||
pub fn generate() -> Self {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut rng = rand_08::thread_rng();
|
||||
let sym_alg = SymmetricKeyAlgorithm::AES256;
|
||||
let key = sym_alg.new_session_key(&mut rng);
|
||||
ContentKey {
|
||||
@@ -61,7 +64,7 @@ impl ContentKey {
|
||||
/// content key packet as a v6 PKESK. Mirrors C#
|
||||
/// `PgpSessionKey.GenerateForAead()`.
|
||||
pub fn generate_aead() -> Self {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut rng = rand_08::thread_rng();
|
||||
let key = SymmetricKeyAlgorithm::AES256.new_session_key(&mut rng);
|
||||
ContentKey {
|
||||
session_key: PlainSessionKey::V6 { key },
|
||||
@@ -106,7 +109,7 @@ impl ContentKey {
|
||||
signer: &PrivateKey,
|
||||
plaintext: &[u8],
|
||||
) -> Result<Vec<u8>, CryptoError> {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut rng = rand_08::thread_rng();
|
||||
|
||||
// Build a signed (but unencrypted) message: one-pass signature, literal
|
||||
// data, then the signature packet. `seipd_*` would generate its own
|
||||
@@ -131,7 +134,7 @@ impl ContentKey {
|
||||
/// [`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 mut rng = rand_08::thread_rng();
|
||||
|
||||
let seipd = if self.is_aead() {
|
||||
SymEncryptedProtectedData::encrypt_seipdv2(
|
||||
@@ -175,6 +178,146 @@ impl ContentKey {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Encrypt this session key to a recipient **public** key as a PKESK.
|
||||
///
|
||||
/// Used for the sharing invitation `KeyPacket`: the share's passphrase
|
||||
/// session key delivered to an invitee whose public key we resolved via
|
||||
/// `core/v4/keys/all`. Mirrors JS `encryptSessionKey(shareSessionKey,
|
||||
/// inviteePublicKey)`.
|
||||
pub fn to_packet_for_public(&self, pubkey: &PublicKey) -> Result<Vec<u8>, CryptoError> {
|
||||
let (sym_alg, key) = self.session_parts()?;
|
||||
|
||||
let packet = if self.is_aead() {
|
||||
recipient_encryption_key_public(pubkey.signed(), ContentKeyPacketV6Op { key })?
|
||||
} else {
|
||||
recipient_encryption_key_public(pubkey.signed(), ContentKeyPacketOp { sym_alg, key })?
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
packet
|
||||
.to_writer_with_header(&mut out)
|
||||
.map_err(|e| CryptoError::Encrypt(format!("serialize session key packet: {e}")))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Encrypt `data` to one or more recipient keys under this session key,
|
||||
/// returning an armored PGP message: a PKESK per recipient followed by the
|
||||
/// SEIPD body. No inline signature — Proton signs share/node passphrases with
|
||||
/// a *detached* signature carried separately.
|
||||
///
|
||||
/// Used to build a standard share's `SharePassphrase`, which must be
|
||||
/// decryptable by both the node key and the owning address key (JS
|
||||
/// `generateKey([nodeKey, addressKey], addressKey)`), while keeping this
|
||||
/// session key so it can later be handed to invitees.
|
||||
pub fn encrypt_message_to(
|
||||
&self,
|
||||
recipients: &[&PrivateKey],
|
||||
data: &[u8],
|
||||
) -> Result<String, CryptoError> {
|
||||
if recipients.is_empty() {
|
||||
return Err(CryptoError::Encrypt(
|
||||
"encrypt_message_to needs at least one recipient".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut message = Vec::new();
|
||||
for recipient in recipients {
|
||||
message.extend_from_slice(&self.to_packet(recipient)?);
|
||||
}
|
||||
message.extend_from_slice(&self.encrypt_block(data)?);
|
||||
|
||||
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}")))
|
||||
}
|
||||
|
||||
/// Encrypt and inline-sign `data` as **text** under *this* session key,
|
||||
/// wrapped to `recipient`, returning an armored PGP message (PKESK + SEIPD).
|
||||
///
|
||||
/// Unlike [`PrivateKey::encrypt_and_sign`](crate::crypto::PrivateKey::encrypt_and_sign),
|
||||
/// which mints a fresh session key, this reuses the session key already held
|
||||
/// here. Renaming a device's root folder needs exactly this: Proton's rename
|
||||
/// endpoint keeps the link's existing name *key packet* and replaces only the
|
||||
/// *data packet*, so the new name text must be encrypted under the **same**
|
||||
/// session key as the current name — otherwise the retained key packet can no
|
||||
/// longer decrypt the data packet (MDC failure). Mirrors C#
|
||||
/// `DeviceCrypto.GetRenameRequest` /
|
||||
/// `PgpEncrypter.EncryptAndSignText(name, EncryptionSecrets(shareKey, nameSessionKey), signingKey)`,
|
||||
/// where `nameSessionKey` is recovered from the current name via
|
||||
/// [`PrivateKey::recover_message_session_key`](crate::crypto::PrivateKey::recover_message_session_key).
|
||||
pub fn encrypt_and_sign_text_to(
|
||||
&self,
|
||||
recipient: &PrivateKey,
|
||||
signer: &PrivateKey,
|
||||
data: &[u8],
|
||||
) -> Result<String, CryptoError> {
|
||||
let mut rng = rand_08::thread_rng();
|
||||
|
||||
// Build a signed (but unencrypted) *text* message — one-pass signature,
|
||||
// text literal, then the signature packet — then seal it under this
|
||||
// session key (as [`encrypt_thumbnail`](Self::encrypt_thumbnail) does).
|
||||
let mut builder = MessageBuilder::from_bytes(bytes::Bytes::new(), data.to_vec());
|
||||
builder.sign_text();
|
||||
builder.sign(
|
||||
&signer.key().primary_key,
|
||||
signer.password(),
|
||||
HashAlgorithm::Sha256,
|
||||
);
|
||||
let inner = builder
|
||||
.to_vec(&mut rng)
|
||||
.map_err(|e| CryptoError::Encrypt(format!("sign name: {e}")))?;
|
||||
|
||||
let mut message = self.to_packet(recipient)?;
|
||||
message.extend_from_slice(&self.seal_seipd(&inner)?);
|
||||
|
||||
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}")))
|
||||
}
|
||||
|
||||
/// Encrypt this session key **symmetrically** under a password as a v4 SKESK
|
||||
/// packet, returning the raw packet bytes.
|
||||
///
|
||||
/// Used for a public share link's `SharePassphraseKeyPacket`: the share
|
||||
/// session key wrapped with the SRP-salted passphrase so anyone with the link
|
||||
/// password can open it. The S2K (salt + iteration count) travels inside the
|
||||
/// packet, so the recipient re-derives the key from the same password. Mirrors
|
||||
/// JS `encryptSessionKeyWithPassword`.
|
||||
pub fn encrypt_with_password(&self, password: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||
let (sym_alg, key) = self.session_parts()?;
|
||||
let mut rng = rand_08::thread_rng();
|
||||
let s2k = StringToKey::new_default(&mut rng);
|
||||
let skesk = SymKeyEncryptedSessionKey::encrypt_v4(
|
||||
&Password::from(password),
|
||||
&RawSessionKey::from(key),
|
||||
s2k,
|
||||
sym_alg,
|
||||
)
|
||||
.map_err(|e| CryptoError::Encrypt(format!("skesk encrypt: {e}")))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
skesk
|
||||
.to_writer_with_header(&mut out)
|
||||
.map_err(|e| CryptoError::Encrypt(format!("serialize skesk: {e}")))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The raw symmetric key bytes of this content key.
|
||||
///
|
||||
/// Mirrors C# `PgpSessionKey.Export()`; used to produce the file's
|
||||
@@ -350,6 +493,23 @@ impl PrivateKey {
|
||||
/// 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))`.
|
||||
/// Recover the session key of an armored PGP message addressed to this key.
|
||||
///
|
||||
/// De-armors the message and decrypts its leading PKESK to recover the
|
||||
/// symmetric session key (the trailing SEIPD is ignored). Used when creating
|
||||
/// a share to lift a node's passphrase/name session keys so they can be
|
||||
/// re-encrypted to the share key (JS `getNodePrivateAndSessionKeys`).
|
||||
pub fn recover_message_session_key(
|
||||
&self,
|
||||
armored_message: &str,
|
||||
) -> Result<ContentKey, CryptoError> {
|
||||
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}")))?;
|
||||
self.decrypt_content_key(&raw)
|
||||
}
|
||||
|
||||
pub fn rewrap_message_to(
|
||||
&self,
|
||||
armored_message: &str,
|
||||
@@ -430,7 +590,7 @@ impl RecipientOp for ContentKeyPacketOp<'_> {
|
||||
type Out = PublicKeyEncryptedSessionKey;
|
||||
|
||||
fn run(self, pubkey: &impl EncryptionKey) -> pgp::errors::Result<Self::Out> {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut rng = rand_08::thread_rng();
|
||||
let session_key = RawSessionKey::from(self.key);
|
||||
PublicKeyEncryptedSessionKey::from_session_key_v3(
|
||||
&mut rng,
|
||||
@@ -451,7 +611,7 @@ impl RecipientOp for ContentKeyPacketV6Op<'_> {
|
||||
type Out = PublicKeyEncryptedSessionKey;
|
||||
|
||||
fn run(self, pubkey: &impl EncryptionKey) -> pgp::errors::Result<Self::Out> {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut rng = rand_08::thread_rng();
|
||||
let session_key = RawSessionKey::from(self.key);
|
||||
PublicKeyEncryptedSessionKey::from_session_key_v6(&mut rng, &session_key, pubkey)
|
||||
}
|
||||
@@ -468,7 +628,7 @@ mod tests {
|
||||
/// 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 mut rng = rand_08::thread_rng();
|
||||
let params = SecretKeyParamsBuilder::default()
|
||||
.key_type(KeyType::Rsa(2048))
|
||||
.can_sign(true)
|
||||
@@ -483,7 +643,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn content_key_packet_and_block_round_trip() {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut rng = rand_08::thread_rng();
|
||||
let signed = generate_node_key("pw");
|
||||
let armored = signed
|
||||
.to_armored_string(None.into())
|
||||
@@ -513,6 +673,42 @@ mod tests {
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_key_password_skesk_round_trip() {
|
||||
// Public-link path: wrap the share session key under a password as a
|
||||
// SKESK, then re-derive the key from the same password (via the packet's
|
||||
// own S2K) and recover the session key.
|
||||
let content_key = ContentKey::generate();
|
||||
let password = b"the-link-password";
|
||||
|
||||
let skesk_bytes = content_key
|
||||
.encrypt_with_password(password)
|
||||
.expect("skesk encrypt");
|
||||
|
||||
// The bytes are a bare SKESK (no data packet); parse the packet directly.
|
||||
let mut parser = PacketParser::new(Cursor::new(&skesk_bytes));
|
||||
let packet = parser.next().expect("a packet").expect("valid packet");
|
||||
let Packet::SymKeyEncryptedSessionKey(skesk) = packet else {
|
||||
panic!("expected a SKESK packet");
|
||||
};
|
||||
|
||||
// Re-derive the symmetric key from the password using the packet's S2K,
|
||||
// then decrypt the wrapped session key.
|
||||
let s2k = skesk.s2k().expect("skesk has s2k");
|
||||
let alg = SymmetricKeyAlgorithm::AES256;
|
||||
let derived = s2k
|
||||
.derive_key(&password[..], alg.key_size())
|
||||
.expect("derive key");
|
||||
let recovered = skesk.decrypt(derived).expect("decrypt skesk");
|
||||
|
||||
match &recovered {
|
||||
PlainSessionKey::V3_4 { key, .. } => {
|
||||
assert_eq!(key.as_ref(), content_key.export().unwrap());
|
||||
}
|
||||
other => panic!("unexpected session key variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_content_key_round_trip() {
|
||||
// Upload path: generate a content key, seal it to the node key as a
|
||||
@@ -559,6 +755,85 @@ mod tests {
|
||||
assert_eq!(recovered.decrypt_block(&block).expect("decrypt"), plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_reusing_session_key_survives_key_packet_retention() {
|
||||
// Device rename: Proton keeps the link's existing name *key packet* (PKESK)
|
||||
// and swaps only its *data packet* (SEIPD). Reproduce that here — encrypt a
|
||||
// fresh name to the share key, recover its session key, encrypt a new name
|
||||
// under that same session key, then reassemble the message as the server
|
||||
// would (original key packet + new data packet) and confirm it decrypts.
|
||||
let share = super::super::encrypt::generate_node_key().expect("generate share key");
|
||||
let signer = super::super::encrypt::generate_node_key().expect("generate signer");
|
||||
|
||||
// The original name, encrypted to the share key with a fresh session key.
|
||||
let original = share
|
||||
.key
|
||||
.encrypt_and_sign(&signer.key, b"my-device", true, false)
|
||||
.expect("encrypt original name");
|
||||
assert_eq!(
|
||||
share
|
||||
.key
|
||||
.decrypt_armored_message(&original)
|
||||
.expect("decrypt original"),
|
||||
b"my-device"
|
||||
);
|
||||
|
||||
// Recover that session key and re-encrypt the new name under it.
|
||||
let name_session_key = share
|
||||
.key
|
||||
.recover_message_session_key(&original)
|
||||
.expect("recover name session key");
|
||||
let renamed = name_session_key
|
||||
.encrypt_and_sign_text_to(&share.key, &signer.key, b"my-device-renamed")
|
||||
.expect("encrypt renamed");
|
||||
|
||||
// A naive re-encrypt would round-trip on its own, but the server discards
|
||||
// our key packet. Reassemble original-PKESK + renamed-SEIPD and decrypt.
|
||||
let original_pkesk = {
|
||||
let mut raw = Vec::new();
|
||||
let mut dearmor = Dearmor::new(Cursor::new(original.as_bytes()));
|
||||
std::io::Read::read_to_end(&mut dearmor, &mut raw).expect("de-armor original");
|
||||
let mut parser = PacketParser::new(Cursor::new(&raw));
|
||||
let packet = parser.next().expect("a packet").expect("valid packet");
|
||||
let Packet::PublicKeyEncryptedSessionKey(pkesk) = packet else {
|
||||
panic!("expected a leading PKESK");
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
pkesk
|
||||
.to_writer_with_header(&mut out)
|
||||
.expect("serialize pkesk");
|
||||
out
|
||||
};
|
||||
let renamed_seipd = {
|
||||
let mut raw = Vec::new();
|
||||
let mut dearmor = Dearmor::new(Cursor::new(renamed.as_bytes()));
|
||||
std::io::Read::read_to_end(&mut dearmor, &mut raw).expect("de-armor renamed");
|
||||
serialize_seipd(&raw).expect("serialize renamed seipd")
|
||||
};
|
||||
|
||||
let mut server_stored = original_pkesk;
|
||||
server_stored.extend_from_slice(&renamed_seipd);
|
||||
let mut armored = Vec::new();
|
||||
pgp::armor::write(
|
||||
&RawPackets(&server_stored),
|
||||
pgp::armor::BlockType::Message,
|
||||
&mut armored,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("armor server-stored message");
|
||||
let armored = String::from_utf8(armored).expect("utf8");
|
||||
|
||||
assert_eq!(
|
||||
share
|
||||
.key
|
||||
.decrypt_armored_message(&armored)
|
||||
.expect("decrypt server-reassembled renamed name"),
|
||||
b"my-device-renamed",
|
||||
"reusing the recovered session key must keep the retained key packet valid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_encrypt_inline_signed_round_trip() {
|
||||
// A thumbnail block is encrypt-and-inline-signed under the content key.
|
||||
@@ -698,7 +973,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_non_pkesk_content_key_packet() {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut rng = rand_08::thread_rng();
|
||||
let key = PrivateKey::from_armored(
|
||||
&generate_node_key("pw")
|
||||
.to_armored_string(None.into())
|
||||
|
||||
@@ -11,13 +11,13 @@ use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use bytes::Bytes;
|
||||
use pgp::composed::{
|
||||
ArmorOptions, DetachedSignature, EncryptionCaps, KeyType, MessageBuilder,
|
||||
SecretKeyParamsBuilder, SignedSecretKey, SubkeyParamsBuilder,
|
||||
SecretKeyParamsBuilder, SignedPublicKey, SignedSecretKey, SubkeyParamsBuilder,
|
||||
};
|
||||
use pgp::crypto::hash::HashAlgorithm;
|
||||
use pgp::crypto::sym::SymmetricKeyAlgorithm;
|
||||
use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData};
|
||||
use pgp::packet::{Notation, SignatureConfig, SignatureType, Subpacket, SubpacketData};
|
||||
use pgp::types::{CompressionAlgorithm, EncryptionKey, KeyDetails, KeyVersion};
|
||||
use rand::RngCore;
|
||||
use rand_08::RngCore;
|
||||
|
||||
use super::errors::CryptoError;
|
||||
use super::keys::PrivateKey;
|
||||
@@ -64,6 +64,33 @@ pub(crate) fn recipient_encryption_key<Op: RecipientOp>(
|
||||
))
|
||||
}
|
||||
|
||||
/// Like [`recipient_encryption_key`] but for a recipient we only hold the
|
||||
/// **public** key of (e.g. a sharing invitee resolved via `core/v4/keys/all`).
|
||||
///
|
||||
/// `EncryptionKey for SignedPublicKey` encrypts to the *primary* key, but Proton
|
||||
/// keys have a signing primary and a separate encryption subkey, so we must pick
|
||||
/// the encryption-capable (sub)key ourselves — same rule as the secret path.
|
||||
pub(crate) fn recipient_encryption_key_public<Op: RecipientOp>(
|
||||
key: &SignedPublicKey,
|
||||
op: Op,
|
||||
) -> Result<Op::Out, CryptoError> {
|
||||
if key.primary_key.algorithm().can_encrypt() {
|
||||
return op
|
||||
.run(&key.primary_key)
|
||||
.map_err(|e| CryptoError::Encrypt(format!("encrypt to primary key: {e}")));
|
||||
}
|
||||
for sub in &key.public_subkeys {
|
||||
if sub.key.algorithm().can_encrypt() {
|
||||
return op
|
||||
.run(&sub.key)
|
||||
.map_err(|e| CryptoError::Encrypt(format!("encrypt to subkey: {e}")));
|
||||
}
|
||||
}
|
||||
Err(CryptoError::Encrypt(
|
||||
"public 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.
|
||||
@@ -99,7 +126,7 @@ pub fn generate_node_key_aead() -> Result<GeneratedNodeKey, CryptoError> {
|
||||
/// Generate a node key (Ed25519 primary + X25519 encryption subkey) at the given
|
||||
/// OpenPGP key version. v4 for legacy files/folders, v6 for AEAD files.
|
||||
fn generate_node_key_versioned(version: KeyVersion) -> Result<GeneratedNodeKey, CryptoError> {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut rng = rand_08::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}")))?;
|
||||
@@ -144,7 +171,7 @@ fn generate_node_key_versioned(version: KeyVersion) -> Result<GeneratedNodeKey,
|
||||
/// 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);
|
||||
rand_08::thread_rng().fill_bytes(&mut bytes);
|
||||
BASE64.encode(bytes).into_bytes()
|
||||
}
|
||||
|
||||
@@ -158,7 +185,7 @@ fn generate_passphrase() -> Vec<u8> {
|
||||
/// 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);
|
||||
rand_08::thread_rng().fill_bytes(&mut bytes);
|
||||
encrypt_and_sign(node_key.key(), Some(node_key), &bytes, false, false)
|
||||
}
|
||||
|
||||
@@ -216,7 +243,7 @@ pub fn build_volume_creation_material(
|
||||
let folder_passphrase_signature = address_key.sign_detached(&folder.passphrase)?;
|
||||
|
||||
let mut hash_key = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut hash_key);
|
||||
rand_08::thread_rng().fill_bytes(&mut hash_key);
|
||||
let folder_hash_key = folder
|
||||
.key
|
||||
.encrypt_and_sign(address_key, &hash_key, false, false)?;
|
||||
@@ -257,7 +284,7 @@ pub(crate) fn encrypt_and_sign(
|
||||
/// 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 mut rng = rand_08::thread_rng();
|
||||
let key = &signer.key().primary_key;
|
||||
let pw = signer.password();
|
||||
|
||||
@@ -293,6 +320,212 @@ pub(crate) fn sign_detached(signer: &PrivateKey, data: &[u8]) -> Result<String,
|
||||
.map_err(|e| CryptoError::Encrypt(format!("armor signature: {e}")))
|
||||
}
|
||||
|
||||
/// The notation name GopenPGP uses to carry a signature *context* for domain
|
||||
/// separation (`context@proton.ch`, critical). Proton signs sharing invitations,
|
||||
/// members and external invitations under distinct contexts so a signature made
|
||||
/// for one purpose cannot be replayed as another.
|
||||
const SIGNATURE_CONTEXT_NOTATION: &[u8] = b"context@proton.ch";
|
||||
|
||||
/// Like [`sign_detached`] but stamps a GopenPGP signature *context* — a critical
|
||||
/// `context@proton.ch` notation — into the hashed subpackets, and returns the
|
||||
/// **raw** (binary) detached-signature packet bytes rather than armoring them.
|
||||
///
|
||||
/// The sharing endpoints carry these signatures as base64 of the binary packet
|
||||
/// (JS `signature.toBase64()`), not as an armored block. Mirrors the JS
|
||||
/// `openPGPCrypto.sign(data, key, { signatureContext: { critical: true, value } })`.
|
||||
pub(crate) fn sign_detached_binary_with_context(
|
||||
signer: &PrivateKey,
|
||||
data: &[u8],
|
||||
context: &str,
|
||||
) -> Result<Vec<u8>, CryptoError> {
|
||||
let mut rng = rand_08::thread_rng();
|
||||
let key = &signer.key().primary_key;
|
||||
let pw = signer.password();
|
||||
|
||||
let mut config = SignatureConfig::from_key(&mut rng, key, SignatureType::Binary)
|
||||
.map_err(|e| CryptoError::Encrypt(format!("signature config: {e}")))?;
|
||||
|
||||
// Same subpacket stamping as `sign_detached` (issuer fingerprint + creation
|
||||
// time, else GopenPGP rejects the signature) plus the critical context
|
||||
// notation the server verifies against.
|
||||
config.hashed_subpackets = vec![
|
||||
Subpacket::regular(SubpacketData::IssuerFingerprint(key.fingerprint()))
|
||||
.map_err(|e| CryptoError::Encrypt(format!("issuer fingerprint subpacket: {e}")))?,
|
||||
Subpacket::regular(SubpacketData::SignatureCreationTime(
|
||||
pgp::types::Timestamp::now(),
|
||||
))
|
||||
.map_err(|e| CryptoError::Encrypt(format!("creation-time subpacket: {e}")))?,
|
||||
Subpacket::critical(SubpacketData::Notation(Notation {
|
||||
readable: true,
|
||||
name: Bytes::from_static(SIGNATURE_CONTEXT_NOTATION),
|
||||
value: Bytes::copy_from_slice(context.as_bytes()),
|
||||
}))
|
||||
.map_err(|e| CryptoError::Encrypt(format!("context notation subpacket: {e}")))?,
|
||||
];
|
||||
if key.version() <= KeyVersion::V4 {
|
||||
config.unhashed_subpackets = vec![
|
||||
Subpacket::regular(SubpacketData::IssuerKeyId(key.legacy_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}")))?;
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
pgp::ser::Serialize::to_writer(&DetachedSignature::new(signature), &mut bytes)
|
||||
.map_err(|e| CryptoError::Encrypt(format!("serialize signature: {e}")))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// GopenPGP signing context for the inviter's signature over a sharing
|
||||
/// invitation key packet (`drive.share-member.inviter`).
|
||||
pub const SHARING_INVITER_CONTEXT: &str = "drive.share-member.inviter";
|
||||
/// GopenPGP signing context for an accepting member's signature over the share
|
||||
/// session key (`drive.share-member.member`).
|
||||
pub const SHARING_MEMBER_CONTEXT: &str = "drive.share-member.member";
|
||||
/// GopenPGP signing context for the inviter's signature over an external
|
||||
/// (non-Proton) invitation (`drive.share-member.external-invitation`).
|
||||
pub const SHARING_EXTERNAL_INVITATION_CONTEXT: &str = "drive.share-member.external-invitation";
|
||||
|
||||
/// The locked share key plus the crypto payload the `volumes/{vid}/shares`
|
||||
/// endpoint needs to create a standard (shared) share on a node.
|
||||
///
|
||||
/// Mirrors JS `SharingCryptoService.generateShareKeys` output. The `share_key`
|
||||
/// and `share_session_key` are kept (not sent) so invitations can encrypt the
|
||||
/// share session key to invitees right after the share is created.
|
||||
pub struct StandardShareMaterial {
|
||||
/// Locked share secret key (armored) — `ShareKey`.
|
||||
pub share_key_armored: String,
|
||||
/// Share passphrase encrypted to `[node_key, address_key]` — `SharePassphrase`.
|
||||
pub share_passphrase: String,
|
||||
/// Detached signature over the share passphrase by the address key.
|
||||
pub share_passphrase_signature: String,
|
||||
/// base64 PKESK: the node's passphrase session key re-encrypted to the share
|
||||
/// key — `PassphraseKeyPacket` (lets the share decrypt the node passphrase).
|
||||
pub passphrase_key_packet: String,
|
||||
/// base64 PKESK: the node's name session key re-encrypted to the share key —
|
||||
/// `NameKeyPacket`.
|
||||
pub name_key_packet: String,
|
||||
/// The unlocked share key (not sent; kept for follow-up crypto).
|
||||
pub share_key: PrivateKey,
|
||||
/// The share passphrase session key (not sent; handed to invitees).
|
||||
pub share_session_key: super::content::ContentKey,
|
||||
}
|
||||
|
||||
/// Build the crypto material for a standard share over a node.
|
||||
///
|
||||
/// Mirrors JS `generateShareKeys(nodeKeys, addressKey)`:
|
||||
/// - a fresh share key is generated and locked with a random passphrase;
|
||||
/// - that passphrase is encrypted (via a fresh session key) to **both** the node
|
||||
/// key and the address key, and detached-signed by the address key — so any
|
||||
/// admin holding the node key, and the owner, can unwrap the share;
|
||||
/// - the node's own passphrase and name session keys are re-encrypted to the new
|
||||
/// share key, so the share grants access to the node's passphrase and name.
|
||||
pub fn build_standard_share_material(
|
||||
node_key: &PrivateKey,
|
||||
node_passphrase_session_key: &super::content::ContentKey,
|
||||
node_name_session_key: &super::content::ContentKey,
|
||||
address_key: &PrivateKey,
|
||||
) -> Result<StandardShareMaterial, CryptoError> {
|
||||
let generated = generate_node_key()?;
|
||||
|
||||
let share_session_key = super::content::ContentKey::generate();
|
||||
let share_passphrase =
|
||||
share_session_key.encrypt_message_to(&[node_key, address_key], &generated.passphrase)?;
|
||||
let share_passphrase_signature = address_key.sign_detached(&generated.passphrase)?;
|
||||
|
||||
let passphrase_key_packet =
|
||||
BASE64.encode(node_passphrase_session_key.to_packet(&generated.key)?);
|
||||
let name_key_packet = BASE64.encode(node_name_session_key.to_packet(&generated.key)?);
|
||||
|
||||
Ok(StandardShareMaterial {
|
||||
share_key_armored: generated.locked_armored,
|
||||
share_passphrase,
|
||||
share_passphrase_signature,
|
||||
passphrase_key_packet,
|
||||
name_key_packet,
|
||||
share_key: generated.key,
|
||||
share_session_key,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encrypt a sharing invitation for a Proton user.
|
||||
///
|
||||
/// The share's passphrase session key is encrypted to the invitee's public key
|
||||
/// (`KeyPacket`) and the packet bytes are detached-signed by the inviter's
|
||||
/// address key under the `drive.share-member.inviter` context
|
||||
/// (`KeyPacketSignature`). Both are returned base64-encoded. Mirrors JS
|
||||
/// `encryptInvitation`.
|
||||
pub fn encrypt_invitation(
|
||||
share_session_key: &super::content::ContentKey,
|
||||
inviter_key: &PrivateKey,
|
||||
invitee_public_key: &super::verify::PublicKey,
|
||||
) -> Result<(String, String), CryptoError> {
|
||||
let key_packet = share_session_key.to_packet_for_public(invitee_public_key)?;
|
||||
let signature =
|
||||
sign_detached_binary_with_context(inviter_key, &key_packet, SHARING_INVITER_CONTEXT)?;
|
||||
Ok((BASE64.encode(&key_packet), BASE64.encode(&signature)))
|
||||
}
|
||||
|
||||
/// Sign an accepted invitation's share session key.
|
||||
///
|
||||
/// The invitation's `KeyPacket` (base64 PKESK addressed to the invitee) is
|
||||
/// decrypted with the invitee's address keys to recover the share session key,
|
||||
/// whose raw bytes are detached-signed under the `drive.share-member.member`
|
||||
/// context by the invitee's key. Returns the base64 signature the
|
||||
/// `.../accept` endpoint expects as `SessionKeySignature`. Mirrors JS
|
||||
/// `acceptInvitation`.
|
||||
pub fn accept_invitation(
|
||||
base64_key_packet: &str,
|
||||
invitee_keys: &[PrivateKey],
|
||||
signing_key: &PrivateKey,
|
||||
) -> Result<String, CryptoError> {
|
||||
let key_packet = BASE64
|
||||
.decode(base64_key_packet)
|
||||
.map_err(|e| CryptoError::Parse(format!("invitation key packet base64: {e}")))?;
|
||||
|
||||
let session_key = invitee_keys
|
||||
.iter()
|
||||
.find_map(|k| k.decrypt_content_key(&key_packet).ok())
|
||||
.ok_or_else(|| {
|
||||
CryptoError::Decrypt("no invitee key could decrypt the invitation key packet".into())
|
||||
})?;
|
||||
|
||||
let signature = sign_detached_binary_with_context(
|
||||
signing_key,
|
||||
&session_key.export()?,
|
||||
SHARING_MEMBER_CONTEXT,
|
||||
)?;
|
||||
Ok(BASE64.encode(&signature))
|
||||
}
|
||||
|
||||
/// Sign an external (non-Proton) invitation.
|
||||
///
|
||||
/// The invitee has no Proton key to encrypt the share session key to, so instead
|
||||
/// the inviter signs the payload `"{invitee_email}|{base64(share session key)}"`
|
||||
/// under the `drive.share-member.external-invitation` context. When the invitee
|
||||
/// later signs up, the server can bind the pending invitation to their new key.
|
||||
/// Returns the base64 signature the external-invitations endpoint expects.
|
||||
/// Mirrors JS `encryptExternalInvitation`.
|
||||
pub fn encrypt_external_invitation(
|
||||
share_session_key: &super::content::ContentKey,
|
||||
inviter_key: &PrivateKey,
|
||||
invitee_email: &str,
|
||||
) -> Result<String, CryptoError> {
|
||||
let payload = format!(
|
||||
"{invitee_email}|{}",
|
||||
BASE64.encode(share_session_key.export()?)
|
||||
);
|
||||
let signature = sign_detached_binary_with_context(
|
||||
inviter_key,
|
||||
payload.as_bytes(),
|
||||
SHARING_EXTERNAL_INVITATION_CONTEXT,
|
||||
)?;
|
||||
Ok(BASE64.encode(&signature))
|
||||
}
|
||||
|
||||
/// Inline encrypt-and-sign against a selected recipient public key.
|
||||
struct EncryptSignOp<'a> {
|
||||
data: Vec<u8>,
|
||||
@@ -305,7 +538,7 @@ impl RecipientOp for EncryptSignOp<'_> {
|
||||
type Out = String;
|
||||
|
||||
fn run(self, pubkey: &impl EncryptionKey) -> pgp::errors::Result<String> {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut rng = rand_08::thread_rng();
|
||||
let mut builder = MessageBuilder::from_bytes(Bytes::new(), self.data)
|
||||
.seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256);
|
||||
|
||||
@@ -439,4 +672,155 @@ mod tests {
|
||||
.expect("decrypt hash key");
|
||||
assert_eq!(hash_key.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_share_material_round_trips() {
|
||||
use super::super::content::ContentKey;
|
||||
|
||||
// Stand-ins for the node key, the owning address key, and the node's own
|
||||
// passphrase + name session keys.
|
||||
let node = generate_node_key().expect("generate node key");
|
||||
let address = generate_node_key().expect("generate address key");
|
||||
let node_passphrase_sk = ContentKey::generate();
|
||||
let node_name_sk = ContentKey::generate();
|
||||
|
||||
let material = build_standard_share_material(
|
||||
&node.key,
|
||||
&node_passphrase_sk,
|
||||
&node_name_sk,
|
||||
&address.key,
|
||||
)
|
||||
.expect("build share material");
|
||||
|
||||
// The share passphrase must be decryptable by BOTH the node key and the
|
||||
// address key, and its detached signature verify against the address key.
|
||||
let via_node = node
|
||||
.key
|
||||
.decrypt_armored_message(&material.share_passphrase)
|
||||
.expect("decrypt share passphrase via node key");
|
||||
let via_address = address
|
||||
.key
|
||||
.decrypt_armored_message(&material.share_passphrase)
|
||||
.expect("decrypt share passphrase via address key");
|
||||
assert_eq!(via_node, via_address);
|
||||
address
|
||||
.key
|
||||
.verify_detached_signature(&material.share_passphrase_signature, &via_node)
|
||||
.expect("verify share passphrase signature");
|
||||
|
||||
// That passphrase unlocks the share key.
|
||||
let share_key =
|
||||
PrivateKey::from_armored(&material.share_key_armored, &via_node).expect("unlock share");
|
||||
|
||||
// The passphrase/name key packets re-deliver the node's session keys to
|
||||
// the share key: decrypting them with the share key yields the originals.
|
||||
let recovered_pp = share_key
|
||||
.decrypt_content_key(&BASE64.decode(&material.passphrase_key_packet).unwrap())
|
||||
.expect("recover passphrase session key");
|
||||
assert_eq!(
|
||||
recovered_pp.export().unwrap(),
|
||||
node_passphrase_sk.export().unwrap()
|
||||
);
|
||||
let recovered_name = share_key
|
||||
.decrypt_content_key(&BASE64.decode(&material.name_key_packet).unwrap())
|
||||
.expect("recover name session key");
|
||||
assert_eq!(
|
||||
recovered_name.export().unwrap(),
|
||||
node_name_sk.export().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invitation_key_packet_round_trips_to_invitee() {
|
||||
use super::super::content::ContentKey;
|
||||
use super::super::verify::PublicKey;
|
||||
use pgp::composed::Deserializable as _;
|
||||
|
||||
let node = generate_node_key().expect("generate node key");
|
||||
let address = generate_node_key().expect("generate address key");
|
||||
let material = build_standard_share_material(
|
||||
&node.key,
|
||||
&ContentKey::generate(),
|
||||
&ContentKey::generate(),
|
||||
&address.key,
|
||||
)
|
||||
.expect("build share material");
|
||||
|
||||
// The invitee is any Proton-shaped key; we hold only its public half.
|
||||
let invitee = generate_node_key().expect("generate invitee key");
|
||||
let invitee_pub_armored = invitee
|
||||
.key
|
||||
.signed_public_key()
|
||||
.to_armored_string(Default::default())
|
||||
.expect("armor invitee public key");
|
||||
let invitee_pub = PublicKey::from_armored(&invitee_pub_armored).expect("parse invitee pub");
|
||||
|
||||
let (key_packet_b64, signature_b64) =
|
||||
encrypt_invitation(&material.share_session_key, &address.key, &invitee_pub)
|
||||
.expect("encrypt invitation");
|
||||
|
||||
// The invitee recovers the share session key from the key packet.
|
||||
let key_packet = BASE64.decode(&key_packet_b64).expect("decode key packet");
|
||||
let recovered = invitee
|
||||
.key
|
||||
.decrypt_content_key(&key_packet)
|
||||
.expect("invitee decrypts key packet");
|
||||
assert_eq!(
|
||||
recovered.export().unwrap(),
|
||||
material.share_session_key.export().unwrap()
|
||||
);
|
||||
|
||||
// The inviter's context signature over the key packet parses and verifies
|
||||
// against the inviter (address) key.
|
||||
let sig_bytes = BASE64.decode(&signature_b64).expect("decode signature");
|
||||
let sig = DetachedSignature::from_bytes(&sig_bytes[..]).expect("parse signature");
|
||||
sig.signature
|
||||
.verify(&address.key.signed_public_key(), &key_packet[..])
|
||||
.expect("verify inviter context signature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_with_a_clone_does_not_corrupt_the_original_for_decryption() {
|
||||
// Regression for the live device-rename failure: the account caches
|
||||
// unlocked address keys and hands out `PrivateKey` *clones*. If signing
|
||||
// with a clone mutated shared secret-key state (pgp interior mutability),
|
||||
// a later decryption with the cached original would fail with an MDC
|
||||
// error. Prove clone + sign leaves the original intact for decryption.
|
||||
let address = generate_node_key().expect("generate address key");
|
||||
|
||||
// Something encrypted to the address key (stands in for a share passphrase).
|
||||
let secret = b"share passphrase bytes".to_vec();
|
||||
let armored = address
|
||||
.key
|
||||
.encrypt(&secret)
|
||||
.expect("encrypt to address key");
|
||||
|
||||
// First decryption works.
|
||||
assert_eq!(
|
||||
address
|
||||
.key
|
||||
.decrypt_armored_message(&armored)
|
||||
.expect("decrypt #1"),
|
||||
secret
|
||||
);
|
||||
|
||||
// Sign with a *clone* of the cached key (what rename_device does).
|
||||
let clone = address.key.clone();
|
||||
let _sig = clone
|
||||
.sign_detached(b"device name")
|
||||
.expect("sign with clone");
|
||||
let _enc = clone
|
||||
.encrypt_and_sign(&clone, b"device name", true, false)
|
||||
.expect("encrypt+sign with clone");
|
||||
|
||||
// The original must still decrypt afterwards.
|
||||
assert_eq!(
|
||||
address
|
||||
.key
|
||||
.decrypt_armored_message(&armored)
|
||||
.expect("decrypt #2 after signing with clone"),
|
||||
secret,
|
||||
"signing with a clone must not corrupt the original key"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,45 @@ pub struct SrpProofs {
|
||||
pub expected_server_proof: Vec<u8>,
|
||||
}
|
||||
|
||||
/// An SRP verifier for a password, as sent when creating a Proton public share
|
||||
/// link. Mirrors go-srp `GenerateVerifier` / GopenPGP `getSrpVerifier`.
|
||||
pub struct SrpVerifier {
|
||||
/// base64 of the random 10-byte SRP salt (`UrlPasswordSalt`).
|
||||
pub salt: String,
|
||||
/// base64 of the little-endian verifier bytes (`SRPVerifier`).
|
||||
pub verifier: String,
|
||||
}
|
||||
|
||||
/// Generate an SRP verifier `v = g^x mod N` for `password`, where `g = 2`, `N`
|
||||
/// is Proton's signed modulus, and `x` is the Proton password hash over a fresh
|
||||
/// random salt. Used to secure a public share link with a password.
|
||||
///
|
||||
/// Mirrors go-srp `GenerateVerifier`: the salt is 10 random bytes and the
|
||||
/// verifier is emitted little-endian, both base64-encoded.
|
||||
pub fn generate_verifier(
|
||||
password: &[u8],
|
||||
signed_modulus: &str,
|
||||
bit_length: usize,
|
||||
) -> Result<SrpVerifier, CryptoError> {
|
||||
let byte_len = bit_length / 8;
|
||||
let modulus = verify_and_decode_modulus(signed_modulus)?;
|
||||
|
||||
let mut salt = [0u8; 10];
|
||||
getrandom::fill(&mut salt).map_err(|e| CryptoError::Decrypt(format!("rng: {e}")))?;
|
||||
|
||||
let hashed_password = hash_password_v3(password, &salt, &modulus)?;
|
||||
let n = from_le(&modulus);
|
||||
let x = from_le(&hashed_password);
|
||||
let g = BigUint::from(2u32);
|
||||
let verifier = g.modpow(&x, &n);
|
||||
let verifier_le = to_le_fixed(&verifier, byte_len)?;
|
||||
|
||||
Ok(SrpVerifier {
|
||||
salt: BASE64.encode(salt),
|
||||
verifier: BASE64.encode(verifier_le),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -174,7 +213,7 @@ fn generate_ephemeral(
|
||||
|
||||
loop {
|
||||
let mut buf = vec![0u8; byte_len];
|
||||
getrandom::getrandom(&mut buf).map_err(|e| CryptoError::Decrypt(format!("rng: {e}")))?;
|
||||
getrandom::fill(&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;
|
||||
|
||||
@@ -55,7 +55,7 @@ impl PublicKey {
|
||||
Ok(Self { key })
|
||||
}
|
||||
|
||||
fn signed(&self) -> &SignedPublicKey {
|
||||
pub(crate) fn signed(&self) -> &SignedPublicKey {
|
||||
&self.key
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::Rng;
|
||||
use rand::RngExt;
|
||||
use reqwest::{Method, StatusCode};
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
@@ -573,7 +573,7 @@ fn backoff(policy: &RetryPolicy, attempt: u32) -> Duration {
|
||||
if ceiling_ms == 0 {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
Duration::from_millis(rand::thread_rng().gen_range(0..=ceiling_ms))
|
||||
Duration::from_millis(rand::rng().random_range(0..=ceiling_ms))
|
||||
}
|
||||
|
||||
fn ensure_trailing_slash(url: &str) -> String {
|
||||
|
||||
Reference in new issue
Block a user