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.
File without changes
@@ -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