diff --git a/.env.example b/.env.example index 945a683f..d28b761e 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,12 @@ CONSENSUS_ENDPOINTS=http://localhost:3500 # Internal structure of the secret must hold public validator keys in hex form without 0x as # secret keys, and signing keys in hex form without 0x prefix as secret vault. # HASHI_VAULT_KEY_PATH=path/inside/hashi/vault/k/v/engine +# A prefix in the K/V secret engine common for a group of signing keys. +# Internal structure of keys under prefix must be as follows: +# / -- {"": ""} +# "" can be any key value like 'value' or 'key', the public key will be discovered +# from the prefix anyway. +# HASHI_VAULT_KEY_PREFIX=path/inside/hashi/vault/k/v/engine # Path to the deposit_data.json file # Default is ${DATA_DIR}/${VAULT_CONTRACT_ADDRESS}/deposit_data.json diff --git a/src/commands/start.py b/src/commands/start.py index 13dfad6d..88e3f652 100644 --- a/src/commands/start.py +++ b/src/commands/start.py @@ -183,6 +183,12 @@ multiple=True, help='Key path(s) in the K/V secret engine where validator signing keys are stored.', ) +@click.option( + '--hashi-vault-key-prefix', + envvar='HASHI_VAULT_KEY_PREFIX', + multiple=True, + help='Key prefix(es) in the K/V secret engine under which validator signing keys are stored.', +) @click.option( '--hashi-vault-parallelism', envvar='HASHI_VAULT_PARALLELISM', @@ -244,6 +250,7 @@ def start( keystores_password_file: str | None, remote_signer_url: str | None, hashi_vault_key_path: list[str] | None, + hashi_vault_key_prefix: list[str] | None, hashi_vault_token: str | None, hashi_vault_url: str | None, hashi_vault_parallelism: int, @@ -278,6 +285,7 @@ def start( remote_signer_url=remote_signer_url, hashi_vault_token=hashi_vault_token, hashi_vault_key_paths=hashi_vault_key_path, + hashi_vault_key_prefixes=hashi_vault_key_prefix, hashi_vault_parallelism=hashi_vault_parallelism, hashi_vault_url=hashi_vault_url, hot_wallet_file=hot_wallet_file, diff --git a/src/commands/validators_exit.py b/src/commands/validators_exit.py index 0e5b0ff7..c6d3019c 100644 --- a/src/commands/validators_exit.py +++ b/src/commands/validators_exit.py @@ -98,6 +98,12 @@ class ValidatorExit: envvar='HASHI_VAULT_KEY_PATH', help='Key path in the K/V secret engine where validator signing keys are stored.', ) +@click.option( + '--hashi-vault-key-prefix', + envvar='HASHI_VAULT_KEY_PREFIX', + multiple=True, + help='Key prefix(es) in the K/V secret engine under which validator signing keys are stored.', +) @click.option( '--hashi-vault-parallelism', envvar='HASHI_VAULT_PARALLELISM', @@ -136,6 +142,7 @@ def validators_exit( consensus_endpoints: str, remote_signer_url: str, hashi_vault_key_path: list[str] | None, + hashi_vault_key_prefix: list[str] | None, hashi_vault_token: str | None, hashi_vault_url: str | None, hashi_vault_engine_name: str, @@ -159,6 +166,7 @@ def validators_exit( remote_signer_url=remote_signer_url, hashi_vault_token=hashi_vault_token, hashi_vault_key_paths=hashi_vault_key_path, + hashi_vault_key_prefixes=hashi_vault_key_prefix, hashi_vault_url=hashi_vault_url, hashi_vault_engine_name=hashi_vault_engine_name, hashi_vault_parallelism=hashi_vault_parallelism, diff --git a/src/config/settings.py b/src/config/settings.py index e50ebd21..ef67dabd 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -53,6 +53,7 @@ class Settings(metaclass=Singleton): remote_signer_use_deposit_data: bool dappnode: bool = False hashi_vault_key_paths: list[str] | None + hashi_vault_key_prefixes: list[str] | None hashi_vault_url: str | None hashi_vault_engine_name: str hashi_vault_token: str | None @@ -116,6 +117,7 @@ def set( remote_signer_url: str | None = None, dappnode: bool = False, hashi_vault_key_paths: list[str] | None = None, + hashi_vault_key_prefixes: list[str] | None = None, hashi_vault_url: str | None = None, hashi_vault_engine_name: str = DEFAULT_HASHI_VAULT_ENGINE_NAME, hashi_vault_token: str | None = None, @@ -183,6 +185,7 @@ def set( self.hashi_vault_url = hashi_vault_url self.hashi_vault_engine_name = hashi_vault_engine_name self.hashi_vault_key_paths = hashi_vault_key_paths + self.hashi_vault_key_prefixes = hashi_vault_key_prefixes self.hashi_vault_token = hashi_vault_token self.hashi_vault_parallelism = hashi_vault_parallelism diff --git a/src/test_fixtures/hashi_vault.py b/src/test_fixtures/hashi_vault.py index 49a289bd..ff3cef80 100644 --- a/src/test_fixtures/hashi_vault.py +++ b/src/test_fixtures/hashi_vault.py @@ -34,6 +34,26 @@ def mocked_hashi_vault( '8bc90a3110cf2b1ebaf8f5367bbfec1066797fca1f71ddbbf4f8f37ef74064404a78c31284c571656b7cb6efa29445ab': '56336628453e51cb9158da0651ea27dcb297eacdbd5cffdf0ea9d65fa154c327', } + # Generated via + # eth-staking-smith existing-mnemonic \ + # --chain holesky \ + # --num_validators 2 \ + # --mnemonic 'route flight verb churn work creek crane hole obscure young shaft area bird border refuse usage flash engage burden retreat drama bamboo profit sense' + _hashi_vault_prefixed_pk_sk_mapping1 = { + '8b09379ca969e8283a42a09285f430e8bd58c70bb33b44397ae81dac01b1403d0f631f156d211b6931a1c6284e2e469c': '5d88e114821bf871f321399d99fe58cb24d6434b416f112e8e46077e05399dc0', + '8979806d4e5d841758868b208df0dd961c12a0cf044e2de1d18e269ca0ad0308672be2f71d3d5606834764fe5b1d0bc4': '01352aec5cadb78eba6f716570d28b40f24b96c522dac535bc81375ceb54bf0b', + } + + # Generated via + # eth-staking-smith existing-mnemonic \ + # --chain holesky \ + # --num_validators 2 \ + # --mnemonic 'lion toilet tooth guess excuse wise amateur evolve moment damage curtain image zebra dress drill circle luggage seminar similar symptom happy floor govern gravity' + _hashi_vault_prefixed_pk_sk_mapping2 = { + '859f3fc64e32a1e95aadc7a7ec35207f6305951e7dafacf9252aaa9edef3d1edf74d268041cb59ca64e703ba064890be': '17dd0ad25bd239092bfa47b53c94d7eec2f3621a99ffafc28cd3c6b25a72d7f9', + 'a60dcf78a344afc297b4917f76b5b387924153182390361d5199c3455299d67fbb932b77943ffe5477150304f3cb600f': '4f768f0b9589fdff6e8371dd268d8d78b97bf968f6fc469657332cff48b1dea4', + } + def _mocked_secret_path(data, url, **kwargs) -> CallbackResult: return CallbackResult( status=200, @@ -46,13 +66,25 @@ def _mocked_secret_path(data, url, **kwargs) -> CallbackResult: ), # type: ignore ) + def _mocked_secrets_list(data, url, **kwargs) -> CallbackResult: + return CallbackResult( + status=200, + body=json.dumps( + dict( + data=dict( + keys=data, + ) + ) + ), # type: ignore + ) + def _mocked_error_path(url, **kwargs) -> CallbackResult: return CallbackResult( status=200, body=json.dumps(dict(errors=list('token not provided'))) # type: ignore ) with aioresponses() as m: - # Mocked signing keys endpoints + # Mocked bundled signing keys endpoints m.get( f'{hashi_vault_url}/v1/secret/data/ethereum/signing/keystores', callback=partial(_mocked_secret_path, _hashi_vault_pk_sk_mapping_1), @@ -68,7 +100,38 @@ def _mocked_error_path(url, **kwargs) -> CallbackResult: callback=partial(_mocked_secret_path, _hashi_vault_pk_sk_mapping_2), repeat=True, ) - # Mocked signing keys endpoints with custom engine name + # Mocked prefixed signing keys endpoints + m.add( + f'{hashi_vault_url}/v1/secret/metadata/ethereum/signing/prefixed1', + callback=partial( + _mocked_secrets_list, list(_hashi_vault_prefixed_pk_sk_mapping1.keys()) + ), + repeat=True, + method='LIST', + ) + for _pk, _sk in _hashi_vault_prefixed_pk_sk_mapping1.items(): + m.get( + f'{hashi_vault_url}/v1/secret/data/ethereum/signing/prefixed1/{_pk}', + callback=partial(_mocked_secret_path, {'value': _sk}), + repeat=True, + ) + + m.add( + f'{hashi_vault_url}/v1/secret/metadata/ethereum/signing/prefixed2', + callback=partial( + _mocked_secrets_list, list(_hashi_vault_prefixed_pk_sk_mapping2.keys()) + ), + repeat=True, + method='LIST', + ) + for _pk, _sk in _hashi_vault_prefixed_pk_sk_mapping2.items(): + m.get( + f'{hashi_vault_url}/v1/secret/data/ethereum/signing/prefixed2/{_pk}', + callback=partial(_mocked_secret_path, {'value': _sk}), + repeat=True, + ) + + # Mocked bundled signing keys endpoints with custom engine name m.get( f'{hashi_vault_url}/v1/custom/data/ethereum/signing/keystores', callback=partial(_mocked_secret_path, _hashi_vault_pk_sk_mapping_1), diff --git a/src/validators/keystores/hashi_vault.py b/src/validators/keystores/hashi_vault.py index 6bdd49a7..e65bd768 100644 --- a/src/validators/keystores/hashi_vault.py +++ b/src/validators/keystores/hashi_vault.py @@ -1,8 +1,10 @@ +import abc import asyncio import itertools import logging import urllib.parse from dataclasses import dataclass +from typing import Iterator from aiohttp import ClientSession, ClientTimeout from eth_typing import HexStr @@ -22,6 +24,7 @@ class HashiVaultConfiguration: url: str engine_name: str key_paths: list[str] + key_prefixes: list[str] parallelism: int @classmethod @@ -29,7 +32,10 @@ def from_settings(cls) -> 'HashiVaultConfiguration': if not ( settings.hashi_vault_url is not None and settings.hashi_vault_token is not None - and settings.hashi_vault_key_paths is not None + and ( + settings.hashi_vault_key_paths is not None + or settings.hashi_vault_key_prefixes is not None + ) ): raise RuntimeError( 'All three of URL, token and key path must be specified for hashi vault' @@ -38,52 +44,70 @@ def from_settings(cls) -> 'HashiVaultConfiguration': token=settings.hashi_vault_token, url=settings.hashi_vault_url, engine_name=settings.hashi_vault_engine_name, - key_paths=settings.hashi_vault_key_paths, + key_paths=settings.hashi_vault_key_paths or [], + key_prefixes=settings.hashi_vault_key_prefixes or [], parallelism=settings.hashi_vault_parallelism, ) - def secret_url(self, key_path: str) -> str: + def secret_url(self, key_path: str, location: str = 'data') -> str: return urllib.parse.urljoin( self.url, - f'/v1/{self.engine_name}/data/{key_path}', + f'/v1/{self.engine_name}/{location}/{key_path}', ) + def prefix_url(self, keys_prefix: str) -> str: + """An URL for Vault secrets engine location that holds prefixes for keys.""" + keys_prefix = keys_prefix.strip('/') + # URL is used for listing, so it lists metadata + return self.secret_url(keys_prefix, location='metadata') -class HashiVaultKeystore(LocalKeystore): - @staticmethod - async def load() -> 'HashiVaultKeystore': - """Extracts private keys from the keystores.""" - hashi_vault_config = HashiVaultConfiguration.from_settings() - parallelism = hashi_vault_config.parallelism - key_paths = hashi_vault_config.key_paths - merged_keys = Keys({}) +@dataclass +class HashiVaultKeysLoader(metaclass=abc.ABCMeta): + config: HashiVaultConfiguration + input_iter: Iterator[str] + + def session(self) -> ClientSession: + return ClientSession( + timeout=ClientTimeout(HASHI_VAULT_TIMEOUT), + headers={'X-Vault-Token': self.config.token}, + ) - key_paths_iter = iter(key_paths) - while key_chunk := list(itertools.islice(key_paths_iter, parallelism)): - async with ClientSession( - timeout=ClientTimeout(HASHI_VAULT_TIMEOUT), - headers={'X-Vault-Token': hashi_vault_config.token}, - ) as session: + @staticmethod + def merge_keys_responses(keys_responses: list[Keys], merged_keys: Keys) -> None: + """Merge keys objects, proactively searching for duplicate keys to prevent + potential slashing.""" + for keys in keys_responses: + for pk, sk in keys.items(): + if pk in merged_keys: + logger.error('Duplicate validator key %s found in hashi vault', pk) + raise RuntimeError('Found duplicate key in path') + merged_keys[pk] = sk + + @abc.abstractmethod + async def load(self, merged_keys: Keys) -> None: + """Populate merged_keys structure with validator keys from given loader.""" + raise NotImplementedError + + +class HashiVaultBundledKeysLoader(HashiVaultKeysLoader): + async def load(self, merged_keys: Keys) -> None: + """Load all the key bundles from input locations.""" + while key_chunk := list(itertools.islice(self.input_iter, self.config.parallelism)): + async with self.session() as session: keys_responses = await asyncio.gather( *[ - HashiVaultKeystore._load_hashi_vault_keys( + self._load_bundled_hashi_vault_keys( session=session, - secret_url=hashi_vault_config.secret_url(key_path), + secret_url=self.config.secret_url(key_path), ) for key_path in key_chunk ] ) - for keys in keys_responses: - for pk, sk in keys.items(): - if pk in merged_keys: - logger.error('Duplicate validator key %s found in hashi vault', pk) - raise RuntimeError('Found duplicate key in path') - merged_keys[pk] = sk - return HashiVaultKeystore(merged_keys) + self.merge_keys_responses(keys_responses, merged_keys) @staticmethod - async def _load_hashi_vault_keys(session: ClientSession, secret_url: str) -> Keys: + async def _load_bundled_hashi_vault_keys(session: ClientSession, secret_url: str) -> Keys: """ Load public and private keys from hashi vault K/V secret engine. @@ -109,3 +133,106 @@ async def _load_hashi_vault_keys(session: ClientSession, secret_url: str) -> Key keys.append((add_0x_prefix(HexStr(pk)), BLSPrivkey(sk_bytes))) validator_keys = Keys(dict(keys)) return validator_keys + + +class HashiVaultPrefixedKeysLoader(HashiVaultKeysLoader): + async def load(self, merged_keys: Keys) -> None: + """Discover all the keys under given prefix. Then, load the keys into merged structure.""" + prefix_leaf_location_tuples = [] + while prefix_chunk := list(itertools.islice(self.input_iter, self.config.parallelism)): + async with self.session() as session: + prefix_leaf_location_tuples += await asyncio.gather( + *[ + self._find_prefixed_hashi_vault_keys( + session=session, + prefix=prefix_path, + prefix_url=self.config.prefix_url(prefix_path), + ) + for prefix_path in prefix_chunk + ] + ) + + # Flattened list of prefix, pubkey tuples + keys_paired_with_prefix: list[tuple[str, str]] = sum( + prefix_leaf_location_tuples, + [], + ) + prefixed_keys_iter = iter(keys_paired_with_prefix) + while prefixed_chunk := list(itertools.islice(prefixed_keys_iter, self.config.parallelism)): + async with self.session() as session: + keys_responses = await asyncio.gather( + *[ + self._load_prefixed_hashi_vault_key( + session=session, + secret_url=self.config.secret_url(f'{key_prefix}/{key_path}'), + ) + for (key_prefix, key_path) in prefixed_chunk + ] + ) + self.merge_keys_responses(keys_responses, merged_keys) + + @staticmethod + async def _find_prefixed_hashi_vault_keys( + session: ClientSession, prefix: str, prefix_url: str + ) -> list[tuple[str, str]]: + """ + Discover public keys under prefix in hashi vault K/V secret engine + + All public keys must be a final chunk of the secret path without 0x prefix, + all secret keys are stored under these paths with arbitrary secret dictionary + key, and secret value with or without 0x prefix. + """ + logger.info('Will discover validator keys in %s', prefix_url) + response = await session.request(method='LIST', url=prefix_url) + response.raise_for_status() + key_paths = await response.json() + if 'data' not in key_paths: + logger.error('Failed to discover keys in hashi vault') + for error in key_paths.get('errors', []): + logger.error('hashi vault error: %s', error) + raise RuntimeError('Can not discover validator public keys from hashi vault') + discovered_keys = key_paths['data']['keys'] + return list(zip([prefix] * len(discovered_keys), discovered_keys)) + + @staticmethod + async def _load_prefixed_hashi_vault_key(session: ClientSession, secret_url: str) -> Keys: + logger.info('Will load keys from %s', secret_url) + response = await session.get(url=secret_url) + response.raise_for_status() + key_data = await response.json() + if 'data' not in key_data: + logger.error('Failed to retrieve keys from hashi vault') + for error in key_data.get('errors', []): + logger.error('hashi vault error: %s', error) + raise RuntimeError('Can not retrieve validator signing keys from hashi vault') + # Last chunk of URL is a public key + pk = add_0x_prefix(HexStr(secret_url.strip('/').split('/')[-1])) + if len(key_data['data']['data']) > 1: + raise RuntimeError( + f'Invalid multi-value secret at path {secret_url}, ' + 'should only contain single value', + ) + sk = list(key_data['data']['data'].values())[0] + sk_bytes = Web3.to_bytes(hexstr=sk) + return Keys({pk: BLSPrivkey(sk_bytes)}) + + +class HashiVaultKeystore(LocalKeystore): + @staticmethod + async def load() -> 'HashiVaultKeystore': + """Extracts private keys from the keystores.""" + hashi_vault_config = HashiVaultConfiguration.from_settings() + + merged_keys = Keys({}) + + for loader_class, input_iter in { + HashiVaultBundledKeysLoader: iter(hashi_vault_config.key_paths), + HashiVaultPrefixedKeysLoader: iter(hashi_vault_config.key_prefixes), + }.items(): + loader = loader_class( + config=hashi_vault_config, + input_iter=input_iter, + ) + await loader.load(merged_keys) + + return HashiVaultKeystore(merged_keys) diff --git a/src/validators/keystores/tests/test_hashi_vault.py b/src/validators/keystores/tests/test_hashi_vault.py index b4e3c049..79de23cf 100644 --- a/src/validators/keystores/tests/test_hashi_vault.py +++ b/src/validators/keystores/tests/test_hashi_vault.py @@ -3,14 +3,16 @@ from src.config.settings import settings from src.validators.keystores.hashi_vault import ( + HashiVaultBundledKeysLoader, HashiVaultConfiguration, HashiVaultKeystore, + HashiVaultPrefixedKeysLoader, ) class TestHashiVault: @pytest.mark.usefixtures('mocked_hashi_vault') - async def test_hashi_vault_keystores_loading( + async def test_hashi_vault_bundled_keystores_loading( self, hashi_vault_url: str, ): @@ -18,18 +20,66 @@ async def test_hashi_vault_keystores_loading( settings.hashi_vault_engine_name = 'secret' settings.hashi_vault_token = 'Secret' settings.hashi_vault_key_paths = [] + settings.hashi_vault_key_prefixes = [] settings.hashi_vault_parallelism = 1 config = HashiVaultConfiguration.from_settings() async with ClientSession() as session: - keystore = await HashiVaultKeystore._load_hashi_vault_keys( + keystore = await HashiVaultBundledKeysLoader._load_bundled_hashi_vault_keys( session=session, secret_url=config.secret_url('ethereum/signing/keystores'), ) assert len(keystore) == 2 + @pytest.mark.usefixtures('mocked_hashi_vault') + async def test_hashi_vault_prefixed_keystores_finding( + self, + hashi_vault_url: str, + ): + settings.hashi_vault_url = hashi_vault_url + settings.hashi_vault_engine_name = 'secret' + settings.hashi_vault_token = 'Secret' + settings.hashi_vault_key_paths = [] + settings.hashi_vault_key_prefixes = [] + settings.hashi_vault_parallelism = 1 + + config = HashiVaultConfiguration.from_settings() + + async with ClientSession() as session: + keystores_prefixes = await HashiVaultPrefixedKeysLoader._find_prefixed_hashi_vault_keys( + session=session, + prefix='ethereum/signing/prefixed1', + prefix_url=config.prefix_url('ethereum/signing/prefixed1'), + ) + assert len(keystores_prefixes) == 2 + + @pytest.mark.usefixtures('mocked_hashi_vault') + async def test_hashi_vault_prefixed_keystores_loading( + self, + hashi_vault_url: str, + ): + settings.hashi_vault_url = hashi_vault_url + settings.hashi_vault_engine_name = 'secret' + settings.hashi_vault_token = 'Secret' + settings.hashi_vault_key_paths = [] + settings.hashi_vault_key_prefixes = [] + settings.hashi_vault_parallelism = 1 + + config = HashiVaultConfiguration.from_settings() + + async with ClientSession() as session: + keystore = await HashiVaultPrefixedKeysLoader._load_prefixed_hashi_vault_key( + session=session, + secret_url=config.secret_url( + 'ethereum/signing/prefixed1/8b09379ca969e8283a42a09285f430e8bd58c70bb33b44397ae81dac01b1403d0f631f156d211b6931a1c6284e2e469c', + ), + ) + assert list(keystore.keys()) == [ + '0x8b09379ca969e8283a42a09285f430e8bd58c70bb33b44397ae81dac01b1403d0f631f156d211b6931a1c6284e2e469c' + ] + @pytest.mark.usefixtures('mocked_hashi_vault') async def test_hashi_vault_keystores_not_configured( self, @@ -45,7 +95,7 @@ async def test_hashi_vault_keystores_not_configured( await HashiVaultConfiguration.from_settings() @pytest.mark.usefixtures('mocked_hashi_vault') - async def test_hashi_vault_keystores_inaccessible( + async def test_hashi_vault_bundled_keystores_inaccessible( self, hashi_vault_url: str, ): @@ -53,6 +103,7 @@ async def test_hashi_vault_keystores_inaccessible( settings.hashi_vault_engine_name = 'secret' settings.hashi_vault_token = 'Secret' settings.hashi_vault_key_path = [] + settings.hashi_vault_key_prefixes = [] settings.hashi_vault_parallelism = 1 with pytest.raises( @@ -60,13 +111,13 @@ async def test_hashi_vault_keystores_inaccessible( ): config = HashiVaultConfiguration.from_settings() async with ClientSession() as session: - await HashiVaultKeystore._load_hashi_vault_keys( + await HashiVaultBundledKeysLoader._load_bundled_hashi_vault_keys( session=session, secret_url=config.secret_url('ethereum/inaccessible/keystores'), ) @pytest.mark.usefixtures('mocked_hashi_vault') - async def test_hashi_vault_keystores_parallel( + async def test_hashi_vault_bundled_keystores_parallel( self, hashi_vault_url: str, ): @@ -77,15 +128,21 @@ async def test_hashi_vault_keystores_parallel( 'ethereum/signing/keystores', 'ethereum/signing/other/keystores', ] + settings.hashi_vault_key_prefixes = [] settings.hashi_vault_parallelism = 2 - keystore = HashiVaultKeystore({}) - keys = await keystore.load() + config = HashiVaultConfiguration.from_settings() + loader = HashiVaultBundledKeysLoader( + config=config, + input_iter=iter(settings.hashi_vault_key_paths), + ) + keys = {} + await loader.load(keys) assert len(keys) == 4 @pytest.mark.usefixtures('mocked_hashi_vault') - async def test_hashi_vault_keystores_sequential( + async def test_hashi_vault_bundled_keystores_sequential( self, hashi_vault_url: str, ): @@ -98,8 +155,14 @@ async def test_hashi_vault_keystores_sequential( ] settings.hashi_vault_parallelism = 1 - keystore = HashiVaultKeystore({}) - keys = await keystore.load() + config = HashiVaultConfiguration.from_settings() + + loader = HashiVaultBundledKeysLoader( + config=config, + input_iter=iter(settings.hashi_vault_key_paths), + ) + keys = {} + await loader.load(keys) assert len(keys) == 4 @@ -135,9 +198,53 @@ async def test_hashi_vault_keystores_loading_custom_engine_name( config = HashiVaultConfiguration.from_settings() async with ClientSession() as session: - keystore = await HashiVaultKeystore._load_hashi_vault_keys( + keystore = await HashiVaultBundledKeysLoader._load_bundled_hashi_vault_keys( session=session, secret_url=config.secret_url('ethereum/signing/keystores'), ) assert len(keystore) == 2 + + @pytest.mark.usefixtures('mocked_hashi_vault') + async def test_hashi_vault_keystores_prefixed_loader( + self, + hashi_vault_url: str, + ): + settings.hashi_vault_url = hashi_vault_url + settings.hashi_vault_engine_name = 'secret' + settings.hashi_vault_token = 'Secret' + settings.hashi_vault_key_paths = [] + settings.hashi_vault_key_prefixes = [] + settings.hashi_vault_parallelism = 1 + + config = HashiVaultConfiguration.from_settings() + + loader = HashiVaultPrefixedKeysLoader( + config=config, input_iter=iter(['ethereum/signing/prefixed1']) + ) + keystore = {} + await loader.load(keystore) + + assert len(keystore) == 2 + + @pytest.mark.usefixtures('mocked_hashi_vault') + async def test_hashi_vault_load_bundled_and_prefixed( + self, + hashi_vault_url: str, + ): + settings.hashi_vault_url = hashi_vault_url + settings.hashi_vault_engine_name = 'secret' + settings.hashi_vault_token = 'Secret' + settings.hashi_vault_key_paths = [ + 'ethereum/signing/keystores', + 'ethereum/signing/other/keystores', + ] + settings.hashi_vault_key_prefixes = [ + 'ethereum/signing/prefixed1', + 'ethereum/signing/prefixed2', + ] + settings.hashi_vault_parallelism = 2 + + keystore = HashiVaultKeystore({}) + keys = await keystore.load() + assert len(keys) == 8