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:
Shautvast 2026-02-19 21:31:10 +01:00
parent fba2623f15
commit 05b6aac692
10 changed files with 1186 additions and 11 deletions

2
.gitignore vendored
View file

@ -2,3 +2,5 @@
docker-data/
config.toml
.idea/
bridge.toml
session.json

599
Cargo.lock generated
View file

@ -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"

View file

@ -1,3 +1,6 @@
[workspace]
members = [".", "proton-bridge"]
[package]
name = "tuimail"
version = "0.1.0"

78
PROTON.md Normal file
View 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
View 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
View 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)
}
}

View 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
View 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
View 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
}

View file

@ -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()?;