-
Notifications
You must be signed in to change notification settings - Fork 90
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
cmd: add combine
command
#1799
Changes from all commits
4b7d303
0a6eccd
bbf1c1f
fa3873f
574b9bb
2e061ba
94936c5
6567ccc
6f6e976
9475101
58e203b
69d043b
5925a42
2336f68
92b3fac
b06e23f
41d7289
dbe7889
273618f
53eb82b
d21717a
371431f
3107d1a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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.") | ||
} |
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 | ||
} | ||
|
||
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we allow Happy to skip this if not a concern There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.