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.
@@ -0,0 +1,3 @@
1
+ """Application-tier encrypted Django fields backed by Tink keysets and KMS."""
2
+
3
+ __version__ = "0.1.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."""