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.
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.
We read both rather than assuming. The answers were not what we expected:
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.
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.
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.
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:
changed: AudioEncoder.mlmodelc/weights/weight.bin.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.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.
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.
github.com/conrader/plainsay — inspect the MIT-licensed Mac client