From 05b6aac692c6f741ef382eeb58fc3cfa0e179105 Mon Sep 17 00:00:00 2001 From: Shautvast Date: Thu, 19 Feb 2026 21:31:10 +0100 Subject: [PATCH] Add proton-bridge crate: workspace setup and SRP authentication (Step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 4 +- Cargo.lock | 599 +++++++++++++++++++++++++++++++++++- Cargo.toml | 3 + PROTON.md | 78 +++++ proton-bridge/Cargo.toml | 16 + proton-bridge/src/auth.rs | 234 ++++++++++++++ proton-bridge/src/config.rs | 35 +++ proton-bridge/src/main.rs | 21 ++ proton-bridge/src/srp.rs | 203 ++++++++++++ src/main.rs | 4 +- 10 files changed, 1186 insertions(+), 11 deletions(-) create mode 100644 PROTON.md create mode 100644 proton-bridge/Cargo.toml create mode 100644 proton-bridge/src/auth.rs create mode 100644 proton-bridge/src/config.rs create mode 100644 proton-bridge/src/main.rs create mode 100644 proton-bridge/src/srp.rs diff --git a/.gitignore b/.gitignore index c64dfc5..7d90bf6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /target docker-data/ config.toml -.idea/ \ No newline at end of file +.idea/ +bridge.toml +session.json \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 8de5d4b..0bbc657 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 228a0f3..761a5e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = [".", "proton-bridge"] + [package] name = "tuimail" version = "0.1.0" diff --git a/PROTON.md b/PROTON.md new file mode 100644 index 0000000..5993533 --- /dev/null +++ b/PROTON.md @@ -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 \ No newline at end of file diff --git a/proton-bridge/Cargo.toml b/proton-bridge/Cargo.toml new file mode 100644 index 0000000..ead9cd1 --- /dev/null +++ b/proton-bridge/Cargo.toml @@ -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 diff --git a/proton-bridge/src/auth.rs b/proton-bridge/src/auth.rs new file mode 100644 index 0000000..c459986 --- /dev/null +++ b/proton-bridge/src/auth.rs @@ -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 { + 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::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 { + 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 { + // 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 { + 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) + } +} \ No newline at end of file diff --git a/proton-bridge/src/config.rs b/proton-bridge/src/config.rs new file mode 100644 index 0000000..2a20b43 --- /dev/null +++ b/proton-bridge/src/config.rs @@ -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, +} + +#[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> { + let content = fs::read_to_string("bridge.toml")?; + Ok(toml::from_str(&content)?) + } +} \ No newline at end of file diff --git a/proton-bridge/src/main.rs b/proton-bridge/src/main.rs new file mode 100644 index 0000000..d9893ca --- /dev/null +++ b/proton-bridge/src/main.rs @@ -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); } + } +} \ No newline at end of file diff --git a/proton-bridge/src/srp.rs b/proton-bridge/src/srp.rs new file mode 100644 index 0000000..31daa2a --- /dev/null +++ b/proton-bridge/src/srp.rs @@ -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 { + 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 +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index dbea796..78fd8b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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()?;