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.
Files changed (142) hide show
  1. messagefoundry/__init__.py +108 -0
  2. messagefoundry/__main__.py +1155 -0
  3. messagefoundry/api/__init__.py +27 -0
  4. messagefoundry/api/app.py +1581 -0
  5. messagefoundry/api/approvals.py +184 -0
  6. messagefoundry/api/auth_models.py +211 -0
  7. messagefoundry/api/auth_routes.py +655 -0
  8. messagefoundry/api/field_authz.py +96 -0
  9. messagefoundry/api/models.py +374 -0
  10. messagefoundry/api/security.py +247 -0
  11. messagefoundry/api/tls.py +47 -0
  12. messagefoundry/auth/__init__.py +39 -0
  13. messagefoundry/auth/data/common_passwords.NOTICE +13 -0
  14. messagefoundry/auth/data/common_passwords.txt +10000 -0
  15. messagefoundry/auth/identity.py +71 -0
  16. messagefoundry/auth/ldap.py +264 -0
  17. messagefoundry/auth/notifications.py +68 -0
  18. messagefoundry/auth/passwords.py +53 -0
  19. messagefoundry/auth/permissions.py +120 -0
  20. messagefoundry/auth/policy.py +153 -0
  21. messagefoundry/auth/ratelimit.py +55 -0
  22. messagefoundry/auth/service.py +1323 -0
  23. messagefoundry/auth/tokens.py +26 -0
  24. messagefoundry/auth/totp.py +174 -0
  25. messagefoundry/checks.py +174 -0
  26. messagefoundry/config/__init__.py +30 -0
  27. messagefoundry/config/active_environment.py +80 -0
  28. messagefoundry/config/ai_policy.py +140 -0
  29. messagefoundry/config/code_sets.py +260 -0
  30. messagefoundry/config/connections_edit.py +200 -0
  31. messagefoundry/config/connections_file.py +287 -0
  32. messagefoundry/config/db_lookup.py +117 -0
  33. messagefoundry/config/environments.py +116 -0
  34. messagefoundry/config/ingest_time.py +83 -0
  35. messagefoundry/config/models.py +240 -0
  36. messagefoundry/config/reference.py +158 -0
  37. messagefoundry/config/response.py +83 -0
  38. messagefoundry/config/run_context.py +153 -0
  39. messagefoundry/config/settings.py +1311 -0
  40. messagefoundry/config/state.py +99 -0
  41. messagefoundry/config/tls_policy.py +110 -0
  42. messagefoundry/config/wiring.py +1918 -0
  43. messagefoundry/console/__init__.py +20 -0
  44. messagefoundry/console/__main__.py +274 -0
  45. messagefoundry/console/_async.py +107 -0
  46. messagefoundry/console/change_password.py +111 -0
  47. messagefoundry/console/client.py +552 -0
  48. messagefoundry/console/connections.py +324 -0
  49. messagefoundry/console/login.py +107 -0
  50. messagefoundry/console/mfa.py +205 -0
  51. messagefoundry/console/reauth.py +94 -0
  52. messagefoundry/console/search.py +57 -0
  53. messagefoundry/console/service_control.py +137 -0
  54. messagefoundry/console/sessions.py +122 -0
  55. messagefoundry/console/shell.py +410 -0
  56. messagefoundry/console/status.py +377 -0
  57. messagefoundry/console/users_page.py +282 -0
  58. messagefoundry/console/widgets.py +553 -0
  59. messagefoundry/generators/README.md +27 -0
  60. messagefoundry/generators/__init__.py +15 -0
  61. messagefoundry/generators/_core.py +589 -0
  62. messagefoundry/generators/_hl7data.py +428 -0
  63. messagefoundry/generators/adt.py +286 -0
  64. messagefoundry/generators/all_types.py +24 -0
  65. messagefoundry/generators/bar.py +28 -0
  66. messagefoundry/generators/dft.py +20 -0
  67. messagefoundry/generators/mdm.py +39 -0
  68. messagefoundry/generators/mfn.py +46 -0
  69. messagefoundry/generators/oml.py +32 -0
  70. messagefoundry/generators/orl.py +30 -0
  71. messagefoundry/generators/orm.py +23 -0
  72. messagefoundry/generators/oru.py +21 -0
  73. messagefoundry/generators/ras.py +20 -0
  74. messagefoundry/generators/rde.py +54 -0
  75. messagefoundry/generators/siu.py +64 -0
  76. messagefoundry/generators/vxu.py +20 -0
  77. messagefoundry/hl7schema.py +75 -0
  78. messagefoundry/last_resort.py +55 -0
  79. messagefoundry/logging_setup.py +332 -0
  80. messagefoundry/parsing/__init__.py +64 -0
  81. messagefoundry/parsing/consistency.py +166 -0
  82. messagefoundry/parsing/groups.py +228 -0
  83. messagefoundry/parsing/message.py +453 -0
  84. messagefoundry/parsing/peek.py +237 -0
  85. messagefoundry/parsing/split.py +120 -0
  86. messagefoundry/parsing/summary.py +46 -0
  87. messagefoundry/parsing/tree.py +128 -0
  88. messagefoundry/parsing/validate.py +95 -0
  89. messagefoundry/parsing/x12/__init__.py +46 -0
  90. messagefoundry/parsing/x12/delimiters.py +140 -0
  91. messagefoundry/parsing/x12/errors.py +30 -0
  92. messagefoundry/parsing/x12/interchange.py +232 -0
  93. messagefoundry/parsing/x12/message.py +200 -0
  94. messagefoundry/parsing/x12/peek.py +207 -0
  95. messagefoundry/pipeline/__init__.py +21 -0
  96. messagefoundry/pipeline/alert_sinks.py +486 -0
  97. messagefoundry/pipeline/alerts.py +100 -0
  98. messagefoundry/pipeline/cert_expiry.py +219 -0
  99. messagefoundry/pipeline/cluster.py +955 -0
  100. messagefoundry/pipeline/cluster_sqlserver.py +444 -0
  101. messagefoundry/pipeline/config_convergence.py +137 -0
  102. messagefoundry/pipeline/dryrun.py +450 -0
  103. messagefoundry/pipeline/engine.py +756 -0
  104. messagefoundry/pipeline/leader_tasks.py +158 -0
  105. messagefoundry/pipeline/reference_sync.py +369 -0
  106. messagefoundry/pipeline/retention.py +289 -0
  107. messagefoundry/pipeline/security_notify.py +168 -0
  108. messagefoundry/pipeline/state_convergence.py +143 -0
  109. messagefoundry/pipeline/wiring_runner.py +1722 -0
  110. messagefoundry/py.typed +0 -0
  111. messagefoundry/redaction.py +71 -0
  112. messagefoundry/scaffold.py +321 -0
  113. messagefoundry/secrets_dpapi.py +129 -0
  114. messagefoundry/store/__init__.py +46 -0
  115. messagefoundry/store/audit_tee.py +67 -0
  116. messagefoundry/store/base.py +758 -0
  117. messagefoundry/store/crypto.py +166 -0
  118. messagefoundry/store/keyprovider.py +192 -0
  119. messagefoundry/store/postgres.py +3447 -0
  120. messagefoundry/store/sqlserver.py +3014 -0
  121. messagefoundry/store/store.py +3790 -0
  122. messagefoundry/timezone.py +207 -0
  123. messagefoundry/transports/__init__.py +50 -0
  124. messagefoundry/transports/base.py +269 -0
  125. messagefoundry/transports/database.py +693 -0
  126. messagefoundry/transports/file.py +551 -0
  127. messagefoundry/transports/framing.py +164 -0
  128. messagefoundry/transports/loopback.py +53 -0
  129. messagefoundry/transports/mllp.py +644 -0
  130. messagefoundry/transports/remotefile.py +664 -0
  131. messagefoundry/transports/rest.py +281 -0
  132. messagefoundry/transports/signing.py +321 -0
  133. messagefoundry/transports/soap.py +507 -0
  134. messagefoundry/transports/tcp.py +307 -0
  135. messagefoundry/transports/timer.py +146 -0
  136. messagefoundry/transports/x12.py +323 -0
  137. messagefoundry-0.1.0.dist-info/METADATA +212 -0
  138. messagefoundry-0.1.0.dist-info/RECORD +142 -0
  139. messagefoundry-0.1.0.dist-info/WHEEL +4 -0
  140. messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
  141. messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
  142. 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
+ )