hyperscale-crypto 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.
- hyperscale/crypto/__init__.py +3 -0
- hyperscale/crypto/apps.py +42 -0
- hyperscale/crypto/canary.py +125 -0
- hyperscale/crypto/conf.py +103 -0
- hyperscale/crypto/encoding.py +26 -0
- hyperscale/crypto/exceptions.py +10 -0
- hyperscale/crypto/fields.py +558 -0
- hyperscale/crypto/keyset_files.py +147 -0
- hyperscale/crypto/keysets.py +291 -0
- hyperscale/crypto/management/__init__.py +0 -0
- hyperscale/crypto/management/base.py +35 -0
- hyperscale/crypto/management/commands/__init__.py +0 -0
- hyperscale/crypto/management/commands/keyset_add_key.py +34 -0
- hyperscale/crypto/management/commands/keyset_destroy.py +26 -0
- hyperscale/crypto/management/commands/keyset_init.py +37 -0
- hyperscale/crypto/management/commands/keyset_reencrypt.py +46 -0
- hyperscale/crypto/management/commands/keyset_retire.py +28 -0
- hyperscale/crypto/management/commands/keyset_rotate.py +92 -0
- hyperscale/crypto/management/commands/keyset_status.py +55 -0
- hyperscale/crypto/migrations/0001_initial.py +72 -0
- hyperscale/crypto/migrations/__init__.py +0 -0
- hyperscale/crypto/models.py +57 -0
- hyperscale/crypto/py.typed +0 -0
- hyperscale/crypto/registry.py +30 -0
- hyperscale/crypto/rotation.py +409 -0
- hyperscale/crypto/state.py +36 -0
- hyperscale/crypto/tasks.py +33 -0
- hyperscale/crypto/testing.py +35 -0
- hyperscale_crypto-0.1.0.dist-info/METADATA +390 -0
- hyperscale_crypto-0.1.0.dist-info/RECORD +32 -0
- hyperscale_crypto-0.1.0.dist-info/WHEEL +4 -0
- hyperscale_crypto-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from django.apps import AppConfig
|
|
6
|
+
|
|
7
|
+
_SKIP_COMMANDS = {
|
|
8
|
+
"makemigrations",
|
|
9
|
+
"migrate",
|
|
10
|
+
"collectstatic",
|
|
11
|
+
"check",
|
|
12
|
+
"showmigrations",
|
|
13
|
+
"sqlmigrate",
|
|
14
|
+
"keyset_init",
|
|
15
|
+
"keyset_status",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HyperscaleCryptoConfig(AppConfig):
|
|
20
|
+
name = "hyperscale.crypto"
|
|
21
|
+
label = "hyperscale_crypto"
|
|
22
|
+
verbose_name = "Hyperscale crypto"
|
|
23
|
+
|
|
24
|
+
def ready(self) -> None:
|
|
25
|
+
from django.db import connection
|
|
26
|
+
|
|
27
|
+
from hyperscale.crypto import canary
|
|
28
|
+
from hyperscale.crypto import keysets
|
|
29
|
+
from hyperscale.crypto.conf import load_config
|
|
30
|
+
|
|
31
|
+
load_config() # loud defaults: fail at setup, not first use
|
|
32
|
+
if _SKIP_COMMANDS & set(sys.argv) or "pytest" in sys.argv[0]:
|
|
33
|
+
return
|
|
34
|
+
try:
|
|
35
|
+
if canary.table_ready():
|
|
36
|
+
# No timer here: under a pre-fork server this is the parent
|
|
37
|
+
# process, and each worker arms its own on first use.
|
|
38
|
+
with keysets.without_timer():
|
|
39
|
+
canary.check_or_write()
|
|
40
|
+
finally:
|
|
41
|
+
# Nor a database connection carried across a fork.
|
|
42
|
+
connection.close()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
4
|
+
from django.db import connection
|
|
5
|
+
from django.db import DatabaseError
|
|
6
|
+
|
|
7
|
+
from hyperscale.crypto import keysets
|
|
8
|
+
from hyperscale.crypto.encoding import decode
|
|
9
|
+
from hyperscale.crypto.encoding import encode
|
|
10
|
+
from hyperscale.crypto.exceptions import KeysetError
|
|
11
|
+
from hyperscale.crypto.keyset_files import KEYSET_NAMES
|
|
12
|
+
from hyperscale.crypto.models import KeysetCanary
|
|
13
|
+
from hyperscale.crypto.models import KeysetState
|
|
14
|
+
|
|
15
|
+
CANARY_PLAINTEXT = b"hyperscale.crypto canary"
|
|
16
|
+
_AD = b"hyperscale_crypto.keysetcanary"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def table_ready() -> bool:
|
|
20
|
+
try:
|
|
21
|
+
return KeysetCanary._meta.db_table in connection.introspection.table_names()
|
|
22
|
+
except DatabaseError:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load(ks: keysets.Keysets) -> None:
|
|
27
|
+
"""Load (unwrap) both keysets; an error names the keyset that failed."""
|
|
28
|
+
try:
|
|
29
|
+
for name in KEYSET_NAMES:
|
|
30
|
+
ks.proto(name)
|
|
31
|
+
except KeysetError as exc:
|
|
32
|
+
raise ImproperlyConfigured(str(exc)) from exc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def new_values(ks: keysets.Keysets) -> dict[str, str]:
|
|
36
|
+
"""Fresh canary values under the aead primary and the active daead key."""
|
|
37
|
+
try:
|
|
38
|
+
aead_value = encode(ks.aead().encrypt(CANARY_PLAINTEXT, _AD))
|
|
39
|
+
except Exception as exc: # KeysetError, tink.TinkError
|
|
40
|
+
raise ImproperlyConfigured(
|
|
41
|
+
f"the aead keyset cannot write the canary: {exc}"
|
|
42
|
+
) from exc
|
|
43
|
+
active = KeysetState.load().daead_active_key_id
|
|
44
|
+
try:
|
|
45
|
+
daead_value = encode(
|
|
46
|
+
ks.daead(active).encrypt_deterministically(CANARY_PLAINTEXT, _AD)
|
|
47
|
+
)
|
|
48
|
+
except Exception as exc: # KeysetError, tink.TinkError
|
|
49
|
+
raise ImproperlyConfigured(
|
|
50
|
+
f"the daead keyset cannot write the canary under the active key "
|
|
51
|
+
f"{active}: {exc}"
|
|
52
|
+
) from exc
|
|
53
|
+
return {"aead_value": aead_value, "daead_value": daead_value}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_or_write() -> None:
|
|
57
|
+
ks = keysets.get()
|
|
58
|
+
_load(ks)
|
|
59
|
+
row = KeysetCanary.objects.filter(pk=1).first()
|
|
60
|
+
if row is None:
|
|
61
|
+
row, created = KeysetCanary.objects.get_or_create(pk=1, defaults=new_values(ks))
|
|
62
|
+
if created:
|
|
63
|
+
return
|
|
64
|
+
# Another process wrote it first: verify theirs like any other start.
|
|
65
|
+
_verify(ks, row)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _verify(ks: keysets.Keysets, row: KeysetCanary) -> None:
|
|
69
|
+
try:
|
|
70
|
+
aead_plain = ks.aead().decrypt(decode(row.aead_value), _AD)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
raise ImproperlyConfigured(
|
|
73
|
+
f"the aead keyset cannot decrypt the stored canary: {exc}"
|
|
74
|
+
) from exc
|
|
75
|
+
if aead_plain != CANARY_PLAINTEXT:
|
|
76
|
+
raise ImproperlyConfigured(
|
|
77
|
+
"the aead keyset decrypted the stored canary to the wrong plaintext"
|
|
78
|
+
)
|
|
79
|
+
active = KeysetState.load().daead_active_key_id
|
|
80
|
+
try:
|
|
81
|
+
daead_plain = ks.daead(active).decrypt_deterministically(
|
|
82
|
+
decode(row.daead_value), _AD
|
|
83
|
+
)
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
raise ImproperlyConfigured(
|
|
86
|
+
f"the daead keyset cannot decrypt the stored canary: {exc}"
|
|
87
|
+
) from exc
|
|
88
|
+
if daead_plain != CANARY_PLAINTEXT:
|
|
89
|
+
raise ImproperlyConfigured(
|
|
90
|
+
"the daead keyset decrypted the stored canary to the wrong plaintext"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def reencrypt_aead() -> bool:
|
|
95
|
+
"""Move the stored aead canary to the current primary; False if nothing moved.
|
|
96
|
+
|
|
97
|
+
Rotation calls this so retiring the old key leaves the canary readable.
|
|
98
|
+
"""
|
|
99
|
+
ks = keysets.get()
|
|
100
|
+
row = KeysetCanary.objects.select_for_update().filter(pk=1).first()
|
|
101
|
+
if row is None:
|
|
102
|
+
return False
|
|
103
|
+
raw = decode(row.aead_value)
|
|
104
|
+
if keysets.key_id_of(raw) == ks.primary_key_id("aead"):
|
|
105
|
+
return False
|
|
106
|
+
plain = ks.aead().decrypt(raw, _AD)
|
|
107
|
+
row.aead_value = encode(ks.aead().encrypt(plain, _AD))
|
|
108
|
+
row.save(update_fields=["aead_value"])
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def reencrypt_daead(key_id: int) -> bool:
|
|
113
|
+
"""Move the stored daead canary to `key_id`; False if nothing moved."""
|
|
114
|
+
ks = keysets.get()
|
|
115
|
+
row = KeysetCanary.objects.select_for_update().filter(pk=1).first()
|
|
116
|
+
if row is None:
|
|
117
|
+
return False
|
|
118
|
+
raw = decode(row.daead_value)
|
|
119
|
+
if keysets.key_id_of(raw) == key_id:
|
|
120
|
+
return False
|
|
121
|
+
target = ks.daead(key_id) # decrypts under any enabled key
|
|
122
|
+
plain = target.decrypt_deterministically(raw, _AD)
|
|
123
|
+
row.daead_value = encode(target.encrypt_deterministically(plain, _AD))
|
|
124
|
+
row.save(update_fields=["daead_value"])
|
|
125
|
+
return True
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from django.conf import settings
|
|
10
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
11
|
+
|
|
12
|
+
MODES = ("kms", "cleartext")
|
|
13
|
+
_KMS_URI_SCHEMES = ("aws-kms://", "fake-kms://")
|
|
14
|
+
_KNOWN = {
|
|
15
|
+
"mode",
|
|
16
|
+
"kms_key_uri",
|
|
17
|
+
"keyset_dir",
|
|
18
|
+
"reunwrap_seconds",
|
|
19
|
+
"revoke_on_reunwrap_failure",
|
|
20
|
+
"retention_days",
|
|
21
|
+
"git_sha",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class CryptoConfig:
|
|
27
|
+
mode: str
|
|
28
|
+
kms_key_uri: str
|
|
29
|
+
keyset_dir: Path
|
|
30
|
+
reunwrap_seconds: int
|
|
31
|
+
revoke_on_reunwrap_failure: bool
|
|
32
|
+
retention_days: int
|
|
33
|
+
git_sha: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def environment_allows_cleartext() -> bool:
|
|
37
|
+
return os.environ.get("ENV", "") in ("development", "test")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_fake_kms(uri: str) -> bool:
|
|
41
|
+
return uri.startswith("fake-kms://")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _positive_int(cfg: dict[str, Any], key: str, default: int) -> int:
|
|
45
|
+
value = cfg.get(key, default)
|
|
46
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
47
|
+
raise ImproperlyConfigured(
|
|
48
|
+
f"HYPERSCALE_CRYPTO['{key}'] must be a positive integer, not {value!r}"
|
|
49
|
+
)
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_config() -> CryptoConfig:
|
|
54
|
+
raw: Any = getattr(settings, "HYPERSCALE_CRYPTO", None)
|
|
55
|
+
if not isinstance(raw, dict):
|
|
56
|
+
raise ImproperlyConfigured("HYPERSCALE_CRYPTO must be set to a dict")
|
|
57
|
+
cfg = cast("dict[str, Any]", raw)
|
|
58
|
+
unknown = set(cfg) - _KNOWN
|
|
59
|
+
if unknown:
|
|
60
|
+
raise ImproperlyConfigured(
|
|
61
|
+
f"HYPERSCALE_CRYPTO has unknown key(s): {', '.join(sorted(unknown))}"
|
|
62
|
+
)
|
|
63
|
+
mode = cfg.get("mode", "kms")
|
|
64
|
+
if mode not in MODES:
|
|
65
|
+
raise ImproperlyConfigured(f"HYPERSCALE_CRYPTO['mode'] must be one of {MODES}")
|
|
66
|
+
key_uri = str(cfg.get("kms_key_uri", "") or "")
|
|
67
|
+
reunwrap = _positive_int(cfg, "reunwrap_seconds", 900)
|
|
68
|
+
retention = _positive_int(cfg, "retention_days", 35)
|
|
69
|
+
revoke = cfg.get("revoke_on_reunwrap_failure", False)
|
|
70
|
+
if not isinstance(revoke, bool):
|
|
71
|
+
raise ImproperlyConfigured(
|
|
72
|
+
"HYPERSCALE_CRYPTO['revoke_on_reunwrap_failure'] must be True or "
|
|
73
|
+
f"False, not {revoke!r}"
|
|
74
|
+
)
|
|
75
|
+
if "keyset_dir" not in cfg:
|
|
76
|
+
raise ImproperlyConfigured("HYPERSCALE_CRYPTO['keyset_dir'] is required")
|
|
77
|
+
if mode == "kms" and not key_uri:
|
|
78
|
+
raise ImproperlyConfigured(
|
|
79
|
+
"HYPERSCALE_CRYPTO['kms_key_uri'] is required in kms mode"
|
|
80
|
+
)
|
|
81
|
+
if mode == "kms" and not key_uri.startswith(_KMS_URI_SCHEMES):
|
|
82
|
+
raise ImproperlyConfigured(
|
|
83
|
+
"HYPERSCALE_CRYPTO['kms_key_uri'] must start with "
|
|
84
|
+
f"{' or '.join(_KMS_URI_SCHEMES)}"
|
|
85
|
+
)
|
|
86
|
+
if mode == "cleartext" and not environment_allows_cleartext():
|
|
87
|
+
raise ImproperlyConfigured(
|
|
88
|
+
"HYPERSCALE_CRYPTO mode 'cleartext' is only allowed when ENV is "
|
|
89
|
+
"development or test"
|
|
90
|
+
)
|
|
91
|
+
if is_fake_kms(key_uri) and not environment_allows_cleartext():
|
|
92
|
+
raise ImproperlyConfigured(
|
|
93
|
+
"a fake-kms:// key URI is only allowed when ENV is development or test"
|
|
94
|
+
)
|
|
95
|
+
return CryptoConfig(
|
|
96
|
+
mode=mode,
|
|
97
|
+
kms_key_uri=key_uri,
|
|
98
|
+
keyset_dir=Path(cfg["keyset_dir"]),
|
|
99
|
+
reunwrap_seconds=reunwrap,
|
|
100
|
+
revoke_on_reunwrap_failure=revoke,
|
|
101
|
+
retention_days=retention,
|
|
102
|
+
git_sha=str(cfg.get("git_sha", os.environ.get("GIT_SHA", ""))),
|
|
103
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The `hc1:` storage encoding of a raw Tink ciphertext."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
|
|
7
|
+
from hyperscale.crypto.exceptions import DecryptionError
|
|
8
|
+
|
|
9
|
+
PREFIX = "hc1:"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def encode(raw: bytes) -> str:
|
|
13
|
+
return PREFIX + base64.urlsafe_b64encode(raw).decode("ascii")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_encoded(value: object) -> bool:
|
|
17
|
+
return isinstance(value, str) and value.startswith(PREFIX)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def decode(text: str) -> bytes:
|
|
21
|
+
if not is_encoded(text):
|
|
22
|
+
raise DecryptionError("stored value is not an hc1 ciphertext")
|
|
23
|
+
try:
|
|
24
|
+
return base64.urlsafe_b64decode(text[len(PREFIX) :])
|
|
25
|
+
except ValueError as exc:
|
|
26
|
+
raise DecryptionError("stored ciphertext is not valid base64") from exc
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class CryptoError(Exception):
|
|
2
|
+
"""Base for every error this package raises at runtime."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DecryptionError(CryptoError):
|
|
6
|
+
"""A stored value could not be decrypted (wrong key, wrong context, corrupt)."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class KeysetError(CryptoError):
|
|
10
|
+
"""A keyset file or keyset state is unusable."""
|