← Back to Pollis

Assurance

What Pollis checks about its own code, and what those checks actually found — including the times they found something.

Four independent things run on this codebase: an independent rebuild of every release, machine-checked proofs of the parts where a bug loses messages, a dependency policy enforced on every pull request, and a set of signing keys whose custody is written down. All four have run for months. None of them was published, which meant you had to take our word for results we were already producing.

Everything below is a link to a machine-generated record, not a summary you have to trust. The artifacts page is the companion: it covers the transparency log and the commands that check a download.

1 · Independent rebuild of every release

After each release, a workflow rebuilds the Linux AppImage payload from the public source at that tag, on a clean runner, and asserts that the hash it gets is the hash already recorded in the public transparency log. It uses no Pollis secrets — that is deliberate and load-bearing, so that anyone can fork it and run the identical check with no credentials from us. It trusts exactly one pinned ML-DSA-44 key.

Until now that verdict went to a job log and a CI artifact that expires after 90 days. Below is the durable record.

Loading rebuild verdicts…

Where this guarantee ends

It covers the Linux AppImage payload only. Not the .deb, not the .rpm, not macOS, not Windows. Those platforms have published-bytes-match and build provenance, but nothing yet re-derives them from source.

Reproduction needs the published build recipe, not source alone, because one build-time value is compiled in. A third party with no recipe can still prove log inclusion — that half needs no build inputs at all — but not bit-for-bit reproduction.

Two releases were never checked. The rebuild only runs when the release workflow concludes successfully, so v1.9.0 and v1.9.1 were skipped entirely and no verdict exists for them. They are in the table, marked as such, rather than quietly omitted.

Some historical red runs were not real failures. Before commit 564353ae (16 August 2026) the workflow could report "did not reproduce" for a release that was merely not yet published into the log — the underlying check exits cleanly over an empty artifact set, so the wait loop gave up immediately and compared the rebuild against nothing. Two runs failed exactly that way (v1.9.4 and v1.9.5) and both in fact reproduced byte-identically. The reds are left in the table rather than deleted.

v1.9.3 reproduces. Its August 13 rebuild did not, and sat unexplained. Re-running exactly the same check on 17 August — same tag, same public source, no other change — landed on the same CI machine image the release had used and produced a payload byte-identical to the one in the log (07bccca8…, run 32036282718). One variable was different in August; holding it constant makes the bytes match.

What that variable was. A Linux AppImage is not purely a function of our source: the bundler copies the app's system libraries off the build machine into the bundle. GitHub rebuilds the machine image behind its ubuntu-22.04 label every few weeks and rolls it out gradually, so the release build and its rebuild — 36 minutes apart — landed on different images, and seven vendored libraries (Kerberos, SQLite, systemd, udev) came out different. Nothing built from Pollis source changed except the order of a list of content hashes inside the executable, which a compile-time dependency emits in filesystem order and which carries no meaning.

The August run stays in the table, still red, rather than being deleted or quietly recoloured: it genuinely could not perform the comparison it reported on. What changed is that the workflow now reads the machine image the release recorded, compares it against its own, and says which situation it is in — did not reproduce, build environment drifted, or no recorded recipe. All three still fail, because an inconclusive rebuild is not a passing one; only the first is a claim about the shipped binary. The full write-up, with the byte evidence, is in docs/reproducible-builds-residuals.md.

2 · Machine-checked correctness

Pollis's hard promise is messages must work: delivery to every current member, decryption, offline and online cycles, reconnects. The places where that promise can break are small, pure decision functions — when a delivery cursor may advance, when a commit may be accepted at an epoch, when old data may be deleted. Those are checked by machine, not by testing a happy path.

TLA+ — the design

Two specifications are checked exhaustively by TLC on every relevant pull request. Exhaustive means every reachable state of the model is enumerated, not sampled.

Measured on a run of scripts/tlc-check.sh against TLC v1.8.0.
SpecChecksModel boundStatesResult
CommitLog OnePerEpoch, Gapless, OpeningClosesTheHead, NoForeignAdopt, LineageMonotone 3 clients, max 2 commits, max epoch 3 72,228 distinct
depth 13
all hold
Delivery NoLossForCurrentMember, CursorMonotone, AcceptedLossesOnly, CursorLeqClock 2 devices, max 3 messages, max epoch 2 130,484 distinct
depth 19
all hold

