Supply chain

Nobody was checking the speech models.

Plainsay is signed and notarised. Then, on first run, it downloads a few hundred megabytes of Core ML from the internet and executes it. Until v0.2.27, nothing verified that file — and neither of the libraries we depend on verifies it either. This is what we found when we went to look, and what we did about it.

The shape of the gap

An external reviewer raised it as the last item on a list of otherwise small findings, and rated it honestly: high precondition, high impact. An attacker needs to be in a position to serve modified model files. That is not a low bar.

But it is the one place unsigned code enters an otherwise tight chain. Everything else that runs on your Mac is signed by us and notarised by Apple. The component that actually does the transcribing arrives over the network and is compiled and executed. For an app whose entire pitch is that your audio never leaves the machine, that is the wrong gap to leave open.

The honest line
This was open from the first release until v0.2.27, on 26 August 2026. It was not exploited as far as we know, and we have no way to prove that — which is rather the point of fixing it.

What the libraries actually guarantee

We read both rather than assuming. The answers were not what we expected:

WHISPERKITShips a Hugging Face client that does have SHA-256 machinery — but it hashes a file only to decide whether an already-cached copy can be reused, and enforces a hash strictly only in offline mode. The fresh-download path writes the file, writes its metadata, and returns. A model downloaded for the first time is never verified.
FLUIDAUDIONo hashing at all. Its only use of ETags is If-Range, to resume an interrupted transfer.

Both also resolve from a moving branch, and neither exposes a revision parameter through the API we call. So there was nothing to reuse, and pinning a commit was not reachable without forking. That left verifying it ourselves.

Why the obvious fix is not a fix

The tempting approach is to ask the server for the file's hash and check the download against it. That proves the transfer was not corrupted — which TLS already tells you. It proves nothing about whether the file is the one you meant to run, because the hash and the file come from the same place over the same connection.

So the expected digests for every model Plainsay can download are pinned in a manifest that ships inside the signed app bundle, and are recomputed locally after download and before the model is loaded. Someone who controls what the model host serves cannot also change what we expect it to be, because changing that means changing a notarised binary.

It is a lockfile, not a signature

We want to be precise about what this buys, because overstating it would be worse than not having it.

The pins record what the model repositories served at the moment they were taken — exactly as a Package.resolved records what a dependency resolved to. They cannot tell you the upstream model was honest that day. What they do is make any later change visible, which is the realistic attack: a repository, or an account with push access to one, quietly starting to serve something different than it did when a human last looked.

The manifest is generated from Hugging Face's tree API without downloading the models — a few hundred kilobytes of JSON rather than several gigabytes of weights. Large files carry a real SHA-256; smaller ones carry git's blob SHA-1. Both are recomputable on your Mac, and each pin records which applies.

Two decisions that are easy to get wrong

  • A rejected model is deleted. Both libraries treat “the files are already here” as a reason to skip downloading. A rejected model left on disk would be reused and rejected again on every launch, for ever, with no way out short of finding the cache by hand. Removing it means the next attempt simply re-downloads.
  • Unexpected extra files are reported, never fatal. Both libraries write sidecars next to the weights and Core ML leaves artifacts of its own. A file that is not in the manifest is not loaded by name and so never executes — whereas failing on one would let a harmless packaging change break transcription for everybody. A pinned file that is missing or changed is always fatal.

How we know it works

Synthetic tests prove the digest functions but not the pins. Wrong pins would pass every one of them while every real user hit a hard failure on first run — looking for all the world like an app that simply cannot download its own model. That is the worst place to be wrong, so it was checked end to end:

  • A genuine 147 MB download of the smallest model, from the pinned commit, verifies clean across 19 files and both digest types.
  • Flipping a single bit inside a 41 MB weights file is caught and named: changed: AudioEncoder.mlmodelc/weights/weight.bin.
  • The git blob digest is asserted against git hash-object itself rather than a constant — that framing is easy to get subtly wrong, and it fails in the direction that looks like stale pins rather than a broken implementation.
  • A test fails if a model is ever added without pins, so the hole cannot reopen by omission.

What is still open

The pin is only as good as the moment it was taken. If an upstream model is republished, the check fails closed and a human has to look at what changed and re-pin deliberately. We did not wire that check into CI: it would then fail on every unrelated change, and the correct response to a changed pin is someone reading the diff — which is exactly what a permanently red build trains people to stop doing.

We would rather say that plainly than imply the problem is now permanently solved.

You don't have to take our word for it

All of the above is in the open: the verification code, the pinned manifest, the generator, and the tests including the single-bit tamper case. Plainsay's Mac client is MIT licensed and public. If this page was worth reading, the diff is worth reading.

Dictation you can audit.

Download for macOS Free · MIT licensed · macOS 14+, Apple silicon