secure-credentials-kit 0.1.0__tar.gz → 0.2.0__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.
Files changed (26) hide show
  1. {secure_credentials_kit-0.1.0/secure_credentials_kit.egg-info → secure_credentials_kit-0.2.0}/PKG-INFO +11 -5
  2. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/README.md +10 -4
  3. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/pyproject.toml +1 -1
  4. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/secure_credentials_kit/cli.py +7 -3
  5. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/secure_credentials_kit/management/commands/credentials_generate_key.py +1 -1
  6. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/secure_credentials_kit/utils.py +21 -4
  7. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0/secure_credentials_kit.egg-info}/PKG-INFO +11 -5
  8. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/secure_credentials_kit.egg-info/top_level.txt +1 -0
  9. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/tests/test_key_roles.py +53 -0
  10. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/tests/test_packaging.py +2 -2
  11. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/LICENSE +0 -0
  12. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/secure_credentials_kit/__init__.py +0 -0
  13. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/secure_credentials_kit/credentials.py +0 -0
  14. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/secure_credentials_kit/fastapi.py +0 -0
  15. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/secure_credentials_kit/management/__init__.py +0 -0
  16. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/secure_credentials_kit/management/commands/__init__.py +0 -0
  17. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/secure_credentials_kit/management/commands/credentials_edit.py +0 -0
  18. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/secure_credentials_kit/secrets_loader.py +0 -0
  19. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/secure_credentials_kit.egg-info/SOURCES.txt +0 -0
  20. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/secure_credentials_kit.egg-info/dependency_links.txt +0 -0
  21. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/secure_credentials_kit.egg-info/entry_points.txt +0 -0
  22. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/secure_credentials_kit.egg-info/requires.txt +0 -0
  23. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/setup.cfg +0 -0
  24. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/setup.py +0 -0
  25. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/tests/test_fastapi.py +0 -0
  26. {secure_credentials_kit-0.1.0 → secure_credentials_kit-0.2.0}/tests/test_secrets_loader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-credentials-kit
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A secure encrypted credentials system for Django and FastAPI, inspired by Rails credentials
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/lexpank/django-secure-credentials-kit
@@ -36,6 +36,7 @@ A secure, encrypted credentials system for Django and FastAPI, inspired by Rails
36
36
  - Environment-specific encrypted credentials
37
37
  - Framework-neutral CLI for generating and editing encrypted credentials
38
38
  - Master keys for editing credentials and read-only keys for application runtime access
39
+ - Signed encrypted credential files backed by an asymmetric signing/verification key pair
39
40
  - Django management commands
40
41
  - FastAPI helpers for loading credentials into application state
41
42
 
@@ -103,16 +104,21 @@ Add secret keys to `.gitignore`:
103
104
  echo "secrets/*.key" >> .gitignore
104
105
  ```
105
106
 
106
- Generate a new key:
107
+ Generate a new key pair:
107
108
 
108
109
  ```sh
109
110
  secure-credentials-kit generate-key <environment>
110
111
  ```
111
112
 
112
- This creates two keys:
113
+ This creates two role-specific keys:
113
114
 
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.
115
+ - `secrets/<environment>.master.key` can decrypt, edit, encrypt, and sign credentials with the private signing key.
116
+ - `secrets/<environment>.readonly.key` can decrypt and verify credentials with the public verification key, but cannot produce accepted credential updates.
117
+
118
+ Key files are stored as one-line base64url payloads. The decoded payload contains
119
+ the key material and format version; the package detects the key role
120
+ automatically from the key material, so there is no visible `master:` or
121
+ `readonly:` prefix in the file contents.
116
122
 
117
123
  You can regenerate a read-only key from an existing master key:
118
124
 
@@ -6,6 +6,7 @@ A secure, encrypted credentials system for Django and FastAPI, inspired by Rails
6
6
  - Environment-specific encrypted credentials
7
7
  - Framework-neutral CLI for generating and editing encrypted credentials
8
8
  - Master keys for editing credentials and read-only keys for application runtime access
9
+ - Signed encrypted credential files backed by an asymmetric signing/verification key pair
9
10
  - Django management commands
10
11
  - FastAPI helpers for loading credentials into application state
11
12
 
@@ -73,16 +74,21 @@ Add secret keys to `.gitignore`:
73
74
  echo "secrets/*.key" >> .gitignore
74
75
  ```
