Learn

Don't trust the operator, verify. This section goes from "what is a key" to "I checked the log myself", with short animations and a few widgets you can try.

The 60-second version

Pollis is a Slack-style messenger whose operator cannot read your messages or calls: they are encrypted on your device and decrypted only on the devices of the people you are talking to. It also publishes a tamper-evident log of the keys and app versions it hands out, so you can check that nobody slipped in a fake. The cost is that account recovery depends on a key only you hold, so keeping it safe is on you.

New here? Start with the problem, then read straight down.

Each topic also says what its mechanism does not prove. These pages are the introductory version; each links to the auditor-facing design docs for the precise one.
Start here Why any of this matters
Foundations The words and ideas everything else uses
Group messaging (MLS) How a whole group stays encrypted, efficiently
Files & media The same protection, applied to what you attach
Identity Making sure you are talking to the right person
In context How Pollis sits next to apps you already use

About an hour cover to cover. Each track stands on its own.

Start here

The problem with most messaging apps

On Facebook Messenger, Instagram DMs, Snapchat, Discord, Slack, or Telegram's default chats, your message goes from your phone to the company's servers, is stored there, and is then delivered. In that middle step the company holds a readable copy of what you wrote.

That copy powers search, backups, and syncing between your phone and laptop. It also means the message is only as private as the company's promise not to look, and as secure as the systems behind that promise. The copy can be:

Read to target ads or train models. Who you talk to, about what, and how often is valuable to platforms that make money from knowing about you.

Handed over on demand. A company that can read your messages can be compelled to share them with a government or law enforcement, including for reasons unrelated to any crime. Journalists, activists, protesters, and ordinary people have had private messages pulled this way. (For background, see the Guardian's NSA Files and the 2013 global surveillance disclosures, and the EFF's ongoing coverage of surveillance.)

Stolen in a breach. When a company is hacked, the attacker can read whatever the company could.

None of this requires a malicious company. A well-meaning one can be bought, breached, or served a court order, and "we won't look" can change with a new owner or a new law. The fix is a system where the company cannot read your messages, even if it wanted to or were forced to. That is what Pollis does.

Where this fits

Unreadable contents are the foundation the next few topics build. Who you talk to and when still needs care, and the transparency topics later show how to check Pollis's claims instead of trusting them. The next topic maps out who can see what.

An ordinary messaging app keeps a readable copy of your message on its servers, and that copy can be used against you in three ways.
Full transcript

Think about how you message someone on most apps today. Your message goes from your phone to the company's servers, gets stored there, and is then sent on to your friend. The key detail is that middle step: the company keeps a readable copy of what you wrote.

That copy is useful. It powers search, backups, and syncing across your devices. But it also means your message is only as private as the company's promise not to look, and the security of everything that promise relies on.

That readable copy can be used to target ads or train models, because what you say and who you say it to is valuable. It can be handed to a government or law enforcement on demand, including for reasons that have nothing to do with any crime. And it can be stolen in a breach, because a central readable copy is a tempting target.

None of this needs the company to be evil. A good company can still be bought, breached, or ordered by a court, and a policy can change. The only real fix is to build the app so the company cannot read your messages at all. That is what Pollis does, and the rest of this section explains how.

Related reading: end-to-end encryption on Wikipedia, and Signal, a widely used private messenger built on the same principle.

Track A · Foundations · Topic 1

Who can see what

On most messaging apps, your privacy rests on a company keeping a promise. A company can be hacked, bought, or handed a court order regardless of what it promised. Pollis is built so that we are unable to read your messages, rather than merely choosing not to. Here is who can see what.

Your device sees everything. You type and read there, so that is the only place messages are decrypted. Someone who gets into your unlocked device can read what is on it; no cryptography prevents that.

Our servers see the envelope, not the letter. They carry sealed messages they cannot open, because the keys never leave your devices. They do see delivery information: that an account sent something, roughly when, roughly how big, and to which conversation. Even under a court order, that metadata is all a server can produce. Your messages, calls, and keys never exist on our side in readable form.

Getting the right key to the right person takes care. Encryption only helps if the key your app uses really belongs to the person you meant. Safety numbers (Topic 6) and our public key logs (Topic 9) let you confirm that yourself.

Calls are covered too. Voice and video go through a media server, but each audio and video frame is encrypted on your device with a key derived from the conversation's MLS group. The media server forwards frames it has no key for, and the key rotates whenever the group's membership or keys change.

What this does not cover

Someone holding your unlocked device can read what is on it. The delivery metadata above is visible to whoever carries your traffic. And a new device starts empty, because there is no history backup (reasoning in Topic 4).

A readable message, then a sealed one, then the delivery details on the outside of the envelope. The colour key used throughout: green is sealed, amber is visible to us, red is outside anyone's protection.
Full transcript

Most messaging apps ask you to trust a company. Not its promise, its ability. A company that means well can still be hacked, bought, or ordered by a court. So Pollis is built so that reading your messages is not something we choose not to do. It is something we cannot do.

Your message is sealed on your device before it goes anywhere. Our servers pass it along without ever holding the key. It opens on your friend's device.

We cannot see what is inside. We can see the outside: that your account sent something, when, roughly how big, and which conversation it belongs to. That is called metadata, and it can be sensitive on its own, which is why later sections show how Pollis shrinks it.

There is also a subtler point. Encryption only helps if you are using the right key for the right person. If the wrong key ever slipped in, a message could be read in the middle without breaking any of the math. That is exactly what safety numbers and our public key logs let you check.

Here is the full picture. Green, we cannot see. Amber, we can, and we say so. Red is outside what any messenger can defend: your own device, and plain software bugs.

Everything else in Learn is about shrinking how much of the green depends on trusting us, and publishing evidence for whatever is left.

The rigorous version: docs/security-whitepaper.md §1.1, and the trusted/untrusted split in CLAUDE.md → "Security model".

Track A · Foundations · Topic 2

Keys, encryption, signatures, and hashes

