A bring-your-own-key desktop app ends up holding a lot of other people's secrets. Ours stores keys for speech recognition, translation and LLM providers in a JSON settings file under the user's profile. For the first versions they sat there in plain text. Anything that could read the user's files could read the keys: backup tools, sync clients, a careless "send me your settings file" over support chat.
On Windows the standard answer is DPAPI. CryptProtectData encrypts bytes with a key derived from the current user's logon credentials. Only the same user on the same machine can decrypt them with CryptUnprotectData. There's no key file to manage and no password prompt. This post covers how we wired it in with plain ctypes and, more importantly, how the migration and failure paths work.
The ctypes part
The Win32 signature uses a DATA_BLOB struct (a length and a pointer) for input and output:
class _DataBlob(ctypes.Structure):
_fields_ = [
("cbData", wintypes.DWORD),
("pbData", ctypes.POINTER(ctypes.c_ubyte)),
]
def _blob(data: bytes):
buffer = ctypes.create_string_buffer(data)
return _DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))), buffer
_blob returns the buffer alongside the struct on purpose. The struct only holds a raw pointer, so if the buffer is garbage-collected before the call returns, DPAPI reads freed memory. The caller keeps a reference until after the call.
The call itself sets argtypes/restype explicitly, passes CRYPTPROTECT_UI_FORBIDDEN (a background app must never pop a dialog), and frees the output with LocalFree, because DPAPI allocates it:
ok = crypt32.CryptProtectData(
ctypes.byref(input_blob), "Talkaside settings", None, None, None,
CRYPTPROTECT_UI_FORBIDDEN, ctypes.byref(output_blob),
)
if not ok:
raise SecretProtectionError(f"Windows DPAPI protection failed ({ctypes.get_last_error()})")
try:
protected = ctypes.string_at(output_blob.pbData, output_blob.cbData)
finally:
kernel32.LocalFree(ctypes.cast(output_blob.pbData, wintypes.HLOCAL))
return SECRET_PREFIX + base64.b64encode(protected).decode("ascii")
use_last_error=True on the WinDLL handles makes ctypes.get_last_error() return the real Win32 error code, not whatever a later call left behind.
A prefix on every stored secret
Encrypted values are stored as dpapi:v1:<base64>. The prefix does three jobs:
- It tells encrypted from plaintext, which is what makes migration possible (below).
-
It makes
protectidempotent. A value that already starts with the prefix is returned unchanged, so re-saving settings never double-encrypts. -
The
v1leaves room to change the scheme later (adding entropy, for example) without guessing what an old value is.
The public functions are small and refuse to be clever:
def protect_secret(value):
if not isinstance(value, str) or not value or value.startswith(SECRET_PREFIX):
return value
if sys.platform != "win32":
return value
return _windows_protect(value)
def unprotect_secret(value):
if not isinstance(value, str) or not value.startswith(SECRET_PREFIX):
return value
if sys.platform != "win32":
raise SecretProtectionError("A Windows DPAPI secret cannot be read on this OS")
return _windows_unprotect(value)
Empty values stay empty. Plain values pass through unprotect untouched. Off Windows, protecting is a no-op (useful for development) but reading a DPAPI value is an explicit error, not a silent empty string.
Migrating old settings
Existing users had plaintext keys on disk. On load, the settings module checks whether any sensitive key is a non-empty string without the prefix. If so, it marks the settings for an upgrade. Every sensitive key goes through unprotect_secret (plaintext comes back as-is), and the settings are written back. The save path runs protect_secret over the same list of keys, so the file on disk ends up encrypted after the first launch of the new version, without the user doing anything.
Saving is atomic: write to settings.json.tmp, then os.replace over the real file. A crash mid-write can't leave a half-written settings file with half the keys missing.
The failure path that matters most
DPAPI decryption can legitimately fail. The user copied their profile to a new PC, restored a backup under a different account, or Windows reset their credentials. Here's what happens then:
- The failing key is logged and set to an empty string in memory, so the app still starts and that provider simply has no key.
- A flag records that recovery failed.
- The settings are not written back during that load, even if a migration would otherwise trigger a save.
Point 3 is the one that's easy to get wrong. If the loader saved after a failed decrypt, it would replace the encrypted blob on disk with an empty string. If the failure was temporary, or the user moves the file back to the right account, the keys would already be gone. Not saving keeps the original ciphertext intact until the user deliberately enters a new key.
What DPAPI does and doesn't protect against
It protects the file at rest: copied off the machine, synced to a cloud drive, attached to a support email, or read by another Windows account. It doesn't protect against code running as the same user. Malware with the user's privileges can call CryptUnprotectData just as the app does. That's an honest limit, worth stating in the privacy notes rather than implying the keys are "unbreakable".
The other thing to remember: anything that is not a stored secret stays readable. A key typed into a field, sent to a provider and logged at debug level is a leak DPAPI can't help with. The same list of sensitive keys should drive both encryption and log redaction.
Takeaways
- On Windows, DPAPI via
ctypestakes under 140 lines with no third-party package. - Prefix and version every encrypted value. It makes protection idempotent and migration trivial.
- Migrate on load, save atomically.
- Never persist after a failed decrypt. Losing access for one launch is recoverable; overwriting the ciphertext isn't.
I'm building Talkaside, a Windows app for job interviews and calls in another language.
Drafted with AI assistance from this project's code and logs.


Top comments (0)