75
76
 
76
- Generate a new key:
77
+ Generate a new key pair:
77
78
 
78
79
  ```sh
79
80
  secure-credentials-kit generate-key <environment>
80
81
  ```
81
82
 
82
- This creates two keys:
83
+ This creates two role-specific keys:
83
84
 
84
- - `secrets/<environment>.master.key` can decrypt, edit, encrypt, and sign credentials.
85
- - `secrets/<environment>.readonly.key` can decrypt and verify credentials, but cannot produce accepted credential updates.
85
+ - `secrets/<environment>.master.key` can decrypt, edit, encrypt, and sign credentials with the private signing key.
86
+ - `secrets/<environment>.readonly.key` can decrypt and verify credentials with the public verification key, but cannot produce accepted credential updates.
87
+
88
+ Key files are stored as one-line base64url payloads. The decoded payload contains
89
+ the key material and format version; the package detects the key role
90
+ automatically from the key material, so there is no visible `master:` or
91
+ `readonly:` prefix in the file contents.
86
92
 
87
93
  You can regenerate a read-only key from an existing master key:
88
94
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "secure-credentials-kit"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "A secure encrypted credentials system for Django and FastAPI, inspired by Rails credentials"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10,<3.15"
@@ -10,7 +10,7 @@ def print_key_paths(env: str, key_paths: dict) -> None:
10
10
 
11
11
  def generate_key_main(argv=None) -> int:
12
12
  parser = argparse.ArgumentParser(
13
- description="Generate an encryption key for secure credentials."
13
+ description="Generate secure credentials keys for an environment."
14
14
  )
15
15
  parser.add_argument("env", help="Environment name")
16
16
  parser.add_argument("--secrets-dir", default="secrets", help="Credentials directory")
@@ -55,7 +55,7 @@ def main(argv=None) -> int:
55
55
 
56
56
  generate_parser = subparsers.add_parser(
57
57
  "generate-key",
58
- help="Generate an encryption key for an environment.",
58
+ help="Generate secure credentials keys for an environment.",
59
59
  )
60
60
  generate_parser.add_argument("env", help="Environment name")
61
61
  generate_parser.add_argument(
@@ -80,7 +80,11 @@ def main(argv=None) -> int:
80
80
  default="secrets",
81
81
  help="Credentials directory",
82
82
  )
83
- edit_parser.add_argument("--editor", help="Editor command to use")
83
+ edit_parser.add_argument(
84
+ "--editor",
85
+ default="nano",
86
+ help="Editor command to use"
87
+ )
84
88
 
85
89
  args = parser.parse_args(argv)
86
90
 
@@ -4,7 +4,7 @@ from secure_credentials_kit.credentials import generate_credentials_key
4
4
 
5
5
 
6
6
  class Command(BaseCommand):
7
- help = "Generate a new encryption key"
7
+ help = "Generate secure credentials keys"
8
8
 
9
9
  def add_arguments(self, parser):
10
10
  parser.add_argument("env", type=str, help="Environment name")
@@ -1,4 +1,5 @@
1
1
  import base64
2
+ import binascii
2
3
  import json
3
4
  from typing import Tuple
4
5
 
@@ -12,7 +13,8 @@ def _base64_encode(data: bytes) -> str:
12
13
 
13
14
 
14
15
  def _base64_decode(data: str) -> bytes:
15
- return base64.urlsafe_b64decode(data.encode("utf-8"))
16
+ padded_data = data + "=" * (-len(data) % 4)
17
+ return base64.urlsafe_b64decode(padded_data.encode("utf-8"))
16
18
 
17
19
 
18
20
  def _generate_signing_key_pair():
@@ -50,7 +52,11 @@ def _verify_signature(verification_key: str, data: str, signature: str) -> None:
50
52
 
51
53
 
52
54
  def serialize_key(key_data: dict) -> str:
53
- return json.dumps(key_data, sort_keys=True)
55
+ payload = key_data.copy()
56
+ payload.pop("role", None)
57
+ return _base64_encode(
58
+ json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
59
+ )
54
60
 
55
61
 
56
62
  def parse_key(key: str) -> dict:
@@ -59,7 +65,11 @@ def parse_key(key: str) -> dict:
59
65
  try:
60
66
  key_data = json.loads(stripped_key)
61
67
  except json.JSONDecodeError:
62
- raise ValueError("Unsupported credentials key format")
68
+ try:
69
+ decoded_key = _base64_decode(stripped_key).decode("utf-8")
70
+ key_data = json.loads(decoded_key)
71
+ except (binascii.Error, json.JSONDecodeError, UnicodeDecodeError):
72
+ raise ValueError("Unsupported credentials key format")
63
73
 
64
74
  if not isinstance(key_data, dict):
65
75
  raise ValueError("Unsupported credentials key format")
@@ -68,8 +78,13 @@ def parse_key(key: str) -> dict:
68
78
  raise ValueError("Unsupported credentials key format")
69
79
 
70
80
  role = key_data.get("role")
71
- if role not in {MASTER_KEY_ROLE, READONLY_KEY_ROLE}:
81
+ inferred_role = MASTER_KEY_ROLE if key_data.get("signing_key") else READONLY_KEY_ROLE
82
+ if role is None:
83
+ role = inferred_role
84
+ elif role not in {MASTER_KEY_ROLE, READONLY_KEY_ROLE}:
72
85
  raise ValueError("Unsupported credentials key role")
86
+ elif role != inferred_role:
87
+ raise ValueError("Credentials key role does not match key material")
73
88
 
74
89
  if not key_data.get("encryption_key"):
75
90
  raise ValueError("Credentials key is missing encryption_key")
@@ -80,6 +95,8 @@ def parse_key(key: str) -> dict:
80
95
  if role == MASTER_KEY_ROLE and not key_data.get("signing_key"):
81
96
  raise ValueError("Master credentials key is missing signing_key")
82
97
 
98
+ key_data = key_data.copy()
99
+ key_data["role"] = role
83
100
  return key_data
84
101
 
85
102
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-credentials-kit
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A secure encrypted credentials system for Django and FastAPI, inspired by Rails credentials
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/lexpank/django-secure-credentials-kit
@@ -36,6 +36,7 @@ A secure, encrypted credentials system for Django and FastAPI, inspired by Rails
36
36
  - Environment-specific encrypted credentials
37
37
  - Framework-neutral CLI for generating and editing encrypted credentials
38
38
  - Master keys for editing credentials and read-only keys for application runtime access
39
+ - Signed encrypted credential files backed by an asymmetric signing/verification key pair
39
40
  - Django management commands
40
41
  - FastAPI helpers for loading credentials into application state
41
42
 
@@ -103,16 +104,21 @@ Add secret keys to `.gitignore`:
103
104
  echo "secrets/*.key" >> .gitignore
104
105
  ```
105
106
 
106
- Generate a new key:
107
+ Generate a new key pair:
107
108
 
108
109
  ```sh
109
110
  secure-credentials-kit generate-key <environment>
