forked from Silverfish/proton-bridge
We build too many walls and not enough bridges
This commit is contained in:
309
pkg/pmapi/Changelog.md
Normal file
309
pkg/pmapi/Changelog.md
Normal 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
19
pkg/pmapi/Makefile
Normal 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
204
pkg/pmapi/addresses.go
Normal 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()
|
||||
}
|
||||
90
pkg/pmapi/addresses_test.go
Normal file
90
pkg/pmapi/addresses_test.go
Normal 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
264
pkg/pmapi/attachments.go
Normal 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
|
||||
}
|
||||
222
pkg/pmapi/attachments_test.go
Normal file
222
pkg/pmapi/attachments_test.go
Normal 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
506
pkg/pmapi/auth.go
Normal 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
366
pkg/pmapi/auth_test.go
Normal 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())
|
||||
}
|
||||
23
pkg/pmapi/auth_test_export.go
Normal file
23
pkg/pmapi/auth_test_export.go
Normal 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
217
pkg/pmapi/bugs.go
Normal 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
180
pkg/pmapi/bugs_test.go
Normal 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
503
pkg/pmapi/client.go
Normal 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
215
pkg/pmapi/client_test.go
Normal 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
43
pkg/pmapi/config.go
Normal 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
24
pkg/pmapi/config_dev.go
Normal 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
37
pkg/pmapi/config_local.go
Normal 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
25
pkg/pmapi/config_nopin.go
Normal 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
23
pkg/pmapi/conrep.go
Normal 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
430
pkg/pmapi/contacts.go
Normal 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
677
pkg/pmapi/contacts_test.go
Normal 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)
|
||||
}
|
||||
51
pkg/pmapi/conversations.go
Normal file
51
pkg/pmapi/conversations.go
Normal 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
|
||||
}
|
||||
373
pkg/pmapi/dialer_with_proxy.go
Normal file
373
pkg/pmapi/dialer_with_proxy.go
Normal 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
|
||||
}
|
||||
126
pkg/pmapi/dialer_with_proxy_test.go
Normal file
126
pkg/pmapi/dialer_with_proxy_test.go
Normal 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
237
pkg/pmapi/events.go
Normal 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
524
pkg/pmapi/events_test.go
Normal 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
157
pkg/pmapi/import.go
Normal 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
155
pkg/pmapi/import_test.go
Normal 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
138
pkg/pmapi/key.go
Normal 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
295
pkg/pmapi/keyring.go
Normal 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
94
pkg/pmapi/keyring_test.go
Normal 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
177
pkg/pmapi/labels.go
Normal 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
186
pkg/pmapi/labels_test.go
Normal 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
810
pkg/pmapi/messages.go
Normal 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
223
pkg/pmapi/messages_test.go
Normal 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---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---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
43
pkg/pmapi/metrics.go
Normal 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
43
pkg/pmapi/metrics_test.go
Normal 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
47
pkg/pmapi/passwords.go
Normal 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
62
pkg/pmapi/pmapi_test.go
Normal 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
304
pkg/pmapi/proxy.go
Normal 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
304
pkg/pmapi/proxy_test.go
Normal 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
90
pkg/pmapi/req.go
Normal 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
72
pkg/pmapi/res.go
Normal 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
173
pkg/pmapi/sentry.go
Normal 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
65
pkg/pmapi/sentry_test.go
Normal 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
166
pkg/pmapi/server_test.go
Normal 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
118
pkg/pmapi/settings.go
Normal 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()
|
||||
}
|
||||
22
pkg/pmapi/testdata/keyring_addressKeysPrimaryHasToken_JSON
vendored
Normal file
22
pkg/pmapi/testdata/keyring_addressKeysPrimaryHasToken_JSON
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
22
pkg/pmapi/testdata/keyring_addressKeysSecondaryHasToken_JSON
vendored
Normal file
22
pkg/pmapi/testdata/keyring_addressKeysSecondaryHasToken_JSON
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
12
pkg/pmapi/testdata/keyring_addressKeysWithTokens_JSON
vendored
Normal file
12
pkg/pmapi/testdata/keyring_addressKeysWithTokens_JSON
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
12
pkg/pmapi/testdata/keyring_addressKeysWithoutTokens_JSON
vendored
Normal file
12
pkg/pmapi/testdata/keyring_addressKeysWithoutTokens_JSON
vendored
Normal 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
62
pkg/pmapi/testdata/keyring_userKey
vendored
Normal file
@ -0,0 +1,62 @@
|
||||
-----BEGIN PGP PRIVATE KEY BLOCK-----
|
||||
Version: OpenPGP.js v4.4.5
|
||||
Comment: testpassphrase
|
||||
|
||||
xcLYBFzGzhEBCADBxfqTFMqfQzT77A5tuuhPFwPq8dfC2evs8u1OvTqFbztY
|
||||
5FOuSxzduyeDqQ1Fx6dKEOKgcYE8t1Uh4VSS7z6bTdY8j9yrL81kCVB46sE1
|
||||
OzStzyx/5l7OdH/pM4F+aKslnLvqlw0UeJr+UNizVtOCEUaNfVjPK3cc1ocx
|
||||
v+36K4RnnyfEtjUW9gDZbhgaF02G5ILHmWmbgM7I+77gCd2wI0EdY9s/JZQ+
|
||||
VmkMFqoMdY9PyBchoOIPUkkGQi1SaF4IEzMaAUSbnCYkHHY/SbfDTcR46VGq
|
||||
cXlkB1rq5xskaUQ9r+giCC/K4pc7bBkI1lQ7ADVuWvdrWnWapK0FO6CfABEB
|
||||
AAEAB/0YPhPJ0phA/EWviN+16bmGVOZNaVapjt2zMMybWmrtEQv3OeWgO3nP
|
||||
4cohRi/zaCBCphcm+dxbLhftW7AFi/9PVcR09436MB+oTCQFugpUWw+4TmA5
|
||||
BidxTpDxf4X2vH3rquQLBufWL6U7JlPeKAGL1xZ2aCq0DIeOk5D+xTjZizV2
|
||||
GIyQRVCLWb+LfDmvvcp3Y94X60KXdBAMuS1ZMKcY3Sl8VAXNB4KQsC/kByzf
|
||||
6FCB097XZRYV7lvJJQ7+6Wisb3yVi8sEQx2sFm5fAp+0qi3a6zRTEp49r6Hr
|
||||
gyWViH5zOOpA7DcNwx1Bwhi7GG0tak6EUnnKUNLfOupglcphBADmpXCgT4nc
|
||||
uSBYTiZSVcB/ICCkTxVsHL1WcXtPK2Ikzussx2n9kb0rapvuC0YLipX9lUkQ
|
||||
fyeC3jQJeCyN79AkDGkOfWaESueT2hM0Po+RwDgMibKn6yJ1zebz4Lc2J3C9
|
||||
oVFcAnql+9KyGsAPn03fyQzDnvhNnJvHJi4Hx8AWoQQA1xLoXeVBjRi0IjjU
|
||||
E6Mqaq5RLEog4kXRp86VSSEGHBwyIYnDiM//gjseo/CXuVyHwL7UXitp8s1B
|
||||
D1uE3APrhqUS66fD5pkF+z+RcSqiIv7I76NJ24Cdg38L6seGSjOHrq7/dEeG
|
||||
K6WqfQUCEjta3yNSg7pXb2wn2WZqKIK+rz8EALZRuMXeql/FtO3Cjb0sv7oT
|
||||
9dLP4cn1bskGRJ+Vok9lfCERbfXGccoAk3V+qSfpHgKxsebkRbUhf+trOGnw
|
||||
tW+kBWo/5hYGQuN+A9JogSJViT+nuZyE+x1/rKswDFmlMSdf2GIDARWIV0gc
|
||||
b1yOEwUmNBSthPcnFXvBr4BG3XTtNPTNLSJhcm9uMjEtM0BzYWRlbWJlLm9y
|
||||
ZyIgPGFyb24yMS0zQHNhZGVtYmUub3JnPsLAdQQQAQgAHwUCXMbOEQYLCQcI
|
||||
AwIEFQgKAgMWAgECGQECGwMCHgEACgkQZ/B4v2b2xB6XUgf/dHGRHimyMR78
|
||||
QYbEm2cuaEvOtq4a+J6Zv3P4VOWAbvkGWS9LDKSvVi60vq4oYOmF54HgPzur
|
||||
nA4OtZDf0HKwQK45VZ7CYD693o70jkKPrAAJG3yTsbesfiS7RbFyGKzKJ7EL
|
||||
nsUIJkfgm/SlKmXU/u8MOBO5Wg7/TcsS33sRWHl90j+9jbhqdl92R+vY/CwC
|
||||
ieFkQA7/TDv1u+NAalH+Lpkd8AIuEcki+TAogZ7oi/SnofwnoB7BxRm+mIkp
|
||||
ZZhIDSCaPOzLG8CSZ81d3HVHhqbf8dh0DFKFoUYyKdbOqIkNWWASf+c/ZEme
|
||||
IWcekY8hqwf/raZ56tGM/bRwYPcotMfC1wRcxs4RAQgAsMb5/ELWmrfPy3ba
|
||||
5qif+RXhGSbjitATNgHpoPUHrfTC7cn4JWHqehoXLAQpFAoKd+O/ZNpZozK9
|
||||
ilpqGUx05yMw06jNQEhYIbgIF4wzPpz02Lp6YeMwdF5LF+Rw83PHdHrA/wRV
|
||||
/QjL04+kZnN+G5HmzMlhFY+oZSpL+Gp1bTXgtAVDkhCnMB5tP2VwULMGyJ+X
|
||||
vRYxwTK2CrLjIVZv5n1VYY+caCowU6j/XFqvlCJj+G5oV+UhFOWffaMRXhOh
|
||||
a64RrhqT1Np7wCLvLMP2wpys9xlMcLQJLqDNxqOTp504V7dm67ncC0fKUsT4
|
||||
m4oTktnxKPd6MU+4VYveaLCquwARAQABAAf4u9s7gpGErs1USxmDO9TlyGZK
|
||||
aBlri8nMf3s+hOJCOo3cRaRHJBfdY6pu/baG6H6JTsWzeY4MHwr6N+dhVIEh
|
||||
FPMa9EZAjagyc4GugxWGiMVTfU+2AEfdrdynhQKMgXSctnnNCdkRuX0nwqb3
|
||||
nlupm1hsz2ze4+Wg0BKSLS0FQdoUbITdJUR69OHr4dNJVHWYI0JSBx4SdhV3
|
||||
y9163dDvmc+lW9AEaD53vyZWfzCHZxsR/gI32VmT0z5gn1t8w9AOdXo2lA1H
|
||||
bf7wh4/qCyujGu64ToZtiEny/GCyM6PofLtiZuJNLw3s/y+B2tKv22aTJ760
|
||||
+Gib1xB9WcWjKyrxBADoeCyq+nHGrl0CwOkmjanlFymgo7mnBOXuiFOvGrKk
|
||||
M1meMU1TI4TEBWkVnDVMcSejgjAf/bX1dtouba1tMAMu7DlaV/0EwbSADRel
|
||||
RSqEbIzIOys+y9TY/BMI/uCKNyEKHvu1KUXADb+CBpdBpCfMBWDANFlo9xLz
|
||||
Ajcmu2dyawQAwquwC0VXQcvzfs+Hd5au5XvHdm1KidOiAdu6PH1SrOgenIN4
|
||||
lkEjHrJD9jmloO2/GVcxDBB2pmf0B4HEg7DuY9LXBrksP5eSbbRc5+UH1HUv
|
||||
u82AqQnfNKTd/jae+lLwaOS++ohtwMkkD6W0LdWnHPjyyXg4Oi9zPID3asRu
|
||||
3PED/3CYyjl6S8GTMY4FNH7Yxu9+NV2xpKE92Hf8K/hnYlmSSVKDCEeOJtLt
|
||||
BkkcSqY6liCNSMmJdVyAF2GrR+zmDac7UQRssf57oOWtSsGozt0aqJXuspMT
|
||||
6aB+P1UhZ8Ly9rWZNiJ0jwyfnQNOLCYDaqjFmiSpqrNnJ2Q1Xge3+k80P9DC
|
||||
wF8EGAEIAAkFAlzGzhECGwwACgkQZ/B4v2b2xB5wlwgAjZA1zdv5irFjyWVo
|
||||
4/itONtyO1NbdpyYpcct7vD0oV+a4wahQP0J3Kk1GhZ5tvAoZF/jakQQOM5o
|
||||
GjUYpXAGnr09Mv9EiQ2pDwXc2yq0WfXnGxNrpzOqdtV+IqY9NYkl55Tme7x+
|
||||
WRvrkPSUeUsyEGvxwR1stdv8eg9jUmxdl8Io3PYoFJJlrM/6aXeC1r3KOj7q
|
||||
XAnR0XHJ+QBSNKCWLlQv5hui9BKfcLiVKFK/dNhs82nRyhPr4sWFw6MTqdAK
|
||||
4zkn7l0jmy6Evi1AiiGPiHPnxeNErnofOIEh4REQj00deZADHrixTLtx2FuR
|
||||
uaSC3IcBmBsj1fNb4eYXElILjQ==
|
||||
=fMOl
|
||||
-----END PGP PRIVATE KEY BLOCK-----
|
||||
10
pkg/pmapi/testdata/keyring_userKey_JSON
vendored
Normal file
10
pkg/pmapi/testdata/keyring_userKey_JSON
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
3
pkg/pmapi/testdata/routes/HTTP_200.json
vendored
Normal file
3
pkg/pmapi/testdata/routes/HTTP_200.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"Code": 1000
|
||||
}
|
||||
4
pkg/pmapi/testdata/routes/HTTP_401.json
vendored
Normal file
4
pkg/pmapi/testdata/routes/HTTP_401.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"Code": 5000,
|
||||
"Error": "Status unauthorized"
|
||||
}
|
||||
4
pkg/pmapi/testdata/routes/HTTP_402.json
vendored
Normal file
4
pkg/pmapi/testdata/routes/HTTP_402.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"Code": 5000,
|
||||
"Error": "Status payment required"
|
||||
}
|
||||
43
pkg/pmapi/testdata/routes/addresses/get_response.json
vendored
Normal file
43
pkg/pmapi/testdata/routes/addresses/get_response.json
vendored
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
5
pkg/pmapi/testdata/routes/auth/2fa/post_401_bad_password.json
vendored
Normal file
5
pkg/pmapi/testdata/routes/auth/2fa/post_401_bad_password.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"Code": 8002,
|
||||
"Error": "Incorrect login credentials.",
|
||||
"Details": {}
|
||||
}
|
||||
5
pkg/pmapi/testdata/routes/auth/2fa/post_422_bad_password.json
vendored
Normal file
5
pkg/pmapi/testdata/routes/auth/2fa/post_422_bad_password.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"Code": 8002,
|
||||
"Error": "Incorrect login credentials. Please try again",
|
||||
"Details": {}
|
||||
}
|
||||
4
pkg/pmapi/testdata/routes/auth/2fa/post_response.json
vendored
Normal file
4
pkg/pmapi/testdata/routes/auth/2fa/post_response.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"Code": 1000,
|
||||
"Scope": "full mail payments reset keys"
|
||||
}
|
||||
4
pkg/pmapi/testdata/routes/auth/delete_response.json
vendored
Normal file
4
pkg/pmapi/testdata/routes/auth/delete_response.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"Code": 1000
|
||||
}
|
||||
|
||||
65
pkg/pmapi/testdata/routes/auth/enc_priv_key.asc
vendored
Normal file
65
pkg/pmapi/testdata/routes/auth/enc_priv_key.asc
vendored
Normal 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-----
|
||||
|
||||
|
||||
15
pkg/pmapi/testdata/routes/auth/encrypted_access_token.asc
vendored
Normal file
15
pkg/pmapi/testdata/routes/auth/encrypted_access_token.asc
vendored
Normal 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-----
|
||||
13
pkg/pmapi/testdata/routes/auth/info/post_response.json
vendored
Normal file
13
pkg/pmapi/testdata/routes/auth/info/post_response.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
11
pkg/pmapi/testdata/routes/auth/post_response.json
vendored
Normal file
11
pkg/pmapi/testdata/routes/auth/post_response.json
vendored
Normal 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=="
|
||||
}
|
||||
10
pkg/pmapi/testdata/routes/auth/refresh/post_resp_has_uid.json
vendored
Normal file
10
pkg/pmapi/testdata/routes/auth/refresh/post_resp_has_uid.json
vendored
Normal 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"
|
||||
}
|
||||
8
pkg/pmapi/testdata/routes/auth/refresh/post_response.json
vendored
Normal file
8
pkg/pmapi/testdata/routes/auth/refresh/post_response.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Code": 1000,
|
||||
"AccessToken": "de0423049b44243afeec7d9c1d99be7b46da1e8a",
|
||||
"ExpiresIn": 360000,
|
||||
"TokenType": "Bearer",
|
||||
"Scope": "full mail payments reset keys",
|
||||
"RefreshToken": "b894b4c4f20003f12d486900d8b88c7d68e67235"
|
||||
}
|
||||
21
pkg/pmapi/testdata/routes/contacts/put_response.json
vendored
Normal file
21
pkg/pmapi/testdata/routes/contacts/put_response.json
vendored
Normal 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=="
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
13
pkg/pmapi/testdata/routes/keys/salts/get_response.json
vendored
Normal file
13
pkg/pmapi/testdata/routes/keys/salts/get_response.json
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"Code": 1000,
|
||||
"KeySalts": [
|
||||
{
|
||||
"ID": "IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA==",
|
||||
"KeySalt": "abc"
|
||||
},
|
||||
{
|
||||
"ID": "_pm5NXefHCdfqXuHiQ_zcOsfC4rGaCwV4lxuJt5qCmZBh4RiQ0k5iA8wLLaphTWHWAETz-WDqjLDRXNWftciXw==",
|
||||
"KeySalt": "abc"
|
||||
}
|
||||
]
|
||||
}
|
||||
55
pkg/pmapi/testdata/routes/messages/get_response.json
vendored
Normal file
55
pkg/pmapi/testdata/routes/messages/get_response.json
vendored
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
17
pkg/pmapi/testdata/routes/messages/label/put_response.json
vendored
Normal file
17
pkg/pmapi/testdata/routes/messages/label/put_response.json
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"Code": 1001,
|
||||
"Responses": [
|
||||
{
|
||||
"ID": "LKJLalkfejl==",
|
||||
"Response": {
|
||||
"Code": 1000
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "ASia83sJaL==",
|
||||
"Response": {
|
||||
"Code": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
27
pkg/pmapi/testdata/routes/users/get_response.json
vendored
Normal file
27
pkg/pmapi/testdata/routes/users/get_response.json
vendored
Normal 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
4
pkg/pmapi/testdata/symmetric_key.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"Key": "ExXmnSiQ2QCey20YLH6qlLhkY3xnIBC1AwlIXwK/HvY=",
|
||||
"Algo": "aes256"
|
||||
}
|
||||
63
pkg/pmapi/testdata/testPrivateKey
vendored
Normal file
63
pkg/pmapi/testdata/testPrivateKey
vendored
Normal 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
63
pkg/pmapi/testdata/testPrivateKeyLegacy
vendored
Normal 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
33
pkg/pmapi/testdata/testPublicKey
vendored
Normal 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
130
pkg/pmapi/users.go
Normal 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
97
pkg/pmapi/users_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user