Encryption is what removes the readable copy. It scrambles a message so that only someone with the right key can turn it back into text (see Wikipedia's definition). Done end to end, the message is scrambled on your device and unscrambled only on the recipient's, so the servers in between carry noise (see end-to-end encryption, and how Signal describes the same idea).

The same technology protects online banking, the padlock in your browser's address bar, your phone's storage, and your password manager. Surveillance of private citizens is documented, not hypothetical: the 2013 disclosures documented bulk collection of ordinary people's communications, and the EFF tracks cases where private messages were demanded for reasons unrelated to any crime.

Four terms appear on every page that follows. None needs math.

A key is a very large, randomly chosen number. Large enough that trying every possibility is out of reach. A symmetric key (the kind AES-256 or ChaCha20 uses) is one of 2256 possible values. By Bruce Schneier's thermodynamic argument, a perfect computer running at the theoretical minimum energy per step would need more energy than the Sun will emit in its remaining lifetime just to count through 2128 values. Public-key algorithms use different math: a 256-bit elliptic-curve key like the X25519 keys Pollis uses gives roughly 128 bits of security, still far beyond any feasible attack. "A key never leaves your device" means that number stays in your device's secure storage and is never sent to us.

Encryption is a lock that scrambles. Your message plus a key gives noise; the noise plus the right key gives your message back. The wrong key gives you nothing, not a partial or blurry version.

A key can come in two different halves. One is public, one private. Anything locked with the public half can only be opened with the private half, so you can publish the public half while the private half stays on your device. Anyone can then send you something only you can open, without agreeing on a secret in advance.

Signatures run the same idea in reverse. Sign something with your private half and anyone with your public half can confirm it came from you and was not altered. Change one character and the check fails.

A hash is a fingerprint for data. Any input, of any size, gives a short string. The same input always gives the same fingerprint, changing one character gives a completely different one, and you cannot work backwards to the input. That lets you say "this is exactly the file I meant" without publishing the file. The long root codes on our artifacts page are hashes.

The algorithms. For scrambling a message with a shared key, common ones are AES and ChaCha20; Pollis's group messages use ChaCha20-Poly1305. For agreeing on a shared key over an untrusted network, Pollis uses X25519, an elliptic-curve key exchange, carried inside MLS by HPKE, paired with ML-KEM-768, a key exchange designed to resist quantum computers. The two are combined by a construction called X-Wing, so an attacker has to break both. Large quantum computers do not exist yet, but someone can record traffic today and wait. For the same reason, Pollis signs with ML-DSA-44 rather than Ed25519, which most of the internet still uses. (A signature scheme signs; it does not encrypt. RSA is an older algorithm that can do both.) For fingerprints, Pollis uses the SHA-2 family: SHA-256 in the transparency logs and for attachments, SHA-384 inside MLS. All of these are public and well studied; their security depends on keeping the key secret, not the method.

Worth knowing

The encryption itself is rarely what fails. What matters in practice is where keys are kept, whether a public key really belongs to the person it claims to, and whether the app doing the locking is the one you think it is. The rest of this section covers those three questions.

Four short scenes, no formulas: the size of a key, a lock with no "almost", the two-half trick that lets strangers write to you privately, and one character changing a fingerprint completely.
Full transcript

A key is a very large number. Large enough that guessing it is not hard, it is out of reach, because there is not enough time to try.

Encryption is a lock. Your message goes in with a key and out comes noise. Put the noise back in with the right key and your message returns. Wrong key, and you get nothing. Not a blurry version, not most of it. Nothing.

Now the idea that makes this work between strangers. Your key comes in two halves. One is public, you hand it to anyone. One is private, and it never leaves your device. Anything locked with the public half can only be opened by the private half. A stranger can lock a message to you, and then cannot open it themselves.

Run the same idea backwards and you get signatures. Mark something with your private half and anyone with your public half can confirm it came from you and was not changed. Change one word and the check fails.

Last idea: a hash. It is a fingerprint for data. Any file, any size, in, and a short string out. Same file, same fingerprint, every time. Change one character and it is completely different. Try it yourself in the box below.

Key, encryption, signature, hash. Everything else is those four ideas arranged. And the math is not the weak point. What matters is where the keys are kept, whether a public key really belongs to who it says, and whether the app is honest. That is what the rest of Learn is about.

The rigorous version: the ML-DSA-44 keys and OS-keystore storage in auth.rs, the device-signed writes in ds_client.rs, and the SHA-256 hashing in merkle.rs.

Track B · MLS · Topic 3

Group chats, made efficient

The obvious way to encrypt for a group is to encrypt each message separately for every member: four copies for five people, ninety-nine for a hundred. That gets slow and expensive, and when someone leaves, everyone needs fresh keys from everyone else.

Pollis uses MLS, Messaging Layer Security (RFC 9420), which arranges the group as a tree. Every member is a box along the bottom. Boxes pair up into boxes above them, up to a single box at the top. Each box holds a key, and you know the keys on the line from your box to the top. The top key is shared by everyone and protects the group's messages.

When something changes, whoever makes the change sends one small update along their line, and each part of the group derives the piece it needs from what it already knows. The cost drops from one message per member to about the height of the tree: roughly seven steps for a hundred people, about ten for a thousand.

Two terms used throughout Pollis. An epoch is the group's version number; it increments every time the keys change. A commit is the update that increments it, such as adding someone or refreshing keys. Every device must apply commits in the same order. Keeping that order right across devices and reconnections is one of the hardest parts of Pollis, and it is why every commit is recorded in a public log (Topic 9).

Worth knowing

The tree protects message contents and group membership. It does not hide that a group exists or that messages are flowing (the metadata point from Topic 1). If a device applies updates out of order, it cannot read new messages until it catches up; nothing leaks.

The copy-per-person approach piling up, then the tree being built, then one small update travelling to the top instead of to everybody, and a member who left watching the next message stay sealed.
Full transcript

Locking a message for one person is easy. Groups are where it gets interesting. The obvious approach is to encrypt the message separately for every person. Five people, four copies. A hundred people means ninety-nine copies of every message. And when someone leaves, everyone needs new keys.

MLS arranges the group as a tree instead. Everyone sits along the bottom. Boxes pair up all the way to a single box at the top. Each box holds a key, and you know the keys on the line from your box up to the top. The top key is shared by everyone, and it protects the group's messages.

When something changes, the person making the change sends one small update along their line, and each part of the group unlocks the piece it needs from what it already knows. Ninety-nine messages becomes about seven.

Leaving works the same way. When a member is removed, the keys along their line are replaced. The next message goes out, and for them it stays sealed. They still hold the old key, and it opens nothing.

Two words you will see everywhere. An epoch is the group's version number. A commit is the update that makes it tick.

Everyone applies those updates in the same order. If a device misses one, it falls a step behind and cannot read what comes next until it catches up. Keeping that order right is one of the hardest parts of the app, and it is why every commit is written to a public log, which is where we go next.

Want to go deeper on MLS? This talk explains the ratchet tree well:

The rigorous version: RFC 9420 itself, our OpenMLS usage in commands/mls/, and the ordering invariant in docs/transparency.md.

Track B · MLS · Topic 4

Joining, leaving, and your history

To add you to a group, someone has to lock the group's current keys so only you can open them, even while you are offline. So every Pollis device publishes small lockboxes in advance, called key packages: anyone can drop something in, only that device can open it. When someone adds you, they seal the group's current state into one and send you a welcome. You open it with your private key, and you are in.

The welcome carries the keys as they are now, not the keys from before you joined. It cannot, because MLS is designed so that new keys reveal nothing about old ones; that is the same property that makes removal work. So you cannot read messages sent to a group before you joined. The app is not hiding them; the keys do not exist on your device. Every app built on this standard behaves the same way.

Leaving. When a member is removed, everyone else moves to fresh keys and the removed device cannot read anything new. What it already saw stays on it. Removal is enforced by the keys, not by a server setting you have to trust.

Your own devices work the same way. Your phone and laptop are separate members of each group, with their own keys. Keys are never copied between them, since a key in transit can be stolen. Adding your laptop is like adding a person.

That leads to the sharp edge: a new device starts empty. There is no history backup. Your old messages live on your old device and nowhere else. Our servers only ever held sealed envelopes, and we do not make a backup.

The usual backup design stores your history on the server behind a PIN or passphrase. That puts a copy of your conversations on our infrastructure, guarded by something short and guessable, which is what Pollis exists to avoid. If you want your history on a new device, keep the old one.

What we promise, and what we do not

Three kinds of loss are expected. Two come from the cryptography: messages sent before you joined, and a new device starting empty. The third is a policy choice: if every one of your devices goes quiet for more than a year, messages still waiting to be delivered only to you may be deleted before you come back.

That clock runs on when your device last checked in, not on the message's age. A device you still use keeps its pending mail however old it is, and opening the app again resets the clock. We set the window at twelve months rather than the software's six-month default, because a phone left in a drawer through a long trip, an illness, or parental leave should not cost you messages. Without some limit, mail waiting on devices that never return would pile up forever.

Everything else should work: delivery to every current member, going offline and back, reconnecting, accepting a message request. A message lost for any other reason is a bug.

Lockboxes left in advance, a welcome that carries only today's keys, earlier messages staying sealed, and the backup question as a trade-off where neither option is free.
Full transcript

To add you to a group, someone needs to lock its keys so only you can open them, even while you are offline. So every device leaves a small lockbox in advance. Anyone can drop something in. Only you can open it.

When you are added, an existing member seals the group's current state into your lockbox and sends it over. You open it, and you are in.

Now the part people do not expect. You received the keys as they are now, not the older ones. New keys cannot be used to work out old ones, and that is the very thing that makes removing someone work.

So messages sent before you joined stay sealed. It is not that the app is hiding them. The key genuinely does not fit.

Removing someone is the same idea. Their line of keys is replaced, and the next message is sealed to them. What they already saw stays on their device. We cannot reach into it.

Your own devices work the same way. Your phone and laptop are separate members, each with their own keys. Nothing is copied between them, because a key that travels can be stolen along the way.

Which brings up one deliberate trade. A new device starts empty. There is no history backup. We could store your history on our servers behind a PIN, but that would put a copy of every conversation on our infrastructure behind a short, guessable code, which is exactly what this app exists to avoid. So we do not. If you want your history, keep your device.

What we do promise is narrow and firm. Three kinds of loss are expected: messages from before you joined, a new device starting empty, and — if every device you own goes quiet for over a year — mail that was still waiting only for you. Everything else has to arrive, and anything else going missing is a bug.

The rigorous version: the enrollment and GroupInfo flows in the MLS wiki article, and the three-acceptable-losses policy stated verbatim in CLAUDE.md → "Messages must work; history is bounded, not flaky".

Track B · MLS · Topic 5

A stolen key is a window, not a door

Suppose someone copies a key off your device today. What do they get, looking backward in time and forward?

The past: forward secrecy. Nothing. Each new key is derived from the previous one by a one-way step that cannot be reversed, and used keys are deleted. A key stolen today opens today's messages; the material for yesterday's no longer exists. (To handle messages that arrive out of order, the protocol briefly keeps a small, bounded set of recent message keys, so the boundary is that window rather than a hard line.)

Forward secrecy protects recorded network traffic, not your device. Your messages are stored decrypted in a local database so the app can show them instantly, and that database is protected by a key in your operating system's keystore. Someone inside your unlocked device can read your history directly. What forward secrecy guarantees is that traffic recorded months ago stays sealed, even with a key stolen today.

The future: post-compromise security. Normally a stolen key keeps working until you notice and start over. In MLS the group heals on its own. Ordinary updates, such as adding a member or a routine key refresh, replace keys along part of the tree. Once a member the attacker does not control makes an update, the stolen key stops working, without anyone having to notice the theft.

This is the payoff of the tree from Topic 3: rekeying is cheap, so it happens often, and the window in which a stolen key is useful stays short. It does not open backward, and it closes on its own.

The limits

Someone with continuous access to your device receives every update you do and stays in; this recovers from a break that has ended, not one in progress. Messages already decrypted on your device can be read by whoever holds it. Neither property hides metadata. Healing depends on activity, so a quiet group refreshes its keys less often. And none of this deletes messages; it limits how much time a stolen key covers.

Time runs left to right: a one-way key chain, the caveat about an unlocked screen, a break that heals on an ordinary update, and the window narrowing as the group stays active.
Full transcript

Suppose the worst has already happened. Someone has a key from your device, today. What did they get? Two questions: what about the past, and what about the future.

The past first. They try yesterday's messages and get nothing. Keys move forward in one-way steps. Each key is made from the one before it, through a process that cannot be reversed, like mixing paint. And once a key is used, it is thrown away. Yesterday's key does not exist anywhere anymore.

One point, because this gets overstated. If your old messages are still on your screen, someone holding your unlocked device can read them. Forward secrecy protects recorded traffic: someone who captured your data months ago still cannot open it with today's key.

Now the future. Normally a stolen key means you are compromised for a long time. Here, someone else in the group, someone the attacker does not control, does something ordinary, like refreshing their keys. New keys flow along the tree, and the stolen key stops working. Nobody had to detect anything. The group healed by carrying on.

Sealed on the left by forward secrecy. Sealed on the right by the next update. A stolen key is a window, not a door, and the more the group talks, the narrower it gets.

The limit: if someone never loses access to your device, they get every update you do and stay in. This recovers from a break that ended, not one still happening.

The rigorous version: the key schedule in RFC 9420 §8, the epoch advance in the MLS wiki article, and the local-storage caveat in CLAUDE.md → "Storage".

Files & media

Photos and files, encrypted before they leave

Photos, voice clips, and PDFs follow the same rule as text: the operator never sees the contents. They take a slightly different path, though.

Before anything leaves your device, it takes a fingerprint of the file (a SHA-256 hash of the exact bytes), derives a key from that fingerprint, encrypts the file with AES-256-GCM, and uploads only the encrypted result to Cloudflare R2, an S3-style object store. Your device never holds storage credentials: it asks the Delivery Service for a short-lived, pre-signed upload link.

The fingerprint goes inside the encrypted message, so MLS protects it along with the text. Only people in the conversation learn which stored blob a message points at, and only they can re-derive the key to open it.

Why derive the key from the file? So identical files cost storage only once. If two people send the same 8 MB slide deck, both uploads produce the byte-for-byte identical encrypted blob, and the second upload is skipped. This is called convergent encryption.

The storage (R2)
An encrypted blob, its size, and an object name containing the file's fingerprint (not its filename). It never receives the key.
The Delivery Service
The fingerprint and object name, so it can record the file for dedup and mint the pre-signed link. Never the key, the filename, or the plaintext.
People in the chat
The fingerprint and filename, inside the encrypted message, so they can fetch the blob, re-derive the key, and open the file.
Everyone else
Nothing useful. The link is short-lived and the blob is meaningless without the key.
The trade this makes

Convergent encryption is deterministic: identical files produce identical blobs. So someone who already has a copy of a specific file can compute its fingerprint and check whether that exact file was uploaded. The contents of files an observer does not have stay secret; the presence of a file they can already guess does not. For ordinary attachments that is a reasonable trade, but it matters if even the existence of a known file would be sensitive. Separately, the on-device cache re-encrypts every file at rest under a key tied to your local database.

The rigorous version: the upload, convergent key derivation, and cross-user dedup in r2.rs, and the loopback media server that decrypts on demand in media_server.rs.

Track C · Identity · Topic 6

Making sure you have the right person's key

Encryption does not, on its own, tell you who is on the other end. When you first message someone, your app fetches their public key from our servers. If the wrong key were handed over, a message could be read in the middle while both sides still saw a padlock, because the encryption works perfectly on the wrong key. Every encrypted messenger has this gap. Pollis addresses it two ways.

Safety numbers. Your app combines your key and your contact's key into a 60-digit code, shown as twelve 5-digit blocks. You both compute the same code from the same pair of keys; different keys give different codes. Compare them over a channel an eavesdropper cannot touch (side by side, read aloud on a call, or by scanning a QR code) and a mismatch shows up immediately.

That only helps if you compare, and most people never do. The second safeguard works even when nobody checks.

A public key log. Every identity key Pollis lists for an account goes into an append-only public log (Topic 9). Your app checks that the key the log shows for you matches the key you hold, and alerts you if they disagree. A substituted key would sit in a permanent public record, and the real owner's device would flag it. The log is republished daily, so a swapped key surfaces within about a day, the same bound Certificate Transparency uses for web certificates.

Trust on first use. The first time you talk to someone, your app remembers their key and flags any later change. Changes are usually innocent (a new phone, a reinstall), so a flag is a prompt to re-check before sharing anything sensitive, not an accusation. Pollis shows a shield next to verified contacts and a distinct warning when a remembered key has changed.

Compare safety numbers once with the people who matter, and let the log cover the rest.

Worth knowing

A safety number nobody compares protects nobody, and your app cannot tell an innocent key change from a real problem, which is why it asks you. The public log makes a swapped key permanent and visible; it records and deters a swap but does not block it. None of this affects metadata (Topic 1).

A message read in the middle while both screens show a padlock, safety numbers matching and then not, most people skipping the comparison, and the public log that catches a swap anyway.
Full transcript

Encryption protects the message. It does not tell you who is on the other end. When you first message someone, your app fetches their public key from our servers. That is the gap.

If the wrong key were handed over, a message could be opened in the middle, read, and re-sealed. Both screens would still show a padlock, because nothing is wrong with the encryption. It is working perfectly, on the wrong key. Every encrypted app has this problem. Here are the two safeguards.

First, safety numbers. Your device combines your key with your contact's and produces a short code. They do the same. Same two keys, same code. If the keys differed, the codes would differ. Compare them any way an eavesdropper cannot touch, side by side, read aloud, or by QR code, and a mismatch shows up right away.

The catch is you have to actually check, and most people never do. So there is a second safeguard that works even when nobody checks.

Every identity key we publish goes into a public log that can only grow. A swapped key would have to appear there, in the open, permanently, and it could not be quietly removed later. Your own device reads that log and compares it against the key you hold. If they ever disagree, your app tells you. The swap stops being invisible and becomes a public record.

One last thing. The first time you talk to someone, your app remembers their key. If it changes, you get a note. Usually that is innocent, a new phone or a reinstall. Sometimes it is not. Your app cannot tell the difference, which is why it asks you.

Compare safety numbers once, with the people who matter. The log handles the rest.

The rigorous version: the safety-number derivation and TOFU pin store in the safety wiki article, and the account-key self-audit in transparency.rs.

Track C · Identity

Losing a device, and getting back in

If no company can read your account, no company can open it for you either, including us. Recovery is built around two things only you hold.

Your PIN protects a device. Your Secret Key protects your account. The PIN is a short code, local to one device, that unlocks the keys in that device's keychain; it is never sent anywhere. The Secret Key is a long random recovery code (it looks like A3-9XK4Q-7MTP2-…) shown once when you sign up. Write it down and keep it somewhere safe.

How recovery works. The server keeps a copy of your account key so you can get back in on a fresh device, but encrypted so only your Secret Key opens it: HKDF-SHA256 turns the Secret Key into an unwrapping key, and AES-256-GCM decrypts the stored copy. The server never holds your Secret Key, so neither we nor anyone who steals the stored copy can open your account.

Adding a device. The easy path is approving it from a device you already have. The new device shows an eight-character code, you type it into the existing device, and the two complete an X25519 key agreement (Topic 2) through the server without revealing the key to it. The code is the human-checked part, so even a server that tampers with its own database cannot slip in a fake approval. The other path is your Secret Key, which needs no second device. Either way the new device joins your groups from today forward, with no back-history (Topic 4).

With your Secret Key kept safe, losing a device just means signing in on a new one.

The one hard rule

If you lose your Secret Key and every device you were signed in on, the account starts fresh. Nobody, including us, holds a spare key to your account, so there is nobody to ask for one. It is the same trade as a safe with no manufacturer override.

The two keys and their jobs, the server-side copy only your Secret Key opens, the two ways to add a device, and the one hard limit.
Full transcript

Two keys, two jobs. Your PIN unlocks one device, and it is local to that device; we never see it. Your Secret Key recovers your whole account. It is shown to you exactly once, and you write it down and keep it safe, like the deed to a house.

Here is how recovery works. The server keeps a copy of your account key, but it keeps it locked in a box. Only your Secret Key opens that box, and the server does not have your Secret Key. So the company cannot open your account, and neither can anyone who steals the box.

To sign in on a new device you have two ways. The easy way is to approve it from a device you already have: the new device shows an eight-character code and you type it into the older one, and they agree on a private key in plain sight of the server. The other way is your Secret Key, the same recovery without a second device. Either way, the new device then joins every group you are in, from today forward, with no old history, which is the boundary from Topic 4.

And one hard rule. If you lose your Secret Key and every device you were ever signed in on, the account starts fresh. No one holds a spare key to your account, including us. That is the price of a door only you can open, and it is the point.

The rigorous version: the recovery and device-enrollment flows in auth.rs, and the account-key and device-certificate model in the security whitepaper.

Track D · Transparency · Topic 7

Merkle trees and append-only logs

Suppose we publish a list, say every identity key we have ever issued, and promise only to add to it, never edit or delete. How could you confirm that without taking our word for it?

Downloading the whole list every day and comparing does not scale, and it fails if you miss a day. The answer is a Merkle tree.

Hash each entry in the list. Hash those hashes together in pairs, giving half as many. Repeat until a single hash is left.

That final hash is the root. When this page was written, the binaries log's root on our artifacts page was:

root: 37945cb2f61ee43782259a3893336b8ba8b8679d3af1612742deeec75e46cc0c

The root depends on every entry. Change one character anywhere and that entry's hash changes, then its parent's, all the way up, and the root is completely different. Keeping the root the same after an edit would require two different inputs with the same hash, which is what hash functions are built to prevent.

So the root fingerprints the whole list in sixty-four hex characters. Instead of downloading a list that grows forever, you compare one short root, and any claimed version of the list either matches it or does not.

What it does and does not do

A Merkle tree does not say whether an entry is truthful; it makes the list tamper-evident. Showing you one version and someone else another, or changing the record later, becomes detectable by comparing roots, which you can do yourself.

Building the tree from the bottom up, then changing one entry and watching the root move.
Full transcript

Say we publish a list, every key we have ever issued, and we promise to only ever add to it. Never edit, never delete. How could you confirm that for yourself?

You could download the whole list every day and compare. But it grows forever, and it only works if you never miss a day.

Here is the real answer. Take every entry and hash it. Take those fingerprints in pairs and hash each pair together. Half as many. Do it again, and again, until one is left. That is the root.

This is the actual root of one of our live logs right now. Watch what happens if one character in one entry changes. Its fingerprint changes, which changes its parent's, which changes the next one up, and the root is completely different.

There is no way to edit an entry and keep the root the same. That would mean two different things with the same fingerprint, which these functions are built to prevent. So the root is a fingerprint of the entire list, in sixty-four characters. You do not download fifty thousand entries to check the list; you compare sixty-four characters.

A Merkle tree does not decide whether an entry is truthful. What it removes is the ability to show one version of the list to one person and a different version to another, or to change it later. Everyone comparing roots gets the same answer. Try it: change any entry and watch the root move.

The rigorous version: docs/transparency.md and the RFC 6962 hashing in verifiable-log/src/merkle.rs. See the live roots on the artifacts page.

Track D · Transparency · Topic 8

Proving an entry is really in the log

Given a root, two questions follow.

Is my entry in there? An inclusion proof answers this without the whole list. You get the hashes of the neighbours along the path from your entry to the root. Hash your entry, combine it with the first neighbour, hash again, and keep climbing. If you land on the published root, your entry is in the tree; if not, it is not.

For a thousand entries that is about ten hashes; for a million, about twenty. Doubling the list adds one step, so proofs stay small as the log grows.

Is this the same list as before, only longer? A consistency proof answers this, and it is what makes "append-only" checkable. Given last week's root and today's, it is a small set of hashes showing last week's list sits unchanged inside today's, with new entries only at the end. If an old entry had been edited or removed, no such proof could exist.

Who says which root is the real one? We sign it. Each signed tree head covers the root, the number of entries, and a timestamp. The Pollis apps and the verifier carry the public half of the signing key built in. The scheme is ML-DSA-44, which is post-quantum, because a signature over history has to stay meaningful for as long as anyone might re-check the past.

The key is built in, not downloaded, because a bad server could otherwise hand over its own key together with a self-consistent fake list, and everything would check out.

The key is 2,624 hex characters, built into every app and published on the artifacts page. An app with no pinned key reports that it could not check rather than trusting whatever the server sends. The current key is newly generated: the earlier key and its seed were retired, not re-encoded into the new format.

56ab128f3f10107382802e69d3de8659d0127c711feb9c849f5b213c6f2d0af3…

Could different people be shown different lists? This is called equivocation. A signed, append-only log could still show one list to auditors and a slightly different one to a single person, each internally consistent. Any two people who compare the signed heads they were shown will spot the difference, and because both heads are signed, the difference is proof of misbehaviour. Our publishing job checks what is actually being served for this.

In sum: the root fingerprints the list, an inclusion proof puts your entry in it, a consistency proof shows it was not rewritten, the signature under a built-in key shows it is ours, and comparing heads catches two versions being served.

Where the guarantee ends

None of this makes serving two different lists impossible; it makes it provable once anyone compares. If nobody compares signed heads, two versions could coexist unnoticed. Today the routine check is our own publishing job plus anyone running the verifier; more independent checkers would help.

Climbing an audit path, watching a made-up entry miss the root, a rewrite that cannot produce a consistency proof, and the two-versions case that comparison settles.
Full transcript

You have a root, sixty-four characters standing in for an entire list. Two questions. First: is my entry actually in there?

You do not need the whole list. You need the neighbours along the path from your entry to the top. Hash yours, combine it with a neighbour, hash, combine with the next, and climb. If you land on the published root, your entry is in the tree. If you land anywhere else, it is not. A million entries needs about twenty hashes. Double the list, add one step.

Second question: is this the same list as last week, only longer? A consistency proof shows last week's tree sitting inside today's, unchanged, with the new entries added on the end. If an old entry had been edited, no such proof could exist. So append-only is not a promise, it is something you check.

Who says which root is real? We sign it: the root, the count, and the time, sealed together. That is a signed tree head. And the key we sign with is built into the app and the verifier, not downloaded. If it were downloaded, a bad server could hand over its own key with a matching fake list, and everything would appear to check out. Because it is built in, that is caught right away.

Which leaves one question: could different people be shown different lists? A clean one for the auditors, and a slightly different one for a single person. Each on its own would check out. Neither person could tell alone.

So they compare. Two signed heads, side by side. They differ, and both carry a signature, so there is no explaining it away. That is why this is published rather than promised: comparing makes serving two versions impossible to get away with.

Root. Inclusion. Consistency. Signature. Comparison. Each one closes a door.

The rigorous version: verifiable-log/src/proof.rs for both proof checks, sth.rs for signed heads and the equivocation test, and docs/transparency.md → "Trust model". The live signed head is on the artifacts page.

Track D · Transparency · Topic 9

Pollis's three logs, and what each one protects

Three append-only logs, one signing key, one public site at verify.pollis.com. Each protects against a different problem.

Log 1, account keys: the wrong key being used for you. Every identity key we list for an account goes in here, in order, with a version number. A swapped key would have to be published here, in a record your own device audits. Versions only move forward, so an older key cannot be slipped back in and history cannot be renumbered.

Log 2, commits: a group's history being rewritten. Every device must apply a group's updates in the same order, and that order is what a bad server might try to reorder or split. So every commit is logged, under one rule: each epoch of a conversation appears once, and two different commits can never claim the same epoch. The log refuses a split, and if one were recorded anyway, both entries would stay in the signed public log for anyone to find.

This log never contains message content: each entry stores only the hash of a sealed commit the server cannot open. The entry is public, though, and carries some structure: a conversation pseudonym, a sender pseudonym, the epoch number, and a sequence number. The pseudonyms are hashes of the real ids that change every 1,024 log entries, and the sender's is different in every group, so an outsider reading verify.pollis.com/v1/entries.json cannot follow a person across groups or a conversation across windows. Within a window they can still see that some conversation had a burst of membership or key changes.

Log 3, binaries: a tampered app shipped to one person. The fingerprints of every released installer go in here, and the same release and platform can never come to mean different files later. A version built for a single person would either appear in the public log or be missing from it. Either way it is detectable.

Why three logs. A signed statement about one log must never pass as a statement about another, so each log signs under its own label and a statement from one fails to verify against the others. They share one key and one site, so what you have to trust is still a single built-in key.

What these logs do and do not do

These logs make tampering permanent and public, not impossible, and they say nothing about ordinary bugs: a log proves what was published, not that it was correct. The commit log publishes some activity metadata, as described above. Rotating pseudonyms also has a cost: a split whose two halves fall on either side of a window boundary carries two different pseudonyms, so an outsider replaying the log cannot see it, though group members and our own builder still can. Finally, the logs are republished daily (and right after each release), so a very recent release or key may not be in them yet. The app shows "not in the log yet" as pending, not as an alarm.

Each log and the problem it protects against, an attempted split being refused, and a signed statement that will not fit another log's slot.
Full transcript

Three logs. One signing key. One address: verify dot pollis dot com. Each one protects against a different problem.

The first holds every identity key we have published. For a swapped key to be used, it would have to be written here, in public, where the real owner's app is checking. And the rule is that versions only move forward. An old key cannot be slipped back in.

The second holds every update in every conversation. Groups move forward in epochs, and everyone applies the same updates in the same order. If a server tried to split the history, showing two different updates as the same epoch, the rule would refuse it: one epoch, one update. And what is stored is sealed. The log shows the order of a conversation, never a word of its content.

The third holds the fingerprint of every app we release. If a special version were built for one person, its fingerprint would either be in this public log, where anyone can see it, or missing from it, where that person's own app would notice. There is no third option.

Why three logs instead of one? Because a signed statement about one must never work for another. A valid statement from the message log does not fit the binaries log. Each log signs under its own label, so the signature says which log it belongs to.

And the limits, plainly. These make tampering permanent and public, not impossible. They also say nothing about bugs. A log proves what was published, not that it is correct.

One practical note. The logs rebuild once a day, so something published an hour ago may genuinely not be in them yet. That is why your app says pending instead of raising an alarm.

The rigorous version: docs/transparency.md → "Three domain-separated trees" and "The commit-log invariant", plus the per-tree contexts in verifiable-log-builder/src.

Track D · Transparency · Topic 10

Binary transparency, and reproducible builds

Everything else on these pages assumes the app on your machine is the app whose source you can read. A tampered app defeats everything else.

Code signing, the check your operating system runs before starting an app, proves less than people assume. An Apple or Windows signature proves the holder of the signing key produced these bytes, not what the bytes do. A tampered build signed with the right key passes every check your system makes.

So we publish the fingerprint of every release into a public log. The question becomes "is this the same app Pollis published to everyone?" rather than "did Pollis sign this?"

Each release is recorded at three levels, because "the app" is not one file. The payload is the program and its resources before any signature; an independent person can rebuild this from source and compare. The signed level is what you download after Apple or Windows wraps it; signing mixes in unique data, so two builds never produce an identical signed file. The installed program level is the fingerprint of the main program as it sits on your machine, which is what a running app can measure about itself. The app's "verify this build" check compares the running program against that fingerprint.

What this buys you. If a different build were made for one person, its fingerprint would either be missing from the log, which that person's app would notice, or appear in the public log next to the normal release.

Reproducible builds. A logged fingerprint proves we published these bytes, not that they came from the source you can read. That needs reproducible builds, where anyone compiling the source at a given version gets a byte-for-byte identical result. Ordinary builds bake in timestamps, file paths, and ordering that vary between machines, so this is hard.

The Linux AppImage is reproducible. An independent rebuild from the public source and the published build recipe, with none of our secrets, produces the fingerprint we logged, and it runs automatically after every release. macOS and Windows are not there yet. The signing wrapper is never reproducible on any platform.

Every release also carries a build provenance record in a public log we do not run, showing which automated workflow produced the bytes; checking it involves no Pollis key. Provenance says who built it; reproducibility says whether it matches the source. Having both means one compromised key does not undo everything.

Where the guarantee ends today

On macOS and Windows you currently get "these are the bytes Pollis published, and this workflow built them," but not "these bytes match the source you can read." Only the Linux AppImage closes that loop today; the Linux .deb and .rpm are not rebuilt either. Every rebuild's verdict is published per release on the assurance page, failures included. The signed wrapper can never match on any platform, because signatures mix in unique data.

A valid signature on a tampered app, the three levels a release is recorded at, the two ways a special build gives itself away, and current reproducibility status.
Full transcript

Your operating system checks that an app is signed before it runs it. That check proves less than you would think. A signature proves the company's key produced these bytes. It says nothing about what the bytes do. A tampered build that was still signed with the right key would pass every check your computer makes.

So we publish the fingerprint of every release, in public. The question changes. Not "did they sign it?" but "is this the app they gave everyone, or a different one?"

Each release is recorded at three levels, because the app is not one file. The payload is the program before any signature; that is the level someone else can rebuild from source. The signed level is what you download, after notarization, which mixes in unique data on purpose, so it is never identical twice. And the installed-program level is the fingerprint of the main program as it sits on your machine, which is what a running app can measure about itself.

Here is what it buys you. Two people download the app. One gets the public build, whose fingerprint is in the log. If a different build were made for one person, its fingerprint is either missing from the log, which their own app notices, or in the log, in public next to the real release. There is no quiet option.

Now the part to be careful about. A fingerprint proves we published these bytes. It does not prove they came from the source code you can read. For that you need reproducible builds: anyone compiling the source gets an identical result.

For the Linux AppImage, we have that. An independent rebuild, from the public source and the published build recipe with none of our secrets, produces the same fingerprint we logged, and it runs automatically after every release. On macOS and Windows, not yet. That is the current frontier, and the Linux build shows the approach already working. And the signing wrapper is never reproducible anywhere, by design.

There is one more record. Every release carries a build provenance record in a public log that is not ours, proving which workflow produced the bytes, with no key of ours involved in checking it. A different question from reproducibility, and a useful one when the worry is a compromised key.

The rigorous version: docs/verifiable-builds-design.md, the residual list in reproducible-builds-residuals.md, and the independent rebuild-verify workflow you can run from a fork. Live release hashes are on the artifacts page.

Track D · Transparency · Topic 11

Verify it yourself

This topic has you check the logs on your own machine, in about two minutes, two ways: reading the files directly, and running the verifier.

The verification data is just files. verify.pollis.com serves a folder of plain JSON files, with no server-side logic or database lookup. A service that computes answers could give different people different answers; files written in advance cannot. Our daily job re-reads what is being served to confirm it matches what was signed.

/v1/<log>/sth/latest.json is the current signed statement and /v1/<log>/entries.json the log's entries, where <log> is binaries or account-keys (the commit log sits directly under /v1/). /verify/release/<tag> is a ready-made report for one release. /v1/<log>/public_key.json is the signing key, served for convenience only: real verification compares against a key you already have (Topic 8).

Fetch the current statement from any terminal:

curl -s https://verify.pollis.com/v1/binaries/sth/latest.json

The verifier does the checking. The JSON tells you what is claimed. pollis-verify, a small standalone program, checks it: it re-hashes entries, rebuilds the tree, checks the proofs against the signed statement, checks the signature against a key built into the program, and re-runs the rules the log enforces. It uses the same verification code as the app.

Checking in the browser. The log signs with ML-DSA-44, so that its statements about history stay valid even after a quantum computer could forge an Ed25519 signature. Browsers do not ship ML-DSA, so a page that checks it has to bring its own cryptography. The Key Transparency page does: it runs the same checks as pollis-verify group in your browser, against the static log files and the pinned key, using a pinned copy of an audited ML-DSA library. The command-line verifier remains the check that does not depend on code a web page hands you.

The signature covers the root, so changing one character of the history changes the signed bytes and no edited log keeps a valid signature. The verifier takes the base URL first, then what to check:

# are the commit and account-key logs consistent and append-only?
pollis-verify remote  https://verify.pollis.com

# what keys have we published for this user, in what order?
pollis-verify account https://verify.pollis.com <user-id>

# is this conversation's history a single unbroken line?
pollis-verify group   https://verify.pollis.com <conversation-id>

# are this release's files in the log, with valid proofs?
pollis-verify release https://verify.pollis.com v1.5.3

A real run of the last command against the live log, captured 2026-07-23:

$ pollis-verify release https://verify.pollis.com v1.5.3

Release: v1.5.3
Found:   yes
STH:     tree_size 60  root 37945cb2f61ee43782259a3893336b8ba8b8679d3af1612742deeec75e46cc0c
Artifacts (publish order):
  darwin   aarch64  dmg       payload  payload 774c8f…ea92  artifact 774c8f…ea92  [included ✓]
  darwin   aarch64  dmg       signed   payload 774c8f…ea92  artifact eef7ae…c155  [included ✓]
  darwin   aarch64  dmg       exe      payload 774c8f…ea92  artifact 77fc24…bea5  [included ✓]
  windows  x86_64   nsis      payload  payload 88547f…7926  artifact 88547f…7926  [included ✓]
  windows  x86_64   nsis      signed   payload 88547f…7926  artifact 16edeb…e92b  [included ✓]
  windows  x86_64   nsis      exe      payload 88547f…7926  artifact 0f4c0f…73a6  [included ✓]
  linux    x86_64   appimage  payload  payload 9b1ab1…e653  artifact 9b1ab1…e653  [included ✓]
  linux    x86_64   deb       payload  payload 7f19ec…1a5a  artifact 7f19ec…1a5a  [included ✓]
  linux    x86_64   deb       exe      payload 7f19ec…1a5a  artifact 5d9281…55b5  [included ✓]
  linux    x86_64   rpm       payload  payload 586cfa…cbaa  artifact 586cfa…cbaa  [included ✓]
  linux    x86_64   rpm       exe      payload 586cfa…cbaa  artifact e45d0c…400b  [included ✓]

PASS: release binaries tree is valid

tree_size 60 and the root are the signed statement from Topic 8, the same one the curl above returns. Each [included ✓] is a proof your machine recomputed, not a stored status. The payload / signed / exe levels are from Topic 10. PASS means the whole tree replayed cleanly under the built-in key with every rule holding.

What a failure means. A FAIL is not necessarily an attack. The log may be mid-rebuild, or the release may be newer than the last rebuild (Topic 9). The result that would matter is a valid signature over a tree that disagrees with one someone else was served: the two-versions case from Topic 8. That is why comparing results with someone else running the tool is the most useful thing you can do with it.

After every release, a separate workflow rebuilds the Linux payload from public source and checks it against the log; each verdict, including two releases that were never checked, is recorded on the assurance page. Your run of pollis-verify is the independent check.

Where it comes from. pollis-verify ships as a standalone binary with checksums, and its built-in key is printed in the release notes. If you are auditing us seriously, build it from source and read what it does first.

Where the guarantee ends

A PASS proves the log you were served is consistent, append-only, and signed by the built-in key. A single run cannot tell you whether someone else was served the same log; only comparing can. And a verifier we hand you, checking a key we built in, is only as trustworthy as your decision to build it from source and read it. Both gaps shrink as more people run it and compare.

The folder of plain files, a command-line fetch, real verifier output ending in PASS, and the comparison that takes two people.
Full transcript

Everything we have talked about is checkable, in about two minutes. First, the data, which is barely a service at all. It is a folder of plain files. No database, no logic, nothing that can behave differently depending on who is asking. A service that computes an answer could give different people different answers. A folder of files written in advance cannot.

You can fetch any of them right now. Here is the current signed statement: the root, the entry count, and a signature. And here is the same root on our website. Same sixty-four characters, two different places.

But reading the files only tells you what is claimed. To check the claims, use the verifier. It is a small program you download, or build yourself, and if you are seriously auditing us, build it. It re-hashes every entry, rebuilds the tree, checks every proof, and checks the signature against a key built into it. Not downloaded. Built in. One command.

Watch what comes back. Tree size, sixty. The root, the same one you just fetched. Then every file in the release, each with a tick. Those ticks are not a status someone wrote down. Each one is a proof your machine just recomputed. And PASS means the whole tree checked out under a key that cannot be swapped on you.

A failure does not always mean an attack. It can mean the log is mid-rebuild, or a release is newer than the last daily rebuild. The result that would matter is two valid signatures over two different trees, which is the two-versions case, and the only one you cannot spot alone.

So run it, and compare with someone.

The rigorous version: pollis-verify.rs (the CLI you just ran), the shared verification path in verifiable-log-serve, and the static API layout in docs/transparency.md.

Track D · Transparency · Topic 12

Reading the artifacts page and the Security page

On the website: the artifacts page. Latest releases shows the current version of each thing we ship, with download links.

Release proofs shows, for the current release, whether it was found in the binaries log, whether the tree checked out, and what is in it. Each row is one file with the fields from Topic 10: platform, package, level, and two fingerprints, each with a tick meaning "a proof was checked". The tree size and root are the signed statement from Topic 8, the same root the command line and the verifier return. Not in log yet usually means the log has not been rebuilt since the release (Topic 9).

Daily transparency self-audit shows the current signed statement for all three logs. The one key you trust is the built-in signing key everything above depends on. The page notes that its in-browser key check is a convenience; real verification uses a key you obtained independently (Topic 11).

In the app: the Security page. This build shows a version, a commit, and a verdict from the in-app check (Topic 10), one of four. Build publicly verified: this exact app's fingerprint is in the public log for this release. Build publication pending: the release is not in the rebuilt log yet, which is normal right after a release. Verification unavailable: the check could not run, because the log was unreachable or this release predates the installed-program record. Build not in public log: the release is published with comparable fingerprints, and this one is not among them.

The last two differ as "could not check" differs from "checked, and something is wrong."

Below that, account key is your device confirming that the key the log shows for you matches the key you hold (Topics 6 and 9). Devices and security events records enrollments and resets. Safety numbers per contact show the shield states from Topic 6, including the warning for a changed key.

What green actually means

Green means what Pollis published is consistent, permanent, and includes the app you are running. It does not mean the code is bug-free, that your device is uncompromised, or that metadata is hidden from whoever carries your traffic.

Both dashboards labelled, one release row taken apart, the same root in three places, and the four build results, focusing on the two that are easy to confuse.
Full transcript

You have every idea you need to read our dashboards. Latest releases shows what we currently ship, and where to get it.

Release proofs is the one that matters. For the current version: is it in the binaries log, did the tree check out, and what is inside.

Each row is one part of a release. Platform. Package. Level, which is one of the three from earlier. Two fingerprints. And a tick. Read that tick carefully. It does not mean someone ticked a box. It means a proof was recomputed and it held. The tree size and root are the signed statement, and this root is the same string you would get from the command line or the verifier. If it says not in log yet, that is almost always the daily rebuild, not a problem.

Below that, the daily self-audit shows the current statement for all three logs, and at the bottom, the one key everything reduces to. The page also notes that its in-browser key check is a convenience, not the real safeguard. Real verification uses a key you obtained independently.

Now in the app. This build tells you whether the exact app you are running is in the public log. Four possible results. Pending means the log has not rebuilt yet. Unavailable means the check could not run, because the log was unreachable or the release is older than the record it compares against. And not in public log is the loud one: the release is published, there is something to compare against, and this one is not it.

Unavailable versus not in the log is the difference between "I could not check" and "I checked, and something is wrong." It is worth keeping straight.

Below that: your account key, checked against the public log. Your devices. Your security events. And safety numbers for each contact.

Green across all of it means the things we published are consistent, permanent, and include the app you are running. It does not mean the code has no bugs, that your device is safe, or that metadata has gone anywhere. We keep shrinking what you take on trust, and publish the evidence for what is left. Now you can check it.

The rigorous version: the page itself, the artifacts page, plus artifacts.js for how the proofs are rendered and BuildVerifyLine.tsx for the four verdicts.