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.
Requirements
Section titled “Requirements”- Python 3.14 or newer.
cryptography>=50.0.1,<51.0andmsgspec>=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
Cryptfacade.
Quick start
Section titled “Quick start”import osfrom 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.
Prepare secret storage
Section titled “Prepare secret storage”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.
Controlled rotation
Section titled “Controlled rotation”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.
Core concepts
Section titled “Core concepts”Cipher selection
Section titled “Cipher selection”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.
Envelope format
Section titled “Envelope format”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.
Authentication
Section titled “Authentication”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.
Common workflows
Section titled “Common workflows”Encrypt application values
Section titled “Encrypt application values”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.
Rotate a key or cipher
Section titled “Rotate a key or cipher”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.
Test a service directly
Section titled “Test a service directly”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.
Examples
Section titled “Examples”Exercise every supported cipher
Section titled “Exercise every supported cipher”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")Inspect envelope metadata
Section titled “Inspect envelope metadata”import base64import jsonfrom 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 Noneprint(sorted(envelope))Reject a modified GCM value
Section titled “Reject a modified GCM value”import base64import jsonfrom 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] ^= 1envelope["value"] = base64.b64encode(changed).decode()tampered = base64.b64encode(json.dumps(envelope).encode()).decode()
try: crypt.decrypt(tampered)except RuntimeError: print("tampering rejected")Use the application facade
Section titled “Use the application facade”from orionis.support.facades import Crypt
token = Crypt.encrypt("private value")plaintext = Crypt.decrypt(token)assert plaintext == "private value"Configuration
Section titled “Configuration”config/app.py supplies the settings:
| Setting | Environment | Meaning |
|---|---|---|
app.cipher | APP_CIPHER | One of the four supported AES identifiers; default is AES-256-CBC. |
app.key | APP_KEY | Raw 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.
Integration with Orionis
Section titled “Integration with Orionis”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.
Errors and edge cases
Section titled “Errors and edge cases”- Empty plaintext and payload strings raise
ValueError; non-strings raiseTypeError. - 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
ValueErrorbefore 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.
Performance and concurrency
Section titled “Performance and concurrency”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.
Public API
Section titled “Public API”Encrypter(app)
Section titled “Encrypter(app)”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.
encrypt(plaintext: str) -> str
Section titled “encrypt(plaintext: str) -> str”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.
decrypt(payload: str) -> str
Section titled “decrypt(payload: str) -> str”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.
IEncrypter
Section titled “IEncrypter”The contract declares only synchronous encrypt and decrypt. Import it from orionis.encrypter.contracts when typing a container-resolved dependency.
Index · foundation · hashing