def handle_migration_completed(): """ When migration completes outside of the current process, we rely on a notification to inform the current process that it needs to reset/refresh its keyring. This allows us to stop using the legacy keyring in an already-running daemon if migration is completed using the CLI. """ KeyringWrapper.get_shared_instance().refresh_keyrings()
def remove_master_passphrase(current_passphrase: Optional[str]) -> None: """ Removes the user-provided master passphrase, and replaces it with the default master passphrase. The keyring contents will remain encrypted, but to the default passphrase. """ KeyringWrapper.get_shared_instance().remove_master_passphrase( current_passphrase)
def test_legacy_keyring_does_not_support_master_passphrase(self): """ CryptFileKeyring (legacy keyring) should not support setting a master passphrase """ # Expect: legacy keyring in use and master passphrase is not supported assert KeyringWrapper.get_shared_instance().legacy_keyring is not None assert KeyringWrapper.get_shared_instance().using_legacy_keyring( ) is True assert KeyringWrapper.get_shared_instance( ).keyring_supports_master_passphrase() is False
def test_set_master_passphrase_on_keyring(self): """ Setting a master passphrase should cache the passphrase and be usable to unlock the keyring. Using an old passphrase should not unlock the keyring. """ # When: setting the master passphrase KeyringWrapper.get_shared_instance().set_master_passphrase( DEFAULT_PASSPHRASE_IF_NO_MASTER_PASSPHRASE, "testing one two three") # Expect: the master passphrase is cached and can be validated assert KeyringWrapper.get_shared_instance( ).get_cached_master_passphrase() == ("testing one two three", True) assert KeyringWrapper.get_shared_instance().master_passphrase_is_valid( "testing one two three") is True # When: changing the master passphrase KeyringWrapper.get_shared_instance().set_master_passphrase( "testing one two three", "potato potato potato") # Expect: the new master passphrase is cached and can be validated assert KeyringWrapper.get_shared_instance( ).get_cached_master_passphrase() == ("potato potato potato", True) assert KeyringWrapper.get_shared_instance().master_passphrase_is_valid( "potato potato potato") is True # Expect: old passphrase should not validate assert KeyringWrapper.get_shared_instance().master_passphrase_is_valid( "testing one two three") is False
def set_master_passphrase(current_passphrase: Optional[str], new_passphrase: str, allow_migration: bool = True) -> None: """ Encrypts the keyring contents to new passphrase, provided that the current passphrase can decrypt the contents """ KeyringWrapper.get_shared_instance().set_master_passphrase( current_passphrase, new_passphrase, allow_migration=allow_migration)
def test_master_passphrase_is_valid(self): """ The default master passphrase should unlock the populated keyring (without any keys) """ # Expect: default master passphrase should validate assert ( KeyringWrapper.get_shared_instance().master_passphrase_is_valid( DEFAULT_PASSPHRASE_IF_NO_MASTER_PASSPHRASE) is True) # Expect: bogus passphrase should not validate assert KeyringWrapper.get_shared_instance().master_passphrase_is_valid( "foobarbaz") is False
def test_default_cached_master_passphrase(self): """ The default passphrase DEFAULT_PASSPHRASE_IF_NO_MASTER_PASSPHRASE is set """ # Expect: cached passphrase set to DEFAULT_PASSPHRASE_IF_NO_MASTER_PASSPHRASE by default assert KeyringWrapper.get_shared_instance( ).get_cached_master_passphrase() == ( DEFAULT_PASSPHRASE_IF_NO_MASTER_PASSPHRASE, False, ) assert KeyringWrapper.get_shared_instance( ).has_cached_master_passphrase() is True
def test_set_master_passphrase_with_hint(self): """ Setting a passphrase hint at the same time as setting the passphrase """ # When: setting the master passphrase with a hint KeyringWrapper.get_shared_instance().set_master_passphrase( None, "new master passphrase", passphrase_hint="some passphrase hint") # Expect: hint can be retrieved assert KeyringWrapper.get_shared_instance().get_master_passphrase_hint( ) == "some passphrase hint"
def migrate_legacy_keyring(passphrase: Optional[str] = None, cleanup_legacy_keyring: bool = False) -> None: """ Begins legacy keyring migration in a non-interactive manner """ if passphrase is not None and passphrase != "": KeyringWrapper.get_shared_instance().set_master_passphrase( current_passphrase=None, new_passphrase=passphrase, write_to_keyring=False, allow_migration=False) KeyringWrapper.get_shared_instance().migrate_legacy_keyring( cleanup_legacy_keyring=cleanup_legacy_keyring)
def test_using_file_keyring_without_legacy_keyring(self): """ In the case of a new installation (no legacy CryptFileKeyring) using a FileKeyring with some content, the legacy keyring should not be used. """ # Expect: the new keyring should have content assert KeyringWrapper.get_shared_instance().keyring.has_content( ) is True # Expect: the new keyring should be in use assert KeyringWrapper.get_shared_instance().legacy_keyring is None assert KeyringWrapper.get_shared_instance().using_legacy_keyring( ) is False assert KeyringWrapper.get_shared_instance().get_keyring( ) == KeyringWrapper.get_shared_instance().keyring
def test_using_new_file_keyring(self): """ In the case of a new installation using a new FileKeyring, the legacy keyring should not be used. """ # Expect: the new keyring should not have any content assert KeyringWrapper.get_shared_instance().keyring.has_content( ) is False # Expect: the new keyring should be in use assert KeyringWrapper.get_shared_instance().legacy_keyring is None assert KeyringWrapper.get_shared_instance().using_legacy_keyring( ) is False assert KeyringWrapper.get_shared_instance().get_keyring( ) == KeyringWrapper.get_shared_instance().keyring
def test_passphrase_hint(self): """ Setting and retrieving the passphrase hint """ # Expect: no hint set by default assert KeyringWrapper.get_shared_instance().get_master_passphrase_hint( ) is None # When: setting the passphrase hint while setting the master passphrase KeyringWrapper.get_shared_instance().set_master_passphrase( None, "passphrase", passphrase_hint="rhymes with bassphrase") # Expect: to retrieve the passphrase hint that was just set assert KeyringWrapper.get_shared_instance().get_master_passphrase_hint( ) == "rhymes with bassphrase"
def test_multiple_writers(self): num_workers = 20 keyring_path = str( KeyringWrapper.get_shared_instance().keyring.keyring_path) passphrase_list = list( map( lambda x: ("test-service", f"test-user-{x}", f"passphrase {x}", keyring_path, x, num_workers), range(num_workers), )) # Create a directory for each process to indicate readiness ready_dir: Path = Path(keyring_path).parent / "ready" mkdir(ready_dir) finished_dir: Path = Path(keyring_path).parent / "finished" mkdir(finished_dir) # When: spinning off children to each set a passphrase concurrently with Pool(processes=num_workers) as pool: res = pool.starmap_async(dummy_set_passphrase, passphrase_list) # Wait up to 30 seconds for all processes to indicate readiness assert poll_directory(ready_dir, num_workers, 30) is True log.warning(f"Test setup complete: {num_workers} workers ready") # Signal that testing should begin start_file_path: Path = ready_dir / "start" with open(start_file_path, "w") as f: f.write(f"{os.getpid()}\n") # Wait up to 30 seconds for all processes to indicate completion assert poll_directory(finished_dir, num_workers, 30) is True log.warning(f"Finished: {num_workers} workers finished") # Collect results res.get( timeout=10 ) # 10 second timeout to prevent a bad test from spoiling the fun # Expect: parent process should be able to find all passphrases that were set by the child processes for item in passphrase_list: expected_passphrase = item[2] actual_passphrase = KeyringWrapper.get_shared_instance( ).get_passphrase(service=item[0], user=item[1]) assert expected_passphrase == actual_passphrase
def test_using_file_keyring_with_legacy_keyring(self): """ In the case that an existing CryptFileKeyring (legacy) keyring exists and we're using a new FileKeyring with some keys in it, the FileKeyring's use should be used instead of the legacy keyring. """ # Expect: the new keyring should have content assert KeyringWrapper.get_shared_instance().keyring.has_content( ) is True # Expect: the new keyring should be in use assert KeyringWrapper.get_shared_instance().legacy_keyring is None assert KeyringWrapper.get_shared_instance().using_legacy_keyring( ) is False assert KeyringWrapper.get_shared_instance().get_keyring( ) == KeyringWrapper.get_shared_instance().keyring
def test_populated_file_keyring_has_master_passphrase(self): """ Populated keyring should have the default master passphrase set """ # Expect: master passphrase is set assert KeyringWrapper.get_shared_instance().has_master_passphrase( ) is True
def test_writer_lock_succeeds(self): """ If a write lock is already held, another process will be able to acquire the same lock once the lock is released by the current holder """ lock_path = FileKeyring.lockfile_path_for_file_path(KeyringWrapper.get_shared_instance().keyring.keyring_path) lock = fasteners.InterProcessReaderWriterLock(str(lock_path)) # When: a writer lock is already acquired lock.acquire_write_lock() child_proc_fn = dummy_fn_requiring_writer_lock timeout = 0.25 attempts = 4 with Pool(processes=1) as pool: # When: a child process attempts to acquire the same writer lock, failing after 1 second res = pool.starmap_async(child_writer_dispatch, [(child_proc_fn, lock_path, timeout, attempts)]) # Brief delay to allow the child to timeout once sleep(0.25) # When: the writer lock is released lock.release_write_lock() # Expect: the child to acquire the writer lock result = res.get(timeout=10) # 10 second timeout to prevent a bad test from spoiling the fun assert result[0] == "A winner is you!"
def _patch_and_create_keychain( self, *, user: str, service: str, populate: bool, setup_cryptfilekeyring: bool, existing_keyring_path: Optional[str], use_os_credential_store: bool, ): existing_keyring_dir = Path(existing_keyring_path).parent if existing_keyring_path else None temp_dir = existing_keyring_dir or tempfile.mkdtemp(prefix="test_keyring_wrapper") mock_supports_keyring_passphrase_patch = patch("chia.util.keychain.supports_keyring_passphrase") mock_supports_keyring_passphrase = mock_supports_keyring_passphrase_patch.start() # Patch supports_keyring_passphrase() to return True mock_supports_keyring_passphrase.return_value = True mock_supports_os_passphrase_storage_patch = patch("chia.util.keychain.supports_os_passphrase_storage") mock_supports_os_passphrase_storage = mock_supports_os_passphrase_storage_patch.start() # Patch supports_os_passphrase_storage() to return use_os_credential_store mock_supports_os_passphrase_storage.return_value = use_os_credential_store mock_configure_backend_patch = patch.object(KeyringWrapper, "_configure_backend") mock_configure_backend = mock_configure_backend_patch.start() setup_mock_file_keyring(mock_configure_backend, temp_dir, populate=populate) mock_configure_legacy_backend_patch: Any = None if setup_cryptfilekeyring is False: mock_configure_legacy_backend_patch = patch.object(KeyringWrapper, "_configure_legacy_backend") mock_configure_legacy_backend = mock_configure_legacy_backend_patch.start() mock_configure_legacy_backend.return_value = None mock_data_root_patch = patch.object(platform_, "data_root") mock_data_root = mock_data_root_patch.start() # Mock CryptFileKeyring's file_path indirectly by changing keyring.util.platform_.data_root # We don't want CryptFileKeyring finding the real legacy keyring mock_data_root.return_value = temp_dir if setup_cryptfilekeyring is True: crypt_file_keyring = create_empty_cryptfilekeyring() add_dummy_key_to_cryptfilekeyring(crypt_file_keyring) keychain = Keychain(user=user, service=service) keychain.keyring_wrapper = KeyringWrapper(keys_root_path=Path(temp_dir)) # Stash the temp_dir in the keychain instance keychain._temp_dir = temp_dir # type: ignore # Stash the patches in the keychain instance keychain._mock_supports_keyring_passphrase_patch = mock_supports_keyring_passphrase_patch # type: ignore keychain._mock_supports_os_passphrase_storage_patch = mock_supports_os_passphrase_storage_patch # type: ignore keychain._mock_configure_backend_patch = mock_configure_backend_patch # type: ignore keychain._mock_configure_legacy_backend_patch = mock_configure_legacy_backend_patch # type: ignore keychain._mock_data_root_patch = mock_data_root_patch # type: ignore return keychain
def get_cached_master_passphrase() -> str: """ Returns the cached master passphrase """ passphrase, _ = KeyringWrapper.get_shared_instance( ).get_cached_master_passphrase() return passphrase
def test_file_keyring_supports_master_passphrase(self): """ File keyrings should support setting a master passphrase """ # Expect: keyring supports a master passphrase assert KeyringWrapper.get_shared_instance( ).keyring_supports_master_passphrase() is True
def test_writer_lock_timeout(self): """ If a writer lock is already held, another process should not be able to acquire the same lock, failing after n attempts """ lock_path = FileKeyring.lockfile_path_for_file_path(KeyringWrapper.get_shared_instance().keyring.keyring_path) lock = fasteners.InterProcessReaderWriterLock(str(lock_path)) # When: a writer lock is already acquired lock.acquire_write_lock() child_proc_fn = dummy_fn_requiring_writer_lock timeout = 0.25 attempts = 4 with Pool(processes=1) as pool: # When: a child process attempts to acquire the same writer lock, failing after 1 second res = pool.starmap_async(child_writer_dispatch, [(child_proc_fn, lock_path, timeout, attempts)]) # Expect: the child to fail acquiring the writer lock (raises as FileKeyringLockTimeout) with pytest.raises(FileKeyringLockTimeout): # 10 second timeout to prevent a bad test from spoiling the fun (raises as TimeoutException) res.get(timeout=10) lock.release_write_lock()
def __init__(self, user: Optional[str] = None, service: Optional[str] = None): self.user = user if user is not None else default_keychain_user() self.service = service if service is not None else default_keychain_service( ) self.keyring_wrapper = KeyringWrapper.get_shared_instance()
def test_writer_lock_initially_blocked_by_readers(self): """ When a reader lock is already held, another thread/process should not be able to acquire the lock for writing until the reader releases its lock """ lock_path = FileKeyring.lockfile_path_for_file_path(KeyringWrapper.get_shared_instance().keyring.keyring_path) lock = fasteners.InterProcessReaderWriterLock(str(lock_path)) # When: a reader lock is already acquired assert lock.acquire_read_lock() is True child_proc_function = dummy_fn_requiring_writer_lock timeout = 1 attempts = 4 with Pool(processes=1) as pool: # When: a child process attempts to acquire the same lock for writing, failing after 4 seconds res = pool.starmap_async(child_writer_dispatch, [(child_proc_function, lock_path, timeout, attempts)]) # When: we verify that the writer lock is not immediately acquired with pytest.raises(TimeoutError): res.get(timeout=1) # When: the reader releases its lock lock.release_read_lock() # Expect: the child process to acquire the writer lock result = res.get(timeout=10) # 10 second timeout to prevent a bad test from spoiling the fun assert result[0] == "A winner is you!"
def test_writer_lock_released_on_abort(self): """ When a child process is holding the lock and aborts/crashes, we should be able to acquire the lock """ # Avoid running on macOS: calling abort() triggers the CrashReporter prompt, interfering with automated testing if platform == "darwin": return lock_path = FileKeyring.lockfile_path_for_file_path(KeyringWrapper.get_shared_instance().keyring.keyring_path) lock = fasteners.InterProcessReaderWriterLock(str(lock_path)) # When: a writer lock is already acquired lock.acquire_write_lock() child_proc_function = dummy_abort_fn timeout = 0.25 attempts = 4 with Pool(processes=1) as pool: # When: a child process attempts to acquire the same writer lock, failing after 1 second res = pool.starmap_async(child_writer_dispatch, [(child_proc_function, lock_path, timeout, attempts)]) # When: the writer lock is released lock.release_write_lock() # When: timing out waiting for the child process (because it aborted) with pytest.raises(TimeoutError): res.get(timeout=1) # Expect: Reacquiring the lock should succeed after the child exits, automatically releasing the lock assert lock.acquire_write_lock(timeout=(1)) is True
def test_writer_lock_reacquisition_failure(self): """ After the child process acquires the writer lock (and sleeps), the previous holder should not be able to quickly reacquire the lock """ lock_path = FileKeyring.lockfile_path_for_file_path(KeyringWrapper.get_shared_instance().keyring.keyring_path) lock = fasteners.InterProcessReaderWriterLock(str(lock_path)) # When: a writer lock is already acquired lock.acquire_write_lock() child_proc_function = dummy_sleep_fn # Sleeps for DUMMY_SLEEP_VALUE seconds timeout = 0.25 attempts = 8 with Pool(processes=1) as pool: # When: a child process attempts to acquire the same writer lock, failing after 1 second pool.starmap_async(child_writer_dispatch, [(child_proc_function, lock_path, timeout, attempts)]) # When: the writer lock is released lock.release_write_lock() # Brief delay to allow the child to acquire the lock sleep(1) # Expect: Reacquiring the lock should fail due to the child holding the lock and sleeping assert lock.acquire_write_lock(timeout=0.25) is False
def test_empty_file_keyring_doesnt_have_master_passphrase(self): """ A new/unpopulated file keyring should not have a master passphrase set """ # Expect: no master passphrase set assert KeyringWrapper.get_shared_instance().has_master_passphrase( ) is False
def dummy_set_passphrase(service, user, passphrase, keyring_path, index, num_workers): with TempKeyring(existing_keyring_path=keyring_path, delete_on_cleanup=False): if platform == "linux" or platform == "win32" or platform == "cygwin": # FileKeyring's setup_keyring_file_watcher needs to be called explicitly here, # otherwise file events won't be detected in the child process KeyringWrapper.get_shared_instance( ).keyring.setup_keyring_file_watcher() # Write out a file indicating this process is ready to begin ready_file_path: Path = Path( keyring_path).parent / "ready" / f"{index}.ready" with open(ready_file_path, "w") as f: f.write(f"{os.getpid()}\n") # Wait up to 30 seconds for all processes to indicate readiness start_file_path: Path = Path(ready_file_path.parent) / "start" remaining_attempts = 120 while remaining_attempts > 0: if start_file_path.exists(): break else: sleep(0.25) remaining_attempts -= 1 assert remaining_attempts >= 0 KeyringWrapper.get_shared_instance().set_passphrase( service=service, user=user, passphrase=passphrase) found_passphrase = KeyringWrapper.get_shared_instance().get_passphrase( service, user) if found_passphrase != passphrase: log.error( f"[pid:{os.getpid()}] error: didn't get expected passphrase: " f"get_passphrase: {found_passphrase}" # lgtm [py/clear-text-logging-sensitive-data] f", expected: {passphrase}" # lgtm [py/clear-text-logging-sensitive-data] ) # Write out a file indicating this process has completed its work finished_file_path: Path = Path( keyring_path).parent / "finished" / f"{index}.finished" with open(finished_file_path, "w") as f: f.write(f"{os.getpid()}\n") assert found_passphrase == passphrase
def test_remove_master_passphrase_from_empty_keyring(self): """ An empty keyring doesn't require a current passphrase to remove the master passphrase. Removing the master passphrase will set the default master passphrase on the keyring. """ # When: removing the master passphrase from an empty keyring, current passphrase isn't necessary KeyringWrapper.get_shared_instance().remove_master_passphrase(None) # Expect: default master passphrase is set assert KeyringWrapper.get_shared_instance( ).get_cached_master_passphrase() == ( DEFAULT_PASSPHRASE_IF_NO_MASTER_PASSPHRASE, True, ) assert ( KeyringWrapper.get_shared_instance().master_passphrase_is_valid( DEFAULT_PASSPHRASE_IF_NO_MASTER_PASSPHRASE) is True)
def test_multiple_writers(self): num_workers = 20 keyring_path = str(KeyringWrapper.get_shared_instance().keyring.keyring_path) passphrase_list = list( map(lambda x: ("test-service", f"test-user-{x}", f"passphrase {x}", keyring_path), range(num_workers)) ) # When: spinning off children to each set a passphrase concurrently with Pool(processes=num_workers) as pool: res = pool.starmap_async(dummy_set_passphrase, passphrase_list) res.get(timeout=10) # 10 second timeout to prevent a bad test from spoiling the fun # Expect: parent process should be able to find all passphrases that were set by the child processes for item in passphrase_list: expected_passphrase = item[2] actual_passphrase = KeyringWrapper.get_shared_instance().get_passphrase(service=item[0], user=item[1]) assert expected_passphrase == actual_passphrase
def test_remove_master_passphrase_from_populated_keyring(self): """ A populated keyring will require a current passphrase when removing the master passphrase. Removing the master passphrase will set the default master passphrase on the keyring. """ # When: the master passphrase is set KeyringWrapper.get_shared_instance().set_master_passphrase( DEFAULT_PASSPHRASE_IF_NO_MASTER_PASSPHRASE, "It's dangerous to go alone, take this!") # When: removing the master passphrase KeyringWrapper.get_shared_instance().remove_master_passphrase( "It's dangerous to go alone, take this!") # Expect: default master passphrase is set, old passphrase doesn't validate assert KeyringWrapper.get_shared_instance( ).get_cached_master_passphrase() == ( DEFAULT_PASSPHRASE_IF_NO_MASTER_PASSPHRASE, True, ) assert ( KeyringWrapper.get_shared_instance().master_passphrase_is_valid( DEFAULT_PASSPHRASE_IF_NO_MASTER_PASSPHRASE) is True) assert ( KeyringWrapper.get_shared_instance().master_passphrase_is_valid( "It's dangerous to go alone, take this!") is False)
def master_passphrase_is_valid(passphrase: str, force_reload: bool = False) -> bool: """ Checks whether the provided passphrase can unlock the keyring. If force_reload is true, the keyring payload will be re-read from the backing file. If false, the passphrase will be checked against the in-memory payload. """ return KeyringWrapper.get_shared_instance().master_passphrase_is_valid( passphrase, force_reload=force_reload)