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.

01

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.

What'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.

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.

02

Multi-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.

Callback 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.

Webhook authentication

Every webhook request must carry X-Telegram-Bot-Api-Secret-Token. Verified with timingSafeEqual to prevent timing oracles.

Force-reply contexts

Multi-step flows (e.g. saving a password) key on the composite (chatId, messageId) — never on messageId alone. Replay across chats impossible.

03

In 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.

04

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.

Activity-log payloads

For secret events, payload_after records {label, secretSuffix} only. The encrypted blob is explicitly excluded.