locke 0.5.1__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.
- locke-0.5.1/.gitignore +46 -0
- locke-0.5.1/PKG-INFO +98 -0
- locke-0.5.1/README.md +68 -0
- locke-0.5.1/locke/__init__.py +159 -0
- locke-0.5.1/locke/cli/__init__.py +0 -0
- locke-0.5.1/locke/cli/_key_util.py +56 -0
- locke-0.5.1/locke/cli/bio.py +87 -0
- locke-0.5.1/locke/cli/config_cmd.py +55 -0
- locke-0.5.1/locke/cli/decrypt.py +111 -0
- locke-0.5.1/locke/cli/encrypt.py +183 -0
- locke-0.5.1/locke/cli/init_cmd.py +341 -0
- locke-0.5.1/locke/cli/keystore.py +202 -0
- locke-0.5.1/locke/cli/main.py +183 -0
- locke-0.5.1/locke/cli/registry.py +126 -0
- locke-0.5.1/locke/cli/status.py +218 -0
- locke-0.5.1/locke/cli/validate_cmd.py +311 -0
- locke-0.5.1/locke/cli/vault_import.py +809 -0
- locke-0.5.1/locke/crypto.py +187 -0
- locke-0.5.1/locke/env.py +47 -0
- locke-0.5.1/locke/errors.py +108 -0
- locke-0.5.1/locke/flatten.py +48 -0
- locke-0.5.1/locke/keystore.py +791 -0
- locke-0.5.1/locke/pipeline.py +195 -0
- locke-0.5.1/locke/vault.py +404 -0
- locke-0.5.1/pyproject.toml +52 -0
- locke-0.5.1/tests/__init__.py +0 -0
- locke-0.5.1/tests/conftest.py +19 -0
- locke-0.5.1/tests/integration/__init__.py +0 -0
- locke-0.5.1/tests/unit/__init__.py +0 -0
- locke-0.5.1/tests/unit/test_crypto.py +116 -0
- locke-0.5.1/tests/unit/test_flatten.py +50 -0
- locke-0.5.1/tests/unit/test_keystore.py +655 -0
- locke-0.5.1/tests/unit/test_pipeline.py +118 -0
- locke-0.5.1/uv.lock +690 -0
locke-0.5.1/.gitignore
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/hook_outputs/
|
|
2
|
+
**/hook_outputs/
|
|
3
|
+
|
|
4
|
+
# Python
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.pyc
|
|
7
|
+
*.pyo
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
*.eggs/
|
|
14
|
+
|
|
15
|
+
# TypeScript/Node
|
|
16
|
+
node_modules/
|
|
17
|
+
typescript/dist/
|
|
18
|
+
*.tsbuildinfo
|
|
19
|
+
# Yarn Berry (PnP + cache)
|
|
20
|
+
.yarn/
|
|
21
|
+
.pnp.*
|
|
22
|
+
yarn.lock
|
|
23
|
+
|
|
24
|
+
# Rust
|
|
25
|
+
rust/target/
|
|
26
|
+
*.rs.bk
|
|
27
|
+
|
|
28
|
+
# IDE
|
|
29
|
+
.idea/
|
|
30
|
+
.vscode/
|
|
31
|
+
*.swp
|
|
32
|
+
*.swo
|
|
33
|
+
|
|
34
|
+
# OS
|
|
35
|
+
.DS_Store
|
|
36
|
+
Thumbs.db
|
|
37
|
+
|
|
38
|
+
# Environment / Secrets
|
|
39
|
+
.env
|
|
40
|
+
.env.*
|
|
41
|
+
!.env.example
|
|
42
|
+
|
|
43
|
+
# Locke runtime (plaintext config should never be committed)
|
|
44
|
+
.locke/config.json
|
|
45
|
+
|
|
46
|
+
hook_outputs
|
locke-0.5.1/PKG-INFO
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: locke
|
|
3
|
+
Version: 0.5.1
|
|
4
|
+
Summary: Unified credentials framework — encrypted config, OS keystore, Vaultwarden integration
|
|
5
|
+
Project-URL: Homepage, https://gitlab.com/martin-wieser/locke
|
|
6
|
+
Project-URL: Repository, https://gitlab.com/martin-wieser/locke
|
|
7
|
+
Project-URL: Issues, https://gitlab.com/martin-wieser/locke/-/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: <3.15,>=3.11
|
|
19
|
+
Requires-Dist: click>=8.1
|
|
20
|
+
Requires-Dist: cryptography>=42.0
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: keyring>=25.0
|
|
23
|
+
Requires-Dist: winrt-runtime>=3.0; sys_platform == 'win32'
|
|
24
|
+
Requires-Dist: winrt-windows-foundation>=3.0; sys_platform == 'win32'
|
|
25
|
+
Requires-Dist: winrt-windows-security-credentials-ui>=3.0; sys_platform == 'win32'
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# Locke — Python
|
|
32
|
+
|
|
33
|
+
Unified credentials framework — encrypted config, OS keystore, Vaultwarden integration.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install -e ".[dev]"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
### Library
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import locke
|
|
47
|
+
|
|
48
|
+
# Load and decrypt config → flat env vars
|
|
49
|
+
env = locke.load_config(".locke/config.encrypted.json")
|
|
50
|
+
# env["MONGO_URI"], env["SENTRY_DSN"], etc.
|
|
51
|
+
|
|
52
|
+
# Resolve a single credential
|
|
53
|
+
cred = locke.resolve_credential("LOCKE_ENCRYPTION_KEY")
|
|
54
|
+
|
|
55
|
+
# Get a vault secret
|
|
56
|
+
secret = locke.get_vault_secret("myproject/staging/api_key")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### CLI
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Decrypt + flatten → shell exports
|
|
63
|
+
eval $(locke env)
|
|
64
|
+
|
|
65
|
+
# Decrypt to stdout
|
|
66
|
+
locke decrypt
|
|
67
|
+
|
|
68
|
+
# Encrypt plaintext config
|
|
69
|
+
locke encrypt
|
|
70
|
+
|
|
71
|
+
# Manage OS keystore
|
|
72
|
+
locke keystore set LOCKE_ENCRYPTION_KEY --prompt
|
|
73
|
+
locke keystore get LOCKE_ENCRYPTION_KEY
|
|
74
|
+
|
|
75
|
+
# Get vault secret
|
|
76
|
+
locke vault get myproject/staging/api_key
|
|
77
|
+
|
|
78
|
+
# Initialize project
|
|
79
|
+
locke init --project myproject --tenant staging
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Environment Variables
|
|
83
|
+
|
|
84
|
+
| Variable | Purpose |
|
|
85
|
+
|----------|---------|
|
|
86
|
+
| `LOCKE_ENV` | Override environment detection |
|
|
87
|
+
| `LOCKE_ENCRYPTION_KEY` | Encryption key (if not in keystore) |
|
|
88
|
+
| `LOCKE_USE_BIOMETRIC` | Set `false` to disable biometric gating |
|
|
89
|
+
| `LOCKE_VAULT_URL` | Vaultwarden server URL |
|
|
90
|
+
| `LOCKE_VAULT_USERNAME` | Vaultwarden username |
|
|
91
|
+
|
|
92
|
+
## Testing
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
cd python
|
|
96
|
+
pip install -e ".[dev]"
|
|
97
|
+
pytest
|
|
98
|
+
```
|
locke-0.5.1/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Locke — Python
|
|
2
|
+
|
|
3
|
+
Unified credentials framework — encrypted config, OS keystore, Vaultwarden integration.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e ".[dev]"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Library
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import locke
|
|
17
|
+
|
|
18
|
+
# Load and decrypt config → flat env vars
|
|
19
|
+
env = locke.load_config(".locke/config.encrypted.json")
|
|
20
|
+
# env["MONGO_URI"], env["SENTRY_DSN"], etc.
|
|
21
|
+
|
|
22
|
+
# Resolve a single credential
|
|
23
|
+
cred = locke.resolve_credential("LOCKE_ENCRYPTION_KEY")
|
|
24
|
+
|
|
25
|
+
# Get a vault secret
|
|
26
|
+
secret = locke.get_vault_secret("myproject/staging/api_key")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### CLI
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Decrypt + flatten → shell exports
|
|
33
|
+
eval $(locke env)
|
|
34
|
+
|
|
35
|
+
# Decrypt to stdout
|
|
36
|
+
locke decrypt
|
|
37
|
+
|
|
38
|
+
# Encrypt plaintext config
|
|
39
|
+
locke encrypt
|
|
40
|
+
|
|
41
|
+
# Manage OS keystore
|
|
42
|
+
locke keystore set LOCKE_ENCRYPTION_KEY --prompt
|
|
43
|
+
locke keystore get LOCKE_ENCRYPTION_KEY
|
|
44
|
+
|
|
45
|
+
# Get vault secret
|
|
46
|
+
locke vault get myproject/staging/api_key
|
|
47
|
+
|
|
48
|
+
# Initialize project
|
|
49
|
+
locke init --project myproject --tenant staging
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Environment Variables
|
|
53
|
+
|
|
54
|
+
| Variable | Purpose |
|
|
55
|
+
|----------|---------|
|
|
56
|
+
| `LOCKE_ENV` | Override environment detection |
|
|
57
|
+
| `LOCKE_ENCRYPTION_KEY` | Encryption key (if not in keystore) |
|
|
58
|
+
| `LOCKE_USE_BIOMETRIC` | Set `false` to disable biometric gating |
|
|
59
|
+
| `LOCKE_VAULT_URL` | Vaultwarden server URL |
|
|
60
|
+
| `LOCKE_VAULT_USERNAME` | Vaultwarden username |
|
|
61
|
+
|
|
62
|
+
## Testing
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
cd python
|
|
66
|
+
pip install -e ".[dev]"
|
|
67
|
+
pytest
|
|
68
|
+
```
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Locke — Unified credentials framework."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from locke.crypto import decrypt_config, encrypt_config
|
|
9
|
+
from locke.flatten import flatten_config
|
|
10
|
+
from locke.pipeline import resolve_credential
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def __getattr__(name: str):
|
|
14
|
+
if name == "get_vault_secret":
|
|
15
|
+
from locke.vault import get_vault_secret
|
|
16
|
+
return get_vault_secret
|
|
17
|
+
if name == "VaultClient":
|
|
18
|
+
from locke.vault import VaultClient
|
|
19
|
+
return VaultClient
|
|
20
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
21
|
+
|
|
22
|
+
__version__ = "0.5.1"
|
|
23
|
+
__all__ = [
|
|
24
|
+
"VaultClient",
|
|
25
|
+
"decrypt_config",
|
|
26
|
+
"decrypt_config_file",
|
|
27
|
+
"encrypt_config",
|
|
28
|
+
"find_config",
|
|
29
|
+
"flatten_config",
|
|
30
|
+
"get_vault_secret",
|
|
31
|
+
"inject_env",
|
|
32
|
+
"load_config",
|
|
33
|
+
"read_locke_config",
|
|
34
|
+
"resolve_credential",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def find_config(
|
|
41
|
+
project_root: str | Path,
|
|
42
|
+
env: str | None = None,
|
|
43
|
+
) -> Path | None:
|
|
44
|
+
"""Locate an encrypted config file using standard search order.
|
|
45
|
+
|
|
46
|
+
Searches ``project_root``, ``project_root/.locke``, and ``project_root/src``
|
|
47
|
+
for files matching ``config.encrypted.{env}.json``, then
|
|
48
|
+
``config.encrypted.development.json``, then ``config.encrypted.json``.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
project_root: Project root directory.
|
|
52
|
+
env: Environment name (e.g. ``"production"``). Falls back to the
|
|
53
|
+
``LOCKE_ENV`` env var if not provided.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Path to the first matching file, or ``None``.
|
|
57
|
+
"""
|
|
58
|
+
project_root = Path(project_root)
|
|
59
|
+
env = env or os.environ.get("LOCKE_ENV", "")
|
|
60
|
+
|
|
61
|
+
search_dirs = [project_root, project_root / ".locke", project_root / "src"]
|
|
62
|
+
for search_dir in search_dirs:
|
|
63
|
+
if env:
|
|
64
|
+
p = search_dir / f"config.encrypted.{env}.json"
|
|
65
|
+
if p.is_file():
|
|
66
|
+
return p
|
|
67
|
+
for fallback in ("config.encrypted.development.json", "config.encrypted.json"):
|
|
68
|
+
p = search_dir / fallback
|
|
69
|
+
if p.is_file():
|
|
70
|
+
return p
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def decrypt_config_file(
|
|
75
|
+
config_path: str | Path,
|
|
76
|
+
key_name: str,
|
|
77
|
+
) -> dict:
|
|
78
|
+
"""Resolve key via pipeline, decrypt config file, return plaintext dict.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
config_path: Path to config.encrypted.json.
|
|
82
|
+
key_name: Credential name (e.g. ``MYAPP_CONFIG_ENCRYPTION_KEY``).
|
|
83
|
+
"""
|
|
84
|
+
config_path = Path(config_path)
|
|
85
|
+
with open(config_path) as f:
|
|
86
|
+
encrypted = json.load(f)
|
|
87
|
+
|
|
88
|
+
credential = resolve_credential(key_name)
|
|
89
|
+
return decrypt_config(encrypted, credential.value)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def inject_env(
|
|
93
|
+
config: dict,
|
|
94
|
+
env_map: dict[tuple[str, ...], str],
|
|
95
|
+
) -> int:
|
|
96
|
+
"""Inject decrypted config values into ``os.environ``.
|
|
97
|
+
|
|
98
|
+
Existing env vars are NOT overwritten.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
config: Decrypted nested config dict.
|
|
102
|
+
env_map: Mapping of ``(section, key)`` tuples to env var names.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Number of variables set.
|
|
106
|
+
"""
|
|
107
|
+
count = 0
|
|
108
|
+
for path, env_var in env_map.items():
|
|
109
|
+
value = config
|
|
110
|
+
for segment in path:
|
|
111
|
+
if isinstance(value, dict):
|
|
112
|
+
value = value.get(segment)
|
|
113
|
+
else:
|
|
114
|
+
value = None
|
|
115
|
+
break
|
|
116
|
+
if value is None or value == "":
|
|
117
|
+
continue
|
|
118
|
+
if isinstance(value, list):
|
|
119
|
+
value = ",".join(str(v) for v in value)
|
|
120
|
+
elif isinstance(value, bool):
|
|
121
|
+
value = str(value).lower()
|
|
122
|
+
else:
|
|
123
|
+
value = str(value)
|
|
124
|
+
if os.environ.get(env_var):
|
|
125
|
+
continue
|
|
126
|
+
os.environ[env_var] = value
|
|
127
|
+
count += 1
|
|
128
|
+
return count
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def read_locke_config(
|
|
132
|
+
project_root: str | Path = ".",
|
|
133
|
+
) -> dict | None:
|
|
134
|
+
"""Read the ``.locke/locke.json`` config if it exists.
|
|
135
|
+
|
|
136
|
+
Returns the parsed dict, or ``None`` if the file is missing.
|
|
137
|
+
"""
|
|
138
|
+
locke_json = Path(project_root) / ".locke" / "locke.json"
|
|
139
|
+
if not locke_json.is_file():
|
|
140
|
+
return None
|
|
141
|
+
try:
|
|
142
|
+
with open(locke_json) as f:
|
|
143
|
+
return json.load(f)
|
|
144
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
145
|
+
logger.debug("Failed to read %s: %s", locke_json, exc)
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def load_config(
|
|
150
|
+
config_path: str | Path,
|
|
151
|
+
key_name: str = "LOCKE_ENCRYPTION_KEY",
|
|
152
|
+
) -> dict[str, str]:
|
|
153
|
+
"""Resolve key, decrypt, and auto-flatten to SCREAMING_SNAKE_CASE.
|
|
154
|
+
|
|
155
|
+
For projects that need a custom env mapping, use
|
|
156
|
+
:func:`decrypt_config_file` + :func:`inject_env` instead.
|
|
157
|
+
"""
|
|
158
|
+
plaintext = decrypt_config_file(config_path, key_name)
|
|
159
|
+
return flatten_config(plaintext)
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared key resolution helper for CLI commands."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from locke.pipeline import resolve_credential
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_key(key_source: str, key_name: str | None) -> str:
|
|
12
|
+
"""Resolve an encryption key from the given source.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
key_source: One of "keystore", "env", "prompt".
|
|
16
|
+
key_name: Credential name (required for keystore/env).
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
The resolved key string.
|
|
20
|
+
"""
|
|
21
|
+
if key_source == "prompt":
|
|
22
|
+
return click.prompt("Encryption key", hide_input=True)
|
|
23
|
+
|
|
24
|
+
if key_source == "env":
|
|
25
|
+
if not key_name:
|
|
26
|
+
click.echo("Error: --key-name required with --key-source=env", err=True)
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
key = os.environ.get(key_name)
|
|
29
|
+
if not key:
|
|
30
|
+
click.echo(f"Error: {key_name} not set", err=True)
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
return key
|
|
33
|
+
|
|
34
|
+
# Default: keystore (uses full pipeline: keystore → env → glab → dotenv)
|
|
35
|
+
if not key_name:
|
|
36
|
+
click.echo(
|
|
37
|
+
"Error: --key-name required (e.g. --key-name MYAPP_CONFIG_ENCRYPTION_KEY)",
|
|
38
|
+
err=True,
|
|
39
|
+
)
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
cred = resolve_credential(key_name)
|
|
42
|
+
return cred.value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
KEY_NAME_OPTION = click.option(
|
|
46
|
+
"--key-name",
|
|
47
|
+
default=None,
|
|
48
|
+
help="Credential name for the encryption key (e.g. MYAPP_CONFIG_ENCRYPTION_KEY)",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
KEY_SOURCE_OPTION = click.option(
|
|
52
|
+
"--key-source",
|
|
53
|
+
type=click.Choice(["keystore", "env", "prompt"]),
|
|
54
|
+
default="keystore",
|
|
55
|
+
help="Where to get the encryption key",
|
|
56
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""CLI commands for biometric session management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
def bio() -> None:
|
|
13
|
+
"""Manage biometric session."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@bio.command()
|
|
17
|
+
def logout() -> None:
|
|
18
|
+
"""Invalidate the persistent biometric session.
|
|
19
|
+
|
|
20
|
+
After logout, the next keystore access will prompt Windows Hello again.
|
|
21
|
+
"""
|
|
22
|
+
from locke.keystore import invalidate_bio_session
|
|
23
|
+
|
|
24
|
+
if invalidate_bio_session():
|
|
25
|
+
click.echo("Biometric session invalidated.")
|
|
26
|
+
else:
|
|
27
|
+
click.echo("No active biometric session.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@bio.command()
|
|
31
|
+
def status() -> None:
|
|
32
|
+
"""Show biometric session status."""
|
|
33
|
+
import json as json_mod
|
|
34
|
+
|
|
35
|
+
from locke.keystore import (
|
|
36
|
+
_BIO_SESSION_FILE,
|
|
37
|
+
_bio_session_ttl,
|
|
38
|
+
_biometric_enabled,
|
|
39
|
+
_compute_session_hmac,
|
|
40
|
+
_get_bio_session_dir,
|
|
41
|
+
_get_machine_id,
|
|
42
|
+
_get_username,
|
|
43
|
+
_read_bio_session,
|
|
44
|
+
check_windows_hello_available,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
click.echo("Biometric status:")
|
|
48
|
+
click.echo(f" Enabled: {_biometric_enabled()}")
|
|
49
|
+
click.echo(f" Windows Hello: {check_windows_hello_available()}")
|
|
50
|
+
click.echo(f" Session TTL: {_bio_session_ttl():.0f}s ({_bio_session_ttl() / 86400:.1f} days)")
|
|
51
|
+
|
|
52
|
+
session_file = _get_bio_session_dir() / _BIO_SESSION_FILE
|
|
53
|
+
if not session_file.is_file():
|
|
54
|
+
click.echo(" Session: none")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
with open(session_file) as f:
|
|
59
|
+
data = json_mod.load(f)
|
|
60
|
+
verified_at = float(data["verified_at"])
|
|
61
|
+
machine_id = str(data["machine_id"])
|
|
62
|
+
username = str(data["username"])
|
|
63
|
+
stored_hmac = str(data["hmac"])
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
click.echo(f" Session: corrupt ({exc})")
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
ts = datetime.datetime.fromtimestamp(verified_at).strftime("%Y-%m-%d %H:%M:%S")
|
|
69
|
+
age = time.time() - verified_at
|
|
70
|
+
remaining = _bio_session_ttl() - age
|
|
71
|
+
|
|
72
|
+
machine_ok = machine_id == _get_machine_id()
|
|
73
|
+
user_ok = username == _get_username()
|
|
74
|
+
expected_hmac = _compute_session_hmac(verified_at, machine_id, username)
|
|
75
|
+
hmac_ok = stored_hmac == expected_hmac
|
|
76
|
+
valid = _read_bio_session()
|
|
77
|
+
|
|
78
|
+
click.echo(f" Verified at: {ts}")
|
|
79
|
+
click.echo(f" Age: {age:.0f}s ({age / 3600:.1f}h)")
|
|
80
|
+
if remaining > 0:
|
|
81
|
+
click.echo(f" Remaining: {remaining:.0f}s ({remaining / 3600:.1f}h)")
|
|
82
|
+
else:
|
|
83
|
+
click.echo(f" Remaining: expired")
|
|
84
|
+
click.echo(f" Machine match: {machine_ok}")
|
|
85
|
+
click.echo(f" User match: {user_ok}")
|
|
86
|
+
click.echo(f" HMAC intact: {hmac_ok}")
|
|
87
|
+
click.echo(f" Valid: {valid}")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""locke config — config file discovery and management."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
def config() -> None:
|
|
10
|
+
"""Config file discovery and management."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@config.command(name="find")
|
|
14
|
+
@click.option(
|
|
15
|
+
"--env",
|
|
16
|
+
"env_name",
|
|
17
|
+
default=None,
|
|
18
|
+
help="Environment name (e.g. development, staging, production)",
|
|
19
|
+
)
|
|
20
|
+
@click.option(
|
|
21
|
+
"--project-root",
|
|
22
|
+
default=".",
|
|
23
|
+
type=click.Path(exists=True, file_okay=False),
|
|
24
|
+
help="Project root directory (default: current directory)",
|
|
25
|
+
)
|
|
26
|
+
def config_find(env_name: str | None, project_root: str) -> None:
|
|
27
|
+
"""Find the encrypted config file for an environment.
|
|
28
|
+
|
|
29
|
+
Prints the absolute path to stdout for use in shell scripts.
|
|
30
|
+
Exits with code 1 if no config file is found.
|
|
31
|
+
|
|
32
|
+
\b
|
|
33
|
+
Examples:
|
|
34
|
+
locke config find
|
|
35
|
+
locke config find --env production
|
|
36
|
+
locke config find --env staging --project-root /path/to/project
|
|
37
|
+
|
|
38
|
+
\b
|
|
39
|
+
Shell integration:
|
|
40
|
+
CONFIG=$(locke config find --env production)
|
|
41
|
+
docker run -v "$CONFIG:/app/config.encrypted.json:ro" ...
|
|
42
|
+
"""
|
|
43
|
+
from locke import find_config
|
|
44
|
+
|
|
45
|
+
path = find_config(project_root, env=env_name)
|
|
46
|
+
if path:
|
|
47
|
+
click.echo(str(path.resolve()))
|
|
48
|
+
else:
|
|
49
|
+
env_display = env_name or "any"
|
|
50
|
+
click.echo(
|
|
51
|
+
f"Error: no encrypted config found for env={env_display} "
|
|
52
|
+
f"in {project_root}",
|
|
53
|
+
err=True,
|
|
54
|
+
)
|
|
55
|
+
sys.exit(1)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""locke decrypt — decrypt an encrypted config file."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from locke.crypto import decrypt_config
|
|
10
|
+
from locke.cli._key_util import get_key
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command()
|
|
14
|
+
@click.argument("config_path", required=False, default=None)
|
|
15
|
+
@click.option(
|
|
16
|
+
"--config",
|
|
17
|
+
"config_opt",
|
|
18
|
+
default=None,
|
|
19
|
+
help="Path to encrypted config file (alternative to positional arg)",
|
|
20
|
+
)
|
|
21
|
+
@click.option(
|
|
22
|
+
"--output", "-o",
|
|
23
|
+
"output_path",
|
|
24
|
+
default=None,
|
|
25
|
+
help="Output file path (default: stdout)",
|
|
26
|
+
)
|
|
27
|
+
@click.option("--key-name", default=None, help="Credential name for the encryption key")
|
|
28
|
+
@click.option(
|
|
29
|
+
"--key-source",
|
|
30
|
+
type=click.Choice(["keystore", "env", "prompt"]),
|
|
31
|
+
default="keystore",
|
|
32
|
+
help="Where to get the encryption key",
|
|
33
|
+
)
|
|
34
|
+
@click.option(
|
|
35
|
+
"--env",
|
|
36
|
+
"env_name",
|
|
37
|
+
default=None,
|
|
38
|
+
help="Environment name for auto-discovery (e.g. development, staging, production)",
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--project-root",
|
|
42
|
+
default=".",
|
|
43
|
+
help="Project root for auto-discovery (default: current directory)",
|
|
44
|
+
)
|
|
45
|
+
def decrypt(
|
|
46
|
+
config_path: str | None,
|
|
47
|
+
config_opt: str | None,
|
|
48
|
+
output_path: str | None,
|
|
49
|
+
key_name: str | None,
|
|
50
|
+
key_source: str,
|
|
51
|
+
env_name: str | None,
|
|
52
|
+
project_root: str,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Decrypt an encrypted config file.
|
|
55
|
+
|
|
56
|
+
\b
|
|
57
|
+
Examples:
|
|
58
|
+
locke decrypt config.encrypted.development.json --key-name MYAPP_CONFIG_ENCRYPTION_KEY
|
|
59
|
+
locke decrypt --env production --key-name MYAPP_CONFIG_ENCRYPTION_KEY
|
|
60
|
+
locke decrypt --key-name MYAPP_CONFIG_ENCRYPTION_KEY # auto-discovers config
|
|
61
|
+
"""
|
|
62
|
+
# Resolve input path: positional arg > --config > auto-discover
|
|
63
|
+
path_str = config_path or config_opt
|
|
64
|
+
if not path_str:
|
|
65
|
+
from locke import find_config
|
|
66
|
+
found = find_config(project_root, env=env_name)
|
|
67
|
+
if found:
|
|
68
|
+
path_str = str(found)
|
|
69
|
+
click.echo(f"Auto-discovered: {path_str}", err=True)
|
|
70
|
+
else:
|
|
71
|
+
click.echo(
|
|
72
|
+
"Error: no encrypted config file found. "
|
|
73
|
+
"Provide a path or use --env / --project-root.",
|
|
74
|
+
err=True,
|
|
75
|
+
)
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
with open(path_str) as f:
|
|
80
|
+
encrypted = json.load(f)
|
|
81
|
+
except FileNotFoundError:
|
|
82
|
+
click.echo(f"Error: File not found: {path_str}", err=True)
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
except json.JSONDecodeError:
|
|
85
|
+
click.echo(f"Error: Invalid JSON in {path_str}", err=True)
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
|
|
88
|
+
# Auto-resolve key_name from .locke/locke.json if not provided
|
|
89
|
+
if not key_name:
|
|
90
|
+
from locke import read_locke_config
|
|
91
|
+
locke_cfg = read_locke_config(project_root)
|
|
92
|
+
if locke_cfg and locke_cfg.get("keyName"):
|
|
93
|
+
key_name = locke_cfg["keyName"]
|
|
94
|
+
click.echo(f"Using key name from .locke/locke.json: {key_name}", err=True)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
key = get_key(key_source, key_name)
|
|
98
|
+
plaintext = decrypt_config(encrypted, key)
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
click.echo(f"Error: {exc}", err=True)
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
output = json.dumps(plaintext, indent=2, sort_keys=True)
|
|
104
|
+
|
|
105
|
+
if output_path:
|
|
106
|
+
with open(output_path, "w") as f:
|
|
107
|
+
f.write(output)
|
|
108
|
+
f.write("\n")
|
|
109
|
+
click.echo(f"Decrypted config written to {output_path}", err=True)
|
|
110
|
+
else:
|
|
111
|
+
click.echo(output)
|