diff --git a/tools/shoestring/README.md b/tools/shoestring/README.md index 7d3ea3f98..b928d88df 100644 --- a/tools/shoestring/README.md +++ b/tools/shoestring/README.md @@ -96,6 +96,21 @@ import-bootstrap --config CONFIG --bootstrap BOOTSTRAP --bootstrap BOOTSTRAP path to bootstrap target directory ``` +### import-harvesters + +Imports harvesters from an existing harvesters.dat file. + + +``` +import-harvesters --config CONFIG --in-harvesters IN_HARVESTERS --in-pem IN_PEM [--out-harvesters OUT_HARVESTERS] [--out-pem OUT_PEM] + + --config CONFIG path to shoestring configuration file + --in-harvesters IN_HARVESTERS input harvesters.dat file that is encrypted with in-pem + --in-pem IN_PEM PEM file that can be used to decrypt in-harvesters + --out-harvesters OUT_HARVESTERS output harvesters.dat file that will be encrypted with out-pem + --out-pem OUT_PEM PEM file that can be used to encrypt out-harvesters +``` + ### pemtool Generates a main private key PEM file that can be used by shoestring. diff --git a/tools/shoestring/shoestring/__main__.py b/tools/shoestring/shoestring/__main__.py index 7c46ef26a..071a5e5ee 100644 --- a/tools/shoestring/shoestring/__main__.py +++ b/tools/shoestring/shoestring/__main__.py @@ -20,6 +20,7 @@ def parse_args(args): register_subcommand(subparsers, 'announce-transaction', _('main-announce-transaction-help')) register_subcommand(subparsers, 'health', _('main-health-help')) register_subcommand(subparsers, 'import-bootstrap', _('main-import-bootstrap-help')) + register_subcommand(subparsers, 'import-harvesters', _('main-import-harvesters-help')) register_subcommand(subparsers, 'init', _('main-init-help')) register_subcommand(subparsers, 'min-cosignatures-count', _('main-min-cosignatures-count-help')) register_subcommand(subparsers, 'pemtool', _('main-pemtool-help')) diff --git a/tools/shoestring/shoestring/commands/import_harvesters.py b/tools/shoestring/shoestring/commands/import_harvesters.py new file mode 100644 index 000000000..b65ee52fe --- /dev/null +++ b/tools/shoestring/shoestring/commands/import_harvesters.py @@ -0,0 +1,104 @@ +import sys +from functools import partial +from pathlib import Path + +from symbolchain.CryptoTypes import PrivateKey, PublicKey +from symbolchain.impl.CipherHelpers import decode_aes_gcm, encode_aes_gcm +from symbolchain.symbol.KeyPair import KeyPair +from symbolchain.symbol.SharedKey import SharedKey +from zenlog import log + +from shoestring.internal.PemUtils import read_private_key_from_private_key_pem_file +from shoestring.internal.ShoestringConfiguration import parse_shoestring_configuration + +HARVESTER_ENTRY_PAYLOAD_SIZE = sum([ + PublicKey.SIZE, # ephemeral public key + 16, # aes gcm tag + 12, # aes gcm initialization vector + 2 * PrivateKey.SIZE, # encrypted harvester signing private key | encrypted harvester vrf private key +]) + + +def _private_key_to_address(network, private_key): + public_key = KeyPair(private_key).public_key + address = network.public_key_to_address(public_key) + return address + + +def _visit_harvesters(harvesters_filepath, encryption_key_pair, visit): + with open(harvesters_filepath, 'rb') as infile: + for harvester_entry_payload in iter(partial(infile.read, HARVESTER_ENTRY_PAYLOAD_SIZE), b''): + ephemeral_public_key = PublicKey(harvester_entry_payload[:PublicKey.SIZE]) + encrypted_payload = harvester_entry_payload[PublicKey.SIZE:] + + decrypted_payload = decode_aes_gcm(SharedKey, encryption_key_pair, ephemeral_public_key, encrypted_payload) + + signing_private_key = PrivateKey(decrypted_payload[:PrivateKey.SIZE]) + vrf_private_key = PrivateKey(decrypted_payload[PrivateKey.SIZE:]) + + visit(signing_private_key, vrf_private_key) + + +def print_all_harvester_addresses(network, harvesters_filepath, key_pair): + class ConsolePrinter: + def __init__(self): + self.identifier = 1 + + def print(self, signing_private_key, _vrf_private_key): + log.info(_private_key_to_address(network, signing_private_key)) + + self.identifier += 1 + + log.info(_('import-harvesters-list-header').format(filepath=harvesters_filepath, public_key=key_pair.public_key)) + + printer = ConsolePrinter() + _visit_harvesters(harvesters_filepath, key_pair, printer.print) + + +class HarvesterEncrypter: + def __init__(self, encryption_public_key, outfile): + self.encryption_public_key = encryption_public_key + self.outfile = outfile + + def append(self, signing_private_key, vrf_private_key): + payload = signing_private_key.bytes + vrf_private_key.bytes + ephemeral_key_pair = KeyPair(PrivateKey.random()) + (tag, initialization_vector, encrypted_payload) = encode_aes_gcm(SharedKey, ephemeral_key_pair, self.encryption_public_key, payload) + self.outfile.write(ephemeral_key_pair.public_key.bytes) + self.outfile.write(tag) + self.outfile.write(initialization_vector) + self.outfile.write(encrypted_payload) + + +def run_main(args): + if args.in_harvesters == args.out_harvesters: + log.error(_('import-harvesters-error-in-harvesters-is-equal-to-out-harvesters')) + sys.exit(1) + + config = parse_shoestring_configuration(args.config) + + in_harvesters_key_pair = KeyPair(read_private_key_from_private_key_pem_file(args.in_pem)) + print_all_harvester_addresses(config.network, args.in_harvesters, in_harvesters_key_pair) + + if not args.out_harvesters: + return + + out_harvesters_filepath = Path(args.out_harvesters) + with open(out_harvesters_filepath, 'wb') as outfile: + out_harvesters_key_pair = KeyPair(read_private_key_from_private_key_pem_file(args.out_pem)) + encrypter = HarvesterEncrypter(out_harvesters_key_pair.public_key, outfile) + _visit_harvesters(args.in_harvesters, in_harvesters_key_pair, encrypter.append) + + out_harvesters_filepath.chmod(0o600) + print_all_harvester_addresses(config.network, out_harvesters_filepath, out_harvesters_key_pair) + + +def add_arguments(parser): + parser.add_argument('--config', help=_('argument-help-config'), required=True) + parser.add_argument('--in-harvesters', help=_('argument-help-import-harvesters-in-harvesters'), required=True) + parser.add_argument('--in-pem', help=_('argument-help-import-harvesters-in-pem'), required=True) + + parser.add_argument('--out-harvesters', help=_('argument-help-import-harvesters-out-harvesters')) + parser.add_argument('--out-pem', help=_('argument-help-import-harvesters-out-pem')) + + parser.set_defaults(func=run_main) diff --git a/tools/shoestring/shoestring/lang/en/LC_MESSAGES/messages.po b/tools/shoestring/shoestring/lang/en/LC_MESSAGES/messages.po index ea1d4c32e..bcbd357d4 100644 --- a/tools/shoestring/shoestring/lang/en/LC_MESSAGES/messages.po +++ b/tools/shoestring/shoestring/lang/en/LC_MESSAGES/messages.po @@ -26,8 +26,8 @@ msgid "argument-help-ca-key-path" msgstr "path to main private key PEM file" #: shoestring/commands/announce_transaction.py:35 -#: shoestring/commands/health.py:75 shoestring/commands/import_bootstrap.py:33 -#: shoestring/commands/init.py:29 +#: shoestring/commands/health.py:74 shoestring/commands/import_bootstrap.py:33 +#: shoestring/commands/import_harvesters.py:94 shoestring/commands/init.py:29 #: shoestring/commands/min_cosignatures_count.py:34 #: shoestring/commands/renew_certificates.py:29 #: shoestring/commands/renew_voting_keys.py:112 @@ -36,7 +36,7 @@ msgstr "path to main private key PEM file" msgid "argument-help-config" msgstr "path to shoestring configuration file" -#: shoestring/commands/health.py:76 +#: shoestring/commands/health.py:75 #: shoestring/commands/renew_certificates.py:30 #: shoestring/commands/renew_voting_keys.py:113 #: shoestring/commands/reset_data.py:88 shoestring/commands/setup.py:138 @@ -47,6 +47,22 @@ msgstr "installation directory (default: {default_path})" msgid "argument-help-import-bootstrap-bootstrap" msgstr "path to bootstrap target directory" +#: shoestring/commands/import_harvesters.py:95 +msgid "argument-help-import-harvesters-in-harvesters" +msgstr "input harvesters.dat file that is encrypted with in-pem" + +#: shoestring/commands/import_harvesters.py:96 +msgid "argument-help-import-harvesters-in-pem" +msgstr "PEM file that can be used to decrypt in-harvesters" + +#: shoestring/commands/import_harvesters.py:98 +msgid "argument-help-import-harvesters-out-harvesters" +msgstr "output harvesters.dat file that will be encrypted with out-pem" + +#: shoestring/commands/import_harvesters.py:99 +msgid "argument-help-import-harvesters-out-pem" +msgstr "PEM file that can be used to encrypt out-harvesters" + #: shoestring/commands/min_cosignatures_count.py:36 msgid "argument-help-min-cosignatures-count-update" msgstr "update the shoestring configuration file" @@ -134,11 +150,11 @@ msgstr "copying TREE {source_path} to {destination_path}" msgid "general-created-aggregate-transaction" msgstr "created aggregate transaction with hash {transaction_hash}" -#: shoestring/healthagents/peer_api.py:19 +#: shoestring/healthagents/peer_api.py:25 msgid "health-peer-api-error" msgstr "cannot access peer API at {host} on port {port}" -#: shoestring/healthagents/peer_api.py:17 +#: shoestring/healthagents/peer_api.py:23 msgid "health-peer-api-success" msgstr "peer API accessible, height = {height}" @@ -186,7 +202,7 @@ msgstr "HTTPS certificate looks invalid: {error_message}" msgid "health-rest-https-certificate-valid" msgstr "HTTPS certificate looks ok: valid from {start_date} to {end_date}" -#: shoestring/commands/health.py:70 +#: shoestring/commands/health.py:69 msgid "health-running-health-agent" msgstr "running health agent for {module_name}" @@ -244,6 +260,14 @@ msgstr "" "bootstrap directory provided ({directory}) does not look like bootstrap's" " target directory, nothing to import" +#: shoestring/commands/import_harvesters.py:74 +msgid "import-harvesters-error-in-harvesters-is-equal-to-out-harvesters" +msgstr "in-harvesters and out-harvesters must be different" + +#: shoestring/commands/import_harvesters.py:51 +msgid "import-harvesters-list-header" +msgstr "listing harvesters in {filepath} using public key {public_key}" + #: shoestring/__main__.py:20 msgid "main-announce-transaction-help" msgstr "announces a transaction to the network" @@ -257,34 +281,38 @@ msgid "main-import-bootstrap-help" msgstr "imports settings from a bootstap installation" #: shoestring/__main__.py:23 +msgid "main-import-harvesters-help" +msgstr "imports harvesters from an existing harvesters.dat file" + +#: shoestring/__main__.py:24 msgid "main-init-help" msgstr "extracts a template shoestring configuration file from a package" -#: shoestring/__main__.py:24 +#: shoestring/__main__.py:25 msgid "main-min-cosignatures-count-help" msgstr "detects minimum cosignatures required for an account" -#: shoestring/__main__.py:25 +#: shoestring/__main__.py:26 msgid "main-pemtool-help" msgstr "generates PEM files" -#: shoestring/__main__.py:26 +#: shoestring/__main__.py:27 msgid "main-renew-certificates-help" msgstr "renews certificates" -#: shoestring/__main__.py:27 +#: shoestring/__main__.py:28 msgid "main-renew-voting-keys-help" msgstr "renews voting keys" -#: shoestring/__main__.py:28 +#: shoestring/__main__.py:29 msgid "main-reset-data-help" msgstr "resets data to allow a resync from scratch" -#: shoestring/__main__.py:29 +#: shoestring/__main__.py:30 msgid "main-setup-help" msgstr "sets up a node" -#: shoestring/__main__.py:30 +#: shoestring/__main__.py:31 msgid "main-signer-help" msgstr "signs a transaction" @@ -296,7 +324,7 @@ msgstr "valid subcommands" msgid "main-title" msgstr "Shoestring Tool" -#: shoestring/__main__.py:31 +#: shoestring/__main__.py:32 msgid "main-upgrade-help" msgstr "upgrades a node to the latest client version" diff --git a/tools/shoestring/shoestring/lang/ja/LC_MESSAGES/messages.po b/tools/shoestring/shoestring/lang/ja/LC_MESSAGES/messages.po index 465b5c9b8..0f71d0eab 100644 --- a/tools/shoestring/shoestring/lang/ja/LC_MESSAGES/messages.po +++ b/tools/shoestring/shoestring/lang/ja/LC_MESSAGES/messages.po @@ -24,8 +24,8 @@ msgid "argument-help-ca-key-path" msgstr "" #: shoestring/commands/announce_transaction.py:35 -#: shoestring/commands/health.py:75 shoestring/commands/import_bootstrap.py:33 -#: shoestring/commands/init.py:29 +#: shoestring/commands/health.py:74 shoestring/commands/import_bootstrap.py:33 +#: shoestring/commands/import_harvesters.py:94 shoestring/commands/init.py:29 #: shoestring/commands/min_cosignatures_count.py:34 #: shoestring/commands/renew_certificates.py:29 #: shoestring/commands/renew_voting_keys.py:112 @@ -34,7 +34,7 @@ msgstr "" msgid "argument-help-config" msgstr "" -#: shoestring/commands/health.py:76 +#: shoestring/commands/health.py:75 #: shoestring/commands/renew_certificates.py:30 #: shoestring/commands/renew_voting_keys.py:113 #: shoestring/commands/reset_data.py:88 shoestring/commands/setup.py:138 @@ -45,6 +45,22 @@ msgstr "" msgid "argument-help-import-bootstrap-bootstrap" msgstr "" +#: shoestring/commands/import_harvesters.py:95 +msgid "argument-help-import-harvesters-in-harvesters" +msgstr "" + +#: shoestring/commands/import_harvesters.py:96 +msgid "argument-help-import-harvesters-in-pem" +msgstr "" + +#: shoestring/commands/import_harvesters.py:98 +msgid "argument-help-import-harvesters-out-harvesters" +msgstr "" + +#: shoestring/commands/import_harvesters.py:99 +msgid "argument-help-import-harvesters-out-pem" +msgstr "" + #: shoestring/commands/min_cosignatures_count.py:36 msgid "argument-help-min-cosignatures-count-update" msgstr "" @@ -130,11 +146,11 @@ msgstr "" msgid "general-created-aggregate-transaction" msgstr "" -#: shoestring/healthagents/peer_api.py:19 +#: shoestring/healthagents/peer_api.py:25 msgid "health-peer-api-error" msgstr "" -#: shoestring/healthagents/peer_api.py:17 +#: shoestring/healthagents/peer_api.py:23 msgid "health-peer-api-success" msgstr "" @@ -182,7 +198,7 @@ msgstr "" msgid "health-rest-https-certificate-valid" msgstr "" -#: shoestring/commands/health.py:70 +#: shoestring/commands/health.py:69 msgid "health-running-health-agent" msgstr "" @@ -234,6 +250,14 @@ msgstr "" msgid "import-bootstrap-invalid-directory" msgstr "" +#: shoestring/commands/import_harvesters.py:74 +msgid "import-harvesters-error-in-harvesters-is-equal-to-out-harvesters" +msgstr "" + +#: shoestring/commands/import_harvesters.py:51 +msgid "import-harvesters-list-header" +msgstr "" + #: shoestring/__main__.py:20 msgid "main-announce-transaction-help" msgstr "" @@ -247,34 +271,38 @@ msgid "main-import-bootstrap-help" msgstr "" #: shoestring/__main__.py:23 -msgid "main-init-help" +msgid "main-import-harvesters-help" msgstr "" #: shoestring/__main__.py:24 -msgid "main-min-cosignatures-count-help" +msgid "main-init-help" msgstr "" #: shoestring/__main__.py:25 -msgid "main-pemtool-help" +msgid "main-min-cosignatures-count-help" msgstr "" #: shoestring/__main__.py:26 -msgid "main-renew-certificates-help" +msgid "main-pemtool-help" msgstr "" #: shoestring/__main__.py:27 -msgid "main-renew-voting-keys-help" +msgid "main-renew-certificates-help" msgstr "" #: shoestring/__main__.py:28 -msgid "main-reset-data-help" +msgid "main-renew-voting-keys-help" msgstr "" #: shoestring/__main__.py:29 -msgid "main-setup-help" +msgid "main-reset-data-help" msgstr "" #: shoestring/__main__.py:30 +msgid "main-setup-help" +msgstr "" + +#: shoestring/__main__.py:31 msgid "main-signer-help" msgstr "" @@ -286,7 +314,7 @@ msgstr "" msgid "main-title" msgstr "" -#: shoestring/__main__.py:31 +#: shoestring/__main__.py:32 msgid "main-upgrade-help" msgstr "" diff --git a/tools/shoestring/shoestring/lang/messages.pot b/tools/shoestring/shoestring/lang/messages.pot index 80dba201f..11d48189a 100644 --- a/tools/shoestring/shoestring/lang/messages.pot +++ b/tools/shoestring/shoestring/lang/messages.pot @@ -17,8 +17,8 @@ msgid "argument-help-ca-key-path" msgstr "" #: shoestring/commands/announce_transaction.py:35 -#: shoestring/commands/health.py:75 shoestring/commands/import_bootstrap.py:33 -#: shoestring/commands/init.py:29 +#: shoestring/commands/health.py:74 shoestring/commands/import_bootstrap.py:33 +#: shoestring/commands/import_harvesters.py:94 shoestring/commands/init.py:29 #: shoestring/commands/min_cosignatures_count.py:34 #: shoestring/commands/renew_certificates.py:29 #: shoestring/commands/renew_voting_keys.py:112 @@ -27,7 +27,7 @@ msgstr "" msgid "argument-help-config" msgstr "" -#: shoestring/commands/health.py:76 +#: shoestring/commands/health.py:75 #: shoestring/commands/renew_certificates.py:30 #: shoestring/commands/renew_voting_keys.py:113 #: shoestring/commands/reset_data.py:88 shoestring/commands/setup.py:138 @@ -38,6 +38,22 @@ msgstr "" msgid "argument-help-import-bootstrap-bootstrap" msgstr "" +#: shoestring/commands/import_harvesters.py:95 +msgid "argument-help-import-harvesters-in-harvesters" +msgstr "" + +#: shoestring/commands/import_harvesters.py:96 +msgid "argument-help-import-harvesters-in-pem" +msgstr "" + +#: shoestring/commands/import_harvesters.py:98 +msgid "argument-help-import-harvesters-out-harvesters" +msgstr "" + +#: shoestring/commands/import_harvesters.py:99 +msgid "argument-help-import-harvesters-out-pem" +msgstr "" + #: shoestring/commands/min_cosignatures_count.py:36 msgid "argument-help-min-cosignatures-count-update" msgstr "" @@ -123,11 +139,11 @@ msgstr "" msgid "general-created-aggregate-transaction" msgstr "" -#: shoestring/healthagents/peer_api.py:19 +#: shoestring/healthagents/peer_api.py:25 msgid "health-peer-api-error" msgstr "" -#: shoestring/healthagents/peer_api.py:17 +#: shoestring/healthagents/peer_api.py:23 msgid "health-peer-api-success" msgstr "" @@ -175,7 +191,7 @@ msgstr "" msgid "health-rest-https-certificate-valid" msgstr "" -#: shoestring/commands/health.py:70 +#: shoestring/commands/health.py:69 msgid "health-running-health-agent" msgstr "" @@ -227,6 +243,14 @@ msgstr "" msgid "import-bootstrap-invalid-directory" msgstr "" +#: shoestring/commands/import_harvesters.py:74 +msgid "import-harvesters-error-in-harvesters-is-equal-to-out-harvesters" +msgstr "" + +#: shoestring/commands/import_harvesters.py:51 +msgid "import-harvesters-list-header" +msgstr "" + #: shoestring/__main__.py:20 msgid "main-announce-transaction-help" msgstr "" @@ -240,34 +264,38 @@ msgid "main-import-bootstrap-help" msgstr "" #: shoestring/__main__.py:23 -msgid "main-init-help" +msgid "main-import-harvesters-help" msgstr "" #: shoestring/__main__.py:24 -msgid "main-min-cosignatures-count-help" +msgid "main-init-help" msgstr "" #: shoestring/__main__.py:25 -msgid "main-pemtool-help" +msgid "main-min-cosignatures-count-help" msgstr "" #: shoestring/__main__.py:26 -msgid "main-renew-certificates-help" +msgid "main-pemtool-help" msgstr "" #: shoestring/__main__.py:27 -msgid "main-renew-voting-keys-help" +msgid "main-renew-certificates-help" msgstr "" #: shoestring/__main__.py:28 -msgid "main-reset-data-help" +msgid "main-renew-voting-keys-help" msgstr "" #: shoestring/__main__.py:29 -msgid "main-setup-help" +msgid "main-reset-data-help" msgstr "" #: shoestring/__main__.py:30 +msgid "main-setup-help" +msgstr "" + +#: shoestring/__main__.py:31 msgid "main-signer-help" msgstr "" @@ -279,7 +307,7 @@ msgstr "" msgid "main-title" msgstr "" -#: shoestring/__main__.py:31 +#: shoestring/__main__.py:32 msgid "main-upgrade-help" msgstr "" diff --git a/tools/shoestring/tests/commands/test_import_harvesters.py b/tools/shoestring/tests/commands/test_import_harvesters.py new file mode 100644 index 000000000..f94f8d26a --- /dev/null +++ b/tools/shoestring/tests/commands/test_import_harvesters.py @@ -0,0 +1,133 @@ +import tempfile +from pathlib import Path + +import pytest +from cryptography.exceptions import InvalidTag +from symbolchain.CryptoTypes import PrivateKey, PublicKey +from symbolchain.PrivateKeyStorage import PrivateKeyStorage +from symbolchain.symbol.KeyPair import KeyPair + +from shoestring.__main__ import main +from shoestring.internal.NodeFeatures import NodeFeatures + +from ..test.ConfigurationTestUtils import prepare_shoestring_configuration +from ..test.LogTestUtils import assert_all_messages_are_logged + +# key used to decrypt './tests/resources/harvesters.dat' +ORIGINAL_PUBLIC_KEY = PublicKey('148C8ADE25845040BDB95A0293EB5BD5DB483C606748470B607DCB179FECE4C4') +ORIGINAL_PRIVATE_KEY = PrivateKey('9DD63D277F1DC004FDB849CDD0262AB9FE7BFAF70AB13709DA3D5B1DB01710B2') + +# contents of './tests/resources/harvesters.dat' +HARVESTER_ENTRY_ADDRESSES = [ + 'TBDSKUDXUIOYNVQEORWRO2P4TAQMEX5Q36XZD2Q', + 'TB5Y7SWKDDQWRKIFXLLXF4KCMN7KCCZMBI32WXI', + 'TDBNBF5MXGLQIP3AFO4F2BOCMJJHWE5MOZAJOWA', + 'TDT6756NRFDIFJSNWMLOQDAGIXW5EI5AZ3BX47Y' +] + + +# pylint: disable=invalid-name + +async def test_can_decrypt_harvester_entries_with_correct_in_pem(caplog): + # Arrange: + with tempfile.TemporaryDirectory() as output_directory: + config_filepath = prepare_shoestring_configuration(output_directory, NodeFeatures.PEER) + + # - save private key + storage = PrivateKeyStorage(output_directory, None) + storage.save('original', ORIGINAL_PRIVATE_KEY) + + # Act: + await main([ + 'import-harvesters', + '--config', str(config_filepath), + '--in-harvesters', './tests/resources/harvesters.dat', + '--in-pem', str(Path(output_directory) / 'original.pem') + ]) + + # Assert: all harvester addresses are extracted + expected_log_lines = [ + f'listing harvesters in ./tests/resources/harvesters.dat using public key {ORIGINAL_PUBLIC_KEY}' + ] + HARVESTER_ENTRY_ADDRESSES + assert_all_messages_are_logged(expected_log_lines, caplog) + + +async def test_cannot_decrypt_harvester_entries_with_incorrect_in_pem(): + # Arrange: + with tempfile.TemporaryDirectory() as output_directory: + config_filepath = prepare_shoestring_configuration(output_directory, NodeFeatures.PEER) + + # - save private key + storage = PrivateKeyStorage(output_directory, None) + storage.save('original', PrivateKey.random()) + + # Act + Assert: decryption fails + with pytest.raises(InvalidTag): + await main([ + 'import-harvesters', + '--config', str(config_filepath), + '--in-harvesters', './tests/resources/harvesters.dat', + '--in-pem', str(Path(output_directory) / 'original.pem') + ]) + + +async def test_can_encrypt_harvester_entries_with_out_pem(caplog): + # Arrange: + with tempfile.TemporaryDirectory() as output_directory: + config_filepath = prepare_shoestring_configuration(output_directory, NodeFeatures.PEER) + + new_key_pair = KeyPair(PrivateKey.random()) + out_harvesters_filepath = Path(output_directory) / 'out_harvesters.dat' + + # - save private keys + storage = PrivateKeyStorage(output_directory, None) + storage.save('original', ORIGINAL_PRIVATE_KEY) + storage.save('new', new_key_pair.private_key) + + # Act: + await main([ + 'import-harvesters', + '--config', str(config_filepath), + '--in-harvesters', './tests/resources/harvesters.dat', + '--in-pem', str(Path(output_directory) / 'original.pem'), + '--out-harvesters', str(out_harvesters_filepath), + '--out-pem', str(Path(output_directory) / 'new.pem') + ]) + + # Assert: all harvester addresses are extracted and re-encrypted + expected_log_lines = [ + f'listing harvesters in ./tests/resources/harvesters.dat using public key {ORIGINAL_PUBLIC_KEY}' + ] + HARVESTER_ENTRY_ADDRESSES + [ + f'listing harvesters in {out_harvesters_filepath} using public key {new_key_pair.public_key}' + ] + HARVESTER_ENTRY_ADDRESSES + assert_all_messages_are_logged(expected_log_lines, caplog) + + # - new harvesters file exists and is read-write + assert out_harvesters_filepath.exists() + assert 0o600 == out_harvesters_filepath.stat().st_mode & 0o777 + + +async def test_cannot_encrypt_harvester_entries_with_equal_in_harvesters_and_out_harvesters(): + # Arrange: + with tempfile.TemporaryDirectory() as output_directory: + config_filepath = prepare_shoestring_configuration(output_directory, NodeFeatures.PEER) + + new_key_pair = KeyPair(PrivateKey.random()) + + # - save private keys + storage = PrivateKeyStorage(output_directory, None) + storage.save('original', ORIGINAL_PRIVATE_KEY) + storage.save('new', new_key_pair.private_key) + + # Act + Assert: + with pytest.raises(SystemExit) as ex_info: + await main([ + 'import-harvesters', + '--config', str(config_filepath), + '--in-harvesters', './tests/resources/harvesters.dat', + '--in-pem', str(Path(output_directory) / 'original.pem'), + '--out-harvesters', './tests/resources/harvesters.dat', + '--out-pem', str(Path(output_directory) / 'new.pem') + ]) + + assert 1 == ex_info.value.code diff --git a/tools/shoestring/tests/resources/harvesters.dat b/tools/shoestring/tests/resources/harvesters.dat new file mode 100644 index 000000000..58ee6165e Binary files /dev/null and b/tools/shoestring/tests/resources/harvesters.dat differ