messagefoundry 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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""PHI-at-rest encryption for the message store (STORE-1; WP-5 keyring + rotation).
|
|
4
|
+
|
|
5
|
+
The store persists raw HL7 message bodies and a few PHI-bearing text columns (`messages.error`,
|
|
6
|
+
`queue.last_error`, `message_events.detail`). This module is the cipher behind the store's
|
|
7
|
+
encode/decode seam: with a key configured it transparently AES-256-GCM-encrypts those values before
|
|
8
|
+
they hit disk and decrypts them on read; with **no** key it is the identity (backward-compatible
|
|
9
|
+
default).
|
|
10
|
+
|
|
11
|
+
Stored format (in the existing text columns)::
|
|
12
|
+
|
|
13
|
+
mfenc:v1:<key_id>:<base64(nonce ‖ ciphertext ‖ tag)>
|
|
14
|
+
|
|
15
|
+
``key_id`` is a **fingerprint of the key** (first 16 hex of SHA-256(key)) — stable and
|
|
16
|
+
self-identifying, so the keyring needs no manual numbering and a stored value names the key that
|
|
17
|
+
wrote it. The ``mfenc:v1:`` prefix lets :meth:`decrypt` tell ciphertext from legacy plaintext (so
|
|
18
|
+
reads work during the one-time migration). AES-GCM is an AEAD cipher, so a wrong key or tampered
|
|
19
|
+
value fails the authentication tag.
|
|
20
|
+
|
|
21
|
+
**Key rotation (WP-5, ASVS 11.2.2).** The cipher is a **keyring**: it encrypts with the single
|
|
22
|
+
*active* key and decrypts with whichever configured key matches (active + any retired decrypt-only
|
|
23
|
+
keys), falling back to trying every key — which transparently covers legacy ``key_id='0'`` rows and
|
|
24
|
+
in-progress rotations. ``messagefoundry rotate-key`` re-encrypts every ciphered value under the active
|
|
25
|
+
key. Keys are 32-byte secrets supplied base64 via ``MEFOR_STORE_ENCRYPTION_KEY`` (active) and
|
|
26
|
+
``MEFOR_STORE_ENCRYPTION_KEYS_RETIRED`` (comma-separated, decrypt-only) — never the config file.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import base64
|
|
32
|
+
import hashlib
|
|
33
|
+
import os
|
|
34
|
+
from collections.abc import Sequence
|
|
35
|
+
from typing import Protocol, runtime_checkable
|
|
36
|
+
|
|
37
|
+
PREFIX = "mfenc:v1:"
|
|
38
|
+
_NONCE_BYTES = 12 # 96-bit random nonce, the standard size for AES-GCM
|
|
39
|
+
_KEY_ID_LEN = 16 # hex chars of the SHA-256 key fingerprint embedded as key_id
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CipherError(Exception):
|
|
43
|
+
"""A stored ciphertext could not be decrypted by **any** configured key — an unknown ``key_id``
|
|
44
|
+
with no matching key, a failed AEAD tag (corrupt blob, or a key/old key that wasn't supplied), or
|
|
45
|
+
a malformed value. Call sites **contain** this (dead-letter the row) rather than letting a raw
|
|
46
|
+
``cryptography`` exception escape into a worker."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@runtime_checkable
|
|
50
|
+
class Cipher(Protocol):
|
|
51
|
+
"""Encode/decode seam for at-rest values. ``encrypts`` is False for the identity cipher."""
|
|
52
|
+
|
|
53
|
+
encrypts: bool
|
|
54
|
+
|
|
55
|
+
def encrypt(self, plaintext: str) -> str: ...
|
|
56
|
+
|
|
57
|
+
def decrypt(self, stored: str) -> str: ...
|
|
58
|
+
|
|
59
|
+
def is_encrypted(self, stored: str) -> bool: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _fingerprint(key: bytes) -> str:
|
|
63
|
+
"""A stable, self-identifying ``key_id``: first ``_KEY_ID_LEN`` hex of SHA-256(key). One-way
|
|
64
|
+
(preimage-resistant), so embedding it in the stored prefix reveals nothing about the key."""
|
|
65
|
+
return hashlib.sha256(key).hexdigest()[:_KEY_ID_LEN]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class IdentityCipher:
|
|
69
|
+
"""No-op cipher: values are stored as-is (the default when no key is configured)."""
|
|
70
|
+
|
|
71
|
+
encrypts = False
|
|
72
|
+
|
|
73
|
+
def encrypt(self, plaintext: str) -> str:
|
|
74
|
+
return plaintext
|
|
75
|
+
|
|
76
|
+
def decrypt(self, stored: str) -> str:
|
|
77
|
+
return stored
|
|
78
|
+
|
|
79
|
+
def is_encrypted(self, stored: str) -> bool:
|
|
80
|
+
return stored.startswith(PREFIX)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AesGcmCipher:
|
|
84
|
+
"""AES-256-GCM **keyring** cipher. Encrypts with the active key; decrypts with whichever configured
|
|
85
|
+
key matches the embedded ``key_id`` (and falls back to trying every key, which covers legacy
|
|
86
|
+
``key_id='0'`` rows and in-progress rotations). Construct via :func:`make_cipher`."""
|
|
87
|
+
|
|
88
|
+
encrypts = True
|
|
89
|
+
|
|
90
|
+
def __init__(self, active_key: bytes, retired_keys: Sequence[bytes] = ()) -> None:
|
|
91
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
92
|
+
|
|
93
|
+
self._active_id = _fingerprint(active_key)
|
|
94
|
+
# Insertion order = active first, then retired — the order decrypt() tries keys in. (Python
|
|
95
|
+
# 3.11 dicts preserve insertion order.)
|
|
96
|
+
self._keyring: dict[str, AESGCM] = {self._active_id: AESGCM(active_key)}
|
|
97
|
+
for key in retired_keys:
|
|
98
|
+
self._keyring.setdefault(_fingerprint(key), AESGCM(key))
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def active_key_id(self) -> str:
|
|
102
|
+
"""The fingerprint of the key new writes are encrypted under (rotation re-encrypts to this)."""
|
|
103
|
+
return self._active_id
|
|
104
|
+
|
|
105
|
+
def is_encrypted(self, stored: str) -> bool:
|
|
106
|
+
return stored.startswith(PREFIX)
|
|
107
|
+
|
|
108
|
+
def encrypt(self, plaintext: str) -> str:
|
|
109
|
+
nonce = os.urandom(_NONCE_BYTES)
|
|
110
|
+
ct = self._keyring[self._active_id].encrypt(nonce, plaintext.encode("utf-8"), None)
|
|
111
|
+
blob = base64.b64encode(nonce + ct).decode("ascii")
|
|
112
|
+
return f"{PREFIX}{self._active_id}:{blob}"
|
|
113
|
+
|
|
114
|
+
def decrypt(self, stored: str) -> str:
|
|
115
|
+
from cryptography.exceptions import InvalidTag
|
|
116
|
+
|
|
117
|
+
if not stored.startswith(PREFIX):
|
|
118
|
+
return stored # legacy plaintext (pre-encryption) or a purged/blank value
|
|
119
|
+
# After the prefix: "<key_id>:<base64>". base64 has no ':' so partition is unambiguous.
|
|
120
|
+
key_id, _, blob = stored[len(PREFIX) :].partition(":")
|
|
121
|
+
try:
|
|
122
|
+
raw = base64.b64decode(blob)
|
|
123
|
+
except (ValueError, base64.binascii.Error) as exc: # type: ignore[attr-defined]
|
|
124
|
+
raise CipherError(f"malformed ciphertext (bad base64, key_id={key_id!r})") from exc
|
|
125
|
+
nonce, ct = raw[:_NONCE_BYTES], raw[_NONCE_BYTES:]
|
|
126
|
+
# Try the named key first (the fast path for current-format rows), then every other key — this
|
|
127
|
+
# transparently decrypts legacy key_id='0' rows and rows written under a retired key mid-rotation.
|
|
128
|
+
candidates = [self._keyring[key_id]] if key_id in self._keyring else []
|
|
129
|
+
candidates += [aes for kid, aes in self._keyring.items() if kid != key_id]
|
|
130
|
+
for aes in candidates:
|
|
131
|
+
try:
|
|
132
|
+
return aes.decrypt(nonce, ct, None).decode("utf-8")
|
|
133
|
+
except InvalidTag:
|
|
134
|
+
continue
|
|
135
|
+
raise CipherError(
|
|
136
|
+
f"no configured key decrypts this value (key_id={key_id!r}); if rotating, supply the "
|
|
137
|
+
"prior key via MEFOR_STORE_ENCRYPTION_KEYS_RETIRED"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _decode_key(key_b64: str, name: str) -> bytes:
|
|
142
|
+
try:
|
|
143
|
+
key = base64.b64decode(key_b64, validate=True)
|
|
144
|
+
except (ValueError, base64.binascii.Error) as exc: # type: ignore[attr-defined]
|
|
145
|
+
raise ValueError(f"{name} must be valid base64") from exc
|
|
146
|
+
if len(key) != 32:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"{name} must decode to 32 bytes (got {len(key)}); generate one with `messagefoundry gen-key`"
|
|
149
|
+
)
|
|
150
|
+
return key
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def make_cipher(key_b64: str | None, retired_b64: Sequence[str] = ()) -> Cipher:
|
|
154
|
+
"""Build the store cipher. ``key_b64`` is the active key (base64 32-byte); ``retired_b64`` are
|
|
155
|
+
decrypt-only keys to keep available during a rotation window. Empty active key → identity cipher
|
|
156
|
+
(backward-compatible default)."""
|
|
157
|
+
if not key_b64:
|
|
158
|
+
return IdentityCipher()
|
|
159
|
+
active = _decode_key(key_b64, "MEFOR_STORE_ENCRYPTION_KEY")
|
|
160
|
+
retired = [_decode_key(k, "MEFOR_STORE_ENCRYPTION_KEYS_RETIRED") for k in retired_b64 if k]
|
|
161
|
+
return AesGcmCipher(active, retired)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def generate_key() -> str:
|
|
165
|
+
"""Mint a fresh base64-encoded 32-byte key for ``MEFOR_STORE_ENCRYPTION_KEY``."""
|
|
166
|
+
return base64.b64encode(os.urandom(32)).decode("ascii")
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Pluggable KeyProvider seam for sourcing the store's at-rest data-encryption key (ASVS 13.3.3).
|
|
4
|
+
|
|
5
|
+
This is the seam designed in [ADR 0019](../../docs/adr/0019-pluggable-keyprovider-hsm-kms-vault.md):
|
|
6
|
+
it changes **only how** the base64 active/retired DEK bytes are *provisioned*, never how they are used.
|
|
7
|
+
The :class:`~messagefoundry.store.crypto.Cipher`/``AesGcmCipher`` keyring, the ``mfenc:v1:<key_id>``
|
|
8
|
+
stored format, ``rotate-key``, and every backend are **byte-identical** when ``[store].key_provider`` is
|
|
9
|
+
unset/``auto``.
|
|
10
|
+
|
|
11
|
+
A :class:`KeyProvider` returns exactly the bytes :func:`~messagefoundry.store.crypto.make_cipher` already
|
|
12
|
+
consumes — ``active_key()`` is the base64 of a 32-byte DEK (or ``None`` → identity cipher) and
|
|
13
|
+
``retired_keys()`` is the decrypt-only keyring for a rotation window. Because the provider returns the
|
|
14
|
+
*same* key bytes, existing ``mfenc:v1`` rows decrypt with **no rotation**.
|
|
15
|
+
|
|
16
|
+
**Element-level contract for ``retired_keys()``** (ADR 0019 §1): it returns a ``Sequence`` of
|
|
17
|
+
**individual** base64 32-byte DEK strings — one element per key, each fed straight through
|
|
18
|
+
``_decode_key``. Do **not** pre-join with commas; the comma-split is a property only of the built-in
|
|
19
|
+
``[store].encryption_keys_retired`` string (handled here by :func:`_split_retired`).
|
|
20
|
+
|
|
21
|
+
**Providers** (dispatched by :func:`resolve_key_provider` on ``[store].key_provider``):
|
|
22
|
+
|
|
23
|
+
* ``auto`` (default) — the env-then-DPAPI ladder, **byte-identical** to the pre-seam ``resolve_active_key``.
|
|
24
|
+
* ``env`` — the ``MEFOR_STORE_ENCRYPTION_KEY`` value (``settings.encryption_key``).
|
|
25
|
+
* ``dpapi`` — the Windows DPAPI-protected ``encryption_key_file`` decrypted into memory.
|
|
26
|
+
* ``aws_kms`` | ``azure_kv`` | ``gcp_kms`` | ``vault`` | ``pkcs11`` — external HSM/KMS/Vault providers that
|
|
27
|
+
**envelope-decrypt** a wrapped DEK inside an isolated security module. These are **lazy, optional
|
|
28
|
+
extras** that are **not built here** (ADR 0019 §3/§5 — one provider per follow-on PR), so the base
|
|
29
|
+
install pulls **zero** cloud SDKs. Selecting one before its module ships **fails closed** (ADR 0019
|
|
30
|
+
§4) — it raises :class:`KeyProviderError` out of ``resolve_active_key``, never a silent degrade to the
|
|
31
|
+
identity (plaintext) cipher.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import importlib
|
|
37
|
+
from collections.abc import Sequence
|
|
38
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from messagefoundry.config.settings import StoreSettings
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class KeyProviderError(RuntimeError):
|
|
45
|
+
"""The configured ``[store].key_provider`` could not provision a key — an unknown provider name, or
|
|
46
|
+
an external provider that is selected but not built / cannot resolve. Raised out of
|
|
47
|
+
:func:`resolve_key_provider` (and thus ``resolve_active_key``) so ``open_store`` propagates it and
|
|
48
|
+
``serve`` **fails to start** rather than degrading to the identity cipher (ADR 0019 §4), mirroring
|
|
49
|
+
the existing fail-closed ``DpapiError``/``DpapiUnavailable`` contract for an unreadable key file."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@runtime_checkable
|
|
53
|
+
class KeyProvider(Protocol):
|
|
54
|
+
"""How the store's active/retired DEK bytes are provisioned (ADR 0019 §1).
|
|
55
|
+
|
|
56
|
+
The contract is exactly what :func:`~messagefoundry.store.crypto.make_cipher` accepts:
|
|
57
|
+
``active_key()`` returns the base64 of a 32-byte DEK (or ``None`` → identity cipher) and
|
|
58
|
+
``retired_keys()`` returns the per-element decrypt-only keyring (default ``()``)."""
|
|
59
|
+
|
|
60
|
+
def active_key(self) -> str | None: ...
|
|
61
|
+
|
|
62
|
+
def retired_keys(self) -> Sequence[str]: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _split_retired(retired: str) -> list[str]:
|
|
66
|
+
"""Comma-split the built-in ``[store].encryption_keys_retired`` string into the per-element list
|
|
67
|
+
``make_cipher`` expects (empties filtered) — the same split ``open_store`` has always applied."""
|
|
68
|
+
return [k.strip() for k in retired.split(",") if k.strip()]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class EnvKeyProvider:
|
|
72
|
+
"""``env`` — the ``MEFOR_STORE_ENCRYPTION_KEY`` value (``settings.encryption_key``)."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, settings: StoreSettings) -> None:
|
|
75
|
+
self._settings = settings
|
|
76
|
+
|
|
77
|
+
def active_key(self) -> str | None:
|
|
78
|
+
return self._settings.encryption_key or None
|
|
79
|
+
|
|
80
|
+
def retired_keys(self) -> Sequence[str]:
|
|
81
|
+
return _split_retired(self._settings.encryption_keys_retired)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DpapiKeyProvider:
|
|
85
|
+
"""``dpapi`` — the Windows DPAPI-protected ``encryption_key_file`` ``CryptUnprotectData``'d into the
|
|
86
|
+
base64 store key (WP-11d). A configured-but-unreadable/foreign file raises ``DpapiError`` here
|
|
87
|
+
(fail-closed); an unset file yields ``None`` (→ identity cipher)."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, settings: StoreSettings) -> None:
|
|
90
|
+
self._settings = settings
|
|
91
|
+
|
|
92
|
+
def active_key(self) -> str | None:
|
|
93
|
+
if not self._settings.encryption_key_file:
|
|
94
|
+
return None
|
|
95
|
+
from messagefoundry.secrets_dpapi import load_protected_key
|
|
96
|
+
|
|
97
|
+
return load_protected_key(self._settings.encryption_key_file)
|
|
98
|
+
|
|
99
|
+
def retired_keys(self) -> Sequence[str]:
|
|
100
|
+
return _split_retired(self._settings.encryption_keys_retired)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AutoKeyProvider:
|
|
104
|
+
"""``auto`` (the default) — the env-then-DPAPI ladder: the env key if set, else the DPAPI key file.
|
|
105
|
+
|
|
106
|
+
**BYTE-IDENTICAL** to the pre-seam ``resolve_active_key``: the env key takes precedence so a
|
|
107
|
+
deployment can override the file, and a configured-but-unreadable key file raises ``DpapiError``
|
|
108
|
+
(fail-closed, not silently unencrypted)."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, settings: StoreSettings) -> None:
|
|
111
|
+
self._settings = settings
|
|
112
|
+
|
|
113
|
+
def active_key(self) -> str | None:
|
|
114
|
+
if self._settings.encryption_key:
|
|
115
|
+
return self._settings.encryption_key
|
|
116
|
+
if self._settings.encryption_key_file:
|
|
117
|
+
from messagefoundry.secrets_dpapi import load_protected_key
|
|
118
|
+
|
|
119
|
+
return load_protected_key(self._settings.encryption_key_file)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def retired_keys(self) -> Sequence[str]:
|
|
123
|
+
return _split_retired(self._settings.encryption_keys_retired)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
#: Built-in providers — fully reproduce today's behavior, no external dependency.
|
|
127
|
+
_BUILTIN_PROVIDERS = ("auto", "env", "dpapi")
|
|
128
|
+
|
|
129
|
+
#: External provider name → optional ``pyproject`` extra that will carry its SDK. Each envelope-decrypts
|
|
130
|
+
#: a wrapped DEK inside an isolated security module (ADR 0019 §3). NOT built here — see
|
|
131
|
+
#: :func:`_load_external_provider`. ``key_provider`` is **not** a file-secret: it names a provider, not
|
|
132
|
+
#: key material, so it must never be added to ``_FILE_SECRET_KEYS`` (ADR 0019 §2).
|
|
133
|
+
_EXTERNAL_PROVIDERS: dict[str, str] = {
|
|
134
|
+
"aws_kms": "aws_kms",
|
|
135
|
+
"azure_kv": "azure_kv",
|
|
136
|
+
"gcp_kms": "gcp_kms",
|
|
137
|
+
"vault": "vault",
|
|
138
|
+
"pkcs11": "pkcs11",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#: Every accepted ``[store].key_provider`` value (built-in + external), for validation + error messages.
|
|
142
|
+
KNOWN_PROVIDERS: tuple[str, ...] = (*_BUILTIN_PROVIDERS, *tuple(_EXTERNAL_PROVIDERS))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _load_external_provider(name: str, settings: StoreSettings) -> KeyProvider:
|
|
146
|
+
"""Lazy hook for an external HSM/KMS/Vault envelope-decrypt provider (ADR 0019 §3).
|
|
147
|
+
|
|
148
|
+
The seam: a future per-provider PR ships ``messagefoundry/store/keyprovider_<name>.py`` exposing
|
|
149
|
+
``build_provider(settings) -> KeyProvider`` behind the optional ``<extra>`` extra; this dispatch
|
|
150
|
+
picks it up by import with **no edit here**. Until that module ships the import fails and we **fail
|
|
151
|
+
closed** — the base install pulls **zero** cloud SDKs (no dependency lands from ADR 0019)."""
|
|
152
|
+
extra = _EXTERNAL_PROVIDERS[name]
|
|
153
|
+
module_name = f"{__name__}_{name}"
|
|
154
|
+
try:
|
|
155
|
+
module = importlib.import_module(module_name)
|
|
156
|
+
except ModuleNotFoundError as exc:
|
|
157
|
+
if exc.name == module_name:
|
|
158
|
+
# The provider module itself hasn't shipped yet (the common case today).
|
|
159
|
+
raise KeyProviderError(
|
|
160
|
+
f"[store].key_provider={name!r} (external HSM/KMS/Vault envelope decryption, ADR 0019 "
|
|
161
|
+
f"§3) is designed but not built yet — it lands per-provider under WP-BL3-04 behind the "
|
|
162
|
+
f"optional {extra!r} extra. Use 'auto' (default), 'env', or 'dpapi' until then."
|
|
163
|
+
) from exc
|
|
164
|
+
# The provider module shipped but a backend it imports (e.g. its cloud SDK) is missing — a
|
|
165
|
+
# distinct, actionable failure: install the extra rather than "not built yet".
|
|
166
|
+
raise KeyProviderError(
|
|
167
|
+
f"[store].key_provider={name!r} could not load its backend ({exc.name!r} is missing) — "
|
|
168
|
+
f"install the optional {extra!r} extra."
|
|
169
|
+
) from exc
|
|
170
|
+
# The provider module owns its own construction; annotate the local so mypy keeps the contract.
|
|
171
|
+
provider: KeyProvider = module.build_provider(settings)
|
|
172
|
+
return provider
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def resolve_key_provider(settings: StoreSettings) -> KeyProvider:
|
|
176
|
+
"""Build the :class:`KeyProvider` selected by ``[store].key_provider`` (ADR 0019 §2).
|
|
177
|
+
|
|
178
|
+
``auto`` (default) / ``env`` / ``dpapi`` are the built-ins; the external HSM/KMS/Vault names are
|
|
179
|
+
lazy optional extras (:func:`_load_external_provider`). An unknown name **fails closed** with
|
|
180
|
+
:class:`KeyProviderError` rather than silently falling back to the identity cipher."""
|
|
181
|
+
name = settings.key_provider
|
|
182
|
+
if name == "auto":
|
|
183
|
+
return AutoKeyProvider(settings)
|
|
184
|
+
if name == "env":
|
|
185
|
+
return EnvKeyProvider(settings)
|
|
186
|
+
if name == "dpapi":
|
|
187
|
+
return DpapiKeyProvider(settings)
|
|
188
|
+
if name in _EXTERNAL_PROVIDERS:
|
|
189
|
+
return _load_external_provider(name, settings)
|
|
190
|
+
raise KeyProviderError(
|
|
191
|
+
f"unknown [store].key_provider {name!r}; valid values are {', '.join(KNOWN_PROVIDERS)}"
|
|
192
|
+
)
|