forked from Silverfish/proton-bridge
Merge branch 'release/james' into devel
This commit is contained in:
@ -90,7 +90,7 @@ func (l *listener) Add(eventName string, channel chan<- string) {
|
||||
|
||||
log := log.WithField("name", eventName).WithField("i", len(l.channels[eventName]))
|
||||
l.channels[eventName] = append(l.channels[eventName], channel)
|
||||
log.Debug("Added event listner")
|
||||
log.Debug("Added event listener")
|
||||
}
|
||||
|
||||
// Remove removes an event listener.
|
||||
|
||||
244
pkg/message/encrypt.go
Normal file
244
pkg/message/encrypt.go
Normal file
@ -0,0 +1,244 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package message
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
pmmime "github.com/ProtonMail/proton-bridge/pkg/mime"
|
||||
"github.com/emersion/go-message/textproto"
|
||||
)
|
||||
|
||||
func EncryptRFC822(kr *crypto.KeyRing, r io.Reader) ([]byte, error) {
|
||||
b, err := ioutil.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
header, body, err := readHeaderBody(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
result, err := writeEncryptedPart(kr, header, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := textproto.WriteHeader(buf, *header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := result.WriteTo(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeEncryptedPart(kr *crypto.KeyRing, header *textproto.Header, r io.Reader) (io.WriterTo, error) {
|
||||
decoder := getTransferDecoder(r, header.Get("Content-Transfer-Encoding"))
|
||||
encoded := new(bytes.Buffer)
|
||||
|
||||
contentType, contentParams, err := parseContentType(header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch {
|
||||
case contentType == "", strings.HasPrefix(contentType, "text/"), strings.HasPrefix(contentType, "message/"):
|
||||
header.Del("Content-Transfer-Encoding")
|
||||
|
||||
if charset, ok := contentParams["charset"]; ok {
|
||||
if reader, err := pmmime.CharsetReader(charset, decoder); err == nil {
|
||||
decoder = reader
|
||||
|
||||
// We can decode the charset to utf-8 so let's set that as the content type charset parameter.
|
||||
contentParams["charset"] = "utf-8"
|
||||
|
||||
header.Set("Content-Type", mime.FormatMediaType(contentType, contentParams))
|
||||
}
|
||||
}
|
||||
|
||||
if err := encode(&writeCloser{encoded}, func(w io.Writer) error {
|
||||
return writeEncryptedTextPart(w, decoder, kr)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
case contentType == "multipart/encrypted":
|
||||
if _, err := encoded.ReadFrom(decoder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
case strings.HasPrefix(contentType, "multipart/"):
|
||||
if err := encode(&writeCloser{encoded}, func(w io.Writer) error {
|
||||
return writeEncryptedMultiPart(kr, w, header, decoder)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
default:
|
||||
header.Set("Content-Transfer-Encoding", "base64")
|
||||
|
||||
if err := encode(base64.NewEncoder(base64.StdEncoding, encoded), func(w io.Writer) error {
|
||||
return writeEncryptedAttachmentPart(w, decoder, kr)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func writeEncryptedTextPart(w io.Writer, r io.Reader, kr *crypto.KeyRing) error {
|
||||
dec, err := ioutil.ReadAll(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var arm string
|
||||
|
||||
if msg, err := crypto.NewPGPMessageFromArmored(string(dec)); err != nil {
|
||||
enc, err := kr.Encrypt(crypto.NewPlainMessage(dec), kr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if arm, err = enc.GetArmored(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if arm, err = msg.GetArmored(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := io.WriteString(w, arm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeEncryptedAttachmentPart(w io.Writer, r io.Reader, kr *crypto.KeyRing) error {
|
||||
dec, err := ioutil.ReadAll(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
enc, err := kr.Encrypt(crypto.NewPlainMessage(dec), kr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := w.Write(enc.GetBinary()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeEncryptedMultiPart(kr *crypto.KeyRing, w io.Writer, header *textproto.Header, r io.Reader) error {
|
||||
_, contentParams, err := parseContentType(header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scanner, err := newPartScanner(r, contentParams["boundary"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parts, err := scanner.scanAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writer := newPartWriter(w, contentParams["boundary"])
|
||||
|
||||
for _, part := range parts {
|
||||
header, body, err := readHeaderBody(part.b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := writeEncryptedPart(kr, header, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writer.createPart(func(w io.Writer) error {
|
||||
if err := textproto.WriteHeader(w, *header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := result.WriteTo(w); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return writer.done()
|
||||
}
|
||||
|
||||
func getTransferDecoder(r io.Reader, encoding string) io.Reader {
|
||||
switch strings.ToLower(encoding) {
|
||||
case "base64":
|
||||
return base64.NewDecoder(base64.StdEncoding, r)
|
||||
|
||||
case "quoted-printable":
|
||||
return quotedprintable.NewReader(r)
|
||||
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
func encode(wc io.WriteCloser, fn func(io.Writer) error) error {
|
||||
if err := fn(wc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return wc.Close()
|
||||
}
|
||||
|
||||
type writeCloser struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (writeCloser) Close() error { return nil }
|
||||
|
||||
func parseContentType(val string) (string, map[string]string, error) {
|
||||
if val == "" {
|
||||
val = "text/plain"
|
||||
}
|
||||
|
||||
return pmmime.ParseMediaType(val)
|
||||
}
|
||||
101
pkg/message/encrypt_test.go
Normal file
101
pkg/message/encrypt_test.go
Normal file
@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package message
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEncryptRFC822(t *testing.T) {
|
||||
literal, err := ioutil.ReadFile("testdata/text_plain_latin1.eml")
|
||||
require.NoError(t, err)
|
||||
|
||||
key, err := crypto.GenerateKey("name", "email", "rsa", 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
kr, err := crypto.NewKeyRing(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
enc, err := EncryptRFC822(kr, bytes.NewReader(literal))
|
||||
require.NoError(t, err)
|
||||
|
||||
section(t, enc).
|
||||
expectContentType(is(`text/plain`)).
|
||||
expectContentTypeParam(`charset`, is(`utf-8`)).
|
||||
expectBody(decryptsTo(kr, `ééééééé`))
|
||||
}
|
||||
|
||||
func TestEncryptRFC822Multipart(t *testing.T) {
|
||||
literal, err := ioutil.ReadFile("testdata/multipart_alternative_nested.eml")
|
||||
require.NoError(t, err)
|
||||
|
||||
key, err := crypto.GenerateKey("name", "email", "rsa", 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
kr, err := crypto.NewKeyRing(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
enc, err := EncryptRFC822(kr, bytes.NewReader(literal))
|
||||
require.NoError(t, err)
|
||||
|
||||
section(t, enc).
|
||||
expectContentType(is(`multipart/alternative`))
|
||||
|
||||
section(t, enc, 1).
|
||||
expectContentType(is(`multipart/alternative`))
|
||||
|
||||
section(t, enc, 1, 1).
|
||||
expectContentType(is(`text/plain`)).
|
||||
expectBody(decryptsTo(kr, "*multipart 1.1*\n\n"))
|
||||
|
||||
section(t, enc, 1, 2).
|
||||
expectContentType(is(`text/html`)).
|
||||
expectBody(decryptsTo(kr, `<html>
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
<b>multipart 1.2</b>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
|
||||
section(t, enc, 2).
|
||||
expectContentType(is(`multipart/alternative`))
|
||||
|
||||
section(t, enc, 2, 1).
|
||||
expectContentType(is(`text/plain`)).
|
||||
expectBody(decryptsTo(kr, "*multipart 2.1*\n\n"))
|
||||
|
||||
section(t, enc, 2, 2).
|
||||
expectContentType(is(`text/html`)).
|
||||
expectBody(decryptsTo(kr, `<html>
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
<b>multipart 2.2</b>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
}
|
||||
@ -59,30 +59,3 @@ func GetFlags(m *pmapi.Message) (flags []string) {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ParseFlags sets attributes to pmapi messages based on imap flags.
|
||||
func ParseFlags(m *pmapi.Message, flags []string) {
|
||||
if m.Header.Get("received") == "" {
|
||||
m.Flags = pmapi.FlagSent
|
||||
} else {
|
||||
m.Flags = pmapi.FlagReceived
|
||||
}
|
||||
|
||||
m.Unread = true
|
||||
for _, f := range flags {
|
||||
switch f {
|
||||
case imap.SeenFlag:
|
||||
m.Unread = false
|
||||
case imap.DraftFlag:
|
||||
m.Flags &= ^pmapi.FlagSent
|
||||
m.Flags &= ^pmapi.FlagReceived
|
||||
m.LabelIDs = append(m.LabelIDs, pmapi.DraftLabel)
|
||||
case imap.FlaggedFlag:
|
||||
m.LabelIDs = append(m.LabelIDs, pmapi.StarredLabel)
|
||||
case imap.AnsweredFlag:
|
||||
m.Flags |= pmapi.FlagReplied
|
||||
case AppleMailJunkFlag, ThunderbirdJunkFlag:
|
||||
m.LabelIDs = append(m.LabelIDs, pmapi.SpamLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
127
pkg/message/header.go
Normal file
127
pkg/message/header.go
Normal file
@ -0,0 +1,127 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package message
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/emersion/go-message/textproto"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// HeaderLines returns each line in the given header.
|
||||
func HeaderLines(header []byte) [][]byte {
|
||||
var (
|
||||
lines [][]byte
|
||||
quote int
|
||||
)
|
||||
|
||||
forEachLine(bufio.NewReader(bytes.NewReader(header)), func(line []byte) {
|
||||
l := bytes.SplitN(line, []byte(`: `), 2)
|
||||
isLineContinuation := quote%2 != 0 || // no quotes opened
|
||||
len(l) != 2 || // it doesn't have colon
|
||||
(len(l) == 2 && !bytes.Equal(bytes.TrimSpace(l[0]), l[0])) // has white space in front of header field
|
||||
switch {
|
||||
case len(bytes.TrimSpace(line)) == 0:
|
||||
lines = append(lines, line)
|
||||
|
||||
case isLineContinuation:
|
||||
if len(lines) > 0 {
|
||||
lines[len(lines)-1] = append(lines[len(lines)-1], line...)
|
||||
} else {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
default:
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
quote += bytes.Count(line, []byte(`"`))
|
||||
})
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
func forEachLine(br *bufio.Reader, fn func([]byte)) {
|
||||
for {
|
||||
b, err := br.ReadBytes('\n')
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if len(b) > 0 {
|
||||
fn(b)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fn(b)
|
||||
}
|
||||
}
|
||||
|
||||
func readHeaderBody(b []byte) (*textproto.Header, []byte, error) {
|
||||
rawHeader, body, err := splitHeaderBody(b)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var header textproto.Header
|
||||
|
||||
for _, line := range HeaderLines(rawHeader) {
|
||||
if len(bytes.TrimSpace(line)) > 0 {
|
||||
header.AddRaw(line)
|
||||
}
|
||||
}
|
||||
|
||||
return &header, body, nil
|
||||
}
|
||||
|
||||
func splitHeaderBody(b []byte) ([]byte, []byte, error) {
|
||||
br := bufio.NewReader(bytes.NewReader(b))
|
||||
|
||||
var header []byte
|
||||
|
||||
for {
|
||||
b, err := br.ReadBytes('\n')
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
header = append(header, b...)
|
||||
|
||||
if len(bytes.TrimSpace(b)) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(br)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return header, body, nil
|
||||
}
|
||||
81
pkg/message/header_test.go
Normal file
81
pkg/message/header_test.go
Normal file
@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package message
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHeaderLines(t *testing.T) {
|
||||
want := [][]byte{
|
||||
[]byte("To: somebody\r\n"),
|
||||
[]byte("From: somebody else\r\n"),
|
||||
[]byte("Subject: RE: this is\r\n\ta multiline field: with colon\r\n\tor: many: more: colons\r\n"),
|
||||
[]byte("X-Special: \r\n\tNothing on the first line\r\n\tbut has something on the other lines\r\n"),
|
||||
[]byte("\r\n"),
|
||||
}
|
||||
var header []byte
|
||||
for _, line := range want {
|
||||
header = append(header, line...)
|
||||
}
|
||||
|
||||
assert.Equal(t, want, HeaderLines(header))
|
||||
}
|
||||
|
||||
func TestHeaderLinesMultilineFilename(t *testing.T) {
|
||||
const header = "Content-Type: application/msword; name=\"this is a very long\nfilename.doc\""
|
||||
|
||||
assert.Equal(t, [][]byte{
|
||||
[]byte("Content-Type: application/msword; name=\"this is a very long\nfilename.doc\""),
|
||||
}, HeaderLines([]byte(header)))
|
||||
}
|
||||
|
||||
func TestHeaderLinesMultilineFilenameWithColon(t *testing.T) {
|
||||
const header = "Content-Type: application/msword; name=\"this is a very long\nfilename: too long.doc\""
|
||||
|
||||
assert.Equal(t, [][]byte{
|
||||
[]byte("Content-Type: application/msword; name=\"this is a very long\nfilename: too long.doc\""),
|
||||
}, HeaderLines([]byte(header)))
|
||||
}
|
||||
|
||||
func TestHeaderLinesMultilineFilenameWithColonAndNewline(t *testing.T) {
|
||||
const header = "Content-Type: application/msword; name=\"this is a very long\nfilename: too long.doc\"\n"
|
||||
|
||||
assert.Equal(t, [][]byte{
|
||||
[]byte("Content-Type: application/msword; name=\"this is a very long\nfilename: too long.doc\"\n"),
|
||||
}, HeaderLines([]byte(header)))
|
||||
}
|
||||
|
||||
func TestHeaderLinesMultipleMultilineFilenames(t *testing.T) {
|
||||
const header = `Content-Type: application/msword; name="=E5=B8=B6=E6=9C=89=E5=A4=96=E5=9C=8B=E5=AD=97=E7=AC=A6=E7=9A=84=E9=99=84=E4=
|
||||
=BB=B6.DOC"
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: attachment; filename="=E5=B8=B6=E6=9C=89=E5=A4=96=E5=9C=8B=E5=AD=97=E7=AC=A6=E7=9A=84=E9=99=84=E4=
|
||||
=BB=B6.DOC"
|
||||
Content-ID: <>
|
||||
`
|
||||
|
||||
assert.Equal(t, [][]byte{
|
||||
[]byte("Content-Type: application/msword; name=\"=E5=B8=B6=E6=9C=89=E5=A4=96=E5=9C=8B=E5=AD=97=E7=AC=A6=E7=9A=84=E9=99=84=E4=\n=BB=B6.DOC\"\n"),
|
||||
[]byte("Content-Transfer-Encoding: base64\n"),
|
||||
[]byte("Content-Disposition: attachment; filename=\"=E5=B8=B6=E6=9C=89=E5=A4=96=E5=9C=8B=E5=AD=97=E7=AC=A6=E7=9A=84=E9=99=84=E4=\n=BB=B6.DOC\"\n"),
|
||||
[]byte("Content-ID: <>\n"),
|
||||
}, HeaderLines([]byte(header)))
|
||||
}
|
||||
96
pkg/message/scanner.go
Normal file
96
pkg/message/scanner.go
Normal file
@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package message
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
type partScanner struct {
|
||||
r *bufio.Reader
|
||||
|
||||
boundary string
|
||||
progress int
|
||||
}
|
||||
|
||||
type part struct {
|
||||
b []byte
|
||||
offset int
|
||||
}
|
||||
|
||||
func newPartScanner(r io.Reader, boundary string) (*partScanner, error) {
|
||||
scanner := &partScanner{r: bufio.NewReader(r), boundary: boundary}
|
||||
|
||||
if _, _, err := scanner.readToBoundary(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return scanner, nil
|
||||
}
|
||||
|
||||
func (s *partScanner) scanAll() ([]part, error) {
|
||||
var parts []part
|
||||
|
||||
for {
|
||||
offset := s.progress
|
||||
|
||||
b, more, err := s.readToBoundary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !more {
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
parts = append(parts, part{b: b, offset: offset})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *partScanner) readToBoundary() ([]byte, bool, error) {
|
||||
var res []byte
|
||||
|
||||
for {
|
||||
line, err := s.r.ReadBytes('\n')
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(line) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
s.progress += len(line)
|
||||
|
||||
switch {
|
||||
case bytes.HasPrefix(bytes.TrimSpace(line), []byte("--"+s.boundary)):
|
||||
return bytes.TrimSuffix(bytes.TrimSuffix(res, []byte("\n")), []byte("\r")), true, nil
|
||||
|
||||
case bytes.HasSuffix(bytes.TrimSpace(line), []byte(s.boundary+"--")):
|
||||
return bytes.TrimSuffix(bytes.TrimSuffix(res, []byte("\n")), []byte("\r")), false, nil
|
||||
|
||||
default:
|
||||
res = append(res, line...)
|
||||
}
|
||||
}
|
||||
}
|
||||
136
pkg/message/scanner_test.go
Normal file
136
pkg/message/scanner_test.go
Normal file
@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package message
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestScanner(t *testing.T) {
|
||||
const literal = `this part of the text should be ignored
|
||||
|
||||
--longrandomstring
|
||||
|
||||
body1
|
||||
|
||||
--longrandomstring
|
||||
|
||||
body2
|
||||
|
||||
--longrandomstring--
|
||||
`
|
||||
|
||||
scanner, err := newPartScanner(strings.NewReader(literal), "longrandomstring")
|
||||
require.NoError(t, err)
|
||||
|
||||
parts, err := scanner.scanAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "\nbody1\n", string(parts[0].b))
|
||||
assert.Equal(t, "\nbody2\n", string(parts[1].b))
|
||||
|
||||
assert.Equal(t, "\nbody1\n", literal[parts[0].offset:parts[0].offset+len(parts[0].b)])
|
||||
assert.Equal(t, "\nbody2\n", literal[parts[1].offset:parts[1].offset+len(parts[1].b)])
|
||||
}
|
||||
|
||||
func TestScannerNested(t *testing.T) {
|
||||
const literal = `This is the preamble. It is to be ignored, though it
|
||||
is a handy place for mail composers to include an
|
||||
explanatory note to non-MIME compliant readers.
|
||||
--simple boundary
|
||||
Content-type: multipart/mixed; boundary="nested boundary"
|
||||
|
||||
This is the preamble. It is to be ignored, though it
|
||||
is a handy place for mail composers to include an
|
||||
explanatory note to non-MIME compliant readers.
|
||||
--nested boundary
|
||||
Content-type: text/plain; charset=us-ascii
|
||||
|
||||
This part does not end with a linebreak.
|
||||
--nested boundary
|
||||
Content-type: text/plain; charset=us-ascii
|
||||
|
||||
This part does end with a linebreak.
|
||||
|
||||
--nested boundary--
|
||||
--simple boundary
|
||||
Content-type: text/plain; charset=us-ascii
|
||||
|
||||
This part does end with a linebreak.
|
||||
|
||||
--simple boundary--
|
||||
This is the epilogue. It is also to be ignored.
|
||||
`
|
||||
|
||||
scanner, err := newPartScanner(strings.NewReader(literal), "simple boundary")
|
||||
require.NoError(t, err)
|
||||
|
||||
parts, err := scanner.scanAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, `Content-type: multipart/mixed; boundary="nested boundary"
|
||||
|
||||
This is the preamble. It is to be ignored, though it
|
||||
is a handy place for mail composers to include an
|
||||
explanatory note to non-MIME compliant readers.
|
||||
--nested boundary
|
||||
Content-type: text/plain; charset=us-ascii
|
||||
|
||||
This part does not end with a linebreak.
|
||||
--nested boundary
|
||||
Content-type: text/plain; charset=us-ascii
|
||||
|
||||
This part does end with a linebreak.
|
||||
|
||||
--nested boundary--`, string(parts[0].b))
|
||||
assert.Equal(t, `Content-type: text/plain; charset=us-ascii
|
||||
|
||||
This part does end with a linebreak.
|
||||
`, string(parts[1].b))
|
||||
}
|
||||
|
||||
func TestScannerNoFinalLinebreak(t *testing.T) {
|
||||
const literal = `--nested boundary
|
||||
Content-type: text/plain; charset=us-ascii
|
||||
|
||||
This part does not end with a linebreak.
|
||||
--nested boundary
|
||||
Content-type: text/plain; charset=us-ascii
|
||||
|
||||
This part does end with a linebreak.
|
||||
|
||||
--nested boundary--`
|
||||
|
||||
scanner, err := newPartScanner(strings.NewReader(literal), "nested boundary")
|
||||
require.NoError(t, err)
|
||||
|
||||
parts, err := scanner.scanAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, `Content-type: text/plain; charset=us-ascii
|
||||
|
||||
This part does not end with a linebreak.`, string(parts[0].b))
|
||||
assert.Equal(t, `Content-type: text/plain; charset=us-ascii
|
||||
|
||||
This part does end with a linebreak.
|
||||
`, string(parts[1].b))
|
||||
}
|
||||
@ -212,6 +212,13 @@ func (bs *BodyStructure) hasInfo(sectionPath []int) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (bs *BodyStructure) getInfoCheckSection(sectionPath []int) (sectionInfo *SectionInfo, err error) {
|
||||
if len(*bs) == 1 && len(sectionPath) == 1 && sectionPath[0] == 1 {
|
||||
sectionPath = []int{}
|
||||
}
|
||||
return bs.getInfo(sectionPath)
|
||||
}
|
||||
|
||||
func (bs *BodyStructure) getInfo(sectionPath []int) (sectionInfo *SectionInfo, err error) {
|
||||
path := stringPathFromInts(sectionPath)
|
||||
sectionInfo, ok := (*bs)[path]
|
||||
@ -223,7 +230,7 @@ func (bs *BodyStructure) getInfo(sectionPath []int) (sectionInfo *SectionInfo, e
|
||||
|
||||
// GetSection returns bytes of section including MIME header.
|
||||
func (bs *BodyStructure) GetSection(wholeMail io.ReadSeeker, sectionPath []int) (section []byte, err error) {
|
||||
info, err := bs.getInfo(sectionPath)
|
||||
info, err := bs.getInfoCheckSection(sectionPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -232,7 +239,7 @@ func (bs *BodyStructure) GetSection(wholeMail io.ReadSeeker, sectionPath []int)
|
||||
|
||||
// GetSectionContent returns bytes of section content (excluding MIME header).
|
||||
func (bs *BodyStructure) GetSectionContent(wholeMail io.ReadSeeker, sectionPath []int) (section []byte, err error) {
|
||||
info, err := bs.getInfo(sectionPath)
|
||||
info, err := bs.getInfoCheckSection(sectionPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -251,8 +258,11 @@ func (bs *BodyStructure) GetMailHeaderBytes(wholeMail io.ReadSeeker) (header []b
|
||||
}
|
||||
|
||||
func goToOffsetAndReadNBytes(wholeMail io.ReadSeeker, offset, length int) ([]byte, error) {
|
||||
if length < 1 {
|
||||
return nil, errors.New("requested non positive length")
|
||||
if length == 0 {
|
||||
return []byte{}, nil
|
||||
}
|
||||
if length < 0 {
|
||||
return nil, errors.New("requested negative length")
|
||||
}
|
||||
if offset > 0 {
|
||||
if _, err := wholeMail.Seek(int64(offset), io.SeekStart); err != nil {
|
||||
@ -266,7 +276,7 @@ func goToOffsetAndReadNBytes(wholeMail io.ReadSeeker, offset, length int) ([]byt
|
||||
|
||||
// GetSectionHeader returns the mime header of specified section.
|
||||
func (bs *BodyStructure) GetSectionHeader(sectionPath []int) (header textproto.MIMEHeader, err error) {
|
||||
info, err := bs.getInfo(sectionPath)
|
||||
info, err := bs.getInfoCheckSection(sectionPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -275,7 +285,7 @@ func (bs *BodyStructure) GetSectionHeader(sectionPath []int) (header textproto.M
|
||||
}
|
||||
|
||||
func (bs *BodyStructure) GetSectionHeaderBytes(wholeMail io.ReadSeeker, sectionPath []int) (header []byte, err error) {
|
||||
info, err := bs.getInfo(sectionPath)
|
||||
info, err := bs.getInfoCheckSection(sectionPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@ -82,6 +82,14 @@ func TestGetSection(t *testing.T) {
|
||||
structReader := strings.NewReader(sampleMail)
|
||||
bs, err := NewBodyStructure(structReader)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Bad paths
|
||||
wantPaths := [][]int{{0}, {-1}, {3, 2, 3}}
|
||||
for _, wantPath := range wantPaths {
|
||||
_, err = bs.getInfo(wantPath)
|
||||
require.Error(t, err, "path %v", wantPath)
|
||||
}
|
||||
|
||||
// Whole section.
|
||||
for _, try := range testPaths {
|
||||
mailReader := strings.NewReader(sampleMail)
|
||||
@ -108,6 +116,60 @@ func TestGetSection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSecionNoMIMEParts(t *testing.T) {
|
||||
wantBody := "This is just a simple mail with no multipart structure.\n"
|
||||
wantHeader := `Subject: Sample mail
|
||||
From: John Doe <jdoe@machine.example>
|
||||
To: Mary Smith <mary@example.net>
|
||||
Date: Fri, 21 Nov 1997 09:55:06 -0600
|
||||
Content-Type: plain/text
|
||||
|
||||
`
|
||||
wantMail := wantHeader + wantBody
|
||||
|
||||
r := require.New(t)
|
||||
bs, err := NewBodyStructure(strings.NewReader(wantMail))
|
||||
r.NoError(err)
|
||||
|
||||
// Bad parts
|
||||
wantPaths := [][]int{{0}, {2}, {1, 2, 3}}
|
||||
for _, wantPath := range wantPaths {
|
||||
_, err = bs.getInfoCheckSection(wantPath)
|
||||
r.Error(err, "path %v: %d %d\n__\n%s\n", wantPath)
|
||||
}
|
||||
|
||||
debug := func(wantPath []int, info *SectionInfo, section []byte) string {
|
||||
if info == nil {
|
||||
info = &SectionInfo{}
|
||||
}
|
||||
return fmt.Sprintf("path %v %q: %d %d\n___\n%s\n‾‾‾\n",
|
||||
wantPath, stringPathFromInts(wantPath), info.Start, info.Size,
|
||||
string(section),
|
||||
)
|
||||
}
|
||||
|
||||
// Ok Parts
|
||||
wantPaths = [][]int{{}, {1}}
|
||||
for _, p := range wantPaths {
|
||||
wantPath := append([]int{}, p...)
|
||||
|
||||
info, err := bs.getInfoCheckSection(wantPath)
|
||||
r.NoError(err, debug(wantPath, info, []byte{}))
|
||||
|
||||
section, err := bs.GetSection(strings.NewReader(wantMail), wantPath)
|
||||
r.NoError(err, debug(wantPath, info, section))
|
||||
r.Equal(wantMail, string(section), debug(wantPath, info, section))
|
||||
|
||||
haveBody, err := bs.GetSectionContent(strings.NewReader(wantMail), wantPath)
|
||||
r.NoError(err, debug(wantPath, info, haveBody))
|
||||
r.Equal(wantBody, string(haveBody), debug(wantPath, info, haveBody))
|
||||
|
||||
haveHeader, err := bs.GetSectionHeaderBytes(strings.NewReader(wantMail), wantPath)
|
||||
r.NoError(err, debug(wantPath, info, haveHeader))
|
||||
r.Equal(wantHeader, string(haveHeader), debug(wantPath, info, haveHeader))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMainHeaderBytes(t *testing.T) {
|
||||
wantHeader := []byte(`Subject: Sample mail
|
||||
From: John Doe <jdoe@machine.example>
|
||||
|
||||
48
pkg/message/writer.go
Normal file
48
pkg/message/writer.go
Normal file
@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package message
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
type partWriter struct {
|
||||
w io.Writer
|
||||
boundary string
|
||||
}
|
||||
|
||||
func newPartWriter(w io.Writer, boundary string) *partWriter {
|
||||
return &partWriter{w: w, boundary: boundary}
|
||||
}
|
||||
|
||||
func (w *partWriter) createPart(fn func(io.Writer) error) error {
|
||||
if _, err := fmt.Fprintf(w.w, "\r\n--%v\r\n", w.boundary); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fn(w.w)
|
||||
}
|
||||
|
||||
func (w *partWriter) done() error {
|
||||
if _, err := fmt.Fprintf(w.w, "\r\n--%v--\r\n", w.boundary); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -33,16 +33,24 @@ import (
|
||||
|
||||
pmmime "github.com/ProtonMail/proton-bridge/pkg/mime"
|
||||
|
||||
a "github.com/stretchr/testify/assert"
|
||||
r "github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testAttachmentCleartext = `cc,
|
||||
dille.
|
||||
`
|
||||
|
||||
// Attachment cleartext encrypted with testPrivateKeyRing.
|
||||
const testKeyPacket = `wcBMA0fcZ7XLgmf2AQf/cHhfDRM9zlIuBi+h2W6DKjbbyIHMkgF6ER3JEvn/tSruUH8KTGt0N7Z+a80FFMCuXn1Y1I/nW7MVrNhGuJZAF4OymD8ugvuoAMIQX0eCYEpPXzRIWJBZg82AuowmFMsv8Dgvq4bTZq4cttI3CZcxKUNXuAearmNpmgplUKWj5USmRXK4iGB3VFGjidXkxbElrP4fD5A/rfEZ5aJgCsegqcXxX3MEjWXi9pFzgd/9phOvl1ZFm9U9hNoVAW3QsgmVeihnKaDZUyf2Qsigij21QKAUxw9U3y89eTUIqZAcmIgqeDujA3RWBgJwjtY/lOyhEmkf3AWKzehvf1xtJmCWDg==`
|
||||
const testDataPacket = `0ksB6S4f4l8C1NB8yzmd/jNi0xqEZsyTDLdTP+N4Qxh3NZjla+yGRvC9rGmoUL7XVyowsG/GKTf2LXF/5E5FkX/3WMYwIv1n11ExyAE=`
|
||||
|
||||
var testAttachment = &Attachment{
|
||||
ID: "y6uKIlc2HdoHPAwPSrvf7dXoZNMYvBgxshYUN67cY5DJjL2O8NYewuvGHcYvCfd8LpEoAI_GdymO0Jr0mHlsEw==",
|
||||
Name: "croutonmail.txt",
|
||||
Size: 77,
|
||||
MIMEType: "text/plain",
|
||||
KeyPackets: "wcBMA0fcZ7XLgmf2AQgAiRsOlnm1kSB4/lr7tYe6pBsRGn10GqwUhrwU5PMKOHdCgnO12jO3y3CzP0Yl/jGhAYja9wLDqH8X0sk3tY32u4Sb1Qe5IuzggAiCa4dwOJj5gEFMTHMzjIMPHR7A70XqUxMhmILye8V4KRm/j4c1sxbzA1rM3lYBumQuB5l/ck0Kgt4ZqxHVXHK5Q1l65FHhSXRj8qnunasHa30TYNzP8nmBA8BinnJxpiQ7FGc2umnUhgkFtjm5ixu9vyjr9ukwDTbwAXXfmY+o7tK7kqIXJcmTL6k2UeC6Mz1AagQtRCRtU+bv/3zGojq/trZo9lom3naIeQYa36Ketmcpj2Qwjg==",
|
||||
KeyPackets: testKeyPacket,
|
||||
|
||||
Header: textproto.MIMEHeader{
|
||||
"Content-Description": {"You'll never believe what's in this text file"},
|
||||
"X-Mailer": {"Microsoft Outlook 15.0", "Microsoft Live Mail 42.0"},
|
||||
@ -50,12 +58,13 @@ var testAttachment = &Attachment{
|
||||
MessageID: "h3CD-DT7rLoAw1vmpcajvIPAl-wwDfXR2MHtWID3wuQURDBKTiGUAwd6E2WBbS44QQKeXImW-axm6X0hAfcVCA==",
|
||||
}
|
||||
|
||||
// Part of GET /mail/messages/{id} response from server.
|
||||
const testAttachmentJSON = `{
|
||||
"ID": "y6uKIlc2HdoHPAwPSrvf7dXoZNMYvBgxshYUN67cY5DJjL2O8NYewuvGHcYvCfd8LpEoAI_GdymO0Jr0mHlsEw==",
|
||||
"Name": "croutonmail.txt",
|
||||
"Size": 77,
|
||||
"MIMEType": "text/plain",
|
||||
"KeyPackets": "wcBMA0fcZ7XLgmf2AQgAiRsOlnm1kSB4/lr7tYe6pBsRGn10GqwUhrwU5PMKOHdCgnO12jO3y3CzP0Yl/jGhAYja9wLDqH8X0sk3tY32u4Sb1Qe5IuzggAiCa4dwOJj5gEFMTHMzjIMPHR7A70XqUxMhmILye8V4KRm/j4c1sxbzA1rM3lYBumQuB5l/ck0Kgt4ZqxHVXHK5Q1l65FHhSXRj8qnunasHa30TYNzP8nmBA8BinnJxpiQ7FGc2umnUhgkFtjm5ixu9vyjr9ukwDTbwAXXfmY+o7tK7kqIXJcmTL6k2UeC6Mz1AagQtRCRtU+bv/3zGojq/trZo9lom3naIeQYa36Ketmcpj2Qwjg==",
|
||||
"KeyPackets": "` + testKeyPacket + `",
|
||||
"Headers": {
|
||||
"content-description": "You'll never believe what's in this text file",
|
||||
"x-mailer": [
|
||||
@ -66,68 +75,66 @@ const testAttachmentJSON = `{
|
||||
}
|
||||
`
|
||||
|
||||
const testAttachmentCleartext = `cc,
|
||||
dille.
|
||||
`
|
||||
|
||||
const testAttachmentEncrypted = `wcBMA0fcZ7XLgmf2AQf/cHhfDRM9zlIuBi+h2W6DKjbbyIHMkgF6ER3JEvn/tSruUH8KTGt0N7Z+a80FFMCuXn1Y1I/nW7MVrNhGuJZAF4OymD8ugvuoAMIQX0eCYEpPXzRIWJBZg82AuowmFMsv8Dgvq4bTZq4cttI3CZcxKUNXuAearmNpmgplUKWj5USmRXK4iGB3VFGjidXkxbElrP4fD5A/rfEZ5aJgCsegqcXxX3MEjWXi9pFzgd/9phOvl1ZFm9U9hNoVAW3QsgmVeihnKaDZUyf2Qsigij21QKAUxw9U3y89eTUIqZAcmIgqeDujA3RWBgJwjtY/lOyhEmkf3AWKzehvf1xtJmCWDtJLAekuH+JfAtTQfMs5nf4zYtMahGbMkwy3Uz/jeEMYdzWY5WvshkbwvaxpqFC+11cqMLBvxik39i1xf+RORZF/91jGMCL9Z9dRMcgB`
|
||||
|
||||
const testCreateAttachmentBody = `{
|
||||
// POST /mail/attachment/ response from server.
|
||||
const testCreatedAttachmentBody = `{
|
||||
"Code": 1000,
|
||||
"Attachment": {"ID": "y6uKIlc2HdoHPAwPSrvf7dXoZNMYvBgxshYUN67cY5DJjL2O8NYewuvGHcYvCfd8LpEoAI_GdymO0Jr0mHlsEw=="}
|
||||
}`
|
||||
|
||||
func TestAttachment_UnmarshalJSON(t *testing.T) {
|
||||
r := require.New(t)
|
||||
att := new(Attachment)
|
||||
err := json.Unmarshal([]byte(testAttachmentJSON), att)
|
||||
r.NoError(t, err)
|
||||
r.NoError(err)
|
||||
|
||||
att.MessageID = testAttachment.MessageID // This isn't in the JSON object
|
||||
att.MessageID = testAttachment.MessageID // This isn't in the server response
|
||||
|
||||
r.Equal(t, testAttachment, att)
|
||||
r.Equal(testAttachment, att)
|
||||
}
|
||||
|
||||
func TestClient_CreateAttachment(t *testing.T) {
|
||||
r := require.New(t)
|
||||
s, c := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
r.NoError(t, checkMethodAndPath(req, "POST", "/mail/v4/attachments"))
|
||||
r.NoError(checkMethodAndPath(req, "POST", "/mail/v4/attachments"))
|
||||
|
||||
contentType, params, err := pmmime.ParseMediaType(req.Header.Get("Content-Type"))
|
||||
r.NoError(t, err)
|
||||
r.Equal(t, "multipart/form-data", contentType)
|
||||
r.NoError(err)
|
||||
r.Equal("multipart/form-data", contentType)
|
||||
|
||||
mr := multipart.NewReader(req.Body, params["boundary"])
|
||||
form, err := mr.ReadForm(10 * 1024)
|
||||
r.NoError(t, err)
|
||||
defer r.NoError(t, form.RemoveAll())
|
||||
r.NoError(err)
|
||||
defer r.NoError(form.RemoveAll())
|
||||
|
||||
r.Equal(t, testAttachment.Name, form.Value["Filename"][0])
|
||||
r.Equal(t, testAttachment.MessageID, form.Value["MessageID"][0])
|
||||
r.Equal(t, testAttachment.MIMEType, form.Value["MIMEType"][0])
|
||||
r.Equal(testAttachment.Name, form.Value["Filename"][0])
|
||||
r.Equal(testAttachment.MessageID, form.Value["MessageID"][0])
|
||||
r.Equal(testAttachment.MIMEType, form.Value["MIMEType"][0])
|
||||
|
||||
dataFile, err := form.File["DataPacket"][0].Open()
|
||||
r.NoError(t, err)
|
||||
defer r.NoError(t, dataFile.Close())
|
||||
r.NoError(err)
|
||||
defer r.NoError(dataFile.Close())
|
||||
|
||||
b, err := ioutil.ReadAll(dataFile)
|
||||
r.NoError(t, err)
|
||||
r.Equal(t, testAttachmentCleartext, string(b))
|
||||
r.NoError(err)
|
||||
r.Equal(testAttachmentCleartext, string(b))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Fprint(w, testCreateAttachmentBody)
|
||||
fmt.Fprint(w, testCreatedAttachmentBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
reader := strings.NewReader(testAttachmentCleartext) // In reality, this thing is encrypted
|
||||
created, err := c.CreateAttachment(context.Background(), testAttachment, reader, strings.NewReader(""))
|
||||
r.NoError(t, err)
|
||||
r.NoError(err)
|
||||
|
||||
r.Equal(t, testAttachment.ID, created.ID)
|
||||
r.Equal(testAttachment.ID, created.ID)
|
||||
}
|
||||
|
||||
func TestClient_GetAttachment(t *testing.T) {
|
||||
r := require.New(t)
|
||||
s, c := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
r.NoError(t, checkMethodAndPath(req, "GET", "/mail/v4/attachments/"+testAttachment.ID))
|
||||
r.NoError(checkMethodAndPath(req, "GET", "/mail/v4/attachments/"+testAttachment.ID))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, testAttachmentCleartext)
|
||||
@ -135,39 +142,61 @@ func TestClient_GetAttachment(t *testing.T) {
|
||||
defer s.Close()
|
||||
|
||||
att, err := c.GetAttachment(context.Background(), testAttachment.ID)
|
||||
r.NoError(t, err)
|
||||
r.NoError(err)
|
||||
defer att.Close() //nolint[errcheck]
|
||||
|
||||
// In reality, r contains encrypted data
|
||||
b, err := ioutil.ReadAll(att)
|
||||
r.NoError(t, err)
|
||||
r.NoError(err)
|
||||
|
||||
r.Equal(t, testAttachmentCleartext, string(b))
|
||||
r.Equal(testAttachmentCleartext, string(b))
|
||||
}
|
||||
|
||||
func TestAttachment_Encrypt(t *testing.T) {
|
||||
data := bytes.NewBufferString(testAttachmentCleartext)
|
||||
r, err := testAttachment.Encrypt(testPublicKeyRing, data)
|
||||
a.Nil(t, err)
|
||||
b, err := ioutil.ReadAll(r)
|
||||
a.Nil(t, err)
|
||||
func TestAttachmentDecrypt(t *testing.T) {
|
||||
r := require.New(t)
|
||||
|
||||
// Result is always different, so the best way is to test it by decrypting again.
|
||||
// Another test for decrypting will help us to be sure it's working.
|
||||
dataEnc := bytes.NewBuffer(b)
|
||||
decryptAndCheck(t, dataEnc)
|
||||
rawKeyPacket, err := base64.StdEncoding.DecodeString(testKeyPacket)
|
||||
r.NoError(err)
|
||||
|
||||
rawDataPacket, err := base64.StdEncoding.DecodeString(testDataPacket)
|
||||
r.NoError(err)
|
||||
|
||||
decryptAndCheck(r, bytes.NewBuffer(append(rawKeyPacket, rawDataPacket...)))
|
||||
}
|
||||
|
||||
func TestAttachment_Decrypt(t *testing.T) {
|
||||
dataBytes, _ := base64.StdEncoding.DecodeString(testAttachmentEncrypted)
|
||||
dataReader := bytes.NewBuffer(dataBytes)
|
||||
decryptAndCheck(t, dataReader)
|
||||
func TestAttachmentEncrypt(t *testing.T) {
|
||||
r := require.New(t)
|
||||
|
||||
encryptedReader, err := testAttachment.Encrypt(
|
||||
testPublicKeyRing,
|
||||
bytes.NewBufferString(testAttachmentCleartext),
|
||||
)
|
||||
r.NoError(err)
|
||||
|
||||
// The result is always different due to session key. The best way is to
|
||||
// test result of encryption by decrypting again acn coparet to cleartext.
|
||||
decryptAndCheck(r, encryptedReader)
|
||||
}
|
||||
|
||||
func decryptAndCheck(t *testing.T, data io.Reader) {
|
||||
r, err := testAttachment.Decrypt(data, testPrivateKeyRing)
|
||||
a.Nil(t, err)
|
||||
b, err := ioutil.ReadAll(r)
|
||||
a.Nil(t, err)
|
||||
a.Equal(t, testAttachmentCleartext, string(b))
|
||||
func decryptAndCheck(r *require.Assertions, data io.Reader) {
|
||||
// First separate KeyPacket from encrypted data. In our case keypacket
|
||||
// has 271 bytes.
|
||||
raw, err := ioutil.ReadAll(data)
|
||||
r.NoError(err)
|
||||
rawKeyPacket := raw[:271]
|
||||
rawDataPacket := raw[271:]
|
||||
|
||||
// KeyPacket is retrieve by get GET /mail/messages/{id}
|
||||
haveAttachment := &Attachment{
|
||||
KeyPackets: base64.StdEncoding.EncodeToString(rawKeyPacket),
|
||||
}
|
||||
|
||||
// DataPacket is received from GET /mail/attachments/{id}
|
||||
decryptedReader, err := haveAttachment.Decrypt(bytes.NewBuffer(rawDataPacket), testPrivateKeyRing)
|
||||
r.NoError(err)
|
||||
|
||||
b, err := ioutil.ReadAll(decryptedReader)
|
||||
r.NoError(err)
|
||||
|
||||
r.Equal(testAttachmentCleartext, string(b))
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func loadPMKeys(jsonKeys string) (keys *PMKeys) {
|
||||
@ -31,6 +31,7 @@ func loadPMKeys(jsonKeys string) (keys *PMKeys) {
|
||||
}
|
||||
|
||||
func TestPMKeys_GetKeyRingAndUnlock(t *testing.T) {
|
||||
r := require.New(t)
|
||||
addrKeysWithTokens := loadPMKeys(readTestFile("keyring_addressKeysWithTokens_JSON", false))
|
||||
addrKeysWithoutTokens := loadPMKeys(readTestFile("keyring_addressKeysWithoutTokens_JSON", false))
|
||||
addrKeysPrimaryHasToken := loadPMKeys(readTestFile("keyring_addressKeysPrimaryHasToken_JSON", false))
|
||||
@ -42,7 +43,7 @@ func TestPMKeys_GetKeyRingAndUnlock(t *testing.T) {
|
||||
}
|
||||
|
||||
userKey, err := crypto.NewKeyRing(key)
|
||||
assert.NoError(t, err, "Expected not to receive an error unlocking user key")
|
||||
r.NoError(err, "Expected not to receive an error unlocking user key")
|
||||
|
||||
type args struct {
|
||||
userKeyring *crypto.KeyRing
|
||||
@ -77,9 +78,7 @@ func TestPMKeys_GetKeyRingAndUnlock(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
kr, err := tt.keys.UnlockAll(tt.args.passphrase, tt.args.userKeyring) // nolint[scopelint]
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
r.NoError(err)
|
||||
|
||||
// assert at least one key has been decrypted
|
||||
atLeastOneDecrypted := false
|
||||
@ -96,7 +95,21 @@ func TestPMKeys_GetKeyRingAndUnlock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, atLeastOneDecrypted)
|
||||
r.True(atLeastOneDecrypted)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGopenpgpEncryptAttachment(t *testing.T) {
|
||||
r := require.New(t)
|
||||
|
||||
wantMessage := crypto.NewPlainMessage([]byte(testAttachmentCleartext))
|
||||
|
||||
pgpSplitMessage, err := testPublicKeyRing.EncryptAttachment(wantMessage, "")
|
||||
r.NoError(err)
|
||||
|
||||
haveMessage, err := testPrivateKeyRing.DecryptAttachment(pgpSplitMessage)
|
||||
r.NoError(err)
|
||||
|
||||
r.Equal(wantMessage.Data, haveMessage.Data)
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ import (
|
||||
"encoding/base64"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/pkg/srp"
|
||||
"github.com/ProtonMail/go-srp"
|
||||
)
|
||||
|
||||
func (m *manager) NewClient(uid, acc, ref string, exp time.Time) Client {
|
||||
@ -44,7 +44,7 @@ func (m *manager) NewClientWithRefresh(ctx context.Context, uid, ref string) (Cl
|
||||
return c.withAuth(auth.AccessToken, auth.RefreshToken, expiresIn(auth.ExpiresIn)), auth, nil
|
||||
}
|
||||
|
||||
func (m *manager) NewClientWithLogin(ctx context.Context, username, password string) (Client, *Auth, error) {
|
||||
func (m *manager) NewClientWithLogin(ctx context.Context, username string, password []byte) (Client, *Auth, error) {
|
||||
log.Trace("New client with login")
|
||||
|
||||
info, err := m.getAuthInfo(ctx, GetAuthInfoReq{Username: username})
|
||||
@ -52,12 +52,12 @@ func (m *manager) NewClientWithLogin(ctx context.Context, username, password str
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
srpAuth, err := srp.NewSrpAuth(info.Version, username, password, info.Salt, info.Modulus, info.ServerEphemeral)
|
||||
srpAuth, err := srp.NewAuth(info.Version, username, password, info.Salt, info.Modulus, info.ServerEphemeral)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
proofs, err := srpAuth.GenerateSrpProofs(2048)
|
||||
proofs, err := srpAuth.GenerateProofs(2048)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ import (
|
||||
type Manager interface {
|
||||
NewClient(string, string, string, time.Time) Client
|
||||
NewClientWithRefresh(context.Context, string, string) (Client, *AuthRefresh, error)
|
||||
NewClientWithLogin(context.Context, string, string) (Client, *Auth, error)
|
||||
NewClientWithLogin(context.Context, string, []byte) (Client, *Auth, error)
|
||||
|
||||
DownloadAndVerify(kr *crypto.KeyRing, url, sig string) ([]byte, error)
|
||||
ReportBug(context.Context, ReportBugReq) error
|
||||
|
||||
@ -670,7 +670,7 @@ func (mr *MockManagerMockRecorder) NewClient(arg0, arg1, arg2, arg3 interface{})
|
||||
}
|
||||
|
||||
// NewClientWithLogin mocks base method
|
||||
func (m *MockManager) NewClientWithLogin(arg0 context.Context, arg1, arg2 string) (pmapi.Client, *pmapi.Auth, error) {
|
||||
func (m *MockManager) NewClientWithLogin(arg0 context.Context, arg1 string, arg2 []byte) (pmapi.Client, *pmapi.Auth, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "NewClientWithLogin", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(pmapi.Client)
|
||||
|
||||
@ -20,13 +20,14 @@ package pmapi
|
||||
import (
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/jameskeane/bcrypt"
|
||||
"github.com/ProtonMail/go-srp"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func HashMailboxPassword(password, salt string) ([]byte, error) {
|
||||
// HashMailboxPassword expectects 128bit long salt encoded by standard base64.
|
||||
func HashMailboxPassword(password []byte, salt string) ([]byte, error) {
|
||||
if salt == "" {
|
||||
return []byte(password), nil
|
||||
return password, nil
|
||||
}
|
||||
|
||||
decodedSalt, err := base64.StdEncoding.DecodeString(salt)
|
||||
@ -34,15 +35,10 @@ func HashMailboxPassword(password, salt string) ([]byte, error) {
|
||||
return nil, errors.Wrap(err, "failed to decode salt")
|
||||
}
|
||||
|
||||
encodedSalt := base64.NewEncoding("./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").WithPadding(base64.NoPadding).EncodeToString(decodedSalt)
|
||||
hashResult, err := bcrypt.Hash(password, "$2y$10$"+encodedSalt)
|
||||
hash, err := srp.MailboxPassword(password, decodedSalt)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to bcrypt-hash password")
|
||||
return nil, errors.Wrap(err, "failed to hash password")
|
||||
}
|
||||
|
||||
if len(hashResult) != 60 {
|
||||
return nil, errors.New("pmapi: invalid mailbox password hash")
|
||||
}
|
||||
|
||||
return []byte(hashResult[len(hashResult)-31:]), nil
|
||||
return hash[len(hash)-31:], nil
|
||||
}
|
||||
|
||||
44
pkg/pmapi/passwords_test.go
Normal file
44
pkg/pmapi/passwords_test.go
Normal file
@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMailboxPassword(t *testing.T) {
|
||||
// wantHash was generated with passprase and salt defined below. It
|
||||
// should not change when changing implementation of the function.
|
||||
wantHash := []byte("B5nwpsJQSTJ16ldr64Vdq6oeCCn32Fi")
|
||||
|
||||
// Valid salt is 128bit long (16bytes)
|
||||
// $echo aaaabbbbccccdddd | base64
|
||||
salt := "YWFhYWJiYmJjY2NjZGRkZAo="
|
||||
|
||||
passphrase := []byte("random")
|
||||
|
||||
r := require.New(t)
|
||||
_, err := HashMailboxPassword(passphrase, "badsalt")
|
||||
r.Error(err)
|
||||
|
||||
haveHash, err := HashMailboxPassword(passphrase, salt)
|
||||
r.NoError(err)
|
||||
r.Equal(wantHash, haveHash)
|
||||
}
|
||||
107
pkg/srp/hash.go
107
pkg/srp/hash.go
@ -1,107 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package srp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5" //nolint[gosec]
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/jameskeane/bcrypt"
|
||||
)
|
||||
|
||||
// BCryptHash function bcrypt algorithm to hash password with salt.
|
||||
func BCryptHash(password string, salt string) (string, error) {
|
||||
return bcrypt.Hash(password, salt)
|
||||
}
|
||||
|
||||
// ExpandHash extends the byte data for SRP flow.
|
||||
func ExpandHash(data []byte) []byte {
|
||||
part0 := sha512.Sum512(append(data, 0))
|
||||
part1 := sha512.Sum512(append(data, 1))
|
||||
part2 := sha512.Sum512(append(data, 2))
|
||||
part3 := sha512.Sum512(append(data, 3))
|
||||
return bytes.Join([][]byte{
|
||||
part0[:],
|
||||
part1[:],
|
||||
part2[:],
|
||||
part3[:],
|
||||
}, []byte{})
|
||||
}
|
||||
|
||||
// HashPassword returns the hash of password argument. Based on version number
|
||||
// following arguments are used in addition to password:
|
||||
// * 0, 1, 2: userName and modulus
|
||||
// * 3, 4: salt and modulus.
|
||||
func HashPassword(authVersion int, password, userName string, salt, modulus []byte) ([]byte, error) {
|
||||
switch authVersion {
|
||||
case 4, 3:
|
||||
return hashPasswordVersion3(password, salt, modulus)
|
||||
case 2:
|
||||
return hashPasswordVersion2(password, userName, modulus)
|
||||
case 1:
|
||||
return hashPasswordVersion1(password, userName, modulus)
|
||||
case 0:
|
||||
return hashPasswordVersion0(password, userName, modulus)
|
||||
default:
|
||||
return nil, errors.New("pmapi: unsupported auth version")
|
||||
}
|
||||
}
|
||||
|
||||
// CleanUserName returns the input string in lower-case without characters `_`,
|
||||
// `.` and `-`.
|
||||
func CleanUserName(userName string) string {
|
||||
userName = strings.ReplaceAll(userName, "-", "")
|
||||
userName = strings.ReplaceAll(userName, ".", "")
|
||||
userName = strings.ReplaceAll(userName, "_", "")
|
||||
return strings.ToLower(userName)
|
||||
}
|
||||
|
||||
func hashPasswordVersion3(password string, salt, modulus []byte) (res []byte, err error) {
|
||||
encodedSalt := base64.NewEncoding("./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").WithPadding(base64.NoPadding).EncodeToString(append(salt, []byte("proton")...))
|
||||
crypted, err := BCryptHash(password, "$2y$10$"+encodedSalt)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return ExpandHash(append([]byte(crypted), modulus...)), nil
|
||||
}
|
||||
|
||||
func hashPasswordVersion2(password, userName string, modulus []byte) (res []byte, err error) {
|
||||
return hashPasswordVersion1(password, CleanUserName(userName), modulus)
|
||||
}
|
||||
|
||||
func hashPasswordVersion1(password, userName string, modulus []byte) (res []byte, err error) {
|
||||
prehashed := md5.Sum([]byte(strings.ToLower(userName))) //nolint[gosec]
|
||||
encodedSalt := hex.EncodeToString(prehashed[:])
|
||||
crypted, err := BCryptHash(password, "$2y$10$"+encodedSalt)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return ExpandHash(append([]byte(crypted), modulus...)), nil
|
||||
}
|
||||
|
||||
func hashPasswordVersion0(password, userName string, modulus []byte) (res []byte, err error) {
|
||||
prehashed := sha512.Sum512([]byte(password))
|
||||
return hashPasswordVersion1(base64.StdEncoding.EncodeToString(prehashed[:]), userName, modulus)
|
||||
}
|
||||
219
pkg/srp/srp.go
219
pkg/srp/srp.go
@ -1,219 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package srp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"math/big"
|
||||
|
||||
"golang.org/x/crypto/openpgp"
|
||||
"golang.org/x/crypto/openpgp/clearsign"
|
||||
)
|
||||
|
||||
//nolint[gochecknoglobals]
|
||||
var (
|
||||
ErrDataAfterModulus = errors.New("pm-srp: extra data after modulus")
|
||||
ErrInvalidSignature = errors.New("pm-srp: invalid modulus signature")
|
||||
RandReader = rand.Reader
|
||||
)
|
||||
|
||||
// Store random reader in a variable to be able to overwrite it in tests
|
||||
|
||||
// Amored pubkey for modulus verification.
|
||||
const modulusPubkey = `-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
xjMEXAHLgxYJKwYBBAHaRw8BAQdAFurWXXwjTemqjD7CXjXVyKf0of7n9Ctm
|
||||
L8v9enkzggHNEnByb3RvbkBzcnAubW9kdWx1c8J3BBAWCgApBQJcAcuDBgsJ
|
||||
BwgDAgkQNQWFxOlRjyYEFQgKAgMWAgECGQECGwMCHgEAAPGRAP9sauJsW12U
|
||||
MnTQUZpsbJb53d0Wv55mZIIiJL2XulpWPQD/V6NglBd96lZKBmInSXX/kXat
|
||||
Sv+y0io+LR8i2+jV+AbOOARcAcuDEgorBgEEAZdVAQUBAQdAeJHUz1c9+KfE
|
||||
kSIgcBRE3WuXC4oj5a2/U3oASExGDW4DAQgHwmEEGBYIABMFAlwBy4MJEDUF
|
||||
hcTpUY8mAhsMAAD/XQD8DxNI6E78meodQI+wLsrKLeHn32iLvUqJbVDhfWSU
|
||||
WO4BAMcm1u02t4VKw++ttECPt+HUgPUq5pqQWe5Q2cW4TMsE
|
||||
=Y4Mw
|
||||
-----END PGP PUBLIC KEY BLOCK-----`
|
||||
|
||||
// ReadClearSignedMessage reads the clear text from signed message and verifies
|
||||
// signature. There must be no data appended after signed message in input string.
|
||||
// The message must be sign by key corresponding to `modulusPubkey`.
|
||||
func ReadClearSignedMessage(signedMessage string) (string, error) {
|
||||
modulusBlock, rest := clearsign.Decode([]byte(signedMessage))
|
||||
if len(rest) != 0 {
|
||||
return "", ErrDataAfterModulus
|
||||
}
|
||||
|
||||
modulusKeyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(modulusPubkey)))
|
||||
if err != nil {
|
||||
return "", errors.New("pm-srp: can not read modulus pubkey")
|
||||
}
|
||||
|
||||
_, err = openpgp.CheckDetachedSignature(modulusKeyring, bytes.NewReader(modulusBlock.Bytes), modulusBlock.ArmoredSignature.Body, nil)
|
||||
if err != nil {
|
||||
return "", ErrInvalidSignature
|
||||
}
|
||||
|
||||
return string(modulusBlock.Bytes), nil
|
||||
}
|
||||
|
||||
// SrpProofs object.
|
||||
type SrpProofs struct { //nolint[golint]
|
||||
ClientProof, ClientEphemeral, ExpectedServerProof []byte
|
||||
}
|
||||
|
||||
// SrpAuth stores byte data for the calculation of SRP proofs.
|
||||
type SrpAuth struct { //nolint[golint]
|
||||
Modulus, ServerEphemeral, HashedPassword []byte
|
||||
}
|
||||
|
||||
// NewSrpAuth creates new SrpAuth from strings input. Salt and server ephemeral are in
|
||||
// base64 format. Modulus is base64 with signature attached. The signature is
|
||||
// verified against server key. The version controls password hash algorithm.
|
||||
func NewSrpAuth(version int, username, password, salt, signedModulus, serverEphemeral string) (auth *SrpAuth, err error) {
|
||||
data := &SrpAuth{}
|
||||
|
||||
// Modulus
|
||||
var modulus string
|
||||
modulus, err = ReadClearSignedMessage(signedModulus)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
data.Modulus, err = base64.StdEncoding.DecodeString(modulus)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Password
|
||||
var decodedSalt []byte
|
||||
if version >= 3 {
|
||||
decodedSalt, err = base64.StdEncoding.DecodeString(salt)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
data.HashedPassword, err = HashPassword(version, password, username, decodedSalt, data.Modulus)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Server ephermeral
|
||||
data.ServerEphemeral, err = base64.StdEncoding.DecodeString(serverEphemeral)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// GenerateSrpProofs calculates SPR proofs.
|
||||
func (s *SrpAuth) GenerateSrpProofs(length int) (res *SrpProofs, err error) { //nolint[funlen]
|
||||
toInt := func(arr []byte) *big.Int {
|
||||
var reversed = make([]byte, len(arr))
|
||||
for i := 0; i < len(arr); i++ {
|
||||
reversed[len(arr)-i-1] = arr[i]
|
||||
}
|
||||
return big.NewInt(0).SetBytes(reversed)
|
||||
}
|
||||
|
||||
fromInt := func(num *big.Int) []byte {
|
||||
var arr = num.Bytes()
|
||||
var reversed = make([]byte, length/8)
|
||||
for i := 0; i < len(arr); i++ {
|
||||
reversed[len(arr)-i-1] = arr[i]
|
||||
}
|
||||
return reversed
|
||||
}
|
||||
|
||||
generator := big.NewInt(2)
|
||||
multiplier := toInt(ExpandHash(append(fromInt(generator), s.Modulus...)))
|
||||
|
||||
modulus := toInt(s.Modulus)
|
||||
serverEphemeral := toInt(s.ServerEphemeral)
|
||||
hashedPassword := toInt(s.HashedPassword)
|
||||
|
||||
modulusMinusOne := big.NewInt(0).Sub(modulus, big.NewInt(1))
|
||||
|
||||
if modulus.BitLen() != length {
|
||||
return nil, errors.New("pm-srp: SRP modulus has incorrect size")
|
||||
}
|
||||
|
||||
multiplier = multiplier.Mod(multiplier, modulus)
|
||||
|
||||
if multiplier.Cmp(big.NewInt(1)) <= 0 || multiplier.Cmp(modulusMinusOne) >= 0 {
|
||||
return nil, errors.New("pm-srp: SRP multiplier is out of bounds")
|
||||
}
|
||||
|
||||
if generator.Cmp(big.NewInt(1)) <= 0 || generator.Cmp(modulusMinusOne) >= 0 {
|
||||
return nil, errors.New("pm-srp: SRP generator is out of bounds")
|
||||
}
|
||||
|
||||
if serverEphemeral.Cmp(big.NewInt(1)) <= 0 || serverEphemeral.Cmp(modulusMinusOne) >= 0 {
|
||||
return nil, errors.New("pm-srp: SRP server ephemeral is out of bounds")
|
||||
}
|
||||
|
||||
// Check primality
|
||||
// Doing exponentiation here is faster than a full call to ProbablyPrime while
|
||||
// still perfectly accurate by Pocklington's theorem
|
||||
if big.NewInt(0).Exp(big.NewInt(2), modulusMinusOne, modulus).Cmp(big.NewInt(1)) != 0 {
|
||||
return nil, errors.New("pm-srp: SRP modulus is not prime")
|
||||
}
|
||||
|
||||
// Check safe primality
|
||||
if !big.NewInt(0).Rsh(modulus, 1).ProbablyPrime(10) {
|
||||
return nil, errors.New("pm-srp: SRP modulus is not a safe prime")
|
||||
}
|
||||
|
||||
var clientSecret, clientEphemeral, scramblingParam *big.Int
|
||||
for {
|
||||
for {
|
||||
clientSecret, err = rand.Int(RandReader, modulusMinusOne)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if clientSecret.Cmp(big.NewInt(int64(length*2))) > 0 { // Very likely
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
clientEphemeral = big.NewInt(0).Exp(generator, clientSecret, modulus)
|
||||
scramblingParam = toInt(ExpandHash(append(fromInt(clientEphemeral), fromInt(serverEphemeral)...)))
|
||||
if scramblingParam.Cmp(big.NewInt(0)) != 0 { // Very likely
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
subtracted := big.NewInt(0).Sub(serverEphemeral, big.NewInt(0).Mod(big.NewInt(0).Mul(big.NewInt(0).Exp(generator, hashedPassword, modulus), multiplier), modulus))
|
||||
if subtracted.Cmp(big.NewInt(0)) < 0 {
|
||||
subtracted.Add(subtracted, modulus)
|
||||
}
|
||||
exponent := big.NewInt(0).Mod(big.NewInt(0).Add(big.NewInt(0).Mul(scramblingParam, hashedPassword), clientSecret), modulusMinusOne)
|
||||
sharedSession := big.NewInt(0).Exp(subtracted, exponent, modulus)
|
||||
|
||||
clientProof := ExpandHash(bytes.Join([][]byte{fromInt(clientEphemeral), fromInt(serverEphemeral), fromInt(sharedSession)}, []byte{}))
|
||||
serverProof := ExpandHash(bytes.Join([][]byte{fromInt(clientEphemeral), clientProof, fromInt(sharedSession)}, []byte{}))
|
||||
|
||||
return &SrpProofs{ClientEphemeral: fromInt(clientEphemeral), ClientProof: clientProof, ExpectedServerProof: serverProof}, nil
|
||||
}
|
||||
|
||||
// GenerateVerifier verifier for update pwds and create accounts.
|
||||
func (s *SrpAuth) GenerateVerifier(length int) ([]byte, error) {
|
||||
return nil, errors.New("pm-srp: the client doesn't need SRP GenerateVerifier")
|
||||
}
|
||||
@ -1,111 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package srp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
testServerEphemeral = "l13IQSVFBEV0ZZREuRQ4ZgP6OpGiIfIjbSDYQG3Yp39FkT2B/k3n1ZhwqrAdy+qvPPFq/le0b7UDtayoX4aOTJihoRvifas8Hr3icd9nAHqd0TUBbkZkT6Iy6UpzmirCXQtEhvGQIdOLuwvy+vZWh24G2ahBM75dAqwkP961EJMh67/I5PA5hJdQZjdPT5luCyVa7BS1d9ZdmuR0/VCjUOdJbYjgtIH7BQoZs+KacjhUN8gybu+fsycvTK3eC+9mCN2Y6GdsuCMuR3pFB0RF9eKae7cA6RbJfF1bjm0nNfWLXzgKguKBOeF3GEAsnCgK68q82/pq9etiUDizUlUBcA=="
|
||||
testServerProof = "ffYFIhnhZJAflFJr9FfXbtdsBLkDGH+TUR5sj98wg0iVHyIhIVT6BeZD8tZA75tYlz7uYIanswweB3bjrGfITXfxERgQysQSoPUB284cX4VQm1IfTB/9LPma618MH8OULNluXVu2eizPWnvIn9VLXCaIX+38Xd6xOjmCQgfkpJy3Sh3ndikjqNCGWiKyvERVJi0nTmpAbHmcdeEp1K++ZRbebRhm2d018o/u4H2gu+MF39Hx12zMzEGNMwkNkgKSEQYlqmj57S6tW9JuB30zVZFnw6Krftg1QfJR6zCT1/J57OGp0A/7X/lC6Xz/I33eJvXOpG9GCRCbNiozFg9IXQ=="
|
||||
|
||||
testClientProof = "8dQtp6zIeEmu3D93CxPdEiCWiAE86uDmK33EpxyqReMwUrm/bTL+zCkWa/X7QgLNrt2FBAriyROhz5TEONgZq/PqZnBEBym6Rvo708KHu6S4LFdZkVc0+lgi7yQpNhU8bqB0BCqdSWd3Fjd3xbOYgO7/vnFK+p9XQZKwEh2RmGv97XHwoxefoyXK6BB+VVMkELd4vL7vdqBiOBU3ufOlSp+0XBMVltQ4oi5l1y21pzOA9cw5WTPIPMcQHffNFq/rReHYnqbBqiLlSLyw6K0PcVuN3bvr3rVYfdS1CsM/Rv1DzXlBUl39B2j82y6hdyGcTeplGyAnAcu0CimvynKBvQ=="
|
||||
testModulus = "W2z5HBi8RvsfYzZTS7qBaUxxPhsfHJFZpu3Kd6s1JafNrCCH9rfvPLrfuqocxWPgWDH2R8neK7PkNvjxto9TStuY5z7jAzWRvFWN9cQhAKkdWgy0JY6ywVn22+HFpF4cYesHrqFIKUPDMSSIlWjBVmEJZ/MusD44ZT29xcPrOqeZvwtCffKtGAIjLYPZIEbZKnDM1Dm3q2K/xS5h+xdhjnndhsrkwm9U9oyA2wxzSXFL+pdfj2fOdRwuR5nW0J2NFrq3kJjkRmpO/Genq1UW+TEknIWAb6VzJJJA244K/H8cnSx2+nSNZO3bbo6Ys228ruV9A8m6DhxmS+bihN3ttQ=="
|
||||
testModulusClearSign = `-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA256
|
||||
|
||||
W2z5HBi8RvsfYzZTS7qBaUxxPhsfHJFZpu3Kd6s1JafNrCCH9rfvPLrfuqocxWPgWDH2R8neK7PkNvjxto9TStuY5z7jAzWRvFWN9cQhAKkdWgy0JY6ywVn22+HFpF4cYesHrqFIKUPDMSSIlWjBVmEJZ/MusD44ZT29xcPrOqeZvwtCffKtGAIjLYPZIEbZKnDM1Dm3q2K/xS5h+xdhjnndhsrkwm9U9oyA2wxzSXFL+pdfj2fOdRwuR5nW0J2NFrq3kJjkRmpO/Genq1UW+TEknIWAb6VzJJJA244K/H8cnSx2+nSNZO3bbo6Ys228ruV9A8m6DhxmS+bihN3ttQ==
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
Version: ProtonMail
|
||||
Comment: https://protonmail.com
|
||||
|
||||
wl4EARYIABAFAlwB1j0JEDUFhcTpUY8mAAD8CgEAnsFnF4cF0uSHKkXa1GIa
|
||||
GO86yMV4zDZEZcDSJo0fgr8A/AlupGN9EdHlsrZLmTA1vhIx+rOgxdEff28N
|
||||
kvNM7qIK
|
||||
=q6vu
|
||||
-----END PGP SIGNATURE-----`
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Only for tests, replace the default random reader by something that always
|
||||
// return the same thing
|
||||
RandReader = rand.New(rand.NewSource(42))
|
||||
}
|
||||
|
||||
func TestReadClearSigned(t *testing.T) {
|
||||
cleartext, err := ReadClearSignedMessage(testModulusClearSign)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error but have ", err)
|
||||
}
|
||||
if cleartext != testModulus {
|
||||
t.Fatalf("Expected message\n\t'%s'\nbut have\n\t'%s'", testModulus, cleartext)
|
||||
}
|
||||
|
||||
lastChar := len(testModulusClearSign)
|
||||
wrongSignature := testModulusClearSign[:lastChar-100]
|
||||
wrongSignature += "c"
|
||||
wrongSignature += testModulusClearSign[lastChar-99:]
|
||||
_, err = ReadClearSignedMessage(wrongSignature)
|
||||
if err != ErrInvalidSignature {
|
||||
t.Fatal("Expected the ErrInvalidSignature but have ", err)
|
||||
}
|
||||
|
||||
wrongSignature = testModulusClearSign + "data after modulus"
|
||||
_, err = ReadClearSignedMessage(wrongSignature)
|
||||
if err != ErrDataAfterModulus {
|
||||
t.Fatal("Expected the ErrDataAfterModulus but have ", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSRPauth(t *testing.T) {
|
||||
srp, err := NewSrpAuth(4, "bridgetest", "test", "yKlc5/CvObfoiw==", testModulusClearSign, testServerEphemeral)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error but have ", err)
|
||||
}
|
||||
|
||||
proofs, err := srp.GenerateSrpProofs(2048)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error but have ", err)
|
||||
}
|
||||
|
||||
expectedProof, err := base64.StdEncoding.DecodeString(testServerProof)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error but have ", err)
|
||||
}
|
||||
if !bytes.Equal(proofs.ExpectedServerProof, expectedProof) {
|
||||
t.Fatalf("Expected server proof\n\t'%s'\nbut have\n\t'%s'",
|
||||
testServerProof,
|
||||
base64.StdEncoding.EncodeToString(proofs.ExpectedServerProof),
|
||||
)
|
||||
}
|
||||
|
||||
expectedProof, err = base64.StdEncoding.DecodeString(testClientProof)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error but have ", err)
|
||||
}
|
||||
if !bytes.Equal(proofs.ClientProof, expectedProof) {
|
||||
t.Fatalf("Expected client proof\n\t'%s'\nbut have\n\t'%s'",
|
||||
testClientProof,
|
||||
base64.StdEncoding.EncodeToString(proofs.ClientProof),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user