110
111
  ```
111
112
 
112
- This creates two keys:
113
+ This creates two role-specific keys:
113
114
 
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.
115
+ - `secrets/<environment>.master.key` can decrypt, edit, encrypt, and sign credentials with the private signing key.
116
+ - `secrets/<environment>.readonly.key` can decrypt and verify credentials with the public verification key, but cannot produce accepted credential updates.
117
+
118
+ Key files are stored as one-line base64url payloads. The decoded payload contains
119
+ the key material and format version; the package detects the key role
120
+ automatically from the key material, so there is no visible `master:` or
121
+ `readonly:` prefix in the file contents.
116
122
 
117
123
  You can regenerate a read-only key from an existing master key:
118
124
 
@@ -1,3 +1,4 @@
1
+ import base64
1
2
  import json
2
3
  import tempfile
3
4
  import unittest
@@ -17,6 +18,7 @@ from secure_credentials_kit.utils import (
17
18
  encrypt_credentials_data,
18
19
  is_master_key,
19
20
  is_readonly_key,
21
+ parse_key,
20
22
  serialize_key,
21
23
  )
22
24
 
@@ -45,6 +47,29 @@ def make_readonly_key() -> str:
45
47
 
46
48
 
47
49
  class KeyRoleTests(unittest.TestCase):
50
+ def test_serialized_keys_are_base64_payloads_with_inferred_roles(self):
51
+ key = make_master_key()
52
+
53
+ with self.assertRaises(json.JSONDecodeError):
54
+ json.loads(key)
55
+
56
+ payload = json.loads(base64.urlsafe_b64decode(key).decode("utf-8"))
57
+ self.assertNotIn("role", payload)
58
+ self.assertEqual(payload["version"], KEY_FORMAT_VERSION)
59
+ self.assertEqual(parse_key(key)["role"], MASTER_KEY_ROLE)
60
+
61
+ def test_legacy_json_keys_are_still_supported(self):
62
+ legacy_key = json.dumps(
63
+ {
64
+ "version": KEY_FORMAT_VERSION,
65
+ "role": READONLY_KEY_ROLE,
66
+ "encryption_key": "encryption",
67
+ "verification_key": "verification",
68
+ }
69
+ )
70
+
71
+ self.assertTrue(is_readonly_key(legacy_key))
72
+
48
73
  def test_generated_key_roles_are_identified(self):
49
74
  self.assertTrue(is_master_key(make_master_key()))
50
75
  self.assertTrue(is_readonly_key(make_readonly_key()))
@@ -104,6 +129,27 @@ class KeyRoleTests(unittest.TestCase):
104
129
  self.assertEqual(master_path.read_text(), "master-key")
105
130
  self.assertEqual(readonly_path.read_text(), "readonly-key")
106
131
 
132
+ def test_generated_master_and_readonly_keys_roundtrip_credentials(self):
133
+ with tempfile.TemporaryDirectory() as tmpdir:
134
+ generated = generate_credentials_key("production", tmpdir)
135
+
136
+ master_key = Path(generated["master"]).read_text()
137
+ readonly_key = Path(generated["readonly"]).read_text()
138
+
139
+ self.assertTrue(is_master_key(master_key))
140
+ self.assertTrue(is_readonly_key(readonly_key))
141
+
142
+ encrypted = encrypt_credentials_data(master_key, "api_key: secret")
143
+
144
+ self.assertEqual(
145
+ decrypt_credentials_data(master_key, encrypted),
146
+ "api_key: secret",
147
+ )
148
+ self.assertEqual(
149
+ decrypt_credentials_data(readonly_key, encrypted),
150
+ "api_key: secret",
151
+ )
152
+
107
153
  def test_read_key_prefers_readonly_and_master_key_rejects_readonly_only(self):
108
154
  with tempfile.TemporaryDirectory() as tmpdir:
109
155
  readonly_path = Path(tmpdir) / "production.readonly.key"
@@ -113,6 +159,13 @@ class KeyRoleTests(unittest.TestCase):
113
159
  with self.assertRaises(PermissionError):
114
160
  resolve_master_key_path("production", tmpdir)
115
161
 
162
+ def test_read_key_uses_master_when_readonly_is_absent(self):
163
+ with tempfile.TemporaryDirectory() as tmpdir:
164
+ master_path = Path(tmpdir) / "production.master.key"
165
+ master_path.write_text(make_master_key())
166
+
167
+ self.assertEqual(resolve_read_key_path("production", tmpdir), str(master_path))
168
+
116
169
 
117
170
  if __name__ == "__main__":
118
171
  unittest.main()
@@ -13,11 +13,11 @@ class SetupMetadataTests(unittest.TestCase):
13
13
  with pyproject_path.open("rb") as f:
14
14
  return tomllib.load(f)
15
15
 
16
- def test_package_uses_secure_credentials_kit_name_and_initial_version(self):
16
+ def test_package_uses_secure_credentials_kit_name_and_current_version(self):
17
17
  metadata = self.load_project_metadata()["project"]
18
18
 
19
19
  self.assertEqual(metadata["name"], "secure-credentials-kit")
20
- self.assertEqual(metadata["version"], "0.1.0")
20
+ self.assertEqual(metadata["version"], "0.2.0")
21
21
  self.assertEqual(metadata["requires-python"], ">=3.10,<3.15")
22
22
 
23
23
  def test_django_and_fastapi_are_optional_extras(self):