A passing check is worthless if the invariant has quietly stopped being able to fail, so three deliberately broken variants run alongside and each must produce a counterexample. Dropping the commit-log head guard violates OnePerEpoch; dropping the migration compare-and-swap violates OpeningClosesTheHead; guarding retention on the fastest member rather than the slowest violates NoLossForCurrentMember. If any of those ever passes cleanly, the gate fails.

Kani — the actual Rust

TLA+ checks the design; the design is not the shipped code. 35 Kani harnesses — 19 proofs and 16 teeth — run bounded model checking over the real functions, not a model of them: the delivery watermark, the rejoin gate that keeps a revoked device out of the tree, own-commit canonicalization, gap detection, the Delivery Service's head arithmetic, and the retention floor.

The same teeth discipline applies. Each proof is paired with a mutant — an off-by-one in the watermark, a fail-open rejoin gate, a retention floor that adds slack instead of subtracting it — that Kani is required to refute.

Both results are now published as a run summary and a downloadable artifact on every run: TLA+ runs · Kani runs · design doc · the specs themselves.

Where this guarantee ends

Both are bounded. TLC checks a small model — three clients, a handful of epochs — not an unbounded network, and Kani checks small input domains, not all inputs. A bug that only appears at larger scale is outside both. This is the standard trade: exhaustive over a small space beats sampled over a large one, but it is not a proof about the running system.

They cover the decision functions, not the program. The proofs range over pure functions deliberately extracted for the purpose. The database calls, the network, the MLS library and the UI around them are not verified — they are covered by ordinary tests and a nightly fuzzing soak.

One invariant has no machine-checked defence at all — that the test schema equals the production schema. It is enforced by a mechanical equality check instead, and the design doc says so.

3 · Supply chain

Pollis's Rust lockfile resolves to 1,094 crates. Two gates run on every pull request and every push to main, both against pinned tool versions (cargo-deny 0.19.9, cargo-vet 0.10.2) so the gate itself cannot drift.

