Add asymmetric key support for MailServer requests (#1075)
* add Asymmetric Key support for MailServer requests * remove deprecated notice * fix linter * refactoring Whisper config related to MailServer * fix race condition
This commit is contained in:
parent
93210061ad
commit
38a60135b2
|
@ -28,7 +28,7 @@ func whisperConfig(nodeConfig *params.NodeConfig) (*params.NodeConfig, error) {
|
||||||
return nil, fmt.Errorf("password file: %v", err)
|
return nil, fmt.Errorf("password file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
whisperConfig.Password = string(password)
|
whisperConfig.MailServerPassword = string(password)
|
||||||
}
|
}
|
||||||
|
|
||||||
// firebase configuration
|
// firebase configuration
|
||||||
|
|
|
@ -20,6 +20,7 @@ import (
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -43,8 +44,8 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errDirectoryNotProvided = errors.New("data directory not provided")
|
errDirectoryNotProvided = errors.New("data directory not provided")
|
||||||
errPasswordNotProvided = errors.New("password is not specified")
|
errDecryptionMethodNotProvided = errors.New("decryption method is not provided")
|
||||||
// By default go-ethereum/metrics creates dummy metrics that don't register anything.
|
// By default go-ethereum/metrics creates dummy metrics that don't register anything.
|
||||||
// Real metrics are collected only if -metrics flag is set
|
// Real metrics are collected only if -metrics flag is set
|
||||||
requestProcessTimer = metrics.NewRegisteredTimer("mailserver/requestProcessTime", nil)
|
requestProcessTimer = metrics.NewRegisteredTimer("mailserver/requestProcessTime", nil)
|
||||||
|
@ -82,12 +83,14 @@ type dbImpl interface {
|
||||||
|
|
||||||
// WMailServer whisper mailserver.
|
// WMailServer whisper mailserver.
|
||||||
type WMailServer struct {
|
type WMailServer struct {
|
||||||
db dbImpl
|
db dbImpl
|
||||||
w *whisper.Whisper
|
w *whisper.Whisper
|
||||||
pow float64
|
pow float64
|
||||||
key []byte
|
filter *whisper.Filter
|
||||||
limit *limiter
|
|
||||||
tick *ticker
|
muLimiter sync.RWMutex
|
||||||
|
limiter *limiter
|
||||||
|
tick *ticker
|
||||||
}
|
}
|
||||||
|
|
||||||
// DBKey key to be stored on db.
|
// DBKey key to be stored on db.
|
||||||
|
@ -116,24 +119,26 @@ func (s *WMailServer) Init(shh *whisper.Whisper, config *params.WhisperConfig) e
|
||||||
return errDirectoryNotProvided
|
return errDirectoryNotProvided
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(config.Password) == 0 {
|
if len(config.MailServerPassword) == 0 && config.MailServerAsymKey == nil {
|
||||||
return errPasswordNotProvided
|
return errDecryptionMethodNotProvided
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := db.Open(config.DataDir, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open DB: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.db = db
|
|
||||||
s.w = shh
|
s.w = shh
|
||||||
s.pow = config.MinimumPoW
|
s.pow = config.MinimumPoW
|
||||||
|
|
||||||
if err := s.setupWhisperIdentity(config); err != nil {
|
if err := s.setupRequestMessageDecryptor(config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.setupLimiter(time.Duration(config.MailServerRateLimit) * time.Second)
|
s.setupLimiter(time.Duration(config.MailServerRateLimit) * time.Second)
|
||||||
|
|
||||||
|
// Open database in the last step in order not to init with error
|
||||||
|
// and leave the database open by accident.
|
||||||
|
database, err := db.Open(config.DataDir, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open DB: %s", err)
|
||||||
|
}
|
||||||
|
s.db = database
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -141,23 +146,33 @@ func (s *WMailServer) Init(shh *whisper.Whisper, config *params.WhisperConfig) e
|
||||||
// limit db cleanup.
|
// limit db cleanup.
|
||||||
func (s *WMailServer) setupLimiter(limit time.Duration) {
|
func (s *WMailServer) setupLimiter(limit time.Duration) {
|
||||||
if limit > 0 {
|
if limit > 0 {
|
||||||
s.limit = newLimiter(limit)
|
s.limiter = newLimiter(limit)
|
||||||
s.setupMailServerCleanup(limit)
|
s.setupMailServerCleanup(limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupWhisperIdentity setup the whisper identity (symkey) for the current mail
|
// setupRequestMessageDecryptor setup a Whisper filter to decrypt
|
||||||
// server.
|
// incoming Whisper requests.
|
||||||
func (s *WMailServer) setupWhisperIdentity(config *params.WhisperConfig) error {
|
func (s *WMailServer) setupRequestMessageDecryptor(config *params.WhisperConfig) error {
|
||||||
MailServerKeyID, err := s.w.AddSymKeyFromPassword(config.Password)
|
var filter whisper.Filter
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create symmetric key: %s", err)
|
if config.MailServerPassword != "" {
|
||||||
|
keyID, err := s.w.AddSymKeyFromPassword(config.MailServerPassword)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create symmetric key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
symKey, err := s.w.GetSymKey(keyID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("save symmetric key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filter = whisper.Filter{KeySym: symKey}
|
||||||
|
} else if config.MailServerAsymKey != nil {
|
||||||
|
filter = whisper.Filter{KeyAsym: config.MailServerAsymKey}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.key, err = s.w.GetSymKey(MailServerKeyID)
|
s.filter = &filter
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("save symmetric key: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -168,7 +183,7 @@ func (s *WMailServer) setupMailServerCleanup(period time.Duration) {
|
||||||
if s.tick == nil {
|
if s.tick == nil {
|
||||||
s.tick = &ticker{}
|
s.tick = &ticker{}
|
||||||
}
|
}
|
||||||
go s.tick.run(period, s.limit.deleteExpired)
|
go s.tick.run(period, s.limiter.deleteExpired)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the mailserver and its associated db connection.
|
// Close the mailserver and its associated db connection.
|
||||||
|
@ -244,13 +259,16 @@ func (s *WMailServer) DeliverMail(peer *whisper.Peer, request *whisper.Envelope)
|
||||||
// exceedsPeerRequests in case limit its been setup on the current server and limit
|
// exceedsPeerRequests in case limit its been setup on the current server and limit
|
||||||
// allows the query, it will store/update new query time for the current peer.
|
// allows the query, it will store/update new query time for the current peer.
|
||||||
func (s *WMailServer) exceedsPeerRequests(peer []byte) bool {
|
func (s *WMailServer) exceedsPeerRequests(peer []byte) bool {
|
||||||
if s.limit != nil {
|
s.muLimiter.RLock()
|
||||||
|
defer s.muLimiter.RUnlock()
|
||||||
|
|
||||||
|
if s.limiter != nil {
|
||||||
peerID := string(peer)
|
peerID := string(peer)
|
||||||
if !s.limit.isAllowed(peerID) {
|
if !s.limiter.isAllowed(peerID) {
|
||||||
log.Info("peerID exceeded the number of requests per second")
|
log.Info("peerID exceeded the number of requests per second")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
s.limit.add(peerID)
|
s.limiter.add(peerID)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -345,8 +363,7 @@ func (s *WMailServer) validateRequest(peerID []byte, request *whisper.Envelope)
|
||||||
return false, 0, 0, nil, 0, nil
|
return false, 0, 0, nil, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
f := whisper.Filter{KeySym: s.key}
|
decrypted := request.Open(s.filter)
|
||||||
decrypted := request.Open(&f)
|
|
||||||
if decrypted == nil {
|
if decrypted == nil {
|
||||||
log.Warn("Failed to decrypt p2p request")
|
log.Warn("Failed to decrypt p2p request")
|
||||||
return false, 0, 0, nil, 0, nil
|
return false, 0, 0, nil, 0, nil
|
||||||
|
|
|
@ -23,6 +23,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -48,8 +49,6 @@ type ServerTestParams struct {
|
||||||
key *ecdsa.PrivateKey
|
key *ecdsa.PrivateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataDirPrefix = "whisper-server-test"
|
|
||||||
|
|
||||||
func TestMailserverSuite(t *testing.T) {
|
func TestMailserverSuite(t *testing.T) {
|
||||||
suite.Run(t, new(MailserverSuite))
|
suite.Run(t, new(MailserverSuite))
|
||||||
}
|
}
|
||||||
|
@ -67,86 +66,159 @@ func (s *MailserverSuite) SetupTest() {
|
||||||
s.shh = whisper.New(&whisper.DefaultConfig)
|
s.shh = whisper.New(&whisper.DefaultConfig)
|
||||||
s.shh.RegisterServer(s.server)
|
s.shh.RegisterServer(s.server)
|
||||||
|
|
||||||
var err error
|
tmpDir, err := ioutil.TempDir("", "mailserver-test")
|
||||||
s.dataDir, err = ioutil.TempDir("/tmp", dataDirPrefix)
|
s.Require().NoError(err)
|
||||||
if err != nil {
|
s.dataDir = tmpDir
|
||||||
s.FailNow("failed creating tmp data dir", err)
|
|
||||||
}
|
// required files to validate mail server decryption method
|
||||||
|
asymKeyFile := filepath.Join(tmpDir, "asymkey")
|
||||||
|
passwordFile := filepath.Join(tmpDir, "password")
|
||||||
|
privateKey, err := crypto.GenerateKey()
|
||||||
|
s.Require().NoError(err)
|
||||||
|
err = crypto.SaveECDSA(asymKeyFile, privateKey)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
err = ioutil.WriteFile(passwordFile, []byte("testpassword"), os.ModePerm)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
|
||||||
s.config = ¶ms.WhisperConfig{
|
s.config = ¶ms.WhisperConfig{
|
||||||
DataDir: s.dataDir,
|
DataDir: tmpDir,
|
||||||
Password: "pwd",
|
MailServerAsymKeyFile: asymKeyFile,
|
||||||
MailServerRateLimit: 5,
|
MailServerPasswordFile: passwordFile,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MailserverSuite) TearDownTest() {
|
func (s *MailserverSuite) TearDownTest() {
|
||||||
if s.dataDir != "" {
|
s.Require().NoError(os.RemoveAll(s.config.DataDir))
|
||||||
if err := os.RemoveAll(s.dataDir); err != nil {
|
|
||||||
fmt.Printf("couldn't remove temporary data dir %s: %v", s.dataDir, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MailserverSuite) TestInit() {
|
func (s *MailserverSuite) TestInit() {
|
||||||
|
asymKey, err := crypto.GenerateKey()
|
||||||
|
s.Require().NoError(err)
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
config params.WhisperConfig
|
config params.WhisperConfig
|
||||||
expectedError error
|
expectedError error
|
||||||
limiterActive bool
|
|
||||||
info string
|
info string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
config: params.WhisperConfig{DataDir: ""},
|
config: params.WhisperConfig{DataDir: ""},
|
||||||
expectedError: errDirectoryNotProvided,
|
expectedError: errDirectoryNotProvided,
|
||||||
limiterActive: false,
|
info: "config with empty DataDir",
|
||||||
info: "Initializing a mail server with a config with empty DataDir",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
config: params.WhisperConfig{DataDir: s.dataDir, Password: ""},
|
|
||||||
expectedError: errPasswordNotProvided,
|
|
||||||
limiterActive: false,
|
|
||||||
info: "Initializing a mail server with a config with an empty password",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
config: params.WhisperConfig{DataDir: "/invalid-path", Password: "pwd"},
|
|
||||||
expectedError: errors.New("open DB: mkdir /invalid-path: permission denied"),
|
|
||||||
limiterActive: false,
|
|
||||||
info: "Initializing a mail server with a config with an unexisting DataDir",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
config: *s.config,
|
|
||||||
expectedError: nil,
|
|
||||||
limiterActive: true,
|
|
||||||
info: "Initializing a mail server with a config with correct config and active limiter",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
config: params.WhisperConfig{
|
config: params.WhisperConfig{
|
||||||
DataDir: s.dataDir,
|
DataDir: "/invalid-path",
|
||||||
Password: "pwd",
|
MailServerPassword: "pwd",
|
||||||
|
},
|
||||||
|
expectedError: errors.New("open DB: mkdir /invalid-path: permission denied"),
|
||||||
|
info: "config with an unexisting DataDir",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
config: params.WhisperConfig{
|
||||||
|
DataDir: s.config.DataDir,
|
||||||
|
MailServerPassword: "",
|
||||||
|
MailServerAsymKey: nil,
|
||||||
|
},
|
||||||
|
expectedError: errDecryptionMethodNotProvided,
|
||||||
|
info: "config with an empty password and empty asym key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
config: params.WhisperConfig{
|
||||||
|
DataDir: s.config.DataDir,
|
||||||
|
MailServerPassword: "pwd",
|
||||||
},
|
},
|
||||||
expectedError: nil,
|
expectedError: nil,
|
||||||
limiterActive: false,
|
info: "config with correct DataDir and Password",
|
||||||
info: "Initializing a mail server with a config with empty DataDir and inactive limiter",
|
},
|
||||||
|
{
|
||||||
|
config: params.WhisperConfig{
|
||||||
|
DataDir: s.config.DataDir,
|
||||||
|
MailServerAsymKey: asymKey,
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
info: "config with correct DataDir and AsymKey",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
config: params.WhisperConfig{
|
||||||
|
DataDir: s.config.DataDir,
|
||||||
|
MailServerAsymKey: asymKey,
|
||||||
|
MailServerPassword: "pwd",
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
info: "config with both asym key and password",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
config: params.WhisperConfig{
|
||||||
|
DataDir: s.config.DataDir,
|
||||||
|
MailServerPassword: "pwd",
|
||||||
|
MailServerRateLimit: 5,
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
info: "config with rate limit",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
s.T().Run(tc.info, func(*testing.T) {
|
s.T().Run(tc.info, func(*testing.T) {
|
||||||
s.server.limit = nil
|
mailServer := &WMailServer{}
|
||||||
err := s.server.Init(s.shh, &tc.config)
|
shh := whisper.New(&whisper.DefaultConfig)
|
||||||
s.server.tick = nil
|
shh.RegisterServer(mailServer)
|
||||||
s.server.Close()
|
|
||||||
|
err := mailServer.Init(shh, &tc.config)
|
||||||
s.Equal(tc.expectedError, err)
|
s.Equal(tc.expectedError, err)
|
||||||
s.Equal(tc.limiterActive, (s.server.limit != nil))
|
defer mailServer.Close()
|
||||||
|
|
||||||
|
// db should be open only if there was no error
|
||||||
|
if tc.expectedError == nil {
|
||||||
|
s.NotNil(mailServer.db)
|
||||||
|
} else {
|
||||||
|
s.Nil(mailServer.db)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.config.MailServerRateLimit > 0 {
|
||||||
|
s.NotNil(mailServer.limiter)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MailserverSuite) TestSetupRequestMessageDecryptor() {
|
||||||
|
// without configured Password and AsymKey
|
||||||
|
config := *s.config
|
||||||
|
s.Error(errDecryptionMethodNotProvided, s.server.Init(s.shh, &config))
|
||||||
|
|
||||||
|
// Password should work ok
|
||||||
|
config = *s.config
|
||||||
|
s.NoError(config.ReadMailServerPasswordFile())
|
||||||
|
s.NoError(s.server.Init(s.shh, &config))
|
||||||
|
s.NotNil(s.server.filter.KeySym)
|
||||||
|
s.Nil(s.server.filter.KeyAsym)
|
||||||
|
s.server.Close()
|
||||||
|
|
||||||
|
// AsymKey can also be used
|
||||||
|
config = *s.config
|
||||||
|
s.NoError(config.ReadMailServerAsymKeyFile())
|
||||||
|
s.NoError(s.server.Init(s.shh, &config))
|
||||||
|
s.Nil(s.server.filter.KeySym) // important: symmetric key should be nil
|
||||||
|
s.Equal(config.MailServerAsymKey, s.server.filter.KeyAsym)
|
||||||
|
s.server.Close()
|
||||||
|
|
||||||
|
// when both Password and AsymKey are set, Password has a preference
|
||||||
|
config = *s.config
|
||||||
|
s.NoError(config.ReadMailServerPasswordFile())
|
||||||
|
s.NoError(config.ReadMailServerAsymKeyFile())
|
||||||
|
s.NoError(s.server.Init(s.shh, &config))
|
||||||
|
s.NotNil(s.server.filter.KeySym)
|
||||||
|
s.Nil(s.server.filter.KeyAsym)
|
||||||
|
s.server.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MailserverSuite) TestArchive() {
|
func (s *MailserverSuite) TestArchive() {
|
||||||
err := s.server.Init(s.shh, s.config)
|
err := s.config.ReadMailServerPasswordFile()
|
||||||
s.server.tick = nil
|
s.Require().NoError(err)
|
||||||
s.NoError(err)
|
|
||||||
|
err = s.server.Init(s.shh, s.config)
|
||||||
|
s.Require().NoError(err)
|
||||||
defer s.server.Close()
|
defer s.server.Close()
|
||||||
|
|
||||||
env, err := generateEnvelope(time.Now())
|
env, err := generateEnvelope(time.Now())
|
||||||
|
@ -163,15 +235,15 @@ func (s *MailserverSuite) TestArchive() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MailserverSuite) TestManageLimits() {
|
func (s *MailserverSuite) TestManageLimits() {
|
||||||
s.server.limit = newLimiter(time.Duration(5) * time.Millisecond)
|
s.server.limiter = newLimiter(time.Duration(5) * time.Millisecond)
|
||||||
s.False(s.server.exceedsPeerRequests([]byte("peerID")))
|
s.False(s.server.exceedsPeerRequests([]byte("peerID")))
|
||||||
s.Equal(1, len(s.server.limit.db))
|
s.Equal(1, len(s.server.limiter.db))
|
||||||
firstSaved := s.server.limit.db["peerID"]
|
firstSaved := s.server.limiter.db["peerID"]
|
||||||
|
|
||||||
// second call when limit is not accomplished does not store a new limit
|
// second call when limit is not accomplished does not store a new limit
|
||||||
s.True(s.server.exceedsPeerRequests([]byte("peerID")))
|
s.True(s.server.exceedsPeerRequests([]byte("peerID")))
|
||||||
s.Equal(1, len(s.server.limit.db))
|
s.Equal(1, len(s.server.limiter.db))
|
||||||
s.Equal(firstSaved, s.server.limit.db["peerID"])
|
s.Equal(firstSaved, s.server.limiter.db["peerID"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MailserverSuite) TestDBKey() {
|
func (s *MailserverSuite) TestDBKey() {
|
||||||
|
@ -392,7 +464,11 @@ func (s *MailserverSuite) setupServer(server *WMailServer) {
|
||||||
s.shh = whisper.New(&whisper.DefaultConfig)
|
s.shh = whisper.New(&whisper.DefaultConfig)
|
||||||
s.shh.RegisterServer(server)
|
s.shh.RegisterServer(server)
|
||||||
|
|
||||||
err := server.Init(s.shh, ¶ms.WhisperConfig{DataDir: s.dataDir, Password: password, MinimumPoW: powRequirement})
|
err := server.Init(s.shh, ¶ms.WhisperConfig{
|
||||||
|
DataDir: s.dataDir,
|
||||||
|
MailServerPassword: password,
|
||||||
|
MinimumPoW: powRequirement,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.T().Fatal(err)
|
s.T().Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
package mailserver
|
package mailserver
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type ticker struct {
|
type ticker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
timeTicker *time.Ticker
|
timeTicker *time.Ticker
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,14 +15,19 @@ func (t *ticker) run(period time.Duration, fn func()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t.timeTicker = time.NewTicker(period)
|
tt := time.NewTicker(period)
|
||||||
|
t.mu.Lock()
|
||||||
|
t.timeTicker = tt
|
||||||
|
t.mu.Unlock()
|
||||||
go func() {
|
go func() {
|
||||||
for range t.timeTicker.C {
|
for range tt.C {
|
||||||
fn()
|
fn()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ticker) stop() {
|
func (t *ticker) stop() {
|
||||||
|
t.mu.RLock()
|
||||||
t.timeTicker.Stop()
|
t.timeTicker.Stop()
|
||||||
|
t.mu.RUnlock()
|
||||||
}
|
}
|
||||||
|
|
37
node/node.go
37
node/node.go
|
@ -189,6 +189,28 @@ func activateStatusService(stack *node.Node, config *params.NodeConfig) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerMailServer(whisperService *whisper.Whisper, config *params.WhisperConfig) (err error) {
|
||||||
|
// if the Password is already set, do not override it
|
||||||
|
if config.MailServerPassword == "" && config.MailServerPasswordFile != "" {
|
||||||
|
err = config.ReadMailServerPasswordFile()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// similarly, do not override already configured AsymKey
|
||||||
|
if config.MailServerAsymKey == nil && config.MailServerAsymKeyFile != "" {
|
||||||
|
err = config.ReadMailServerAsymKeyFile()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var mailServer mailserver.WMailServer
|
||||||
|
whisperService.RegisterServer(&mailServer)
|
||||||
|
|
||||||
|
return mailServer.Init(whisperService, config)
|
||||||
|
}
|
||||||
|
|
||||||
// activateShhService configures Whisper and adds it to the given node.
|
// activateShhService configures Whisper and adds it to the given node.
|
||||||
func activateShhService(stack *node.Node, config *params.NodeConfig, db *leveldb.DB) (err error) {
|
func activateShhService(stack *node.Node, config *params.NodeConfig, db *leveldb.DB) (err error) {
|
||||||
if config.WhisperConfig == nil || !config.WhisperConfig.Enabled {
|
if config.WhisperConfig == nil || !config.WhisperConfig.Enabled {
|
||||||
|
@ -223,19 +245,8 @@ func activateShhService(stack *node.Node, config *params.NodeConfig, db *leveldb
|
||||||
|
|
||||||
// enable mail service
|
// enable mail service
|
||||||
if config.WhisperConfig.EnableMailServer {
|
if config.WhisperConfig.EnableMailServer {
|
||||||
if config.WhisperConfig.Password == "" {
|
if err := registerMailServer(whisperService, config.WhisperConfig); err != nil {
|
||||||
if err := config.WhisperConfig.ReadPasswordFile(); err != nil {
|
return nil, fmt.Errorf("failed to register MailServer: %v", err)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Register MailServer")
|
|
||||||
|
|
||||||
var mailServer mailserver.WMailServer
|
|
||||||
whisperService.RegisterServer(&mailServer)
|
|
||||||
err := mailServer.Init(whisperService, config.WhisperConfig)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ package params
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/ecdsa"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -11,8 +12,10 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum/core"
|
"github.com/ethereum/go-ethereum/core"
|
||||||
|
"github.com/ethereum/go-ethereum/crypto"
|
||||||
"github.com/ethereum/go-ethereum/log"
|
"github.com/ethereum/go-ethereum/log"
|
||||||
"github.com/ethereum/go-ethereum/p2p/discv5"
|
"github.com/ethereum/go-ethereum/p2p/discv5"
|
||||||
|
|
||||||
"github.com/status-im/status-go/static"
|
"github.com/status-im/status-go/static"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -85,13 +88,6 @@ type WhisperConfig struct {
|
||||||
// Enabled flag specifies whether protocol is enabled
|
// Enabled flag specifies whether protocol is enabled
|
||||||
Enabled bool
|
Enabled bool
|
||||||
|
|
||||||
// PasswordFile contains a password for symmetric encryption with MailServer.
|
|
||||||
PasswordFile string
|
|
||||||
|
|
||||||
// Password for symmetric encryption with MailServer.
|
|
||||||
// (if no account file selected, then this password is used for symmetric encryption).
|
|
||||||
Password string
|
|
||||||
|
|
||||||
// LightClient should be true if the node should start with an empty bloom filter and not forward messages from other nodes
|
// LightClient should be true if the node should start with an empty bloom filter and not forward messages from other nodes
|
||||||
LightClient bool
|
LightClient bool
|
||||||
|
|
||||||
|
@ -105,6 +101,19 @@ type WhisperConfig struct {
|
||||||
// MinimumPoW minimum PoW for Whisper messages
|
// MinimumPoW minimum PoW for Whisper messages
|
||||||
MinimumPoW float64
|
MinimumPoW float64
|
||||||
|
|
||||||
|
// MailServerPasswordFile contains a password for symmetric encryption with MailServer.
|
||||||
|
MailServerPasswordFile string
|
||||||
|
|
||||||
|
// MailServerPassword for symmetric encryption with MailServer.
|
||||||
|
// (if no account file selected, then this password is used for symmetric encryption).
|
||||||
|
MailServerPassword string
|
||||||
|
|
||||||
|
// MailServerAsymKeyFile is a file with an asymmetric key to decrypt messages sent to MailServer.
|
||||||
|
MailServerAsymKeyFile string
|
||||||
|
|
||||||
|
// MailServerAsymKey is an asymmetric key to decrypt messages sent to MailServer.
|
||||||
|
MailServerAsymKey *ecdsa.PrivateKey
|
||||||
|
|
||||||
// RateLimit minimum time between queries to mail server per peer
|
// RateLimit minimum time between queries to mail server per peer
|
||||||
MailServerRateLimit int
|
MailServerRateLimit int
|
||||||
|
|
||||||
|
@ -121,13 +130,13 @@ type WhisperConfig struct {
|
||||||
EnableNTPSync bool
|
EnableNTPSync bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadPasswordFile reads and returns content of the password file
|
// ReadMailServerPasswordFile reads and returns content of the password file
|
||||||
func (c *WhisperConfig) ReadPasswordFile() error {
|
func (c *WhisperConfig) ReadMailServerPasswordFile() error {
|
||||||
if len(c.PasswordFile) == 0 {
|
if len(c.MailServerPasswordFile) == 0 {
|
||||||
return ErrNoPasswordFileValueSet
|
return ErrNoPasswordFileValueSet
|
||||||
}
|
}
|
||||||
|
|
||||||
password, err := ioutil.ReadFile(c.PasswordFile)
|
password, err := ioutil.ReadFile(c.MailServerPasswordFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -137,11 +146,17 @@ func (c *WhisperConfig) ReadPasswordFile() error {
|
||||||
return ErrEmptyPasswordFile
|
return ErrEmptyPasswordFile
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Password = string(password)
|
c.MailServerPassword = string(password)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadMailServerAsymKeyFile reads and returns a private key from a given file.
|
||||||
|
func (c *WhisperConfig) ReadMailServerAsymKeyFile() (err error) {
|
||||||
|
c.MailServerAsymKey, err = crypto.LoadECDSA(c.MailServerAsymKeyFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// String dumps config object as nicely indented JSON
|
// String dumps config object as nicely indented JSON
|
||||||
func (c *WhisperConfig) String() string {
|
func (c *WhisperConfig) String() string {
|
||||||
data, _ := json.MarshalIndent(c, "", " ") // nolint: gas
|
data, _ := json.MarshalIndent(c, "", " ") // nolint: gas
|
||||||
|
|
|
@ -28,6 +28,9 @@ var (
|
||||||
ErrInvalidMailServerPeer = errors.New("invalid mailServerPeer value")
|
ErrInvalidMailServerPeer = errors.New("invalid mailServerPeer value")
|
||||||
// ErrInvalidSymKeyID is returned when it fails to get a symmetric key.
|
// ErrInvalidSymKeyID is returned when it fails to get a symmetric key.
|
||||||
ErrInvalidSymKeyID = errors.New("invalid symKeyID value")
|
ErrInvalidSymKeyID = errors.New("invalid symKeyID value")
|
||||||
|
// ErrInvalidPublicKey is returned when public key can't be extracted
|
||||||
|
// from MailServer's nodeID.
|
||||||
|
ErrInvalidPublicKey = errors.New("can't extract public key")
|
||||||
)
|
)
|
||||||
|
|
||||||
// -----
|
// -----
|
||||||
|
@ -59,6 +62,9 @@ type MessagesRequest struct {
|
||||||
|
|
||||||
// SymKeyID is an ID of a symmetric key to authenticate to MailServer.
|
// SymKeyID is an ID of a symmetric key to authenticate to MailServer.
|
||||||
// It's derived from MailServer password.
|
// It's derived from MailServer password.
|
||||||
|
//
|
||||||
|
// It's also possible to authenticate request with MailServerPeer
|
||||||
|
// public key.
|
||||||
SymKeyID string `json:"symKeyID"`
|
SymKeyID string `json:"symKeyID"`
|
||||||
|
|
||||||
// Timeout is the time to live of the request specified in seconds.
|
// Timeout is the time to live of the request specified in seconds.
|
||||||
|
@ -128,17 +134,38 @@ func (api *PublicAPI) RequestMessages(_ context.Context, r MessagesRequest) (hex
|
||||||
return nil, fmt.Errorf("Query range is invalid: from > to (%d > %d)", r.From, r.To)
|
return nil, fmt.Errorf("Query range is invalid: from > to (%d > %d)", r.From, r.To)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
mailServerNode, err := discover.ParseNode(r.MailServerPeer)
|
mailServerNode, err := discover.ParseNode(r.MailServerPeer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%v: %v", ErrInvalidMailServerPeer, err)
|
return nil, fmt.Errorf("%v: %v", ErrInvalidMailServerPeer, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
symKey, err := shh.GetSymKey(r.SymKeyID)
|
var (
|
||||||
if err != nil {
|
symKey []byte
|
||||||
return nil, fmt.Errorf("%v: %v", ErrInvalidSymKeyID, err)
|
publicKey *ecdsa.PublicKey
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.SymKeyID != "" {
|
||||||
|
symKey, err = shh.GetSymKey(r.SymKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%v: %v", ErrInvalidSymKeyID, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
publicKey, err = mailServerNode.ID.Pubkey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%v: %v", ErrInvalidPublicKey, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
envelope, err := makeEnvelop(makePayload(r), symKey, api.service.nodeID, shh.MinPow(), now)
|
envelope, err := makeEnvelop(
|
||||||
|
makePayload(r),
|
||||||
|
symKey,
|
||||||
|
publicKey,
|
||||||
|
api.service.nodeID,
|
||||||
|
shh.MinPow(),
|
||||||
|
now,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -175,14 +202,27 @@ func (api *PublicAPI) ConfirmMessagesProcessed(messages []*whisper.Message) erro
|
||||||
// makeEnvelop makes an envelop for a historic messages request.
|
// makeEnvelop makes an envelop for a historic messages request.
|
||||||
// Symmetric key is used to authenticate to MailServer.
|
// Symmetric key is used to authenticate to MailServer.
|
||||||
// PK is the current node ID.
|
// PK is the current node ID.
|
||||||
func makeEnvelop(payload []byte, symKey []byte, nodeID *ecdsa.PrivateKey, pow float64, now time.Time) (*whisper.Envelope, error) {
|
func makeEnvelop(
|
||||||
|
payload []byte,
|
||||||
|
symKey []byte,
|
||||||
|
publicKey *ecdsa.PublicKey,
|
||||||
|
nodeID *ecdsa.PrivateKey,
|
||||||
|
pow float64,
|
||||||
|
now time.Time,
|
||||||
|
) (*whisper.Envelope, error) {
|
||||||
params := whisper.MessageParams{
|
params := whisper.MessageParams{
|
||||||
PoW: pow,
|
PoW: pow,
|
||||||
Payload: payload,
|
Payload: payload,
|
||||||
KeySym: symKey,
|
|
||||||
WorkTime: defaultWorkTime,
|
WorkTime: defaultWorkTime,
|
||||||
Src: nodeID,
|
Src: nodeID,
|
||||||
}
|
}
|
||||||
|
// Either symKey or public key is required.
|
||||||
|
// This condition is verified in `message.Wrap()` method.
|
||||||
|
if len(symKey) > 0 {
|
||||||
|
params.KeySym = symKey
|
||||||
|
} else if publicKey != nil {
|
||||||
|
params.Dst = publicKey
|
||||||
|
}
|
||||||
message, err := whisper.NewSentMessage(¶ms)
|
message, err := whisper.NewSentMessage(¶ms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"github.com/ethereum/go-ethereum/node"
|
"github.com/ethereum/go-ethereum/node"
|
||||||
"github.com/ethereum/go-ethereum/p2p"
|
"github.com/ethereum/go-ethereum/p2p"
|
||||||
whisper "github.com/ethereum/go-ethereum/whisper/whisperv6"
|
whisper "github.com/ethereum/go-ethereum/whisper/whisperv6"
|
||||||
|
"github.com/status-im/status-go/t/helpers"
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -141,7 +142,7 @@ func (s *ShhExtSuite) TestWaitMessageExpired() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ShhExtSuite) TestRequestMessages() {
|
func (s *ShhExtSuite) TestRequestMessagesErrors() {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
shh := whisper.New(nil)
|
shh := whisper.New(nil)
|
||||||
|
@ -152,17 +153,14 @@ func (s *ShhExtSuite) TestRequestMessages() {
|
||||||
},
|
},
|
||||||
}) // in-memory node as no data dir
|
}) // in-memory node as no data dir
|
||||||
s.NoError(err)
|
s.NoError(err)
|
||||||
err = aNode.Register(func(_ *node.ServiceContext) (node.Service, error) {
|
err = aNode.Register(func(*node.ServiceContext) (node.Service, error) {
|
||||||
return shh, nil
|
return shh, nil
|
||||||
})
|
})
|
||||||
s.NoError(err)
|
s.NoError(err)
|
||||||
|
|
||||||
err = aNode.Start()
|
err = aNode.Start()
|
||||||
s.NoError(err)
|
s.NoError(err)
|
||||||
defer func() {
|
defer func() { s.NoError(aNode.Stop()) }()
|
||||||
err := aNode.Stop()
|
|
||||||
s.NoError(err)
|
|
||||||
}()
|
|
||||||
|
|
||||||
mock := newHandlerMock(1)
|
mock := newHandlerMock(1)
|
||||||
service := New(shh, mock, nil, false)
|
service := New(shh, mock, nil, false)
|
||||||
|
@ -182,6 +180,7 @@ func (s *ShhExtSuite) TestRequestMessages() {
|
||||||
// non-existent symmetric key
|
// non-existent symmetric key
|
||||||
hash, err = api.RequestMessages(context.TODO(), MessagesRequest{
|
hash, err = api.RequestMessages(context.TODO(), MessagesRequest{
|
||||||
MailServerPeer: mailServerPeer,
|
MailServerPeer: mailServerPeer,
|
||||||
|
SymKeyID: "invalid-sym-key-id",
|
||||||
})
|
})
|
||||||
s.Nil(hash)
|
s.Nil(hash)
|
||||||
s.EqualError(err, "invalid symKeyID value: non-existent key ID")
|
s.EqualError(err, "invalid symKeyID value: non-existent key ID")
|
||||||
|
@ -193,16 +192,39 @@ func (s *ShhExtSuite) TestRequestMessages() {
|
||||||
MailServerPeer: mailServerPeer,
|
MailServerPeer: mailServerPeer,
|
||||||
SymKeyID: symKeyID,
|
SymKeyID: symKeyID,
|
||||||
})
|
})
|
||||||
s.Contains(err.Error(), "Could not find peer with ID")
|
|
||||||
s.Nil(hash)
|
s.Nil(hash)
|
||||||
|
s.Contains(err.Error(), "Could not find peer with ID")
|
||||||
|
|
||||||
// from is greater than to
|
// from is greater than to
|
||||||
hash, err = api.RequestMessages(context.TODO(), MessagesRequest{
|
hash, err = api.RequestMessages(context.TODO(), MessagesRequest{
|
||||||
From: 10,
|
From: 10,
|
||||||
To: 5,
|
To: 5,
|
||||||
})
|
})
|
||||||
s.Contains(err.Error(), "Query range is invalid: from > to (10 > 5)")
|
|
||||||
s.Nil(hash)
|
s.Nil(hash)
|
||||||
|
s.Contains(err.Error(), "Query range is invalid: from > to (10 > 5)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShhExtSuite) TestRequestMessagesSuccess() {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
shh := whisper.New(nil)
|
||||||
|
aNode, err := node.New(&node.Config{
|
||||||
|
P2P: p2p.Config{
|
||||||
|
MaxPeers: math.MaxInt32,
|
||||||
|
NoDiscovery: true,
|
||||||
|
},
|
||||||
|
}) // in-memory node as no data dir
|
||||||
|
s.NoError(err)
|
||||||
|
err = aNode.Register(func(*node.ServiceContext) (node.Service, error) { return shh, nil })
|
||||||
|
s.NoError(err)
|
||||||
|
|
||||||
|
err = aNode.Start()
|
||||||
|
s.NoError(err)
|
||||||
|
defer func() { err := aNode.Stop(); s.NoError(err) }()
|
||||||
|
|
||||||
|
mock := newHandlerMock(1)
|
||||||
|
service := New(shh, mock, nil, false)
|
||||||
|
api := NewPublicAPI(service)
|
||||||
|
|
||||||
// with a peer acting as a mailserver
|
// with a peer acting as a mailserver
|
||||||
// prepare a node first
|
// prepare a node first
|
||||||
|
@ -214,29 +236,39 @@ func (s *ShhExtSuite) TestRequestMessages() {
|
||||||
},
|
},
|
||||||
}) // in-memory node as no data dir
|
}) // in-memory node as no data dir
|
||||||
s.NoError(err)
|
s.NoError(err)
|
||||||
err = mailNode.Register(func(_ *node.ServiceContext) (node.Service, error) {
|
err = mailNode.Register(func(*node.ServiceContext) (node.Service, error) {
|
||||||
return whisper.New(nil), nil
|
return whisper.New(nil), nil
|
||||||
})
|
})
|
||||||
s.NoError(err)
|
s.NoError(err)
|
||||||
err = mailNode.Start()
|
err = mailNode.Start()
|
||||||
s.NoError(err)
|
s.NoError(err)
|
||||||
defer func() {
|
defer func() { s.NoError(mailNode.Stop()) }()
|
||||||
err := mailNode.Stop()
|
|
||||||
s.NoError(err)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// add mailPeer as a peer
|
// add mailPeer as a peer
|
||||||
aNode.Server().AddPeer(mailNode.Server().Self())
|
aNode.Server().AddPeer(mailNode.Server().Self())
|
||||||
time.Sleep(time.Second) // wait for the peer to be added
|
waitErr := helpers.WaitForPeerAsync(aNode.Server(), mailNode.Server().Self().String(), p2p.PeerEventTypeAdd, time.Second)
|
||||||
|
s.NoError(<-waitErr)
|
||||||
|
|
||||||
// send a request
|
var hash []byte
|
||||||
|
|
||||||
|
// send a request with a symmetric key
|
||||||
|
symKeyID, symKeyErr := shh.AddSymKeyFromPassword("some-pass")
|
||||||
|
s.NoError(symKeyErr)
|
||||||
hash, err = api.RequestMessages(context.TODO(), MessagesRequest{
|
hash, err = api.RequestMessages(context.TODO(), MessagesRequest{
|
||||||
MailServerPeer: mailNode.Server().Self().String(),
|
MailServerPeer: mailNode.Server().Self().String(),
|
||||||
SymKeyID: symKeyID,
|
SymKeyID: symKeyID,
|
||||||
})
|
})
|
||||||
s.NoError(err)
|
s.NoError(err)
|
||||||
s.NotNil(hash)
|
s.NotNil(hash)
|
||||||
|
s.Contains(api.service.tracker.cache, common.BytesToHash(hash))
|
||||||
|
|
||||||
|
// Send a request without a symmetric key. In this case,
|
||||||
|
// a public key extracted from MailServerPeer will be used.
|
||||||
|
hash, err = api.RequestMessages(context.TODO(), MessagesRequest{
|
||||||
|
MailServerPeer: mailNode.Server().Self().String(),
|
||||||
|
})
|
||||||
|
s.NoError(err)
|
||||||
|
s.NotNil(hash)
|
||||||
s.Contains(api.service.tracker.cache, common.BytesToHash(hash))
|
s.Contains(api.service.tracker.cache, common.BytesToHash(hash))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -558,7 +558,7 @@ func (s *WhisperMailboxSuite) startMailboxBackend() (*api.StatusBackend, func())
|
||||||
mailboxConfig.WhisperConfig.Enabled = true
|
mailboxConfig.WhisperConfig.Enabled = true
|
||||||
mailboxConfig.KeyStoreDir = datadir
|
mailboxConfig.KeyStoreDir = datadir
|
||||||
mailboxConfig.WhisperConfig.EnableMailServer = true
|
mailboxConfig.WhisperConfig.EnableMailServer = true
|
||||||
mailboxConfig.WhisperConfig.PasswordFile = filepath.Join(RootDir, "/static/keys/wnodepassword")
|
mailboxConfig.WhisperConfig.MailServerPasswordFile = filepath.Join(RootDir, "/static/keys/wnodepassword")
|
||||||
mailboxConfig.WhisperConfig.DataDir = filepath.Join(datadir, "data")
|
mailboxConfig.WhisperConfig.DataDir = filepath.Join(datadir, "data")
|
||||||
mailboxConfig.DataDir = datadir
|
mailboxConfig.DataDir = datadir
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue