From 4cb6c3892694ef93cb3467b7248ff8c605e2f9df Mon Sep 17 00:00:00 2001 From: aharo Date: Tue, 6 Oct 2026 19:39:56 +0000 Subject: [PATCH] Save the setup key and config files with owner-only permissions The setup script wrote the private key file and the config file with the default mode (usually 0644). Both could be read by any local user. The config file holds the API tokens, and with the "plain text" option it also holds the private key. Both files are now written with mode 0600. If the file already exists from an earlier run, its permissions are tightened too, since the mode given to os.open only applies when a file is created. --- src/bitpay/bitpay_setup.py | 13 +++----- src/bitpay/utils/secret_file.py | 24 ++++++++++++++ tests/unit/utils/__init__.py | 0 tests/unit/utils/test_secret_file.py | 48 ++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 src/bitpay/utils/secret_file.py create mode 100644 tests/unit/utils/__init__.py create mode 100644 tests/unit/utils/test_secret_file.py 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