feature (devices+sharing) + upgraded rand and aeds

This commit is contained in:
nvrl committed 2026-07-15 19:34:40 +02:00
1 parent 78567bf00e
commit 64f977edb3
19 files changed
+2830 -93

No files matched your search

Generated
+155 -41
View File
@@ -15,10 +15,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [ dependencies = [
"bytes", "bytes",
"crypto-common 0.1.7", "crypto-common 0.1.6",
"generic-array", "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]] [[package]]
name = "aes" name = "aes"
version = "0.8.4" version = "0.8.4"
@@ -30,17 +40,42 @@ dependencies = [
"cpufeatures 0.2.17", "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]] [[package]]
name = "aes-gcm" name = "aes-gcm"
version = "0.10.3" version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [ dependencies = [
"aead", "aead 0.5.2",
"aes", "aes 0.8.4",
"cipher 0.4.4", "cipher 0.4.4",
"ctr", "ctr 0.9.2",
"ghash", "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", "subtle",
] ]
@@ -50,7 +85,7 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c"
dependencies = [ dependencies = [
"aes", "aes 0.8.4",
] ]
[[package]] [[package]]
@@ -269,9 +304,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.12.0" version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "bzip2" name = "bzip2"
@@ -340,6 +375,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 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]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.45" version = "0.4.45"
@@ -359,7 +405,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [ dependencies = [
"crypto-common 0.1.7", "crypto-common 0.1.6",
"inout 0.1.4", "inout 0.1.4",
] ]
@@ -369,6 +415,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [ dependencies = [
"block-buffer 0.12.1",
"crypto-common 0.2.2", "crypto-common 0.2.2",
"inout 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -484,9 +537,9 @@ dependencies = [
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core 0.6.4", "rand_core 0.6.4",
@@ -499,7 +552,9 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [ dependencies = [
"getrandom 0.4.3",
"hybrid-array", "hybrid-array",
"rand_core 0.10.1",
] ]
[[package]] [[package]]
@@ -511,6 +566,15 @@ dependencies = [
"cipher 0.4.4", "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]] [[package]]
name = "ctutils" name = "ctutils"
version = "0.4.2" version = "0.4.2"
@@ -690,7 +754,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer 0.10.4", "block-buffer 0.10.4",
"const-oid 0.9.6", "const-oid 0.9.6",
"crypto-common 0.1.7", "crypto-common 0.1.6",
"subtle", "subtle",
] ]
@@ -745,10 +809,10 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28"
dependencies = [ dependencies = [
"aead", "aead 0.5.2",
"cipher 0.4.4", "cipher 0.4.4",
"cmac", "cmac",
"ctr", "ctr 0.9.2",
"subtle", "subtle",
] ]
@@ -966,9 +1030,9 @@ dependencies = [
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
dependencies = [ dependencies = [
"typenum", "typenum",
"version_check", "version_check",
@@ -1011,6 +1075,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1",
] ]
[[package]] [[package]]
@@ -1020,7 +1085,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [ dependencies = [
"opaque-debug", "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]] [[package]]
@@ -1505,9 +1579,9 @@ dependencies = [
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.2" version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]] [[package]]
name = "mime" name = "mime"
@@ -1557,9 +1631,9 @@ dependencies = [
[[package]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.6" version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [ dependencies = [
"num-integer", "num-integer",
"num-traits", "num-traits",
@@ -1639,9 +1713,9 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb"
dependencies = [ dependencies = [
"aead", "aead 0.5.2",
"cipher 0.4.4", "cipher 0.4.4",
"ctr", "ctr 0.9.2",
"subtle", "subtle",
] ]
@@ -1733,9 +1807,9 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
dependencies = [ dependencies = [
"aead", "aead 0.5.2",
"aes", "aes 0.8.4",
"aes-gcm", "aes-gcm 0.10.3",
"aes-kw", "aes-kw",
"argon2", "argon2",
"base64", "base64",
@@ -1833,7 +1907,18 @@ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
"opaque-debug", "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]] [[package]]
@@ -1874,12 +1959,13 @@ dependencies = [
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.1.5" version = "0.1.6"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64", "base64",
"bytes", "bytes",
"futures", "futures",
"getrandom 0.4.3",
"hex", "hex",
"hmac 0.13.0", "hmac 0.13.0",
"pgp", "pgp",
@@ -1897,22 +1983,23 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.1.5" version = "0.1.6"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm 0.11.0",
"async-trait", "async-trait",
"base64", "base64",
"bcrypt", "bcrypt",
"bytes", "bytes",
"chrono", "chrono",
"futures", "futures",
"getrandom 0.2.17", "getrandom 0.4.3",
"hex", "hex",
"hkdf 0.13.0", "hkdf 0.13.0",
"hmac 0.13.0", "hmac 0.13.0",
"num-bigint", "num-bigint",
"num-traits", "num-traits",
"pgp", "pgp",
"rand 0.10.2",
"rand 0.8.6", "rand 0.8.6",
"reqwest", "reqwest",
"serde", "serde",
@@ -1954,7 +2041,7 @@ dependencies = [
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.3.4",
"lru-slab", "lru-slab",
"rand 0.9.4", "rand 0.9.5",
"ring", "ring",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
@@ -2020,14 +2107,25 @@ dependencies = [
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.4" version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [ dependencies = [
"rand_chacha 0.9.0", "rand_chacha 0.9.0",
"rand_core 0.9.5", "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]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.3.1" version = "0.3.1"
@@ -2066,6 +2164,12 @@ dependencies = [
"getrandom 0.3.4", "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]] [[package]]
name = "replace_with" name = "replace_with"
version = "0.1.8" version = "0.1.8"
@@ -2259,9 +2363,9 @@ dependencies = [
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]] [[package]]
name = "same-file" name = "same-file"
@@ -2841,10 +2945,20 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [ dependencies = [
"crypto-common 0.1.7", "crypto-common 0.1.6",
"subtle", "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]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -3363,18 +3477,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.52" version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.52" version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+8 -5
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/proton-sdk", "crates/proton-drive-rs"] members = ["crates/proton-sdk", "crates/proton-drive-rs"]
[workspace.package] [workspace.package]
version = "0.1.5" version = "0.1.6"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://github.com/narrrl/proton-sdk-rs" repository = "https://github.com/narrrl/proton-sdk-rs"
@@ -28,16 +28,19 @@ hex = "0.4"
hmac = "0.13" hmac = "0.13"
sha1 = "0.11" sha1 = "0.11"
sha2 = "0.11" sha2 = "0.11"
aes-gcm = "0.10" aes-gcm = "0.11"
hkdf = "0.13" hkdf = "0.13"
bcrypt = "0.19" bcrypt = "0.19"
pgp = "0.20" pgp = "0.20"
# Match the chrono line rPGP pulls in; used to stamp detached-signature subpackets. # Match the chrono line rPGP pulls in; used to stamp detached-signature subpackets.
chrono = "0.4" chrono = "0.4"
tracing = "0.1" tracing = "0.1"
num-bigint = "0.4" num-bigint = "0.5"
num-traits = "0.2" 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 # 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. # 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" }
+2 -1
View File
@@ -14,7 +14,7 @@ readme = "../../README.md"
documentation = "https://docs.rs/proton-drive-rs" documentation = "https://docs.rs/proton-drive-rs"
[dependencies] [dependencies]
proton-sdk = { path = "../proton-sdk", version = "0.1.5" } proton-sdk = { path = "../proton-sdk", version = "0.1.6" }
tokio.workspace = true tokio.workspace = true
reqwest.workspace = true reqwest.workspace = true
serde.workspace = true serde.workspace = true
@@ -25,6 +25,7 @@ async-trait.workspace = true
futures.workspace = true futures.workspace = true
bytes.workspace = true bytes.workspace = true
base64.workspace = true base64.workspace = true
getrandom.workspace = true
hex.workspace = true hex.workspace = true
hmac.workspace = true hmac.workspace = true
sha1.workspace = true sha1.workspace = true
+899 -15
View File
@@ -13,8 +13,10 @@ use proton_sdk::account::{AccountClient, KeySalt};
use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository}; use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository};
use proton_sdk::crypto::PrivateKey; use proton_sdk::crypto::PrivateKey;
use proton_sdk::crypto::{ use proton_sdk::crypto::{
ContentKey, VerificationKeyRing, VerificationStatus, build_volume_creation_material, ContentKey, DEFAULT_BIT_LENGTH, VerificationKeyRing, VerificationStatus, accept_invitation,
generate_node_hash_key, generate_node_key, generate_node_key_aead, verify_detached, 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::error::{ProtonError, Result};
use proton_sdk::http::ApiHttpClient; use proton_sdk::http::ApiHttpClient;
@@ -35,25 +37,35 @@ use crate::crypto::{
}; };
use crate::devices::{Device, DeviceMetadata, DeviceType}; use crate::devices::{Device, DeviceMetadata, DeviceType};
use crate::dtos::{ use crate::dtos::{
AggregateLinksResponse, BlockCreationRequest, BlockDto, BlockUploadPreparationRequest, AcceptInvitationRequest, AggregateLinksResponse, BlockCreationRequest, BlockDto,
BlockUploadPreparationResponse, BlockVerificationInputResponse, BlockVerifier, BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockVerificationInputResponse,
CommonExtendedAttributes, DeviceCreationDeviceDto, DeviceCreationLinkDto, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, CommonExtendedAttributes,
DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse, CreateBookmarkRequest, CreatePublicLinkRequest, CreatePublicLinkResponse, CreateShareRequest,
DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes, FileContentDigests, CreateShareResponse, DeviceCreationDeviceDto, DeviceCreationLinkDto, DeviceCreationRequest,
FileCreationRequest, FileCreationResponse, FolderChildrenResponse, FolderCreationRequest, DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse, DeviceUpdateRequest,
FolderCreationResponse, LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, DeviceUpdateShareDto, ExtendedAttributes, ExternalInvitationDto, ExternalInvitationResponseDto,
LinkDetailsResponse, LinkDto, LinkType, MoveLinkRequest, MoveMultipleLinksItem, ExternalInvitationsResponse, FileContentDigests, FileCreationRequest, FileCreationResponse,
MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse, 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, NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotosAttributesDto,
RenameLinkRequest, RevisionCreationRequest, RevisionCreationResponse, RevisionDto, RenameLinkRequest, RevisionCreationRequest, RevisionCreationResponse, RevisionDto,
RevisionResponse, RevisionUpdateRequest, ShareResponse, ShareTargetType, SharedWithMeResponse, RevisionResponse, RevisionUpdateRequest, ShareInvitationDto, ShareInvitationsResponse,
ShareMembersResponse, ShareResponse, ShareTargetType, ShareUrlsResponse, SharedWithMeResponse,
ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto,
TimelinePhotoListResponse, VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, TimelinePhotoListResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto,
VolumeTrashResponse, VolumeEventListResponse, VolumeTrashResponse,
}; };
use crate::events::{DriveEvent, DriveEventScopeId}; use crate::events::{DriveEvent, DriveEventScopeId};
use crate::node::{FileThumbnail, Node, NodeKind, RevisionState, Thumbnail, ThumbnailType}; use crate::node::{FileThumbnail, Node, NodeKind, RevisionState, Thumbnail, ThumbnailType};
use crate::photos::{PhotoUploadMetadata, PhotosTimelineItem}; 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`). /// Content blocks are 4 MiB of plaintext each (C# `RevisionWriter.DefaultBlockSize`).
const DEFAULT_BLOCK_SIZE: usize = 1 << 22; const DEFAULT_BLOCK_SIZE: usize = 1 << 22;
@@ -209,6 +221,13 @@ impl ProtonDriveClient {
&self.account &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 /// Attach a telemetry observer to receive a
/// [`TelemetryEvent`](proton_sdk::telemetry::TelemetryEvent) for each /// [`TelemetryEvent`](proton_sdk::telemetry::TelemetryEvent) for each
/// instrumented operation (transfers, navigation, mutations) plus a /// instrumented operation (transfers, navigation, mutations) plus a
@@ -1625,6 +1644,777 @@ impl ProtonDriveClient {
Ok(()) 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. /// The account's registered devices, with their root-folder names decrypted.
/// ///
/// Mirrors C# `DeviceOperations.EnumerateDevicesAsync` (`GET devices` then /// Mirrors C# `DeviceOperations.EnumerateDevicesAsync` (`GET devices` then
@@ -1731,8 +2521,28 @@ impl ProtonDriveClient {
.resolve_membership_address(membership_address_id) .resolve_membership_address(membership_address_id)
.await?; .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(&current_name)?;
let encrypted_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 { let request = RenameLinkRequest {
name: encrypted_name, name: encrypted_name,
name_hash: String::new(), 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. /// The current Unix epoch, in seconds.
fn now_epoch_seconds() -> i64 { fn now_epoch_seconds() -> i64 {
std::time::SystemTime::now() std::time::SystemTime::now()
+414
View File
@@ -1119,3 +1119,417 @@ mod tests {
assert!(ShareTargetType::from_raw(6).is_none()); 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,
}
+5
View File
@@ -41,6 +41,7 @@ mod dtos;
mod events; mod events;
mod node; mod node;
mod photos; mod photos;
mod sharing;
pub use cache::{CachedNodeInfo, DriveEntityCache}; pub use cache::{CachedNodeInfo, DriveEntityCache};
pub use client::ProtonDriveClient; 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 photos::{PhotoTag, PhotoUploadMetadata, PhotosTimelineItem, ProtonPhotosClient};
pub use proton_sdk::account::KeySalt; pub use proton_sdk::account::KeySalt;
pub use proton_sdk::cache::{CacheRepository, EncryptedCacheRepository, InMemoryCacheRepository}; pub use proton_sdk::cache::{CacheRepository, EncryptedCacheRepository, InMemoryCacheRepository};
pub use sharing::{
Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole,
PublicLink, ShareInvitation, ShareMember,
};
pub use proton_sdk; pub use proton_sdk;
+184
View File
@@ -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(&quota.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());
}
+1
View File
@@ -37,6 +37,7 @@ num-bigint.workspace = true
num-traits.workspace = true num-traits.workspace = true
getrandom.workspace = true getrandom.workspace = true
rand.workspace = true rand.workspace = true
rand_08.workspace = true
[dev-dependencies] [dev-dependencies]
# A one-shot loopback HTTP server backs the telemetry-instrumentation test; # A one-shot loopback HTTP server backs the telemetry-instrumentation test;
+37
View File
@@ -43,6 +43,31 @@ pub struct Address {
pub primary_key_id: AddressKeyId, 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. /// Resolves account keys needed to decrypt Drive metadata.
/// ///
/// Construction takes the mailbox (data) password because, per Proton's key /// Construction takes the mailbox (data) password because, per Proton's key
@@ -238,6 +263,18 @@ impl AccountClient {
keys 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. /// Decrypted user (account) keys.
async fn user_keys(&self) -> Result<Vec<PrivateKey>> { async fn user_keys(&self) -> Result<Vec<PrivateKey>> {
{ {
+6
View File
@@ -20,6 +20,12 @@ pub(super) struct UserResponse {
pub(super) struct UserDto { pub(super) struct UserDto {
#[serde(rename = "ID")] #[serde(rename = "ID")]
pub id: UserId, 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")] #[serde(rename = "Keys")]
pub keys: Vec<UserKeyDto>, pub keys: Vec<UserKeyDto>,
} }
+3 -3
View File
@@ -232,7 +232,7 @@ impl EncryptedCacheRepository {
fn encrypt(&self, entry_key: &str, plaintext: &str) -> Result<String> { fn encrypt(&self, entry_key: &str, plaintext: &str) -> Result<String> {
let mut salt = [0u8; SALT_LEN]; let mut salt = [0u8; SALT_LEN];
getrandom::getrandom(&mut salt) getrandom::fill(&mut salt)
.map_err(|e| ProtonError::invalid_operation(format!("cache salt: {e}")))?; .map_err(|e| ProtonError::invalid_operation(format!("cache salt: {e}")))?;
let (key, nonce) = self.derive(&salt, entry_key)?; let (key, nonce) = self.derive(&salt, entry_key)?;
let cipher = Aes256Gcm::new_from_slice(&key) let cipher = Aes256Gcm::new_from_slice(&key)
@@ -241,7 +241,7 @@ impl EncryptedCacheRepository {
// `ciphertext || tag` — matching the C# `[salt][ciphertext][tag]` layout. // `ciphertext || tag` — matching the C# `[salt][ciphertext][tag]` layout.
let sealed = cipher let sealed = cipher
.encrypt( .encrypt(
Nonce::from_slice(&nonce), &Nonce::from(nonce),
Payload { Payload {
msg: plaintext.as_bytes(), msg: plaintext.as_bytes(),
aad: &[], aad: &[],
@@ -268,7 +268,7 @@ impl EncryptedCacheRepository {
let cipher = Aes256Gcm::new_from_slice(&key) let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|e| ProtonError::invalid_operation(format!("cache cipher: {e}")))?; .map_err(|e| ProtonError::invalid_operation(format!("cache cipher: {e}")))?;
match cipher.decrypt( match cipher.decrypt(
Nonce::from_slice(&nonce), &Nonce::from(nonce),
Payload { Payload {
msg: sealed, msg: sealed,
aad: &[], aad: &[],
+5 -3
View File
@@ -17,12 +17,14 @@ mod verify;
pub use content::ContentKey; pub use content::ContentKey;
pub use derive::derive_key_passphrase; pub use derive::derive_key_passphrase;
pub use encrypt::{ pub use encrypt::{
GeneratedNodeKey, VolumeCreationMaterial, build_volume_creation_material, GeneratedNodeKey, SHARING_EXTERNAL_INVITATION_CONTEXT, SHARING_INVITER_CONTEXT,
generate_node_hash_key, generate_node_key, generate_node_key_aead, 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 errors::CryptoError;
pub use keys::{PrivateKey, decrypt_armored_with_keys}; 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}; pub use verify::{PublicKey, VerificationKeyRing, VerificationStatus, verify_detached};
/// Result alias for crypto operations. /// Result alias for crypto operations.
+287 -12
View File
@@ -15,13 +15,16 @@ use pgp::crypto::hash::HashAlgorithm;
use pgp::crypto::sym::SymmetricKeyAlgorithm; use pgp::crypto::sym::SymmetricKeyAlgorithm;
use pgp::packet::{ use pgp::packet::{
LiteralData, Packet, PacketParser, PacketTrait, PublicKeyEncryptedSessionKey, 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::errors::CryptoError;
use super::keys::PrivateKey; use super::keys::PrivateKey;
use super::verify::PublicKey;
/// The AEAD algorithm Proton uses for SEIPDv2 content blocks: AES-256-GCM. /// 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 /// Encrypted v6 session keys carry no algorithm info, so it is fixed here (the
@@ -47,7 +50,7 @@ impl ContentKey {
/// ///
/// Mirrors C# `PgpSessionKey.Generate()`. /// Mirrors C# `PgpSessionKey.Generate()`.
pub fn generate() -> Self { pub fn generate() -> Self {
let mut rng = rand::thread_rng(); let mut rng = rand_08::thread_rng();
let sym_alg = SymmetricKeyAlgorithm::AES256; let sym_alg = SymmetricKeyAlgorithm::AES256;
let key = sym_alg.new_session_key(&mut rng); let key = sym_alg.new_session_key(&mut rng);
ContentKey { ContentKey {
@@ -61,7 +64,7 @@ impl ContentKey {
/// content key packet as a v6 PKESK. Mirrors C# /// content key packet as a v6 PKESK. Mirrors C#
/// `PgpSessionKey.GenerateForAead()`. /// `PgpSessionKey.GenerateForAead()`.
pub fn generate_aead() -> Self { 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); let key = SymmetricKeyAlgorithm::AES256.new_session_key(&mut rng);
ContentKey { ContentKey {
session_key: PlainSessionKey::V6 { key }, session_key: PlainSessionKey::V6 { key },
@@ -106,7 +109,7 @@ impl ContentKey {
signer: &PrivateKey, signer: &PrivateKey,
plaintext: &[u8], plaintext: &[u8],
) -> Result<Vec<u8>, CryptoError> { ) -> 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 // Build a signed (but unencrypted) message: one-pass signature, literal
// data, then the signature packet. `seipd_*` would generate its own // data, then the signature packet. `seipd_*` would generate its own
@@ -131,7 +134,7 @@ impl ContentKey {
/// [`encrypt_thumbnail`](Self::encrypt_thumbnail). /// [`encrypt_thumbnail`](Self::encrypt_thumbnail).
fn seal_seipd(&self, inner: &[u8]) -> Result<Vec<u8>, CryptoError> { fn seal_seipd(&self, inner: &[u8]) -> Result<Vec<u8>, CryptoError> {
let (sym_alg, key) = self.session_parts()?; 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() { let seipd = if self.is_aead() {
SymEncryptedProtectedData::encrypt_seipdv2( SymEncryptedProtectedData::encrypt_seipdv2(
@@ -175,6 +178,146 @@ impl ContentKey {
Ok(out) 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. /// The raw symmetric key bytes of this content key.
/// ///
/// Mirrors C# `PgpSessionKey.Export()`; used to produce the file's /// 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 /// parent → new parent), so the locked node key still unlocks and the node's
/// `NodePassphraseSignature` need not be reissued. Mirrors C# move's /// `NodePassphraseSignature` need not be reissued. Mirrors C# move's
/// `destinationKey.EncryptSessionKey(currentKey.DecryptSessionKey(passphrase))`. /// `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( pub fn rewrap_message_to(
&self, &self,
armored_message: &str, armored_message: &str,
@@ -430,7 +590,7 @@ impl RecipientOp for ContentKeyPacketOp<'_> {
type Out = PublicKeyEncryptedSessionKey; type Out = PublicKeyEncryptedSessionKey;
fn run(self, pubkey: &impl EncryptionKey) -> pgp::errors::Result<Self::Out> { 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); let session_key = RawSessionKey::from(self.key);
PublicKeyEncryptedSessionKey::from_session_key_v3( PublicKeyEncryptedSessionKey::from_session_key_v3(
&mut rng, &mut rng,
@@ -451,7 +611,7 @@ impl RecipientOp for ContentKeyPacketV6Op<'_> {
type Out = PublicKeyEncryptedSessionKey; type Out = PublicKeyEncryptedSessionKey;
fn run(self, pubkey: &impl EncryptionKey) -> pgp::errors::Result<Self::Out> { 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); let session_key = RawSessionKey::from(self.key);
PublicKeyEncryptedSessionKey::from_session_key_v6(&mut rng, &session_key, pubkey) 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 /// Generate a passphrase-locked RSA key able to both sign and encrypt — a
/// stand-in for a Proton node key (single primary, no subkey). /// stand-in for a Proton node key (single primary, no subkey).
fn generate_node_key(passphrase: &str) -> SignedSecretKey { fn generate_node_key(passphrase: &str) -> SignedSecretKey {
let mut rng = rand::thread_rng(); let mut rng = rand_08::thread_rng();
let params = SecretKeyParamsBuilder::default() let params = SecretKeyParamsBuilder::default()
.key_type(KeyType::Rsa(2048)) .key_type(KeyType::Rsa(2048))
.can_sign(true) .can_sign(true)
@@ -483,7 +643,7 @@ mod tests {
#[test] #[test]
fn content_key_packet_and_block_round_trip() { 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 signed = generate_node_key("pw");
let armored = signed let armored = signed
.to_armored_string(None.into()) .to_armored_string(None.into())
@@ -513,6 +673,42 @@ mod tests {
assert_eq!(decrypted, plaintext); 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] #[test]
fn generated_content_key_round_trip() { fn generated_content_key_round_trip() {
// Upload path: generate a content key, seal it to the node key as a // 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); 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] #[test]
fn thumbnail_encrypt_inline_signed_round_trip() { fn thumbnail_encrypt_inline_signed_round_trip() {
// A thumbnail block is encrypt-and-inline-signed under the content key. // A thumbnail block is encrypt-and-inline-signed under the content key.
@@ -698,7 +973,7 @@ mod tests {
#[test] #[test]
fn rejects_non_pkesk_content_key_packet() { 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( let key = PrivateKey::from_armored(
&generate_node_key("pw") &generate_node_key("pw")
.to_armored_string(None.into()) .to_armored_string(None.into())
+393 -9
View File
@@ -11,13 +11,13 @@ use base64::engine::general_purpose::STANDARD as BASE64;
use bytes::Bytes; use bytes::Bytes;
use pgp::composed::{ use pgp::composed::{
ArmorOptions, DetachedSignature, EncryptionCaps, KeyType, MessageBuilder, ArmorOptions, DetachedSignature, EncryptionCaps, KeyType, MessageBuilder,
SecretKeyParamsBuilder, SignedSecretKey, SubkeyParamsBuilder, SecretKeyParamsBuilder, SignedPublicKey, SignedSecretKey, SubkeyParamsBuilder,
}; };
use pgp::crypto::hash::HashAlgorithm; use pgp::crypto::hash::HashAlgorithm;
use pgp::crypto::sym::SymmetricKeyAlgorithm; 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 pgp::types::{CompressionAlgorithm, EncryptionKey, KeyDetails, KeyVersion};
use rand::RngCore; use rand_08::RngCore;
use super::errors::CryptoError; use super::errors::CryptoError;
use super::keys::PrivateKey; 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. /// A freshly generated, passphrase-locked node key.
pub struct GeneratedNodeKey { pub struct GeneratedNodeKey {
/// The unlocked node key, ready to sign and decrypt. /// 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 /// Generate a node key (Ed25519 primary + X25519 encryption subkey) at the given
/// OpenPGP key version. v4 for legacy files/folders, v6 for AEAD files. /// OpenPGP key version. v4 for legacy files/folders, v6 for AEAD files.
fn generate_node_key_versioned(version: KeyVersion) -> Result<GeneratedNodeKey, CryptoError> { 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 passphrase = generate_passphrase();
let pw_string = String::from_utf8(passphrase.clone()) let pw_string = String::from_utf8(passphrase.clone())
.map_err(|e| CryptoError::Encrypt(format!("passphrase is not ascii: {e}")))?; .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). /// 32 random bytes, base64-encoded (the locking passphrase format).
fn generate_passphrase() -> Vec<u8> { fn generate_passphrase() -> Vec<u8> {
let mut bytes = [0u8; 32]; 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() BASE64.encode(bytes).into_bytes()
} }
@@ -158,7 +185,7 @@ fn generate_passphrase() -> Vec<u8> {
/// signer) with `CryptoGenerator.GenerateFolderHashKey` (32 bytes). /// signer) with `CryptoGenerator.GenerateFolderHashKey` (32 bytes).
pub fn generate_node_hash_key(node_key: &PrivateKey) -> Result<String, CryptoError> { pub fn generate_node_hash_key(node_key: &PrivateKey) -> Result<String, CryptoError> {
let mut bytes = [0u8; 32]; 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) 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 folder_passphrase_signature = address_key.sign_detached(&folder.passphrase)?;
let mut hash_key = [0u8; 32]; 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 let folder_hash_key = folder
.key .key
.encrypt_and_sign(address_key, &hash_key, false, false)?; .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 /// Produce an armored detached signature over `data` by `signer`'s primary
/// (signing) key. Mirrors C# `PgpPrivateKey.Sign` / `SignDetached`. /// (signing) key. Mirrors C# `PgpPrivateKey.Sign` / `SignDetached`.
pub(crate) fn sign_detached(signer: &PrivateKey, data: &[u8]) -> Result<String, CryptoError> { 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 key = &signer.key().primary_key;
let pw = signer.password(); 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}"))) .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. /// Inline encrypt-and-sign against a selected recipient public key.
struct EncryptSignOp<'a> { struct EncryptSignOp<'a> {
data: Vec<u8>, data: Vec<u8>,
@@ -305,7 +538,7 @@ impl RecipientOp for EncryptSignOp<'_> {
type Out = String; type Out = String;
fn run(self, pubkey: &impl EncryptionKey) -> pgp::errors::Result<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) let mut builder = MessageBuilder::from_bytes(Bytes::new(), self.data)
.seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256); .seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256);
@@ -439,4 +672,155 @@ mod tests {
.expect("decrypt hash key"); .expect("decrypt hash key");
assert_eq!(hash_key.len(), 32); 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"
);
}
} }
+40 -1
View File
@@ -47,6 +47,45 @@ pub struct SrpProofs {
pub expected_server_proof: Vec<u8>, 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 /// Verify the cleartext-signed modulus against Proton's key and return the raw
/// (base64-decoded) modulus bytes. Mirrors go-srp `readClearSignedMessage`. /// (base64-decoded) modulus bytes. Mirrors go-srp `readClearSignedMessage`.
fn verify_and_decode_modulus(signed_modulus: &str) -> Result<Vec<u8>, CryptoError> { fn verify_and_decode_modulus(signed_modulus: &str) -> Result<Vec<u8>, CryptoError> {
@@ -174,7 +213,7 @@ fn generate_ephemeral(
loop { loop {
let mut buf = vec![0u8; byte_len]; 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; let secret = from_le(&buf) % n_minus_1;
if secret <= lower_bound || secret >= *n_minus_1 { if secret <= lower_bound || secret >= *n_minus_1 {
continue; continue;
+1 -1
View File
@@ -55,7 +55,7 @@ impl PublicKey {
Ok(Self { key }) Ok(Self { key })
} }
fn signed(&self) -> &SignedPublicKey { pub(crate) fn signed(&self) -> &SignedPublicKey {
&self.key &self.key
} }
} }
+2 -2
View File
@@ -8,7 +8,7 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use rand::Rng; use rand::RngExt;
use reqwest::{Method, StatusCode}; use reqwest::{Method, StatusCode};
use serde::Serialize; use serde::Serialize;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
@@ -573,7 +573,7 @@ fn backoff(policy: &RetryPolicy, attempt: u32) -> Duration {
if ceiling_ms == 0 { if ceiling_ms == 0 {
return Duration::ZERO; 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 { fn ensure_trailing_slash(url: &str) -> String {