How Kenimai Protects Your Data
What happens to your data: where it lives, how it's encrypted, who can read what, and where the limits are.
Shared databases
The typical bot architecture is a single database with a users table, a reminders table, maybe a passwords table. Every user's data sits in the same tables, separated by a WHERE user_id = ? clause that the application code hopefully remembers to include.
A single SQL injection, a missed filter, or a leaked database credential exposes everyone. Even “encrypted” databases protect nothing if the key sits next to the data on the same server, which it usually does.
Kenimai uses a different architecture.
Per-user isolation
One database per user, per app. Every user gets their own encrypted SQLite database file for each app (referred to technically as a “plugin”) that stores data. Not rows in a shared table with a user ID column, but a physically separate file on disk, encrypted with a key derived specifically for that user.
If you use Reminders and Notes, that's two encrypted files of your own. Another user has their own two files. The files are encrypted with different keys. There is no shared database where a missing WHERE clause could leak someone else's data, because someone else's data is in a different file entirely.
This is more expensive to operate than a shared database. We chose it because the alternative, trusting application code to always filter correctly, is exactly the kind of thing that fails.
App sandboxing
Apps never touch the database. Having separate files is only half the protection. If an app can open any file, the isolation is organizational, not enforced. So apps don't get access at all.
Each app runs in its own container, but the app container has no access to the encrypted user database files and no encryption keys. It runs on a read-only filesystem with dropped capabilities. It can execute app logic and nothing else.
So how does it read and write data?
Through a dedicated companion container we call a database sidecar. Every app that stores data gets one. The sidecar holds the encryption keys (in memory, never on disk), manages the encryption engine, and translates structured requests into encrypted database operations.
The app sends a request like “find all reminders where category is daily” through a message bus. The sidecar receives it, verifies the caller's identity token, opens the encrypted file for that specific user, runs the query, and sends the results back.
The app never sees the encryption key. Never opens a database file. Never runs SQL. Every read and write goes through the sidecar, gated by a short-lived token that core signs and the app cannot forge. A compromised app container cannot reach another user's data or another app's data, cannot extract the keys, and cannot touch the database files directly.
Message flow
Let's trace what happens when you send remind me to call mom at 5pm on Discord. It crosses four trust zones and three boundaries, and your identity is proven by a short-lived token the app itself never holds.
- PlatformDiscordAuthenticates you with your Discord credentials. We never see them.
- BOUNDARY 1 OF 3 · platform-verified message enters core
- Core · Trusted
- Platform Adapter · builds request context
- Identity Service · Discord ID → Kenimai UUID
- Agent System · routes to the Reminders app
Plugin BridgeSigns short-lived identity token → checked at the sidecar boundary - BUS BOUNDARY 2 OF 3 · ACL · reminders channel only
- App Container · SandboxedReminders AppProcesses command, sends structured write requestno keys, no files, no SQL
- BUS BOUNDARY 3 OF 3 · ACL · reminders sidecar only
- Your DataDB Sidecarverify(token) → derive your keyOpens your database fileyour-uuid.db · ChaCha20-Poly1305Reminder written, encrypted at rest
Every interaction crosses these layers before any data is written. The reply path runs the same way in reverse. When the reminder is due, the scheduler reads it back through the sidecar, hands it to core, and core sends it to you on the platform you used. The return trip crosses the same boundaries, so the message that reaches you at 5pm is gated exactly like the one you sent.
Defense in depth
Your data doesn't sit behind a single wall. An attacker would have to get through every one of these layers to reach anything useful.
An attacker who breaks into an app container still faces channel isolation, the sidecar's token verification, encryption at rest, and the fact that the keys aren't even in the same container.
Prompt injection
Prompt injection is a real risk for any system built on an LLM: crafted text tries to make the model ignore its instructions and do something it should not. The question that matters for your data is whether an injection can cross from one person's account into another's.
It cannot change who you are. Your identity comes from the platform you signed in from and is carried in a token that core signs. The model only chooses which app to call and with what parameters, never whose account it runs in. So an injection always runs inside the account of the person whose message it arrived on, on that person's own data.
On the trusted server, MessageHandler resolves your identity (globalUserId a1b2c3) and Plugin Bridge signs it into a server-held token.
Only the message content crosses into the untrusted zone. The LLM sees no identity and returns only agent, text, time.
The DB sidecar opens your file (a1b2c3.db) from the verified token only. The LLM output becomes bound SQL params, never the file choice.
If the model is tricked into putting a different user_id into a database call, the sidecar ignores it. Every call is scoped to the signed identity, not to fields the model supplied. If the model is steered to the password manager, that still requires your master password before it returns anything.
This is the boundary that holds: an injection can only act inside the account of the person whose message carried it. It can never act as a different user or open another user's file, because identity is cryptographic, not something the model can be talked into. At worst, an injection makes the agent do something pointless inside that one account.
One risk this does not remove is common to every LLM assistant: text that reaches your own session, such as content you ask the agent to read, could try to steer your agent within your account. That stays confined to your account and your own data. We mitigate it, but we do not claim to have eliminated it.
Dashboard security
The dashboard is not a separate system. Web dashboards are typically the weakest part of a bot's security. They often bypass the bot's architecture entirely and query a shared database directly, creating a parallel access path with different (usually weaker) protections.
The dashboard uses the exact same isolation model as chat. Every API call constructs a MessageContext from the authenticated session, binds your globalUserId, and routes through the same plugin bridge and sidecar flow. The same identity tokens, the same per-user encrypted databases, the same capability execution.
Authentication uses session cookies (httpOnly, secure, sameSite=strict) with magic link login. No passwords are stored. Each app explicitly whitelists which capabilities the dashboard can invoke.
If you can't reach another user's data through chat, you can't reach it through the dashboard either. It's the same code path.
Encryption tiers
The system-wide encryption described above is server-side: we derive the keys, so we could theoretically access the data. For most apps (reminders, price alerts, notes) this is a reasonable tradeoff because the alternative (making you enter a password for every bot interaction) would make the bot unusable.
The password manager adds a second encryption layer on top. Your master password derives a separate key via Argon2id (64MB memory cost, 3 iterations). This derived key encrypts your vault entries with AES-256-GCM before they reach the database. We cannot decrypt your stored credentials, even with full database access. (Service names like “Netflix” or “Gmail” are stored in plaintext so you can ask “what is my Netflix password” without unlocking the entire vault first. The actual passwords, usernames, and URLs are not.)
To be clear about what this is and isn't: this is not a zero-knowledge architecture. In a chat bot context, your master password is sent to our server (over HTTPS) where key derivation happens. We store only a hash of the password, never the password itself, and the derived key is held in server memory for 15 minutes so you don't have to re-authenticate on every operation. After 15 minutes of inactivity, it is cleared and you'll need to enter your master password again.
How you authenticate determines what third parties can see:
Both paths start the same: master password sent over HTTPS
Both paths: Argon2id derivation / AES-256-GCM encryption / derived key held 15 min then cleared
We think this distinction matters. Most of your data is encrypted with server-derived keys (we could technically read it). Your passwords are encrypted with a key derived from your master password (we cannot, unless we intercept it during authentication). We'd rather explain the tradeoff than let you assume everything is zero-knowledge when it isn't.
Infrastructure
The master encryption key (from which all per-app and per-user keys are derived) is stored in HashiCorp Vault, not in environment variables or config files. The core service authenticates to Vault via AppRole at startup, retrieves the key, and distributes derived per-app keys to each sidecar over the internal message bus. No sidecar ever sees the master key itself.
All external traffic passes through a Cloudflare Tunnel. No ports are exposed to the internet. Every service binds to localhost only. The server's IP address is never revealed. External connections are TLS-encrypted end-to-end by Cloudflare.
Internal container-to-container traffic is encrypted at two levels. The message bus uses TLS with a self-signed internal CA (plaintext port disabled entirely). The container overlay network adds a second layer of IPsec encryption on all inter-container frames (in Swarm mode). Each service authenticates to the message bus with unique credentials and ACL-scoped permissions.
Identity-bearing messages are also HMAC-SHA256 signed at the application layer using a dedicated signing key (separate from the encryption master key). Each signed message includes a timestamp to prevent replay attacks.
Your messages are never logged in production. The logging system replaces message content with character counts ([User: 142 chars]) and system prompts with hashes. This is enforced at the logging layer, not by convention.
Known limitations
The system is designed to survive a compromised app container. That's the primary threat model and the most likely attack vector. Here's what it doesn't protect against:
Core compromise - If an attacker gains access to the core service or host, they have the master key and can derive all encryption keys (including from sidecar memory). This is an inherent limitation of server-side encryption. However, reaching the core requires chaining multiple exploits: there are no exposed ports (all traffic passes through Cloudflare Tunnel), every container runs with dropped capabilities on a read-only filesystem, and Redis ACLs prevent app containers from accessing core channels. The most realistic path would be a supply chain attack on a dependency, which would still be contained by container isolation. We are exploring hardware-isolated key management (such as HSMs or trusted execution environments like Intel SGX) as a future mitigation, which would prevent the master key from being read even by a compromised host or core process.
Backup retention - When you delete your account, live data is purged immediately; encrypted backups expire within the retention window described under Data rights below.
Compromised app, active session - An app only reaches your data through a short-lived token that core issues while you are using that app. If an attacker fully compromised a specific app's container, they could act on the data of users actively using that one app during that window. They still could not reach other users, other apps, the encryption keys, or the database files directly.
No system is perfectly secure. Ours is designed so that the most likely attack (a vulnerable app) yields nothing useful, and escalation requires compromising increasingly hardened components with increasingly narrow access.
Data rights
Deletion: Delete your account from the dashboard or via chat. 30-day grace period to change your mind. After that, all data is permanently purged across every app and database. Encrypted backups naturally expire within our retention window (up to 5 weeks). We do not yet support selective deletion from backup snapshots.
Export: Download all your data in a portable format. Re-authentication required for security. Password vaults stay encrypted in the export; you decrypt them locally with your master password.
GDPR: We support GDPR data subject rights including access, portability, and erasure. See our privacy policy for details.