Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

cmd: add combine command #1799

Merged
merged 23 commits into from
Feb 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4b7d303
testutil/combine: move combine package to root repo directory
gsora Feb 13, 2023
0a6eccd
cmd: add combine command
gsora Feb 13, 2023
bbf1c1f
chore: satisfy pre-commit
gsora Feb 13, 2023
fa3873f
cmd: rename bindVars to bindCombineVars
gsora Feb 13, 2023
574b9bb
cmd: use cmd.Context() to newCombineFunc instead of creating a new one
gsora Feb 13, 2023
2e061ba
cmd: rename cluster lock flag and default file name
gsora Feb 13, 2023
94936c5
rename keyfile to keystore
gsora Feb 13, 2023
6567ccc
Merge branch 'main' into gsora/combine_cli
gsora Feb 13, 2023
6f6e976
cmd: move combine to a directory based, multi validator approach
gsora Feb 16, 2023
9475101
combine: promote Combine2 to Combine
gsora Feb 16, 2023
58e203b
Squashed commit of the following:
gsora Feb 20, 2023
69d043b
combine: support ".charon" directory style
gsora Feb 20, 2023
5925a42
Update cmd/combine.go
gsora Feb 20, 2023
2336f68
combine: verify lock signatures and hashes
gsora Feb 20, 2023
92b3fac
combine: test "force" flag
gsora Feb 20, 2023
b06e23f
cmd: rename inputDir to keystoresDir for combine command
gsora Feb 22, 2023
41d7289
Merge branch 'main' into gsora/combine_cli
gsora Feb 22, 2023
dbe7889
combine: fix variable declaration style
gsora Feb 22, 2023
273618f
combine: move combined private keys under the `validator_keys` directory
gsora Feb 22, 2023
53eb82b
cmd: reword combine command
gsora Feb 22, 2023
d21717a
cmd: reword combine command
gsora Feb 22, 2023
371431f
cmd: rename combine keystores-dir to cluster-dir
gsora Feb 22, 2023
3107d1a
cmd: change default directory for cluster-dir
gsora Feb 22, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func New() *cobra.Command {
newCreateEnrCmd(runCreateEnrCmd),
newCreateClusterCmd(runCreateCluster),
),
newCombineCmd(newCombineFunc),
)
}

Expand Down
59 changes: 59 additions & 0 deletions cmd/combine.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright © 2022 Obol Labs Inc.
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <http://www.gnu.org/licenses/>.

package cmd

import (
"context"

"github.com/spf13/cobra"
"github.com/spf13/pflag"

"github.com/obolnetwork/charon/combine"
)

func newCombineCmd(runFunc func(ctx context.Context, clusterDir string, force bool) error) *cobra.Command {
var (
clusterDir string
force bool
)

cmd := &cobra.Command{
Use: "combine",
Short: "Combines the private key shares of a distributed validator cluster into a set of standard validator private keys.",
Long: "Combines the private key shares from a threshold of operators in a distributed validator cluster into a set of validator private keys that can be imported into a standard Ethereum validator client.\n\nWarning: running the resulting private keys in a validator alongside the original distributed validator cluster *will* result in slashing.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runFunc(cmd.Context(), clusterDir, force)
},
}

bindCombineFlags(
cmd.Flags(),
&clusterDir,
&force,
)

return cmd
}

func newCombineFunc(ctx context.Context, clusterDir string, force bool) error {
return combine.Combine(ctx, clusterDir, force)
}

func bindCombineFlags(flags *pflag.FlagSet, clusterDir *string, force *bool) {
flags.StringVar(clusterDir, "cluster-dir", ".charon/", `Parent directory containing a number of .charon subdirectories from each node in the cluster.`)
flags.BoolVar(force, "force", false, "Overwrites private keys with the same name if present.")
}
229 changes: 229 additions & 0 deletions combine/combine.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
// Copyright © 2022 Obol Labs Inc.
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <http://www.gnu.org/licenses/>.

package combine

import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"io"
"os"
"path/filepath"

"github.com/obolnetwork/charon/app/errors"
"github.com/obolnetwork/charon/app/log"
"github.com/obolnetwork/charon/app/z"
"github.com/obolnetwork/charon/cluster"
"github.com/obolnetwork/charon/eth2util/keystore"
tblsv2 "github.com/obolnetwork/charon/tbls/v2"
)

