{"uuid": "94c4b0b8-6410-4df2-9123-cd6b0f9f8ddd", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2018-19358", "type": "seen", "source": "https://gist.github.com/YoraiLevi/c47b64c30e587cd642df4b8122964d58", "content": "# Built-In, Machine-Bound Secret Storage on Windows, Linux, and macOS \u2014 A Working Engineer's Guide\n\nYou need to keep a secret on a machine. A backup password, an API token a scheduled job uses at 3am, a key your service reads at startup. The secret has to sit on disk so the machine can use it without a human present \u2014 but it must not sit there as plaintext that anyone who copies the file can read.\n\nThe core idea every OS uses is the same: **encrypt the secret with a key that lives only on this machine**, so the encrypted blob is useless once it leaves. The differences are all in *where the root key lives* (a registry hive, a user's password hash, a TPM chip, a Secure Enclave), *who is allowed to ask for decryption*, and *what survives a restore to new hardware*. Get those three right and you understand the whole landscape.\n\n## When to use machine-bound local storage vs a networked secret manager\n\nReach for **built-in machine-bound storage** when the secret belongs to *this installation*: a daemon's credential, a backup passphrase, a token a local cron job uses. It needs no network, no extra server, and survives in the OS you already trust.\n\nReach for a **networked secret manager** (HashiCorp Vault, cloud KMS, 1Password) when the secret is shared across machines or people, needs central rotation/revocation/audit, or must survive the loss of any single machine. The defining tradeoff: machine-bound storage gives you *no portability by design* \u2014 which is exactly its security property and its operational liability. A networked manager binds to an *identity*, not a machine, so it travels \u2014 which is its convenience and its larger attack surface.\n\nA crucial, recurring caveat threaded through this whole guide: **every OS-managed store decrypts on demand for any code running as the owning principal.** The boundary these mechanisms enforce is \"a different user, or someone who stole the file\" \u2014 *not* \"malware running as you.\" Hold that thought; the [machine-binding section](#what-machine-binding-does-and-does-not-protect-against) makes it precise.\n\n---\n\n## Windows\n\nWindows has one foundational primitive \u2014 DPAPI \u2014 and everything else (Credential Manager, the PowerShell SecretStore, Chrome's cookie encryption) is built on top of it. Understand DPAPI's key hierarchy and you understand the security of every Windows tool above it.\n\n### DPAPI \u2014 `CryptProtectData` / `CryptUnprotectData`\n\n**What it is.** A two-function symmetric API: you hand `CryptProtectData` plaintext bytes and get back an opaque encrypted blob; `CryptUnprotectData` reverses it. There are no key parameters and no algorithm choices \u2014 the OS owns everything. It has shipped [since Windows 2000 and is documented down to the `CRYPTPROTECT_LOCAL_MACHINE` flag and the MAC integrity guarantee](https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata). The .NET wrapper is [`System.Security.Cryptography.ProtectedData.Protect/Unprotect` with a `DataProtectionScope` of `CurrentUser` or `LocalMachine`](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.protecteddata).\n\n**The optional-entropy parameter \u2014 the single most important hardening knob.** Both `CryptProtectData`/`CryptUnprotectData` and the .NET `Protect`/`Unprotect` take a second argument, the *optional entropy* (`pOptionalEntropy`). Every code sample in this guide passes `$null` for brevity, but that is the *weakest* configuration: with no entropy, **any other process running as the same user \u2014 under CurrentUser \u2014 or *any local process at all* under LocalMachine \u2014 can call `Unprotect` on your blob with no further secret.** Passing a per-application entropy value (a fixed, app-specific byte string, ideally combined with a stored random salt) scopes the blob so that a *different* same-user process cannot trivially decrypt it: the caller must also supply the matching entropy. This does not defeat malware that can read your binary or memory to recover the entropy, but it is the primary mechanism for application-scoping a DPAPI blob and should be used in production, especially for LocalMachine scope where the alternative is \"literally any local process can decrypt.\"\n\n**How it works \u2014 the key hierarchy, bottom up.** This is the spine of Windows secret storage:\n\n1. **SYSKEY (boot key)** \u2014 a 128-bit key assembled at boot from four registry sub-keys under `HKLM\\SYSTEM\\CurrentControlSet\\Control\\Lsa`. It is unique per installation.\n2. **The `DPAPI_SYSTEM` LSA secret** \u2014 stored encrypted under `HKLM\\SECURITY\\Policy\\Secrets\\DPAPI_SYSTEM`, decrypted by the SYSKEY at boot. It contains two values: a *machine key* (for LocalMachine scope) and a *user key* (for the service-account path). The [Synacktiv writeup on Windows secrets extraction](https://www.synacktiv.com/en/publications/windows-secrets-extraction-a-summary) documents these locations and the blob structure in detail.\n3. **Per-user master keys** \u2014 files at `%APPDATA%\\Microsoft\\Protect\\\\`. Each is derived (via PBKDF2) from the SHA1 hash of the user's password (UTF-16LE) plus the SID (CurrentUser scope), or from the `DPAPI_SYSTEM` machine key (LocalMachine scope). A `Preferred` file records the current GUID. The [insecurity.be DPAPI deep-dive](https://www.insecurity.be/blog/2020/12/24/dpapi-in-depth-with-tooling-standalone-dpapi/) confirms the master-key files use AES-256 with PBKDF2-SHA512; the [Passcape analysis of the master-key format](https://www.passcape.com/index.php?section=docsys&amp;cmd=details&amp;id=28) documents the cipher and KDF fields. The default iteration count is low \u2014 often around **8000 rounds** for the legacy user master-key path (higher for newer \"Context 3\" blobs) \u2014 which is exactly why offline cracking of a copied master key plus password is feasible on commodity hardware. The iteration count and the ~90-day rotation are *configurable defaults* (via `MasterKeyIterationCount` / password-age policy), not fixed constants.\n4. **Session key** \u2014 a per-call key derived from the master key, encrypting the actual blob. The blob header carries the master-key GUID so the OS knows which master key to load, plus an HMAC for integrity.\n\nFor LocalMachine scope you pass the `CRYPTPROTECT_LOCAL_MACHINE` flag, and then *any user on that machine* can decrypt (and, with no entropy, any local process).\n\n**Machine binding.** Software only \u2014 no hardware required. The binding is through the SYSKEY, which is machine-unique but lives in the registry. A copied blob *plus* the copied master-key file *plus* the user's password can be decrypted offline on another machine. It is effectively OS-installation-bound, not hardware-enforced.\n\n**How to use it (PowerShell, LocalMachine scope for a service):**\n\n```powershell\nAdd-Type -AssemblyName System.Security\n# Encrypt \u2014 note: pass a per-app entropy in production instead of $null (see above)\n$entropy = [Text.Encoding]::UTF8.GetBytes('com.example.mybackup/v1')\n$blob = [System.Security.Cryptography.ProtectedData]::Protect(\n  [Text.Encoding]::UTF8.GetBytes('my-secret'), $entropy,\n  [System.Security.Cryptography.DataProtectionScope]::LocalMachine)\n[IO.File]::WriteAllBytes('C:\\secret.bin', $blob)\n# Decrypt \u2014 the same entropy must be supplied\n$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(\n  [IO.File]::ReadAllBytes('C:\\secret.bin'), $entropy,\n  [System.Security.Cryptography.DataProtectionScope]::LocalMachine)\n[Text.Encoding]::UTF8.GetString($plain)\n```\n\n**DPAPI-NG / CNG DPAPI \u2014 the modern variant.** Classic DPAPI scopes a blob to a single user or to the machine. Its successor, **DPAPI-NG (CNG DPAPI)**, exposed via [`NCryptProtectSecret` / `NCryptUnprotectSecret`](https://learn.microsoft.com/en-us/windows/win32/api/ncryptprotect/nf-ncryptprotect-ncryptprotectsecret), introduces **protection descriptors** \u2014 a string grammar that scopes a blob to a specific SID, security principal, or AD group (e.g. `SID=S-1-5-...` or `LOCAL=user`). This is what \"DPAPI-NG\" actually refers to, and it is the right API when you want a blob decryptable by a *group* of principals, or by a specific account, rather than \"the current user\" or \"any local process.\" It is also the layer behind Windows Hello NGC and SID-protected stores. If you are choosing an API today and need principal- or group-scoped protection, reach for `NCryptProtectSecret` rather than bare `CryptProtectData`.\n\n**How to validate it is really encrypted.** Three checks, applicable to nearly every mechanism in this guide:\n\n1. **Inspect the header.** A DPAPI blob begins `01 00 00 00` followed by the provider GUID bytes `D0 8C 9D DF 01 15 D1 11 8C 7A 00 C0 4F C2 97 EB`. This confirms DPAPI framing (not plaintext) \u2014 though it proves framing, not ciphertext *strength*.\n2. **`strings secret.bin | grep my-secret`** returns nothing.\n3. **Copy the blob to a different Windows installation** (different SYSKEY) and call `CryptUnprotectData` \u2014 it fails with `ERROR_INVALID_DATA`, proving the machine binding.\n\n**Privilege.** CurrentUser scope: a standard user encrypts and decrypts, no elevation. LocalMachine scope: any user encrypts; *any user* decrypts. Rotating master keys is automatic; reading the `DPAPI_SYSTEM` secret requires SYSTEM.\n\n**Roaming profiles and non-persistent VDI.** Because CurrentUser master keys live in `%APPDATA%\\Microsoft\\Protect\\`, DPAPI behaves surprisingly in any deployment where the profile is not a stable local directory. On **roaming profiles, FSLogix / UPD profile containers, and non-persistent VDI**, a CurrentUser blob encrypted in one session can become undecryptable in another if the profile (and thus the master key) is not present, has been reset, or is out of sync with the password used. If you are storing secrets on roaming or non-persistent desktops, prefer **LocalMachine scope** (master key in the machine store, independent of the user profile) or an explicitly TPM-bound mechanism, and test a full logoff/login-on-another-host cycle before relying on it.\n\n**Threat model.** Protects against file theft by another OS and casual inspection of backup archives that lack the master keys. Does **not** protect against: any process running as the same user (CurrentUser) or any local user (LocalMachine) \u2014 worse with no optional entropy; admin/SYSTEM dumping LSASS or copying the SYSTEM + SECURITY hives for offline decryption (weaponized in SharpDPAPI, mimikatz, impacket); or same-user malware. The [insecurity.be analysis also notes](https://www.insecurity.be/blog/2020/12/24/dpapi-in-depth-with-tooling-standalone-dpapi/) that enabling a Windows Hello **Picture Password** causes the clear-text user password to be saved in a System DPAPI blob within the Windows Vault structure \u2014 weakening the guarantee on those machines. A **PIN** is handled differently: it uses Microsoft NGC / DPAPI-NG, where an RSA private key is DPAPI-encrypted under the PIN rather than the clear-text password being written to disk, so do not collapse the two cases.\n\n### DPAPI scope under a service account \u2014 the most common real-world failure\n\nA backup agent usually runs as a Windows **service**, often with no interactive logon. This breaks na\u00efve assumptions, and the failure modes are badly documented, so they are worth stating plainly:\n\n- **LocalSystem / NetworkService / LocalService + CurrentUser scope \u2014 *works*.** These built-in accounts use a separate master-key store at `C:\\Windows\\System32\\Microsoft\\Protect\\S-1-5-18\\User`, protected by the `DPAPI_SYSTEM` *user key*. No profile load is needed, and it is available at boot before any user logs in \u2014 confirming that [machine master keys are protected by the boot-time key hierarchy](https://tierzerosecurity.co.nz/2024/01/22/data-protection-windows-api.html).\n- **LocalSystem / NetworkService + LocalMachine scope \u2014 *often fails*** with `WindowsCryptographicException: The system cannot find the path specified`. Counterintuitively the more-privileged account fails to use the broader scope; [the documented workaround is to run the service as a named user account](https://stackoverflow.com/questions/71181396/windowscryptographicexception-the-system-cannot-find-the-path-specified-when).\n- **A named service account + CurrentUser scope, profile not loaded \u2014 fails** with \"Key not valid for use in specified state.\" [DPAPI needs the user profile loaded](https://github.com/PowerShell/Win32-OpenSSH/issues/452); set *Load User Profile = true* in the SCM, or use a virtual account (`NT SERVICE\\`) which auto-loads.\n- **Boot-time implicit override.** [If a driver or service calls `CryptProtectData` early in boot before any user is established, the function implicitly adds `CRYPTPROTECT_LOCAL_MACHINE`](https://learn.microsoft.com/en-us/previous-versions/windows/embedded/ms938309(v=msdn.10)) \u2014 which explains why some services \"just work\" without the flag.\n\n**gMSA note.** Group Managed Service Accounts rotate their password (default 30 days). Use **LocalMachine scope** under a gMSA to avoid the rotation problem entirely \u2014 this is why [SQL Server's Service Master Key uses the machine-scope DPAPI path and does not need re-backup after a gMSA password rotation](https://www.sqlservercentral.com/forums/topic/service-master-keys-with-group-managed-service-accounts). CurrentUser scope under a gMSA risks blobs becoming undecryptable if the credential-sync hook doesn't fire during rotation.\n\n### Windows Credential Manager \u2014 `CredWrite` / `CredRead`\n\n**What it is.** A structured, per-user credential store (Control Panel \u2192 Credential Manager; CLI `cmdkey.exe`). Each entry is a `CREDENTIAL` struct with a TargetName, UserName, CredentialBlob, Type, and a `Persist` field controlling scope. [The `CredWrite` API documents the struct fields and persistence values](https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credwritew); [`CredRead` documents the logon-session scope requirement](https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credreadw).\n\n**How it works.** The `CredentialBlob` is DPAPI-encrypted (CurrentUser scope) and stored under `%LOCALAPPDATA%\\Microsoft\\Credentials\\`. Metadata is readable at enumeration time; the secret is not. Use `CRED_TYPE_GENERIC` for application secrets. The generic blob is capped at `CRED_MAX_CREDENTIAL_BLOB_SIZE` (2560 bytes) \u2014 too small for large keys or certificates.\n\n**How to use it:**\n\n```\ncmdkey /generic:\"MyApp:DatabasePassword\" /user:\"myapp\" /pass:\"s3cr3t\"\ncmdkey /list\ncmdkey /delete:\"MyApp:DatabasePassword\"\n```\n\nFor programmatic access, the [AdysTech CredentialManager wrapper](https://github.com/AdysTech/CredentialManager) is a clean P/Invoke reference for `CredWrite`/`CredRead`/`CredEnumerate`/`CredDelete`.\n\n**Validate.** Inspect a file under `%LOCALAPPDATA%\\Microsoft\\Credentials\\` in a hex editor \u2014 it carries the DPAPI blob signature, not plaintext. `cmdkey /list` shows targets but never secrets.\n\n**Privilege &amp; threat model.** Standard user, no elevation; only the owning logon session can read back the secret. Inherits DPAPI's CurrentUser threat model: any same-user process can call `CredReadW` with no further auth, and admin/SYSTEM can extract everything via SharpDPAPI/mimikatz. Note a [networked logon session (e.g. SSH) cannot persist to Credential Manager](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/credstores.md). Real-world: [`aws-vault --backend=wincred`](https://dev.to/jajera/configuring-aws-vault-with-the-wincred-backend-for-secure-credential-management-on-windows-2d05) and Git Credential Manager both store tokens here.\n\n### TPM-backed CNG keys \u2014 Microsoft Platform Crypto Provider\n\n**What it is.** This is where Windows gets *hardware* binding. A CNG Key Storage Provider that generates and stores asymmetric key pairs **inside the TPM chip**. Private keys are created in the TPM, never exposed in software, and all private-key operations happen inside the chip. Marked non-exportable, a key is permanently bound to that specific TPM \u2014 it cannot be moved to another machine even by an admin. The [Key Storage and Retrieval docs cover the KSP architecture and key-file locations](https://learn.microsoft.com/en-us/windows/win32/seccng/key-storage-and-retrieval); a [DigiCert PowerShell tutorial shows the concrete workflow](https://docs.digicert.com/en/device-trust-manager/tutorials/generate-and-manage-tpm-protected-keys-in-windows-using-powershell.html).\n\n**How it works.** Open the provider (`Microsoft Platform Crypto Provider`), create a persisted key with `NCryptCreatePersistedKey` (or `CngKey.Create` from .NET), and the private material is generated in TPM hardware. The on-disk metadata blob is wrapped by the TPM's Storage Root Key (SRK) \u2014 which [never leaves the chip per the TPM spec](https://learn.microsoft.com/en-us/windows/security/hardware-security/tpm/tpm-fundamentals). Without that TPM, the file is useless.\n\n**How to use it (and seal a small secret to it):**\n\n```powershell\nGet-Tpm  # TpmPresent must be True\n\n$params = New-Object System.Security.Cryptography.CngKeyCreationParameters\n$params.Provider     = 'Microsoft Platform Crypto Provider'\n$params.ExportPolicy = [System.Security.Cryptography.CngExportPolicies]::None\n$params.KeyCreationOptions = [System.Security.Cryptography.CngKeyCreationOptions]::MachineKey\n[System.Security.Cryptography.CngKey]::Create('RSA', 'MyServiceKey', $params)\n\n$key = [System.Security.Cryptography.CngKey]::Open('MyServiceKey',\n  [System.Security.Cryptography.CngProvider]::new('Microsoft Platform Crypto Provider'))\n$rsa = [System.Security.Cryptography.RSACng]::new($key)\n$enc = $rsa.Encrypt([Text.Encoding]::UTF8.GetBytes('my-secret'),\n  [Security.Cryptography.RSAEncryptionPadding]::OaepSHA256)\n# Decrypt only works on this machine, this TPM:\n$plain = $rsa.Decrypt($enc, [Security.Cryptography.RSAEncryptionPadding]::OaepSHA256)\n```\n\n**Validate.** `$key.Provider.Provider` returns `Microsoft Platform Crypto Provider`. Attempting `Export(...Pkcs8PrivateBlob)` throws `CryptographicException` (\"Key not valid for use in specified state\"), proving non-exportability. Copy the key files to another machine and the TPM there cannot unwrap them.\n\n**Privilege &amp; threat model.** Standard user for user-scoped keys; admin/SYSTEM for machine-scoped (`MachineKey`). **Protects against** physical storage theft, software extraction at *any* privilege level including SYSTEM (the private bytes never enter host memory), and cross-machine decryption. **Does not protect against** using the key while the machine runs \u2014 any process with the right token can request a signing/decryption operation. Caveats: firmware TPMs (fTPM/PTT) are less isolated than discrete chips; TPM 2.0 anti-hammering lockout (governed by the TPM owner-auth dictionary-attack parameters \u2014 `maxTries`, `recoveryTime`, `lockoutRecovery`; Windows sets the defaults, and changing them requires TPM owner authorization rather than an ordinary admin toggle) can affect availability; PCR-sealed blobs can become inaccessible after a BIOS/OS change. **Discrete-TPM caveat \u2014 bus sniffing.** With a *discrete* TPM (a separate chip on an SPI or LPC bus), the link between CPU and TPM is physically exposed: an attacker with brief physical access can attach a **bus interposer/sniffer** and capture secrets in transit, the well-documented BitLocker TPM-sniffing attack. This materially qualifies \"protects against software extraction at any privilege level\" \u2014 it does not protect against a physical bus-interposer on a discrete TPM. Firmware TPMs (fTPM/PTT) are not exposed on an external bus this way but trade that for weaker isolation from the CPU. [Windows Hello for Business and BitLocker both root keys here](https://learn.microsoft.com/en-us/windows/security/hardware-security/tpm/how-windows-uses-the-tpm).\n\n### Credential Guard and VBS Key Guard \u2014 closing the LSASS-dump hole\n\nThe \"admin can dump LSASS\" weakness is not unfixable on modern Windows. **Credential Guard** runs an isolated LSA (`lsaiso.exe`) in a hypervisor-protected VTL1 region that even a compromised kernel or SYSTEM process cannot read; [Microsoft documents the LSA-to-`lsaiso.exe` delegation and TPM-bound VSM master key](https://learn.microsoft.com/en-us/windows/security/identity-protection/credential-guard/how-it-works), and it is [default-on for domain-joined Windows 11 22H2+ and Server 2025](https://learn.microsoft.com/en-us/windows/security/identity-protection/credential-guard/). **Important scope limit:** Credential Guard protects NTLM hashes and Kerberos TGTs \u2014 it does **not** move the `DPAPI_SYSTEM` key out of the SECURITY hive, so it does not change the DPAPI threat model for *application* secrets.\n\nFor application secrets, the relevant modern mitigation is **VBS Key Guard**: create a CNG key with [`NCRYPT_REQUIRE_VBS_FLAG`](https://learn.microsoft.com/en-us/windows/win32/api/ncrypt/nf-ncrypt-ncryptcreatepersistedkey) and the private material lives in VTL1, encrypted at rest by a TPM-bound VSM key. Microsoft's claim is direct: [keys protected this way \"cannot be dumped from process memory or exported in plain text\u2026 preventing exfiltration attacks by any admin-level attacker.\"](https://thewindowsupdate.com/2024/02/08/advancing-key-protection-in-windows-using-vbs/) In .NET this is `CngKeyCreationOptions.RequireVbs` ([value `0x20000`](https://source.dot.net/system.security.cryptography/System/Security/Cryptography/CngKeyCreationOptions.cs.html)). Use `RequireVbs`, not `PreferVbs` \u2014 the latter silently falls back to a software key if VBS is unavailable.\n\n### A note on Chrome/Edge \u2014 DPAPI is no longer the whole story\n\nIt is widely repeated that \"Chrome uses DPAPI\" for cookies. **Since Chrome 127 (July 2024) that is stale.** Chrome and Edge now use **App-Bound Encryption (ABE)**: the AES cookie key is protected by an app-bound key released by a privileged SYSTEM COM service that validates the calling process's path/identity before handing the key back. [Google's announcement frames it explicitly as defeating the same-user infostealer](https://security.googleblog.com/2024/07/improving-security-of-chrome-cookies-on.html) that plain DPAPI cannot stop, and the [`IElevator` COM interface is in the open Chromium source](https://chromium.googlesource.com/chromium/src/+/main/chrome/elevation_service/elevation_service_idl.idl). Third-party reverse engineering has since detailed the underlying mechanism \u2014 the key is wrapped by *two* sequential DPAPI calls (user then SYSTEM) inside that privileged service \u2014 per [CyberArk's C4 writeup](https://www.cyberark.com/resources/threat-research-blog/c4-bomb-blowing-up-chromes-appbound-cookie-encryption), which also [demonstrated padding-oracle and COM-hijack bypasses](https://www.cyberark.com/resources/threat-research-blog/c4-bomb-blowing-up-chromes-appbound-cookie-encryption). This matters because ABE is the clearest real example that the \"any same-user process can decrypt\" rule *can* be raised \u2014 to \"code that holds SYSTEM, sits at the Chrome path, injects into Chrome, or exploits the COM service\" \u2014 so it is meaningfully stronger than DPAPI, not impenetrable.\n\n---\n\n## Linux\n\nLinux has no single blessed primitive. The right answer depends on whether you need *hardware* binding (TPM), *persistence*, and *unattended service* access. For the stated audience \u2014 a service that reads its secret at boot \u2014 **systemd-creds is the cleanest answer**, and the Secret Service API is the desktop answer.\n\n### systemd Credentials \u2014 `systemd-creds` / `LoadCredentialEncrypted`\n\n**What it is.** A first-class systemd mechanism for encrypting small secret blobs and injecting them into a service's private, non-swappable ramfs directory (`$CREDENTIALS_DIRECTORY`) *only* at the moment the service starts. The plaintext never touches swap and never lands on disk. It ships in [systemd 250+ (December 2021)](https://lists.freedesktop.org/archives/systemd-devel/2021-December/047214.html).\n\n**How it works.** `systemd-creds encrypt` uses AES-256-GCM keyed from one or more of three sources, selected with `--with-key`: a **TPM2-sealed secret** that never leaves the chip; a **256-bit host key** at `/var/lib/systemd/credential.secret` (root-only, 0400); or **both combined** (the default when both are available). The [`systemd-creds` man page documents the key modes](https://www.freedesktop.org/software/systemd/man/latest/systemd-creds.html); the [CREDENTIALS.md design doc covers the ramfs placement and unit directives](https://github.com/systemd/systemd/blob/main/docs/CREDENTIALS.md). The credential *name* is embedded in the ciphertext, so a blob cannot be silently reused for a different service.\n\n**Machine binding.** With the default combined key, the blob requires *both* the physical TPM2 *and* the `/var` host key \u2014 neither alone suffices. `--with-key=tpm2` gives pure hardware binding. For the initrd (before `/var` is mounted) use `--with-key=auto-initrd` \u2014 **but note its fallback behavior**: `auto-initrd` uses the TPM2 key *only if a TPM2 is present*; on a TPM-less machine it falls back to a **fixed zero-length (null) key**, i.e. no confidentiality or authenticity \u2014 the blob is then decryptable anywhere (though decryption of a null-key credential is refused on a system with TPM2 + Secure Boot). If you need *guaranteed* hardware binding in the initrd, use `--with-key=tpm2` explicitly rather than `auto-initrd`, so a TPM-less box fails closed instead of silently producing an unbound blob. `--with-key=host` gives software-only binding that can migrate if you also move `credential.secret`.\n\n**How to use it:**\n\n```bash\n# Encrypt at provisioning time:\necho -n 'my-backup-password' | systemd-creds encrypt --name=backup.pass --with-key=tpm2 \\\n  - /etc/credstore.encrypted/backup.pass.cred\n\n# In the unit ([Service] section):\nLoadCredentialEncrypted=backup.pass:/etc/credstore.encrypted/backup.pass.cred\n\n# The service reads:\nExecStart=/usr/bin/mybackup --password-file=%d/backup.pass\n```\n\n**Validate.** `systemd-creds decrypt backup.pass.cred -` succeeds on the original machine and fails on any other. `strings backup.pass.cred | grep` finds nothing. The [smallstep walkthrough sealing a CA password](https://smallstep.com/blog/systemd-creds-hardware-protected-secrets/) is a complete worked example.\n\n**The PCR brittleness trap.** `systemd-creds` seals to *current* PCR values; after a kernel or firmware update those PCRs change and the blob becomes permanently undecryptable. This is a [confirmed, unresolved limitation](https://github.com/systemd/systemd/issues/38763). The update-safe fix is [**systemd-pcrlock**](https://www.freedesktop.org/software/systemd/man/latest/systemd-pcrlock.html) (systemd 255+), which stores a *predictive* policy in a TPM NV index via `PolicyAuthorizeNV` and refreshes it after each legitimate change \u2014 so OS updates no longer break sealed secrets.\n\n**The backup caveat \u2014 `credential.secret` is the whole game for host-key blobs.** `/var/lib/systemd/credential.secret` is root-only `0400`, but file permissions only protect it *on the running system*. A `host` (or `host+tpm2`) blob is keyed off that file, so **anyone who captures `credential.secret` from a filesystem backup can decrypt every host-key credential captured in that same backup, on any machine** \u2014 the pure-`tpm2` portion still requires the original chip, but the host-key component does not. This is the direct analogue of the DPAPI \"blob + master key\" caveat. Operationally: **exclude `credential.secret` from general backups, or back it up separately under stronger protection**, and never let a backup contain both the `.cred` blobs and the host key in the same restorable set. Use `--with-key=tpm2` (no host-key component) if you want the blob to be useless without the physical TPM regardless of what lands in a backup.\n\n**Privilege &amp; threat model.** Root encrypts; the service reads its own UID-restricted, namespace-isolated credential directory. **Protects against** offline disk theft, env-var leaks (credentials are files, not env), and inter-service snooping. **Does not protect against** root on the live machine (root can call `systemd-creds decrypt` or read the host key), the service process itself once it holds the plaintext, or \u2014 for TPM-sealed blobs on a machine with a *discrete* TPM \u2014 a physical SPI/LPC **bus interposer** capturing the unsealed key in transit between CPU and TPM.\n\n### Secret Service API \u2014 GNOME Keyring / KWallet\n\n**What it is.** The desktop answer: a D-Bus session-bus API (`org.freedesktop.secrets`) that any user-session app uses to store/retrieve secrets in a daemon-managed, encrypted-at-rest collection. `libsecret` is the client library; `secret-tool` is the CLI. The [freedesktop spec defines the full D-Bus interface](https://specifications.freedesktop.org/secret-service/latest-single/).\n\n**How it works.** The daemon stores collections as files under `~/.local/share/keyrings/`, encrypted with the collection master password \u2014 usually the login password, auto-unlocked via `pam_gnome_keyring.so`. GNOME uses AES-256-CBC + HMAC-SHA256 on disk. Lookup attributes are stored unencrypted for search; only the secret values are encrypted. The [libsecret C API](https://gnome.pages.gitlab.gnome.org/libsecret/) wraps the D-Bus calls.\n\n**How to use it:**\n\n```bash\nsecret-tool store --label='My DB password' service mydb username dbuser\nsecret-tool lookup service mydb username dbuser\n```\n\n**Machine binding.** **Software only** \u2014 the master password (or its derivative) is the root key. The encrypted file *can* be copied to another machine and decrypted there if the password is known. There is no hardware binding by default.\n\n**Validate.** `file ~/.local/share/keyrings/login.keyring` shows binary, and `strings \u2026 | grep` finds nothing. Copy it to another machine and `secret-tool lookup` fails without the master password.\n\n**Threat model \u2014 read the CVE.** Runs entirely in user space, no root needed. **Protects against** disk theft and cross-user reads. **Does not protect against** any same-UID process: once the keyring is unlocked at login, [*all* of the user's processes can read *all* secrets via D-Bus \u2014 CVE-2018-19358, which GNOME considers by-design](https://wiki.archlinux.org/title/GNOME/Keyring). Session lock does not re-lock the keyring. **For a headless service this path usually fails outright** \u2014 no D-Bus session, no daemon \u2014 which is precisely why systemd-creds is the service recommendation. [Chrome, Firefox, VS Code, and the git libsecret helper all use it on the desktop.](https://specifications.freedesktop.org/secret-service/latest-single/)\n\n### Linux kernel keyring \u2014 `keyctl`, trusted &amp; encrypted keys\n\n**What it is.** The kernel's own in-memory key store, reached via syscalls (`add_key`, `request_key`, `keyctl`). Key types: `user` (readable back), `logon` (write-only \u2014 unreadable even by the storing process, ideal for service creds), `trusted` (TPM2-sealed \u2014 plaintext never leaves the chip), and `encrypted` (AES-wrapped by a master key, typically a trusted key). The [`keyrings(7)` man page covers types, scopes, and the permission model](https://man7.org/linux/man-pages/man7/keyrings.7.html); the [kernel trusted/encrypted-keys doc covers the TPM2 sealing](https://www.kernel.org/doc/html/latest/security/keys/trusted-encrypted.html).\n\n**Machine binding.** `user`/`logon` keys: **none** \u2014 RAM-only, gone on reboot (the blobs must be re-injected at boot, which reintroduces the bootstrap problem). `trusted` keys: **hardware-bound** to the TPM2 SRK, optionally PCR-sealed. `encrypted` keys: as strong as their master key.\n\n**How to use a TPM2-sealed trusted key wrapping an encrypted leaf key:**\n\n```bash\n# One-time: persist a TPM primary at 0x81000001\ntpm2_createprimary --hierarchy o -G rsa2048 -c key.ctxt\ntpm2_evictcontrol -c key.ctxt 0x81000001\n\nkeyctl add trusted kmk 'new 32 keyhandle=0x81000001' @u\nkeyid=$(keyctl search @u trusted kmk)\nkeyctl pipe $keyid &gt; kmk.blob          # save sealed blob to disk\n# On next boot: keyctl add trusted kmk \"load $(cat kmk.blob) keyhandle=0x81000001\" @u\n\nkeyctl add encrypted evm 'new default trusted:kmk 32' @u\nkeyctl pipe $(keyctl search @u encrypted evm) &gt; evm.blob\n```\n\n**Validate.** `keyctl read` on a `logon` key returns `EKEYREJECTED` (intended). `keyctl print` on a trusted key returns only the hex TPM blob. Copy `kmk.blob` to another machine and the load fails \u2014 its TPM cannot unseal it. [Cloudflare documents using the keyring for SSL private-key isolation in production.](https://blog.cloudflare.com/the-linux-kernel-key-retention-service-and-why-you-should-use-it-in-your-next-application/)\n\n**Threat model.** **Protects against** swap exposure (unswappable kernel memory) and cross-process reads (UID/possessor checks); `logon` keys can't be read back even by their creator. **Does not protect against** root (root can in practice obtain any `user`-type key \u2014 assume the owning UID, or use `CAP_SYS_ADMIN` to search/override the keyring permission model \u2014 so treat `user` keys as root-readable), kernel exploits, or loss of all `user`/`logon` keys on reboot. Note: a trusted key's *unsealed* plaintext does live in kernel memory while loaded.\n\n### TPM2 direct sealing \u2014 `tpm2-tools`\n\nFor sealing a secret directly to the TPM (and optionally to PCR boot state) without the kernel keyring, [`tpm2_create` / `tpm2_unseal`](https://tpm2-tools.readthedocs.io/en/latest/man/tpm2_unseal.1/) work against `/dev/tpmrm0`:\n\n```bash\ntpm2_createprimary -C e -g sha256 -G ecc -c primary.ctx\ntpm2_pcrread -Q -o pcr.bin sha256:0,1,2,3\ntpm2_createpolicy -Q --policy-pcr -l sha256:0,1,2,3 -f pcr.bin -L pcr.policy\necho -n 'mysecret' | tpm2_create -C primary.ctx -L pcr.policy -i- -u seal.pub -r seal.priv -c seal.ctx\n# Unseal (same chip, matching PCRs only):\ntpm2_load -C primary.ctx -u seal.pub -r seal.priv -c seal.ctx\ntpm2_unseal -c seal.ctx -p pcr:sha256:0,1,2,3\n```\n\nHardware-bound to the chip; payload capped at 128 bytes (seal an AES key, not the data). Copy `seal.pub`+`seal.priv` to another machine and unseal fails; change the boot chain and a PCR-bound policy refuses to unseal. **Does not protect against** root on the running machine, who can simply call `tpm2_unseal`, nor \u2014 on a discrete TPM \u2014 a physical SPI/LPC **bus interposer** sniffing the unsealed value as it crosses the bus. The [`tpm2-software` parameter-encryption guide](https://tpm2-software.github.io/2021/02/17/Protecting-secrets-at-TPM-interface.html) covers bus-sniffing mitigation (TPM session parameter encryption), which you should enable on discrete-TPM hardware. This is what [`systemd-cryptenroll` and Clevis build on](https://github.com/tpm2-software/tpm2-tools).\n\n### The non-machine-bound contrast: `pass`, gpg-agent, Clevis/Tang\n\nFor completeness and contrast, several widely-used Linux tools are **not** machine-bound:\n\n- [**`pass`**](https://www.passwordstore.org/) stores each secret as a GPG-encrypted file. The encrypting key is a portable GPG key pair \u2014 the `.gpg` files decrypt on *any* machine with that private key. Machine-bound only if the GPG key lives on a hardware token (YubiKey/Nitrokey).\n- **gpg-agent / ssh-agent** are RAM-only passphrase caches, [holding keys in mlock'd non-swappable \"secure memory\" for a TTL](https://www.gnupg.org/documentation/manuals/gnupg26/gpg-agent.1.html). They add *no* persistent storage and no machine binding.\n- [**Clevis/Tang**](https://github.com/latchset/clevis) is pluggable automated decryption. Its `tpm2` pin is machine-bound; its `tang` pin is *network*-bound (any machine that can reach the Tang server decrypts); the `sss` threshold combines both. Excellent for unattended LUKS unlock; choose the `tpm2` or `sss` pin if machine-binding is the goal.\n\n---\n\n## macOS\n\nmacOS has the strongest hardware story (the Secure Enclave) and a genuinely hard limitation for the stated audience (daemons can't reach the good keychain). Both facts matter.\n\n### The two keychains \u2014 and why the CLI lies to you\n\nmacOS has **two** keychain implementations, and this trips up everyone:\n\n- **The file-based keychain** (`login.keychain-db`, `System.keychain`) \u2014 the *only* one the [`security` CLI fully supports](https://keith.github.io/xcode-man-pages/security.1.html). Software-encrypted, **not** hardware-bound: copy `login.keychain-db` to another Mac and it opens with the same password.\n- **The Data Protection keychain** (`~/Library/Keychains//keychain-2.db`) \u2014 the iOS-style, Secure-Enclave-backed store. Hardware-bound when you use a `ThisDeviceOnly` accessibility class. **There is no Apple CLI for it** \u2014 you must write a small Swift/ObjC binary calling `SecItemAdd` with `kSecUseDataProtectionKeychain: true`.\n\n[Apple's TN3137 is the canonical guide to which keychain an app should use](https://developer.apple.com/documentation/technotes/tn3137-on-mac-keychains), and notes the file-based keychain is \"on the road to deprecation.\" An [independent eclecticlight breakdown confirms the CLI/Data-Protection split](https://eclecticlight.co/2023/08/07/an-introduction-to-keychains-and-how-theyve-changed/).\n\n**Access groups are gated by code signing \u2014 the gotcha that bites the \"ship a small Swift binary\" path.** A keychain item belongs to an **access group**, and which access groups a process may use are determined by its **code-signing entitlements** (`keychain-access-groups`) and, by default, its **Application Identifier / Team ID**. This has a sharp practical consequence on macOS: an **unsigned or merely ad-hoc-signed CLI binary may not get a stable keychain access group** at all (its default group derives from a signing identity it lacks), so items it writes can become unreadable after a rebuild, or it can fail to share items with the rest of your app. If you follow this guide's recommendation to ship a small Swift binary for the Data Protection keychain, **sign it with a stable Team ID and the appropriate `keychain-access-groups` entitlement**, and verify the access group survives a rebuild \u2014 do not rely on ad-hoc signing for anything whose keychain items must persist.\n\n### Keychain Services \u2014 Data Protection keychain + ThisDeviceOnly\n\n**What it is.** A SQLite database where each item is encrypted with two AES-256-GCM keys: a **metadata key** (cached in the Application Processor for fast search) and a **per-row secret key** that always round-trips through the Secure Enclave. [Apple's Platform Security guide documents the two-key model and the SEP round-trip](https://support.apple.com/en-az/guide/security/secb0694df1a/web). (This dual-key AES-256-GCM design is the *Data Protection* keychain's; the legacy file-based keychain uses an older, weaker format \u2014 don't conflate them.)\n\n**How it works \u2014 and where the machine binding comes from.** The per-row secret key is wrapped by a class key the Secure Enclave derives. The `kSecAttrAccessible` attribute picks the class and decides *both* when the item unlocks *and* whether the class key is additionally wrapped with the device **hardware UID**. Any value ending `ThisDeviceOnly` activates UID-wrapping, making the blob undecryptable on any other machine. The UID is fused into the SEP's AES Engine at manufacture and [is never exposed to software, not even to Apple](https://support.apple.com/guide/security/the-secure-enclave-sec59b0b31ff/web). For a background service the right class is [`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`](https://developer.apple.com/documentation/security/ksecattraccessibleafterfirstunlockthisdeviceonly) \u2014 accessible after the first post-boot login, bound to this device.\n\n**How to use it (Swift, the machine-bound path):**\n\n```swift\nlet query: [String: Any] = [\n  kSecClass as String: kSecClassGenericPassword,\n  kSecAttrService as String: \"com.example.mybackup\",\n  kSecAttrAccount as String: \"repo-password\",\n  kSecValueData as String: passwordData,\n  kSecUseDataProtectionKeychain as String: true,\n  kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly\n]\nSecItemAdd(query as CFDictionary, nil)\n```\n\n[`SecItemAdd` encrypts the secret automatically](https://developer.apple.com/documentation/security/secitemadd(_:_:)); [`kSecAttrAccessible` controls the protection class](https://developer.apple.com/documentation/security/ksecattraccessible).\n\n**Validate.** Restore a backup of `keychain-2.db` to a different Mac (or VM) and `SecItemCopyMatching` returns `errSecItemNotFound` \u2014 the SEP's UID is absent. [Elcomsoft's forensic analysis empirically confirms ThisDeviceOnly items are useless when restored to a different device](https://blog.elcomsoft.com/2020/08/extracting-and-decrypting-ios-keychain-physical-logical-and-cloud-options-explored/).\n\n**Threat model.** The owning process (same access group \u2014 and recall that group is code-signing-gated, above); no root needed. **Protects against** disk theft and backup-restore to another device. **Does not protect against** same-user malware in the same access group (it can call `SecItemCopyMatching` with no extra auth unless you add a `SecAccessControl`), or a live attacker already running as you.\n\n**A note on the legacy file-based keychain's ACL prompts.** If you instead use the legacy file-based / login keychain (e.g. via the `security` CLI), each item carries a per-item **ACL** that prompts the user (\"Always Allow\" vs \"Allow Once\") keyed on the **code-signing identity of the requesting app**. The practical consequence: **re-signing or rebuilding the requesting binary invalidates the grant, so the user is prompted again** \u2014 the classic \"keychain keeps asking me for permission\" issue. The Data Protection keychain uses `SecAccessControl` rather than these legacy ACLs; if you are seeing repeated prompts from a CLI tool you rebuilt, this signing-identity-keyed ACL is why.\n\n### Secure Enclave keys \u2014 `kSecAttrTokenIDSecureEnclave`\n\n**What it is.** The strongest macOS option: a 256-bit EC (P-256) private key **generated inside the SEP and resident there**. The private scalar never leaves the chip \u2014 ever. The SEP performs signing/ECDH on your behalf and returns only results. [Only 256-bit EC keys are supported, and they must be generated in the SEP, never imported](https://developer.apple.com/documentation/security/ksecattrtokenidsecureenclave).\n\n**How to use it (envelope pattern \u2014 the practical answer for storing a secret):** generate an SE key, store its opaque handle on disk, encrypt your real secret to the SE public key with ECIES, and decrypt via the SEP at runtime. This is exactly what [GoDaddy's `sshenc` does \u2014 opaque `.handle` files that \"contain no secret material\" and \"won't work on another machine because the actual key is bound to this device's Secure Enclave\"](https://github.com/godaddy/sshenc), and what [`age-plugin-se` does for file encryption](https://github.com/remko/age-plugin-se).\n\n```swift\nlet access = SecAccessControlCreateWithFlags(\n  kCFAllocatorDefault,\n  kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,\n  [.privateKeyUsage], nil)!   // no biometry flag \u2192 usable unattended\nlet attributes: [String: Any] = [\n  kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,\n  kSecAttrKeySizeInBits as String: 256,\n  kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,\n  kSecPrivateKeyAttrs as String: [\n    kSecAttrIsPermanent as String: true,\n    kSecAttrApplicationTag as String: \"com.example.mykey\".data(using: .utf8)!,\n    kSecAttrAccessControl as String: access ]\n]\nlet privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, nil)!\n```\n\n**Validate.** `SecKeyCopyExternalRepresentation` on the private key *must* fail \u2014 confirming it cannot be exported. On a second Mac, `SecItemCopyMatching` for the same tag returns nothing.\n\n**Threat model.** **Protects against** disk theft (no key material on disk at all), software extraction even with root, and any cross-machine move. [Secretive, the SSH-agent built on this, documents the consequence plainly: SE keys \"cannot be backed up, and you will not be able to transfer them to a new machine.\"](https://github.com/maxgoedjen/secretive) **Does not protect against** a same-access-group process requesting a signature (it gets results, not the key), or physical SEP attacks. Add [`SecAccessControlCreateWithFlags` with `.biometryCurrentSet`](https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/privatekeyusage) to require Touch ID per use \u2014 but that makes it unusable from an unattended daemon.\n\n### The macOS daemon problem \u2014 stated plainly\n\nHere is the collision the target audience hits. **A true LaunchDaemon (running before any user logs in) cannot use the Data Protection keychain at all** \u2014 [TN3137 states it is \"only available for use by processes running in a user context\u2026 not by those run by launchd as daemons.\"](https://developer.apple.com/documentation/technotes/tn3137-on-mac-keychains) The `security` CLI only touches the file-based keychain, which is not machine-bound. So on macOS there is **no built-in mechanism that is simultaneously machine-bound, scriptable, and usable by a pre-login daemon.** The three realistic resolutions:\n\n- **System.keychain** (`/Library/Keychains/System.keychain`) \u2014 daemon-accessible and scriptable via `sudo security add-generic-password -k /Library/Keychains/System.keychain \u2026`, but **not machine-bound** (the file is copyable). The [canonical answer for pre-login daemon secrets](https://stackoverflow.com/questions/1490501/secure-password-storage-for-a-launchd-daemon), at the cost of hardware binding.\n- **Secure Enclave envelope key** (above) \u2014 machine-bound and usable without a session once the handle is on disk, but requires a small custom binary (which you must code-sign with a stable Team ID, per the access-group note above); the correct choice if you can ship one and the Mac has a T2/Apple-silicon SEP.\n- **LaunchAgent bridge** \u2014 store in the Data Protection keychain, run a LaunchAgent in the user session to fetch it, and hand it to the daemon over XPC. Full SE-backed binding, but the secret is only available *after a user has logged in since boot* \u2014 fine for a daily backup on a Mac that auto-logs-in, not for a true headless pre-login service.\n\n### FileVault \u2014 the root of all macOS machine-binding\n\nEverything above sits on FileVault's foundation. [FileVault uses AES-XTS with a Volume Encryption Key wrapped by a Key Encryption Key derived from the user password **and the hardware UID**](https://support.apple.com/guide/security/volume-encryption-with-filevault-sec4c6dc1b6e/web). The AES Engine sits on the DMA path between NAND and memory, so encryption is transparent and **the AES Engine never exposes the unwrapped volume/file keys to software (including the OS running on the Application Processor)** \u2014 the UID-derived keys stay inside the AES Engine. The consequence Apple states directly: remove the internal SSD and connect it to another Mac and the data is unreadable, because that Mac's UID differs. Enable with `fdesetup enable`; check with `fdesetup status`. External storage does **not** get this \u2014 no AES Engine on that path.\n\n---\n\n## Cross-platform abstraction layers \u2014 what apps actually ship\n\nAlmost no application calls DPAPI or `SecItemAdd` directly. They use a thin cross-platform layer that dispatches to the native store. **The stack is layered, not alternative** \u2014 understanding the bottom layer is understanding the security of everything above it:\n\n- **Electron `safeStorage`** wraps Chromium's **OSCrypt**, which stores one AES master key per app in the OS keystore (Keychain on macOS, DPAPI on Windows, Secret Service on Linux) and AES-encrypts strings with it. The [API documents `encryptString`/`decryptString` and the `getSelectedStorageBackend()` check](https://www.electronjs.org/docs/latest/api/safe-storage). **Critical Linux caveat:** with no keyring daemon, OSCrypt falls back to `basic_text` \u2014 a hardcoded password, i.e. *no real encryption*. Always check the backend is not `basic_text`.\n- **VS Code `SecretStorage`** wraps Electron `safeStorage`, storing ciphertext in a SQLite DB; [secrets are \"encrypted, not synced across machines.\"](https://code.visualstudio.com/api/extension-capabilities/common-capabilities)\n- **Python `keyring`** dispatches to macOS Keychain / Windows Credential Locker / Linux Secret Service. [It adds no encryption of its own and warns that same-executable access needs no OS prompt](https://pypi.org/project/keyring/) \u2014 and on headless Linux it can silently fall back to an alt backend, so check `keyring.get_keyring()`.\n- **Go libraries** [`zalando/go-keyring`](https://github.com/zalando/go-keyring) (three functions, cgo-free) and [`99designs/keyring`](https://github.com/99designs/keyring) (richer, multi-backend including `pass` and `keyctl`) back tools like `aws-vault`.\n- **Git credential helpers** \u2014 [GCM picks `wincredman`/`keychain`/`secretservice`/`gpg` per platform](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/credstores.md); the built-in `osxkeychain`, `wincred`, and `libsecret` helpers map to the native stores.\n- **Docker credential helpers** \u2014 [`osxkeychain`/`wincred`/`secretservice`/`pass` via a simple stdin-JSON protocol](https://github.com/docker/docker-credential-helpers), replacing the base64-in-`config.json` anti-pattern.\n\nThe recurring lesson: these layers inherit the native store's threat model exactly, and the Linux path is the weakest link because the keyring daemon may be absent.\n\n**The anti-pattern to recognize:** the [AWS CLI writes long-lived keys to `~/.aws/credentials` as plaintext INI](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html) \u2014 the most widely deployed *non*-machine-bound credential storage in developer tooling. The fix is [`aws-vault` (stores in the OS keychain) plus the `credential_process` escape hatch](https://github.com/99designs/aws-vault/blob/master/USAGE.md). `gcloud` follows the same plaintext-`application_default_credentials.json` pattern.\n\n**A major option the OS stores don't cover:** in the cloud, the machine-bound answer is often to store *no secret at all* and instead bind to a **workload identity** \u2014 an Azure AD device key sealed in the TPM, an AWS instance role fetched via IMDSv2, a GCP metadata token \u2014 and pull short-lived credentials at runtime. That key *is* genuinely machine-bound (it lives in the TPM), and the credentials it fetches expire, shrinking the blast radius of a stolen disk to nothing.\n\n---\n\n## What machine-binding does and does NOT protect against\n\nThis is the section to read twice. Every mechanism above shares one limitation, and engineers are *routinely* surprised by it.\n\n**The boundary these mechanisms enforce is the user principal, not the process.** DPAPI's `CryptUnprotectData`, Credential Manager's `CredRead`, macOS `SecItemCopyMatching`, Linux `secret-tool lookup`, `keyctl read` on a `user` key \u2014 **all of them succeed for any process running as the owning user, with no additional prompt** (on macOS, after the initial access grant). This is not a bug in any of them. It is the design: **the OS gives you user-boundary isolation, not process-boundary isolation.**\n\nConcretely, machine-binding **protects against**:\n\n- **Offline disk theft.** A copied blob is useless on another machine \u2014 different SYSKEY, different TPM, different Secure Enclave UID, different master password.\n- **A different OS user** on the same machine (for user-scoped variants).\n- **Backup archives** that contain the ciphertext but not the keys.\n\nMachine-binding **does NOT protect against**:\n\n- **Same-user malware.** This is the unmissable one. Any code running in your session can ask the OS to decrypt your secret and the OS will comply. An infostealer running as you is the *documented, weaponized* attack against DPAPI, Keychain, and Secret Service alike. [Google's Chrome Security team says it of DPAPI directly: it \"does not protect against malicious applications able to execute code as the logged in user \u2014 which infostealers take advantage of.\"](https://security.googleblog.com/2024/07/improving-security-of-chrome-cookies-on.html)\n- **Admin / SYSTEM / root.** A privileged attacker dumps LSASS, copies the SYSTEM+SECURITY hives, reads the gnome-keyring daemon's memory, or impersonates `securityd`. [The Synacktiv survey documents the full offline DPAPI extraction chain](https://www.synacktiv.com/en/publications/windows-secrets-extraction-a-summary).\n- **Physical bus interposers on discrete TPMs.** For Windows TPM CNG keys, TPM-sealed `systemd-creds`/`tpm2-tools` blobs, and TPM-backed LUKS unlock, a discrete TPM on an SPI/LPC bus can be sniffed by an attacker with brief physical access (the BitLocker TPM-sniffing attack), capturing the key in transit. Mitigate with TPM session parameter encryption where the tooling supports it; firmware TPMs avoid the external bus but are less isolated from the CPU.\n- **Domain admins** holding the [DPAPI domain backup key](https://learn.microsoft.com/en-us/windows/win32/seccng/cng-dpapi-backup-keys-on-ad-domain-controllers), who can decrypt every domain user's CurrentUser secrets offline.\n\n**The graduated exceptions are worth knowing.** A handful of mechanisms *do* raise the bar above \"any same-user code\":\n\n- A **Secure Enclave key with `.biometryCurrentSet`** requires Touch ID *per operation*, evaluated inside the SEP \u2014 background malware gets `errSecInteractionNotAllowed`.\n- **Chrome's App-Bound Encryption** validates the *calling process's path* before releasing the key, raising same-user theft to \"needs SYSTEM, path-match, injection, or a COM-service exploit.\"\n- **Windows VBS Key Guard** moves key material into a hypervisor-isolated region even SYSTEM cannot read.\n\nBut absent those specific opt-ins, treat every store in this guide as readable by any code running as you. If your threat model includes same-user malware, machine-bound local storage is *not* your answer \u2014 you want hardware-gated per-use authentication or a networked manager with short-lived, revocable credentials.\n\n---\n\n## Comparison table\n\n| Mechanism | OS | Hardware-backed? | Scope | Privilege to read | Privilege to rotate/change | Portable / survives restore? |\n|---|---|---|---|---|---|---|\n| DPAPI CurrentUser | Windows | No (software, SYSKEY) | Per-user | Same user | User (auto on pw change) | No (new SYSKEY breaks it); domain backup key recovers |\n| DPAPI LocalMachine | Windows | No (software) | Any local user | Any user on machine | SYSTEM | No (machine-bound to SYSKEY) |\n| Credential Manager | Windows | No (DPAPI underneath) | Per-user logon session | Same user | User | No (re-encrypted per machine) |\n| TPM CNG key (Platform Crypto Provider) | Windows | **Yes (TPM)** | User or machine | Same user/principal | Same privilege as create | **No \u2014 non-exportable, TPM-bound** |\n| VBS Key Guard | Windows | Yes (VBS + TPM) | User or machine | Same user (key non-extractable) | Same as create | No (TPM-bound) |\n| systemd-creds (tpm2 / host+tpm2) | Linux | **Yes (TPM)** | Service UID | Root \u2192 service UID | Root | No (TPM + host key bound) |\n| Secret Service (GNOME/KWallet) | Linux | No (password) | Per-user session | Same UID (D-Bus) | User | **Yes** (file + password decrypts anywhere) |\n| Kernel trusted key | Linux | **Yes (TPM)** | UID/possessor | Same UID; root | Write perm + TPM | No (TPM-sealed blob) |\n| tpm2-tools sealing | Linux | **Yes (TPM)** | tss group / root | Same; root | tss/root | No (TPM-bound, optional PCR) |\n| `pass` (GPG) | Linux | No (unless HW token) | GPG key holder | GPG key + passphrase | User | **Yes** (portable with the GPG key) |\n| Data Protection keychain + ThisDeviceOnly | macOS | **Yes (SEP UID)** | Access group | Same access group | Creating process | **No \u2014 UID-bound** |\n| Secure Enclave key | macOS | **Yes (SEP)** | Access group | Same access group | Creating process | **No \u2014 non-exportable** |\n| File-based / System keychain | macOS | No (software) | User / all-users | User / root | User / admin | **Yes** (copyable with password) |\n\n---\n\n## Playbook: a backup/restore secret \u2014 what to pick and how to verify\n\nYou want a daily backup password (or repo key) on disk, usable unattended, that doesn't read as plaintext and ideally can't be decrypted if the disk is stolen. Here is the concrete recommendation per OS, plus the one design decision that matters most.\n\n**First, the decision that the title implies and most of these mechanisms get wrong: recovery.** Hardware-bound secrets are *non-recoverable by design* \u2014 replace the TPM, reinstall the OS, or move to new hardware and they are gone. If your backup secret must survive a machine rebuild, do **not** store only a hardware-bound copy. Use **envelope encryption with a portable escrow**: encrypt the real secret with a random data key (DEK); wrap the DEK twice \u2014 once with the machine-bound store for fast daily use, once with a portable offline key (an `age` recipient kept in a safe, a passphrase, a hardware token) for disaster recovery. The [`age` format wraps the same file key independently per recipient](https://age-encryption.org/v1), so either path decrypts:\n\n```bash\nage -r age1 -r age1 secret.txt &gt; secret.age\n# Daily: unwrap with the machine identity. Disaster: age -d -i recovery.key secret.age\n```\n\nThis is the same DEK/KEK split that [AWS Secrets Manager](https://docs.aws.amazon.com/secretsmanager/latest/userguide/security-encryption.html), BitLocker (TPM protector + recovery password), and LUKS (multiple key slots over one volume key) all use. Rotating the *outer* wrapper is cheap \u2014 you re-wrap the DEK, never re-encrypt the data.\n\n**Windows.** For a service running as `LocalSystem`, use **DPAPI LocalMachine scope** \u2014 it works at boot before any login and needs no profile; pass a per-application **optional entropy** value so a different local process cannot trivially `Unprotect` it. For stronger binding that defeats even SYSTEM-level theft, wrap the DEK with a **TPM CNG key** (non-exportable) or use **VBS Key Guard** (`RequireVbs`) \u2014 noting that a discrete TPM remains vulnerable to a physical bus interposer. If the service runs on a roaming profile, FSLogix container, or non-persistent VDI, prefer LocalMachine scope to avoid the profile-bound master-key surprises. Verify: hex-inspect the blob for the DPAPI GUID header (or confirm a TPM-backed provider), `strings | findstr` finds nothing, and **copy the blob to a second Windows box and confirm `Unprotect` fails**. Keep the `age` escrow copy off-machine.\n\n**Linux.** Use **systemd-creds with `--with-key=tpm2`** (or `host+tpm2`). It is purpose-built for exactly this: encrypt once as root, reference it with `LoadCredentialEncrypted=` in the unit, and the service reads plaintext from `$CREDENTIALS_DIRECTORY` in non-swappable ramfs \u2014 no daemon, no D-Bus, no session needed. Guard against the PCR-update trap with **systemd-pcrlock**. Keep `/var/lib/systemd/credential.secret` **out of (or separately protected in) backups** \u2014 a host-key blob plus that file in the same backup decrypts anywhere; pure `--with-key=tpm2` avoids that exposure. Verify: `systemd-creds decrypt` succeeds locally and **fails on another machine**; `strings` finds nothing. Avoid the Secret Service path for headless services \u2014 it usually has no keyring daemon to talk to.\n\n**macOS.** Accept the constraint up front. If the backup runs as a **pre-login LaunchDaemon**, your only built-in scriptable option is **System.keychain** (`sudo security add-generic-password -k /Library/Keychains/System.keychain \u2026`) \u2014 but it is *not* machine-bound, so lean on FileVault for at-rest protection and keep the off-machine escrow. If you can ship a small Swift binary, use a **Secure Enclave envelope key** for genuine hardware binding usable without a session \u2014 and **code-sign it with a stable Team ID and `keychain-access-groups` entitlement**, or its keychain access group may not survive a rebuild. If the Mac auto-logs-in, a **Data Protection keychain item with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`** fetched via a LaunchAgent bridge gives full SE-backed binding after first unlock. Verify: restore the keychain DB (or copy the SE handle) to another Mac and confirm the fetch returns `errSecItemNotFound`.\n\n**The universal verification ritual**, applicable to every mechanism: (1) inspect the blob \u2014 `strings` / hexdump must show ciphertext, not your secret; (2) round-trip decrypt as the *right* principal on the *right* machine \u2014 must succeed; (3) attempt decrypt on a *different machine* \u2014 must fail. Those three checks together are the minimum proof. Confirming only that \"the store API returned success\" proves nothing about whether the output is encrypted or machine-scoped.\n\nAnd keep the threat model honest in your runbook: these mechanisms protect a backup secret against a stolen disk and against other users \u2014 they do **not** protect it against malware running as your backup service's own account. If that is in scope, gate the secret behind per-use hardware authentication or move to short-lived, revocable credentials from a networked manager.\n\n---\n\n### Primary sources by OS\n\n**Windows:** [CryptProtectData](https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata) \u00b7 [.NET ProtectedData](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.protecteddata) \u00b7 [NCryptProtectSecret (DPAPI-NG)](https://learn.microsoft.com/en-us/windows/win32/api/ncryptprotect/nf-ncryptprotect-ncryptprotectsecret) \u00b7 [CredWrite](https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credwritew) / [CredRead](https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credreadw) \u00b7 [NCryptCreatePersistedKey](https://learn.microsoft.com/en-us/windows/win32/api/ncrypt/nf-ncrypt-ncryptcreatepersistedkey) \u00b7 [Key storage &amp; retrieval](https://learn.microsoft.com/en-us/windows/win32/seccng/key-storage-and-retrieval) \u00b7 [TPM fundamentals](https://learn.microsoft.com/en-us/windows/security/hardware-security/tpm/tpm-fundamentals) \u00b7 [Credential Guard](https://learn.microsoft.com/en-us/windows/security/identity-protection/credential-guard/how-it-works) \u00b7 [VBS key protection](https://thewindowsupdate.com/2024/02/08/advancing-key-protection-in-windows-using-vbs/) \u00b7 [Chrome ABE](https://security.googleblog.com/2024/07/improving-security-of-chrome-cookies-on.html) \u00b7 [CyberArk C4 (ABE internals)](https://www.cyberark.com/resources/threat-research-blog/c4-bomb-blowing-up-chromes-appbound-cookie-encryption)\n\n**Linux:** [systemd-creds](https://www.freedesktop.org/software/systemd/man/latest/systemd-creds.html) \u00b7 [systemd CREDENTIALS.md](https://github.com/systemd/systemd/blob/main/docs/CREDENTIALS.md) \u00b7 [systemd-pcrlock](https://www.freedesktop.org/software/systemd/man/latest/systemd-pcrlock.html) \u00b7 [Secret Service spec](https://specifications.freedesktop.org/secret-service/latest-single/) \u00b7 [keyrings(7)](https://man7.org/linux/man-pages/man7/keyrings.7.html) \u00b7 [trusted/encrypted keys](https://www.kernel.org/doc/html/latest/security/keys/trusted-encrypted.html) \u00b7 [tpm2_unseal](https://tpm2-tools.readthedocs.io/en/latest/man/tpm2_unseal.1/) \u00b7 [TPM bus parameter encryption](https://tpm2-software.github.io/2021/02/17/Protecting-secrets-at-TPM-interface.html)\n\n**macOS:** [Keychain data protection](https://support.apple.com/en-az/guide/security/secb0694df1a/web) \u00b7 [TN3137 (two keychains)](https://developer.apple.com/documentation/technotes/tn3137-on-mac-keychains) \u00b7 [kSecAttrAccessible](https://developer.apple.com/documentation/security/ksecattraccessible) \u00b7 [Secure Enclave key](https://developer.apple.com/documentation/security/ksecattrtokenidsecureenclave) \u00b7 [The Secure Enclave](https://support.apple.com/guide/security/the-secure-enclave-sec59b0b31ff/web) \u00b7 [FileVault](https://support.apple.com/guide/security/volume-encryption-with-filevault-sec4c6dc1b6e/web) \u00b7 [security(1)](https://keith.github.io/xcode-man-pages/security.1.html)\n", "creation_timestamp": "2026-06-20T16:00:25.000000Z"}