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

Rework remote signer #288

Merged
merged 10 commits into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

# StakeWise V3 Operator

1. [What is V3 Operator?](#what-is-v3-operator)
Expand Down Expand Up @@ -41,12 +42,13 @@ The validator registration process consists of the following steps:
1. Check whether Vault has accumulated enough assets to register a validator (e.g., 32 ETH for Ethereum)
2. Get the next free validator public key from the deposit data file attached to the operator. The validators are
registered in the same order as specified in the deposit data file.
3. Share the exit signature of the validator with StakeWise Oracles:
1. Using [Shamir's secret sharing](https://en.wikipedia.org/wiki/Shamir%27s_secret_sharing), generate shares for the
validator's BLS private key. The number of shares is equal to the number of oracles.
2. Sign the exit message with every private key share and encrypt exit signatures with oracles' public keys.
3. Obtain BLS signature for exit message using local keystores or remote signer.
4. Share the exit signature of the validator with StakeWise Oracles:
1. Using [Shamir's secret sharing](https://en.wikipedia.org/wiki/Shamir%27s_secret_sharing), split
validator's BLS signature. The number of shares is equal to the number of oracles.
2. Encrypt exit signatures with oracles' public keys.
3. Send encrypted exit signatures to all the oracles and receive registration signatures from them.
4. Send transaction to Vault contract to register the validator.
5. Send transaction to Vault contract to register the validator.

### Exit signatures rotation

Expand Down
14 changes: 13 additions & 1 deletion src/commands/recover.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from src.common.contracts import v2_pool_contract, vault_contract
from src.common.credentials import CredentialManager
from src.common.execution import SECONDS_PER_MONTH
from src.common.logging import setup_logging
from src.common.logging import LOG_LEVELS, setup_logging
from src.common.password import generate_password, get_or_create_password_file
from src.common.utils import greenify, log_verbose
from src.common.validators import validate_eth_address, validate_mnemonic
Expand Down Expand Up @@ -76,6 +76,16 @@
case_sensitive=False,
),
)
@click.option(
'--log-level',
type=click.Choice(
LOG_LEVELS,
case_sensitive=False,
),
default='INFO',
envvar='LOG_LEVEL',
help='The log level.',
)
# pylint: disable-next=too-many-arguments
def recover(
data_dir: str,
Expand All @@ -86,6 +96,7 @@ def recover(
execution_endpoints: str,
per_keystore_password: bool,
no_confirm: bool,
log_level: str,
) -> None:
# pylint: disable=duplicate-code
config = VaultConfig(
Expand All @@ -106,6 +117,7 @@ def recover(
vault=vault,
network=network,
vault_dir=config.vault_dir,
log_level=log_level,
)

try:
Expand Down
206 changes: 71 additions & 135 deletions src/commands/remote_signer_setup.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
import asyncio
import logging
import os
import shutil
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from pathlib import Path

import aiohttp
import click
import milagro_bls_binding as bls
from aiohttp import ClientTimeout
from eth_typing import BLSPrivateKey, HexAddress
from web3 import Web3

from src.common.credentials import Credential
from src.common.execution import get_oracles
from src.common.logging import setup_logging
from src.common.password import get_or_create_password_file
from src.common.utils import log_verbose
from eth_typing import HexAddress

from src.common.logging import LOG_LEVELS, setup_logging
from src.common.utils import chunkify, log_verbose
from src.common.validators import validate_eth_address
from src.common.vault_config import VaultConfig
from src.config.settings import REMOTE_SIGNER_TIMEOUT, settings
from src.config.settings import (
REMOTE_SIGNER_TIMEOUT,
REMOTE_SIGNER_UPLOAD_CHUNK_SIZE,
settings,
)
from src.validators.keystores.local import LocalKeystore
from src.validators.keystores.remote import RemoteSignerKeystore
from src.validators.signing.key_shares import private_key_to_private_key_shares

logger = logging.getLogger(__name__)


@click.option(
Expand All @@ -39,14 +39,6 @@
required=True,
help='The base URL of the remote signer, e.g. http://signer:9000',
)
@click.option(
'--remove-existing-keys',
type=bool,
is_flag=True,
help='Whether to remove existing keys from the remote signer. Useful'
' when the oracle set changes and the previously generated key shares'
' are no longer going to be used.',
)
@click.option(
'--data-dir',
default=str(Path.home() / '.stakewise'),
Expand All @@ -61,41 +53,43 @@
help='Absolute path to the directory with all the encrypted keystores. '
'Default is the directory generated with "create-keys" command.',
)
@click.option(
'--execution-endpoints',
type=str,
envvar='EXECUTION_ENDPOINTS',
prompt='Enter comma separated list of API endpoints for execution nodes',
help='Comma separated list of API endpoints for execution nodes.',
)
@click.option(
'-v',
'--verbose',
help='Enable debug mode. Default is false.',
envvar='VERBOSE',
is_flag=True,
)
@click.command(help='Generates and uploads private key shares to a remote signer.')
@click.option(
'--log-level',
type=click.Choice(
LOG_LEVELS,
case_sensitive=False,
),
default='INFO',
envvar='LOG_LEVEL',
help='The log level.',
)
@click.command(help='Uploads private keys to a remote signer.')
# pylint: disable-next=too-many-arguments
def remote_signer_setup(
vault: HexAddress,
remote_signer_url: str,
remove_existing_keys: bool,
data_dir: str,
keystores_dir: str | None,
execution_endpoints: str,
verbose: bool,
log_level: str,
) -> None:
config = VaultConfig(vault, Path(data_dir))
config.load()
settings.set(
vault=vault,
vault_dir=config.vault_dir,
network=config.network,
execution_endpoints=execution_endpoints,
keystores_dir=keystores_dir,
remote_signer_url=remote_signer_url,
verbose=verbose,
log_level=log_level,
)

try:
Expand All @@ -105,136 +99,78 @@ def remote_signer_setup(
asyncio.get_running_loop()
# we need to create a separate thread so we can block before returning
with ThreadPoolExecutor(1) as pool:
pool.submit(
lambda: asyncio.run(main(remove_existing_keys=remove_existing_keys))
).result()
pool.submit(lambda: asyncio.run(main())).result()
except RuntimeError as e:
if 'no running event loop' == e.args[0]:
# no event loop running
asyncio.run(main(remove_existing_keys=remove_existing_keys))
asyncio.run(main())
else:
raise e
except Exception as e:
log_verbose(e)


# pylint: disable-next=too-many-locals
async def main(remove_existing_keys: bool) -> None:
async def main() -> None:
setup_logging()
keystores = await LocalKeystore.load()
if len(keystores) == 0:
keystore_files = LocalKeystore.list_keystore_files()
if len(keystore_files) == 0:
raise click.ClickException('Keystores not found.')

# Check if remote signer's keymanager API is reachable before taking further steps
async with aiohttp.ClientSession(
timeout=ClientTimeout(connect=REMOTE_SIGNER_TIMEOUT)
) as session:
async with aiohttp.ClientSession(timeout=ClientTimeout(REMOTE_SIGNER_TIMEOUT)) as session:
resp = await session.get(f'{settings.remote_signer_url}/eth/v1/keystores')
if resp.status == 404:
logger.warning(
'make sure that you run remote signer with '
'`--enable-key-manager-api=true` option'
)
if resp.status != 200:
raise RuntimeError(f'Failed to connect to remote signer, returned {await resp.text()}')

oracles = await get_oracles()

try:
remote_signer_keystore = RemoteSignerKeystore.load_from_file(
settings.remote_signer_config_file
)
except FileNotFoundError:
remote_signer_keystore = RemoteSignerKeystore({})

credentials = []
for pubkey, private_key in keystores.keys.items(): # pylint: disable=no-member
private_key_shares = private_key_to_private_key_shares(
private_key=private_key,
threshold=oracles.exit_signature_recover_threshold,
total=len(oracles.public_keys),
)

for idx, private_key_share in enumerate(private_key_shares):
credentials.append(
Credential(
private_key=BLSPrivateKey(int.from_bytes(private_key_share, 'big')),
path=f'share_{pubkey}_{idx}',
network=settings.network,
vault=settings.vault,
# Read keystores without decrypting
keystores_json = []
for keystore_file in keystore_files:
with open(settings.keystores_dir / keystore_file.name, encoding='ascii') as f:
keystores_json.append(f.read())

# Import keystores to remote signer
chunk_size = REMOTE_SIGNER_UPLOAD_CHUNK_SIZE

async with aiohttp.ClientSession(timeout=ClientTimeout(REMOTE_SIGNER_TIMEOUT)) as session:
for keystores_json_chunk, keystore_files_chunk in zip(
chunkify(keystores_json, chunk_size), chunkify(keystore_files, chunk_size)
):
data = {
'keystores': keystores_json_chunk,
'passwords': [kf.password for kf in keystore_files_chunk],
}
upload_url = f'{settings.remote_signer_url}/eth/v1/keystores'
logger.debug('POST %s', upload_url)
resp = await session.post(upload_url, json=data)
if resp.status != 200:
raise RuntimeError(
f'Error occurred during import of keystores to remote signer'
f' - status code {resp.status}, body: {await resp.text()}'
)
)
remote_signer_keystore.pubkeys_to_shares[pubkey] = [
Web3.to_hex(bls.SkToPk(priv_key)) for priv_key in private_key_shares
]

click.echo(
f'Successfully generated {len(credentials)} key shares'
f' for {len(keystores)} private key(s)!',
)

# Import as keystores to remote signer
password = get_or_create_password_file(settings.keystores_password_file)
key_share_keystores = []
for credential in credentials:
key_share_keystores.append(deepcopy(credential.encrypt_signing_keystore(password=password)))

async with aiohttp.ClientSession(
timeout=ClientTimeout(connect=REMOTE_SIGNER_TIMEOUT)
) as session:
data = {
'keystores': [ksk.as_json() for ksk in key_share_keystores],
'passwords': [password for _ in key_share_keystores],
}
resp = await session.post(f'{settings.remote_signer_url}/eth/v1/keystores', json=data)
if resp.status != 200:
raise RuntimeError(
f'Error occurred during import of keystores to remote signer'
f' - status code {resp.status}, body: {await resp.text()}'
)

click.echo(
f'Successfully imported {len(key_share_keystores)} key shares into remote signer.',
f'Successfully imported {len(keystore_files)} keys into remote signer.',
)

# Remove local keystores - keys are loaded in remote signer and are not
# needed locally anymore
for keystore_file in os.listdir(settings.keystores_dir):
os.remove(settings.keystores_dir / keystore_file)

click.echo('Removed keystores from local filesystem.')

# Remove outdated keystores from remote signer
if remove_existing_keys:
active_pubkey_shares = {
pk for pk_list in remote_signer_keystore.pubkeys_to_shares.values() for pk in pk_list
}

async with aiohttp.ClientSession(
timeout=ClientTimeout(connect=REMOTE_SIGNER_TIMEOUT)
) as session:
resp = await session.get(f'{settings.remote_signer_url}/eth/v1/keystores')
pubkeys_data = (await resp.json())['data']
pubkeys_remote_signer = {
pubkey_dict.get('validating_pubkey') for pubkey_dict in pubkeys_data
}

# Only remove pubkeys from signer that are no longer needed
inactive_pubkeys = pubkeys_remote_signer - active_pubkey_shares
# Keys are loaded in remote signer and are not needed locally anymore
if click.confirm('Remove local keystores?'):
shutil.rmtree(settings.keystores_dir)

resp = await session.delete(
f'{settings.remote_signer_url}/eth/v1/keystores',
json={'pubkeys': list(inactive_pubkeys)},
)
if resp.status != 200:
raise RuntimeError(
f'Error occurred while deleting existing keys from remote signer'
f' - status code {resp.status}, body: {await resp.text()}'
)
if settings.keystores_password_dir.exists():
shutil.rmtree(settings.keystores_password_dir)

click.echo(
f'Removed {len(inactive_pubkeys)} keys from remote signer',
)
if settings.keystores_password_file.exists():
os.remove(settings.keystores_password_file)

remote_signer_keystore.save(settings.remote_signer_config_file)
click.echo('Removed keystores from local filesystem.')

click.echo(
f'Done.'
f' Successfully configured operator to use remote signer'
f' for {len(keystores)} public key(s)!',
f' for {len(keystore_files)} public key(s)!',
)
10 changes: 2 additions & 8 deletions src/commands/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import src
from src.common.consensus import get_chain_finalized_head
from src.common.execution import WalletTask
from src.common.logging import setup_logging
from src.common.logging import LOG_LEVELS, setup_logging
from src.common.metrics import MetricsTask, metrics_server
from src.common.startup_check import startup_checks
from src.common.utils import get_build_version, log_verbose
Expand Down Expand Up @@ -190,13 +190,7 @@
@click.option(
'--log-level',
type=click.Choice(
[
'FATAL',
'ERROR',
'WARNING',
'INFO',
'DEBUG',
],
LOG_LEVELS,
case_sensitive=False,
),
default='INFO',
Expand Down
Loading
Loading