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 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)