dotenv-webauthn-crypt 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dotenv_webauthn_crypt-0.1.2/LICENSE +21 -0
- dotenv_webauthn_crypt-0.1.2/PKG-INFO +84 -0
- dotenv_webauthn_crypt-0.1.2/README.md +67 -0
- dotenv_webauthn_crypt-0.1.2/ext/native.cpp +112 -0
- dotenv_webauthn_crypt-0.1.2/pyproject.toml +40 -0
- dotenv_webauthn_crypt-0.1.2/setup.cfg +4 -0
- dotenv_webauthn_crypt-0.1.2/setup.py +17 -0
- dotenv_webauthn_crypt-0.1.2/src/dotenv_webauthn_crypt/__init__.py +3 -0
- dotenv_webauthn_crypt-0.1.2/src/dotenv_webauthn_crypt/cli.py +36 -0
- dotenv_webauthn_crypt-0.1.2/src/dotenv_webauthn_crypt/core.py +138 -0
- dotenv_webauthn_crypt-0.1.2/src/dotenv_webauthn_crypt.egg-info/PKG-INFO +84 -0
- dotenv_webauthn_crypt-0.1.2/src/dotenv_webauthn_crypt.egg-info/SOURCES.txt +15 -0
- dotenv_webauthn_crypt-0.1.2/src/dotenv_webauthn_crypt.egg-info/dependency_links.txt +1 -0
- dotenv_webauthn_crypt-0.1.2/src/dotenv_webauthn_crypt.egg-info/entry_points.txt +2 -0
- dotenv_webauthn_crypt-0.1.2/src/dotenv_webauthn_crypt.egg-info/requires.txt +1 -0
- dotenv_webauthn_crypt-0.1.2/src/dotenv_webauthn_crypt.egg-info/top_level.txt +1 -0
- dotenv_webauthn_crypt-0.1.2/tests/test_core.py +15 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jose Luu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dotenv-webauthn-crypt
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Drop-in dotenv replacement with Windows Hello encryption.
|
|
5
|
+
Author-email: Jose Luu <jose.luu@example.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/joseluu/dotenv-webauthn-crypt
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/joseluu/dotenv-webauthn-crypt/issues
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Classifier: Topic :: Security :: Cryptography
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: cryptography>=42.0.0
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# dotenv-webauthn-crypt
|
|
19
|
+
|
|
20
|
+
**Transparent, Windows Hello-backed encryption for your `.env` files.**
|
|
21
|
+
|
|
22
|
+
`dotenv-webauthn-crypt` is a drop-in replacement for `python-dotenv` that keeps your secrets **encrypted at rest** and gates access behind **WebAuthn (Biometrics/PIN)**. It, ensuring that your Master Key never exists in plaintext on disk.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **Seamless Integration**: Use `load_dotenv()` just like you always have.
|
|
27
|
+
- **Hardware Security**: Private keys stay in the TPM.
|
|
28
|
+
- **Biometric Decryption**: Prompts for Fingerprint/PIN when loading secrets.
|
|
29
|
+
- **Strong Cryptography**: Uses AES-256-GCM for encryption and HKDF-SHA256 for key derivation.
|
|
30
|
+
- **Vault Isolation**: Each `.env` file has its own unique derived vault key.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
### Prerequisites
|
|
35
|
+
- **Windows 10/11** with Windows Hello enabled.
|
|
36
|
+
- **Visual Studio 2022 Build Tools** (only if building from source).
|
|
37
|
+
|
|
38
|
+
```powershell
|
|
39
|
+
pip install dotenv-webauthn-crypt
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
### 1. Initialize your machine
|
|
45
|
+
Create your machine-specific root credential in the TPM:
|
|
46
|
+
```powershell
|
|
47
|
+
dotenv-webauthn-crypt-cli init --user MyWindowsUser
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 2. Encrypt an existing .env file
|
|
51
|
+
This will replace plaintext values with encrypted `ENC:...` blobs:
|
|
52
|
+
```powershell
|
|
53
|
+
dotenv-webauthn-crypt-cli encrypt .env
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 3. Load in your Python code
|
|
57
|
+
```python
|
|
58
|
+
from dotenv_webauthn_crypt import load_dotenv
|
|
59
|
+
|
|
60
|
+
# This will trigger a Windows Hello prompt if encrypted values are detected
|
|
61
|
+
load_dotenv()
|
|
62
|
+
|
|
63
|
+
import os
|
|
64
|
+
print(os.environ.get("MY_SECRET_KEY"))
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Architecture
|
|
68
|
+
|
|
69
|
+
1. **Registration**: `init` creates a non-resident public/private key pair in the TPM. The `CredentialID` is saved locally.
|
|
70
|
+
2. **Encryption**: A `VaultKey` is derived using HKDF from a TPM-backed signature and the file's canonical path.
|
|
71
|
+
3. **Loading**: `load_dotenv` detects `ENC:` prefixes, triggers Windows Hello to get a fresh signature, re-derives the `VaultKey`, and decrypts the values into `os.environ`.
|
|
72
|
+
|
|
73
|
+
## TODO / Roadmap
|
|
74
|
+
|
|
75
|
+
- [ ] Usage of Windows Hello as it uses the TPM (Trusted Platform Module) to sign challenges
|
|
76
|
+
- [ ] **Linux Support**: Implement a Linux backend using the **TPM2-TSS** or **libfido2** for similar biometric/hardware protection.
|
|
77
|
+
- [ ] **macOS Support**: Implement a macOS backend using **Secure Enclave / Touch ID**.
|
|
78
|
+
- [ ] **Credential Rotation**: Add `rekey` command to migrate between hardware credentials.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
Distributed under the MIT License. See `LICENSE` for more information.
|
|
83
|
+
|
|
84
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# dotenv-webauthn-crypt
|
|
2
|
+
|
|
3
|
+
**Transparent, Windows Hello-backed encryption for your `.env` files.**
|
|
4
|
+
|
|
5
|
+
`dotenv-webauthn-crypt` is a drop-in replacement for `python-dotenv` that keeps your secrets **encrypted at rest** and gates access behind **WebAuthn (Biometrics/PIN)**. It, ensuring that your Master Key never exists in plaintext on disk.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Seamless Integration**: Use `load_dotenv()` just like you always have.
|
|
10
|
+
- **Hardware Security**: Private keys stay in the TPM.
|
|
11
|
+
- **Biometric Decryption**: Prompts for Fingerprint/PIN when loading secrets.
|
|
12
|
+
- **Strong Cryptography**: Uses AES-256-GCM for encryption and HKDF-SHA256 for key derivation.
|
|
13
|
+
- **Vault Isolation**: Each `.env` file has its own unique derived vault key.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
### Prerequisites
|
|
18
|
+
- **Windows 10/11** with Windows Hello enabled.
|
|
19
|
+
- **Visual Studio 2022 Build Tools** (only if building from source).
|
|
20
|
+
|
|
21
|
+
```powershell
|
|
22
|
+
pip install dotenv-webauthn-crypt
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### 1. Initialize your machine
|
|
28
|
+
Create your machine-specific root credential in the TPM:
|
|
29
|
+
```powershell
|
|
30
|
+
dotenv-webauthn-crypt-cli init --user MyWindowsUser
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 2. Encrypt an existing .env file
|
|
34
|
+
This will replace plaintext values with encrypted `ENC:...` blobs:
|
|
35
|
+
```powershell
|
|
36
|
+
dotenv-webauthn-crypt-cli encrypt .env
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 3. Load in your Python code
|
|
40
|
+
```python
|
|
41
|
+
from dotenv_webauthn_crypt import load_dotenv
|
|
42
|
+
|
|
43
|
+
# This will trigger a Windows Hello prompt if encrypted values are detected
|
|
44
|
+
load_dotenv()
|
|
45
|
+
|
|
46
|
+
import os
|
|
47
|
+
print(os.environ.get("MY_SECRET_KEY"))
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Architecture
|
|
51
|
+
|
|
52
|
+
1. **Registration**: `init` creates a non-resident public/private key pair in the TPM. The `CredentialID` is saved locally.
|
|
53
|
+
2. **Encryption**: A `VaultKey` is derived using HKDF from a TPM-backed signature and the file's canonical path.
|
|
54
|
+
3. **Loading**: `load_dotenv` detects `ENC:` prefixes, triggers Windows Hello to get a fresh signature, re-derives the `VaultKey`, and decrypts the values into `os.environ`.
|
|
55
|
+
|
|
56
|
+
## TODO / Roadmap
|
|
57
|
+
|
|
58
|
+
- [ ] Usage of Windows Hello as it uses the TPM (Trusted Platform Module) to sign challenges
|
|
59
|
+
- [ ] **Linux Support**: Implement a Linux backend using the **TPM2-TSS** or **libfido2** for similar biometric/hardware protection.
|
|
60
|
+
- [ ] **macOS Support**: Implement a macOS backend using **Secure Enclave / Touch ID**.
|
|
61
|
+
- [ ] **Credential Rotation**: Add `rekey` command to migrate between hardware credentials.
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
Distributed under the MIT License. See `LICENSE` for more information.
|
|
66
|
+
|
|
67
|
+
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#include <pybind11/pybind11.h>
|
|
2
|
+
#include <pybind11/stl.h>
|
|
3
|
+
#include <windows.h>
|
|
4
|
+
#include <webauthn.h>
|
|
5
|
+
#include <vector>
|
|
6
|
+
#include <string>
|
|
7
|
+
#include <stdexcept>
|
|
8
|
+
|
|
9
|
+
namespace py = pybind11;
|
|
10
|
+
|
|
11
|
+
// Helper to convert std::string to std::wstring
|
|
12
|
+
std::wstring to_wstring(const std::string& s) {
|
|
13
|
+
int len = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, NULL, 0);
|
|
14
|
+
std::wstring ws(len, L'\0');
|
|
15
|
+
MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, &ws[0], len);
|
|
16
|
+
return ws;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
std::vector<uint8_t> make_credential(const std::string& rp_id, const std::string& user_name) {
|
|
20
|
+
HWND hwnd = GetForegroundWindow();
|
|
21
|
+
std::wstring wrp_id = to_wstring(rp_id);
|
|
22
|
+
std::wstring wuser_name = to_wstring(user_name);
|
|
23
|
+
|
|
24
|
+
WEBAUTHN_RP_ENTITY_INFORMATION rpInfo = { 0 };
|
|
25
|
+
rpInfo.dwVersion = WEBAUTHN_RP_ENTITY_INFORMATION_CURRENT_VERSION;
|
|
26
|
+
rpInfo.pwszId = wrp_id.c_str();
|
|
27
|
+
rpInfo.pwszName = L"Dotenv WebAuthn Crypt";
|
|
28
|
+
|
|
29
|
+
BYTE userId[] = "user123";
|
|
30
|
+
WEBAUTHN_USER_ENTITY_INFORMATION userInfo = { 0 };
|
|
31
|
+
userInfo.dwVersion = WEBAUTHN_USER_ENTITY_INFORMATION_CURRENT_VERSION;
|
|
32
|
+
userInfo.cbId = sizeof(userId);
|
|
33
|
+
userInfo.pbId = userId;
|
|
34
|
+
userInfo.pwszName = wuser_name.c_str();
|
|
35
|
+
userInfo.pwszDisplayName = wuser_name.c_str();
|
|
36
|
+
|
|
37
|
+
WEBAUTHN_COSE_CREDENTIAL_PARAMETER alg = { 0 };
|
|
38
|
+
alg.dwVersion = WEBAUTHN_COSE_CREDENTIAL_PARAMETER_CURRENT_VERSION;
|
|
39
|
+
alg.pwszCredentialType = WEBAUTHN_CREDENTIAL_TYPE_PUBLIC_KEY;
|
|
40
|
+
alg.lAlg = WEBAUTHN_COSE_ALGORITHM_ECDSA_P256_WITH_SHA256;
|
|
41
|
+
WEBAUTHN_COSE_CREDENTIAL_PARAMETERS pubKeyParams = { 1, &alg };
|
|
42
|
+
|
|
43
|
+
BYTE challenge[32];
|
|
44
|
+
memset(challenge, 0xEE, 32);
|
|
45
|
+
WEBAUTHN_CLIENT_DATA clientData = { 0 };
|
|
46
|
+
clientData.dwVersion = WEBAUTHN_CLIENT_DATA_CURRENT_VERSION;
|
|
47
|
+
clientData.cbClientDataJSON = 32;
|
|
48
|
+
clientData.pbClientDataJSON = challenge;
|
|
49
|
+
clientData.pwszHashAlgId = WEBAUTHN_HASH_ALGORITHM_SHA_256;
|
|
50
|
+
|
|
51
|
+
WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS options = { 0 };
|
|
52
|
+
options.dwVersion = WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS_CURRENT_VERSION;
|
|
53
|
+
options.dwTimeoutMilliseconds = 60000;
|
|
54
|
+
options.dwAuthenticatorAttachment = WEBAUTHN_AUTHENTICATOR_ATTACHMENT_PLATFORM;
|
|
55
|
+
|
|
56
|
+
// Non-Resident Flow: More compatible, does not try to "save" to TPM permanent storage slots
|
|
57
|
+
options.bRequireResidentKey = FALSE;
|
|
58
|
+
|
|
59
|
+
options.dwUserVerificationRequirement = WEBAUTHN_USER_VERIFICATION_REQUIREMENT_REQUIRED;
|
|
60
|
+
options.dwAttestationConveyancePreference = WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE_NONE;
|
|
61
|
+
|
|
62
|
+
PWEBAUTHN_CREDENTIAL_ATTESTATION pAttestation = nullptr;
|
|
63
|
+
HRESULT hr = WebAuthNAuthenticatorMakeCredential(hwnd, &rpInfo, &userInfo, &pubKeyParams, &clientData, &options, &pAttestation);
|
|
64
|
+
|
|
65
|
+
if (FAILED(hr)) {
|
|
66
|
+
throw std::runtime_error("WebAuthNAuthenticatorMakeCredential failed with HRESULT: " + std::to_string(hr));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
std::vector<uint8_t> result(pAttestation->pbCredentialId, pAttestation->pbCredentialId + pAttestation->cbCredentialId);
|
|
70
|
+
WebAuthNFreeCredentialAttestation(pAttestation);
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
std::vector<uint8_t> get_assertion(const std::string& rp_id, const std::vector<uint8_t>& credential_id, const std::vector<uint8_t>& challenge) {
|
|
75
|
+
HWND hwnd = GetForegroundWindow();
|
|
76
|
+
std::wstring wrp_id = to_wstring(rp_id);
|
|
77
|
+
|
|
78
|
+
WEBAUTHN_CLIENT_DATA clientData = { 0 };
|
|
79
|
+
clientData.dwVersion = WEBAUTHN_CLIENT_DATA_CURRENT_VERSION;
|
|
80
|
+
clientData.cbClientDataJSON = (DWORD)challenge.size();
|
|
81
|
+
clientData.pbClientDataJSON = (PBYTE)challenge.data();
|
|
82
|
+
clientData.pwszHashAlgId = WEBAUTHN_HASH_ALGORITHM_SHA_256;
|
|
83
|
+
|
|
84
|
+
WEBAUTHN_CREDENTIAL cred = { 0 };
|
|
85
|
+
cred.dwVersion = WEBAUTHN_CREDENTIAL_CURRENT_VERSION;
|
|
86
|
+
cred.cbId = (DWORD)credential_id.size();
|
|
87
|
+
cred.pbId = const_cast<PBYTE>(credential_id.data());
|
|
88
|
+
cred.pwszCredentialType = WEBAUTHN_CREDENTIAL_TYPE_PUBLIC_KEY;
|
|
89
|
+
WEBAUTHN_CREDENTIALS creds = { 1, &cred };
|
|
90
|
+
|
|
91
|
+
WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS options = { 0 };
|
|
92
|
+
options.dwVersion = WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS_CURRENT_VERSION;
|
|
93
|
+
options.dwTimeoutMilliseconds = 60000;
|
|
94
|
+
options.CredentialList = creds;
|
|
95
|
+
options.dwUserVerificationRequirement = WEBAUTHN_USER_VERIFICATION_REQUIREMENT_REQUIRED;
|
|
96
|
+
|
|
97
|
+
PWEBAUTHN_ASSERTION pAssertion = nullptr;
|
|
98
|
+
HRESULT hr = WebAuthNAuthenticatorGetAssertion(hwnd, wrp_id.c_str(), &clientData, &options, &pAssertion);
|
|
99
|
+
|
|
100
|
+
if (FAILED(hr)) {
|
|
101
|
+
throw std::runtime_error("WebAuthNAuthenticatorGetAssertion failed with HRESULT: " + std::to_string(hr));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
std::vector<uint8_t> result(pAssertion->pbSignature, pAssertion->pbSignature + pAssertion->cbSignature);
|
|
105
|
+
WebAuthNFreeAssertion(pAssertion);
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
PYBIND11_MODULE(_native, m) {
|
|
110
|
+
m.def("make_credential", &make_credential, "Create a WebAuthn credential (non-resident)");
|
|
111
|
+
m.def("get_assertion", &get_assertion, "Get WebAuthn assertion (signature)");
|
|
112
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "pybind11"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dotenv-webauthn-crypt"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Jose Luu", email="jose.luu@example.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "Drop-in dotenv replacement with Windows Hello encryption."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.8"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"cryptography>=42.0.0",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: Microsoft :: Windows",
|
|
21
|
+
"Topic :: Security :: Cryptography",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
"Homepage" = "https://github.com/joseluu/dotenv-webauthn-crypt"
|
|
26
|
+
"Bug Tracker" = "https://github.com/joseluu/dotenv-webauthn-crypt/issues"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
dotenv-webauthn-crypt-cli = "dotenv_webauthn_crypt.cli:main"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools]
|
|
32
|
+
packages = ["dotenv_webauthn_crypt"]
|
|
33
|
+
package-dir = {"" = "src"}
|
|
34
|
+
|
|
35
|
+
[tool.cibuildwheel]
|
|
36
|
+
build = "cp38-* cp39-* cp310-* cp311-* cp312-*"
|
|
37
|
+
skip = "*-win32 pp*"
|
|
38
|
+
|
|
39
|
+
[tool.cibuildwheel.windows]
|
|
40
|
+
before-build = "pip install pybind11"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from setuptools import setup, Extension
|
|
3
|
+
import pybind11
|
|
4
|
+
|
|
5
|
+
ext_modules = [
|
|
6
|
+
Extension(
|
|
7
|
+
"dotenv_webauthn_crypt._native",
|
|
8
|
+
["ext/native.cpp"],
|
|
9
|
+
include_dirs=[pybind11.get_include()],
|
|
10
|
+
language="c++",
|
|
11
|
+
libraries=["webauthn", "bcrypt", "user32"],
|
|
12
|
+
),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
setup(
|
|
16
|
+
ext_modules=ext_modules,
|
|
17
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import argparse
|
|
3
|
+
from .core import load_dotenv, init_credential, encrypt_file
|
|
4
|
+
|
|
5
|
+
def main():
|
|
6
|
+
parser = argparse.ArgumentParser(description="dotenv-webauthn-crypt CLI")
|
|
7
|
+
parser.add_argument("command", choices=["encrypt", "decrypt", "init", "rekey"])
|
|
8
|
+
parser.add_argument("env_path", nargs="?", default=".env")
|
|
9
|
+
parser.add_argument("--user", help="User name for init", default="default_user")
|
|
10
|
+
|
|
11
|
+
args = parser.parse_args()
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
if args.command == "init":
|
|
15
|
+
init_credential(args.user)
|
|
16
|
+
elif args.command == "encrypt":
|
|
17
|
+
encrypt_file(args.env_path)
|
|
18
|
+
elif args.command == "decrypt":
|
|
19
|
+
# Just to verify load_dotenv logic via CLI
|
|
20
|
+
load_dotenv(args.env_path)
|
|
21
|
+
print("Loaded (decrypted) variables into environment.")
|
|
22
|
+
# Show decrypted values for verification
|
|
23
|
+
with open(args.env_path, "r") as f:
|
|
24
|
+
for line in f:
|
|
25
|
+
if "=" in line:
|
|
26
|
+
key, _ = line.strip().split("=", 1)
|
|
27
|
+
import os
|
|
28
|
+
print(f"{key}={os.environ.get(key)}")
|
|
29
|
+
else:
|
|
30
|
+
print(f"Command '{args.command}' not yet implemented.")
|
|
31
|
+
except Exception as e:
|
|
32
|
+
print(f"Error: {e}")
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
main()
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import base64
|
|
3
|
+
import hashlib
|
|
4
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
5
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
6
|
+
from cryptography.hazmat.primitives import hashes
|
|
7
|
+
from cryptography.hazmat.backends import default_backend
|
|
8
|
+
from . import _native
|
|
9
|
+
|
|
10
|
+
# Configuration
|
|
11
|
+
RP_ID = "credentials.dotenv-webauthn.com"
|
|
12
|
+
DATA_DIR = os.path.join(os.environ.get("LOCALAPPDATA", ""), "dotenv-webauthn")
|
|
13
|
+
CREDENTIAL_FILE = os.path.join(DATA_DIR, "credential.bin")
|
|
14
|
+
|
|
15
|
+
def ensure_data_dir():
|
|
16
|
+
if not os.path.exists(DATA_DIR):
|
|
17
|
+
os.makedirs(DATA_DIR)
|
|
18
|
+
|
|
19
|
+
def init_credential(user_name: str = "default_user"):
|
|
20
|
+
ensure_data_dir()
|
|
21
|
+
credential_id = _native.make_credential(RP_ID, user_name)
|
|
22
|
+
with open(CREDENTIAL_FILE, "wb") as f:
|
|
23
|
+
f.write(bytes(credential_id))
|
|
24
|
+
print(f"Root credential initialized and saved to {CREDENTIAL_FILE}")
|
|
25
|
+
|
|
26
|
+
def get_root_credential_id() -> bytes:
|
|
27
|
+
if not os.path.exists(CREDENTIAL_FILE):
|
|
28
|
+
raise FileNotFoundError("Root credential not found. Run 'init' first.")
|
|
29
|
+
with open(CREDENTIAL_FILE, "rb") as f:
|
|
30
|
+
return f.read()
|
|
31
|
+
|
|
32
|
+
def get_master_key() -> bytes:
|
|
33
|
+
credential_id = get_root_credential_id()
|
|
34
|
+
# Challenge can be fixed as per design docs/purpose.md
|
|
35
|
+
challenge = b"dotenv-webauthn-fixed-challenge"
|
|
36
|
+
# Ensure challenge is 32 bytes for consistency with native
|
|
37
|
+
challenge_hash = hashlib.sha256(challenge).digest()
|
|
38
|
+
|
|
39
|
+
signature = _native.get_assertion(RP_ID, list(credential_id), list(challenge_hash))
|
|
40
|
+
return hashlib.sha256(bytes(signature)).digest()
|
|
41
|
+
|
|
42
|
+
def get_vault_key(env_path: str, master_key: bytes) -> bytes:
|
|
43
|
+
vault_id = hashlib.sha256(os.path.abspath(env_path).encode()).digest()
|
|
44
|
+
hkdf = HKDF(
|
|
45
|
+
algorithm=hashes.SHA256(),
|
|
46
|
+
length=32,
|
|
47
|
+
salt=vault_id,
|
|
48
|
+
info=b"dotenv-webauthn-v1",
|
|
49
|
+
backend=default_backend()
|
|
50
|
+
)
|
|
51
|
+
return hkdf.derive(master_key)
|
|
52
|
+
|
|
53
|
+
def encrypt_value(plaintext: str, vault_key: bytes) -> str:
|
|
54
|
+
aesgcm = AESGCM(vault_key)
|
|
55
|
+
nonce = os.urandom(12)
|
|
56
|
+
ciphertext = aesgcm.encrypt(nonce, plaintext.encode('utf-8'), None)
|
|
57
|
+
# Format: version(1 byte) + nonce(12) + ciphertext(N) + tag(included in ciphertext in cryptography lib)
|
|
58
|
+
# version 0x01
|
|
59
|
+
full_data = b'\x01' + nonce + ciphertext
|
|
60
|
+
return "ENC:" + base64.b64encode(full_data).decode('utf-8')
|
|
61
|
+
|
|
62
|
+
def decrypt_value(enc_value: str, vault_key: bytes) -> str:
|
|
63
|
+
if not enc_value.startswith("ENC:"):
|
|
64
|
+
return enc_value
|
|
65
|
+
|
|
66
|
+
data = base64.b64decode(enc_value[4:])
|
|
67
|
+
version = data[0]
|
|
68
|
+
if version != 1:
|
|
69
|
+
raise ValueError(f"Unsupported encryption version: {version}")
|
|
70
|
+
|
|
71
|
+
nonce = data[1:13]
|
|
72
|
+
ciphertext = data[13:]
|
|
73
|
+
|
|
74
|
+
aesgcm = AESGCM(vault_key)
|
|
75
|
+
return aesgcm.decrypt(nonce, ciphertext, None).decode('utf-8')
|
|
76
|
+
|
|
77
|
+
def load_dotenv(dotenv_path: str = ".env"):
|
|
78
|
+
if not os.path.exists(dotenv_path):
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
# Check if we need to decrypt anything first
|
|
82
|
+
needs_decryption = False
|
|
83
|
+
lines = []
|
|
84
|
+
with open(dotenv_path, "r") as f:
|
|
85
|
+
lines = f.readlines()
|
|
86
|
+
for line in lines:
|
|
87
|
+
if "=" in line:
|
|
88
|
+
_, value = line.strip().split("=", 1)
|
|
89
|
+
if value.startswith("ENC:"):
|
|
90
|
+
needs_decryption = True
|
|
91
|
+
break
|
|
92
|
+
|
|
93
|
+
if not needs_decryption:
|
|
94
|
+
# Standard load
|
|
95
|
+
for line in lines:
|
|
96
|
+
if "=" in line:
|
|
97
|
+
key, value = line.strip().split("=", 1)
|
|
98
|
+
os.environ[key] = value
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
# Perform decryption
|
|
102
|
+
master_key = get_master_key()
|
|
103
|
+
vault_key = get_vault_key(dotenv_path, master_key)
|
|
104
|
+
|
|
105
|
+
for line in lines:
|
|
106
|
+
if "=" in line:
|
|
107
|
+
key, value = line.strip().split("=", 1)
|
|
108
|
+
if value.startswith("ENC:"):
|
|
109
|
+
try:
|
|
110
|
+
os.environ[key] = decrypt_value(value, vault_key)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
print(f"Failed to decrypt {key}: {e}")
|
|
113
|
+
else:
|
|
114
|
+
os.environ[key] = value
|
|
115
|
+
|
|
116
|
+
def encrypt_file(dotenv_path: str):
|
|
117
|
+
if not os.path.exists(dotenv_path):
|
|
118
|
+
raise FileNotFoundError(f"{dotenv_path} not found")
|
|
119
|
+
|
|
120
|
+
master_key = get_master_key()
|
|
121
|
+
vault_key = get_vault_key(dotenv_path, master_key)
|
|
122
|
+
|
|
123
|
+
new_lines = []
|
|
124
|
+
with open(dotenv_path, "r") as f:
|
|
125
|
+
for line in f:
|
|
126
|
+
if "=" in line:
|
|
127
|
+
key, value = line.strip().split("=", 1)
|
|
128
|
+
if not value.startswith("ENC:"):
|
|
129
|
+
encrypted = encrypt_value(value, vault_key)
|
|
130
|
+
new_lines.append(f"{key}={encrypted}\n")
|
|
131
|
+
else:
|
|
132
|
+
new_lines.append(line)
|
|
133
|
+
else:
|
|
134
|
+
new_lines.append(line)
|
|
135
|
+
|
|
136
|
+
with open(dotenv_path, "w") as f:
|
|
137
|
+
f.writelines(new_lines)
|
|
138
|
+
print(f"File {dotenv_path} encrypted successfully.")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dotenv-webauthn-crypt
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Drop-in dotenv replacement with Windows Hello encryption.
|
|
5
|
+
Author-email: Jose Luu <jose.luu@example.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/joseluu/dotenv-webauthn-crypt
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/joseluu/dotenv-webauthn-crypt/issues
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Classifier: Topic :: Security :: Cryptography
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: cryptography>=42.0.0
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# dotenv-webauthn-crypt
|
|
19
|
+
|
|
20
|
+
**Transparent, Windows Hello-backed encryption for your `.env` files.**
|
|
21
|
+
|
|
22
|
+
`dotenv-webauthn-crypt` is a drop-in replacement for `python-dotenv` that keeps your secrets **encrypted at rest** and gates access behind **WebAuthn (Biometrics/PIN)**. It, ensuring that your Master Key never exists in plaintext on disk.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **Seamless Integration**: Use `load_dotenv()` just like you always have.
|
|
27
|
+
- **Hardware Security**: Private keys stay in the TPM.
|
|
28
|
+
- **Biometric Decryption**: Prompts for Fingerprint/PIN when loading secrets.
|
|
29
|
+
- **Strong Cryptography**: Uses AES-256-GCM for encryption and HKDF-SHA256 for key derivation.
|
|
30
|
+
- **Vault Isolation**: Each `.env` file has its own unique derived vault key.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
### Prerequisites
|
|
35
|
+
- **Windows 10/11** with Windows Hello enabled.
|
|
36
|
+
- **Visual Studio 2022 Build Tools** (only if building from source).
|
|
37
|
+
|
|
38
|
+
```powershell
|
|
39
|
+
pip install dotenv-webauthn-crypt
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
### 1. Initialize your machine
|
|
45
|
+
Create your machine-specific root credential in the TPM:
|
|
46
|
+
```powershell
|
|
47
|
+
dotenv-webauthn-crypt-cli init --user MyWindowsUser
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 2. Encrypt an existing .env file
|
|
51
|
+
This will replace plaintext values with encrypted `ENC:...` blobs:
|
|
52
|
+
```powershell
|
|
53
|
+
dotenv-webauthn-crypt-cli encrypt .env
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 3. Load in your Python code
|
|
57
|
+
```python
|
|
58
|
+
from dotenv_webauthn_crypt import load_dotenv
|
|
59
|
+
|
|
60
|
+
# This will trigger a Windows Hello prompt if encrypted values are detected
|
|
61
|
+
load_dotenv()
|
|
62
|
+
|
|
63
|
+
import os
|
|
64
|
+
print(os.environ.get("MY_SECRET_KEY"))
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Architecture
|
|
68
|
+
|
|
69
|
+
1. **Registration**: `init` creates a non-resident public/private key pair in the TPM. The `CredentialID` is saved locally.
|
|
70
|
+
2. **Encryption**: A `VaultKey` is derived using HKDF from a TPM-backed signature and the file's canonical path.
|
|
71
|
+
3. **Loading**: `load_dotenv` detects `ENC:` prefixes, triggers Windows Hello to get a fresh signature, re-derives the `VaultKey`, and decrypts the values into `os.environ`.
|
|
72
|
+
|
|
73
|
+
## TODO / Roadmap
|
|
74
|
+
|
|
75
|
+
- [ ] Usage of Windows Hello as it uses the TPM (Trusted Platform Module) to sign challenges
|
|
76
|
+
- [ ] **Linux Support**: Implement a Linux backend using the **TPM2-TSS** or **libfido2** for similar biometric/hardware protection.
|
|
77
|
+
- [ ] **macOS Support**: Implement a macOS backend using **Secure Enclave / Touch ID**.
|
|
78
|
+
- [ ] **Credential Rotation**: Add `rekey` command to migrate between hardware credentials.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
Distributed under the MIT License. See `LICENSE` for more information.
|
|
83
|
+
|
|
84
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
setup.py
|
|
5
|
+
ext/native.cpp
|
|
6
|
+
src/dotenv_webauthn_crypt/__init__.py
|
|
7
|
+
src/dotenv_webauthn_crypt/cli.py
|
|
8
|
+
src/dotenv_webauthn_crypt/core.py
|
|
9
|
+
src/dotenv_webauthn_crypt.egg-info/PKG-INFO
|
|
10
|
+
src/dotenv_webauthn_crypt.egg-info/SOURCES.txt
|
|
11
|
+
src/dotenv_webauthn_crypt.egg-info/dependency_links.txt
|
|
12
|
+
src/dotenv_webauthn_crypt.egg-info/entry_points.txt
|
|
13
|
+
src/dotenv_webauthn_crypt.egg-info/requires.txt
|
|
14
|
+
src/dotenv_webauthn_crypt.egg-info/top_level.txt
|
|
15
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cryptography>=42.0.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dotenv_webauthn_crypt
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from dotenv_webauthn_crypt.core import get_vault_key
|
|
3
|
+
|
|
4
|
+
class TestCore(unittest.TestCase):
|
|
5
|
+
def test_vault_key_derivation(self):
|
|
6
|
+
master_key = b"secret_master_key_32bytes!!!!!"
|
|
7
|
+
env_path = ".env"
|
|
8
|
+
key1 = get_vault_key(env_path, master_key)
|
|
9
|
+
key2 = get_vault_key(env_path, master_key)
|
|
10
|
+
|
|
11
|
+
self.assertEqual(key1, key2)
|
|
12
|
+
self.assertEqual(len(key1), 32)
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
unittest.main()
|