Skip to content

feat(security): pin and verify every model download with secured UX - #575

Open
jatinkrmalik wants to merge 7 commits into
mainfrom
feat/model-hash-verification
Open

feat(security): pin and verify every model download with secured UX#575
jatinkrmalik wants to merge 7 commits into
mainfrom
feat/model-hash-verification

Conversation

@jatinkrmalik

@jatinkrmalik jatinkrmalik commented Jul 24, 2026

Copy link
Copy Markdown
Member

What

Adds SHA256 hash verification for all model downloads (whisper.cpp, whisper, VOSK) to detect tampering or corruption.

image

Why

Hugging Face has had a rough time with security lately:

  • March 2024 - JFrog found ~100 malicious models on HF, 10 with actual payloads including reverse shells via pickle deserialization
  • February 2025 - ReversingLabs discovered models that evaded Picklescan (HF's own scanner) by using 7z compression and corrupting the pickle stream after the payload had already executed
  • December 2025 - Three zero-day bypass vulnerabilities in Picklescan itself (CVE-2025-10155/56/57, CVSS 9.3)
  • 2026 - ShadowPickle research demonstrated 63% evasion rates across all state-of-the-art scanners

The common thread: you can't trust that a model you download is what you think it is. Hash pinning doesn't solve the authenticity problem (if upstream is already malicious, a matching hash just confirms you got the right malicious file), but it does catch:

  • Corrupted downloads
  • Man-in-the-middle attacks
  • Some supply chain swaps
  • Accidental model file changes

How

  • model_hashes.json - Registry of SHA256 hashes for known-good models
  • model_hash_verifier.py - Utility that verifies files against the registry
  • recognition_manager.py - Verifies hashes after runtime downloads
  • install.sh - Verifies hashes during installation

Graceful degradation: if a model isn't in the hash registry, we log a warning but don't block the download. This keeps things working while we build out the hash database.

What this doesn't do

This isn't a complete solution. It's defense-in-depth. For full supply chain security we'd also want:

  • Self-hosted model mirror (planned for next phase)
  • Safetensors format exclusively (eliminates pickle deserialization entirely)
  • Code signing for authenticity guarantees

But hash pinning is low-effort, high-impact, and doesn't require infrastructure changes.

Testing

  • Hashes computed from actual downloads of whisper.cpp models (tiny, tiny.en, base, small, medium)
  • Whisper tiny hash matches the SHA256 embedded in OpenAI's URL
  • Verification logic tested with both matching and mismatching hashes

Checklist

  • Code follows project style guidelines
  • Tests pass (or N/A for this change)
  • Documentation updated (inline comments explain the security rationale)
  • Commit messages are clear

Note

Medium Risk
Touches the critical model download and install path for large binaries loaded into the process; failures block installs/downloads but reduce supply-chain and zip-slip risk.

Overview
Adds SHA256-pinned model integrity for whisper.cpp, Whisper, and VOSK across the app, installer, and docs.

A shipped model_hashes.json registry (refreshed via scripts/update_model_hashes.py) records digest and size for every UI-downloadable model. model_integrity.py enforces HTTPS-only URLs on known hosts, rejects HTTP-downgrade redirects, verifies bytes after download (optional VOCALINUX_STRICT_MODEL_VERIFICATION for unpinned models), and replaces naive VOSK extractall with safe_extract_zip (path traversal, symlinks, expansion cap). recognition_manager wires these checks into all three download paths, cleans up on ModelIntegrityError, and surfaces pin lookup / verify stages to the UI. install.sh shares the same registry for Whisper, whisper.cpp, and VOSK installs and validates zip members before extract.

ModelDownloadDialog and settings copy now present a secured-download flow (stages, lock badge, honest handling when no pin exists). Whisper URLs move to whisper_model_info.py; VOSK uses vosk_model_url with a Portuguese model ID fix. Tests cover registry coverage, installer bash helpers, and tampered / malicious download cases.

Reviewed by Cursor Bugbot for commit e96e6ba. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions github-actions Bot added app Core Python application (src, packaging) installer Install/uninstall scripts labels Jul 24, 2026
Comment thread src/vocalinux/utils/model_hashes.json
Comment thread src/vocalinux/utils/model_hash_verifier.py Outdated
@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file ci CI/CD, build tooling, scripts tests Test suite changes labels Aug 3, 2026
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.34146% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.88%. Comparing base (27eb1d3) to head (e96e6ba).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...ocalinux/speech_recognition/recognition_manager.py 92.59% 3 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #575      +/-   ##
==========================================
+ Coverage   87.58%   87.88%   +0.30%     
==========================================
  Files          35       37       +2     
  Lines        6547     6703     +156     
  Branches     1088     1116      +28     
==========================================
+ Hits         5734     5891     +157     
+ Misses        586      583       -3     
- Partials      227      229       +2     
Files with missing lines Coverage Δ
src/vocalinux/utils/model_integrity.py 100.00% <100.00%> (ø)
src/vocalinux/utils/vosk_model_info.py 100.00% <100.00%> (ø)
src/vocalinux/utils/whisper_model_info.py 100.00% <100.00%> (ø)
...ocalinux/speech_recognition/recognition_manager.py 84.74% <92.59%> (+0.68%) ⬆️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread scripts/update_model_hashes.py Outdated
digests[f"{size}.pt"] = {
"sha256": sha256,
"size": int(response.headers["content-length"]),
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whisper hash refresh crashes

Low Severity

In collect_whisper, directly accessing response.headers["content-length"] can raise a KeyError if the HTTP HEAD response from OpenAI's CDN omits this header. This causes the script to abort, preventing Whisper model pins from being refreshed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e19c7bc. Configure here.

Comment thread install.sh
return 1
fi

return 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Installer skips symlink zip checks

Medium Severity

verify_zip_members_safe only greps unzip -Z1 output for absolute paths and .. segments. It never detects symlink members, yet install.sh still runs unzip after that check. Runtime VOSK extraction uses safe_extract_zip, which rejects symlinks. SECURITY.md states symlinks are refused for VOSK archives, but the installer path does not enforce that.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e19c7bc. Configure here.

Comment thread src/vocalinux/speech_recognition/recognition_manager.py Outdated
Comment thread src/vocalinux/speech_recognition/recognition_manager.py Outdated
Comment thread src/vocalinux/ui/settings_dialog.py Outdated
@cursor

cursor Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Ready for review — secured model downloads

Took over this PR, rebased onto latest main, and finished the security work end-to-end.

What’s new vs the original draft

  • Complete pin registry (72 models) — no TODO_DOWNLOAD_AND_COMPUTE placeholders that would have broken VOSK
  • Zip-slip protection, HTTPS/known-host checks, plain-HTTP redirect rejection
  • Secured download UX with lock badge + staged SHA256 verification messaging
  • Tests covering integrity, installer helpers, and tampered-download rejection (2175 passed)

Demo (validated on the desktop)

Secured download dialog with lock badge

SHA256 verified completion

secured_model_download_sha256_verification_ux.mp4

Speech Model page lock note

Stages the user sees: looking up pin → connecting → downloading over HTTPS → verifying SHA256 → hash matches → ready.

@cursor cursor Bot changed the title feat: add SHA256 hash verification for model downloads feat(security): pin and verify every model download with secured UX Aug 3, 2026
@VocaHQ VocaHQ deleted a comment from cursor Bot Aug 3, 2026
@cursor
cursor Bot force-pushed the feat/model-hash-verification branch from 34c18e8 to e96e6ba Compare August 3, 2026 06:43

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e96e6ba. Configure here.

status = (
f"<span foreground='#e5a50a'>↓ Will download ~{_format_size(info['size_mb'])}</span>"
" · SHA256-pinned"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed apply still shows success

High Severity

When a secured model download fails during reconfigure (for example SHA256 mismatch), _apply_settings_internal catches the error and returns False, but download_and_apply still calls set_complete(True). The secured download dialog then shows success even though settings were not applied and the model was discarded.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e96e6ba. Configure here.

verify_downloaded_model(filepath, model_type, filename)
self._report_download_status("Hash matches — integrity verified", 1.0)
if watching:
self._interruptible_pause(0.5)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancel ignored during hash verify

Medium Severity

After the byte transfer finishes, _verify_download_with_status runs a full-file SHA256 pass and the VOSK path continues with extraction without ever reading _download_cancelled. Cancel only works in _interruptible_pause and in the streaming loops, so a user who cancels while the dialog shows “Verifying SHA256 checksum…” still completes verification, renames or extracts the model, and reports success.

Additional Locations (3)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e96e6ba. Configure here.

Replaces the placeholder hash registry with pinned SHA256 digests for all 72
models the app can download, and hardens the paths that fetch and unpack them.

- model_integrity.py: digest verification, HTTPS/known-host enforcement and a
  zip extractor that refuses members escaping the models directory
- model_hashes.json: 29 whisper.cpp + 5 Whisper + 38 VOSK models, no gaps and
  no placeholder values
- scripts/update_model_hashes.py: regenerates the registry from Hugging Face LFS
  metadata, OpenAI's digest-bearing URLs, and streamed VOSK archives
- recognition_manager: verify before rename/extract, reject plain-HTTP redirects,
  add the missing timeout on Whisper downloads, honest verify status messaging
- install.sh: shared verify_model_sha256 helper reading the same registry, plus
  an unsafe-path check before unzip
- ship model_hashes.json in the wheel and sdist; document in SECURITY.md

Co-authored-by: jatinkrmalik <jatinkrmalik@gmail.com>
- tests/test_model_integrity.py: registry completeness, digest/size mismatch,
  strict mode, URL trust rules, and zip extraction safety
- tests/test_recognition_manager_downloads.py: tampered downloads are deleted,
  plain-HTTP redirects abort, VOSK traversal archives never escape models dir,
  and verify status messaging stays honest for pinned vs unpinned models
- tests/test_installer_model_verification.py: install.sh helpers driven through bash

Co-authored-by: jatinkrmalik <jatinkrmalik@gmail.com>
Make the integrity checks visible so a download feels secured, not silent:

- Download dialog titled "Secured Download" with a channel-secure lock badge
- Staged status: looking up pin → connecting → downloading → verifying → match
- Honest completion copy: "SHA256 verified" only after an actual pin check
- Speech Model page notes that downloads are SHA256-pinned without claiming
  on-disk files were re-hashed

Co-authored-by: jatinkrmalik <jatinkrmalik@gmail.com>
Failed downloads no longer report success: the settings worker re-raises so
the dialog can show the error. Cancel aborts SHA256 hashing and skips
install/extract. The installer now refuses symlink zip members, Whisper pin
refresh tolerates a missing Content-Length, and Portuguese VOSK keeps the
published Facebook zip because vosk-model-pt-0.4 404s on Alphacephei.

Co-authored-by: jatinkrmalik <jatinkrmalik@gmail.com>
@cursor
cursor Bot force-pushed the feat/model-hash-verification branch from e96e6ba to 8ad59bb Compare August 13, 2026 06:59
Catch RuntimeError (including user cancel) before RequestException so a
cancelled verify still deletes the temp file and does not trip mocked
requests exception types.

Co-authored-by: jatinkrmalik <jatinkrmalik@gmail.com>
@cursor

cursor Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Rebased onto latest main and closed the remaining review findings.

Rebase: 19 commits behind main are now included (unused-model deletion, searchable language combo, installer/AppIndicator/util-linux-extra fixes). Portuguese medium/large VOSK stays on the published vosk-model-pt-fb-v0.1.1-20220516_2113 zip — vosk-model-pt-0.4 404s on Alphacephei.

Fixes:

  • Failed apply no longer reports success in the download dialog (the worker re-raises so the dialog can show the error)
  • Cancel during SHA256 verify aborts hashing and skips install/extract
  • Installer zip check now refuses symlink members, matching the runtime extractor
  • Whisper pin refresh no longer crashes when a HEAD response omits Content-Length

The existing screenshot in the description still matches the secured-download UI, so I did not add another.

Pytest (not slow/integration/audio) is green on this revision. Desktop E2E is in progress.

…gaps

The Speech Model info box stacked title, specs, status, a recommendation,
a glyph legend, and a SHA256 essay. Keep name, description, one status
line, and a recommendation only when the current model is not the one
we would pick. Integrity messaging stays on the download dialog.

Also persist settings only after reconfigure succeeds, write Whisper
large as large-v3.pt, refuse symlink model paths, constrain VOSK
extract to the model folder, and hash installer downloads with Python
when sha256sum is missing.
@cursor

cursor Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Uncluttered the Speech Model info card and closed the remaining download-path gaps.

Card UX: name, one-line description, a single status line (Ready · 39M · CPU or Downloads 74 MB · …). The glyph legend and SHA256 essay are gone — the dropdown already uses ✓/↓/★, and integrity messaging stays on the download dialog. The recommendation tip only shows when the selected model is not the one we’d pick.

Also in this revision: persist settings only after reconfigure succeeds; write OpenAI Whisper large as large-v3.pt; refuse symlink model paths at load; constrain VOSK extract to the model folder and honour cancel during extract; installer hashes with Python hashlib when sha256sum is missing, and fails closed if the pin registry is absent.

Selecting a missing model in a settings window without a live engine
was opening the download dialog and crashing with
'NoneType' has no attribute set_download_progress_callback.
@cursor

cursor Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

The 'NoneType' object has no attribute 'set_download_progress_callback' error was from a Settings window that had no live speech engine attached. Downloads through the real tray app work: Tiny Q5_1 completed with SHA256 verified, and a headless Tiny EN Q5_1 download also hashed cleanly.

Settings now skips auto-apply/download when there is no engine, instead of opening a doomed dialog.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app Core Python application (src, packaging) ci CI/CD, build tooling, scripts dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation installer Install/uninstall scripts tests Test suite changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant