Add Proton mailbox password helper
ober
e97c1c32ff600ec5381242d5508d890e559359f6
--- a/README.md +++ b/README.md @@ -99,6 +99,10 @@ The read-only `folders`, `list`, and `message-json` commands perform a fresh interactive auth for each invocation. They fetch Proton API JSON directly and do not store a reusable refresh token or local IMAP password. +The native helper also derives Proton's salted mailbox key passphrase from +`/core/v4/keys/salts`; this is the key-unlock input required before decrypted +message rendering can be enabled. + `auth-info.json` is the `/auth/v4/info` response. The command prompts for the Proton password and emits the SRP `/auth/v4` request body plus the expected server proof to verify after Proton responds. --- a/plan.md +++ b/plan.md @@ -272,6 +272,14 @@ Deliverables: - Unlock user keys. - Unlock address keys. +Done: + +- Native mailbox key password derivation using Proton's MIT-licensed + `proton-srp` implementation and the same 31-byte suffix used by + `go-proton-api`. +- Jerboa wrapper `proton-mailbox-password` with a deterministic Proton test + vector. + Exit criteria: - Key unlock succeeds. --- a/proton-bridge-native/Cargo.lock +++ b/proton-bridge-native/Cargo.lock @@ -1208,6 +1208,7 @@ dependencies = [ name = "proton-bridge-native" version = "0.1.0" dependencies = [ + "base64", "proton-srp", "zeroize", ] --- a/proton-bridge-native/Cargo.toml +++ b/proton-bridge-native/Cargo.toml @@ -9,5 +9,6 @@ name = "proton_bridge_native" crate-type = ["cdylib", "rlib"] [dependencies] +base64 = "0.22" proton-srp = "0.8.2" zeroize = "1.8" --- a/proton-bridge-native/src/lib.rs +++ b/proton-bridge-native/src/lib.rs @@ -1,4 +1,5 @@ -use proton_srp::{SRPAuth, SRPProofB64, SrpHashVersion}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use proton_srp::{mailbox_password_hash, SRPAuth, SRPProofB64, SrpHashVersion}; use std::ffi::CStr; use std::os::raw::c_char; use std::slice; @@ -23,6 +24,18 @@ fn set_last_error(msg: impl Into<String>) { } } +fn mailbox_password_tail(password: &str, key_salt_b64: &str) -> Result<String, String> { + let key_salt = BASE64_STANDARD + .decode(key_salt_b64) + .map_err(|err| err.to_string())?; + let hashed = mailbox_password_hash(password, &key_salt).map_err(|err| err.to_string())?; + let hashed = std::str::from_utf8(hashed.as_bytes()).map_err(|err| err.to_string())?; + if hashed.len() < 31 { + return Err("mailbox password hash is shorter than expected".to_owned()); + } + Ok(hashed[hashed.len() - 31..].to_owned()) +} + unsafe fn cstr_arg(name: &str, value: *const c_char) -> Result<String, i32> { if value.is_null() { set_last_error(format!("{name} is null")); @@ -133,6 +146,48 @@ pub unsafe extern "C" fn pb_native_srp_last_error(out: *mut u8, out_len: *mut u3 } } +/// Derive the Proton salted mailbox key password for a key salt. +/// +/// Proton's API exposes per-key salts from `/core/v4/keys/salts`. The +/// official Go client hashes the login/mailbox password with that salt and +/// uses the final 31 bytes as the OpenPGP key passphrase. +#[no_mangle] +pub unsafe extern "C" fn pb_native_mailbox_password( + password: *const c_char, + key_salt_b64: *const c_char, + out: *mut u8, + out_len: *mut u32, +) -> i32 { + set_last_error(""); + + let mut password = match cstr_arg("password", password) { + Ok(value) => value, + Err(code) => return code, + }; + let key_salt_b64 = match cstr_arg("key_salt_b64", key_salt_b64) { + Ok(value) => value, + Err(code) => { + password.zeroize(); + return code; + } + }; + + let key_pass = match mailbox_password_tail(&password, &key_salt_b64) { + Ok(value) => value, + Err(err) => { + password.zeroize(); + set_last_error(err); + return PB_SRP_ERROR; + } + }; + password.zeroize(); + + match write_string(&key_pass, out, out_len) { + Ok(()) => PB_SRP_OK, + Err(code) => code, + } +} + /// Generate Proton SRP authentication proofs from `/auth/v4/info` values. /// /// All string inputs must be NUL-terminated UTF-8. `salt`, @@ -243,3 +298,15 @@ pub unsafe extern "C" fn pb_native_srp_proofs( out_expected_server_proof_len, ) } + +#[cfg(test)] +mod tests { + use super::mailbox_password_tail; + + #[test] + fn derives_proton_mailbox_key_password_suffix() { + let key_pass = mailbox_password_tail("password", "imK9IHsRcA2Zsv+yROZgbw==").unwrap(); + assert_eq!(key_pass, "Q.Gd9rSsqE0xQ8Qcf0Q9ckInb4hIzOu"); + assert_eq!(key_pass.len(), 31); + } +} --- a/proton-bridge/srp.ss +++ b/proton-bridge/srp.ss @@ -13,7 +13,8 @@ proton-srp-generate-proofs proton-srp-auth-request-payload proton-srp-auth-request-json - proton-srp-server-proof-valid?) + proton-srp-server-proof-valid? + proton-mailbox-password) (import (except (chezscheme) make-hash-table hash-table? @@ -78,11 +79,19 @@ u8* u8* u8* u8* u8* u8*) integer-32)))) + (define c-mailbox-password + (and *native-loaded?* + (guard (e [#t #f]) + (foreign-procedure "pb_native_mailbox_password" + (string string u8* u8*) + integer-32)))) + (def (proton-srp-native-available?) (and *native-loaded?* c-srp-available c-srp-last-error c-srp-proofs + c-mailbox-password (= (c-srp-available) 1))) (def (u32-le-ref bv off) @@ -160,6 +169,13 @@ proof-out (u32-le-ref proof-len 0) server-out (u32-le-ref server-len 0))))) + (def (call-native-mailbox-password password key-salt-b64 cap) + (let* ([out (make-bytevector cap 0)] + [len (make-bytevector 4 0)]) + (u32-le-set! len 0 cap) + (let ([rc (c-mailbox-password password key-salt-b64 out len)]) + (values rc out (u32-le-ref len 0))))) + (def (proton-srp-generate-proofs version username password salt modulus server-ephemeral) (check-native 'proton-srp-generate-proofs) (unless (and (integer? version) (>= version 0)) @@ -197,6 +213,31 @@ [else (error 'proton-srp-generate-proofs (proton-srp-error-string rc))])))) + (def (proton-mailbox-password password key-salt-b64) + (check-native 'proton-mailbox-password) + (for-each + (lambda (name value) + (unless (string? value) + (error 'proton-mailbox-password + (string-append name " must be a string")))) + '("password" "key-salt-b64") + (list password key-salt-b64)) + (call-with-values + (lambda () (call-native-mailbox-password password key-salt-b64 64)) + (lambda (rc out len) + (cond + [(= rc PB-SRP-OK) + (utf8->string (bv-slice out 0 len))] + [(= rc PB-SRP-INSUFFICIENT-BUFFER) + (call-with-values + (lambda () (call-native-mailbox-password password key-salt-b64 len)) + (lambda (rc2 out2 len2) + (unless (= rc2 PB-SRP-OK) + (error 'proton-mailbox-password (proton-srp-error-string rc2))) + (utf8->string (bv-slice out2 0 len2))))] + [else + (error 'proton-mailbox-password (proton-srp-error-string rc))])))) + (def (jref who obj key) (unless (hash-table? obj) (error who "expected JSON object while reading" key)) --- a/test/test-all.ss +++ b/test/test-all.ss @@ -175,6 +175,12 @@ (= (string-length (hashtable-ref payload "ClientEphemeral" "")) 344) (= (string-length (hashtable-ref payload "ClientProof" "")) 344)))))) +(check "mailbox password derives Proton key passphrase suffix" + (let ([key-pass + (proton-mailbox-password "password" "imK9IHsRcA2Zsv+yROZgbw==")]) + (and (string=? key-pass "Q.Gd9rSsqE0xQ8Qcf0Q9ckInb4hIzOu") + (= (string-length key-pass) 31)))) + (define sample-auth-after-srp (string->json-object "{\"UID\":\"uid-1\",\"AccessToken\":\"access-1\",\"RefreshToken\":\"refresh-1\",\"ServerProof\":\"proof\",\"2FA\":{\"Enabled\":2,\"FIDO2\":{\"AuthenticationOptions\":{\"publicKey\":{\"rpId\":\"proton.me\",\"challenge\":[1],\"allowCredentials\":[{\"id\":[2]}]}}}}}"))