secure-credentials-kit 0.1.0__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,105 @@
1
+ import argparse
2
+
3
+ from secure_credentials_kit.credentials import edit_credentials, generate_credentials_key
4
+
5
+
6
+ def print_key_paths(env: str, key_paths: dict) -> None:
7
+ for role, key_path in key_paths.items():
8
+ print(f"{role.title()} key for {env} has been generated and saved to {key_path}")
9
+
10
+
11
+ def generate_key_main(argv=None) -> int:
12
+ parser = argparse.ArgumentParser(
13
+ description="Generate an encryption key for secure credentials."
14
+ )
15
+ parser.add_argument("env", help="Environment name")
16
+ parser.add_argument("--secrets-dir", default="secrets", help="Credentials directory")
17
+ parser.add_argument(
18
+ "--role",
19
+ choices=["all", "master", "readonly"],
20
+ default="all",
21
+ help="Key role to generate",
22
+ )
23
+ args = parser.parse_args(argv)
24
+
25
+ try:
26
+ key_paths = generate_credentials_key(args.env, args.secrets_dir, args.role)
27
+ except (FileExistsError, FileNotFoundError, PermissionError, ValueError) as exc:
28
+ print(exc)
29
+ return 1
30
+
31
+ print_key_paths(args.env, key_paths)
32
+ return 0
33
+
34
+
35
+ def edit_main(argv=None) -> int:
36
+ parser = argparse.ArgumentParser(description="Edit encrypted secure credentials.")
37
+ parser.add_argument("env", help="Environment name")
38
+ parser.add_argument("--secrets-dir", default="secrets", help="Credentials directory")
39
+ parser.add_argument("--editor", help="Editor command to use")
40
+ args = parser.parse_args(argv)
41
+
42
+ try:
43
+ encrypted_path = edit_credentials(args.env, args.secrets_dir, args.editor)
44
+ except (FileNotFoundError, PermissionError, ValueError) as exc:
45
+ print(exc)
46
+ return 1
47
+
48
+ print(f"Data has been encrypted and saved to {encrypted_path}")
49
+ return 0
50
+
51
+
52
+ def main(argv=None) -> int:
53
+ parser = argparse.ArgumentParser(prog="secure-credentials-kit")
54
+ subparsers = parser.add_subparsers(dest="command", required=True)
55
+
56
+ generate_parser = subparsers.add_parser(
57
+ "generate-key",
58
+ help="Generate an encryption key for an environment.",
59
+ )
60
+ generate_parser.add_argument("env", help="Environment name")
61
+ generate_parser.add_argument(
62
+ "--secrets-dir",
63
+ default="secrets",
64
+ help="Credentials directory",
65
+ )
66
+ generate_parser.add_argument(
67
+ "--role",
68
+ choices=["all", "master", "readonly"],
69
+ default="all",
70
+ help="Key role to generate",
71
+ )
72
+
73
+ edit_parser = subparsers.add_parser(
74
+ "edit",
75
+ help="Edit encrypted credentials for an environment.",
76
+ )
77
+ edit_parser.add_argument("env", help="Environment name")
78
+ edit_parser.add_argument(
79
+ "--secrets-dir",
80
+ default="secrets",
81
+ help="Credentials directory",
82
+ )
83
+ edit_parser.add_argument("--editor", help="Editor command to use")
84
+
85
+ args = parser.parse_args(argv)
86
+
87
+ if args.command == "generate-key":
88
+ try:
89
+ key_paths = generate_credentials_key(args.env, args.secrets_dir, args.role)
90
+ except (FileExistsError, FileNotFoundError, PermissionError, ValueError) as exc:
91
+ print(exc)
92
+ return 1
93
+ print_key_paths(args.env, key_paths)
94
+ return 0
95
+
96
+ if args.command == "edit":
97
+ try:
98
+ encrypted_path = edit_credentials(args.env, args.secrets_dir, args.editor)
99
+ except (FileNotFoundError, PermissionError, ValueError) as exc:
100
+ print(exc)
101
+ return 1
102
+ print(f"Data has been encrypted and saved to {encrypted_path}")
103
+ return 0
104
+
105
+ return 1
@@ -0,0 +1,128 @@
1
+ from os import getenv, makedirs, path, remove
2
+ from subprocess import run as subprocess_run
3
+ from typing import Optional
4
+
5
+ from secure_credentials_kit.utils import (
6
+ derive_readonly_key,
7
+ decrypt_credentials_data,
8
+ encrypt_credentials_data,
9
+ generate_credentials_key_pair,
10
+ )
11
+
12
+
13
+ def secret_path(secrets_dir: str, env: str, suffix: str) -> str:
14
+ return path.join(secrets_dir, f"{env}.{suffix}")
15
+
16
+
17
+ def master_key_path(secrets_dir: str, env: str) -> str:
18
+ return secret_path(secrets_dir, env, "master.key")
19
+
20
+
21
+ def readonly_key_path(secrets_dir: str, env: str) -> str:
22
+ return secret_path(secrets_dir, env, "readonly.key")
23
+
24
+
25
+ def read_key(key_path: str) -> str:
26
+ with open(key_path, "r") as f:
27
+ return f.read()
28
+
29
+
30
+ def resolve_read_key_path(env: str, secrets_dir: str = "secrets") -> str:
31
+ for key_path in (
32
+ readonly_key_path(secrets_dir, env),
33
+ master_key_path(secrets_dir, env),
34
+ ):
35
+ if path.exists(key_path):
36
+ return key_path
37
+
38
+ raise FileNotFoundError(f"Key for {env} does not exist!")
39
+
40
+
41
+ def resolve_master_key_path(env: str, secrets_dir: str = "secrets") -> str:
42
+ key_path = master_key_path(secrets_dir, env)
43
+ if path.exists(key_path):
44
+ return key_path
45
+
46
+ if path.exists(readonly_key_path(secrets_dir, env)):
47
+ raise PermissionError(f"Master key for {env} does not exist!")
48
+
49
+ raise FileNotFoundError(f"Key for {env} does not exist!")
50
+
51
+
52
+ def generate_credentials_key(
53
+ env: str,
54
+ secrets_dir: str = "secrets",
55
+ key_role: str = "all",
56
+ ) -> dict:
57
+ """Generate master and readonly credentials keys for an environment."""
58
+ if key_role not in {"all", "master", "readonly"}:
59
+ raise ValueError("key_role must be all, master, or readonly")
60
+
61
+ master_path = master_key_path(secrets_dir, env)
62
+ readonly_path = readonly_key_path(secrets_dir, env)
63
+
64
+ if key_role == "readonly":
65
+ if path.exists(readonly_path):
66
+ raise FileExistsError(f"Readonly key for {env} already exists!")
67
+ master_key = read_key(resolve_master_key_path(env, secrets_dir))
68
+ makedirs(secrets_dir, exist_ok=True)
69
+ with open(readonly_path, "w") as f:
70
+ f.write(derive_readonly_key(master_key))
71
+ return {"readonly": readonly_path}
72
+
73
+ if path.exists(master_path):
74
+ raise FileExistsError(f"Master key for {env} already exists!")
75
+ if key_role == "all" and path.exists(readonly_path):
76
+ raise FileExistsError(f"Readonly key for {env} already exists!")
77
+
78
+ makedirs(secrets_dir, exist_ok=True)
79
+ master_key, readonly_key = generate_credentials_key_pair()
80
+
81
+ with open(master_path, "w") as f:
82
+ f.write(master_key)
83
+
84
+ generated_paths = {"master": master_path}
85
+
86
+ if key_role == "all":
87
+ with open(readonly_path, "w") as f:
88
+ f.write(readonly_key)
89
+ generated_paths["readonly"] = readonly_path
90
+
91
+ return generated_paths
92
+
93
+
94
+ def edit_credentials(
95
+ env: str,
96
+ secrets_dir: str = "secrets",
97
+ editor: Optional[str] = None,
98
+ ) -> str:
99
+ """Open decrypted credentials in an editor, then encrypt them again."""
100
+ from yaml import safe_dump, safe_load
101
+
102
+ key_path = resolve_master_key_path(env, secrets_dir)
103
+ encrypted_path = secret_path(secrets_dir, env, "yml.enc")
104
+ decrypted_path = secret_path(secrets_dir, env, "yml")
105
+
106
+ key = read_key(key_path)
107
+
108
+ if path.exists(encrypted_path):
109
+ with open(encrypted_path, "r") as f:
110
+ encrypted_data = f.read()
111
+ else:
112
+ encrypted_data = encrypt_credentials_data(key, "")
113
+
114
+ with open(decrypted_path, "w") as f:
115
+ f.write(decrypt_credentials_data(key, encrypted_data))
116
+
117
+ subprocess_run([editor or getenv("EDITOR", "nano"), decrypted_path], check=True)
118
+
119
+ with open(decrypted_path, "r") as f:
120
+ yaml_data = safe_load(f) or {}
121
+ if not isinstance(yaml_data, dict):
122
+ raise ValueError("Secure credentials YAML must contain a mapping at the root")
123
+
124
+ with open(encrypted_path, "w") as f:
125
+ f.write(encrypt_credentials_data(key, safe_dump(yaml_data)))
126
+
127
+ remove(decrypted_path)
128
+ return encrypted_path
@@ -0,0 +1,53 @@
1
+ from os import getenv
2
+ from typing import Callable, Optional, TYPE_CHECKING
3
+
4
+ from fastapi import FastAPI, Request
5
+
6
+ if TYPE_CHECKING:
7
+ from secure_credentials_kit.secrets_loader import CredentialsContainer
8
+
9
+
10
+ DEFAULT_STATE_ATTRIBUTE = "credentials"
11
+
12
+
13
+ def resolve_environment(env: Optional[str] = None) -> str:
14
+ """Resolve the credentials environment for a FastAPI application."""
15
+ return (
16
+ env
17
+ or getenv("SECURE_CREDENTIALS_KIT_ENV")
18
+ or getenv("FASTAPI_ENV")
19
+ or getenv("ENV")
20
+ or "development"
21
+ )
22
+
23
+
24
+ def load_credentials(env: Optional[str] = None) -> "CredentialsContainer":
25
+ from secure_credentials_kit.secrets_loader import decrypt_credentials
26
+
27
+ return decrypt_credentials(resolve_environment(env))
28
+
29
+
30
+ def setup_secure_credentials_kit(
31
+ app: FastAPI,
32
+ env: Optional[str] = None,
33
+ state_attribute: str = DEFAULT_STATE_ATTRIBUTE,
34
+ ) -> "CredentialsContainer":
35
+ credentials = load_credentials(env)
36
+ setattr(app.state, state_attribute, credentials)
37
+ return credentials
38
+
39
+
40
+ def get_credentials(
41
+ request: Request,
42
+ state_attribute: str = DEFAULT_STATE_ATTRIBUTE,
43
+ ) -> "CredentialsContainer":
44
+ return getattr(request.app.state, state_attribute)
45
+
46
+
47
+ def credentials_dependency(
48
+ state_attribute: str = DEFAULT_STATE_ATTRIBUTE,
49
+ ) -> Callable[[Request], "CredentialsContainer"]:
50
+ def dependency(request: Request) -> "CredentialsContainer":
51
+ return get_credentials(request, state_attribute)
52
+
53
+ return dependency
File without changes
File without changes
@@ -0,0 +1,20 @@
1
+ from django.core.management.base import BaseCommand
2
+
3
+ from secure_credentials_kit.credentials import edit_credentials
4
+
5
+
6
+ class Command(BaseCommand):
7
+ help = "Encrypt or decrypt data"
8
+
9
+ def add_arguments(self, parser):
10
+ parser.add_argument("env", type=str, help="Environment name")
11
+
12
+ def handle(self, *args, **kwargs):
13
+ env = kwargs["env"]
14
+ try:
15
+ encrypted_path = edit_credentials(env)
16
+ except (FileNotFoundError, PermissionError, ValueError) as exc:
17
+ self.stdout.write(str(exc))
18
+ return
19
+
20
+ self.stdout.write(f"Data has been encrypted and saved to {encrypted_path}")
@@ -0,0 +1,29 @@
1
+ from django.core.management.base import BaseCommand
2
+
3
+ from secure_credentials_kit.credentials import generate_credentials_key
4
+
5
+
6
+ class Command(BaseCommand):
7
+ help = "Generate a new encryption key"
8
+
9
+ def add_arguments(self, parser):
10
+ parser.add_argument("env", type=str, help="Environment name")
11
+ parser.add_argument(
12
+ "--role",
13
+ choices=["all", "master", "readonly"],
14
+ default="all",
15
+ help="Key role to generate",
16
+ )
17
+
18
+ def handle(self, *args, **kwargs):
19
+ env = kwargs["env"]
20
+ try:
21
+ key_paths = generate_credentials_key(env, key_role=kwargs["role"])
22
+ except (FileExistsError, FileNotFoundError, PermissionError, ValueError) as exc:
23
+ self.stdout.write(str(exc))
24
+ return
25
+
26
+ for role, key_path in key_paths.items():
27
+ self.stdout.write(
28
+ f"{role.title()} key for {env} has been generated and saved to {key_path}"
29
+ )
@@ -0,0 +1,63 @@
1
+ from os import path
2
+ from secure_credentials_kit.credentials import read_key, resolve_read_key_path, secret_path
3
+ from secure_credentials_kit.utils import decrypt_credentials_data
4
+
5
+
6
+ def normalize_credentials(credentials):
7
+ if credentials is None:
8
+ return {}
9
+ if not isinstance(credentials, dict):
10
+ raise ValueError("Secure credentials YAML must contain a mapping at the root")
11
+ return credentials
12
+
13
+
14
+ class CredentialsContainer(object):
15
+ """A class to represent a container for credentials."""
16
+ def __init__(self, credentials: dict):
17
+ self._credentials = normalize_credentials(credentials)
18
+
19
+ def dig(self, *args):
20
+ """Dig into the credentials."""
21
+ value = None
22
+
23
+ # Copy the credentials
24
+ credentials = self._credentials.copy()
25
+
26
+ for key in args:
27
+ if key in credentials:
28
+ value = credentials[key]
29
+ credentials = value
30
+ else:
31
+ return None
32
+
33
+ return value
34
+
35
+ def get(self, key: str, default=None):
36
+ return self._credentials.get(key, default)
37
+
38
+
39
+ def decrypt_credentials(env: str, secrets_dir: str = "secrets") -> CredentialsContainer:
40
+ """ Decrypt credentials """
41
+ from yaml import safe_load
42
+
43
+ # Check if the key exists
44
+ encrypted_path = secret_path(secrets_dir, env, "yml.enc")
45
+
46
+ try:
47
+ key_path = resolve_read_key_path(env, secrets_dir)
48
+ except FileNotFoundError:
49
+ print(f"Key for {env} does not exist!")
50
+ return CredentialsContainer({})
51
+
52
+ # Read encrypted data and key
53
+ key = read_key(key_path)
54
+
55
+ if path.exists(encrypted_path):
56
+ with open(encrypted_path, "r") as f:
57
+ data = f.read()
58
+ else:
59
+ print(f"Encrypted data for {env} does not exist!")
60
+ return CredentialsContainer({})
61
+
62
+ credentials = safe_load(decrypt_credentials_data(key, data))
63
+ return CredentialsContainer(credentials)
@@ -0,0 +1,194 @@
1
+ import base64
2
+ import json
3
+ from typing import Tuple
4
+
5
+ KEY_FORMAT_VERSION = 2
6
+ MASTER_KEY_ROLE = "master"
7
+ READONLY_KEY_ROLE = "readonly"
8
+
9
+
10
+ def _base64_encode(data: bytes) -> str:
11
+ return base64.urlsafe_b64encode(data).decode("utf-8")
12
+
13
+
14
+ def _base64_decode(data: str) -> bytes:
15
+ return base64.urlsafe_b64decode(data.encode("utf-8"))
16
+
17
+
18
+ def _generate_signing_key_pair():
19
+ from cryptography.hazmat.primitives import serialization
20
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
21
+
22
+ private_key = Ed25519PrivateKey.generate()
23
+ public_key = private_key.public_key()
24
+
25
+ private_bytes = private_key.private_bytes(
26
+ encoding=serialization.Encoding.Raw,
27
+ format=serialization.PrivateFormat.Raw,
28
+ encryption_algorithm=serialization.NoEncryption(),
29
+ )
30
+ public_bytes = public_key.public_bytes(
31
+ encoding=serialization.Encoding.Raw,
32
+ format=serialization.PublicFormat.Raw,
33
+ )
34
+
35
+ return _base64_encode(private_bytes), _base64_encode(public_bytes)
36
+
37
+
38
+ def _sign_data(signing_key: str, data: str) -> str:
39
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
40
+
41
+ private_key = Ed25519PrivateKey.from_private_bytes(_base64_decode(signing_key))
42
+ return _base64_encode(private_key.sign(data.encode("utf-8")))
43
+
44
+
45
+ def _verify_signature(verification_key: str, data: str, signature: str) -> None:
46
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
47
+
48
+ public_key = Ed25519PublicKey.from_public_bytes(_base64_decode(verification_key))
49
+ public_key.verify(_base64_decode(signature), data.encode("utf-8"))
50
+
51
+
52
+ def serialize_key(key_data: dict) -> str:
53
+ return json.dumps(key_data, sort_keys=True)
54
+
55
+
56
+ def parse_key(key: str) -> dict:
57
+ stripped_key = key.strip()
58
+
59
+ try:
60
+ key_data = json.loads(stripped_key)
61
+ except json.JSONDecodeError:
62
+ raise ValueError("Unsupported credentials key format")
63
+
64
+ if not isinstance(key_data, dict):
65
+ raise ValueError("Unsupported credentials key format")
66
+
67
+ if key_data.get("version") != KEY_FORMAT_VERSION:
68
+ raise ValueError("Unsupported credentials key format")
69
+
70
+ role = key_data.get("role")
71
+ if role not in {MASTER_KEY_ROLE, READONLY_KEY_ROLE}:
72
+ raise ValueError("Unsupported credentials key role")
73
+
74
+ if not key_data.get("encryption_key"):
75
+ raise ValueError("Credentials key is missing encryption_key")
76
+
77
+ if not key_data.get("verification_key"):
78
+ raise ValueError("Credentials key is missing verification_key")
79
+
80
+ if role == MASTER_KEY_ROLE and not key_data.get("signing_key"):
81
+ raise ValueError("Master credentials key is missing signing_key")
82
+
83
+ return key_data
84
+
85
+
86
+ def is_master_key(key: str) -> bool:
87
+ return parse_key(key)["role"] == MASTER_KEY_ROLE
88
+
89
+
90
+ def is_readonly_key(key: str) -> bool:
91
+ return parse_key(key)["role"] == READONLY_KEY_ROLE
92
+
93
+
94
+ def generate_credentials_key_pair() -> Tuple[str, str]:
95
+ encryption_key = generate_encryption_key()
96
+ signing_key, verification_key = _generate_signing_key_pair()
97
+
98
+ master_key = serialize_key(
99
+ {
100
+ "version": KEY_FORMAT_VERSION,
101
+ "role": MASTER_KEY_ROLE,
102
+ "encryption_key": encryption_key,
103
+ "signing_key": signing_key,
104
+ "verification_key": verification_key,
105
+ }
106
+ )
107
+ readonly_key = serialize_key(
108
+ {
109
+ "version": KEY_FORMAT_VERSION,
110
+ "role": READONLY_KEY_ROLE,
111
+ "encryption_key": encryption_key,
112
+ "verification_key": verification_key,
113
+ }
114
+ )
115
+
116
+ return master_key, readonly_key
117
+
118
+
119
+ def derive_readonly_key(master_key: str) -> str:
120
+ key_data = parse_key(master_key)
121
+ if key_data["role"] != MASTER_KEY_ROLE:
122
+ raise PermissionError("Readonly key can only be derived from a master key")
123
+
124
+ return serialize_key(
125
+ {
126
+ "version": KEY_FORMAT_VERSION,
127
+ "role": READONLY_KEY_ROLE,
128
+ "encryption_key": key_data["encryption_key"],
129
+ "verification_key": key_data["verification_key"],
130
+ }
131
+ )
132
+
133
+
134
+ def encrypt_credentials_data(key: str, data: str) -> str:
135
+ key_data = parse_key(key)
136
+
137
+ if key_data["role"] != MASTER_KEY_ROLE:
138
+ raise PermissionError("A master key is required to encrypt credentials")
139
+
140
+ encrypted_data = encrypt_data(key_data["encryption_key"], data)
141
+
142
+ return json.dumps(
143
+ {
144
+ "version": KEY_FORMAT_VERSION,
145
+ "payload": encrypted_data,
146
+ "signature": _sign_data(key_data["signing_key"], encrypted_data),
147
+ },
148
+ sort_keys=True,
149
+ )
150
+
151
+
152
+ def decrypt_credentials_data(key: str, data: str) -> str:
153
+ key_data = parse_key(key)
154
+ stripped_data = data.strip()
155
+
156
+ try:
157
+ envelope = json.loads(stripped_data)
158
+ except json.JSONDecodeError:
159
+ raise ValueError("Encrypted credentials must use a signed envelope")
160
+
161
+ if envelope.get("version") != KEY_FORMAT_VERSION:
162
+ raise ValueError("Unsupported encrypted credentials format")
163
+
164
+ payload = envelope.get("payload")
165
+ signature = envelope.get("signature")
166
+ if not payload or not signature:
167
+ raise ValueError("Encrypted credentials are missing payload or signature")
168
+
169
+ _verify_signature(key_data["verification_key"], payload, signature)
170
+ return decrypt_data(key_data["encryption_key"], payload)
171
+
172
+
173
+ def generate_encryption_key() -> str:
174
+ """ Generate random key for encryption """
175
+ from cryptography.fernet import Fernet
176
+
177
+ key = Fernet.generate_key()
178
+ return key.decode("utf-8")
179
+
180
+
181
+ def encrypt_data(key: str, data: str) -> str:
182
+ """ Encrypt data using key """
183
+ from cryptography.fernet import Fernet
184
+
185
+ f = Fernet(key)
186
+ return f.encrypt(data.encode("utf-8")).decode("utf-8")
187
+
188
+
189
+ def decrypt_data(key: str, data: str) -> str:
190
+ """ Decrypt data using key """
191
+ from cryptography.fernet import Fernet
192
+
193
+ f = Fernet(key)
194
+ return f.decrypt(data.encode("utf-8")).decode("utf-8")
@@ -0,0 +1,227 @@
1
+ Metadata-Version: 2.4
2
+ Name: secure-credentials-kit
3
+ Version: 0.1.0
4
+ Summary: A secure encrypted credentials system for Django and FastAPI, inspired by Rails credentials
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/lexpank/django-secure-credentials-kit
7
+ Project-URL: Issues, https://github.com/lexpank/django-secure-credentials-kit/issues
8
+ Keywords: django,fastapi,credentials,encryption,security
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Framework :: Django
18
+ Classifier: Framework :: FastAPI
19
+ Classifier: Topic :: Security
20
+ Requires-Python: <3.15,>=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: cryptography>=41.0.0
24
+ Requires-Dist: pyyaml>=6.0
25
+ Provides-Extra: django
26
+ Requires-Dist: Django<6.1,>=5.2; extra == "django"
27
+ Provides-Extra: fastapi
28
+ Requires-Dist: fastapi>=0.100.0; extra == "fastapi"
29
+ Dynamic: license-file
30
+
31
+ # Secure Credentials Kit
32
+
33
+ A secure, encrypted credentials system for Django and FastAPI, inspired by Rails credentials.
34
+
35
+ ## Features
36
+ - Environment-specific encrypted credentials
37
+ - Framework-neutral CLI for generating and editing encrypted credentials
38
+ - Master keys for editing credentials and read-only keys for application runtime access
39
+ - Django management commands
40
+ - FastAPI helpers for loading credentials into application state
41
+
42
+ ## Installation
43
+
44
+ The PyPI distribution, Python package, and CLI are all named for Secure
45
+ Credentials Kit:
46
+
47
+ - Distribution: `secure-credentials-kit`
48
+ - Python package: `secure_credentials_kit`
49
+ - CLI: `secure-credentials-kit`
50
+
51
+ Supported versions:
52
+
53
+ - Python 3.10, 3.11, 3.12, 3.13, and 3.14
54
+ - Django 5.2 LTS and Django 6.0
55
+
56
+ For Django:
57
+
58
+ ```sh
59
+ pip install "secure-credentials-kit[django]"
60
+ ```
61
+
62
+ For FastAPI:
63
+
64
+ ```sh
65
+ pip install "secure-credentials-kit[fastapi]"
66
+ ```
67
+
68
+ ## Local Development
69
+
70
+ This project uses `pyproject.toml` for package metadata and uv for local
71
+ dependency management.
72
+
73
+ Install uv, then create a development environment:
74
+
75
+ ```sh
76
+ uv sync
77
+ ```
78
+
79
+ Install framework extras when you need to test integrations:
80
+
81
+ ```sh
82
+ uv sync --extra django
83
+ uv sync --extra fastapi
84
+ ```
85
+
86
+ Run tests:
87
+
88
+ ```sh
89
+ uv run python -m unittest discover -v
90
+ ```
91
+
92
+ Build the package:
93
+
94
+ ```sh
95
+ uv run python -m build
96
+ ```
97
+
98
+ ## Credentials Files
99
+
100
+ Add secret keys to `.gitignore`:
101
+
102
+ ```sh
103
+ echo "secrets/*.key" >> .gitignore
104
+ ```
105
+
106
+ Generate a new key:
107
+
108
+ ```sh
109
+ secure-credentials-kit generate-key <environment>
110
+ ```
111
+
112
+ This creates two keys:
113
+
114
+ - `secrets/<environment>.master.key` can decrypt, edit, encrypt, and sign credentials.
115
+ - `secrets/<environment>.readonly.key` can decrypt and verify credentials, but cannot produce accepted credential updates.
116
+
117
+ You can regenerate a read-only key from an existing master key:
118
+
119
+ ```sh
120
+ secure-credentials-kit generate-key <environment> --role readonly
121
+ ```
122
+
123
+ Edit encrypted credentials:
124
+
125
+ ```sh
126
+ secure-credentials-kit edit <environment>
127
+ ```
128
+
129
+ Editing requires `secrets/<environment>.master.key`. Applications should normally
130
+ run with only `secrets/<environment>.readonly.key`.
131
+
132
+ The editor opens the decrypted YAML. The YAML root must be a mapping:
133
+
134
+ ```yaml
135
+ SOME_ENV_VAR: secret-value
136
+ database:
137
+ url: postgres://user:password@localhost:5432/app
138
+ api:
139
+ token: token-value
140
+ ```
141
+
142
+ Credentials are stored in `secrets/<environment>.yml.enc`, and keys are stored in
143
+ `secrets/<environment>.master.key` and `secrets/<environment>.readonly.key`.
144
+ The encrypted file is generated by the tool and should not be edited by hand. It
145
+ contains a signed encrypted payload similar to:
146
+
147
+ ```json
148
+ {
149
+ "version": 2,
150
+ "payload": "gAAAAAB...",
151
+ "signature": "..."
152
+ }
153
+ ```
154
+
155
+ ## Django Usage
156
+
157
+ Add `secure_credentials_kit` to your `INSTALLED_APPS` in `settings.py`:
158
+
159
+ ```python
160
+ INSTALLED_APPS = [
161
+ ...
162
+ 'secure_credentials_kit',
163
+ ...
164
+ ]
165
+ ```
166
+
167
+ You can also use Django management commands:
168
+
169
+ ```sh
170
+ python manage.py credentials_generate_key <environment>
171
+ ```
172
+
173
+ ```sh
174
+ python manage.py credentials_generate_key <environment> --role readonly
175
+ ```
176
+
177
+ ```sh
178
+ python manage.py credentials_edit <environment>
179
+ ```
180
+
181
+ To load the credentials in your Django app:
182
+
183
+ ```python
184
+ from secure_credentials_kit.secrets_loader import decrypt_credentials
185
+ credentials = decrypt_credentials("environment")
186
+ ```
187
+
188
+ Where `credentials` is an instance of class `CredentialsContainer` containing the decrypted credentials.
189
+
190
+ ## FastAPI Usage
191
+
192
+ Load credentials into FastAPI application state:
193
+
194
+ ```python
195
+ from fastapi import Depends, FastAPI
196
+ from secure_credentials_kit.fastapi import (
197
+ credentials_dependency,
198
+ setup_secure_credentials_kit,
199
+ )
200
+
201
+ app = FastAPI()
202
+ setup_secure_credentials_kit(app, "production")
203
+
204
+
205
+ @app.get("/settings")
206
+ def settings(credentials=Depends(credentials_dependency())):
207
+ return {"api_host": credentials.get("api_host")}
208
+ ```
209
+
210
+ If no environment is passed to `setup_secure_credentials_kit`, the helper checks
211
+ `SECURE_CREDENTIALS_KIT_ENV`, `FASTAPI_ENV`, `ENV`, then falls back to `development`.
212
+
213
+ ## Accessing Credentials
214
+
215
+ To access a credential:
216
+
217
+ ```python
218
+ credentials.get('key')
219
+ ```
220
+
221
+ or
222
+
223
+ ```python
224
+ credentials.dig('key', 'subkey')
225
+ ```
226
+
227
+ for complex nested credentials.
@@ -0,0 +1,16 @@
1
+ secure_credentials_kit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ secure_credentials_kit/cli.py,sha256=TgB0fqfkYyY92OwB4s2UrNPuZCDvTuvmhIna54Ie8wM,3490
3
+ secure_credentials_kit/credentials.py,sha256=_jcoJjSwP_opGzndAKo6g8cLF1JSFoqwKvkfc3qib-I,4090
4
+ secure_credentials_kit/fastapi.py,sha256=ikLC-kZefR1DXu3yImFKquxiq5z-Ne1QPFn1EReC3Ns,1493
5
+ secure_credentials_kit/secrets_loader.py,sha256=undYS0BAIpZX53W5DjeYIkSCVb4qS5jGGxzF-m2QSSg,1918
6
+ secure_credentials_kit/utils.py,sha256=EWGfzKD3yD9Vmc0F8QINbjpHEZAfgH7bVXA9bCRoIzE,5965
7
+ secure_credentials_kit/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ secure_credentials_kit/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ secure_credentials_kit/management/commands/credentials_edit.py,sha256=slLi6QnAcMtCiKx7RZj-7MwSGI1G6XkzQJN7-IeRxJk,640
10
+ secure_credentials_kit/management/commands/credentials_generate_key.py,sha256=v8NlrHfMwy5qGjoEUiRcAZcp1RSY5g2Nu5C7xB2yupY,980
11
+ secure_credentials_kit-0.1.0.dist-info/licenses/LICENSE,sha256=OXjMtetlx854vD4bIH1r1HKvKhzRNnMPPtmtBT8tU_0,1066
12
+ secure_credentials_kit-0.1.0.dist-info/METADATA,sha256=2itV1sNApjuRecDQRHbeavBV5NhilxM4A6mrbE4wDqc,5377
13
+ secure_credentials_kit-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
14
+ secure_credentials_kit-0.1.0.dist-info/entry_points.txt,sha256=dmrkBiN5nVMXaopffcznlydYMGApiNiJeLwsaXcnjyQ,225
15
+ secure_credentials_kit-0.1.0.dist-info/top_level.txt,sha256=bBDDI3bfOffsyqpy5H942mjU8FIMcFbqCCurwhETHz8,28
16
+ secure_credentials_kit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ secure-credentials-kit = secure_credentials_kit.cli:main
3
+ secure-credentials-kit-edit = secure_credentials_kit.cli:edit_main
4
+ secure-credentials-kit-generate-key = secure_credentials_kit.cli:generate_key_main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander
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,2 @@
1
+ dist
2
+ secure_credentials_kit