commit
07532b8403
26
README.md
26
README.md
|
@ -1,22 +1,38 @@
|
||||||
# hydroxide
|
# hydroxide
|
||||||
|
|
||||||
A third-party, open-source ProtonMail CardDAV bridge. For power users only,
|
A third-party, open-source ProtonMail bridge. For power users only, designed to
|
||||||
designed to run on a server.
|
run on a server.
|
||||||
|
|
||||||
|
hydroxide supports CardDAV and SMTP.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Your credentials will be stored on disk encrypted with a 32-byte random
|
Your credentials will be stored on disk encrypted with a 32-byte random
|
||||||
password. When configuring your CardDAV client, you'll need this password.
|
password. When configuring your client, you'll need this password.
|
||||||
You must setup an HTTPS reverse proxy to forward requests to `hydroxide`.
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
go get github.com/emersion/hydroxide
|
go get github.com/emersion/hydroxide
|
||||||
hydroxide auth <username>
|
hydroxide auth <username>
|
||||||
hydroxide
|
```
|
||||||
|
|
||||||
|
### CardDAV
|
||||||
|
|
||||||
|
You must setup an HTTPS reverse proxy to forward requests to `hydroxide`.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
hydroxide carddav
|
||||||
```
|
```
|
||||||
|
|
||||||
Tested on GNOME (Evolution) and Android (DAVDroid).
|
Tested on GNOME (Evolution) and Android (DAVDroid).
|
||||||
|
|
||||||
|
### SMTP
|
||||||
|
|
||||||
|
For now, it only supports unencrypted local connections.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
hydroxide smtp
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
|
@ -10,9 +10,12 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/emersion/go-smtp"
|
||||||
|
|
||||||
"github.com/emersion/hydroxide/auth"
|
"github.com/emersion/hydroxide/auth"
|
||||||
"github.com/emersion/hydroxide/carddav"
|
"github.com/emersion/hydroxide/carddav"
|
||||||
"github.com/emersion/hydroxide/protonmail"
|
"github.com/emersion/hydroxide/protonmail"
|
||||||
|
smtpbackend "github.com/emersion/hydroxide/smtp"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newClient() *protonmail.Client {
|
func newClient() *protonmail.Client {
|
||||||
|
@ -122,7 +125,23 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Bridge password:", bridgePassword)
|
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")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "8080"
|
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())
|
log.Fatal(s.ListenAndServe())
|
||||||
default:
|
default:
|
||||||
log.Fatal("usage: hydroxide")
|
log.Fatal("usage: hydroxide carddav")
|
||||||
|
log.Fatal("usage: hydroxide smtp")
|
||||||
log.Fatal("usage: hydroxide auth <username>")
|
log.Fatal("usage: hydroxide auth <username>")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,189 @@
|
||||||
package protonmail
|
package protonmail
|
||||||
|
|
||||||
type Attachment struct {
|
import (
|
||||||
ID string
|
"bytes"
|
||||||
Name string
|
"encoding/base64"
|
||||||
Size int
|
"errors"
|
||||||
MIMEType string
|
"fmt"
|
||||||
KeyPackets string
|
"io"
|
||||||
//Headers map[string]string
|
"mime/multipart"
|
||||||
}
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/openpgp"
|
||||||
|
"golang.org/x/crypto/openpgp/packet"
|
||||||
|
)
|
||||||
|
|
||||||
type AttachmentKey struct {
|
type AttachmentKey struct {
|
||||||
ID string
|
ID string
|
||||||
Key string
|
Key string
|
||||||
Algo 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
|
||||||
|
}
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
package protonmail
|
package protonmail
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/openpgp"
|
"golang.org/x/crypto/openpgp"
|
||||||
|
"golang.org/x/crypto/openpgp/packet"
|
||||||
)
|
)
|
||||||
|
|
||||||
// primaryIdentity returns the Identity marked as primary or the first identity
|
// 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
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -63,7 +63,7 @@ type Message struct {
|
||||||
SpamScore int
|
SpamScore int
|
||||||
AddressID string
|
AddressID string
|
||||||
Body string
|
Body string
|
||||||
MIMEType string
|
MIMEType string `json:",omitempty"`
|
||||||
CCList []*MessageAddress
|
CCList []*MessageAddress
|
||||||
BCCList []*MessageAddress
|
BCCList []*MessageAddress
|
||||||
Header string
|
Header string
|
||||||
|
@ -214,55 +214,43 @@ const (
|
||||||
type MessagePackage struct {
|
type MessagePackage struct {
|
||||||
Type MessagePackageType
|
Type MessagePackageType
|
||||||
|
|
||||||
BodyKeyPacket string
|
BodyKeyPacket string
|
||||||
AttachmentKeyPackets map[string]string
|
AttachmentKeyPackets map[string]string
|
||||||
Signature int
|
Signature int
|
||||||
|
|
||||||
// Only if encrypted for outside
|
// Only if encrypted for outside
|
||||||
PasswordHint string
|
PasswordHint string
|
||||||
Auth interface{} // TODO
|
Auth interface{} // TODO
|
||||||
Token string
|
Token string
|
||||||
EncToken string
|
EncToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessagePackageSet struct {
|
type MessagePackageSet struct {
|
||||||
Type MessagePackageType // OR of each Type
|
Type MessagePackageType // OR of each Type
|
||||||
Addresses map[string]*MessagePackage
|
Addresses map[string]*MessagePackage
|
||||||
MIMEType string
|
MIMEType string
|
||||||
Body string // Body data packet
|
Body string // Body data packet
|
||||||
|
|
||||||
// Only if cleartext is sent
|
// Only if cleartext is sent
|
||||||
BodyKey string
|
BodyKey string
|
||||||
AttachmentKeys map[string]string
|
AttachmentKeys map[string]string
|
||||||
|
|
||||||
bodyKey *packet.EncryptedKey
|
bodyKey *packet.EncryptedKey
|
||||||
attachmentKeys map[string]*packet.EncryptedKey
|
attachmentKeys map[string]*packet.EncryptedKey
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMessagePackageSet(attachmentKeys map[string]*packet.EncryptedKey) *MessagePackageSet {
|
func NewMessagePackageSet(attachmentKeys map[string]*packet.EncryptedKey) *MessagePackageSet {
|
||||||
return &MessagePackageSet{
|
return &MessagePackageSet{
|
||||||
|
Addresses: make(map[string]*MessagePackage),
|
||||||
attachmentKeys: attachmentKeys,
|
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 {
|
type outgoingMessageWriter struct {
|
||||||
cleartext io.WriteCloser
|
cleartext io.WriteCloser
|
||||||
ciphertext io.WriteCloser
|
ciphertext io.WriteCloser
|
||||||
armored *bytes.Buffer
|
encoded *bytes.Buffer
|
||||||
set *MessagePackageSet
|
set *MessagePackageSet
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *outgoingMessageWriter) Write(p []byte) (int, error) {
|
func (w *outgoingMessageWriter) Write(p []byte) (int, error) {
|
||||||
|
@ -276,23 +264,28 @@ func (w *outgoingMessageWriter) Close() error {
|
||||||
if err := w.ciphertext.Close(); err != nil {
|
if err := w.ciphertext.Close(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.set.Body = w.armored.String()
|
w.set.Body = w.encoded.String()
|
||||||
w.armored = nil
|
w.encoded = nil
|
||||||
return 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{}
|
config := &packet.Config{}
|
||||||
|
|
||||||
if err := set.generateBodyKey(packet.CipherAES256, config); err != nil {
|
key, err := generateUnencryptedKey(packet.CipherAES256, config)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var armored bytes.Buffer
|
|
||||||
ciphertext, err := armor.Encode(&armored, "PGP MESSAGE", nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
encryptedData, err := packet.SerializeSymmetricallyEncrypted(ciphertext, set.bodyKey.CipherFunc, set.bodyKey.Key, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -307,15 +300,16 @@ func (set *MessagePackageSet) Encrypt() (io.WriteCloser, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return &outgoingMessageWriter{
|
return &outgoingMessageWriter{
|
||||||
cleartext: literalData,
|
cleartext: literalData,
|
||||||
ciphertext: ciphertext,
|
ciphertext: ciphertext,
|
||||||
armored: &armored,
|
encoded: &encoded,
|
||||||
set: set,
|
set: set,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (set *MessagePackageSet) AddCleartext(addr string) error {
|
func (set *MessagePackageSet) AddCleartext(addr string) error {
|
||||||
set.Addresses[addr] = &MessagePackage{Type: MessagePackageCleartext}
|
set.Addresses[addr] = &MessagePackage{Type: MessagePackageCleartext}
|
||||||
|
set.Type |= MessagePackageCleartext
|
||||||
|
|
||||||
if set.BodyKey == "" || set.AttachmentKeys == nil {
|
if set.BodyKey == "" || set.AttachmentKeys == nil {
|
||||||
set.BodyKey = base64.StdEncoding.EncodeToString(set.bodyKey.Key)
|
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) {
|
func serializeEncryptedKey(symKey *packet.EncryptedKey, pub *packet.PublicKey, config *packet.Config) (string, error) {
|
||||||
var armored bytes.Buffer
|
var encoded bytes.Buffer
|
||||||
ciphertext, err := armor.Encode(&armored, "PGP MESSAGE", nil)
|
ciphertext := base64.NewEncoder(base64.StdEncoding, &encoded)
|
||||||
|
|
||||||
|
err := packet.SerializeEncryptedKey(ciphertext, pub, symKey.CipherFunc, symKey.Key, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = packet.SerializeEncryptedKey(ciphertext, pub, symKey.CipherFunc, symKey.Key, config)
|
ciphertext.Close()
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return armored.String(), nil
|
return encoded.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (set *MessagePackageSet) AddInternal(addr string, pub *openpgp.Entity) error {
|
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
|
attachmentKeys[att] = attKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set.Type |= MessagePackageInternal
|
||||||
set.Addresses[addr] = &MessagePackage{
|
set.Addresses[addr] = &MessagePackage{
|
||||||
Type: MessagePackageInternal,
|
Type: MessagePackageInternal,
|
||||||
BodyKeyPacket: bodyKey,
|
BodyKeyPacket: bodyKey,
|
||||||
AttachmentKeyPackets: attachmentKeys,
|
AttachmentKeyPackets: attachmentKeys,
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
@ -384,7 +378,7 @@ type OutgoingMessage struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) SendMessage(msg *OutgoingMessage) (sent, parent *Message, err error) {
|
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 {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,6 +82,8 @@ func (c *Client) newJSONRequest(method, path string, body interface{}) (*http.Re
|
||||||
}
|
}
|
||||||
b := buf.Bytes()
|
b := buf.Bytes()
|
||||||
|
|
||||||
|
//log.Printf(">> %v %v\n%v", method, path, string(b))
|
||||||
|
|
||||||
req, err := c.newRequest(method, path, bytes.NewReader(b))
|
req, err := c.newRequest(method, path, bytes.NewReader(b))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
@ -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}
|
||||||
|
}
|
Loading…
Reference in New Issue