← All posts
Deep dive

Trusting a model you didn't compile: a signed catalog and a fail-closed pull chain

When you install a model you didn't build, you need a reason to trust the bytes. OpenASR splits that trust into two separate mechanisms — a sha256-pinned pack and an Ed25519-signed catalog manifest — wired into a pull chain that fails closed.

Every local model you run is a file someone else produced on a machine you’ve never seen. The honest question for any offline transcription tool is not “is the cloud listening” but “why should I trust these weights.” OpenASR’s answer is deliberately small and checkable: ship no weights in the app at all, make exactly one engine responsible for downloads, and put two independent guarantees — a pinned pack and a signed catalog — in front of anything that touches disk.

It helps to be precise about what is signed and what is not, because the two are easy to conflate. The .oasr pack is pinned, not signed: there is no per-pack publisher signature and no key embedded in the file (the pack’s internal layout is a separate subject). The catalog manifest is what carries the Ed25519 signature. Trust flows from the catalog to the pack through a content hash, and from the catalog to you through a single hardcoded key.

Nothing arrives by accident

The distribution ships zero model weights in the app or CLI. openasr-core::pull is the only download and install engine, and the CLI calls into it directly. The desktop build is designed so its bundle carries the sidecar binary and registry/catalog metadata only, with the daemon and a desktop Models page intended to route every install back through that same engine and the webview never downloading artifacts on its own. Runtime surfaces (transcribe, serve, batch, benchmark, realtime, the API) never implicitly fetch a model. If you want bytes on disk, you ask for them out loud.

bashthe only sanctioned install path
$ openasr pull qwen3-asr-0.6b
# resolves the catalog, pins the pack, verifies, then atomically installs

A chain that fails closed

A downloaded pack is not allowed to run until it clears a fixed sequence: an HTTPS catalog pack URL, pinned pack-URL validation, a size and sha256 match, a Rust GGUF preflight, runtime-source validation, and finally a same-directory atomic rename into the installed pack. Each step is a gate, not a hint. Runtime surfaces fail closed on remote URLs, directories, invalid extensions, missing files, invalid runtime metadata, or a failed tensor/layout preflight. The default outcome of anything unexpected is to refuse, not to proceed.

i

Pinned, not signed

The pack’s integrity comes from its sha256 and size being pinned by the catalog entry, plus a GGUF preflight before load. There is no in-pack publisher signature to check — the cryptographic signature lives one level up, on the catalog manifest.

The catalog manifest is the signed object

Here is where the actual signature sits. The catalog manifest is verified with Ed25519 (via ed25519_dalek) under CATALOG_SIGNATURE_SCHEMA_VERSION = 1, algorithm ed25519, key id openasr-catalog-v1, and signing domain openasr.catalog_manifest.v1. It is checked against a single hardcoded trust root whose public key is 92331f10…5105c2ad — one built-in key, not a multi-signer scheme and not live key rotation in production.

Content integrity is enforced alongside the signature. Verification recomputes the catalog’s sha256 and returns CatalogShaMismatch on any difference, and the manifest fields are shape-checked: catalog_sha256 must be exactly 64 lowercase hex characters, and signature.value exactly 128 hex characters. A manifest that doesn’t parse to those exact shapes is rejected before the signature math even runs.

The pack is pinned by content hash; the catalog is signed by one key. Neither mechanism trusts the other’s word for it.

from the catalog security model

Anti-rollback, stated precisely

A signed manifest can still be a stale one, so the catalog carries an epoch. Enforcement is narrow on purpose: a received epoch strictly less than the highest stored epoch is rejected with EpochRollback, and epoch 0 is invalid. Note the word strictly — an equal epoch is accepted, so this is anti-rollback, not a strictly-increasing counter. It blocks a downgrade to an older catalog without forcing every refresh to bump the number.

Offline, but never unsigned

Trust survives going offline, but it does not relax. On a successful HTTPS fetch, OpenASR pulls the adjacent signature manifest, verifies it, rejects epoch rollback, validates the schema, and writes the exact validated contents — plus the signature and the highest accepted epoch — to local cache. If the next HTTPS fetch fails, it loads only that previously-cached signed manifest. Unsigned caches are accepted solely for local file:// or filesystem catalog sources used in development and bundled-resource flows. The live trust source stays HTTPS plus signature; an offline run replays a proof you already verified, rather than inventing a new one.

A model pack carries weights, tokenizer, and metadata in one GGUF-backed .oasr file — but the pack itself carries no publisher signature; integrity is pinned by the catalog and verified at pull time.

None of this was a notarized installer or a published, checksummed release when this was written — those were future intent, and there was no public app build to point at yet. What existed at publish time was the smaller, verifiable thing: a download engine you can name, a pull chain that refuses by default, a pack pinned by its hash, and a catalog signed by exactly one key you can read in the source. That was enough to answer “why trust these weights” without asking you to take anyone’s word for it.

i

Editor's note (2026-07-04)

OpenASR 0.1.0 has since shipped: a checksummed CLI release (GitHub Releases, SHA256SUMS) for macOS arm64 and Linux x86_64, and a Developer ID signed, notarized macOS desktop build. See OpenASR 0.1.0.