04/22 user dashboard view, no credentials popup automatically
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
utils/config_crypto.py — Transparent DPAPI encryption for config.ini values.
|
||||
|
||||
Uses Windows Data Protection API (CryptProtectData / CryptUnprotectData)
|
||||
via pywin32. Encrypted values are stored with a "dpapi:" prefix in
|
||||
config.ini so the format is self-documenting.
|
||||
|
||||
Key properties:
|
||||
- Tied to the current Windows USER account (not just the machine).
|
||||
The encrypted blob is completely unreadable on any other machine or
|
||||
under any other Windows account.
|
||||
- No key to manage, distribute, or store — Windows manages the key
|
||||
transparently via the user's password-derived master key.
|
||||
- Graceful fallback: if pywin32 is not available (e.g. running on a
|
||||
dev Linux box), values are stored/returned as plain text with a
|
||||
warning. This keeps the dev workflow intact.
|
||||
|
||||
Usage
|
||||
-----
|
||||
from utils.config_crypto import encrypt_value, decrypt_value
|
||||
|
||||
stored = encrypt_value("my-secret") # "dpapi:AAAA..."
|
||||
secret = decrypt_value(stored) # "my-secret"
|
||||
|
||||
# For values that may already be plain text (migration):
|
||||
secret = decrypt_value("plain-text") # "plain-text" (no-op)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("config_crypto")
|
||||
|
||||
_DPAPI_PREFIX = "dpapi:"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Low-level DPAPI wrappers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _dpapi_protect(plaintext: str) -> bytes:
|
||||
"""Encrypt a string with Windows DPAPI (current-user scope)."""
|
||||
import win32crypt # noqa: PLC0415
|
||||
data = plaintext.encode("utf-8")
|
||||
encrypted = win32crypt.CryptProtectData(
|
||||
data,
|
||||
"WebChecker config", # optional descriptive label
|
||||
None, # optional entropy (None = no extra entropy)
|
||||
None, # reserved
|
||||
None, # no UI prompt
|
||||
0, # flags: 0 = user-scope (default)
|
||||
)
|
||||
return encrypted
|
||||
|
||||
|
||||
def _dpapi_unprotect(ciphertext_bytes: bytes) -> str:
|
||||
"""Decrypt bytes produced by _dpapi_protect."""
|
||||
import win32crypt # noqa: PLC0415
|
||||
_desc, plaintext_bytes = win32crypt.CryptUnprotectData(
|
||||
ciphertext_bytes,
|
||||
None, # optional entropy
|
||||
None, # reserved
|
||||
None, # no UI prompt
|
||||
0, # flags
|
||||
)
|
||||
return plaintext_bytes.decode("utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def encrypt_value(plaintext: str) -> str:
|
||||
"""
|
||||
Encrypt *plaintext* and return a "dpapi:<base64>" string suitable for
|
||||
storage in config.ini.
|
||||
|
||||
If the value is already encrypted (starts with "dpapi:"), it is returned
|
||||
unchanged. If DPAPI is unavailable, the plain value is returned with a
|
||||
warning.
|
||||
"""
|
||||
if not plaintext:
|
||||
return plaintext
|
||||
|
||||
if plaintext.startswith(_DPAPI_PREFIX):
|
||||
return plaintext # already encrypted
|
||||
|
||||
try:
|
||||
ciphertext = _dpapi_protect(plaintext)
|
||||
return _DPAPI_PREFIX + base64.b64encode(ciphertext).decode("ascii")
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"pywin32 not available — config values stored as plain text. "
|
||||
"Install pywin32 for credential protection."
|
||||
)
|
||||
return plaintext
|
||||
except Exception as exc:
|
||||
logger.error(f"DPAPI encryption failed: {exc}. Storing plain text.")
|
||||
return plaintext
|
||||
|
||||
|
||||
def decrypt_value(stored: str) -> str:
|
||||
"""
|
||||
Decrypt a value returned by encrypt_value().
|
||||
|
||||
- If *stored* starts with "dpapi:", it is decrypted and the plaintext
|
||||
is returned.
|
||||
- Otherwise the value is assumed to be plain text and returned as-is
|
||||
(handles legacy / non-Windows environments).
|
||||
|
||||
Raises RuntimeError if the DPAPI decryption fails (e.g. wrong user
|
||||
account or corrupted data).
|
||||
"""
|
||||
if not stored:
|
||||
return stored
|
||||
|
||||
if not stored.startswith(_DPAPI_PREFIX):
|
||||
return stored # plain text — pass through (legacy / no-DPAPI env)
|
||||
|
||||
b64_part = stored[len(_DPAPI_PREFIX):]
|
||||
try:
|
||||
ciphertext = base64.b64decode(b64_part)
|
||||
return _dpapi_unprotect(ciphertext)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"pywin32 not available — cannot decrypt DPAPI value. "
|
||||
"Returning raw stored value."
|
||||
)
|
||||
return stored
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to decrypt a protected config value.\n\n"
|
||||
f"This usually means the config.ini was created by a different "
|
||||
f"Windows user account or on a different machine.\n\n"
|
||||
f"Please re-enter your settings in the Settings dialog.\n\n"
|
||||
f"Technical detail: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def is_encrypted(value: str) -> bool:
|
||||
"""Return True if *value* has already been DPAPI-encrypted."""
|
||||
return value.startswith(_DPAPI_PREFIX)
|
||||
Reference in New Issue
Block a user