""" Phase D6 -- OS-keychain-backed credential storage. Extends config.settings's existing 2-level precedence cascade (real env var > local config.json) with a third, LOWEST-precedence source: the OS keychain (Windows Credential Manager / macOS Keychain / Linux Secret Service), accessed via the `keyring` package. Mirrors config.settings's established "inject into os.environ" design exactly, so no other call site in the codebase (every existing os.getenv(...) call) needs to change -- this module is only ever consumed via load_credentials_into_environ(), called once from config/settings.py, after the local-config injection loop and before NEO4J_URI etc. are read as module-level constants. A closed, explicit list of 6 managed keys -- deliberately not "whatever key someone passes in" -- because the OS keychain is a shared, per-user credential store; writing arbitrary app data into it would misuse the mechanism. Every keyring call is wrapped in try/except at the point of *use* (get_password/set_password), not at import or backend-selection time, because that is empirically where a missing backend actually raises (NoKeyringError): keyring itself does not raise on `import keyring` or `keyring.get_keyring()` even with zero usable backends -- it silently falls back to a no-op "fail" backend and only errors when something actually tries to read or write through it. """ import os try: import keyring except ImportError: # pragma: no cover -- keyring is a requirements.txt # dependency now, but importing this module must never crash a # process that somehow lacks it (e.g. an old frozen build made # before keyring was added) -- degrade to "no keychain available." keyring = None _SERVICE_NAME = "BharatGraph" # Closed list -- the ONLY keys this module will ever read from or write # to the OS keychain. Anything else is a programming error, not a # missing-value case. MANAGED_KEYS = frozenset({ "ADMIN_SECRET", "NEO4J_URI", "NEO4J_USER", "NEO4J_PASSWORD", "DATAGOV_API_KEY", "OPENSANCTIONS_API_KEY", }) def _check_managed(key: str) -> None: assert key in MANAGED_KEYS, ( f"{key!r} is not one of config.credentials.MANAGED_KEYS -- the " "OS keychain is a shared, per-user store and this module " "deliberately only manages a fixed, explicit set of keys." ) def get_credential(key: str) -> str: """Read one managed credential from the OS keychain. Returns "" (never raises) if keyring isn't importable, no backend is available, or the key was never set -- callers throughout this codebase already treat an empty string as "not configured" (every os.getenv(key, "") call site), so this matches that convention instead of introducing a new one. """ _check_managed(key) if keyring is None: return "" try: value = keyring.get_password(_SERVICE_NAME, key) except Exception: # Broad on purpose: a missing backend raises NoKeyringError, but # a *working* backend can still raise its own backend-specific # error (e.g. a locked Secret Service prompt the user dismissed). # Both mean "treat as unset" here. return "" return value or "" def set_credential(key: str, value: str) -> bool: """Write one managed credential to the OS keychain. Returns True on success, False on any failure (no keyring, no backend, backend-specific error) -- never raises. Callers (today, only api/routes/admin.py's auto-generated ADMIN_SECRET) fall back to the legacy config.json mechanism when this returns False. """ _check_managed(key) if keyring is None: return False try: keyring.set_password(_SERVICE_NAME, key, value) return True except Exception: return False def load_credentials_into_environ() -> None: """Inject every managed credential into os.environ, at the lowest precedence: a value already present in os.environ -- whether from a real environment variable, .env, or config.settings's own local config.json injection, which runs before this is called -- is never overwritten. Safe to call unconditionally, including in web mode: with no OS keychain backend available, get_credential() returns "" for every key and this becomes a no-op. """ for key in MANAGED_KEYS: if os.getenv(key): continue value = get_credential(key) if value: os.environ[key] = value def _mask(value: str) -> str: """Mask a secret for display: show at most its last 4 characters.""" if len(value) <= 4: return "*" * len(value) return "*" * (len(value) - 4) + value[-4:] def _cli() -> int: """Tiny CLI for Phase D6, ahead of D5's setup-wizard UI. Usage: python -m config.credentials list python -m config.credentials get KEY [--reveal] python -m config.credentials set KEY [VALUE] python -m config.credentials delete KEY Exists so the 5 managed keys with no other write path yet (every- thing except ADMIN_SECRET, which api/routes/admin.py already writes automatically) can be tested against a REAL OS keychain -- something no amount of sandbox simulation can substitute for -- before D5's setup wizard exists to do this through an actual UI. Not a replacement for D5: set_credential()/get_credential() are the exact same functions a future UI would call, so nothing here needs to change when that UI is built. """ import argparse import getpass parser = argparse.ArgumentParser( prog="python -m config.credentials", description="Manage BharatGraph's OS-keychain-stored credentials (Phase D6).", ) sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("list", help="Show which managed keys currently have a value (masked).") p_get = sub.add_parser("get", help="Read one managed key.") p_get.add_argument("key", choices=sorted(MANAGED_KEYS)) p_get.add_argument("--reveal", action="store_true", help="Print the full value instead of masking it.") p_set = sub.add_parser("set", help="Write one managed key.") p_set.add_argument("key", choices=sorted(MANAGED_KEYS)) p_set.add_argument("value", nargs="?", help="Value to store. Omit to be prompted (hidden input, not echoed).") p_del = sub.add_parser("delete", help="Remove one managed key from the keychain.") p_del.add_argument("key", choices=sorted(MANAGED_KEYS)) args = parser.parse_args() if keyring is None: print("keyring is not installed -- nothing to do.") return 1 print(f"Using keychain backend: {keyring.get_keyring()}") if args.command == "list": for key in sorted(MANAGED_KEYS): value = get_credential(key) print(f" {key:24s} {_mask(value) if value else '(not set)'}") return 0 if args.command == "get": value = get_credential(args.key) if not value: print(f"{args.key}: (not set)") return 1 print(f"{args.key}: {value if args.reveal else _mask(value)}") return 0 if args.command == "set": value = args.value if args.value is not None else getpass.getpass(f"Value for {args.key}: ") if not value: print("Refusing to store an empty value.") return 1 if set_credential(args.key, value): print(f"{args.key}: stored in OS keychain.") return 0 print(f"{args.key}: could not store in OS keychain (no backend available?). Nothing was written.") return 1 if args.command == "delete": try: keyring.delete_password(_SERVICE_NAME, args.key) print(f"{args.key}: deleted from OS keychain.") return 0 except Exception as e: print(f"{args.key}: could not delete ({type(e).__name__}: {e}).") return 1 return 1 # pragma: no cover -- argparse's required=True makes this unreachable if __name__ == "__main__": import sys as _sys _sys.exit(_cli())