We build too many walls and not enough bridges

This commit is contained in:
Jakub
2020-04-08 12:59:16 +02:00
commit 17f4d6097a
494 changed files with 62753 additions and 0 deletions

309
pkg/pmapi/Changelog.md Normal file
View File

@ -0,0 +1,309 @@
# Do not modify this file!
It is here for historical reasons only. All changes should be documented in the
Changelog at the root of this repository.
# Changelog for API
> NOTE we are using versioning for go-pmapi in format `major.minor.bugfix`
> * major stays at version 1 for the forseeable future
> * minor is increased when a force upgrade happens or in case of major breaking changes
> * patch is increased when new features are added
## v1.0.16
### Fixed
* Potential crash when reporting cert pin failure
## v1.0.15
### Changed
* Merge only 50 events into one
* Response header timeout increased from 10s to 30s
### Fixed
* Make keyring unlocking threadsafe
## v1.0.14
### Added
* Config for disabling TLS cert fingerprint checking
### Fixed
* Ensure sensitive stuff is cleared on client logout even if requests fail
## v1.0.13
### Fixed
* Correctly set Transport in http client
## v1.0.12
### Changed
* Only `http.RoundTripper` interface is needed instead of full `http.Transport` struct
### Added
* GODT-61 (and related): Use DoH to find and switch to a proxy server if the API becomes unreachable
* GODT-67 added random wait to not cause spikes on server after StatusTooManyRequests
### Fixed
* FirstReadTimeout was wrongly timeout of the whole request including repeating ones, now it's really only timeout for the first read
## v1.0.11
### Added
* GODT-53 `Message.Type` added with constants `MessageType*`
## v1.0.10
### Added
* GODT-55 exporting DANGEROUSLYSetUID
### Changed
* The full communication between clien and API is logged if logrus level is trace
## v1.0.9
### Fixed
* Use correct address type value (because API starts counting from 1 but we were counting from 0)
## v1.0.8
### Added
* Introdcution of connection manager
### Fixed
* Deadlock during the auth-refresh
* Fixed an issue where some events were being discarded when merging
## v1.0.7
### Changed
* The given access token is saved during auth refresh if none was available yet
## v1.0.6
### Added
* `ClientConfig.Timeout` to be able to configure the whole timeout of request
* `ClientConfig.FirstReadTimeout` to be able to configure the timeout of request to the first byte
* `ClientConfig.MinSpeed` to be able to configure the timeout when the connection is too slow (limitation in minimum bytes per second)
* Set default timeouts for http.Transport with certificate pinning
### Changed
* http.Client by default uses ProxyFromEnvironment to support HTTP_PROXY and HTTPS_PROXY environment variables
## v1.0.5
### Added
* `ContentTypeMultipartEncrypted` MIME content type for encrypted email
* `MessageCounts` in event struct
## v1.0.4
### Added
* `PMKeys` for parsing and reading KeyRing
* `clearableKey` to rewrite memory
* Proton/backend-communication#25 Unlock with tokens (OneKey2RuleThemAll Phase I)
### Changed
* Update of gopenpgp: convert JSON to KeyRing in PMAPI
* `user.KeyRing` -> `user.KeyRing()`
* typo `client.GetAddresses()`
### Removed
* `address.KeyRing`
## v1.0.2 v1.0.3
### Changed
* Fixed capitalisation in a few places
* Added /metrics API route
* Changed function names to be compliant with go linter
* Encrypt with primary key only
* Fix `client.doBuffered` - closing body before handling unauthorized request
* go-pm-crypto -> GopenPGP
* redefine old functions in `keyring.go`
* `attachment.Decrypt` drops returning signature (does signature check by default)
* `attachment.Encrypt` is using readers instead of writers
* `attachment.DetachedSign` drops writer param and returns signature as a reader
* `message.Decrypt` drops returning signature (does signature check by default)
* Changed TLS report URL to https://reports.protonmail.ch/reports/tls
* Moved from current to soon TLS pin
## v1.0.1
### Removed
* `ClientID` from all auth routes
* `ErrorDescription` from error
## v1.0.0
### Changed
* `client.AuthInfo` does return 2FA information only when authenticated, for the first login information available in `Auth.HasTwoFactor`
* `client.Auth` does not accept 2FA code in favor of `client.Auth2FA`
* `client.Unlock` supports only new way of unlock with directly available access token
### Added
* `Res.StatusCode` to pass HTTP status code to responses
* `Auth.HasTwoFactor` method to determine whether account has enabled 2FA (same as `AuthInfo.HasTwoFactor`)
* `Auth2FA*` structs for 2FA endpoint
* `client.Auth2FA` method to fully unlock session with 2FA code
* `ErrUnauthorized` when request cannot be authorized
* `ErrBad2FACode` when bad 2FA and user cannot try again
* `ErrBad2FACodeTryAgain` when bad 2FA but user can try again
## 2019-08-06
### Added
* Send TLS issue report to API
* Cert fingerpring with `TLSPinning` struct
* Check API certificate fingerprint and verify hostname
### Changed
* Using `AddressID` for `/messge/count` and `/conversations/count`
* Less of copying of responses from the server in the memory
## 2019-08-01
* low case for `sirupsen`
* using go modules
## 2019-07-15
### Changed
* `client.Auths` field is removed in favor of function `client.SetAuths` which opens possibility to use interface
## 2019-05-18
### Changed
* proton/backend-communication#11 x-pm-uid sent always for `/auth/refresh`
* proton/backend-communication#11 UID never changes
## 2019-05-28
### Added
* New test server patern using callbacks
* Responses are read from json files
### Changed
* `auth_tests.go` to new callback server pattern
* Linter fixes for tests
### Removed
* `TestClient_Do_expired` due to no effect, use `DoUnauthorized` instead
## 2019-05-24
* Help functions for test
* CI with Lint
## 2019-05-23
* Log userID
## 2019-05-21
* Fix unlocking user keys
## 2019-04-25
### Changed
* rename `Uid` -> `UID` proton/backend-communication#11
## 2019-04-09
### Added
* sending attachments as zip `application/octet-stream`
* function `ReportReq.AddAttachment()`
* data memeber `ReportReq.Attachments`
* general function to report bug `client.Report(req ReportReq)` with object as parameter
### Changed
* `client.ReportBug` and `client.ReportBugWithClient` functions are obsolete and they uses `client.Report(req ReportReq)`
* `client.ReportCrash` is obsolete. Use sentry instead
* `Api`->`API`, `Uid`->`UID`
## 2019-03-13
* user id in raven
* add file position of panic sender
## 2019-03-06
* #30 update `pm-crypto` to store `KeyRing.FirstKeyID`
* #30 Add key salt to `Auth` object from `GetKeySalts` request
* #30 Add route `GET /keys/salt`
* removed unused `PmCrypto`
## 2019-02-20
* removed unused `decryptAccessToken`
## 2019-01-21
* #29 Parsing all goroutines from pprof
* #29 Sentry `Threads` implementation
* #29 using sentry for crashes
## 2019-01-07
* refactor `pmapi.DecryptString` -> `pmcrypto.KeyRing.DecryptString`
* fixed tests
* `crypto` -> `pmcrypto`
* refactoring code using repos `go-pm-crypto`, `go-pm-mime` and `go-srp`
## 2018-12-10
* #26 adding `Flags` field to message
* #26 removing fields deprecated by `Flags`: `IsEncrypted`, `Type`, `IsReplied`, `IsRepliedAll`, `IsForwarded`
* #26 removing deprecated consts (see #26 for replacement)
* #26 fixing tests (compiling not working)
## 2018-11-19
### Added
* Wait and retry from `DoJson` if banned from api
### Changed
* `ErrNoInternet` -> `ErrAPINotReachable`
* Adding codes for force upgrade: 5004 and 5005
* Adding codes for API offline: 7001
* Adding codes for BansRequests: 85131
## 2018-09-18
### Added
* `client.decryptAccessToken` if privateKey is received (tested with local api) #23
### Changed
* added fields to User
* local config TLS skip verify
## 2018-09-06
### Changed
* decrypt token only if needed
### Broken
* Tests are not working
## APIv3 UPDATE (2018-08-01)
* issue Desktop-Bridge#561
### Added
* Key flag consts
* `EventAddress`
* `MailSettings` object and route call
* `Client.KeyRingForAddressID`
* `AuthInfo.HasTwoFactor()`
* `Auth.HasMailboxPassword()`
### Changed
* Addresses are part of client
* Update user updates also addresses
* `BodyKey` and `AttachmentKey` contains `Key` and `Algorithm`
* `keyPair` (not use Pubkey) -> `pmKeyObject`
* lots of indent
* bugs route
* two factor (ready to U2F)
* Reorder some to match order in doc (easier to )
* omit address Order when empty
* update user and addresses in `CurrentUser()`
* `User.Unlock()` -> `Client.UnlockAddresses()`
* `AuthInfo.Uid` -> `AuthInfo.Uid()`
* `User.Addresses` -> `Client.Addresses()`
### Removed
* User v3 removed plenty (now in settings)
* Message v3 removed plenty (Starred is label)

19
pkg/pmapi/Makefile Normal file
View File

@ -0,0 +1,19 @@
export GO111MODULE=on
LINTVER="v1.21.0"
LINTSRC="https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh"
check-has-go:
@which go || (echo "Install Go-lang!" && exit 1)
install-dev-dependencies: install-linter
install-linter: check-has-go
curl -sfL $(LINTSRC) | sh -s -- -b $(shell go env GOPATH)/bin $(LINTVER)
lint:
which golangci-lint || $(MAKE) install-linter
golangci-lint run ./... \
test:
go test -run=${TESTRUN} ./...

204
pkg/pmapi/addresses.go Normal file
View File

@ -0,0 +1,204 @@
// Copyright (c) 2020 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 (
"fmt"
"strings"
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
)
// Address statuses.
const (
DisabledAddress = iota
EnabledAddress
)
// Address receive values.
const (
CannotReceive = iota
CanReceive
)
// Address HasKeys values.
const (
MissingKeys = iota
KeysPresent
)
// Address types.
const (
_ = iota // Skip first.
OriginalAddress
AliasAddress
CustomAddress
PremiumAddress
)
// Address Send values.
const (
NoSendAddress = iota
MainSendAddress
SecondarySendAddress
)
// Address represents a user's address.
type Address struct {
ID string
DomainID string
Email string
Send int
Receive int
Status int
Order int `json:",omitempty"`
Type int
DisplayName string
Signature string
MemberID string `json:",omitempty"`
MemberName string `json:",omitempty"`
HasKeys int
Keys PMKeys
}
// AddressList is a list of addresses.
type AddressList []*Address
type AddressesRes struct {
Res
Addresses AddressList
}
// KeyRing returns the (possibly unlocked) PMKeys KeyRing.
func (a *Address) KeyRing() *pmcrypto.KeyRing {
return a.Keys.KeyRing
}
// ByID returns an address by id. Returns nil if no address is found.
func (l AddressList) ByID(id string) *Address {
for _, addr := range l {
if addr.ID == id {
return addr
}
}
return nil
}
func (l AddressList) ActiveEmails() (addresses []string) {
for _, a := range l {
if a.Receive == CanReceive {
addresses = append(addresses, a.Email)
}
}
return
}
// Main gets the main address.
func (l AddressList) Main() *Address {
for _, addr := range l {
if addr.Order == 1 {
return addr
}
}
return l[0] // Should not happen.
}
// ByEmail gets an address by email. Returns nil if no address is found.
func (l AddressList) ByEmail(email string) *Address {
email = SanitizeEmail(email)
for _, addr := range l {
if strings.EqualFold(addr.Email, email) {
return addr
}
}
return nil
}
func SanitizeEmail(email string) string {
splitAt := strings.Split(email, "@")
if len(splitAt) != 2 {
return email
}
splitPlus := strings.Split(splitAt[0], "+")
email = splitPlus[0] + "@" + splitAt[1]
return email
}
func ConstructAddress(headerEmail string, addressEmail string) string {
splitAtHeader := strings.Split(headerEmail, "@")
if len(splitAtHeader) != 2 {
return addressEmail
}
splitPlus := strings.Split(splitAtHeader[0], "+")
if len(splitPlus) != 2 {
return addressEmail
}
splitAtAddress := strings.Split(addressEmail, "@")
if len(splitAtAddress) != 2 {
return addressEmail
}
return splitAtAddress[0] + "+" + splitPlus[1] + "@" + splitAtAddress[1]
}
// GetAddresses requests all of current user addresses (without pagination).
func (c *Client) GetAddresses() (addresses AddressList, err error) {
req, err := NewRequest("GET", "/addresses", nil)
if err != nil {
return
}
var res AddressesRes
if err = c.DoJSON(req, &res); err != nil {
return
}
return res.Addresses, res.Err()
}
func (c *Client) Addresses() AddressList {
return c.addresses
}
// UnlockAddresses unlocks all keys for all addresses of current user.
func (c *Client) UnlockAddresses(passphrase []byte) (err error) {
for _, a := range c.addresses {
if a.HasKeys == MissingKeys {
continue
}
// Unlock the address token using the UserKey, use the unlocked token to unlock the keyring.
if err = a.Keys.unlockKeyRing(c.kr, passphrase, c.keyLocker); err != nil {
err = fmt.Errorf("pmapi: cannot unlock private key of address %v: %v", a.Email, err)
return
}
}
return
}
func (c *Client) KeyRingForAddressID(addrID string) *pmcrypto.KeyRing {
addr := c.addresses.ByID(addrID)
if addr == nil {
addr = c.addresses.Main()
}
return addr.KeyRing()
}

View File

@ -0,0 +1,90 @@
// Copyright (c) 2020 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 (
"net/http"
"testing"
)
var testAddressList = AddressList{
&Address{
ID: "1",
Email: "root@nsa.gov",
Send: SecondarySendAddress,
Status: EnabledAddress,
Order: 2,
},
&Address{
ID: "2",
Email: "root@gchq.gov.uk",
Send: MainSendAddress,
Status: EnabledAddress,
Order: 1,
},
&Address{
ID: "3",
Email: "root@protonmail.com",
Send: NoSendAddress,
Status: DisabledAddress,
Order: 3,
},
}
func routeGetAddresses(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
Ok(tb, checkMethodAndPath(r, "GET", "/addresses"))
Ok(tb, isAuthReq(r, testUID, testAccessToken))
return "addresses/get_response.json"
}
func routeGetSalts(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
Ok(tb, checkMethodAndPath(r, "GET", "/keys/salts"))
Ok(tb, isAuthReq(r, testUID, testAccessToken))
return "keys/salts/get_response.json"
}
func TestAddressList(t *testing.T) {
input := "1"
addr := testAddressList.ByID(input)
if addr != testAddressList[0] {
t.Errorf("ById(%s) expected:\n%v\n but have:\n%v\n", input, testAddressList[0], addr)
}
input = "42"
addr = testAddressList.ByID(input)
if addr != nil {
t.Errorf("ById expected nil for %s but have : %v\n", input, addr)
}
input = "root@protonmail.com"
addr = testAddressList.ByEmail(input)
if addr != testAddressList[2] {
t.Errorf("ByEmail(%s) expected:\n%v\n but have:\n%v\n", input, testAddressList[2], addr)
}
input = "idontexist@protonmail.com"
addr = testAddressList.ByEmail(input)
if addr != nil {
t.Errorf("ByEmail expected nil for %s but have : %v\n", input, addr)
}
addr = testAddressList.Main()
if addr != testAddressList[1] {
t.Errorf("Main() expected:\n%v\n but have:\n%v\n", testAddressList[1], addr)
}
}

264
pkg/pmapi/attachments.go Normal file
View File

@ -0,0 +1,264 @@
// Copyright (c) 2020 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 (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/textproto"
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
)
type header textproto.MIMEHeader
type rawHeader map[string]json.RawMessage
func (h *header) UnmarshalJSON(b []byte) error {
if *h == nil {
*h = make(header)
}
raw := make(rawHeader)
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
for k, v := range raw {
// Most headers are string because they have only one value.
var s string
if err := json.Unmarshal(v, &s); err == nil {
textproto.MIMEHeader(*h).Set(k, s)
continue
}
// If it's not a string, it must be an array of strings.
var a []string
if err := json.Unmarshal(v, &a); err != nil {
return fmt.Errorf("pmapi: attachment header field is neither a string nor an array of strings: %v", err)
}
for _, vv := range a {
textproto.MIMEHeader(*h).Add(k, vv)
}
}
return nil
}
// Attachment represents a message attachment.
type Attachment struct {
ID string `json:",omitempty"`
MessageID string `json:",omitempty"` // msg v3 ???
Name string `json:",omitempty"`
Size int64 `json:",omitempty"`
MIMEType string `json:",omitempty"`
ContentID string `json:",omitempty"`
KeyPackets string `json:",omitempty"`
Signature string `json:",omitempty"`
Header textproto.MIMEHeader `json:"-"`
}
// Define a new type to prevent MarshalJSON/UnmarshalJSON infinite loops.
type attachment Attachment
type rawAttachment struct {
attachment
Header header `json:"Headers,omitempty"`
}
func (a *Attachment) MarshalJSON() ([]byte, error) {
var raw rawAttachment
raw.attachment = attachment(*a)
if a.Header != nil {
raw.Header = header(a.Header)
}
return json.Marshal(&raw)
}
func (a *Attachment) UnmarshalJSON(b []byte) error {
var raw rawAttachment
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
*a = Attachment(raw.attachment)
if raw.Header != nil {
a.Header = textproto.MIMEHeader(raw.Header)
}
return nil
}
// Decrypt decrypts this attachment's data from r using the keys from kr.
func (a *Attachment) Decrypt(r io.Reader, kr *pmcrypto.KeyRing) (decrypted io.Reader, err error) {
keyPackets, err := base64.StdEncoding.DecodeString(a.KeyPackets)
if err != nil {
return
}
return decryptAttachment(kr, keyPackets, r)
}
// Encrypt encrypts an attachment.
func (a *Attachment) Encrypt(kr *pmcrypto.KeyRing, att io.Reader) (encrypted io.Reader, err error) {
return encryptAttachment(kr, att, a.Name)
}
func (a *Attachment) DetachedSign(kr *pmcrypto.KeyRing, att io.Reader) (signed io.Reader, err error) {
return signAttachment(kr, att)
}
type CreateAttachmentRes struct {
Res
Attachment *Attachment
}
func writeAttachment(w *multipart.Writer, att *Attachment, r io.Reader, sig io.Reader) (err error) {
// Create metadata fields.
if err = w.WriteField("Filename", att.Name); err != nil {
return
}
if err = w.WriteField("MessageID", att.MessageID); err != nil {
return
}
if err = w.WriteField("MIMEType", att.MIMEType); err != nil {
return
}
if err = w.WriteField("ContentID", att.ContentID); err != nil {
return
}
// And send attachment data.
ff, err := w.CreateFormFile("DataPacket", "DataPacket.pgp")
if err != nil {
return
}
if _, err = io.Copy(ff, r); err != nil {
return
}
// And send attachment data.
sigff, err := w.CreateFormFile("Signature", "Signature.pgp")
if err != nil {
return
}
if _, err = io.Copy(sigff, sig); err != nil {
return
}
return err
}
// CreateAttachment uploads an attachment. It must be already encrypted and contain a MessageID.
//
// The returned created attachment contains the new attachment ID and its size.
func (c *Client) CreateAttachment(att *Attachment, r io.Reader, sig io.Reader) (created *Attachment, err error) {
req, w, err := NewMultipartRequest("POST", "/attachments")
if err != nil {
return
}
// We will write the request as long as it is sent to the API.
var res CreateAttachmentRes
done := make(chan error, 1)
go (func() {
done <- c.DoJSON(req, &res)
})()
if err = writeAttachment(w.Writer, att, r, sig); err != nil {
return
}
_ = w.Close()
if err = <-done; err != nil {
return
}
if err = res.Err(); err != nil {
return
}
created = res.Attachment
return
}
type UpdateAttachmentSignatureReq struct {
Signature string
}
func (c *Client) UpdateAttachmentSignature(attachmentID, signature string) (err error) {
updateReq := &UpdateAttachmentSignatureReq{signature}
req, err := NewJSONRequest("PUT", "/attachments/"+attachmentID+"/signature", updateReq)
if err != nil {
return
}
var res Res
if err = c.DoJSON(req, &res); err != nil {
return
}
return
}
// DeleteAttachment removes an attachment. message is the message ID, att is the attachment ID.
func (c *Client) DeleteAttachment(attID string) (err error) {
req, err := NewRequest("DELETE", "/attachments/"+attID, nil)
if err != nil {
return
}
var res Res
if err = c.DoJSON(req, &res); err != nil {
return
}
err = res.Err()
return
}
// GetAttachment gets an attachment's content. The returned data is encrypted.
func (c *Client) GetAttachment(id string) (att io.ReadCloser, err error) {
if id == "" {
err = errors.New("pmapi: cannot get an attachment with an empty id")
return
}
req, err := NewRequest("GET", "/attachments/"+id, nil)
if err != nil {
return
}
res, err := c.Do(req, true)
if err != nil {
return
}
att = res.Body
return
}

View File

@ -0,0 +1,222 @@
// Copyright (c) 2020 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 (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
var testAttachment = &Attachment{
ID: "y6uKIlc2HdoHPAwPSrvf7dXoZNMYvBgxshYUN67cY5DJjL2O8NYewuvGHcYvCfd8LpEoAI_GdymO0Jr0mHlsEw==",
Name: "croutonmail.txt",
Size: 77,
MIMEType: "text/plain",
KeyPackets: "wcBMA0fcZ7XLgmf2AQgAiRsOlnm1kSB4/lr7tYe6pBsRGn10GqwUhrwU5PMKOHdCgnO12jO3y3CzP0Yl/jGhAYja9wLDqH8X0sk3tY32u4Sb1Qe5IuzggAiCa4dwOJj5gEFMTHMzjIMPHR7A70XqUxMhmILye8V4KRm/j4c1sxbzA1rM3lYBumQuB5l/ck0Kgt4ZqxHVXHK5Q1l65FHhSXRj8qnunasHa30TYNzP8nmBA8BinnJxpiQ7FGc2umnUhgkFtjm5ixu9vyjr9ukwDTbwAXXfmY+o7tK7kqIXJcmTL6k2UeC6Mz1AagQtRCRtU+bv/3zGojq/trZo9lom3naIeQYa36Ketmcpj2Qwjg==",
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"},
},
MessageID: "h3CD-DT7rLoAw1vmpcajvIPAl-wwDfXR2MHtWID3wuQURDBKTiGUAwd6E2WBbS44QQKeXImW-axm6X0hAfcVCA==",
}
const testAttachmentJSON = `{
"ID": "y6uKIlc2HdoHPAwPSrvf7dXoZNMYvBgxshYUN67cY5DJjL2O8NYewuvGHcYvCfd8LpEoAI_GdymO0Jr0mHlsEw==",
"Name": "croutonmail.txt",
"Size": 77,
"MIMEType": "text/plain",
"KeyPackets": "wcBMA0fcZ7XLgmf2AQgAiRsOlnm1kSB4/lr7tYe6pBsRGn10GqwUhrwU5PMKOHdCgnO12jO3y3CzP0Yl/jGhAYja9wLDqH8X0sk3tY32u4Sb1Qe5IuzggAiCa4dwOJj5gEFMTHMzjIMPHR7A70XqUxMhmILye8V4KRm/j4c1sxbzA1rM3lYBumQuB5l/ck0Kgt4ZqxHVXHK5Q1l65FHhSXRj8qnunasHa30TYNzP8nmBA8BinnJxpiQ7FGc2umnUhgkFtjm5ixu9vyjr9ukwDTbwAXXfmY+o7tK7kqIXJcmTL6k2UeC6Mz1AagQtRCRtU+bv/3zGojq/trZo9lom3naIeQYa36Ketmcpj2Qwjg==",
"Headers": {
"content-description": "You'll never believe what's in this text file",
"x-mailer": [
"Microsoft Outlook 15.0",
"Microsoft Live Mail 42.0"
]
}
}
`
const testAttachmentCleartext = `cc,
dille.
`
const testAttachmentEncrypted = `wcBMA0fcZ7XLgmf2AQf/cHhfDRM9zlIuBi+h2W6DKjbbyIHMkgF6ER3JEvn/tSruUH8KTGt0N7Z+a80FFMCuXn1Y1I/nW7MVrNhGuJZAF4OymD8ugvuoAMIQX0eCYEpPXzRIWJBZg82AuowmFMsv8Dgvq4bTZq4cttI3CZcxKUNXuAearmNpmgplUKWj5USmRXK4iGB3VFGjidXkxbElrP4fD5A/rfEZ5aJgCsegqcXxX3MEjWXi9pFzgd/9phOvl1ZFm9U9hNoVAW3QsgmVeihnKaDZUyf2Qsigij21QKAUxw9U3y89eTUIqZAcmIgqeDujA3RWBgJwjtY/lOyhEmkf3AWKzehvf1xtJmCWDtJLAekuH+JfAtTQfMs5nf4zYtMahGbMkwy3Uz/jeEMYdzWY5WvshkbwvaxpqFC+11cqMLBvxik39i1xf+RORZF/91jGMCL9Z9dRMcgB`
const testCreateAttachmentBody = `{
"Code": 1000,
"Attachment": {"ID": "y6uKIlc2HdoHPAwPSrvf7dXoZNMYvBgxshYUN67cY5DJjL2O8NYewuvGHcYvCfd8LpEoAI_GdymO0Jr0mHlsEw=="}
}`
const testDeleteAttachmentBody = `{
"Code": 1000
}`
func TestAttachment_UnmarshalJSON(t *testing.T) {
att := new(Attachment)
if err := json.Unmarshal([]byte(testAttachmentJSON), att); err != nil {
t.Fatal("Expected no error while unmarshaling JSON, got:", err)
}
att.MessageID = testAttachment.MessageID // This isn't in the JSON object
if !reflect.DeepEqual(testAttachment, att) {
t.Errorf("Invalid attachment: expected %+v but got %+v", testAttachment, att)
}
}
func TestClient_CreateAttachment(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "POST", "/attachments"))
contentType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
t.Error("Expected no error while parsing request content type, got:", err)
}
if contentType != "multipart/form-data" {
t.Errorf("Invalid request content type: expected %v but got %v", "multipart/form-data", contentType)
}
mr := multipart.NewReader(r.Body, params["boundary"])
form, err := mr.ReadForm(10 * 1024)
if err != nil {
t.Error("Expected no error while parsing request form, got:", err)
}
defer Ok(t, form.RemoveAll())
if form.Value["Filename"][0] != testAttachment.Name {
t.Errorf("Invalid attachment filename: expected %v but got %v", testAttachment.Name, form.Value["Filename"][0])
}
if form.Value["MessageID"][0] != testAttachment.MessageID {
t.Errorf("Invalid attachment message id: expected %v but got %v", testAttachment.MessageID, form.Value["MessageID"][0])
}
if form.Value["MIMEType"][0] != testAttachment.MIMEType {
t.Errorf("Invalid attachment message id: expected %v but got %v", testAttachment.MIMEType, form.Value["MIMEType"][0])
}
dataFile, err := form.File["DataPacket"][0].Open()
if err != nil {
t.Error("Expected no error while opening packets file, got:", err)
}
defer Ok(t, dataFile.Close())
b, err := ioutil.ReadAll(dataFile)
if err != nil {
t.Error("Expected no error while reading packets file, got:", err)
}
if string(b) != testAttachmentCleartext {
t.Errorf("Invalid attachment packets: expected %v but got %v", testAttachment.KeyPackets, string(b))
}
fmt.Fprint(w, testCreateAttachmentBody)
}))
defer s.Close()
r := strings.NewReader(testAttachmentCleartext) // In reality, this thing is encrypted
created, err := c.CreateAttachment(testAttachment, r, strings.NewReader(""))
if err != nil {
t.Fatal("Expected no error while creating attachment, got:", err)
}
if created.ID != testAttachment.ID {
t.Errorf("Invalid attachment id: expected %v but got %v", testAttachment.ID, created.ID)
}
}
func TestClient_DeleteAttachment(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "DELETE", "/attachments/"+testAttachment.ID))
b := &bytes.Buffer{}
if n, _ := b.ReadFrom(r.Body); n != 0 {
t.Fatal("expected no body but have: ", b.String())
}
fmt.Fprint(w, testDeleteAttachmentBody)
}))
defer s.Close()
err := c.DeleteAttachment(testAttachment.ID)
if err != nil {
t.Fatal("Expected no error while deleting attachment, got:", err)
}
}
func TestClient_GetAttachment(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "GET", "/attachments/"+testAttachment.ID))
fmt.Fprint(w, testAttachmentCleartext)
}))
defer s.Close()
r, err := c.GetAttachment(testAttachment.ID)
if err != nil {
t.Fatal("Expected no error while getting attachment, got:", err)
}
defer r.Close() //nolint[errcheck]
// In reality, r contains encrypted data
b, err := ioutil.ReadAll(r)
if err != nil {
t.Fatal("Expected no error while reading attachment, got:", err)
}
if string(b) != testAttachmentCleartext {
t.Errorf("Invalid attachment data: expected %q but got %q", testAttachmentCleartext, string(b))
}
}
func TestAttachment_Encrypt(t *testing.T) {
data := bytes.NewBufferString(testAttachmentCleartext)
r, err := testAttachment.Encrypt(testPublicKeyRing, data)
assert.Nil(t, err)
b, err := ioutil.ReadAll(r)
assert.Nil(t, err)
// 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)
}
func TestAttachment_Decrypt(t *testing.T) {
dataBytes, _ := base64.StdEncoding.DecodeString(testAttachmentEncrypted)
dataReader := bytes.NewBuffer(dataBytes)
decryptAndCheck(t, dataReader)
}
func decryptAndCheck(t *testing.T, data io.Reader) {
r, err := testAttachment.Decrypt(data, testPrivateKeyRing)
assert.Nil(t, err)
b, err := ioutil.ReadAll(r)
assert.Nil(t, err)
assert.Equal(t, testAttachmentCleartext, string(b))
}

506
pkg/pmapi/auth.go Normal file
View File

@ -0,0 +1,506 @@
// Copyright (c) 2020 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 (
"crypto/subtle"
"encoding/base64"
"errors"
"net/http"
"strings"
"time"
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
"github.com/ProtonMail/proton-bridge/pkg/srp"
)
var ErrBad2FACode = errors.New("incorrect 2FA code")
var ErrBad2FACodeTryAgain = errors.New("incorrect 2FA code: please try again")
type AuthInfoReq struct {
Username string
}
type U2FInfo struct {
Challenge string
RegisteredKeys []struct {
Version string
KeyHandle string
}
}
type TwoFactorInfo struct {
Enabled int // 0 for disabled, 1 for OTP, 2 for U2F, 3 for both.
TOTP int
U2F U2FInfo
}
func (twoFactor *TwoFactorInfo) hasTwoFactor() bool {
return twoFactor.Enabled > 0
}
// AuthInfo contains data used when authenticating a user. It should be
// provided to Client.Auth(). Each AuthInfo can be used for only one login attempt.
type AuthInfo struct {
TwoFA *TwoFactorInfo `json:"2FA,omitempty"`
version int
salt string
modulus string
srpSession string
serverEphemeral string
}
func (a *AuthInfo) HasTwoFactor() bool {
if a.TwoFA == nil {
return false
}
return a.TwoFA.hasTwoFactor()
}
type AuthInfoRes struct {
Res
AuthInfo
Modulus string
ServerEphemeral string
Version int
Salt string
SRPSession string
}
func (res *AuthInfoRes) getAuthInfo() *AuthInfo {
info := &res.AuthInfo
// Some fields in AuthInfo are private, so we need to copy them from AuthRes
// (private fields cannot be populated by json).
info.version = res.Version
info.salt = res.Salt
info.modulus = res.Modulus
info.srpSession = res.SRPSession
info.serverEphemeral = res.ServerEphemeral
return info
}
type AuthReq struct {
Username string
ClientProof string
ClientEphemeral string
SRPSession string
}
// Auth contains data after a successful authentication. It should be provided to Client.Unlock().
type Auth struct {
accessToken string // Read from AuthRes.
ExpiresIn int
Scope string
uid string // Read from AuthRes.
RefreshToken string
KeySalt string
EventID string
PasswordMode int
TwoFA *TwoFactorInfo `json:"2FA,omitempty"`
}
func (s *Auth) UID() string {
return s.uid
}
func (s *Auth) HasTwoFactor() bool {
if s.TwoFA == nil {
return false
}
return s.TwoFA.hasTwoFactor()
}
func (s *Auth) HasMailboxPassword() bool {
return s.PasswordMode == 2
}
func (s *Auth) hasFullScope() bool {
return strings.Contains(s.Scope, "full")
}
type AuthRes struct {
Res
Auth
AccessToken string
TokenType string
UID string
ServerProof string
}
func (res *AuthRes) getAuth() *Auth {
auth := &res.Auth
// Some fields in Auth are private, so we need to copy them from AuthRes
// (private fields cannot be populated by json).
auth.accessToken = res.AccessToken
auth.uid = res.UID
return auth
}
type Auth2FAReq struct {
TwoFactorCode string
// Prepared for U2F:
// U2F U2FRequest
}
type Auth2FA struct {
Scope string
}
type Auth2FARes struct {
Res
Scope string
}
func (res *Auth2FARes) getAuth2FA() *Auth2FA {
return &Auth2FA{
Scope: res.Scope,
}
}
type AuthRefreshReq struct {
ResponseType string
GrantType string
RefreshToken string
UID string
RedirectURI string
State string
}
// SetAuths sets auths channel.
func (c *Client) SetAuths(auths chan<- *Auth) {
c.auths = auths
}
// AuthInfo gets authentication info for a user.
func (c *Client) AuthInfo(username string) (info *AuthInfo, err error) {
infoReq := &AuthInfoReq{
Username: username,
}
req, err := NewJSONRequest("POST", "/auth/info", infoReq)
if err != nil {
return
}
var infoRes AuthInfoRes
if err = c.DoJSON(req, &infoRes); err != nil {
return
}
info, err = infoRes.getAuthInfo(), infoRes.Err()
return
}
func srpProofsFromInfo(info *AuthInfo, username, password string, fallbackVersion int) (proofs *srp.SrpProofs, err error) {
version := info.version
if version == 0 {
version = fallbackVersion
}
srpAuth, err := srp.NewSrpAuth(version, username, password, info.salt, info.modulus, info.serverEphemeral)
if err != nil {
return
}
proofs, err = srpAuth.GenerateSrpProofs(2048)
return
}
func (c *Client) tryAuth(username, password string, info *AuthInfo, fallbackVersion int) (res *AuthRes, err error) {
proofs, err := srpProofsFromInfo(info, username, password, fallbackVersion)
if err != nil {
return
}
authReq := &AuthReq{
Username: username,
ClientEphemeral: base64.StdEncoding.EncodeToString(proofs.ClientEphemeral),
ClientProof: base64.StdEncoding.EncodeToString(proofs.ClientProof),
SRPSession: info.srpSession,
}
req, err := NewJSONRequest("POST", "/auth", authReq)
if err != nil {
return
}
var authRes AuthRes
if err = c.DoJSON(req, &authRes); err != nil {
return
}
if err = authRes.Err(); err != nil {
return
}
serverProof, err := base64.StdEncoding.DecodeString(authRes.ServerProof)
if err != nil {
return
}
if subtle.ConstantTimeCompare(proofs.ExpectedServerProof, serverProof) != 1 {
return nil, errors.New("pmapi: bad server proof")
}
res, err = &authRes, authRes.Err()
return res, err
}
func (c *Client) tryFullAuth(username, password string, fallbackVersion int) (info *AuthInfo, authRes *AuthRes, err error) {
info, err = c.AuthInfo(username)
if err != nil {
return
}
authRes, err = c.tryAuth(username, password, info, fallbackVersion)
return
}
// Auth will authenticate a user.
func (c *Client) Auth(username, password string, info *AuthInfo) (auth *Auth, err error) {
if info == nil {
if info, err = c.AuthInfo(username); err != nil {
return
}
}
authRes, err := c.tryAuth(username, password, info, 2)
if err != nil && info.version == 0 && srp.CleanUserName(username) != strings.ToLower(username) {
info, authRes, err = c.tryFullAuth(username, password, 1)
}
if err != nil && info.version == 0 {
_, authRes, err = c.tryFullAuth(username, password, 0)
}
if err != nil {
return
}
auth = authRes.getAuth()
c.uid = auth.UID()
c.accessToken = auth.accessToken
if c.auths != nil {
c.auths <- auth
}
if c.tokenManager != nil {
c.tokenManager.SetToken(c.userID, c.uid+":"+auth.RefreshToken)
c.log.Info("Set token from auth " + c.uid + ":" + auth.RefreshToken)
}
// Auth has to be fully unlocked to get key salt. During `Auth` it can happen
// only to accounts without 2FA. For 2FA accounts, it's done in `Auth2FA`.
if auth.hasFullScope() {
err = c.setKeySaltToAuth(auth)
if err != nil {
return nil, err
}
}
c.expiresAt = time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second)
return auth, err
}
// Auth2FA will authenticate a user into full scope.
// `Auth` struct contains method `HasTwoFactor` deciding whether this has to be done.
func (c *Client) Auth2FA(twoFactorCode string, auth *Auth) (*Auth2FA, error) {
auth2FAReq := &Auth2FAReq{
TwoFactorCode: twoFactorCode,
}
req, err := NewJSONRequest("POST", "/auth/2fa", auth2FAReq)
if err != nil {
return nil, err
}
var auth2FARes Auth2FARes
if err := c.DoJSON(req, &auth2FARes); err != nil {
return nil, err
}
if err := auth2FARes.Err(); err != nil {
switch auth2FARes.StatusCode {
case http.StatusUnauthorized:
return nil, ErrBad2FACode
case http.StatusUnprocessableEntity:
return nil, ErrBad2FACodeTryAgain
default:
return nil, err
}
}
if err := c.setKeySaltToAuth(auth); err != nil {
return nil, err
}
return auth2FARes.getAuth2FA(), nil
}
func (c *Client) setKeySaltToAuth(auth *Auth) error {
// KeySalt already set up, no need to do it again.
if auth.KeySalt != "" {
return nil
}
user, err := c.CurrentUser()
if err != nil {
return err
}
salts, err := c.GetKeySalts()
if err != nil {
return err
}
for _, s := range salts {
if s.ID == user.KeyRing().FirstKeyID {
auth.KeySalt = s.KeySalt
break
}
}
return nil
}
// Unlock decrypts the key ring.
// If the password is invalid, IsUnlockError(err) will return true.
func (c *Client) Unlock(password string) (kr *pmcrypto.KeyRing, err error) {
if _, err = c.CurrentUser(); err != nil {
return
}
c.keyLocker.Lock()
defer c.keyLocker.Unlock()
kr = c.user.KeyRing()
if err = unlockKeyRingNoErrorWhenAlreadyUnlocked(kr, []byte(password)); err != nil {
return
}
c.kr = kr
return kr, err
}
// AuthRefresh will refresh an expired access token.
func (c *Client) AuthRefresh(uidAndRefreshToken string) (auth *Auth, err error) {
// If we don't yet have a saved access token, save this one in case the refresh fails!
// That way we can try again later (see handleUnauthorizedStatus).
if c.tokenManager != nil {
currentAccessToken := c.tokenManager.GetToken(c.userID)
if currentAccessToken == "" {
c.log.WithField("token", uidAndRefreshToken).
Info("Currently have no access token, setting given one")
c.tokenManager.SetToken(c.userID, uidAndRefreshToken)
}
}
split := strings.Split(uidAndRefreshToken, ":")
if len(split) != 2 {
err = ErrInvalidToken
return
}
refreshReq := &AuthRefreshReq{
ResponseType: "token",
GrantType: "refresh_token",
RefreshToken: split[1],
UID: split[0],
RedirectURI: "https://protonmail.ch",
State: "random_string",
}
// UID must be set for `x-pm-uid` header field, see backend-communication#11
c.uid = split[0]
req, err := NewJSONRequest("POST", "/auth/refresh", refreshReq)
if err != nil {
return
}
var res AuthRes
if err = c.DoJSON(req, &res); err != nil {
return
}
if err = res.Err(); err != nil {
return
}
auth = res.getAuth()
// UID should never change after auth, see backend-communication#11
auth.uid = c.uid
if c.auths != nil {
c.auths <- auth
}
c.uid = auth.UID()
c.accessToken = auth.accessToken
if c.tokenManager != nil {
c.tokenManager.SetToken(c.userID, c.uid+":"+res.RefreshToken)
c.log.Info("Set token from auth refresh " + c.uid + ":" + res.RefreshToken)
}
c.expiresAt = time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second)
return auth, err
}
// Logout logs the current user out.
func (c *Client) Logout() (err error) {
req, err := NewRequest("DELETE", "/auth", nil)
if err != nil {
return
}
var res Res
if err = c.DoJSON(req, &res); err != nil {
return
}
if err = res.Err(); err != nil {
return
}
// This can trigger a deadlock! We don't want to do it if the above requests failed (GODT-154).
// That's why it's not in the deferred statement above.
if c.auths != nil {
c.auths <- nil
}
// This should ideally be deferred at the top of this method so that it is executed
// regardless of what happens, but we currently don't have a way to prevent ourselves
// from using a logged out client. So for now, it's down here, as it was in Charles release.
// defer func() {
c.uid = ""
c.accessToken = ""
c.kr = nil
// c.addresses = nil
c.user = nil
if c.tokenManager != nil {
c.tokenManager.SetToken(c.userID, "")
}
// }()
return err
}

366
pkg/pmapi/auth_test.go Normal file
View File

@ -0,0 +1,366 @@
// Copyright (c) 2020 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 (
"encoding/json"
"math/rand"
"net/http"
"testing"
"time"
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
"github.com/ProtonMail/proton-bridge/pkg/srp"
"github.com/sirupsen/logrus"
a "github.com/stretchr/testify/assert"
r "github.com/stretchr/testify/require"
)
var aLongTimeAgo = time.Unix(233431200, 0)
var testIdentity = &pmcrypto.Identity{
Name: "UserID",
Email: "",
}
const (
testUsername = "jason"
testAPIPassword = "apple"
testUID = "729ad6012421d67ad26950dc898bebe3a6e3caa2" //nolint[gosec]
testAccessToken = "de0423049b44243afeec7d9c1d99be7b46da1e8a" //nolint[gosec]
testAccessTokenOld = "feb3159ac63fb05119bcf4480d939278aa746926" //nolint[gosec]
testRefreshToken = "a49b98256745bb497bec20e9b55f5de16f01fb52" //nolint[gosec]
testRefreshTokenNew = "b894b4c4f20003f12d486900d8b88c7d68e67235" //nolint[gosec]
)
var testAuthInfo = &AuthInfo{
TwoFA: &TwoFactorInfo{TOTP: 1},
version: 4,
salt: "yKlc5/CvObfoiw==",
modulus: "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nW2z5HBi8RvsfYzZTS7qBaUxxPhsfHJFZpu3Kd6s1JafNrCCH9rfvPLrfuqocxWPgWDH2R8neK7PkNvjxto9TStuY5z7jAzWRvFWN9cQhAKkdWgy0JY6ywVn22+HFpF4cYesHrqFIKUPDMSSIlWjBVmEJZ/MusD44ZT29xcPrOqeZvwtCffKtGAIjLYPZIEbZKnDM1Dm3q2K/xS5h+xdhjnndhsrkwm9U9oyA2wxzSXFL+pdfj2fOdRwuR5nW0J2NFrq3kJjkRmpO/Genq1UW+TEknIWAb6VzJJJA244K/H8cnSx2+nSNZO3bbo6Ys228ruV9A8m6DhxmS+bihN3ttQ==\n-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwl4EARYIABAFAlwB1j0JEDUFhcTpUY8mAAD8CgEAnsFnF4cF0uSHKkXa1GIa\nGO86yMV4zDZEZcDSJo0fgr8A/AlupGN9EdHlsrZLmTA1vhIx+rOgxdEff28N\nkvNM7qIK\n=q6vu\n-----END PGP SIGNATURE-----\n",
srpSession: "9b2946bbd9055f17c34940abdce0c3d3",
serverEphemeral: "5tfigcLKoM0DPWYB+EqYE7QlqsiT63iOVlO5ZX0lTMEILSsrRdVCYrN8L3zkinsAjUZ/cx5wIS7N05k66uZb+ZE3lFOJS2s1BkqLvCrGxYL0e3n5YAnzHYlvCCJKXw/sK57ntfF1OOoblBXX6dw5LjeeDglEep2/DaE0TjD8WUpq4Ls2HlQGn9wrC7dFO2lJXsMhRffxKghiOsdvCLXDmwXginzn/LFezA8KrDsWOBSEGntwpg3s1xFj5h8BqtRHvC0igmoscqgw+3GCMTJ0NZAQ/L+5aJ/0ccL0WBK208ltCNl+/X6Sz0kpyvOP4RqFJhC1auVDJ9AjZQYSYZ1NEQ==",
}
// testAuth has default values which are adjusted in each test.
var testAuth = &Auth{
EventID: "NcKPtU5eMNPMrDkIMbEJrgMtC9yQ7Xc5ZBT-tB3UtV1rZ324RWfCIdBI758q0UnsfywS8CkNenIQlWLIX_dUng==",
ExpiresIn: 86400,
RefreshToken: "feb3159ac63fb05119bcf4480d939278aa746926",
Scope: "full mail payments reset keys",
accessToken: testAccessToken,
uid: testUID,
}
var testAuth2FA = &Auth2FA{
Scope: "full mail payments reset keys",
}
var testAuthRefreshReq = AuthRefreshReq{
ResponseType: "token",
GrantType: "refresh_token",
RefreshToken: testRefreshToken,
UID: testUID,
RedirectURI: "https://protonmail.ch",
State: "random_string",
}
var testAuthReq = AuthReq{
Username: testUsername,
ClientProof: "axfvYdl9iXZjY6zQ+hBYmY7X3TDc/9JtSvrmyZXhDxjxkXB3Hro27t1KItmFIJloItY5sLZDs0eEEZJI34oFZD4ViSG0kfB7ZXcCZ9Jse+U5OFu4vdnPTGolnSofRMEs1NR6ePXzH7mQ10qoq43ity3ve2vmhQNuJNlHAPynKf2WqKOgxq7mmkBzEpXES4mIhwwgVbOygKcUSvguz5E5g13ATF0ZX2d9SJWAbZ262Tks+h99Cdk/dOfgLQhr0nO/r0cpwP84W2RWU2Q34LNkKuuQHkjmxelgBleGq54tCbhoCAYPP6vapgrQjNoVAC/dkjIIAoNL9bJSIynFM5znAA==",
ClientEphemeral: "mK+eSMosfZO/Cs5s+vcbjpsN7F8UAObwlKKnCy/z9FpoMRM2PfTe5ywLBgffmLYaapPq7XOxaqaj08kcZLHcM1fIA2JQZZTKPnESN1qAQztJ3/YHMI0op6yBgzx9803OjIznjCD2B3XBSMOHIG4oG0UwocsIX32hiMnYlMMkt8NGrityPlnmEbxpRna3fu9LEZ+v0uo6PjKCrO7+9E3uaMi64HadXBfyx2raBFFwA+yh7FvE7U+hl3AJclEre4d8pmfhMdxXze1soJI8fMuqaa07rY0r0rF5mLLTuqTIGRFkU1qG9loq9+IMsSwgkt1P3ghW63JK7Y6LWdDy0d6cAg==",
SRPSession: "9b2946bbd9055f17c34940abdce0c3d3",
}
var testAuth2FAReq = Auth2FAReq{
TwoFactorCode: "424242",
}
func init() {
logrus.SetLevel(logrus.DebugLevel)
srp.RandReader = rand.New(rand.NewSource(42))
}
func TestClient_AuthInfo(t *testing.T) {
finish, c := newTestServerCallbacks(t,
func(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
Ok(t, checkMethodAndPath(r, "POST", "/auth/info"))
var infoReq AuthInfoReq
Ok(t, json.NewDecoder(r.Body).Decode(&infoReq))
Equals(t, infoReq.Username, testUsername)
return "/auth/info/post_response.json"
},
)
defer finish()
info, err := c.AuthInfo(testCurrentUser.Name)
Ok(t, err)
Equals(t, testAuthInfo, info)
}
// TestClient_Auth reflects changes from proton/backend-communcation#3.
func TestClient_Auth(t *testing.T) {
srp.RandReader = rand.New(rand.NewSource(42))
finish, c := newTestServerCallbacks(t,
func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
a.Nil(t, checkMethodAndPath(req, "POST", "/auth"))
var authReq AuthReq
r.Nil(t, json.NewDecoder(req.Body).Decode(&authReq))
r.Equal(t, testAuthReq, authReq)
return "/auth/post_response.json"
},
routeGetUsers,
routeGetAddresses,
routeGetSalts,
)
defer finish()
auth, err := c.Auth(testUsername, testAPIPassword, testAuthInfo)
r.Nil(t, err)
r.True(t, c.user.KeyRing().FirstKeyID != "", "Parsing First key ID issue")
exp := &Auth{}
*exp = *testAuth
exp.accessToken = testAccessToken
exp.RefreshToken = testRefreshToken
exp.KeySalt = "abc"
a.Equal(t, exp, auth)
}
func TestClient_Auth2FA(t *testing.T) {
finish, c := newTestServerCallbacks(t,
func(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
Ok(t, checkMethodAndPath(r, "POST", "/auth/2fa"))
var info2FAReq Auth2FAReq
Ok(t, json.NewDecoder(r.Body).Decode(&info2FAReq))
Equals(t, info2FAReq.TwoFactorCode, testAuth2FAReq.TwoFactorCode)
return "/auth/2fa/post_response.json"
},
routeGetUsers,
routeGetAddresses,
routeGetSalts,
)
defer finish()
c.uid = testUID
c.accessToken = testAccessToken
auth2FA, err := c.Auth2FA(testAuth2FAReq.TwoFactorCode, testAuth)
Ok(t, err)
Equals(t, testAuth2FA, auth2FA)
}
func TestClient_Auth2FA_Fail(t *testing.T) {
finish, c := newTestServerCallbacks(t,
func(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
Ok(t, checkMethodAndPath(r, "POST", "/auth/2fa"))
var info2FAReq Auth2FAReq
Ok(t, json.NewDecoder(r.Body).Decode(&info2FAReq))
Equals(t, info2FAReq.TwoFactorCode, testAuth2FAReq.TwoFactorCode)
return "/auth/2fa/post_401_bad_password.json"
},
)
defer finish()
c.uid = testUID
c.accessToken = testAccessToken
_, err := c.Auth2FA(testAuth2FAReq.TwoFactorCode, testAuth)
Equals(t, ErrBad2FACode, err)
}
func TestClient_Auth2FA_Retry(t *testing.T) {
finish, c := newTestServerCallbacks(t,
func(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
Ok(t, checkMethodAndPath(r, "POST", "/auth/2fa"))
var info2FAReq Auth2FAReq
Ok(t, json.NewDecoder(r.Body).Decode(&info2FAReq))
Equals(t, info2FAReq.TwoFactorCode, testAuth2FAReq.TwoFactorCode)
return "/auth/2fa/post_422_bad_password.json"
},
)
defer finish()
c.uid = testUID
c.accessToken = testAccessToken
_, err := c.Auth2FA(testAuth2FAReq.TwoFactorCode, testAuth)
Equals(t, ErrBad2FACodeTryAgain, err)
}
func TestClient_Unlock(t *testing.T) {
finish, c := newTestServerCallbacks(t,
routeGetUsers,
routeGetAddresses,
)
defer finish()
c.uid = testUID
c.accessToken = testAccessToken
_, err := c.Unlock("wrong")
a.True(t, IsUnlockError(err), "expected error, pasword is wrong")
_, err = c.Unlock(testMailboxPassword)
a.Nil(t, err)
a.Equal(t, testUID, c.uid)
a.Equal(t, testAccessToken, c.accessToken)
// second try should not fail because there is an unlocked key already
_, err = c.Unlock("wrong")
a.Nil(t, err)
}
func TestClient_Unlock_EncPrivKey(t *testing.T) {
finish, c := newTestServerCallbacks(t,
routeGetUsers,
routeGetAddresses,
)
defer finish()
c.uid = testUID
c.accessToken = testAccessToken
_, err := c.Unlock(testMailboxPassword)
Ok(t, err)
Equals(t, testUID, c.uid)
Equals(t, testAccessToken, c.accessToken)
}
func routeAuthRefresh(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
Ok(tb, checkMethodAndPath(r, "POST", "/auth/refresh"))
Ok(tb, checkHeader(r.Header, "x-pm-uid", testUID))
var refreshReq AuthRefreshReq
Ok(tb, json.NewDecoder(r.Body).Decode(&refreshReq))
Equals(tb, testAuthRefreshReq, refreshReq)
return "/auth/refresh/post_response.json"
}
// TestClient_AuthRefresh reflects changes from proton/backend-communcation#11.
func TestClient_AuthRefresh(t *testing.T) {
finish, c := newTestServerCallbacks(t,
routeAuthRefresh,
)
defer finish()
c.uid = "" // Testing that we always send correct `x-pm-uid`.
c.accessToken = "oldToken"
auth, err := c.AuthRefresh(testUID + ":" + testRefreshToken)
Ok(t, err)
exp := &Auth{}
*exp = *testAuth
exp.accessToken = testAccessToken
exp.KeySalt = ""
exp.EventID = ""
exp.ExpiresIn = 360000
exp.RefreshToken = testRefreshTokenNew
Equals(t, exp, auth)
}
func routeAuthRefreshHasUID(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
Ok(tb, checkMethodAndPath(r, "POST", "/auth/refresh"))
Ok(tb, checkHeader(r.Header, "x-pm-uid", testUID))
var refreshReq AuthRefreshReq
Ok(tb, json.NewDecoder(r.Body).Decode(&refreshReq))
Equals(tb, testAuthRefreshReq, refreshReq)
return "/auth/refresh/post_resp_has_uid.json"
}
// TestClient_AuthRefresh reflects changes from proton/backend-communcation#3.
func TestClient_AuthRefresh_HasUID(t *testing.T) {
finish, c := newTestServerCallbacks(t,
routeAuthRefreshHasUID,
)
defer finish()
c.uid = testUID
c.accessToken = "oldToken"
auth, err := c.AuthRefresh(testUID + ":" + testRefreshToken)
Ok(t, err)
exp := &Auth{}
*exp = *testAuth
exp.accessToken = testAccessToken
exp.KeySalt = ""
exp.EventID = ""
exp.ExpiresIn = 360000
exp.RefreshToken = testRefreshTokenNew
Equals(t, exp, auth)
}
func TestClient_Logout(t *testing.T) {
finish, c := newTestServerCallbacks(t,
func(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
Ok(t, checkMethodAndPath(r, "DELETE", "/auth"))
Ok(t, isAuthReq(r, testUID, testAccessToken))
return "auth/delete_response.json"
},
)
defer finish()
c.uid = testUID
c.accessToken = testAccessToken
Ok(t, c.Logout())
}
func TestClient_DoUnauthorized(t *testing.T) {
finish, c := newTestServerCallbacks(t,
func(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
Ok(t, checkMethodAndPath(r, "GET", "/"))
return httpResponse(http.StatusUnauthorized)
},
routeAuthRefresh,
func(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
Ok(t, checkMethodAndPath(r, "GET", "/"))
Ok(t, isAuthReq(r, testUID, testAccessToken))
return httpResponse(http.StatusOK)
},
)
defer finish()
c.uid = testUID
c.accessToken = testAccessTokenOld
c.expiresAt = aLongTimeAgo
c.tokenManager = NewTokenManager()
c.tokenManager.tokenMap[c.userID] = testUID + ":" + testRefreshToken
req, err := NewRequest("GET", "/", nil)
Ok(t, err)
res, err := c.Do(req, true)
Ok(t, err)
defer Ok(t, res.Body.Close())
}

View File

@ -0,0 +1,23 @@
// Copyright (c) 2020 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
// DANGEROUSLYSetUID SHOULD NOT be used!!! This is only for testing purposes.
func (s *Auth) DANGEROUSLYSetUID(uid string) {
s.uid = uid
}

217
pkg/pmapi/bugs.go Normal file
View File

@ -0,0 +1,217 @@
// Copyright (c) 2020 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 (
"archive/zip"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"runtime"
"strings"
)
// ClientType is required by API.
const (
EmailClientType = iota + 1
VPNClientType
)
type reportAtt struct {
name, filename string
body io.Reader
}
// ReportReq stores data for report.
type ReportReq struct {
OS string `json:",omitempty"`
OSVersion string `json:",omitempty"`
Browser string `json:",omitempty"`
BrowserVersion string `json:",omitempty"`
BrowserExtensions string `json:",omitempty"`
Resolution string `json:",omitempty"`
DisplayMode string `json:",omitempty"`
Client string `json:",omitempty"`
ClientVersion string `json:",omitempty"`
ClientType int `json:",omitempty"`
Title string `json:",omitempty"`
Description string `json:",omitempty"`
Username string `json:",omitempty"`
Email string `json:",omitempty"`
Country string `json:",omitempty"`
ISP string `json:",omitempty"`
Debug string `json:",omitempty"`
Attachments []reportAtt `json:",omitempty"`
}
// AddAttachment to report.
func (rep *ReportReq) AddAttachment(name, filename string, r io.Reader) {
rep.Attachments = append(rep.Attachments, reportAtt{name: name, filename: filename, body: r})
}
func writeMultipartReport(w *multipart.Writer, rep *ReportReq) error { // nolint[funlen]
fieldData := map[string]string{
"OS": rep.OS,
"OSVersion": rep.OSVersion,
"Browser": rep.Browser,
"BrowserVersion": rep.BrowserVersion,
"BrowserExtensions": rep.BrowserExtensions,
"Resolution": rep.Resolution,
"DisplayMode": rep.DisplayMode,
"Client": rep.Client,
"ClientVersion": rep.ClientVersion,
"ClientType": "1",
"Title": rep.Title,
"Description": rep.Description,
"Username": rep.Username,
"Email": rep.Email,
"Country": rep.Country,
"ISP": rep.ISP,
"Debug": rep.Debug,
}
for field, data := range fieldData {
if data == "" {
continue
}
if err := w.WriteField(field, data); err != nil {
return err
}
}
quoteEscaper := strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
for _, att := range rep.Attachments {
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
quoteEscaper.Replace(att.name), quoteEscaper.Replace(att.filename+".zip")))
h.Set("Content-Type", "application/octet-stream")
//h.Set("Content-Transfere-Encoding", "base64")
attWr, err := w.CreatePart(h)
if err != nil {
return err
}
zipArch := zip.NewWriter(attWr)
zipWr, err := zipArch.Create(att.filename)
//b64 := base64.NewEncoder(base64.StdEncoding, zipWr)
if err != nil {
return err
}
_, err = io.Copy(zipWr, att.body)
if err != nil {
return err
}
err = zipArch.Close()
//err = b64.Close()
if err != nil {
return err
}
}
return nil
}
// Report sends request as json or multipart (if has attachment).
func (c *Client) Report(rep ReportReq) (err error) {
rep.Client = c.config.ClientID
rep.ClientVersion = c.config.AppVersion
rep.ClientType = EmailClientType
var req *http.Request
var w *MultipartWriter
if len(rep.Attachments) > 0 {
req, w, err = NewMultipartRequest("POST", "/reports/bug")
} else {
req, err = NewJSONRequest("POST", "/reports/bug", rep)
}
if err != nil {
return
}
var res Res
done := make(chan error, 1)
go func() {
done <- c.DoJSON(req, &res)
}()
if w != nil {
err = writeMultipartReport(w.Writer, &rep)
if err != nil {
c.log.Errorln("report write: ", err)
return
}
err = w.Close()
if err != nil {
c.log.Errorln("report close: ", err)
return
}
}
if err = <-done; err != nil {
return
}
return res.Err()
}
// ReportBug is old. Use Report instead.
func (c *Client) ReportBug(os, osVersion, title, description, username, email string) (err error) {
return c.ReportBugWithEmailClient(os, osVersion, title, description, username, email, "")
}
// ReportBugWithEmailClient is old. Use Report instead.
func (c *Client) ReportBugWithEmailClient(os, osVersion, title, description, username, email, emailClient string) (err error) {
bugReq := ReportReq{
OS: os,
OSVersion: osVersion,
Browser: emailClient,
Title: title,
Description: description,
Username: username,
Email: email,
}
return c.Report(bugReq)
}
// ReportCrash is old. Use sentry instead.
func (c *Client) ReportCrash(stacktrace string) (err error) {
crashReq := ReportReq{
Client: c.config.ClientID,
ClientVersion: c.config.AppVersion,
ClientType: EmailClientType,
OS: runtime.GOOS,
Debug: stacktrace,
}
req, err := NewJSONRequest("POST", "/reports/crash", crashReq)
if err != nil {
return
}
var res Res
if err = c.DoJSON(req, &res); err != nil {
return
}
err = res.Err()
return
}

180
pkg/pmapi/bugs_test.go Normal file
View File

@ -0,0 +1,180 @@
// Copyright (c) 2020 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 (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"runtime"
"strings"
"testing"
)
var testBugsReportReq = ReportReq{
OS: "Mac OSX",
OSVersion: "10.11.6",
Client: "demoapp",
ClientVersion: "GoPMAPI_1.0.14",
ClientType: 1,
Title: "Big Bug",
Description: "Cannot fetch new messages",
Username: "apple",
Email: "apple@gmail.com",
}
var testBugsReportReqWithEmailClient = ReportReq{
OS: "Mac OSX",
OSVersion: "10.11.6",
Browser: "AppleMail",
Client: "demoapp",
ClientVersion: "GoPMAPI_1.0.14",
ClientType: 1,
Title: "Big Bug",
Description: "Cannot fetch new messages",
Username: "Apple",
Email: "apple@gmail.com",
}
var testBugsCrashReq = ReportReq{
OS: runtime.GOOS,
Client: "demoapp",
ClientVersion: "GoPMAPI_1.0.14",
ClientType: 1,
Debug: "main.func·001()\n/Users/sunny/Code/Go/src/scratch/stack.go:21 +0xabruntime.panic(0x80b80, 0x2101fb150)\n/usr/local/Cellar/go/1.2/libexec/src/pkg/runtime/panic.c:248 +0x106\nmain.inner()/Users/sunny/Code/Go/src/scratch/stack.go:27 +0x68\nmain.outer()\n/Users/sunny/Code/Go/src/scratch/stack.go:13 +0x1a\nmain.main()\n/Users/sunny/Code/Go/src/scratch/stack.go:9 +0x1a",
}
const testBugsBody = `{
"Code": 1000
}
`
const testAttachmentJSONZipped = "PK\x03\x04\x14\x00\b\x00\b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\b\x00\x00\x00last.log\\Rَ\xaaH\x00}ﯨ\xf8r\x1f\xeeܖED;\xe9\ap\x03\x11\x11\x97\x0e8\x99L\xb0(\xa1\xa0\x16\x85b\x91I\xff\xfbD{\x99\xc9}\xab:K\x9d\xa4\xce\xf9\xe7\t\x00\x00z\xf6\xb4\xf7\x02z\xb7a\xe5\xd8\x04*V̭\x8d\xd1lvE}\xd6\xe3\x80\x1f\xd7nX\x9bI[\xa6\xe1a=\xd4a\xa8M\x97\xd9J\xf1F\xeb\x105U\xbd\xb0`XO\xce\xf1hu\x99q\xc3\xfe{\x11ߨ'-\v\x89Z\xa4\x9c5\xaf\xaf\xbd?>R\xd6\x11E\xf7\x1cX\xf0JpF#L\x9eE+\xbe\xe8\x1d\xee\ued2e\u007f\xde]\u06dd\xedo\x97\x87E\xa0V\xf4/$\xc2\xecK\xed\xa0\xdb&\x829\x12\xe5\x9do\xa0\xe9\x1a\xd2\x19\x1e\xf5`\x95гb\xf8\x89\x81\xb7\xa5G\x18\x95\xf3\x9d9\xe8\x93B\x17!\x1a^\xccr\xbb`\xb2\xb4\xb86\x87\xb4h\x0e\xda\xc6u<+\x9e$̓\x95\xccSo\xea\xa4\xdbH!\xe9g\x8b\xd4\b\xb3hܬ\xa6Wk\x14He\xae\x8aPU\xaa\xc1\xee$\xfbH\xb3\xab.I\f<\x89\x06q\xe3-3-\x99\xcdݽ\xe5v\x99\xedn\xac\xadn\xe8Rp=\xb4nJ\xed\xd5\r\x8d\xde\x06Ζ\xf6\xb3\x01\x94\xcb\xf6\xd4\x19r\xe1\xaa$4+\xeaW\xa6F\xfa0\x97\x9cD\f\x8e\xd7\xd6z\v,G\xf3e2\xd4\xe6V\xba\v\xb6\xd9\xe8\xca*\x16\x95V\xa4J\xfbp\xddmF\x8c\x9a\xc6\xc8Č-\xdb\v\xf6\xf5\xf9\x02*\x15e\x874\xc9\xe7\"\xa3\x1an\xabq}ˊq\x957\xd3\xfd\xa91\x82\xe0Lß\\\x17\x8e\x9e_\xed`\t\xe9~5̕\x03\x9a\f\xddN6\xa2\xc4\x17\xdb\xc9V\x1c~\x9e\xea\xbe\xda-xv\xed\x8b\xe2\xc8DŽS\x95E6\xf2\xc3H\x1d:HPx\xc9\x14\xbfɒ\xff\xea\xb4P\x14\xa3\xe2\xfe\xfd\x1f+z\x80\x903\x81\x98\xf8\x15\xa3\x12\x16\xf8\"0g\xf7~B^\xfd \x040T\xa3\x02\x9c\x10\xc1\xa8F\xa0I#\xf1\xa3\x04\x98\x01\x91\xe2\x12\xdc;\x06gL\xd0g\xc0\xe3\xbd\xf6\xd7}&\xa8轀?\xbfяy`X\xf0\x92\x9f\x05\xf0*A8ρ\xac=K\xff\xf3\xfe\xa6Z\xe1\x1a\x017\xc2\x04\f\x94g\xa9\xf7-\xfb\xebqz\u007fz\u007f\xfa7\x00\x00\xff\xffPK\a\b\xf5\\\v\xe5I\x02\x00\x00\r\x03\x00\x00PK\x01\x02\x14\x00\x14\x00\b\x00\b\x00\x00\x00\x00\x00\xf5\\\v\xe5I\x02\x00\x00\r\x03\x00\x00\b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00last.logPK\x05\x06\x00\x00\x00\x00\x01\x00\x01\x006\x00\x00\x00\u007f\x02\x00\x00\x00\x00" //nolint[misspell]
func TestClient_BugReport(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "POST", "/reports/bug"))
Ok(t, isAuthReq(r, testUID, testAccessToken))
var bugsReportReq ReportReq
Ok(t, json.NewDecoder(r.Body).Decode(&bugsReportReq))
Equals(t, testBugsReportReq, bugsReportReq)
fmt.Fprint(w, testBugsBody)
}))
defer s.Close()
c.uid = testUID
c.accessToken = testAccessToken
Ok(t, c.ReportBug(
testBugsReportReq.OS,
testBugsReportReq.OSVersion,
testBugsReportReq.Title,
testBugsReportReq.Description,
testBugsReportReq.Username,
testBugsReportReq.Email,
))
}
func TestClient_BugReportWithAttachment(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "POST", "/reports/bug"))
Ok(t, isAuthReq(r, testUID, testAccessToken))
Ok(t, r.ParseMultipartForm(10*1024))
for field, expected := range map[string]string{
"OS": testBugsReportReq.OS,
"OSVersion": testBugsReportReq.OSVersion,
"Client": testBugsReportReq.Client,
"ClientVersion": testBugsReportReq.ClientVersion,
"ClientType": fmt.Sprintf("%d", testBugsReportReq.ClientType),
"Title": testBugsReportReq.Title,
"Description": testBugsReportReq.Description,
"Username": testBugsReportReq.Username,
"Email": testBugsReportReq.Email,
} {
if r.PostFormValue(field) != expected {
t.Errorf("Field %q has %q but expected %q", field, r.PostFormValue(field), expected)
}
}
attReader, err := r.MultipartForm.File["log"][0].Open()
Ok(t, err)
log, err := ioutil.ReadAll(attReader)
Ok(t, err)
Equals(t, []byte(testAttachmentJSONZipped), log)
fmt.Fprint(w, testBugsBody)
}))
defer s.Close()
c.uid = testUID
c.accessToken = testAccessToken
rep := testBugsReportReq
rep.AddAttachment("log", "last.log", strings.NewReader(testAttachmentJSON))
Ok(t, c.Report(rep))
}
func TestClient_BugReportWithEmailClient(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "POST", "/reports/bug"))
Ok(t, isAuthReq(r, testUID, testAccessToken))
var bugsReportReq ReportReq
Ok(t, json.NewDecoder(r.Body).Decode(&bugsReportReq))
Equals(t, testBugsReportReqWithEmailClient, bugsReportReq)
fmt.Fprint(w, testBugsBody)
}))
defer s.Close()
c.uid = testUID
c.accessToken = testAccessToken
Ok(t, c.ReportBugWithEmailClient(
testBugsReportReqWithEmailClient.OS,
testBugsReportReqWithEmailClient.OSVersion,
testBugsReportReqWithEmailClient.Title,
testBugsReportReqWithEmailClient.Description,
testBugsReportReqWithEmailClient.Username,
testBugsReportReqWithEmailClient.Email,
testBugsReportReqWithEmailClient.Browser,
))
}
func TestClient_BugsCrash(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "POST", "/reports/crash"))
Ok(t, isAuthReq(r, testUID, testAccessToken))
var bugsCrashReq ReportReq
Ok(t, json.NewDecoder(r.Body).Decode(&bugsCrashReq))
Equals(t, testBugsCrashReq, bugsCrashReq)
fmt.Fprint(w, testBugsBody)
}))
defer s.Close()
c.uid = testUID
c.accessToken = testAccessToken
Ok(t, c.ReportCrash(testBugsCrashReq.Debug))
}

503
pkg/pmapi/client.go Normal file
View File

@ -0,0 +1,503 @@
// Copyright (c) 2020 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 (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"math/rand"
"net/http"
"reflect"
"strconv"
"strings"
"sync"
"time"
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
"github.com/jaytaylor/html2text"
"github.com/sirupsen/logrus"
)
// Version of the API.
const Version = 3
// API return codes.
const (
ForceUpgradeBadAPIVersion = 5003
ForceUpgradeInvalidAPI = 5004
ForceUpgradeBadAppVersion = 5005
APIOffline = 7001
ImportMessageTooLong = 36022
BansRequests = 85131
)
// The output errors.
var (
ErrInvalidToken = errors.New("refresh token invalid")
ErrAPINotReachable = errors.New("cannot reach the server")
ErrUpgradeApplication = errors.New("application upgrade required")
)
type ErrUnauthorized struct {
error
}
func (err *ErrUnauthorized) Error() string {
return fmt.Sprintf("unauthorized access: %+v", err.error.Error())
}
type TokenManager struct {
tokensLocker sync.Locker
tokenMap map[string]string
}
func NewTokenManager() *TokenManager {
tm := &TokenManager{
tokensLocker: &sync.Mutex{},
tokenMap: map[string]string{},
}
return tm
}
func (tm *TokenManager) GetToken(userID string) string {
tm.tokensLocker.Lock()
defer tm.tokensLocker.Unlock()
return tm.tokenMap[userID]
}
func (tm *TokenManager) SetToken(userID, token string) {
tm.tokensLocker.Lock()
defer tm.tokensLocker.Unlock()
tm.tokenMap[userID] = token
}
// ClientConfig contains Client configuration.
type ClientConfig struct {
// The client application name and version.
AppVersion string
// The client ID.
ClientID string
TokenManager *TokenManager
// Transport specifies the mechanism by which individual HTTP requests are made.
// If nil, http.DefaultTransport is used.
Transport http.RoundTripper
// Timeout specifies the timeout from request to getting response headers to our API.
// Passed to http.Client, empty means no timeout.
Timeout time.Duration
// FirstReadTimeout specifies the timeout from getting response to the first read of body response.
// This timeout is applied only when MinSpeed is used.
// Default is 5 minutes.
FirstReadTimeout time.Duration
// MinSpeed specifies minimum Bytes per second or the request will be canceled.
// Zero means no limitation.
MinSpeed int64
}
// Client to communicate with API.
type Client struct {
auths chan<- *Auth // Channel that sends Auth responses back to the bridge.
log *logrus.Entry
config *ClientConfig
client *http.Client
conrep ConnectionReporter
uid string
accessToken string
userID string // Twice here because Username is not unique.
requestLocker sync.Locker
keyLocker sync.Locker
tokenManager *TokenManager
expiresAt time.Time
user *User
addresses AddressList
kr *pmcrypto.KeyRing
}
// NewClient creates a new API client.
func NewClient(cfg *ClientConfig, userID string) *Client {
hc := &http.Client{
Timeout: cfg.Timeout,
}
if cfg.Transport != nil {
cfgTransport, ok := cfg.Transport.(*http.Transport)
if ok {
// In future use Clone here.
// https://go-review.googlesource.com/c/go/+/174597/
transport := &http.Transport{}
*transport = *cfgTransport //nolint
if transport.Proxy == nil {
transport.Proxy = http.ProxyFromEnvironment
}
hc.Transport = transport
} else {
hc.Transport = cfg.Transport
}
} else if defaultTransport != nil {
hc.Transport = defaultTransport
}
log := logrus.WithFields(logrus.Fields{
"pkg": "pmapi",
"userID": userID,
})
return &Client{
log: log,
config: cfg,
client: hc,
tokenManager: cfg.TokenManager,
userID: userID,
requestLocker: &sync.Mutex{},
keyLocker: &sync.Mutex{},
}
}
// SetConnectionReporter sets the connection reporter used by the client to report when
// internet connection is lost.
func (c *Client) SetConnectionReporter(conrep ConnectionReporter) {
c.conrep = conrep
}
// reportLostConnection reports that the internet connection has been lost using the connection reporter.
// If the connection reporter has not been set, this does nothing.
func (c *Client) reportLostConnection() {
if c.conrep != nil {
err := c.conrep.NotifyConnectionLost()
if err != nil {
logrus.WithError(err).Error("Failed to notify of lost connection")
}
}
}
// Do makes an API request. It does not check for HTTP status code errors.
func (c *Client) Do(req *http.Request, retryUnauthorized bool) (res *http.Response, err error) {
// Copy the request body in case we need to retry it.
var bodyBuffer []byte
if req.Body != nil {
defer req.Body.Close() //nolint[errcheck]
bodyBuffer, err = ioutil.ReadAll(req.Body)
if err != nil {
return nil, err
}
r := bytes.NewReader(bodyBuffer)
req.Body = ioutil.NopCloser(r)
}
return c.doBuffered(req, bodyBuffer, retryUnauthorized)
}
// If needed it retries using req and buffered body.
func (c *Client) doBuffered(req *http.Request, bodyBuffer []byte, retryUnauthorized bool) (res *http.Response, err error) { // nolint[funlen]
isAuthReq := strings.Contains(req.URL.Path, "/auth")
req.Header.Set("x-pm-appversion", c.config.AppVersion)
req.Header.Set("x-pm-apiversion", strconv.Itoa(Version))
if c.uid != "" {
req.Header.Set("x-pm-uid", c.uid)
}
if c.accessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.accessToken)
}
c.log.Debugln("Requesting ", req.Method, req.URL.RequestURI())
if logrus.GetLevel() == logrus.TraceLevel {
head := ""
for i, v := range req.Header {
head += i + ": "
head += strings.Join(v, "")
head += "\n"
}
c.log.Tracef("REQHEAD \n%s", head)
c.log.Tracef("REQBODY '%s'", string(bodyBuffer))
}
hasBody := len(bodyBuffer) > 0
if res, err = c.client.Do(req); err != nil {
if res == nil {
c.log.WithError(err).Error("Cannot get response")
err = ErrAPINotReachable
c.reportLostConnection()
}
return
}
resDate := res.Header.Get("Date")
if resDate != "" {
if serverTime, err := http.ParseTime(resDate); err == nil {
pmcrypto.GetGopenPGP().UpdateTime(serverTime.Unix())
}
}
if res.StatusCode == http.StatusUnauthorized {
if hasBody {
r := bytes.NewReader(bodyBuffer)
req.Body = ioutil.NopCloser(r)
}
if !isAuthReq {
_, _ = io.Copy(ioutil.Discard, res.Body)
_ = res.Body.Close()
return c.handleStatusUnauthorized(req, bodyBuffer, res, retryUnauthorized)
}
}
// Retry induced by HTTP status code>
retryAfter := 10
doRetry := res.StatusCode == http.StatusTooManyRequests
if doRetry {
if headerAfter, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && headerAfter > 0 {
retryAfter = headerAfter
}
// To avoid spikes when all clients retry at the same time, we add some random wait.
retryAfter += rand.Intn(10)
if hasBody {
r := bytes.NewReader(bodyBuffer)
req.Body = ioutil.NopCloser(r)
}
c.log.Warningf("Retrying %s after %ds induced by http code %d", req.URL.Path, retryAfter, res.StatusCode)
time.Sleep(time.Duration(retryAfter) * time.Second)
_, _ = io.Copy(ioutil.Discard, res.Body)
_ = res.Body.Close()
return c.doBuffered(req, bodyBuffer, false)
}
return res, err
}
// DoJSON performs the request and unmarshals the response as JSON into data.
// If the API returns a non-2xx HTTP status code, the error returned will contain status
// and response as plaintext. API errors must be checked by the caller.
// It is performed buffered, in case we need to retry.
func (c *Client) DoJSON(req *http.Request, data interface{}) error {
// Copy the request body in case we need to retry it
var reqBodyBuffer []byte
if req.Body != nil {
defer req.Body.Close() //nolint[errcheck]
var err error
if reqBodyBuffer, err = ioutil.ReadAll(req.Body); err != nil {
return err
}
req.Body = ioutil.NopCloser(bytes.NewReader(reqBodyBuffer))
}
return c.doJSONBuffered(req, reqBodyBuffer, data)
}
// doJSONBuffered performs a buffered json request (see DoJSON for more information).
func (c *Client) doJSONBuffered(req *http.Request, reqBodyBuffer []byte, data interface{}) error { // nolint[funlen]
req.Header.Set("Accept", "application/vnd.protonmail.v1+json")
var cancelRequest context.CancelFunc
if c.config.MinSpeed > 0 {
var ctx context.Context
ctx, cancelRequest = context.WithCancel(req.Context())
defer func() {
cancelRequest()
}()
req = req.WithContext(ctx)
}
res, err := c.doBuffered(req, reqBodyBuffer, false)
if err != nil {
return err
}
defer res.Body.Close() //nolint[errcheck]
var resBody []byte
if c.config.MinSpeed == 0 {
resBody, err = ioutil.ReadAll(res.Body)
} else {
resBody, err = c.readAllMinSpeed(res.Body, cancelRequest)
}
// The server response may contain data which we want to have in memory
// for as little time as possible (such as keys). Go is garbage collected,
// so we are not in charge of when the memory will actually be cleared.
// We can at least try to rewrite the original data to mitigate this problem.
defer func() {
for i := 0; i < len(resBody); i++ {
resBody[i] = byte(65)
}
}()
if logrus.GetLevel() == logrus.TraceLevel {
head := ""
for i, v := range res.Header {
head += i + ": "
head += strings.Join(v, "")
head += "\n"
}
c.log.Tracef("RESHEAD \n%s", head)
c.log.Tracef("RESBODY '%s'", resBody)
}
if err != nil {
return err
}
// Retry induced by API code.
errCode := &Res{}
if err := json.Unmarshal(resBody, errCode); err == nil {
if errCode.Code == BansRequests {
retryAfter := 3
c.log.Warningf("Retrying %s after %ds induced by API code %d", req.URL.Path, retryAfter, errCode.Code)
time.Sleep(time.Duration(retryAfter) * time.Second)
if len(reqBodyBuffer) > 0 {
req.Body = ioutil.NopCloser(bytes.NewReader(reqBodyBuffer))
}
return c.doJSONBuffered(req, reqBodyBuffer, data)
}
}
if err := json.Unmarshal(resBody, data); err != nil {
// Check to see if this is due to a non 2xx HTTP status code.
if res.StatusCode != http.StatusOK {
r := bytes.NewReader(bytes.ReplaceAll(resBody, []byte("\n"), []byte("\\n")))
plaintext, err := html2text.FromReader(r)
if err == nil {
return fmt.Errorf("Error: \n\n" + res.Status + "\n\n" + plaintext)
}
}
if errJS, ok := err.(*json.SyntaxError); ok {
return fmt.Errorf("invalid json %v (offset:%d) ", errJS.Error(), errJS.Offset)
}
return fmt.Errorf("unmarshal fail: %v ", err)
}
// Set StatusCode in case data struct supports that field.
// It's safe to set StatusCode, server returns Code. StatusCode should be preferred over Code.
dataValue := reflect.ValueOf(data).Elem()
statusCodeField := dataValue.FieldByName("StatusCode")
if statusCodeField.IsValid() && statusCodeField.CanSet() && statusCodeField.Kind() == reflect.Int {
statusCodeField.SetInt(int64(res.StatusCode))
}
if res.StatusCode != http.StatusOK {
c.log.Warnf("request %s %s NOT OK: %s", req.Method, req.URL.Path, res.Status)
}
return nil
}
func (c *Client) readAllMinSpeed(data io.Reader, cancelRequest context.CancelFunc) ([]byte, error) {
firstReadTimeout := c.config.FirstReadTimeout
if firstReadTimeout == 0 {
firstReadTimeout = 5 * time.Minute
}
timer := time.AfterFunc(firstReadTimeout, func() {
cancelRequest()
})
var buffer bytes.Buffer
for {
_, err := io.CopyN(&buffer, data, c.config.MinSpeed)
timer.Stop()
timer.Reset(1 * time.Second)
if err == io.EOF {
break
} else if err != nil {
return nil, err
}
}
return ioutil.ReadAll(&buffer)
}
func (c *Client) refreshAccessToken() (err error) {
c.log.Debug("Refreshing token")
refreshToken := c.tokenManager.GetToken(c.userID)
c.log.WithField("token", refreshToken).Info("Current refresh token")
if refreshToken == "" {
if c.auths != nil {
c.auths <- nil
}
if c.tokenManager != nil {
c.tokenManager.SetToken(c.userID, "")
}
return ErrInvalidToken
}
auth, err := c.AuthRefresh(refreshToken)
if err != nil {
c.log.WithError(err).WithField("auths", c.auths).Debug("Token refreshing failed")
// The refresh failed, so we should log the user out.
// A nil value in the Auths channel will trigger this.
if c.auths != nil {
c.auths <- nil
}
if c.tokenManager != nil {
c.tokenManager.SetToken(c.userID, "")
}
return
}
c.uid = auth.UID()
c.accessToken = auth.accessToken
return err
}
func (c *Client) handleStatusUnauthorized(req *http.Request, reqBodyBuffer []byte, res *http.Response, retry bool) (retryRes *http.Response, err error) {
c.log.Info("Handling unauthorized status")
// If this is not a retry, then it is the first time handling status unauthorized,
// so try again without refreshing the access token.
if !retry {
c.log.Debug("Handling unauthorized status by retrying")
c.requestLocker.Lock()
defer c.requestLocker.Unlock()
_, _ = io.Copy(ioutil.Discard, res.Body)
_ = res.Body.Close()
return c.doBuffered(req, reqBodyBuffer, true)
}
// This is already a retry, so we will try to refresh the access token before trying again.
if err = c.refreshAccessToken(); err != nil {
c.log.WithError(err).Warn("Cannot refresh token")
err = &ErrUnauthorized{err}
return
}
_, err = io.Copy(ioutil.Discard, res.Body)
if err != nil {
c.log.WithError(err).Warn("Failed to read out response body")
}
_ = res.Body.Close()
return c.doBuffered(req, reqBodyBuffer, true)
}

215
pkg/pmapi/client_test.go Normal file
View File

@ -0,0 +1,215 @@
// Copyright (c) 2020 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 (
"context"
"fmt"
"io"
"io/ioutil"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var testClientConfig = &ClientConfig{
AppVersion: "GoPMAPI_1.0.14",
ClientID: "demoapp",
FirstReadTimeout: 500 * time.Millisecond,
MinSpeed: 256,
}
func newTestClient() *Client {
c := NewClient(testClientConfig, "tester")
c.tokenManager = NewTokenManager()
return c
}
func TestClient_Do(t *testing.T) {
const testResBody = "Hello World!"
var receivedReq *http.Request
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedReq = r
fmt.Fprint(w, testResBody)
}))
defer s.Close()
req, err := NewRequest("GET", "/", nil)
if err != nil {
t.Fatal("Expected no error while creating request, got:", err)
}
res, err := c.Do(req, true)
if err != nil {
t.Fatal("Expected no error while executing request, got:", err)
}
b, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal("Expected no error while reading response, got:", err)
}
require.Nil(t, res.Body.Close())
if string(b) != testResBody {
t.Fatalf("Invalid response body: expected %v, got %v", testResBody, string(b))
}
h := receivedReq.Header
if h.Get("x-pm-appversion") != testClientConfig.AppVersion {
t.Fatalf("Invalid app version header: expected %v, got %v", testClientConfig.AppVersion, h.Get("x-pm-appversion"))
}
if h.Get("x-pm-apiversion") != fmt.Sprintf("%v", Version) {
t.Fatalf("Invalid api version header: expected %v, got %v", Version, h.Get("x-pm-apiversion"))
}
if h.Get("x-pm-uid") != "" {
t.Fatalf("Expected no uid header when not authenticated, got %v", h.Get("x-pm-uid"))
}
if h.Get("Authorization") != "" {
t.Fatalf("Expected no authentication header when not authenticated, got %v", h.Get("Authorization"))
}
}
func TestClient_DoRetryAfter(t *testing.T) {
testStart := time.Now()
secondAttemptTime := time.Now()
finish, c := newTestServerCallbacks(t,
func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
w.Header().Set("content-type", "application/json;charset=utf-8")
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusTooManyRequests)
return ""
},
func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
w.Header().Set("content-type", "application/json;charset=utf-8")
w.WriteHeader(http.StatusOK)
secondAttemptTime = time.Now()
return "/HTTP_200.json"
},
)
defer finish()
require.Nil(t, c.SendSimpleMetric("some_category", "some_action", "some_label"))
waitedTime := secondAttemptTime.Sub(testStart)
isInRange := 1*time.Second < waitedTime && waitedTime <= 11*time.Second
require.True(t, isInRange, "Waited time: %v", waitedTime)
}
type slowTransport struct {
transport http.RoundTripper
firstBodySleep time.Duration
}
func (t *slowTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.transport.RoundTrip(req)
if err == nil {
resp.Body = &slowReadCloser{
req: req,
readCloser: resp.Body,
firstBodySleep: t.firstBodySleep,
}
}
return resp, err
}
type slowReadCloser struct {
req *http.Request
readCloser io.ReadCloser
firstBodySleep time.Duration
}
func (r *slowReadCloser) Read(p []byte) (n int, err error) {
// Normally timeout is processed by Read function.
// It's hard to test slow connection; we need to manually
// check when context is Done, because otherwise timeout
// happens only during failed Read which will not happen
// in this artificial environment.
select {
case <-r.req.Context().Done():
return 0, context.Canceled
case <-time.After(r.firstBodySleep):
}
return r.readCloser.Read(p)
}
func (r *slowReadCloser) Close() error {
return r.readCloser.Close()
}
func TestClient_FirstReadTimeout(t *testing.T) {
requestTimeout := testClientConfig.FirstReadTimeout + 1*time.Second
finish, c := newTestServerCallbacks(t,
func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
return "/HTTP_200.json"
},
)
defer finish()
c.client.Transport = &slowTransport{
transport: c.client.Transport,
firstBodySleep: requestTimeout,
}
started := time.Now()
err := c.SendSimpleMetric("some_category", "some_action", "some_label")
require.Error(t, err, "cannot reach the server")
require.True(t, time.Since(started) < requestTimeout, "Actual waited time: %v", time.Since(started))
}
func TestClient_MinSpeedTimeout(t *testing.T) {
finish, c := newTestServerCallbacks(t,
routeSlow(2*time.Second),
)
defer finish()
err := c.SendSimpleMetric("some_category", "some_action", "some_label")
require.Error(t, err, "cannot reach the server")
}
func TestClient_MinSpeedNoTimeout(t *testing.T) {
finish, c := newTestServerCallbacks(t,
routeSlow(500*time.Millisecond),
)
defer finish()
err := c.SendSimpleMetric("some_category", "some_action", "some_label")
require.Nil(t, err)
}
func routeSlow(delay time.Duration) func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
return func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
w.Header().Set("content-type", "application/json;charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("{\"code\":1000,\"key\":\""))
for chunk := 1; chunk <= 10; chunk++ {
// We need to write enough bytes which enforce flushing data
// because writer used by httptest does not implement Flusher.
for i := 1; i <= 10000; i++ {
_, _ = w.Write([]byte("a"))
}
time.Sleep(delay)
}
_, _ = w.Write([]byte("\"}"))
return ""
}
}

43
pkg/pmapi/config.go Normal file
View File

@ -0,0 +1,43 @@
// Copyright (c) 2020 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 (
"net/http"
"runtime"
)
// RootURL is the API root URL.
//
// This can be changed using build flags: pmapi_local for "http://localhost/api",
// pmapi_dev or pmapi_prod. Default is pmapi_prod.
var RootURL = "https://api.protonmail.ch" //nolint[gochecknoglobals]
// CurrentUserAgent is the default User-Agent for go-pmapi lib. This can be changed to program
// version and email client.
// e.g. Bridge/1.0.4 (Windows) MicrosoftOutlook/16.0.9330.2087
var CurrentUserAgent = "GoPMAPI/1.0.14 (" + runtime.GOOS + "; no client)" //nolint[gochecknoglobals]
// The HTTP transport to use by default.
var defaultTransport = &http.Transport{ //nolint[gochecknoglobals]
Proxy: http.ProxyFromEnvironment,
}
// checkTLSCerts controls whether TLS certs are checked against known fingerprints.
// The default is for this to always be done.
var checkTLSCerts = true //nolint[gochecknoglobals]

24
pkg/pmapi/config_dev.go Normal file
View File

@ -0,0 +1,24 @@
// Copyright (c) 2020 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/>.
// +build pmapi_dev
package pmapi
func init() {
RootURL = "https://dev.protonmail.com/api"
}

37
pkg/pmapi/config_local.go Normal file
View File

@ -0,0 +1,37 @@
// Copyright (c) 2020 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/>.
// +build pmapi_local
package pmapi
import (
"crypto/tls"
"net/http"
)
func init() {
// Use port above 1000 which doesn't need root access to start anything on it.
// Now the port is rounded pi. :-)
RootURL = "http://127.0.0.1:3142/api"
// TLS certificate is self-signed
defaultTransport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}

25
pkg/pmapi/config_nopin.go Normal file
View File

@ -0,0 +1,25 @@
// Copyright (c) 2020 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/>.
// +build pmapi_nopin
package pmapi
func init() {
// This config disables TLS cert checking.
checkTLSCerts = false
}

23
pkg/pmapi/conrep.go Normal file
View File

@ -0,0 +1,23 @@
// Copyright (c) 2020 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
// ConnectionReporter provides a way to report when internet connection is lost.
type ConnectionReporter interface {
NotifyConnectionLost() error
}

430
pkg/pmapi/contacts.go Normal file
View File

@ -0,0 +1,430 @@
// Copyright (c) 2020 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 (
"errors"
"net/url"
"strconv"
)
type Card struct {
Type int
Data string
Signature string
}
const (
CardEncrypted = 1
CardSigned = 2
)
type Contact struct {
ID string
Name string
UID string
Size int64
CreateTime int64
ModifyTime int64
LabelIDs []string
ContactEmails []ContactEmail
Cards []Card
}
type ContactEmail struct {
ID string
Name string
Email string
Type []string
Defaults int
Order int
ContactID string
LabelIDs []string
}
var errVerificationFailed = errors.New("signature verification failed")
//================= Public utility functions ======================
func (c *Client) EncryptAndSignCards(cards []Card) ([]Card, error) {
var err error
for i := range cards {
card := &cards[i]
if isEncryptedCardType(card.Type) {
if isSignedCardType(card.Type) {
if card.Signature, err = c.sign(card.Data); err != nil {
return nil, err
}
}
if card.Data, err = c.encrypt(card.Data, nil); err != nil {
return nil, err
}
} else if isSignedCardType(card.Type) {
if card.Signature, err = c.sign(card.Data); err != nil {
return nil, err
}
}
}
return cards, nil
}
func (c *Client) DecryptAndVerifyCards(cards []Card) ([]Card, error) {
for i := range cards {
card := &cards[i]
if isEncryptedCardType(card.Type) {
signedCard, err := c.decrypt(card.Data)
if err != nil {
return nil, err
}
card.Data = signedCard
}
if isSignedCardType(card.Type) {
err := c.verify(card.Data, card.Signature)
if err != nil {
return cards, errVerificationFailed
}
}
}
return cards, nil
}
//====================== READ ===========================
type ContactsListRes struct {
Res
Contacts []*Contact
}
// GetContacts gets all contacts.
func (c *Client) GetContacts(page int, pageSize int) (contacts []*Contact, err error) {
v := url.Values{}
v.Set("Page", strconv.Itoa(page))
if pageSize > 0 {
v.Set("PageSize", strconv.Itoa(pageSize))
}
req, err := NewRequest("GET", "/contacts?"+v.Encode(), nil)
if err != nil {
return
}
var res ContactsListRes
if err = c.DoJSON(req, &res); err != nil {
return
}
contacts, err = res.Contacts, res.Err()
return
}
// GetContactByID gets contact details specified by contact ID.
func (c *Client) GetContactByID(id string) (contactDetail Contact, err error) {
req, err := NewRequest("GET", "/contacts/"+id, nil)
if err != nil {
return
}
type ContactRes struct {
Res
Contact Contact
}
var res ContactRes
if err = c.DoJSON(req, &res); err != nil {
return
}
contactDetail, err = res.Contact, res.Err()
return
}
// GetContactsForExport gets contacts in vCard format, signed and encrypted.
func (c *Client) GetContactsForExport(page int, pageSize int) (contacts []Contact, err error) {
v := url.Values{}
v.Set("Page", strconv.Itoa(page))
if pageSize > 0 {
v.Set("PageSize", strconv.Itoa(pageSize))
}
req, err := NewRequest("GET", "/contacts/export?"+v.Encode(), nil)
if err != nil {
return
}
type ContactsDetailsRes struct {
Res
Contacts []Contact
}
var res ContactsDetailsRes
if err = c.DoJSON(req, &res); err != nil {
return
}
contacts, err = res.Contacts, res.Err()
return
}
type ContactsEmailsRes struct {
Res
ContactEmails []ContactEmail
Total int
}
// GetAllContactsEmails gets all emails from all contacts.
func (c *Client) GetAllContactsEmails(page int, pageSize int) (contactsEmails []ContactEmail, err error) {
v := url.Values{}
v.Set("Page", strconv.Itoa(page))
if pageSize > 0 {
v.Set("PageSize", strconv.Itoa(pageSize))
}
req, err := NewRequest("GET", "/contacts/emails?"+v.Encode(), nil)
if err != nil {
return
}
var res ContactsEmailsRes
if err = c.DoJSON(req, &res); err != nil {
return
}
contactsEmails, err = res.ContactEmails, res.Err()
return
}
// GetContactEmailByEmail gets all emails from all contacts matching a specified email string.
func (c *Client) GetContactEmailByEmail(email string, page int, pageSize int) (contactEmails []ContactEmail, err error) {
v := url.Values{}
v.Set("Page", strconv.Itoa(page))
if pageSize > 0 {
v.Set("PageSize", strconv.Itoa(pageSize))
}
v.Set("Email", email)
req, err := NewRequest("GET", "/contacts/emails?"+v.Encode(), nil)
if err != nil {
return
}
var res ContactsEmailsRes
if err = c.DoJSON(req, &res); err != nil {
return
}
contactEmails, err = res.ContactEmails, res.Err()
return
}
//============================ CREATE ====================================
type CardsList struct {
Cards []Card
}
type ContactsCards struct {
Contacts []CardsList
}
type SingleContactResponse struct {
Res
Contact Contact
}
type IndexedContactResponse struct {
Index int
Response SingleContactResponse
}
type AddContactsResponse struct {
Res
Responses []IndexedContactResponse
}
type AddContactsReq struct {
ContactsCards
Overwrite int
Groups int
Labels int
}
// AddContacts adds contacts specified by cards. Performs signing and encrypting based on card type.
func (c *Client) AddContacts(cards ContactsCards, overwrite int, groups int, labels int) (res *AddContactsResponse, err error) {
reqBody := AddContactsReq{
ContactsCards: cards,
Overwrite: overwrite,
Groups: groups,
Labels: labels,
}
req, err := NewJSONRequest("POST", "/contacts", reqBody)
if err != nil {
return
}
var addContactsRes AddContactsResponse
if err = c.DoJSON(req, &addContactsRes); err != nil {
return
}
res, err = &addContactsRes, addContactsRes.Err()
return
}
// ================================= UPDATE =======================================
type UpdateContactResponse struct {
Res
Contact Contact
}
type UpdateContactReq struct {
Cards []Card
}
// UpdateContact updates contact identified by contact ID. Modified contact is specified by cards.
func (c *Client) UpdateContact(id string, cards []Card) (res *UpdateContactResponse, err error) {
reqBody := UpdateContactReq{
Cards: cards,
}
req, err := NewJSONRequest("PUT", "/contacts/"+id, reqBody)
if err != nil {
return
}
var updateContactRes UpdateContactResponse
if err = c.DoJSON(req, &updateContactRes); err != nil {
return
}
res, err = &updateContactRes, updateContactRes.Err()
return
}
type SingleIDResponse struct {
Res
ID string
}
type UpdateContactGroupsResponse struct {
Res
Response SingleIDResponse
}
func (c *Client) AddContactGroups(groupID string, contactEmailIDs []string) (res *UpdateContactGroupsResponse, err error) {
return c.modifyContactGroups(groupID, addContactGroupsAction, contactEmailIDs)
}
func (c *Client) RemoveContactGroups(groupID string, contactEmailIDs []string) (res *UpdateContactGroupsResponse, err error) {
return c.modifyContactGroups(groupID, removeContactGroupsAction, contactEmailIDs)
}
const (
removeContactGroupsAction = 0
addContactGroupsAction = 1
)
type ModifyContactGroupsReq struct {
LabelID string
Action int
ContactEmailIDs []string
}
func (c *Client) modifyContactGroups(groupID string, modifyContactGroupsAction int, contactEmailIDs []string) (res *UpdateContactGroupsResponse, err error) {
reqBody := ModifyContactGroupsReq{
LabelID: groupID,
Action: modifyContactGroupsAction,
ContactEmailIDs: contactEmailIDs,
}
req, err := NewJSONRequest("PUT", "/contacts/group", reqBody)
if err != nil {
return
}
if err = c.DoJSON(req, &res); err != nil {
return
}
err = res.Err()
return
}
// ================================= DELETE =======================================
type DeleteReq struct {
IDs []string
}
// DeleteContacts deletes contacts specified by an array of contact IDs.
func (c *Client) DeleteContacts(ids []string) (err error) {
deleteReq := DeleteReq{
IDs: ids,
}
req, err := NewJSONRequest("PUT", "/contacts/delete", deleteReq)
if err != nil {
return
}
type DeleteContactsRes struct {
Res
Responses []struct {
ID string
Response Res
}
}
var res DeleteContactsRes
if err = c.DoJSON(req, &res); err != nil {
return
}
if err = res.Err(); err != nil {
return
}
return
}
// DeleteAllContacts deletes all contacts.
func (c *Client) DeleteAllContacts() (err error) {
req, err := NewRequest("DELETE", "/contacts", nil)
if err != nil {
return
}
var res Res
if err = c.DoJSON(req, &res); err != nil {
return
}
if err = res.Err(); err != nil {
return
}
return
}
//===================== Private utility methods =======================
func isSignedCardType(cardType int) bool {
return (cardType & CardSigned) == CardSigned
}
func isEncryptedCardType(cardType int) bool {
return (cardType & CardEncrypted) == CardEncrypted
}

677
pkg/pmapi/contacts_test.go Normal file
View File

@ -0,0 +1,677 @@
// Copyright (c) 2020 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 (
"encoding/json"
"fmt"
"net/http"
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
var (
CleartextCard = 0
EncryptedCard = 1
SignedCard = 2
EncryptedSignedCard = 3
)
var testAddContactsReq = AddContactsReq{
ContactsCards: ContactsCards{
Contacts: []CardsList{
{
Cards: []Card{
{
Type: 2,
Data: `BEGIN:VCARD
VERSION:4.0
FN;TYPE=fn:Bob
item1.EMAIL:bob.tester@protonmail.com
UID:proton-web-cd974706-5cde-0e53-e131-c49c88a92ece
END:VCARD
`,
Signature: ``,
},
},
},
},
},
Overwrite: 0,
Groups: 0,
Labels: 0,
}
var testAddContactsResponseBody = `{
"Code": 1001,
"Responses": [
{
"Index": 0,
"Response": {
"Code": 1000,
"Contact": {
"ID": "EU7qYvPAdgJ-zl53hw_btO1WG8TN2FYh2cTIFq1_T6KqulwgxF8CzPjVk_RBUdEejtLvfynlelVNoZwMK_9X2g==",
"Name": "Bob",
"UID": "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece",
"Size": 139,
"CreateTime": 1517319495,
"ModifyTime": 1517319495,
"ContactEmails": [
{
"ID": "VT4NoPeQPk48_vg0CVmk63n5mB6CZn9q-P_DYODhOUemhuzUkgBFGF1MktVArjX5zsVdfVlEBFObvt0_K5NwPg==",
"Name": "Bob",
"Email": "bob.tester@protonmail.com",
"Type": [],
"Defaults": 1,
"Order": 1,
"ContactID": "EU7qYvPAdgJ-zl53hw_btO1WG8TN2FYh2cTIFq1_T6KqulwgxF8CzPjVk_RBUdEejtLvfynlelVNoZwMK_9X2g==",
"LabelIDs": []
}
],
"LabelIDs": []
}
}
}
]
}`
var testContactCreated = &AddContactsResponse{
Res: Res{
Code: 1001,
StatusCode: 200,
},
Responses: []IndexedContactResponse{
{
Index: 0,
Response: SingleContactResponse{
Res: Res{
Code: 1000,
},
Contact: Contact{
ID: "EU7qYvPAdgJ-zl53hw_btO1WG8TN2FYh2cTIFq1_T6KqulwgxF8CzPjVk_RBUdEejtLvfynlelVNoZwMK_9X2g==",
Name: "Bob",
UID: "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece",
Size: 139,
CreateTime: 1517319495,
ModifyTime: 1517319495,
ContactEmails: []ContactEmail{
{
ID: "VT4NoPeQPk48_vg0CVmk63n5mB6CZn9q-P_DYODhOUemhuzUkgBFGF1MktVArjX5zsVdfVlEBFObvt0_K5NwPg==",
Name: "Bob",
Email: "bob.tester@protonmail.com",
Type: []string{},
Defaults: 1,
Order: 1,
ContactID: "EU7qYvPAdgJ-zl53hw_btO1WG8TN2FYh2cTIFq1_T6KqulwgxF8CzPjVk_RBUdEejtLvfynlelVNoZwMK_9X2g==",
LabelIDs: []string{},
},
},
LabelIDs: []string{},
},
},
},
},
}
var testContactUpdated = &UpdateContactResponse{
Res: Res{
Code: 1000,
StatusCode: 200,
},
Contact: Contact{
ID: "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==",
Name: "Bob",
UID: "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece",
Size: 303,
CreateTime: 1517416603,
ModifyTime: 1517416656,
ContactEmails: []ContactEmail{
{
ID: "14n6vuf1zbeo3zsYzgV471S6xJ9gzl7-VZ8tcOTQq6ifBlNEre0SUdUM7sXh6e2Q_4NhJZaU9c7jLdB1HCV6dA==",
Name: "Bob",
Email: "bob.changed.tester@protonmail.com",
Type: []string{},
Defaults: 1,
Order: 1,
ContactID: "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==",
LabelIDs: []string{},
},
},
LabelIDs: []string{},
},
}
func TestContact_AddContact(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "POST", "/contacts"))
var addContactsReq AddContactsReq
if err := json.NewDecoder(r.Body).Decode(&addContactsReq); err != nil {
t.Error("Expecting no error while reading request body, got:", err)
}
if !reflect.DeepEqual(testAddContactsReq.ContactsCards, addContactsReq.ContactsCards) {
t.Errorf("Invalid contacts request: expected %+v but got %+v", testAddContactsReq.ContactsCards, addContactsReq.ContactsCards)
}
fmt.Fprint(w, testAddContactsResponseBody)
}))
defer s.Close()
created, err := c.AddContacts(testAddContactsReq.ContactsCards, 0, 0, 0)
if err != nil {
t.Fatal("Expected no error while adding contact, got:", err)
}
if !reflect.DeepEqual(created, testContactCreated) {
t.Fatalf("Invalid created contact: expected %+v, got %+v", testContactCreated, created)
}
}
var testGetContactsResponseBody = `{
"Code": 1000,
"Contacts": [
{
"ID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
"Name": "Alice",
"UID": "proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b",
"Size": 243,
"CreateTime": 1517395498,
"ModifyTime": 1517395498,
"LabelIDs": []
},
{
"ID": "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==",
"Name": "Bob",
"UID": "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece",
"Size": 303,
"CreateTime": 1517394677,
"ModifyTime": 1517394678,
"LabelIDs": []
}
],
"Total": 2
}`
var testGetContacts = []*Contact{
{
ID: "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
Name: "Alice",
UID: "proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b",
Size: 243,
CreateTime: 1517395498,
ModifyTime: 1517395498,
LabelIDs: []string{},
},
{
ID: "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==",
Name: "Bob",
UID: "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece",
Size: 303,
CreateTime: 1517394677,
ModifyTime: 1517394678,
LabelIDs: []string{},
},
}
func TestContact_GetContacts(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "GET", "/contacts?Page=0&PageSize=1000"))
fmt.Fprint(w, testGetContactsResponseBody)
}))
defer s.Close()
contacts, err := c.GetContacts(0, 1000)
if err != nil {
t.Fatal("Expected no error while getting contacts, got:", err)
}
if !reflect.DeepEqual(contacts, testGetContacts) {
t.Fatalf("Invalid created contact: expected %+v, got %+v", testGetContacts, contacts)
}
}
var testGetContactByIDResponseBody = `{
"Code": 1000,
"Contact": {
"ID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
"Name": "Alice",
"UID": "proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b",
"Size": 243,
"CreateTime": 1517395498,
"ModifyTime": 1517395498,
"Cards": [
{
"Type": 3,
"Data": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQf/RnOQRpo8DVJHQSJRgckEaUQvdMcADiM4L23diyiS\nQfclby/Ve2WInmvZc2RJ3rWENfeqyDZE6krQT642pKiW09GOIyVIjl+hje9y\nE4HBX0AIAWv7QhhKX6UZcM5dYSFbV3j3QxQB8A4Thng2G6ltotMTlbtcHbhu\n96Lt6ngA1tngXLSF5seyflnoiSQ5gLi2qVzrd95dIP6D4Ottcp929/4hDGmq\nPyxw9dColx6gVd1bmIDSI6ewkET4Grmo6QYqjSvjqLOf0PqHKzqypSFLkI5l\nmmnWKYTQCgl9wX+hq6Qz5E+m/BtbkdeX0YxYUss2e+oSAzJmnfdETErG9U5z\n3NJqAc3sgdwDzfWHBzogAxAbDHiqrF6zMlR5SFvZ6nRU7M2DTOE5dJhf+zOp\n1WSKn5LR46LGyt0m5wJPDjaGyQdPffAO4EULvwhGENe10UxRjY1qcUmjYOtS\nunl/vh3afI9PC1jj+HHJD2VgCA==\n=UpcY\n-----END PGP MESSAGE-----\n",
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4pCRDMO9BwcW4mpAAA6h0H/2+97koXzly5pu9hpbaW\n75d1Q976RjMr5DjAx6tKFtSzznel8YfWgvA6OQmMGdPY8ae7/+3mwCJZYWy/\nXVvUfCSflmYpSIKGfP+Vm1XezWY1W84DGhiFj5n8sdaWisv3bpFwFf1YR3Ae\noBoZ4ufNzaQALRqGPMgXETtXZCtzuL/+0vGSKj5SLECiRcSE4jCPEVRy2bcl\nWJyB9r4VmcjF042OMHxphXoYmTEWvgigyaQFHNORu5cK9EHfHpCG6IcjGbdx\n+9Px5YnDY1ix+YpBKePGSTlLE0u6ow0VTUrdvNjl7IUBaRcfJcIIdgCBOTMw\n1uQ/yeyP46V5AFXFnIKeZeQ=\n=FlOf\n-----END PGP SIGNATURE-----\n"
},
{
"Type": 2,
"Data": "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Alice\nitem1.EMAIL:alice@protonmail.com\nUID:proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b\nEND:VCARD",
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4qCRDMO9BwcW4mpAAA3jUIAJ88mIyO8Yj0+evSFXnK\nNxNdjNgn7t1leY0BWlh1nkK76XrZEPipdw2QU8cOcZzn1Wby2SGfZVkwoPc4\nzAhPT4WKbkFVqXhDry5399kLwGYJCxdEcw/oPyYj+YgpQKMxhTrQq21tbEwr\n7JDRBXgi3Cckh/XsteFHOIiAVnM7BV6zFudipnYxa4uNF0Bf4VbUZx1Mm0Wb\nMJaGsO5reqQUQzDPO5TdSAZ8qGSdjVv7RESgUu5DckcDSsnB987Zbh9uFc22\nfPYmb6zA0cEZh3dAjpDPT7cg8hlvfYBb+kP3sLFyLiIkdEG8Pcagjf0k+l76\nr1IsPlYBx2LJmsJf+WDNlj8=\n=Xn+3\n-----END PGP SIGNATURE-----\n"
}
],
"ContactEmails": [
{
"ID": "4m2sBxLq4McqD0D330Kuy5xG-yyDNXyLEjG5_RYcjy9X-3qHGNP07DNOWLY40TYtUAQr4fAVp8zOcZ_z2o6H-A==",
"Name": "Alice",
"Email": "alice@protonmail.com",
"Type": [],
"Defaults": 1,
"Order": 1,
"ContactID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
"LabelIDs": []
}
],
"LabelIDs": []
}
}`
var testGetContactByID = Contact{
ID: "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
Name: "Alice",
UID: "proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b",
Size: 243,
CreateTime: 1517395498,
ModifyTime: 1517395498,
Cards: []Card{
{
Type: 3,
Data: "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQf/RnOQRpo8DVJHQSJRgckEaUQvdMcADiM4L23diyiS\nQfclby/Ve2WInmvZc2RJ3rWENfeqyDZE6krQT642pKiW09GOIyVIjl+hje9y\nE4HBX0AIAWv7QhhKX6UZcM5dYSFbV3j3QxQB8A4Thng2G6ltotMTlbtcHbhu\n96Lt6ngA1tngXLSF5seyflnoiSQ5gLi2qVzrd95dIP6D4Ottcp929/4hDGmq\nPyxw9dColx6gVd1bmIDSI6ewkET4Grmo6QYqjSvjqLOf0PqHKzqypSFLkI5l\nmmnWKYTQCgl9wX+hq6Qz5E+m/BtbkdeX0YxYUss2e+oSAzJmnfdETErG9U5z\n3NJqAc3sgdwDzfWHBzogAxAbDHiqrF6zMlR5SFvZ6nRU7M2DTOE5dJhf+zOp\n1WSKn5LR46LGyt0m5wJPDjaGyQdPffAO4EULvwhGENe10UxRjY1qcUmjYOtS\nunl/vh3afI9PC1jj+HHJD2VgCA==\n=UpcY\n-----END PGP MESSAGE-----\n",
Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4pCRDMO9BwcW4mpAAA6h0H/2+97koXzly5pu9hpbaW\n75d1Q976RjMr5DjAx6tKFtSzznel8YfWgvA6OQmMGdPY8ae7/+3mwCJZYWy/\nXVvUfCSflmYpSIKGfP+Vm1XezWY1W84DGhiFj5n8sdaWisv3bpFwFf1YR3Ae\noBoZ4ufNzaQALRqGPMgXETtXZCtzuL/+0vGSKj5SLECiRcSE4jCPEVRy2bcl\nWJyB9r4VmcjF042OMHxphXoYmTEWvgigyaQFHNORu5cK9EHfHpCG6IcjGbdx\n+9Px5YnDY1ix+YpBKePGSTlLE0u6ow0VTUrdvNjl7IUBaRcfJcIIdgCBOTMw\n1uQ/yeyP46V5AFXFnIKeZeQ=\n=FlOf\n-----END PGP SIGNATURE-----\n",
},
{
Type: 2,
Data: "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Alice\nitem1.EMAIL:alice@protonmail.com\nUID:proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b\nEND:VCARD",
Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4qCRDMO9BwcW4mpAAA3jUIAJ88mIyO8Yj0+evSFXnK\nNxNdjNgn7t1leY0BWlh1nkK76XrZEPipdw2QU8cOcZzn1Wby2SGfZVkwoPc4\nzAhPT4WKbkFVqXhDry5399kLwGYJCxdEcw/oPyYj+YgpQKMxhTrQq21tbEwr\n7JDRBXgi3Cckh/XsteFHOIiAVnM7BV6zFudipnYxa4uNF0Bf4VbUZx1Mm0Wb\nMJaGsO5reqQUQzDPO5TdSAZ8qGSdjVv7RESgUu5DckcDSsnB987Zbh9uFc22\nfPYmb6zA0cEZh3dAjpDPT7cg8hlvfYBb+kP3sLFyLiIkdEG8Pcagjf0k+l76\nr1IsPlYBx2LJmsJf+WDNlj8=\n=Xn+3\n-----END PGP SIGNATURE-----\n",
},
},
ContactEmails: []ContactEmail{
{
ID: "4m2sBxLq4McqD0D330Kuy5xG-yyDNXyLEjG5_RYcjy9X-3qHGNP07DNOWLY40TYtUAQr4fAVp8zOcZ_z2o6H-A==",
Name: "Alice",
Email: "alice@protonmail.com",
Type: []string{},
Defaults: 1,
Order: 1,
ContactID: "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
LabelIDs: []string{},
},
},
LabelIDs: []string{},
}
func TestContact_GetContactById(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "GET", "/contacts/s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg=="))
fmt.Fprint(w, testGetContactByIDResponseBody)
}))
defer s.Close()
contact, err := c.GetContactByID("s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==")
if err != nil {
t.Fatal("Expected no error while getting contacts, got:", err)
}
if !reflect.DeepEqual(contact, testGetContactByID) {
t.Fatalf("Invalid got contact: expected %+v, got %+v", testGetContactByID, contact)
}
}
var testGetContactsForExportResponseBody = `{
"Code": 1000,
"Contacts": [
{
"ID": "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==",
"Cards": [
{
"Type": 2,
"Data": "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Bob\nitem1.EMAIL:bob.changed.tester@protonmail.com\nUID:proton-web-cd974706-5cde-0e53-e131-c49c88a92ece\nEND:VCARD\n",
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZr2CRDMO9BwcW4mpAAAtxwIAFGgPO+xH4PHppffQC1R\nxCp/Bjzaq5rDUE3ZMKVJ1sFqGVlq2bP5CIN4w2XCe/MuZ+z2o87fSEtt2n7i\n0/8Ah35u4czn7t8FZoW8u9WwHPURa8gUbP3fYpVASBY1Bt2fUxJrSUYn5KQp\njJM/DgF99bhIjOTuhx9IN7DFKG647Arq+GJh9M6RJNxkb3CBfcCVUXoIwMB7\nnM/fA1r+mcl8dQam0WKVJgy9aO2XUUR62w1SpqJlXY3z8hKvXjjskzU3DQk5\net07RLVQvhy2nCZePsM+TJzL8OBbTa1aF/p1xPe+HND7t3ZCm9tQOY+UhK5H\nbhPbQY48KGdci1dTcm2HbsQ=\n=iOnV\n-----END PGP SIGNATURE-----\n"
},
{
"Type": 3,
"Data": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQgABsQWnqZadqrHDN43McGhEYfJjOB66R5HhkQAUavP\nHaAHpJciGxfz6tbztQu4C6kdMA80ElbD8c+bJqalw6ZbT4seoP4TTQLykD1n\n0LuNBlaW4x8kfd8rZzFdckk/dY2PruX6byAjSZslnZlZSwp99AJJbvJtfXRR\nzunKMbDieRkaApGZYT25wT5mz1embpXFesvO4nDkOEQCa0uyti3mNSLhYlf/\ntbaOS3WM9VYM9eB9YRZGzJNxMtTxOsd45tBlGCHnCzWEUnJdqZuYzH2QOky7\nMckXhk6YwyemYi/q7OOgSYEg/0lCs2EK3b//14yPDx8Bj5G7rZrnDgsP+BHj\nu9KaAZb2pSBPQoJ2DY3Y4A2Sg8GjaX5CMO9D6GKJkZSYkXddQgcmw7sVPUS+\n+5JaPXlfxoJOOn9kj9A6LDC6eMhYaLujG1BKcZ16DB0jqfwMnPLJ+bYEdatr\nKMvd9rbdsDwQ/tfk11VvHpiEBCNZjxM2+bdBLl9q2EXaLXi+dz/rJg5C0A9u\nNS2CzCUvg6+jNUzHo/RBfRXvlNV8tw==\n=mE2b\n-----END PGP MESSAGE-----\n",
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZr2CRDMO9BwcW4mpAAApucIAD/uwWuV6DOg127XIPG6\n/jluL8jmwyCJX9noL6S8ZVMOymziKSh4/P1QyMPC5SL4lMPEiuaEdyetfBkU\n+5hW3tcZ+ptxmDi59SVYqmXTVewPgeB7t8c5nbzCuVuzA7ZAo8HAXHzFVQDS\nj9fKVGjZzQkmlwdcfnkXHAF0Ejilv9wxOOYgqVDuzm7JXVF3Um7nAgGKTJE5\n5CNnrEjmJGapj96mQFwXzET/kAhNIBw9tL5FAkDlKImdw8C0w9sXdvDu3yVM\ntvUZ5o2rR6ft0SC1byFso49vgJ/syeK6P2pPzltZJbsp4MvmlPUB0/G1XRU+\nI7q4IOWCvs8RD88ADmOty2o=\n=hyZE\n-----END PGP SIGNATURE-----\n"
}
]
},
{
"ID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
"Cards": [
{
"Type": 3,
"Data": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQf/RnOQRpo8DVJHQSJRgckEaUQvdMcADiM4L23diyiS\nQfclby/Ve2WInmvZc2RJ3rWENfeqyDZE6krQT642pKiW09GOIyVIjl+hje9y\nE4HBX0AIAWv7QhhKX6UZcM5dYSFbV3j3QxQB8A4Thng2G6ltotMTlbtcHbhu\n96Lt6ngA1tngXLSF5seyflnoiSQ5gLi2qVzrd95dIP6D4Ottcp929/4hDGmq\nPyxw9dColx6gVd1bmIDSI6ewkET4Grmo6QYqjSvjqLOf0PqHKzqypSFLkI5l\nmmnWKYTQCgl9wX+hq6Qz5E+m/BtbkdeX0YxYUss2e+oSAzJmnfdETErG9U5z\n3NJqAc3sgdwDzfWHBzogAxAbDHiqrF6zMlR5SFvZ6nRU7M2DTOE5dJhf+zOp\n1WSKn5LR46LGyt0m5wJPDjaGyQdPffAO4EULvwhGENe10UxRjY1qcUmjYOtS\nunl/vh3afI9PC1jj+HHJD2VgCA==\n=UpcY\n-----END PGP MESSAGE-----\n",
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4pCRDMO9BwcW4mpAAA6h0H/2+97koXzly5pu9hpbaW\n75d1Q976RjMr5DjAx6tKFtSzznel8YfWgvA6OQmMGdPY8ae7/+3mwCJZYWy/\nXVvUfCSflmYpSIKGfP+Vm1XezWY1W84DGhiFj5n8sdaWisv3bpFwFf1YR3Ae\noBoZ4ufNzaQALRqGPMgXETtXZCtzuL/+0vGSKj5SLECiRcSE4jCPEVRy2bcl\nWJyB9r4VmcjF042OMHxphXoYmTEWvgigyaQFHNORu5cK9EHfHpCG6IcjGbdx\n+9Px5YnDY1ix+YpBKePGSTlLE0u6ow0VTUrdvNjl7IUBaRcfJcIIdgCBOTMw\n1uQ/yeyP46V5AFXFnIKeZeQ=\n=FlOf\n-----END PGP SIGNATURE-----\n"
},
{
"Type": 2,
"Data": "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Alice\nitem1.EMAIL:alice@protonmail.com\nUID:proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b\nEND:VCARD",
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4qCRDMO9BwcW4mpAAA3jUIAJ88mIyO8Yj0+evSFXnK\nNxNdjNgn7t1leY0BWlh1nkK76XrZEPipdw2QU8cOcZzn1Wby2SGfZVkwoPc4\nzAhPT4WKbkFVqXhDry5399kLwGYJCxdEcw/oPyYj+YgpQKMxhTrQq21tbEwr\n7JDRBXgi3Cckh/XsteFHOIiAVnM7BV6zFudipnYxa4uNF0Bf4VbUZx1Mm0Wb\nMJaGsO5reqQUQzDPO5TdSAZ8qGSdjVv7RESgUu5DckcDSsnB987Zbh9uFc22\nfPYmb6zA0cEZh3dAjpDPT7cg8hlvfYBb+kP3sLFyLiIkdEG8Pcagjf0k+l76\nr1IsPlYBx2LJmsJf+WDNlj8=\n=Xn+3\n-----END PGP SIGNATURE-----\n"
}
]
}
],
"Total": 2
}`
var testGetContactsForExport = []Contact{
{
ID: "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==",
Cards: []Card{
{
Type: 2,
Data: "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Bob\nitem1.EMAIL:bob.changed.tester@protonmail.com\nUID:proton-web-cd974706-5cde-0e53-e131-c49c88a92ece\nEND:VCARD\n",
Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZr2CRDMO9BwcW4mpAAAtxwIAFGgPO+xH4PHppffQC1R\nxCp/Bjzaq5rDUE3ZMKVJ1sFqGVlq2bP5CIN4w2XCe/MuZ+z2o87fSEtt2n7i\n0/8Ah35u4czn7t8FZoW8u9WwHPURa8gUbP3fYpVASBY1Bt2fUxJrSUYn5KQp\njJM/DgF99bhIjOTuhx9IN7DFKG647Arq+GJh9M6RJNxkb3CBfcCVUXoIwMB7\nnM/fA1r+mcl8dQam0WKVJgy9aO2XUUR62w1SpqJlXY3z8hKvXjjskzU3DQk5\net07RLVQvhy2nCZePsM+TJzL8OBbTa1aF/p1xPe+HND7t3ZCm9tQOY+UhK5H\nbhPbQY48KGdci1dTcm2HbsQ=\n=iOnV\n-----END PGP SIGNATURE-----\n",
},
{
Type: 3,
Data: "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQgABsQWnqZadqrHDN43McGhEYfJjOB66R5HhkQAUavP\nHaAHpJciGxfz6tbztQu4C6kdMA80ElbD8c+bJqalw6ZbT4seoP4TTQLykD1n\n0LuNBlaW4x8kfd8rZzFdckk/dY2PruX6byAjSZslnZlZSwp99AJJbvJtfXRR\nzunKMbDieRkaApGZYT25wT5mz1embpXFesvO4nDkOEQCa0uyti3mNSLhYlf/\ntbaOS3WM9VYM9eB9YRZGzJNxMtTxOsd45tBlGCHnCzWEUnJdqZuYzH2QOky7\nMckXhk6YwyemYi/q7OOgSYEg/0lCs2EK3b//14yPDx8Bj5G7rZrnDgsP+BHj\nu9KaAZb2pSBPQoJ2DY3Y4A2Sg8GjaX5CMO9D6GKJkZSYkXddQgcmw7sVPUS+\n+5JaPXlfxoJOOn9kj9A6LDC6eMhYaLujG1BKcZ16DB0jqfwMnPLJ+bYEdatr\nKMvd9rbdsDwQ/tfk11VvHpiEBCNZjxM2+bdBLl9q2EXaLXi+dz/rJg5C0A9u\nNS2CzCUvg6+jNUzHo/RBfRXvlNV8tw==\n=mE2b\n-----END PGP MESSAGE-----\n",
Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZr2CRDMO9BwcW4mpAAApucIAD/uwWuV6DOg127XIPG6\n/jluL8jmwyCJX9noL6S8ZVMOymziKSh4/P1QyMPC5SL4lMPEiuaEdyetfBkU\n+5hW3tcZ+ptxmDi59SVYqmXTVewPgeB7t8c5nbzCuVuzA7ZAo8HAXHzFVQDS\nj9fKVGjZzQkmlwdcfnkXHAF0Ejilv9wxOOYgqVDuzm7JXVF3Um7nAgGKTJE5\n5CNnrEjmJGapj96mQFwXzET/kAhNIBw9tL5FAkDlKImdw8C0w9sXdvDu3yVM\ntvUZ5o2rR6ft0SC1byFso49vgJ/syeK6P2pPzltZJbsp4MvmlPUB0/G1XRU+\nI7q4IOWCvs8RD88ADmOty2o=\n=hyZE\n-----END PGP SIGNATURE-----\n",
},
},
},
{
ID: "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
Cards: []Card{
{
Type: 3,
Data: "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQf/RnOQRpo8DVJHQSJRgckEaUQvdMcADiM4L23diyiS\nQfclby/Ve2WInmvZc2RJ3rWENfeqyDZE6krQT642pKiW09GOIyVIjl+hje9y\nE4HBX0AIAWv7QhhKX6UZcM5dYSFbV3j3QxQB8A4Thng2G6ltotMTlbtcHbhu\n96Lt6ngA1tngXLSF5seyflnoiSQ5gLi2qVzrd95dIP6D4Ottcp929/4hDGmq\nPyxw9dColx6gVd1bmIDSI6ewkET4Grmo6QYqjSvjqLOf0PqHKzqypSFLkI5l\nmmnWKYTQCgl9wX+hq6Qz5E+m/BtbkdeX0YxYUss2e+oSAzJmnfdETErG9U5z\n3NJqAc3sgdwDzfWHBzogAxAbDHiqrF6zMlR5SFvZ6nRU7M2DTOE5dJhf+zOp\n1WSKn5LR46LGyt0m5wJPDjaGyQdPffAO4EULvwhGENe10UxRjY1qcUmjYOtS\nunl/vh3afI9PC1jj+HHJD2VgCA==\n=UpcY\n-----END PGP MESSAGE-----\n",
Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4pCRDMO9BwcW4mpAAA6h0H/2+97koXzly5pu9hpbaW\n75d1Q976RjMr5DjAx6tKFtSzznel8YfWgvA6OQmMGdPY8ae7/+3mwCJZYWy/\nXVvUfCSflmYpSIKGfP+Vm1XezWY1W84DGhiFj5n8sdaWisv3bpFwFf1YR3Ae\noBoZ4ufNzaQALRqGPMgXETtXZCtzuL/+0vGSKj5SLECiRcSE4jCPEVRy2bcl\nWJyB9r4VmcjF042OMHxphXoYmTEWvgigyaQFHNORu5cK9EHfHpCG6IcjGbdx\n+9Px5YnDY1ix+YpBKePGSTlLE0u6ow0VTUrdvNjl7IUBaRcfJcIIdgCBOTMw\n1uQ/yeyP46V5AFXFnIKeZeQ=\n=FlOf\n-----END PGP SIGNATURE-----\n",
},
{
Type: 2,
Data: "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Alice\nitem1.EMAIL:alice@protonmail.com\nUID:proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b\nEND:VCARD",
Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4qCRDMO9BwcW4mpAAA3jUIAJ88mIyO8Yj0+evSFXnK\nNxNdjNgn7t1leY0BWlh1nkK76XrZEPipdw2QU8cOcZzn1Wby2SGfZVkwoPc4\nzAhPT4WKbkFVqXhDry5399kLwGYJCxdEcw/oPyYj+YgpQKMxhTrQq21tbEwr\n7JDRBXgi3Cckh/XsteFHOIiAVnM7BV6zFudipnYxa4uNF0Bf4VbUZx1Mm0Wb\nMJaGsO5reqQUQzDPO5TdSAZ8qGSdjVv7RESgUu5DckcDSsnB987Zbh9uFc22\nfPYmb6zA0cEZh3dAjpDPT7cg8hlvfYBb+kP3sLFyLiIkdEG8Pcagjf0k+l76\nr1IsPlYBx2LJmsJf+WDNlj8=\n=Xn+3\n-----END PGP SIGNATURE-----\n",
},
},
},
}
func TestContact_GetContactsForExport(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "GET", "/contacts/export?Page=0&PageSize=1000"))
fmt.Fprint(w, testGetContactsForExportResponseBody)
}))
defer s.Close()
contacts, err := c.GetContactsForExport(0, 1000)
if err != nil {
t.Fatal("Expected no error while getting contacts for export, got:", err)
}
if !reflect.DeepEqual(contacts, testGetContactsForExport) {
t.Fatalf("Invalid contact for export: expected %+v, got %+v", testGetContactsForExport, contacts)
}
}
var testGetContactsEmailsResponseBody = `{
"Code": 1000,
"ContactEmails": [
{
"ID": "Hgyz1tG0OiC2v_hMIVOa6juMOAp_recWNzWII7a79Tfwdx08Jy3FJY0_Y_UtFYwbi6mN-Xx1sOI9_GmUGAcwWg==",
"Name": "Bob",
"Email": "bob.changed.tester@protonmail.com",
"Type": [],
"Defaults": 1,
"Order": 1,
"ContactID": "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==",
"LabelIDs": []
},
{
"ID": "4m2sBxLq4McqD0D330Kuy5xG-yyDNXyLEjG5_RYcjy9X-3qHGNP07DNOWLY40TYtUAQr4fAVp8zOcZ_z2o6H-A==",
"Name": "Alice",
"Email": "alice@protonmail.com",
"Type": [],
"Defaults": 1,
"Order": 1,
"ContactID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
"LabelIDs": []
}
],
"Total": 2
}`
var testGetContactsEmails = []ContactEmail{
{
ID: "Hgyz1tG0OiC2v_hMIVOa6juMOAp_recWNzWII7a79Tfwdx08Jy3FJY0_Y_UtFYwbi6mN-Xx1sOI9_GmUGAcwWg==",
Name: "Bob",
Email: "bob.changed.tester@protonmail.com",
Type: []string{},
Defaults: 1,
Order: 1,
ContactID: "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==",
LabelIDs: []string{},
},
{
ID: "4m2sBxLq4McqD0D330Kuy5xG-yyDNXyLEjG5_RYcjy9X-3qHGNP07DNOWLY40TYtUAQr4fAVp8zOcZ_z2o6H-A==",
Name: "Alice",
Email: "alice@protonmail.com",
Type: []string{},
Defaults: 1,
Order: 1,
ContactID: "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
LabelIDs: []string{},
},
}
func TestContact_GetAllContactsEmails(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "GET", "/contacts/emails?Page=0&PageSize=1000"))
fmt.Fprint(w, testGetContactsEmailsResponseBody)
}))
defer s.Close()
contactsEmails, err := c.GetAllContactsEmails(0, 1000)
if err != nil {
t.Fatal("Expected no error while getting contacts for export, got:", err)
}
if !reflect.DeepEqual(contactsEmails, testGetContactsEmails) {
t.Fatalf("Invalid contact for export: expected %+v, got %+v", testGetContactsEmails, contactsEmails)
}
}
var testUpdateContactReq = UpdateContactReq{
Cards: []Card{
{
Type: 2,
Data: `BEGIN:VCARD
VERSION:4.0
FN;TYPE=fn:Bob
item1.EMAIL:bob.changed.tester@protonmail.com
UID:proton-web-cd974706-5cde-0e53-e131-c49c88a92ece
END:VCARD
`,
Signature: ``,
},
},
}
var testUpdateContactResponseBody = `{
"Code": 1000,
"Contact": {
"ID": "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==",
"Name": "Bob",
"UID": "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece",
"Size": 303,
"CreateTime": 1517416603,
"ModifyTime": 1517416656,
"ContactEmails": [
{
"ID": "14n6vuf1zbeo3zsYzgV471S6xJ9gzl7-VZ8tcOTQq6ifBlNEre0SUdUM7sXh6e2Q_4NhJZaU9c7jLdB1HCV6dA==",
"Name": "Bob",
"Email": "bob.changed.tester@protonmail.com",
"Type": [],
"Defaults": 1,
"Order": 1,
"ContactID": "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==",
"LabelIDs": []
}
],
"LabelIDs": []
}
}`
func TestContact_UpdateContact(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "PUT", "/contacts/l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ=="))
var updateContactReq UpdateContactReq
if err := json.NewDecoder(r.Body).Decode(&updateContactReq); err != nil {
t.Error("Expecting no error while reading request body, got:", err)
}
if !reflect.DeepEqual(testUpdateContactReq.Cards, updateContactReq.Cards) {
t.Errorf("Invalid contacts request: expected %+v but got %+v", testUpdateContactReq.Cards, updateContactReq.Cards)
}
fmt.Fprint(w, testUpdateContactResponseBody)
}))
defer s.Close()
created, err := c.UpdateContact("l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==", testUpdateContactReq.Cards)
if err != nil {
t.Fatal("Expected no error while updating contact, got:", err)
}
if !reflect.DeepEqual(created, testContactUpdated) {
t.Fatalf("Invalid updated contact: expected\n%+v\ngot\n%+v\n", testContactUpdated, created)
}
}
var testDeleteContactsReq = DeleteReq{
IDs: []string{
"s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
},
}
var testDeleteContactsResponseBody = `{
"Code": 1001,
"Responses": [
{
"ID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
"Response": {
"Code": 1000
}
}
]
}`
func TestContact_DeleteContacts(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "PUT", "/contacts/delete"))
var deleteContactsReq DeleteReq
if err := json.NewDecoder(r.Body).Decode(&deleteContactsReq); err != nil {
t.Error("Expecting no error while reading request body, got:", err)
}
if !reflect.DeepEqual(testDeleteContactsReq.IDs, deleteContactsReq.IDs) {
t.Errorf("Invalid delete contacts request: expected %+v but got %+v", deleteContactsReq.IDs, testDeleteContactsReq.IDs)
}
fmt.Fprint(w, testDeleteContactsResponseBody)
}))
defer s.Close()
err := c.DeleteContacts([]string{"s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg=="})
if err != nil {
t.Fatal("Expected no error while getting contacts for export, got:", err)
}
}
var testDeleteAllResponseBody = `{
"Code": 1000
}`
func TestContact_DeleteAllContacts(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "DELETE", "/contacts"))
fmt.Fprint(w, testDeleteAllResponseBody)
}))
defer s.Close()
err := c.DeleteAllContacts()
if err != nil {
t.Fatal("Expected no error while getting contacts for export, got:", err)
}
}
func TestContact_isSignedCardType(t *testing.T) {
if !isSignedCardType(SignedCard) || !isSignedCardType(EncryptedSignedCard) {
t.Fatal("isSignedCardType shouldn't return false for signed card types")
}
if isSignedCardType(CleartextCard) || isSignedCardType(EncryptedCard) {
t.Fatal("isSignedCardType shouldn't return true for non-signed card types")
}
}
func TestContact_isEncryptedCardType(t *testing.T) {
if !isEncryptedCardType(EncryptedCard) || !isEncryptedCardType(EncryptedSignedCard) {
t.Fatal("isEncryptedCardType shouldn't return false for encrypted card types")
}
if isEncryptedCardType(CleartextCard) || isEncryptedCardType(SignedCard) {
t.Fatal("isEncryptedCardType shouldn't return true for non-encrypted card types")
}
}
var testCardsEncrypted = []Card{
{
Type: EncryptedSignedCard,
Data: "-----BEGIN PGP MESSAGE-----\nVersion: GopenPGP 0.0.1 (ddacebe0)\nComment: https://gopenpgp.org\n\nwcBMA0fcZ7XLgmf2AQf/fLKA6ZCkDxumpDoUoFQfO86B9LFuqGEJq+voP12C6UXo\nfB2nTy/K4+VosLKYOkU9sW1PZOCL+i00z+zkqUZ6jchbZBpzwy/UCTmpPRw5zrmr\nW6bZCwwgqJSGVWrvcrDA3bW9cn/HHqQqU6jNeXIF+IuhTscRAJVGehJZYWjr1lgB\nToJhg4+//Bgp/Fxzz8Fej/fsokgOlRJ8xcZKYx0rKL/+Il0u2jnd08kJTegpaY+6\nBlsYBzfYq25WkS02iy02wHbt6XD7AxFDi4WDjsM8bryLSm/KNWrejqfDYb/tMAKa\nKNJqK39/EUewzp1gHEXiGmdDEIFTKCHTDTPV84mwf9I1Ae4yoLs+ilYE6sSk7DCh\nPSWjDC8lpKzmw93slsejTG93HJKQPcZ0rLBpv6qPZX6widNYjDE=\n=QFxr\n-----END PGP MESSAGE-----",
Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: GopenPGP 0.0.1 (ddacebe0)\nComment: https://gopenpgp.org\n\nwsBcBAABCgAQBQJdZQ1kCRA+tiWe3yHfJAAA9nMH/0X7pS8TGt6Ox0BewRh0vjfQ\n9LPLwbOiHdj97LNqutZcLlDTfm9SPH82221ZpVILWhB0u2kFeNUGihVbjAqJGYJn\nEk2TELLwn8csYRy9r5JkyUirqrvh7jgl4vs1yt8O/3Yb4ARudOoZr8Yrb4+NVNe0\nCcwQJnH/fJPtF1hbarKwtKtCo3IFwTis4pc8qWJRpBH61z1mO0Yr/LIh85QndhnF\nnZ/3MkWOY0kp2gl4ptqtNUw7z+JJ4LLVdT3ycdVK7GVTZmIG90y5KKxwJvrwbS7/\n8rmPGPQ5diLEMrzuKC2plXT6Pdy0ShtZxie2C3JY86e7ol7xvl0pNqxzOrj424w=\n=AOTG\n-----END PGP SIGNATURE-----",
},
}
var testCardsCleartext = []Card{
{
Type: EncryptedSignedCard,
Data: "data",
Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: GopenPGP 0.0.1 (ddacebe0)\nComment: https://gopenpgp.org\n\nwsBcBAABCgAQBQJdZQ1kCRA+tiWe3yHfJAAA9nMH/0X7pS8TGt6Ox0BewRh0vjfQ\n9LPLwbOiHdj97LNqutZcLlDTfm9SPH82221ZpVILWhB0u2kFeNUGihVbjAqJGYJn\nEk2TELLwn8csYRy9r5JkyUirqrvh7jgl4vs1yt8O/3Yb4ARudOoZr8Yrb4+NVNe0\nCcwQJnH/fJPtF1hbarKwtKtCo3IFwTis4pc8qWJRpBH61z1mO0Yr/LIh85QndhnF\nnZ/3MkWOY0kp2gl4ptqtNUw7z+JJ4LLVdT3ycdVK7GVTZmIG90y5KKxwJvrwbS7/\n8rmPGPQ5diLEMrzuKC2plXT6Pdy0ShtZxie2C3JY86e7ol7xvl0pNqxzOrj424w=\n=AOTG\n-----END PGP SIGNATURE-----",
},
}
func TestClient_Encrypt(t *testing.T) {
c := newTestClient()
c.kr = testPrivateKeyRing
cardEncrypted, err := c.EncryptAndSignCards(testCardsCleartext)
assert.Nil(t, err)
// 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.
cardCleartext, err := c.DecryptAndVerifyCards(cardEncrypted)
assert.Nil(t, err)
assert.Equal(t, testCardsCleartext[0].Data, cardCleartext[0].Data)
}
func TestClient_Decrypt(t *testing.T) {
c := newTestClient()
c.kr = testPrivateKeyRing
cardCleartext, err := c.DecryptAndVerifyCards(testCardsEncrypted)
assert.Nil(t, err)
assert.Equal(t, testCardsCleartext[0].Data, cardCleartext[0].Data)
}

View File

@ -0,0 +1,51 @@
// Copyright (c) 2020 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
// ConversationsCount have same structure as MessagesCount.
type ConversationsCount MessagesCount
// ConversationsCountsRes holds response from server.
type ConversationsCountsRes struct {
Res
Counts []*ConversationsCount
}
// Conversation contains one body and multiple metadata.
type Conversation struct{}
// CountConversations counts conversations by label.
func (c *Client) CountConversations(addressID string) (counts []*ConversationsCount, err error) {
reqURL := "/conversations/count"
if addressID != "" {
reqURL += ("?AddressID=" + addressID)
}
req, err := NewRequest("GET", reqURL, nil)
if err != nil {
return
}
var res ConversationsCountsRes
if err = c.DoJSON(req, &res); err != nil {
return
}
counts, err = res.Counts, res.Err()
return
}

View File

@ -0,0 +1,373 @@
// Copyright (c) 2020 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 (
"bytes"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io/ioutil"
"net"
"net/http"
"strconv"
"time"
"github.com/sirupsen/logrus"
)
// TLSReport is inspired by https://tools.ietf.org/html/rfc7469#section-3.
type TLSReport struct {
// DateTime of observed pin validation in time.RFC3339 format.
DateTime string `json:"date-time"`
// Hostname to which the UA made original request that failed pin validation.
Hostname string `json:"hostname"`
// Port to which the UA made original request that failed pin validation.
Port int `json:"port"`
// EffectiveExpirationDate for noted pins in time.RFC3339 format.
EffectiveExpirationDate string `json:"effective-expiration-date"`
// IncludeSubdomains indicates whether or not the UA has noted the
// includeSubDomains directive for the Known Pinned Host.
IncludeSubdomains bool `json:"include-subdomains"`
// NotedHostname indicates the hostname that the UA noted when it noted
// the Known Pinned Host. This field allows operators to understand why
// Pin Validation was performed for, e.g., foo.example.com when the
// noted Known Pinned Host was example.com with includeSubDomains set.
NotedHostname string `json:"noted-hostname"`
// ServedCertificateChain is the certificate chain, as served by
// the Known Pinned Host during TLS session setup. It is provided as an
// array of strings; each string pem1, ... pemN is the Privacy-Enhanced
// Mail (PEM) representation of each X.509 certificate as described in
// [RFC7468].
ServedCertificateChain []string `json:"served-certificate-chain"`
// ValidatedCertificateChain is the certificate chain, as
// constructed by the UA during certificate chain verification. (This
// may differ from the served-certificate-chain.) It is provided as an
// array of strings; each string pem1, ... pemN is the PEM
// representation of each X.509 certificate as described in [RFC7468].
// UAs that build certificate chains in more than one way during the
// validation process SHOULD send the last chain built. In this way,
// they can avoid keeping too much state during the validation process.
ValidatedCertificateChain []string `json:"validated-certificate-chain"`
// The known-pins are the Pins that the UA has noted for the Known
// Pinned Host. They are provided as an array of strings with the
// syntax: known-pin = token "=" quoted-string
// e.g.:
// ```
// "known-pins": [
// 'pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="',
// "pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""
// ]
// ```
KnownPins []string `json:"known-pins"`
// AppVersion is used to set `x-pm-appversion` json format from datatheorem/TrustKit.
AppVersion string `json:"app-version"`
}
// ErrTLSMatch indicates that no TLS fingerprint match could be found.
var ErrTLSMatch = fmt.Errorf("TLS fingerprint match not found")
// DialerWithPinning will provide dial function which checks the fingerprints of public cert
// received from contacted server. If no match found among know pinse it will report using
// ReportCertIssueLocal.
type DialerWithPinning struct {
// isReported will stop reporting if true.
isReported bool
// report stores known pins.
report TLSReport
// When reportURI is not empty the tls issue report will be send to this URI.
reportURI string
// ReportCertIssueLocal is used send signal to application about certificate issue.
// It is used only if set.
ReportCertIssueLocal func()
// proxyManager manages API proxies.
proxyManager *proxyManager
// A logger for logging messages.
log logrus.FieldLogger
}
func NewDialerWithPinning(reportURI string, report TLSReport) *DialerWithPinning {
log := logrus.WithField("pkg", "pmapi/tls-pinning")
proxyManager := newProxyManager(dohProviders, proxyQuery)
return &DialerWithPinning{
isReported: false,
reportURI: reportURI,
report: report,
proxyManager: proxyManager,
log: log,
}
}
func NewPMAPIPinning(appVersion string) *DialerWithPinning {
return NewDialerWithPinning(
"https://reports.protonmail.ch/reports/tls",
TLSReport{
EffectiveExpirationDate: time.Now().Add(365 * 24 * 60 * 60 * time.Second).Format(time.RFC3339),
IncludeSubdomains: false,
ValidatedCertificateChain: []string{},
ServedCertificateChain: []string{},
AppVersion: appVersion,
// NOTE: the proxy pins are the same for all proxy servers, guaranteed by infra team ;)
KnownPins: []string{
`pin-sha256="drtmcR2kFkM8qJClsuWgUzxgBkePfRCkRpqUesyDmeE="`, // current
`pin-sha256="YRGlaY0jyJ4Jw2/4M8FIftwbDIQfh8Sdro96CeEel54="`, // hot
`pin-sha256="AfMENBVvOS8MnISprtvyPsjKlPooqh8nMB/pvCrpJpw="`, // cold
`pin-sha256="EU6TS9MO0L/GsDHvVc9D5fChYLNy5JdGYpJw0ccgetM="`, // proxy main
`pin-sha256="iKPIHPnDNqdkvOnTClQ8zQAIKG0XavaPkcEo0LBAABA="`, // proxy backup 1
`pin-sha256="MSlVrBCdL0hKyczvgYVSRNm88RicyY04Q2y5qrBt0xA="`, // proxy backup 2
`pin-sha256="C2UxW0T1Ckl9s+8cXfjXxlEqwAfPM4HiW2y3UdtBeCw="`, // proxy backup 3
},
},
)
}
func (p *DialerWithPinning) reportCertIssue(connState tls.ConnectionState) {
p.isReported = true
if p.ReportCertIssueLocal != nil {
go p.ReportCertIssueLocal()
}
if p.reportURI != "" {
p.report.NotedHostname = connState.ServerName
p.report.ServedCertificateChain = marshalCert7468(connState.PeerCertificates)
if len(connState.VerifiedChains) > 0 {
p.report.ServedCertificateChain = marshalCert7468(
connState.VerifiedChains[len(connState.VerifiedChains)-1],
)
}
go p.reportCertIssueRemote()
}
}
func (p *DialerWithPinning) reportCertIssueRemote() {
b, err := json.Marshal(p.report)
if err != nil {
p.log.Errorf("marshal request: %v", err)
return
}
req, err := http.NewRequest("POST", p.reportURI, bytes.NewReader(b))
if err != nil {
p.log.Errorf("create request: %v", err)
}
req.Header.Add("Content-Type", "application/json")
req.Header.Set("User-Agent", CurrentUserAgent)
req.Header.Set("x-pm-apiversion", strconv.Itoa(Version))
req.Header.Set("x-pm-appversion", p.report.AppVersion)
p.log.Debugf("report req: %+v\n", req)
c := &http.Client{}
res, err := c.Do(req)
p.log.Debugf("res: %+v\nerr: %v", res, err)
if err != nil {
return
}
_, _ = ioutil.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
p.log.Errorf("response status: %v", res.Status)
}
_ = res.Body.Close()
}
func certFingerprint(cert *x509.Certificate) string {
hash := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
return fmt.Sprintf(`pin-sha256=%q`, base64.StdEncoding.EncodeToString(hash[:]))
}
func marshalCert7468(certs []*x509.Certificate) (pemCerts []string) {
var buffer bytes.Buffer
for _, cert := range certs {
if err := pem.Encode(&buffer, &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}); err != nil {
logrus.WithField("pkg", "pmapi/tls-pinning").Errorf("encoding TLS cert: %v", err)
}
pemCerts = append(pemCerts, buffer.String())
buffer.Reset()
}
return pemCerts
}
func (p *DialerWithPinning) TransportWithPinning() *http.Transport {
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialTLS: p.dialAndCheckFingerprints,
MaxIdleConns: 100,
IdleConnTimeout: 5 * time.Minute,
ExpectContinueTimeout: 500 * time.Millisecond,
// GODT-126: this was initially 10s but logs from users showed a significant number
// were hitting this timeout, possibly due to flaky wifi taking >10s to reconnect.
// Bumping to 30s for now to avoid this problem.
ResponseHeaderTimeout: 30 * time.Second,
// If we allow up to 30 seconds for response headers, it is reasonable to allow up
// to 30 seconds for the TLS handshake to take place.
TLSHandshakeTimeout: 30 * time.Second,
}
}
// dialAndCheckFingerprint to set as http.Transport.DialTLS.
//
// * note that when DialTLS is not nil the Transport.TLSClientConfig and Transport.TLSHandshakeTimeout are ignored.
// * dialAndCheckFingerprints fails if certificate is not valid (not signed by authority or not matching hostname).
// * dialAndCheckFingerprints will pass if certificate pin does not have a match, but will send notification using
// p.ReportCertIssueLocal() and p.reportCertIssueRemote() if they are not nil.
func (p *DialerWithPinning) dialAndCheckFingerprints(network, address string) (conn net.Conn, err error) {
// If DoH is enabled, we hardfail on fingerprint mismatches.
if globalIsDoHAllowed() && p.isReported {
return nil, ErrTLSMatch
}
// Try to dial the given address but use a proxy if necessary.
if conn, err = p.dialWithProxyFallback(network, address); err != nil {
return
}
// If cert issue was already reported, we don't want to check fingerprints anymore.
if p.isReported {
return nil, ErrTLSMatch
}
// Check the cert fingerprint to ensure it is known.
if err = p.checkFingerprints(conn); err != nil {
p.log.WithError(err).Error("Error checking cert fingerprints")
return
}
return
}
// dialWithProxyFallback tries to dial the given address but falls back to alternative proxies if need be.
func (p *DialerWithPinning) dialWithProxyFallback(network, address string) (conn net.Conn, err error) {
var host, port string
if host, port, err = net.SplitHostPort(address); err != nil {
return
}
// Try to dial, and if it succeeds, then just return.
if conn, err = p.dial(network, address); err == nil {
return
}
// If DoH is not allowed, give up. Or, if we are dialing something other than the API
// (e.g. we dial protonmail.com/... to check for updates), there's also no point in
// continuing since a proxy won't help us reach that.
if !globalIsDoHAllowed() || host != stripProtocol(GlobalGetRootURL()) {
return
}
// Find a new proxy.
var proxy string
if proxy, err = p.proxyManager.findProxy(); err != nil {
return
}
// Switch to the proxy.
p.log.WithField("proxy", proxy).Debug("Switching to proxy")
p.proxyManager.useProxy(proxy)
// Retry dial with proxy.
return p.dial(network, net.JoinHostPort(proxy, port))
}
// dial returns a connection to the given address using the given network.
func (p *DialerWithPinning) dial(network, address string) (conn net.Conn, err error) {
var port string
if p.report.Hostname, port, err = net.SplitHostPort(address); err != nil {
return
}
if p.report.Port, err = strconv.Atoi(port); err != nil {
return
}
p.report.DateTime = time.Now().Format(time.RFC3339)
dialer := &net.Dialer{Timeout: 10 * time.Second}
// If we are not dialing the standard API then we should skip cert verification checks.
var tlsConfig *tls.Config = nil
if address != stripProtocol(globalOriginalURL) {
tlsConfig = &tls.Config{InsecureSkipVerify: true} // nolint[gosec]
}
return tls.DialWithDialer(dialer, network, address, tlsConfig)
}
func (p *DialerWithPinning) checkFingerprints(conn net.Conn) (err error) {
if !checkTLSCerts {
return
}
connState := conn.(*tls.Conn).ConnectionState()
hasFingerprintMatch := false
for _, peerCert := range connState.PeerCertificates {
fingerprint := certFingerprint(peerCert)
for i, pin := range p.report.KnownPins {
if pin == fingerprint {
hasFingerprintMatch = true
if i != 0 {
p.log.Warnf("Matched fingerprint (%q) was not primary pinned key (was key #%d)", fingerprint, i)
}
break
}
}
if hasFingerprintMatch {
break
}
}
if !hasFingerprintMatch {
p.reportCertIssue(connState)
return ErrTLSMatch
}
return err
}

View File

@ -0,0 +1,126 @@
// Copyright (c) 2020 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 (
"net/http"
"net/http/httptest"
"testing"
)
const liveAPI = "https://api.protonmail.ch"
var testLiveConfig = &ClientConfig{
AppVersion: "Bridge_1.2.4-test",
ClientID: "Bridge",
}
func newTestDialerWithPinning() (*int, *DialerWithPinning) {
called := 0
p := NewPMAPIPinning(testLiveConfig.AppVersion)
p.ReportCertIssueLocal = func() { called++ }
testLiveConfig.Transport = p.TransportWithPinning()
return &called, p
}
func TestTLSPinValid(t *testing.T) {
called, _ := newTestDialerWithPinning()
RootURL = liveAPI
client := NewClient(testLiveConfig, "pmapi"+t.Name())
_, err := client.AuthInfo("this.address.is.disabled")
Ok(t, err)
Equals(t, 0, *called)
}
func TestTLSPinBackup(t *testing.T) {
called, p := newTestDialerWithPinning()
p.report.KnownPins[1] = p.report.KnownPins[0]
p.report.KnownPins[0] = ""
RootURL = liveAPI
client := NewClient(testLiveConfig, "pmapi"+t.Name())
_, err := client.AuthInfo("this.address.is.disabled")
Ok(t, err)
Equals(t, 0, *called)
}
func _TestTLSPinNoMatch(t *testing.T) { // nolint[unused]
called, p := newTestDialerWithPinning()
for i := 0; i < len(p.report.KnownPins); i++ {
p.report.KnownPins[i] = "testing"
}
RootURL = liveAPI
client := NewClient(testLiveConfig, "pmapi"+t.Name())
_, err := client.AuthInfo("this.address.is.disabled")
Ok(t, err)
// check that it will be called only once per session
client = NewClient(testLiveConfig, "pmapi"+t.Name())
_, err = client.AuthInfo("this.address.is.disabled")
Ok(t, err)
Equals(t, 1, *called)
}
func _TestTLSPinInvalid(t *testing.T) { // nolint[unused]
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSONResponsefromFile(t, w, "/auth/info/post_response.json", 0)
}))
defer ts.Close()
called, _ := newTestDialerWithPinning()
client := NewClient(testLiveConfig, "pmapi"+t.Name())
RootURL = liveAPI
_, err := client.AuthInfo("this.address.is.disabled")
Ok(t, err)
RootURL = ts.URL
_, err = client.AuthInfo("this.address.is.disabled")
Assert(t, err != nil, "error is expected but have %v", err)
Equals(t, 1, *called)
}
func _TestTLSSignedCertWrongPublicKey(t *testing.T) { // nolint[unused]
_, dialer := newTestDialerWithPinning()
_, err := dialer.dialAndCheckFingerprints("tcp", "rsa4096.badssl.com:443")
Assert(t, err != nil, "expected dial to fail because of wrong public key: ", err.Error())
}
func _TestTLSSignedCertTrustedPublicKey(t *testing.T) { // nolint[unused]
_, dialer := newTestDialerWithPinning()
dialer.report.KnownPins = append(dialer.report.KnownPins, `pin-sha256="W8/42Z0ffufwnHIOSndT+eVzBJSC0E8uTIC8O6mEliQ="`)
_, err := dialer.dialAndCheckFingerprints("tcp", "rsa4096.badssl.com:443")
Assert(t, err == nil, "expected dial to succeed because public key is known and cert is signed by CA: ", err.Error())
}
func _TestTLSSelfSignedCertTrustedPublicKey(t *testing.T) { // nolint[unused]
_, dialer := newTestDialerWithPinning()
dialer.report.KnownPins = append(dialer.report.KnownPins, `pin-sha256="9SLklscvzMYj8f+52lp5ze/hY0CFHyLSPQzSpYYIBm8="`)
_, err := dialer.dialAndCheckFingerprints("tcp", "self-signed.badssl.com:443")
Assert(t, err == nil, "expected dial to succeed because public key is known despite cert being self-signed: ", err.Error())
}

237
pkg/pmapi/events.go Normal file
View File

@ -0,0 +1,237 @@
// Copyright (c) 2020 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 (
"encoding/json"
"net/http"
"net/mail"
)
// Event represents changes since the last check.
type Event struct {
// The current event ID.
EventID string
// If set to one, all cached data must be fetched again.
Refresh int
// If set to one, fetch more events.
More int
// Changes applied to messages.
Messages []*EventMessage
// Counts of messages per labels.
MessageCounts []*MessagesCount
// Changes applied to labels.
Labels []*EventLabel
// Current user status.
User User
// Changes to addresses.
Addresses []*EventAddress
// Messages to show to the user.
Notices []string
}
// EventAction is the action that created a change.
type EventAction int
const (
EventDelete EventAction = iota // Item has been deleted.
EventCreate // Item has been created.
EventUpdate // Item has been updated.
EventUpdateFlags // For messages: flags have been updated.
)
// Flags for event refresh.
const (
EventRefreshMail = 1
EventRefreshContact = 2
EventRefreshAll = 255
)
// maxNumberOfMergedEvents limits how many events are merged into one. It means
// when GetEvent is called and event returns there is more events, it will
// automatically fetch next one and merge it up to this number of events.
const maxNumberOfMergedEvents = 50
// EventItem is an item that has changed.
type EventItem struct {
ID string
Action EventAction
}
// EventMessage is a message that has changed.
type EventMessage struct {
EventItem
// If the message has been created, the new message.
Created *Message `json:"-"`
// If the message has been updated, the updated fields.
Updated *EventMessageUpdated `json:"-"`
}
// eventMessage defines a new type to prevent MarshalJSON/UnmarshalJSON infinite loops.
type eventMessage EventMessage
type rawEventMessage struct {
eventMessage
// This will be parsed depending on the action.
Message json.RawMessage `json:",omitempty"`
}
func (em *EventMessage) UnmarshalJSON(b []byte) (err error) {
var raw rawEventMessage
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
*em = EventMessage(raw.eventMessage)
switch em.Action {
case EventCreate:
em.Created = &Message{ID: raw.ID}
return json.Unmarshal(raw.Message, em.Created)
case EventUpdate, EventUpdateFlags:
em.Updated = &EventMessageUpdated{ID: raw.ID}
return json.Unmarshal(raw.Message, em.Updated)
}
return nil
}
func (em *EventMessage) MarshalJSON() ([]byte, error) {
var raw rawEventMessage
raw.eventMessage = eventMessage(*em)
var err error
switch em.Action {
case EventCreate:
raw.Message, err = json.Marshal(em.Created)
case EventUpdate, EventUpdateFlags:
raw.Message, err = json.Marshal(em.Updated)
}
if err != nil {
return nil, err
}
return json.Marshal(raw)
}
// EventMessageUpdated contains changed fields for an updated message.
type EventMessageUpdated struct {
ID string
Subject *string
Unread *int
Flags *int64
Sender *mail.Address
ToList *[]*mail.Address
CCList *[]*mail.Address
BCCList *[]*mail.Address
Time int64
// Fields only present for EventUpdateFlags.
LabelIDs []string
LabelIDsAdded []string
LabelIDsRemoved []string
}
// EventLabel is a label that has changed.
type EventLabel struct {
EventItem
Label *Label
}
// EventAddress is an address that has changed.
type EventAddress struct {
EventItem
Address *Address
}
type EventRes struct {
Res
*Event
}
type LatestEventRes struct {
Res
*Event
}
// GetEvent returns a summary of events that occurred since last. To get the latest event,
// provide an empty last value. The latest event is always empty.
func (c *Client) GetEvent(last string) (event *Event, err error) {
return c.getEvent(last, 1)
}
func (c *Client) getEvent(last string, numberOfMergedEvents int) (event *Event, err error) {
var req *http.Request
if last == "" {
req, err = NewRequest("GET", "/events/latest", nil)
if err != nil {
return
}
var res LatestEventRes
if err = c.DoJSON(req, &res); err != nil {
return
}
event, err = res.Event, res.Err()
} else {
req, err = NewRequest("GET", "/events/"+last, nil)
if err != nil {
return
}
var res EventRes
if err = c.DoJSON(req, &res); err != nil {
return
}
event, err = res.Event, res.Err()
if err != nil {
return
}
if event.More == 1 && numberOfMergedEvents < maxNumberOfMergedEvents {
var moreEvents *Event
if moreEvents, err = c.getEvent(event.EventID, numberOfMergedEvents+1); err != nil {
return
}
event = mergeEvents(event, moreEvents)
}
}
return event, err
}
// mergeEvents combines an old events and a new events object.
// This is not as simple as just blindly joining the two because some things should only be taken from the new events.
func mergeEvents(eventsOld *Event, eventsNew *Event) (mergedEvents *Event) {
mergedEvents = &Event{
EventID: eventsNew.EventID,
Refresh: eventsOld.Refresh | eventsNew.Refresh,
More: eventsNew.More,
Messages: append(eventsOld.Messages, eventsNew.Messages...),
MessageCounts: append(eventsOld.MessageCounts, eventsNew.MessageCounts...),
Labels: append(eventsOld.Labels, eventsNew.Labels...),
User: eventsNew.User,
Addresses: append(eventsOld.Addresses, eventsNew.Addresses...),
Notices: append(eventsOld.Notices, eventsNew.Notices...),
}
return
}

524
pkg/pmapi/events_test.go Normal file
View File

@ -0,0 +1,524 @@
// Copyright (c) 2020 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 (
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClient_GetEvent(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.NoError(t, checkMethodAndPath(r, "GET", "/events/latest"))
fmt.Fprint(w, testEventBody)
}))
defer s.Close()
event, err := c.GetEvent("")
require.NoError(t, err)
require.Equal(t, testEvent, event)
}
func TestClient_GetEvent_withID(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.NoError(t, checkMethodAndPath(r, "GET", "/events/"+testEvent.EventID))
fmt.Fprint(w, testEventBody)
}))
defer s.Close()
event, err := c.GetEvent(testEvent.EventID)
require.NoError(t, err)
require.Equal(t, testEvent, event)
}
// We first call GetEvent with id of eventID1, which returns More=1 so we fetch with id eventID2.
func TestClient_GetEvent_mergeEvents(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.RequestURI() {
case "/events/eventID1":
assert.NoError(t, checkMethodAndPath(r, "GET", "/events/eventID1"))
fmt.Fprint(w, testEventBodyMore1)
case "/events/eventID2":
assert.NoError(t, checkMethodAndPath(r, "GET", "/events/eventID2"))
fmt.Fprint(w, testEventBodyMore2)
default:
t.Fail()
}
}))
defer s.Close()
event, err := c.GetEvent("eventID1")
require.NoError(t, err)
require.Equal(t, testEventMerged, event)
}
func TestClient_GetEvent_mergeMaxNumberOfEvents(t *testing.T) {
numberOfCalls := 0
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
numberOfCalls++
re := regexp.MustCompile(`/eventID([0-9]+)`)
eventIDString := re.FindStringSubmatch(r.URL.RequestURI())[1]
eventID, err := strconv.Atoi(eventIDString)
require.NoError(t, err)
if numberOfCalls > maxNumberOfMergedEvents*2 {
require.Fail(t, "Too many calls!")
}
fmt.Println("")
body := strings.ReplaceAll(testEventBodyMore1, "eventID2", "eventID"+strconv.Itoa(eventID+1))
fmt.Fprint(w, body)
}))
defer s.Close()
event, err := c.GetEvent("eventID1")
require.NoError(t, err)
require.Equal(t, maxNumberOfMergedEvents, numberOfCalls)
require.Equal(t, 1, event.More)
}
var (
testEventMessageUpdateUnread = 0
testEvent = &Event{
EventID: "eventID1",
Refresh: 0,
Messages: []*EventMessage{
{
EventItem: EventItem{ID: "hdI7aIgUO1hFplCIcJHB0jShRVsAzS0AB75wGCaiNVeIHXLmaUnt4eJ8l7c7L6uk4g0ZdXhGWG5gfh6HHgAZnw==", Action: EventCreate},
Created: &Message{
ID: "hdI7aIgUO1hFplCIcJHB0jShRVsAzS0AB75wGCaiNVeIHXLmaUnt4eJ8l7c7L6uk4g0ZdXhGWG5gfh6HHgAZnw==",
Subject: "Hey there",
},
},
{
EventItem: EventItem{ID: "bSFLAimPSfGz2Kj0aV3l3AyXsof_Vf7sfrrMJ8ifgGJe-f2NG2eLaEGXLytjMhq9wnLMtkoZpO2uBXM4nOVa5g==", Action: EventUpdateFlags},
Updated: &EventMessageUpdated{
ID: "bSFLAimPSfGz2Kj0aV3l3AyXsof_Vf7sfrrMJ8ifgGJe-f2NG2eLaEGXLytjMhq9wnLMtkoZpO2uBXM4nOVa5g==",
Unread: &testEventMessageUpdateUnread,
Time: 1472391377,
LabelIDsAdded: []string{ArchiveLabel},
LabelIDsRemoved: []string{InboxLabel},
},
},
{
EventItem: EventItem{ID: "XRBMBYnSkaEJWtqFACp2kjlNc-7GjzX3SnPcOtWK4PyLG11Nhsg0uxPYjTXoClQfB-EHVDl9gE3w2PVuj93jBg==", Action: EventDelete},
},
},
MessageCounts: []*MessagesCount{
{
LabelID: "0",
Total: 19,
Unread: 2,
},
{
LabelID: "6",
Total: 1,
Unread: 0,
},
},
Notices: []string{"Server will be down in 2min because of a NSA attack"},
}
testEventMerged = &Event{
EventID: "eventID3",
Refresh: 1,
Messages: []*EventMessage{
{
EventItem: EventItem{ID: "msgID1", Action: EventCreate},
Created: &Message{
ID: "id",
Subject: "Hey there",
},
},
{
EventItem: EventItem{ID: "msgID2", Action: EventCreate},
Created: &Message{
ID: "id",
Subject: "Hey there again",
},
},
},
MessageCounts: []*MessagesCount{
{
LabelID: "label1",
Total: 19,
Unread: 2,
},
{
LabelID: "label2",
Total: 1,
Unread: 0,
},
{
LabelID: "label2",
Total: 2,
Unread: 1,
},
{
LabelID: "label3",
Total: 1,
Unread: 0,
},
},
Notices: []string{"Server will be down in 2min because of a NSA attack", "Just kidding lol"},
Labels: []*EventLabel{
{
EventItem: EventItem{
ID: "labelID1",
Action: 1,
},
Label: &Label{
ID: "id",
Name: "Event Label 1",
},
},
{
EventItem: EventItem{
ID: "labelID2",
Action: 1,
},
Label: &Label{
ID: "id",
Name: "Event Label 2",
},
},
},
User: User{
ID: "userID1",
Name: "user",
UsedSpace: 23456,
},
Addresses: []*EventAddress{
{
EventItem: EventItem{
ID: "addressID1",
Action: 2,
},
Address: &Address{
ID: "id",
DisplayName: "address 1",
},
},
{
EventItem: EventItem{
ID: "addressID2",
Action: 2,
},
Address: &Address{
ID: "id",
DisplayName: "address 2",
},
},
},
}
)
const (
testEventBody = `{
"EventID": "eventID1",
"Refresh": 0,
"Messages": [
{
"ID": "hdI7aIgUO1hFplCIcJHB0jShRVsAzS0AB75wGCaiNVeIHXLmaUnt4eJ8l7c7L6uk4g0ZdXhGWG5gfh6HHgAZnw==",
"Action": 1,
"Message": {
"ID": "hdI7aIgUO1hFplCIcJHB0jShRVsAzS0AB75wGCaiNVeIHXLmaUnt4eJ8l7c7L6uk4g0ZdXhGWG5gfh6HHgAZnw==",
"Subject": "Hey there"
}
},
{
"ID": "bSFLAimPSfGz2Kj0aV3l3AyXsof_Vf7sfrrMJ8ifgGJe-f2NG2eLaEGXLytjMhq9wnLMtkoZpO2uBXM4nOVa5g==",
"Action": 3,
"Message": {
"ConversationID": "2oX3EILYRuZ0IRBVlzMg1oV5eazQL67sFIHlcR8bjickPn7K4id4sJZuAB6n0pdtI3hRIVsjCpgWfRm8c_x3IQ==",
"Unread": 0,
"Time": 1472391377,
"Location": 6,
"LabelIDsAdded": [
"6"
],
"LabelIDsRemoved": [
"0"
]
}
},
{
"ID": "XRBMBYnSkaEJWtqFACp2kjlNc-7GjzX3SnPcOtWK4PyLG11Nhsg0uxPYjTXoClQfB-EHVDl9gE3w2PVuj93jBg==",
"Action": 0
}
],
"Conversations": [
{
"ID": "2oX3EILYRuZ0IRBVlzMg1oV5eazQL67sFIHlcR8bjickPn7K4id4sJZuAB6n0pdtI3hRIVsjCpgWfRm8c_x3IQ==",
"Action": 1,
"Conversation": {
"ID": "2oX3EILYRuZ0IRBVlzMg1oV5eazQL67sFIHlcR8bjickPn7K4id4sJZuAB6n0pdtI3hRIVsjCpgWfRm8c_x3IQ==",
"Order": 1616,
"Subject": "Hey there",
"Senders": [
{
"Address": "apple@protonmail.com",
"Name": "apple@protonmail.com"
}
],
"Recipients": [
{
"Address": "apple@protonmail.com",
"Name": "apple@protonmail.com"
}
],
"NumMessages": 1,
"NumUnread": 1,
"NumAttachments": 0,
"ExpirationTime": 0,
"TotalSize": 636,
"AddressID": "QMJs2dzTx7uqpH5PNgIzjULywU4gO9uMBhEMVFOAVJOoUml54gC0CCHtW9qYwzH-zYbZwMv3MFYncPjW1Usq7Q==",
"LabelIDs": [
"0"
],
"Labels": [
{
"Count": 1,
"NumMessages": 1,
"NumUnread": 1,
"ID": "0"
}
]
}
}
],
"Total": {
"Locations": [
{
"Location": 0,
"Count": 19
},
{
"Location": 1,
"Count": 16
},
{
"Location": 2,
"Count": 16
},
{
"Location": 3,
"Count": 17
},
{
"Location": 6,
"Count": 1
}
],
"Labels": [
{
"LabelID": "LLz8ysmVxwr4dF6mWpClePT0SpSWOEvzTdq17RydSl4ndMckvY1K63HeXDzn03BJQwKYvgf-eWT8Qfd9WVuIEQ==",
"Count": 2
},
{
"LabelID": "BvbqbySUPo9uWW_eR8tLA13NUsQMz3P4Zhw4UnpvrKqURnrHlE6L2Au0nplHfHlVXFgGz4L4hJ9-BYllOL-L5g==",
"Count": 2
}
],
"Starred": 3
},
"Unread": {
"Locations": [
{
"Location": 0,
"Count": 2
},
{
"Location": 1,
"Count": 0
},
{
"Location": 2,
"Count": 0
},
{
"Location": 3,
"Count": 0
},
{
"Location": 6,
"Count": 0
}
],
"Labels": [
{
"LabelID": "LLz8ysmVxwr4dF6mWpClePT0SpSWOEvzTdq17RydSl4ndMckvY1K63HeXDzn03BJQwKYvgf-eWT8Qfd9WVuIEQ==",
"Count": 0
},
{
"LabelID": "BvbqbySUPo9uWW_eR8tLA13NUsQMz3P4Zhw4UnpvrKqURnrHlE6L2Au0nplHfHlVXFgGz4L4hJ9-BYllOL-L5g==",
"Count": 0
}
],
"Starred": 0
},
"MessageCounts": [
{
"LabelID": "0",
"Total": 19,
"Unread": 2
},
{
"LabelID": "6",
"Total": 1,
"Unread": 0
}
],
"ConversationCounts": [
{
"LabelID": "0",
"Total": 19,
"Unread": 2
},
{
"LabelID": "6",
"Total": 1,
"Unread": 0
}
],
"UsedSpace": 7552905,
"Notices": ["Server will be down in 2min because of a NSA attack"],
"Code": 1000
}
`
testEventBodyMore1 = `{
"EventID": "eventID2",
"More": 1,
"Refresh": 1,
"Messages": [
{
"ID": "msgID1",
"Action": 1,
"Message": {
"ID": "id",
"Subject": "Hey there"
}
}
],
"MessageCounts": [
{
"LabelID": "label1",
"Total": 19,
"Unread": 2
},
{
"LabelID": "label2",
"Total": 1,
"Unread": 0
}
],
"Labels": [
{
"ID":"labelID1",
"Action":1,
"Label":{
"ID":"id",
"Name":"Event Label 1"
}
}
],
"User": {
"ID": "userID1",
"Name": "user",
"UsedSpace": 12345
},
"Addresses": [
{
"ID": "addressID1",
"Action": 2,
"Address": {
"ID": "id",
"DisplayName": "address 1"
}
}
],
"Notices": ["Server will be down in 2min because of a NSA attack"]
}
`
testEventBodyMore2 = `{
"EventID": "eventID3",
"Refresh": 0,
"Messages": [
{
"ID": "msgID2",
"Action": 1,
"Message": {
"ID": "id",
"Subject": "Hey there again"
}
}
],
"MessageCounts": [
{
"LabelID": "label2",
"Total": 2,
"Unread": 1
},
{
"LabelID": "label3",
"Total": 1,
"Unread": 0
}
],
"Labels": [
{
"ID":"labelID2",
"Action":1,
"Label":{
"ID":"id",
"Name":"Event Label 2"
}
}
],
"User": {
"ID": "userID1",
"Name": "user",
"UsedSpace": 23456
},
"Addresses": [
{
"ID": "addressID2",
"Action": 2,
"Address": {
"ID": "id",
"DisplayName": "address 2"
}
}
],
"Notices": ["Just kidding lol"]
}
`
)

157
pkg/pmapi/import.go Normal file
View File

@ -0,0 +1,157 @@
// Copyright (c) 2020 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 (
"encoding/json"
"io"
"mime/multipart"
"strconv"
)
// Import errors.
const (
ImportMessageTooLarge = 36022
)
// ImportReq is an import request.
type ImportReq struct {
// A list of messages that will be imported.
Messages []*ImportMsgReq
}
// WriteTo writes the import request to a multipart writer.
func (req *ImportReq) WriteTo(w *multipart.Writer) (err error) {
// Create Metadata field.
mw, err := w.CreateFormField("Metadata")
if err != nil {
return
}
// Build metadata.
metadata := map[string]*ImportMsgReq{}
for i, msg := range req.Messages {
name := strconv.Itoa(i)
metadata[name] = msg
}
// Write metadata.
if err = json.NewEncoder(mw).Encode(metadata); err != nil {
return
}
// Write messages.
for i, msg := range req.Messages {
name := strconv.Itoa(i)
var fw io.Writer
if fw, err = w.CreateFormFile(name, name+".eml"); err != nil {
return err
}
if _, err = fw.Write(msg.Body); err != nil {
return
}
}
return err
}
// ImportMsgReq is a request to import a message. All fields are optional except AddressID and Body.
type ImportMsgReq struct {
// The address where the message will be imported.
AddressID string
// The full MIME message.
Body []byte `json:"-"`
// 0: read, 1: unread.
Unread int
// 1 if the message has been replied.
IsReplied int
// 1 if the message has been replied to all.
IsRepliedAll int
// 1 if the message has been forwarded.
IsForwarded int
// The time when the message was received as a Unix time.
Time int64
// The type of the imported message.
Flags int64
// The labels to apply to the imported message. Must contain at least one system label.
LabelIDs []string
}
// ImportRes is a response to an import request.
type ImportRes struct {
Res
Responses []struct {
Name string
Response struct {
Res
MessageID string
}
}
}
// ImportMsgRes is a response to a single message import request.
type ImportMsgRes struct {
// The error encountered while importing the message, if any.
Error error
// The newly created message ID.
MessageID string
}
// Import imports messages to the user's account.
func (c *Client) Import(reqs []*ImportMsgReq) (resps []*ImportMsgRes, err error) {
importReq := &ImportReq{Messages: reqs}
req, w, err := NewMultipartRequest("POST", "/import")
if err != nil {
return
}
// We will write the request as long as it is sent to the API.
var importRes ImportRes
done := make(chan error, 1)
go (func() {
done <- c.DoJSON(req, &importRes)
})()
// Write the request.
if err = importReq.WriteTo(w.Writer); err != nil {
return
}
_ = w.Close()
if err = <-done; err != nil {
return
}
if err = importRes.Err(); err != nil {
return
}
resps = make([]*ImportMsgRes, len(importRes.Responses))
for i, r := range importRes.Responses {
resps[i] = &ImportMsgRes{
Error: r.Response.Err(),
MessageID: r.Response.MessageID,
}
}
return resps, err
}

155
pkg/pmapi/import_test.go Normal file
View File

@ -0,0 +1,155 @@
// Copyright (c) 2020 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 (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"mime"
"mime/multipart"
"net/http"
"reflect"
"testing"
)
var testImportReqs = []*ImportMsgReq{
{
AddressID: "QMJs2dzTx7uqpH5PNgIzjULywU4gO9uMBhEMVFOAVJOoUml54gC0CCHtW9qYwzH-zYbZwMv3MFYncPjW1Usq7Q==",
Body: []byte("Hello World!"),
Unread: 0,
Flags: FlagReceived | FlagImported,
LabelIDs: []string{ArchiveLabel},
},
}
const testImportBody = `{
"Code": 1001,
"Responses": [{
"Name": "0",
"Response": {"Code": 1000, "MessageID": "UKjSNz95KubYjrYmfbv1mbIfGxzY6D64mmHmVpWhkeEau-u0PIS4ru5IFMHgX6WjKpWYKCht3oiOtL5-wZChNg=="}
}]
}`
var testImportRes = &ImportMsgRes{
Error: nil,
MessageID: "UKjSNz95KubYjrYmfbv1mbIfGxzY6D64mmHmVpWhkeEau-u0PIS4ru5IFMHgX6WjKpWYKCht3oiOtL5-wZChNg==",
}
func TestClient_Import(t *testing.T) { // nolint[funlen]
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "POST", "/import"))
contentType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
t.Error("Expected no error while parsing request content type, got:", err)
}
if contentType != "multipart/form-data" {
t.Errorf("Invalid request content type: expected %v but got %v", "multipart/form-data", contentType)
}
mr := multipart.NewReader(r.Body, params["boundary"])
// First part is metadata.
p, err := mr.NextPart()
if err != nil {
t.Error("Expected no error while reading first part of request body, got:", err)
}
contentDisp, params, err := mime.ParseMediaType(p.Header.Get("Content-Disposition"))
if err != nil {
t.Error("Expected no error while parsing part content disposition, got:", err)
}
if contentDisp != "form-data" {
t.Errorf("Invalid part content disposition: expected %v but got %v", "form-data", contentType)
}
if params["name"] != "Metadata" {
t.Errorf("Invalid part name: expected %v but got %v", "Metadata", params["name"])
}
metadata := map[string]*ImportMsgReq{}
if err := json.NewDecoder(p).Decode(&metadata); err != nil {
t.Error("Expected no error while parsing metadata json, got:", err)
}
if len(metadata) != 1 {
t.Errorf("Expected metadata to contain exactly one item, got %v", metadata)
}
req := metadata["0"]
if metadata["0"] == nil {
t.Errorf("Expected metadata to contain one item indexed by 0, got %v", metadata)
}
// No Body in metadata.
expected := *testImportReqs[0]
expected.Body = nil
if !reflect.DeepEqual(&expected, req) {
t.Errorf("Invalid message metadata: expected %v, got %v", &expected, req)
}
// Second part is message body.
p, err = mr.NextPart()
if err != nil {
t.Error("Expected no error while reading second part of request body, got:", err)
}
contentDisp, params, err = mime.ParseMediaType(p.Header.Get("Content-Disposition"))
if err != nil {
t.Error("Expected no error while parsing part content disposition, got:", err)
}
if contentDisp != "form-data" {
t.Errorf("Invalid part content disposition: expected %v but got %v", "form-data", contentType)
}
if params["name"] != "0" {
t.Errorf("Invalid part name: expected %v but got %v", "0", params["name"])
}
b, err := ioutil.ReadAll(p)
if err != nil {
t.Error("Expected no error while reading second part body, got:", err)
}
if string(b) != string(testImportReqs[0].Body) {
t.Errorf("Invalid message body: expected %v but got %v", string(testImportReqs[0].Body), string(b))
}
// No more parts.
_, err = mr.NextPart()
if err != io.EOF {
t.Error("Expected no more parts but error was not EOF, got:", err)
}
fmt.Fprint(w, testImportBody)
}))
defer s.Close()
imported, err := c.Import(testImportReqs)
if err != nil {
t.Fatal("Expected no error while importing, got:", err)
}
if len(imported) != 1 {
t.Fatalf("Expected exactly one imported message, got %v", len(imported))
}
if !reflect.DeepEqual(testImportRes, imported[0]) {
t.Errorf("Invalid response for imported message: expected %+v but got %+v", testImportRes, imported[0])
}
}

138
pkg/pmapi/key.go Normal file
View File

@ -0,0 +1,138 @@
// Copyright (c) 2020 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 (
"fmt"
"net/http"
"net/url"
"strings"
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
)
// Flags
const (
UseToVerifyFlag = 1 << iota
UseToEncryptFlag
)
type PublicKeyRes struct {
Res
RecipientType int
MIMEType string
Keys []PublicKey
}
type PublicKey struct {
Flags int
PublicKey string
}
// PublicKeys returns the public keys of the given email addresses.
func (c *Client) PublicKeys(emails []string) (keys map[string]*pmcrypto.KeyRing, err error) {
if len(emails) == 0 {
err = fmt.Errorf("pmapi: cannot get public keys: no email address provided")
return
}
keys = map[string]*pmcrypto.KeyRing{}
for _, email := range emails {
email = url.QueryEscape(email)
var req *http.Request
if req, err = NewRequest("GET", "/keys?Email="+email, nil); err != nil {
return
}
var res PublicKeyRes
if err = c.DoJSON(req, &res); err != nil {
return
}
for _, key := range res.Keys {
if key.Flags&UseToEncryptFlag == UseToEncryptFlag {
var kr *pmcrypto.KeyRing
if kr, err = pmcrypto.ReadArmoredKeyRing(strings.NewReader(key.PublicKey)); err != nil {
return
}
keys[email] = kr
}
}
}
return keys, err
}
const (
RecipientInternal = 1
RecipientExternal = 2
)
// GetPublicKeysForEmail returns all sending public keys for the given email address.
func (c *Client) GetPublicKeysForEmail(email string) (keys []PublicKey, internal bool, err error) {
email = url.QueryEscape(email)
var req *http.Request
if req, err = NewRequest("GET", "/keys?Email="+email, nil); err != nil {
return
}
var res PublicKeyRes
if err = c.DoJSON(req, &res); err != nil {
return
}
internal = res.RecipientType == RecipientInternal
for _, key := range res.Keys {
if key.Flags&UseToEncryptFlag == UseToEncryptFlag {
keys = append(keys, key)
}
}
return
}
// KeySalt contains id and salt for key.
type KeySalt struct {
ID, KeySalt string
}
// KeySaltRes is used to unmarshal API response.
type KeySaltRes struct {
Res
KeySalts []KeySalt
}
// GetKeySalts sends request to get list of key salts (n.b. locked route).
func (c *Client) GetKeySalts() (keySalts []KeySalt, err error) {
var req *http.Request
if req, err = NewRequest("GET", "/keys/salts", nil); err != nil {
return
}
var res KeySaltRes
if err = c.DoJSON(req, &res); err != nil {
return
}
keySalts = res.KeySalts
return
}

295
pkg/pmapi/keyring.go Normal file
View File

@ -0,0 +1,295 @@
// Copyright (c) 2020 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 (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"sync"
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
)
// clearableKey is a region of memory intended to hold a private key and which can be securely
// cleared by calling clear().
type clearableKey []byte
// UnmarshalJSON Removes quotation and unescapes CR, LF.
func (pk *clearableKey) UnmarshalJSON(b []byte) (err error) {
b = bytes.Trim(b, "\"")
b = bytes.ReplaceAll(b, []byte("\\n"), []byte("\n"))
b = bytes.ReplaceAll(b, []byte("\\r"), []byte("\r"))
*pk = b
return
}
// clear irreversibly destroys the full range of `clearableKey` by filling it with zeros to ensure
// nobody can see what was in there (e.g. while waiting for the garbage collector to clean it up).
func (pk *clearableKey) clear() {
for b := range *pk {
(*pk)[b] = 0
}
}
type PMKey struct {
ID string
Version int
Flags int
Fingerprint string
Primary int
Token *string `json:",omitempty"`
Signature *string `json:",omitempty"`
}
type PMKeys struct {
Keys []PMKey
KeyRing *pmcrypto.KeyRing
}
func (k *PMKeys) UnmarshalJSON(b []byte) (err error) {
var rawKeys []struct {
PMKey
PrivateKey clearableKey
}
if err = json.Unmarshal(b, &rawKeys); err != nil {
return
}
k.KeyRing = &pmcrypto.KeyRing{}
for _, rawKey := range rawKeys {
err = k.KeyRing.ReadFrom(bytes.NewReader(rawKey.PrivateKey), true)
rawKey.PrivateKey.clear()
if err != nil {
return
}
k.Keys = append(k.Keys, rawKey.PMKey)
}
if len(k.Keys) > 0 {
k.KeyRing.FirstKeyID = k.Keys[0].ID
}
return
}
// unlockKeyRing tries to unlock them with the provided keyRing using the token
// and if the token is not available it will use passphrase. It will not fail
// if keyring contains at least one unlocked private key.
func (k *PMKeys) unlockKeyRing(userKeyring *pmcrypto.KeyRing, passphrase []byte, locker sync.Locker) (err error) {
locker.Lock()
defer locker.Unlock()
if k == nil {
err = errors.New("keys is a nil object")
return
}
for _, key := range k.Keys {
if key.Token == nil || key.Signature == nil {
if err = unlockKeyRingNoErrorWhenAlreadyUnlocked(k.KeyRing, passphrase); err != nil {
return
}
continue
}
message, err := pmcrypto.NewPGPMessageFromArmored(*key.Token)
if err != nil {
return err
}
signature, err := pmcrypto.NewPGPSignatureFromArmored(*key.Signature)
if err != nil {
return err
}
if userKeyring == nil {
return errors.New("userkey required to decrypt tokens but wasn't provided")
}
token, err := userKeyring.Decrypt(message, nil, 0)
if err != nil {
return err
}
err = userKeyring.VerifyDetached(token, signature, 0)
if err != nil {
return err
}
err = unlockKeyRingNoErrorWhenAlreadyUnlocked(k.KeyRing, token.GetBinary())
if err != nil {
return fmt.Errorf("wrong token: %v", err)
}
}
return nil
}
type unlockError struct {
error
}
func (err *unlockError) Error() string {
return "Invalid mailbox password (" + err.error.Error() + ")"
}
// IsUnlockError checks whether the error is due to failure to unlock (which is represented by an unexported type).
func IsUnlockError(err error) bool {
_, ok := err.(*unlockError)
return ok
}
func unlockKeyRingNoErrorWhenAlreadyUnlocked(kr *pmcrypto.KeyRing, passphrase []byte) (err error) {
if err = kr.Unlock(passphrase); err != nil {
// Do not fail if it has already unlocked keys.
hasUnlockedKey := false
for _, e := range kr.GetEntities() {
if e.PrivateKey != nil && !e.PrivateKey.Encrypted {
hasUnlockedKey = true
break
}
for _, se := range e.Subkeys {
if se.PrivateKey != nil && (!se.Sig.FlagsValid || se.Sig.FlagEncryptStorage || se.Sig.FlagEncryptCommunications) && !e.PrivateKey.Encrypted {
hasUnlockedKey = true
break
}
}
if hasUnlockedKey {
break
}
}
if !hasUnlockedKey {
err = &unlockError{err}
return
}
err = nil
}
return
}
// ErrNoKeyringAvailable represents an error caused by a keyring being nil or having no entities.
var ErrNoKeyringAvailable = errors.New("no keyring available")
func (c *Client) encrypt(plain string, signer *pmcrypto.KeyRing) (armored string, err error) {
return encrypt(c.kr, plain, signer)
}
func encrypt(encrypter *pmcrypto.KeyRing, plain string, signer *pmcrypto.KeyRing) (armored string, err error) {
if encrypter == nil || encrypter.FirstKey() == nil {
return "", ErrNoKeyringAvailable
}
plainMessage := pmcrypto.NewPlainMessageFromString(plain)
// We use only primary key to encrypt the message. Our keyring contains all keys (primary, old and deacivated ones).
pgpMessage, err := encrypter.FirstKey().Encrypt(plainMessage, signer)
if err != nil {
return
}
return pgpMessage.GetArmored()
}
func (c *Client) decrypt(armored string) (plain string, err error) {
return decrypt(c.kr, armored)
}
func decrypt(decrypter *pmcrypto.KeyRing, armored string) (plainBody string, err error) {
if decrypter == nil {
return "", ErrNoKeyringAvailable
}
pgpMessage, err := pmcrypto.NewPGPMessageFromArmored(armored)
if err != nil {
return
}
plainMessage, err := decrypter.Decrypt(pgpMessage, nil, 0)
if err != nil {
return
}
return plainMessage.GetString(), nil
}
func (c *Client) sign(plain string) (armoredSignature string, err error) {
if c.kr == nil {
return "", ErrNoKeyringAvailable
}
plainMessage := pmcrypto.NewPlainMessageFromString(plain)
pgpSignature, err := c.kr.SignDetached(plainMessage)
if err != nil {
return
}
return pgpSignature.GetArmored()
}
func (c *Client) verify(plain, amroredSignature string) (err error) {
plainMessage := pmcrypto.NewPlainMessageFromString(plain)
pgpSignature, err := pmcrypto.NewPGPSignatureFromArmored(amroredSignature)
if err != nil {
return
}
verifyTime := int64(0) // By default it will use current timestamp.
return c.kr.VerifyDetached(plainMessage, pgpSignature, verifyTime)
}
func encryptAttachment(kr *pmcrypto.KeyRing, data io.Reader, filename string) (encrypted io.Reader, err error) {
if kr == nil || kr.FirstKey() == nil {
return nil, ErrNoKeyringAvailable
}
dataBytes, err := ioutil.ReadAll(data)
if err != nil {
return
}
plainMessage := pmcrypto.NewPlainMessage(dataBytes)
// We use only primary key to encrypt the message. Our keyring contains all keys (primary, old and deacivated ones).
pgpSplitMessage, err := kr.FirstKey().EncryptAttachment(plainMessage, filename)
if err != nil {
return
}
packets := append(pgpSplitMessage.KeyPacket, pgpSplitMessage.DataPacket...)
return bytes.NewReader(packets), nil
}
func decryptAttachment(kr *pmcrypto.KeyRing, keyPackets []byte, data io.Reader) (decrypted io.Reader, err error) {
if kr == nil {
return nil, ErrNoKeyringAvailable
}
dataBytes, err := ioutil.ReadAll(data)
if err != nil {
return
}
pgpSplitMessage := pmcrypto.NewPGPSplitMessage(keyPackets, dataBytes)
plainMessage, err := kr.DecryptAttachment(pgpSplitMessage)
if err != nil {
return
}
return plainMessage.NewReader(), nil
}
func signAttachment(encrypter *pmcrypto.KeyRing, data io.Reader) (signature io.Reader, err error) {
if encrypter == nil {
return nil, ErrNoKeyringAvailable
}
dataBytes, err := ioutil.ReadAll(data)
if err != nil {
return
}
plainMessage := pmcrypto.NewPlainMessage(dataBytes)
sig, err := encrypter.SignDetached(plainMessage)
if err != nil {
return
}
return bytes.NewReader(sig.GetBinary()), nil
}

94
pkg/pmapi/keyring_test.go Normal file
View File

@ -0,0 +1,94 @@
// Copyright (c) 2020 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 (
"encoding/json"
"strings"
"sync"
"testing"
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
"github.com/stretchr/testify/assert"
)
func loadPMKeys(jsonKeys string) (keys *PMKeys) {
_ = json.Unmarshal([]byte(jsonKeys), &keys)
return
}
func TestPMKeys_GetKeyRingAndUnlock(t *testing.T) {
addrKeysWithTokens := loadPMKeys(readTestFile("keyring_addressKeysWithTokens_JSON", false))
addrKeysWithoutTokens := loadPMKeys(readTestFile("keyring_addressKeysWithoutTokens_JSON", false))
addrKeysPrimaryHasToken := loadPMKeys(readTestFile("keyring_addressKeysPrimaryHasToken_JSON", false))
addrKeysSecondaryHasToken := loadPMKeys(readTestFile("keyring_addressKeysSecondaryHasToken_JSON", false))
userKey, err := pmcrypto.ReadArmoredKeyRing(strings.NewReader(readTestFile("keyring_userKey", false)))
assert.NoError(t, err, "Expected not to receive an error unlocking user key")
type args struct {
userKeyring *pmcrypto.KeyRing
passphrase []byte
}
tests := []struct {
name string
keys *PMKeys
args args
}{
{
name: "AddressKeys locked with tokens",
keys: addrKeysWithTokens,
args: args{userKeyring: userKey, passphrase: []byte("testpassphrase")},
},
{
name: "AddressKeys locked with passphrase, not tokens",
keys: addrKeysWithoutTokens,
args: args{userKeyring: userKey, passphrase: []byte("testpassphrase")},
},
{
name: "AddressKeys, primary locked with token, secondary with passphrase",
keys: addrKeysPrimaryHasToken,
args: args{userKeyring: userKey, passphrase: []byte("testpassphrase")},
},
{
name: "AddressKeys, primary locked with passphrase, secondary with token",
keys: addrKeysSecondaryHasToken,
args: args{userKeyring: userKey, passphrase: []byte("testpassphrase")},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempLocker := &sync.Mutex{}
err := tt.keys.unlockKeyRing(tt.args.userKeyring, tt.args.passphrase, tempLocker) // nolint[scopelint]
if !assert.NoError(t, err) {
return
}
// assert at least one key has been decrypted
atLeastOneDecrypted := false
for _, e := range tt.keys.KeyRing.GetEntities() { // nolint[scopelint]
if !e.PrivateKey.Encrypted {
atLeastOneDecrypted = true
break
}
}
assert.True(t, atLeastOneDecrypted)
})
}
}

177
pkg/pmapi/labels.go Normal file
View File

@ -0,0 +1,177 @@
// Copyright (c) 2020 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 "fmt"
// System labels
const (
InboxLabel = "0"
AllDraftsLabel = "1"
AllSentLabel = "2"
TrashLabel = "3"
SpamLabel = "4"
AllMailLabel = "5"
ArchiveLabel = "6"
SentLabel = "7"
DraftLabel = "8"
StarredLabel = "10"
LabelTypeMailbox = 1
LabelTypeContactGroup = 2
)
// IsSystemLabel checks if a label is a pre-defined system label.
func IsSystemLabel(label string) bool {
switch label {
case InboxLabel, DraftLabel, SentLabel, TrashLabel, SpamLabel, ArchiveLabel, StarredLabel, AllMailLabel, AllSentLabel, AllDraftsLabel:
return true
}
return false
}
// LabelColors provides the RGB values of the available label colors.
var LabelColors = []string{ //nolint[gochecknoglobals]
"#7272a7",
"#cf5858",
"#c26cc7",
"#7569d1",
"#69a9d1",
"#5ec7b7",
"#72bb75",
"#c3d261",
"#e6c04c",
"#e6984c",
"#8989ac",
"#cf7e7e",
"#c793ca",
"#9b94d1",
"#a8c4d5",
"#97c9c1",
"#9db99f",
"#c6cd97",
"#e7d292",
"#dfb286",
}
type LabelAction int
const (
RemoveLabel LabelAction = iota
AddLabel
)
// Label for message.
type Label struct {
ID string
Name string
Color string
Order int `json:",omitempty"`
Display int // Not used for now, leave it empty.
Exclusive int
Type int
Notify int
}
type LabelListRes struct {
Res
Labels []*Label
}
func (c *Client) ListLabels() (labels []*Label, err error) {
return c.ListLabelType(LabelTypeMailbox)
}
func (c *Client) ListContactGroups() (labels []*Label, err error) {
return c.ListLabelType(LabelTypeContactGroup)
}
// ListLabelType lists all labels created by the user.
func (c *Client) ListLabelType(labelType int) (labels []*Label, err error) {
req, err := NewRequest("GET", fmt.Sprintf("/labels?%d", labelType), nil)
if err != nil {
return
}
var res LabelListRes
if err = c.DoJSON(req, &res); err != nil {
return
}
labels, err = res.Labels, res.Err()
return
}
type LabelReq struct {
*Label
}
type LabelRes struct {
Res
Label *Label
}
// CreateLabel creates a new label.
func (c *Client) CreateLabel(label *Label) (created *Label, err error) {
labelReq := &LabelReq{label}
req, err := NewJSONRequest("POST", "/labels", labelReq)
if err != nil {
return
}
var res LabelRes
if err = c.DoJSON(req, &res); err != nil {
return
}
created, err = res.Label, res.Err()
return
}
// UpdateLabel updates a label.
func (c *Client) UpdateLabel(label *Label) (updated *Label, err error) {
labelReq := &LabelReq{label}
req, err := NewJSONRequest("PUT", "/labels/"+label.ID, labelReq)
if err != nil {
return
}
var res LabelRes
if err = c.DoJSON(req, &res); err != nil {
return
}
updated, err = res.Label, res.Err()
return
}
// DeleteLabel deletes a label.
func (c *Client) DeleteLabel(id string) (err error) {
req, err := NewRequest("DELETE", "/labels/"+id, nil)
if err != nil {
return
}
var res Res
if err = c.DoJSON(req, &res); err != nil {
return
}
err = res.Err()
return
}

186
pkg/pmapi/labels_test.go Normal file
View File

@ -0,0 +1,186 @@
// Copyright (c) 2020 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 (
"bytes"
"encoding/json"
"fmt"
"net/http"
"reflect"
"testing"
)
const testLabelsBody = `{
"Labels": [
{
"ID": "LLz8ysmVxwr4dF6mWpClePT0SpSWOEvzTdq17RydSl4ndMckvY1K63HeXDzn03BJQwKYvgf-eWT8Qfd9WVuIEQ==",
"Name": "CroutonMail is awesome :)",
"Color": "#7272a7",
"Display": 0,
"Order": 1,
"Type": 1
},
{
"ID": "BvbqbySUPo9uWW_eR8tLA13NUsQMz3P4Zhw4UnpvrKqURnrHlE6L2Au0nplHfHlVXFgGz4L4hJ9-BYllOL-L5g==",
"Name": "Royal sausage",
"Color": "#cf5858",
"Display": 1,
"Order": 2,
"Type": 1
}
],
"Code": 1000
}
`
var testLabels = []*Label{
{ID: "LLz8ysmVxwr4dF6mWpClePT0SpSWOEvzTdq17RydSl4ndMckvY1K63HeXDzn03BJQwKYvgf-eWT8Qfd9WVuIEQ==", Name: "CroutonMail is awesome :)", Color: "#7272a7", Order: 1, Display: 0, Type: LabelTypeMailbox},
{ID: "BvbqbySUPo9uWW_eR8tLA13NUsQMz3P4Zhw4UnpvrKqURnrHlE6L2Au0nplHfHlVXFgGz4L4hJ9-BYllOL-L5g==", Name: "Royal sausage", Color: "#cf5858", Order: 2, Display: 1, Type: LabelTypeMailbox},
}
var testLabelReq = LabelReq{&Label{
Name: "sava",
Color: "#c26cc7",
Display: 1,
}}
const testCreateLabelBody = `{
"Label": {
"ID": "otkpEZzG--8dMXvwyLXLQWB72hhBhNGzINjH14rUDfywvOyeN01cDxDrS3Koifxf6asA7Xcwtldm0r_MCmWiAQ==",
"Name": "sava",
"Color": "#c26cc7",
"Display": 1,
"Order": 3,
"Type": 1
},
"Code": 1000
}
`
var testLabelCreated = &Label{
ID: "otkpEZzG--8dMXvwyLXLQWB72hhBhNGzINjH14rUDfywvOyeN01cDxDrS3Koifxf6asA7Xcwtldm0r_MCmWiAQ==",
Name: "sava",
Color: "#c26cc7",
Order: 3,
Display: 1,
Type: LabelTypeMailbox,
}
const testDeleteLabelBody = `{
"Code": 1000
}
`
func TestClient_ListLabels(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "GET", "/labels?1"))
fmt.Fprint(w, testLabelsBody)
}))
defer s.Close()
labels, err := c.ListLabels()
if err != nil {
t.Fatal("Expected no error while listing labels, got:", err)
}
if !reflect.DeepEqual(labels, testLabels) {
for i, l := range testLabels {
t.Errorf("expected %d: %#v\n", i, l)
}
for i, l := range labels {
t.Errorf("got %d: %#v\n", i, l)
}
t.Fatalf("Not same")
}
}
func TestClient_CreateLabel(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "POST", "/labels"))
body := &bytes.Buffer{}
_, err := body.ReadFrom(r.Body)
Ok(t, err)
if bytes.Contains(body.Bytes(), []byte("Order")) {
t.Fatal("Body contains `Order`: ", body.String())
}
var labelReq LabelReq
if err := json.NewDecoder(body).Decode(&labelReq); err != nil {
t.Error("Expecting no error while reading request body, got:", err)
}
if !reflect.DeepEqual(testLabelReq.Label, labelReq.Label) {
t.Errorf("Invalid label request: expected %+v but got %+v", testLabelReq.Label, labelReq.Label)
}
fmt.Fprint(w, testCreateLabelBody)
}))
defer s.Close()
created, err := c.CreateLabel(testLabelReq.Label)
if err != nil {
t.Fatal("Expected no error while creating label, got:", err)
}
if !reflect.DeepEqual(created, testLabelCreated) {
t.Fatalf("Invalid created label: expected %+v, got %+v", testLabelCreated, created)
}
}
func TestClient_UpdateLabel(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "PUT", "/labels/"+testLabelCreated.ID))
var labelReq LabelReq
if err := json.NewDecoder(r.Body).Decode(&labelReq); err != nil {
t.Error("Expecting no error while reading request body, got:", err)
}
if !reflect.DeepEqual(testLabelCreated, labelReq.Label) {
t.Errorf("Invalid label request: expected %+v but got %+v", testLabelCreated, labelReq.Label)
}
fmt.Fprint(w, testCreateLabelBody)
}))
defer s.Close()
updated, err := c.UpdateLabel(testLabelCreated)
if err != nil {
t.Fatal("Expected no error while updating label, got:", err)
}
if !reflect.DeepEqual(updated, testLabelCreated) {
t.Fatalf("Invalid updated label: expected %+v, got %+v", testLabelCreated, updated)
}
}
func TestClient_DeleteLabel(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "DELETE", "/labels/"+testLabelCreated.ID))
fmt.Fprint(w, testDeleteLabelBody)
}))
defer s.Close()
err := c.DeleteLabel(testLabelCreated.ID)
if err != nil {
t.Fatal("Expected no error while deleting label, got:", err)
}
}

810
pkg/pmapi/messages.go Normal file
View File

@ -0,0 +1,810 @@
// Copyright (c) 2020 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 (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/mail"
"net/url"
"strconv"
"strings"
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
"golang.org/x/crypto/openpgp/packet"
)
// Header types.
const (
MessageHeader = "-----BEGIN PGP MESSAGE-----"
MessageTail = "-----END PGP MESSAGE-----"
MessageHeaderLegacy = "---BEGIN ENCRYPTED MESSAGE---"
MessageTailLegacy = "---END ENCRYPTED MESSAGE---"
RandomKeyHeader = "---BEGIN ENCRYPTED RANDOM KEY---"
RandomKeyTail = "---END ENCRYPTED RANDOM KEY---"
)
// Sort types.
const (
SortByTo = "To"
SortByFrom = "From"
SortBySubject = "Subject"
SortBySize = "Size"
SortByTime = "Time"
SortByID = "ID"
SortDesc = true
SortAsc = false
)
// Message actions.
const (
ActionReply = 0
ActionReplyAll = 1
ActionForward = 2
)
// Message flag definitions.
const (
FlagReceived = 1
FlagSent = 2
FlagInternal = 4
FlagE2E = 8
FlagAuto = 16
FlagReplied = 32
FlagRepliedAll = 64
FlagForwarded = 128
FlagAutoreplied = 256
FlagImported = 512
FlagOpened = 1024
FlagReceiptSent = 2048
)
// Draft flags.
const (
FlagReceiptRequest = 1 << 16
FlagPublicKey = 1 << 17
FlagSign = 1 << 18
)
// Spam flags.
const (
FlagSpfFail = 1 << 24
FlagDkimFail = 1 << 25
FlagDmarcFail = 1 << 26
FlagHamManual = 1 << 27
FlagSpamAuto = 1 << 28
FlagSpamManual = 1 << 29
FlagPhishingAuto = 1 << 30
FlagPhishingManual = 1 << 31
)
// Message flag masks.
const (
FlagMaskGeneral = 4095
FlagMaskDraft = FlagReceiptRequest * 7
FlagMaskSpam = FlagSpfFail * 255
FlagMask = FlagMaskGeneral | FlagMaskDraft | FlagMaskSpam
)
// INTERNAL, AUTO are immutable. E2E is immutable except for drafts on send.
const (
FlagMaskAdd = 4067 + (16777216 * 168)
)
// Content types.
const (
ContentTypeMultipartMixed = "multipart/mixed"
ContentTypeMultipartEncrypted = "multipart/encrypted"
ContentTypePlainText = "text/plain"
ContentTypeHTML = "text/html"
)
// LabelsOperation is the operation to apply to labels.
type LabelsOperation int
const (
KeepLabels LabelsOperation = iota // Do nothing.
ReplaceLabels // Replace current labels with new ones.
AddLabels // Add new labels to current ones.
RemoveLabels // Remove specified labels from current ones.
)
const (
MessageTypeInbox int = iota
MessageTypeDraft
MessageTypeSent
MessageTypeInboxAndSent
)
// Due to API limitations, we shouldn't make requests with more than 100 message IDs at a time.
const messageIDPageSize = 100
// Message structure.
type Message struct {
ID string `json:",omitempty"`
Order int64 `json:",omitempty"`
ConversationID string `json:",omitempty"` // only filter
Subject string
Unread int
Type int
Flags int64
Sender *mail.Address
ReplyTo *mail.Address `json:",omitempty"`
ReplyTos []*mail.Address `json:",omitempty"`
ToList []*mail.Address
CCList []*mail.Address
BCCList []*mail.Address
Time int64 // Unix time
Size int64
NumAttachments int
ExpirationTime int64 // Unix time
SpamScore int
AddressID string
Body string `json:",omitempty"`
Attachments []*Attachment
LabelIDs []string
ExternalID string
Header mail.Header
MIMEType string
}
// NewMessage initializes a new message.
func NewMessage() *Message {
return &Message{
ToList: []*mail.Address{},
CCList: []*mail.Address{},
BCCList: []*mail.Address{},
Attachments: []*Attachment{},
LabelIDs: []string{},
}
}
// Define a new type to prevent MarshalJSON/UnmarshalJSON infinite loops.
type message Message
type rawMessage struct {
message
Header string `json:",omitempty"`
}
func (m *Message) MarshalJSON() ([]byte, error) {
var raw rawMessage
raw.message = message(*m)
b := &bytes.Buffer{}
_ = http.Header(m.Header).Write(b)
raw.Header = b.String()
return json.Marshal(&raw)
}
func (m *Message) UnmarshalJSON(b []byte) error {
var raw rawMessage
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
*m = Message(raw.message)
if raw.Header != "" && raw.Header != "(No Header)" {
msg, err := mail.ReadMessage(strings.NewReader(raw.Header + "\r\n\r\n"))
if err == nil {
m.Header = msg.Header
}
}
return nil
}
func (m *Message) IsBodyEncrypted() bool {
trimmedBody := strings.TrimSpace(m.Body)
return strings.HasPrefix(trimmedBody, MessageHeader) &&
strings.HasSuffix(trimmedBody, MessageTail)
}
func (m *Message) IsLegacyMessage() bool {
return strings.Contains(m.Body, RandomKeyHeader) &&
strings.Contains(m.Body, RandomKeyTail) &&
strings.Contains(m.Body, MessageHeaderLegacy) &&
strings.Contains(m.Body, MessageTailLegacy) &&
strings.Contains(m.Body, MessageHeader) &&
strings.Contains(m.Body, MessageTail)
}
func (m *Message) Decrypt(kr *pmcrypto.KeyRing) (err error) {
if m.IsLegacyMessage() {
return m.DecryptLegacy(kr)
}
if !m.IsBodyEncrypted() {
return
}
armored := strings.TrimSpace(m.Body)
body, err := decrypt(kr, armored)
if err != nil {
return
}
m.Body = body
return
}
func (m *Message) DecryptLegacy(kr *pmcrypto.KeyRing) (err error) {
randomKeyStart := strings.Index(m.Body, RandomKeyHeader) + len(RandomKeyHeader)
randomKeyEnd := strings.Index(m.Body, RandomKeyTail)
randomKey := m.Body[randomKeyStart:randomKeyEnd]
signedKey, err := decrypt(kr, strings.TrimSpace(randomKey))
if err != nil {
return
}
bytesKey, err := decodeBase64UTF8(signedKey)
if err != nil {
return
}
messageStart := strings.Index(m.Body, MessageHeaderLegacy) + len(MessageHeaderLegacy)
messageEnd := strings.Index(m.Body, MessageTailLegacy)
message := m.Body[messageStart:messageEnd]
bytesMessage, err := decodeBase64UTF8(message)
if err != nil {
return
}
block, err := aes.NewCipher(bytesKey)
if err != nil {
return
}
prefix := make([]byte, block.BlockSize()+2)
bytesMessageReader := bytes.NewReader(bytesMessage)
_, err = io.ReadFull(bytesMessageReader, prefix)
if err != nil {
return
}
s := packet.NewOCFBDecrypter(block, prefix, packet.OCFBResync)
if s == nil {
err = errors.New("pmapi: incorrect key for legacy decryption")
return
}
reader := cipher.StreamReader{S: s, R: bytesMessageReader}
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(reader)
plaintextBytes := buf.Bytes()
plaintext := ""
for i := 0; i < len(plaintextBytes); i++ {
plaintext += string(plaintextBytes[i])
}
bytesPlaintext, err := decodeBase64UTF8(plaintext)
if err != nil {
return
}
m.Body = string(bytesPlaintext)
return err
}
func decodeBase64UTF8(input string) (output []byte, err error) {
input = strings.TrimSpace(input)
decodedMessage, err := base64.StdEncoding.DecodeString(input)
if err != nil {
return
}
utf8DecodedMessage := []rune(string(decodedMessage))
output = make([]byte, len(utf8DecodedMessage))
for i := 0; i < len(utf8DecodedMessage); i++ {
output[i] = byte(int(utf8DecodedMessage[i]))
}
return
}
func (m *Message) Encrypt(encrypter, signer *pmcrypto.KeyRing) (err error) {
if m.IsBodyEncrypted() {
err = errors.New("pmapi: trying to encrypt an already encrypted message")
return
}
m.Body, err = encrypt(encrypter, m.Body, signer)
return
}
func (m *Message) Has(flag int64) bool {
return (m.Flags & flag) == flag
}
// MessagesCount contains message counts for one label.
type MessagesCount struct {
LabelID string
Total int
Unread int
}
// MessagesFilter contains fields to filter messages.
type MessagesFilter struct {
Page int
PageSize int
Limit int
LabelID string
Sort string // Time by default (Time, To, From, Subject, Size).
Desc *bool
Begin int64 // Unix time.
End int64 // Unix time.
BeginID string
EndID string
Keyword string
To string
From string
Subject string
ConversationID string
AddressID string
ID []string
Attachments *bool
Unread *bool
ExternalID string // MIME Message-Id (only valid for messages).
AutoWildcard *bool
}
func (filter *MessagesFilter) urlValues() url.Values { // nolint[funlen]
v := url.Values{}
if filter.Page != 0 {
v.Set("Page", strconv.Itoa(filter.Page))
}
if filter.PageSize != 0 {
v.Set("PageSize", strconv.Itoa(filter.PageSize))
}
if filter.Limit != 0 {
v.Set("Limit", strconv.Itoa(filter.Limit))
}
if filter.LabelID != "" {
v.Set("LabelID", filter.LabelID)
}
if filter.Sort != "" {
v.Set("Sort", filter.Sort)
}
if filter.Desc != nil {
if *filter.Desc {
v.Set("Desc", "1")
} else {
v.Set("Desc", "0")
}
}
if filter.Begin != 0 {
v.Set("Begin", strconv.Itoa(int(filter.Begin)))
}
if filter.End != 0 {
v.Set("End", strconv.Itoa(int(filter.End)))
}
if filter.BeginID != "" {
v.Set("BeginID", filter.BeginID)
}
if filter.EndID != "" {
v.Set("EndID", filter.EndID)
}
if filter.Keyword != "" {
v.Set("Keyword", filter.Keyword)
}
if filter.To != "" {
v.Set("To", filter.To)
}
if filter.From != "" {
v.Set("From", filter.From)
}
if filter.Subject != "" {
v.Set("Subject", filter.Subject)
}
if filter.ConversationID != "" {
v.Set("ConversationID", filter.ConversationID)
}
if filter.AddressID != "" {
v.Set("AddressID", filter.AddressID)
}
if len(filter.ID) > 0 {
for _, id := range filter.ID {
v.Add("ID[]", id)
}
}
if filter.Attachments != nil {
if *filter.Attachments {
v.Set("Attachments", "1")
} else {
v.Set("Attachments", "0")
}
}
if filter.Unread != nil {
if *filter.Unread {
v.Set("Unread", "1")
} else {
v.Set("Unread", "0")
}
}
if filter.ExternalID != "" {
v.Set("ExternalID", filter.ExternalID)
}
if filter.AutoWildcard != nil {
if *filter.AutoWildcard {
v.Set("AutoWildcard", "1")
} else {
v.Set("AutoWildcard", "0")
}
}
return v
}
type MessagesListRes struct {
Res
Total int
Messages []*Message
}
// ListMessages gets message metadata.
func (c *Client) ListMessages(filter *MessagesFilter) (msgs []*Message, total int, err error) {
req, err := NewRequest("GET", "/messages", nil)
if err != nil {
return
}
req.URL.RawQuery = filter.urlValues().Encode()
var res MessagesListRes
if err = c.DoJSON(req, &res); err != nil {
// If the URI was too long and we searched with IDs, we will try again without the API IDs.
if strings.Contains(err.Error(), "api returned: 414") && len(filter.ID) > 0 {
filter.ID = []string{}
return c.ListMessages(filter)
}
return
}
msgs, total, err = res.Messages, res.Total, res.Err()
return
}
type MessagesCountsRes struct {
Res
Counts []*MessagesCount
}
// CountMessages counts messages by label.
func (c *Client) CountMessages(addressID string) (counts []*MessagesCount, err error) {
reqURL := "/messages/count"
if addressID != "" {
reqURL += ("?AddressID=" + addressID)
}
req, err := NewRequest("GET", reqURL, nil)
if err != nil {
return
}
var res MessagesCountsRes
if err = c.DoJSON(req, &res); err != nil {
return
}
counts, err = res.Counts, res.Err()
return
}
type MessageRes struct {
Res
Message *Message
}
// GetMessage retrieves a message.
func (c *Client) GetMessage(id string) (msg *Message, err error) {
req, err := NewRequest("GET", "/messages/"+id, nil)
if err != nil {
return
}
var res MessageRes
if err = c.DoJSON(req, &res); err != nil {
return
}
msg, err = res.Message, res.Err()
return
}
type SendMessageReq struct {
ExpirationTime int64 `json:",omitempty"`
// AutoSaveContacts int `json:",omitempty"`
// Data for encrypted recipients.
Packages []*MessagePackage
}
// Message package types.
const (
InternalPackage = 1
EncryptedOutsidePackage = 2
ClearPackage = 4
PGPInlinePackage = 8
PGPMIMEPackage = 16
ClearMIMEPackage = 32
)
// Signature types.
const (
NoSignature = 0
YesSignature = 1
)
type MessagePackage struct {
Addresses map[string]*MessageAddress
Type int
MIMEType string
Body string // base64-encoded encrypted data packet.
BodyKey AlgoKey // base64-encoded session key (only if cleartext recipients).
AttachmentKeys map[string]AlgoKey // Only include if cleartext & attachments.
}
type MessageAddress struct {
Type int
BodyKeyPacket string // base64-encoded key packet.
Signature int // 0 = None, 1 = Detached, 2 = Attached/Armored
AttachmentKeyPackets map[string]string
}
type AlgoKey struct {
Key string
Algorithm string
}
type SendMessageRes struct {
Res
Sent *Message
// Parent is only present if the sent message has a parent (reply/reply all/forward).
Parent *Message
}
func (c *Client) SendMessage(id string, sendReq *SendMessageReq) (sent, parent *Message, err error) {
if id == "" {
err = errors.New("pmapi: cannot send message with an empty id")
return
}
if sendReq.Packages == nil {
sendReq.Packages = []*MessagePackage{}
}
req, err := NewJSONRequest("POST", "/messages/"+id, sendReq)
if err != nil {
return
}
var res SendMessageRes
if err = c.DoJSON(req, &res); err != nil {
return
}
sent, parent, err = res.Sent, res.Parent, res.Err()
return
}
const (
DraftActionReply = 0
DraftActionReplyAll = 1
DraftActionForward = 2
)
type DraftReq struct {
Message *Message
ParentID string `json:",omitempty"`
Action int
AttachmentKeyPackets []string
}
func (c *Client) CreateDraft(m *Message, parent string, action int) (created *Message, err error) {
createReq := &DraftReq{Message: m, ParentID: parent, Action: action, AttachmentKeyPackets: []string{}}
req, err := NewJSONRequest("POST", "/messages", createReq)
if err != nil {
return
}
var res MessageRes
if err = c.DoJSON(req, &res); err != nil {
return
}
created, err = res.Message, res.Err()
return
}
type MessagesActionReq struct {
IDs []string
}
type MessagesActionRes struct {
Res
Responses []struct {
ID string
Response Res
}
}
func (res MessagesActionRes) Err() error {
if err := res.Res.Err(); err != nil {
return err
}
for _, msgRes := range res.Responses {
if err := msgRes.Response.Err(); err != nil {
return err
}
}
return nil
}
// doMessagesAction performs paged requests to doMessagesActionInner.
// This can eventually be done in parallel though.
func (c *Client) doMessagesAction(action string, ids []string) (err error) {
for len(ids) > messageIDPageSize {
var requestIDs []string
requestIDs, ids = ids[:messageIDPageSize], ids[messageIDPageSize:]
if err = c.doMessagesActionInner(action, requestIDs); err != nil {
return
}
}
return c.doMessagesActionInner(action, ids)
}
// doMessagesActionInner is the non-paged inner method of doMessagesAction.
// You should not call this directly unless you know what you are doing (it can overload the server).
func (c *Client) doMessagesActionInner(action string, ids []string) (err error) {
actionReq := &MessagesActionReq{IDs: ids}
req, err := NewJSONRequest("PUT", "/messages/"+action, actionReq)
if err != nil {
return
}
var res MessagesActionRes
if err = c.DoJSON(req, &res); err != nil {
return
}
err = res.Err()
return
}
func (c *Client) MarkMessagesRead(ids []string) error {
return c.doMessagesAction("read", ids)
}
func (c *Client) MarkMessagesUnread(ids []string) error {
return c.doMessagesAction("unread", ids)
}
func (c *Client) DeleteMessages(ids []string) error {
return c.doMessagesAction("delete", ids)
}
func (c *Client) UndeleteMessages(ids []string) error {
return c.doMessagesAction("undelete", ids)
}
type LabelMessagesReq struct {
LabelID string
IDs []string
}
// LabelMessages labels the given message IDs with the given label.
// The requests are performed paged; this can eventually be done in parallel.
func (c *Client) LabelMessages(ids []string, label string) (err error) {
for len(ids) > messageIDPageSize {
var requestIDs []string
requestIDs, ids = ids[:messageIDPageSize], ids[messageIDPageSize:]
if err = c.labelMessages(requestIDs, label); err != nil {
return
}
}
return c.labelMessages(ids, label)
}
func (c *Client) labelMessages(ids []string, label string) (err error) {
labelReq := &LabelMessagesReq{LabelID: label, IDs: ids}
req, err := NewJSONRequest("PUT", "/messages/label", labelReq)
if err != nil {
return
}
var res MessagesActionRes
if err = c.DoJSON(req, &res); err != nil {
return
}
err = res.Err()
return
}
// UnlabelMessages removes the given label from the given message IDs.
// The requests are performed paged; this can eventually be done in parallel.
func (c *Client) UnlabelMessages(ids []string, label string) (err error) {
for len(ids) > messageIDPageSize {
var requestIDs []string
requestIDs, ids = ids[:messageIDPageSize], ids[messageIDPageSize:]
if err = c.unlabelMessages(requestIDs, label); err != nil {
return
}
}
return c.unlabelMessages(ids, label)
}
func (c *Client) unlabelMessages(ids []string, label string) (err error) {
labelReq := &LabelMessagesReq{LabelID: label, IDs: ids}
req, err := NewJSONRequest("PUT", "/messages/unlabel", labelReq)
if err != nil {
return
}
var res MessagesActionRes
if err = c.DoJSON(req, &res); err != nil {
return
}
err = res.Err()
return
}
func (c *Client) EmptyFolder(labelID, addressID string) (err error) {
if labelID == "" {
return errors.New("pmapi: labelID parameter is empty string")
}
reqURL := "/messages/empty?LabelID=" + labelID
if addressID != "" {
reqURL += ("&AddressID=" + addressID)
}
req, err := NewRequest("DELETE", reqURL, nil)
if err != nil {
return
}
var res Res
if err = c.DoJSON(req, &res); err != nil {
return
}
err = res.Err()
return
}

223
pkg/pmapi/messages_test.go Normal file
View File

@ -0,0 +1,223 @@
// Copyright (c) 2020 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 (
"fmt"
"net/http"
"strings"
"testing"
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
"github.com/stretchr/testify/assert"
)
const testMessageCleartext = `<div>jeej saas<br></div><div><br></div><div class="protonmail_signature_block"><div>Sent from <a href="https://protonmail.ch">ProtonMail</a>, encrypted email based in Switzerland.<br></div><div><br></div></div>`
const testMessageCleartextLegacy = `<div>flkasjfkjasdklfjasd<br></div><div>fasd<br></div><div>jfasjdfjasd<br></div><div>fj<br></div><div>asdfj<br></div><div>sadjf<br></div><div>sadjf<br></div><div>asjdf<br></div><div>jasd<br></div><div>fj<br></div><div>asdjf<br></div><div>asdjfsad<br></div><div>fasdlkfjasdjfkljsadfljsdfjsdljflkdsjfkljsdlkfjsdlk<br></div><div>jasfd<br></div><div>jsd<br></div><div>jf<br></div><div>sdjfjsdf<br></div><div><br></div><div>djfskjsladf<br></div><div>asd<br></div><div>fja<br></div><div>sdjfajsf<br></div><div>jas<br></div><div>fas<br></div><div>fj<br></div><div>afj<br></div><div>ajf<br></div><div>af<br></div><div>asdfasdfasd<br></div><div>Sent from <a href="https://protonmail.ch">ProtonMail</a>, encrypted email based in Switzerland.<br></div><div>dshfljsadfasdf<br></div><div>as<br></div><div>df<br></div><div>asd<br></div><div>fasd<br></div><div>f<br></div><div>asd<br></div><div>fasdflasdklfjsadlkjf</div><div>asd<br></div><div>fasdlkfjasdlkfjklasdjflkasjdflaslkfasdfjlasjflkasflksdjflkjasdf<br></div><div>asdflkasdjflajsfljaslkflasf<br></div><div>asdfkas<br></div><div>dfjas<br></div><div>djf<br></div><div>asjf<br></div><div>asj<br></div><div>faj<br></div><div>f<br></div><div>afj<br></div><div>sdjaf<br></div><div>jas<br></div><div>sdfj<br></div><div>ajf<br></div><div>aj<br></div><div>ajsdafafdaaf<br></div><div>a<br></div><div>f<br></div><div>lasl;ga<br></div><div>sags<br></div><div>ad<br></div><div>gags<br></div><div>g<br></div><div>ga<br></div><div>a<br></div><div>gg<br></div><div>a<br></div><div>ag<br></div><div>ag<br></div><div>agga.g.ga,ag.ag./ga<br></div><div><br></div><div>dsga<br></div><div>sg<br></div><div><br></div><div>gasga\g\g\g\g\g\n\y\t\r\\r\r\\n\n\n\<br></div><div><br></div><div><br></div><div>sd<br></div><div>asdf<br></div><div>asdf<br></div><div>dsa<br></div><div>fasd<br></div><div>f</div>`
const testMessageEncrypted = `-----BEGIN PGP MESSAGE-----
Version: OpenPGP.js v1.2.0
Comment: http://openpgpjs.org
wcBMA0fcZ7XLgmf2AQf+JPulpEOWwmY/Sfze8rBpYvrO2cebSSkjCgapFfXG
CI4PA+rb+WGkn9uBJf3FgEEg76c2ZqGh9zXTyrdHyFLm8ekarvxzgLpvcei/
p18IzcxsWnaM+1uknL4bKUtK3298gIl6xrfc4eVEA8tqUPUkSLSGk7uggjhj
zEYR4zIgMa0c6sMVcZ1Idvy9gGsTIvvcZJ4h1lKVUl8gba+qr1D76RaAf5xS
SBT74q9HhgfEMZwk6hXAp4MYY5h+lIsuhFu5kQ9fhZKU0PWS7ljddv854ZxS
9gHKPBerv4NBjkkCLp9xa2QNjDnu1fNlzlJpfCavp6wDdC83GiT61VRHPE4s
J9LASwFwgOrPmB8Mi867AQM0dddbj4Qe5ghlUcF1XnybkwfHqvQA1QT50d5n
ddFyxwIjvI/Nsn8MTCSnmrWCrjQ7v8JC73NyGxO5k6ZlUnc6BQVie78QJo5a
ftzl5b6nwlCYuXI8R6N/t5MXzrC5GwR8nvjH6kgbUVTLL1hO2Sbgyq5bBKLW
jjylTsZDHUGi4OX7q7eet5/RhKusWdvR0cHEaZAVD6BhTNN0mFBJ5bM1SINI
9gxJVqKJe7j4nJP4PGZBJrokZihhiBS/WEbJdvS54frYajGKjMavB3VhFP6k
qi5aiqGJKOJOV/G8yIwtdtxac3UL34eWo69U39Zx2mNfSXCzSjuafCr1nmAS
4g==
=Uw3B
-----END PGP MESSAGE-----
`
const testMessageEncryptedLegacy = `---BEGIN ENCRYPTED MESSAGE---esK5w7TCgVnDj8KQHBvDvhJObcOvw6/Cv2/CjMOpw5UES8KQwq/CiMOpI3MrexLDimzDmsKqVmwQw7vDkcKlRgXCosOpwoJgV8KEBCslSGbDtsOlw5gow7NxG8OSw6JNPlYuwrHCg8K5w6vDi8Kww5V5wo/Dl8KgwpnCi8Kww7nChMKdw5FHwoxmCGbCm8O6wpDDmRVEWsO7wqnCtVnDlMKORDbDnjbCqcOnNMKEwoPClFlaw6k1w5TDpcOGJsOUw5Unw5fCrcK3XnLCoRBBwo/DpsKAJiTDrUHDuGEQXz/DjMOhTCN7esO5ZjVIQSoFZMOyF8Kgw6nChcKmw6fCtcOBcW7Ck8KJwpTDnCzCnz3DjFY7wp5jUsOhw7XDosKQNsOUBmLDksKzPcO4fE/Dmw1GecKew4/CmcOJTFXDsB5uMcOFd1vDmX9ow4bDpCPDoU3Drw8oScKOXznDisKfYF3DvMKoEy0DDmzDhlHDjwIyC8OzRS/CnEZ4woM9w5cnw51fw6MZMAzDk8O3CDXDoyHDvzlFwqDCg8KsTnAiaMOsIyfCmUEaw6nChMK5TMOxG8KEHUNIwo1seMOXw5HDhyVawrzCr8KmFWHDpMO3asKpwrQbbMOlwoMew4t1Jz51wp9Jw6kGWcOzc8KgwpLCpsOHOMOgYB3DiMOxLcOQB8K7AcOyWF3CmnwfK8Kxw6XDm2TCiT/CnVTCg8Omw7Ngwp3CuUAHw6/CjRLDgcKsU8O/w6gXJ0cIw6pZMcOxEWETwpd4w58Mwr5SBMKORQjCi3FYcULDgx09w5M7SH7DrMKrw4gnXMKjwqUrBMOLwqQyF0nDhcKuwqTDqsO2w7LCnGjCvkbDgDgcw54xAkEiQMKUFlzDkMOew73CmkU4wrnCjw3DvsKaW8K0InA+w4sPSXfDuhbClMKgUcKeCMORw5ZYJcKnNEzDoMOhw7MYCX4DwqIQwoHCvsOaB1UAI8KVw6LCvcOTw53CuSgow4kZdHw5aRkYw7ZyV8OsP0LCh8KnwpIuw4p1NisoEcKcwrjDhcOtMzdvw5rDmsK3IAdAw7M4J8K+w6zCmR3CuMKUw4lqw6osPMObw53Dg8K3wqLCrsKZwr8mPcK4w4QWw5LCnwZeH1bDgwwiXcKbUhHDk1DDk0MLwoDDqMKXw5skNsKAAcOFw77Di8KNGCBzP8OcwrI5wodQQwQyw5V0wrInwrPDt8O+T8KbNsKVw7Mzw7HCsMOjwpcewoPCuMOUEsOow6QZVDjDpgbDlMOBGDXCtMOmw6jDuMKfw4nDlWTDq8Kqd0TDvwPCpSzDlA4JO3EHwrlBWcK5w7DCscOwCMK2wpsvwrYNIcOgBBXChMK0w6nCosKWEVd+w7cEal5hIcO4SWrCu0TDrW5Yw4XCmBgCwpc7YVwIwqPCi8OlGDzDmyJ/woHCscOtw4zDuC7CpUXCrDAJwp7Cj8KxPX3CrhDCvVB2w7PCosKbw7F+V11hY8Omwq1eQcO8w4wcRMKBJ2LDgW/DomXDhwkgAlxmQcKew6HDq8Ouw6ASeG/DlcKgUcKmLMOowpQWNcKJJcKDa3XDksK/woHCo3d6wrHDpMOqwqs/UUXCjUpnwrHCmsOyJx4bwoHChAnDi0TCpjLDrBvCvEghw5VtfhPCk8K5KsKIw75FCsOyDsKtV17CicOjwqAnF8OHHC0qMsOEwrgEwr13c8KZw4fDn8KXw73CksKAw4QTGRgIG8KMMXwpwrRBT2DDq8K3AsOQXl/DqMKYMivClsKiXcOhGkvDmsK9w77Cmmpvwrhsd8Kaw7bDgQ/DuCU2CyTDtjnCgn/DiMOtSyPDnsOfVTstccO6EVXDrj03MUHDvDDCgsO7BFQFEX3DszIyw7Rsw7pNwpjCs8OCLR9UbsOlw5USw73DiWJqVXTCl2tFw7FaAcKaw7l5a3Mvw5TCpMKCwpbDi3fCi8KHwrfDugUZwo5hw7fChsKDw5ZhPjA7w7HDjcO9wrrCjUbDoy4JXA1JICRDw49UNsOYOsK9FGE5wqhAw67DumnDqW0cwqbCu8OedEbDqcOfw50MVH8twpVLH8O3LsKvacKJw75xTMKkOcOJw4/DvsOYwqRwZcOnwqfCm2XCnRJFwqEgX8KLPsKfwpQWw6nChm82w6hME10KTRhGw5LCj1stPiXClsO8w7rCocOLw6lFw7tAZ8K0O3wswpZ4wqvCmMOFwpzDhMKVRRQjw53CikECPMOKZcOOwoAKcMK7WMO3K8Okw4bCjgrCisKLRsKewqzDvmtnw584wrtiw6RFVsKPecOpIhx7TsKzw4TCisKyw6nCqcK+w6fChsKxw5kWSsOgfD7CkRfCncKGKMOubsKoBA9Fe2YHwrx4aQNSG8Kpw5zDrMO1FMOPZcKSIVnDrHxOBsKyBcKmYwQMOl7CiRvCnDNVw7NaesOoPR3CrnQEwr9Xw600BSFYECnDgi1OFS7DoFYJw4M6wrzCog09WFPCmiHDogjDpQFjdsKKIsOWFsKXd0TDjXU3CsONRX3DssOrw4HDmX0Mw7rDiENvwpPCghsXacK2w6XCkMOICcKVw4nCkMO8RcOUw4zCn1VJw752RAUawqhdw5dEwqbDh0wAMH/DlTrChC/DosOoGsOPw5nClTcyw5XDlsKhNsKAcBINwpxUAi8Rw5Jvwpckwq4uBy0nw51dP2UGbidATX1FLMKFw5zDsQxewp3DlMKwwo3CrhBPJGR7cVHCnTUnwrDDksO0AcO5T3jCm245OnUVUT8WD1HDhTnCqnbCt8OjMDvCsAzCjsKSwoDDlDhtw7cFwpsDaS7CvVLDu0zDnlvDlMOEwrnCgVzCgcOZN8Oxwp0LSMKswq/DrMK9fcKTL1zDgcOvwofCtWAoL0IKR8OWwqpPw6QfVsKcwqxTXGEPKCFydX4Mw5jDmcOEWlPCgMKDPcOJw7HDgcOMahzCjMO7HyPDo8K3Y8OswqPDgSQ+w6wfw67Cr8O/w61oMsO+woTDrnECI2TDuMK5wrzDusOHw5/CosKFwrciQF3Csj5aw7DDpMKwZMK3Z8KlRBIcLcKvM2/CtBk8JMKWwqVyw6RNwoUhwoDCsXbCrD04wpQ4F8KOcMKIw7PDtMKqZRTCjsKSOMOKCMKYQ8OhwqZ1dGrChcKXLSnDiT7CrEjCihckNcOXw63CkUYpT8KTwq7CgMKiw7PCqmBzwq/Crz50XcKEGlLCrUBjw6ASVsObD8K9wpZ6eBHCi2FTMVcDSzvDgwtxw5ZJHlF5woDDtsKTwovChMOyYMKOSCt7w7hGDDsFaMOewrrCjRbDrGPDg2rCpsO3wo8IEMO9wqjCrG0mRXHDocKJwqQYdsKOw7UUwqIUwq/CqUlKW8ObwpcZGizCpgd4dAZBXMOYw5s5w6HDvkEgw6sbRxAwwoBSOyXCjDPDpsKlwrPCrl/DqsOswoJJDWzDp8Ocw5nDrE5FWm3DncKVwpnCqMKiwoDDmMONQcOEwpwRwonCsh0Tw7FCw6Nfw7U7wp7DnMKnfMOHCMOnw4TClcOVwrzCiiddUj3CmsOgwqvDhxfDjsOMWcKDZnvDocObw77Do1rDgMKHVsKCLcOXRMOHD0RNwpEdwozCrBnDqBYWwojCiVzCjTTCqcO5wqgAwqhhw7tnw5ZuOcOYNGTDiR1GAEzDuE0PeErDnlQlfsOjw6UGWUUNw6TCmgx8NMKzDMKgL8O3esKDwprDoTl8wrbDvVDCvU4Iw5sAwr/DugcoR8KMw4hNeMKSw7Jmw4rDjG8NbcO8w7jCs8OvfFXCoBBNfcOqNsK0EQLCncKPw53DrsOiwolvwqjCr8OZDsORw47DiyA+VcOMSg5wworDgGx0w7sgKMOyDMOyZRkgw43CqUHDicKfwpDCo8OII8KvKsOxDcKoFsOaw7HCgXTDssK7B8KIwoNcw4zCu8KBw4vCvFjDkWLDl8OyB8O/w4oYw5DCslzDk2kDw7jDgcOJw4jComXDkwdfw61xw53Cv8KPf11iwq0kKsKDw7nCmiVNF0NqLMKvwqvDjhQ3ZXbDomvDs8OKQQ7CocOnwr1Fw7xZRMK6w41cw5DDgzzCthIoAMOBQcOPbcOPVx/Cm8OYw7pHwo/CvCxhCcKVw7vChShnw6rClUQ7w6dbZMOrw4hpw7lZXMOxw5pnUXHDiMOLDxrDiA/DtMKqw6zDjXRJwp07BsKEwoTClBHCritDYXgzT3RWDcOlw4lfw4Vbw7fCj8K0w4AnwqjCrxPDpCVXF8KbY8OMPwQvwqdaw6E8w4AHPcKbNGl8wpQMX2PDp0pJfcOyGsOUXkNww5jCg8Obwo7DryjCisKeYiQ/XUzDvRvDncOtCMKJwqxHw6LDh8KwwrV7LGPCkcKOIXbCv8KHwpnDi1keQkLDssOSw7XCk8K+w7YdSMKAQmbDo8KPw7xywpnCsgANNTJYScKkNAvDo8KZw6Ayw6tmC8KaTsKEbcOZTx3DilrDtUjDi8OWV8K/wrocwpNKLlYbbcOmPcKPwrvCsTpLey5Xw58XJBPCo8KEPWJrwqZJX1fCncKDw4AZw4hWw5pTw7pidlzDtMO6w7t9DcK+R8KefMOfETvCskgjOgHCqcK7UgHCgsOfwrt8bcKQw5FeZcOiw4Faw7hRTjDDocOuEMOoEm04NQTCrCjDvMOaNDV6V8OHc8OTdMOndCh7HMOqw7HDnlzCl3MqwpjDiiDDtcKmCknCuBcQwobDvcOUN2LDmsOeHMOmPMKeH0nCt0nDgsO8w73CkRDDmMOuacO9w5J1KsKswqY7UMKyHHzDjMOjw5QOSWUhw4jCpMKJw4DCtcKNdcKPLcOFJsOqQ14=---END ENCRYPTED MESSAGE---||---BEGIN ENCRYPTED RANDOM KEY--------BEGIN PGP MESSAGE-----
Version: OpenPGP.js v0.9.0
Comment: http://openpgpjs.org
wcBMA2tjJVxNCRhtAQf/YzkQoUqaqa3NJ/c1apIF/dsl7yJ4GdVrC3/w7lxE
2CO5ioQD4s6QMWP2Y9dOdVl2INwz8eXOds9NS+1nMs4SoMbrpJnAjx8Cthti
1Z/8eWMU023LYahds8BYM0T435K/2tTB5GTA4uTl2y8Xzz2PbptQ4PrUDaII
+egeQQyPA0yuoRDwpaeTiaBYOSa06YYuK5Agr0buQAxRIMCxI2o+fucjoabv
FsQHKGu20U5GlJroSIyIVVkaH3evhNti/AnYX1HuokcGEQNsF5vo4SjWcH23
2P86EIV+w5lUWC1FN9vZCyvbvyuqLHQMtqKVn4GBOkIc3bYQ0jru3a0FG4Cx
bNJ0ASps2+p3Vxe0d+so2iFV92ByQ+0skyCUwCNUlwOV5V5f2fy1ImXk4mXI
cO/bcbqRxx3pG9gkPIh43FoQktTT+tsJ5vS53qfaLGdhCYfkrWjsKu+2P9Xg
+Cr8clh6NTblhfkoAS1gzjA3XgsgEFrtP+OGqwg=
=c5WU
-----END PGP MESSAGE-----
---END ENCRYPTED RANDOM KEY---
`
const testMessageSigned = `-----BEGIN PGP MESSAGE-----
Version: OpenPGP.js v4.5.3
Comment: https://openpgpjs.org
wcBMA0fcZ7XLgmf2AQgAgnHOlcAwVu2AnVfi2fIQHSkTQ0OFnZMJMRR3MJ1q
HtUW8jkSLcurL0Sn/tBFLIIR4YT2tQMzV7cvZzZyBEuZM4OYnDp8xSmoszPh
Gc/nvYG0A0pmKAQkL27v05Dul8oUWA0APT51urghH2Pzm7NdOMtTKIE4LQjS
mBfQ6Cf14uKV0xGS9v2dSFjFxxXEEpMQ+k60NCKRYClN2LVVxf3OKXbuugds
m2GUGn3CuFsiabosIUv4EcdE3aD9HbNo+PIWLJWRJIYJSc5+FWcbwXuIIFgC
XX1s7OV53ceZJnhjCmDE0N2ZOLLAYWED2zRvUa+CAqG+hZgc/3Ia+UmJUVuZ
BNLAugFuRsOVgh3olUIz0vazHhyGG0XIsNqmRm0U9SIfhWkPPHBmU6Xht6Qw
EvLbBfKTYHxX01yQUNgIv4S/TULeQuUjZQfsNYNXXGepS+jiCoIdEgUwpvre
OMFGsypwQXVCFYO/GQdYanMQRTckEexyBY4hGYVrevDM1yG/zGJIdbfI2L+1
1cz76jI8PtzL+S0zcVkevLcjjsHm2Je959uSida9jara7Bymr0y56UdoXoWX
4vZ0kQNo58eEEV0zg7dit4lDvwcuSZMW6K//xNtRQ4QX7/EDtlcYqBJXPwJY
eQSBVeYbeUbZ+PHJdu5gbI85BJNE2dKcS1bdOhEU2lPLYpvmMpPdot9TwnJb
dN3l8yDyhScGvTIZqlxhU7HCM9VHAS0bDqCUoO8EruztUSgjMI+gKC9+xdVU
yrkF7K23UNLWflROMv4cp0LDRB57619Y2w5lY/MG5bS0jSfMWBwnJG2AF28c
2tYKnHw6rpZXvXnlDmEDT8suTzuTGA==
=Sir8
-----END PGP MESSAGE-----
`
const testMessageSigner = `-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: OpenPGP.js v0.7.1
Comment: http://openpgpjs.org
xsBNBFSI0BMBB/9td6B5RDzVSFTlFzYOS4JxIb5agtNW1rbA4FeLoC47bGLR
8E42IA6aKcO4H0vOZ1lFms0URiKk1DjCMXn3AUErbxqiV5IATRZLwliH6vwy
PI6j5rtGF8dyxYfwmLtoUNkDcPdcFEb4NCdowsN7e8tKU0bcpouZcQhAqawC
9nEdaG/gS5w+2k4hZX2lOKS1EF5SvP48UadlspEK2PLAIp5wB9XsFS9ey2wu
elzkSfDh7KUAlteqFGSMqIgYH62/gaKm+TcckfZeyiMHWFw6sfrcFQ3QOZPq
ahWt0Rn9XM5xBAxx5vW0oceuQ1vpvdfFlM5ix4gn/9w6MhmStaCee8/fABEB
AAHNBlVzZXJJRMLAcgQQAQgAJgUCVIjQHQYLCQgHAwIJEASDR1Fk7GNTBBUI
AgoDFgIBAhsDAh4BAADmhAf/Yt0mCfWqQ25NNGUN14pKKgnPm68zwj1SmMGa
pU7+7ItRpoFNaDwV5QYiQSLC1SvSb1ZeKoY928GPKfqYyJlBpTPL9zC1OHQj
9+2yYauHjYW9JWQM7hst2S2LBcdiQPOs3ybWPaO9yaccV4thxKOCPvyClaS5
b9T4Iv9GEVZQIUvArkwI8hyzIi6skRgxflGheq1O+S1W4Gzt2VtYvo8g8r6W
GzAGMw2nrs2h0+vUr+dLDgIbFCTc5QU99d5jE/e5Hw8iqBxv9tqB1hVATf8T
wC8aU5MTtxtabOiBgG0PsBs6oIwjFqEjpOIza2/AflPZfo7stp6IiwbwvTHo
1NlHoM7ATQRUiNAdAQf/eOLJYxX4lUQUzrNQgASDNE8gJPj7ywcGzySyqr0Y
5rbG57EjtKMIgZrpzJRpSCuRbBjfsltqJ5Q9TBAbPO+oR3rue0LqPKMnmr/q
KsHswBJRfsb/dbktUNmv/f7R9IVyOuvyP6RgdGeloxdGNeWiZSA6AZYI+WGc
xaOvVDPz8thtnML4G4MUhXxxNZ7JzQ0Lfz6mN8CCkblIP5xpcJsyRU7lUsGD
EJGZX0JH/I8bRVN1Xu08uFinIkZyiXRJ5ZGgF3Dns6VbIWmbttY54tBELtk+
5g9pNSl9qiYwiCdwuZrA//NmD3xlZIN8sG4eM7ZUibZ23vEq+bUt1++6Mpba
GQARAQABwsBfBBgBCAATBQJUiNAfCRAEg0dRZOxjUwIbDAAAlpMH/085qZdO
mGRAlbvViUNhF2rtHvCletC48WHGO1ueSh9VTxalkP21YAYLJ4JgJzArJ7tH
lEeiKiHm8YU9KhLe11Yv/o3AiKIAQjJiQluvk+mWdMcddB4fBjL6ttMTRAXe
gHnjtMoamHbSZdeUTUadv05Fl6ivWtpXlODG4V02YvDiGBUbDosdGXEqDtpT
g6MYlj3QMvUiUNQvt7YGMJS8A9iQ9qBNzErgRW8L6CON2RmpQ/wgwP5nwUHz
JjY51d82Vj8bZeI8LdsX41SPoUhyC7kmNYpw9ZRy7NlrCt8dBIOB4/BKEJ2G
ClW54lp9eeOfYTsdTSbn9VaSO0E6m2/Q4Tk=
=WFtr
-----END PGP PUBLIC KEY BLOCK-----`
func TestMessage_IsBodyEncrypted(t *testing.T) {
msg := &Message{Body: testMessageEncrypted}
Assert(t, msg.IsBodyEncrypted(), "the body should be encrypted")
msg.Body = testMessageCleartext
Assert(t, !msg.IsBodyEncrypted(), "the body should not be encrypted")
}
func TestMessage_Decrypt(t *testing.T) {
msg := &Message{Body: testMessageEncrypted}
err := msg.Decrypt(testPrivateKeyRing)
Ok(t, err)
Equals(t, testMessageCleartext, msg.Body)
}
func TestMessage_Decrypt_Legacy(t *testing.T) {
testPrivateKeyLegacy := readTestFile("testPrivateKeyLegacy", false)
testPrivateKeyRingLegacy, err := pmcrypto.ReadArmoredKeyRing(strings.NewReader(testPrivateKeyLegacy))
Ok(t, err)
Ok(t, testPrivateKeyRingLegacy.Unlock([]byte(testMailboxPasswordLegacy)))
msg := &Message{Body: testMessageEncryptedLegacy}
err = msg.Decrypt(testPrivateKeyRingLegacy)
Ok(t, err)
Equals(t, testMessageCleartextLegacy, msg.Body)
}
func TestMessage_Decrypt_signed(t *testing.T) {
msg := &Message{Body: testMessageSigned}
err := msg.Decrypt(testPrivateKeyRing)
Ok(t, err)
Equals(t, testMessageCleartext, msg.Body)
}
func TestMessage_Encrypt(t *testing.T) {
signer, err := pmcrypto.ReadArmoredKeyRing(strings.NewReader(testMessageSigner))
Ok(t, err)
msg := &Message{Body: testMessageCleartext}
Ok(t, msg.Encrypt(testPrivateKeyRing, testPrivateKeyRing))
err = msg.Decrypt(testPrivateKeyRing)
Ok(t, err)
Equals(t, testMessageCleartext, msg.Body)
Equals(t, testIdentity, signer.Identities()[0])
}
func routeLabelMessages(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
Ok(tb, checkMethodAndPath(r, "PUT", "/messages/label"))
return "messages/label/put_response.json"
}
func TestMessage_LabelMessages_NoPaging(t *testing.T) {
// This should be only enough IDs to produce one page.
testIDs := []string{}
for i := 0; i < messageIDPageSize-1; i++ {
testIDs = append(testIDs, fmt.Sprintf("%v", i))
}
// There should be enough IDs to produce just one page so the endpoint should be called once.
finish, c := newTestServerCallbacks(t,
routeLabelMessages,
)
defer finish()
c.uid = testUID
c.accessToken = testAccessToken
assert.NoError(t, c.LabelMessages(testIDs, "mylabel"))
}
func TestMessage_LabelMessages_Paging(t *testing.T) {
// This should be enough IDs to produce three pages.
testIDs := []string{}
for i := 0; i < 3*messageIDPageSize; i++ {
testIDs = append(testIDs, fmt.Sprintf("%v", i))
}
// There should be enough IDs to produce three pages so the endpoint should be called three times.
finish, c := newTestServerCallbacks(t,
routeLabelMessages,
routeLabelMessages,
routeLabelMessages,
)
defer finish()
c.uid = testUID
c.accessToken = testAccessToken
assert.NoError(t, c.LabelMessages(testIDs, "mylabel"))
}

43
pkg/pmapi/metrics.go Normal file
View File

@ -0,0 +1,43 @@
// Copyright (c) 2020 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 (
"net/url"
)
// SendSimpleMetric makes a simple GET request to send a simple metrics report.
func (c *Client) SendSimpleMetric(category, action, label string) (err error) {
v := url.Values{}
v.Set("Category", category)
v.Set("Action", action)
v.Set("Label", label)
req, err := NewRequest("GET", "/metrics?"+v.Encode(), nil)
if err != nil {
return
}
var res Res
if err = c.DoJSON(req, &res); err != nil {
return
}
err = res.Err()
return
}

43
pkg/pmapi/metrics_test.go Normal file
View File

@ -0,0 +1,43 @@
// Copyright (c) 2020 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 (
"fmt"
"net/http"
"testing"
)
const testSendSimpleMetricsBody = `{
"Code": 1000
}
`
func TestClient_SendSimpleMetric(t *testing.T) {
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "GET", "/metrics?Action=some_action&Category=some_category&Label=some_label"))
fmt.Fprint(w, testSendSimpleMetricsBody)
}))
defer s.Close()
err := c.SendSimpleMetric("some_category", "some_action", "some_label")
if err != nil {
t.Fatal("Expected no error while sending simple metric, got:", err)
}
}

47
pkg/pmapi/passwords.go Normal file
View File

@ -0,0 +1,47 @@
// Copyright (c) 2020 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 (
"encoding/base64"
"errors"
"github.com/jameskeane/bcrypt"
)
func HashMailboxPassword(password, keySalt string) (hashedPassword string, err error) {
if keySalt == "" {
hashedPassword = password
return
}
decodedSalt, err := base64.StdEncoding.DecodeString(keySalt)
if err != nil {
return
}
encodedSalt := base64.NewEncoding("./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").WithPadding(base64.NoPadding).EncodeToString(decodedSalt)
hashResult, err := bcrypt.Hash(password, "$2y$10$"+encodedSalt)
if err != nil {
return
}
if len(hashResult) != 60 {
err = errors.New("pmapi: invalid mailbox password hash")
return
}
hashedPassword = hashResult[len(hashResult)-31:]
return
}

62
pkg/pmapi/pmapi_test.go Normal file
View File

@ -0,0 +1,62 @@
// Copyright (c) 2020 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 (
"io/ioutil"
"strings"
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
)
const testMailboxPassword = "apple"
const testMailboxPasswordLegacy = "123"
var (
testPrivateKeyRing *pmcrypto.KeyRing
testPublicKeyRing *pmcrypto.KeyRing
)
func init() {
testPrivateKey := readTestFile("testPrivateKey", false)
testPublicKey := readTestFile("testPublicKey", false)
var err error
if testPrivateKeyRing, err = pmcrypto.ReadArmoredKeyRing(strings.NewReader(testPrivateKey)); err != nil {
panic(err)
}
if testPublicKeyRing, err = pmcrypto.ReadArmoredKeyRing(strings.NewReader(testPublicKey)); err != nil {
panic(err)
}
if err := testPrivateKeyRing.Unlock([]byte(testMailboxPassword)); err != nil {
panic(err)
}
}
func readTestFile(name string, trimNewlines bool) string { // nolint[unparam]
data, err := ioutil.ReadFile("testdata/" + name)
if err != nil {
panic(err)
}
if trimNewlines {
return strings.TrimRight(string(data), "\n")
}
return string(data)
}

304
pkg/pmapi/proxy.go Normal file
View File

@ -0,0 +1,304 @@
// Copyright (c) 2020 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 (
"crypto/tls"
"encoding/base64"
"strings"
"sync"
"time"
"github.com/go-resty/resty/v2"
"github.com/miekg/dns"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
const (
proxyRevertTime = 24 * time.Hour
proxySearchTimeout = 30 * time.Second
proxyQueryTimeout = 10 * time.Second
proxyLookupWait = 5 * time.Second
proxyQuery = "dMFYGSLTQOJXXI33ONVQWS3BOMNUA.protonpro.xyz"
)
var dohProviders = []string{ //nolint[gochecknoglobals]
"https://dns11.quad9.net/dns-query",
"https://dns.google/dns-query",
}
// globalAllowDoH controls whether or not to enable use of DoH/Proxy in pmapi.
var globalAllowDoH = false // nolint[golint]
// globalProxyMutex allows threadsafe modification of proxy state.
var globalProxyMutex = sync.RWMutex{} // nolint[golint]
// globalOriginalURL backs up the original API url so it can be restored later.
var globalOriginalURL = RootURL // nolint[golint]
// globalIsDoHAllowed returns whether or not to use DoH.
func globalIsDoHAllowed() bool { // nolint[golint]
globalProxyMutex.RLock()
defer globalProxyMutex.RUnlock()
return globalAllowDoH
}
// GlobalAllowDoH enables DoH.
func GlobalAllowDoH() { // nolint[golint]
globalProxyMutex.Lock()
defer globalProxyMutex.Unlock()
globalAllowDoH = true
}
// GlobalDisallowDoH disables DoH and sets the RootURL back to what it was.
func GlobalDisallowDoH() { // nolint[golint]
globalProxyMutex.Lock()
defer globalProxyMutex.Unlock()
globalAllowDoH = false
RootURL = globalOriginalURL
}
// globalSetRootURL sets the global RootURL.
func globalSetRootURL(url string) { // nolint[golint]
globalProxyMutex.Lock()
defer globalProxyMutex.Unlock()
RootURL = url
}
// GlobalGetRootURL returns the global RootURL.
func GlobalGetRootURL() (url string) { // nolint[golint]
globalProxyMutex.RLock()
defer globalProxyMutex.RUnlock()
return RootURL
}
// isProxyEnabled returns whether or not we are currently using a proxy.
func isProxyEnabled() bool { // nolint[golint]
return globalOriginalURL != GlobalGetRootURL()
}
// proxyManager manages known proxies.
type proxyManager struct {
// dohLookup is used to look up the given query at the given DoH provider, returning the TXT records>
dohLookup func(query, provider string) (urls []string, err error)
providers []string // List of known doh providers.
query string // The query string used to find proxies.
proxyCache []string // All known proxies, cached in case DoH providers are unreachable.
useDuration time.Duration // How much time to use the proxy before returning to the original API.
findTimeout, lookupTimeout time.Duration // Timeouts for DNS query and proxy search.
lastLookup time.Time // The time at which we last attempted to find a proxy.
}
// newProxyManager creates a new proxyManager that queries the given DoH providers
// to retrieve DNS records for the given query string.
func newProxyManager(providers []string, query string) (p *proxyManager) { // nolint[unparam]
p = &proxyManager{
providers: providers,
query: query,
useDuration: proxyRevertTime,
findTimeout: proxySearchTimeout,
lookupTimeout: proxyQueryTimeout,
}
// Use the default DNS lookup method; this can be overridden if necessary.
p.dohLookup = p.defaultDoHLookup
return
}
// findProxy returns a new proxy domain which is not equal to the current RootURL.
// It returns an error if the process takes longer than ProxySearchTime.
func (p *proxyManager) findProxy() (proxy string, err error) {
if time.Now().Before(p.lastLookup.Add(proxyLookupWait)) {
return "", errors.New("not looking for a proxy, too soon")
}
p.lastLookup = time.Now()
proxyResult := make(chan string)
errResult := make(chan error)
go func() {
if err = p.refreshProxyCache(); err != nil {
logrus.WithError(err).Warn("Failed to refresh proxy cache, cache may be out of date")
}
for _, proxy := range p.proxyCache {
if proxy != stripProtocol(GlobalGetRootURL()) && p.canReach(proxy) {
proxyResult <- proxy
return
}
}
errResult <- errors.New("no proxy available")
}()
select {
case <-time.After(p.findTimeout):
logrus.Error("Timed out finding a proxy server")
return "", errors.New("timed out finding a proxy")
case proxy = <-proxyResult:
logrus.WithField("proxy", proxy).Info("Found proxy server")
return
case err = <-errResult:
logrus.WithError(err).Error("Failed to find available proxy server")
return
}
}
// useProxy sets the proxy server to use. It returns to the original RootURL after 24 hours.
func (p *proxyManager) useProxy(proxy string) {
if !isProxyEnabled() {
p.disableProxyAfter(p.useDuration)
}
globalSetRootURL(https(proxy))
}
// disableProxyAfter disables the proxy after the given amount of time.
func (p *proxyManager) disableProxyAfter(d time.Duration) {
go func() {
<-time.After(d)
globalSetRootURL(globalOriginalURL)
}()
}
// refreshProxyCache loads the latest proxies from the known providers.
func (p *proxyManager) refreshProxyCache() error {
logrus.Info("Refreshing proxy cache")
for _, provider := range p.providers {
if proxies, err := p.dohLookup(p.query, provider); err == nil {
p.proxyCache = proxies
// We also want to allow bridge to switch back to the standard API at any time.
p.proxyCache = append(p.proxyCache, globalOriginalURL)
logrus.WithField("proxies", proxies).Info("Available proxies")
return nil
}
}
return errors.New("lookup failed with all DoH providers")
}
// canReach returns whether we can reach the given url.
// NOTE: we skip cert verification to stop it complaining that cert name doesn't match hostname.
func (p *proxyManager) canReach(url string) bool {
pinger := resty.New().
SetHostURL(https(url)).
SetTimeout(p.lookupTimeout).
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}) // nolint[gosec]
if _, err := pinger.R().Get("/tests/ping"); err != nil {
return false
}
return true
}
// defaultDoHLookup is the default implementation of the proxy manager's DoH lookup.
// It looks up DNS TXT records for the given query URL using the given DoH provider.
// It returns a list of all found TXT records.
// If the whole process takes more than ProxyQueryTime then an error is returned.
func (p *proxyManager) defaultDoHLookup(query, dohProvider string) (data []string, err error) {
dataResult := make(chan []string)
errResult := make(chan error)
go func() {
// Build new DNS request in RFC1035 format.
dnsRequest := new(dns.Msg).SetQuestion(dns.Fqdn(query), dns.TypeTXT)
// Pack the DNS request message into wire format.
rawRequest, err := dnsRequest.Pack()
if err != nil {
errResult <- errors.Wrap(err, "failed to pack DNS request")
return
}
// Encode wire-format DNS request message as base64url (RFC4648) without padding chars.
encodedRequest := base64.RawURLEncoding.EncodeToString(rawRequest)
// Make DoH request to the given DoH provider.
rawResponse, err := resty.New().R().SetQueryParam("dns", encodedRequest).Get(dohProvider)
if err != nil {
errResult <- errors.Wrap(err, "failed to make DoH request")
return
}
// Unpack the DNS response.
dnsResponse := new(dns.Msg)
if err = dnsResponse.Unpack(rawResponse.Body()); err != nil {
errResult <- errors.Wrap(err, "failed to unpack DNS response")
return
}
// Pick out the TXT answers.
for _, answer := range dnsResponse.Answer {
if t, ok := answer.(*dns.TXT); ok {
data = append(data, t.Txt...)
}
}
dataResult <- data
}()
select {
case <-time.After(p.lookupTimeout):
logrus.WithField("provider", dohProvider).Error("Timed out querying DNS records")
return []string{}, errors.New("timed out querying DNS records")
case data = <-dataResult:
logrus.WithField("data", data).Info("Received TXT records")
return
case err = <-errResult:
logrus.WithField("provider", dohProvider).WithError(err).Error("Failed to query DNS records")
return
}
}
func stripProtocol(url string) string {
if strings.HasPrefix(url, "https://") {
return strings.TrimPrefix(url, "https://")
}
if strings.HasPrefix(url, "http://") {
return strings.TrimPrefix(url, "http://")
}
return url
}
func https(url string) string {
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
url = "https://" + url
}
return url
}

304
pkg/pmapi/proxy_test.go Normal file
View File

@ -0,0 +1,304 @@
// Copyright (c) 2020 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 (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
)
const (
TestDoHQuery = "dMFYGSLTQOJXXI33ONVQWS3BOMNUA.protonpro.xyz"
TestQuad9Provider = "https://dns11.quad9.net/dns-query"
TestGoogleProvider = "https://dns.google/dns-query"
)
func TestProxyManager_FindProxy(t *testing.T) {
blockAPI()
defer unblockAPI()
proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer proxy.Close()
p := newProxyManager([]string{"not used"}, "not used")
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy.URL}, nil }
url, err := p.findProxy()
require.NoError(t, err)
require.Equal(t, proxy.URL, url)
}
func TestProxyManager_FindProxy_ChooseReachableProxy(t *testing.T) {
blockAPI()
defer unblockAPI()
badProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
goodProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
// Close the bad proxy first so it isn't reachable; we should then choose the good proxy.
badProxy.Close()
defer goodProxy.Close()
p := newProxyManager([]string{"not used"}, "not used")
p.dohLookup = func(q, p string) ([]string, error) { return []string{badProxy.URL, goodProxy.URL}, nil }
url, err := p.findProxy()
require.NoError(t, err)
require.Equal(t, goodProxy.URL, url)
}
func TestProxyManager_FindProxy_FailIfNoneReachable(t *testing.T) {
blockAPI()
defer unblockAPI()
badProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
anotherBadProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
// Close the proxies to simulate them not being reachable.
badProxy.Close()
anotherBadProxy.Close()
p := newProxyManager([]string{"not used"}, "not used")
p.dohLookup = func(q, p string) ([]string, error) { return []string{badProxy.URL, anotherBadProxy.URL}, nil }
_, err := p.findProxy()
require.Error(t, err)
}
func TestProxyManager_FindProxy_LookupTimeout(t *testing.T) {
blockAPI()
defer unblockAPI()
proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer proxy.Close()
p := newProxyManager([]string{"not used"}, "not used")
p.lookupTimeout = time.Second
p.dohLookup = func(q, p string) ([]string, error) { time.Sleep(2 * time.Second); return nil, nil }
// The findProxy should fail because lookup takes 2 seconds but we only allow 1 second.
_, err := p.findProxy()
require.Error(t, err)
}
func TestProxyManager_FindProxy_FindTimeout(t *testing.T) {
blockAPI()
defer unblockAPI()
slowProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
}))
defer slowProxy.Close()
p := newProxyManager([]string{"not used"}, "not used")
p.findTimeout = time.Second
p.dohLookup = func(q, p string) ([]string, error) { return []string{slowProxy.URL}, nil }
// The findProxy should fail because lookup takes 2 seconds but we only allow 1 second.
_, err := p.findProxy()
require.Error(t, err)
}
func TestProxyManager_UseProxy(t *testing.T) {
blockAPI()
defer unblockAPI()
proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer proxy.Close()
p := newProxyManager([]string{"not used"}, "not used")
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy.URL}, nil }
url, err := p.findProxy()
require.NoError(t, err)
p.useProxy(url)
require.Equal(t, proxy.URL, GlobalGetRootURL())
}
func TestProxyManager_UseProxy_MultipleTimes(t *testing.T) {
blockAPI()
defer unblockAPI()
proxy1 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer proxy1.Close()
proxy2 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer proxy2.Close()
proxy3 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer proxy3.Close()
p := newProxyManager([]string{"not used"}, "not used")
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy1.URL}, nil }
url, err := p.findProxy()
require.NoError(t, err)
p.useProxy(url)
require.Equal(t, proxy1.URL, GlobalGetRootURL())
// Have to wait so as to not get rejected.
time.Sleep(proxyLookupWait)
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy2.URL}, nil }
url, err = p.findProxy()
require.NoError(t, err)
p.useProxy(url)
require.Equal(t, proxy2.URL, GlobalGetRootURL())
// Have to wait so as to not get rejected.
time.Sleep(proxyLookupWait)
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy3.URL}, nil }
url, err = p.findProxy()
require.NoError(t, err)
p.useProxy(url)
require.Equal(t, proxy3.URL, GlobalGetRootURL())
}
func TestProxyManager_UseProxy_RevertAfterTime(t *testing.T) {
blockAPI()
defer unblockAPI()
proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer proxy.Close()
p := newProxyManager([]string{"not used"}, "not used")
p.useDuration = time.Second
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy.URL}, nil }
url, err := p.findProxy()
require.NoError(t, err)
require.Equal(t, proxy.URL, url)
p.useProxy(url)
require.Equal(t, proxy.URL, GlobalGetRootURL())
time.Sleep(2 * time.Second)
require.Equal(t, globalOriginalURL, GlobalGetRootURL())
}
func TestProxyManager_UseProxy_RevertIfProxyStopsWorkingAndOriginalAPIIsReachable(t *testing.T) {
// Don't block the API here because we want it to be working so the test can find it.
defer unblockAPI()
proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer proxy.Close()
p := newProxyManager([]string{"not used"}, "not used")
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy.URL}, nil }
url, err := p.findProxy()
require.NoError(t, err)
require.Equal(t, proxy.URL, url)
p.useProxy(url)
require.Equal(t, proxy.URL, GlobalGetRootURL())
// Simulate that the proxy stops working.
proxy.Close()
time.Sleep(proxyLookupWait)
// We should now find the original API URL if it is working again.
url, err = p.findProxy()
require.NoError(t, err)
require.Equal(t, globalOriginalURL, url)
p.useProxy(url)
require.Equal(t, globalOriginalURL, GlobalGetRootURL())
}
func TestProxyManager_UseProxy_FindSecondAlternativeIfFirstFailsAndAPIIsStillBlocked(t *testing.T) {
blockAPI()
defer unblockAPI()
proxy1 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer proxy1.Close()
proxy2 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer proxy2.Close()
p := newProxyManager([]string{"not used"}, "not used")
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy1.URL, proxy2.URL}, nil }
// Find a proxy.
url, err := p.findProxy()
require.NoError(t, err)
p.useProxy(url)
require.Equal(t, proxy1.URL, GlobalGetRootURL())
// Have to wait so as to not get rejected.
time.Sleep(proxyLookupWait)
// The proxy stops working and the protonmail API is still blocked.
proxy1.Close()
// Should switch to the second proxy because both the first proxy and the protonmail API are blocked.
url, err = p.findProxy()
require.NoError(t, err)
p.useProxy(url)
require.Equal(t, proxy2.URL, GlobalGetRootURL())
}
func TestProxyManager_DoHLookup_Quad9(t *testing.T) {
p := newProxyManager([]string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
records, err := p.dohLookup(TestDoHQuery, TestQuad9Provider)
require.NoError(t, err)
require.NotEmpty(t, records)
}
func TestProxyManager_DoHLookup_Google(t *testing.T) {
p := newProxyManager([]string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
records, err := p.dohLookup(TestDoHQuery, TestGoogleProvider)
require.NoError(t, err)
require.NotEmpty(t, records)
}
func TestProxyManager_DoHLookup_FindProxy(t *testing.T) {
p := newProxyManager([]string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
url, err := p.findProxy()
require.NoError(t, err)
require.NotEmpty(t, url)
}
func TestProxyManager_DoHLookup_FindProxyFirstProviderUnreachable(t *testing.T) {
p := newProxyManager([]string{"https://unreachable", TestGoogleProvider}, TestDoHQuery)
url, err := p.findProxy()
require.NoError(t, err)
require.NotEmpty(t, url)
}
// testAPIURLBackup is used to hold the globalOriginalURL because we clear it for test purposes and need to restore it.
var testAPIURLBackup = globalOriginalURL
// blockAPI prevents tests from reaching the standard API, forcing them to find a proxy.
func blockAPI() {
globalSetRootURL("")
globalOriginalURL = ""
}
// unblockAPI allow tests to reach the standard API again.
func unblockAPI() {
globalOriginalURL = testAPIURLBackup
globalSetRootURL(globalOriginalURL)
}

90
pkg/pmapi/req.go Normal file
View File

@ -0,0 +1,90 @@
// Copyright (c) 2020 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 (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
)
// NewRequest creates a new request.
func NewRequest(method, path string, body io.Reader) (req *http.Request, err error) {
req, err = http.NewRequest(method, GlobalGetRootURL()+path, body)
if req != nil {
req.Header.Set("User-Agent", CurrentUserAgent)
}
return
}
// NewJSONRequest create a new JSON request.
func NewJSONRequest(method, path string, body interface{}) (*http.Request, error) {
b, err := json.Marshal(body)
if err != nil {
panic(err)
}
req, err := NewRequest(method, path, bytes.NewReader(b))
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/json")
return req, nil
}
type MultipartWriter struct {
*multipart.Writer
c io.Closer
}
func (w *MultipartWriter) Close() error {
if err := w.Writer.Close(); err != nil {
return err
}
return w.c.Close()
}
// NewMultipartRequest creates a new multipart request.
//
// The multipart request is written as long as it is sent to the API. That means
// that writing the request and sending it MUST be done in parallel. If the
// request fails, subsequent writes to the multipart writer will fail with an
// io.ErrClosedPipe error.
func NewMultipartRequest(method, path string) (req *http.Request, w *MultipartWriter, err error) {
// The pipe will connect the multipart writer and the HTTP request body.
pr, pw := io.Pipe()
// pw needs to be closed once the multipart writer is closed.
w = &MultipartWriter{
multipart.NewWriter(pw),
pw,
}
req, err = NewRequest(method, path, pr)
if err != nil {
return
}
req.Header.Add("Content-Type", w.FormDataContentType())
return
}

72
pkg/pmapi/res.go Normal file
View File

@ -0,0 +1,72 @@
// Copyright (c) 2020 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
// Common response codes.
const (
CodeOk = 1000
)
// Res is an API response.
type Res struct {
// The response code is the code from the body JSON. It's still used,
// but preference is to use HTTP status code instead for new changes.
Code int
StatusCode int
// The error, if there is any.
*ResError
}
// Err returns error if the response is an error. Otherwise, returns nil.
func (res Res) Err() error {
if res.ResError == nil {
return nil
}
if res.Code == ForceUpgradeBadAPIVersion ||
res.Code == ForceUpgradeInvalidAPI ||
res.Code == ForceUpgradeBadAppVersion {
return ErrUpgradeApplication
}
if res.Code == APIOffline {
return ErrAPINotReachable
}
return &Error{
Code: res.Code,
ErrorMessage: res.ResError.Error,
}
}
type ResError struct {
Error string
}
// Error is an API error.
type Error struct {
// The error code.
Code int
// The error message.
ErrorMessage string `json:"Error"`
}
func (err Error) Error() string {
return err.ErrorMessage
}

173
pkg/pmapi/sentry.go Normal file
View File

@ -0,0 +1,173 @@
// Copyright (c) 2020 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 (
"fmt"
"regexp"
"runtime"
"runtime/pprof"
"strconv"
"strings"
"github.com/getsentry/raven-go"
)
const fileParseError = "[file parse error]"
var isGoroutine = regexp.MustCompile("^goroutine [[:digit:]]+.*") //nolint[gochecknoglobals]
// SentryThreads implements standard sentry thread report.
type SentryThreads struct {
Values []Thread `json:"values"`
}
// Class specifier.
func (s *SentryThreads) Class() string { return "threads" }
// Thread wraps a single stacktrace.
type Thread struct {
ID int `json:"id"`
Name string `json:"name"`
Crashed bool `json:"crashed"`
Stacktrace *raven.Stacktrace `json:"stacktrace"`
}
// TraceAllRoutines traces all goroutines and saves them to the current object.
func (s *SentryThreads) TraceAllRoutines() {
s.Values = []Thread{}
goroutines := &strings.Builder{}
_ = pprof.Lookup("goroutine").WriteTo(goroutines, 2)
thread := Thread{ID: -1}
var frame *raven.StacktraceFrame
for _, v := range strings.Split(goroutines.String(), "\n") {
// Ignore empty lines.
if v == "" {
continue
}
// New routine.
if isGoroutine.MatchString(v) {
if thread.ID >= 0 {
s.Values = append(s.Values, thread)
}
thread = Thread{ID: thread.ID + 1, Name: v, Crashed: thread.ID == -1, Stacktrace: &raven.Stacktrace{Frames: []*raven.StacktraceFrame{}}}
continue
}
// New function.
if frame == nil {
frame = &raven.StacktraceFrame{Function: v}
continue
}
// Set filename and add frame.
if frame.Filename == "" {
fld := strings.Fields(v)
if len(fld) != 2 {
frame.Filename = fileParseError
frame.AbsolutePath = v
} else {
frame.Filename = fld[0]
sp := strings.Split(fld[0], ":")
if len(sp) > 1 {
i, err := strconv.Atoi(sp[len(sp)-1])
if err == nil {
frame.Filename = strings.Join(sp[:len(sp)-1], ":")
frame.Lineno = i
}
}
}
if frame.AbsolutePath == "" && frame.Filename != fileParseError {
frame.AbsolutePath = frame.Filename
if sp := strings.Split(frame.Filename, "/"); len(sp) > 1 {
frame.Filename = sp[len(sp)-1]
}
}
thread.Stacktrace.Frames = append([]*raven.StacktraceFrame{frame}, thread.Stacktrace.Frames...)
frame = nil
continue
}
}
// Add last thread.
s.Values = append(s.Values, thread)
}
func findPanicSender(s *SentryThreads, err error) string {
out := "error nil"
if err != nil {
out = err.Error()
}
for _, thread := range s.Values {
if !thread.Crashed {
continue
}
for i, fr := range thread.Stacktrace.Frames {
if strings.HasSuffix(fr.Filename, "panic.go") && strings.HasPrefix(fr.Function, "panic") {
// Next frame if any.
j := 0
if i > j {
j = i - 1
}
// Directory and filename.
fname := thread.Stacktrace.Frames[j].AbsolutePath
if sp := strings.Split(fname, "/"); len(sp) > 2 {
fname = strings.Join(sp[len(sp)-2:], "/")
}
// Line number.
if ln := thread.Stacktrace.Frames[j].Lineno; ln > 0 {
fname = fmt.Sprintf("%s:%d", fname, ln)
}
out = fmt.Sprintf("%s: %s", fname, out)
break // Just first panic.
}
}
}
return out
}
// ReportSentryCrash reports a sentry crash with stacktrace from all goroutines.
func (c *Client) ReportSentryCrash(reportErr error) (err error) {
if reportErr == nil {
return
}
tags := map[string]string{
"OS": runtime.GOOS,
"Client": c.config.ClientID,
"Version": c.config.AppVersion,
"UserAgent": CurrentUserAgent,
"UserID": c.userID,
}
threads := &SentryThreads{}
threads.TraceAllRoutines()
errorWithFile := findPanicSender(threads, reportErr)
packet := raven.NewPacket(errorWithFile, threads)
eventID, ch := raven.Capture(packet, tags)
if err = <-ch; err == nil {
c.log.Warn("Reported error with id: ", eventID)
} else {
c.log.Errorf("Can not report `%s` due to `%s`", reportErr.Error(), err.Error())
}
return err
}

65
pkg/pmapi/sentry_test.go Normal file
View File

@ -0,0 +1,65 @@
// Copyright (c) 2020 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 (
"errors"
"testing"
"github.com/getsentry/raven-go"
)
func TestSentryCrashReport(t *testing.T) {
c := NewClient(testClientConfig, "bridgetest")
if err := c.ReportSentryCrash(errors.New("Testing crash report - api proxy; goroutines with threads, find origin")); err != nil {
t.Fatal("Expected no error while report, but have", err)
}
}
func (s *SentryThreads) TraceAllRoutinesTest() {
s.Values = []Thread{
{
ID: 0,
Name: "goroutine 20 [running]",
Crashed: true,
Stacktrace: &raven.Stacktrace{
Frames: []*raven.StacktraceFrame{
{
Filename: "/home/dev/build/go-1.10.2/go/src/runtime/pprof/pprof.go",
Function: "runtime/pprof.writeGoroutineStacks(0x9b7de0, 0xc4203e2900, 0xd0, 0xd0)",
Lineno: 650,
},
},
},
},
{
ID: 1,
Name: "goroutine 20 [chan receive]",
Crashed: false,
Stacktrace: &raven.Stacktrace{
Frames: []*raven.StacktraceFrame{
{
Filename: "/home/dev/build/go-1.10.2/go/src/testing/testing.go",
Function: "testing.(*T).Run(0xc4203e42d0, 0x90f445, 0x15, 0x97d358, 0x47a501)",
Lineno: 825,
},
},
},
},
}
}

166
pkg/pmapi/server_test.go Normal file
View File

@ -0,0 +1,166 @@
// Copyright (c) 2020 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 (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"regexp"
"runtime"
"strconv"
"testing"
"github.com/hashicorp/go-multierror"
)
var (
colRed = "\033[1;31m"
colNon = "\033[0;39m"
reHTTPCode = regexp.MustCompile(`(HTTP|get|post|put|delete)_(\d{3}).*.json`)
)
// Assert fails the test if the condition is false.
func Assert(tb testing.TB, condition bool, msg string, v ...interface{}) {
if !condition {
_, file, line, _ := runtime.Caller(1)
vv := []interface{}{filepath.Base(file), line, colRed}
vv = append(vv, v...)
vv = append(vv, colNon)
fmt.Printf("%s:%d: %s"+msg+"%s\n\n", vv...)
tb.FailNow()
}
}
// Ok fails the test if an err is not nil.
func Ok(tb testing.TB, err error) {
if err != nil {
_, file, line, _ := runtime.Caller(1)
fmt.Printf("%s:%d: %sunexpected error: %s%s\n\n", filepath.Base(file), line, colRed, err.Error(), colNon)
tb.FailNow()
}
}
// Equals fails the test if exp is not equal to act.
func Equals(tb testing.TB, exp, act interface{}) {
if !reflect.DeepEqual(exp, act) {
_, file, line, _ := runtime.Caller(1)
fmt.Printf("%s:%d:\n\n%s\texp: %#v\n\n\tgot: %#v%s\n\n", filepath.Base(file), line, colRed, exp, act, colNon)
tb.FailNow()
}
}
// newTestServer is old function and should be replaced everywhere by newTestServerCallbacks.
func newTestServer(h http.Handler) (*httptest.Server, *Client) {
s := httptest.NewServer(h)
RootURL = s.URL
return s, newTestClient()
}
func newTestServerCallbacks(tb testing.TB, callbacks ...func(testing.TB, http.ResponseWriter, *http.Request) string) (func(), *Client) {
reqNum := 0
_, file, line, _ := runtime.Caller(1)
file = filepath.Base(file)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqNum++
if reqNum > len(callbacks) {
fmt.Printf(
"%s:%d: %sServer was requeted %d times which is more requests than expected %d%s\n\n",
file, line, colRed, reqNum, len(callbacks), colNon,
)
tb.FailNow()
}
response := callbacks[reqNum-1](tb, w, r)
if response != "" {
writeJSONResponsefromFile(tb, w, response, reqNum-1)
}
}))
RootURL = server.URL
finish := func() {
server.CloseClientConnections() // Closing without waiting for finishing requests.
if reqNum != len(callbacks) {
fmt.Printf(
"%s:%d: %sServer was requested %d times but expected to be %d times%s\n\n",
file, line, colRed, reqNum, len(callbacks), colNon,
)
tb.Error("server failed")
}
}
return finish, newTestClient()
}
func checkMethodAndPath(r *http.Request, method, path string) error {
var result *multierror.Error
if err := checkHeader(r.Header, "x-pm-appversion", "GoPMAPI_1.0.14"); err != nil {
result = multierror.Append(result, err)
}
if err := checkHeader(r.Header, "x-pm-apiversion", "3"); err != nil {
result = multierror.Append(result, err)
}
if r.Method != method {
err := fmt.Errorf("Invalid request method expected %v, got %v", method, r.Method)
result = multierror.Append(result, err)
}
if r.URL.RequestURI() != path {
err := fmt.Errorf("Invalid request path expected %v, got %v", path, r.URL.RequestURI())
result = multierror.Append(result, err)
}
return result.ErrorOrNil()
}
func httpResponse(code int) string {
return fmt.Sprintf("HTTP_%d.json", code)
}
func writeJSONResponsefromFile(tb testing.TB, w http.ResponseWriter, response string, reqNum int) {
if match := reHTTPCode.FindAllSubmatch([]byte(response), -1); len(match) != 0 {
httpCode, err := strconv.Atoi(string(match[0][len(match[0])-1]))
Ok(tb, err)
w.WriteHeader(httpCode)
}
f, err := os.Open("./testdata/routes/" + response)
Ok(tb, err)
w.Header().Set("content-type", "application/json;charset=utf-8")
w.Header().Set("x-test-pmapi-response", fmt.Sprintf("%s:%d", tb.Name(), reqNum))
_, err = io.Copy(w, f)
Ok(tb, err)
}
func checkHeader(h http.Header, field, exp string) error {
val := h.Get(field)
if val != exp {
msg := "wrong field %s expected %q but have %q"
return fmt.Errorf(msg, field, exp, val)
}
return nil
}
func isAuthReq(r *http.Request, uid, token string) error { // nolint[unparam]
if err := checkHeader(r.Header, "x-pm-uid", uid); err != nil {
return err
}
if err := checkHeader(r.Header, "authorization", "Bearer "+token); err != nil {
return err
}
return nil
}

118
pkg/pmapi/settings.go Normal file
View File

@ -0,0 +1,118 @@
// Copyright (c) 2020 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
type UserSettings struct {
PasswordMode int
Email struct {
Value string
Status int
Notify int
Reset int
}
Phone struct {
Value string
Status int
Notify int
Reset int
}
News int
Locale string
LogAuth string
InvoiceText string
TOTP int
U2FKeys []struct {
Label string
KeyHandle string
Compromised int
}
}
// GetUserSettings gets general settings.
func (c *Client) GetUserSettings() (settings UserSettings, err error) {
req, err := NewRequest("GET", "/settings", nil)
if err != nil {
return
}
var res struct {
Res
UserSettings UserSettings
}
if err = c.DoJSON(req, &res); err != nil {
return
}
return res.UserSettings, res.Err()
}
type MailSettings struct {
DisplayName string
Signature string `json:",omitempty"`
Theme string `json:",omitempty"`
AutoSaveContacts int
AutoWildcardSearch int
ComposerMode int
MessageButtons int
ShowImages int
ShowMoved int
ViewMode int
ViewLayout int
SwipeLeft int
SwipeRight int
AlsoArchive int
Hotkeys int
PMSignature int
ImageProxy int
TLS int
RightToLeft int
AttachPublicKey int
Sign int
PGPScheme int
PromptPin int
Autocrypt int
NumMessagePerPage int
DraftMIMEType string
ReceiveMIMEType string
ShowMIMEType string
// Undocumented -- there's only `null` in example:
// AutoResponder string
}
// GetMailSettings gets contact details specified by contact ID.
func (c *Client) GetMailSettings() (settings MailSettings, err error) {
req, err := NewRequest("GET", "/settings/mail", nil)
if err != nil {
return
}
var res struct {
Res
MailSettings MailSettings
}
if err = c.DoJSON(req, &res); err != nil {
return
}
return res.MailSettings, res.Err()
}

View File

@ -0,0 +1,22 @@
[
{
"ID": "hKRtZeTDhvzfAaycb5BOVx6Y3hc3gs4QvET8H_YZBTwAQBPp3h6FI4nnkJePCYuM9CG0zf7TQzOJeB2rPi0YmQ==",
"Primary": 1,
"Flags": 3,
"Version": 3,
"Activation": null,
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nxcMGBFzGzj4BCADh9uuhG3mDhlbFMgQpvP6AReyJYa6KSV9a6AJsNI0/DkVz\nFenIodav/r4RBgSoblqxCa/QH6galbX0eB8Vtxx4xTXCcHHZzTM9oAb4s8r6\ndGchNsSkoLspVwmc2fYfDRCTcapmeza7Jym8gD9wFUSWSwpC49jDeT6VIcOd\n+qNXgIJX1V36BOAB53lKenMYnxfzzAyJDeL/sQ/SWE9h/X+wGdkVmk7gk3pa\nQ9URoxAf7MO808hHyQYcjTV4tOHD/9k+AZC6sqmr2KAefYCxCMoCDj1wQp3J\nN6MfK2Zvc1r5KFQM2YzUXrMNrycAjo7gGeZnsvz758Q+8ouSP7iXGRl/ABEB\nAAH+CQMIQt3QQIJZYjRgTVgIQNSEkhMn4GJ5lQQPBXD0/YyK3xP0bm/bsX8S\nz9dAm1nkP0l8Q9z3lR6OcAX2O/KsqJdQGmJEZAaadz2je+EEWo1FDYaL76E6\n6z+AuteP8UtP61jBt3pTfIlhW8o7o2SlM1lvytpvc5FQplZ/iUuwfsIltCiD\n9VDkprNOVrRsjoh8BV4TrrIvU8qga58Aeg7SjOmm+3oMZ7yPTYirr11Tx/m3\nj1uzdEDfiPk4LmvlzSsWwKZuy6fSul+n92+9qN81wmdts/I2ucuKvOINZim4\nlk/p2AOsPjWpAgkefpTVLZnu2IH+VAyaXt1Fl84badXx4N921nPs7ova1Ud5\n2RddBc7b/01DtOyBSWDoNskLGpsc2mqz9kdkwwQKNjChzZc9nmY9M+AIfgT3\n+2DSQIuoJYPX69DKi/bZDwRzoHmiwiHT6Us7qxd6kD1dzCIHTptxwZQp4Tow\nnN6lmtK4S6O2B47+ROn8s0N+EH9GR8F6mvOTayNLH5yicpR3M4Of0ClvFa0G\n+JUKBXIQXUvF03G3nTPU17nLibC81UmbK3zobfbrLfuU2gU+sY+OfE1E/+GO\nSFpZcrkoRqRr39CfLkLk+GjU7RCLNddb5LxgaurVZo5h0Y7Rr8VvOQMWjjl/\nvTAG7gU/HtXi24TijNC0fP6j9w43K4b6t1SZYn5us7RRlFlWGKMSPf5Q9j6/\nryo3xULUQv0lTCQPtfQQ5UWE5ZpQ4Kjt/k5+/YtfuOcMbrDU4qa+H+rrisBu\nko7f4Wn0iYjRwRIuWh1NfUM3rIbNhq7/wonasEFOeFdPwprzMaawx3rL0Pq5\n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\nV0GB3IX8qBHjAfPmqN9JPxBIn3/6ag==\n=U1yD\n-----END PGP PRIVATE KEY BLOCK-----\n",
"Token": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMAxW/uQhm1PKxAQf8D4BAoXqnYgoTr7jEiabP9NkeuD6Z88fZDgUzXte5\n6aYMmCtPr0Vef1eDl2x0S0q/YJgR+A5Icmjxk+jG9nMSnRPlAozJfTCXu/Oy\nfkI4CcClGQv7U1EzfknBVNEKmuPO8XlEkGZ5lW/TzWk8ZtQqJsVuqZlZ5qQE\n/p8FNYEZ5RHPhhKlotA+T6XKh23z9mcN/JNEsSNcx+guERbivtTwnd5JpxQJ\nMNWzUYh+K0W7LgxoAo2jevDd4CZ/0sAAViIl+QrShodseV9agbldGUUoIMzn\nHUuIV6VYi7X82eKQXDInrtPc9IHekbDDFoncLnsGGrEqD/8O/qOHHOPwNwU+\n+cHATAOUTDv/ccWq1QEIALl8SNOQBmCuanAceUTSwCkM1fC3Ddqa8ZmnMMyG\nnDWDI7XkFU31CO10lN20/kAWjdN2073B/NT+17cp8fCqbQ1pAFJHpabdqmbI\ntm3pCC5M6otTN+MhjgFYcBuxo0rq9qtuEzz5j4Ub9MIIJTurUHMEPMI462Dc\ndK/d8BvDkU7q67Lkp65vpe9e/pv0lMMrQjdohnTHNgbZhbI/Z5LU0ApD//Ye\ncSC26BRUMJITiGKb9pKGAi0/ig0jJfzykgEarzOsY/v0W7016AMka+NsHuNc\n7Qfyg2LApF5s9Z6aK+Uy851haUk+p+abjBdcWACziAmimVGjlRYm49ra4UJ7\nc6LSeAElM584e4Z3VnqajAJWbmWt2atgBQmcwEBBsdNAtzIagNydMbBBIELA\n5wGUbMXfQxrMOo/Mdac/5EMTHT0/kVQNIBHtpX5SknWvbc5DjIHoH3+BbL87\neaxhKW94hMubKNKT3dbm4PKHtsMiS3TGkFZ3GjvA0Cy/Kw==\n=b1N8\n-----END PGP MESSAGE-----\n",
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAGBQJcxtSkAAoJEGfweL9m9sQefz8IAJhpEOvG7d+PVgq3bEL+\nXzY8yTUKB1ZXEMbcR/uHUWSfu0F8zy4CNtSG5HUVjNxx1xzzJVJcxWB/ljO+\n/bJSFOFexTyNh8i/xU+CiBfm5RhAFTYF9xFwfD3LKp5gaalJAhWhArk1/Wuh\nWTDpFpk39uzBRKNwcnSgiJYPxOjAZxj+w/hhHPwmco5cUwMiMR5MNrfzKf+x\nwX3Cfs9fsiiCDzohBzbK0FFsMnJ8aXNVsDBjEA1KrB7sdyaf8FnaM0RsFRDb\njxtIgfcFBbyNJh4414Unt4AYTIrIWhK4OOXI3AfsJy8p6KRBKQUcUkKcDxab\nPTXOPZsZ+UgQ5MevyVnP1zfCwFwEAQEIAAYFAlzG1KQACgkQm1DMMBzxetx/\nPwf9F04uHtix0zDpP1IvG4VYlor4rjYTdfXqxxiFXHO6MZXJoigS1E71E8r4\nsqZ6PoQ5/xCj2A01KRhuF1Bon3mEZEwaIUuBqTV91sLsVWfoxgyPAYpr6gK/\n1W9JhVNNrVRMGox42LQUjyiq0ESrfWmqC8SuMfZMoUoBZycHicA50RbyOUnT\nLO57ArL3JIVmYtyosaXM3idzmNHmaXSkcGt4cvTVysJZQrneaxmikfm5CH2O\n1z2goLBNnzsbRionoV6gCukZOiM/d14yiyeYtsFJ7u/vodkI5y0M0sF8VnN/\njL9keP4ZpHiJ4MBd6tyIH0pLueDRlurFL3fcHsEzD5SrOA==\n=wGCk\n-----END PGP SIGNATURE-----\n"
},
{
"ID": "MhF8dxtN5Lz_GruskN3L9kTKWusZHBdhWaxc7w1tgze2qB6uM9AyC6mWRQg1B7WGZ_r-9gn-XC95-IkpNJr0jg==",
"Primary": 0,
"Flags": 3,
"Version": 3,
"Activation": null,
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\n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\nYYsMDlA=\n=ZwJf\n-----END PGP PRIVATE KEY BLOCK-----",
"Token": null,
"Signature": null
}
]

View File

@ -0,0 +1,22 @@
[
{
"ID": "MhF8dxtN5Lz_GruskN3L9kTKWusZHBdhWaxc7w1tgze2qB6uM9AyC6mWRQg1B7WGZ_r-9gn-XC95-IkpNJr0jg==",
"Primary": 1,
"Flags": 3,
"Version": 3,
"Activation": null,
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\n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\nYYsMDlA=\n=ZwJf\n-----END PGP PRIVATE KEY BLOCK-----",
"Token": null,
"Signature": null
},
{
"ID": "hKRtZeTDhvzfAaycb5BOVx6Y3hc3gs4QvET8H_YZBTwAQBPp3h6FI4nnkJePCYuM9CG0zf7TQzOJeB2rPi0YmQ==",
"Primary": 0,
"Flags": 3,
"Version": 3,
"Activation": null,
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nxcMGBFzGzj4BCADh9uuhG3mDhlbFMgQpvP6AReyJYa6KSV9a6AJsNI0/DkVz\nFenIodav/r4RBgSoblqxCa/QH6galbX0eB8Vtxx4xTXCcHHZzTM9oAb4s8r6\ndGchNsSkoLspVwmc2fYfDRCTcapmeza7Jym8gD9wFUSWSwpC49jDeT6VIcOd\n+qNXgIJX1V36BOAB53lKenMYnxfzzAyJDeL/sQ/SWE9h/X+wGdkVmk7gk3pa\nQ9URoxAf7MO808hHyQYcjTV4tOHD/9k+AZC6sqmr2KAefYCxCMoCDj1wQp3J\nN6MfK2Zvc1r5KFQM2YzUXrMNrycAjo7gGeZnsvz758Q+8ouSP7iXGRl/ABEB\nAAH+CQMIQt3QQIJZYjRgTVgIQNSEkhMn4GJ5lQQPBXD0/YyK3xP0bm/bsX8S\nz9dAm1nkP0l8Q9z3lR6OcAX2O/KsqJdQGmJEZAaadz2je+EEWo1FDYaL76E6\n6z+AuteP8UtP61jBt3pTfIlhW8o7o2SlM1lvytpvc5FQplZ/iUuwfsIltCiD\n9VDkprNOVrRsjoh8BV4TrrIvU8qga58Aeg7SjOmm+3oMZ7yPTYirr11Tx/m3\nj1uzdEDfiPk4LmvlzSsWwKZuy6fSul+n92+9qN81wmdts/I2ucuKvOINZim4\nlk/p2AOsPjWpAgkefpTVLZnu2IH+VAyaXt1Fl84badXx4N921nPs7ova1Ud5\n2RddBc7b/01DtOyBSWDoNskLGpsc2mqz9kdkwwQKNjChzZc9nmY9M+AIfgT3\n+2DSQIuoJYPX69DKi/bZDwRzoHmiwiHT6Us7qxd6kD1dzCIHTptxwZQp4Tow\nnN6lmtK4S6O2B47+ROn8s0N+EH9GR8F6mvOTayNLH5yicpR3M4Of0ClvFa0G\n+JUKBXIQXUvF03G3nTPU17nLibC81UmbK3zobfbrLfuU2gU+sY+OfE1E/+GO\nSFpZcrkoRqRr39CfLkLk+GjU7RCLNddb5LxgaurVZo5h0Y7Rr8VvOQMWjjl/\nvTAG7gU/HtXi24TijNC0fP6j9w43K4b6t1SZYn5us7RRlFlWGKMSPf5Q9j6/\nryo3xULUQv0lTCQPtfQQ5UWE5ZpQ4Kjt/k5+/YtfuOcMbrDU4qa+H+rrisBu\nko7f4Wn0iYjRwRIuWh1NfUM3rIbNhq7/wonasEFOeFdPwprzMaawx3rL0Pq5\n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\nV0GB3IX8qBHjAfPmqN9JPxBIn3/6ag==\n=U1yD\n-----END PGP PRIVATE KEY BLOCK-----\n",
"Token": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMAxW/uQhm1PKxAQf8D4BAoXqnYgoTr7jEiabP9NkeuD6Z88fZDgUzXte5\n6aYMmCtPr0Vef1eDl2x0S0q/YJgR+A5Icmjxk+jG9nMSnRPlAozJfTCXu/Oy\nfkI4CcClGQv7U1EzfknBVNEKmuPO8XlEkGZ5lW/TzWk8ZtQqJsVuqZlZ5qQE\n/p8FNYEZ5RHPhhKlotA+T6XKh23z9mcN/JNEsSNcx+guERbivtTwnd5JpxQJ\nMNWzUYh+K0W7LgxoAo2jevDd4CZ/0sAAViIl+QrShodseV9agbldGUUoIMzn\nHUuIV6VYi7X82eKQXDInrtPc9IHekbDDFoncLnsGGrEqD/8O/qOHHOPwNwU+\n+cHATAOUTDv/ccWq1QEIALl8SNOQBmCuanAceUTSwCkM1fC3Ddqa8ZmnMMyG\nnDWDI7XkFU31CO10lN20/kAWjdN2073B/NT+17cp8fCqbQ1pAFJHpabdqmbI\ntm3pCC5M6otTN+MhjgFYcBuxo0rq9qtuEzz5j4Ub9MIIJTurUHMEPMI462Dc\ndK/d8BvDkU7q67Lkp65vpe9e/pv0lMMrQjdohnTHNgbZhbI/Z5LU0ApD//Ye\ncSC26BRUMJITiGKb9pKGAi0/ig0jJfzykgEarzOsY/v0W7016AMka+NsHuNc\n7Qfyg2LApF5s9Z6aK+Uy851haUk+p+abjBdcWACziAmimVGjlRYm49ra4UJ7\nc6LSeAElM584e4Z3VnqajAJWbmWt2atgBQmcwEBBsdNAtzIagNydMbBBIELA\n5wGUbMXfQxrMOo/Mdac/5EMTHT0/kVQNIBHtpX5SknWvbc5DjIHoH3+BbL87\neaxhKW94hMubKNKT3dbm4PKHtsMiS3TGkFZ3GjvA0Cy/Kw==\n=b1N8\n-----END PGP MESSAGE-----\n",
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAGBQJcxtSkAAoJEGfweL9m9sQefz8IAJhpEOvG7d+PVgq3bEL+\nXzY8yTUKB1ZXEMbcR/uHUWSfu0F8zy4CNtSG5HUVjNxx1xzzJVJcxWB/ljO+\n/bJSFOFexTyNh8i/xU+CiBfm5RhAFTYF9xFwfD3LKp5gaalJAhWhArk1/Wuh\nWTDpFpk39uzBRKNwcnSgiJYPxOjAZxj+w/hhHPwmco5cUwMiMR5MNrfzKf+x\nwX3Cfs9fsiiCDzohBzbK0FFsMnJ8aXNVsDBjEA1KrB7sdyaf8FnaM0RsFRDb\njxtIgfcFBbyNJh4414Unt4AYTIrIWhK4OOXI3AfsJy8p6KRBKQUcUkKcDxab\nPTXOPZsZ+UgQ5MevyVnP1zfCwFwEAQEIAAYFAlzG1KQACgkQm1DMMBzxetx/\nPwf9F04uHtix0zDpP1IvG4VYlor4rjYTdfXqxxiFXHO6MZXJoigS1E71E8r4\nsqZ6PoQ5/xCj2A01KRhuF1Bon3mEZEwaIUuBqTV91sLsVWfoxgyPAYpr6gK/\n1W9JhVNNrVRMGox42LQUjyiq0ESrfWmqC8SuMfZMoUoBZycHicA50RbyOUnT\nLO57ArL3JIVmYtyosaXM3idzmNHmaXSkcGt4cvTVysJZQrneaxmikfm5CH2O\n1z2goLBNnzsbRionoV6gCukZOiM/d14yiyeYtsFJ7u/vodkI5y0M0sF8VnN/\njL9keP4ZpHiJ4MBd6tyIH0pLueDRlurFL3fcHsEzD5SrOA==\n=wGCk\n-----END PGP SIGNATURE-----\n"
}
]

View File

@ -0,0 +1,12 @@
[
{
"ID": "hKRtZeTDhvzfAaycb5BOVx6Y3hc3gs4QvET8H_YZBTwAQBPp3h6FI4nnkJePCYuM9CG0zf7TQzOJeB2rPi0YmQ==",
"Primary": 1,
"Flags": 3,
"Version": 3,
"Activation": null,
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nxcMGBFzGzj4BCADh9uuhG3mDhlbFMgQpvP6AReyJYa6KSV9a6AJsNI0/DkVz\nFenIodav/r4RBgSoblqxCa/QH6galbX0eB8Vtxx4xTXCcHHZzTM9oAb4s8r6\ndGchNsSkoLspVwmc2fYfDRCTcapmeza7Jym8gD9wFUSWSwpC49jDeT6VIcOd\n+qNXgIJX1V36BOAB53lKenMYnxfzzAyJDeL/sQ/SWE9h/X+wGdkVmk7gk3pa\nQ9URoxAf7MO808hHyQYcjTV4tOHD/9k+AZC6sqmr2KAefYCxCMoCDj1wQp3J\nN6MfK2Zvc1r5KFQM2YzUXrMNrycAjo7gGeZnsvz758Q+8ouSP7iXGRl/ABEB\nAAH+CQMIQt3QQIJZYjRgTVgIQNSEkhMn4GJ5lQQPBXD0/YyK3xP0bm/bsX8S\nz9dAm1nkP0l8Q9z3lR6OcAX2O/KsqJdQGmJEZAaadz2je+EEWo1FDYaL76E6\n6z+AuteP8UtP61jBt3pTfIlhW8o7o2SlM1lvytpvc5FQplZ/iUuwfsIltCiD\n9VDkprNOVrRsjoh8BV4TrrIvU8qga58Aeg7SjOmm+3oMZ7yPTYirr11Tx/m3\nj1uzdEDfiPk4LmvlzSsWwKZuy6fSul+n92+9qN81wmdts/I2ucuKvOINZim4\nlk/p2AOsPjWpAgkefpTVLZnu2IH+VAyaXt1Fl84badXx4N921nPs7ova1Ud5\n2RddBc7b/01DtOyBSWDoNskLGpsc2mqz9kdkwwQKNjChzZc9nmY9M+AIfgT3\n+2DSQIuoJYPX69DKi/bZDwRzoHmiwiHT6Us7qxd6kD1dzCIHTptxwZQp4Tow\nnN6lmtK4S6O2B47+ROn8s0N+EH9GR8F6mvOTayNLH5yicpR3M4Of0ClvFa0G\n+JUKBXIQXUvF03G3nTPU17nLibC81UmbK3zobfbrLfuU2gU+sY+OfE1E/+GO\nSFpZcrkoRqRr39CfLkLk+GjU7RCLNddb5LxgaurVZo5h0Y7Rr8VvOQMWjjl/\nvTAG7gU/HtXi24TijNC0fP6j9w43K4b6t1SZYn5us7RRlFlWGKMSPf5Q9j6/\nryo3xULUQv0lTCQPtfQQ5UWE5ZpQ4Kjt/k5+/YtfuOcMbrDU4qa+H+rrisBu\nko7f4Wn0iYjRwRIuWh1NfUM3rIbNhq7/wonasEFOeFdPwprzMaawx3rL0Pq5\n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\nV0GB3IX8qBHjAfPmqN9JPxBIn3/6ag==\n=U1yD\n-----END PGP PRIVATE KEY BLOCK-----\n",
"Token": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMAxW/uQhm1PKxAQf8D4BAoXqnYgoTr7jEiabP9NkeuD6Z88fZDgUzXte5\n6aYMmCtPr0Vef1eDl2x0S0q/YJgR+A5Icmjxk+jG9nMSnRPlAozJfTCXu/Oy\nfkI4CcClGQv7U1EzfknBVNEKmuPO8XlEkGZ5lW/TzWk8ZtQqJsVuqZlZ5qQE\n/p8FNYEZ5RHPhhKlotA+T6XKh23z9mcN/JNEsSNcx+guERbivtTwnd5JpxQJ\nMNWzUYh+K0W7LgxoAo2jevDd4CZ/0sAAViIl+QrShodseV9agbldGUUoIMzn\nHUuIV6VYi7X82eKQXDInrtPc9IHekbDDFoncLnsGGrEqD/8O/qOHHOPwNwU+\n+cHATAOUTDv/ccWq1QEIALl8SNOQBmCuanAceUTSwCkM1fC3Ddqa8ZmnMMyG\nnDWDI7XkFU31CO10lN20/kAWjdN2073B/NT+17cp8fCqbQ1pAFJHpabdqmbI\ntm3pCC5M6otTN+MhjgFYcBuxo0rq9qtuEzz5j4Ub9MIIJTurUHMEPMI462Dc\ndK/d8BvDkU7q67Lkp65vpe9e/pv0lMMrQjdohnTHNgbZhbI/Z5LU0ApD//Ye\ncSC26BRUMJITiGKb9pKGAi0/ig0jJfzykgEarzOsY/v0W7016AMka+NsHuNc\n7Qfyg2LApF5s9Z6aK+Uy851haUk+p+abjBdcWACziAmimVGjlRYm49ra4UJ7\nc6LSeAElM584e4Z3VnqajAJWbmWt2atgBQmcwEBBsdNAtzIagNydMbBBIELA\n5wGUbMXfQxrMOo/Mdac/5EMTHT0/kVQNIBHtpX5SknWvbc5DjIHoH3+BbL87\neaxhKW94hMubKNKT3dbm4PKHtsMiS3TGkFZ3GjvA0Cy/Kw==\n=b1N8\n-----END PGP MESSAGE-----\n",
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAGBQJcxtSkAAoJEGfweL9m9sQefz8IAJhpEOvG7d+PVgq3bEL+\nXzY8yTUKB1ZXEMbcR/uHUWSfu0F8zy4CNtSG5HUVjNxx1xzzJVJcxWB/ljO+\n/bJSFOFexTyNh8i/xU+CiBfm5RhAFTYF9xFwfD3LKp5gaalJAhWhArk1/Wuh\nWTDpFpk39uzBRKNwcnSgiJYPxOjAZxj+w/hhHPwmco5cUwMiMR5MNrfzKf+x\nwX3Cfs9fsiiCDzohBzbK0FFsMnJ8aXNVsDBjEA1KrB7sdyaf8FnaM0RsFRDb\njxtIgfcFBbyNJh4414Unt4AYTIrIWhK4OOXI3AfsJy8p6KRBKQUcUkKcDxab\nPTXOPZsZ+UgQ5MevyVnP1zfCwFwEAQEIAAYFAlzG1KQACgkQm1DMMBzxetx/\nPwf9F04uHtix0zDpP1IvG4VYlor4rjYTdfXqxxiFXHO6MZXJoigS1E71E8r4\nsqZ6PoQ5/xCj2A01KRhuF1Bon3mEZEwaIUuBqTV91sLsVWfoxgyPAYpr6gK/\n1W9JhVNNrVRMGox42LQUjyiq0ESrfWmqC8SuMfZMoUoBZycHicA50RbyOUnT\nLO57ArL3JIVmYtyosaXM3idzmNHmaXSkcGt4cvTVysJZQrneaxmikfm5CH2O\n1z2goLBNnzsbRionoV6gCukZOiM/d14yiyeYtsFJ7u/vodkI5y0M0sF8VnN/\njL9keP4ZpHiJ4MBd6tyIH0pLueDRlurFL3fcHsEzD5SrOA==\n=wGCk\n-----END PGP SIGNATURE-----\n"
}
]

View File

@ -0,0 +1,12 @@
[
{
"ID": "MhF8dxtN5Lz_GruskN3L9kTKWusZHBdhWaxc7w1tgze2qB6uM9AyC6mWRQg1B7WGZ_r-9gn-XC95-IkpNJr0jg==",
"Primary": 1,
"Flags": 3,
"Version": 3,
"Activation": null,
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\n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\nYYsMDlA=\n=ZwJf\n-----END PGP PRIVATE KEY BLOCK-----",
"Token": null,
"Signature": null
}
]

62
pkg/pmapi/testdata/keyring_userKey vendored Normal file
View File

@ -0,0 +1,62 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----
Version: OpenPGP.js v4.4.5
Comment: testpassphrase
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
uaSC3IcBmBsj1fNb4eYXElILjQ==
=fMOl
-----END PGP PRIVATE KEY BLOCK-----

10
pkg/pmapi/testdata/keyring_userKey_JSON vendored Normal file
View File

@ -0,0 +1,10 @@
[
{
"ID": "IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA==",
"Version": 3,
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\r\nVersion: OpenPGP.js v0.7.1\r\nComment: http://openpgpjs.org\r\n\r\n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\nqqGRQm3MxoTdgQUShAwbNwNNQR9cInfMnA==\r\n=2wIY\r\n-----END PGP PRIVATE KEY BLOCK-----\r\n",
"Fingerprint": "c93f767df53b0ca8395cfde90483475164ec6353",
"Activation": null,
"Primary": 1
}
]

View File

@ -0,0 +1,3 @@
{
"Code": 1000
}

View File

@ -0,0 +1,4 @@
{
"Code": 5000,
"Error": "Status unauthorized"
}

View File

@ -0,0 +1,4 @@
{
"Code": 5000,
"Error": "Status payment required"
}

View File

@ -0,0 +1,43 @@
{
"Code": 1000,
"Addresses": [
{
"ID": "qmhrlFY24BhSHiFplF0B7G_cMVLi1sokaWIhfNaee6dRtdIZPYnqgI4-MpAb8h3JhOOykKv8ZsuTH8X_SrUZSg==",
"DomainID": "l8vWAXHBQmv0u7OVtPbcqMa4iwQaBqowINSQjPrxAr-Da8fVPKUkUcqAq30_BCxj1X0nW70HQRmAa-rIvzmKUA==",
"Email": "jason@protonmail.com",
"Send": 1,
"Receive": 1,
"Status": 1,
"Type": 1,
"Order": 1,
"DisplayName": "D L'u, P.D. \u5b9a\u8d85",
"Signature": "hi there",
"HasKeys": 1,
"Keys": [
{
"ID": "IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA==",
"Version": 3,
"Flags": 3,
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\r\nVersion: OpenPGP.js v0.7.1\r\nComment: http://openpgpjs.org\r\n\r\n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\nqqGRQm3MxoTdgQUShAwbNwNNQR9cInfMnA==\r\n=2wIY\r\n-----END PGP PRIVATE KEY BLOCK-----\r\n",
"Activation": null,
"Primary": 1,
"Token": null
}
]
},
{
"ID": "_pm5NXefHCdfqXuHiQ_zcOsfC4rGaCwV4lxuJt5qCmZBh4RiQ0k5iA8wLLaphTWHWAETz-WDqjLDRXNWftciXw==",
"DomainID": "l8vWAXHBQmv0u7OVtPbcqMa4iwQaBqowINSQjPrxAr-Da8fVPKUkUcqAq30_BCxj1X0nW70HQRmAa-rIvzmKUA==",
"Email": "hi@protonmail.dev",
"Send": 1,
"Receive": 0,
"Status": 0,
"Type": 2,
"Order": 2,
"DisplayName": "hi",
"Signature": "hi there",
"HasKeys": 0,
"Keys": []
}
]
}

View File

@ -0,0 +1,5 @@
{
"Code": 8002,
"Error": "Incorrect login credentials.",
"Details": {}
}

View File

@ -0,0 +1,5 @@
{
"Code": 8002,
"Error": "Incorrect login credentials. Please try again",
"Details": {}
}

View File

@ -0,0 +1,4 @@
{
"Code": 1000,
"Scope": "full mail payments reset keys"
}

View File

@ -0,0 +1,4 @@
{
"Code": 1000
}

View File

@ -0,0 +1,65 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----
Version: OpenPGP.js v0.7.1
Comment: http://openpgpjs.org
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
qqGRQm3MxoTdgQUShAwbNwNNQR9cInfMnA==
=2wIY
-----END PGP PRIVATE KEY BLOCK-----

View File

@ -0,0 +1,15 @@
-----BEGIN PGP MESSAGE-----
Version: OpenPGP.js v1.2.0
Comment: http://openpgpjs.org
wcBMA0fcZ7XLgmf2AQf/RxDfA7g85KzH4371D/jx6deJIXPOWAqgTlGQMsTt
yg4ny3phSC2An/bUXNEBm8UMXqqtS7O+S8n1GjkDrCOkxyC+HugOFQwtybzI
eRX0X0qqvR6ry940SNGjPfJJ4Z0FYSLJtT8YxqO38t38WAYV1j9mBBVPMPJF
r7cQXxEcQAd6NZWF1Cf5Ajuum/zFjbA10Ksbi1tC4fsdtHcS94h1GCfsdNQi
xxbAuoyNYX2wsc6WX8IcmDNn564ZoHfvf2tX4Csf+2czByyOPtfyCn1aee51
I40/I+65w8NfYEfzu7pbUcdo041Xg3lOhDNcuX/zANNw6zEWbE+12G5KVvwC
NNJgARWnwnOKtov2d73wGqNawn21SzA+zEd2mAPv1LPPIupW+0xOUSp5muov
aLEjcIuZeu+vyhXGZxIgoY4Bw8XCO9uWKZuzmqp+AOIP+kSi5aWnOaDFIOq0
B3KtZ33bMZeX
=mig5
-----END PGP MESSAGE-----

View File

@ -0,0 +1,13 @@
{
"Code": 1000,
"Modulus": "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nW2z5HBi8RvsfYzZTS7qBaUxxPhsfHJFZpu3Kd6s1JafNrCCH9rfvPLrfuqocxWPgWDH2R8neK7PkNvjxto9TStuY5z7jAzWRvFWN9cQhAKkdWgy0JY6ywVn22+HFpF4cYesHrqFIKUPDMSSIlWjBVmEJZ/MusD44ZT29xcPrOqeZvwtCffKtGAIjLYPZIEbZKnDM1Dm3q2K/xS5h+xdhjnndhsrkwm9U9oyA2wxzSXFL+pdfj2fOdRwuR5nW0J2NFrq3kJjkRmpO/Genq1UW+TEknIWAb6VzJJJA244K/H8cnSx2+nSNZO3bbo6Ys228ruV9A8m6DhxmS+bihN3ttQ==\n-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwl4EARYIABAFAlwB1j0JEDUFhcTpUY8mAAD8CgEAnsFnF4cF0uSHKkXa1GIa\nGO86yMV4zDZEZcDSJo0fgr8A/AlupGN9EdHlsrZLmTA1vhIx+rOgxdEff28N\nkvNM7qIK\n=q6vu\n-----END PGP SIGNATURE-----\n",
"ServerEphemeral": "5tfigcLKoM0DPWYB+EqYE7QlqsiT63iOVlO5ZX0lTMEILSsrRdVCYrN8L3zkinsAjUZ/cx5wIS7N05k66uZb+ZE3lFOJS2s1BkqLvCrGxYL0e3n5YAnzHYlvCCJKXw/sK57ntfF1OOoblBXX6dw5LjeeDglEep2/DaE0TjD8WUpq4Ls2HlQGn9wrC7dFO2lJXsMhRffxKghiOsdvCLXDmwXginzn/LFezA8KrDsWOBSEGntwpg3s1xFj5h8BqtRHvC0igmoscqgw+3GCMTJ0NZAQ/L+5aJ/0ccL0WBK208ltCNl+/X6Sz0kpyvOP4RqFJhC1auVDJ9AjZQYSYZ1NEQ==",
"Version": 4,
"Salt": "yKlc5/CvObfoiw==",
"SRPSession": "9b2946bbd9055f17c34940abdce0c3d3",
"TwoFactor": 0,
"2FA": {
"TOTP": 1,
"U2F": null
}
}

View File

@ -0,0 +1,11 @@
{
"Code": 1000,
"AccessToken": "de0423049b44243afeec7d9c1d99be7b46da1e8a",
"ExpiresIn": 86400,
"TokenType": "Bearer",
"Scope": "full mail payments reset keys",
"UID": "729ad6012421d67ad26950dc898bebe3a6e3caa2",
"RefreshToken": "a49b98256745bb497bec20e9b55f5de16f01fb52",
"EventID": "NcKPtU5eMNPMrDkIMbEJrgMtC9yQ7Xc5ZBT-tB3UtV1rZ324RWfCIdBI758q0UnsfywS8CkNenIQlWLIX_dUng==",
"ServerProof": "jZaSvHT6HKSZ2Vl41Q5Po/23KVqEagw1nmgwBDcLLgxzU0QMxVHpZBdiujknpVVV3kAZ9QALiHieoo8yGELpPyrWrIwP38Dw4iT9UL1tprPj2pAhJ3ZsPjQR1peamS1YiJXIbky/FraXEjD50Q/3bSAPP1B2LWJN6s+lrba//Dsp8y6Vp4sEQ2BShrkBTwY3U9/bJ0oaE1Z/j5lN9I6JNmVyFGNW76icU7SfSnYdSiCd//FgkfVtyexYRmNgg9UxbAz7M7wjCyrQGuTVSF5/YzdUp+VxBVosaEh6H0AH4PfP49o85vrgMYBim0ixjm7Eh3xJTuqgbxjzutrS08A4mw=="
}

View File

@ -0,0 +1,10 @@
{
"Code": 1000,
"AccessToken": "de0423049b44243afeec7d9c1d99be7b46da1e8a",
"ExpiresIn": 360000,
"TokenType": "Bearer",
"Uid": "differentUID",
"UID": "differentUID",
"Scope": "full mail payments reset keys",
"RefreshToken": "b894b4c4f20003f12d486900d8b88c7d68e67235"
}

View File

@ -0,0 +1,8 @@
{
"Code": 1000,
"AccessToken": "de0423049b44243afeec7d9c1d99be7b46da1e8a",
"ExpiresIn": 360000,
"TokenType": "Bearer",
"Scope": "full mail payments reset keys",
"RefreshToken": "b894b4c4f20003f12d486900d8b88c7d68e67235"
}

View File

@ -0,0 +1,21 @@
{
"Code": 1000,
"Contact": {
"ID": "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==",
"Name": "Bob",
"UID": "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece",
"Size": 303,
"CreateTime": 1517416603,
"ModifyTime": 1517416656,
"ContactEmails": [
{
"ID": "14n6vuf1zbeo3zsYzgV471S6xJ9gzl7-VZ8tcOTQq6ifBlNEre0SUdUM7sXh6e2Q_4NhJZaU9c7jLdB1HCV6dA==",
"Name": "Bob",
"Email": "bob.changed.tester@protonmail.com",
"Defaults": 1,
"Order": 1,
"ContactID": "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ=="
}
]
}
}

View File

@ -0,0 +1,13 @@
{
"Code": 1000,
"KeySalts": [
{
"ID": "IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA==",
"KeySalt": "abc"
},
{
"ID": "_pm5NXefHCdfqXuHiQ_zcOsfC4rGaCwV4lxuJt5qCmZBh4RiQ0k5iA8wLLaphTWHWAETz-WDqjLDRXNWftciXw==",
"KeySalt": "abc"
}
]
}

View File

@ -0,0 +1,55 @@
{
"Code": 1000,
"Message": {
"ID": "AeUizgtA3H44qRgcr-HdBApwLiUhlQg5kB81mg_QalWotmQJIHep9OScWIo7Wu9pnYxM4RqQxJnr3BE4kh4y_Q==",
"Order": 851654,
"ConversationID": "FK4MKKIVJqOC9Pg_sAxCjNWf8PM9yGzrXO3eXq8sk5RJB6HtaRBNUEcnvJBrQVPAtrDSoTNq4Du3FpqIxyMhHQ==",
"Subject": "Welcome to ProtonMail!",
"Unread": 0,
"SenderAddress": "notify@protonmail.com",
"SenderName": "ProtonMail",
"Sender": {
"Address": "notify@protonmail.com",
"Name": "ProtonMail"
},
"ToList": [
{
"Address": "apple@protonmail.com",
"Name": ""
}
],
"Time": 1414098386,
"Size": 4398,
"NumAttachments": 1,
"ExpirationTime": 0,
"Flags": 1,
"SpamScore": 0,
"AddressID": "QMJs2dzTx7uqpH5PNgIzjULywU4gO9uMBhEMVFOAVJOoUml54gC0CCHtW9qYwzH-zYbZwMv3MFYncPjW1Usq7Q==",
"CCList": [],
"BCCList": [],
"ExternalID": null,
"Body": "<div>jeej saas<br></div><div><br></div><div class=\"protonmail_signature_block\"><div>Sent from <a href=\"https://protonmail.ch\">ProtonMail</a>, encrypted email based in Switzerland.<br></div><div><br></div></div>",
"Header": "Content-Description: an awesome email\r\nX-Mailer: CroutonMail\r\n",
"ReplyTo": {
"Address": "notify@protonmail.com",
"Name": "ProtonMail"
},
"Attachments": [
{
"ID": "y6uKIlc2HdoHPAwPSrvf7dXoZNMYvBgxshYUN67cY5DJjL2O8NYewuvGHcYvCfd8LpEoAI_GdymO0Jr0mHlsEw==",
"Name": "croutonmail.txt",
"Size": 77,
"MIMEType": "text/plain",
"KeyPackets": "wcBMA0fcZ7XLgmf2AQgAiRsOlnm1kSB4/lr7tYe6pBsRGn10GqwUhrwU5PMKOHdCgnO12jO3y3CzP0Yl/jGhAYja9wLDqH8X0sk3tY32u4Sb1Qe5IuzggAiCa4dwOJj5gEFMTHMzjIMPHR7A70XqUxMhmILye8V4KRm/j4c1sxbzA1rM3lYBumQuB5l/ck0Kgt4ZqxHVXHK5Q1l65FHhSXRj8qnunasHa30TYNzP8nmBA8BinnJxpiQ7FGc2umnUhgkFtjm5ixu9vyjr9ukwDTbwAXXfmY+o7tK7kqIXJcmTL6k2UeC6Mz1AagQtRCRtU+bv/3zGojq/trZo9lom3naIeQYa36Ketmcpj2Qwjg==",
"Headers": {
"content-description": "You'll never believe what's in this text file"
},
"MessageID": "h3CD-DT7rLoAw1vmpcajvIPAl-wwDfXR2MHtWID3wuQURDBKTiGUAwd6E2WBbS44QQKeXImW-axm6X0hAfcVCA=="
}
],
"LabelIDs": [
"10",
"0"
]
}
}

View File

@ -0,0 +1,17 @@
{
"Code": 1001,
"Responses": [
{
"ID": "LKJLalkfejl==",
"Response": {
"Code": 1000
}
},
{
"ID": "ASia83sJaL==",
"Response": {
"Code": 1000
}
}
]
}

View File

@ -0,0 +1,27 @@
{
"Code": 1000,
"User": {
"ID": "MJLke8kWh1BBvG95JBIrZvzpgsZ94hNNgjNHVyhXMiv4g9cn6SgvqiIFR5cigpml2LD_iUk_3DkV29oojTt3eA==",
"Name": "jason",
"UsedSpace": 96691332,
"Currency": "USD",
"Credit": 0,
"MaxSpace": 10737418240,
"MaxUpload": 26214400,
"Role": 2,
"Private": 1,
"Subscribed": 1,
"Services": 1,
"Delinquent": 0,
"Keys": [
{
"ID": "IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA==",
"Version": 3,
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\r\nVersion: OpenPGP.js v0.7.1\r\nComment: http://openpgpjs.org\r\n\r\n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\nqqGRQm3MxoTdgQUShAwbNwNNQR9cInfMnA==\r\n=2wIY\r\n-----END PGP PRIVATE KEY BLOCK-----\r\n",
"Fingerprint": "c93f767df53b0ca8395cfde90483475164ec6353",
"Activation": null,
"Primary": 1
}
]
}
}

4
pkg/pmapi/testdata/symmetric_key.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"Key": "ExXmnSiQ2QCey20YLH6qlLhkY3xnIBC1AwlIXwK/HvY=",
"Algo": "aes256"
}

63
pkg/pmapi/testdata/testPrivateKey vendored Normal file
View File

@ -0,0 +1,63 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----
Version: OpenPGP.js v0.7.1
Comment: http://openpgpjs.org
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
qqGRQm3MxoTdgQUShAwbNwNNQR9cInfMnA==
=2wIY
-----END PGP PRIVATE KEY BLOCK-----

63
pkg/pmapi/testdata/testPrivateKeyLegacy vendored Normal file
View File

@ -0,0 +1,63 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----
Version: OpenPGP.js v0.9.0
Comment: http://openpgpjs.org
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
PPekG4ttm6z3/BqqVplanIRSXlsqyp6J8A==
=Pyb1
-----END PGP PRIVATE KEY BLOCK-----

33
pkg/pmapi/testdata/testPublicKey vendored Normal file
View File

@ -0,0 +1,33 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: OpenPGP.js v0.7.1
Comment: http://openpgpjs.org
xsBNBFRJbc0BCAC0mMLZPDBbtSCWvxwmOfXfJkE2+ssM3ux21LhD/bPiWefE
WSHlCjJ8PqPHy7snSiUuxuj3f9AvXPvg+mjGLBwu1/QsnSP24sl3qD2onl39
vPiLJXUqZs20ZRgnvX70gjkgEzMFBxINiy2MTIG+4RU8QA7y8KzWev0btqKi
MeVa+GLEHhgZ2KPOn4Jv1q4bI9hV0C9NUe2tTXS6/Vv3vbCY7lRR0kbJ65T5
c8CmpqJuASIJNrSXM/Q3NnnsY4kBYH0s5d2FgbASQvzrjuC2rngUg0EoPsrb
DEVRA2/BCJonw7aASiNCrSP92lkZdtYlax/pcoE/mQ4WSwySFmcFT7yFABEB
AAHNBlVzZXJJRMLAcgQQAQgAJgUCVEltzwYLCQgHAwIJED62JZ7fId8kBBUI
AgoDFgIBAhsDAh4BAAD0nQf9EtH9TC0JqSs8q194Zo244jjlJFM3EzxOSULq
0zbywlLORfyoo/O8jU/HIuGz+LT98JDtnltTqfjWgu6pS3ZL2/L4AGUKEoB7
OI6oIdRwzMc61sqI+Qpbzxo7rzufH4CiXZc6cxORUgL550xSCcqnq0q1mds7
h5roKDzxMW6WLiEsc1dN8IQKzC7Ec5wA7U4oNGsJ3TyI8jkIs0IhXrRCd26K
0TW8Xp6GCsfblWXosR13y89WVNgC+xrrJKTZEisc0tRlneIgjcwEUvwfIg2n
9cDUFA/5BsfzTW5IurxqDEziIVP0L44PXjtJrBQaGMPlEbtP5i2oi3OADVX2
XbvsRc7ATQRUSW3PAQgAkPnu5fps5zhOB/e618v/iF3KiogxUeRhA68TbvA+
xnFfTxCx2Vo14aOL0CnaJ8gO5yRSqfomL2O1kMq07N1MGbqucbmc+aSfoElc
+Gd5xBE/w3RcEhKcAaYTi35vG22zlZup4x3ElioyIarOssFEkQgNNyDf5AXZ
jdHLA6qVxeqAb/Ff74+y9HUmLPSsRU9NwFzvK3Jv8C/ubHVLzTYdFgYkc4W1
Uug9Ou08K+/4NEMrwnPFBbZdJAuUjQz2zW2ZiEKiBggiorH2o5N3mYUnWEmU
vqL3EOS8TbWo8UBIW3DDm2JiZR8VrEgvBtc9mVDUj/x+5pR07Fy1D6DjRmAc
9wARAQABwsBfBBgBCAATBQJUSW3SCRA+tiWe3yHfJAIbDAAA/iwH/ik9RKZM
B9Ir0x5mGpKPuqhugwrc3d04m1sOdXJm2NtD4ddzSEvzHwaPNvEvUl5v7FVM
zf6+6mYGWHyNP4+e7RtwYLlRpud6smuGyDSsotUYyumiqP6680ZIeWVQ+a1T
ThNs878mAJy1FhvQFdTmA8XIC616hDFpamQKPlpoO1a0wZnQhrPwT77HDYEE
a+hqY4Jr/a7ui40S+7xYRHKL/7ZAS4/grWllhU3dbNrwSzrOKwrA/U0/9t73
8Ap6JL71YymDeaL4sutcoaahda1pTrMWePtrCltz6uySwbZs7GXoEzjX3EAH
+6qhkUJtzMaE3YEFEoQMGzcDTUEfXCJ3zJw=
=yT9U
-----END PGP PUBLIC KEY BLOCK-----

130
pkg/pmapi/users.go Normal file
View File

@ -0,0 +1,130 @@
// Copyright (c) 2020 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 (
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
"github.com/getsentry/raven-go"
)
// Role values.
const (
FreeUserRole = iota
PaidMemberRole
PaidAdminRole
)
// User status
const (
DeletedUser = 0
DisabledUser = 1
ActiveUser = 2
VPNAdminUser = 3
AdminUser = 4
SuperUser = 5
)
// Delinquent values.
const (
CurrentUser = iota
AvailableUser
OverdueUser
DelinquentUser
NoReceiveUser
)
// PMSignature values.
const (
PMSignatureDisabled = iota
PMSignatureEnabled
PMSignatureLocked
)
// User holds the user details.
type User struct {
ID string
Name string
UsedSpace int64
Currency string
Credit int
MaxSpace int64
MaxUpload int64
Role int
Private int
Subscribed int
Services int
VPN struct {
Status int
ExpirationTime int
PlanName string
MaxConnect int
MaxTier int
}
Deliquent int
Keys PMKeys
}
// UserRes holds structure of JSON response.
type UserRes struct {
Res
User *User
}
// KeyRing returns the (possibly unlocked) PMKeys KeyRing.
func (u *User) KeyRing() *pmcrypto.KeyRing {
return u.Keys.KeyRing
}
// UpdateUser retrieves details about user and loads its addresses.
func (c *Client) UpdateUser() (user *User, err error) {
req, err := NewRequest("GET", "/users", nil)
if err != nil {
return
}
var res UserRes
if err = c.DoJSON(req, &res); err != nil {
return
}
user, err = res.User, res.Err()
if err != nil {
return nil, err
}
c.user = user
c.log.Infoln("update user:", user.ID)
raven.SetUserContext(&raven.User{ID: user.ID})
var tmpList AddressList
if tmpList, err = c.GetAddresses(); err == nil {
c.addresses = tmpList
}
return user, err
}
// CurrentUser return currently active user or user will be updated.
func (c *Client) CurrentUser() (user *User, err error) {
if c.user != nil && len(c.addresses) != 0 {
user = c.user
return
}
return c.UpdateUser()
}

97
pkg/pmapi/users_test.go Normal file
View File

@ -0,0 +1,97 @@
// Copyright (c) 2020 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 (
"fmt"
"net/http"
"net/url"
"testing"
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
r "github.com/stretchr/testify/require"
)
var testCurrentUser = &User{
ID: "MJLke8kWh1BBvG95JBIrZvzpgsZ94hNNgjNHVyhXMiv4g9cn6SgvqiIFR5cigpml2LD_iUk_3DkV29oojTt3eA==",
Name: "jason",
UsedSpace: 96691332,
Currency: "USD",
Role: 2,
Subscribed: 1,
Services: 1,
MaxSpace: 10737418240,
MaxUpload: 26214400,
Private: 1,
Keys: *loadPMKeys(readTestFile("keyring_userKey_JSON", false)),
}
func routeGetUsers(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
Ok(tb, checkMethodAndPath(r, "GET", "/users"))
Ok(tb, isAuthReq(r, testUID, testAccessToken))
return "users/get_response.json"
}
const testPublicKeysBody = `{
"Code": 1000,
"RecipientType": 1,
"MIMEType": "text/html",
"Keys": [
{ "Flags": 3, "PublicKey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: OpenPGP.js v0.7.1\nComment: http://openpgpjs.org\n\nxsBNBFSI0BMBB/9td6B5RDzVSFTlFzYOS4JxIb5agtNW1rbA4FeLoC47bGLR\n8E42IA6aKcO4H0vOZ1lFms0URiKk1DjCMXn3AUErbxqiV5IATRZLwliH6vwy\nPI6j5rtGF8dyxYfwmLtoUNkDcPdcFEb4NCdowsN7e8tKU0bcpouZcQhAqawC\n9nEdaG/gS5w+2k4hZX2lOKS1EF5SvP48UadlspEK2PLAIp5wB9XsFS9ey2wu\nelzkSfDh7KUAlteqFGSMqIgYH62/gaKm+TcckfZeyiMHWFw6sfrcFQ3QOZPq\nahWt0Rn9XM5xBAxx5vW0oceuQ1vpvdfFlM5ix4gn/9w6MhmStaCee8/fABEB\nAAHNBlVzZXJJRMLAcgQQAQgAJgUCVIjQHQYLCQgHAwIJEASDR1Fk7GNTBBUI\nAgoDFgIBAhsDAh4BAADmhAf/Yt0mCfWqQ25NNGUN14pKKgnPm68zwj1SmMGa\npU7+7ItRpoFNaDwV5QYiQSLC1SvSb1ZeKoY928GPKfqYyJlBpTPL9zC1OHQj\n9+2yYauHjYW9JWQM7hst2S2LBcdiQPOs3ybWPaO9yaccV4thxKOCPvyClaS5\nb9T4Iv9GEVZQIUvArkwI8hyzIi6skRgxflGheq1O+S1W4Gzt2VtYvo8g8r6W\nGzAGMw2nrs2h0+vUr+dLDgIbFCTc5QU99d5jE/e5Hw8iqBxv9tqB1hVATf8T\nwC8aU5MTtxtabOiBgG0PsBs6oIwjFqEjpOIza2/AflPZfo7stp6IiwbwvTHo\n1NlHoM7ATQRUiNAdAQf/eOLJYxX4lUQUzrNQgASDNE8gJPj7ywcGzySyqr0Y\n5rbG57EjtKMIgZrpzJRpSCuRbBjfsltqJ5Q9TBAbPO+oR3rue0LqPKMnmr/q\nKsHswBJRfsb/dbktUNmv/f7R9IVyOuvyP6RgdGeloxdGNeWiZSA6AZYI+WGc\nxaOvVDPz8thtnML4G4MUhXxxNZ7JzQ0Lfz6mN8CCkblIP5xpcJsyRU7lUsGD\nEJGZX0JH/I8bRVN1Xu08uFinIkZyiXRJ5ZGgF3Dns6VbIWmbttY54tBELtk+\n5g9pNSl9qiYwiCdwuZrA//NmD3xlZIN8sG4eM7ZUibZ23vEq+bUt1++6Mpba\nGQARAQABwsBfBBgBCAATBQJUiNAfCRAEg0dRZOxjUwIbDAAAlpMH/085qZdO\nmGRAlbvViUNhF2rtHvCletC48WHGO1ueSh9VTxalkP21YAYLJ4JgJzArJ7tH\nlEeiKiHm8YU9KhLe11Yv/o3AiKIAQjJiQluvk+mWdMcddB4fBjL6ttMTRAXe\ngHnjtMoamHbSZdeUTUadv05Fl6ivWtpXlODG4V02YvDiGBUbDosdGXEqDtpT\ng6MYlj3QMvUiUNQvt7YGMJS8A9iQ9qBNzErgRW8L6CON2RmpQ/wgwP5nwUHz\nJjY51d82Vj8bZeI8LdsX41SPoUhyC7kmNYpw9ZRy7NlrCt8dBIOB4/BKEJ2G\nClW54lp9eeOfYTsdTSbn9VaSO0E6m2/Q4Tk=\n=WFtr\n-----END PGP PUBLIC KEY BLOCK-----"},
{ "Flags": 1, "PublicKey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: OpenPGP.js v0.7.1\nComment: http://openpgpjs.org\n\nxsBNBFSI0BMBB/9td6B5RDzVSFTlFzYOS4JxIb5agtNW1rbA4FeLoC47bGLR\n8E42IA6aKcO4H0vOZ1lFms0URiKk1DjCMXn3AUErbxqiV5IATRZLwliH6vwy\nPI6j5rtGF8dyxYfwmLtoUNkDcPdcFEb4NCdowsN7e8tKU0bcpouZcQhAqawC\n9nEdaG/gS5w+2k4hZX2lOKS1EF5SvP48UadlspEK2PLAIp5wB9XsFS9ey2wu\nelzkSfDh7KUAlteqFGSMqIgYH62/gaKm+TcckfZeyiMHWFw6sfrcFQ3QOZPq\nahWt0Rn9XM5xBAxx5vW0oceuQ1vpvdfFlM5ix4gn/9w6MhmStaCee8/fABEB\nAAHNBlVzZXJJRMLAcgQQAQgAJgUCVIjQHQYLCQgHAwIJEASDR1Fk7GNTBBUI\nAgoDFgIBAhsDAh4BAADmhAf/Yt0mCfWqQ25NNGUN14pKKgnPm68zwj1SmMGa\npU7+7ItRpoFNaDwV5QYiQSLC1SvSb1ZeKoY928GPKfqYyJlBpTPL9zC1OHQj\n9+2yYauHjYW9JWQM7hst2S2LBcdiQPOs3ybWPaO9yaccV4thxKOCPvyClaS5\nb9T4Iv9GEVZQIUvArkwI8hyzIi6skRgxflGheq1O+S1W4Gzt2VtYvo8g8r6W\nGzAGMw2nrs2h0+vUr+dLDgIbFCTc5QU99d5jE/e5Hw8iqBxv9tqB1hVATf8T\nwC8aU5MTtxtabOiBgG0PsBs6oIwjFqEjpOIza2/AflPZfo7stp6IiwbwvTHo\n1NlHoM7ATQRUiNAdAQf/eOLJYxX4lUQUzrNQgASDNE8gJPj7ywcGzySyqr0Y\n5rbG57EjtKMIgZrpzJRpSCuRbBjfsltqJ5Q9TBAbPO+oR3rue0LqPKMnmr/q\nKsHswBJRfsb/dbktUNmv/f7R9IVyOuvyP6RgdGeloxdGNeWiZSA6AZYI+WGc\nxaOvVDPz8thtnML4G4MUhXxxNZ7JzQ0Lfz6mN8CCkblIP5xpcJsyRU7lUsGD\nEJGZX0JH/I8bRVN1Xu08uFinIkZyiXRJ5ZGgF3Dns6VbIWmbttY54tBELtk+\n5g9pNSl9qiYwiCdwuZrA//NmD3xlZIN8sG4eM7ZUibZ23vEq+bUt1++6Mpba\nGQARAQABwsBfBBgBCAATBQJUiNAfCRAEg0dRZOxjUwIbDAAAlpMH/085qZdO\nmGRAlbvViUNhF2rtHvCletC48WHGO1ueSh9VTxalkP21YAYLJ4JgJzArJ7tH\nlEeiKiHm8YU9KhLe11Yv/o3AiKIAQjJiQluvk+mWdMcddB4fBjL6ttMTRAXe\ngHnjtMoamHbSZdeUTUadv05Fl6ivWtpXlODG4V02YvDiGBUbDosdGXEqDtpT\ng6MYlj3QMvUiUNQvt7YGMJS8A9iQ9qBNzErgRW8L6CON2RmpQ/wgwP5nwUHz\nJjY51d82Vj8bZeI8LdsX41SPoUhyC7kmNYpw9ZRy7NlrCt8dBIOB4/BKEJ2G\nClW54lp9eeOfYTsdTSbn9VaSO0E6m2/Q4Tk=\n=WFtr\n-----END PGP PUBLIC KEY BLOCK-----"}
]}`
func TestClient_CurrentUser(t *testing.T) {
finish, c := newTestServerCallbacks(t,
routeGetUsers,
routeGetAddresses,
)
defer finish()
c.uid = testUID
c.accessToken = testAccessToken
user, err := c.CurrentUser()
r.Nil(t, err)
// Ignore KeyRings during the check because they have unexported fields and cannot be compared
r.True(t, cmp.Equal(user, testCurrentUser, cmpopts.IgnoreTypes(&pmcrypto.KeyRing{})))
r.Nil(t, c.UnlockAddresses([]byte(testMailboxPassword)))
}
func TestClient_PublicKeys(t *testing.T) {
email := "jason@protonmail.com"
escaped := url.QueryEscape(email)
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Ok(t, checkMethodAndPath(r, "GET", "/keys?Email="+escaped))
fmt.Fprint(w, testPublicKeysBody)
}))
defer s.Close()
keys, err := c.PublicKeys([]string{email})
if err != nil {
t.Fatal("Expected no error while getting current user, got:", err)
}
if len(keys) != 1 || keys[escaped] == nil {
t.Fatalf("Expected only one key for %v, got %#v", email, keys)
}
}