Encrypted Session Store

Encrypt session transcripts at rest — wrap any store to keep message content off disk in plaintext

EncryptedSessionStore wraps any session store so message content and metadata are encrypted before they reach disk — keys stay in PRAISONAI_SESSION_KEY, plaintext never lands on the filesystem.

Diagram

Encryption at Rest

🤖 Agent

🔐 EncryptedSessionStore

💾 SqliteSessionStore

📁 Ciphertext on disk

Quick Start

  1. Enable in three lines
    python
    import os
    from praisonaiagents import Agent
    from praisonaiagents.session import SqliteSessionStore, EncryptedSessionStore
    
    store = EncryptedSessionStore(
        SqliteSessionStore(path="~/.praisonai/sessions.db"),
        key=os.environ["PRAISONAI_SESSION_KEY"],
    )
    
    agent = Agent(name="Assistant", session_store=store)
    agent.start("My card is 4111 1111 1111 1111")   # persisted as ciphertext
  2. Generate a key
    python
    from praisonaiagents.session import generate_session_key
    
    print(generate_session_key())   # store this somewhere safe

    Or on the CLI:

    bash
    python -c "from praisonaiagents.session import generate_session_key; print(generate_session_key())"
    export PRAISONAI_SESSION_KEY="<paste-the-key>"

How It Works

The wrapper encrypts content on the way in and decrypts it on the way out — the inner store only ever sees ciphertext.

Diagram
DiskInner StoreEncryptedSessionStoreAgentUserDiskInner StoreEncryptedSessionStoreAgentUser“My card is 4111…”add_message(session, “user”, plaintext)Fernet.encrypt(plaintext)add_message(…, “praisonai:enc:v1:…”)write ciphertext(later) resume sessionget_chat_history(session)get_chat_history(session)read ciphertextrows with ciphertextFernet.decrypt each rowrows with plaintextcontinues conversation

What is encrypted / not encrypted

FieldEncrypted?Why
content (message body)✅ YesPrimary sensitive data
metadata (dict)✅ YesMay contain PII; wrapped as {"__enc__": ciphertext}
tool_calls (function arguments)✅ YesArguments can carry sensitive data
role (user / assistant / system)❌ NoStore indexes and orders on it
session_id❌ NoStore uses it as primary key
timestamp❌ NoStore orders on it
tool_call_id❌ NoOpaque correlation id for turn linking

Configuration Options

The wrapper takes the inner store and a Fernet key. Everything else is inherited from whatever store you wrap.

Constructor argTypeDefaultDescription
storeany session store— (required)The inner store to wrap. SqliteSessionStore, DefaultSessionStore, HierarchicalSessionStore, or any custom store.
keystr \bytes— (required)Fernet key. Empty string → SessionEncryptionError. Malformed → SessionEncryptionError.
AttributeValueMeaning
EncryptedSessionStore.PREFIX"praisonai:enc:v1:"Marks a value this wrapper produced — plaintext rows written before encryption was enabled are recognised by the absence of this prefix and returned as-is.

Errors and Honest Limits

These conditions are designed in, not accidental — each fails loudly rather than silently.

search() raises instead of returning []. Stored content is ciphertext, so substring matching cannot work. An empty list would be indistinguishable from a genuine miss — the more dangerous answer. Callers should get_chat_history() and filter in memory, or use an unencrypted store when search matters more than confidentiality.

Wrong key raises SessionEncryptionError rather than yielding gibberish:

“the key does not match the one it was written with. Session transcripts are unrecoverable without their original key.”

Empty or malformed key is refused at construction time — fails fast rather than at first use.

Losing the key loses the transcripts. By design — no recovery path.


Enabling encryption on an existing store does not break history

Switching encryption on leaves earlier plaintext rows readable — they are recognised and returned untouched.

Diagram

Yes

No

📖 Read row

starts with
praisonai:enc:v1:?

🔓 Decrypt

↩️ Return as-is

✅ plaintext

  • Rows written before encryption was switched on lack the praisonai:enc:v1: prefix and are returned as-is — not fed to the decrypter, not reported as corrupt.
  • Legacy metadata containing an __enc__ key as regular data (not our envelope) is preserved intact — the wrapper recognises only the exact one-key envelope shape with the crypto prefix.
  • Unwrapped methods are forwarded via __getattr__, so wrapping cannot silently drop capabilities the inner store has.

Common Patterns

Opt-in via environment

Leaving PRAISONAI_SESSION_KEY unset yields the plain store, so existing deployments are unaffected until they set the env var.

python
import os
from praisonaiagents import Agent
from praisonaiagents.session import SqliteSessionStore, EncryptedSessionStore

def build_store():
    inner = SqliteSessionStore(db_path="~/.praisonai/sessions.db")
    key = os.environ.get("PRAISONAI_SESSION_KEY")
    return EncryptedSessionStore(inner, key=key) if key else inner

agent = Agent(name="Support", session_store=build_store())

Filter in memory instead of search()

python
history = store.get_chat_history("chat-42")
matches = [m for m in history if "refund" in m["content"].lower()]

Best Practices

Keep the key out of source and out of the container image

Load from PRAISONAI_SESSION_KEY or a secrets manager — never commit a Fernet key.

Rotate carefully

There is no automatic re-encryption path. Rotating requires re-writing existing sessions with the new key (out of scope for this wrapper).

Filter in memory instead of using search()

When you need to find something in an encrypted session, get_chat_history() + Python filtering is the supported path.

Choose a different design if session ids must also be confidential

Ids, roles, and timestamps stay readable — the store indexes on them.


Related