Add proton-bridge crate: workspace setup and SRP authentication (Step 2)
- Convert tuimail repo to Cargo workspace with tuimail and proton-bridge members - Add proton-bridge binary crate with config, SRP 6a, and auth modules - Implement ProtonMail SRP 6a exactly matching go-srp: - Little-endian bigints throughout - expandHash = SHA512(data||0..3) producing 256 bytes - k, u, M1, M2 all via expandHash with 256-byte normalised inputs - Password hashing v3/v4: bcrypt($2y$, salt+proton) + expandHash(output||N) - Authenticate against Proton API (auth/info → auth/v4), verify server proof - Persist session (UID, access/refresh tokens) to session.json - Add bridge.toml and session.json to .gitignore (contain credentials/tokens) - Add PROTON.md with full build plan for the mini-bridge Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fba2623f15
commit
05b6aac692
10 changed files with 1186 additions and 11 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,3 +2,5 @@
|
|||
docker-data/
|
||||
config.toml
|
||||
.idea/
|
||||
bridge.toml
|
||||
session.json
|
||||
599
Cargo.lock
generated
599
Cargo.lock
generated
|
|
@ -87,6 +87,12 @@ dependencies = [
|
|||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "auto_encoder"
|
||||
version = "0.1.10"
|
||||
|
|
@ -154,6 +160,15 @@ version = "2.11.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
|
|
@ -163,6 +178,26 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-cipher"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa136449e765dc7faa244561ccae839c394048667929af599b5d931ebe7b7f10"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blowfish"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91d01392750dd899a2528948d6b856afe2df508d627fc7c339868c0bd0141b4b"
|
||||
dependencies = [
|
||||
"block-cipher",
|
||||
"byteorder",
|
||||
"opaque-debug 0.2.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bufstream"
|
||||
version = "0.1.4"
|
||||
|
|
@ -181,6 +216,18 @@ version = "1.25.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
|
|
@ -350,6 +397,16 @@ dependencies = [
|
|||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-mac"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csscolorparser"
|
||||
version = "0.6.2"
|
||||
|
|
@ -466,13 +523,22 @@ version = "0.1.13"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
|
|
@ -679,6 +745,15 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
|
|
@ -740,6 +815,28 @@ dependencies = [
|
|||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi 0.9.0+wasi-snapshot-preview1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
|
|
@ -813,12 +910,121 @@ version = "0.4.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "126888268dcc288495a26bf004b38c5fdbb31682f992c84ceb046a1f0fe38840"
|
||||
dependencies = [
|
||||
"crypto-mac",
|
||||
"digest 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
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 = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"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"
|
||||
|
|
@ -1016,6 +1222,22 @@ dependencies = [
|
|||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
|
|
@ -1209,6 +1431,17 @@ dependencies = [
|
|||
"quoted_printable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5a279bb9607f9f53c22d496eade00d138d1bdcccd07d74650387cf94942a15"
|
||||
dependencies = [
|
||||
"block-buffer 0.9.0",
|
||||
"digest 0.9.0",
|
||||
"opaque-debug 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
|
|
@ -1260,7 +1493,7 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
|||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
|
|
@ -1330,6 +1563,16 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[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-conv"
|
||||
version = "0.2.0"
|
||||
|
|
@ -1347,6 +1590,15 @@ dependencies = [
|
|||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[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-traits"
|
||||
version = "0.2.19"
|
||||
|
|
@ -1402,6 +1654,18 @@ dependencies = [
|
|||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
|
|
@ -1524,7 +1788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1575,7 +1839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1638,6 +1902,12 @@ version = "0.2.16"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
|
|
@ -1678,6 +1948,15 @@ version = "0.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
|
|
@ -1722,6 +2001,22 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proton-bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"num-bigint",
|
||||
"pwhash",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tokio",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.30"
|
||||
|
|
@ -1751,6 +2046,21 @@ version = "0.11.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "pwhash"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1068eebd5d8aa4bbd6cbba05ff2647ad32f8ec86a3b73417b95522383c4bd18f"
|
||||
dependencies = [
|
||||
"blowfish",
|
||||
"byteorder",
|
||||
"hmac",
|
||||
"md-5",
|
||||
"rand 0.7.3",
|
||||
"sha-1",
|
||||
"sha2 0.9.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
|
|
@ -1781,13 +2091,57 @@ version = "5.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
|
||||
dependencies = [
|
||||
"getrandom 0.1.16",
|
||||
"libc",
|
||||
"rand_chacha 0.2.2",
|
||||
"rand_core 0.5.1",
|
||||
"rand_hc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[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_core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
|
||||
dependencies = [
|
||||
"getrandom 0.1.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1795,6 +2149,18 @@ 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_hc"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
|
||||
dependencies = [
|
||||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
|
|
@ -1925,6 +2291,42 @@ version = "1.9.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rstest"
|
||||
version = "0.26.1"
|
||||
|
|
@ -1982,6 +2384,15 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
|
|
@ -2118,6 +2529,18 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[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 = "servo_arc"
|
||||
version = "0.4.3"
|
||||
|
|
@ -2127,6 +2550,32 @@ dependencies = [
|
|||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha-1"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6"
|
||||
dependencies = [
|
||||
"block-buffer 0.9.0",
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest 0.9.0",
|
||||
"opaque-debug 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800"
|
||||
dependencies = [
|
||||
"block-buffer 0.9.0",
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest 0.9.0",
|
||||
"opaque-debug 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
|
|
@ -2135,7 +2584,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
|||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2267,6 +2716,12 @@ dependencies = [
|
|||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
|
|
@ -2289,6 +2744,15 @@ dependencies = [
|
|||
"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"
|
||||
|
|
@ -2380,7 +2844,7 @@ dependencies = [
|
|||
"pest",
|
||||
"pest_derive",
|
||||
"phf 0.11.3",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"signal-hook",
|
||||
"siphasher",
|
||||
"terminfo",
|
||||
|
|
@ -2486,13 +2950,36 @@ version = "1.49.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.0.2+spec-1.1.0"
|
||||
|
|
@ -2553,6 +3040,51 @@ version = "1.0.6+spec-1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
|
||||
|
||||
[[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.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[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"
|
||||
|
|
@ -2584,6 +3116,12 @@ 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 = "tui-markdown"
|
||||
version = "0.3.7"
|
||||
|
|
@ -2739,6 +3277,21 @@ dependencies = [
|
|||
"winapi-util",
|
||||
]
|
||||
|
||||
[[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.9.0+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
|
|
@ -2776,6 +3329,20 @@ dependencies = [
|
|||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.108"
|
||||
|
|
@ -2842,6 +3409,16 @@ dependencies = [
|
|||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.85"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wezterm-bidi"
|
||||
version = "0.2.3"
|
||||
|
|
@ -2860,7 +3437,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7"
|
|||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"mac_address",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 1.0.69",
|
||||
"uuid",
|
||||
]
|
||||
|
|
@ -3342,6 +3919,12 @@ dependencies = [
|
|||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.3"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
[workspace]
|
||||
members = [".", "proton-bridge"]
|
||||
|
||||
[package]
|
||||
name = "tuimail"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
78
PROTON.md
Normal file
78
PROTON.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# ProtonMail Mini-Bridge
|
||||
|
||||
A minimal ProtonMail Bridge implementation in Rust that exposes local IMAP and SMTP servers,
|
||||
allowing `skim` (and any other standard email client) to connect without code changes.
|
||||
|
||||
The full ProtonMail Bridge is ~50,000 lines of Go. This mini-bridge targets only the subset
|
||||
of functionality that `skim` needs: one account, INBOX only, one concurrent client.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Project scaffold
|
||||
New binary crate with `Cargo.toml` dependencies (`tokio`, `reqwest`, `proton-srp`, `rpgp`,
|
||||
`serde`, `toml`). Config file format covering ProtonMail credentials and local bind ports.
|
||||
|
||||
### 2. ProtonMail authentication
|
||||
SRP 6a login flow against the Proton API:
|
||||
- POST `/auth/info` with username → receive modulus, server ephemeral, salt
|
||||
- Compute SRP proof locally using `proton-srp`
|
||||
- POST `/auth/login` with client proof → receive access token + encrypted private keys
|
||||
- Handle optional TOTP 2FA interactively on first run
|
||||
- Persist session (access token + refresh token) to disk to avoid re-authenticating on restart
|
||||
|
||||
### 3. ProtonMail API client
|
||||
Thin `reqwest`-based HTTP wrapper around the endpoints the bridge needs:
|
||||
- List messages (with pagination)
|
||||
- Fetch single message (metadata + encrypted body)
|
||||
- Delete message
|
||||
- Fetch recipient public key (for outbound encryption)
|
||||
- Send message (modelled from the open-source `ProtonMail/proton-bridge` Go implementation)
|
||||
|
||||
### 4. Crypto layer
|
||||
Using `rpgp` or `proton-crypto-rs`:
|
||||
- Decrypt the user's private key (delivered encrypted by the API, unlocked with the mailbox password)
|
||||
- Decrypt incoming message bodies with the private key
|
||||
- Encrypt outbound messages to recipient public keys
|
||||
|
||||
### 5. Message store
|
||||
In-memory (optionally persisted) mapping between IMAP sequence numbers and Proton message IDs.
|
||||
IMAP uses stable sequential integers; Proton uses opaque string IDs. The store must:
|
||||
- Assign sequence numbers to messages in order
|
||||
- Renumber correctly after deletes
|
||||
- Survive restarts without breaking existing client state
|
||||
|
||||
### 6. IMAP server
|
||||
TCP listener on `localhost:143` (configurable). Implements the nine commands `skim` uses:
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `LOGIN` | Accept local credentials (no real auth needed) |
|
||||
| `NOOP` | Keepalive / connection check |
|
||||
| `SELECT INBOX` | Open mailbox, report message count |
|
||||
| `FETCH range BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)]` | List emails |
|
||||
| `FETCH seq BODY.PEEK[]` | Fetch full message body |
|
||||
| `SEARCH OR SUBJECT "..." FROM "..."` | Search by subject or sender |
|
||||
| `STORE seq +FLAGS (\Deleted)` | Mark for deletion |
|
||||
| `EXPUNGE` | Delete marked messages |
|
||||
| `LOGOUT` | Disconnect |
|
||||
|
||||
Each command translates to API client + crypto layer calls via the message store.
|
||||
|
||||
### 7. SMTP server
|
||||
TCP listener on `localhost:587` (configurable). Minimal implementation:
|
||||
- EHLO, AUTH, MAIL FROM, RCPT TO, DATA, QUIT
|
||||
- On DATA completion: hand the message to the crypto layer to encrypt, then POST via API client
|
||||
|
||||
## Build order
|
||||
|
||||
Components 2 → 3 → 4 can be built and tested with a simple CLI harness before any
|
||||
network server exists. Component 5 is pure logic with no I/O. Components 6 and 7 are
|
||||
the final pieces and can be validated by pointing `skim` at localhost.
|
||||
|
||||
## References
|
||||
|
||||
- [ProtonMail/proton-bridge](https://github.com/ProtonMail/proton-bridge) — official Bridge (Go, open source)
|
||||
- [ProtonMail/go-proton-api](https://github.com/ProtonMail/go-proton-api) — official Go API client
|
||||
- [ProtonMail/proton-crypto-rs](https://github.com/ProtonMail/proton-crypto-rs) — official Rust crypto crates
|
||||
- [ProtonMail/proton-srp](https://github.com/ProtonMail/proton-srp) — official Rust SRP implementation
|
||||
- [rpgp](https://github.com/rpgp/rpgp) — pure Rust OpenPGP implementation
|
||||
16
proton-bridge/Cargo.toml
Normal file
16
proton-bridge/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "proton-bridge"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml = "1.0"
|
||||
sha2 = "0.10"
|
||||
num-bigint = "0.4"
|
||||
base64 = "0.22"
|
||||
rand = "0.8"
|
||||
pwhash = "0.3" # bcrypt with caller-supplied salt
|
||||
234
proton-bridge/src/auth.rs
Normal file
234
proton-bridge/src/auth.rs
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
|
||||
use crate::config::ProtonConfig;
|
||||
use crate::srp;
|
||||
|
||||
const API_BASE: &str = "https://mail.proton.me/api";
|
||||
const SESSION_FILE: &str = "session.json";
|
||||
|
||||
// ── API types ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AuthInfoRequest<'a> {
|
||||
#[serde(rename = "Username")]
|
||||
username: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthInfoResponse {
|
||||
#[serde(rename = "Modulus")]
|
||||
modulus: String,
|
||||
#[serde(rename = "ServerEphemeral")]
|
||||
server_ephemeral: String,
|
||||
#[serde(rename = "Version")]
|
||||
version: u32,
|
||||
#[serde(rename = "Salt")]
|
||||
salt: String,
|
||||
#[serde(rename = "SRPSession")]
|
||||
srp_session: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AuthRequest<'a> {
|
||||
#[serde(rename = "Username")]
|
||||
username: &'a str,
|
||||
#[serde(rename = "ClientEphemeral")]
|
||||
client_ephemeral: &'a str,
|
||||
#[serde(rename = "ClientProof")]
|
||||
client_proof: &'a str,
|
||||
#[serde(rename = "SRPSession")]
|
||||
srp_session: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthResponse {
|
||||
#[serde(rename = "UID")]
|
||||
uid: String,
|
||||
#[serde(rename = "AccessToken")]
|
||||
access_token: String,
|
||||
#[serde(rename = "RefreshToken")]
|
||||
refresh_token: String,
|
||||
#[serde(rename = "ServerProof")]
|
||||
server_proof: String,
|
||||
/// Integer bitmask: 0 = none, 1 = TOTP, 2 = FIDO2.
|
||||
#[serde(rename = "TwoFactor")]
|
||||
two_factor: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TotpRequest<'a> {
|
||||
#[serde(rename = "TwoFactorCode")]
|
||||
code: &'a str,
|
||||
}
|
||||
|
||||
// ── persisted session ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Session {
|
||||
pub uid: String,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
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())
|
||||
}
|
||||
|
||||
pub fn load() -> Option<Self> {
|
||||
let json = fs::read_to_string(SESSION_FILE).ok()?;
|
||||
serde_json::from_str(&json).ok()
|
||||
}
|
||||
}
|
||||
|
||||
// ── auth flow ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a reqwest client with the headers Proton's API requires.
|
||||
pub fn build_client() -> Result<Client, String> {
|
||||
Client::builder()
|
||||
.user_agent("ProtonMail-Bridge-Rust/0.1")
|
||||
.default_headers({
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert("x-pm-appversion", "Other".parse().unwrap());
|
||||
headers
|
||||
})
|
||||
.build()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Authenticate against the Proton API and return a valid session.
|
||||
/// Loads a cached session from disk if available; falls back to full SRP login.
|
||||
pub async fn authenticate(client: &Client, config: &ProtonConfig) -> Result<Session, String> {
|
||||
if let Some(session) = Session::load() {
|
||||
println!("Loaded cached session for {}", config.username);
|
||||
return Ok(session);
|
||||
}
|
||||
login(client, config).await
|
||||
}
|
||||
|
||||
async fn login(client: &Client, config: &ProtonConfig) -> Result<Session, String> {
|
||||
// Step 1: request SRP challenge
|
||||
let info: AuthInfoResponse = client
|
||||
.post(format!("{}/auth/info", API_BASE))
|
||||
.json(&AuthInfoRequest { username: &config.username })
|
||||
.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())?;
|
||||
|
||||
// The modulus arrives PGP-armored — strip the armor to get the raw base64.
|
||||
let modulus_b64 = strip_pgp_armor(&info.modulus)?;
|
||||
|
||||
// Step 2: compute SRP proofs
|
||||
let proofs = srp::generate_proofs(
|
||||
info.version,
|
||||
&config.password,
|
||||
&info.salt,
|
||||
&info.server_ephemeral,
|
||||
&modulus_b64,
|
||||
)?;
|
||||
|
||||
// Step 3: submit proof
|
||||
let auth_resp = client
|
||||
.post(format!("{}/auth/v4", API_BASE))
|
||||
.json(&AuthRequest {
|
||||
username: &config.username,
|
||||
client_ephemeral: &proofs.client_ephemeral,
|
||||
client_proof: &proofs.client_proof,
|
||||
srp_session: &info.srp_session,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !auth_resp.status().is_success() {
|
||||
let status = auth_resp.status();
|
||||
let body = auth_resp.text().await.unwrap_or_default();
|
||||
return Err(format!("auth/v4 failed ({status}): {body}"));
|
||||
}
|
||||
|
||||
let auth: AuthResponse = auth_resp.json().await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Verify the server proved it knows the password too
|
||||
if auth.server_proof != proofs.expected_server_proof {
|
||||
return Err("Server proof verification failed — possible MITM".to_string());
|
||||
}
|
||||
|
||||
// Step 4: handle TOTP if enabled
|
||||
let session = Session {
|
||||
uid: auth.uid.clone(),
|
||||
access_token: auth.access_token.clone(),
|
||||
refresh_token: auth.refresh_token.clone(),
|
||||
};
|
||||
|
||||
if auth.two_factor & 1 != 0 {
|
||||
submit_totp(client, &session).await?;
|
||||
}
|
||||
|
||||
session.save()?;
|
||||
println!("Authenticated as {}", config.username);
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
async fn submit_totp(client: &Client, session: &Session) -> Result<(), String> {
|
||||
print!("TOTP code: ");
|
||||
io::stdout().flush().unwrap();
|
||||
let mut code = String::new();
|
||||
io::stdin().read_line(&mut code).map_err(|e| e.to_string())?;
|
||||
let code = code.trim();
|
||||
|
||||
client
|
||||
.post(format!("{}/auth/v4/2fa", API_BASE))
|
||||
.bearer_auth(&session.access_token)
|
||||
.header("x-pm-uid", &session.uid)
|
||||
.json(&TotpRequest { code })
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Remove PGP armor lines and return only the base64 body.
|
||||
/// The modulus arrives as:
|
||||
/// -----BEGIN PGP SIGNED MESSAGE-----
|
||||
/// ...base64...
|
||||
/// -----BEGIN PGP SIGNATURE-----
|
||||
/// ...
|
||||
fn strip_pgp_armor(armored: &str) -> Result<String, String> {
|
||||
let mut in_body = false;
|
||||
let mut body = String::new();
|
||||
for line in armored.lines() {
|
||||
if line.starts_with("-----BEGIN PGP SIGNED MESSAGE") {
|
||||
in_body = false;
|
||||
continue;
|
||||
}
|
||||
if line.is_empty() && !in_body {
|
||||
in_body = true;
|
||||
continue;
|
||||
}
|
||||
if line.starts_with("-----BEGIN PGP SIGNATURE") {
|
||||
break;
|
||||
}
|
||||
if in_body {
|
||||
body.push_str(line.trim());
|
||||
}
|
||||
}
|
||||
if body.is_empty() {
|
||||
Err("failed to extract modulus from PGP armor".to_string())
|
||||
} else {
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
35
proton-bridge/src/config.rs
Normal file
35
proton-bridge/src/config.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Config {
|
||||
pub proton: ProtonConfig,
|
||||
pub bridge: BridgeConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ProtonConfig {
|
||||
pub username: String,
|
||||
/// Login password (used in SRP). For two-password accounts this is separate
|
||||
/// from the mailbox password.
|
||||
pub password: String,
|
||||
/// Mailbox password — only needed for two-password mode accounts.
|
||||
pub mailbox_password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct BridgeConfig {
|
||||
/// Local IMAP port (skim connects here). Default: 1143.
|
||||
pub imap_port: u16,
|
||||
/// Local SMTP port (skim sends here). Default: 1025.
|
||||
pub smtp_port: u16,
|
||||
/// Password skim uses to authenticate against the local IMAP/SMTP servers.
|
||||
pub local_password: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let content = fs::read_to_string("bridge.toml")?;
|
||||
Ok(toml::from_str(&content)?)
|
||||
}
|
||||
}
|
||||
21
proton-bridge/src/main.rs
Normal file
21
proton-bridge/src/main.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
mod auth;
|
||||
mod config;
|
||||
mod srp;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let config = match config::Config::load() {
|
||||
Ok(c) => c,
|
||||
Err(e) => { eprintln!("Failed to load bridge.toml: {}", e); std::process::exit(1); }
|
||||
};
|
||||
|
||||
let client = match auth::build_client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => { eprintln!("Failed to build HTTP client: {}", e); std::process::exit(1); }
|
||||
};
|
||||
|
||||
match auth::authenticate(&client, &config.proton).await {
|
||||
Ok(session) => println!("Session UID: {}", session.uid),
|
||||
Err(e) => { eprintln!("Authentication failed: {}", e); std::process::exit(1); }
|
||||
}
|
||||
}
|
||||
203
proton-bridge/src/srp.rs
Normal file
203
proton-bridge/src/srp.rs
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/// SRP 6a implementation matching ProtonMail's go-srp exactly.
|
||||
///
|
||||
/// Key differences from textbook SRP:
|
||||
/// - All bigints are little-endian byte arrays.
|
||||
/// - k, u, M1, M2 all use expandHash (256 bytes) not plain SHA-512.
|
||||
/// - Password hashing (version 3/4) uses bcrypt with a server-supplied salt.
|
||||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use base64::Engine;
|
||||
use num_bigint::BigUint;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha512};
|
||||
|
||||
pub struct SrpProofs {
|
||||
/// A (client ephemeral), little-endian, 256 bytes, base64-encoded.
|
||||
pub client_ephemeral: String,
|
||||
/// M1 (client proof), base64-encoded.
|
||||
pub client_proof: String,
|
||||
/// M2 (expected server proof), base64-encoded — used to verify the server.
|
||||
pub expected_server_proof: String,
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Expand data to 256 bytes: SHA512(data || 0) || SHA512(data || 1) || ...
|
||||
/// Note: the counter byte is a *suffix* (matching go-srp).
|
||||
pub fn expand_hash(data: &[u8]) -> [u8; 256] {
|
||||
let mut out = [0u8; 256];
|
||||
for i in 0u8..4 {
|
||||
let mut h = Sha512::new();
|
||||
h.update(data);
|
||||
h.update([i]);
|
||||
out[i as usize * 64..(i as usize + 1) * 64].copy_from_slice(&h.finalize());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Encode a BigUint as a fixed 256-byte little-endian array.
|
||||
fn to_le256(n: &BigUint) -> [u8; 256] {
|
||||
let bytes = n.to_bytes_le();
|
||||
let mut out = [0u8; 256];
|
||||
out[..bytes.len().min(256)].copy_from_slice(&bytes[..bytes.len().min(256)]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Decode a little-endian byte slice into a BigUint.
|
||||
fn from_le(b: &[u8]) -> BigUint {
|
||||
BigUint::from_bytes_le(b)
|
||||
}
|
||||
|
||||
// ── main entry point ────────────────────────────────────────────────────────
|
||||
|
||||
pub fn generate_proofs(
|
||||
version: u32,
|
||||
password: &str,
|
||||
salt_b64: &str,
|
||||
server_ephemeral_b64: &str,
|
||||
modulus_b64: &str,
|
||||
) -> Result<SrpProofs, String> {
|
||||
let n_bytes = B64.decode(modulus_b64).map_err(|e| e.to_string())?;
|
||||
let b_bytes = B64.decode(server_ephemeral_b64).map_err(|e| e.to_string())?;
|
||||
let salt_bytes = B64.decode(salt_b64).map_err(|e| e.to_string())?;
|
||||
|
||||
let n = from_le(&n_bytes);
|
||||
let g = BigUint::from(2u32);
|
||||
let b = from_le(&b_bytes);
|
||||
|
||||
// k = expandHash(g || N) mod N
|
||||
// go-srp uses fromInt(bitLength, N) — always exactly 256 bytes, not raw decoded bytes.
|
||||
let n_le = to_le256(&n);
|
||||
let g_le = to_le256(&g);
|
||||
let mut kh = Vec::with_capacity(512);
|
||||
kh.extend_from_slice(&g_le);
|
||||
kh.extend_from_slice(&n_le);
|
||||
let k = from_le(&expand_hash(&kh)) % &n;
|
||||
|
||||
// x = password hash (ProtonMail-specific, see hash_password)
|
||||
let x_bytes = hash_password(version, password, &salt_bytes, &n_bytes)?;
|
||||
let x = from_le(&x_bytes);
|
||||
|
||||
// a: random secret with bitLength*2 < a < N-1
|
||||
let n_minus_one = &n - BigUint::from(1u32);
|
||||
let a = loop {
|
||||
let mut buf = [0u8; 256];
|
||||
rand::thread_rng().fill_bytes(&mut buf);
|
||||
let candidate = from_le(&buf) % &n_minus_one;
|
||||
if candidate > BigUint::from(512u32) {
|
||||
break candidate;
|
||||
}
|
||||
};
|
||||
|
||||
// A = g^a mod N
|
||||
let big_a = g.modpow(&a, &n);
|
||||
let a_bytes = to_le256(&big_a);
|
||||
|
||||
// u = expandHash(A || B) — B normalized to 256 bytes like A
|
||||
let b_le = to_le256(&b);
|
||||
let mut ub = Vec::with_capacity(512);
|
||||
ub.extend_from_slice(&a_bytes);
|
||||
ub.extend_from_slice(&b_le);
|
||||
let u = from_le(&expand_hash(&ub));
|
||||
|
||||
// v = g^x mod N
|
||||
let v = g.modpow(&x, &n);
|
||||
|
||||
// S = (B - k*v mod N)^(a + u*x mod N-1) mod N
|
||||
let kv = (&k * &v) % &n;
|
||||
let b_minus_kv = if b >= kv.clone() {
|
||||
(b - kv) % &n
|
||||
} else {
|
||||
(&n - (kv - b) % &n) % &n
|
||||
};
|
||||
let exp = (&a + &u * &x) % &n_minus_one;
|
||||
let s = b_minus_kv.modpow(&exp, &n);
|
||||
let s_bytes = to_le256(&s);
|
||||
|
||||
// M1 = expandHash(A || B || S)
|
||||
let mut m1b = Vec::with_capacity(768);
|
||||
m1b.extend_from_slice(&a_bytes);
|
||||
m1b.extend_from_slice(&b_le);
|
||||
m1b.extend_from_slice(&s_bytes);
|
||||
let m1 = expand_hash(&m1b);
|
||||
|
||||
// M2 = expandHash(A || M1 || S)
|
||||
let mut m2b = Vec::with_capacity(768);
|
||||
m2b.extend_from_slice(&a_bytes);
|
||||
m2b.extend_from_slice(&m1);
|
||||
m2b.extend_from_slice(&s_bytes);
|
||||
let m2 = expand_hash(&m2b);
|
||||
|
||||
Ok(SrpProofs {
|
||||
client_ephemeral: B64.encode(a_bytes),
|
||||
client_proof: B64.encode(m1),
|
||||
expected_server_proof: B64.encode(m2),
|
||||
})
|
||||
}
|
||||
|
||||
// ── password hashing ────────────────────────────────────────────────────────
|
||||
|
||||
/// Hash the password using ProtonMail's scheme (versions 3 and 4).
|
||||
///
|
||||
/// Algorithm (from go-srp / ProtonMail/bcrypt):
|
||||
/// 1. Append b"proton" to the raw salt bytes.
|
||||
/// 2. Encode with bcrypt's own base64 alphabet (./ABC...).
|
||||
/// 3. bcrypt(password, cost=10, salt="$2y$10$" + encoded).
|
||||
/// 4. expandHash(bcrypt_output || N_bytes).
|
||||
fn hash_password(
|
||||
version: u32,
|
||||
password: &str,
|
||||
salt: &[u8],
|
||||
modulus: &[u8],
|
||||
) -> Result<[u8; 256], String> {
|
||||
match version {
|
||||
3 | 4 => {
|
||||
// Step 1: salt + "proton"
|
||||
let mut salt_extended = salt.to_vec();
|
||||
salt_extended.extend_from_slice(b"proton");
|
||||
|
||||
// Step 2: bcrypt base64 (alphabet: ./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789)
|
||||
// pwhash BcryptSetup.salt expects the raw 22-char bcrypt-base64
|
||||
// salt only — NOT the full "$2y$10$..." format string.
|
||||
let encoded_salt = bcrypt_base64_encode(&salt_extended);
|
||||
|
||||
// Step 3: bcrypt with that salt
|
||||
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: {}", e))?;
|
||||
|
||||
// Step 4: expandHash(bcrypt_output || N)
|
||||
let mut data = hashed.into_bytes();
|
||||
data.extend_from_slice(modulus);
|
||||
Ok(expand_hash(&data))
|
||||
}
|
||||
_ => Err(format!("unsupported SRP version: {}", version)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode bytes using bcrypt's own base64 alphabet.
|
||||
fn bcrypt_base64_encode(input: &[u8]) -> String {
|
||||
const ALPHABET: &[u8] = b"./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
while i < input.len() {
|
||||
let b0 = input[i] as u32;
|
||||
let b1 = if i + 1 < input.len() { input[i + 1] as u32 } else { 0 };
|
||||
let b2 = if i + 2 < input.len() { input[i + 2] as u32 } else { 0 };
|
||||
out.push(ALPHABET[((b0 >> 2) & 0x3f) as usize] as char);
|
||||
out.push(ALPHABET[(((b0 << 4) | (b1 >> 4)) & 0x3f) as usize] as char);
|
||||
if i + 1 < input.len() {
|
||||
out.push(ALPHABET[(((b1 << 2) | (b2 >> 6)) & 0x3f) as usize] as char);
|
||||
}
|
||||
if i + 2 < input.len() {
|
||||
out.push(ALPHABET[(b2 & 0x3f) as usize] as char);
|
||||
}
|
||||
i += 3;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ use crossterm::{
|
|||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use skim::config::Config;
|
||||
use tuimail::config::Config;
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
Terminal,
|
||||
|
|
@ -29,7 +29,7 @@ fn main() -> io::Result<()> {
|
|||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let result = skim::main(&config, &mut terminal);
|
||||
let result = tuimail::main(&config, &mut terminal);
|
||||
|
||||
// --- Restore terminal (always, even on error) ---
|
||||
disable_raw_mode()?;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue