Security
How we handle your data, linked to source.
listbull stores your /password secrets and OpenRouter keys AES-256-GCM-encrypted; scopes every read and write to your chat; and authenticates the Telegram webhook with a constant-time secret check. Each claim below links to the actual code so you can verify it. See the disclaimer at the bottom for what's out of scope.
Encryption at rest
AES-256-GCM via ENV_KEY. Plaintext never reaches the database, never enters the activity log, never appears in any log statement.
Algorithm
AES-256-GCM with a 12-byte random IV per encryption and a 128-bit auth tag. Envelope format: base64(iv ‖ authTag ‖ ciphertext). node:crypto only.
encryption.tsWhat's encrypted
/password payloads in items.secret_encrypted; per-chat BYOK OpenRouter keys in chats.openrouter_api_key_encrypted. Both opaque envelope strings in TEXT columns.
schema.ts (secret_encrypted)Reveal flow
Decryption is lazy. Plaintext goes to Telegram as HTML <code> for tap-to-copy, then auto-deletes after 15 seconds. The activity_log row records {label, suffix} only.
reveal-secret.tsMulti-tenant isolation
Every Telegram chat is a tenant. No query reads or writes another chat's data; every callback handler verifies chat ownership before mutation.
Query scoping
Every executor under src/lib/server/tools/ filters by ctx.chatId before any read or write. Search, create, update, complete, delete — same pattern.
search-items.tsCallback verification
When a user taps an inline button like item:toggle:<uuid>, the handler enforces and(eq(items.id, uuid), eq(items.chatId, currentChatId)) before mutation. A guessed UUID from another chat resolves to nothing.
item-action-callback.tsWebhook authentication
Every webhook request must carry X-Telegram-Bot-Api-Secret-Token. Verified with timingSafeEqual to prevent timing oracles.
webhook/route.tsForce-reply contexts
Multi-step flows (e.g. saving a password) key on the composite (chatId, messageId) — never on messageId alone. Replay across chats impossible.
bot-action-contexts.tsIn transit
HTTPS-only end-to-end; the app never listens on a public port directly.
TLS termination
Docker Compose binds the app to 127.0.0.1:3000. A reverse proxy (Caddy / Traefik / Cloudflare) terminates TLS and forwards to localhost.
Outbound calls
Two external destinations: Telegram (the chat surface) and OpenRouter (the LLM turn). No analytics outbound by default — Sentry + Umami are opt-in via build args.
Logging discipline
No plaintext secret material is logged. The activity_log table is the only audit surface.
Decrypt failures
If decryption errors, the log line records itemId + a generic error message — never the ciphertext, never the key, never the plaintext.
reveal-secret.ts (error path)Activity-log payloads
For secret events, payload_after records {label, secretSuffix} only. The encrypted blob is explicitly excluded.
handle-message.ts (secret_created)