QivunaDevelopers

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.

Not available yet Encrypted bots are not open at launch. Every new bot is a plain bot; in its chat the app says that messages are not end-to-end encrypted and that Qivuna and the bot's developer can read them. The key methods answer 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 readQivuna's server does see
  • the text and formatting of messages people send an encrypted bot, 1:1 or addressed to it in an encrypted group;
  • the data of button presses in those chats;
  • the bot's replies, button labels and answers;
  • the body of webhook deliveries sealed with qbw1 (an extra layer on top of TLS).
  • Metadata: who wrote to which bot, in which group, when, how often, and how large each envelope is; whether it was a message or a press; how many replies the bot sent and when;
  • which bots are attached to which encrypted groups and who attached them;
  • for which bot developers a person published a key;
  • which group messages are shared bot answers;
  • names, ids and chat details it puts into updates (it asserts them; they are not inside the envelope);
  • everything in rooms where bots have ordinary (server) grants — public groups and channels — and everything of plain bots and of QivBot.

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.

The one check that only a person can do Your bot does not verify the certificate itself; it learns from 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#

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.

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.

AlertMeaningEffect
foreign_active_keyThe 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_reactivatedA key the SDK retired is reported active again (a rollback).Not used unless you opt in.
active_key_revokedYour current key was revoked outside this process.No sealing until a new key is active.
foreign_proposalA proposal is waiting that this process did not make.Warning: the owner must not confirm it.

Encrypted 1:1 chats#

  1. The person's phone seals the message (an s2 envelope, 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.
  2. Your bot receives encrypted_message: kind: "message", the private chat (is_e2ee: true), from with e2ee_key, and the envelope. There is no reply token in 1:1 chats.
  3. 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 with editMessageText {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.

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#

What the SDK checks before your handler runs#

  1. The update_id is not done or in progress.
  2. from.e2ee_key is present, 32 bytes, not a weak key, not the bot's own, and not pinned to another person.
  3. The envelope opens with one of the bot's keys and that sender key.
  4. The plaintext is a well-formed QB1 request of the kind the update says, its context (p or g) matches the chat type, and its time is within 24 hours of now.
  5. Its request id (rq) was not seen before (remembered for 48 hours).
  6. The sender key is pinned to that person (or onUserKeyChanged accepted 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.

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.

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:

Not protected, by design or as accepted risks:

Checklist for running an encrypted bot#