Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions secure_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,32 +123,93 @@ def unwrap(self, blob: Dict[str, Any]) -> bytes:
return self._call(base64.b64decode(blob["blob"]), False)


PROBE_TIMEOUT = 3.0 # a healthy keyring answers in milliseconds
UNWRAP_TIMEOUT = 60.0 # may legitimately wait for the user to unlock it


def _with_timeout(fn: Any, seconds: float) -> Any:
"""Run `fn` in a daemon thread; raise TimeoutError if it does not finish.

A keyring backend can block forever when it needs a graphical
confirmation that nobody can give (a locked macOS Keychain on a headless
machine, no Secret Service session). The thread is abandoned: it is a
daemon, so it never keeps the program alive.
"""
import threading

box: Dict[str, Any] = {}

def run() -> None:
try:
box["value"] = fn()
except BaseException as e: # noqa: BLE001 - re-raised in the caller
box["error"] = e

t = threading.Thread(target=run, daemon=True)
t.start()
t.join(seconds)
if t.is_alive():
raise TimeoutError("keyring did not answer in time")
if "error" in box:
raise box["error"]
return box.get("value")


class KeyringProtector:
name = "keyring"
SERVICE = "NullifyPDF"
USER = "master"
_probe_result: "Dict[int, bool]" = {}

@staticmethod
def available() -> bool:
"""A real keyring backend that answers promptly.

Set NULLIFYPDF_DISABLE_KEYRING=1 to never touch the system keyring
(used by the tests, so they never reach a real Keychain).
"""
if os.environ.get("NULLIFYPDF_DISABLE_KEYRING") == "1":
return False
try:
import keyring # type: ignore[import-not-found]
from keyring.backends import fail, null # type: ignore[import-not-found]

backend = keyring.get_keyring()
return not isinstance(backend, (fail.Keyring, null.Keyring))
if isinstance(backend, (fail.Keyring, null.Keyring)):
return False
key = id(backend)
if key in KeyringProtector._probe_result:
return KeyringProtector._probe_result[key]

def probe() -> bool:
keyring.set_password("NullifyPDF-probe", "probe", "x")
ok = keyring.get_password("NullifyPDF-probe", "probe") == "x"
keyring.delete_password("NullifyPDF-probe", "probe")
return ok

try:
ok = bool(_with_timeout(probe, PROBE_TIMEOUT))
except Exception:
ok = False
KeyringProtector._probe_result[key] = ok
return ok
except Exception:
return False

def wrap(self, key: bytes) -> Dict[str, Any]:
import keyring # type: ignore[import-not-found]

keyring.set_password(self.SERVICE, self.USER, base64.b64encode(key).decode("ascii"))
secret = base64.b64encode(key).decode("ascii")
_with_timeout(
lambda: keyring.set_password(self.SERVICE, self.USER, secret), UNWRAP_TIMEOUT
)
return {"protector": self.name, "service": self.SERVICE, "user": self.USER}

def unwrap(self, blob: Dict[str, Any]) -> bytes:
import keyring # type: ignore[import-not-found]

secret = keyring.get_password(blob.get("service", self.SERVICE), blob.get("user", self.USER))
svc, usr = blob.get("service", self.SERVICE), blob.get("user", self.USER)
secret = _with_timeout(lambda: keyring.get_password(svc, usr), UNWRAP_TIMEOUT)
if not secret:
raise OSError("Segreto non presente nel portachiavi.")
return base64.b64decode(secret)
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
"""Pytest configuration shared by the test suite."""

import os
import pathlib
import sys


# Tests must never touch a real system keyring (a locked macOS Keychain on a
# headless runner blocks forever waiting for a dialog).
os.environ.setdefault("NULLIFYPDF_DISABLE_KEYRING", "1")

PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent
root_str = str(PROJECT_ROOT)
if root_str not in sys.path:
Expand Down
43 changes: 43 additions & 0 deletions tests/test_secure_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ def delete_password(self, service, username):

old = keyring.get_keyring()
keyring.set_keyring(MemoryKeyring())
monkeypatch.delenv("NULLIFYPDF_DISABLE_KEYRING", raising=False)
secure_store.KeyringProtector._probe_result.clear()
try:
monkeypatch.setattr(secure_store.DpapiProtector, "available", staticmethod(lambda: False))
key = secure_store.load_or_create_master_key(tmp_path)
Expand All @@ -198,3 +200,44 @@ def delete_password(self, service, username):
secure_store.load_or_create_master_key(tmp_path)
finally:
keyring.set_keyring(old)


def test_a_keyring_that_never_answers_is_skipped_not_waited_for(tmp_path, monkeypatch):
"""A locked Keychain waiting for a dialog must not hang the program."""
import time

keyring = pytest.importorskip("keyring")
from keyring.backend import KeyringBackend

class HangingKeyring(KeyringBackend):
priority = 1

def set_password(self, service, username, password):
time.sleep(30)

def get_password(self, service, username):
time.sleep(30)

def delete_password(self, service, username):
time.sleep(30)

old = keyring.get_keyring()
keyring.set_keyring(HangingKeyring())
monkeypatch.delenv("NULLIFYPDF_DISABLE_KEYRING", raising=False)
monkeypatch.setattr(secure_store, "PROBE_TIMEOUT", 0.3)
monkeypatch.setattr(secure_store.DpapiProtector, "available", staticmethod(lambda: False))
secure_store.KeyringProtector._probe_result.clear()
try:
t0 = time.time()
assert secure_store.KeyringProtector.available() is False
assert time.time() - t0 < 5
secure_store.load_or_create_master_key(tmp_path)
assert json.loads((tmp_path / "keystore.json").read_text())["master"]["protector"] == "keyfile"
finally:
keyring.set_keyring(old)
secure_store.KeyringProtector._probe_result.clear()


def test_keyring_can_be_disabled_by_environment(monkeypatch):
monkeypatch.setenv("NULLIFYPDF_DISABLE_KEYRING", "1")
assert secure_store.KeyringProtector.available() is False
Loading