gemstone-utils 0.4.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.
- gemstone_utils/__init__.py +0 -0
- gemstone_utils/crypto.py +188 -0
- gemstone_utils/db.py +119 -0
- gemstone_utils/election.py +251 -0
- gemstone_utils/encrypted_fields.py +131 -0
- gemstone_utils/experimental/__init__.py +0 -0
- gemstone_utils/experimental/azexp_backend.py +99 -0
- gemstone_utils/experimental/secrets_resolver.py +190 -0
- gemstone_utils/key_id.py +31 -0
- gemstone_utils/key_mgmt/__init__.py +253 -0
- gemstone_utils/key_mgmt/kdf/__init__.py +45 -0
- gemstone_utils/key_mgmt/kdf/pbkdf2.py +75 -0
- gemstone_utils/key_mgmt/registry.py +30 -0
- gemstone_utils/sqlalchemy/__init__.py +0 -0
- gemstone_utils/sqlalchemy/encrypted_type.py +93 -0
- gemstone_utils/sqlalchemy/key_storage.py +336 -0
- gemstone_utils/sqlalchemy/lazy_secret.py +31 -0
- gemstone_utils/types.py +40 -0
- gemstone_utils-0.4.0.dist-info/METADATA +278 -0
- gemstone_utils-0.4.0.dist-info/RECORD +23 -0
- gemstone_utils-0.4.0.dist-info/WHEEL +5 -0
- gemstone_utils-0.4.0.dist-info/licenses/LICENSE +373 -0
- gemstone_utils-0.4.0.dist-info/top_level.txt +1 -0
|
File without changes
|
gemstone_utils/crypto.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright 2026, Clinton Bunch. All rights reserved.
|
|
3
|
+
# gemstone_utils/crypto.py
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from base64 import urlsafe_b64encode, urlsafe_b64decode
|
|
9
|
+
from typing import Any, Callable, Dict, Final, Mapping, NamedTuple, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from cryptography.hazmat.primitives import hashes
|
|
12
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
13
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---- KEK derivation ---------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
# OWASP-style order of magnitude for PBKDF2-HMAC-SHA256 when params omit iterations
|
|
19
|
+
# (used by key_mgmt persisted KDF defaults).
|
|
20
|
+
DEFAULT_PBKDF2_ITERATIONS_STRONG = 600_000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def derive_pbkdf2_hmac_sha256(
|
|
24
|
+
passphrase: str,
|
|
25
|
+
salt: bytes,
|
|
26
|
+
*,
|
|
27
|
+
iterations: int,
|
|
28
|
+
length: int = 32,
|
|
29
|
+
) -> bytes:
|
|
30
|
+
"""
|
|
31
|
+
Derive key bytes using cryptography's PBKDF2HMAC (SHA-256).
|
|
32
|
+
"""
|
|
33
|
+
if not isinstance(passphrase, str):
|
|
34
|
+
raise TypeError("passphrase must be a str")
|
|
35
|
+
if not isinstance(salt, (bytes, bytearray)):
|
|
36
|
+
raise TypeError("salt must be bytes")
|
|
37
|
+
|
|
38
|
+
kdf = PBKDF2HMAC(
|
|
39
|
+
algorithm=hashes.SHA256(),
|
|
40
|
+
length=length,
|
|
41
|
+
salt=salt,
|
|
42
|
+
iterations=iterations,
|
|
43
|
+
)
|
|
44
|
+
return kdf.derive(passphrase.encode("utf-8"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---- AES-GCM primitives -----------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def aesgcm_encrypt(dk: bytes, plaintext: bytes, aad: Optional[bytes] = None) -> bytes:
|
|
51
|
+
aesgcm = AESGCM(dk)
|
|
52
|
+
nonce = os.urandom(12)
|
|
53
|
+
ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
|
|
54
|
+
return nonce + ciphertext
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def aesgcm_decrypt(dk: bytes, blob: bytes, aad: Optional[bytes] = None) -> bytes:
|
|
58
|
+
if len(blob) < 12 + 16:
|
|
59
|
+
raise ValueError("ciphertext blob too short")
|
|
60
|
+
|
|
61
|
+
nonce = blob[:12]
|
|
62
|
+
ciphertext = blob[12:]
|
|
63
|
+
aesgcm = AESGCM(dk)
|
|
64
|
+
return aesgcm.decrypt(nonce, ciphertext, aad)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---- Symmetric algorithm registry -------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _a256_validate_sym_params(params: Dict[str, Any]) -> None:
|
|
71
|
+
if params:
|
|
72
|
+
raise ValueError(f"A256GCM does not accept algorithm parameters (got {params!r})")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _a256_encrypt_impl(
|
|
76
|
+
key: bytes, plaintext: bytes, _params: Dict[str, Any]
|
|
77
|
+
) -> Tuple[bytes, Dict[str, Any]]:
|
|
78
|
+
return aesgcm_encrypt(key, plaintext), {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _a256_decrypt_impl(key: bytes, ciphertext: bytes, _params: Dict[str, Any]) -> bytes:
|
|
82
|
+
return aesgcm_decrypt(key, ciphertext)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SymAlgSpec(NamedTuple):
|
|
86
|
+
"""Registered symmetric algorithm: key size, param validation, and crypto ops."""
|
|
87
|
+
|
|
88
|
+
key_length: int
|
|
89
|
+
validate_sym_params: Callable[[Dict[str, Any]], None]
|
|
90
|
+
encrypt_impl: Callable[[bytes, bytes, Dict[str, Any]], Tuple[bytes, Dict[str, Any]]]
|
|
91
|
+
decrypt_impl: Callable[[bytes, bytes, Dict[str, Any]], bytes]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
SYM_ALG_REGISTRY: Dict[str, SymAlgSpec] = {
|
|
95
|
+
"A256GCM": SymAlgSpec(
|
|
96
|
+
key_length=32,
|
|
97
|
+
validate_sym_params=_a256_validate_sym_params,
|
|
98
|
+
encrypt_impl=_a256_encrypt_impl,
|
|
99
|
+
decrypt_impl=_a256_decrypt_impl,
|
|
100
|
+
),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
RECOMMENDED_DATA_ALG: Final[str] = "A256GCM"
|
|
104
|
+
assert RECOMMENDED_DATA_ALG in SYM_ALG_REGISTRY
|
|
105
|
+
|
|
106
|
+
SUPPORTED_SYM_ALGS: frozenset[str] = frozenset(SYM_ALG_REGISTRY.keys())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_supported_sym_alg(alg: str) -> bool:
|
|
110
|
+
return alg in SYM_ALG_REGISTRY
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def sym_alg_key_length(alg: str) -> int:
|
|
114
|
+
spec = SYM_ALG_REGISTRY.get(alg)
|
|
115
|
+
if spec is None:
|
|
116
|
+
raise ValueError(f"Unsupported symmetric alg: {alg}")
|
|
117
|
+
return spec.key_length
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def recommended_data_alg() -> str:
|
|
121
|
+
"""
|
|
122
|
+
Symmetric algorithm id recommended for **new** application field encryption.
|
|
123
|
+
|
|
124
|
+
Matches the default for :attr:`~gemstone_utils.types.KeyContext.alg` and
|
|
125
|
+
persisted :attr:`~gemstone_utils.sqlalchemy.key_storage.GemstoneKeyRecord.data_alg`.
|
|
126
|
+
"""
|
|
127
|
+
return RECOMMENDED_DATA_ALG
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def generate_key_by_alg(alg: str) -> bytes:
|
|
131
|
+
"""Return ``os.urandom(key_length)`` for the registered algorithm."""
|
|
132
|
+
return os.urandom(sym_alg_key_length(alg))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def encrypt_alg(
|
|
136
|
+
alg: str,
|
|
137
|
+
key: bytes,
|
|
138
|
+
plaintext: bytes,
|
|
139
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
140
|
+
) -> Tuple[bytes, Dict[str, Any]]:
|
|
141
|
+
"""
|
|
142
|
+
Encrypt with a registered symmetric algorithm.
|
|
143
|
+
|
|
144
|
+
Returns ``(ciphertext, updated_params)``. Callers persist ``updated_params``
|
|
145
|
+
in the wire JSON segment when nonces or metadata are stored outside the blob.
|
|
146
|
+
"""
|
|
147
|
+
spec = SYM_ALG_REGISTRY.get(alg)
|
|
148
|
+
if spec is None:
|
|
149
|
+
raise ValueError(f"Unsupported symmetric alg: {alg}")
|
|
150
|
+
p = dict(params) if params is not None else {}
|
|
151
|
+
spec.validate_sym_params(p)
|
|
152
|
+
return spec.encrypt_impl(key, plaintext, p)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def decrypt_alg(
|
|
156
|
+
alg: str,
|
|
157
|
+
key: bytes,
|
|
158
|
+
ciphertext: bytes,
|
|
159
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
160
|
+
) -> bytes:
|
|
161
|
+
spec = SYM_ALG_REGISTRY.get(alg)
|
|
162
|
+
if spec is None:
|
|
163
|
+
raise ValueError(f"Unsupported symmetric alg: {alg}")
|
|
164
|
+
p = dict(params) if params is not None else {}
|
|
165
|
+
spec.validate_sym_params(p)
|
|
166
|
+
return spec.decrypt_impl(key, ciphertext, p)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def encrypt_with_alg(alg: str, key: bytes, plaintext: bytes) -> bytes:
|
|
170
|
+
"""Backward-compatible: same as ``encrypt_alg`` but returns ciphertext only."""
|
|
171
|
+
blob, _params = encrypt_alg(alg, key, plaintext, None)
|
|
172
|
+
return blob
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def decrypt_with_alg(alg: str, key: bytes, blob: bytes) -> bytes:
|
|
176
|
+
"""Backward-compatible: decrypt with empty symmetric parameters."""
|
|
177
|
+
return decrypt_alg(alg, key, blob, None)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---- Base64 helpers ---------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def b64encode(data: bytes) -> str:
|
|
184
|
+
return urlsafe_b64encode(data).decode("ascii")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def b64decode(data: str) -> bytes:
|
|
188
|
+
return urlsafe_b64decode(data.encode("ascii"))
|
gemstone_utils/db.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright 2026,
|
|
3
|
+
# gemstone_utils/db.py
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import create_engine, event
|
|
10
|
+
from sqlalchemy.engine import Engine, make_url
|
|
11
|
+
from sqlalchemy.engine.url import URL
|
|
12
|
+
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GemstoneDB(DeclarativeBase):
|
|
16
|
+
"""Shared declarative base for gemstone_utils ORM models."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_engine: Optional[Engine] = None
|
|
20
|
+
_session_factory: Optional[sessionmaker[Session]] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_sqlite(drivername: str) -> bool:
|
|
24
|
+
return drivername == "sqlite" or drivername.startswith("sqlite+")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_mysql_family(drivername: str) -> bool:
|
|
28
|
+
return drivername.startswith("mysql") or drivername.startswith("mariadb")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_postgresql(drivername: str) -> bool:
|
|
32
|
+
return drivername.startswith("postgresql")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _apply_dialect_engine_kwargs(url: URL, engine_kw: dict[str, Any]) -> URL:
|
|
36
|
+
"""
|
|
37
|
+
Apply backend-specific defaults. Caller ``**engine_kw`` values win over
|
|
38
|
+
defaults (via setdefault / merged connect_args).
|
|
39
|
+
"""
|
|
40
|
+
kw = engine_kw
|
|
41
|
+
driver = url.drivername
|
|
42
|
+
|
|
43
|
+
if _is_mysql_family(driver):
|
|
44
|
+
kw.setdefault("pool_pre_ping", True)
|
|
45
|
+
kw.setdefault("pool_recycle", 3600)
|
|
46
|
+
if "charset" not in url.query:
|
|
47
|
+
url = url.update_query_dict({"charset": "utf8mb4"})
|
|
48
|
+
|
|
49
|
+
elif _is_postgresql(driver):
|
|
50
|
+
kw.setdefault("pool_pre_ping", True)
|
|
51
|
+
kw.setdefault("pool_recycle", 3600)
|
|
52
|
+
merged_connect: dict[str, Any] = {}
|
|
53
|
+
merged_connect.setdefault("options", "-c timezone=UTC")
|
|
54
|
+
user_connect = kw.get("connect_args")
|
|
55
|
+
if user_connect:
|
|
56
|
+
merged_connect.update(user_connect)
|
|
57
|
+
kw["connect_args"] = merged_connect
|
|
58
|
+
|
|
59
|
+
return url
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _register_sqlite_pragmas(engine: Engine) -> None:
|
|
63
|
+
@event.listens_for(engine, "connect")
|
|
64
|
+
def _on_sqlite_connect(dbapi_conn, connection_record) -> None:
|
|
65
|
+
cursor = dbapi_conn.cursor()
|
|
66
|
+
try:
|
|
67
|
+
cursor.execute("PRAGMA journal_mode=WAL")
|
|
68
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
69
|
+
cursor.execute("PRAGMA busy_timeout=5000")
|
|
70
|
+
finally:
|
|
71
|
+
cursor.close()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def init_db(db_url: str, *, echo: bool = False, **engine_kw: Any) -> Engine:
|
|
75
|
+
"""
|
|
76
|
+
Configure the process-global SQLAlchemy engine and session factory, then
|
|
77
|
+
create any missing tables for all models registered on
|
|
78
|
+
:attr:`GemstoneDB.metadata` (call after every plugin/module that defines
|
|
79
|
+
``GemstoneDB`` subclasses has been imported).
|
|
80
|
+
|
|
81
|
+
Applies light dialect-specific defaults (SQLite WAL and pragmas; MySQL /
|
|
82
|
+
MariaDB utf8mb4 + pool tuning; PostgreSQL UTC session timezone + pool
|
|
83
|
+
tuning). Pass ``**engine_kw`` to override or extend :func:`create_engine`
|
|
84
|
+
arguments.
|
|
85
|
+
|
|
86
|
+
Returns the new :class:`~sqlalchemy.engine.Engine`.
|
|
87
|
+
"""
|
|
88
|
+
global _engine, _session_factory
|
|
89
|
+
|
|
90
|
+
url = make_url(db_url)
|
|
91
|
+
kw = dict(engine_kw)
|
|
92
|
+
url = _apply_dialect_engine_kwargs(url, kw)
|
|
93
|
+
|
|
94
|
+
_engine = create_engine(url, echo=echo, **kw)
|
|
95
|
+
|
|
96
|
+
if _is_sqlite(url.drivername):
|
|
97
|
+
_register_sqlite_pragmas(_engine)
|
|
98
|
+
|
|
99
|
+
_session_factory = sessionmaker(
|
|
100
|
+
bind=_engine,
|
|
101
|
+
autoflush=True,
|
|
102
|
+
autocommit=False,
|
|
103
|
+
expire_on_commit=False,
|
|
104
|
+
class_=Session,
|
|
105
|
+
)
|
|
106
|
+
GemstoneDB.metadata.create_all(bind=_engine)
|
|
107
|
+
return _engine
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_session() -> Session:
|
|
111
|
+
"""
|
|
112
|
+
Return a new :class:`~sqlalchemy.orm.Session` bound to the engine from
|
|
113
|
+
:func:`init_db`. The caller should close the session when done (or use it
|
|
114
|
+
as a context manager: ``with get_session() as session:``).
|
|
115
|
+
"""
|
|
116
|
+
if _session_factory is None:
|
|
117
|
+
raise RuntimeError("init_db(...) must be called before get_session()")
|
|
118
|
+
|
|
119
|
+
return _session_factory()
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright 2026,
|
|
3
|
+
# gemstone_utils/election.py
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from typing import Optional, Iterator
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
from sqlalchemy import DateTime, String, delete, select
|
|
14
|
+
from sqlalchemy.exc import IntegrityError
|
|
15
|
+
from sqlalchemy.orm import Mapped, Session, mapped_column
|
|
16
|
+
|
|
17
|
+
from .db import GemstoneDB, get_session
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _utcnow() -> datetime:
|
|
21
|
+
return datetime.now(timezone.utc)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ns(ns: Optional[str]) -> str:
|
|
25
|
+
return ns or "default"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_expire_seconds: int = 60
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def set_expire(sec: int) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Set the candidate and leader lease expiration window (seconds).
|
|
34
|
+
|
|
35
|
+
The application should call this once at startup.
|
|
36
|
+
"""
|
|
37
|
+
global _expire_seconds
|
|
38
|
+
if not isinstance(sec, int) or sec <= 0:
|
|
39
|
+
raise ValueError("expire seconds must be a positive int")
|
|
40
|
+
_expire_seconds = sec
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ElectionCandidate(GemstoneDB):
|
|
44
|
+
__tablename__ = "gemstone_election_candidate"
|
|
45
|
+
|
|
46
|
+
ns: Mapped[str] = mapped_column(String(255), primary_key=True)
|
|
47
|
+
candidate_id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
|
48
|
+
|
|
49
|
+
last_heartbeat_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
50
|
+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ElectionLeader(GemstoneDB):
|
|
54
|
+
__tablename__ = "gemstone_election_leader"
|
|
55
|
+
|
|
56
|
+
ns: Mapped[str] = mapped_column(String(255), primary_key=True)
|
|
57
|
+
|
|
58
|
+
leader_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
|
|
59
|
+
lease_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
60
|
+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class _Lease:
|
|
65
|
+
now: datetime
|
|
66
|
+
expires_at: datetime
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _lease() -> _Lease:
|
|
70
|
+
now = _utcnow()
|
|
71
|
+
return _Lease(now=now, expires_at=now + timedelta(seconds=_expire_seconds))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@contextmanager
|
|
75
|
+
def _session_scope(session: Optional[Session]) -> Iterator[Session]:
|
|
76
|
+
if session is not None:
|
|
77
|
+
yield session
|
|
78
|
+
return
|
|
79
|
+
s = get_session()
|
|
80
|
+
try:
|
|
81
|
+
yield s
|
|
82
|
+
finally:
|
|
83
|
+
s.close()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def register_candidate(candidate_id: UUID, ns: Optional[str] = None, *, session: Optional[Session] = None) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Register or refresh a candidate in the election namespace.
|
|
89
|
+
"""
|
|
90
|
+
n = _ns(ns)
|
|
91
|
+
cid = str(candidate_id)
|
|
92
|
+
lease = _lease()
|
|
93
|
+
|
|
94
|
+
with _session_scope(session) as s:
|
|
95
|
+
with s.begin():
|
|
96
|
+
row = s.get(ElectionCandidate, {"ns": n, "candidate_id": cid})
|
|
97
|
+
if row is None:
|
|
98
|
+
s.add(
|
|
99
|
+
ElectionCandidate(
|
|
100
|
+
ns=n,
|
|
101
|
+
candidate_id=cid,
|
|
102
|
+
last_heartbeat_at=lease.now,
|
|
103
|
+
expires_at=lease.expires_at,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
row.last_heartbeat_at = lease.now
|
|
108
|
+
row.expires_at = lease.expires_at
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def heartbeat(candidate_id: UUID, ns: Optional[str] = None, *, session: Optional[Session] = None) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Refresh a candidate heartbeat and extend its expiry window.
|
|
114
|
+
|
|
115
|
+
If the candidate is missing, this behaves like register_candidate().
|
|
116
|
+
"""
|
|
117
|
+
register_candidate(candidate_id, ns, session=session)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def unregister_candidate(candidate_id: UUID, ns: Optional[str] = None, *, session: Optional[Session] = None) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Remove a candidate from the registry. If it is currently leader, clear the
|
|
123
|
+
leader row for faster failover (best-effort).
|
|
124
|
+
"""
|
|
125
|
+
n = _ns(ns)
|
|
126
|
+
cid = str(candidate_id)
|
|
127
|
+
now = _utcnow()
|
|
128
|
+
|
|
129
|
+
with _session_scope(session) as s:
|
|
130
|
+
with s.begin():
|
|
131
|
+
s.execute(delete(ElectionCandidate).where(ElectionCandidate.ns == n, ElectionCandidate.candidate_id == cid))
|
|
132
|
+
|
|
133
|
+
leader = s.get(ElectionLeader, n)
|
|
134
|
+
if leader and leader.leader_id == cid:
|
|
135
|
+
leader.leader_id = None
|
|
136
|
+
leader.lease_expires_at = None
|
|
137
|
+
leader.updated_at = now
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def list_candidates(ns: Optional[str] = None, *, session: Optional[Session] = None) -> list[UUID]:
|
|
141
|
+
"""
|
|
142
|
+
Return the currently-active candidates in the namespace (expires_at > now).
|
|
143
|
+
"""
|
|
144
|
+
n = _ns(ns)
|
|
145
|
+
now = _utcnow()
|
|
146
|
+
|
|
147
|
+
with _session_scope(session) as s:
|
|
148
|
+
rows = s.execute(
|
|
149
|
+
select(ElectionCandidate.candidate_id)
|
|
150
|
+
.where(ElectionCandidate.ns == n, ElectionCandidate.expires_at > now)
|
|
151
|
+
.order_by(ElectionCandidate.candidate_id)
|
|
152
|
+
).scalars()
|
|
153
|
+
return [UUID(x) for x in rows]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def is_leader(candidate_id: UUID, ns: Optional[str] = None, *, session: Optional[Session] = None) -> bool:
|
|
157
|
+
"""
|
|
158
|
+
Return True if candidate_id holds an unexpired lease for the namespace.
|
|
159
|
+
"""
|
|
160
|
+
n = _ns(ns)
|
|
161
|
+
cid = str(candidate_id)
|
|
162
|
+
now = _utcnow()
|
|
163
|
+
|
|
164
|
+
with _session_scope(session) as s:
|
|
165
|
+
leader = s.get(ElectionLeader, n)
|
|
166
|
+
if leader is None or leader.leader_id is None or leader.lease_expires_at is None:
|
|
167
|
+
return False
|
|
168
|
+
return leader.leader_id == cid and leader.lease_expires_at > now
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def elect(candidate_id: UUID, ns: Optional[str] = None, *, session: Optional[Session] = None) -> UUID:
|
|
172
|
+
"""
|
|
173
|
+
Attempt to acquire (or renew) leadership for candidate_id.
|
|
174
|
+
|
|
175
|
+
Returns the UUID of the current leader for the namespace after this call.
|
|
176
|
+
"""
|
|
177
|
+
n = _ns(ns)
|
|
178
|
+
cid = str(candidate_id)
|
|
179
|
+
with _session_scope(session) as s:
|
|
180
|
+
# Contention-safe: if two candidates attempt to create the leader row at
|
|
181
|
+
# the same time, one may hit IntegrityError on insert/flush. Retry once
|
|
182
|
+
# with a fresh transaction.
|
|
183
|
+
for _ in range(2):
|
|
184
|
+
lease = _lease()
|
|
185
|
+
try:
|
|
186
|
+
with s.begin():
|
|
187
|
+
# Ensure the candidate is present/active.
|
|
188
|
+
row = s.get(ElectionCandidate, {"ns": n, "candidate_id": cid})
|
|
189
|
+
if row is None:
|
|
190
|
+
s.add(
|
|
191
|
+
ElectionCandidate(
|
|
192
|
+
ns=n,
|
|
193
|
+
candidate_id=cid,
|
|
194
|
+
last_heartbeat_at=lease.now,
|
|
195
|
+
expires_at=lease.expires_at,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
row.last_heartbeat_at = lease.now
|
|
200
|
+
row.expires_at = lease.expires_at
|
|
201
|
+
|
|
202
|
+
# Acquire a row lock on the leader entry if supported.
|
|
203
|
+
try:
|
|
204
|
+
leader = s.execute(
|
|
205
|
+
select(ElectionLeader).where(ElectionLeader.ns == n).with_for_update()
|
|
206
|
+
).scalar_one_or_none()
|
|
207
|
+
except Exception:
|
|
208
|
+
# SQLite and some drivers may not support FOR UPDATE cleanly.
|
|
209
|
+
leader = s.get(ElectionLeader, n)
|
|
210
|
+
|
|
211
|
+
if leader is None:
|
|
212
|
+
leader = ElectionLeader(
|
|
213
|
+
ns=n,
|
|
214
|
+
leader_id=cid,
|
|
215
|
+
lease_expires_at=lease.expires_at,
|
|
216
|
+
updated_at=lease.now,
|
|
217
|
+
)
|
|
218
|
+
s.add(leader)
|
|
219
|
+
s.flush() # may raise IntegrityError under contention
|
|
220
|
+
|
|
221
|
+
# Renew if we are leader, or take over if expired/cleared.
|
|
222
|
+
if leader.leader_id is None or leader.lease_expires_at is None:
|
|
223
|
+
leader.leader_id = cid
|
|
224
|
+
leader.lease_expires_at = lease.expires_at
|
|
225
|
+
leader.updated_at = lease.now
|
|
226
|
+
elif leader.leader_id == cid:
|
|
227
|
+
leader.lease_expires_at = lease.expires_at
|
|
228
|
+
leader.updated_at = lease.now
|
|
229
|
+
elif leader.lease_expires_at <= lease.now:
|
|
230
|
+
leader.leader_id = cid
|
|
231
|
+
leader.lease_expires_at = lease.expires_at
|
|
232
|
+
leader.updated_at = lease.now
|
|
233
|
+
|
|
234
|
+
# Ensure a usable leader_id is returned.
|
|
235
|
+
if leader.leader_id is None:
|
|
236
|
+
leader.leader_id = cid
|
|
237
|
+
leader.lease_expires_at = lease.expires_at
|
|
238
|
+
leader.updated_at = lease.now
|
|
239
|
+
|
|
240
|
+
return UUID(leader.leader_id)
|
|
241
|
+
except IntegrityError:
|
|
242
|
+
# Auto-rollback occurs; retry once.
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
# If we still can't elect due to persistent contention, fall back to
|
|
246
|
+
# reading current leader without attempting insert again.
|
|
247
|
+
leader = s.get(ElectionLeader, n)
|
|
248
|
+
if leader and leader.leader_id and leader.lease_expires_at and leader.lease_expires_at > _utcnow():
|
|
249
|
+
return UUID(leader.leader_id)
|
|
250
|
+
return UUID(cid)
|
|
251
|
+
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright 2026, Clinton Bunch. All rights reserved.
|
|
3
|
+
# gemstone_utils/encrypted_fields.py
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import warnings
|
|
9
|
+
from typing import Any, Dict, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from .crypto import (
|
|
12
|
+
SUPPORTED_SYM_ALGS,
|
|
13
|
+
SYM_ALG_REGISTRY,
|
|
14
|
+
b64decode,
|
|
15
|
+
b64encode,
|
|
16
|
+
decrypt_alg,
|
|
17
|
+
encrypt_alg,
|
|
18
|
+
is_supported_sym_alg,
|
|
19
|
+
)
|
|
20
|
+
from .key_id import normalize_key_id
|
|
21
|
+
from .types import KeyContext
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_encrypted_prefix(value: str) -> bool:
|
|
25
|
+
if not isinstance(value, str) or not value.startswith("$"):
|
|
26
|
+
return False
|
|
27
|
+
parts = value.split("$")
|
|
28
|
+
if len(parts) < 4 or parts[0] != "":
|
|
29
|
+
return False
|
|
30
|
+
return parts[1] in SUPPORTED_SYM_ALGS
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _params_json_bytes(params: Dict[str, Any]) -> bytes:
|
|
34
|
+
return json.dumps(params, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _encode_params_segment(params: Dict[str, Any]) -> str:
|
|
38
|
+
return b64encode(_params_json_bytes(params))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _decode_params_segment(segment: str) -> Dict[str, Any]:
|
|
42
|
+
raw = b64decode(segment)
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(raw.decode("utf-8"))
|
|
45
|
+
except json.JSONDecodeError as e:
|
|
46
|
+
raise ValueError("invalid algorithm parameters (not valid JSON)") from e
|
|
47
|
+
if not isinstance(data, dict):
|
|
48
|
+
raise ValueError("algorithm parameters must be a JSON object")
|
|
49
|
+
return data
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_key_id_segment(seg: str) -> str:
|
|
53
|
+
if seg.isdigit():
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"legacy integer key id in encrypted field; migrate ciphertext to UUID "
|
|
56
|
+
"key ids (see gemstone_utils migration docs) before using this version"
|
|
57
|
+
)
|
|
58
|
+
return normalize_key_id(seg)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def format_encrypted_field(
|
|
62
|
+
alg: str,
|
|
63
|
+
keyid: str,
|
|
64
|
+
blob: bytes,
|
|
65
|
+
params: Optional[Dict[str, Any]] = None,
|
|
66
|
+
) -> str:
|
|
67
|
+
if not is_supported_sym_alg(alg):
|
|
68
|
+
raise ValueError(f"Unsupported symmetric alg: {alg}")
|
|
69
|
+
p = {} if params is None else params
|
|
70
|
+
kid = normalize_key_id(keyid)
|
|
71
|
+
return f"${alg}${kid}${_encode_params_segment(p)}${b64encode(blob)}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_encrypted_field(value: str) -> Tuple[str, str, Dict[str, Any], bytes]:
|
|
75
|
+
parts = value.split("$")
|
|
76
|
+
if parts[0] != "":
|
|
77
|
+
raise ValueError("invalid encrypted field format")
|
|
78
|
+
|
|
79
|
+
if len(parts) == 4:
|
|
80
|
+
warnings.warn(
|
|
81
|
+
"Four-part encrypted fields (no algorithm-parameters segment) are deprecated "
|
|
82
|
+
"and will be removed in gemstone_utils 0.9.0; re-encrypt or run a key rotation "
|
|
83
|
+
"with gemstone_utils >= 0.3.0 to migrate.",
|
|
84
|
+
DeprecationWarning,
|
|
85
|
+
stacklevel=2,
|
|
86
|
+
)
|
|
87
|
+
alg_id = parts[1]
|
|
88
|
+
keyid = _parse_key_id_segment(parts[2])
|
|
89
|
+
blob = b64decode(parts[3])
|
|
90
|
+
return alg_id, keyid, {}, blob
|
|
91
|
+
|
|
92
|
+
if len(parts) == 5:
|
|
93
|
+
alg_id = parts[1]
|
|
94
|
+
keyid = _parse_key_id_segment(parts[2])
|
|
95
|
+
params = _decode_params_segment(parts[3])
|
|
96
|
+
blob = b64decode(parts[4])
|
|
97
|
+
return alg_id, keyid, params, blob
|
|
98
|
+
|
|
99
|
+
raise ValueError("invalid encrypted field format")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _validate_alg_params(alg_id: str, params: Dict[str, Any]) -> None:
|
|
103
|
+
spec = SYM_ALG_REGISTRY.get(alg_id)
|
|
104
|
+
if spec is None:
|
|
105
|
+
raise ValueError(f"unsupported algorithm: {alg_id}")
|
|
106
|
+
spec.validate_sym_params(params)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def encrypt_string(plaintext: Optional[str], keyctx: KeyContext) -> Optional[str]:
|
|
110
|
+
if plaintext is None:
|
|
111
|
+
return None
|
|
112
|
+
blob, out_params = encrypt_alg(
|
|
113
|
+
keyctx.alg, keyctx.key, plaintext.encode("utf-8"), None
|
|
114
|
+
)
|
|
115
|
+
return format_encrypted_field(keyctx.alg, keyctx.keyid, blob, out_params)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def decrypt_string(value: Optional[str], keyctx: KeyContext) -> Optional[str]:
|
|
119
|
+
if value is None:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
alg_id, keyid, params, blob = parse_encrypted_field(value)
|
|
123
|
+
|
|
124
|
+
if alg_id != keyctx.alg:
|
|
125
|
+
raise ValueError(f"unsupported algorithm: {alg_id}")
|
|
126
|
+
if keyid != keyctx.keyid:
|
|
127
|
+
raise ValueError(f"unexpected keyid {keyid}")
|
|
128
|
+
|
|
129
|
+
_validate_alg_params(alg_id, params)
|
|
130
|
+
|
|
131
|
+
return decrypt_alg(keyctx.alg, keyctx.key, blob, params).decode("utf-8")
|
|
File without changes
|