From 808a96cdd1aad949ddeb8ac492a84581b2e47279 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 25 Jun 2026 09:42:34 +0200 Subject: [PATCH] init --- .gitignore | 24 + Cargo.lock | 3126 ++++++++++++++++++++++ Cargo.toml | 34 + README.md | 88 + crates/proton-drive-sdk/Cargo.toml | 27 + crates/proton-drive-sdk/src/client.rs | 1320 +++++++++ crates/proton-drive-sdk/src/crypto.rs | 43 + crates/proton-drive-sdk/src/dtos.rs | 535 ++++ crates/proton-drive-sdk/src/lib.rs | 42 + crates/proton-drive-sdk/src/node.rs | 83 + crates/proton-sdk/Cargo.toml | 30 + crates/proton-sdk/src/account.rs | 267 ++ crates/proton-sdk/src/account/dtos.rs | 104 + crates/proton-sdk/src/api.rs | 113 + crates/proton-sdk/src/config.rs | 57 + crates/proton-sdk/src/crypto.rs | 24 + crates/proton-sdk/src/crypto/content.rs | 618 +++++ crates/proton-sdk/src/crypto/derive.rs | 76 + crates/proton-sdk/src/crypto/encrypt.rs | 276 ++ crates/proton-sdk/src/crypto/errors.rs | 25 + crates/proton-sdk/src/crypto/keys.rs | 107 + crates/proton-sdk/src/crypto/messages.rs | 73 + crates/proton-sdk/src/crypto/srp.rs | 437 +++ crates/proton-sdk/src/error.rs | 57 + crates/proton-sdk/src/http.rs | 388 +++ crates/proton-sdk/src/ids.rs | 103 + crates/proton-sdk/src/lib.rs | 23 + crates/proton-sdk/src/session.rs | 310 +++ 28 files changed, 8410 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 crates/proton-drive-sdk/Cargo.toml create mode 100644 crates/proton-drive-sdk/src/client.rs create mode 100644 crates/proton-drive-sdk/src/crypto.rs create mode 100644 crates/proton-drive-sdk/src/dtos.rs create mode 100644 crates/proton-drive-sdk/src/lib.rs create mode 100644 crates/proton-drive-sdk/src/node.rs create mode 100644 crates/proton-sdk/Cargo.toml create mode 100644 crates/proton-sdk/src/account.rs create mode 100644 crates/proton-sdk/src/account/dtos.rs create mode 100644 crates/proton-sdk/src/api.rs create mode 100644 crates/proton-sdk/src/config.rs create mode 100644 crates/proton-sdk/src/crypto.rs create mode 100644 crates/proton-sdk/src/crypto/content.rs create mode 100644 crates/proton-sdk/src/crypto/derive.rs create mode 100644 crates/proton-sdk/src/crypto/encrypt.rs create mode 100644 crates/proton-sdk/src/crypto/errors.rs create mode 100644 crates/proton-sdk/src/crypto/keys.rs create mode 100644 crates/proton-sdk/src/crypto/messages.rs create mode 100644 crates/proton-sdk/src/crypto/srp.rs create mode 100644 crates/proton-sdk/src/error.rs create mode 100644 crates/proton-sdk/src/http.rs create mode 100644 crates/proton-sdk/src/ids.rs create mode 100644 crates/proton-sdk/src/lib.rs create mode 100644 crates/proton-sdk/src/session.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3ec7d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Generated by Cargo +# will have compiled files and executables +debug +target + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ + +# rustc will dump stack traces when hitting an internal compiler error to PWD +rustc-ice-*.txt + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..26b1f28 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3126 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "bytes", + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aes-kw" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" +dependencies = [ + "aes", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", + "zeroize", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abaf6da45c74385272ddf00e1ac074c7d8a6c1a1dda376902bd6a427522a8b2c" +dependencies = [ + "base64", + "blowfish", + "getrandom 0.3.4", + "subtle", + "zeroize", +] + +[[package]] +name = "bitfields" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d84268bbf9b487d31fe4b849edbefcd3911422d7a07de855a2da1f70ab3d1c" +dependencies = [ + "bitfields-impl", +] + +[[package]] +name = "bitfields-impl" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c93edde7bb4416c35c85048e34f78999dcb47d199bde3b1d79286156f3e2fb" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "thiserror", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "buffer-redux" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431a9cc8d7efa49bc326729264537f5e60affce816c66edf434350778c9f4f54" +dependencies = [ + "memchr", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", + "libbz2-rs-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "camellia" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "cast5" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfb-mode" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" +dependencies = [ + "cipher", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc24" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cx448" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c0cf476284b03eb6c10e78787b21c7abb7d7d43cb2f02532ba6b831ed892fa" +dependencies = [ + "crypto-bigint", + "elliptic-curve", + "pkcs8", + "rand_core 0.6.4", + "serdect 0.3.0", + "sha3", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dsa" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" +dependencies = [ + "digest", + "num-bigint-dig", + "num-traits", + "pkcs8", + "rfc6979", + "sha2", + "signature", + "zeroize", +] + +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead", + "cipher", + "cmac", + "ctr", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "base64ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serde_json", + "serdect 0.2.0", + "subtle", + "tap", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "bitvec", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idea" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477" +dependencies = [ + "cipher", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", + "signature", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libbz2-rs-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0864a00c8d019e36216b69c2c4ce50b83b7bd966add3cf5ba554ec44f8bebcf5" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ocb3" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pgp" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91d320242d9b686612b15526fe38711afdf856e112eaa4775ce25b0d9b12b11" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "aes-kw", + "argon2", + "base64", + "bitfields", + "block-padding", + "blowfish", + "buffer-redux", + "byteorder", + "bytes", + "bzip2", + "camellia", + "cast5", + "cfb-mode", + "chrono", + "cipher", + "const-oid", + "crc24", + "curve25519-dalek", + "cx448", + "derive_builder", + "derive_more", + "des", + "digest", + "dsa", + "eax", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "flate2", + "generic-array", + "hex", + "hkdf", + "idea", + "k256", + "log", + "md-5", + "nom", + "num-bigint-dig", + "num-traits", + "num_enum", + "ocb3", + "p256", + "p384", + "p521", + "rand 0.8.6", + "regex", + "ripemd", + "rsa", + "sha1", + "sha1-checked", + "sha2", + "sha3", + "signature", + "smallvec", + "snafu", + "twofish", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proton-drive-sdk" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64", + "bytes", + "futures", + "hex", + "hmac", + "pgp", + "proton-sdk", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "sha1", + "sha2", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "proton-sdk" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64", + "bcrypt", + "bytes", + "futures", + "getrandom 0.2.17", + "hex", + "hmac", + "num-bigint", + "num-traits", + "pgp", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "sha2", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest", + "sha1", + "zeroize", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twofish" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013" +dependencies = [ + "cipher", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zlib-rs" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e2f8570 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,34 @@ +[workspace] +resolver = "2" +members = ["crates/proton-sdk", "crates/proton-drive-sdk"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" +repository = "https://github.com/ProtonDriveApps/sdk" +rust-version = "1.80" + +[workspace.dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "multipart"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_repr = "0.1" +thiserror = "2" +async-trait = "0.1" +futures = "0.3" +bytes = "1" +base64 = "0.22" +hex = "0.4" +hmac = "0.12" +sha1 = "0.10" +sha2 = "0.10" +bcrypt = "0.17" +pgp = "0.16" +tracing = "0.1" +num-bigint = "0.4" +num-traits = "0.2" +getrandom = "0.2" +# rPGP re-exports rand_core 0.6 traits; match its 0.8 line for keygen/encryption RNG. +rand = "0.8" diff --git a/README.md b/README.md new file mode 100644 index 0000000..219244f --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# proton-sdk-rs + +A pure-Rust reimplementation of the [Proton Drive SDK](https://github.com/ProtonDriveApps/sdk). +No dependency on the official NativeAOT core — it talks to the Proton Drive API +and does the OpenPGP crypto directly. + +> Unofficial, third-party project. Not affiliated with or endorsed by Proton. +> If you build on it, follow the operational requirements in the upstream SDK +> README (honest `x-pm-appversion`, event-based sync, no Proton branding, etc.). + +## Status + +**Milestone 1 — session + read operations.** Implemented: + +- Core: typed IDs, API response envelope, config, reqwest HTTP client with + `x-pm-uid` / `x-pm-appversion` / bearer auth and transparent 401 token refresh. +- Session: `resume` from existing tokens, `end`. +- Account: user keys, address keys, and Proton's bcrypt key-passphrase + derivation (the mailbox password is required even for read — see below). +- Drive read: `get_my_files_folder`, `get_node`, `enumerate_folder_children`, + with share and node (link) decryption and recursive parent-key resolution. + +Not yet implemented: SRP password login (`begin`), uploads/downloads, sharing, +events, trash, photos, signature-verification enforcement, persistent caching. + +## Workspace + +| Crate | Purpose | +| --- | --- | +| `crates/proton-sdk` | Core account/session/crypto (`Proton.Sdk` analogue) | +| `crates/proton-drive-sdk` | High-level Drive client (`Proton.Drive.Sdk` analogue) | + +OpenPGP is provided by the [`pgp`](https://crates.io/crates/pgp) crate (rPGP). + +## The mailbox-password requirement + +Proton's key model is layered, so **read-only decryption still needs the user's +mailbox (data) password**, not just session tokens: + +``` +mailbox password + key salt --bcrypt--> key passphrase + -> unlock user keys -> decrypt address-key token -> unlock address key + -> decrypt share/node passphrase -> unlock node key -> decrypt names +``` + +`ProtonApiSession::resume` takes the tokens; `ProtonDriveClient::new` takes the +mailbox password. + +## Usage + +```rust,no_run +use proton_sdk::config::ProtonClientConfiguration; +use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; +use proton_drive_sdk::ProtonDriveClient; + +# async fn run() -> proton_sdk::error::Result<()> { +let config = ProtonClientConfiguration::new("external-drive-myapp@0.1.0-alpha"); +let session = ProtonApiSession::resume(config, ResumeParameters { + session_id: "uid".into(), + username: "user@proton.me".into(), + user_id: "user-id".into(), + access_token: "access".into(), + refresh_token: "refresh".into(), + scopes: vec![], + is_waiting_for_second_factor_code: false, + password_mode: PasswordMode::Single, +})?; + +let drive = ProtonDriveClient::new(&session, b"mailbox-password".to_vec()); +let root = drive.get_my_files_folder().await?; +for child in drive.enumerate_folder_children(&root.uid).await? { + println!("{} ({:?})", child.name, child.kind); +} +# Ok(()) +# } +``` + +## Build & test + +```bash +cargo build +cargo test +``` + +## License + +MIT, matching upstream. The MIT license covers this source only; use of Proton's +hosted services remains subject to Proton's terms. diff --git a/crates/proton-drive-sdk/Cargo.toml b/crates/proton-drive-sdk/Cargo.toml new file mode 100644 index 0000000..f266053 --- /dev/null +++ b/crates/proton-drive-sdk/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "proton-drive-sdk" +description = "High-level Proton Drive client for Rust" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +proton-sdk = { path = "../proton-sdk" } +tokio.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_repr.workspace = true +thiserror.workspace = true +async-trait.workspace = true +futures.workspace = true +bytes.workspace = true +base64.workspace = true +hex.workspace = true +hmac.workspace = true +sha1.workspace = true +sha2.workspace = true +pgp.workspace = true +tracing.workspace = true diff --git a/crates/proton-drive-sdk/src/client.rs b/crates/proton-drive-sdk/src/client.rs new file mode 100644 index 0000000..245a4c5 --- /dev/null +++ b/crates/proton-drive-sdk/src/client.rs @@ -0,0 +1,1320 @@ +//! The high-level Drive client and its read operations. + +use std::collections::HashMap; +use std::io::{Cursor, Read}; +use std::sync::Arc; + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use sha2::{Digest, Sha256}; +use tokio::sync::Mutex; + +use proton_sdk::account::AccountClient; +use proton_sdk::crypto::PrivateKey; +use proton_sdk::error::{ProtonError, Result}; +use proton_sdk::http::ApiHttpClient; +use proton_sdk::crypto::{generate_node_hash_key, generate_node_key, ContentKey}; +use proton_sdk::ids::{AddressId, LinkId, NodeUid, ShareId, VolumeId}; +use proton_sdk::session::ProtonApiSession; + +use hmac::{Hmac, Mac}; +use sha1::Sha1; + +use crate::crypto::{decrypt_link, decrypt_share_key}; +use crate::dtos::{ + AggregateLinksResponse, BlockCreationRequest, BlockDto, BlockUploadPreparationRequest, + BlockUploadPreparationResponse, BlockVerificationInputResponse, BlockVerifier, + CommonExtendedAttributes, ExtendedAttributes, FileContentDigests, FileCreationRequest, + FileCreationResponse, FolderChildrenResponse, FolderCreationRequest, FolderCreationResponse, + LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto, LinkType, MoveLinkRequest, + MultipleLinksRequest, MyFilesShareResponse, RenameLinkRequest, RevisionCreationRequest, + RevisionCreationResponse, RevisionDto, RevisionResponse, RevisionUpdateRequest, + ThumbnailCreationRequest, ThumbnailDto, +}; +use crate::node::{Node, NodeKind, Thumbnail}; + +/// Content blocks are 4 MiB of plaintext each (C# `RevisionWriter.DefaultBlockSize`). +const DEFAULT_BLOCK_SIZE: usize = 1 << 22; + +/// Maximum links per batch trash/restore/delete request (C# +/// `NodeOperations.MaximumBatchCount`). +const MAX_BATCH_COUNT: usize = 150; + +/// High-level Proton Drive client. +/// +/// Holds an authenticated session plus an [`AccountClient`]. Because Proton's +/// key model requires the mailbox password for decryption, the client is +/// constructed with it (see [`ProtonDriveClient::new`]). +#[derive(Clone)] +pub struct ProtonDriveClient { + http: ApiHttpClient, + account: AccountClient, + cache: Arc>, +} + +#[derive(Default)] +struct DriveCache { + main_volume_id: Option, + my_files_share: Option, + my_files_root: Option, + /// Decrypted node key per folder, used as the parent key for its children. + folder_keys: HashMap, +} + +/// An open revision draft ready to receive content blocks: the target revision +/// plus the keys and signing identity needed to encrypt, sign and seal it. +/// Produced by [`ProtonDriveClient::create_file_draft`] (new file) or +/// [`ProtonDriveClient::create_revision_draft`] (new revision on an existing +/// file); consumed by `write_blocks` + `seal_revision`. +struct RevisionDraft { + volume_id: VolumeId, + link_id: LinkId, + revision_id: String, + node_key: PrivateKey, + content_key: ContentKey, + address_id: AddressId, + email: String, + signing_key: PrivateKey, +} + +/// Outcome of writing every content block of a revision: the content manifest +/// (concatenated ciphertext SHA-256 digests, in index order) plus the metadata +/// the sealing step records in the revision's extended attributes. +struct BlockWriteResult { + manifest: Vec, + block_sizes: Vec, + total_size: i64, + sha1_hex: String, +} + +#[derive(Clone)] +struct ShareKey { + #[allow(dead_code)] + share_id: ShareId, + /// The address that owns the share — the membership/signing address used + /// when creating nodes under it. + address_id: AddressId, + key: PrivateKey, +} + +impl ProtonDriveClient { + /// Build a Drive client from a resumed session and the mailbox password. + pub fn new(session: &ProtonApiSession, mailbox_password: impl Into>) -> Self { + Self { + http: session.http().clone(), + account: AccountClient::new(session, mailbox_password), + cache: Arc::new(Mutex::new(DriveCache::default())), + } + } + + /// Resolve (and cache) the user's "My Files" root folder. + pub async fn get_my_files_folder(&self) -> Result { + self.ensure_my_files().await?; + let root_uid = self + .cache + .lock() + .await + .my_files_root + .clone() + .expect("ensure_my_files populates the root uid"); + // The root folder was decrypted and cached during ensure_my_files; fetch + // it back as a public node. + self.get_node(&root_uid) + .await? + .ok_or_else(|| ProtonError::invalid_operation("My Files root folder not found")) + } + + /// Fetch a single node's decrypted metadata, or `None` if it does not exist. + pub async fn get_node(&self, uid: &NodeUid) -> Result> { + let response = self + .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) + .await?; + let Some(details) = response.links.into_iter().next() else { + return Ok(None); + }; + + let parent_key = self + .resolve_parent_key(&uid.volume_id, &details.link) + .await?; + let node = self.build_node(&uid.volume_id, &details, &parent_key).await?; + Ok(Some(node)) + } + + /// Enumerate the (non-trashed) children of a folder. + pub async fn enumerate_folder_children(&self, folder_uid: &NodeUid) -> Result> { + let folder_key = self.folder_node_key(folder_uid).await?; + + let mut nodes = Vec::new(); + let mut anchor: Option = None; + + loop { + let mut path = format!( + "v2/volumes/{}/folders/{}/children", + folder_uid.volume_id, folder_uid.link_id + ); + if let Some(anchor_id) = &anchor { + path.push_str(&format!("?AnchorID={anchor_id}")); + } + + let page: FolderChildrenResponse = self.http.get(&path).await?; + if page.link_ids.is_empty() { + break; + } + + let details = self + .get_link_details(&folder_uid.volume_id, &page.link_ids) + .await?; + + for child in &details.links { + match self + .build_node(&folder_uid.volume_id, child, &folder_key) + .await + { + Ok(node) => nodes.push(node), + Err(e) => { + tracing::warn!(link_id = %child.link.id, error = %e, "skipping undecryptable child"); + } + } + } + + if !page.more_results_exist { + break; + } + anchor = page.anchor_id; + if anchor.is_none() { + break; + } + } + + Ok(nodes) + } + + /// The main volume id, resolved via My Files. + pub async fn main_volume_id(&self) -> Result { + self.ensure_my_files().await?; + Ok(self + .cache + .lock() + .await + .main_volume_id + .clone() + .expect("ensure_my_files populates the volume id")) + } + + /// Download and decrypt a file's active revision, returning its plaintext. + pub async fn download_file(&self, uid: &NodeUid) -> Result> { + let mut buf = Vec::new(); + self.download_file_to(uid, &mut buf).await?; + Ok(buf) + } + + /// Download and decrypt a file's active revision into `output`. + /// + /// Mirrors C# `FileDownloader` + `RevisionReader`: resolve the node key, + /// decrypt the content key, then for each block fetch its ciphertext from + /// block storage, decrypt it with the content session key, and write the + /// plaintext out — accumulating the content manifest (thumbnail digests + /// followed by per-block SHA-256 digests) for an authenticity check. + /// + /// Manifest-signature verification is currently best-effort: it is enforced + /// only when the node key is the signer; address-key signatures require + /// public-key resolution (`core/v4/keys/all`), not yet implemented. + pub async fn download_file_to( + &self, + uid: &NodeUid, + output: &mut W, + ) -> Result<()> { + let details = self + .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) + .await?; + let detail = details + .links + .into_iter() + .next() + .ok_or_else(|| ProtonError::invalid_operation(format!("file {uid} not found")))?; + let link = detail.link; + let file = detail + .file + .ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?; + + let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| { + ProtonError::invalid_operation("file is missing its content key packet") + })?; + let content_key_packet = BASE64 + .decode(content_key_packet_b64.trim()) + .map_err(|e| ProtonError::invalid_operation(format!("decode content key packet: {e}")))?; + let revision_id = file + .active_revision + .map(|r| r.id) + .ok_or_else(|| ProtonError::invalid_operation("file has no active revision"))?; + + // Resolve the node key and the content (session) key. + let parent_key = self.resolve_parent_key(&uid.volume_id, &link).await?; + let node_key = decrypt_link(&parent_key, &link)?.node_key; + let content_key = node_key.decrypt_content_key(&content_key_packet)?; + + let (revision, blocks) = self + .fetch_revision_blocks(&uid.volume_id, &uid.link_id, &revision_id) + .await?; + + // Manifest: thumbnail digests (ordered by type) then block digests in order. + let mut manifest = Vec::new(); + let mut thumbnails: Vec<&ThumbnailDto> = revision.thumbnails.iter().collect(); + thumbnails.sort_by_key(|t| t.thumbnail_type); + for thumb in thumbnails { + if let Some(hash_b64) = &thumb.hash_digest { + let digest = BASE64.decode(hash_b64.trim()).map_err(|e| { + ProtonError::invalid_operation(format!("decode thumbnail digest: {e}")) + })?; + manifest.extend_from_slice(&digest); + } + } + + for block in &blocks { + let ciphertext = self + .http + .get_storage_blob(&block.bare_url, &block.token) + .await?; + let digest = Sha256::digest(&ciphertext); + let plaintext = content_key.decrypt_block(&ciphertext)?; + output + .write_all(&plaintext) + .map_err(|e| ProtonError::invalid_operation(format!("write block: {e}")))?; + manifest.extend_from_slice(&digest); + } + + verify_manifest(&revision, &node_key, &manifest); + Ok(()) + } + + /// Upload a new file under `parent_uid` with the given plaintext `contents`. + /// + /// Core single-file path (legacy SEIPDv1, no thumbnails, buffered). Mirrors + /// C# `NewFileDraftProvider` + `RevisionWriter`: create a draft (new node + /// key + content key), encrypt/sign/verify/upload each 4 MiB block, then + /// seal the revision with a signed manifest and encrypted extended + /// attributes. Returns the new file's [`NodeUid`]. + pub async fn upload_file( + &self, + parent_uid: &NodeUid, + name: &str, + media_type: &str, + contents: &[u8], + ) -> Result { + self.upload_file_from( + parent_uid, + name, + media_type, + Cursor::new(contents), + contents.len() as i64, + Vec::new(), + false, + ) + .await + } + + /// Streaming variant of [`upload_file`]: read the plaintext from `reader` + /// block by block instead of buffering it all in memory. `intended_size` is + /// the draft-creation size hint (C# `IntendedUploadSize`); the authoritative + /// size recorded in the revision's extended attributes is the actual number + /// of bytes streamed. + /// + /// `thumbnails` are caller-rendered preview images attached to the revision; + /// pass an empty `Vec` for none. They are uploaded before the content blocks + /// and their ciphertext digests lead the content manifest. + /// + /// When `aead` is set, the file's content key and blocks use PGP AEAD + /// (SEIPDv2 / AES-256-GCM) instead of the legacy SEIPDv1 path; this mirrors + /// the C# `DriveCryptoEncryptBlocksWithPgpAead` feature flag. New revisions + /// of an existing file inherit its content key's mode regardless. + /// + /// `reader` is a blocking [`Read`]; reads happen between block uploads, so a + /// slow reader stalls the upload but never buffers more than one block. + pub async fn upload_file_from( + &self, + parent_uid: &NodeUid, + name: &str, + media_type: &str, + reader: R, + intended_size: i64, + thumbnails: Vec, + aead: bool, + ) -> Result { + let draft = self + .create_file_draft(parent_uid, name, media_type, intended_size, aead) + .await?; + let file_uid = NodeUid::new(draft.volume_id.clone(), draft.link_id.clone()); + + let written = self.write_blocks(&draft, reader, thumbnails).await?; + self.seal_revision(&draft, &written).await?; + + Ok(file_uid) + } + + /// Upload a new revision of an existing file with the given plaintext + /// `contents`, superseding its currently active revision. + /// + /// Mirrors C# `NewRevisionDraftProvider`: reuse the file's existing node and + /// content keys (no new key generation, no name/passphrase), open a draft + /// revision based on the active one, then write blocks and seal exactly as a + /// fresh upload. The new revision becomes active once sealed. + pub async fn upload_new_revision(&self, file_uid: &NodeUid, contents: &[u8]) -> Result<()> { + self.upload_new_revision_from( + file_uid, + Cursor::new(contents), + contents.len() as i64, + Vec::new(), + ) + .await + } + + /// Streaming variant of [`upload_new_revision`]: read the plaintext from + /// `reader` block by block. See [`upload_file_from`] for the `intended_size`, + /// `thumbnails` and reader semantics. + pub async fn upload_new_revision_from( + &self, + file_uid: &NodeUid, + reader: R, + intended_size: i64, + thumbnails: Vec, + ) -> Result<()> { + let draft = self.create_revision_draft(file_uid, intended_size).await?; + + let written = self.write_blocks(&draft, reader, thumbnails).await?; + self.seal_revision(&draft, &written).await?; + + Ok(()) + } + + /// Create a new (empty) folder named `name` under `parent_uid`, returning + /// its [`NodeUid`]. + /// + /// Mirrors C# `NodeOperations.CreateFolderAsync` / `FolderCreationRequest`: + /// generate a node key plus the folder's own child-name hash key, encrypt + /// and sign the name/passphrase/hash-key to the parent (the hash key to the + /// folder's own node key), then POST the folder. Live validation pending. + pub async fn create_folder(&self, parent_uid: &NodeUid, name: &str) -> Result { + let volume_id = parent_uid.volume_id.clone(); + + // Resolve the parent folder key + hash key and the membership address. + let parent_key = self.folder_node_key(parent_uid).await?; + let parent_hash_key = self.parent_hash_key(parent_uid, &parent_key).await?; + let (_address_id, email, signing_key) = self.membership_address().await?; + + // Generate the folder's node key and its own child-name hash key (the + // hash key is encrypted to and signed by the folder's own node key). + let node = generate_node_key()?; + let node_hash_key = generate_node_hash_key(&node.key)?; + + let encrypted_name = parent_key.encrypt_and_sign(&signing_key, name.as_bytes(), true, false)?; + let name_hash = hex::encode(hmac_sha256(&parent_hash_key, name.as_bytes())); + let encrypted_passphrase = parent_key.encrypt(&node.passphrase)?; + let passphrase_signature = signing_key.sign_detached(&node.passphrase)?; + + let request = FolderCreationRequest { + name: encrypted_name, + name_hash, + parent_link_id: parent_uid.link_id.clone(), + passphrase: encrypted_passphrase, + passphrase_signature, + key: node.locked_armored, + node_hash_key, + signature_email: email, + }; + + let path = format!("v2/volumes/{volume_id}/folders"); + let created: FolderCreationResponse = self.http.post(&path, &request).await?; + + Ok(NodeUid::new(volume_id, created.folder.link_id)) + } + + /// Rename `uid` to `new_name`, re-encrypting the name to its parent folder. + /// + /// Mirrors C# `NodeOperations.RenameAsync` / `RenameLinkRequest`: encrypt and + /// sign the new name to the parent, recompute its name hash, and send the + /// node's *current* name hash as `OriginalHash`. `MIMEType` is the file's + /// media type (unchanged) or `null` for a folder. Live validation pending. + pub async fn rename_node(&self, uid: &NodeUid, new_name: &str) -> Result<()> { + let details = self + .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) + .await?; + let detail = details + .links + .into_iter() + .next() + .ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} not found")))?; + let link = detail.link; + + let parent_id = link + .parent_id + .clone() + .ok_or_else(|| ProtonError::invalid_operation("cannot rename the root node"))?; + let parent_uid = NodeUid::new(uid.volume_id.clone(), parent_id); + + let parent_key = self.folder_node_key(&parent_uid).await?; + let parent_hash_key = self.parent_hash_key(&parent_uid, &parent_key).await?; + let (_address_id, email, signing_key) = self.membership_address().await?; + + // The original (current) name hash, recomputed from the decrypted name. + let current_name = parent_key.decrypt_armored_message(&link.name)?; + let original_hash = hex::encode(hmac_sha256(&parent_hash_key, ¤t_name)); + + let encrypted_name = + parent_key.encrypt_and_sign(&signing_key, new_name.as_bytes(), true, false)?; + let name_hash = hex::encode(hmac_sha256(&parent_hash_key, new_name.as_bytes())); + let media_type = detail.file.as_ref().map(|f| f.media_type.clone()); + + let request = RenameLinkRequest { + name: encrypted_name, + name_hash, + name_signature_email: email, + media_type, + original_hash, + }; + let path = format!("v2/volumes/{}/links/{}/rename", uid.volume_id, uid.link_id); + let _: proton_sdk::api::ApiResponse = self.http.put(&path, &request).await?; + Ok(()) + } + + /// Move `uid` under `new_parent`, re-encrypting its passphrase and name to + /// the destination folder. + /// + /// Mirrors C# `NodeOperations.MoveAsync` / `MoveLinkRequest`: the node + /// passphrase is *rewrapped* to the destination parent key + /// (`destinationKey.EncryptSessionKey(currentKey.DecryptSessionKey(...))`) so + /// the secret — and thus the locked node key and its + /// `NodePassphraseSignature` — is unchanged; the name is re-encrypted + signed + /// to the destination; `Hash` is the new name hash under the destination's + /// hash key, `OriginalHash` the current hash under the source parent's. Only + /// same-volume moves are supported (cross-volume needs `NewShareID` + + /// re-signing). Live validation pending. + pub async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()> { + if uid.volume_id != new_parent.volume_id { + return Err(ProtonError::invalid_operation( + "cross-volume move is not supported", + )); + } + + let details = self + .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) + .await?; + let detail = details + .links + .into_iter() + .next() + .ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} not found")))?; + let link = detail.link; + + let parent_id = link + .parent_id + .clone() + .ok_or_else(|| ProtonError::invalid_operation("cannot move the root node"))?; + let source_parent_uid = NodeUid::new(uid.volume_id.clone(), parent_id); + + // Source side: decrypt the current name and hash it under the source's + // hash key (the request's `OriginalHash`). + let source_parent_key = self.folder_node_key(&source_parent_uid).await?; + let source_hash_key = self + .parent_hash_key(&source_parent_uid, &source_parent_key) + .await?; + let name = source_parent_key.decrypt_armored_message(&link.name)?; + let original_hash = hex::encode(hmac_sha256(&source_hash_key, &name)); + + // Destination side: rewrap the passphrase, re-encrypt + sign the name, + // and hash it under the destination's hash key. + let dest_parent_key = self.folder_node_key(new_parent).await?; + let dest_hash_key = self.parent_hash_key(new_parent, &dest_parent_key).await?; + let (_address_id, email, signing_key) = self.membership_address().await?; + + let passphrase = source_parent_key.rewrap_message_to(&link.passphrase, &dest_parent_key)?; + let encrypted_name = dest_parent_key.encrypt_and_sign(&signing_key, &name, true, false)?; + let name_hash = hex::encode(hmac_sha256(&dest_hash_key, &name)); + + let request = MoveLinkRequest { + parent_link_id: new_parent.link_id.clone(), + passphrase, + // The rewrap preserves the plaintext, so the signature is unchanged. + passphrase_signature: link.passphrase_signature, + name: encrypted_name, + name_signature_email: email, + name_hash, + original_hash, + }; + let path = format!("v2/volumes/{}/links/{}/move", uid.volume_id, uid.link_id); + let _: proton_sdk::api::ApiResponse = self.http.put(&path, &request).await?; + Ok(()) + } + + /// Move `uids` to the trash. Mirrors C# `NodeOperations.TrashAsync` + /// (`POST v2/volumes/{vid}/trash_multiple`). Live validation pending. + pub async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<()> { + for (volume_id, link_ids) in group_by_volume(uids) { + for chunk in link_ids.chunks(MAX_BATCH_COUNT) { + let path = format!("v2/volumes/{volume_id}/trash_multiple"); + let body = MultipleLinksRequest { link_ids: chunk }; + let response: AggregateLinksResponse = self.http.post(&path, &body).await?; + check_aggregate("trash", response)?; + } + } + Ok(()) + } + + /// Restore `uids` from the trash. Mirrors C# + /// `NodeOperations.RestoreFromTrashAsync` + /// (`PUT v2/volumes/{vid}/trash/restore_multiple`). Live validation pending. + pub async fn restore_nodes(&self, uids: &[NodeUid]) -> Result<()> { + for (volume_id, link_ids) in group_by_volume(uids) { + for chunk in link_ids.chunks(MAX_BATCH_COUNT) { + let path = format!("v2/volumes/{volume_id}/trash/restore_multiple"); + let body = MultipleLinksRequest { link_ids: chunk }; + let response: AggregateLinksResponse = self.http.put(&path, &body).await?; + check_aggregate("restore", response)?; + } + } + Ok(()) + } + + /// Permanently delete `uids` (which must already be in the trash). Mirrors + /// C# `NodeOperations.DeleteFromTrashAsync` + /// (`POST v2/volumes/{vid}/trash/delete_multiple`). Live validation pending. + pub async fn delete_nodes(&self, uids: &[NodeUid]) -> Result<()> { + for (volume_id, link_ids) in group_by_volume(uids) { + for chunk in link_ids.chunks(MAX_BATCH_COUNT) { + let path = format!("v2/volumes/{volume_id}/trash/delete_multiple"); + let body = MultipleLinksRequest { link_ids: chunk }; + let response: AggregateLinksResponse = self.http.post(&path, &body).await?; + check_aggregate("delete", response)?; + } + } + Ok(()) + } + + /// Permanently empty the main volume's trash. Mirrors C# + /// `TrashApiClient.EmptyAsync` (`DELETE volumes/{vid}/trash`). Live + /// validation pending. + pub async fn empty_trash(&self) -> Result<()> { + let volume_id = self.main_volume_id().await?; + let path = format!("volumes/{volume_id}/trash"); + let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; + Ok(()) + } + + // ---- internals --------------------------------------------------------- + + /// Create a fresh-file draft: generate a node key + content key, encrypt the + /// name/passphrase/content-key-packet to the parent, and POST the draft. + /// Mirrors C# `NewFileDraftProvider`. + async fn create_file_draft( + &self, + parent_uid: &NodeUid, + name: &str, + media_type: &str, + intended_upload_size: i64, + aead: bool, + ) -> Result { + let volume_id = parent_uid.volume_id.clone(); + + // Resolve the parent folder key + hash key and the membership address. + let parent_key = self.folder_node_key(parent_uid).await?; + let parent_hash_key = self.parent_hash_key(parent_uid, &parent_key).await?; + let (address_id, email, signing_key) = self.membership_address().await?; + + // Generate the node key + content key and the file-creation secrets. + let node = generate_node_key()?; + let content_key = if aead { + ContentKey::generate_aead() + } else { + ContentKey::generate() + }; + + let encrypted_name = parent_key.encrypt_and_sign(&signing_key, name.as_bytes(), true, false)?; + let name_hash = hex::encode(hmac_sha256(&parent_hash_key, name.as_bytes())); + let encrypted_passphrase = parent_key.encrypt(&node.passphrase)?; + let passphrase_signature = signing_key.sign_detached(&node.passphrase)?; + let content_key_packet = content_key.to_packet(&node.key)?; + let content_key_signature = node.key.sign_detached(&content_key.export()?)?; + + let create_request = FileCreationRequest { + name: encrypted_name, + name_hash, + parent_link_id: parent_uid.link_id.clone(), + passphrase: encrypted_passphrase, + passphrase_signature, + key: node.locked_armored.clone(), + media_type: media_type.to_owned(), + content_key_packet: BASE64.encode(&content_key_packet), + content_key_signature, + signature_address: email.clone(), + client_uid: Some(self.http.session_id().to_string()), + intended_upload_size, + }; + + let create_path = format!("v2/volumes/{volume_id}/files"); + let created: FileCreationResponse = self.http.post(&create_path, &create_request).await?; + + Ok(RevisionDraft { + volume_id, + link_id: created.file.link_id, + revision_id: created.file.revision_id, + node_key: node.key, + content_key, + address_id, + email, + signing_key, + }) + } + + /// Open a new-revision draft on an existing file: recover the file's node and + /// content keys, then POST a revision based on the currently active one. + /// Mirrors C# `NewRevisionDraftProvider`. + async fn create_revision_draft( + &self, + file_uid: &NodeUid, + intended_upload_size: i64, + ) -> Result { + let volume_id = file_uid.volume_id.clone(); + + // Recover the file's existing secrets (node key + content key). + let details = self + .get_link_details(&volume_id, std::slice::from_ref(&file_uid.link_id)) + .await?; + let detail = details + .links + .into_iter() + .next() + .ok_or_else(|| ProtonError::invalid_operation(format!("file {file_uid} not found")))?; + let link = detail.link; + let file = detail + .file + .ok_or_else(|| ProtonError::invalid_operation(format!("node {file_uid} is not a file")))?; + + let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| { + ProtonError::invalid_operation("file is missing its content key packet") + })?; + let content_key_packet = BASE64 + .decode(content_key_packet_b64.trim()) + .map_err(|e| ProtonError::invalid_operation(format!("decode content key packet: {e}")))?; + let active_revision_id = file + .active_revision + .map(|r| r.id) + .ok_or_else(|| ProtonError::invalid_operation("file has no active revision"))?; + + let parent_key = self.resolve_parent_key(&volume_id, &link).await?; + let node_key = decrypt_link(&parent_key, &link)?.node_key; + let content_key = node_key.decrypt_content_key(&content_key_packet)?; + + let (address_id, email, signing_key) = self.membership_address().await?; + + // Create the revision draft, superseding the active revision. + let request = RevisionCreationRequest { + current_revision_id: active_revision_id, + client_uid: Some(self.http.session_id().to_string()), + intended_upload_size, + }; + let path = format!("v2/volumes/{volume_id}/files/{}/revisions", file_uid.link_id); + let created: RevisionCreationResponse = self.http.post(&path, &request).await?; + + Ok(RevisionDraft { + volume_id, + link_id: file_uid.link_id.clone(), + revision_id: created.revision.revision_id, + node_key, + content_key, + address_id, + email, + signing_key, + }) + } + + /// Encrypt, sign, verify and upload every content block of a draft revision, + /// accumulating the content manifest and extended-attribute metadata. + /// + /// Thumbnails (if any) are uploaded first, in `ThumbnailType` order, and + /// their ciphertext digests lead the manifest — mirroring C# + /// `RevisionWriter.UploadBlocksAsync` (thumbnails then content blocks) and + /// the download path's manifest ordering. Then `reader` is streamed one + /// [`DEFAULT_BLOCK_SIZE`] block at a time: only a single plaintext block + /// (plus its ciphertext) is held in memory, and the SHA-1 digest and + /// total-size counter are folded incrementally. An empty reader yields zero + /// content blocks (an empty file). + async fn write_blocks( + &self, + draft: &RevisionDraft, + mut reader: R, + mut thumbnails: Vec, + ) -> Result { + // Confirm the verification input matches our node/content key. + let verification_code = self + .fetch_verification_code( + &draft.volume_id, + &draft.link_id, + &draft.revision_id, + &draft.node_key, + ) + .await?; + + let mut manifest = Vec::new(); + + // Thumbnail digests lead the manifest, ordered by thumbnail type to + // match the download path's `sort_by_key(thumbnail_type)`. + thumbnails.sort_by_key(|t| t.thumbnail_type); + for thumbnail in &thumbnails { + let digest = self.upload_thumbnail(draft, thumbnail).await?; + manifest.extend_from_slice(&digest); + } + + let mut block_sizes = Vec::new(); + let mut sha1 = Sha1::new(); + let mut total_size: i64 = 0; + let mut buf = vec![0u8; DEFAULT_BLOCK_SIZE]; + + // Block indices are 1-based (C# `blockNumber = i + 1`). + let mut index = 1_i32; + loop { + let n = read_full_block(&mut reader, &mut buf)?; + if n == 0 { + break; + } + let chunk = &buf[..n]; + + sha1.update(chunk); + total_size += n as i64; + + let ciphertext = draft.content_key.encrypt_block(chunk)?; + let digest = Sha256::digest(&ciphertext); + let token = verification_token(&verification_code, &ciphertext); + + // Detached signature over the plaintext, then encrypted to the node key. + let plaintext_signature = draft.signing_key.sign_detached(chunk)?; + let encrypted_signature = draft.node_key.encrypt(plaintext_signature.as_bytes())?; + + let prepare = BlockUploadPreparationRequest { + address_id: draft.address_id.clone(), + volume_id: draft.volume_id.clone(), + link_id: draft.link_id.clone(), + revision_id: draft.revision_id.clone(), + blocks: vec![BlockCreationRequest { + index, + size: ciphertext.len() as i32, + encrypted_signature, + hash: BASE64.encode(digest), + verifier: BlockVerifier { + token: BASE64.encode(&token), + }, + }], + thumbnails: Vec::new(), + }; + let prepared: BlockUploadPreparationResponse = + self.http.post("blocks", &prepare).await?; + let target = prepared.upload_targets.into_iter().next().ok_or_else(|| { + ProtonError::invalid_operation("block upload preparation returned no target") + })?; + + self.http + .post_storage_blob(&target.bare_url, &target.token, ciphertext) + .await?; + + manifest.extend_from_slice(&digest); + block_sizes.push(n as i32); + index += 1; + } + + Ok(BlockWriteResult { + manifest, + block_sizes, + total_size, + sha1_hex: hex::encode(sha1.finalize()), + }) + } + + /// Encrypt, hash and upload a single thumbnail block, returning its + /// ciphertext SHA-256 digest (a manifest entry). + /// + /// Mirrors C# `BlockUploader.UploadThumbnailAsync`: the thumbnail is + /// encrypt-and-inline-signed under the content key, prepared as its own + /// upload request (no content blocks), and uploaded to the returned + /// thumbnail target. + async fn upload_thumbnail( + &self, + draft: &RevisionDraft, + thumbnail: &Thumbnail, + ) -> Result<[u8; 32]> { + let ciphertext = draft + .content_key + .encrypt_thumbnail(&draft.signing_key, &thumbnail.content)?; + let digest = Sha256::digest(&ciphertext); + + let prepare = BlockUploadPreparationRequest { + address_id: draft.address_id.clone(), + volume_id: draft.volume_id.clone(), + link_id: draft.link_id.clone(), + revision_id: draft.revision_id.clone(), + blocks: Vec::new(), + thumbnails: vec![ThumbnailCreationRequest { + size: ciphertext.len() as i32, + thumbnail_type: thumbnail.thumbnail_type.as_i32(), + hash: BASE64.encode(digest), + }], + }; + let prepared: BlockUploadPreparationResponse = self.http.post("blocks", &prepare).await?; + let target = prepared + .thumbnail_upload_targets + .into_iter() + .next() + .ok_or_else(|| { + ProtonError::invalid_operation("thumbnail upload preparation returned no target") + })?; + + self.http + .post_storage_blob(&target.bare_url, &target.token, ciphertext) + .await?; + + Ok(digest.into()) + } + + /// Seal a draft revision: PUT a signed content manifest plus encrypted + + /// signed extended attributes, making the revision active. + async fn seal_revision( + &self, + draft: &RevisionDraft, + written: &BlockWriteResult, + ) -> Result<()> { + let manifest_signature = draft.signing_key.sign_detached(&written.manifest)?; + + let extended_attributes = ExtendedAttributes { + common: CommonExtendedAttributes { + size: written.total_size, + block_sizes: written.block_sizes.clone(), + digests: FileContentDigests { + sha1: written.sha1_hex.clone(), + }, + }, + }; + let xattr_json = serde_json::to_vec(&extended_attributes) + .map_err(|e| ProtonError::invalid_operation(format!("serialize xattr: {e}")))?; + let encrypted_xattr = + draft.node_key.encrypt_and_sign(&draft.signing_key, &xattr_json, false, true)?; + + let seal_request = RevisionUpdateRequest { + manifest_signature, + signature_address: draft.email.clone(), + checksum_verified: false, + extended_attributes: Some(encrypted_xattr), + }; + let seal_path = format!( + "v2/volumes/{}/files/{}/revisions/{}", + draft.volume_id, draft.link_id, draft.revision_id + ); + let _: proton_sdk::api::ApiResponse = self.http.put(&seal_path, &seal_request).await?; + Ok(()) + } + + /// The membership address for the My Files share: its id, email, and + /// primary (signing) private key. + async fn membership_address(&self) -> Result<(AddressId, String, PrivateKey)> { + self.ensure_my_files().await?; + let address_id = self + .cache + .lock() + .await + .my_files_share + .as_ref() + .expect("ensure_my_files populates the share") + .address_id + .clone(); + + let address = self + .account + .addresses() + .await? + .into_iter() + .find(|a| a.id == address_id) + .ok_or_else(|| ProtonError::invalid_operation("membership address not found"))?; + + 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("membership address has no primary key"))?; + + Ok((address_id, address.email, signing_key)) + } + + /// Decrypt the parent folder's hash key (HMAC key for name hashing). + async fn parent_hash_key( + &self, + parent_uid: &NodeUid, + parent_key: &PrivateKey, + ) -> Result> { + let details = self + .get_link_details(&parent_uid.volume_id, std::slice::from_ref(&parent_uid.link_id)) + .await?; + let folder = details + .links + .into_iter() + .next() + .and_then(|d| d.folder) + .ok_or_else(|| ProtonError::invalid_operation("parent node is not a folder"))?; + Ok(parent_key.decrypt_armored_message(&folder.hash_key)?) + } + + /// Fetch the block verification code, validating that the returned content + /// key packet decrypts under our node key (C# `NodeKeyAndSessionKey` check). + async fn fetch_verification_code( + &self, + volume_id: &VolumeId, + link_id: &LinkId, + revision_id: &str, + node_key: &PrivateKey, + ) -> Result> { + let path = + format!("v2/volumes/{volume_id}/links/{link_id}/revisions/{revision_id}/verification"); + let response: BlockVerificationInputResponse = self.http.get(&path).await?; + + let packet = BASE64 + .decode(response.content_key_packet.trim()) + .map_err(|e| ProtonError::invalid_operation(format!("decode verification packet: {e}")))?; + node_key.decrypt_content_key(&packet).map_err(|e| { + ProtonError::invalid_operation(format!("verification content key mismatch: {e}")) + })?; + + BASE64 + .decode(response.verification_code.trim()) + .map_err(|e| ProtonError::invalid_operation(format!("decode verification code: {e}"))) + } + + /// Fetch every block of a revision (paginated), returning the revision + /// metadata plus the contiguous, index-sorted block list. + async fn fetch_revision_blocks( + &self, + volume_id: &VolumeId, + link_id: &LinkId, + revision_id: &str, + ) -> Result<(RevisionDto, Vec)> { + const PAGE_SIZE: i32 = 50; + + let mut blocks: Vec = Vec::new(); + let mut metadata: Option = None; + let mut from_index: i32 = 1; + + loop { + let path = format!( + "v2/volumes/{volume_id}/files/{link_id}/revisions/{revision_id}?FromBlockIndex={from_index}&PageSize={PAGE_SIZE}&NoBlockUrls=0" + ); + let response: RevisionResponse = self.http.get(&path).await?; + let mut revision = response.revision; + let page = std::mem::take(&mut revision.blocks); + let page_len = page.len(); + + if metadata.is_none() { + metadata = Some(revision); + } + blocks.extend(page); + + if page_len < PAGE_SIZE as usize { + break; + } + from_index = blocks.iter().map(|b| b.index).max().unwrap_or(from_index) + 1; + } + + blocks.sort_by_key(|b| b.index); + for (offset, block) in blocks.iter().enumerate() { + if block.index != offset as i32 + 1 { + return Err(ProtonError::invalid_operation( + "file contents are incomplete (non-contiguous blocks)", + )); + } + } + + let metadata = metadata + .ok_or_else(|| ProtonError::invalid_operation("revision returned no metadata"))?; + Ok((metadata, blocks)) + } + + async fn ensure_my_files(&self) -> Result<()> { + if self.cache.lock().await.my_files_share.is_some() { + return Ok(()); + } + + let response: MyFilesShareResponse = self.http.get("v2/shares/my-files").await?; + let volume_id = response.volume.id.clone(); + let share_id = response.share.id.clone(); + + let share_key = decrypt_share_key(&self.account, &response.share).await?; + + // The My Files root link's parent key is the share key. + let root_link = &response.link.link; + let root_uid = NodeUid::new(volume_id.clone(), root_link.id.clone()); + let decrypted_root = decrypt_link(&share_key, root_link)?; + + let mut cache = self.cache.lock().await; + cache.main_volume_id = Some(volume_id); + cache.my_files_root = Some(root_uid.clone()); + cache + .folder_keys + .insert(root_uid, decrypted_root.node_key); + cache.my_files_share = Some(ShareKey { + share_id, + address_id: response.share.address_id.clone(), + key: share_key, + }); + Ok(()) + } + + async fn root_share_key(&self) -> Result { + self.ensure_my_files().await?; + Ok(self + .cache + .lock() + .await + .my_files_share + .as_ref() + .expect("ensure_my_files populates the share key") + .key + .clone()) + } + + /// Decrypted node key for a folder, decrypting (and caching) ancestors as + /// needed. + async fn folder_node_key(&self, uid: &NodeUid) -> Result { + if let Some(key) = self.cache.lock().await.folder_keys.get(uid) { + return Ok(key.clone()); + } + + let details = self + .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) + .await?; + let link = &details + .links + .first() + .ok_or_else(|| ProtonError::invalid_operation(format!("folder {uid} not found")))? + .link; + + let parent_key = self.resolve_parent_key(&uid.volume_id, link).await?; + let decrypted = decrypt_link(&parent_key, link)?; + + self.cache + .lock() + .await + .folder_keys + .insert(uid.clone(), decrypted.node_key.clone()); + Ok(decrypted.node_key) + } + + /// Resolve the key used to decrypt `link` (i.e. its parent's key). + /// + /// Walks the ancestor chain upward until it reaches a cached folder key or + /// the volume root (whose parent key is the share key), then decrypts back + /// down, caching each ancestor's node key. Iterative to avoid async + /// recursion. Mirrors `GetEntryPointKeyOrThrowAsync`. + async fn resolve_parent_key(&self, volume_id: &VolumeId, link: &LinkDto) -> Result { + let mut ancestry: Vec = Vec::new(); + let mut current = link.parent_id.clone(); + let mut base_key: Option = None; + + while let Some(parent_id) = current { + let uid = NodeUid::new(volume_id.clone(), parent_id.clone()); + + if let Some(key) = self.cache.lock().await.folder_keys.get(&uid) { + base_key = Some(key.clone()); + break; + } + + let details = self + .get_link_details(volume_id, std::slice::from_ref(&parent_id)) + .await?; + let ancestor = details + .links + .into_iter() + .next() + .ok_or_else(|| ProtonError::invalid_operation(format!("ancestor {uid} not found")))? + .link; + + current = ancestor.parent_id.clone(); + ancestry.push(ancestor); + } + + // Start from the resolved base (cached ancestor key, or the share key + // for a root) and decrypt downward toward `link`'s parent. + let mut key = match base_key { + Some(key) => key, + None => self.root_share_key().await?, + }; + + for ancestor in ancestry.iter().rev() { + let decrypted = decrypt_link(&key, ancestor)?; + let uid = NodeUid::new(volume_id.clone(), ancestor.id.clone()); + self.cache + .lock() + .await + .folder_keys + .insert(uid, decrypted.node_key.clone()); + key = decrypted.node_key; + } + + Ok(key) + } + + async fn get_link_details( + &self, + volume_id: &VolumeId, + link_ids: &[LinkId], + ) -> Result { + let path = format!("v2/volumes/{volume_id}/links"); + let body = LinkDetailsRequest { link_ids }; + self.http.post(&path, &body).await + } + + async fn build_node( + &self, + volume_id: &VolumeId, + details: &LinkDetailsDto, + parent_key: &PrivateKey, + ) -> Result { + let link = &details.link; + let decrypted = decrypt_link(parent_key, link)?; + + let uid = NodeUid::new(volume_id.clone(), link.id.clone()); + let parent_uid = link + .parent_id + .clone() + .map(|pid| NodeUid::new(volume_id.clone(), pid)); + + let kind = match link.parsed_type() { + LinkType::Folder | LinkType::Album => { + // Cache the folder's node key for later child enumeration. + self.cache + .lock() + .await + .folder_keys + .insert(uid.clone(), decrypted.node_key.clone()); + NodeKind::Folder + } + LinkType::File => { + let file = details.file.as_ref().ok_or_else(|| { + ProtonError::invalid_operation("file node missing file properties") + })?; + NodeKind::File { + media_type: file.media_type.clone(), + total_size_on_storage: file.total_size_on_storage, + } + } + LinkType::Unknown => { + return Err(ProtonError::invalid_operation(format!( + "unsupported link type {}", + link.link_type + ))); + } + }; + + Ok(Node { + uid, + parent_uid, + kind, + name: decrypted.name, + creation_time: link.creation_time, + modification_time: link.modification_time, + trashed: link.is_trashed(), + signature_email: link.signature_email.clone(), + }) + } +} + +/// Best-effort verification of a downloaded revision's content manifest. +/// +/// Mirrors C# `RevisionReader.VerifyManifestAsync`, but only the node-key +/// branch is enforced; an address-key signer requires public-key resolution +/// (`core/v4/keys/all`), still unimplemented, so it is logged and skipped. +/// Failures are logged rather than fatal, matching this milestone's best-effort +/// stance on signature verification. +fn verify_manifest(revision: &RevisionDto, node_key: &PrivateKey, manifest: &[u8]) { + let Some(signature) = &revision.manifest_signature else { + tracing::debug!("revision has no manifest signature; skipping integrity check"); + return; + }; + + let signed_by_node = revision + .signature_email + .as_deref() + .unwrap_or("") + .is_empty(); + if !signed_by_node { + tracing::warn!( + email = ?revision.signature_email, + "manifest signed by an address key; verification pending public-key resolution" + ); + return; + } + + match node_key.verify_detached_signature(signature, manifest) { + Ok(()) => tracing::debug!("manifest signature verified"), + Err(e) => tracing::warn!(error = %e, "manifest signature verification failed"), + } +} + +/// Fill `buf` from `reader`, returning the number of bytes read. Reads +/// repeatedly until `buf` is full or EOF, so a short `read` mid-stream never +/// splits a content block early; a return of `0` means clean EOF. +fn read_full_block(reader: &mut R, buf: &mut [u8]) -> Result { + let mut filled = 0; + while filled < buf.len() { + match reader.read(&mut buf[filled..]) { + Ok(0) => break, + Ok(n) => filled += n, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(ProtonError::invalid_operation(format!("read block: {e}"))), + } + } + Ok(filled) +} + +/// Group node uids by volume, preserving order, so each volume's links are +/// batched into a single request family (C# groups by `VolumeId`). +fn group_by_volume(uids: &[NodeUid]) -> Vec<(VolumeId, Vec)> { + let mut groups: Vec<(VolumeId, Vec)> = Vec::new(); + for uid in uids { + match groups.iter_mut().find(|(vid, _)| *vid == uid.volume_id) { + Some((_, link_ids)) => link_ids.push(uid.link_id.clone()), + None => groups.push((uid.volume_id.clone(), vec![uid.link_id.clone()])), + } + } + groups +} + +/// Fail if any per-link response in a batch aggregate carries a non-success +/// code (the top-level envelope is `MultipleResponses`, so the real status is +/// per link). `op` names the operation for the error message. +fn check_aggregate(op: &str, response: AggregateLinksResponse) -> Result<()> { + let failures: Vec = response + .responses + .iter() + .filter(|pair| !pair.response.is_success()) + .map(|pair| format!("{} ({:?})", pair.link_id, pair.response.code)) + .collect(); + if failures.is_empty() { + Ok(()) + } else { + Err(ProtonError::invalid_operation(format!( + "{op} failed for {} link(s): {}", + failures.len(), + failures.join(", ") + ))) + } +} + +/// HMAC-SHA256 of `data` under `key` (the parent folder hash key). +fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec { + let mut mac = >::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +/// Block verification token: `verificationCode XOR ciphertextPrefix`, with the +/// ciphertext prefix zero-padded or truncated to the code length. Mirrors C# +/// `VerificationToken.Create`. +fn verification_token(code: &[u8], ciphertext: &[u8]) -> Vec { + code.iter() + .enumerate() + .map(|(i, c)| c ^ ciphertext.get(i).copied().unwrap_or(0)) + .collect() +} diff --git a/crates/proton-drive-sdk/src/crypto.rs b/crates/proton-drive-sdk/src/crypto.rs new file mode 100644 index 0000000..24fb067 --- /dev/null +++ b/crates/proton-drive-sdk/src/crypto.rs @@ -0,0 +1,43 @@ +//! Drive-specific decryption: shares and link (node) keys/names. +//! +//! Mirrors `ShareCrypto` and the link half of `NodeCrypto` from the C# SDK. +//! Signature verification is treated as best-effort (not enforced) for this +//! read milestone. + +use proton_sdk::account::AccountClient; +use proton_sdk::crypto::{decrypt_armored_with_keys, PrivateKey}; +use proton_sdk::error::{ProtonError, Result}; + +use crate::dtos::{LinkDto, ShareDto}; + +/// Decrypt a share key: decrypt the share passphrase with the membership +/// address keys, then unlock the share's secret key with it. +pub async fn decrypt_share_key(account: &AccountClient, share: &ShareDto) -> Result { + let address_keys = account.address_private_keys(&share.address_id).await?; + + let passphrase = decrypt_armored_with_keys(&share.passphrase, &address_keys)?; + + let key = PrivateKey::from_armored(&share.key, &passphrase)?; + Ok(key) +} + +/// A link decrypted with its parent key. +pub struct DecryptedLink { + /// The node's own private key (unlocked). + pub node_key: PrivateKey, + /// The decrypted node name. + pub name: String, +} + +/// Decrypt a link's passphrase and name using the parent node/share key, and +/// unlock the node's key. +pub fn decrypt_link(parent_key: &PrivateKey, link: &LinkDto) -> Result { + let passphrase = parent_key.decrypt_armored_message(&link.passphrase)?; + let node_key = PrivateKey::from_armored(&link.key, &passphrase)?; + + let name_bytes = parent_key.decrypt_armored_message(&link.name)?; + let name = String::from_utf8(name_bytes) + .map_err(|e| ProtonError::invalid_operation(format!("node name is not valid UTF-8: {e}")))?; + + Ok(DecryptedLink { node_key, name }) +} diff --git a/crates/proton-drive-sdk/src/dtos.rs b/crates/proton-drive-sdk/src/dtos.rs new file mode 100644 index 0000000..d3bcb8d --- /dev/null +++ b/crates/proton-drive-sdk/src/dtos.rs @@ -0,0 +1,535 @@ +//! Serde DTOs for the Drive read endpoints (shares, volumes, links, folders). +//! +//! Some fields are deserialized for wire-format fidelity / forthcoming +//! milestones (uploads, signature verification) but not yet read. +#![allow(dead_code)] + +use serde::{Deserialize, Serialize}; + +use proton_sdk::ids::{AddressId, LinkId, ShareId, VolumeId}; + +/// `GET v2/shares/my-files` +#[derive(Debug, Deserialize)] +pub struct MyFilesShareResponse { + #[serde(rename = "Volume")] + pub volume: ShareVolumeDto, + #[serde(rename = "Share")] + pub share: ShareDto, + #[serde(rename = "Link")] + pub link: LinkDetailsDto, +} + +#[derive(Debug, Deserialize)] +pub struct ShareVolumeDto { + #[serde(rename = "VolumeID")] + pub id: VolumeId, +} + +#[derive(Debug, Deserialize)] +pub struct ShareDto { + #[serde(rename = "ShareID")] + pub id: ShareId, + #[serde(rename = "Key")] + pub key: String, + #[serde(rename = "Passphrase")] + pub passphrase: String, + #[serde(rename = "PassphraseSignature")] + pub passphrase_signature: Option, + #[serde(rename = "AddressID")] + pub address_id: AddressId, +} + +/// `POST v2/volumes/{vid}/links` request body. +#[derive(Debug, Serialize)] +pub struct LinkDetailsRequest<'a> { + #[serde(rename = "LinkIDs")] + pub link_ids: &'a [LinkId], +} + +/// `POST v2/volumes/{vid}/links` response. +#[derive(Debug, Deserialize)] +pub struct LinkDetailsResponse { + #[serde(rename = "Links")] + pub links: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct LinkDetailsDto { + #[serde(rename = "Link")] + pub link: LinkDto, + #[serde(rename = "Folder")] + pub folder: Option, + #[serde(rename = "File")] + pub file: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LinkDto { + #[serde(rename = "LinkID")] + pub id: LinkId, + #[serde(rename = "Type")] + pub link_type: i32, + #[serde(rename = "ParentLinkID")] + pub parent_id: Option, + #[serde(rename = "State")] + pub state: i32, + #[serde(rename = "CreateTime")] + pub creation_time: i64, + #[serde(rename = "ModifyTime")] + pub modification_time: i64, + #[serde(rename = "Trashed")] + pub trash_time: Option, + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "NodeKey")] + pub key: String, + #[serde(rename = "NodePassphrase")] + pub passphrase: String, + #[serde(rename = "NodePassphraseSignature")] + pub passphrase_signature: Option, + #[serde(rename = "SignatureEmail")] + pub signature_email: Option, + #[serde(rename = "NameSignatureEmail")] + pub name_signature_email: Option, +} + +impl LinkDto { + pub fn parsed_type(&self) -> LinkType { + LinkType::from_raw(self.link_type) + } + + pub fn is_trashed(&self) -> bool { + self.state == LinkState::Trashed as i32 || self.trash_time.is_some() + } +} + +#[derive(Debug, Deserialize)] +pub struct FolderDto { + #[serde(rename = "NodeHashKey")] + pub hash_key: String, + #[serde(rename = "XAttr")] + pub extended_attributes: Option, +} + +#[derive(Debug, Deserialize)] +pub struct FileDto { + #[serde(rename = "MediaType")] + pub media_type: String, + #[serde(rename = "TotalEncryptedSize")] + pub total_size_on_storage: i64, + /// Base64 PKESK packet for the file's content key, addressed to the node key. + #[serde(rename = "ContentKeyPacket")] + pub content_key_packet: Option, + /// Detached signature over the content key (best-effort verification). + #[serde(rename = "ContentKeyPacketSignature")] + pub content_key_signature: Option, + #[serde(rename = "ActiveRevision")] + pub active_revision: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ActiveRevisionDto { + #[serde(rename = "RevisionID")] + pub id: String, + #[serde(rename = "CreateTime")] + pub creation_time: i64, + #[serde(rename = "EncryptedSize")] + pub encrypted_size: i64, +} + +/// `GET v2/volumes/{vid}/files/{lid}/revisions/{rid}` — one page of a revision's +/// block listing. +#[derive(Debug, Deserialize)] +pub struct RevisionResponse { + #[serde(rename = "Revision")] + pub revision: RevisionDto, +} + +#[derive(Debug, Deserialize)] +pub struct RevisionDto { + #[serde(rename = "ID")] + pub id: String, + /// Detached signature over the content manifest (thumbnail + block digests). + #[serde(rename = "ManifestSignature")] + pub manifest_signature: Option, + /// Email of the signer; empty/absent means the node key signed. + #[serde(rename = "SignatureEmail")] + pub signature_email: Option, + #[serde(rename = "XAttr")] + pub extended_attributes: Option, + #[serde(rename = "Thumbnails", default)] + pub thumbnails: Vec, + #[serde(rename = "Blocks", default)] + pub blocks: Vec, +} + +/// One content block of a revision. +#[derive(Debug, Deserialize)] +pub struct BlockDto { + #[serde(rename = "Index")] + pub index: i32, + /// Absolute URL on block storage. + #[serde(rename = "BareURL")] + pub bare_url: String, + /// Per-block storage authorization token (`pm-storage-token` header). + #[serde(rename = "Token")] + pub token: String, +} + +#[derive(Debug, Deserialize)] +pub struct ThumbnailDto { + #[serde(rename = "Type")] + pub thumbnail_type: i32, + /// Base64 SHA-256 digest of the thumbnail's ciphertext (manifest input). + #[serde(rename = "Hash")] + pub hash_digest: Option, +} + +/// `GET v2/volumes/{vid}/folders/{lid}/children` +#[derive(Debug, Deserialize)] +pub struct FolderChildrenResponse { + #[serde(rename = "LinkIDs")] + pub link_ids: Vec, + #[serde(rename = "AnchorID")] + pub anchor_id: Option, + #[serde(rename = "More")] + pub more_results_exist: bool, +} + +/// Drive link (node) type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinkType { + Folder, + File, + Album, + Unknown, +} + +impl LinkType { + fn from_raw(raw: i32) -> Self { + match raw { + 1 => Self::Folder, + 2 => Self::File, + 3 => Self::Album, + _ => Self::Unknown, + } + } +} + +/// Drive link state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinkState { + Draft = 0, + Active = 1, + Trashed = 2, + Deleted = 3, + Restoring = 4, +} + +// ---- Upload (write) DTOs -------------------------------------------------- + +/// `POST v2/volumes/{vid}/files` — create a new file draft. +/// +/// Mirrors C# `FileCreationRequest` (+ its `NodeCreationRequest` base). All +/// encrypted/armored fields are produced client-side; `Hash` is the lowercase +/// hex name HMAC, `ContentKeyPacket` is base64. +#[derive(Debug, Serialize)] +pub struct FileCreationRequest { + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "Hash")] + pub name_hash: String, + #[serde(rename = "ParentLinkID")] + pub parent_link_id: LinkId, + #[serde(rename = "NodePassphrase")] + pub passphrase: String, + #[serde(rename = "NodePassphraseSignature")] + pub passphrase_signature: String, + #[serde(rename = "NodeKey")] + pub key: String, + #[serde(rename = "MIMEType")] + pub media_type: String, + #[serde(rename = "ContentKeyPacket")] + pub content_key_packet: String, + #[serde(rename = "ContentKeyPacketSignature")] + pub content_key_signature: String, + #[serde(rename = "SignatureAddress")] + pub signature_address: String, + #[serde(rename = "ClientUID", skip_serializing_if = "Option::is_none")] + pub client_uid: Option, + #[serde(rename = "IntendedUploadSize")] + pub intended_upload_size: i64, +} + +/// `POST v2/volumes/{vid}/files` response. +#[derive(Debug, Deserialize)] +pub struct FileCreationResponse { + #[serde(rename = "File")] + pub file: FileCreationIdentifiers, +} + +#[derive(Debug, Deserialize)] +pub struct FileCreationIdentifiers { + #[serde(rename = "ID")] + pub link_id: LinkId, + #[serde(rename = "RevisionID")] + pub revision_id: String, +} + +/// `POST v2/volumes/{vid}/folders` — create a new folder. +/// +/// Mirrors C# `FolderCreationRequest` (+ its `NodeCreationRequest` base). Like +/// [`FileCreationRequest`] but with a `NodeHashKey` (the folder's child-name +/// HMAC key, encrypted to its own node key) instead of any content-key fields. +#[derive(Debug, Serialize)] +pub struct FolderCreationRequest { + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "Hash")] + pub name_hash: String, + #[serde(rename = "ParentLinkID")] + pub parent_link_id: LinkId, + #[serde(rename = "NodePassphrase")] + pub passphrase: String, + #[serde(rename = "NodePassphraseSignature")] + pub passphrase_signature: String, + #[serde(rename = "NodeKey")] + pub key: String, + #[serde(rename = "NodeHashKey")] + pub node_hash_key: String, + /// Folder create uses `SignatureEmail` (file create uses `SignatureAddress`). + #[serde(rename = "SignatureEmail")] + pub signature_email: String, +} + +/// `POST v2/volumes/{vid}/folders` response. +#[derive(Debug, Deserialize)] +pub struct FolderCreationResponse { + #[serde(rename = "Folder")] + pub folder: FolderCreationIdentifiers, +} + +#[derive(Debug, Deserialize)] +pub struct FolderCreationIdentifiers { + #[serde(rename = "ID")] + pub link_id: LinkId, +} + +/// `PUT v2/volumes/{vid}/links/{lid}/rename` — rename a node. +/// +/// Mirrors C# `RenameLinkRequest`. `Hash`/`OriginalHash` are lowercase-hex name +/// HMACs (new and current). `MIMEType` is always present: the media type for a +/// file, `null` for a folder. +#[derive(Debug, Serialize)] +pub struct RenameLinkRequest { + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "Hash")] + pub name_hash: String, + #[serde(rename = "NameSignatureEmail")] + pub name_signature_email: String, + #[serde(rename = "MIMEType")] + pub media_type: Option, + #[serde(rename = "OriginalHash")] + pub original_hash: String, +} + +/// `PUT v2/volumes/{vid}/links/{lid}/move` — move a node to a new parent. +/// +/// Mirrors C# `MoveLinkRequest`. `NodePassphrase` is the node passphrase +/// rewrapped (session-key re-encrypted) to the destination parent key; the +/// secret is unchanged, so `NodePassphraseSignature` is carried over untouched. +/// `Name` is re-encrypted + signed to the destination parent. `Hash` is the new +/// name hash under the destination's hash key; `OriginalHash` the current hash +/// under the source parent's. Same-volume moves only (no `NewShareID`). +#[derive(Debug, Serialize)] +pub struct MoveLinkRequest { + #[serde(rename = "ParentLinkID")] + pub parent_link_id: LinkId, + #[serde(rename = "NodePassphrase")] + pub passphrase: String, + #[serde(rename = "NodePassphraseSignature", skip_serializing_if = "Option::is_none")] + pub passphrase_signature: Option, + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "NameSignatureEmail")] + pub name_signature_email: String, + #[serde(rename = "Hash")] + pub name_hash: String, + #[serde(rename = "OriginalHash")] + pub original_hash: String, +} + +/// `{ LinkIDs: [...] }` — batch link-id body for trash / restore / delete. +/// Mirrors C# `MultipleLinksNullaryRequest`. +#[derive(Debug, Serialize)] +pub struct MultipleLinksRequest<'a> { + #[serde(rename = "LinkIDs")] + pub link_ids: &'a [LinkId], +} + +/// Aggregate response for batch link operations: a per-link result list. The +/// top-level envelope is `1001 MultipleResponses`; the real per-link status is +/// in each [`LinkIdResponsePair`]. Mirrors C# `AggregateApiResponse`. +#[derive(Debug, Deserialize)] +pub struct AggregateLinksResponse { + #[serde(rename = "Responses", default)] + pub responses: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct LinkIdResponsePair { + #[serde(rename = "LinkID")] + pub link_id: LinkId, + #[serde(rename = "Response")] + pub response: proton_sdk::api::ApiResponse, +} + +/// `POST v2/volumes/{vid}/files/{lid}/revisions` — create a new revision on an +/// existing file. Mirrors C# `RevisionCreationRequest`. +#[derive(Debug, Serialize)] +pub struct RevisionCreationRequest { + /// The currently active revision this draft supersedes. + #[serde(rename = "CurrentRevisionID")] + pub current_revision_id: String, + #[serde(rename = "ClientUID", skip_serializing_if = "Option::is_none")] + pub client_uid: Option, + #[serde(rename = "IntendedUploadSize")] + pub intended_upload_size: i64, +} + +/// `POST v2/volumes/{vid}/files/{lid}/revisions` response. +#[derive(Debug, Deserialize)] +pub struct RevisionCreationResponse { + #[serde(rename = "Revision")] + pub revision: RevisionCreationIdentity, +} + +#[derive(Debug, Deserialize)] +pub struct RevisionCreationIdentity { + #[serde(rename = "ID")] + pub revision_id: String, +} + +/// `GET v2/volumes/{vid}/links/{lid}/revisions/{rid}/verification`. +#[derive(Debug, Deserialize)] +pub struct BlockVerificationInputResponse { + /// Base64 verification code XORed with the block ciphertext prefix. + #[serde(rename = "VerificationCode")] + pub verification_code: String, + /// Base64 content key packet (re-encrypted to the node key) for the check. + #[serde(rename = "ContentKeyPacket")] + pub content_key_packet: String, +} + +/// `POST blocks` — request upload targets for content/thumbnail blocks. +/// +/// Mirrors C# `BlockUploadPreparationRequest`. +#[derive(Debug, Serialize)] +pub struct BlockUploadPreparationRequest { + #[serde(rename = "AddressID")] + pub address_id: AddressId, + #[serde(rename = "VolumeID")] + pub volume_id: VolumeId, + #[serde(rename = "LinkID")] + pub link_id: LinkId, + #[serde(rename = "RevisionID")] + pub revision_id: String, + #[serde(rename = "BlockList")] + pub blocks: Vec, + #[serde(rename = "ThumbnailList")] + pub thumbnails: Vec, +} + +#[derive(Debug, Serialize)] +pub struct BlockCreationRequest { + #[serde(rename = "Index")] + pub index: i32, + #[serde(rename = "Size")] + pub size: i32, + /// Armored PGP message: the block's detached signature, encrypted to the + /// node key. + #[serde(rename = "EncSignature")] + pub encrypted_signature: String, + /// Base64 SHA-256 of the block ciphertext. + #[serde(rename = "Hash")] + pub hash: String, + #[serde(rename = "Verifier")] + pub verifier: BlockVerifier, +} + +#[derive(Debug, Serialize)] +pub struct BlockVerifier { + /// Base64 verification token (`code XOR ciphertext_prefix`). + #[serde(rename = "Token")] + pub token: String, +} + +/// Thumbnail creation entry in a block-upload preparation request. Mirrors C# +/// `ThumbnailCreationRequest` (`Size`, `Type`, base64 ciphertext `Hash`). +#[derive(Debug, Serialize)] +pub struct ThumbnailCreationRequest { + #[serde(rename = "Size")] + pub size: i32, + #[serde(rename = "Type")] + pub thumbnail_type: i32, + #[serde(rename = "Hash")] + pub hash: String, +} + +/// `POST blocks` response. +#[derive(Debug, Deserialize)] +pub struct BlockUploadPreparationResponse { + #[serde(rename = "UploadLinks")] + pub upload_targets: Vec, + #[serde(rename = "ThumbnailLinks", default)] + pub thumbnail_upload_targets: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct BlockUploadTarget { + #[serde(rename = "BareURL")] + pub bare_url: String, + #[serde(rename = "Token")] + pub token: String, +} + +/// `PUT v2/volumes/{vid}/files/{lid}/revisions/{rid}` — seal the revision. +/// +/// Mirrors C# `RevisionUpdateRequest`. +#[derive(Debug, Serialize)] +pub struct RevisionUpdateRequest { + #[serde(rename = "ManifestSignature")] + pub manifest_signature: String, + #[serde(rename = "SignatureAddress")] + pub signature_address: String, + #[serde(rename = "ChecksumVerified")] + pub checksum_verified: bool, + #[serde(rename = "XAttr", skip_serializing_if = "Option::is_none")] + pub extended_attributes: Option, +} + +/// The decrypted `XAttr` JSON payload for a revision (encrypted to the node key +/// before upload). Mirrors C# `ExtendedAttributes` / `CommonExtendedAttributes`. +#[derive(Debug, Serialize)] +pub struct ExtendedAttributes { + #[serde(rename = "Common")] + pub common: CommonExtendedAttributes, +} + +#[derive(Debug, Serialize)] +pub struct CommonExtendedAttributes { + #[serde(rename = "Size")] + pub size: i64, + #[serde(rename = "BlockSizes")] + pub block_sizes: Vec, + #[serde(rename = "Digests")] + pub digests: FileContentDigests, +} + +#[derive(Debug, Serialize)] +pub struct FileContentDigests { + /// Lowercase hex SHA-1 of the full plaintext. + #[serde(rename = "SHA1")] + pub sha1: String, +} diff --git a/crates/proton-drive-sdk/src/lib.rs b/crates/proton-drive-sdk/src/lib.rs new file mode 100644 index 0000000..6a59ded --- /dev/null +++ b/crates/proton-drive-sdk/src/lib.rs @@ -0,0 +1,42 @@ +//! High-level Proton Drive client (Rust port). +//! +//! Built on top of [`proton_sdk`]. Milestone 1 covers session-backed read +//! operations: resolving the My Files folder, fetching node metadata and +//! enumerating folder children. +//! +//! ```no_run +//! # async fn run() -> proton_sdk::error::Result<()> { +//! use proton_sdk::config::ProtonClientConfiguration; +//! use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; +//! use proton_drive_sdk::ProtonDriveClient; +//! +//! let config = ProtonClientConfiguration::new("external-drive-myapp@0.1.0-alpha"); +//! let session = ProtonApiSession::resume(config, ResumeParameters { +//! session_id: "uid".into(), +//! username: "user@proton.me".into(), +//! user_id: "user-id".into(), +//! access_token: "access".into(), +//! refresh_token: "refresh".into(), +//! scopes: vec![], +//! is_waiting_for_second_factor_code: false, +//! password_mode: PasswordMode::Single, +//! })?; +//! +//! let drive = ProtonDriveClient::new(&session, b"mailbox-password".to_vec()); +//! let root = drive.get_my_files_folder().await?; +//! for child in drive.enumerate_folder_children(&root.uid).await? { +//! println!("{} ({:?})", child.name, child.kind); +//! } +//! # Ok(()) +//! # } +//! ``` + +mod client; +mod crypto; +mod dtos; +mod node; + +pub use client::ProtonDriveClient; +pub use node::{Node, NodeKind, Thumbnail, ThumbnailType}; + +pub use proton_sdk; diff --git a/crates/proton-drive-sdk/src/node.rs b/crates/proton-drive-sdk/src/node.rs new file mode 100644 index 0000000..b93ed92 --- /dev/null +++ b/crates/proton-drive-sdk/src/node.rs @@ -0,0 +1,83 @@ +//! Public, decrypted node model returned to callers. + +use proton_sdk::ids::NodeUid; + +/// A decrypted Drive node (folder or file). +#[derive(Debug, Clone)] +pub struct Node { + pub uid: NodeUid, + pub parent_uid: Option, + pub kind: NodeKind, + /// Decrypted node name. + pub name: String, + /// Creation time, epoch seconds. + pub creation_time: i64, + /// Last modification time, epoch seconds. + pub modification_time: i64, + pub trashed: bool, + /// Email address that signed the node, if present. + pub signature_email: Option, +} + +/// Folder- or file-specific node data. +#[derive(Debug, Clone)] +pub enum NodeKind { + Folder, + File { + media_type: String, + /// Total encrypted size on cloud storage, in bytes. + total_size_on_storage: i64, + }, +} + +impl Node { + pub fn is_folder(&self) -> bool { + matches!(self.kind, NodeKind::Folder) + } + + pub fn is_file(&self) -> bool { + matches!(self.kind, NodeKind::File { .. }) + } +} + +/// The kind of a thumbnail. Mirrors C# `Proton.Drive.Sdk.Nodes.ThumbnailType`. +/// +/// The discriminant is the wire `Type` value sent to the API and the key the +/// download path sorts by when building the content manifest. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[repr(i32)] +pub enum ThumbnailType { + /// The small thumbnail (`Type = 1`). + Thumbnail = 1, + /// The larger preview image (`Type = 2`). + Preview = 2, +} + +impl ThumbnailType { + /// The wire `Type` discriminant. + pub fn as_i32(self) -> i32 { + self as i32 + } +} + +/// A caller-supplied, already-rendered thumbnail to attach to an upload. +/// +/// The SDK does not generate thumbnails — the caller renders the image bytes +/// (matching the C# SDK, where the platform supplies the bitmap). Mirrors C# +/// `Proton.Drive.Sdk.Nodes.Thumbnail`. +#[derive(Debug, Clone)] +pub struct Thumbnail { + pub thumbnail_type: ThumbnailType, + /// The rendered image bytes (e.g. JPEG/WebP). Must be non-empty. + pub content: Vec, +} + +impl Thumbnail { + /// Create a thumbnail from rendered image bytes. + pub fn new(thumbnail_type: ThumbnailType, content: Vec) -> Self { + Self { + thumbnail_type, + content, + } + } +} diff --git a/crates/proton-sdk/Cargo.toml b/crates/proton-sdk/Cargo.toml new file mode 100644 index 0000000..2c43f2d --- /dev/null +++ b/crates/proton-sdk/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "proton-sdk" +description = "Core Proton account/session/crypto primitives for the Rust Proton SDK" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +tokio.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_repr.workspace = true +thiserror.workspace = true +async-trait.workspace = true +futures.workspace = true +bytes.workspace = true +base64.workspace = true +hex.workspace = true +hmac.workspace = true +sha2.workspace = true +bcrypt.workspace = true +pgp.workspace = true +tracing.workspace = true +num-bigint.workspace = true +num-traits.workspace = true +getrandom.workspace = true +rand.workspace = true diff --git a/crates/proton-sdk/src/account.rs b/crates/proton-sdk/src/account.rs new file mode 100644 index 0000000..71966b5 --- /dev/null +++ b/crates/proton-sdk/src/account.rs @@ -0,0 +1,267 @@ +//! Account client: user, addresses and their decrypted private keys. +//! +//! Mirrors the parts of `ProtonAccountClient` / `AddressOperations` that the +//! Drive read path needs. The unlock chain is: +//! +//! 1. mailbox password + per-key salt → bcrypt-derived passphrase +//! 2. passphrase → unlock **user keys** +//! 3. user keys → decrypt an **address key token** → unlock the address key +//! +//! (Some address keys have no token and instead reuse an account-key passphrase +//! derived in step 1.) Decrypted keys are cached for the lifetime of the client. + +mod dtos; + +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::Mutex; + +use crate::crypto::{self, PrivateKey}; +use crate::error::{ProtonError, Result}; +use crate::http::ApiHttpClient; +use crate::ids::AddressId; +use crate::session::ProtonApiSession; + +use dtos::{AddressDto, AddressListResponse, KeySaltListResponse, UserResponse}; + +/// Public view of an email address attached to the account. +#[derive(Debug, Clone)] +pub struct Address { + pub id: AddressId, + pub email: String, + pub order: i32, + pub status: i32, + /// Index, within the decrypted key list, of the primary key. + pub primary_key_index: usize, +} + +/// Resolves account keys needed to decrypt Drive metadata. +/// +/// Construction takes the mailbox (data) password because, per Proton's key +/// model, it is required even for read-only decryption. +#[derive(Clone)] +pub struct AccountClient { + inner: Arc, +} + +struct Inner { + http: ApiHttpClient, + mailbox_password: Vec, + cache: Mutex, +} + +#[derive(Default)] +struct Cache { + /// key id → passphrase derived from the mailbox password and key salts. + key_passphrases: Option>>, + user_keys: Option>, + addresses: Option>, + address_keys: HashMap>, +} + +impl AccountClient { + pub fn new(session: &ProtonApiSession, mailbox_password: impl Into>) -> Self { + Self { + inner: Arc::new(Inner { + http: session.http().clone(), + mailbox_password: mailbox_password.into(), + cache: Mutex::new(Cache::default()), + }), + } + } + + /// All addresses on the account, ordered by their `Order` field. + pub async fn addresses(&self) -> Result> { + { + let cache = self.inner.cache.lock().await; + if let Some(addresses) = &cache.addresses { + return Ok(addresses.clone()); + } + } + + // Ensure user keys (and thus the key passphrases) are loaded first. + self.user_keys().await?; + + let response: AddressListResponse = self.inner.http.get("core/v4/addresses").await?; + let mut addresses = Vec::with_capacity(response.addresses.len()); + for dto in &response.addresses { + let (address, keys) = self.decrypt_address(dto).await?; + self.inner + .cache + .lock() + .await + .address_keys + .insert(address.id.clone(), keys); + addresses.push(address); + } + addresses.sort_by_key(|a| a.order); + + self.inner.cache.lock().await.addresses = Some(addresses.clone()); + Ok(addresses) + } + + /// The default (lowest-ordered) address. + pub async fn default_address(&self) -> Result
{ + self.addresses() + .await? + .into_iter() + .next() + .ok_or_else(|| ProtonError::invalid_operation("user has no address")) + } + + /// Decrypted private keys for a given address. + pub async fn address_private_keys(&self, address_id: &AddressId) -> Result> { + { + let cache = self.inner.cache.lock().await; + if let Some(keys) = cache.address_keys.get(address_id) { + return Ok(keys.clone()); + } + } + + // Loading all addresses populates the per-address key cache. + self.addresses().await?; + + let cache = self.inner.cache.lock().await; + cache + .address_keys + .get(address_id) + .cloned() + .ok_or_else(|| { + ProtonError::invalid_operation(format!("no keys for address {address_id}")) + }) + } + + /// Decrypted user (account) keys. + async fn user_keys(&self) -> Result> { + { + let cache = self.inner.cache.lock().await; + if let Some(keys) = &cache.user_keys { + return Ok(keys.clone()); + } + } + + let passphrases = self.key_passphrases().await?; + let response: UserResponse = self.inner.http.get("core/v4/users").await?; + + let mut keys = Vec::new(); + for key_dto in &response.user.keys { + if !key_dto.is_active() { + continue; + } + let Some(passphrase) = passphrases.get(key_dto.id.as_str()) else { + tracing::warn!(key_id = %key_dto.id, "no passphrase for user key"); + continue; + }; + match PrivateKey::from_armored(&key_dto.private_key, passphrase) { + Ok(key) => keys.push(key), + Err(e) => tracing::warn!(key_id = %key_dto.id, error = %e, "failed to unlock user key"), + } + } + + if keys.is_empty() { + return Err(ProtonError::invalid_operation( + "no active user key could be unlocked", + )); + } + + self.inner.cache.lock().await.user_keys = Some(keys.clone()); + Ok(keys) + } + + /// Derive (and cache) the per-key passphrases from the mailbox password and + /// the account's key salts. + async fn key_passphrases(&self) -> Result>> { + { + let cache = self.inner.cache.lock().await; + if let Some(passphrases) = &cache.key_passphrases { + return Ok(passphrases.clone()); + } + } + + let response: KeySaltListResponse = self.inner.http.get("core/v4/keys/salts").await?; + let mut passphrases = HashMap::new(); + for salt in &response.key_salts { + let Some(salt_b64) = &salt.value else { continue }; + if salt_b64.is_empty() { + continue; + } + let salt_bytes = decode_base64(salt_b64)?; + let passphrase = + crypto::derive_key_passphrase(&self.inner.mailbox_password, &salt_bytes)?; + passphrases.insert(salt.key_id.clone(), passphrase); + } + + self.inner.cache.lock().await.key_passphrases = Some(passphrases.clone()); + Ok(passphrases) + } + + /// Build the public [`Address`] and unlock all of its active private keys. + async fn decrypt_address(&self, dto: &AddressDto) -> Result<(Address, Vec)> { + let user_keys = self.user_keys().await?; + let passphrases = self.key_passphrases().await?; + + let mut keys = Vec::new(); + let mut primary_key_index = None; + + for key_dto in &dto.keys { + if !key_dto.is_active() { + continue; + } + + // Two ways to obtain the passphrase that unlocks an address key: + // decrypt its token with the user keys, or fall back to an + // account-key passphrase derived from the mailbox password. + let passphrase = match (&key_dto.token, &key_dto.signature) { + (Some(token), Some(_signature)) => { + match crypto::decrypt_armored_with_keys(token, &user_keys) { + Ok(passphrase) => passphrase, + Err(e) => { + tracing::warn!(key_id = %key_dto.id, error = %e, "failed to decrypt address key token"); + continue; + } + } + } + _ => match passphrases.get(key_dto.id.as_str()) { + Some(passphrase) => passphrase.clone(), + None => { + tracing::warn!(key_id = %key_dto.id, "no passphrase for address key"); + continue; + } + }, + }; + + match PrivateKey::from_armored(&key_dto.private_key, &passphrase) { + Ok(key) => { + if key_dto.is_primary() && primary_key_index.is_none() { + primary_key_index = Some(keys.len()); + } + keys.push(key); + } + Err(e) => { + tracing::warn!(key_id = %key_dto.id, error = %e, "failed to unlock address key"); + } + } + } + + let primary_key_index = primary_key_index + .ok_or_else(|| ProtonError::invalid_operation(format!("address {} has no primary key", dto.id)))?; + + let address = Address { + id: dto.id.clone(), + email: dto.email.clone(), + order: dto.order, + status: dto.status, + primary_key_index, + }; + + Ok((address, keys)) + } +} + +fn decode_base64(value: &str) -> Result> { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(value) + .map_err(|e| ProtonError::invalid_operation(format!("invalid base64 salt: {e}"))) +} diff --git a/crates/proton-sdk/src/account/dtos.rs b/crates/proton-sdk/src/account/dtos.rs new file mode 100644 index 0000000..9d783fe --- /dev/null +++ b/crates/proton-sdk/src/account/dtos.rs @@ -0,0 +1,104 @@ +//! Serde DTOs for the account/user/address/key-salt endpoints. +//! +//! Proton encodes booleans as integers (`0`/`1`), so flag fields are kept as +//! `i32` with `is_*` accessors rather than relying on bool deserialization. +//! +//! A few fields are kept for wire-format fidelity but not yet read. +#![allow(dead_code)] + +use serde::Deserialize; + +use crate::ids::{AddressId, AddressKeyId, UserId, UserKeyId}; + +#[derive(Debug, Deserialize)] +pub(super) struct UserResponse { + #[serde(rename = "User")] + pub user: UserDto, +} + +#[derive(Debug, Deserialize)] +pub(super) struct UserDto { + #[serde(rename = "ID")] + pub id: UserId, + #[serde(rename = "Keys")] + pub keys: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct UserKeyDto { + #[serde(rename = "ID")] + pub id: UserKeyId, + #[serde(rename = "PrivateKey")] + pub private_key: String, + #[serde(rename = "Primary")] + pub primary: i32, + #[serde(rename = "Active")] + pub active: i32, +} + +impl UserKeyDto { + pub fn is_active(&self) -> bool { + self.active != 0 + } +} + +#[derive(Debug, Deserialize)] +pub(super) struct AddressListResponse { + #[serde(rename = "Addresses")] + pub addresses: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct AddressDto { + #[serde(rename = "ID")] + pub id: AddressId, + #[serde(rename = "Email")] + pub email: String, + #[serde(rename = "Status")] + pub status: i32, + #[serde(rename = "Order")] + pub order: i32, + #[serde(rename = "Keys")] + pub keys: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct AddressKeyDto { + #[serde(rename = "ID")] + pub id: AddressKeyId, + #[serde(rename = "PrivateKey")] + pub private_key: String, + #[serde(rename = "Token")] + pub token: Option, + #[serde(rename = "Signature")] + pub signature: Option, + #[serde(rename = "Primary")] + pub primary: i32, + #[serde(rename = "Active")] + pub active: i32, +} + +impl AddressKeyDto { + pub fn is_active(&self) -> bool { + self.active != 0 + } + + pub fn is_primary(&self) -> bool { + self.primary != 0 + } +} + +#[derive(Debug, Deserialize)] +pub(super) struct KeySaltListResponse { + #[serde(rename = "KeySalts")] + pub key_salts: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct KeySalt { + #[serde(rename = "ID")] + pub key_id: String, + /// Base64-encoded 16-byte salt; `null` for keys without a salt. + #[serde(rename = "KeySalt")] + pub value: Option, +} diff --git a/crates/proton-sdk/src/api.rs b/crates/proton-sdk/src/api.rs new file mode 100644 index 0000000..5857178 --- /dev/null +++ b/crates/proton-sdk/src/api.rs @@ -0,0 +1,113 @@ +//! API response envelope and response codes shared across all Proton endpoints. + +use serde::{Deserialize, Deserializer}; + +/// The common envelope every Proton API JSON response embeds. +/// +/// Successful responses carry `Code == 1000` plus their endpoint-specific +/// fields; failures carry a non-success code and an `Error` message. +#[derive(Debug, Clone, Deserialize)] +pub struct ApiResponse { + #[serde(rename = "Code")] + pub code: ResponseCode, + + #[serde(rename = "Error", default)] + pub error_message: Option, +} + +impl ApiResponse { + pub fn is_success(&self) -> bool { + matches!(self.code, ResponseCode::Success) + } +} + +/// Application-level response codes returned in the `Code` field. +/// +/// Mirrors `Proton.Sdk.Api.ResponseCode`. Unknown / future codes deserialize to +/// [`ResponseCode::Unknown`] via [`ResponseCode::from_raw`] rather than failing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i64)] +pub enum ResponseCode { + Unknown = 0, + + Unauthorized = 401, + Forbidden = 403, + RequestTimeout = 408, + + Success = 1000, + MultipleResponses = 1001, + InvalidRequirements = 2000, + InvalidValue = 2001, + NotEnoughPermissions = 2011, + NotEnoughPermissionsToGrantPermissions = 2026, + InvalidEncryptedIdFormat = 2061, + AlreadyExists = 2500, + DoesNotExist = 2501, + Timeout = 2503, + IncompatibleState = 2511, + InvalidApp = 5002, + OutdatedApp = 5003, + Offline = 7001, + IncorrectLoginCredentials = 8002, + AccountDeleted = 10_002, + AccountDisabled = 10_003, + InvalidRefreshToken = 10_013, + NoActiveSubscription = 22_110, + AddressMissing = 33_102, + DomainExternal = 33_103, + ProtonDriveUnknown = 200_000, + InsufficientQuota = 200_001, + InsufficientSpace = 200_002, + InsufficientVolumeQuota = 200_100, + TooManyChildren = 200_300, + NestingTooDeep = 200_301, +} + +impl ResponseCode { + /// Map a raw integer code to a known variant, falling back to [`Self::Unknown`]. + pub fn from_raw(raw: i64) -> Self { + match raw { + 401 => Self::Unauthorized, + 403 => Self::Forbidden, + 408 => Self::RequestTimeout, + 1000 => Self::Success, + 1001 => Self::MultipleResponses, + 2000 => Self::InvalidRequirements, + 2001 => Self::InvalidValue, + 2011 => Self::NotEnoughPermissions, + 2026 => Self::NotEnoughPermissionsToGrantPermissions, + 2061 => Self::InvalidEncryptedIdFormat, + 2500 => Self::AlreadyExists, + 2501 => Self::DoesNotExist, + 2503 => Self::Timeout, + 2511 => Self::IncompatibleState, + 5002 => Self::InvalidApp, + 5003 => Self::OutdatedApp, + 7001 => Self::Offline, + 8002 => Self::IncorrectLoginCredentials, + 10_002 => Self::AccountDeleted, + 10_003 => Self::AccountDisabled, + 10_013 => Self::InvalidRefreshToken, + 22_110 => Self::NoActiveSubscription, + 33_102 => Self::AddressMissing, + 33_103 => Self::DomainExternal, + 200_000 => Self::ProtonDriveUnknown, + 200_001 => Self::InsufficientQuota, + 200_002 => Self::InsufficientSpace, + 200_100 => Self::InsufficientVolumeQuota, + 200_300 => Self::TooManyChildren, + 200_301 => Self::NestingTooDeep, + _ => Self::Unknown, + } + } +} + +impl<'de> Deserialize<'de> for ResponseCode { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let raw = i64::deserialize(deserializer)?; + Ok(ResponseCode::from_raw(raw)) + } +} diff --git a/crates/proton-sdk/src/config.rs b/crates/proton-sdk/src/config.rs new file mode 100644 index 0000000..ed7ee63 --- /dev/null +++ b/crates/proton-sdk/src/config.rs @@ -0,0 +1,57 @@ +//! Client configuration and defaults. + +use std::time::Duration; + +/// Default base URL for the Proton Drive API. +pub const DEFAULT_BASE_URL: &str = "https://drive-api.proton.me/"; + +/// Default redirect URI sent with token refresh requests. +pub const DEFAULT_REFRESH_REDIRECT_URI: &str = "https://proton.me"; + +/// Default per-request timeout, matching `ProtonApiDefaults.DefaultTimeoutSeconds`. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +/// Content type used for the `Accept` header on API requests. +pub const API_CONTENT_TYPE: &str = "application/vnd.protonmail.api+json"; + +/// Configuration for a Proton client session. +/// +/// `app_version` is mandatory and must honour the operational requirements in +/// the SDK README (`external-drive-{name}@{semver}-{channel}`), sent as the +/// `x-pm-appversion` header. +#[derive(Debug, Clone)] +pub struct ProtonClientConfiguration { + pub base_url: String, + pub app_version: String, + pub user_agent: String, + pub refresh_redirect_uri: String, + pub request_timeout: Duration, +} + +impl ProtonClientConfiguration { + /// Create a configuration with the given app version and otherwise defaults. + pub fn new(app_version: impl Into) -> Self { + Self { + base_url: DEFAULT_BASE_URL.to_owned(), + app_version: app_version.into(), + user_agent: String::new(), + refresh_redirect_uri: DEFAULT_REFRESH_REDIRECT_URI.to_owned(), + request_timeout: DEFAULT_TIMEOUT, + } + } + + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into(); + self + } + + pub fn with_user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = user_agent.into(); + self + } + + pub fn with_request_timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout; + self + } +} diff --git a/crates/proton-sdk/src/crypto.rs b/crates/proton-sdk/src/crypto.rs new file mode 100644 index 0000000..f415cac --- /dev/null +++ b/crates/proton-sdk/src/crypto.rs @@ -0,0 +1,24 @@ +//! PGP cryptography primitives backed by rPGP. +//! +//! Proton's key model is layered: an address key (unlocked from the account +//! passphrase) decrypts a share/node *passphrase*, which in turn unlocks the +//! locked node key, which decrypts names, hash keys and content keys. This +//! module exposes the small set of operations that chain implements. + +mod content; +mod derive; +mod encrypt; +mod errors; +mod keys; +mod messages; +mod srp; + +pub use content::ContentKey; +pub use derive::derive_key_passphrase; +pub use encrypt::{generate_node_hash_key, generate_node_key, GeneratedNodeKey}; +pub use errors::CryptoError; +pub use keys::{decrypt_armored_with_keys, PrivateKey}; +pub use srp::{generate_proofs, SrpProofs, DEFAULT_BIT_LENGTH}; + +/// Result alias for crypto operations. +pub type CryptoResult = std::result::Result; diff --git a/crates/proton-sdk/src/crypto/content.rs b/crates/proton-sdk/src/crypto/content.rs new file mode 100644 index 0000000..4ec46e3 --- /dev/null +++ b/crates/proton-sdk/src/crypto/content.rs @@ -0,0 +1,618 @@ +//! File content-key decryption and block (session-key) decryption. +//! +//! Proton Drive encrypts every block of a file with a single symmetric *content +//! key*. The content key itself is delivered as a PGP public-key-encrypted +//! session-key (PKESK) packet addressed to the node key. Mirrors the C# +//! `NodeCrypto.DecryptContentKey` (`nodeKey.DecryptSessionKey(contentKeyPacket)`) +//! followed by `PgpSessionKey.OpenDecryptingStream` for each block. + +use std::io::Cursor; + +use pgp::armor::Dearmor; +use pgp::composed::{MessageBuilder, PlainSessionKey}; +use pgp::crypto::aead::{AeadAlgorithm, ChunkSize}; +use pgp::crypto::hash::HashAlgorithm; +use pgp::crypto::sym::SymmetricKeyAlgorithm; +use pgp::packet::{ + LiteralData, Packet, PacketParser, PacketTrait, PublicKeyEncryptedSessionKey, + SymEncryptedProtectedData, +}; +use pgp::types::{EskType, PkeskVersion, PublicKeyTrait}; + +use super::encrypt::{recipient_encryption_key, RecipientOp}; +use super::errors::CryptoError; +use super::keys::PrivateKey; + +/// 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 +/// official clients hardcode `aes256` + `gcm` likewise). +const AEAD_ALGO: AeadAlgorithm = AeadAlgorithm::Gcm; +/// AEAD streaming chunk size: 128 KiB (C# `PgpAeadStreamingChunkLength`). +const AEAD_CHUNK_SIZE: ChunkSize = ChunkSize::C128KiB; + +/// A decrypted file content key (a symmetric PGP session key). +/// +/// Decrypts the file's data blocks; each block is a PGP message encrypted to +/// this session key, carrying no ESK of its own (the session key is supplied +/// externally, here). A key may be *legacy* (a V3/V4 session key sealing SEIPDv1 +/// blocks) or *AEAD* (a V6 session key sealing SEIPDv2 / AES-256-GCM blocks); the +/// underlying [`PlainSessionKey`] variant records which. +#[derive(Clone)] +pub struct ContentKey { + session_key: PlainSessionKey, +} + +impl ContentKey { + /// Generate a fresh AES-256 content key for a new file (legacy SEIPDv1 path). + /// + /// Mirrors C# `PgpSessionKey.Generate()`. + pub fn generate() -> Self { + let mut rng = rand::thread_rng(); + let sym_alg = SymmetricKeyAlgorithm::AES256; + let key = sym_alg.new_session_key(&mut rng).to_vec(); + ContentKey { + session_key: PlainSessionKey::V3_4 { sym_alg, key }, + } + } + + /// Generate a fresh AES-256 content key for the AEAD path (SEIPDv2 blocks). + /// + /// The key is held as a V6 session key, which is delivered in the file's + /// content key packet as a v6 PKESK. Mirrors C# + /// `PgpSessionKey.GenerateForAead()`. + pub fn generate_aead() -> Self { + let mut rng = rand::thread_rng(); + let key = SymmetricKeyAlgorithm::AES256.new_session_key(&mut rng).to_vec(); + ContentKey { + session_key: PlainSessionKey::V6 { key }, + } + } + + /// Whether this content key seals blocks with AEAD (SEIPDv2) rather than the + /// legacy SEIPDv1 path. Determined by the session-key version (a V6 session + /// key is the AEAD case). Mirrors C# `PgpSessionKey.IsAead()`. + pub fn is_aead(&self) -> bool { + matches!(self.session_key, PlainSessionKey::V6 { .. }) + } + + /// Encrypt one file block: wrap `plaintext` in a literal-data packet and seal + /// it under this content key — as a SEIPDv1 (AES-256-CFB + MDC) packet for a + /// legacy key, or a SEIPDv2 (AES-256-GCM, 128 KiB chunks) packet for an AEAD + /// key. The result is a bare data packet with no ESK, matching Proton's block + /// format (the session key travels in the file's content key packet). + pub fn encrypt_block(&self, plaintext: &[u8]) -> Result, CryptoError> { + let literal = LiteralData::from_bytes(bytes::Bytes::new(), plaintext.to_vec().into()) + .map_err(|e| CryptoError::Encrypt(format!("literal packet: {e}")))?; + let mut inner = Vec::new(); + literal + .to_writer_with_header(&mut inner) + .map_err(|e| CryptoError::Encrypt(format!("serialize literal: {e}")))?; + + self.seal_seipd(&inner) + } + + /// Encrypt and inline-sign a thumbnail block under this content key. + /// + /// Unlike a content block (which carries a *detached* signature uploaded + /// separately), a thumbnail block is encrypt-and-signed in one pass: the + /// signature is embedded in the SEIPD payload. Mirrors C# + /// `BlockUploader.UploadThumbnailAsync` + /// (`ContentKey.OpenEncryptingAndSigningReadStream` with no detached + /// signature stream). Like [`encrypt_block`](Self::encrypt_block) the result + /// is a bare SEIPDv1 packet with no ESK — the session key travels in the + /// file's content key packet. + pub fn encrypt_thumbnail( + &self, + signer: &PrivateKey, + plaintext: &[u8], + ) -> Result, CryptoError> { + let mut rng = rand::thread_rng(); + + // Build a signed (but unencrypted) message: one-pass signature, literal + // data, then the signature packet. `seipd_*` would generate its own + // session key, so we leave the builder unencrypted and seal the bytes + // ourselves with our content key below. + let mut builder = MessageBuilder::from_bytes(bytes::Bytes::new(), plaintext.to_vec()); + builder.sign(&signer.key().primary_key, signer.password(), HashAlgorithm::Sha256); + let inner = builder + .to_vec(&mut rng) + .map_err(|e| CryptoError::Encrypt(format!("sign thumbnail: {e}")))?; + + self.seal_seipd(&inner) + } + + /// Seal an already-serialized inner payload as a bare SEIPD packet under this + /// content key: SEIPDv1 for a legacy key, SEIPDv2 (AES-256-GCM) for an AEAD + /// key. Shared by [`encrypt_block`](Self::encrypt_block) and + /// [`encrypt_thumbnail`](Self::encrypt_thumbnail). + fn seal_seipd(&self, inner: &[u8]) -> Result, CryptoError> { + let (sym_alg, key) = self.session_parts()?; + let mut rng = rand::thread_rng(); + + let seipd = if self.is_aead() { + SymEncryptedProtectedData::encrypt_seipdv2( + &mut rng, + sym_alg, + AEAD_ALGO, + AEAD_CHUNK_SIZE, + key, + inner, + ) + .map_err(|e| CryptoError::Encrypt(format!("encrypt seipdv2: {e}")))? + } else { + SymEncryptedProtectedData::encrypt_seipdv1(&mut rng, sym_alg, key, inner) + .map_err(|e| CryptoError::Encrypt(format!("encrypt seipdv1: {e}")))? + }; + + let mut out = Vec::new(); + seipd + .to_writer_with_header(&mut out) + .map_err(|e| CryptoError::Encrypt(format!("serialize seipd: {e}")))?; + Ok(out) + } + + /// Encrypt this content key to `node_key` as a v3 PKESK packet — the file's + /// `ContentKeyPacket`. Mirrors C# `nodeKey.EncryptSessionKey(contentKey)`. + pub fn to_packet(&self, node_key: &PrivateKey) -> Result, CryptoError> { + let (sym_alg, key) = self.session_parts()?; + + // An AEAD content key is a v6 session key (no algorithm byte); a legacy + // key is a v3 PKESK carrying the symmetric algorithm. + let packet = if self.is_aead() { + recipient_encryption_key(node_key.key(), ContentKeyPacketV6Op { key })? + } else { + recipient_encryption_key(node_key.key(), ContentKeyPacketOp { sym_alg, key })? + }; + + let mut out = Vec::new(); + packet + .to_writer_with_header(&mut out) + .map_err(|e| CryptoError::Encrypt(format!("serialize content key packet: {e}")))?; + Ok(out) + } + + /// The raw symmetric key bytes of this content key. + /// + /// Mirrors C# `PgpSessionKey.Export()`; used to produce the file's + /// `ContentKeyPacketSignature` (the node key signs these bytes). + pub fn export(&self) -> Result, CryptoError> { + let (_sym_alg, key) = self.session_parts()?; + Ok(key.to_vec()) + } + + /// Symmetric algorithm and raw key material for this content key. A V6 (AEAD) + /// session key carries no algorithm, so AES-256 is assumed — matching the + /// official clients, which hardcode it for v6 session keys. + fn session_parts(&self) -> Result<(SymmetricKeyAlgorithm, &[u8]), CryptoError> { + match &self.session_key { + PlainSessionKey::V3_4 { sym_alg, key } => Ok((*sym_alg, key)), + PlainSessionKey::V6 { key } => Ok((SymmetricKeyAlgorithm::AES256, key)), + _ => Err(CryptoError::Encrypt( + "content key is not a V3/V4 or V6 session key".into(), + )), + } + } + + /// Decrypt a single file block with this content key. + /// + /// A block body is a bare SEIPD packet with no ESK of its own (the session + /// key arrives via the file's content key packet). rPGP's `Message` parser + /// rejects a message that *starts* with a SEIPD packet, so we parse packets + /// directly, locate the SEIPD (tolerating a leading PKESK, e.g. a full + /// round-trip message), and decrypt it with the supplied session key. + pub fn decrypt_block(&self, ciphertext: &[u8]) -> Result, CryptoError> { + let (sym_alg, key) = self.session_parts()?; + + let mut parser = PacketParser::new(Cursor::new(ciphertext)); + let seipd = loop { + let packet = parser + .next() + .ok_or_else(|| CryptoError::Parse("block has no SEIPD packet".into()))? + .map_err(|e| CryptoError::Parse(format!("block packet: {e}")))?; + match packet { + Packet::SymEncryptedProtectedData(data) => break data, + // Skip a content-key PKESK if the caller passed a full message. + Packet::PublicKeyEncryptedSessionKey(_) => continue, + other => { + return Err(CryptoError::Parse(format!( + "unexpected block packet: {:?}", + other.tag() + ))) + } + } + }; + + let inner = seipd + .decrypt(key, Some(sym_alg)) + .map_err(|e| CryptoError::Decrypt(format!("block: {e}")))?; + + // The decrypted payload is a serialized literal-data packet. + let mut inner_parser = PacketParser::new(Cursor::new(&inner)); + let literal = inner_parser + .next() + .ok_or_else(|| CryptoError::Parse("block payload is empty".into()))? + .map_err(|e| CryptoError::Parse(format!("block payload packet: {e}")))?; + match literal { + Packet::LiteralData(data) => Ok(data.data().to_vec()), + other => Err(CryptoError::Parse(format!( + "block payload is not literal data: {:?}", + other.tag() + ))), + } + } +} + +impl PrivateKey { + /// Decrypt a content-key packet — a bare PKESK addressed to this node key. + /// + /// Mirrors C# `nodeKey.DecryptSessionKey(contentKeyPacket.Span)`. The PKESK + /// may target either the node's primary key or an encryption subkey, so both + /// are tried (the underlying NativeAOT lib does this matching for us). + pub fn decrypt_content_key(&self, packet_bytes: &[u8]) -> Result { + let mut parser = PacketParser::new(Cursor::new(packet_bytes)); + let packet = parser + .next() + .ok_or_else(|| CryptoError::Parse("empty content key packet".into()))? + .map_err(|e| CryptoError::Parse(format!("content key packet: {e}")))?; + + let Packet::PublicKeyEncryptedSessionKey(pkesk) = packet else { + return Err(CryptoError::Parse( + "content key packet is not a PKESK".into(), + )); + }; + + let values = pkesk + .values() + .map_err(|e| CryptoError::Parse(format!("content key PKESK values: {e}")))?; + let esk_type = match pkesk.version() { + PkeskVersion::V6 => EskType::V6, + _ => EskType::V3_4, + }; + + // `decrypt_session_key` returns `Result>`: outer = passphrase + // fit, inner = the decryption result. Try the primary key, then any + // encryption subkeys (Proton node keys may carry one). + let pw = self.password(); + let key = self.key(); + + let mut last_err: CryptoError; + match key.decrypt_session_key(&pw, values, esk_type) { + Ok(Ok(session_key)) => return Ok(ContentKey { session_key }), + Ok(Err(e)) | Err(e) => last_err = CryptoError::Decrypt(format!("content key: {e}")), + } + for subkey in &key.secret_subkeys { + match subkey.decrypt_session_key(&pw, values, esk_type) { + Ok(Ok(session_key)) => return Ok(ContentKey { session_key }), + Ok(Err(e)) | Err(e) => { + last_err = CryptoError::Decrypt(format!("content key (subkey): {e}")) + } + } + } + Err(last_err) + } +} + +impl PrivateKey { + /// Re-encrypt the key packet of `armored_message` — a PGP message currently + /// addressed to this key — to `destination`, preserving the encrypted data + /// packet (and thus the plaintext and any *detached* signature over it). + /// + /// This is the session-key rewrap a node **move** performs on a node + /// passphrase: the secret stays identical, only its recipient changes (old + /// parent → new parent), so the locked node key still unlocks and the node's + /// `NodePassphraseSignature` need not be reissued. Mirrors C# move's + /// `destinationKey.EncryptSessionKey(currentKey.DecryptSessionKey(passphrase))`. + pub fn rewrap_message_to( + &self, + armored_message: &str, + destination: &PrivateKey, + ) -> Result { + // De-armor the message to raw packet bytes (PKESK followed by SEIPD). + 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}")))?; + + // Recover the session key from the leading PKESK (decrypt_content_key + // reads only that packet) and serialize the original SEIPD body to + // preserve the ciphertext — and thus the plaintext and its signature. + let session = self.decrypt_content_key(&raw)?; + let body = serialize_seipd(&raw)?; + + // Re-encrypt the session key to the destination and reattach the body. + let mut message = session.to_packet(destination)?; + message.extend_from_slice(&body); + + // Re-armor as a PGP MESSAGE block. + 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}"))) + } +} + +/// Serialize the SEIPD (data) packet of a de-armored message, header included. +/// Used by [`PrivateKey::rewrap_message_to`] to carry the original ciphertext +/// over to the re-encrypted message unchanged. +fn serialize_seipd(raw: &[u8]) -> Result, CryptoError> { + let mut parser = PacketParser::new(Cursor::new(raw)); + loop { + let packet = parser + .next() + .ok_or_else(|| CryptoError::Parse("message has no SEIPD packet".into()))? + .map_err(|e| CryptoError::Parse(format!("message packet: {e}")))?; + if let Packet::SymEncryptedProtectedData(data) = packet { + let mut out = Vec::new(); + data.to_writer_with_header(&mut out) + .map_err(|e| CryptoError::Encrypt(format!("serialize seipd: {e}")))?; + return Ok(out); + } + } +} + +/// A `Serialize` wrapper over already-serialized PGP packet bytes, so they can +/// be re-armored via [`pgp::armor::write`] without rebuilding a `Message`. +struct RawPackets<'a>(&'a [u8]); + +impl pgp::ser::Serialize for RawPackets<'_> { + fn to_writer(&self, writer: &mut W) -> pgp::errors::Result<()> { + writer.write_all(self.0)?; + Ok(()) + } + + fn write_len(&self) -> usize { + self.0.len() + } +} + +/// Encrypt a content key to a recipient as a v3 PKESK (`from_session_key_v3`). +struct ContentKeyPacketOp<'a> { + sym_alg: SymmetricKeyAlgorithm, + key: &'a [u8], +} + +impl RecipientOp for ContentKeyPacketOp<'_> { + type Out = PublicKeyEncryptedSessionKey; + + fn run(self, pubkey: &impl PublicKeyTrait) -> pgp::errors::Result { + let mut rng = rand::thread_rng(); + PublicKeyEncryptedSessionKey::from_session_key_v3(&mut rng, self.key, self.sym_alg, pubkey) + } +} + +/// Encrypt an AEAD content key to a recipient as a v6 PKESK +/// (`from_session_key_v6`). A v6 session key carries no algorithm byte. +struct ContentKeyPacketV6Op<'a> { + key: &'a [u8], +} + +impl RecipientOp for ContentKeyPacketV6Op<'_> { + type Out = PublicKeyEncryptedSessionKey; + + fn run(self, pubkey: &impl PublicKeyTrait) -> pgp::errors::Result { + let mut rng = rand::thread_rng(); + PublicKeyEncryptedSessionKey::from_session_key_v6(&mut rng, self.key, pubkey) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pgp::composed::{ + KeyType, MessageBuilder, SecretKeyParamsBuilder, SignedSecretKey, + }; + use pgp::crypto::sym::SymmetricKeyAlgorithm; + + /// Generate a passphrase-locked RSA key able to both sign and encrypt — a + /// stand-in for a Proton node key (single primary, no subkey). + fn generate_node_key(passphrase: &str) -> SignedSecretKey { + let mut rng = rand::thread_rng(); + let params = SecretKeyParamsBuilder::default() + .key_type(KeyType::Rsa(2048)) + .can_sign(true) + .can_certify(true) + .can_encrypt(true) + .primary_user_id("node ".into()) + .passphrase(Some(passphrase.to_owned())) + .build() + .expect("key params"); + params + .generate(&mut rng) + .expect("generate") + .sign(&mut rng, &passphrase.into()) + .expect("sign") + } + + #[test] + fn content_key_packet_and_block_round_trip() { + let mut rng = rand::thread_rng(); + let signed = generate_node_key("pw"); + let armored = signed + .to_armored_string(None.into()) + .expect("armor secret key"); + + // Encrypt a "block" to the node key: the resulting message carries a + // PKESK (the content key packet) followed by the SEIPD (the block body). + let plaintext = b"the quick brown fox jumps over 13 lazy dogs".to_vec(); + let mut builder = MessageBuilder::from_bytes(String::new(), plaintext.clone()) + .seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256); + builder + .encrypt_to_key(&mut rng, &signed.primary_key.public_key()) + .expect("encrypt to key"); + let message_bytes = builder.to_vec(&mut rng).expect("serialize message"); + + let key = PrivateKey::from_armored(&armored, b"pw").expect("parse node key"); + + // `decrypt_content_key` consumes only the leading PKESK packet; the same + // bytes then decrypt as a block once the content key is recovered. + let content_key = key + .decrypt_content_key(&message_bytes) + .expect("decrypt content key"); + let decrypted = content_key + .decrypt_block(&message_bytes) + .expect("decrypt block"); + + assert_eq!(decrypted, plaintext); + } + + #[test] + fn generated_content_key_round_trip() { + // Upload path: generate a content key, seal it to the node key as a + // content-key packet, then recover it and round-trip a block. + let signed = generate_node_key("pw"); + let armored = signed.to_armored_string(None.into()).expect("armor"); + let node_key = PrivateKey::from_armored(&armored, b"pw").expect("parse node key"); + + let content_key = ContentKey::generate(); + let packet = content_key.to_packet(&node_key).expect("content key packet"); + + let recovered = node_key + .decrypt_content_key(&packet) + .expect("decrypt content key"); + + let plaintext = b"upload round trip block".to_vec(); + let block = content_key.encrypt_block(&plaintext).expect("encrypt block"); + let decrypted = recovered.decrypt_block(&block).expect("decrypt block"); + + assert_eq!(decrypted, plaintext); + } + + #[test] + fn content_key_round_trip_through_x25519_node_key() { + // The real Proton case: an Ed25519 signing primary with an X25519 + // encryption subkey. `to_packet` must target the subkey and + // `decrypt_content_key` must recover it. + let node = super::super::encrypt::generate_node_key().expect("generate node key"); + let content_key = ContentKey::generate(); + + let packet = content_key.to_packet(&node.key).expect("content key packet"); + let recovered = node.key.decrypt_content_key(&packet).expect("decrypt"); + + let plaintext = b"x25519 block".to_vec(); + let block = content_key.encrypt_block(&plaintext).expect("encrypt block"); + assert_eq!(recovered.decrypt_block(&block).expect("decrypt"), plaintext); + } + + #[test] + fn thumbnail_encrypt_inline_signed_round_trip() { + // A thumbnail block is encrypt-and-inline-signed under the content key. + // It must decrypt back to the plaintext via the same session key, and — + // unlike a content block — carry its signature inside the SEIPD payload. + let signer = super::super::encrypt::generate_node_key().expect("generate signer"); + let content_key = ContentKey::generate(); + + let plaintext = b"thumbnail image bytes".to_vec(); + let block = content_key + .encrypt_thumbnail(&signer.key, &plaintext) + .expect("encrypt thumbnail"); + + // The bare SEIPD decrypts under the content key; its payload begins with + // a one-pass signature packet (not bare literal data), so `decrypt_block` + // would reject it — confirm the inline-signed structure directly. + let (sym_alg, key) = content_key.session_parts().expect("session parts"); + let mut parser = PacketParser::new(Cursor::new(&block)); + let seipd = match parser.next().expect("packet").expect("seipd") { + Packet::SymEncryptedProtectedData(d) => d, + other => panic!("expected SEIPD, got {:?}", other.tag()), + }; + let inner = seipd.decrypt(key, Some(sym_alg)).expect("decrypt thumbnail"); + + let mut inner_parser = PacketParser::new(Cursor::new(&inner)); + let first = inner_parser.next().expect("inner packet").expect("inner"); + assert!( + matches!(first, Packet::OnePassSignature(_)), + "thumbnail payload should be inline-signed, got {:?}", + first.tag() + ); + // The literal data follows the one-pass signature. + let literal = inner_parser.next().expect("literal packet").expect("literal"); + match literal { + Packet::LiteralData(data) => assert_eq!(data.data(), plaintext.as_slice()), + other => panic!("expected literal data, got {:?}", other.tag()), + } + } + + #[test] + fn aead_content_key_round_trip_through_x25519_node_key() { + // AEAD upload path: generate an AEAD content key (V6 session key), seal + // it to the node key as a v6 PKESK, recover it, and round-trip a SEIPDv2 + // block. The recovered key must still report as AEAD. + let node = super::super::encrypt::generate_node_key().expect("generate node key"); + let content_key = ContentKey::generate_aead(); + assert!(content_key.is_aead()); + + let packet = content_key.to_packet(&node.key).expect("content key packet"); + let recovered = node.key.decrypt_content_key(&packet).expect("decrypt"); + assert!(recovered.is_aead(), "recovered key should be AEAD"); + + // A block larger than one AEAD chunk (128 KiB) exercises chunked GCM. + let plaintext = vec![0xABu8; (1 << 17) + 1234]; + let block = content_key.encrypt_block(&plaintext).expect("encrypt block"); + assert_eq!( + recovered.decrypt_block(&block).expect("decrypt block"), + plaintext + ); + } + + #[test] + fn rewrap_message_to_moves_passphrase_between_parents() { + // Move: a node passphrase encrypted (+signed) to the old parent must be + // rewrapped to the new parent without changing the plaintext, so the new + // parent recovers the exact same secret and the detached signature over + // it still verifies. + let old_parent = super::super::encrypt::generate_node_key().expect("old parent"); + let new_parent = super::super::encrypt::generate_node_key().expect("new parent"); + let signer = super::super::encrypt::generate_node_key().expect("signer"); + + let passphrase = b"node-key locking passphrase bytes".to_vec(); + let armored = old_parent + .key + .encrypt_and_sign(&signer.key, &passphrase, false, false) + .expect("encrypt passphrase to old parent"); + // The move keeps the detached NodePassphraseSignature, so sign separately. + let signature = signer.key.sign_detached(&passphrase).expect("sign passphrase"); + + // The new parent cannot read the old-parent message yet. + assert!(new_parent.key.decrypt_armored_message(&armored).is_err()); + + let rewrapped = old_parent + .key + .rewrap_message_to(&armored, &new_parent.key) + .expect("rewrap to new parent"); + + // New parent recovers the identical plaintext; old signature still holds. + let recovered = new_parent + .key + .decrypt_armored_message(&rewrapped) + .expect("new parent decrypt"); + assert_eq!(recovered, passphrase); + signer + .key + .verify_detached_signature(&signature, &recovered) + .expect("passphrase signature still verifies"); + } + + #[test] + fn rejects_non_pkesk_content_key_packet() { + let mut rng = rand::thread_rng(); + let key = PrivateKey::from_armored( + &generate_node_key("pw").to_armored_string(None.into()).unwrap(), + b"pw", + ) + .unwrap(); + + // An unencrypted message begins with a literal-data packet, not a PKESK. + let bytes = MessageBuilder::from_bytes(String::new(), b"hi".to_vec()) + .to_vec(&mut rng) + .unwrap(); + + assert!(key.decrypt_content_key(&bytes).is_err()); + } +} diff --git a/crates/proton-sdk/src/crypto/derive.rs b/crates/proton-sdk/src/crypto/derive.rs new file mode 100644 index 0000000..8da33f6 --- /dev/null +++ b/crates/proton-sdk/src/crypto/derive.rs @@ -0,0 +1,76 @@ +//! Proton key-passphrase derivation. +//! +//! Proton derives the passphrase that unlocks a user/address key from the +//! mailbox password and a per-key salt using bcrypt: `bcrypt(password, salt)` +//! then dropping the first 29 characters (the `$2y$10$` prefix plus the 22-char +//! encoded salt), leaving the 31-char hash. Mirrors `DeriveSecretFromPassword` +//! in the C# SDK (which calls `SrpClient.HashPassword`). + +use bcrypt::HashParts; + +use super::errors::CryptoError; + +const BCRYPT_COST: u32 = 10; +const BCRYPT_PREFIX_LEN: usize = 29; + +/// Derive the unlocking passphrase for a key from the mailbox password and the +/// key's 16-byte salt. +pub fn derive_key_passphrase(password: &[u8], salt: &[u8]) -> Result, CryptoError> { + if salt.len() != 16 { + return Err(CryptoError::Unlock(format!( + "key salt must be 16 bytes, got {}", + salt.len() + ))); + } + + let mut salt_buf = [0u8; 16]; + salt_buf.copy_from_slice(salt); + + let parts: HashParts = bcrypt::hash_with_salt(password, BCRYPT_COST, salt_buf) + .map_err(|e| CryptoError::Unlock(format!("bcrypt: {e}")))?; + + // `$2b$10$<22 char salt><31 char hash>` — drop everything up to and + // including the encoded salt to obtain the passphrase bytes. + let hash = parts.to_string(); + let passphrase = hash + .get(BCRYPT_PREFIX_LEN..) + .ok_or_else(|| CryptoError::Unlock("bcrypt hash shorter than expected".into()))?; + + Ok(passphrase.as_bytes().to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SALT: [u8; 16] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, + ]; + + #[test] + fn derives_31_byte_passphrase() { + let passphrase = derive_key_passphrase(b"hunter2", &SALT).unwrap(); + // The bcrypt hash portion after the `$2b$10$<22-char salt>` prefix. + assert_eq!(passphrase.len(), 31); + } + + #[test] + fn derivation_is_deterministic() { + let a = derive_key_passphrase(b"hunter2", &SALT).unwrap(); + let b = derive_key_passphrase(b"hunter2", &SALT).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn different_password_yields_different_passphrase() { + let a = derive_key_passphrase(b"hunter2", &SALT).unwrap(); + let b = derive_key_passphrase(b"hunter3", &SALT).unwrap(); + assert_ne!(a, b); + } + + #[test] + fn rejects_wrong_salt_length() { + assert!(derive_key_passphrase(b"hunter2", &[0u8; 8]).is_err()); + } +} diff --git a/crates/proton-sdk/src/crypto/encrypt.rs b/crates/proton-sdk/src/crypto/encrypt.rs new file mode 100644 index 0000000..692359a --- /dev/null +++ b/crates/proton-sdk/src/crypto/encrypt.rs @@ -0,0 +1,276 @@ +//! Encryption / signing primitives for the upload (write) path. +//! +//! Mirrors the encrypt-and-sign helpers the C# SDK builds on top of the +//! NativeAOT core (`PgpPrivateKey.Encrypt*`, `EncryptSessionKey`, +//! `SignDetached`). All operations target Proton keys whose primary is a +//! signing key with a separate encryption subkey, so recipient selection must +//! pick the encryption-capable (sub)key. + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use bytes::Bytes; +use pgp::composed::{ + ArmorOptions, KeyType, MessageBuilder, SecretKeyParamsBuilder, SignedSecretKey, + StandaloneSignature, SubkeyParamsBuilder, +}; +use pgp::crypto::hash::HashAlgorithm; +use pgp::crypto::sym::SymmetricKeyAlgorithm; +use pgp::packet::{SignatureConfig, SignatureType}; +use pgp::types::{CompressionAlgorithm, KeyVersion, Password, PublicKeyTrait}; +use rand::RngCore; + +use super::errors::CryptoError; +use super::keys::PrivateKey; + +/// A generic operation run against a recipient's encryption-capable public key. +/// +/// `dyn PublicKeyTrait` is not itself `PublicKeyTrait` (and the packet +/// constructors take `&impl PublicKeyTrait`, a `Sized` generic), so a plain +/// trait-object closure cannot carry the selected key. This trait is the +/// generic-callback workaround: [`recipient_encryption_key`] picks the concrete +/// key type and dispatches into `run` monomorphized for that type. +pub(crate) trait RecipientOp { + /// The value produced by the operation. + type Out; + /// Run the operation against the selected public key. + fn run(self, pubkey: &impl PublicKeyTrait) -> pgp::errors::Result; +} + +/// Run `op` against the encryption-capable public key of `key`. +/// +/// Proton keys are typically an Ed25519 signing primary plus an X25519 +/// encryption subkey, so the primary is used only when it is itself an +/// encryption key, otherwise the first encryption-capable subkey is selected. +pub(crate) fn recipient_encryption_key( + key: &SignedSecretKey, + op: Op, +) -> Result { + let primary = key.primary_key.public_key(); + if primary.is_encryption_key() { + return op + .run(primary) + .map_err(|e| CryptoError::Encrypt(format!("encrypt to primary key: {e}"))); + } + for sub in &key.secret_subkeys { + let pubsub = sub.public_key(); + if pubsub.is_encryption_key() { + return op + .run(&pubsub) + .map_err(|e| CryptoError::Encrypt(format!("encrypt to subkey: {e}"))); + } + } + Err(CryptoError::Encrypt( + "key has no encryption-capable (sub)key".into(), + )) +} + +/// A freshly generated, passphrase-locked node key. +pub struct GeneratedNodeKey { + /// The unlocked node key, ready to sign and decrypt. + pub key: PrivateKey, + /// The armored, passphrase-locked secret key (the `NodeKey` request field). + pub locked_armored: String, + /// The random passphrase that locks the key, base64 of 32 random bytes. + /// Mirrors C# `CryptoGenerator.GeneratePassphrase`. + pub passphrase: Vec, +} + +/// Generate a new Proton node key: an Ed25519 signing primary plus an X25519 +/// encryption subkey, locked with a random base64 passphrase. +/// +/// Mirrors C# `NodeOperations.GetCommonCreationParameters` key generation +/// (`PgpPrivateKey.Generate("Drive key", "no-reply@proton.me", Default)` then +/// `key.Lock(passphrase)`). +pub fn generate_node_key() -> Result { + let mut rng = rand::thread_rng(); + let passphrase = generate_passphrase(); + let pw_string = String::from_utf8(passphrase.clone()) + .map_err(|e| CryptoError::Encrypt(format!("passphrase is not ascii: {e}")))?; + + let subkey = SubkeyParamsBuilder::default() + .version(KeyVersion::V4) + .key_type(KeyType::X25519) + .can_encrypt(true) + .passphrase(Some(pw_string.clone())) + .build() + .map_err(|e| CryptoError::Encrypt(format!("node subkey params: {e}")))?; + + let params = SecretKeyParamsBuilder::default() + .version(KeyVersion::V4) + .key_type(KeyType::Ed25519) + .can_certify(true) + .can_sign(true) + .primary_user_id("Drive key ".into()) + .passphrase(Some(pw_string)) + .subkey(subkey) + .build() + .map_err(|e| CryptoError::Encrypt(format!("node key params: {e}")))?; + + let secret = params + .generate(&mut rng) + .map_err(|e| CryptoError::Encrypt(format!("generate node key: {e}")))?; + let signed = secret + .sign(&mut rng, &Password::from(passphrase.as_slice())) + .map_err(|e| CryptoError::Encrypt(format!("sign node key: {e}")))?; + let locked_armored = signed + .to_armored_string(None.into()) + .map_err(|e| CryptoError::Encrypt(format!("armor node key: {e}")))?; + + let key = PrivateKey::from_armored(&locked_armored, &passphrase)?; + + Ok(GeneratedNodeKey { + key, + locked_armored, + passphrase, + }) +} + +/// 32 random bytes, base64-encoded (the locking passphrase format). +fn generate_passphrase() -> Vec { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + BASE64.encode(bytes).into_bytes() +} + +/// Generate a folder's node hash key: 32 random bytes encrypted to **and** +/// signed by the folder's own `node_key`. +/// +/// This is the HMAC-SHA256 key used to hash child names under the folder; the +/// read path recovers it via `decrypt_armored_message` with the folder node key +/// (see drive `parent_hash_key`). Mirrors C# `FolderOperations.CreateAsync` +/// (`key.EncryptAndSign(hashKey, key)` — the node key is both recipient and +/// signer) with `CryptoGenerator.GenerateFolderHashKey` (32 bytes). +pub fn generate_node_hash_key(node_key: &PrivateKey) -> Result { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + encrypt_and_sign(node_key.key(), Some(node_key), &bytes, false, false) +} + +/// Encrypt `data` to `recipient`, optionally inline-signing with `signer`, and +/// return the armored PGP message. Mirrors C# `PgpEncrypter.EncryptAndSign` +/// (and the encrypt-only path when `signer` is `None`). +pub(crate) fn encrypt_and_sign( + recipient: &SignedSecretKey, + signer: Option<&PrivateKey>, + data: &[u8], + text: bool, + compress: bool, +) -> Result { + recipient_encryption_key( + recipient, + EncryptSignOp { + data: data.to_vec(), + signer, + text, + compress, + }, + ) +} + +/// Produce an armored detached signature over `data` by `signer`'s primary +/// (signing) key. Mirrors C# `PgpPrivateKey.Sign` / `SignDetached`. +pub(crate) fn sign_detached(signer: &PrivateKey, data: &[u8]) -> Result { + let mut rng = rand::thread_rng(); + let key = &signer.key().primary_key; + let pw = signer.password(); + + let config = SignatureConfig::from_key(&mut rng, key, SignatureType::Binary) + .map_err(|e| CryptoError::Encrypt(format!("signature config: {e}")))?; + let signature = config + .sign(key, &pw, data) + .map_err(|e| CryptoError::Encrypt(format!("detached sign: {e}")))?; + + StandaloneSignature::new(signature) + .to_armored_string(ArmorOptions::default()) + .map_err(|e| CryptoError::Encrypt(format!("armor signature: {e}"))) +} + +/// Inline encrypt-and-sign against a selected recipient public key. +struct EncryptSignOp<'a> { + data: Vec, + signer: Option<&'a PrivateKey>, + text: bool, + compress: bool, +} + +impl RecipientOp for EncryptSignOp<'_> { + type Out = String; + + fn run(self, pubkey: &impl PublicKeyTrait) -> pgp::errors::Result { + let mut rng = rand::thread_rng(); + let mut builder = MessageBuilder::from_bytes(Bytes::new(), self.data) + .seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256); + + if self.compress { + builder.compression(CompressionAlgorithm::ZLIB); + } + if self.text { + builder.sign_text(); + } + if let Some(signer) = self.signer { + builder.sign( + &signer.key().primary_key, + signer.password(), + HashAlgorithm::Sha256, + ); + } + + builder.encrypt_to_key(&mut rng, pubkey)?; + builder.to_armored_string(&mut rng, ArmorOptions::default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_node_key_encrypt_sign_round_trip() { + // A generated node key (Ed25519 primary + X25519 subkey) must be able to + // act as both a recipient (encrypt to its subkey) and a signer. + let node = generate_node_key().expect("generate node key"); + let signer = generate_node_key().expect("generate signer key"); + + // Encrypt + inline-sign a payload to the node key, then decrypt it back. + let plaintext = b"extended attributes payload".to_vec(); + let armored = node + .key + .encrypt_and_sign(&signer.key, &plaintext, false, true) + .expect("encrypt and sign"); + let decrypted = node + .key + .decrypt_armored_message(&armored) + .expect("decrypt"); + assert_eq!(decrypted, plaintext); + + // Encrypt-only (no signature) must also round-trip. + let armored_only = node.key.encrypt(&plaintext).expect("encrypt only"); + let decrypted_only = node + .key + .decrypt_armored_message(&armored_only) + .expect("decrypt only"); + assert_eq!(decrypted_only, plaintext); + + // A detached signature by the signer verifies against the signer key. + let data = b"manifest bytes"; + let sig = signer.key.sign_detached(data).expect("detached sign"); + signer + .key + .verify_detached_signature(&sig, data) + .expect("verify detached signature"); + } + + #[test] + fn node_hash_key_round_trips_under_node_key() { + // The folder hash key is 32 random bytes encrypted to the folder's own + // node key; the read path recovers it with that same node key. + let node = generate_node_key().expect("generate node key"); + + let armored = generate_node_hash_key(&node.key).expect("generate hash key"); + let hash_key = node + .key + .decrypt_armored_message(&armored) + .expect("decrypt hash key"); + assert_eq!(hash_key.len(), 32); + } +} diff --git a/crates/proton-sdk/src/crypto/errors.rs b/crates/proton-sdk/src/crypto/errors.rs new file mode 100644 index 0000000..152d564 --- /dev/null +++ b/crates/proton-sdk/src/crypto/errors.rs @@ -0,0 +1,25 @@ +//! Crypto error type. + +/// Failure of a PGP operation. +#[derive(Debug, thiserror::Error)] +pub enum CryptoError { + /// An armored or binary key/message failed to parse. + #[error("failed to parse PGP data: {0}")] + Parse(String), + + /// A locked private key could not be unlocked with the supplied passphrase. + #[error("failed to unlock private key: {0}")] + Unlock(String), + + /// Decryption of a message or session key failed. + #[error("failed to decrypt: {0}")] + Decrypt(String), + + /// Encryption, signing or key generation failed. + #[error("failed to encrypt: {0}")] + Encrypt(String), + + /// Signature verification failed (when verification is enforced). + #[error("signature verification failed: {0}")] + Verification(String), +} diff --git a/crates/proton-sdk/src/crypto/keys.rs b/crates/proton-sdk/src/crypto/keys.rs new file mode 100644 index 0000000..ae6b4c2 --- /dev/null +++ b/crates/proton-sdk/src/crypto/keys.rs @@ -0,0 +1,107 @@ +//! PGP secret-key parsing and the layered Proton decryption chain. +//! +//! rPGP 0.16 decrypts directly with a `(key, password)` pair rather than a +//! distinct pre-unlock step, so a [`PrivateKey`] simply pairs a parsed +//! [`SignedSecretKey`] with the passphrase that unlocks it. + +use pgp::composed::{Deserializable, SignedSecretKey, StandaloneSignature}; +use pgp::types::Password; + +use super::errors::CryptoError; +use super::messages; + +/// A Proton private key: a parsed secret key plus the passphrase that unlocks it. +/// +/// `Password` is not `Clone`, so we retain the raw passphrase bytes and build a +/// `Password` on demand for each operation. +#[derive(Clone)] +pub struct PrivateKey { + key: SignedSecretKey, + passphrase: Vec, +} + +impl PrivateKey { + /// Parse an armored secret key and validate that `passphrase` unlocks it. + pub fn from_armored(armored: &str, passphrase: &[u8]) -> Result { + let (key, _headers) = SignedSecretKey::from_string(armored) + .map_err(|e| CryptoError::Parse(format!("secret key: {e}")))?; + + // Confirm the passphrase is correct up front, mirroring the C# `Unlock`. + // `unlock` returns `Result>`: the outer layer reports whether + // the secret params could be decrypted (i.e. the passphrase fit), the + // inner layer is our (trivial) closure result. + let password = password_from_bytes(passphrase); + key.unlock(&password, |_, _| Ok(())) + .map_err(|e| CryptoError::Unlock(e.to_string()))? + .map_err(|e| CryptoError::Unlock(e.to_string()))?; + + Ok(Self { + key, + passphrase: passphrase.to_vec(), + }) + } + + /// Decrypt an armored PGP message addressed to this key. + pub fn decrypt_armored_message(&self, armored: &str) -> Result, CryptoError> { + messages::decrypt_armored(armored, &self.key, &self.password()) + } + + /// Decrypt a binary (de-armored) PGP message addressed to this key. + pub fn decrypt_binary_message(&self, ciphertext: &[u8]) -> Result, CryptoError> { + messages::decrypt_binary(ciphertext, &self.key, &self.password()) + } + + /// Verify an armored detached signature over `data` against this key's + /// public key. Returns `Ok(())` only on a good signature. + pub fn verify_detached_signature(&self, armored_sig: &str, data: &[u8]) -> Result<(), CryptoError> { + let (sig, _headers) = StandaloneSignature::from_string(armored_sig) + .map_err(|e| CryptoError::Parse(format!("detached signature: {e}")))?; + sig.verify(&self.key.signed_public_key(), data) + .map_err(|e| CryptoError::Verification(e.to_string())) + } + + /// Encrypt `data` to this key and inline-sign it with `signer`, returning + /// the armored PGP message. Used for node names and extended attributes. + pub fn encrypt_and_sign( + &self, + signer: &PrivateKey, + data: &[u8], + text: bool, + compress: bool, + ) -> Result { + super::encrypt::encrypt_and_sign(&self.key, Some(signer), data, text, compress) + } + + /// Encrypt `data` to this key without signing, returning the armored PGP + /// message. Used for node passphrases and encrypted block signatures. + pub fn encrypt(&self, data: &[u8]) -> Result { + super::encrypt::encrypt_and_sign(&self.key, None, data, false, false) + } + + /// Produce an armored detached signature over `data` with this key's + /// primary (signing) key. + pub fn sign_detached(&self, data: &[u8]) -> Result { + super::encrypt::sign_detached(self, data) + } + + pub(crate) fn key(&self) -> &SignedSecretKey { + &self.key + } + + pub(crate) fn password(&self) -> Password { + password_from_bytes(&self.passphrase) + } +} + +/// Decrypt an armored message, trying each key in `keys` until one succeeds. +pub fn decrypt_armored_with_keys(armored: &str, keys: &[PrivateKey]) -> Result, CryptoError> { + let owned: Vec<(&SignedSecretKey, Password)> = + keys.iter().map(|k| (k.key(), k.password())).collect(); + let refs: Vec<(&SignedSecretKey, &Password)> = + owned.iter().map(|(k, p)| (*k, p)).collect(); + messages::decrypt_armored_any(armored, &refs) +} + +pub(crate) fn password_from_bytes(bytes: &[u8]) -> Password { + Password::from(bytes) +} diff --git a/crates/proton-sdk/src/crypto/messages.rs b/crates/proton-sdk/src/crypto/messages.rs new file mode 100644 index 0000000..e884a98 --- /dev/null +++ b/crates/proton-sdk/src/crypto/messages.rs @@ -0,0 +1,73 @@ +//! PGP message decryption helpers. + +use pgp::composed::{Message, SignedSecretKey}; +use pgp::types::Password; + +use super::errors::CryptoError; + +/// Decrypt an armored PGP message with `key`/`key_pw`, returning plaintext. +/// +/// Signature verification is *not* enforced here: Proton treats authorship +/// verification as a separate, non-fatal concern, so the plaintext is returned +/// even when a signature is absent or unverified. +pub fn decrypt_armored( + armored: &str, + key: &SignedSecretKey, + key_pw: &Password, +) -> Result, CryptoError> { + let (message, _headers) = Message::from_string(armored) + .map_err(|e| CryptoError::Parse(format!("message: {e}")))?; + + let decrypted = message + .decrypt(key_pw, key) + .map_err(|e| CryptoError::Decrypt(e.to_string()))?; + + read_plaintext(decrypted) +} + +/// Decrypt a binary (already de-armored) PGP message. +pub fn decrypt_binary( + ciphertext: &[u8], + key: &SignedSecretKey, + key_pw: &Password, +) -> Result, CryptoError> { + let message = Message::from_bytes(ciphertext) + .map_err(|e| CryptoError::Parse(format!("message: {e}")))?; + + let decrypted = message + .decrypt(key_pw, key) + .map_err(|e| CryptoError::Decrypt(e.to_string()))?; + + read_plaintext(decrypted) +} + +/// Decrypt an armored message, trying each candidate key until one succeeds. +/// +/// Proton key tokens are encrypted to one of several user keys; we don't know +/// which a priori, so we attempt them in turn. +pub fn decrypt_armored_any( + armored: &str, + keys: &[(&SignedSecretKey, &Password)], +) -> Result, CryptoError> { + let mut last_err = CryptoError::Decrypt("no candidate keys".into()); + for (key, pw) in keys { + match decrypt_armored(armored, key, pw) { + Ok(plaintext) => return Ok(plaintext), + Err(e) => last_err = e, + } + } + Err(last_err) +} + +/// Drain a decrypted message to bytes, transparently decompressing if needed. +pub(crate) fn read_plaintext(mut message: Message<'_>) -> Result, CryptoError> { + while message.is_compressed() { + message = message + .decompress() + .map_err(|e| CryptoError::Decrypt(format!("decompress: {e}")))?; + } + + message + .as_data_vec() + .map_err(|e| CryptoError::Decrypt(format!("read literal: {e}"))) +} diff --git a/crates/proton-sdk/src/crypto/srp.rs b/crates/proton-sdk/src/crypto/srp.rs new file mode 100644 index 0000000..4e92a57 --- /dev/null +++ b/crates/proton-sdk/src/crypto/srp.rs @@ -0,0 +1,437 @@ +//! Proton SRP password-login proofs. +//! +//! Pure-Rust port of `ProtonMail/go-srp` (the algorithm behind the C# SDK's +//! `Proton.Cryptography.Srp.SrpClient`, which is an external NativeAOT lib). The +//! flow: hash the password (bcrypt + `expandHash`), then run Proton's SRP-6a +//! variant to produce the client ephemeral, the client proof to send, and the +//! expected server proof to check against the `auth/v4` response. +//! +//! All wire integers are little-endian, fixed to `bit_length / 8` bytes (256 for +//! the 2048-bit group). The modulus is delivered as a PGP cleartext-signed +//! message; we verify the signature against Proton's embedded SRP modulus key +//! before trusting it. + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use num_bigint::BigUint; +use num_traits::One; +use pgp::composed::{CleartextSignedMessage, Deserializable, SignedPublicKey}; +use sha2::{Digest, Sha512}; + +use super::errors::CryptoError; + +/// The default SRP group size used by Proton. +pub const DEFAULT_BIT_LENGTH: usize = 2048; + +/// Proton's public key for verifying the SRP modulus signature (`proton@srp.modulus`). +const MODULUS_PUBKEY: &str = "-----BEGIN PGP PUBLIC KEY BLOCK----- + +xjMEXAHLgxYJKwYBBAHaRw8BAQdAFurWXXwjTemqjD7CXjXVyKf0of7n9Ctm +L8v9enkzggHNEnByb3RvbkBzcnAubW9kdWx1c8J3BBAWCgApBQJcAcuDBgsJ +BwgDAgkQNQWFxOlRjyYEFQgKAgMWAgECGQECGwMCHgEAAPGRAP9sauJsW12U +MnTQUZpsbJb53d0Wv55mZIIiJL2XulpWPQD/V6NglBd96lZKBmInSXX/kXat +Sv+y0io+LR8i2+jV+AbOOARcAcuDEgorBgEEAZdVAQUBAQdAeJHUz1c9+KfE +kSIgcBRE3WuXC4oj5a2/U3oASExGDW4DAQgHwmEEGBYIABMFAlwBy4MJEDUF +hcTpUY8mAhsMAAD/XQD8DxNI6E78meodQI+wLsrKLeHn32iLvUqJbVDhfWSU +WO4BAMcm1u02t4VKw++ttECPt+HUgPUq5pqQWe5Q2cW4TMsE +=Y4Mw +-----END PGP PUBLIC KEY BLOCK-----"; + +/// The result of running the client side of the SRP handshake. +pub struct SrpProofs { + /// `A` — the client ephemeral, sent to the server (`ClientEphemeral`). + pub client_ephemeral: Vec, + /// `M1` — proof sent to the server (`ClientProof`). + pub client_proof: Vec, + /// `M2` — proof we expect back from the server (`ServerProof`). + pub expected_server_proof: Vec, +} + +/// Verify the cleartext-signed modulus against Proton's key and return the raw +/// (base64-decoded) modulus bytes. Mirrors go-srp `readClearSignedMessage`. +fn verify_and_decode_modulus(signed_modulus: &str) -> Result, CryptoError> { + let (verification_key, _) = SignedPublicKey::from_string(MODULUS_PUBKEY) + .map_err(|e| CryptoError::Parse(format!("modulus pubkey: {e}")))?; + + let (message, _headers) = CleartextSignedMessage::from_string(signed_modulus) + .map_err(|e| CryptoError::Parse(format!("signed modulus: {e}")))?; + + message + .verify(&verification_key) + .map_err(|e| CryptoError::Verification(format!("modulus signature: {e}")))?; + + BASE64 + .decode(message.text().trim()) + .map_err(|e| CryptoError::Parse(format!("modulus base64: {e}"))) +} + +/// `expandHash`: SHA-512 of `data || i` for `i` in `0..4`, concatenated (256 bytes). +fn expand_hash(data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(256); + for i in 0u8..4 { + let mut hasher = Sha512::new(); + hasher.update(data); + hasher.update([i]); + out.extend_from_slice(&hasher.finalize()); + } + out +} + +/// Hash the password for auth versions 3/4: `expandHash(bcrypt(pw, salt+"proton") || modulus)`. +/// +/// `salt` is the raw (decoded) login salt; `modulus` is the raw modulus bytes. +fn hash_password_v3( + password: &[u8], + salt: &[u8], + modulus: &[u8], +) -> Result, CryptoError> { + // Proton appends the literal "proton" to the 10-byte salt, yielding bcrypt's + // required 16-byte salt. + let mut salt_buf = [0u8; 16]; + if salt.len() + 6 != 16 { + return Err(CryptoError::Unlock(format!( + "SRP login salt must be 10 bytes, got {}", + salt.len() + ))); + } + salt_buf[..salt.len()].copy_from_slice(salt); + salt_buf[salt.len()..].copy_from_slice(b"proton"); + + let parts = bcrypt::hash_with_salt(password, 10, salt_buf) + .map_err(|e| CryptoError::Unlock(format!("bcrypt: {e}")))?; + + // go-srp hashes the full `$2y$10$` bcrypt string. The `bcrypt` + // crate emits a `$2b$` variant tag; rebuild with `$2y$` so the byte input to + // expandHash matches the reference exactly. (`2b`/`2y` produce identical hash + // bytes; only the embedded tag differs.) + let s = parts.to_string(); + let fields: Vec<&str> = s.split('$').collect(); + // fields = ["", "2b", "10", "<22-char salt><31-char hash>"] + let crypted = match fields.as_slice() { + [_, _variant, cost, tail] => format!("$2y${cost}${tail}"), + _ => return Err(CryptoError::Unlock("unexpected bcrypt output".into())), + }; + + let mut input = crypted.into_bytes(); + input.extend_from_slice(modulus); + Ok(expand_hash(&input)) +} + +/// Convert little-endian fixed-width bytes to a `BigUint`. +fn from_le(bytes: &[u8]) -> BigUint { + BigUint::from_bytes_le(bytes) +} + +/// Convert a `BigUint` to little-endian bytes, zero-padded to `byte_len`. +fn to_le_fixed(n: &BigUint, byte_len: usize) -> Result, CryptoError> { + let mut v = n.to_bytes_le(); + if v.len() > byte_len { + return Err(CryptoError::Decrypt("SRP value exceeds group size".into())); + } + v.resize(byte_len, 0); + Ok(v) +} + +/// Validate the modulus and server ephemeral (go-srp `checkParams`). +/// +/// We re-check the cheap structural properties and run the single Lucas-test +/// exponentiation; the full safe-prime primality test is skipped because the +/// modulus signature has already been verified against Proton's key. +fn check_params(bit_length: usize, n: &BigUint, b: &BigUint) -> Result<(), CryptoError> { + if n.bits() as usize != bit_length { + return Err(CryptoError::Verification("SRP modulus has wrong size".into())); + } + // 2 generates the whole group only when N ≡ 3 (mod 8). + if (n % 8u32) != BigUint::from(3u32) { + return Err(CryptoError::Verification("SRP modulus is not 3 mod 8".into())); + } + let n_minus_1 = n - 1u32; + if *b <= BigUint::one() || *b >= n_minus_1 { + return Err(CryptoError::Verification( + "SRP server ephemeral out of bounds".into(), + )); + } + // Lucas test (base 2): 2^((N-1)/2) ≡ -1 (mod N) proves primality and that 2 + // is a generator of the full group. + let half = &n_minus_1 >> 1; + if BigUint::from(2u32).modpow(&half, n) != n_minus_1 { + return Err(CryptoError::Verification("SRP modulus is not prime".into())); + } + Ok(()) +} + +/// Generate a random client secret in the valid range, plus the client +/// ephemeral `A = g^a mod N` and scrambling parameter `u`. +fn generate_ephemeral( + bit_length: usize, + n: &BigUint, + n_minus_1: &BigUint, + server_ephemeral: &[u8], +) -> Result<(BigUint, Vec, BigUint), CryptoError> { + let byte_len = bit_length / 8; + let lower_bound = BigUint::from((bit_length * 2) as u64); + let g = BigUint::from(2u32); + + loop { + let mut buf = vec![0u8; byte_len]; + getrandom::getrandom(&mut buf) + .map_err(|e| CryptoError::Decrypt(format!("rng: {e}")))?; + let secret = from_le(&buf) % n_minus_1; + if secret <= lower_bound || secret >= *n_minus_1 { + continue; + } + + let a = g.modpow(&secret, n); + let a_bytes = to_le_fixed(&a, byte_len)?; + + let mut su = a_bytes.clone(); + su.extend_from_slice(server_ephemeral); + let scrambling = from_le(&expand_hash(&su)); + if scrambling.bits() == 0 { + continue; + } + + return Ok((secret, a_bytes, scrambling)); + } +} + +/// Run the client side of the Proton SRP handshake. +/// +/// * `version` — auth version from `auth/v4/info` (3/4 supported). +/// * `password` — the user's login password. +/// * `salt` — raw (decoded) login salt. +/// * `signed_modulus` — the cleartext-signed modulus string. +/// * `server_ephemeral` — raw (decoded) server ephemeral `B`. +pub fn generate_proofs( + version: i32, + password: &[u8], + salt: &[u8], + signed_modulus: &str, + server_ephemeral: &[u8], + bit_length: usize, +) -> Result { + if version < 3 { + return Err(CryptoError::Unlock(format!( + "unsupported SRP auth version {version}" + ))); + } + + let byte_len = bit_length / 8; + let modulus = verify_and_decode_modulus(signed_modulus)?; + let hashed_password = hash_password_v3(password, salt, &modulus)?; + + let n = from_le(&modulus); + let b = from_le(server_ephemeral); + check_params(bit_length, &n, &b)?; + + let n_minus_1 = &n - 1u32; + let x = from_le(&hashed_password); + let g = BigUint::from(2u32); + + // Multiplier k = expandHash(g || N) mod N, bounded. + let mut k_input = to_le_fixed(&g, byte_len)?; + k_input.extend_from_slice(&to_le_fixed(&n, byte_len)?); + let k = from_le(&expand_hash(&k_input)) % &n; + if k <= BigUint::one() || k >= n_minus_1 { + return Err(CryptoError::Verification("SRP multiplier out of bounds".into())); + } + + let (secret, a_bytes, scrambling) = + generate_ephemeral(bit_length, &n, &n_minus_1, server_ephemeral)?; + + finish_proofs( + &n, + &n_minus_1, + byte_len, + &k, + &x, + server_ephemeral, + &secret, + &a_bytes, + &scrambling, + ) +} + +/// Assemble the shared secret and proofs from a chosen client secret and +/// scrambling parameter. Separated out so the math is testable without the RNG. +#[allow(clippy::too_many_arguments)] +fn finish_proofs( + n: &BigUint, + n_minus_1: &BigUint, + byte_len: usize, + k: &BigUint, + x: &BigUint, + server_ephemeral: &[u8], + secret: &BigUint, + a_bytes: &[u8], + scrambling: &BigUint, +) -> Result { + let g = BigUint::from(2u32); + + // base = (B - k * g^x) mod N + let b = from_le(server_ephemeral); + let gx = g.modpow(x, n); + let kgx = (k * &gx) % n; + let base = (&b + n - kgx) % n; + + // exponent = (u * x + a) mod (N-1) + let exponent = (scrambling * x + secret) % n_minus_1; + + let shared = base.modpow(&exponent, n); + let shared_bytes = to_le_fixed(&shared, byte_len)?; + + // M1 = expandHash(A || B || S); M2 = expandHash(A || M1 || S) + let mut cp = a_bytes.to_vec(); + cp.extend_from_slice(server_ephemeral); + cp.extend_from_slice(&shared_bytes); + let client_proof = expand_hash(&cp); + + let mut sp = a_bytes.to_vec(); + sp.extend_from_slice(&client_proof); + sp.extend_from_slice(&shared_bytes); + let expected_server_proof = expand_hash(&sp); + + Ok(SrpProofs { + client_ephemeral: a_bytes.to_vec(), + client_proof, + expected_server_proof, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Vectors from ProtonMail/go-srp `srp_test.go`. The modulus is signed by the + // production modulus key, so it exercises our real verification path. + const TEST_MODULUS_B64: &str = "W2z5HBi8RvsfYzZTS7qBaUxxPhsfHJFZpu3Kd6s1JafNrCCH9rfvPLrfuqocxWPgWDH2R8neK7PkNvjxto9TStuY5z7jAzWRvFWN9cQhAKkdWgy0JY6ywVn22+HFpF4cYesHrqFIKUPDMSSIlWjBVmEJZ/MusD44ZT29xcPrOqeZvwtCffKtGAIjLYPZIEbZKnDM1Dm3q2K/xS5h+xdhjnndhsrkwm9U9oyA2wxzSXFL+pdfj2fOdRwuR5nW0J2NFrq3kJjkRmpO/Genq1UW+TEknIWAb6VzJJJA244K/H8cnSx2+nSNZO3bbo6Ys228ruV9A8m6DhxmS+bihN3ttQ=="; + const TEST_MODULUS_CLEARSIGN: &str = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nW2z5HBi8RvsfYzZTS7qBaUxxPhsfHJFZpu3Kd6s1JafNrCCH9rfvPLrfuqocxWPgWDH2R8neK7PkNvjxto9TStuY5z7jAzWRvFWN9cQhAKkdWgy0JY6ywVn22+HFpF4cYesHrqFIKUPDMSSIlWjBVmEJZ/MusD44ZT29xcPrOqeZvwtCffKtGAIjLYPZIEbZKnDM1Dm3q2K/xS5h+xdhjnndhsrkwm9U9oyA2wxzSXFL+pdfj2fOdRwuR5nW0J2NFrq3kJjkRmpO/Genq1UW+TEknIWAb6VzJJJA244K/H8cnSx2+nSNZO3bbo6Ys228ruV9A8m6DhxmS+bihN3ttQ==\n-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwl4EARYIABAFAlwB1j0JEDUFhcTpUY8mAAD8CgEAnsFnF4cF0uSHKkXa1GIa\nGO86yMV4zDZEZcDSJo0fgr8A/AlupGN9EdHlsrZLmTA1vhIx+rOgxdEff28N\nkvNM7qIK\n=q6vu\n-----END PGP SIGNATURE-----"; + const TEST_SALT_B64: &str = "yKlc5/CvObfoiw=="; + const TEST_USERNAME: &str = "jakubqa"; + const TEST_PASSWORD: &[u8] = b"abc123"; + + #[test] + fn expand_hash_is_256_bytes() { + assert_eq!(expand_hash(b"abc").len(), 256); + } + + #[test] + fn modulus_signature_verifies_and_decodes() { + let decoded = verify_and_decode_modulus(TEST_MODULUS_CLEARSIGN).expect("verify modulus"); + let expected = BASE64.decode(TEST_MODULUS_B64).unwrap(); + assert_eq!(decoded, expected); + } + + #[test] + fn rejects_tampered_modulus_signature() { + // Flip a byte deep in the base64 payload so the signature no longer matches. + let tampered = TEST_MODULUS_CLEARSIGN.replacen("W2z5", "X2z5", 1); + assert!(verify_and_decode_modulus(&tampered).is_err()); + } + + // Full SRP-6a round trip: compute the server side in-test (the server holds + // the verifier v = g^x) and confirm our client proofs agree with what the + // server would derive. Proves the base/exponent/shared-secret math without + // depending on a non-reproducible RNG sequence. + #[test] + fn client_server_round_trip() { + let _ = TEST_USERNAME; + let bit_length = DEFAULT_BIT_LENGTH; + let byte_len = bit_length / 8; + + let modulus = verify_and_decode_modulus(TEST_MODULUS_CLEARSIGN).unwrap(); + let salt = BASE64.decode(TEST_SALT_B64).unwrap(); + let hashed = hash_password_v3(TEST_PASSWORD, &salt, &modulus).unwrap(); + + let n = from_le(&modulus); + let n_minus_1 = &n - 1u32; + let x = from_le(&hashed); + let g = BigUint::from(2u32); + + // Multiplier k (same as production path). + let mut k_input = to_le_fixed(&g, byte_len).unwrap(); + k_input.extend_from_slice(&to_le_fixed(&n, byte_len).unwrap()); + let k = from_le(&expand_hash(&k_input)) % &n; + + // Server: verifier and ephemeral B = (k*v + g^b) mod N. + let v = g.modpow(&x, &n); + let server_secret = BigUint::from(0x5eed_1234_u64); + let b_pub = (&k * &v + g.modpow(&server_secret, &n)) % &n; + let server_ephemeral = to_le_fixed(&b_pub, byte_len).unwrap(); + + // Client: chosen secret a, ephemeral A, scrambling u. + let client_secret = BigUint::from(0xabcd_ef01_u64); + let a_pub = g.modpow(&client_secret, &n); + let a_bytes = to_le_fixed(&a_pub, byte_len).unwrap(); + let mut u_input = a_bytes.clone(); + u_input.extend_from_slice(&server_ephemeral); + let u = from_le(&expand_hash(&u_input)); + + let proofs = finish_proofs( + &n, + &n_minus_1, + byte_len, + &k, + &x, + &server_ephemeral, + &client_secret, + &a_bytes, + &u, + ) + .unwrap(); + + // Server-side shared secret: S = (A * v^u)^b mod N. + let server_shared = (&a_pub * v.modpow(&u, &n) % &n).modpow(&server_secret, &n); + let server_shared_bytes = to_le_fixed(&server_shared, byte_len).unwrap(); + + let mut cp = a_bytes.clone(); + cp.extend_from_slice(&server_ephemeral); + cp.extend_from_slice(&server_shared_bytes); + let server_view_client_proof = expand_hash(&cp); + + let mut sp = a_bytes.clone(); + sp.extend_from_slice(&server_view_client_proof); + sp.extend_from_slice(&server_shared_bytes); + let server_proof = expand_hash(&sp); + + assert_eq!( + proofs.client_proof, server_view_client_proof, + "client proof must match server's derivation" + ); + assert_eq!( + proofs.expected_server_proof, server_proof, + "expected server proof must match server's M2" + ); + } + + #[test] + fn generate_proofs_runs_end_to_end() { + // Exercises the full RNG path against the real (valid, safe-prime) modulus. + let salt = BASE64.decode(TEST_SALT_B64).unwrap(); + let modulus = verify_and_decode_modulus(TEST_MODULUS_CLEARSIGN).unwrap(); + // Build a server ephemeral that passes checkParams (1 < B < N-1). + let n = from_le(&modulus); + let x = from_le(&hash_password_v3(TEST_PASSWORD, &salt, &modulus).unwrap()); + let g = BigUint::from(2u32); + let byte_len = DEFAULT_BIT_LENGTH / 8; + let mut k_input = to_le_fixed(&g, byte_len).unwrap(); + k_input.extend_from_slice(&to_le_fixed(&n, byte_len).unwrap()); + let k = from_le(&expand_hash(&k_input)) % &n; + let v = g.modpow(&x, &n); + let b_pub = (&k * &v + g.modpow(&BigUint::from(7u32), &n)) % &n; + let server_ephemeral = to_le_fixed(&b_pub, byte_len).unwrap(); + + let proofs = generate_proofs( + 4, + TEST_PASSWORD, + &salt, + TEST_MODULUS_CLEARSIGN, + &server_ephemeral, + DEFAULT_BIT_LENGTH, + ) + .expect("generate proofs"); + assert_eq!(proofs.client_ephemeral.len(), byte_len); + assert_eq!(proofs.client_proof.len(), 256); + assert_eq!(proofs.expected_server_proof.len(), 256); + } +} diff --git a/crates/proton-sdk/src/error.rs b/crates/proton-sdk/src/error.rs new file mode 100644 index 0000000..ab6adeb --- /dev/null +++ b/crates/proton-sdk/src/error.rs @@ -0,0 +1,57 @@ +//! Error types for the core SDK. + +use crate::api::ResponseCode; + +pub type Result = std::result::Result; + +/// Top-level error for the core SDK. +#[derive(Debug, thiserror::Error)] +pub enum ProtonError { + /// The API returned a non-success response envelope or HTTP status. + #[error(transparent)] + Api(#[from] ProtonApiError), + + /// Transport-level failure (DNS, TLS, timeout, connection reset). + #[error("HTTP transport error: {0}")] + Transport(#[from] reqwest::Error), + + /// Failed to (de)serialize a request or response body. + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + /// A cryptographic operation failed. + #[error("cryptography error: {0}")] + Crypto(#[from] crate::crypto::CryptoError), + + /// The SDK was used in a way that violates an invariant. + #[error("invalid operation: {0}")] + InvalidOperation(String), +} + +impl ProtonError { + pub fn invalid_operation(message: impl Into) -> Self { + Self::InvalidOperation(message.into()) + } +} + +/// An error reported by the Proton API in its response envelope. +#[derive(Debug, Clone, thiserror::Error)] +#[error("proton api error {code:?} (http {http_status}): {message}")] +pub struct ProtonApiError { + /// Application-level response code from the `Code` field. + pub code: ResponseCode, + /// HTTP status code of the response. + pub http_status: u16, + /// Human-readable message from the `Error` field, if present. + pub message: String, +} + +impl ProtonApiError { + pub fn is_unauthorized(&self) -> bool { + self.http_status == 401 + } + + pub fn is_invalid_refresh_token(&self) -> bool { + matches!(self.code, ResponseCode::InvalidRefreshToken) + } +} diff --git a/crates/proton-sdk/src/http.rs b/crates/proton-sdk/src/http.rs new file mode 100644 index 0000000..3d47f56 --- /dev/null +++ b/crates/proton-sdk/src/http.rs @@ -0,0 +1,388 @@ +//! HTTP plumbing: header injection, the Proton response envelope, bearer-token +//! authentication and transparent 401 refresh. +//! +//! Mirrors `HttpApiCallBuilder`, `AuthorizationHandler` and `TokenCredential` +//! from the C# SDK, collapsed into a single reqwest-based client since Rust has +//! no `DelegatingHandler` pipeline. + +use std::sync::Arc; + +use reqwest::{Method, StatusCode}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use tokio::sync::Mutex; + +use crate::api::{ApiResponse, ResponseCode}; +use crate::config::{ProtonClientConfiguration, API_CONTENT_TYPE}; +use crate::error::{ProtonApiError, ProtonError, Result}; +use crate::ids::SessionId; + +const SESSION_ID_HEADER: &str = "x-pm-uid"; +const APP_VERSION_HEADER: &str = "x-pm-appversion"; +const STORAGE_TOKEN_HEADER: &str = "pm-storage-token"; + +/// The mutable authentication tokens for a session, shared between every +/// request and the refresh path. +#[derive(Debug, Clone)] +pub struct Tokens { + pub access_token: String, + pub refresh_token: String, +} + +/// A reqwest-backed client bound to a single authenticated session. +/// +/// Cloning is cheap (everything is reference-counted) and shares the same token +/// state, so a refresh triggered by one request is visible to all others. +#[derive(Clone)] +pub struct ApiHttpClient { + inner: Arc, +} + +struct Inner { + http: reqwest::Client, + base_url: String, + config: ProtonClientConfiguration, + session_id: SessionId, + tokens: Mutex, +} + +impl ApiHttpClient { + /// Build a client for an authenticated session. + pub fn new( + config: ProtonClientConfiguration, + session_id: SessionId, + tokens: Tokens, + ) -> Result { + let http = reqwest::Client::builder() + .timeout(config.request_timeout) + .build()?; + + let base_url = ensure_trailing_slash(&config.base_url); + + Ok(Self { + inner: Arc::new(Inner { + http, + base_url, + config, + session_id, + tokens: Mutex::new(tokens), + }), + }) + } + + /// Snapshot the current tokens (e.g. to persist for a later `resume`). + pub async fn current_tokens(&self) -> Tokens { + self.inner.tokens.lock().await.clone() + } + + /// `GET {url}` against block storage, returning the raw (still-encrypted) + /// blob bytes. + /// + /// Block storage lives on a different host from the API: the URL is + /// absolute and authorization is a per-block `pm-storage-token` header + /// rather than the session bearer. Mirrors C# `StorageApiClient + /// .GetBlobStreamAsync`. A successful response is raw binary; an error + /// response is the usual JSON envelope. + pub async fn get_storage_blob(&self, url: &str, token: &str) -> Result> { + let mut request = self + .inner + .http + .get(url) + .header(STORAGE_TOKEN_HEADER, token); + + if !self.inner.config.user_agent.is_empty() { + request = request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent); + } + + let response = request.send().await?; + let status = response.status(); + let bytes = response.bytes().await?; + + // Success bodies are raw block bytes (not JSON); only error responses + // carry the envelope. + if let Ok(envelope) = serde_json::from_slice::(&bytes) { + if !envelope.is_success() { + return Err(api_error(status, &bytes)); + } + } else if !status.is_success() { + return Err(api_error(status, &bytes)); + } + + Ok(bytes.to_vec()) + } + + /// `POST {url}` a block blob to storage as `multipart/form-data`. + /// + /// Mirrors C# `StorageApiClient.UploadBlobAsync`: a single `Block` part + /// (filename `blob`, `application/octet-stream`) on the storage host, + /// authorized by the per-block `pm-storage-token` header rather than the + /// session bearer. The response is the usual JSON envelope. + pub async fn post_storage_blob(&self, url: &str, token: &str, blob: Vec) -> Result<()> { + let part = reqwest::multipart::Part::bytes(blob) + .file_name("blob") + .mime_str("application/octet-stream") + .map_err(ProtonError::from)?; + let form = reqwest::multipart::Form::new().part("Block", part); + + let mut request = self + .inner + .http + .post(url) + .header(STORAGE_TOKEN_HEADER, token) + .multipart(form); + + if !self.inner.config.user_agent.is_empty() { + request = request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent); + } + + let response = request.send().await?; + let status = response.status(); + let bytes = response.bytes().await?; + + if let Ok(envelope) = serde_json::from_slice::(&bytes) { + if !envelope.is_success() { + return Err(api_error(status, &bytes)); + } + } else if !status.is_success() { + return Err(api_error(status, &bytes)); + } + Ok(()) + } + + pub fn session_id(&self) -> &SessionId { + &self.inner.session_id + } + + /// `GET {path}` returning a typed success body. + pub async fn get(&self, path: &str) -> Result { + self.send::<(), T>(Method::GET, path, None).await + } + + /// `POST {path}` with a JSON body, returning a typed success body. + pub async fn post(&self, path: &str, body: &B) -> Result { + self.send::(Method::POST, path, Some(body)).await + } + + /// `PUT {path}` with a JSON body, returning a typed success body. + pub async fn put(&self, path: &str, body: &B) -> Result { + self.send::(Method::PUT, path, Some(body)).await + } + + /// `DELETE {path}` returning a typed success body. + pub async fn delete(&self, path: &str) -> Result { + self.send::<(), T>(Method::DELETE, path, None).await + } + + async fn send( + &self, + method: Method, + path: &str, + body: Option<&B>, + ) -> Result { + let access_token = self.inner.tokens.lock().await.access_token.clone(); + + let response = self + .send_with_token(method.clone(), path, body, &access_token) + .await?; + + let response = if response.status() == StatusCode::UNAUTHORIZED { + self.handle_unauthorized(method, path, body, response, access_token) + .await? + } else { + response + }; + + parse_response(response).await + } + + async fn handle_unauthorized( + &self, + method: Method, + path: &str, + body: Option<&B>, + response: reqwest::Response, + rejected_access_token: String, + ) -> Result { + // Don't bother refreshing for terminal account states. + let bytes = response.bytes().await?; + if let Ok(envelope) = serde_json::from_slice::(&bytes) { + if matches!( + envelope.code, + ResponseCode::AccountDeleted | ResponseCode::AccountDisabled + ) { + return Err(api_error(StatusCode::UNAUTHORIZED, &bytes)); + } + } + + let access_token = self.refresh_access_token(&rejected_access_token).await?; + self.send_with_token(method, path, body, &access_token).await + } + + async fn send_with_token( + &self, + method: Method, + path: &str, + body: Option<&B>, + access_token: &str, + ) -> Result { + let url = format!("{}{}", self.inner.base_url, path.trim_start_matches('/')); + let mut request = self + .inner + .http + .request(method, url) + .header(SESSION_ID_HEADER, self.inner.session_id.as_str()) + .header(APP_VERSION_HEADER, &self.inner.config.app_version) + .header(reqwest::header::ACCEPT, API_CONTENT_TYPE) + .bearer_auth(access_token); + + if !self.inner.config.user_agent.is_empty() { + request = request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent); + } + + if let Some(body) = body { + request = request.json(body); + } + + Ok(request.send().await?) + } + + /// Refresh the session tokens, deduplicating concurrent refreshes: if the + /// in-memory access token already differs from the rejected one, another + /// task refreshed first and we reuse its result. + async fn refresh_access_token(&self, rejected_access_token: &str) -> Result { + let mut guard = self.inner.tokens.lock().await; + + if guard.access_token != rejected_access_token { + return Ok(guard.access_token.clone()); + } + + let refreshed = self.request_refresh(&guard.refresh_token).await?; + *guard = refreshed.clone(); + Ok(refreshed.access_token) + } + + async fn request_refresh(&self, refresh_token: &str) -> Result { + let url = format!("{}auth/v4/refresh", self.inner.base_url); + let body = SessionRefreshRequest { + response_type: "token", + grant_type: "refresh_token", + refresh_token, + redirect_uri: &self.inner.config.refresh_redirect_uri, + }; + + // The refresh call carries the session id but, deliberately, no bearer + // token (the access token is the thing being replaced). + let response = self + .inner + .http + .post(url) + .header(SESSION_ID_HEADER, self.inner.session_id.as_str()) + .header(APP_VERSION_HEADER, &self.inner.config.app_version) + .header(reqwest::header::ACCEPT, API_CONTENT_TYPE) + .json(&body) + .send() + .await?; + + let refreshed: SessionRefreshResponse = parse_response(response).await?; + Ok(Tokens { + access_token: refreshed.access_token, + refresh_token: refreshed.refresh_token, + }) + } +} + +#[derive(Serialize)] +struct SessionRefreshRequest<'a> { + #[serde(rename = "ResponseType")] + response_type: &'a str, + #[serde(rename = "GrantType")] + grant_type: &'a str, + #[serde(rename = "RefreshToken")] + refresh_token: &'a str, + #[serde(rename = "RedirectURI")] + redirect_uri: &'a str, +} + +#[derive(serde::Deserialize)] +struct SessionRefreshResponse { + #[serde(rename = "AccessToken")] + access_token: String, + #[serde(rename = "RefreshToken")] + refresh_token: String, +} + +/// `POST {path}` without a session: no `x-pm-uid` and no bearer token. +/// +/// Used by the SRP login flow (`auth/v4/info`, `auth/v4`), which runs before a +/// session exists. Mirrors the C# SDK's `BeginAsync`, which issues these calls +/// on a session-less `HttpClient`. +pub async fn post_unauthenticated( + config: &ProtonClientConfiguration, + path: &str, + body: &B, +) -> Result { + let http = reqwest::Client::builder() + .timeout(config.request_timeout) + .build()?; + + let base_url = ensure_trailing_slash(&config.base_url); + let url = format!("{}{}", base_url, path.trim_start_matches('/')); + + let mut request = http + .post(url) + .header(APP_VERSION_HEADER, &config.app_version) + .header(reqwest::header::ACCEPT, API_CONTENT_TYPE) + .json(body); + + if !config.user_agent.is_empty() { + request = request.header(reqwest::header::USER_AGENT, &config.user_agent); + } + + parse_response(request.send().await?).await +} + +/// Read a response body, enforce the Proton success envelope, and deserialize +/// the typed success payload. +async fn parse_response(response: reqwest::Response) -> Result { + let status = response.status(); + let bytes = response.bytes().await?; + + // Every Proton response embeds the envelope; a missing/non-success code or a + // non-2xx HTTP status is an API error. `MultipleResponses` (1001) is a batch + // multi-status, not a failure: the real per-item codes live in the body, so + // the caller (e.g. trash/restore/delete) inspects them itself. + if let Ok(envelope) = serde_json::from_slice::(&bytes) { + if !envelope.is_success() && envelope.code != ResponseCode::MultipleResponses { + return Err(api_error(status, &bytes)); + } + } else if !status.is_success() { + return Err(api_error(status, &bytes)); + } + + Ok(serde_json::from_slice::(&bytes)?) +} + +fn api_error(status: StatusCode, bytes: &[u8]) -> ProtonError { + let envelope = serde_json::from_slice::(bytes).ok(); + let code = envelope + .as_ref() + .map(|e| e.code) + .unwrap_or(ResponseCode::Unknown); + let message = envelope + .and_then(|e| e.error_message) + .unwrap_or_else(|| status.canonical_reason().unwrap_or("unknown error").to_owned()); + + ProtonError::Api(ProtonApiError { + code, + http_status: status.as_u16(), + message, + }) +} + +fn ensure_trailing_slash(url: &str) -> String { + if url.ends_with('/') { + url.to_owned() + } else { + format!("{url}/") + } +} diff --git a/crates/proton-sdk/src/ids.rs b/crates/proton-sdk/src/ids.rs new file mode 100644 index 0000000..eaec21f --- /dev/null +++ b/crates/proton-sdk/src/ids.rs @@ -0,0 +1,103 @@ +//! Strongly-typed string identifiers used across the SDK. +//! +//! Proton API identifiers are opaque strings. We wrap them in newtypes so the +//! type system prevents mixing e.g. a [`ShareId`] with a [`LinkId`], mirroring +//! the dedicated ID types in the C# SDK. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +macro_rules! string_id { + ($(#[$meta:meta])* $name:ident) => { + $(#[$meta])* + #[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(pub String); + + impl $name { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } + } + + impl fmt::Debug for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, concat!(stringify!($name), "({:?})"), self.0) + } + } + + impl From for $name { + fn from(value: String) -> Self { + Self(value) + } + } + + impl From<&str> for $name { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } + } + }; +} + +string_id!( + /// Identifies an authenticated API session (`x-pm-uid`). + SessionId +); +string_id!( + /// Identifies a user account. + UserId +); +string_id!( + /// Identifies a user-level encryption key. + UserKeyId +); +string_id!( + /// Identifies an email address attached to an account. + AddressId +); +string_id!( + /// Identifies an address-level encryption key. + AddressKeyId +); +string_id!( + /// Identifies a Drive volume. + VolumeId +); +string_id!( + /// Identifies a Drive share. + ShareId +); +string_id!( + /// Identifies a Drive link (node) within a volume. + LinkId +); + +/// Globally addresses a Drive node: a [`LinkId`] qualified by its [`VolumeId`]. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct NodeUid { + pub volume_id: VolumeId, + pub link_id: LinkId, +} + +impl NodeUid { + pub fn new(volume_id: VolumeId, link_id: LinkId) -> Self { + Self { volume_id, link_id } + } +} + +impl fmt::Display for NodeUid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}~{}", self.volume_id, self.link_id) + } +} diff --git a/crates/proton-sdk/src/lib.rs b/crates/proton-sdk/src/lib.rs new file mode 100644 index 0000000..a9dec5c --- /dev/null +++ b/crates/proton-sdk/src/lib.rs @@ -0,0 +1,23 @@ +//! Core Proton account/session/crypto primitives for the Rust Proton SDK. +//! +//! Pure-Rust reimplementation of the foundational `Proton.Sdk` layer of the +//! official [Proton Drive SDK](https://github.com/ProtonDriveApps/sdk). This +//! crate has no dependency on the native NativeAOT core; it talks to the Proton +//! API directly. +//! +//! Scope (matching the official SDK): Drive business-logic foundations only. +//! Login/SRP is provided behind [`session::ProtonApiSession::begin`] but read +//! workflows can be driven entirely through +//! [`session::ProtonApiSession::resume`] with pre-obtained tokens. + +pub mod account; +pub mod api; +pub mod config; +pub mod crypto; +pub mod error; +pub mod http; +pub mod ids; +pub mod session; + +pub use error::{ProtonApiError, ProtonError, Result}; +pub use session::ProtonApiSession; diff --git a/crates/proton-sdk/src/session.rs b/crates/proton-sdk/src/session.rs new file mode 100644 index 0000000..4798cc3 --- /dev/null +++ b/crates/proton-sdk/src/session.rs @@ -0,0 +1,310 @@ +//! Authenticated API session. +//! +//! Mirrors `Proton.Sdk.ProtonApiSession`. [`ProtonApiSession::resume`] rebuilds +//! a session from previously obtained tokens — the path used by all read +//! workflows. [`ProtonApiSession::begin`] (SRP password login) is intentionally +//! deferred, matching the official SDK's stance that login flows live outside +//! the core Drive scope. + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use serde::{Deserialize, Serialize}; + +use crate::config::ProtonClientConfiguration; +use crate::crypto; +use crate::error::{ProtonError, Result}; +use crate::http::{self, ApiHttpClient, Tokens}; +use crate::ids::{SessionId, UserId}; + +/// Whether the account uses a single password or a separate data password. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PasswordMode { + Single, + Dual, +} + +impl PasswordMode { + /// Map the wire value (`1` = single, `2` = dual); anything else defaults to dual. + fn from_wire(value: i32) -> Self { + match value { + 1 => PasswordMode::Single, + _ => PasswordMode::Dual, + } + } +} + +/// Parameters required to resume a session from persisted credentials. +#[derive(Debug, Clone)] +pub struct ResumeParameters { + pub session_id: SessionId, + pub username: String, + pub user_id: UserId, + pub access_token: String, + pub refresh_token: String, + pub scopes: Vec, + pub is_waiting_for_second_factor_code: bool, + pub password_mode: PasswordMode, +} + +/// An authenticated Proton API session. +#[derive(Clone)] +pub struct ProtonApiSession { + http: ApiHttpClient, + session_id: SessionId, + username: String, + user_id: UserId, + scopes: Vec, + password_mode: PasswordMode, + is_waiting_for_second_factor: bool, +} + +impl ProtonApiSession { + /// Resume a session from previously obtained tokens. + pub fn resume(config: ProtonClientConfiguration, params: ResumeParameters) -> Result { + let tokens = Tokens { + access_token: params.access_token, + refresh_token: params.refresh_token, + }; + let http = ApiHttpClient::new(config, params.session_id.clone(), tokens)?; + + Ok(Self { + http, + session_id: params.session_id, + username: params.username, + user_id: params.user_id, + scopes: params.scopes, + password_mode: params.password_mode, + is_waiting_for_second_factor: params.is_waiting_for_second_factor_code, + }) + } + + /// SRP password login. + /// + /// Mirrors C# `ProtonApiSession.BeginAsync`: initiate an SRP session + /// (`auth/v4/info`), run the client handshake, then authenticate + /// (`auth/v4`). The returned session is ready for read/write workflows once + /// the data password is applied (see CLAUDE.md key chain); if the account + /// requires a second factor, [`is_waiting_for_second_factor`] is set and the + /// caller must complete 2FA before authorized scopes are granted. + /// + /// Both API calls are unauthenticated (no session id / bearer), matching the + /// reference SDK. + pub async fn begin( + config: ProtonClientConfiguration, + username: &str, + password: &[u8], + ) -> Result { + let init: SessionInitiationResponse = http::post_unauthenticated( + &config, + "auth/v4/info", + &SessionInitiationRequest { username }, + ) + .await?; + + let salt = BASE64 + .decode(init.salt.trim()) + .map_err(|e| ProtonError::invalid_operation(format!("decode SRP salt: {e}")))?; + let server_ephemeral = BASE64 + .decode(init.server_ephemeral.trim()) + .map_err(|e| ProtonError::invalid_operation(format!("decode server ephemeral: {e}")))?; + + let proofs = crypto::generate_proofs( + init.version, + password, + &salt, + &init.modulus, + &server_ephemeral, + crypto::DEFAULT_BIT_LENGTH, + )?; + + let auth: AuthenticationResponse = http::post_unauthenticated( + &config, + "auth/v4", + &AuthenticationRequest { + username, + client_ephemeral: BASE64.encode(&proofs.client_ephemeral), + client_proof: BASE64.encode(&proofs.client_proof), + srp_session: init.srp_session, + }, + ) + .await?; + + // Reject a forged server: the server must prove it holds the verifier. + let server_proof = BASE64 + .decode(auth.server_proof.trim()) + .map_err(|e| ProtonError::invalid_operation(format!("decode server proof: {e}")))?; + if server_proof != proofs.expected_server_proof { + return Err(ProtonError::invalid_operation( + "SRP server proof mismatch — server failed authentication", + )); + } + + let tokens = Tokens { + access_token: auth.access_token, + refresh_token: auth.refresh_token, + }; + let http = ApiHttpClient::new(config, auth.session_id.clone(), tokens)?; + + Ok(Self { + http, + session_id: auth.session_id, + username: username.to_owned(), + user_id: auth.user_id, + scopes: auth.scopes, + password_mode: PasswordMode::from_wire(auth.password_mode), + is_waiting_for_second_factor: auth + .second_factor + .map(|f| f.is_enabled()) + .unwrap_or(false), + }) + } + + /// Submit a second-factor code (`POST auth/v4/2fa`). + /// + /// Mirrors C# `ProtonApiSession.ApplySecondFactorCodeAsync`: on success the + /// server returns the now-elevated scopes; clear the waiting flag and adopt + /// them. Must be called on a session whose [`is_waiting_for_second_factor`] + /// is set (typically right after [`begin`], before the session is shared + /// with a Drive client). + pub async fn apply_second_factor_code(&mut self, code: &str) -> Result<()> { + let response: ScopesResponse = self + .http + .post("auth/v4/2fa", &SecondFactorValidationRequest { code }) + .await?; + self.is_waiting_for_second_factor = false; + self.scopes = response.scopes; + Ok(()) + } + + /// Refresh the session's authorized scopes (`GET auth/v4/scopes`). + /// + /// Mirrors C# `ProtonApiSession.RefreshScopesAsync`. + pub async fn refresh_scopes(&mut self) -> Result<()> { + let response: ScopesResponse = self.http.get("auth/v4/scopes").await?; + self.scopes = response.scopes; + Ok(()) + } + + /// End the session server-side (`DELETE auth/v4`). + pub async fn end(&self) -> Result<()> { + let _: crate::api::ApiResponse = self.http.delete("auth/v4").await?; + Ok(()) + } + + pub fn http(&self) -> &ApiHttpClient { + &self.http + } + + pub fn session_id(&self) -> &SessionId { + &self.session_id + } + + pub fn user_id(&self) -> &UserId { + &self.user_id + } + + pub fn username(&self) -> &str { + &self.username + } + + pub fn scopes(&self) -> &[String] { + &self.scopes + } + + pub fn password_mode(&self) -> PasswordMode { + self.password_mode + } + + /// Whether the account still needs a second-factor code before its scopes + /// are fully authorized. + pub fn is_waiting_for_second_factor(&self) -> bool { + self.is_waiting_for_second_factor + } + + /// Snapshot current tokens for persistence (they may have rotated on refresh). + pub async fn current_tokens(&self) -> Tokens { + self.http.current_tokens().await + } +} + +#[derive(Serialize)] +struct SessionInitiationRequest<'a> { + #[serde(rename = "Username")] + username: &'a str, +} + +#[derive(Deserialize)] +struct SessionInitiationResponse { + #[serde(rename = "Version")] + version: i32, + /// Cleartext-signed modulus (verified before use). + #[serde(rename = "Modulus")] + modulus: String, + /// Base64 server ephemeral `B`. + #[serde(rename = "ServerEphemeral")] + server_ephemeral: String, + /// Base64 login salt. + #[serde(rename = "Salt")] + salt: String, + #[serde(rename = "SRPSession")] + srp_session: String, +} + +#[derive(Serialize)] +struct AuthenticationRequest<'a> { + #[serde(rename = "Username")] + username: &'a str, + /// Base64 client ephemeral `A`. + #[serde(rename = "ClientEphemeral")] + client_ephemeral: String, + /// Base64 client proof `M1`. + #[serde(rename = "ClientProof")] + client_proof: String, + #[serde(rename = "SRPSession")] + srp_session: String, +} + +#[derive(Deserialize)] +struct AuthenticationResponse { + #[serde(rename = "UID")] + session_id: SessionId, + #[serde(rename = "UserID")] + user_id: UserId, + /// Base64 server proof `M2`. + #[serde(rename = "ServerProof")] + server_proof: String, + #[serde(rename = "AccessToken")] + access_token: String, + #[serde(rename = "RefreshToken")] + refresh_token: String, + #[serde(rename = "Scopes", default)] + scopes: Vec, + #[serde(rename = "PasswordMode")] + password_mode: i32, + #[serde(rename = "2FA")] + second_factor: Option, +} + +#[derive(Serialize)] +struct SecondFactorValidationRequest<'a> { + #[serde(rename = "TwoFactorCode")] + code: &'a str, +} + +#[derive(Deserialize)] +struct ScopesResponse { + #[serde(rename = "Scopes", default)] + scopes: Vec, +} + +#[derive(Deserialize)] +struct SecondFactorInfo { + #[serde(rename = "Enabled", default)] + enabled: i32, +} + +impl SecondFactorInfo { + fn is_enabled(&self) -> bool { + self.enabled != 0 + } +}