diff --git a/README.md b/README.md index 7f72e63..0f9510d 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,38 @@ # hydroxide -A third-party, open-source ProtonMail CardDAV bridge. For power users only, -designed to run on a server. +A third-party, open-source ProtonMail bridge. For power users only, designed to +run on a server. + +hydroxide supports CardDAV and SMTP. ## 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. -You must setup an HTTPS reverse proxy to forward requests to `hydroxide`. +password. When configuring your client, you'll need this password. ```shell go get github.com/emersion/hydroxide hydroxide auth -hydroxide +``` + +### CardDAV + +You must setup an HTTPS reverse proxy to forward requests to `hydroxide`. + +```shell +hydroxide carddav ``` Tested on GNOME (Evolution) and Android (DAVDroid). +### SMTP + +For now, it only supports unencrypted local connections. + +```shell +hydroxide smtp +``` + ## License MIT diff --git a/cmd/hydroxide/hydroxide.go b/cmd/hydroxide/hydroxide.go index 435e242..222efac 100644 --- a/cmd/hydroxide/hydroxide.go +++ b/cmd/hydroxide/hydroxide.go @@ -10,9 +10,12 @@ import ( "os" "time" + "github.com/emersion/go-smtp" + "github.com/emersion/hydroxide/auth" "github.com/emersion/hydroxide/carddav" "github.com/emersion/hydroxide/protonmail" + smtpbackend "github.com/emersion/hydroxide/smtp" ) func newClient() *protonmail.Client { @@ -122,7 +125,23 @@ func main() { } fmt.Println("Bridge password:", bridgePassword) - case "": + case "smtp": + port := os.Getenv("PORT") + if port == "" { + port = "1465" + } + + sessions := auth.NewManager(newClient) + + be := smtpbackend.New(sessions) + s := smtp.NewServer(be) + s.Addr = "127.0.0.1:" + port + s.Domain = "localhost" // TODO: make this configurable + s.AllowInsecureAuth = true // TODO: remove this + + log.Println("Starting SMTP server at", s.Addr) + log.Fatal(s.ListenAndServe()) + case "carddav": port := os.Getenv("PORT") if port == "" { port = "8080" @@ -167,10 +186,11 @@ func main() { }), } - log.Println("Starting server at", s.Addr) + log.Println("Starting CardDAV server at", s.Addr) log.Fatal(s.ListenAndServe()) default: - log.Fatal("usage: hydroxide") + log.Fatal("usage: hydroxide carddav") + log.Fatal("usage: hydroxide smtp") log.Fatal("usage: hydroxide auth ") } } diff --git a/protonmail/attachments.go b/protonmail/attachments.go index a44ab20..ab6f013 100644 --- a/protonmail/attachments.go +++ b/protonmail/attachments.go @@ -1,16 +1,189 @@ package protonmail -type Attachment struct { - ID string - Name string - Size int - MIMEType string - KeyPackets string - //Headers map[string]string -} +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "strconv" + "strings" + + "golang.org/x/crypto/openpgp" + "golang.org/x/crypto/openpgp/packet" +) type AttachmentKey struct { ID string Key string Algo string } + +type Attachment struct { + ID string + MessageID string + Name string + Size int + MIMEType string + ContentID string + KeyPackets string // encrypted with the user's key, base64-encoded + //Headers map[string]string + Signature string + + unencryptedKey *packet.EncryptedKey +} + +// GenerateKey generates an encrypted key and encrypts it to the provided +// recipients. Usually, the recipient is the user himself. +// +// The returned key is NOT encrypted. +func (att *Attachment) GenerateKey(to []*openpgp.Entity) (*packet.EncryptedKey, error) { + config := &packet.Config{} + + var encodedKeyPackets bytes.Buffer + keyPackets := base64.NewEncoder(base64.StdEncoding, &encodedKeyPackets) + + unencryptedKey, err := generateUnencryptedKey(packet.CipherAES256, config) + if err != nil { + return nil, err + } + + for _, pub := range to { + encKey, ok := encryptionKey(pub, config.Now()) + if !ok { + return nil, errors.New("cannot encrypt an attachment to key id " + strconv.FormatUint(pub.PrimaryKey.KeyId, 16) + " because it has no encryption keys") + } + + err := packet.SerializeEncryptedKey(keyPackets, encKey.PublicKey, unencryptedKey.CipherFunc, unencryptedKey.Key, config) + if err != nil { + return nil, err + } + } + + keyPackets.Close() + att.unencryptedKey = unencryptedKey + att.KeyPackets = encodedKeyPackets.String() + return unencryptedKey, nil +} + +// Encrypt encrypts to w the data that will be written to the returned +// io.WriteCloser. +// +// Prior to calling Encrypt, an attachment key must have been generated with +// GenerateKey. +// +// signed is ignored for now. +func (att *Attachment) Encrypt(ciphertext io.Writer, signed *openpgp.Entity) (cleartext io.WriteCloser, err error) { + config := &packet.Config{} + + if att.unencryptedKey == nil { + return nil, errors.New("cannot encrypt attachment: no attachment key available") + } + + encryptedData, err := packet.SerializeSymmetricallyEncrypted(ciphertext, att.unencryptedKey.CipherFunc, att.unencryptedKey.Key, config) + if err != nil { + return nil, err + } + + // TODO: sign, see https://github.com/golang/crypto/blob/master/openpgp/write.go#L287 + + literalData, err := packet.SerializeLiteral(encryptedData, true, att.Name, 0) + if err != nil { + return nil, err + } + + return literalData, nil +} + +// GetAttachment downloads an attachment's payload. The returned io.ReadCloser +// may be encrypted. +func (c *Client) GetAttachment(id string) (io.ReadCloser, error) { + req, err := c.newRequest(http.MethodGet, "/attachments/"+id, nil) + if err != nil { + return nil, err + } + + resp, err := c.do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("cannot get attachment %q: %v %v", id, resp.Status, resp.StatusCode) + } + + return resp.Body, nil +} + +// CreateAttachment uploads a new attachment. r must be an PGP data packet +// encrypted with att.KeyPackets. +func (c *Client) CreateAttachment(att *Attachment, r io.Reader) (created *Attachment, err error) { + pr, pw := io.Pipe() + mw := multipart.NewWriter(pw) + + go func() { + if err := mw.WriteField("Filename", att.Name); err != nil { + pw.CloseWithError(err) + return + } + + if err := mw.WriteField("MessageID", att.MessageID); err != nil { + pw.CloseWithError(err) + return + } + + if err := mw.WriteField("MIMEType", att.MIMEType); err != nil { + pw.CloseWithError(err) + return + } + + if att.ContentID != "" { + if err := mw.WriteField("ContentID", att.ContentID); err != nil { + pw.CloseWithError(err) + return + } + } + + if w, err := mw.CreateFormFile("KeyPackets", "KeyPackets.pgp"); err != nil { + pw.CloseWithError(err) + return + } else { + kpr := base64.NewDecoder(base64.StdEncoding, strings.NewReader(att.KeyPackets)) + if _, err := io.Copy(w, kpr); err != nil { + pw.CloseWithError(err) + return + } + } + + if w, err := mw.CreateFormFile("DataPacket", "DataPacket.pgp"); err != nil { + pw.CloseWithError(err) + return + } else if _, err := io.Copy(w, r); err != nil { + pw.CloseWithError(err) + return + } + + // TODO: Signature + + pw.CloseWithError(mw.Close()) + }() + + req, err := c.newRequest(http.MethodPost, "/attachments", pr) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", mw.FormDataContentType()) + + var respData struct { + resp + Attachment *Attachment + } + if err := c.doJSON(req, &respData); err != nil { + return nil, err + } + + return respData.Attachment, nil +} diff --git a/protonmail/crypto.go b/protonmail/crypto.go index 365ec68..bc8fe0c 100644 --- a/protonmail/crypto.go +++ b/protonmail/crypto.go @@ -1,9 +1,11 @@ package protonmail import ( + "io" "time" "golang.org/x/crypto/openpgp" + "golang.org/x/crypto/openpgp/packet" ) // primaryIdentity returns the Identity marked as primary or the first identity @@ -89,3 +91,15 @@ func signingKey(e *openpgp.Entity, now time.Time) (openpgp.Key, bool) { return openpgp.Key{}, false } + +func generateUnencryptedKey(cipher packet.CipherFunction, config *packet.Config) (*packet.EncryptedKey, error) { + symKey := make([]byte, cipher.KeySize()) + if _, err := io.ReadFull(config.Random(), symKey); err != nil { + return nil, err + } + + return &packet.EncryptedKey{ + CipherFunc: cipher, + Key: symKey, + }, nil +} diff --git a/protonmail/messages.go b/protonmail/messages.go index 1df5cd4..0e548e9 100644 --- a/protonmail/messages.go +++ b/protonmail/messages.go @@ -63,7 +63,7 @@ type Message struct { SpamScore int AddressID string Body string - MIMEType string + MIMEType string `json:",omitempty"` CCList []*MessageAddress BCCList []*MessageAddress Header string @@ -214,55 +214,43 @@ const ( type MessagePackage struct { Type MessagePackageType - BodyKeyPacket string + BodyKeyPacket string AttachmentKeyPackets map[string]string - Signature int + Signature int // Only if encrypted for outside PasswordHint string - Auth interface{} // TODO - Token string - EncToken string + Auth interface{} // TODO + Token string + EncToken string } type MessagePackageSet struct { - Type MessagePackageType // OR of each Type + Type MessagePackageType // OR of each Type Addresses map[string]*MessagePackage - MIMEType string - Body string // Body data packet + MIMEType string + Body string // Body data packet // Only if cleartext is sent - BodyKey string + BodyKey string AttachmentKeys map[string]string - bodyKey *packet.EncryptedKey + bodyKey *packet.EncryptedKey attachmentKeys map[string]*packet.EncryptedKey } func NewMessagePackageSet(attachmentKeys map[string]*packet.EncryptedKey) *MessagePackageSet { return &MessagePackageSet{ + Addresses: make(map[string]*MessagePackage), attachmentKeys: attachmentKeys, } } -func (set *MessagePackageSet) generateBodyKey(cipher packet.CipherFunction, config *packet.Config) error { - symKey := make([]byte, cipher.KeySize()) - if _, err := io.ReadFull(config.Random(), symKey); err != nil { - return err - } - - set.bodyKey = &packet.EncryptedKey{ - CipherFunc: cipher, - Key: symKey, - } - return nil -} - type outgoingMessageWriter struct { - cleartext io.WriteCloser + cleartext io.WriteCloser ciphertext io.WriteCloser - armored *bytes.Buffer - set *MessagePackageSet + encoded *bytes.Buffer + set *MessagePackageSet } func (w *outgoingMessageWriter) Write(p []byte) (int, error) { @@ -276,23 +264,28 @@ func (w *outgoingMessageWriter) Close() error { if err := w.ciphertext.Close(); err != nil { return err } - w.set.Body = w.armored.String() - w.armored = nil + w.set.Body = w.encoded.String() + w.encoded = nil return nil } -func (set *MessagePackageSet) Encrypt() (io.WriteCloser, error) { +// Encrypt encrypts the data that will be written to the returned +// io.WriteCloser. +// +// The signed parameter is ignored for now. +func (set *MessagePackageSet) Encrypt(mimeType string, signed *openpgp.Entity) (io.WriteCloser, error) { + set.MIMEType = mimeType + config := &packet.Config{} - if err := set.generateBodyKey(packet.CipherAES256, config); err != nil { - return nil, err - } - - var armored bytes.Buffer - ciphertext, err := armor.Encode(&armored, "PGP MESSAGE", nil) + key, err := generateUnencryptedKey(packet.CipherAES256, config) if err != nil { return nil, err } + set.bodyKey = key + + var encoded bytes.Buffer + ciphertext := base64.NewEncoder(base64.StdEncoding, &encoded) encryptedData, err := packet.SerializeSymmetricallyEncrypted(ciphertext, set.bodyKey.CipherFunc, set.bodyKey.Key, config) if err != nil { @@ -307,15 +300,16 @@ func (set *MessagePackageSet) Encrypt() (io.WriteCloser, error) { } return &outgoingMessageWriter{ - cleartext: literalData, + cleartext: literalData, ciphertext: ciphertext, - armored: &armored, - set: set, + encoded: &encoded, + set: set, }, nil } func (set *MessagePackageSet) AddCleartext(addr string) error { set.Addresses[addr] = &MessagePackage{Type: MessagePackageCleartext} + set.Type |= MessagePackageCleartext if set.BodyKey == "" || set.AttachmentKeys == nil { set.BodyKey = base64.StdEncoding.EncodeToString(set.bodyKey.Key) @@ -330,18 +324,17 @@ func (set *MessagePackageSet) AddCleartext(addr string) error { } func serializeEncryptedKey(symKey *packet.EncryptedKey, pub *packet.PublicKey, config *packet.Config) (string, error) { - var armored bytes.Buffer - ciphertext, err := armor.Encode(&armored, "PGP MESSAGE", nil) + var encoded bytes.Buffer + ciphertext := base64.NewEncoder(base64.StdEncoding, &encoded) + + err := packet.SerializeEncryptedKey(ciphertext, pub, symKey.CipherFunc, symKey.Key, config) if err != nil { return "", err } - err = packet.SerializeEncryptedKey(ciphertext, pub, symKey.CipherFunc, symKey.Key, config) - if err != nil { - return "", err - } + ciphertext.Close() - return armored.String(), nil + return encoded.String(), nil } func (set *MessagePackageSet) AddInternal(addr string, pub *openpgp.Entity) error { @@ -366,9 +359,10 @@ func (set *MessagePackageSet) AddInternal(addr string, pub *openpgp.Entity) erro attachmentKeys[att] = attKey } + set.Type |= MessagePackageInternal set.Addresses[addr] = &MessagePackage{ - Type: MessagePackageInternal, - BodyKeyPacket: bodyKey, + Type: MessagePackageInternal, + BodyKeyPacket: bodyKey, AttachmentKeyPackets: attachmentKeys, } return nil @@ -384,7 +378,7 @@ type OutgoingMessage struct { } func (c *Client) SendMessage(msg *OutgoingMessage) (sent, parent *Message, err error) { - req, err := c.newJSONRequest(http.MethodPut, "/messages/"+msg.ID, msg) + req, err := c.newJSONRequest(http.MethodPost, "/messages/send/"+msg.ID, msg) if err != nil { return nil, nil, err } diff --git a/protonmail/protonmail.go b/protonmail/protonmail.go index c61a769..a5a723f 100644 --- a/protonmail/protonmail.go +++ b/protonmail/protonmail.go @@ -82,6 +82,8 @@ func (c *Client) newJSONRequest(method, path string, body interface{}) (*http.Re } b := buf.Bytes() + //log.Printf(">> %v %v\n%v", method, path, string(b)) + req, err := c.newRequest(method, path, bytes.NewReader(b)) if err != nil { return nil, err diff --git a/smtp/smtp.go b/smtp/smtp.go new file mode 100644 index 0000000..af6a8e2 --- /dev/null +++ b/smtp/smtp.go @@ -0,0 +1,339 @@ +package smtp + +import ( + "bytes" + "errors" + "fmt" + "io" + + "github.com/emersion/go-message/mail" + "github.com/emersion/go-smtp" + "golang.org/x/crypto/openpgp" + "golang.org/x/crypto/openpgp/packet" + + "github.com/emersion/hydroxide/auth" + "github.com/emersion/hydroxide/protonmail" +) + +func toPMAddressList(addresses []*mail.Address) []*protonmail.MessageAddress { + l := make([]*protonmail.MessageAddress, len(addresses)) + for i, addr := range addresses { + l[i] = &protonmail.MessageAddress{ + Name: addr.Name, + Address: addr.Address, + } + } + return l +} + +func formatHeader(h mail.Header) string { + var b bytes.Buffer + for k, values := range h.Header { + for _, v := range values { + b.WriteString(fmt.Sprintf("%s: %s\r\n", k, v)) + } + } + return b.String() +} + +type user struct { + c *protonmail.Client + u *protonmail.User + privateKeys openpgp.EntityList +} + +func (u *user) Send(from string, to []string, r io.Reader) error { + // Parse the incoming MIME message header + mr, err := mail.CreateReader(r) + if err != nil { + return err + } + + subject, _ := mr.Header.Subject() + fromList, _ := mr.Header.AddressList("From") + toList, _ := mr.Header.AddressList("To") + ccList, _ := mr.Header.AddressList("Cc") + bccList, _ := mr.Header.AddressList("Bcc") + + if len(fromList) != 1 { + return errors.New("the From field must contain exactly one address") + } + if len(toList) == 0 && len(ccList) == 0 && len(bccList) == 0 { + return errors.New("no recipient specified") + } + + fromAddrStr := fromList[0].Address + var fromAddr *protonmail.Address + for _, addr := range u.u.Addresses { + if addr.Email == fromAddrStr { + fromAddr = addr + break + } + } + if fromAddr == nil { + return errors.New("unknown sender address") + } + if len(fromAddr.Keys) == 0 { + return errors.New("sender address has no private key") + } + + // TODO: get appropriate private key + encryptedPrivateKey, err := fromAddr.Keys[0].Entity() + if err != nil { + return fmt.Errorf("cannot parse sender private key: %v", err) + } + + var privateKey *openpgp.Entity + for _, e := range u.privateKeys { + if e.PrimaryKey.KeyId == encryptedPrivateKey.PrimaryKey.KeyId { + privateKey = e + break + } + } + if privateKey == nil { + return errors.New("sender address key hasn't been decrypted") + } + + msg := &protonmail.Message{ + ToList: toPMAddressList(toList), + CCList: toPMAddressList(ccList), + BCCList: toPMAddressList(bccList), + Subject: subject, + Header: formatHeader(mr.Header), + AddressID: fromAddr.ID, + } + + // Create an empty draft + plaintext, err := msg.Encrypt([]*openpgp.Entity{privateKey}, privateKey) + if err != nil { + return err + } + if err := plaintext.Close(); err != nil { + return err + } + + // TODO: parentID from In-Reply-To + msg, err = u.c.CreateDraftMessage(msg, "") + if err != nil { + return fmt.Errorf("cannot create draft message: %v", err) + } + + // Parse the incoming MIME message body + // Save the message text into a buffer + // Upload attachments + + var body *bytes.Buffer + var bodyType string + attachmentKeys := make(map[string]*packet.EncryptedKey) + + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } else if err != nil { + return err + } + + switch h := p.Header.(type) { + case mail.TextHeader: + t, _, err := h.ContentType() + if err != nil { + break + } + + if body != nil && t != "text/html" { + break + } + + body = &bytes.Buffer{} + bodyType = t + if _, err := io.Copy(body, p.Body); err != nil { + return err + } + case mail.AttachmentHeader: + t, _, err := h.ContentType() + if err != nil { + break + } + + filename, err := h.Filename() + if err != nil { + break + } + + att := &protonmail.Attachment{ + MessageID: msg.ID, + Name: filename, + MIMEType: t, + ContentID: h.Get("Content-Id"), + // TODO: Header + } + + attKey, err := att.GenerateKey([]*openpgp.Entity{privateKey}) + if err != nil { + return fmt.Errorf("cannot generate attachment key: %v", err) + } + + pr, pw := io.Pipe() + + go func() { + cleartext, err := att.Encrypt(pw, privateKey) + if err != nil { + pw.CloseWithError(err) + return + } + if _, err := io.Copy(cleartext, p.Body); err != nil { + pw.CloseWithError(err) + return + } + pw.CloseWithError(cleartext.Close()) + }() + + att, err = u.c.CreateAttachment(att, pr) + if err != nil { + return fmt.Errorf("cannot upload attachment: %v", err) + } + + attachmentKeys[att.ID] = attKey + } + } + + if body == nil { + return errors.New("message doesn't contain a body part") + } + + // Encrypt the body and update the draft + msg.MIMEType = bodyType + plaintext, err = msg.Encrypt([]*openpgp.Entity{privateKey}, privateKey) + if err != nil { + return err + } + if _, err := io.Copy(plaintext, bytes.NewReader(body.Bytes())); err != nil { + return err + } + if err := plaintext.Close(); err != nil { + return err + } + + msg, err = u.c.UpdateDraftMessage(msg) + if err != nil { + return fmt.Errorf("cannot update draft message: %v", err) + } + + // Split internal recipients and plaintext recipients + + recipients := make([]*mail.Address, 0, len(toList)+len(ccList)+len(bccList)) + recipients = append(recipients, toList...) + recipients = append(recipients, ccList...) + recipients = append(recipients, bccList...) + + var plaintextRecipients []string + encryptedRecipients := make(map[string]*openpgp.Entity) + for _, rcpt := range recipients { + resp, err := u.c.GetPublicKeys(rcpt.Address) + if err != nil { + return fmt.Errorf("cannot get public key for address %q: %v", rcpt.Address, err) + } + + if len(resp.Keys) == 0 { + plaintextRecipients = append(plaintextRecipients, rcpt.Address) + break + } + + // TODO: only keys with Send == 1 + pub, err := resp.Keys[0].Entity() + if err != nil { + return err + } + + encryptedRecipients[rcpt.Address] = pub + } + + // Create and send the outgoing message + outgoing := &protonmail.OutgoingMessage{ID: msg.ID} + + if len(plaintextRecipients) > 0 { + plaintextSet := protonmail.NewMessagePackageSet(attachmentKeys) + + plaintext, err := plaintextSet.Encrypt(bodyType, privateKey) + if err != nil { + return err + } + if _, err := io.Copy(plaintext, bytes.NewReader(body.Bytes())); err != nil { + plaintext.Close() + return err + } + if err := plaintext.Close(); err != nil { + return err + } + + for _, rcpt := range plaintextRecipients { + if err := plaintextSet.AddCleartext(rcpt); err != nil { + return err + } + } + + outgoing.Packages = append(outgoing.Packages, plaintextSet) + } + + if len(encryptedRecipients) > 0 { + encryptedSet := protonmail.NewMessagePackageSet(attachmentKeys) + + plaintext, err := encryptedSet.Encrypt(bodyType, privateKey) + if err != nil { + return err + } + if _, err := io.Copy(plaintext, bytes.NewReader(body.Bytes())); err != nil { + plaintext.Close() + return err + } + if err := plaintext.Close(); err != nil { + return err + } + + for rcpt, pub := range encryptedRecipients { + if err := encryptedSet.AddInternal(rcpt, pub); err != nil { + return err + } + } + + outgoing.Packages = append(outgoing.Packages, encryptedSet) + } + + _, _, err = u.c.SendMessage(outgoing) + if err != nil { + return fmt.Errorf("cannot send message: %v", err) + } + + return nil +} + +func (u *user) Logout() error { + u.c = nil + u.privateKeys = nil + return nil +} + +type backend struct { + sessions *auth.Manager +} + +func (be *backend) Login(username, password string) (smtp.User, error) { + c, privateKeys, err := be.sessions.Auth(username, password) + if err != nil { + return nil, err + } + + u, err := c.GetCurrentUser() + if err != nil { + return nil, err + } + + // TODO: decrypt private keys in u.Addresses + + return &user{c, u, privateKeys}, nil +} + +func New(sessions *auth.Manager) smtp.Backend { + return &backend{sessions} +}