Skip to content

Protecting and recovering application values

Encryption lets you recover sensitive text using a stable application key. Choose a format and plan key custody before storing the first tokens.

  • Python 3.14 or newer.
  • cryptography>=50.0.1,<51.0 and msgspec>=0.21.1, installed by Orionis.
  • An exact 16-byte key for AES-128 or 32-byte key for AES-256.
  • A booted Orionis container when using the Crypt facade.
import os
from orionis.encrypter import Encrypter
class App:
key = os.urandom(32)
def config(self, name: str):
return {
"app.key": self.key,
"app.cipher": "AES-256-GCM",
}[name]
crypt = Encrypter(App())
token = crypt.encrypt("private value")
assert token != "private value"
assert crypt.decrypt(token) == "private value"
print("round trip ok")

Save this standalone program as check_encryption.py at the application root and run uv run python check_encryption.py. It prints round trip ok. Its random key exists only in that process: restarting it cannot decrypt earlier tokens. In application code use the configured Crypt facade and a stable APP_KEY; do not generate a new key for each request.

The API encrypts non-empty text. Serialize structured documents first and store the complete opaque token in a sufficiently wide column. IV, tag, JSON, and two Base64 layers increase its size; a field sized for the original text may be too small. The library adds no expiration, associated context, key identifier, or replay protection.

Explicitly select AES-256-GCM for a new workflow requiring authenticity. Current configuration defaults to AES-256-CBC; CBC checks padding but includes no MAC. Successful CBC decryption does not prove that content was never modified. Encryption also does not decide which user may recover a value: authorize access before calling decrypt().

The b"k" * 32 keys in examples are test fixtures. In production retain the random key in your secret management system and share a stable value across workers and deployments that must read the same tokens. Do not replace APP_KEY without a migration: there is no key ring or automatic lookup of previous keys.

Retain access to the original key and cipher during migration. Decrypt with the previous configuration, encrypt with the new one, and replace a record only after both operations succeed. An interrupted migration must resume without confusing versions; version markers and key custody belong to the application. Include backups in the same plan. Never indiscriminately try keys in a public endpoint.

For a new application flow using authenticated encryption, set APP_CIPHER=AES-256-GCM before storing values and retain the corresponding 32-byte APP_KEY. If the project already contains encrypted values, follow the rotation process instead of switching the cipher directly. Crypt.encrypt and Crypt.decrypt are synchronous; do not add await.

One Encrypter instance is fixed to the configured cipher and key. Supported identifiers are AES-128-CBC, AES-256-CBC, AES-128-GCM, and AES-256-GCM. A payload can only be decrypted by an instance configured with the same cipher and key.

encrypt() UTF-8 encodes the text, creates a fresh random IV, encrypts it, serializes iv, value, tag, and cipher as JSON, then Base64-encodes the JSON. Binary fields are themselves Base64 strings. The format is transportable text, not a hash.

GCM authenticates the ciphertext and rejects modification through its 16-byte tag. CBC uses PKCS7 padding but this implementation does not add a MAC; prefer GCM for new security-sensitive data. Never expose, log, or commit the application key.

Use Crypt.encrypt(value) after application boot, persist the returned token as opaque text, and call Crypt.decrypt(token) only at the trust boundary that needs plaintext.

Old tokens do not decrypt under a new key or a different configured cipher. A safe rotation therefore needs a versioned key strategy or a controlled read-old/write-new migration before configuration changes.

Supply a small app double whose config() returns normalized bytes and a cipher string. This avoids global facade state and makes round-trip and failure cases deterministic except for token bytes.

from orionis.encrypter import Encrypter
class App:
def __init__(self, cipher: str) -> None:
size = 16 if cipher.startswith("AES-128") else 32
self.values = {"app.key": b"k" * size, "app.cipher": cipher}
def config(self, name: str):
return self.values[name]
for cipher in sorted(Encrypter.SUPPORTED_CIPHERS):
service = Encrypter(App(cipher))
assert service.decrypt(service.encrypt("Orionis")) == "Orionis"
print("four ciphers ok")
import base64
import json
from orionis.encrypter import Encrypter
class App:
def config(self, name: str):
return {"app.key": b"k" * 32, "app.cipher": "AES-256-GCM"}[name]
token = Encrypter(App()).encrypt("inspect me")
envelope = json.loads(base64.b64decode(token))
assert envelope["cipher"] == "AES-256-GCM"
assert envelope["tag"] is not None
print(sorted(envelope))
import base64
import json
from orionis.encrypter import Encrypter
class App:
def config(self, name: str):
return {"app.key": b"k" * 32, "app.cipher": "AES-256-GCM"}[name]
crypt = Encrypter(App())
envelope = json.loads(base64.b64decode(crypt.encrypt("sealed")))
changed = bytearray(base64.b64decode(envelope["value"]))
changed[0] ^= 1
envelope["value"] = base64.b64encode(changed).decode()
tampered = base64.b64encode(json.dumps(envelope).encode()).decode()
try:
crypt.decrypt(tampered)
except RuntimeError:
print("tampering rejected")
from orionis.support.facades import Crypt
token = Crypt.encrypt("private value")
plaintext = Crypt.decrypt(token)
assert plaintext == "private value"

config/app.py supplies the settings:

SettingEnvironmentMeaning
app.cipherAPP_CIPHEROne of the four supported AES identifiers; default is AES-256-CBC.
app.keyAPP_KEYRaw text or base64:-prefixed key with exactly the cipher’s required byte length.

The validated application config converts the key to bytes. If no key is configured, SecureKeyGenerator creates a random Laravel-compatible base64: value, persists it through Env.set, and normalizes it. Persist a stable production key: losing it makes existing ciphertext unrecoverable.

EncrypterProvider is a core, non-deferred provider. It binds IEncrypter to Encrypter as a singleton, then pins Crypt during async boot so synchronous calls do not return a deferred dispatcher.

Stringable.encrypt/decrypt, registration flows, view globals, and MCP state use the service or facade. Dependency-injected code can request IEncrypter; ordinary application code can import Crypt from orionis.support.facades.

  • Empty plaintext and payload strings raise ValueError; non-strings raise TypeError.
  • Unsupported ciphers and wrong key sizes fail at construction.
  • Invalid outer Base64/JSON or typed fields raise ValueError.
  • Payload cipher mismatch and invalid IV size raise ValueError before decryption.
  • GCM requires a 16-byte tag; authentication failures surface as wrapped RuntimeError.
  • CBC rejects empty ciphertext and invalid PKCS7 padding, but it does not authenticate metadata or ciphertext.
  • The plaintext must be valid UTF-8 text; this API is not a raw-byte encryption interface.

The implementation is synchronous and CPU-bound. GCM caches one immutable AESGCM object per service; CBC creates a cipher context per call. Payload encoding uses reusable msgspec structures/decoder. Each call keeps data local and obtains a fresh IV from os.urandom, so the singleton can serve concurrent callers without mutable per-operation state.

Token size includes JSON and two Base64 layers plus IV/tag overhead. Avoid using encryption as a streaming format for large files; use a purpose-built streaming scheme and authenticated mode instead.

Reads app.key as bytes and app.cipher as a supported string. Construction raises ValueError for an unsupported cipher or an incorrect key length. Constants such as SUPPORTED_CIPHERS, key sizes, IV sizes, and tag size describe the accepted wire format.

Accepts a non-empty string and returns a new Base64 envelope. A random IV means encrypting the same plaintext twice should produce different tokens. Invalid input raises TypeError or ValueError; cryptographic failures are wrapped in RuntimeError.

Decodes and validates the envelope, requires its cipher to match configuration, checks IV/tag sizes, decrypts, and UTF-8 decodes the result. It rejects empty/non-string input, malformed envelopes, mismatched ciphers, bad padding, invalid tags, and authentication failures.

The contract declares only synchronous encrypt and decrypt. Import it from orionis.encrypter.contracts when typing a container-resolved dependency.


Index · foundation · hashing