Skip to content

Rework remote signer #288

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

Merged
merged 10 commits into from
Feb 7, 2024
162 changes: 41 additions & 121 deletions src/commands/remote_signer_setup.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,22 @@
import asyncio
import logging
import os
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 eth_typing import HexAddress

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 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.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 +34,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 Down Expand Up @@ -80,7 +67,6 @@
def remote_signer_setup(
vault: HexAddress,
remote_signer_url: str,
remove_existing_keys: bool,
data_dir: str,
keystores_dir: str | None,
execution_endpoints: str,
Expand All @@ -96,6 +82,7 @@ def remote_signer_setup(
keystores_dir=keystores_dir,
remote_signer_url=remote_signer_url,
verbose=verbose,
log_level='DEBUG',
)

try:
Expand All @@ -105,136 +92,69 @@ 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 != 200:
# In case of 404 make sure that you run remote signer with
# `--enable-key-manager-api=true` option
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 = 10
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)
for keystore_file in keystore_files:
os.remove(settings.keystores_dir / keystore_file.name)

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

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()}'
)

click.echo(
f'Removed {len(inactive_pubkeys)} keys from remote signer',
)

remote_signer_keystore.save(settings.remote_signer_config_file)

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)!',
)
4 changes: 3 additions & 1 deletion src/validators/keystores/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
from src.validators.keystores.hashi_vault import HashiVaultKeystore
from src.validators.keystores.local import LocalKeystore
from src.validators.keystores.remote import RemoteSignerKeystore
from src.validators.utils import load_deposit_data

logger = logging.getLogger(__name__)


async def load_keystore() -> BaseKeystore:
if settings.remote_signer_url:
remote_keystore = await RemoteSignerKeystore.load()
deposit_data = load_deposit_data(settings.vault, settings.deposit_data_file)
remote_keystore = RemoteSignerKeystore(deposit_data.public_keys)
logger.info(
'Using remote signer at %s for %i public keys',
settings.remote_signer_url,
Expand Down
Loading