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.
- secure_credentials_kit/__init__.py +0 -0
- secure_credentials_kit/cli.py +105 -0
- secure_credentials_kit/credentials.py +128 -0
- secure_credentials_kit/fastapi.py +53 -0
- secure_credentials_kit/management/__init__.py +0 -0
- secure_credentials_kit/management/commands/__init__.py +0 -0
- secure_credentials_kit/management/commands/credentials_edit.py +20 -0
- secure_credentials_kit/management/commands/credentials_generate_key.py +29 -0
- secure_credentials_kit/secrets_loader.py +63 -0
- secure_credentials_kit/utils.py +194 -0
- secure_credentials_kit-0.1.0.dist-info/METADATA +227 -0
- secure_credentials_kit-0.1.0.dist-info/RECORD +16 -0
- secure_credentials_kit-0.1.0.dist-info/WHEEL +5 -0
- secure_credentials_kit-0.1.0.dist-info/entry_points.txt +4 -0
- secure_credentials_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
- secure_credentials_kit-0.1.0.dist-info/top_level.txt +2 -0
|
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,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.
|