cargo-deny — advisories, licences, sources

  • Advisories. Every RustSec advisory against the graph fails the build unless it has a named exemption with a written reason. There are 29 exemptions and no blanket suppression — each names a specific RUSTSEC- id. Twenty-one are unmaintained-crate notices with no vulnerability (ten of them the GTK3 bindings Tauri's WebKit backend requires, for which no GTK4 Tauri backend exists). Eight are real vulnerabilities with no reachable fix; those are listed individually below.
  • Licences. An allow-list of 17 identifiers, each one verified as actually present in the graph rather than allowed speculatively. MPL-2.0 is permitted with the reasoning written down (file-level copyleft, no reach across a module boundary).
  • Sources. crates.io only, plus exactly one git dependency: an openmls commit pinned by revision, needed for the post-quantum MLS ciphersuite that no published release carries yet. The entry matches a repository URL, and the revision is pinned in Cargo.toml, so it cannot silently follow upstream main.
The eight exempted advisories that are actual vulnerabilities, with the reason recorded in deny.toml.
AdvisoryCrateWhy it is exempt
RUSTSEC-2023-0071rsa No upstream fix. Transitive via LiveKit's JWT library; the decryption-timing oracle is not reachable on the signing path we use.
RUSTSEC-2026-0049
RUSTSEC-2026-0098
RUSTSEC-2026-0099
RUSTSEC-2026-0104
rustls-webpki 0.101 / 0.102 Certificate-validation issues fixed only on the 0.103 line, which our own direct TLS already uses. The old lines are pinned by LiveKit and libSQL upstream; waiting on their rustls bump.
RUSTSEC-2026-0258h2 0.3 Empty-DATA-frame denial of service, fixed only on the 0.4 line with no 0.3 backport. Reachable only as libSQL's outbound client to Turso, never on the Delivery Service's inbound listener, which is hyper 1.9 and does not use it.
RUSTSEC-2026-0194
RUSTSEC-2026-0195
quick-xml Every vulnerable instance is build-time code generation or dev-only. No runtime path parses untrusted XML.

cargo-vet — review provenance

cargo-vet records, per crate version, who has reviewed it. Pollis imports 517 audits from six organisations that publish theirs — Mozilla (206), Google (117), Bytecode Alliance (84), ISRG (52), Zcash (42) and Embark (16) — plus 35 crates trusted by publisher identity. The remainder sit in an exemption baseline.

Where this guarantee ends

Pollis has audited zero crates itself. supply-chain/audits.toml is empty. 858 exemption entries cover 716 crates, which is the dependency graph as it stood when the gate was switched on. What the gate actually enforces is a ratchet: no new or bumped crate can enter without a deliberate, reviewable change to that file. It is not, and should not be read as, "every dependency has been reviewed".

Duplicate-version and wildcard checks warn rather than fail. A large native tree carries transitive duplicates we do not control, so those are visible in the job log but do not block.

There is no scheduled re-scan and no automated dependency-update bot. Advisories are checked when a pull request runs or on a push to main. A newly published advisory against an unchanged dependency is not caught until the next such run.

Neither gate covers the JavaScript half or vendored C/C++. The renderer's pnpm tree appears in the per-release SBOM but is not vetted by these tools, and the C and C++ compiled inside build scripts (notably the WebRTC crates) is in neither.

Read the policy directly: deny.toml · supply-chain/ · gate runs.

4 · Signing keys, custody and rotation

Every claim on the artifacts page reduces to one pinned ML-DSA-44 key, key id 6cbd4b2aed5c4bf1. This section is about who holds it, what happens if it is stolen, and what has already been done to it.

KeySignsCustody
Transparency-log signing key
ML-DSA-44
Signed tree heads for all three logs A GitHub Actions secret, backed up in a secrets manager
Offline root key
ML-DSA-44
Nothing but key-set statements: "these keys may sign these logs, until this date" Offline. Deliberately absent from CI, from the secrets manager and from the repository
Release code-signing macOS (Apple Developer ID) and Windows (Azure Trusted Signing) installers GitHub Actions secrets
Sigstore identity cosign signatures and SLSA provenance No held key at all — a short-lived certificate bound to the release workflow's OIDC identity

A real rotation has been executed. In August 2026 the log signing key was replaced with fresh material and all three trees were republished from source data under new domain-separation contexts. It was not a formality: the previous key had been derived from the same 32-byte seed as its Ed25519 predecessor, so the earlier post-quantum migration had changed the format without changing the secret. The rotation was rehearsed against production with the old key still installed first, which caught two bugs that would each have made it a silent no-op. The result was verified independently, from a machine that had not performed the rotation.

A compromise does not require shipping a new build. Because clients pin a key set authorised by the offline root, the response to a stolen signing key is to sign a new key set that omits the compromised key entirely and republish — no client update, no flag day. The runbook is explicit that a compromised key is dropped rather than given a short expiry, because an attacker who can delay a client's fetch keeps it alive for exactly that long. It is equally explicit that you act on suspicion: you do not need proof.

Where this guarantee ends

GitHub Actions is inside the publishing trust boundary, and the whitepaper says so. The log signing key lives in Actions secrets and the trees are built and signed in CI, so a compromise of that environment could sign a tree. The post-publish self-audit and the equivocation tripwire detect that after the fact; they cannot prevent it. The same is true of the release-signing keys. Pollis operates the only log, which is why the independent verifier is released — so a third party can audit without asking us for anything.

The rotation shipped without an overlap window. The custody design calls for a minimum 90-day period in which both keys are pinned; the rotation that was actually performed did not have one. An auditor holding cached pre-rotation tree heads sees the change as equivocation and has to re-pin from the announcement rather than verify the transition.

Root compromise has no in-band recovery. If the offline root is lost or stolen, the fix does require a client release: mint a new root, ship a build pinning both, wait out the overlap, then publish under the new root alone.

The pinned key is copied by hand into eight places — the Rust core, this website, two workflows, the key-set statement, and three documents — with no check that they agree. One deployed copy going stale has already shown a false tamper alarm to every visitor once.

Compromise runbook · Custody & rotation design · The signed key set.

Reporting a vulnerability

Report privately through a GitHub security advisory, not a public issue. The machine-readable policy is at /.well-known/security.txt and the terms are in the disclosure policy.

Pollis is source-available, not open source: PolyForm Noncommercial 1.0.0. You may read, build, audit and modify it for noncommercial purposes; commercial use needs a separate licence. Auditing the code and publishing what you find is explicitly permitted.