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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any