// Combine combines validator keys contained in inputDir, and writes the original BLS12-381 private keys.
// Combine is validator-aware: it'll recombine all the validator keys listed in the "Validator" field of the lock file.
// To do so, the user must prepare inputDir as follows:
// - place the ".charon" directory in inputDir, renamed to another name
//
// Combine will create a new directory named after the public key of each validator key reconstructed, containing each
// keystore under the "validator_keys" subdirectory.
func Combine(ctx context.Context, inputDir string, force bool) error {
log.Info(ctx, "Recombining key shares",
z.Str("input_dir", inputDir),
)

lock, possibleKeyPaths, err := loadLockfile(inputDir)
if err != nil {
return errors.Wrap(err, "cannot open lock file")
}

privkeys := make(map[int][]tblsv2.PrivateKey)

for _, pkp := range possibleKeyPaths {
secrets, err := keystore.LoadKeys(pkp)
if err != nil {
return errors.Wrap(err, "cannot load keystore", z.Str("path", pkp))
}

for idx, secret := range secrets {
privkeys[idx] = append(privkeys[idx], secret)
}
}

for idx, pkSet := range privkeys {
log.Info(ctx, "Recombining key share", z.Int("validator_number", idx))
shares, err := secretsToShares(lock, pkSet)
if err != nil {
return err
}

if len(shares) < lock.Threshold {
return errors.New("insufficient number of keys", z.Int("validator_number", idx))
}

secret, err := tblsv2.RecoverSecret(shares, uint(len(lock.Operators)), uint(lock.Threshold))
if err != nil {
return errors.Wrap(err, "cannot recover shares", z.Int("validator_number", idx))
}

// require that the generated secret pubkey matches what's in the lockfile for the idx validator
val := lock.Validators[idx]

valPk, err := val.PublicKey()
if err != nil {
return errors.Wrap(err, "public key for validator from lockfile", z.Int("validator_number", idx))
}

genPubkey, err := tblsv2.SecretToPublicKey(secret)
if err != nil {
return errors.Wrap(err, "public key for validator from generated secret", z.Int("validator_number", idx))
}

if valPk != genPubkey {
return errors.New("generated and lockfile public key for validator DO NOT match", z.Int("validator_number", idx))
}

outPath := filepath.Join(inputDir, val.PublicKeyHex(), "validator_keys")
if err := os.MkdirAll(outPath, 0o755); err != nil {
return errors.Wrap(err, "output directory creation", z.Int("validator_number", idx))
}

ksPath := filepath.Join(outPath, "keystore-0.json")
_, err = os.Stat(ksPath)
if err == nil && !force {
return errors.New("refusing to overwrite existing private key", z.Int("validator_number", idx), z.Str("path", ksPath))
}

if err := keystore.StoreKeys([]tblsv2.PrivateKey{secret}, outPath); err != nil {
return errors.Wrap(err, "cannot store keystore", z.Int("validator_number", idx))
}
}

return nil
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't looked at the encompassing error handling around this function, but can a partial success happen where we don't manage to combine all keys successfully but do output some? We probably don't want to allow that, and should fail entirely if that's not already the case. (assume we freak out if err != nil

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, the function will error out if one of the validator keys can't be combined but the artifacts of the other keys will remain.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm merging this PR, let's address this behavior in a separate one if needs a change.


func secretsToShares(lock cluster.Lock, secrets []tblsv2.PrivateKey) (map[int]tblsv2.PrivateKey, error) {
n := len(lock.Operators)

resp := make(map[int]tblsv2.PrivateKey)
for idx, secret := range secrets {
pubkey, err := tblsv2.SecretToPublicKey(secret)
if err != nil {
return nil, errors.Wrap(err, "pubkey from share")
}

var found bool
for _, val := range lock.Validators {
for i := 0; i < n; i++ {
pubShare, err := val.PublicShare(i)
if err != nil {
return nil, errors.Wrap(err, "pubshare from lock")
}

if !bytes.Equal(pubkey[:], pubShare[:]) {
continue
}

resp[idx+1] = secret
found = true

break
}

if found {
break
}
}

if !found {
return nil, errors.New("share not found in lock")
}
}

return resp, nil
}

// loadLockfile loads a lockfile from one of the charon directories contained in dir.
// It checks that all the directories containing a validator_keys subdirectory contain the same cluster_lock.json file.
// It returns the cluster.Lock read from the lock file, and a list of directories that possibly contains keys.
func loadLockfile(dir string) (cluster.Lock, []string, error) {
root, err := os.ReadDir(dir)
if err != nil {
return cluster.Lock{}, nil, errors.Wrap(err, "can't read directory")
}

var (
lfFound bool
lastLockfileHash [32]byte
lfContent []byte
possibleValKeysDir []string
)

for _, sd := range root {
if !sd.IsDir() {
continue
}

// try opening the lock file
lfPath := filepath.Join(dir, sd.Name(), "cluster-lock.json")
b, err := os.Open(lfPath)
if err != nil {
continue
}

// does this directory contains a "validator_keys" directory? if yes continue and add it as a candidate
vcdPath := filepath.Join(dir, sd.Name(), "validator_keys")
_, err = os.ReadDir(vcdPath)
if err != nil {
continue
}

possibleValKeysDir = append(possibleValKeysDir, vcdPath)

lfc, err := io.ReadAll(b)
if err != nil {
continue
}

lfHash := sha256.Sum256(lfc)

if lastLockfileHash != [32]byte{} && lfHash != lastLockfileHash {
return cluster.Lock{}, nil, errors.New("found different lockfile in node directory", z.Str("name", sd.Name()))
}

lastLockfileHash = lfHash
lfContent = lfc
lfFound = true
}

if !lfFound {
return cluster.Lock{}, nil, errors.New("lock file not found")
}

var lock cluster.Lock
if err := json.Unmarshal(lfContent, &lock); err != nil {
return cluster.Lock{}, nil, errors.Wrap(err, "unmarshal lock file")
}

if err := lock.VerifyHashes(); err != nil {
return cluster.Lock{}, nil, errors.Wrap(err, "cluster lock hash verification failed")
}

if err := lock.VerifySignatures(); err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we allow --no-verify flags on this command @corverroos ? Imagined use case would be trying to recombine a really old cluster in a modern (>v1.0) charon version, and not being able to get it to verifySignatures correctly? (Or we can just pinky promise to always support old signatures/verification for anything that could possibly be in prod?)

Happy to skip this if not a concern

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm merging this PR, let's address this behavior in a separate one if needs a change.

return cluster.Lock{}, nil, errors.Wrap(err, "cluster lock signature verification failed")
}

return lock, possibleValKeysDir, nil
}
Loading