publish threshold signer code
This commit is contained in:
parent
cb67dad18d
commit
8ce1083293
5
Makefile
5
Makefile
|
@ -2,11 +2,14 @@ GOLINT:=$(shell go list -f {{.Target}} golang.org/x/lint/golint)
|
|||
|
||||
all: build
|
||||
|
||||
build: build/signer
|
||||
build: build/signer build/key2shares
|
||||
|
||||
build/signer: cmd/signer/main.go $(wildcard internal/**/*.go)
|
||||
CGO_ENABLED=0 go build -o ./build/signer ${gobuild_flags} ./cmd/signer
|
||||
|
||||
build/key2shares: cmd/key2shares/main.go $(wildcard internal/**/*.go)
|
||||
CGO_ENABLED=0 go build -o ./build/key2shares ${gobuild_flags} ./cmd/key2shares
|
||||
|
||||
lint: tools
|
||||
@$(GOLINT) -set_exit_status ./...
|
||||
|
||||
|
|
82
README.md
82
README.md
|
@ -1,26 +1,61 @@
|
|||
# Tendermint Validator
|
||||
|
||||
A lightweight single key tendermint validator for sentry nodes.
|
||||
A [multi-party-computation](https://en.wikipedia.org/wiki/Secure_multi-party_computation) signing service for Tendermint nodes using threshold Ed25519 signatures.
|
||||
|
||||
## Design
|
||||
|
||||
A lightweight alternative to using a full node instance for validating blocks. The validator is able to connect to any number of sentry nodes and will sign blocks provided by the nodes. The validator maintains a watermark file to protect against double signing.
|
||||
Validator operators for tendermint chains balance operational and risk tradeoffs to avoid penalties via slashing for liveliness faults or double signing blocks.
|
||||
|
||||
Traditional high-availability systems where the keys exist on hot spares risk double signing if there are failover detection bugs. Low-availability systems, or manual failover, risk downtime if manual intervention cannot respond in a timely manner.
|
||||
|
||||
Multi-party computation using threshold signatures is able to provide high-availability while maintaining high security and avoiding double signing via failover detection bugs.
|
||||
|
||||
## Pre-requisites
|
||||
|
||||
Before starting, please make sure to fully understand node and validator requirements and operation for your particular network and chain.
|
||||
Before starting, please make sure to fully understand node and validator requirements and operation for your particular network and chain. Operation of the mpc tendermint validator assumes prior and detailed knowledge of these aspects.
|
||||
|
||||
## Setup
|
||||
|
||||
_The security of any key material is outside the scope of this guide. At a minimum we recommend performing key material steps on airgapped computers and using your audited security procedures._
|
||||
|
||||
### Setup Validator Instance
|
||||
### Create Shares
|
||||
|
||||
Configure the instance with a [toml](https://github.com/toml-lang/toml) file. Below is a sample configuration.
|
||||
Use the `key2shares` utility to split a single validator key into participant shares. Prior to splitting a key, decide on the total number of share holders and how many (threshold) are required to produce a valid signature.
|
||||
|
||||
Here we split an ed25519 validating key into 3 shares with a threshold of 2. Together, any 2 key shares can produce a valid signature but no single share can produce a valid signature for our initial key.
|
||||
|
||||
```bash
|
||||
key2shares --total 3 --threshold 2 /path/to/priv_validator_key.json
|
||||
```
|
||||
|
||||
`key2shares` writes the shares to the current working directory.
|
||||
|
||||
The above example would create 3 output files:
|
||||
|
||||
```
|
||||
private_share_1.json
|
||||
private_share_2.json
|
||||
private_share_3.json
|
||||
```
|
||||
|
||||
The `.json` files contain the private shares and new private RSA keys, one per party, as well as the public RSA keys of the other cosigners.
|
||||
|
||||
_The RSA keys are generated by key2shares and used to secure party-to-party communication._
|
||||
|
||||
### Setup Validator Instances
|
||||
|
||||
Each private share is installed to a separate tendermint mpc validator instance.
|
||||
|
||||
_The specifics of instance setup and security are outside the scope of this guide, but we recommend practices like geographic distribution of instances and limiting physical and remote instance access as appropriate._
|
||||
|
||||
Each instance has a [toml](https://github.com/toml-lang/toml) configuration file. Below is a sample file corresponding to instance `1`.
|
||||
|
||||
```toml
|
||||
# Path to priv validator key json file
|
||||
key_file = "/path/to/priv_validator_key.json"
|
||||
mode = "mpc"
|
||||
|
||||
# Each validator instance has its own private share.
|
||||
# Avoid putting more than one share per instance.
|
||||
key_file = "/path/to/private_share_1.json"
|
||||
|
||||
# The state directory stores watermarks for double signing protection.
|
||||
# Each validator instance maintains a watermark.
|
||||
|
@ -29,8 +64,28 @@ state_dir = "/path/to/state/dir"
|
|||
# The network chain id for your p2p nodes
|
||||
chain_id = "chain-id-here"
|
||||
|
||||
# The required number of participant share signatures.
|
||||
# This must match the `--threshold` value specified during key2shares
|
||||
cosigner_threshold = 2
|
||||
|
||||
# IP address and port for receiving communication from other validator instances.
|
||||
# The validator instances must communicate during the signing process.
|
||||
cosigner_listen_address = "tcp://0.0.0.0:1234"
|
||||
|
||||
# Each validator peer appears in a `cosigner` section.
|
||||
# This sample file is for validator ID 1, so we configure sections for peers 2 and 3.
|
||||
[[cosigner]]
|
||||
# The ID of this peer, these must match the key IDs.
|
||||
id = 2
|
||||
# The IP address and port for communication with this peer
|
||||
remote_address = "tcp://2.2.2.2:1234"
|
||||
|
||||
[[cosigner]]
|
||||
id = 3
|
||||
remote_address = "tcp://3.3.3.3:1234"
|
||||
|
||||
# Configure any number of p2p network nodes.
|
||||
# We recommend at least 2 nodes for redundancy.
|
||||
# We recommend at least 2 nodes per cosigner for redundancy.
|
||||
[[node]]
|
||||
address = "tcp://<node-a ip>:1234"
|
||||
|
||||
|
@ -38,11 +93,13 @@ address = "tcp://<node-a ip>:1234"
|
|||
address = "tcp://<node-b ip>:1234"
|
||||
```
|
||||
|
||||
Configuration for instances `2` and `3` would be similar. The `cosigner` sections would contain the respective peers, and the `node` sections would contain nodes for the cosigners.
|
||||
|
||||
## Configure p2p network nodes
|
||||
|
||||
Validators are not directly connected to the p2p network nor do they store chain and application state. They rely on nodes to receive blocks from the p2p network, make signing requests, and relay the signed blocks back to the p2p network.
|
||||
Mpc validators are not directly connected to the p2p network nor do they store chain and application state. They rely on nodes to receive blocks from the p2p network, make signing requests, and relay the signed blocks back to the p2p network.
|
||||
|
||||
To make a node available as a relay for a validator, find the `priv_validator_laddr` (or equivalent) configuration item in your node's configuration file. Change this value, to accept connections on an IP address and port of your choosing.
|
||||
To make a node available as a relay for an mpc validator, find the `priv_validator_laddr` (or equivalent) configuration item in your node's configuration file. Change this value, to accept connections on an IP address and port of your choosing.
|
||||
|
||||
```diff
|
||||
# TCP or UNIX socket address for Tendermint to listen on for
|
||||
|
@ -57,7 +114,7 @@ _We recommend hosting nodes on separate and isolated infrastructure from your va
|
|||
|
||||
## Launch validator
|
||||
|
||||
Once your validator instance and node is configured, you can launch the signer.
|
||||
Once your validator instance and node is configured, you can launch the signer. When `threshold` signers are online, they will start signing block requests from their network nodes.
|
||||
|
||||
```bash
|
||||
signer --config /path/to/config.toml
|
||||
|
@ -80,3 +137,6 @@ software or this license, under any kind of legal claim.
|
|||
|
||||
- https://docs.tendermint.com/master/tendermint-core/validators.html
|
||||
- https://hub.cosmos.network/master/validators/overview.html
|
||||
- [Provably Secure Distributed Schnorr Signatures
|
||||
and a (t, n) Threshold Scheme for Implicit
|
||||
Certificates](http://cacr.uwaterloo.ca/techreports/2001/corr2001-13.ps)
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
|
||||
"tendermint-signer/internal/signer"
|
||||
|
||||
amino "github.com/tendermint/go-amino"
|
||||
"github.com/tendermint/tendermint/crypto/ed25519"
|
||||
cryptoAmino "github.com/tendermint/tendermint/crypto/encoding/amino"
|
||||
cmn "github.com/tendermint/tendermint/libs/common"
|
||||
"github.com/tendermint/tendermint/privval"
|
||||
tsed25519 "gitlab.com/polychainlabs/threshold-ed25519/pkg"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var cdc = amino.NewCodec()
|
||||
cryptoAmino.RegisterAmino(cdc)
|
||||
|
||||
var threshold = flag.Int("threshold", 2, "the number of shares required to produce a valid signature")
|
||||
var total = flag.Int("total", 2, "the total number of shareholders")
|
||||
flag.Parse()
|
||||
|
||||
if len(flag.Args()) != 1 {
|
||||
log.Fatal("positional argument priv_validator_key.json is required")
|
||||
}
|
||||
|
||||
keyFilePath := flag.Args()[0]
|
||||
keyJSONBytes, err := ioutil.ReadFile(keyFilePath)
|
||||
if err != nil {
|
||||
cmn.Exit(err.Error())
|
||||
}
|
||||
pvKey := privval.FilePVKey{}
|
||||
err = cdc.UnmarshalJSON(keyJSONBytes, &pvKey)
|
||||
if err != nil {
|
||||
cmn.Exit(fmt.Sprintf("Error reading PrivValidator key from %v: %v\n", keyFilePath, err))
|
||||
}
|
||||
|
||||
privKeyBytes := [64]byte{}
|
||||
|
||||
// extract the raw private key bytes from the loaded key
|
||||
// we need this to compute the expanded secret
|
||||
switch ed25519Key := pvKey.PrivKey.(type) {
|
||||
case ed25519.PrivKeyEd25519:
|
||||
if len(ed25519Key) != len(privKeyBytes) {
|
||||
panic("Key length inconsistency")
|
||||
}
|
||||
copy(privKeyBytes[:], ed25519Key[:])
|
||||
break
|
||||
default:
|
||||
panic("Not an ed25519 private key")
|
||||
}
|
||||
|
||||
// generate shares from secret
|
||||
shares := tsed25519.DealShares(tsed25519.ExpandSecret(privKeyBytes[:32]), uint8(*threshold), uint8(*total))
|
||||
|
||||
// generate all rsa keys
|
||||
rsaKeys := make([]*rsa.PrivateKey, len(shares))
|
||||
pubkeys := make([]*rsa.PublicKey, len(shares))
|
||||
for idx := range shares {
|
||||
bitSize := 4096
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, bitSize)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
rsaKeys[idx] = rsaKey
|
||||
pubkeys[idx] = &rsaKey.PublicKey
|
||||
}
|
||||
|
||||
// write shares and keys to private share files
|
||||
for idx, share := range shares {
|
||||
shareID := idx + 1
|
||||
|
||||
privateFilename := fmt.Sprintf("private_share_%d.json", shareID)
|
||||
|
||||
cosignerKey := signer.CosignerKey{
|
||||
PubKey: pvKey.PubKey,
|
||||
ShareKey: share,
|
||||
ID: shareID,
|
||||
RSAKey: *rsaKeys[idx],
|
||||
CosignerKeys: pubkeys,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(&cosignerKey, "", " ")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = ioutil.WriteFile(privateFilename, jsonBytes, 0644)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("Created Share %d\n", shareID)
|
||||
}
|
||||
}
|
|
@ -15,6 +15,7 @@ import (
|
|||
cmn "github.com/tendermint/tendermint/libs/common"
|
||||
tmlog "github.com/tendermint/tendermint/libs/log"
|
||||
"github.com/tendermint/tendermint/privval"
|
||||
"github.com/tendermint/tendermint/types"
|
||||
)
|
||||
|
||||
func fileExists(filename string) bool {
|
||||
|
@ -44,6 +45,7 @@ func main() {
|
|||
|
||||
logger.Info(
|
||||
"Tendermint Validator",
|
||||
"mode", config.Mode,
|
||||
"priv-key", config.PrivValKeyFile,
|
||||
"priv-state-dir", config.PrivValStateDir,
|
||||
)
|
||||
|
@ -53,23 +55,119 @@ func main() {
|
|||
// services to stop on shutdown
|
||||
var services []cmn.Service
|
||||
|
||||
var pv types.PrivValidator
|
||||
|
||||
chainID := config.ChainID
|
||||
if chainID == "" {
|
||||
log.Fatal("chain_id option is required")
|
||||
}
|
||||
|
||||
stateFile := path.Join(config.PrivValStateDir, fmt.Sprintf("%s_priv_validator_state.json", chainID))
|
||||
if config.Mode == "single" {
|
||||
stateFile := path.Join(config.PrivValStateDir, fmt.Sprintf("%s_priv_validator_state.json", chainID))
|
||||
|
||||
if !fileExists(stateFile) {
|
||||
log.Fatalf("State file missing: %s\n", stateFile)
|
||||
var val types.PrivValidator
|
||||
if fileExists(stateFile) {
|
||||
val = privval.LoadFilePV(config.PrivValKeyFile, stateFile)
|
||||
} else {
|
||||
logger.Info("Initializing empty state file")
|
||||
val = privval.LoadFilePVEmptyState(config.PrivValKeyFile, stateFile)
|
||||
}
|
||||
|
||||
pv = &signer.PvGuard{PrivValidator: val}
|
||||
} else if config.Mode == "mpc" {
|
||||
if config.CosignerThreshold == 0 {
|
||||
log.Fatal("The `cosigner_threshold` option is required in `threshold` mode")
|
||||
}
|
||||
|
||||
if config.ListenAddress == "" {
|
||||
log.Fatal("The cosigner_listen_address option is required in `threshold` mode")
|
||||
}
|
||||
|
||||
key, err := signer.LoadCosignerKey(config.PrivValKeyFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// ok to auto initialize on disk since the cosigner share is the one that actually
|
||||
// protects against double sign - this exists as a cache for the final signature
|
||||
stateFile := path.Join(config.PrivValStateDir, fmt.Sprintf("%s_priv_validator_state.json", chainID))
|
||||
signState, err := signer.LoadOrCreateSignState(stateFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// state for our cosigner share
|
||||
// Not automatically initialized on disk to avoid double sign risk
|
||||
shareStateFile := path.Join(config.PrivValStateDir, fmt.Sprintf("%s_share_sign_state.json", chainID))
|
||||
shareSignState, err := signer.LoadSignState(shareStateFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
cosigners := []signer.Cosigner{}
|
||||
remoteCosigners := []signer.RemoteCosigner{}
|
||||
|
||||
// add ourselves as a peer so localcosigner can handle GetEphSecPart requests
|
||||
peers := []signer.CosignerPeer{signer.CosignerPeer{
|
||||
ID: key.ID,
|
||||
PublicKey: key.RSAKey.PublicKey,
|
||||
}}
|
||||
|
||||
for _, cosignerConfig := range config.Cosigners {
|
||||
cosigner := signer.NewRemoteCosigner(cosignerConfig.ID, cosignerConfig.Address)
|
||||
cosigners = append(cosigners, cosigner)
|
||||
remoteCosigners = append(remoteCosigners, *cosigner)
|
||||
|
||||
if cosignerConfig.ID < 1 || cosignerConfig.ID > len(key.CosignerKeys) {
|
||||
log.Fatalf("Unexpected cosigner ID %d", cosignerConfig.ID)
|
||||
}
|
||||
|
||||
pubKey := key.CosignerKeys[cosignerConfig.ID-1]
|
||||
peers = append(peers, signer.CosignerPeer{
|
||||
ID: cosigner.GetID(),
|
||||
PublicKey: *pubKey,
|
||||
})
|
||||
}
|
||||
|
||||
total := len(config.Cosigners) + 1
|
||||
localCosignerConfig := signer.LocalCosignerConfig{
|
||||
CosignerKey: key,
|
||||
SignState: &shareSignState,
|
||||
RsaKey: key.RSAKey,
|
||||
Peers: peers,
|
||||
Total: uint8(total),
|
||||
Threshold: uint8(config.CosignerThreshold),
|
||||
}
|
||||
|
||||
localCosigner := signer.NewLocalCosigner(localCosignerConfig)
|
||||
|
||||
val := signer.NewThresholdValidator(&signer.ThresholdValidatorOpt{
|
||||
Pubkey: key.PubKey,
|
||||
Threshold: config.CosignerThreshold,
|
||||
SignState: signState,
|
||||
Cosigner: localCosigner,
|
||||
Peers: cosigners,
|
||||
})
|
||||
|
||||
rpcServerConfig := signer.CosignerRpcServerConfig{
|
||||
Logger: logger,
|
||||
ListenAddress: config.ListenAddress,
|
||||
Cosigner: localCosigner,
|
||||
Peers: remoteCosigners,
|
||||
}
|
||||
|
||||
rpcServer := signer.NewCosignerRpcServer(&rpcServerConfig)
|
||||
rpcServer.Start()
|
||||
services = append(services, rpcServer)
|
||||
|
||||
pv = &signer.PvGuard{PrivValidator: val}
|
||||
} else {
|
||||
log.Fatalf("Unsupported mode: %s", config.Mode)
|
||||
}
|
||||
|
||||
val := privval.LoadFilePV(config.PrivValKeyFile, stateFile)
|
||||
pv := &signer.PvGuard{PrivValidator: val}
|
||||
|
||||
for _, node := range config.Nodes {
|
||||
dialer := net.Dialer{Timeout: 30 * time.Second}
|
||||
signer := signer.NewNodeClient(node.Address, logger, config.ChainID, pv, dialer)
|
||||
signer := signer.NewReconnRemoteSigner(node.Address, logger, config.ChainID, pv, dialer)
|
||||
|
||||
err := signer.Start()
|
||||
if err != nil {
|
||||
|
|
6
go.mod
6
go.mod
|
@ -11,11 +11,15 @@ require (
|
|||
github.com/gogo/protobuf v1.2.1 // indirect
|
||||
github.com/golang/protobuf v1.3.2 // indirect
|
||||
github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf // indirect
|
||||
github.com/gorilla/websocket v1.4.2 // indirect
|
||||
github.com/magiconair/properties v1.8.0 // indirect
|
||||
github.com/pkg/errors v0.8.1 // indirect
|
||||
github.com/stretchr/testify v1.3.0 // indirect
|
||||
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect
|
||||
github.com/stretchr/testify v1.3.0
|
||||
github.com/tendermint/go-amino v0.14.1
|
||||
github.com/tendermint/tendermint v0.31.5
|
||||
gitlab.com/polychainlabs/edwards25519 v0.0.0-20200206000358-2272e01758fb
|
||||
gitlab.com/polychainlabs/threshold-ed25519 v0.0.0-20200221030822-1c35a36a51c1
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 // indirect
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 // indirect
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894 // indirect
|
||||
|
|
15
go.sum
15
go.sum
|
@ -41,6 +41,8 @@ github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs
|
|||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf h1:+RRA9JqSOZFfKrOeqr2z77+8R2RKyh8PG66dcu1V0ck=
|
||||
github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||
|
@ -58,6 +60,8 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
|||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 h1:MkV+77GLUNo5oJ0jf870itWm3D0Sjh7+Za9gazKc5LQ=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
|
@ -65,6 +69,10 @@ github.com/tendermint/go-amino v0.14.1 h1:o2WudxNfdLNBwMyl2dqOJxiro5rfrEaU0Ugs6o
|
|||
github.com/tendermint/go-amino v0.14.1/go.mod h1:i/UKE5Uocn+argJJBb12qTZsCDBcAYMbR92AaJVmKso=
|
||||
github.com/tendermint/tendermint v0.31.5 h1:vTet8tCq3B9/J9Yo11dNZ8pOB7NtSy++bVSfkP4KzR4=
|
||||
github.com/tendermint/tendermint v0.31.5/go.mod h1:ymcPyWblXCplCPQjbOYbrF1fWnpslATMVqiGgWbZrlc=
|
||||
gitlab.com/polychainlabs/edwards25519 v0.0.0-20200206000358-2272e01758fb h1:AdKE1d1TA0YYg8FDChkZP/nFOQDOMOUxLYzac/eVYUU=
|
||||
gitlab.com/polychainlabs/edwards25519 v0.0.0-20200206000358-2272e01758fb/go.mod h1:3KUw8eOHmnX0bANpqw52h/JNsGJxfVIFyhIXH5zeprc=
|
||||
gitlab.com/polychainlabs/threshold-ed25519 v0.0.0-20200221030822-1c35a36a51c1 h1:zdHgoRHlPqZfRMew5hX7aPZuxNURbcobivw+4gMvipk=
|
||||
gitlab.com/polychainlabs/threshold-ed25519 v0.0.0-20200221030822-1c35a36a51c1/go.mod h1:jrwPQCmLa5H51H4qgUAZcigi2rz2kFLVEkGe3eNlzHw=
|
||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
|
||||
|
@ -73,6 +81,9 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL
|
|||
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3 h1:x/bBzNauLQAlE3fLku/xy92Y8QwKX5HZymrMz2IiKFc=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367 h1:0IiAsCRByjO2QjX7ZPkw5oU9x+n1YqRL802rjC0c3Aw=
|
||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
|
@ -82,6 +93,7 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwL
|
|||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e h1:o3PsSEY8E4eXWkXrIP9YJALUkVZqzHJT5DOasTyn8Vs=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
|
@ -98,6 +110,9 @@ golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGm
|
|||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b h1:qMK98NmNCRVDIYFycQ5yVRkvgDUFfdP8Ip4KqmDEB7g=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7 h1:EBZoQjiKKPaLbPrbpssUfuHtwM6KV/vb4U85g/cigFY=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 h1:Nw54tB0rB7hY/N0NQvRW8DG4Yk3Q6T9cu9RcFQDu1tc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
|
|
|
@ -16,6 +16,7 @@ type CosignerConfig struct {
|
|||
}
|
||||
|
||||
type Config struct {
|
||||
Mode string `toml:"mode"`
|
||||
PrivValKeyFile string `toml:"key_file"`
|
||||
PrivValStateDir string `toml:"state_dir"`
|
||||
ChainID string `toml:"chain_id"`
|
||||
|
@ -28,6 +29,9 @@ type Config struct {
|
|||
func LoadConfigFromFile(file string) (Config, error) {
|
||||
var config Config
|
||||
|
||||
// default mode is mpc
|
||||
config.Mode = "mpc"
|
||||
|
||||
reader, err := os.Open(file)
|
||||
if err != nil {
|
||||
return config, err
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
package signer
|
||||
|
||||
import "time"
|
||||
|
||||
// CosignerSignRequest is sent to a co-signer to obtain their signature for the SignBytes
|
||||
// The SignBytes should be a serialized block
|
||||
type CosignerSignRequest struct {
|
||||
SignBytes []byte
|
||||
}
|
||||
|
||||
type CosignerSignResponse struct {
|
||||
EphemeralPublic []byte
|
||||
Timestamp time.Time
|
||||
Signature []byte
|
||||
}
|
||||
|
||||
type CosignerGetEphemeralSecretPartRequest struct {
|
||||
ID int
|
||||
Height int64
|
||||
Round int64
|
||||
Step int8
|
||||
}
|
||||
|
||||
type CosignerHasEphemeralSecretPartRequest struct {
|
||||
ID int
|
||||
Height int64
|
||||
Round int64
|
||||
Step int8
|
||||
}
|
||||
|
||||
type CosignerHasEphemeralSecretPartResponse struct {
|
||||
Exists bool
|
||||
EphemeralSecretPublicKey []byte
|
||||
}
|
||||
|
||||
type CosignerGetEphemeralSecretPartResponse struct {
|
||||
SourceID int
|
||||
SourceEphemeralSecretPublicKey []byte
|
||||
EncryptedSharePart []byte
|
||||
SourceSig []byte
|
||||
}
|
||||
|
||||
type CosignerSetEphemeralSecretPartRequest struct {
|
||||
SourceID int
|
||||
SourceEphemeralSecretPublicKey []byte
|
||||
Height int64
|
||||
Round int64
|
||||
Step int8
|
||||
EncryptedSharePart []byte
|
||||
SourceSig []byte
|
||||
}
|
||||
|
||||
// Cosigner interface is a set of methods for an m-of-n threshold signature.
|
||||
// This interface abstracts the underlying key storage and management
|
||||
type Cosigner interface {
|
||||
// Get the ID of the cosigner
|
||||
// The ID is the shamir index: 1, 2, etc...
|
||||
GetID() int
|
||||
|
||||
// Get the ephemeral secret part for an ephemeral share
|
||||
// The ephemeral secret part is encrypted for the receiver
|
||||
GetEphemeralSecretPart(req CosignerGetEphemeralSecretPartRequest) (CosignerGetEphemeralSecretPartResponse, error)
|
||||
|
||||
// Store an ephemeral secret share part provided by another cosigner
|
||||
SetEphemeralSecretPart(req CosignerSetEphemeralSecretPartRequest) error
|
||||
|
||||
// Query whether the cosigner has an ehpemeral secret part set
|
||||
HasEphemeralSecretPart(req CosignerHasEphemeralSecretPartRequest) (CosignerHasEphemeralSecretPartResponse, error)
|
||||
|
||||
// Sign the requested bytes
|
||||
Sign(req CosignerSignRequest) (CosignerSignResponse, error)
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
package signer
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/tendermint/tendermint/crypto"
|
||||
tmed25519 "github.com/tendermint/tendermint/crypto/ed25519"
|
||||
)
|
||||
|
||||
// CosignerKey is a single key for an m-of-n threshold signer.
|
||||
type CosignerKey struct {
|
||||
PubKey crypto.PubKey `json:"pub_key"`
|
||||
ShareKey []byte `json:"secret_share"`
|
||||
RSAKey rsa.PrivateKey `json:"rsa_key"`
|
||||
ID int `json:"id"`
|
||||
CosignerKeys []*rsa.PublicKey `json:"rsa_pubs"`
|
||||
}
|
||||
|
||||
func (cosignerKey *CosignerKey) MarshalJSON() ([]byte, error) {
|
||||
type Alias CosignerKey
|
||||
|
||||
// marshal our private key and all public keys
|
||||
privateBytes := x509.MarshalPKCS1PrivateKey(&cosignerKey.RSAKey)
|
||||
rsaPubKeysBytes := make([][]byte, 0)
|
||||
for _, pubKey := range cosignerKey.CosignerKeys {
|
||||
publicBytes := x509.MarshalPKCS1PublicKey(pubKey)
|
||||
rsaPubKeysBytes = append(rsaPubKeysBytes, publicBytes)
|
||||
}
|
||||
|
||||
pubkey, err := cdc.MarshalBinaryBare(cosignerKey.PubKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.Marshal(&struct {
|
||||
RSAKey []byte `json:"rsa_key"`
|
||||
Pubkey []byte `json:"pub_key"`
|
||||
CosignerKeys [][]byte `json:"rsa_pubs"`
|
||||
*Alias
|
||||
}{
|
||||
Pubkey: pubkey,
|
||||
RSAKey: privateBytes,
|
||||
CosignerKeys: rsaPubKeysBytes,
|
||||
Alias: (*Alias)(cosignerKey),
|
||||
})
|
||||
}
|
||||
|
||||
func (cosignerKey *CosignerKey) UnmarshalJSON(data []byte) error {
|
||||
type Alias CosignerKey
|
||||
|
||||
aux := &struct {
|
||||
RSAKey []byte `json:"rsa_key"`
|
||||
Pubkey []byte `json:"pub_key"`
|
||||
CosignerKeys [][]byte `json:"rsa_pubs"`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(cosignerKey),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
privateKey, err := x509.ParsePKCS1PrivateKey(aux.RSAKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var pubkey tmed25519.PubKeyEd25519
|
||||
err = cdc.UnmarshalBinaryBare(aux.Pubkey, &pubkey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// unmarshal the public key bytes for each cosigner
|
||||
cosignerKey.CosignerKeys = make([]*rsa.PublicKey, 0)
|
||||
for _, bytes := range aux.CosignerKeys {
|
||||
cosignerRsaPubkey, err := x509.ParsePKCS1PublicKey(bytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cosignerKey.CosignerKeys = append(cosignerKey.CosignerKeys, cosignerRsaPubkey)
|
||||
}
|
||||
|
||||
cosignerKey.RSAKey = *privateKey
|
||||
cosignerKey.PubKey = pubkey
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadCosignerKey loads a CosignerKey from file.
|
||||
func LoadCosignerKey(file string) (CosignerKey, error) {
|
||||
pvKey := CosignerKey{}
|
||||
keyJSONBytes, err := ioutil.ReadFile(file)
|
||||
if err != nil {
|
||||
return pvKey, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(keyJSONBytes, &pvKey)
|
||||
if err != nil {
|
||||
return pvKey, err
|
||||
}
|
||||
|
||||
return pvKey, nil
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package signer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadCosignerKey(test *testing.T) {
|
||||
key, err := LoadCosignerKey("../../test/cosigner-key.json")
|
||||
require.NoError(test, err)
|
||||
require.Equal(test, key.ID, 3)
|
||||
|
||||
// public key from cosigner pubs array should match public key from our private key
|
||||
require.Equal(test, &key.RSAKey.PublicKey, key.CosignerKeys[key.ID-1])
|
||||
}
|
|
@ -0,0 +1,229 @@
|
|||
package signer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
cmn "github.com/tendermint/tendermint/libs/common"
|
||||
"github.com/tendermint/tendermint/libs/log"
|
||||
server "github.com/tendermint/tendermint/rpc/lib/server"
|
||||
rpc_types "github.com/tendermint/tendermint/rpc/lib/types"
|
||||
)
|
||||
|
||||
type RpcSignRequest struct {
|
||||
SignBytes []byte
|
||||
}
|
||||
|
||||
type RpcSignResponse struct {
|
||||
Timestamp time.Time
|
||||
Signature []byte
|
||||
}
|
||||
|
||||
type RpcGetEphemeralSecretPartRequest struct {
|
||||
ID int
|
||||
Height int64
|
||||
Round int64
|
||||
Step int8
|
||||
}
|
||||
|
||||
type RpcGetEphemeralSecretPartResponse struct {
|
||||
SourceID int
|
||||
SourceEphemeralSecretPublicKey []byte
|
||||
EncryptedSharePart []byte
|
||||
SourceSig []byte
|
||||
}
|
||||
|
||||
type CosignerRpcServerConfig struct {
|
||||
Logger log.Logger
|
||||
ListenAddress string
|
||||
Cosigner Cosigner
|
||||
Peers []RemoteCosigner
|
||||
}
|
||||
|
||||
// CosignerRpcServer responds to rpc sign requests using a cosigner instance
|
||||
type CosignerRpcServer struct {
|
||||
cmn.BaseService
|
||||
|
||||
logger log.Logger
|
||||
listenAddress string
|
||||
listener net.Listener
|
||||
cosigner Cosigner
|
||||
peers []RemoteCosigner
|
||||
}
|
||||
|
||||
// NewCosignerRpcServer instantiates a local cosigner with the specified key and sign state
|
||||
func NewCosignerRpcServer(config *CosignerRpcServerConfig) *CosignerRpcServer {
|
||||
cosignerRpcServer := &CosignerRpcServer{
|
||||
cosigner: config.Cosigner,
|
||||
listenAddress: config.ListenAddress,
|
||||
peers: config.Peers,
|
||||
logger: config.Logger,
|
||||
}
|
||||
|
||||
cosignerRpcServer.BaseService = *cmn.NewBaseService(config.Logger, "CosignerRpcServer", cosignerRpcServer)
|
||||
return cosignerRpcServer
|
||||
}
|
||||
|
||||
// OnStart starts the rpm server to respond to remote CosignerSignRequests
|
||||
func (rpcServer *CosignerRpcServer) OnStart() error {
|
||||
proto, address := cmn.ProtocolAndAddress(rpcServer.listenAddress)
|
||||
|
||||
lis, err := net.Listen(proto, address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rpcServer.listener = lis
|
||||
|
||||
routes := map[string]*server.RPCFunc{
|
||||
"Sign": server.NewRPCFunc(rpcServer.rpcSignRequest, "arg"),
|
||||
"GetEphemeralSecretPart": server.NewRPCFunc(rpcServer.rpcGetEphemeralSecretPart, "arg"),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
server.RegisterRPCFuncs(mux, routes, cdc, log.NewFilter(rpcServer.Logger, log.AllowError()))
|
||||
|
||||
tcpLogger := rpcServer.Logger.With("socket", "tcp")
|
||||
tcpLogger = log.NewFilter(tcpLogger, log.AllowError())
|
||||
config := server.DefaultConfig()
|
||||
|
||||
go func() {
|
||||
defer lis.Close()
|
||||
server.StartHTTPServer(lis, mux, tcpLogger, config)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rpcServer *CosignerRpcServer) Addr() net.Addr {
|
||||
if rpcServer.listener == nil {
|
||||
return nil
|
||||
}
|
||||
return rpcServer.listener.Addr()
|
||||
}
|
||||
|
||||
func (rpcServer *CosignerRpcServer) rpcSignRequest(ctx *rpc_types.Context, req RpcSignRequest) (*RpcSignResponse, error) {
|
||||
response := &RpcSignResponse{}
|
||||
|
||||
height, round, step, err := UnpackHRS(req.SignBytes)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(len(rpcServer.peers))
|
||||
|
||||
// ping peers for our ephemeral share part
|
||||
for _, peer := range rpcServer.peers {
|
||||
request := func(peer RemoteCosigner) {
|
||||
|
||||
// need to do these requests in parallel..!!
|
||||
|
||||
// RPC requests are blocking
|
||||
// to prevent it from hanging our process indefinitely, we use a timeout context and a goroutine
|
||||
partReqCtx, partReqCtxCancel := context.WithTimeout(context.Background(), time.Second)
|
||||
|
||||
go func() {
|
||||
partRequest := CosignerGetEphemeralSecretPartRequest{
|
||||
ID: rpcServer.cosigner.GetID(),
|
||||
Height: height,
|
||||
Round: round,
|
||||
Step: step,
|
||||
}
|
||||
|
||||
// if we already have an ephemeral secret part for this HRS, we don't need to re-query for it
|
||||
hasResp, err := rpcServer.cosigner.HasEphemeralSecretPart(CosignerHasEphemeralSecretPartRequest{
|
||||
ID: peer.GetID(),
|
||||
Height: height,
|
||||
Round: round,
|
||||
Step: step,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
rpcServer.logger.Error("HasEphemeralSecretPart req error", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if hasResp.Exists {
|
||||
partReqCtxCancel()
|
||||
return
|
||||
}
|
||||
|
||||
partResponse, err := peer.GetEphemeralSecretPart(partRequest)
|
||||
if err != nil {
|
||||
rpcServer.logger.Error("GetEphemeralSecretPart req error", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// no need to contine if timed out
|
||||
select {
|
||||
case <-partReqCtx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
defer partReqCtxCancel()
|
||||
|
||||
// set the share part from the response
|
||||
err = rpcServer.cosigner.SetEphemeralSecretPart(CosignerSetEphemeralSecretPartRequest{
|
||||
SourceID: partResponse.SourceID,
|
||||
SourceEphemeralSecretPublicKey: partResponse.SourceEphemeralSecretPublicKey,
|
||||
EncryptedSharePart: partResponse.EncryptedSharePart,
|
||||
Height: height,
|
||||
Round: round,
|
||||
Step: step,
|
||||
SourceSig: partResponse.SourceSig,
|
||||
})
|
||||
if err != nil {
|
||||
rpcServer.logger.Error("SetEphemeralSecretPart req error", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// wait for timeout or done
|
||||
select {
|
||||
case <-partReqCtx.Done():
|
||||
}
|
||||
|
||||
wg.Done()
|
||||
}
|
||||
|
||||
go request(peer)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// after getting any share parts we could, we sign
|
||||
resp, err := rpcServer.cosigner.Sign(CosignerSignRequest{
|
||||
SignBytes: req.SignBytes,
|
||||
})
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
|
||||
response.Timestamp = resp.Timestamp
|
||||
response.Signature = resp.Signature
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (rpcServer *CosignerRpcServer) rpcGetEphemeralSecretPart(ctx *rpc_types.Context, req RpcGetEphemeralSecretPartRequest) (*RpcGetEphemeralSecretPartResponse, error) {
|
||||
response := &RpcGetEphemeralSecretPartResponse{}
|
||||
|
||||
partResp, err := rpcServer.cosigner.GetEphemeralSecretPart(CosignerGetEphemeralSecretPartRequest{
|
||||
ID: req.ID,
|
||||
Height: req.Height,
|
||||
Round: req.Round,
|
||||
Step: req.Step,
|
||||
})
|
||||
if err != nil {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
response.SourceID = partResp.SourceID
|
||||
response.SourceEphemeralSecretPublicKey = partResp.SourceEphemeralSecretPublicKey
|
||||
response.EncryptedSharePart = partResp.EncryptedSharePart
|
||||
response.SourceSig = partResp.SourceSig
|
||||
|
||||
return response, nil
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
package signer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tendermint/tendermint/libs/log"
|
||||
"github.com/tendermint/tendermint/types"
|
||||
)
|
||||
|
||||
type DummyCosigner struct{}
|
||||
|
||||
func (cosigner *DummyCosigner) GetID() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (cosigner *DummyCosigner) Sign(signReq CosignerSignRequest) (CosignerSignResponse, error) {
|
||||
return CosignerSignResponse{
|
||||
Signature: []byte("foobar"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (cosigner *DummyCosigner) GetEphemeralSecretPart(req CosignerGetEphemeralSecretPartRequest) (CosignerGetEphemeralSecretPartResponse, error) {
|
||||
return CosignerGetEphemeralSecretPartResponse{
|
||||
SourceID: 1,
|
||||
SourceEphemeralSecretPublicKey: []byte("foo"),
|
||||
EncryptedSharePart: []byte("bar"),
|
||||
SourceSig: []byte("source sig"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (cosigner *DummyCosigner) HasEphemeralSecretPart(req CosignerHasEphemeralSecretPartRequest) (CosignerHasEphemeralSecretPartResponse, error) {
|
||||
return CosignerHasEphemeralSecretPartResponse{
|
||||
Exists: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (cosigner *DummyCosigner) SetEphemeralSecretPart(req CosignerSetEphemeralSecretPartRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCosignerRpcServerSign(test *testing.T) {
|
||||
dummyCosigner := &DummyCosigner{}
|
||||
|
||||
logger := log.NewTMLogger(log.NewSyncWriter(os.Stdout))
|
||||
|
||||
config := CosignerRpcServerConfig{
|
||||
Logger: logger,
|
||||
ListenAddress: "tcp://0.0.0.0:0",
|
||||
Cosigner: dummyCosigner,
|
||||
}
|
||||
|
||||
rpcServer := NewCosignerRpcServer(&config)
|
||||
|
||||
rpcServer.Start()
|
||||
|
||||
// pack a vote into sign bytes
|
||||
var vote types.CanonicalVote
|
||||
vote.ChainID = "foobar"
|
||||
vote.Height = 1
|
||||
vote.Round = 0
|
||||
vote.Type = types.PrevoteType
|
||||
|
||||
signBytes, err := cdc.MarshalBinaryLengthPrefixed(vote)
|
||||
require.NoError(test, err)
|
||||
|
||||
remoteCosigner := NewRemoteCosigner(2, rpcServer.Addr().String())
|
||||
resp, err := remoteCosigner.Sign(CosignerSignRequest{
|
||||
SignBytes: signBytes,
|
||||
})
|
||||
require.NoError(test, err)
|
||||
require.Equal(test, resp, CosignerSignResponse{
|
||||
Signature: []byte("foobar"),
|
||||
})
|
||||
|
||||
rpcServer.Stop()
|
||||
}
|
||||
|
||||
func TestCosignerRpcServerGetEphemeralSecretPart(test *testing.T) {
|
||||
dummyCosigner := &DummyCosigner{}
|
||||
|
||||
logger := log.NewTMLogger(log.NewSyncWriter(os.Stdout))
|
||||
|
||||
config := CosignerRpcServerConfig{
|
||||
Logger: logger,
|
||||
ListenAddress: "tcp://0.0.0.0:0",
|
||||
Cosigner: dummyCosigner,
|
||||
}
|
||||
|
||||
rpcServer := NewCosignerRpcServer(&config)
|
||||
rpcServer.Start()
|
||||
|
||||
remoteCosigner := NewRemoteCosigner(2, rpcServer.Addr().String())
|
||||
|
||||
resp, err := remoteCosigner.GetEphemeralSecretPart(CosignerGetEphemeralSecretPartRequest{})
|
||||
require.NoError(test, err)
|
||||
require.Equal(test, resp, CosignerGetEphemeralSecretPartResponse{
|
||||
SourceID: 1,
|
||||
SourceEphemeralSecretPublicKey: []byte("foo"),
|
||||
EncryptedSharePart: []byte("bar"),
|
||||
SourceSig: []byte("source sig"),
|
||||
})
|
||||
|
||||
rpcServer.Stop()
|
||||
}
|
|
@ -0,0 +1,406 @@
|
|||
package signer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
tm_ed25519 "github.com/tendermint/tendermint/crypto/ed25519"
|
||||
"gitlab.com/polychainlabs/edwards25519"
|
||||
tsed25519 "gitlab.com/polychainlabs/threshold-ed25519/pkg"
|
||||
)
|
||||
|
||||
type HRSKey struct {
|
||||
Height int64
|
||||
Round int64
|
||||
Step int8
|
||||
}
|
||||
|
||||
// return true if we are less than the other key
|
||||
func (hrsKey *HRSKey) Less(other HRSKey) bool {
|
||||
if hrsKey.Height < other.Height {
|
||||
return true
|
||||
}
|
||||
|
||||
if hrsKey.Height > other.Height {
|
||||
return false
|
||||
}
|
||||
|
||||
// height is equal, check round
|
||||
|
||||
if hrsKey.Round < other.Round {
|
||||
return true
|
||||
}
|
||||
|
||||
if hrsKey.Round > other.Round {
|
||||
return false
|
||||
}
|
||||
|
||||
// round is equal, check step
|
||||
|
||||
if hrsKey.Step < other.Step {
|
||||
return true
|
||||
}
|
||||
|
||||
// everything is equal
|
||||
return false
|
||||
}
|
||||
|
||||
type CosignerPeer struct {
|
||||
ID int
|
||||
PublicKey rsa.PublicKey
|
||||
}
|
||||
|
||||
type LocalCosignerConfig struct {
|
||||
CosignerKey CosignerKey
|
||||
SignState *SignState
|
||||
RsaKey rsa.PrivateKey
|
||||
Peers []CosignerPeer
|
||||
Total uint8
|
||||
Threshold uint8
|
||||
}
|
||||
|
||||
type PeerMetadata struct {
|
||||
Share []byte
|
||||
EphemeralSecretPublicKey []byte
|
||||
}
|
||||
|
||||
type HrsMetadata struct {
|
||||
// need to be _total_ entries per player
|
||||
Secret []byte
|
||||
DealtShares []tsed25519.Scalar
|
||||
Peers []PeerMetadata
|
||||
}
|
||||
|
||||
// LocalCosigner responds to sign requests using their share key
|
||||
// The cosigner maintains a watermark to avoid double-signing
|
||||
//
|
||||
// LocalCosigner signing is thread saafe
|
||||
type LocalCosigner struct {
|
||||
pubKeyBytes []byte
|
||||
key CosignerKey
|
||||
rsaKey rsa.PrivateKey
|
||||
total uint8
|
||||
threshold uint8
|
||||
|
||||
// stores the last sign state for a share we have fully signed
|
||||
// incremented whenever we are asked to sign a share
|
||||
lastSignState *SignState
|
||||
|
||||
// signing is thread safe
|
||||
lastSignStateMutex sync.Mutex
|
||||
|
||||
// Height, Round, Step -> metadata
|
||||
hrsMeta map[HRSKey]HrsMetadata
|
||||
peers map[int]CosignerPeer
|
||||
}
|
||||
|
||||
func NewLocalCosigner(cfg LocalCosignerConfig) *LocalCosigner {
|
||||
cosigner := &LocalCosigner{
|
||||
key: cfg.CosignerKey,
|
||||
lastSignState: cfg.SignState,
|
||||
rsaKey: cfg.RsaKey,
|
||||
hrsMeta: make(map[HRSKey]HrsMetadata),
|
||||
peers: make(map[int]CosignerPeer),
|
||||
total: cfg.Total,
|
||||
threshold: cfg.Threshold,
|
||||
}
|
||||
|
||||
for _, peer := range cfg.Peers {
|
||||
cosigner.peers[peer.ID] = peer
|
||||
}
|
||||
|
||||
// cache the public key bytes for signing operations
|
||||
switch ed25519Key := cosigner.key.PubKey.(type) {
|
||||
case tm_ed25519.PubKeyEd25519:
|
||||
cosigner.pubKeyBytes = make([]byte, len(ed25519Key))
|
||||
copy(cosigner.pubKeyBytes[:], ed25519Key[:])
|
||||
break
|
||||
default:
|
||||
panic("Not an ed25519 public key")
|
||||
}
|
||||
|
||||
return cosigner
|
||||
}
|
||||
|
||||
// GetID returns the id of the cosigner
|
||||
// Implements Cosigner interface
|
||||
func (cosigner *LocalCosigner) GetID() int {
|
||||
return cosigner.key.ID
|
||||
}
|
||||
|
||||
// Sign the sign request using the cosigner's share
|
||||
// Return the signed bytes or an error
|
||||
// Implements Cosigner interface
|
||||
func (cosigner *LocalCosigner) Sign(req CosignerSignRequest) (CosignerSignResponse, error) {
|
||||
cosigner.lastSignStateMutex.Lock()
|
||||
defer cosigner.lastSignStateMutex.Unlock()
|
||||
|
||||
res := CosignerSignResponse{}
|
||||
lss := cosigner.lastSignState
|
||||
|
||||
height, round, step, err := UnpackHRS(req.SignBytes)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
sameHRS, err := lss.CheckHRS(height, round, step)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
// If the HRS is the same the sign bytes may still differ by timestamp
|
||||
// It is ok to re-sign a different timestamp if that is the only difference in the sign bytes
|
||||
if sameHRS {
|
||||
if bytes.Equal(req.SignBytes, lss.SignBytes) {
|
||||
res.EphemeralPublic = lss.EphemeralPublic
|
||||
res.Signature = lss.Signature
|
||||
return res, nil
|
||||
} else if _, ok := lss.OnlyDifferByTimestamp(req.SignBytes); !ok {
|
||||
return res, errors.New("Mismatched data")
|
||||
}
|
||||
|
||||
// saame HRS, and only differ by timestamp - ok to sign again
|
||||
}
|
||||
|
||||
hrsKey := HRSKey{
|
||||
Height: height,
|
||||
Round: round,
|
||||
Step: step,
|
||||
}
|
||||
meta, ok := cosigner.hrsMeta[hrsKey]
|
||||
if !ok {
|
||||
return res, errors.New("No metadata at HRS")
|
||||
}
|
||||
|
||||
shareParts := make([]tsed25519.Scalar, 0)
|
||||
publicKeys := make([]tsed25519.Element, 0)
|
||||
|
||||
// calculate secret and public keys
|
||||
for _, peer := range meta.Peers {
|
||||
if len(peer.Share) == 0 {
|
||||
continue
|
||||
}
|
||||
shareParts = append(shareParts, peer.Share)
|
||||
publicKeys = append(publicKeys, peer.EphemeralSecretPublicKey)
|
||||
}
|
||||
|
||||
ephemeralShare := tsed25519.AddScalars(shareParts)
|
||||
ephemeralPublic := tsed25519.AddElements(publicKeys)
|
||||
|
||||
// check bounds for ephemeral share to avoid passing out of bounds valids to SignWithShare
|
||||
{
|
||||
if len(ephemeralShare) != 32 {
|
||||
return res, errors.New("Ephemeral share is out of bounds.")
|
||||
}
|
||||
|
||||
var scalarBytes [32]byte
|
||||
copy(scalarBytes[:], ephemeralShare)
|
||||
if !edwards25519.ScMinimal(&scalarBytes) {
|
||||
return res, errors.New("Ephemeral share is out of bounds.")
|
||||
}
|
||||
}
|
||||
|
||||
share := cosigner.key.ShareKey[:]
|
||||
sig := tsed25519.SignWithShare(req.SignBytes, share, ephemeralShare, cosigner.pubKeyBytes, ephemeralPublic)
|
||||
|
||||
cosigner.lastSignState.Height = height
|
||||
cosigner.lastSignState.Round = round
|
||||
cosigner.lastSignState.Step = step
|
||||
cosigner.lastSignState.EphemeralPublic = ephemeralPublic
|
||||
cosigner.lastSignState.Signature = sig
|
||||
cosigner.lastSignState.SignBytes = req.SignBytes
|
||||
cosigner.lastSignState.Save()
|
||||
|
||||
for existingKey := range cosigner.hrsMeta {
|
||||
// delete any HRS lower than our signed level
|
||||
// we will not be providing parts for any lower HRS
|
||||
if existingKey.Less(hrsKey) {
|
||||
delete(cosigner.hrsMeta, existingKey)
|
||||
}
|
||||
}
|
||||
|
||||
res.EphemeralPublic = ephemeralPublic
|
||||
res.Signature = sig
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Get the ephemeral secret part for an ephemeral share
|
||||
// The ephemeral secret part is encrypted for the receiver
|
||||
func (cosigner *LocalCosigner) GetEphemeralSecretPart(req CosignerGetEphemeralSecretPartRequest) (CosignerGetEphemeralSecretPartResponse, error) {
|
||||
res := CosignerGetEphemeralSecretPartResponse{}
|
||||
|
||||
// protects the meta map
|
||||
cosigner.lastSignStateMutex.Lock()
|
||||
defer cosigner.lastSignStateMutex.Unlock()
|
||||
|
||||
hrsKey := HRSKey{
|
||||
Height: req.Height,
|
||||
Round: req.Round,
|
||||
Step: req.Step,
|
||||
}
|
||||
|
||||
meta, ok := cosigner.hrsMeta[hrsKey]
|
||||
// generate metadata placeholder
|
||||
if !ok {
|
||||
secret := make([]byte, 32)
|
||||
rand.Read(secret)
|
||||
|
||||
meta = HrsMetadata{
|
||||
Secret: secret,
|
||||
Peers: make([]PeerMetadata, cosigner.total),
|
||||
}
|
||||
|
||||
// split this secret with shamirs
|
||||
// !! dealt shares need to be saved because dealing produces different shares each time!
|
||||
meta.DealtShares = tsed25519.DealShares(meta.Secret, cosigner.threshold, cosigner.total)
|
||||
|
||||
cosigner.hrsMeta[hrsKey] = meta
|
||||
}
|
||||
|
||||
ourEphPublicKey := tsed25519.ScalarMultiplyBase(meta.Secret)
|
||||
|
||||
// set our values
|
||||
meta.Peers[cosigner.key.ID-1].Share = meta.DealtShares[cosigner.key.ID-1]
|
||||
meta.Peers[cosigner.key.ID-1].EphemeralSecretPublicKey = ourEphPublicKey
|
||||
|
||||
// grab the peer info for the ID being requested
|
||||
peer, ok := cosigner.peers[req.ID]
|
||||
if !ok {
|
||||
return res, errors.New("Unknown peer ID")
|
||||
}
|
||||
|
||||
sharePart := meta.DealtShares[req.ID-1]
|
||||
|
||||
// use RSA public to encrypt user's share part
|
||||
encrypted, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &peer.PublicKey, sharePart, nil)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
res.SourceID = cosigner.key.ID
|
||||
res.SourceEphemeralSecretPublicKey = ourEphPublicKey
|
||||
res.EncryptedSharePart = encrypted
|
||||
|
||||
// sign the response payload with our private key
|
||||
// cosigners can verify the signature to confirm sender validity
|
||||
{
|
||||
jsonBytes, err := cdc.MarshalJSON(res)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
digest := sha256.Sum256(jsonBytes)
|
||||
signature, err := rsa.SignPSS(rand.Reader, &cosigner.rsaKey, crypto.SHA256, digest[:], nil)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
res.SourceSig = signature
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (cosigner *LocalCosigner) HasEphemeralSecretPart(req CosignerHasEphemeralSecretPartRequest) (CosignerHasEphemeralSecretPartResponse, error) {
|
||||
res := CosignerHasEphemeralSecretPartResponse{
|
||||
Exists: false,
|
||||
}
|
||||
|
||||
// protects the meta map
|
||||
cosigner.lastSignStateMutex.Lock()
|
||||
defer cosigner.lastSignStateMutex.Unlock()
|
||||
|
||||
hrsKey := HRSKey{
|
||||
Height: req.Height,
|
||||
Round: req.Round,
|
||||
Step: req.Step,
|
||||
}
|
||||
|
||||
meta, ok := cosigner.hrsMeta[hrsKey]
|
||||
if ok {
|
||||
pub := meta.Peers[req.ID-1].EphemeralSecretPublicKey
|
||||
if len(pub) > 0 {
|
||||
res.Exists = true
|
||||
res.EphemeralSecretPublicKey = pub
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Store an ephemeral secret share part provided by another cosigner
|
||||
func (cosigner *LocalCosigner) SetEphemeralSecretPart(req CosignerSetEphemeralSecretPartRequest) error {
|
||||
|
||||
// Verify the source signature
|
||||
{
|
||||
if req.SourceSig == nil {
|
||||
return errors.New("SourceSig field is required")
|
||||
}
|
||||
|
||||
digestMsg := CosignerGetEphemeralSecretPartResponse{}
|
||||
digestMsg.SourceID = req.SourceID
|
||||
digestMsg.SourceEphemeralSecretPublicKey = req.SourceEphemeralSecretPublicKey
|
||||
digestMsg.EncryptedSharePart = req.EncryptedSharePart
|
||||
|
||||
digestBytes, err := cdc.MarshalJSON(digestMsg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
digest := sha256.Sum256(digestBytes)
|
||||
peer, ok := cosigner.peers[req.SourceID]
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("Unknown cosigner: %d", req.SourceID)
|
||||
}
|
||||
|
||||
peerPub := peer.PublicKey
|
||||
err = rsa.VerifyPSS(&peerPub, crypto.SHA256, digest[:], req.SourceSig, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// protects the meta map
|
||||
cosigner.lastSignStateMutex.Lock()
|
||||
defer cosigner.lastSignStateMutex.Unlock()
|
||||
|
||||
hrsKey := HRSKey{
|
||||
Height: req.Height,
|
||||
Round: req.Round,
|
||||
Step: req.Step,
|
||||
}
|
||||
|
||||
meta, ok := cosigner.hrsMeta[hrsKey]
|
||||
// generate metadata placeholder
|
||||
if !ok {
|
||||
secret := make([]byte, 32)
|
||||
rand.Read(secret)
|
||||
|
||||
meta = HrsMetadata{
|
||||
Secret: secret,
|
||||
Peers: make([]PeerMetadata, cosigner.total),
|
||||
}
|
||||
|
||||
meta.DealtShares = tsed25519.DealShares(meta.Secret, cosigner.threshold, cosigner.total)
|
||||
|
||||
cosigner.hrsMeta[hrsKey] = meta
|
||||
}
|
||||
|
||||
// decrypt share
|
||||
sharePart, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, &cosigner.rsaKey, req.EncryptedSharePart, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set slot
|
||||
meta.Peers[req.SourceID-1].Share = sharePart
|
||||
meta.Peers[req.SourceID-1].EphemeralSecretPublicKey = req.SourceEphemeralSecretPublicKey
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,262 @@
|
|||
package signer
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
tm_ed25519 "github.com/tendermint/tendermint/crypto/ed25519"
|
||||
"github.com/tendermint/tendermint/types"
|
||||
tsed25519 "gitlab.com/polychainlabs/threshold-ed25519/pkg"
|
||||
)
|
||||
|
||||
func TestLocalCosignerGetID(test *testing.T) {
|
||||
dummyPub := tm_ed25519.PubKeyEd25519{}
|
||||
|
||||
bitSize := 4096
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, bitSize)
|
||||
require.NoError(test, err)
|
||||
|
||||
key := CosignerKey{
|
||||
PubKey: dummyPub,
|
||||
ShareKey: []byte{},
|
||||
ID: 1,
|
||||
}
|
||||
signState := SignState{
|
||||
Height: 0,
|
||||
Round: 0,
|
||||
Step: 0,
|
||||
}
|
||||
|
||||
config := LocalCosignerConfig{
|
||||
CosignerKey: key,
|
||||
SignState: &signState,
|
||||
RsaKey: *rsaKey,
|
||||
Peers: []CosignerPeer{CosignerPeer{
|
||||
ID: 1,
|
||||
PublicKey: rsaKey.PublicKey,
|
||||
}},
|
||||
}
|
||||
|
||||
cosigner := NewLocalCosigner(config)
|
||||
require.Equal(test, cosigner.GetID(), 1)
|
||||
}
|
||||
|
||||
func TestLocalCosignerSign2of2(test *testing.T) {
|
||||
// Test signing with a 2 of 2
|
||||
|
||||
total := uint8(2)
|
||||
threshold := uint8(2)
|
||||
|
||||
bitSize := 4096
|
||||
rsaKey1, err := rsa.GenerateKey(rand.Reader, bitSize)
|
||||
require.NoError(test, err)
|
||||
|
||||
rsaKey2, err := rsa.GenerateKey(rand.Reader, bitSize)
|
||||
require.NoError(test, err)
|
||||
|
||||
peers := []CosignerPeer{CosignerPeer{
|
||||
ID: 1,
|
||||
PublicKey: rsaKey1.PublicKey,
|
||||
}, CosignerPeer{
|
||||
ID: 2,
|
||||
PublicKey: rsaKey2.PublicKey,
|
||||
}}
|
||||
|
||||
privateKey := tm_ed25519.GenPrivKey()
|
||||
|
||||
privKeyBytes := [64]byte{}
|
||||
copy(privKeyBytes[:], privateKey[:])
|
||||
secretShares := tsed25519.DealShares(tsed25519.ExpandSecret(privKeyBytes[:32]), threshold, total)
|
||||
|
||||
key1 := CosignerKey{
|
||||
PubKey: privateKey.PubKey(),
|
||||
ShareKey: secretShares[0],
|
||||
ID: 1,
|
||||
}
|
||||
|
||||
stateFile1, err := ioutil.TempFile("", "state1.json")
|
||||
require.NoError(test, err)
|
||||
defer os.Remove(stateFile1.Name())
|
||||
|
||||
signState1, err := LoadOrCreateSignState(stateFile1.Name())
|
||||
|
||||
key2 := CosignerKey{
|
||||
PubKey: privateKey.PubKey(),
|
||||
ShareKey: secretShares[1],
|
||||
ID: 2,
|
||||
}
|
||||
|
||||
stateFile2, err := ioutil.TempFile("", "state2.json")
|
||||
require.NoError(test, err)
|
||||
defer os.Remove(stateFile2.Name())
|
||||
signState2, err := LoadOrCreateSignState(stateFile2.Name())
|
||||
require.NoError(test, err)
|
||||
|
||||
config1 := LocalCosignerConfig{
|
||||
CosignerKey: key1,
|
||||
SignState: &signState1,
|
||||
RsaKey: *rsaKey1,
|
||||
Peers: peers,
|
||||
Total: total,
|
||||
Threshold: threshold,
|
||||
}
|
||||
|
||||
config2 := LocalCosignerConfig{
|
||||
CosignerKey: key2,
|
||||
SignState: &signState2,
|
||||
RsaKey: *rsaKey2,
|
||||
Peers: peers,
|
||||
Total: total,
|
||||
Threshold: threshold,
|
||||
}
|
||||
|
||||
var cosigner1 Cosigner
|
||||
var cosigner2 Cosigner
|
||||
|
||||
cosigner1 = NewLocalCosigner(config1)
|
||||
cosigner2 = NewLocalCosigner(config2)
|
||||
|
||||
require.Equal(test, cosigner1.GetID(), 1)
|
||||
require.Equal(test, cosigner2.GetID(), 2)
|
||||
|
||||
publicKeys := make([]tsed25519.Element, 0)
|
||||
|
||||
// get part 2 from cosigner 1 and give to cosigner 2
|
||||
{
|
||||
resp, err := cosigner1.GetEphemeralSecretPart(CosignerGetEphemeralSecretPartRequest{
|
||||
ID: 2,
|
||||
Height: 1,
|
||||
Round: 0,
|
||||
Step: 2,
|
||||
})
|
||||
require.NoError(test, err)
|
||||
|
||||
publicKeys = append(publicKeys, resp.SourceEphemeralSecretPublicKey)
|
||||
|
||||
err = cosigner2.SetEphemeralSecretPart(CosignerSetEphemeralSecretPartRequest{
|
||||
SourceID: resp.SourceID,
|
||||
Height: 1,
|
||||
Round: 0,
|
||||
Step: 2,
|
||||
SourceEphemeralSecretPublicKey: resp.SourceEphemeralSecretPublicKey,
|
||||
EncryptedSharePart: resp.EncryptedSharePart,
|
||||
SourceSig: resp.SourceSig,
|
||||
})
|
||||
require.NoError(test, err)
|
||||
}
|
||||
|
||||
// get part 1 from cosigner 2 and give to cosigner 1
|
||||
{
|
||||
resp, err := cosigner2.GetEphemeralSecretPart(CosignerGetEphemeralSecretPartRequest{
|
||||
ID: 1,
|
||||
Height: 1,
|
||||
Round: 0,
|
||||
Step: 2,
|
||||
})
|
||||
require.NoError(test, err)
|
||||
|
||||
publicKeys = append(publicKeys, resp.SourceEphemeralSecretPublicKey)
|
||||
|
||||
err = cosigner1.SetEphemeralSecretPart(CosignerSetEphemeralSecretPartRequest{
|
||||
SourceID: resp.SourceID,
|
||||
Height: 1,
|
||||
Round: 0,
|
||||
Step: 2,
|
||||
SourceEphemeralSecretPublicKey: resp.SourceEphemeralSecretPublicKey,
|
||||
EncryptedSharePart: resp.EncryptedSharePart,
|
||||
SourceSig: resp.SourceSig,
|
||||
})
|
||||
require.NoError(test, err)
|
||||
}
|
||||
|
||||
ephemeralPublic := tsed25519.AddElements(publicKeys)
|
||||
|
||||
fmt.Printf("public keys: %x\n", publicKeys)
|
||||
fmt.Printf("eph pub: %x\n", ephemeralPublic)
|
||||
|
||||
// pack a vote into sign bytes
|
||||
var vote types.CanonicalVote
|
||||
vote.ChainID = "foobar"
|
||||
vote.Height = 1
|
||||
vote.Round = 0
|
||||
vote.Type = types.PrevoteType
|
||||
|
||||
signBytes, err := cdc.MarshalBinaryLengthPrefixed(vote)
|
||||
require.NoError(test, err)
|
||||
|
||||
// sign with cosigner 1
|
||||
sigRes1, err := cosigner1.Sign(CosignerSignRequest{
|
||||
SignBytes: signBytes,
|
||||
})
|
||||
require.NoError(test, err)
|
||||
|
||||
sigRes2, err := cosigner2.Sign(CosignerSignRequest{
|
||||
SignBytes: signBytes,
|
||||
})
|
||||
require.NoError(test, err)
|
||||
|
||||
sigIds := []int{1, 2}
|
||||
sigArr := [][]byte{sigRes1.Signature, sigRes2.Signature}
|
||||
|
||||
fmt.Printf("sig arr: %x\n", sigArr)
|
||||
|
||||
combinedSig := tsed25519.CombineShares(total, sigIds, sigArr)
|
||||
signature := append(ephemeralPublic, combinedSig...)
|
||||
|
||||
fmt.Printf("signature: %x\n", signature)
|
||||
require.True(test, privateKey.PubKey().VerifyBytes(signBytes, signature))
|
||||
}
|
||||
|
||||
func TestLocalCosignerWatermark(test *testing.T) {
|
||||
/*
|
||||
privateKey := tm_ed25519.GenPrivKey()
|
||||
|
||||
privKeyBytes := [64]byte{}
|
||||
copy(privKeyBytes[:], privateKey[:])
|
||||
secretShares := tsed25519.DealShares(privKeyBytes[:32], 2, 2)
|
||||
|
||||
key1 := CosignerKey{
|
||||
PubKey: privateKey.PubKey(),
|
||||
ShareKey: secretShares[0],
|
||||
ID: 1,
|
||||
}
|
||||
|
||||
stateFile1, err := ioutil.TempFile("", "state1.json")
|
||||
require.NoError(test, err)
|
||||
defer os.Remove(stateFile1.Name())
|
||||
|
||||
signState1, err := LoadOrCreateSignState(stateFile1.Name())
|
||||
|
||||
cosigner1 := NewLocalCosigner(key1, &signState1)
|
||||
|
||||
ephPublicKey, ephPrivateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(test, err)
|
||||
|
||||
ephShares := tsed25519.DealShares(ephPrivateKey.Seed(), 2, 2)
|
||||
|
||||
signReq1 := CosignerSignRequest{
|
||||
EphemeralPublic: ephPublicKey,
|
||||
EphemeralShareSecret: ephShares[0],
|
||||
Height: 2,
|
||||
Round: 0,
|
||||
Step: 0,
|
||||
SignBytes: []byte("Hello World!"),
|
||||
}
|
||||
|
||||
_, err = cosigner1.Sign(signReq1)
|
||||
require.NoError(test, err)
|
||||
|
||||
// watermark should have increased after signing
|
||||
require.Equal(test, signState1.Height, int64(2))
|
||||
|
||||
// revert the height to a lower number and check if signing is rejected
|
||||
signReq1.Height = 1
|
||||
_, err = cosigner1.Sign(signReq1)
|
||||
require.Error(test, err, "height regression. Got 1, last height 2")
|
||||
*/
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
package signer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
client "github.com/tendermint/tendermint/rpc/lib/client"
|
||||
)
|
||||
|
||||
// RemoteCosigner uses tendermint rpc to request signing from a remote cosigner
|
||||
type RemoteCosigner struct {
|
||||
id int
|
||||
address string
|
||||
}
|
||||
|
||||
// NewRemoteCosigner returns a newly initialized RemoteCosigner
|
||||
func NewRemoteCosigner(id int, address string) *RemoteCosigner {
|
||||
cosigner := &RemoteCosigner{
|
||||
id: id,
|
||||
address: address,
|
||||
}
|
||||
return cosigner
|
||||
}
|
||||
|
||||
// GetID returns the ID of the remote cosigner
|
||||
// Implements the cosigner interface
|
||||
func (cosigner *RemoteCosigner) GetID() int {
|
||||
return cosigner.id
|
||||
}
|
||||
|
||||
// Sign the sign request using the cosigner's share
|
||||
// Return the signed bytes or an error
|
||||
func (cosigner *RemoteCosigner) Sign(signReq CosignerSignRequest) (CosignerSignResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"arg": RpcSignRequest{
|
||||
SignBytes: signReq.SignBytes,
|
||||
},
|
||||
}
|
||||
|
||||
remoteClient := client.NewJSONRPCClient(cosigner.address)
|
||||
result := &CosignerSignResponse{}
|
||||
_, err := remoteClient.Call("Sign", params, result)
|
||||
if err != nil {
|
||||
return CosignerSignResponse{}, err
|
||||
}
|
||||
|
||||
return CosignerSignResponse{
|
||||
Timestamp: result.Timestamp,
|
||||
Signature: result.Signature,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (cosigner *RemoteCosigner) GetEphemeralSecretPart(req CosignerGetEphemeralSecretPartRequest) (CosignerGetEphemeralSecretPartResponse, error) {
|
||||
resp := CosignerGetEphemeralSecretPartResponse{}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"arg": RpcGetEphemeralSecretPartRequest{
|
||||
ID: req.ID,
|
||||
Height: req.Height,
|
||||
Round: req.Round,
|
||||
Step: req.Step,
|
||||
},
|
||||
}
|
||||
|
||||
remoteClient := client.NewJSONRPCClient(cosigner.address)
|
||||
result := &RpcGetEphemeralSecretPartResponse{}
|
||||
_, err := remoteClient.Call("GetEphemeralSecretPart", params, result)
|
||||
if err != nil {
|
||||
return CosignerGetEphemeralSecretPartResponse{}, err
|
||||
}
|
||||
|
||||
resp.SourceID = result.SourceID
|
||||
resp.SourceEphemeralSecretPublicKey = result.SourceEphemeralSecretPublicKey
|
||||
resp.EncryptedSharePart = result.EncryptedSharePart
|
||||
resp.SourceSig = result.SourceSig
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (cosigner *RemoteCosigner) HasEphemeralSecretPart(req CosignerHasEphemeralSecretPartRequest) (CosignerHasEphemeralSecretPartResponse, error) {
|
||||
res := CosignerHasEphemeralSecretPartResponse{}
|
||||
return res, errors.New("Not Implemented")
|
||||
}
|
||||
|
||||
func (cosigner *RemoteCosigner) SetEphemeralSecretPart(req CosignerSetEphemeralSecretPartRequest) error {
|
||||
return errors.New("Not Implemented")
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
package signer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tendermint/tendermint/libs/log"
|
||||
server "github.com/tendermint/tendermint/rpc/lib/server"
|
||||
rpc_types "github.com/tendermint/tendermint/rpc/lib/types"
|
||||
)
|
||||
|
||||
func rpcSignRequest(ctx *rpc_types.Context, req RpcSignRequest) (*RpcSignResponse, error) {
|
||||
return &RpcSignResponse{Signature: []byte("hello world")}, nil
|
||||
}
|
||||
|
||||
func rpcGetEphemeralSecretPart(ctx *rpc_types.Context, req RpcGetEphemeralSecretPartRequest) (*RpcGetEphemeralSecretPartResponse, error) {
|
||||
response := &RpcGetEphemeralSecretPartResponse{
|
||||
SourceID: 1,
|
||||
SourceEphemeralSecretPublicKey: []byte("foo"),
|
||||
EncryptedSharePart: []byte("bar"),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func TestRemoteCosignerSign(test *testing.T) {
|
||||
lis, err := net.Listen("tcp", "0.0.0.0:0")
|
||||
require.NoError(test, err)
|
||||
defer lis.Close()
|
||||
|
||||
logger := log.NewTMLogger(log.NewSyncWriter(os.Stdout))
|
||||
serv := func() {
|
||||
routes := map[string]*server.RPCFunc{
|
||||
"Sign": server.NewRPCFunc(rpcSignRequest, "arg"),
|
||||
"GetEphemeralSecretPart": server.NewRPCFunc(rpcGetEphemeralSecretPart, "arg"),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
server.RegisterRPCFuncs(mux, routes, cdc, logger)
|
||||
|
||||
tcpLogger := logger.With("socket", "tcp")
|
||||
config := server.DefaultConfig()
|
||||
server.StartHTTPServer(lis, mux, tcpLogger, config)
|
||||
}
|
||||
go serv()
|
||||
|
||||
port := lis.Addr().(*net.TCPAddr).Port
|
||||
cosigner := NewRemoteCosigner(2, fmt.Sprintf("tcp://0.0.0.0:%d", port))
|
||||
|
||||
resp, err := cosigner.Sign(CosignerSignRequest{})
|
||||
require.NoError(test, err)
|
||||
require.Equal(test, resp.Signature, []byte("hello world"))
|
||||
}
|
||||
|
||||
func TestRemoteCosignerGetEphemeralSecretPart(test *testing.T) {
|
||||
lis, err := net.Listen("tcp", "0.0.0.0:0")
|
||||
require.NoError(test, err)
|
||||
defer lis.Close()
|
||||
|
||||
logger := log.NewTMLogger(log.NewSyncWriter(os.Stdout))
|
||||
serv := func() {
|
||||
routes := map[string]*server.RPCFunc{
|
||||
"Sign": server.NewRPCFunc(rpcSignRequest, "arg"),
|
||||
"GetEphemeralSecretPart": server.NewRPCFunc(rpcGetEphemeralSecretPart, "arg"),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
server.RegisterRPCFuncs(mux, routes, cdc, logger)
|
||||
|
||||
tcpLogger := logger.With("socket", "tcp")
|
||||
config := server.DefaultConfig()
|
||||
server.StartHTTPServer(lis, mux, tcpLogger, config)
|
||||
}
|
||||
go serv()
|
||||
|
||||
port := lis.Addr().(*net.TCPAddr).Port
|
||||
cosigner := NewRemoteCosigner(2, fmt.Sprintf("tcp://0.0.0.0:%d", port))
|
||||
|
||||
resp, err := cosigner.GetEphemeralSecretPart(CosignerGetEphemeralSecretPartRequest{})
|
||||
require.NoError(test, err)
|
||||
require.Equal(test, resp, CosignerGetEphemeralSecretPartResponse{
|
||||
SourceID: 1,
|
||||
SourceEphemeralSecretPublicKey: []byte("foo"),
|
||||
EncryptedSharePart: []byte("bar"),
|
||||
})
|
||||
}
|
|
@ -13,8 +13,9 @@ import (
|
|||
"github.com/tendermint/tendermint/types"
|
||||
)
|
||||
|
||||
// NodeClient dials a node responds to signature requests using its privVal.
|
||||
type NodeClient struct {
|
||||
// ReconnRemoteSigner dials using its dialer and responds to any
|
||||
// signature requests using its privVal.
|
||||
type ReconnRemoteSigner struct {
|
||||
cmn.BaseService
|
||||
|
||||
address string
|
||||
|
@ -25,19 +26,19 @@ type NodeClient struct {
|
|||
dialer net.Dialer
|
||||
}
|
||||
|
||||
// NewNodeClient return a NodeClient that will dial using the given
|
||||
// NewReconnRemoteSigner return a ReconnRemoteSigner that will dial using the given
|
||||
// dialer and respond to any signature requests over the connection
|
||||
// using the given privVal.
|
||||
//
|
||||
// If the connection is broken, the NodeClient will attempt to reconnect.
|
||||
func NewNodeClient(
|
||||
// If the connection is broken, the ReconnRemoteSigner will attempt to reconnect.
|
||||
func NewReconnRemoteSigner(
|
||||
address string,
|
||||
logger log.Logger,
|
||||
chainID string,
|
||||
privVal types.PrivValidator,
|
||||
dialer net.Dialer,
|
||||
) *NodeClient {
|
||||
rs := &NodeClient{
|
||||
) *ReconnRemoteSigner {
|
||||
rs := &ReconnRemoteSigner{
|
||||
address: address,
|
||||
chainID: chainID,
|
||||
privVal: privVal,
|
||||
|
@ -50,13 +51,13 @@ func NewNodeClient(
|
|||
}
|
||||
|
||||
// OnStart implements cmn.Service.
|
||||
func (rs *NodeClient) OnStart() error {
|
||||
func (rs *ReconnRemoteSigner) OnStart() error {
|
||||
go rs.loop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// main loop for NodeClient
|
||||
func (rs *NodeClient) loop() {
|
||||
// main loop for ReconnRemoteSigner
|
||||
func (rs *ReconnRemoteSigner) loop() {
|
||||
var conn net.Conn
|
||||
for {
|
||||
if !rs.IsRunning() {
|
||||
|
@ -120,7 +121,7 @@ func (rs *NodeClient) loop() {
|
|||
}
|
||||
}
|
||||
|
||||
func (rs *NodeClient) handleRequest(req privval.RemoteSignerMsg) (privval.RemoteSignerMsg, error) {
|
||||
func (rs *ReconnRemoteSigner) handleRequest(req privval.RemoteSignerMsg) (privval.RemoteSignerMsg, error) {
|
||||
var res privval.RemoteSignerMsg
|
||||
var err error
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
package signer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
amino "github.com/tendermint/go-amino"
|
||||
cryptoAmino "github.com/tendermint/tendermint/crypto/encoding/amino"
|
||||
"github.com/tendermint/tendermint/privval"
|
||||
"github.com/tendermint/tendermint/types"
|
||||
)
|
||||
|
||||
var codec = amino.NewCodec()
|
||||
|
@ -28,3 +30,18 @@ func WriteMsg(writer io.Writer, msg interface{}) (err error) {
|
|||
_, err = codec.MarshalBinaryLengthPrefixedWriter(writer, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// UnpackHRS deserializes sign bytes and gets the height, round, and step
|
||||
func UnpackHRS(signBytes []byte) (height int64, round int64, step int8, err error) {
|
||||
var proposal types.CanonicalProposal
|
||||
if err := cdc.UnmarshalBinaryLengthPrefixed(signBytes, &proposal); err == nil {
|
||||
return proposal.Height, proposal.Round, stepPropose, nil
|
||||
}
|
||||
|
||||
var vote types.CanonicalVote
|
||||
if err := cdc.UnmarshalBinaryLengthPrefixed(signBytes, &vote); err == nil {
|
||||
return vote.Height, vote.Round, CanonicalVoteToStep(&vote), nil
|
||||
}
|
||||
|
||||
return 0, 0, 0, errors.New("Could not UnpackHRS from sign bytes")
|
||||
}
|
||||
|
|
|
@ -0,0 +1,192 @@
|
|||
package signer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"time"
|
||||
|
||||
cmn "github.com/tendermint/tendermint/libs/common"
|
||||
"github.com/tendermint/tendermint/types"
|
||||
tmtime "github.com/tendermint/tendermint/types/time"
|
||||
)
|
||||
|
||||
const (
|
||||
stepNone int8 = 0 // Used to distinguish the initial state
|
||||
stepPropose int8 = 1
|
||||
stepPrevote int8 = 2
|
||||
stepPrecommit int8 = 3
|
||||
)
|
||||
|
||||
func CanonicalVoteToStep(vote *types.CanonicalVote) int8 {
|
||||
switch vote.Type {
|
||||
case types.PrevoteType:
|
||||
return stepPrevote
|
||||
case types.PrecommitType:
|
||||
return stepPrecommit
|
||||
default:
|
||||
panic("Unknown vote type")
|
||||
}
|
||||
}
|
||||
|
||||
func VoteToStep(vote *types.Vote) int8 {
|
||||
switch vote.Type {
|
||||
case types.PrevoteType:
|
||||
return stepPrevote
|
||||
case types.PrecommitType:
|
||||
return stepPrecommit
|
||||
default:
|
||||
panic("Unknown vote type")
|
||||
}
|
||||
}
|
||||
|
||||
func ProposalToStep(_ *types.Proposal) int8 {
|
||||
return stepPropose
|
||||
}
|
||||
|
||||
// SignState stores signing information for high level watermark management.
|
||||
type SignState struct {
|
||||
Height int64 `json:"height"`
|
||||
Round int64 `json:"round"`
|
||||
Step int8 `json:"step"`
|
||||
EphemeralPublic []byte `json:"ephemeral_public"`
|
||||
Signature []byte `json:"signature,omitempty"`
|
||||
SignBytes cmn.HexBytes `json:"signbytes,omitempty"`
|
||||
|
||||
filePath string
|
||||
}
|
||||
|
||||
// Save persists the FilePvLastSignState to its filePath.
|
||||
func (signState *SignState) Save() {
|
||||
outFile := signState.filePath
|
||||
if outFile == "" {
|
||||
panic("cannot save SignState: filePath not set")
|
||||
}
|
||||
jsonBytes, err := cdc.MarshalJSONIndent(signState, "", " ")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = cmn.WriteFileAtomic(outFile, jsonBytes, 0600)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// CheckHRS checks the given height, round, step (HRS) against that of the
|
||||
// SignState. It returns an error if the arguments constitute a regression,
|
||||
// or if they match but the SignBytes are empty.
|
||||
// Returns true if the HRS matches the arguments and the SignBytes are not empty (indicating
|
||||
// we have already signed for this HRS, and can reuse the existing signature).
|
||||
// It panics if the HRS matches the arguments, there's a SignBytes, but no Signature.
|
||||
func (signState *SignState) CheckHRS(height int64, round int64, step int8) (bool, error) {
|
||||
if signState.Height > height {
|
||||
return false, fmt.Errorf("height regression. Got %v, last height %v", height, signState.Height)
|
||||
}
|
||||
|
||||
if signState.Height == height {
|
||||
if signState.Round > round {
|
||||
return false, fmt.Errorf("round regression at height %v. Got %v, last round %v", height, round, signState.Round)
|
||||
}
|
||||
|
||||
if signState.Round == round {
|
||||
if signState.Step > step {
|
||||
return false, fmt.Errorf("step regression at height %v round %v. Got %v, last step %v", height, round, step, signState.Step)
|
||||
} else if signState.Step == step {
|
||||
if signState.SignBytes != nil {
|
||||
if signState.Signature == nil {
|
||||
panic("pv: Signature is nil but SignBytes is not!")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
return false, errors.New("no SignBytes found")
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// LoadSignState loads a sign state from disk.
|
||||
func LoadSignState(filepath string) (SignState, error) {
|
||||
state := SignState{}
|
||||
stateJSONBytes, err := ioutil.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
err = cdc.UnmarshalJSON(stateJSONBytes, &state)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
state.filePath = filepath
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// LoadOrCreateSignState loads the sign state from filepath
|
||||
// If the sign state could not be loaded, an empty sign state is initialized
|
||||
// and saved to filepath.
|
||||
func LoadOrCreateSignState(filepath string) (SignState, error) {
|
||||
existing, err := LoadSignState(filepath)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// There was an error loading the sign state
|
||||
// Make an empty sign state and save it
|
||||
state := SignState{}
|
||||
state.filePath = filepath
|
||||
state.Save()
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// OnlyDifferByTimestamp returns true if the sign bytes of the sign state
|
||||
// are the same as the new sign bytes excluding the timestamp.
|
||||
func (signState *SignState) OnlyDifferByTimestamp(signBytes []byte) (time.Time, bool) {
|
||||
if signState.Step == stepPropose {
|
||||
return checkProposalOnlyDifferByTimestamp(signState.SignBytes, signBytes)
|
||||
} else if signState.Step == stepPrevote || signState.Step == stepPrecommit {
|
||||
return checkVoteOnlyDifferByTimestamp(signState.SignBytes, signBytes)
|
||||
}
|
||||
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func checkVoteOnlyDifferByTimestamp(lastSignBytes, newSignBytes []byte) (time.Time, bool) {
|
||||
var lastVote, newVote types.CanonicalVote
|
||||
if err := cdc.UnmarshalBinaryLengthPrefixed(lastSignBytes, &lastVote); err != nil {
|
||||
panic(fmt.Sprintf("LastSignBytes cannot be unmarshalled into vote: %v", err))
|
||||
}
|
||||
if err := cdc.UnmarshalBinaryLengthPrefixed(newSignBytes, &newVote); err != nil {
|
||||
panic(fmt.Sprintf("signBytes cannot be unmarshalled into vote: %v", err))
|
||||
}
|
||||
|
||||
lastTime := lastVote.Timestamp
|
||||
|
||||
// set the times to the same value and check equality
|
||||
now := tmtime.Now()
|
||||
lastVote.Timestamp = now
|
||||
newVote.Timestamp = now
|
||||
lastVoteBytes, _ := cdc.MarshalJSON(lastVote)
|
||||
newVoteBytes, _ := cdc.MarshalJSON(newVote)
|
||||
|
||||
return lastTime, bytes.Equal(newVoteBytes, lastVoteBytes)
|
||||
}
|
||||
|
||||
func checkProposalOnlyDifferByTimestamp(lastSignBytes, newSignBytes []byte) (time.Time, bool) {
|
||||
var lastProposal, newProposal types.CanonicalProposal
|
||||
if err := cdc.UnmarshalBinaryLengthPrefixed(lastSignBytes, &lastProposal); err != nil {
|
||||
panic(fmt.Sprintf("LastSignBytes cannot be unmarshalled into proposal: %v", err))
|
||||
}
|
||||
if err := cdc.UnmarshalBinaryLengthPrefixed(newSignBytes, &newProposal); err != nil {
|
||||
panic(fmt.Sprintf("signBytes cannot be unmarshalled into proposal: %v", err))
|
||||
}
|
||||
|
||||
lastTime := lastProposal.Timestamp
|
||||
// set the times to the same value and check equality
|
||||
now := tmtime.Now()
|
||||
lastProposal.Timestamp = now
|
||||
newProposal.Timestamp = now
|
||||
lastProposalBytes, _ := cdc.MarshalBinaryLengthPrefixed(lastProposal)
|
||||
newProposalBytes, _ := cdc.MarshalBinaryLengthPrefixed(newProposal)
|
||||
|
||||
return lastTime, bytes.Equal(newProposalBytes, lastProposalBytes)
|
||||
}
|
|
@ -0,0 +1,357 @@
|
|||
package signer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
amino "github.com/tendermint/go-amino"
|
||||
"github.com/tendermint/tendermint/crypto"
|
||||
cryptoAmino "github.com/tendermint/tendermint/crypto/encoding/amino"
|
||||
"github.com/tendermint/tendermint/types"
|
||||
tsed25519 "gitlab.com/polychainlabs/threshold-ed25519/pkg"
|
||||
)
|
||||
|
||||
var cdc = amino.NewCodec()
|
||||
|
||||
func init() {
|
||||
cryptoAmino.RegisterAmino(cdc)
|
||||
}
|
||||
|
||||
type ThresholdValidator struct {
|
||||
threshold int
|
||||
|
||||
pubkey crypto.PubKey
|
||||
|
||||
// stores the last sign state for a block we have fully signed
|
||||
// Cached to respond to SignVote requests if we already have a signature
|
||||
lastSignState SignState
|
||||
|
||||
// our own cosigner
|
||||
cosigner Cosigner
|
||||
|
||||
// peer cosigners
|
||||
peers []Cosigner
|
||||
}
|
||||
|
||||
type ThresholdValidatorOpt struct {
|
||||
Pubkey crypto.PubKey
|
||||
Threshold int
|
||||
SignState SignState
|
||||
Cosigner Cosigner
|
||||
Peers []Cosigner
|
||||
}
|
||||
|
||||
// NewThresholdValidator creates and returns a new ThresholdValidator
|
||||
func NewThresholdValidator(opt *ThresholdValidatorOpt) *ThresholdValidator {
|
||||
validator := &ThresholdValidator{}
|
||||
validator.cosigner = opt.Cosigner
|
||||
validator.peers = opt.Peers
|
||||
validator.threshold = opt.Threshold
|
||||
validator.pubkey = opt.Pubkey
|
||||
validator.lastSignState = opt.SignState
|
||||
return validator
|
||||
}
|
||||
|
||||
// GetPubKey returns the public key of the validator.
|
||||
// Implements PrivValidator.
|
||||
func (pv *ThresholdValidator) GetPubKey() crypto.PubKey {
|
||||
return pv.pubkey
|
||||
}
|
||||
|
||||
// SignVote signs a canonical representation of the vote, along with the
|
||||
// chainID. Implements PrivValidator.
|
||||
func (pv *ThresholdValidator) SignVote(chainID string, vote *types.Vote) error {
|
||||
|
||||
// round the timestamp to nearest second to reduce non-deterministic signature log messages
|
||||
vote.Timestamp = vote.Timestamp.Round(time.Second)
|
||||
|
||||
block := &block{
|
||||
Height: vote.Height,
|
||||
Round: int64(vote.Round),
|
||||
Step: VoteToStep(vote),
|
||||
Timestamp: vote.Timestamp,
|
||||
SignBytes: vote.SignBytes(chainID),
|
||||
}
|
||||
sig, stamp, err := pv.signBlock(chainID, block)
|
||||
|
||||
vote.Signature = sig
|
||||
vote.Timestamp = stamp
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SignProposal signs a canonical representation of the proposal, along with
|
||||
// the chainID. Implements PrivValidator.
|
||||
func (pv *ThresholdValidator) SignProposal(chainID string, proposal *types.Proposal) error {
|
||||
|
||||
// round the timestamp to nearest second to reduce non-deterministic signature log messages
|
||||
proposal.Timestamp = proposal.Timestamp.Round(time.Second)
|
||||
|
||||
block := &block{
|
||||
Height: proposal.Height,
|
||||
Round: int64(proposal.Round),
|
||||
Step: ProposalToStep(proposal),
|
||||
Timestamp: proposal.Timestamp,
|
||||
SignBytes: proposal.SignBytes(chainID),
|
||||
}
|
||||
sig, stamp, err := pv.signBlock(chainID, block)
|
||||
|
||||
proposal.Signature = sig
|
||||
proposal.Timestamp = stamp
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type block struct {
|
||||
Height int64
|
||||
Round int64
|
||||
Step int8
|
||||
SignBytes []byte
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
func (pv *ThresholdValidator) signBlock(chainID string, block *block) ([]byte, time.Time, error) {
|
||||
height, round, step, stamp := block.Height, block.Round, block.Step, block.Timestamp
|
||||
|
||||
// the block sign state for caching full block signatures
|
||||
lss := pv.lastSignState
|
||||
|
||||
// check watermark
|
||||
sameHRS, err := lss.CheckHRS(height, int64(round), step)
|
||||
if err != nil {
|
||||
return nil, stamp, err
|
||||
}
|
||||
|
||||
signBytes := block.SignBytes
|
||||
|
||||
if sameHRS {
|
||||
if bytes.Equal(signBytes, lss.SignBytes) {
|
||||
return lss.Signature, block.Timestamp, nil
|
||||
} else if timestamp, ok := lss.OnlyDifferByTimestamp(signBytes); ok {
|
||||
return lss.Signature, timestamp, nil
|
||||
}
|
||||
|
||||
return nil, stamp, errors.New("conflicting data")
|
||||
}
|
||||
|
||||
total := uint8(len(pv.peers) + 1)
|
||||
|
||||
// destination for share signatures
|
||||
shareSignatures := make([][]byte, total)
|
||||
|
||||
// share sigs is updated by goroutines
|
||||
shareSignaturesMutex := sync.Mutex{}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(len(pv.peers))
|
||||
|
||||
ourID := pv.cosigner.GetID()
|
||||
|
||||
// have our cosigner generate ephemeral info at the current height
|
||||
_, err = pv.cosigner.GetEphemeralSecretPart(CosignerGetEphemeralSecretPartRequest{
|
||||
ID: ourID,
|
||||
Height: height,
|
||||
Round: round,
|
||||
Step: step,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, stamp, err
|
||||
}
|
||||
|
||||
// There are two layers of goroutines for each cosigner.
|
||||
// The outer routine for each cosigner to dispatch signing in parallel. This outer routine
|
||||
// block on the signing request completing.
|
||||
// The inner routine (formed within each request goroutine), dispatches the actual signing call.
|
||||
// This is to support a time out which can happen when using remote signers.
|
||||
for _, cosigner := range pv.peers {
|
||||
request := func(cosigner Cosigner) {
|
||||
cosignerId := cosigner.GetID()
|
||||
cosignerIdx := cosignerId - 1
|
||||
|
||||
// cosigner.Sign makes a blocking RPC request (with no timeout)
|
||||
// to prevent it from hanging our process indefinitely, we use a timeout context
|
||||
// and another goroutine
|
||||
signCtx, signCtxCancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
|
||||
go func() {
|
||||
hasResp, err := pv.cosigner.HasEphemeralSecretPart(CosignerHasEphemeralSecretPartRequest{
|
||||
ID: cosignerId,
|
||||
Height: height,
|
||||
Round: round,
|
||||
Step: step,
|
||||
})
|
||||
|
||||
// did we timeout or finish elsewhere?
|
||||
select {
|
||||
case <-signCtx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR HasEphemeralSecretPart: %s\n", err)
|
||||
signCtxCancel()
|
||||
return
|
||||
}
|
||||
|
||||
if !hasResp.Exists {
|
||||
// if we don't already have an ephemeral secret part for the HRS, we need to get one
|
||||
ephSecretResp, err := cosigner.GetEphemeralSecretPart(CosignerGetEphemeralSecretPartRequest{
|
||||
ID: ourID,
|
||||
Height: height,
|
||||
Round: round,
|
||||
Step: step,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR GetEphemeralSecretPart %s\n", err)
|
||||
}
|
||||
|
||||
// did we timeout or finish elsewhere?
|
||||
select {
|
||||
case <-signCtx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
signCtxCancel()
|
||||
return
|
||||
}
|
||||
|
||||
// set the response for ourselves
|
||||
err = pv.cosigner.SetEphemeralSecretPart(CosignerSetEphemeralSecretPartRequest{
|
||||
SourceID: ephSecretResp.SourceID,
|
||||
SourceEphemeralSecretPublicKey: ephSecretResp.SourceEphemeralSecretPublicKey,
|
||||
EncryptedSharePart: ephSecretResp.EncryptedSharePart,
|
||||
Height: height,
|
||||
Round: round,
|
||||
Step: step,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR SetEphemeralSecretPart %s\n", err)
|
||||
}
|
||||
|
||||
// did we timeout or finish elsewhere?
|
||||
select {
|
||||
case <-signCtx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
signCtxCancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ask the cosigner to sign with their share
|
||||
sigResp, err := cosigner.Sign(CosignerSignRequest{
|
||||
SignBytes: signBytes,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR Sign %s\n", err)
|
||||
}
|
||||
|
||||
// did we timeout or finish elsewhere?
|
||||
select {
|
||||
case <-signCtx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
signCtxCancel()
|
||||
return
|
||||
}
|
||||
|
||||
// The signCtx is done if it times out or if the blockCtx done cancels it
|
||||
select {
|
||||
case <-signCtx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
defer signCtxCancel()
|
||||
|
||||
shareSignaturesMutex.Lock()
|
||||
defer shareSignaturesMutex.Unlock()
|
||||
|
||||
shareSignatures[cosignerIdx] = make([]byte, len(sigResp.Signature))
|
||||
copy(shareSignatures[cosignerIdx], sigResp.Signature)
|
||||
}()
|
||||
|
||||
// the sign context finished or timed out
|
||||
select {
|
||||
case <-signCtx.Done():
|
||||
}
|
||||
|
||||
wg.Done()
|
||||
}
|
||||
|
||||
go request(cosigner)
|
||||
}
|
||||
|
||||
// Wait for all cosigners to be complete
|
||||
// A Cosigner will either respond in time, or be canceled with timeout
|
||||
wg.Wait()
|
||||
|
||||
shareSignaturesMutex.Lock()
|
||||
defer shareSignaturesMutex.Unlock()
|
||||
|
||||
// sign with our share now
|
||||
signResp, err := pv.cosigner.Sign(CosignerSignRequest{
|
||||
SignBytes: signBytes,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, stamp, err
|
||||
}
|
||||
|
||||
ephemeralPublic := signResp.EphemeralPublic
|
||||
|
||||
shareSignatures[ourID-1] = make([]byte, len(signResp.Signature))
|
||||
copy(shareSignatures[ourID-1], signResp.Signature)
|
||||
|
||||
// collect all valid responses into array of ids and signatures for the threshold lib
|
||||
sigIds := make([]int, 0)
|
||||
shareSigs := make([][]byte, 0)
|
||||
for idx, shareSig := range shareSignatures {
|
||||
if len(shareSig) == 0 {
|
||||
continue
|
||||
}
|
||||
sigIds = append(sigIds, idx+1)
|
||||
|
||||
// we are ok to use the share signatures - complete boolean
|
||||
// prevents future concurrent access
|
||||
shareSigs = append(shareSigs, shareSig)
|
||||
}
|
||||
|
||||
if len(sigIds) < pv.threshold {
|
||||
return nil, stamp, errors.New("Not enough co-signers")
|
||||
}
|
||||
|
||||
// assemble into final signature
|
||||
combinedSig := tsed25519.CombineShares(total, sigIds, shareSigs)
|
||||
|
||||
signature := append(ephemeralPublic, combinedSig...)
|
||||
|
||||
// verify the combined signature before saving to watermark
|
||||
if !pv.pubkey.VerifyBytes(signBytes, signature) {
|
||||
return nil, stamp, errors.New("Combined signature is not valid")
|
||||
}
|
||||
|
||||
pv.lastSignState.Height = height
|
||||
pv.lastSignState.Round = round
|
||||
pv.lastSignState.Step = step
|
||||
pv.lastSignState.Signature = signature
|
||||
pv.lastSignState.SignBytes = signBytes
|
||||
pv.lastSignState.Save()
|
||||
|
||||
return signature, stamp, nil
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"rsa_key": "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",
|
||||
"pub_key": "FiTeZCA2QoZNqlsfZLdYAMeCCNsH4dAKxC2sqkGAwcYSIyBgdQ==",
|
||||
"secret_share": "SPhjpgM6rx4Iv2/XpQ4cDBsdgwkQeF9NnsaFDH+g6QY=",
|
||||
"id": 3,
|
||||
"rsa_pubs": [
|
||||
"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"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue