Encrypted bots
An encrypted bot holds a key pair on its own server. People's phones seal what they send it to that key; Qivuna's server relays the ciphertext and cannot read it. This page says exactly what that protects, and what it does not.
404 not_found until encrypted bots open. The changelog will say when.The model in one paragraph#
The two ends are a person's phone and your bot's server. Qivuna's server stores and forwards envelopes, and holds the directory of keys. “End-to-end encrypted” therefore means: Qivuna cannot read it; whoever runs the bot can. The app says this in the chat's label — “🤖 Bot · End-to-end encrypted to the bot. Only the bot's developer can read what you send it.” — and you must not suggest otherwise to your users.
What Qivuna's server sees#
| Qivuna's server cannot read | Qivuna's server does see |
|---|---|
|
|
Files are not part of encrypted bot messages in v1: an encrypted bot cannot send files, and a file a member attaches while addressing a bot goes to the group only.
Bot keys and owner certification#
The bot key#
The SDK draws 32 random bytes on your server as the X25519 private key, and derives the Ed25519 signing key from it:
x25519_private = 32 random bytes (never leaves your server)
ed25519_seed = HKDF-SHA256(ikm = x25519_private, salt = "", info = "Qivuna.AccountSigning.v1", L = 32)
fingerprint = base64(SHA-256(x25519_public ‖ ed25519_public))[0:22] ("security code")
It proposes the public halves with setMyEncryptionKey, proving it holds the private key:
pop = Ed25519(ed25519_seed, "Qivuna.Bot.v1.pop|" ‖ bot_number ‖ x25519_public ‖ ed25519_public ‖ uint64_be(t))
t is the current time in milliseconds (±10 minutes); a proof is accepted once, a key used by another bot or a weak (small-order) key is refused, and a bot may propose 5 keys an hour. Proposing changes nothing for anybody. QivBot only asks the owner to confirm.
The owner's certificate#
The owner opens QivBot → the bot → Encryption → Confirm new code. The confirmation screen computes the security code on the phone, from the exact key it is about to sign — never from a value the server sends. The owner compares it with the code your server printed, taps It matches, confirms with their authenticator code or 2-step password, and the phone signs a key certificate with the owner's own account signing key:
bk1|<base64(J)>|<base64(Ed25519(owner signing key, "Qivuna.Bot.v1.keycert|" ‖ J))>
J = {"v":1,"bot":<bot user id>,"num":"7123456789","x":"<x25519>","e":"<ed25519>",
"st":"active","dev":<owner user id>,"seq":<n>,"t":<unix ms>}
People's phones accept a bot key only through such a certificate, verified under the owner's signing key (which each phone pins like any contact's). They seal only to the current key: the newest verified active certificate from the bot's current owner that no verified revocation names. A message signed by any other certified key — superseded, revoked, or certified by a previous owner — is shown with “Sent with an old security code”; one signed by an uncertified key is shown as not from the bot.
getMyEncryptionKey which key is active. If an owner confirmed a key that is not your server's — by mistake, or because someone else proposed it — phones would seal to that key and its holder could read what people send the bot. Comparing the security code on the confirmation screen with the one your server printed is what prevents this. The SDKs also raise foreign_proposal when a proposal they did not make is waiting, and foreign_active_key if such a key becomes active (see Key alerts).Rotation, revocation and transfer#
- Rotation: propose a new key; when the owner confirms, the phone signs in one step a revocation of the old key and the certificate of the new one. Queued encrypted updates sealed to the old key are deleted and its reply tokens stop working. People see “@bot's security code changed”.
- Revocation (QivBot → Encryption → Revoke code) is a signed certificate too. Until a new key is certified, nobody can start, message or address the bot.
- Transfer: the old owner signs the revocation of the active key as part of the offer; the new owner certifies a new key. Phones show a loud “@bot has a new owner” warning and block sending until the person acknowledges it.
- If the Qivuna team transfers a bot (for example after an owner's account is closed), there is no signature of the old owner: the old key is retired without a revocation certificate. Phones still never treat a key certified by a previous owner as current.
Plain bots may also propose and certify keys — for qbw1 webhook encryption, and to be ready to switch to encrypted. Switching a bot to encrypted is one way.
Pinning: trust on first use#
Each person has a separate key for each bot developer (their user bot key, derived on their phone from their account key and your id namespace). It arrives as from.e2ee_key on encrypted updates, and the envelope must open with it.
- The SDK pins it the first time it sees that person: user id → key and key → user id, with compare-and-set.
- First sight is trust on first use. For a person your bot has never seen, a malicious server could present a key of its own and talk to your bot as that person. It could not read that person's real messages, nor your replies to them.
- A later change of a person's key means they reset their account key — or that someone is trying to impersonate them. The SDK refuses the message and asks your
onUserKeyChanged/on_user_key_changedcallback, which refuses by default. Accept only with a reason you trust (for example, the person confirms out of band). - Phones likewise pin the bot owner's signing key at first sight; a different key later shows as a change.
Key alerts#
The SDKs compare the server's view of the bot's keys with the keys your server holds and raise an alert (onKeyAlert / on_key_alert) when they disagree. Treat every alert as an incident and check with the owner what they confirmed in the app.
| Alert | Meaning | Effect |
|---|---|---|
foreign_active_key | The active key is none of your server's keys: phones now seal to a key someone else holds. | The SDK stops sealing; sends fail. Revoke that key in QivBot and certify one of yours. |
retired_key_reactivated | A key the SDK retired is reported active again (a rollback). | Not used unless you opt in. |
active_key_revoked | Your current key was revoked outside this process. | No sealing until a new key is active. |
foreign_proposal | A proposal is waiting that this process did not make. | Warning: the owner must not confirm it. |
Encrypted 1:1 chats#
- The person's phone seals the message (an
s2envelope, sender key = their user bot key, reader = your bot's current key) and names the key it sealed to. If that is no longer the active key, the server refuses and the phone re-checks the bot's certificates before trying again. - Your bot receives
encrypted_message:kind: "message", the private chat (is_e2ee: true),fromwithe2ee_key, and theenvelope. There is no reply token in 1:1 chats. - The SDK opens it, checks the request (below) and calls your handler. Your reply is a signed bot message sealed to the person's key:
sendMessage {chat_id, envelope}. You can edit it within 48 hours witheditMessageText {envelope}, and delete it.
Text, entities, keyboards and captions never travel in plaintext next to an envelope; plaintext sends into an encrypted bot's chat answer 403 e2ee_required. Without an active key, encrypted sends answer 409 key_not_active.
Encrypted groups: addressed mode#
Private groups on Qivuna are end-to-end encrypted, and a bot never receives a group's key. An admin can attach an encrypted bot to such a group; the admin's phone signs the attachment (a ba1 record) and members' phones verify it before ever sending anything to the bot.
- The bot receives only what a member explicitly sends to it:
/cmd@your_bot, a bare command only that bot advertises, an@mention, or a reply to its answer. Before sending, the member sees a “🔒 → 🤖 @your_bot” chip, and the first time a confirmation: “This message will also be sent, encrypted, to @your_bot. @your_bot can read only messages you send to it.” Nothing else of the group — other messages, quotes, files — ever goes to the bot. - Each such message arrives as
encrypted_messagewith the group aschat, amessage_idthat identifies this request (it is not a group message id), and areply_token. - You answer with
sendMessage {chat_id: <group>, envelope, reply_token}. The answer goes to that member's inbox only (“Only you can see this · 🤖 @your_bot”) and the result is{inbox_id, date, chat}. The member can share it to the group, where everyone sees it as “shared from 🤖 @your_bot”, verified by your bot's signature. - The request carries an encrypted return address (
ret) that only the member's own phones can open. Echo it unchanged in your reply (the SDK does): phones place an answer in the group only if it opens there and matches the request. - Button presses arrive as
encrypted_messagewithkind: "callback"and acallback_query_id; answer once within 15 minutes withanswerCallbackQuery {callback_query_id, envelope}(a toast for the presser), and/or reply with the reply token. - Moderation, media and member lists answer
403 e2ee_unsupported; a plaintextsendMessageanswers403 e2ee_required. - When the bot is detached (signed by an admin, or removed), its queued requests from that group are deleted and its reply tokens there stop working. Answers already in members' inboxes stay theirs until they expire.
A bot that was in a private group before it became encrypted is suspended at that moment (my_chat_member with new_status: "suspended_e2ee"); an admin can re-add it in encrypted mode.
Reply tokens#
- Format
rt1_+ 43 base64url characters. Valid for 1 hour and 5 sends. - Bound to your bot, the member, the group, that attachment and the key the member sealed to. A key change, a detach (even followed by a new attach), the member leaving or being banned, or the group no longer being encrypted void it.
- Missing, expired, used up or void: one answer,
403 reply_window_closedwithparameter: reply_token. Answer the member's next request instead. - A member's inbox holds at most 50 items a day from one bot and 500 in all; items expire after 7 days. Past that your send answers
429.
What the SDK checks before your handler runs#
- The
update_idis not done or in progress. from.e2ee_keyis present, 32 bytes, not a weak key, not the bot's own, and not pinned to another person.- The envelope opens with one of the bot's keys and that sender key.
- The plaintext is a well-formed QB1 request of the kind the update says, its context (
porg) matches the chat type, and its time is within 24 hours of now. - Its request id (
rq) was not seen before (remembered for 48 hours). - The sender key is pinned to that person (or
onUserKeyChangedaccepted a change).
Anything that fails is reported to onRefused / on_refused and acknowledged, never handled. Replies are checked against the phones' own rules before signing (text ≤ 4,096 characters, answers ≤ 200, keyboards ≤ 8 rows of ≤ 4, 32 buttons, https:// URLs only), so the SDK never signs something a phone would refuse.
Treat chat, from and the context as server-asserted. Bind your answer to them, but never authorise on them alone: the envelope proves only that the holder of the pinned key wrote it. Callback data in encrypted presses comes from the person's phone: treat it as input.
Handlers run at least once#
The SDKs lease each update while your handler runs and mark it done afterwards. If the process dies in between, the server delivers the update again and your handler runs again. A failing handler is retried after 1, 2, 4 and 8 seconds; after 5 attempts the update goes to the store's dead letter file (with no message content unless you opt in) and is acknowledged.
Make side effects idempotent: key database writes, payments and outgoing replies on update_id or the request's rq. This matters more here than for plain bots: a reply token allows only five sends, and a duplicated reply uses one up.
Keys at rest#
The SDK's FileStateStore keeps the bot's private keys in keys.json, next to the people's pinned keys and the replay memory.
keys.jsonis not encrypted by default: only file permissions protect it (directory 0700, files 0600 on POSIX, checked at start-up; an owner-only ACL on Windows).- Give a key-encryption key (KEK) — 32 bytes, or a function returning them, ideally a data key unwrapped by your KMS; or the base64 environment variable
QIVUNA_BOT_KEK— and the keys are stored AES-256-GCM encrypted under a key derived from it (HKDF, labelQivuna.BotSDK.keys.v2). Without the KEK, an encrypted file refuses to load. - The KEK protects copies of the state directory: backups, disk images, a stolen volume. It does not protect against anyone who runs as the same user or can read the process (an environment-variable KEK is visible in
/proc/<pid>/environ; memory holds the keys). Prefer a KMS-backed callback. - If a plaintext
keys.jsonmay already have been copied, encrypting it now does not help: rotate the key. - Back up
keys.jsonand the KEK. Losing them means a new key, which every person sees as a security-code change, and the bot cannot read what was sealed to the old one. - If your bot key is compromised: revoke it in QivBot at once (Encryption → Revoke code), revoke the bot's tokens, then generate and certify a new key.
Wire formats#
The SDKs implement these; you need them only to write your own client. The test vectors are normative: where this page and the vectors disagree, the vectors win.
- Primitives: X25519, Ed25519 (strict: canonical keys, no small-order points, S < L, cofactorless verification), HKDF-SHA256 (32 bytes), AES-256-GCM (12-byte nonce, 16-byte tag), SHA-256. Base64 is the standard alphabet with padding unless stated.
s2envelope (as people's encrypted 1:1 messages):s2|nonce|body|mac|wrap,wrap,…; a random content key encrypts the body; each reader (the sender's own copy first) gets the content key wrapped underHKDF(X25519(eph, R) ‖ X25519(sender, R), salt = eph ‖ R ‖ sender, info = "Qivuna.Envelope.v2.wrap"). At most 20,000 characters.- QB1 plaintext: the character U+0000, then
QB1, then compact JSON. A request is{"v":1,"type":"req","k":"msg"|"cb","rq",…,"t","ctx":"p"|"g","text"|"cb","ret"?,…}; a bot message is{"v":1,"type":"bot","bot":<bot_id>,"j":base64(J),"s":base64(sig)}withsig = Ed25519(bot key, "Qivuna.Bot.v1.reply|" ‖ J).bot_idcomes fromgetMyEncryptionKey. ret: AES-256-GCM under a key only the member's account holds; the bot only echoes it.qbw1: see Webhooks.
Exactly what is and is not protected#
Protected, assuming the key phones seal to is really your bot's and the phones run the official app:
- Qivuna's server cannot read encrypted messages, presses, replies or answers, or
qbw1bodies. - It cannot forge a reply that phones show as your bot's: replies are signed by your bot key, which the owner certified on their phone.
- It cannot make your bot accept a forged message from a person after the bot pinned that person's key.
- It cannot replay old requests past the SDK's checks (request ids, the 24-hour window), nor re-insert an old bot message as new on a phone.
Not protected, by design or as accepted risks:
- Your server. Whoever runs the bot, and anyone who compromises its server or its key, reads everything sent to it.
- Metadata (listed above).
- First sight of a person's key at your bot, and of the owner's signing key at a phone, is trust on first use.
- A key the owner confirmed by mistake would let its holder read new messages; the code comparison and the SDK's alerts are the defence.
- The server can hide a signed detach and keep serving the old attachment; phones then keep showing the “🔒 → 🤖” chip on each addressed message. An attachment signed by an admin stays valid after that admin leaves; other admins can detach.
- The server can present a group request to your bot as a 1:1 one (or choose which group it appears in). Answers still reach only the person who asked, and land in a group only if that person's phone accepts the return address.
- A compromised phone, a modified app, or a person who shares what they received.
- Plain bots, QivBot and rooms with ordinary grants are not end-to-end encrypted at all.
Checklist for running an encrypted bot#
- Persistent state store, backed up; a KEK from your KMS.
- Print the security code where the owner can see it, and have them compare it on the confirmation screen.
- Alert on key alerts and on refused updates (
onRefused); keep the default refusal of changed user keys unless you have a reason. - Idempotent handlers.
- Your privacy notice says that you, the developer, can read what people send the bot.
- A plan for key compromise: revoke in QivBot, revoke tokens, rotate.