utic-invocation-settings 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.
- utic_invocation_settings/__init__.py +78 -0
- utic_invocation_settings/_canonical.py +40 -0
- utic_invocation_settings/cache.py +64 -0
- utic_invocation_settings/consumer.py +123 -0
- utic_invocation_settings/crypto.py +98 -0
- utic_invocation_settings/devtools/__init__.py +0 -0
- utic_invocation_settings/devtools/make_vector.py +54 -0
- utic_invocation_settings/envelope.py +80 -0
- utic_invocation_settings/errors.py +28 -0
- utic_invocation_settings/invoke.py +51 -0
- utic_invocation_settings-0.1.0.dist-info/METADATA +65 -0
- utic_invocation_settings-0.1.0.dist-info/RECORD +13 -0
- utic_invocation_settings-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""utic-invocation-settings — consume encrypted Unstructured plugin invocation settings.
|
|
2
|
+
|
|
3
|
+
Public, self-contained on ``cryptography`` + ``pydantic``. Reads the v1 envelope defined in
|
|
4
|
+
cellular-dataplane/docs/envelope-contract-v1.md: RSA-OAEP-256-wrapped AES-256-GCM, with
|
|
5
|
+
authenticated caching, decrypting inside the plugin at invoke time.
|
|
6
|
+
|
|
7
|
+
Typical use in a plugin::
|
|
8
|
+
|
|
9
|
+
from utic_invocation_settings import (
|
|
10
|
+
extract_envelope, decrypt_settings, TTLCache,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
_settings_cache = TTLCache(ttl_seconds=300)
|
|
14
|
+
_key_cache = TTLCache(ttl_seconds=3600)
|
|
15
|
+
|
|
16
|
+
def handle_invoke(body, load_private_key):
|
|
17
|
+
env = extract_envelope(body)
|
|
18
|
+
if env is None:
|
|
19
|
+
... # transitional: fall back to the legacy JOB_SETTINGS_FILE
|
|
20
|
+
settings = decrypt_settings(
|
|
21
|
+
env,
|
|
22
|
+
private_key_loader=load_private_key,
|
|
23
|
+
settings_cache=_settings_cache,
|
|
24
|
+
key_cache=_key_cache,
|
|
25
|
+
)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from .cache import TTLCache
|
|
31
|
+
from .consumer import PrivateKeyLoader, decrypt_settings, open_envelope
|
|
32
|
+
|
|
33
|
+
# NOTE: `seal_settings` (the producer helper) is intentionally NOT re-exported here. This is a
|
|
34
|
+
# consumer library; producing envelopes is the Secrets Provider's job (it owns its own producer —
|
|
35
|
+
# "format is the contract, not shared code"). `seal_settings` remains importable from
|
|
36
|
+
# `utic_invocation_settings.crypto` for tests / dev tooling.
|
|
37
|
+
from .crypto import compute_kid
|
|
38
|
+
from .envelope import (
|
|
39
|
+
CONTENT_ALG,
|
|
40
|
+
FORMAT,
|
|
41
|
+
KEY_ALG,
|
|
42
|
+
ContentEncryption,
|
|
43
|
+
Envelope,
|
|
44
|
+
KeyEncryption,
|
|
45
|
+
protected_aad,
|
|
46
|
+
)
|
|
47
|
+
from .errors import (
|
|
48
|
+
DecryptionError,
|
|
49
|
+
IntegrityError,
|
|
50
|
+
InvocationSettingsError,
|
|
51
|
+
KeyNotFoundError,
|
|
52
|
+
MalformedEnvelopeError,
|
|
53
|
+
UnsupportedFormatError,
|
|
54
|
+
)
|
|
55
|
+
from .invoke import RESERVED_ENVELOPE_KEY, extract_envelope
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"CONTENT_ALG",
|
|
59
|
+
"ContentEncryption",
|
|
60
|
+
"DecryptionError",
|
|
61
|
+
"Envelope",
|
|
62
|
+
"FORMAT",
|
|
63
|
+
"IntegrityError",
|
|
64
|
+
"InvocationSettingsError",
|
|
65
|
+
"KEY_ALG",
|
|
66
|
+
"KeyEncryption",
|
|
67
|
+
"KeyNotFoundError",
|
|
68
|
+
"MalformedEnvelopeError",
|
|
69
|
+
"PrivateKeyLoader",
|
|
70
|
+
"RESERVED_ENVELOPE_KEY",
|
|
71
|
+
"TTLCache",
|
|
72
|
+
"UnsupportedFormatError",
|
|
73
|
+
"compute_kid",
|
|
74
|
+
"decrypt_settings",
|
|
75
|
+
"extract_envelope",
|
|
76
|
+
"open_envelope",
|
|
77
|
+
"protected_aad",
|
|
78
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Canonical JSON + digest helpers. These MUST match the wire contract (envelope-contract-v1.md)
|
|
2
|
+
byte-for-byte, because the producer and consumer both derive digests and GCM AAD from them."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def canonical_json_bytes(obj: Any) -> bytes:
|
|
13
|
+
"""Deterministic JSON: sorted keys, no whitespace, UTF-8, non-ASCII preserved.
|
|
14
|
+
|
|
15
|
+
This is the exact serialization the ``settings_digest`` is taken over and the exact form the
|
|
16
|
+
plaintext is encrypted from, so producer and consumer agree bit-for-bit.
|
|
17
|
+
"""
|
|
18
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
|
|
19
|
+
"utf-8"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sha256_oci(data: bytes) -> str:
|
|
24
|
+
"""OCI-style content digest: ``sha256:<lowercase-hex>``."""
|
|
25
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def b64encode(data: bytes) -> str:
|
|
29
|
+
return base64.b64encode(data).decode("ascii")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def b64decode(data: str) -> bytes:
|
|
33
|
+
# validate=True rejects non-alphabet characters instead of silently discarding them, so a
|
|
34
|
+
# tampered/corrupt envelope field fails closed here rather than sliding into decrypt.
|
|
35
|
+
return base64.b64decode(data, validate=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def b64url_unpadded(data: bytes) -> str:
|
|
39
|
+
"""Unpadded base64url, per RFC 7516 (used for the ``kid``)."""
|
|
40
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""A small thread-safe TTL cache.
|
|
2
|
+
|
|
3
|
+
Two instances are typically used (see ``consumer``): decrypted settings keyed by a full-envelope
|
|
4
|
+
fingerprint, and unwrapped AES keys keyed by recipient ``kid`` plus ``encryption_key_digest``.
|
|
5
|
+
Both keys are derived from public envelope metadata; neither exposes plaintext.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TTLCache:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
ttl_seconds: float,
|
|
20
|
+
maxsize: int = 256,
|
|
21
|
+
time_fn: Callable[[], float] = time.monotonic,
|
|
22
|
+
) -> None:
|
|
23
|
+
if ttl_seconds <= 0:
|
|
24
|
+
raise ValueError("ttl_seconds must be positive")
|
|
25
|
+
if maxsize <= 0:
|
|
26
|
+
raise ValueError("maxsize must be positive")
|
|
27
|
+
self._ttl = ttl_seconds
|
|
28
|
+
self._maxsize = maxsize
|
|
29
|
+
self._time = time_fn
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
# key -> (expires_at, value)
|
|
32
|
+
self._store: dict[str, tuple[float, Any]] = {}
|
|
33
|
+
|
|
34
|
+
def get(self, key: str) -> Any | None:
|
|
35
|
+
now = self._time()
|
|
36
|
+
with self._lock:
|
|
37
|
+
entry = self._store.get(key)
|
|
38
|
+
if entry is None:
|
|
39
|
+
return None
|
|
40
|
+
expires_at, value = entry
|
|
41
|
+
if expires_at <= now:
|
|
42
|
+
self._store.pop(key, None)
|
|
43
|
+
return None
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
def set(self, key: str, value: Any) -> None:
|
|
47
|
+
now = self._time()
|
|
48
|
+
with self._lock:
|
|
49
|
+
# Evict expired entries, then the oldest-expiring if still over capacity.
|
|
50
|
+
if len(self._store) >= self._maxsize and key not in self._store:
|
|
51
|
+
self._evict_locked(now)
|
|
52
|
+
self._store[key] = (now + self._ttl, value)
|
|
53
|
+
|
|
54
|
+
def _evict_locked(self, now: float) -> None:
|
|
55
|
+
expired = [k for k, (exp, _) in self._store.items() if exp <= now]
|
|
56
|
+
for k in expired:
|
|
57
|
+
self._store.pop(k, None)
|
|
58
|
+
while len(self._store) >= self._maxsize:
|
|
59
|
+
oldest = min(self._store, key=lambda k: self._store[k][0])
|
|
60
|
+
self._store.pop(oldest, None)
|
|
61
|
+
|
|
62
|
+
def clear(self) -> None:
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._store.clear()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Consumer orchestration: turn an :class:`Envelope` into plaintext settings, with caching.
|
|
2
|
+
|
|
3
|
+
This is the plugin-facing entry point. The decrypt order follows envelope-contract-v1.md:
|
|
4
|
+
authenticated-envelope cache -> recipient-scoped AES-key cache -> RSA unwrap -> GCM open -> digest
|
|
5
|
+
verify -> parse -> cache.
|
|
6
|
+
The plugin supplies a ``private_key_loader`` that maps a ``kid`` to its RSA private key (loaded from
|
|
7
|
+
whatever secret mount the deployment provides) — the library stays deployment-agnostic.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
17
|
+
|
|
18
|
+
from ._canonical import b64decode, sha256_oci
|
|
19
|
+
from .cache import TTLCache
|
|
20
|
+
from .crypto import _AES256_KEY_BYTES, _GCM_NONCE_BYTES, aesgcm_open, unwrap_aes_key
|
|
21
|
+
from .envelope import FORMAT, Envelope, envelope_fingerprint, protected_aad
|
|
22
|
+
from .errors import (
|
|
23
|
+
DecryptionError,
|
|
24
|
+
IntegrityError,
|
|
25
|
+
KeyNotFoundError,
|
|
26
|
+
MalformedEnvelopeError,
|
|
27
|
+
UnsupportedFormatError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# kid -> RSA private key (or None if this process holds no key for that kid).
|
|
31
|
+
PrivateKeyLoader = Callable[[str], "rsa.RSAPrivateKey | None"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def open_envelope(
|
|
35
|
+
envelope: Envelope,
|
|
36
|
+
*,
|
|
37
|
+
private_key_loader: PrivateKeyLoader,
|
|
38
|
+
key_cache: TTLCache | None = None,
|
|
39
|
+
) -> bytes:
|
|
40
|
+
"""Decrypt an envelope to the raw plaintext settings bytes. Fails closed on any problem."""
|
|
41
|
+
if envelope.format != FORMAT:
|
|
42
|
+
raise UnsupportedFormatError(f"unsupported envelope format: {envelope.format!r}")
|
|
43
|
+
|
|
44
|
+
kid = envelope.key_encryption.kid
|
|
45
|
+
key_digest = envelope.key_encryption.encryption_key_digest
|
|
46
|
+
# The CEK digest is identical when a producer wraps one CEK for multiple recipients. Scope the
|
|
47
|
+
# cache to the recipient so possession of key A never authorizes an envelope addressed to key B.
|
|
48
|
+
key_cache_key = f"{kid}|{key_digest}"
|
|
49
|
+
aes_key: bytes | None = key_cache.get(key_cache_key) if key_cache is not None else None
|
|
50
|
+
|
|
51
|
+
if aes_key is None:
|
|
52
|
+
private_key = private_key_loader(kid)
|
|
53
|
+
if private_key is None:
|
|
54
|
+
raise KeyNotFoundError(f"no private key for kid {kid!r}")
|
|
55
|
+
try:
|
|
56
|
+
aes_key = unwrap_aes_key(private_key, b64decode(envelope.key_encryption.encrypted_key))
|
|
57
|
+
except Exception as exc: # RSA unwrap failure — do not leak detail
|
|
58
|
+
raise DecryptionError("failed to unwrap the content-encryption key") from exc
|
|
59
|
+
if sha256_oci(aes_key) != key_digest:
|
|
60
|
+
raise IntegrityError("unwrapped AES key does not match encryption_key_digest")
|
|
61
|
+
if key_cache is not None:
|
|
62
|
+
key_cache.set(key_cache_key, aes_key)
|
|
63
|
+
|
|
64
|
+
# AESGCM silently accepts 16/24-byte keys and any nonce length, so enforce the A256GCM
|
|
65
|
+
# parameters the envelope header promises before decrypting with them.
|
|
66
|
+
if len(aes_key) != _AES256_KEY_BYTES:
|
|
67
|
+
raise MalformedEnvelopeError(
|
|
68
|
+
f"content-encryption key is {len(aes_key)} bytes; A256GCM requires "
|
|
69
|
+
f"{_AES256_KEY_BYTES}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
iv = b64decode(envelope.content_encryption.iv)
|
|
74
|
+
ciphertext_with_tag = b64decode(envelope.content_encryption.ciphertext)
|
|
75
|
+
except Exception as exc: # invalid base64 — fail closed without detail
|
|
76
|
+
raise DecryptionError("failed to authenticate/decrypt settings ciphertext") from exc
|
|
77
|
+
if len(iv) != _GCM_NONCE_BYTES:
|
|
78
|
+
raise MalformedEnvelopeError(
|
|
79
|
+
f"iv is {len(iv)} bytes; AES-GCM requires exactly {_GCM_NONCE_BYTES}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
plaintext = aesgcm_open(aes_key, iv, ciphertext_with_tag, protected_aad(envelope))
|
|
84
|
+
except Exception as exc: # GCM authentication failure
|
|
85
|
+
raise DecryptionError("failed to authenticate/decrypt settings ciphertext") from exc
|
|
86
|
+
|
|
87
|
+
if sha256_oci(plaintext) != envelope.settings_digest:
|
|
88
|
+
raise IntegrityError("decrypted settings do not match settings_digest")
|
|
89
|
+
return plaintext
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def decrypt_settings(
|
|
93
|
+
envelope: Envelope,
|
|
94
|
+
*,
|
|
95
|
+
private_key_loader: PrivateKeyLoader,
|
|
96
|
+
settings_cache: TTLCache | None = None,
|
|
97
|
+
key_cache: TTLCache | None = None,
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
"""Decrypt + parse settings, using authenticated caches when provided.
|
|
100
|
+
|
|
101
|
+
A cached value is deep-copied on the way out so callers cannot mutate the cache.
|
|
102
|
+
"""
|
|
103
|
+
fingerprint = envelope_fingerprint(envelope)
|
|
104
|
+
if settings_cache is not None:
|
|
105
|
+
cached = settings_cache.get(fingerprint)
|
|
106
|
+
if cached is not None:
|
|
107
|
+
return copy.deepcopy(cached)
|
|
108
|
+
|
|
109
|
+
plaintext = open_envelope(envelope, private_key_loader=private_key_loader, key_cache=key_cache)
|
|
110
|
+
# Normalize decoded plaintext into the declared dict[str, Any] contract; fail closed on
|
|
111
|
+
# malformed JSON or a non-object payload. `plaintext` is bytes, so invalid UTF-8 surfaces as
|
|
112
|
+
# UnicodeDecodeError from json.loads — both mean "not valid JSON". `from None` so no
|
|
113
|
+
# plaintext-bearing detail leaks.
|
|
114
|
+
try:
|
|
115
|
+
settings = json.loads(plaintext)
|
|
116
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
117
|
+
raise IntegrityError("decrypted settings are not valid JSON") from None
|
|
118
|
+
if not isinstance(settings, dict):
|
|
119
|
+
raise IntegrityError("decrypted settings are not a JSON object")
|
|
120
|
+
|
|
121
|
+
if settings_cache is not None:
|
|
122
|
+
settings_cache.set(fingerprint, copy.deepcopy(settings))
|
|
123
|
+
return settings
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Crypto primitives, built directly on ``cryptography`` (no private deps).
|
|
2
|
+
|
|
3
|
+
AES-256-GCM content encryption + RSA-OAEP-256 key wrap, exactly as frozen in
|
|
4
|
+
envelope-contract-v1.md. ``seal_settings`` is the producer helper (used by tests and available to an
|
|
5
|
+
internal producer that prefers to import rather than duplicate); ``open_bytes`` is the raw consumer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
16
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
17
|
+
|
|
18
|
+
from ._canonical import b64decode, b64encode, b64url_unpadded, canonical_json_bytes, sha256_oci
|
|
19
|
+
from .envelope import (
|
|
20
|
+
CONTENT_ALG,
|
|
21
|
+
FORMAT,
|
|
22
|
+
KEY_ALG,
|
|
23
|
+
ContentEncryption,
|
|
24
|
+
Envelope,
|
|
25
|
+
KeyEncryption,
|
|
26
|
+
protected_aad,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_AES256_KEY_BYTES = 32
|
|
30
|
+
_GCM_NONCE_BYTES = 12
|
|
31
|
+
_OAEP = padding.OAEP(
|
|
32
|
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
33
|
+
algorithm=hashes.SHA256(),
|
|
34
|
+
label=None,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def compute_kid(public_key: rsa.RSAPublicKey) -> str:
|
|
39
|
+
"""RFC 7516-style key id: unpadded base64url of SHA-256 over the DER SPKI."""
|
|
40
|
+
spki = public_key.public_bytes(
|
|
41
|
+
encoding=serialization.Encoding.DER,
|
|
42
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
43
|
+
)
|
|
44
|
+
return b64url_unpadded(hashlib.sha256(spki).digest())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def unwrap_aes_key(private_key: rsa.RSAPrivateKey, encrypted_key: bytes) -> bytes:
|
|
48
|
+
"""RSA-OAEP-256 unwrap of the AES key."""
|
|
49
|
+
return private_key.decrypt(encrypted_key, _OAEP)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def aesgcm_open(key: bytes, iv: bytes, ciphertext_with_tag: bytes, aad: bytes) -> bytes:
|
|
53
|
+
"""AES-256-GCM decrypt. Raises on authentication failure (tampered ciphertext/tag/aad)."""
|
|
54
|
+
return AESGCM(key).decrypt(iv, ciphertext_with_tag, aad)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def seal_settings(
|
|
58
|
+
settings: Any,
|
|
59
|
+
recipient_public_key: rsa.RSAPublicKey,
|
|
60
|
+
*,
|
|
61
|
+
aes_key: bytes | None = None,
|
|
62
|
+
iv: bytes | None = None,
|
|
63
|
+
) -> Envelope:
|
|
64
|
+
"""Producer helper: build a v1 envelope for ``settings`` sealed to ``recipient_public_key``.
|
|
65
|
+
|
|
66
|
+
``aes_key``/``iv`` are injectable for deterministic test vectors; otherwise fresh-random.
|
|
67
|
+
"""
|
|
68
|
+
plaintext = canonical_json_bytes(settings)
|
|
69
|
+
aes_key = aes_key if aes_key is not None else AESGCM.generate_key(bit_length=256)
|
|
70
|
+
iv = iv if iv is not None else os.urandom(_GCM_NONCE_BYTES)
|
|
71
|
+
# Reject downgraded/injected material: AESGCM silently accepts 16/24-byte keys and any nonce
|
|
72
|
+
# length, which would produce an envelope whose A256GCM header lies about the cipher strength.
|
|
73
|
+
if len(aes_key) != _AES256_KEY_BYTES:
|
|
74
|
+
raise ValueError("aes_key must be 32 bytes (AES-256) to match the A256GCM envelope header")
|
|
75
|
+
if len(iv) != _GCM_NONCE_BYTES:
|
|
76
|
+
raise ValueError(f"iv must be exactly {_GCM_NONCE_BYTES} bytes for AES-GCM")
|
|
77
|
+
kid = compute_kid(recipient_public_key)
|
|
78
|
+
|
|
79
|
+
# AAD depends only on the protected header, all known pre-encryption.
|
|
80
|
+
aad = canonical_json_bytes({"cea": CONTENT_ALG, "format": FORMAT, "kea": KEY_ALG, "kid": kid})
|
|
81
|
+
ciphertext_with_tag = AESGCM(aes_key).encrypt(iv, plaintext, aad)
|
|
82
|
+
encrypted_key = recipient_public_key.encrypt(aes_key, _OAEP)
|
|
83
|
+
|
|
84
|
+
return Envelope(
|
|
85
|
+
format=FORMAT,
|
|
86
|
+
settings_digest=sha256_oci(plaintext),
|
|
87
|
+
key_encryption=KeyEncryption(
|
|
88
|
+
alg=KEY_ALG,
|
|
89
|
+
kid=kid,
|
|
90
|
+
encryption_key_digest=sha256_oci(aes_key),
|
|
91
|
+
encrypted_key=b64encode(encrypted_key),
|
|
92
|
+
),
|
|
93
|
+
content_encryption=ContentEncryption(
|
|
94
|
+
alg=CONTENT_ALG,
|
|
95
|
+
iv=b64encode(iv),
|
|
96
|
+
ciphertext=b64encode(ciphertext_with_tag),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Generate an example envelope vector for cross-implementation checks.
|
|
2
|
+
|
|
3
|
+
``build_example_vector`` seals a small settings object to a fresh keypair and returns the
|
|
4
|
+
private-key PEM, the envelope, and the original settings. ``python -m
|
|
5
|
+
utic_invocation_settings.devtools.make_vector`` prints a JSON blob a second (e.g. non-Python)
|
|
6
|
+
implementation can decrypt to confirm wire compatibility. RSA keygen is not deterministic, so this
|
|
7
|
+
is an *example* vector, not a frozen one; freeze by checking in a specific output when needed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from cryptography.hazmat.primitives import serialization
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
17
|
+
|
|
18
|
+
from ..crypto import seal_settings
|
|
19
|
+
from ..envelope import Envelope
|
|
20
|
+
|
|
21
|
+
_EXAMPLE_SETTINGS: dict[str, Any] = {
|
|
22
|
+
"job_metadata": {"job_id": "job-123", "tenant_id": "tenant-abc"},
|
|
23
|
+
"connector": {"kind": "example", "base_url": "https://example.invalid"},
|
|
24
|
+
"secret_ref": "sha256:deadbeef",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_example_vector() -> tuple[bytes, Envelope, dict[str, Any]]:
|
|
29
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
|
|
30
|
+
envelope = seal_settings(_EXAMPLE_SETTINGS, private_key.public_key())
|
|
31
|
+
private_pem = private_key.private_bytes(
|
|
32
|
+
encoding=serialization.Encoding.PEM,
|
|
33
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
34
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
35
|
+
)
|
|
36
|
+
return private_pem, envelope, dict(_EXAMPLE_SETTINGS)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main() -> None:
|
|
40
|
+
private_pem, envelope, settings = build_example_vector()
|
|
41
|
+
print(
|
|
42
|
+
json.dumps(
|
|
43
|
+
{
|
|
44
|
+
"private_key_pem": private_pem.decode("ascii"),
|
|
45
|
+
"envelope": envelope.model_dump(mode="json"),
|
|
46
|
+
"expected_settings": settings,
|
|
47
|
+
},
|
|
48
|
+
indent=2,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
main()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The envelope wire model (envelope-contract-v1.md). Pydantic models mirror the frozen JSON shape;
|
|
2
|
+
``protected_aad`` derives the GCM associated data binding ciphertext to its format + recipient."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
from ._canonical import canonical_json_bytes, sha256_oci
|
|
12
|
+
|
|
13
|
+
FORMAT = "u10d.invocation-settings.v1"
|
|
14
|
+
KEY_ALG = "RSA-OAEP-256"
|
|
15
|
+
CONTENT_ALG = "A256GCM"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# The crypto submodels have a fixed shape within a `format` version — any structural change is a
|
|
19
|
+
# new format (a coordinated producer+consumer change), so forbidding unknown fields here fails fast
|
|
20
|
+
# on producer/client drift without costing forward-compat.
|
|
21
|
+
class KeyEncryption(BaseModel):
|
|
22
|
+
model_config = ConfigDict(extra="forbid")
|
|
23
|
+
|
|
24
|
+
alg: Literal["RSA-OAEP-256"]
|
|
25
|
+
kid: str
|
|
26
|
+
encryption_key_digest: str
|
|
27
|
+
encrypted_key: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ContentEncryption(BaseModel):
|
|
31
|
+
model_config = ConfigDict(extra="forbid")
|
|
32
|
+
|
|
33
|
+
alg: Literal["A256GCM"]
|
|
34
|
+
iv: str
|
|
35
|
+
ciphertext: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# NOTE: the top-level Envelope stays lenient (no extra="forbid") on purpose — the v1 wire contract
|
|
39
|
+
# explicitly permits ADDITIVE optional plaintext fields within a format version (that is how
|
|
40
|
+
# `expires_at`/`credential_version` were introduced). Forbidding extras here would break that
|
|
41
|
+
# forward-compatibility. Structural changes to the crypto envelope get a `format` bump instead.
|
|
42
|
+
class Envelope(BaseModel):
|
|
43
|
+
format: str
|
|
44
|
+
settings_digest: str
|
|
45
|
+
key_encryption: KeyEncryption
|
|
46
|
+
content_encryption: ContentEncryption
|
|
47
|
+
# Plaintext, optional. `expires_at` drives the OAuth settings-refresher; `credential_version`
|
|
48
|
+
# lets a consumer tell it received newer material.
|
|
49
|
+
expires_at: datetime | None = None
|
|
50
|
+
credential_version: int | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def envelope_fingerprint(envelope: Envelope) -> str:
|
|
54
|
+
"""Return a cache key for this exact authenticated message.
|
|
55
|
+
|
|
56
|
+
``settings_digest`` identifies the plaintext and is supplied by the sender, so it cannot
|
|
57
|
+
authorize a decrypted-settings cache hit on its own. The fingerprint covers every field that
|
|
58
|
+
participates in recipient selection or authenticated decryption. The two optional advisory
|
|
59
|
+
fields are excluded because v1 does not authenticate them.
|
|
60
|
+
"""
|
|
61
|
+
return sha256_oci(
|
|
62
|
+
canonical_json_bytes(
|
|
63
|
+
envelope.model_dump(exclude={"expires_at", "credential_version"}, mode="json")
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def protected_aad(envelope: Envelope) -> bytes:
|
|
69
|
+
"""GCM associated data = compact canonical JSON of the protected header.
|
|
70
|
+
|
|
71
|
+
Binding format + algs + kid means a swapped or relabeled envelope fails authentication.
|
|
72
|
+
"""
|
|
73
|
+
return canonical_json_bytes(
|
|
74
|
+
{
|
|
75
|
+
"cea": envelope.content_encryption.alg,
|
|
76
|
+
"format": envelope.format,
|
|
77
|
+
"kea": envelope.key_encryption.alg,
|
|
78
|
+
"kid": envelope.key_encryption.kid,
|
|
79
|
+
}
|
|
80
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Error taxonomy. Every failure fails closed — no partial/at-risk plaintext is ever returned."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InvocationSettingsError(Exception):
|
|
7
|
+
"""Base class for all envelope/decrypt errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnsupportedFormatError(InvocationSettingsError):
|
|
11
|
+
"""The envelope ``format`` is not one this library understands."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class KeyNotFoundError(InvocationSettingsError):
|
|
15
|
+
"""No private key was available for the envelope's ``kid``."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DecryptionError(InvocationSettingsError):
|
|
19
|
+
"""RSA unwrap or AES-GCM open failed (includes GCM authentication-tag failure)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IntegrityError(InvocationSettingsError):
|
|
23
|
+
"""A digest did not match the decrypted material (settings or AES key)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MalformedEnvelopeError(InvocationSettingsError):
|
|
27
|
+
"""The supplied value is not a valid envelope: wrong shape, or crypto material (key/IV sizes)
|
|
28
|
+
that violates the frozen v1 contract."""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Wrapper-independent extraction of the envelope from a plugin ``/invoke`` request.
|
|
2
|
+
|
|
3
|
+
Plugins pull the envelope out of a reserved, out-of-schema field of the invoke body themselves,
|
|
4
|
+
rather than declaring it as a handler parameter — so nothing here depends on the ``etl-uvicorn``
|
|
5
|
+
schema generator, and removing that wrapper later changes nothing about settings delivery. See
|
|
6
|
+
cellular-dataplane/prompts/ERA-01-Settings.md (decision 7).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Mapping
|
|
12
|
+
|
|
13
|
+
import pydantic
|
|
14
|
+
|
|
15
|
+
from .envelope import Envelope
|
|
16
|
+
from .errors import MalformedEnvelopeError
|
|
17
|
+
|
|
18
|
+
# Reserved key carrying the settings envelope in the invoke request body.
|
|
19
|
+
RESERVED_ENVELOPE_KEY = "invocation_settings"
|
|
20
|
+
|
|
21
|
+
# Sentinel distinguishing a truly-absent reserved key from one present with a ``None`` value.
|
|
22
|
+
_ABSENT = object()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def extract_envelope(payload: Mapping[str, Any]) -> Envelope | None:
|
|
26
|
+
"""Return the :class:`Envelope` from ``payload[RESERVED_ENVELOPE_KEY]``.
|
|
27
|
+
|
|
28
|
+
Returns ``None`` only when the reserved key is **absent** — the transitional signal to fall
|
|
29
|
+
back to the legacy ``JOB_SETTINGS_FILE`` path. A present key fails closed: a ``null`` (or
|
|
30
|
+
otherwise non-envelope) value is treated as a malformed envelope and raises
|
|
31
|
+
``MalformedEnvelopeError`` rather than silently degrading to the legacy path. This keeps
|
|
32
|
+
"no envelope was sent" and "an envelope was sent but is invalid" distinct.
|
|
33
|
+
|
|
34
|
+
Accepts an already-parsed ``Envelope`` or a raw mapping (which is validated).
|
|
35
|
+
"""
|
|
36
|
+
raw = payload.get(RESERVED_ENVELOPE_KEY, _ABSENT)
|
|
37
|
+
if raw is _ABSENT:
|
|
38
|
+
return None
|
|
39
|
+
if isinstance(raw, Envelope):
|
|
40
|
+
return raw
|
|
41
|
+
# Present-but-null / present-but-not-a-mapping flows here and fails closed via validation.
|
|
42
|
+
try:
|
|
43
|
+
return Envelope.model_validate(raw)
|
|
44
|
+
except pydantic.ValidationError as exc:
|
|
45
|
+
# `from None` so the verbose pydantic error tree doesn't leak into the domain-error
|
|
46
|
+
# boundary — mirrors how `decrypt_settings` translates low-level failures. A short
|
|
47
|
+
# summary (count + first error) is enough signal without dumping the full tree.
|
|
48
|
+
first_error = exc.errors()[0]["msg"]
|
|
49
|
+
raise MalformedEnvelopeError(
|
|
50
|
+
f"invalid envelope: {exc.error_count()} validation error(s), first: {first_error}"
|
|
51
|
+
) from None
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: utic-invocation-settings
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Public library for consuming encrypted Unstructured plugin invocation settings (AES-256-GCM + RSA-OAEP-256 envelope, decrypt + cache).
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: cryptography<47.0.0,>=43.0.0
|
|
7
|
+
Requires-Dist: pydantic<3.0.0,>=2.12.5
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# utic-invocation-settings
|
|
11
|
+
|
|
12
|
+
Public library for **consuming encrypted Unstructured plugin invocation settings** — the plugin-side
|
|
13
|
+
half of the cellular-dataplane "settings in the invoke payload" design. It reads the v1 settings
|
|
14
|
+
envelope (RSA-OAEP-256-wrapped **AES-256-GCM**), decrypts **inside the plugin** at invoke time, and
|
|
15
|
+
caches only previously authenticated envelopes.
|
|
16
|
+
|
|
17
|
+
It is deliberately **self-contained on `cryptography` + `pydantic`** — no dependency on any
|
|
18
|
+
private-feed package — so it can be published to public PyPI and imported by external plugin authors.
|
|
19
|
+
|
|
20
|
+
## Why
|
|
21
|
+
|
|
22
|
+
Under the cellular dataplane, a shared pod may serve multiple tenants, so a plugin identity decrypts
|
|
23
|
+
settings routed to that plugin rather than a shared service handing out plaintext. Settings arrive as an
|
|
24
|
+
opaque ciphertext envelope; this library turns that envelope into a plain settings object, verifying
|
|
25
|
+
integrity and never logging secrets.
|
|
26
|
+
|
|
27
|
+
The wire format is frozen in `cellular-dataplane/docs/envelope-contract-v1.md`. The producer
|
|
28
|
+
(Secrets Provider / operator) emits exactly that shape; this library is the reference consumer.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from utic_invocation_settings import extract_envelope, decrypt_settings, TTLCache
|
|
34
|
+
|
|
35
|
+
_settings_cache = TTLCache(ttl_seconds=300) # keyed by full-envelope fingerprint
|
|
36
|
+
_key_cache = TTLCache(ttl_seconds=3600) # keyed by recipient + encryption-key digest
|
|
37
|
+
|
|
38
|
+
def load_private_key(kid: str):
|
|
39
|
+
# Load the RSA private key for this plugin identity's certificate from the mounted secret.
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
def on_invoke(body: dict):
|
|
43
|
+
env = extract_envelope(body)
|
|
44
|
+
if env is None:
|
|
45
|
+
return load_legacy_job_settings_file() # transitional dual-path
|
|
46
|
+
return decrypt_settings(
|
|
47
|
+
env,
|
|
48
|
+
private_key_loader=load_private_key,
|
|
49
|
+
settings_cache=_settings_cache,
|
|
50
|
+
key_cache=_key_cache,
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Every failure (unknown format, missing key, RSA/GCM failure, digest mismatch) raises a subclass of
|
|
55
|
+
`InvocationSettingsError` — it never returns partial or unverified plaintext.
|
|
56
|
+
|
|
57
|
+
## Develop
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
make install # uv sync
|
|
61
|
+
make test # unit tests + coverage
|
|
62
|
+
make check # ruff
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Note:** published to public PyPI on merge to main (see repo README).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
utic_invocation_settings/__init__.py,sha256=euJ7WEnA8LGJA0X1oCMrbph1lPZg82vs6TpCCiHZWj0,2293
|
|
2
|
+
utic_invocation_settings/_canonical.py,sha256=bOmbWg--WdoecJg2QFGHMjPNvnLTELOSclBS2kFfHSI,1418
|
|
3
|
+
utic_invocation_settings/cache.py,sha256=fqMwWDx_Jv6dDj4gJRid6G66NqfqKkeQk4nTzBZ6YjA,2153
|
|
4
|
+
utic_invocation_settings/consumer.py,sha256=ZLrbi1DbKy2eTGsADR4S06kFEDKRFXvxdvzwFfXGNZM,5333
|
|
5
|
+
utic_invocation_settings/crypto.py,sha256=MyPDiZtA-xRpFy5Exy846DnS2aperrPdg4ogSHrnslw,3674
|
|
6
|
+
utic_invocation_settings/envelope.py,sha256=bJOyxssgXRwmHK69cl0IjDuS3E5_Jv_kWcXwA2xXFcA,2942
|
|
7
|
+
utic_invocation_settings/errors.py,sha256=ANnrx8xAEn8rLa4nzmZJCvKuCQFiwYJcTVUJK9zWnu4,945
|
|
8
|
+
utic_invocation_settings/invoke.py,sha256=2QYKvD1gjfU3GutVspBQeAJX9yFOHsseHYz_0DmanIk,2333
|
|
9
|
+
utic_invocation_settings/devtools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
utic_invocation_settings/devtools/make_vector.py,sha256=Hf_6nRgbU_VEkvzfIrx-H1M6LkInsfYIVs2BwuUjKwE,1890
|
|
11
|
+
utic_invocation_settings-0.1.0.dist-info/METADATA,sha256=RYc07q4yYTDtSS91RNqeRmQvJGuSzdrNiK5dKRo8IiI,2587
|
|
12
|
+
utic_invocation_settings-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
13
|
+
utic_invocation_settings-0.1.0.dist-info/RECORD,,
|