diff --git a/.gitignore b/.gitignore index a1338d6..6a1a0cf 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 .glide/ + +auth.json diff --git a/README.md b/README.md index 23416be..f3fc89c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,19 @@ # hydroxide -A third-party, open-source ProtonMail bridge +A third-party, open-source ProtonMail CardDAV bridge. ## Usage +Your credentials will be stored on disk encrypted with a 32-byte random +password. When configuring your CardDAV client, you'll need this password. + ```shell -go run cmd/hydroxide/hydroxide.go +go get github.com/emersion/hydroxide +hydroxide auth +hydroxide ``` -Tested on GNOME and Android. +Tested on GNOME (Evolution) and Android (DAVDroid). ## License diff --git a/carddav/carddav.go b/carddav/carddav.go index 6808eda..7d4b511 100644 --- a/carddav/carddav.go +++ b/carddav/carddav.go @@ -10,6 +10,10 @@ import ( "github.com/emersion/go-webdav/carddav" ) +type contextKey string + +const ClientContextKey = contextKey("client") + type addressObject struct { c *protonmail.Client contact *protonmail.ContactExport diff --git a/cmd/hydroxide/hydroxide.go b/cmd/hydroxide/hydroxide.go index 0e5d38a..5fd9c47 100644 --- a/cmd/hydroxide/hydroxide.go +++ b/cmd/hydroxide/hydroxide.go @@ -2,113 +2,301 @@ package main import ( "bufio" + "crypto/rand" + "encoding/base64" "encoding/json" + "errors" + "flag" "fmt" + "io" "log" "net/http" "os" "github.com/emersion/hydroxide/carddav" "github.com/emersion/hydroxide/protonmail" + "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/nacl/secretbox" ) const authFile = "auth.json" -func readCachedAuth() (*protonmail.Auth, error) { +type cachedAuth struct { + protonmail.Auth + LoginPassword string + MailboxPassword string + // TODO: add padding +} + +func readCachedAuths() (map[string]string, error) { f, err := os.Open(authFile) - if err != nil { + if os.IsNotExist(err) { + return nil, nil + } else if err != nil { return nil, err } defer f.Close() - auth := new(protonmail.Auth) - err = json.NewDecoder(f).Decode(auth) - return auth, err + auths := make(map[string]string) + err = json.NewDecoder(f).Decode(&auths) + return auths, err } -func saveAuth(auth *protonmail.Auth) error { +func saveAuths(auths map[string]string) error { f, err := os.Create(authFile) if err != nil { return err } defer f.Close() - return json.NewEncoder(f).Encode(auth) + return json.NewEncoder(f).Encode(auths) } -func main() { - c := &protonmail.Client{ +func encrypt(msg []byte, secretKey *[32]byte) (string, error) { + var nonce [24]byte + if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil { + return "", err + } + + encrypted := secretbox.Seal(nonce[:], msg, &nonce, secretKey) + return base64.StdEncoding.EncodeToString(encrypted), nil +} + +func decrypt(encryptedString string, secretKey *[32]byte) ([]byte, error) { + encrypted, err := base64.StdEncoding.DecodeString(encryptedString) + if err != nil { + return nil, err + } + + var nonce [24]byte + copy(nonce[:], encrypted[:24]) + decrypted, ok := secretbox.Open(nil, encrypted[24:], &nonce, secretKey) + if !ok { + return nil, errors.New("decryption error") + } + return decrypted, nil +} + +func encryptAndSaveAuth(auth *cachedAuth, username string, secretKey *[32]byte) error { + cleartext, err := json.Marshal(auth) + if err != nil { + return err + } + + encrypted, err := encrypt(cleartext, secretKey) + if err != nil { + return err + } + + auths, err := readCachedAuths() + if err != nil { + return err + } + + if auths == nil { + auths = make(map[string]string) + } + auths[username] = encrypted + + return saveAuths(auths) +} + +func newClient() *protonmail.Client { + return &protonmail.Client{ RootURL: "https://dev.protonmail.com/api", AppVersion: "Web_3.11.1", ClientID: "Web", ClientSecret: "4957cc9a2e0a2a49d02475c9d013478d", } - - scanner := bufio.NewScanner(os.Stdin) - - var password string - auth, err := readCachedAuth() - if err == nil { - var err error - auth, err = c.AuthRefresh(auth) - if err != nil { - log.Fatal(err) - } - } else if os.IsNotExist(err) { - fmt.Printf("Username: ") - scanner.Scan() - username := scanner.Text() - - fmt.Printf("Password: ") - scanner.Scan() - password = scanner.Text() - - authInfo, err := c.AuthInfo(username) - if err != nil { - log.Fatal(err) - } - - var twoFactorCode string - if authInfo.TwoFactor == 1 { - fmt.Printf("2FA code: ") - scanner.Scan() - twoFactorCode = scanner.Text() - } - - auth, err = c.Auth(username, password, twoFactorCode, authInfo) - if err != nil { - log.Fatal(err) - } - } else { - log.Fatal(err) - } - - if err := saveAuth(auth); err != nil { - log.Fatal(err) - } - - if auth.PasswordMode == protonmail.PasswordTwo || password == "" { - if auth.PasswordMode == protonmail.PasswordTwo { - fmt.Printf("Mailbox password: ") - } else { - fmt.Printf("Password: ") - } - scanner.Scan() - password = scanner.Text() - } - - _, err = c.Unlock(auth, password) - if err != nil { - log.Fatal(err) - } - - h := carddav.NewHandler(c) - - s := &http.Server{ - Addr: "127.0.0.1:8080", - Handler: h, - } - - log.Println("Starting server at", s.Addr) - log.Fatal(s.ListenAndServe()) +} + +func authenticate(c *protonmail.Client, cachedAuth *cachedAuth) error { + auth, err := c.AuthRefresh(&cachedAuth.Auth) + if err != nil { + // TODO: handle expired token, re-authenticate + return err + } + cachedAuth.Auth = *auth + + _, err = c.Unlock(auth, cachedAuth.MailboxPassword) + return err +} + +type session struct { + h http.Handler + hashedSecretKey []byte +} + +func main() { + flag.Parse() + + switch flag.Arg(0) { + case "auth": + username := flag.Arg(1) + scanner := bufio.NewScanner(os.Stdin) + + c := newClient() + + var auth *protonmail.Auth + /*if cachedAuth, ok := auths[username]; ok { + var err error + auth, err = c.AuthRefresh(auth) + if err != nil { + // TODO: handle expired token error + log.Fatal(err) + } + }*/ + + var loginPassword string + if auth == nil { + fmt.Printf("Password: ") + scanner.Scan() + loginPassword = scanner.Text() + + authInfo, err := c.AuthInfo(username) + if err != nil { + log.Fatal(err) + } + + var twoFactorCode string + if authInfo.TwoFactor == 1 { + fmt.Printf("2FA code: ") + scanner.Scan() + twoFactorCode = scanner.Text() + } + + auth, err = c.Auth(username, loginPassword, twoFactorCode, authInfo) + if err != nil { + log.Fatal(err) + } + } + + var mailboxPassword string + if auth.PasswordMode == protonmail.PasswordSingle { + mailboxPassword = loginPassword + } + if mailboxPassword == "" { + if auth.PasswordMode == protonmail.PasswordTwo { + fmt.Printf("Mailbox password: ") + } else { + fmt.Printf("Password: ") + } + scanner.Scan() + mailboxPassword = scanner.Text() + } + + _, err := c.Unlock(auth, mailboxPassword) + if err != nil { + log.Fatal(err) + } + + var secretKey [32]byte + if _, err := io.ReadFull(rand.Reader, secretKey[:]); err != nil { + log.Fatal(err) + } + bridgePassword := base64.StdEncoding.EncodeToString(secretKey[:]) + + err = encryptAndSaveAuth(&cachedAuth{ + *auth, + loginPassword, + mailboxPassword, + }, username, &secretKey) + if err != nil { + log.Fatal(err) + } + + fmt.Println("Bridge password:", bridgePassword) + case "": + sessions := make(map[string]*session) + + s := &http.Server{ + Addr: "127.0.0.1:8080", + Handler: http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + resp.Header().Set("WWW-Authenticate", "Basic") + + username, password, ok := req.BasicAuth() + if !ok { + resp.WriteHeader(http.StatusUnauthorized) + return + } + + var secretKey [32]byte + passwordBytes, err := base64.StdEncoding.DecodeString(password) + if err != nil || len(passwordBytes) != len(secretKey) { + resp.WriteHeader(http.StatusUnauthorized) + return + } + copy(secretKey[:], passwordBytes) + + var h http.Handler + s, ok := sessions[username] + if ok { + err := bcrypt.CompareHashAndPassword(s.hashedSecretKey, secretKey[:]) + if err != nil { + resp.WriteHeader(http.StatusUnauthorized) + return + } + + h = s.h + } else { + auths, err := readCachedAuths() + if err != nil && !os.IsNotExist(err) { + resp.WriteHeader(http.StatusInternalServerError) + return + } + + encrypted, ok := auths[username] + if !ok { + resp.WriteHeader(http.StatusUnauthorized) + return + } + + decrypted, err := decrypt(encrypted, &secretKey) + if err != nil { + resp.WriteHeader(http.StatusUnauthorized) + return + } + + var cachedAuth cachedAuth + if err := json.Unmarshal(decrypted, &cachedAuth); err != nil { + resp.WriteHeader(http.StatusInternalServerError) + return + } + + // authenticate updates cachedAuth with the new refresh token + c := newClient() + if err := authenticate(c, &cachedAuth); err != nil { + resp.WriteHeader(http.StatusInternalServerError) + return + } + + if err := encryptAndSaveAuth(&cachedAuth, username, &secretKey); err != nil { + resp.WriteHeader(http.StatusInternalServerError) + return + } + + hashed, err := bcrypt.GenerateFromPassword(secretKey[:], bcrypt.DefaultCost) + if err != nil { + resp.WriteHeader(http.StatusInternalServerError) + return + } + + h = carddav.NewHandler(c) + + sessions[username] = &session{ + h: h, + hashedSecretKey: hashed, + } + } + + h.ServeHTTP(resp, req) + }), + } + + log.Println("Starting server at", s.Addr) + log.Fatal(s.ListenAndServe()) + default: + log.Fatal("usage: hydroxide") + log.Fatal("usage: hydroxide auth ") + } }