Add authentication, fixes #2
This commit is contained in:
parent
9a26c9d63a
commit
5e0754d0a1
|
@ -12,3 +12,5 @@
|
||||||
|
|
||||||
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
|
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
|
||||||
.glide/
|
.glide/
|
||||||
|
|
||||||
|
auth.json
|
||||||
|
|
11
README.md
11
README.md
|
@ -1,14 +1,19 @@
|
||||||
# hydroxide
|
# hydroxide
|
||||||
|
|
||||||
A third-party, open-source ProtonMail bridge
|
A third-party, open-source ProtonMail CardDAV bridge.
|
||||||
|
|
||||||
## Usage
|
## 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
|
```shell
|
||||||
go run cmd/hydroxide/hydroxide.go
|
go get github.com/emersion/hydroxide
|
||||||
|
hydroxide auth <username>
|
||||||
|
hydroxide
|
||||||
```
|
```
|
||||||
|
|
||||||
Tested on GNOME and Android.
|
Tested on GNOME (Evolution) and Android (DAVDroid).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,10 @@ import (
|
||||||
"github.com/emersion/go-webdav/carddav"
|
"github.com/emersion/go-webdav/carddav"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const ClientContextKey = contextKey("client")
|
||||||
|
|
||||||
type addressObject struct {
|
type addressObject struct {
|
||||||
c *protonmail.Client
|
c *protonmail.Client
|
||||||
contact *protonmail.ContactExport
|
contact *protonmail.ContactExport
|
||||||
|
|
|
@ -2,66 +2,156 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/emersion/hydroxide/carddav"
|
"github.com/emersion/hydroxide/carddav"
|
||||||
"github.com/emersion/hydroxide/protonmail"
|
"github.com/emersion/hydroxide/protonmail"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"golang.org/x/crypto/nacl/secretbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
const authFile = "auth.json"
|
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)
|
f, err := os.Open(authFile)
|
||||||
if err != nil {
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
auth := new(protonmail.Auth)
|
auths := make(map[string]string)
|
||||||
err = json.NewDecoder(f).Decode(auth)
|
err = json.NewDecoder(f).Decode(&auths)
|
||||||
return auth, err
|
return auths, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveAuth(auth *protonmail.Auth) error {
|
func saveAuths(auths map[string]string) error {
|
||||||
f, err := os.Create(authFile)
|
f, err := os.Create(authFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
return json.NewEncoder(f).Encode(auth)
|
return json.NewEncoder(f).Encode(auths)
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func encrypt(msg []byte, secretKey *[32]byte) (string, error) {
|
||||||
c := &protonmail.Client{
|
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",
|
RootURL: "https://dev.protonmail.com/api",
|
||||||
AppVersion: "Web_3.11.1",
|
AppVersion: "Web_3.11.1",
|
||||||
ClientID: "Web",
|
ClientID: "Web",
|
||||||
ClientSecret: "4957cc9a2e0a2a49d02475c9d013478d",
|
ClientSecret: "4957cc9a2e0a2a49d02475c9d013478d",
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
|
||||||
var password string
|
c := newClient()
|
||||||
auth, err := readCachedAuth()
|
|
||||||
if err == nil {
|
var auth *protonmail.Auth
|
||||||
|
/*if cachedAuth, ok := auths[username]; ok {
|
||||||
var err error
|
var err error
|
||||||
auth, err = c.AuthRefresh(auth)
|
auth, err = c.AuthRefresh(auth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// TODO: handle expired token error
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
} else if os.IsNotExist(err) {
|
}*/
|
||||||
fmt.Printf("Username: ")
|
|
||||||
scanner.Scan()
|
|
||||||
username := scanner.Text()
|
|
||||||
|
|
||||||
|
var loginPassword string
|
||||||
|
if auth == nil {
|
||||||
fmt.Printf("Password: ")
|
fmt.Printf("Password: ")
|
||||||
scanner.Scan()
|
scanner.Scan()
|
||||||
password = scanner.Text()
|
loginPassword = scanner.Text()
|
||||||
|
|
||||||
authInfo, err := c.AuthInfo(username)
|
authInfo, err := c.AuthInfo(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -75,40 +165,138 @@ func main() {
|
||||||
twoFactorCode = scanner.Text()
|
twoFactorCode = scanner.Text()
|
||||||
}
|
}
|
||||||
|
|
||||||
auth, err = c.Auth(username, password, twoFactorCode, authInfo)
|
auth, err = c.Auth(username, loginPassword, twoFactorCode, authInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := saveAuth(auth); err != nil {
|
var mailboxPassword string
|
||||||
log.Fatal(err)
|
if auth.PasswordMode == protonmail.PasswordSingle {
|
||||||
|
mailboxPassword = loginPassword
|
||||||
}
|
}
|
||||||
|
if mailboxPassword == "" {
|
||||||
if auth.PasswordMode == protonmail.PasswordTwo || password == "" {
|
|
||||||
if auth.PasswordMode == protonmail.PasswordTwo {
|
if auth.PasswordMode == protonmail.PasswordTwo {
|
||||||
fmt.Printf("Mailbox password: ")
|
fmt.Printf("Mailbox password: ")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("Password: ")
|
fmt.Printf("Password: ")
|
||||||
}
|
}
|
||||||
scanner.Scan()
|
scanner.Scan()
|
||||||
password = scanner.Text()
|
mailboxPassword = scanner.Text()
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.Unlock(auth, password)
|
_, err := c.Unlock(auth, mailboxPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := carddav.NewHandler(c)
|
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{
|
s := &http.Server{
|
||||||
Addr: "127.0.0.1:8080",
|
Addr: "127.0.0.1:8080",
|
||||||
Handler: h,
|
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.Println("Starting server at", s.Addr)
|
||||||
log.Fatal(s.ListenAndServe())
|
log.Fatal(s.ListenAndServe())
|
||||||
|
default:
|
||||||
|
log.Fatal("usage: hydroxide")
|
||||||
|
log.Fatal("usage: hydroxide auth <username>")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue