diff --git a/src/bitpay/bitpay_setup.py b/src/bitpay/bitpay_setup.py index 2b99f09..6333152 100644 --- a/src/bitpay/bitpay_setup.py +++ b/src/bitpay/bitpay_setup.py @@ -5,6 +5,7 @@ import requests from utils.key_utils import * +from utils.secret_file import write_secret_file from exceptions.bitpay_exception import BitPayException # Will be set to Test otherwise @@ -77,8 +78,7 @@ def store_key(private_key: str) -> None: ) if input_value.lower() == "f": - with open(str(private_key_path), "wb") as f: - f.write(private_key.encode()) + write_secret_file(str(private_key_path), private_key) plain_private_key = None print("Private key saved at path:", private_key_path) select_tokens(private_key) @@ -171,12 +171,9 @@ def update_config_file() -> None: } } - with open(os.path.abspath("bitpay.config.json"), "w") as outfile: - json.dump(config, outfile, indent=2) - print( - "Generated configuration file at path: ", - os.path.abspath("bitpay.config.json"), - ) + config_path = os.path.abspath("bitpay.config.json") + write_secret_file(config_path, json.dumps(config, indent=2)) + print("Generated configuration file at path: ", config_path) print("Configuration generated successfully! \n") print( diff --git a/src/bitpay/utils/secret_file.py b/src/bitpay/utils/secret_file.py new file mode 100644 index 0000000..6c449a5 --- /dev/null +++ b/src/bitpay/utils/secret_file.py @@ -0,0 +1,24 @@ +import os + +SECRET_FILE_MODE = 0o600 + + +def write_secret_file(path: str, content: str) -> None: + """ + Writes a file that holds secrets (private key, API tokens) so only the owner + can read and write it. + + The mode given to os.open only applies when the file is created. If the file + already exists, for example from an older setup run, its permissions are + tightened before and after writing. On Windows, chmod only controls the + read-only flag, so this has no effect there. + """ + if os.path.exists(path): + os.chmod(path, SECRET_FILE_MODE) + + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0) + fd = os.open(path, flags, SECRET_FILE_MODE) + with os.fdopen(fd, "wb") as file: + file.write(content.encode("utf-8")) + + os.chmod(path, SECRET_FILE_MODE) diff --git a/tests/unit/utils/__init__.py b/tests/unit/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/utils/test_secret_file.py b/tests/unit/utils/test_secret_file.py new file mode 100644 index 0000000..8be04d2 --- /dev/null +++ b/tests/unit/utils/test_secret_file.py @@ -0,0 +1,48 @@ +import os +import sys + +import pytest + +from bitpay.utils.secret_file import write_secret_file + +# POSIX permissions do not apply on Windows. +posix_only = pytest.mark.skipif( + sys.platform == "win32", reason="POSIX permissions do not apply on Windows" +) + + +@pytest.mark.unit +@posix_only +def test_creates_file_readable_only_by_owner(tmp_path): # type: ignore + file = tmp_path / "private_key.pem" + + write_secret_file(str(file), "abc") + + assert os.stat(file).st_mode & 0o777 == 0o600 + assert file.read_text() == "abc" + + +@pytest.mark.unit +@posix_only +def test_tightens_existing_file_with_wider_permissions(tmp_path): # type: ignore + file = tmp_path / "bitpay.config.json" + file.write_text("old content that is longer") + os.chmod(file, 0o644) + + write_secret_file(str(file), "new") + + assert os.stat(file).st_mode & 0o777 == 0o600 + assert file.read_text() == "new" + + +@pytest.mark.unit +@posix_only +def test_ignores_permissive_umask(tmp_path): # type: ignore + file = tmp_path / "private_key.pem" + old_umask = os.umask(0) + try: + write_secret_file(str(file), "abc") + finally: + os.umask(old_umask) + + assert os.stat(file).st_mode & 0o777 == 0o600