From 70b2b0348e385f8c5904bb420b9a0ac4c6b56c65 Mon Sep 17 00:00:00 2001 From: Shautvast Date: Sun, 22 Feb 2026 15:55:18 +0100 Subject: [PATCH] Decrypt ProtonMail messages end-to-end via key passphrase fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key insight from go-proton-api SaltForKey: ProtonMail uses only the last 31 chars of the bcrypt output as the key passphrase — not the full 60-char string. One line fix, two days of debugging. Also adds the full crypto layer (crypto.rs): user key unlock, address key token decryption, and message body decryption via rpgp. Includes SRP auth, session caching with locked-scope handling, TOTP, and the ProtonMail API client for inbox listing and message fetch. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 1137 ++++++++++++++++++++++++++++++++++- proton-bridge/Cargo.toml | 8 +- proton-bridge/src/api.rs | 167 +++++ proton-bridge/src/auth.rs | 60 +- proton-bridge/src/config.rs | 5 + proton-bridge/src/crypto.rs | 408 +++++++++++++ proton-bridge/src/main.rs | 190 +++++- proton-bridge/src/srp.rs | 2 +- 8 files changed, 1942 insertions(+), 35 deletions(-) create mode 100644 proton-bridge/src/crypto.rs diff --git a/Cargo.lock b/Cargo.lock index 0bbc657..70e0bfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,50 @@ 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 = [ + "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 = "ahash" version = "0.8.12" @@ -57,6 +101,56 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.101" @@ -72,6 +166,19 @@ dependencies = [ "object", ] +[[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 = "arrayvec" version = "0.5.2" @@ -112,6 +219,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.13.1" @@ -124,6 +237,25 @@ 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.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" +dependencies = [ + "base64 0.22.1", + "blowfish 0.9.1", + "getrandom 0.2.17", + "subtle", + "zeroize", +] + [[package]] name = "bincode" version = "1.3.3" @@ -148,6 +280,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bitfield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d7e60934ceec538daadb9d8432424ed043a904d8e0243f3c6446bce549a46ac" + [[package]] name = "bitflags" version = "1.3.2" @@ -160,6 +298,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.9.0" @@ -187,6 +334,15 @@ 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.5.0" @@ -198,6 +354,35 @@ dependencies = [ "opaque-debug 0.2.3", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "buffer-redux" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431a9cc8d7efa49bc326729264537f5e60affce816c66edf434350778c9f4f54" +dependencies = [ + "memchr", +] + [[package]] name = "bufstream" version = "0.1.4" @@ -228,6 +413,25 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[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 = "castaway" version = "0.2.4" @@ -247,6 +451,15 @@ dependencies = [ "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" @@ -303,6 +516,33 @@ dependencies = [ "stacker", ] +[[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 0.10.7", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "compact_str" version = "0.9.0" @@ -317,6 +557,12 @@ dependencies = [ "static_assertions", ] +[[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" @@ -351,6 +597,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crc24" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" + [[package]] name = "crc32fast" version = "1.5.0" @@ -368,7 +620,7 @@ checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags 2.11.0", "crossterm_winapi", - "derive_more", + "derive_more 2.1.1", "document-features", "mio", "parking_lot", @@ -387,6 +639,18 @@ dependencies = [ "winapi", ] +[[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" @@ -394,6 +658,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -440,14 +705,74 @@ dependencies = [ "syn 2.0.116", ] +[[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 0.10.7", + "fiat-crypto 0.2.9", + "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 2.0.116", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[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 2.0.116", ] [[package]] @@ -463,13 +788,24 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.116", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.116", ] @@ -480,12 +816,32 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + [[package]] name = "deltae" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[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 = "deranged" version = "0.5.6" @@ -495,13 +851,65 @@ dependencies = [ "powerfmt", ] +[[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 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[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 2.0.116", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] + [[package]] name = "derive_more" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "derive_more-impl", + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", + "unicode-xid", ] [[package]] @@ -517,6 +925,15 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "diff" version = "0.1.13" @@ -539,7 +956,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -562,6 +981,22 @@ dependencies = [ "litrs", ] +[[package]] +name = "dsa" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" +dependencies = [ + "digest 0.10.7", + "num-bigint-dig", + "num-traits", + "pkcs8", + "rfc6979", + "sha2 0.10.9", + "signature", + "zeroize", +] + [[package]] name = "dtoa" version = "1.0.11" @@ -577,12 +1012,95 @@ dependencies = [ "dtoa", ] +[[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 0.10.7", + "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", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "ed448-goldilocks" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87b5fa9e9e3dd5fe1369f380acd3dcdfa766dbd0a1cd5b048fb40e38a6a78e79" +dependencies = [ + "fiat-crypto 0.1.20", + "hex", + "subtle", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email-encoding" version = "0.4.1" @@ -608,6 +1126,29 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -664,6 +1205,28 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -804,6 +1367,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -862,12 +1426,33 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug 0.3.1", + "polyval", +] + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[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.14.5" @@ -910,6 +1495,15 @@ 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 0.12.1", +] + [[package]] name = "hmac" version = "0.8.1" @@ -920,6 +1514,15 @@ dependencies = [ "digest 0.9.0", ] +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "http" version = "1.4.0" @@ -1136,6 +1739,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[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" @@ -1209,13 +1821,22 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instability" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" dependencies = [ - "darling", + "darling 0.23.0", "indoc", "proc-macro2", "quote", @@ -1238,6 +1859,18 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "iter-read" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071ed4cc1afd86650602c7b11aa2e1ce30762a1c27193201cb5cee9c6ebb1294" + [[package]] name = "itertools" version = "0.14.0" @@ -1253,6 +1886,30 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jiff" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -1263,6 +1920,20 @@ dependencies = [ "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 0.10.9", + "signature", +] + [[package]] name = "kasuari" version = "0.4.11" @@ -1274,6 +1945,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + [[package]] name = "lab" version = "0.11.0" @@ -1285,6 +1965,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -1334,6 +2017,12 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "line-clipping" version = "0.3.5" @@ -1442,6 +2131,16 @@ dependencies = [ "opaque-debug 0.3.1", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1573,6 +2272,23 @@ dependencies = [ "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.5", + "serde", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -1599,6 +2315,17 @@ 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" @@ -1606,6 +2333,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.116", ] [[package]] @@ -1626,12 +2376,30 @@ dependencies = [ "memchr", ] +[[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.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "onig" version = "6.5.1" @@ -1719,6 +2487,44 @@ dependencies = [ "num-traits", ] +[[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 0.10.9", +] + +[[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 0.10.9", +] + +[[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 0.10.9", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1742,6 +2548,26 @@ dependencies = [ "windows-link", ] +[[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" @@ -1791,6 +2617,73 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "pgp" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1877a97fd422433220ad272eb008ec55691944b1200e9eb204e3cb2cb69d34e9" +dependencies = [ + "aes", + "aes-gcm", + "aes-kw", + "argon2", + "base64 0.22.1", + "bitfield", + "block-padding", + "blowfish 0.9.1", + "bstr", + "buffer-redux", + "byteorder", + "camellia", + "cast5", + "cfb-mode", + "chrono", + "cipher", + "const-oid", + "crc24", + "curve25519-dalek", + "derive_builder", + "derive_more 1.0.0", + "des", + "digest 0.10.7", + "dsa", + "eax", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "flate2", + "generic-array", + "hex", + "hkdf", + "idea", + "iter-read", + "k256", + "log", + "md-5 0.10.6", + "nom 7.1.3", + "num-bigint-dig", + "num-traits", + "num_enum", + "ocb3", + "p256", + "p384", + "p521", + "rand 0.8.5", + "ripemd", + "rsa", + "sha1", + "sha1-checked", + "sha2 0.10.9", + "sha3", + "signature", + "smallvec", + "thiserror 1.0.69", + "twofish", + "x25519-dalek", + "x448", + "zeroize", +] + [[package]] name = "phf" version = "0.11.3" @@ -1908,6 +2801,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[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.32" @@ -1927,12 +2841,33 @@ dependencies = [ "time", ] +[[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 0.3.1", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1983,6 +2918,15 @@ dependencies = [ "syn 2.0.116", ] +[[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.4.0" @@ -2005,13 +2949,19 @@ dependencies = [ name = "proton-bridge" version = "0.1.0" dependencies = [ + "aes", "base64 0.22.1", + "bcrypt", + "cfb-mode", + "env_logger", "num-bigint", + "pgp", "pwhash", "rand 0.8.5", "reqwest", "serde", "serde_json", + "sha1", "sha2 0.10.9", "tokio", "toml", @@ -2052,10 +3002,10 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1068eebd5d8aa4bbd6cbba05ff2647ad32f8ec86a3b73417b95522383c4bd18f" dependencies = [ - "blowfish", + "blowfish 0.5.0", "byteorder", - "hmac", - "md-5", + "hmac 0.8.1", + "md-5 0.9.1", "rand 0.7.3", "sha-1", "sha2 0.9.9", @@ -2327,6 +3277,45 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rstest" version = "0.26.1" @@ -2429,6 +3418,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[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", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.6.0" @@ -2460,7 +3463,7 @@ checksum = "feef350c36147532e1b79ea5c1f3791373e61cbd9a6a2615413b3807bb164fb7" dependencies = [ "bitflags 2.11.0", "cssparser", - "derive_more", + "derive_more 2.1.1", "log", "new_debug_unreachable", "phf 0.13.1", @@ -2563,6 +3566,28 @@ dependencies = [ "opaque-debug 0.3.1", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1", + "zeroize", +] + [[package]] name = "sha2" version = "0.9.9" @@ -2587,6 +3612,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2624,6 +3659,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -2664,6 +3709,22 @@ dependencies = [ "windows-sys 0.60.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" @@ -3157,6 +4218,15 @@ dependencies = [ "tui-markdown", ] +[[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.19.0" @@ -3210,6 +4280,16 @@ 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 = "url" version = "2.5.8" @@ -3840,6 +4920,29 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[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 = "x448" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cd07d4fae29e07089dbcacf7077cd52dce7760125ca9a4dd5a35ca603ffebb" +dependencies = [ + "ed448-goldilocks", + "hex", + "rand_core 0.5.1", +] + [[package]] name = "yaml-rust" version = "0.4.5" @@ -3924,6 +5027,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] [[package]] name = "zerotrie" diff --git a/proton-bridge/Cargo.toml b/proton-bridge/Cargo.toml index ead9cd1..4a10bba 100644 --- a/proton-bridge/Cargo.toml +++ b/proton-bridge/Cargo.toml @@ -13,4 +13,10 @@ sha2 = "0.10" num-bigint = "0.4" base64 = "0.22" rand = "0.8" -pwhash = "0.3" # bcrypt with caller-supplied salt +pwhash = "0.3" # bcrypt with caller-supplied salt (used for SRP) +bcrypt = "0.15" # reference bcrypt impl for key passphrase derivation +pgp = { version = "0.14", default-features = false } # rpgp — OpenPGP decrypt +env_logger = "0.11" +aes = "0.8" +cfb-mode = "0.8" +sha1 = "0.10" diff --git a/proton-bridge/src/api.rs b/proton-bridge/src/api.rs index 7e1bff5..f7c0a3f 100644 --- a/proton-bridge/src/api.rs +++ b/proton-bridge/src/api.rs @@ -128,6 +128,93 @@ struct KeysPayload { keys: Vec, } +// ── User private keys ───────────────────────────────────────────────────────── + +/// One of the user's encrypted private keys. +#[derive(Debug, Clone, Deserialize)] +pub struct UserKey { + #[serde(rename = "ID")] + pub id: String, + /// PGP-armored private key, encrypted with the user's key passphrase. + #[serde(rename = "PrivateKey")] + pub private_key: String, + /// 1 = primary key. + #[serde(rename = "Primary")] + pub primary: u32, + #[serde(rename = "Active")] + pub active: u32, +} + +/// Inner User object returned by GET /core/v4/users. +#[derive(Deserialize)] +struct UserObject { + #[serde(rename = "Keys")] + keys: Vec, +} + +#[derive(Deserialize)] +struct UserPayload { + #[serde(rename = "User")] + user: UserObject, +} + +// ── Key salts ───────────────────────────────────────────────────────────────── + +/// Per-key bcrypt salt used to derive the key-unlock passphrase. +#[derive(Debug, Clone, Deserialize)] +pub struct KeySalt { + #[serde(rename = "ID")] + pub id: String, + /// Base64-encoded 16-byte bcrypt salt. Null / absent for keys without a salt. + #[serde(rename = "KeySalt")] + pub key_salt: Option, +} + +#[derive(Deserialize)] +struct KeySaltsPayload { + #[serde(rename = "KeySalts")] + key_salts: Vec, +} + +// ── Address keys ───────────────────────────────────────────────────────────── + +/// One key attached to an email address. +/// In newer ProtonMail accounts the `token` field holds the address-key +/// passphrase encrypted with the user key — decrypt it first, then use the +/// plaintext token as the passphrase to unlock `private_key`. +/// In older accounts `token` is absent and the key shares the user-key passphrase. +#[derive(Debug, Clone, Deserialize)] +pub struct AddressKey { + #[serde(rename = "ID")] + pub id: String, + #[serde(rename = "PrivateKey")] + pub private_key: String, + /// Encrypted passphrase (new-format accounts). None in old-format accounts. + #[serde(rename = "Token")] + pub token: Option, + #[serde(rename = "Primary")] + pub primary: u32, + #[serde(rename = "Active")] + pub active: u32, +} + +/// One of the user's email addresses with its associated keys. +#[derive(Debug, Clone, Deserialize)] +pub struct Address { + #[serde(rename = "ID")] + pub id: String, + #[serde(rename = "Email")] + pub email: String, + #[serde(rename = "Keys")] + pub keys: Vec, +} + +#[derive(Deserialize)] +struct AddressesPayload { + #[serde(rename = "Addresses")] + addresses: Vec
, +} + // ── API client ──────────────────────────────────────────────────────────────── /// Stateless API client. Pass a freshly-validated `Session` to each call. @@ -249,4 +336,84 @@ impl<'a> ApiClient<'a> { } Ok(env.data.keys) } + + /// Fetch the user's encrypted private keys via GET /core/v4/users. + /// Keys are embedded in the returned User object. + /// Each `PrivateKey` field is PGP-armored and must be unlocked + /// with the passphrase derived by `crypto::derive_key_passphrase`. + pub async fn get_user_keys(&self) -> Result, String> { + let [h0, h1] = self.auth_headers(); + let env: Envelope = self + .client + .get(format!("{}/core/v4/users", API_BASE)) + .header(h0.0, h0.1) + .header(h1.0, h1.1) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())? + .json() + .await + .map_err(|e| e.to_string())?; + + if env.code != 1000 { + return Err(format!("get_user_keys: API code {}", env.code)); + } + Ok(env.data.user.keys) + } + + /// Fetch the per-key bcrypt salts used to derive key-unlock passphrases. + /// Fetch per-key bcrypt salts. Returns the raw response text on failure + /// so the caller can log it and fall back to an empty salt list. + pub async fn get_key_salts(&self) -> Result, String> { + let [h0, h1] = self.auth_headers(); + let resp = self + .client + .get(format!("{}/core/v4/keys/salts", API_BASE)) + .header(h0.0, h0.1) + .header(h1.0, h1.1) + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("get_key_salts {status}: {body}")); + } + + let body = resp.text().await.map_err(|e| e.to_string())?; + println!("key_salts raw: {body}"); + let env: Envelope = + serde_json::from_str(&body).map_err(|e| format!("key_salts parse: {e}"))?; + if env.code != 1000 { + return Err(format!("get_key_salts: API code {}", env.code)); + } + Ok(env.data.key_salts) + } + + /// Fetch the user's email addresses and their associated private keys. + /// Messages are encrypted to address keys, not user keys. + pub async fn get_addresses(&self) -> Result, String> { + let [h0, h1] = self.auth_headers(); + let env: Envelope = self + .client + .get(format!("{}/core/v4/addresses", API_BASE)) + .header(h0.0, h0.1) + .header(h1.0, h1.1) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())? + .json() + .await + .map_err(|e| e.to_string())?; + + if env.code != 1000 { + return Err(format!("get_addresses: API code {}", env.code)); + } + Ok(env.data.addresses) + } } diff --git a/proton-bridge/src/auth.rs b/proton-bridge/src/auth.rs index a1c97bf..deabdba 100644 --- a/proton-bridge/src/auth.rs +++ b/proton-bridge/src/auth.rs @@ -61,6 +61,10 @@ struct AuthResponse { /// Integer bitmask: 0 = none, 1 = TOTP, 2 = FIDO2. #[serde(rename = "TwoFactor")] two_factor: u32, + /// e.g. "full" or "locked". "locked" means key-sensitive endpoints + /// (like /core/v4/keys/salts) return 403 until we call the unlock endpoint. + #[serde(rename = "Scope", default)] + scope: String, } #[derive(Serialize)] @@ -102,6 +106,11 @@ pub struct Session { pub refresh_token: String, /// Unix timestamp at which the access token expires. pub expires_at: u64, + /// Space-separated OAuth scopes granted by the server. + /// The `locked` scope is required for /core/v4/keys/salts. + /// Refreshed tokens lose `locked`; only fresh SRP+TOTP logins grant it. + #[serde(default)] + pub scope: String, } impl Session { @@ -113,6 +122,11 @@ impl Session { now + REFRESH_MARGIN_SECS >= self.expires_at } + /// Returns true if this session has the `locked` scope needed for key salts. + pub fn has_locked_scope(&self) -> bool { + self.scope.split_whitespace().any(|s| s == "locked") + } + pub fn save(&self) -> Result<(), String> { let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; fs::write(SESSION_FILE, json).map_err(|e| e.to_string()) @@ -122,6 +136,10 @@ impl Session { let json = fs::read_to_string(SESSION_FILE).ok()?; serde_json::from_str(&json).ok() } + + pub fn delete() { + let _ = fs::remove_file(SESSION_FILE); + } } fn expires_at_from_now(expires_in: u64) -> u64 { @@ -148,21 +166,33 @@ pub fn build_client() -> Result { } /// Return a valid session, refreshing or re-logging in as needed. +/// +/// Requires the `locked` scope (needed for /core/v4/keys/salts). +/// Refreshed tokens lose that scope, so we always fall back to a full +/// SRP+TOTP login when it is absent. pub async fn authenticate(client: &Client, config: &ProtonConfig) -> Result { if let Some(session) = Session::load() { - if !session.is_expired() { + if !session.has_locked_scope() { + println!("Cached session missing 'locked' scope — re-authenticating…"); + } else if !session.is_expired() { println!("Using cached session for {}", config.username); return Ok(session); - } - println!("Access token expired, refreshing…"); - match refresh(client, &session).await { - Ok(refreshed) => { - refreshed.save()?; - println!("Session refreshed for {}", config.username); - return Ok(refreshed); - } - Err(e) => { - println!("Refresh failed ({}), falling back to full login", e); + } else { + println!("Access token expired, refreshing…"); + match refresh(client, &session).await { + Ok(refreshed) => { + // Refreshed tokens don't carry the locked scope; save the + // new tokens but only use this session if it has the scope. + if refreshed.has_locked_scope() { + refreshed.save()?; + println!("Session refreshed for {}", config.username); + return Ok(refreshed); + } + println!("Refreshed token lacks 'locked' scope — falling back to full login"); + } + Err(e) => { + println!("Refresh failed ({}), falling back to full login", e); + } } } } @@ -194,6 +224,7 @@ async fn refresh(client: &Client, session: &Session) -> Result access_token: resp.access_token, refresh_token: resp.refresh_token, expires_at: expires_at_from_now(resp.expires_in), + scope: String::new(), // refreshed tokens don't carry the locked scope }) } @@ -254,6 +285,7 @@ async fn login(client: &Client, config: &ProtonConfig) -> Result Result, + /// Override the derived user-key passphrase directly (for debugging). + /// Set this to the output of: + /// node -e "const b=require('bcryptjs'); console.log(b.hashSync(PASSWORD, '\$2y\$10\$' + KEY_SALT_B64))" + /// where KEY_SALT_B64 is the keySalt from /core/v4/keys/salts for your primary user key. + pub user_key_passphrase: Option, } #[derive(Debug, Deserialize, Clone)] diff --git a/proton-bridge/src/crypto.rs b/proton-bridge/src/crypto.rs new file mode 100644 index 0000000..3849736 --- /dev/null +++ b/proton-bridge/src/crypto.rs @@ -0,0 +1,408 @@ +/// Crypto layer (Step 4). +/// +/// Responsibilities: +/// 1. Derive the key-unlock passphrase from the user's password + per-key salt. +/// 2. Parse and hold the unlocked PGP private key. +/// 3. Decrypt message bodies (OpenPGP, asymmetric). +/// +/// Algorithm notes (from go-crypto / gopenpgp): +/// - Key passphrase = bcrypt($2y$, cost=10, keySalt, password). +/// Unlike SRP there is NO "proton" suffix on the salt and NO expandHash. +/// The full 60-char bcrypt output string IS the passphrase. +/// - Message bodies are standard OpenPGP public-key encrypted messages. +/// rpgp handles the PKESK → session key → literal data layers automatically. +use std::io::Cursor; + +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use pgp::types::PublicKeyTrait; +use pgp::{Deserializable, Message, SignedSecretKey}; + +use crate::srp::bcrypt_base64_encode; + +// ── Key passphrase derivation ───────────────────────────────────────────────── + +/// Derive the passphrase used to unlock a user private key. +/// +/// Matches ProtonMail's `srp.MailboxPassword` + `SaltForKey` exactly: +/// 1. Decode `key_salt_b64` (standard base64) → 16 raw bytes. +/// 2. bcrypt($2y$, cost=10, salt=raw_16_bytes, password+\0) → 60-char string. +/// 3. Return only the last 31 chars (the hash portion; strip `$2y$10$<22-salt>`). +/// +/// `key_salt_b64` is the base64-encoded 16-byte salt from `GET /core/v4/keys/salts`. +/// If the salt is empty (legacy keys without a salt), the raw password is returned. +pub fn derive_key_passphrase(password: &str, key_salt_b64: &str) -> Result { + if key_salt_b64.is_empty() { + return Ok(password.to_string()); + } + + let salt_bytes = B64.decode(key_salt_b64).map_err(|e| e.to_string())?; + let salt: [u8; 16] = salt_bytes + .try_into() + .map_err(|_| "key salt must be exactly 16 bytes".to_string())?; + + let full = bcrypt::hash_with_salt(password, 10, salt) + .map_err(|e| format!("bcrypt error: {e}"))? + .format_for_version(bcrypt::Version::TwoY); + + // ProtonMail uses only the last 31 chars — the hash portion after the + // 29-char prefix "$2y$10$<22-char-bcrypt-salt>". + // See go-proton-api SaltForKey: `saltedKeyPass[len(saltedKeyPass)-31:]` + Ok(full[full.len() - 31..].to_string()) +} + +/// Replicates the exact bcryptjs salt-decoding behaviour used by ProtonMail's web client. +/// +/// ProtonMail JS calls `bcryptjs.hashSync(password, "$2y$10$" + keySalt_b64)`. +/// bcryptjs decodes the 22 chars after `"$2y$10$"` using its own 64-char alphabet +/// (`./A-Za-z0-9`). Crucially, bcryptjs's `base64_decode` BREAKs out of its loop +/// when c1 or c2 of any 4-char group is invalid (64/255) — it does NOT skip/filter. +/// +/// Example: salt `/+FQehpJut/ngT45Nv/YhQ==` +/// c1='/' (valid=1), c2='+' (invalid=255) → BREAK after 0 bytes → 16-byte zero salt. +/// +/// Standard base64 decoding (what `derive_key_passphrase` does) can produce +/// completely different bytes for the same salt string, causing an AES-key mismatch. +pub fn derive_key_passphrase_js(password: &str, key_salt_b64: &str) -> Result { + if key_salt_b64.is_empty() { + return Ok(password.to_string()); + } + + // bcrypt alphabet — same index ordering bcryptjs uses in its decode table. + const ALPHA: &[u8] = b"./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let mut table = [255u8; 256]; + for (i, &c) in ALPHA.iter().enumerate() { + table[c as usize] = i as u8; + } + + // bcryptjs reads the first 22 chars after "$2y$10$", which are the first 22 + // chars of key_salt_b64 (standard base64 of 16 bytes = 22 data chars + "=="). + let chars: Vec = key_salt_b64.bytes().take(22).collect(); + + // Replicate bcryptjs base64_decode exactly: BREAK on any invalid char. + // Any byte not written stays 0 (bcryptjs initialises its salt array to zeroes). + let mut salt = [0u8; 16]; + let mut pos = 0usize; + let mut i = 0usize; + while pos < 16 && i + 1 < chars.len() { + let c1 = table[chars[i] as usize] as u32; + let c2 = table[chars[i + 1] as usize] as u32; + if c1 == 255 || c2 == 255 { break; } // bcryptjs breaks on invalid c1 or c2 + if pos < 16 { salt[pos] = ((c1 << 2) | ((c2 & 0x30) >> 4)) as u8; pos += 1; } + + if i + 2 >= chars.len() { break; } + let c3 = table[chars[i + 2] as usize] as u32; + if c3 == 255 { break; } // bcryptjs breaks on invalid c3 + if pos < 16 { salt[pos] = (((c2 & 0x0f) << 4) | ((c3 & 0x3c) >> 2)) as u8; pos += 1; } + + if i + 3 >= chars.len() { break; } + let c4 = table[chars[i + 3] as usize] as u32; + if c4 == 255 { break; } // bcryptjs breaks on invalid c4 + if pos < 16 { salt[pos] = (((c3 & 0x03) << 6) | c4) as u8; pos += 1; } + + i += 4; + } + + eprintln!(" [bcrypt-js] effective salt bytes: {}", + salt.iter().map(|b| format!("{b:02x}")).collect::()); + + let parts = bcrypt::hash_with_salt(password, 10, salt) + .map_err(|e| format!("bcrypt-js decode error: {e}"))?; + Ok(parts.format_for_version(bcrypt::Version::TwoY)) +} + +/// Same derivation using pwhash (kept for comparison / fallback). +pub fn derive_key_passphrase_pwhash(password: &str, key_salt_b64: &str) -> Result { + if key_salt_b64.is_empty() { + return Ok(password.to_string()); + } + + let salt_bytes = B64.decode(key_salt_b64).map_err(|e| e.to_string())?; + let encoded_salt = bcrypt_base64_encode(&salt_bytes); + + let hashed = pwhash::bcrypt::hash_with( + pwhash::bcrypt::BcryptSetup { + salt: Some(&encoded_salt), + cost: Some(10), + variant: Some(pwhash::bcrypt::BcryptVariant::V2y), + }, + password, + ) + .map_err(|e| format!("bcrypt error (pwhash): {e}"))?; + + Ok(hashed.to_string()) +} + +// ── Private key management ──────────────────────────────────────────────────── + +/// A parsed, ready-to-use private key. +pub struct PrivateKey { + inner: SignedSecretKey, + passphrase: String, +} + +impl PrivateKey { + /// Parse the PGP-armored private key and verify its structure. + /// The `passphrase` is kept to supply during decryption (rpgp unlocks + /// subkeys on-the-fly when decrypting each message). + pub fn unlock(armored: &str, passphrase: &str) -> Result { + let (key, _headers) = + SignedSecretKey::from_armor_single(Cursor::new(armored.as_bytes())) + .map_err(|e| format!("parse private key: {e}"))?; + + key.verify().map_err(|e| format!("invalid private key: {e}"))?; + + Ok(Self { + inner: key, + passphrase: passphrase.to_string(), + }) + } + + /// Return primary key ID + all subkey IDs as hex strings. + pub fn key_ids(&self) -> Vec { + let mut ids = vec![format!("{:?}", self.inner.key_id())]; + for sk in &self.inner.secret_subkeys { + ids.push(format!("{:?}", sk.key_id())); + } + ids + } + + pub fn passphrase_suffix(&self) -> String { + self.passphrase.chars().rev().take(8).collect::() + .chars().rev().collect() + } +} + +/// Print the key IDs of every key in the pool for diagnostics. +pub fn dump_key_pool(pool: &[PrivateKey]) { + for (i, k) in pool.iter().enumerate() { + eprintln!(" [pool] key[{i}]: {:?} (pp: ...{})", + k.key_ids(), k.passphrase_suffix()); + } +} + +/// Print the PKESK recipient key IDs of a PGP-armored message. +pub fn dump_message_pkesk(armored: &str) { + let Ok((msg, _)) = Message::from_armor_single(Cursor::new(armored.as_bytes())) else { + eprintln!(" [body-diag] failed to parse message armor"); + return; + }; + if let pgp::Message::Encrypted { esk, .. } = &msg { + for e in esk { + if let pgp::composed::message::Esk::PublicKeyEncryptedSessionKey(pkesk) = e { + let kid = pkesk.id().map(|k| format!("{k:?}")).unwrap_or("(anon)".into()); + let ver = pkesk.version(); + eprintln!(" [body-diag] PKESK version={ver:?} recipient={kid}"); + } + } + } else { + eprintln!(" [body-diag] message is not Encrypted variant"); + } +} + +// ── Message decryption ──────────────────────────────────────────────────────── + +/// Decrypt an address key's `Token` field using the user's private key. +/// +/// In newer ProtonMail accounts, each address key's passphrase is stored as a +/// PGP-encrypted token (encrypted to the user key). Decrypting it gives the +/// raw passphrase bytes used to unlock the address key. +pub fn decrypt_token(encrypted_token: &str, user_key: &PrivateKey) -> Result { + let (msg, _) = Message::from_armor_single(Cursor::new(encrypted_token.as_bytes())) + .map_err(|e| format!("parse token: {e}"))?; + + // Diagnostic: print key IDs, algorithms, and S2K params. + eprintln!(" [token diag] user primary key ID: {:?} algo={:?} sha1_cksum={}", + user_key.inner.key_id(), user_key.inner.algorithm(), + user_key.inner.primary_key.has_sha1_checksum()); + for sk in &user_key.inner.secret_subkeys { + eprintln!(" [token diag] user subkey ID: {:?} algo={:?} sha1_cksum={} s2k={:?}", + sk.key_id(), sk.algorithm(), + sk.key.has_sha1_checksum(), + sk.key.secret_params()); + } + if let Message::Encrypted { esk, .. } = &msg { + for e in esk { + if let pgp::composed::message::Esk::PublicKeyEncryptedSessionKey(pkesk) = e { + let kid = pkesk.id().map(|k| format!("{k:?}")).unwrap_or("(anon)".into()); + let algo = pkesk.algorithm().map(|a| format!("{a:?}")).unwrap_or("?".into()); + let ver = pkesk.version(); + eprintln!(" [token diag] PKESK version={ver:?} algo={algo} recipient={kid}"); + } + } + } + + let passphrase = user_key.passphrase.clone(); + let (decrypted, _) = msg + .decrypt(|| passphrase, &[&user_key.inner]) + .map_err(|e| format!("decrypt token: {e}"))?; + + let bytes = decrypted + .get_content() + .map_err(|e| format!("get token content: {e}"))? + .ok_or_else(|| "empty token".to_string())?; + + String::from_utf8(bytes).map_err(|e| format!("token utf8: {e}")) +} + +// ── Passphrase diagnostic ───────────────────────────────────────────────────── + +/// Independently test passphrase candidates against every ECDH subkey's encrypted material. +/// +/// Uses rpgp's own S2K `derive_key` (so we trust that part), then runs AES-256-CFB decrypt +/// and SHA-1 checksum verification ourselves — bypassing rpgp's unlock path — so that we +/// can isolate whether the problem is the passphrase or something inside rpgp. +/// +/// Prints a result line for every (subkey × candidate) combination. +/// Also runs a manual S2K to detect if rpgp's derive_key uses the raw count byte or decoded count. +pub fn diagnose_subkey_passphrase(key: &PrivateKey, candidates: &[(&str, &str)]) { + use pgp::types::{S2kParams, SecretParams, StringToKey}; + use cfb_mode::cipher::{AsyncStreamCipher, KeyIvInit}; + use cfb_mode::Decryptor; + use aes::Aes256; + use sha1::Sha1; + use sha2::Sha256; + + // Manual SHA-256-based OpenPGP S2K (RFC 4880 §3.7.1.3). + // Returns 32 bytes; caller slices to key_size. + fn manual_s2k_sha256(passphrase: &str, s2k_salt: &[u8; 8], count: u64) -> [u8; 32] { + use sha2::Digest; + let body: Vec = s2k_salt.iter().chain(passphrase.as_bytes()).copied().collect(); + let mut hasher = Sha256::new(); + let mut total = 0u64; + while total < count { + let rem = (count - total) as usize; + if rem >= body.len() { + hasher.update(&body); + total += body.len() as u64; + } else { + hasher.update(&body[..rem]); + total = count; + } + } + hasher.finalize().into() + } + + fn check_sha1(enc_data: &[u8], aes_key: &[u8], iv: &[u8]) -> (bool, String) { + use sha1::Digest; + let mut pt = enc_data.to_vec(); + let Ok(d) = Decryptor::::new_from_slices(aes_key, iv) else { + return (false, "cfb-err".into()); + }; + d.decrypt(&mut pt); + let preview: String = pt.iter().take(6).map(|b| format!("{b:02x}")).collect(); + if pt.len() < 20 { return (false, preview); } + let split = pt.len() - 20; + let actual: [u8; 20] = Sha1::digest(&pt[..split]).into(); + let ok = actual.as_slice() == &pt[split..]; + (ok, preview) + } + + for sk in &key.inner.secret_subkeys { + eprintln!(" [diag] subkey {:?}", sk.key_id()); + match sk.key.secret_params() { + SecretParams::Plain(_) => eprintln!(" [diag] subkey is unencrypted"), + SecretParams::Encrypted(enc) => { + let enc_data = enc.data(); + let s2k_params = enc.string_to_key_params(); + + if let S2kParams::Cfb { sym_alg, s2k, iv } = s2k_params { + let key_size = sym_alg.key_size(); + eprintln!( + " [diag] enc_data ({} bytes): {}", + enc_data.len(), + enc_data.iter().map(|b| format!("{b:02x}")).collect::() + ); + eprintln!( + " [diag] iv: {}", + iv.iter().map(|b| format!("{b:02x}")).collect::() + ); + + // Extract raw S2K params for manual comparison. + let raw_params: Option<([u8; 8], u8)> = + if let StringToKey::IteratedAndSalted { salt, count, .. } = s2k { + Some((*salt, *count)) + } else { None }; + + for (label, passphrase) in candidates { + // Path A: rpgp's derive_key + let (rpgp_ok, rpgp_preview, rpgp_key4) = match s2k.derive_key(passphrase, key_size) { + Ok(aes_key) => { + let k4: String = aes_key.iter().take(4) + .map(|b| format!("{b:02x}")).collect(); + let (ok, preview) = check_sha1(enc_data, &aes_key, iv); + (ok, preview, k4) + } + Err(e) => { + eprintln!(" [diag] {label} → S2K error: {e}"); + continue; + } + }; + + // Path B+C: manual S2K with decoded count (65536) and raw count (96) + if let Some((s2k_salt, count_byte)) = raw_params { + let decoded_count = (16u64 + (count_byte as u64 & 15)) + << ((count_byte as u64 >> 4) + 6); + let raw_count = count_byte as u64; + + let key_dec = manual_s2k_sha256(passphrase, &s2k_salt, decoded_count); + let key_raw_c = manual_s2k_sha256(passphrase, &s2k_salt, raw_count); + let k4_dec: String = key_dec.iter().take(4) + .map(|b| format!("{b:02x}")).collect(); + let k4_raw: String = key_raw_c.iter().take(4) + .map(|b| format!("{b:02x}")).collect(); + let (ok_dec, _) = check_sha1(enc_data, &key_dec[..key_size], iv); + let (ok_raw, _) = check_sha1(enc_data, &key_raw_c[..key_size], iv); + + let keys_agree = rpgp_key4 == k4_dec; + let sha1_str = if rpgp_ok { "OK ✓" } else { "FAIL" }; + eprintln!( + " [diag] {label} → SHA-1 {sha1_str} (pt:{rpgp_preview}) \ + rpgp={rpgp_key4} man65k={k4_dec}(ok:{ok_dec}) \ + man96={k4_raw}(ok:{ok_raw}) agree:{keys_agree}" + ); + if rpgp_ok || ok_dec || ok_raw { + eprintln!(" [diag] *** CORRECT PASSPHRASE: {label} ***"); + } + } else { + let sha1_str = if rpgp_ok { "OK ✓" } else { "FAIL" }; + eprintln!(" [diag] {label} → SHA-1 {sha1_str} (pt:{rpgp_preview})"); + if rpgp_ok { eprintln!(" [diag] *** CORRECT PASSPHRASE: {label} ***"); } + } + } + } else { + eprintln!(" [diag] non-CFB S2K params: {s2k_params:?}"); + } + } + } + } +} + +/// Decrypt a ProtonMail message body. +/// +/// `encrypted_body` is the PGP-armored ciphertext from `Message.body`. +/// `keys` is every candidate private key (user key + all address keys). +/// rpgp picks the one whose key ID matches the PKESK packet in the message. +/// Returns the plaintext (HTML or plain text; consult `Message.mime_type`). +pub fn decrypt_body(encrypted_body: &str, keys: &[&PrivateKey]) -> Result { + let (msg, _headers) = + Message::from_armor_single(Cursor::new(encrypted_body.as_bytes())) + .map_err(|e| format!("parse PGP message: {e}"))?; + + // Try each key until one succeeds (the right one matches the PKESK key ID). + let mut last_err = String::from("no keys provided"); + for key in keys { + let passphrase = key.passphrase.clone(); + match msg.decrypt(|| passphrase, &[&key.inner]) { + Ok((decrypted, _)) => { + let bytes = decrypted + .get_content() + .map_err(|e| format!("get content: {e}"))? + .ok_or_else(|| "decrypted message has no content".to_string())?; + return String::from_utf8(bytes).map_err(|e| format!("utf8 decode: {e}")); + } + Err(e) => last_err = e.to_string(), + } + } + Err(format!("decrypt: {last_err}")) +} \ No newline at end of file diff --git a/proton-bridge/src/main.rs b/proton-bridge/src/main.rs index 9f414b6..6f63241 100644 --- a/proton-bridge/src/main.rs +++ b/proton-bridge/src/main.rs @@ -1,12 +1,14 @@ mod api; mod auth; mod config; +mod crypto; mod srp; use api::{ApiClient, LABEL_INBOX}; #[tokio::main] async fn main() { + env_logger::init(); let config = match config::Config::load() { Ok(c) => c, Err(e) => { eprintln!("Failed to load bridge.toml: {}", e); std::process::exit(1); } @@ -23,20 +25,184 @@ async fn main() { }; println!("Session UID: {}", session.uid); - // Step 3 smoke-test: list the first page of inbox messages. let api = ApiClient::new(&client, &session); - match api.list_messages(LABEL_INBOX, 0, 10).await { - Ok((messages, total)) => { - println!("\nInbox ({total} total):"); - for m in &messages { - println!( - " [{:>5}] {:50} — {}", - if m.unread == 1 { "UNREAD" } else { " read" }, - m.subject, - m.sender.address, - ); + + // ── Step 3: list inbox ──────────────────────────────────────────────────── + let (messages, total) = match api.list_messages(LABEL_INBOX, 0, 10).await { + Ok(r) => r, + Err(e) => { eprintln!("list_messages failed: {}", e); std::process::exit(1); } + }; + println!("\nInbox ({total} total):"); + for m in &messages { + println!( + " [{}] {:50} — {}", + if m.unread == 1 { "UNREAD" } else { " read" }, + m.subject, + m.sender.address, + ); + } + + // ── Step 4: decrypt first message body ─────────────────────────────────── + let first = match messages.first() { + Some(m) => m, + None => { println!("\nInbox is empty — nothing to decrypt."); return; } + }; + + // Fetch user keys, address keys, and bcrypt salts in parallel. + let (user_keys_res, addresses_res, key_salts_res) = tokio::join!( + api.get_user_keys(), + api.get_addresses(), + api.get_key_salts(), + ); + + let user_keys = user_keys_res.unwrap_or_else(|e| { eprintln!("get_user_keys: {e}"); vec![] }); + let addresses = addresses_res.unwrap_or_else(|e| { eprintln!("get_addresses: {e}"); vec![] }); + + // key_salts requires the 'locked' OAuth scope. The server may revoke it even + // when our cached session still claims to have it. If we get 403, purge the + // session so the next run does a full SRP+TOTP login that re-grants 'locked'. + let key_salts = match key_salts_res { + Ok(salts) => salts, + Err(e) => { + eprintln!("get_key_salts: {e}"); + if e.contains("403") { + auth::Session::delete(); + eprintln!("Session 'locked' scope expired server-side — session.json deleted."); + eprintln!("Re-run; you will be prompted for your TOTP code to regain 'locked' scope."); + std::process::exit(1); + } + vec![] + } + }; + + // In two-password mode the mailbox password unlocks the keys. + let password = config.proton.mailbox_password + .as_deref() + .unwrap_or(&config.proton.password); + + // Derive passphrase for a key ID via bcrypt(password, keySalt). + // Falls back to raw password when salt is absent. + let passphrase_for = |key_id: &str| -> String { + let salt = key_salts.iter() + .find(|s| s.id == key_id) + .and_then(|s| s.key_salt.as_deref()) + .unwrap_or(""); + crypto::derive_key_passphrase(password, salt) + .unwrap_or_else(|_| password.to_string()) + }; + + // Phase 1: unlock user keys — try multiple passphrase candidates so we can + // identify which derivation is correct. + let mut user_private_keys: Vec<(String, crypto::PrivateKey)> = Vec::new(); + for uk in &user_keys { + if uk.active == 1 { + let salt = key_salts.iter() + .find(|s| s.id == uk.id) + .and_then(|s| s.key_salt.as_deref()) + .unwrap_or(""); + + // Derive key passphrase per ProtonMail's go-proton-api SaltForKey: + // bcrypt($2y$, cost=10, raw_salt_16_bytes, password+\0)[last 31 chars] + eprintln!(" [key-salt] raw API value: {:?} (len={})", salt, salt.len()); + + let pp = crypto::derive_key_passphrase(password, salt) + .unwrap_or_else(|_| password.to_string()); + eprintln!(" [pp] len={} last8=...{}", pp.len(), + pp.chars().rev().take(8).collect::().chars().rev().collect::()); + + let mut candidates: Vec<(&str, String)> = vec![ + ("bcrypt-31", pp), + ("raw-password", password.to_string()), + ]; + + // Manual override — set user_key_passphrase in bridge.toml to bypass derivation. + if let Some(ref override_pp) = config.proton.user_key_passphrase { + eprintln!(" [override] using user_key_passphrase from config"); + candidates.push(("config-override", override_pp.clone())); + } + + // Use the first candidate to store the parsed key structure. + // (unlock() only parses the key; actual passphrase is tested during decrypt.) + let first_pp = candidates[0].1.clone(); + match crypto::PrivateKey::unlock(&uk.private_key, &first_pp) { + Ok(k) => { + println!("user key {} parsed OK", &uk.id[..8]); + // Run the independent S2K+CFB+SHA1 diagnostic for all candidates. + let diag_candidates: Vec<(&str, &str)> = candidates + .iter() + .map(|(l, p)| (*l, p.as_str())) + .collect(); + crypto::diagnose_subkey_passphrase(&k, &diag_candidates); + // Store all candidate passphrases so token decrypt can try each. + for (label, pp) in &candidates { + match crypto::PrivateKey::unlock(&uk.private_key, pp) { + Ok(k2) => user_private_keys.push((label.to_string(), k2)), + Err(e) => eprintln!("user key {} [{}] FAILED: {}", &uk.id[..8], label, e), + } + } + } + Err(e) => eprintln!("user key {} parse FAILED: {}", &uk.id[..8], e), } } - Err(e) => eprintln!("list_messages failed: {}", e), + } + + // Phase 2: unlock address keys. + // Newer accounts: the address key's passphrase is stored as a PGP-encrypted + // Token (encrypted to the user key). Decrypt the token, then unlock. + // Older accounts: address keys share the same bcrypt passphrase as user keys. + let mut key_pool: Vec = Vec::new(); + for addr in &addresses { + for ak in &addr.keys { + if ak.active == 1 { + let pp = if let Some(token) = &ak.token { + let mut token_pp = None; + for (label, uk) in &user_private_keys { + match crypto::decrypt_token(token, uk) { + Ok(p) => { + println!("token decrypted using [{}] passphrase!", label); + token_pp = Some(p); + break; + } + Err(e) => eprintln!("decrypt_token [{}] ({}): {e}", label, addr.email), + } + } + token_pp.unwrap_or_else(|| passphrase_for(&ak.id)) + } else { + passphrase_for(&ak.id) + }; + match crypto::PrivateKey::unlock(&ak.private_key, &pp) { + Ok(k) => { + println!("address key {} ({}) unlocked", &ak.id[..8], addr.email); + key_pool.push(k); + } + Err(e) => eprintln!("address key {} ({}): {e}", &ak.id[..8], addr.email), + } + } + } + } + // Include user keys as fallback (messages may be encrypted to the user key). + key_pool.extend(user_private_keys.into_iter().map(|(_, k)| k)); + println!("\n{} key(s) in pool ({} address(es))", key_pool.len(), addresses.len()); + + // Fetch and decrypt the first message. + let full_msg = match api.get_message(&first.id).await { + Ok(m) => m, + Err(e) => { eprintln!("get_message failed: {}", e); std::process::exit(1); } + }; + + crypto::dump_key_pool(&key_pool); + crypto::dump_message_pkesk(&full_msg.body); + + let key_refs: Vec<&crypto::PrivateKey> = key_pool.iter().collect(); + match crypto::decrypt_body(&full_msg.body, &key_refs) { + Ok(plaintext) => { + println!("\n── Decrypted body ({}) ──────────────────", full_msg.mime_type); + let preview: String = plaintext.chars().take(2000).collect(); + println!("{preview}"); + if plaintext.len() > 2000 { + println!("… ({} chars total)", plaintext.len()); + } + } + Err(e) => eprintln!("decrypt_body failed: {}", e), } } \ No newline at end of file diff --git a/proton-bridge/src/srp.rs b/proton-bridge/src/srp.rs index 98c02dd..4cabce8 100644 --- a/proton-bridge/src/srp.rs +++ b/proton-bridge/src/srp.rs @@ -177,7 +177,7 @@ fn hash_password( } /// Encode bytes using bcrypt's own base64 alphabet. -fn bcrypt_base64_encode(input: &[u8]) -> String { +pub(crate) fn bcrypt_base64_encode(input: &[u8]) -> String { const ALPHABET: &[u8] = b"./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let mut out = String::new(); let mut i = 0;