Skip to content

Verify Open VSX extension signatures — installs and extension updates work again - #103

Open
ndemianc wants to merge 5 commits into
developfrom
feat/extension-signature-verification
Open

ndemianc wants to merge 5 commits into
developfrom
feat/extension-signature-verification

Conversation

@ndemianc

@ndemianc ndemianc commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

What was wrong

Installing any extension from Open VSX in a released LevelCode ends in:

Cannot install '…' extension because LevelCode cannot verify the extension signature.
Signature verification was not executed.

The same failure happens silently at every start for the automatic update of installed extensions, so they never update.

The editor verifies a download by loading a module named @vscode/vsce-sign. That module is Microsoft's: closed source and licensed for use "only with" Microsoft's own products. It is not in Code-OSS and so not in any LevelCode build. With nothing to load, the app cannot verify, and a built app that cannot verify refuses.

What this does

LevelCode gets its own module for that slot. It verifies Open VSX's own signature on each package, against a key that ships in the app.

Commit
a57e99f The verifier (modules/extension-signature, 300 lines, no dependencies) and its suite. Open VSX serves, beside every .vsix, an archive with a 64-byte Ed25519 signature over the file. verify() accepts a package only if a pinned key signed exactly those bytes.
5da84f0 The build ships it. scripts/extension-signature.mjs installs it into the built app, proves it works before the app is signed, and adds two checks that ask the real thing: smoke (the app) and registry (Open VSX). Docs.
d4d58f2 A daily watch on Open VSX's signing key. Its own commit so it can be dropped alone.
5c9bce0 Review fixes. The registry check now follows a redirect by hand, and only to the registry or the one host it keeps its files on. The doc says who requires the archive's empty .signature.p7s entry (the editor, not this module).

Three things about the design:

  • The key is pinned, not fetched. Open VSX also serves its public key, and the obvious verifier downloads it, which asks the server being checked for the answer. The trusted keys are in keys.json inside the app.
  • No core patch. A build step copies three files into the built app under the name the editor imports, the way strip-proprietary.mjs works. When I proposed this option I said "a small core patch"; it turned out not to need one, so nothing has to be re-applied on a Code-OSS bump.
  • A pass means one thing: the file is what Open VSX published. Not that the publisher signed it, and not that the extension is safe.

docs/EXTENSION-SIGNATURES.md has the whole account, including what a user sees for each refusal.

What the review found

Copilot's first finding was right, and wider than reported. The registry check refused to start a request anywhere but the registry, then let fetch follow redirects anywhere. Open VSX answers every download with a redirect to openvsx.eclipsecontent.org, so the check was leaving the registry on every ordinary run. My test double served files directly, which is why nothing caught it; it now redirects the way the real registry does.

The fix checks each redirect before anything is sent to it. Only the registry and a host named in CONTENT_ORIGINS pass, at most three redirects deep. The cost: when Open VSX moves its file host, users notice nothing, but this check reports "could not check" and names the new host until it is added to that list. The doc has a section for that day.

The second finding was a true sentence that could not be checked from this repository; the doc now says where the editor does it.

How it was checked

On the shipped app. I copied the code folder of the installed 1.3.1 and ran it with the installed executable, with throwaway data folders:

State of the copy check smoke (the app installs an extension)
As shipped fails fails: "Signature verification was not executed"
After install passes passes
Another key pinned fails fails: 'Untrusted', nothing installed

With the module in place, Claude Code, EditorConfig and GitLens each install with Success. Executed: true in the app's own log.

On Open VSX. All 762 extensions sampled on 2026-10-04 (newest, oldest back to 2020, most and least downloaded) are signed and name the one pinned key. 64 of them, 87 MB of packages, were downloaded and verified with it. After the review fix, a run against the real registry sends every request with redirects off, and each is answered by the host it was sent to. With the content host taken off the list, the check reports "could not check", names that host, and contacts only open-vsx.org.

By the suite. 59 cases; the gate is 50 suites and 951 cases, green on macOS (Node 24) and in a Linux container with no network (Node 18). Each of the first three commits passes the gate from a clean export. The three cases added for the review fail on the reviewed code.

By mutation. For the first three commits: 108 single edits to the module, its keys, the script, the two shell scripts, the workflow and the fixtures; 106 turn a case red. The other two are the two catch-alls in verify() that turn an unexpected exception into a refusal; every read is bounds-checked first, so nothing reaches them. For the review fix: 19 more edits to the redirect handling, all caught.

What was not checked

  • A full build. build-macos.sh has not been run with the new step. The step is two lines that call the script on the same folder the strip steps use, and I ran the script on a real app's folder, but not through gulp.
  • The two new CI steps on a runner: smoke on the built app in release.yml, and the daily workflow (a schedule only runs from the default branch). smoke fails a build only when the app itself reports a signature failure; anything else is a warning.
  • A click in the window. The check went through the app's command line. Installs from the Extensions view run in another process, which imports the module the same way from the same folder (check confirms the name resolves from both bundles), but I did not click Install in a rebuilt app.

A way to close all three before merging, if you want it: gh workflow run release.yml --ref feat/extension-signature-verification builds both apps without drafting a release, and runs smoke on each.

Decisions that are yours

  1. The daily workflow (d4d58f2). It costs a few seconds of a Linux runner a day and mails you when Open VSX changes its key. Without it you learn of a key change at the next release or from a user. It will also fail when Open VSX moves its file host, until the new host is added to the list; that is a one-line change. Drop the commit if you would rather not have a cron in the repo.
  2. An extension with no signature is refused (NotSigned). That is the editor's rule for a gallery set in product.json, not something this PR adds, but it becomes visible now. None of the 762 sampled was unsigned.
  3. Two third-party packages are committed as test fixtures (MIT, 4 KB and 9 KB, attributed in NOTICE). They are how the suite shows, offline, that the pinned key verifies what the registry really serves.

For the next release notes

  • Extensions install and update again, verified.
  • Anyone who set "extensions.verifySignature": false to get around the old refusal is still unverified until they remove it.

Not in this PR

  • The Learn More button in the refusal dialog still opens Microsoft's page about the VS Code Marketplace.
  • No user-facing docs page on levelcode.ai yet.

…ry's key pinned

Every LevelCode release so far refuses every signed extension from Open VSX:

    Cannot install '…' extension because LevelCode cannot verify the
    extension signature. Signature verification was not executed.

and, with no dialog at all, fails to update any installed extension at every
start. The editor verifies a download by loading a module named
@vscode/vsce-sign and calling its verify(). That module is Microsoft's — closed,
licensed for use "only with" Microsoft's own products — so it is not in
Code-OSS and not in any build made from it. With nothing to load the editor
cannot verify, and a built app that cannot verify refuses.

This is the module LevelCode will load in that slot
(modules/extension-signature). It is not in a build yet; the next commit puts
it there.

What it verifies. Open VSX signs each package: beside every .vsix it serves an
archive holding .signature.sig, a 64-byte Ed25519 signature over the .vsix
bytes. verify() accepts a package only if a key it trusts made that signature
over exactly those bytes. Everything else is a refusal, as one of the result
codes the editor already has words for — never an exception.

The key is pinned, not fetched. Open VSX serves its public key too, and the
existing open-source verifier downloads it by default, which asks the server
being checked for the answer. The keys this module trusts are in keys.json
beside it and are read from nowhere else. The one key there was checked before
it went in: every one of 762 extensions sampled on 2026-10-04 (the newest, the
oldest back to 2020, the most and the least downloaded) names it, none is
unsigned, and the signatures of 64 of them — 87 MB of packages — verify with it.

What a pass means: the file is what Open VSX published. Not that the publisher
signed it, and not that the extension is safe.

The archive is parsed before anything about it is known, so the reader takes
nothing on trust. Every offset and length is checked against the buffer; the
signature must be 64 bytes before it is read; nothing inflates past what it
declares; zip64, encrypted and multi-disk archives, other compression methods
and a second signature entry are refused rather than handled. The manifest in
the archive is not signed. It is consulted only to word a refusal — a damaged
download reads PackageIntegrityCheckFailed, an intact package under an unknown
key reads Untrusted — and can never lift one.

Plain Node, no dependencies, 300 lines. The signature check runs off the event
loop; the editor's shared process has other work.

Tests: modules/extension-signature/test/, 31 cases. Keys are generated and
archives written by hand, so each lie an archive can tell is told on purpose.
Every prefix of an archive, and every archive with one byte changed, is put to
the reader. Two real packages from Open VSX (MIT; attributed in NOTICE and in
the fixtures' README) show that the pinned key verifies what the registry
really serves, and the shipped verifier is run with every socket refused.

scripts/test-extensions.sh now discovers modules/*/test/*.test.js too. The
module's tests cannot live under extensions/, whose test folders ship in the
app.

45 mutations of the module and its keys: 43 fail a case. The two that survive
are the two catch-alls that turn an exception nobody expects into a refusal;
every read is bounds-checked before it is made, so nothing is known to reach
them. Two checks the mutations showed to decide nothing were removed instead of
tested. 50 suites pass on macOS (Node 24) and in a Linux container (Node 18).
… asked directly

The module from the previous commit does nothing until a built app can load
it. scripts/extension-signature.mjs puts it there, and proves it works.

install <app-code-folder>
  Copies three files to node_modules/@vscode/vsce-sign in the BUILT app: the
  name the editor imports. Nothing in the editor's source is patched, so there
  is nothing to re-apply on a Code-OSS bump — the same move as
  strip-proprietary.mjs. build-macos.sh passes --replace: the checkout decides
  what a build ships. make-dmg.sh does not: an app that already carries a
  LevelCode verifier keeps it, because the keys an app trusts are its build's
  and not the signer's. A module of that name that is not ours is an error.

check <app-code-folder>
  Fails unless the built editor still names @vscode/vsce-sign, the name
  resolves to our module from the very bundles that name it, and the module —
  imported by that name from those folders, in a process of its own — accepts
  a real Open VSX package and refuses it with one byte changed. Both
  build-macos.sh and make-dmg.sh run it: an upstream change that would bring
  "not executed" back fails the build, and an app that cannot verify is not
  signed.

smoke <LevelCode.app>
  Asks the app. Its own command line installs one four-kilobyte extension into
  throwaway folders, as a command-line process with no window, and the app's
  log must say the signature verified. release.yml runs it on each arch after
  the build. It fails only on the app's own words; a registry that cannot be
  reached is a warning.

registry
  The watch that pinning needs. The day Open VSX signs with another key, every
  LevelCode already installed refuses what the new key signs, until a release
  carries it. This asks the registry which key its newest extensions name and
  verifies some of them for real. Exit 1 is evidence: a key that is not pinned,
  signatures that stopped verifying, or no signatures at all. Not reaching the
  registry is a warning, or exit 2 with --strict. release.yml runs it in the
  test gate, before the hour of macOS build.

docs/EXTENSION-SIGNATURES.md is the whole account: what a pass means, what a
user sees for each refusal, how the pinned key was checked, and the runbook for
the two days this will need attention — Open VSX changing its key, and a
Code-OSS bump. The limits are listed there: "Install Anyway" and
extensions.verifySignature are still upstream's; the dialog shows only the
result code, with the reason in the log at trace level; its "Learn More" still
opens Microsoft's page.

Checked against the real thing, short of a rebuild. The code folder of the
shipped 1.3.1 app was copied and run by the installed executable:

  as shipped          check fails; smoke fails with "not executed" — the bug
  after install       check passes; smoke passes; Claude Code, EditorConfig
                      and GitLens install, each with "Success. Executed: true"
                      in the app's own log
  another key pinned  check fails; smoke fails with 'Untrusted'; nothing is
                      installed

and `registry` against open-vsx.org itself: all 30 newest extensions name the
pinned key, and two were downloaded and verified.

NOT checked: a gulp build with this step in it, and the smoke step on a GitHub
runner. The step fails a build only when the app itself reports a signature
failure; anything else it cannot make sense of is a warning.

Tests: the suite goes from 31 to 56 cases. The app, the registry and the
network are stand-ins there, and what the script does with their answers is
what is pinned. 63 mutations of the script, the two shell scripts, the
workflow, the fixtures and the runbook's heading: each fails a case. 50 suites
pass on macOS (Node 24) and in a Linux container (Node 18).
The keys are pinned in the app, so a key change at Open VSX is an outage for
every installed LevelCode until a release carries the new key. The release
gate asks the registry, but only when a release is cut. This asks daily
(.github/workflows/openvsx-key.yml runs extension-signature.mjs registry
--strict), so that day is known the day it comes and not from a bug report.

A failed run is the alarm; docs/EXTENSION-SIGNATURES.md says what to do then.
"Could not ask" is tried three times over ten minutes before it fails the run:
an outage is not news, but a check that has gone blind is.

Its own commit so that it can be reverted alone. It costs a few seconds of a
Linux runner a day. GitHub mails a failed scheduled run to whoever last edited
the schedule, and stops scheduling in a repository idle for 60 days.

The workflow has not run: a schedule only runs from the default branch.
Copilot AI balanced review requested due to automatic review settings October 5, 2026 04:35

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

The registry watcher must prevent cross-origin redirects before safely processing untrusted registry responses.

Review effort: Balanced
Findings: 1 High severity · 1 Low severity

Open (2)
What changed in this PR

Adds pinned Open VSX signature verification to built LevelCode apps, restoring secure extension installation and updates.

Changes:

  • Adds the dependency-free verifier, pinned key, fixtures, and tests.
  • Integrates verification into builds, packaging, release CI, and daily monitoring.
  • Documents the trust model and operational runbooks.
File Description
SECURITY.md Documents extension verification scope.
README.md Adds verifier architecture overview.
NOTICE Attributes bundled test fixtures.
CLAUDE.md Records verifier development conventions.
scripts/​test-extensions.sh Discovers module tests.
scripts/​build-macos.sh Installs and checks the verifier.
scripts/​make-dmg.sh Checks before signing.
scripts/​extension-signature.mjs Implements install, check, smoke, and registry commands.
modules/​extension-signature/​index.js Implements signature and archive verification.
modules/​extension-signature/​keys.json Pins the Open VSX key.
modules/​extension-signature/​package.json Defines the replacement module.
modules/​extension-signature/​test/​extensionSignature.test.js Tests verifier and build integration.
modules/​extension-signature/​test/​fixtures/​README.md Documents fixture provenance.
modules/​extension-signature/​test/​fixtures/​perrinjerome.git-rebase-syntax-0.0.1.vsix Real package fixture.
modules/​extension-signature/​test/​fixtures/​perrinjerome.git-rebase-syntax-0.0.1.sigzip Corresponding signature fixture.
modules/​extension-signature/​test/​fixtures/​coolbear.systemd-unit-file-1.0.6.vsix Second package fixture.
modules/​extension-signature/​test/​fixtures/​coolbear.systemd-unit-file-1.0.6.sigzip Corresponding signature fixture.
docs/​EXTENSION-SIGNATURES.md Adds trust and rotation runbooks.
docs/​RELEASING.md Adds release verification steps.
.github/​workflows/​release.yml Adds registry and app smoke checks.
.github/​workflows/​openvsx-key.yml Adds daily key monitoring.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/extension-signature.mjs Outdated
Comment thread docs/EXTENSION-SIGNATURES.md Outdated
… to where the registry keeps its files

Two findings from the review. One was right, and wider than it said. The other
was a true sentence in the doc that nobody could check from this repository.

1. The registry check went wherever it was redirected. It refused to START a
   request anywhere but the registry, and then handed the request to fetch(),
   which follows a redirect anywhere. The comment beside it — "only addresses
   on the registry itself are followed" — was not true, and not only under
   attack: Open VSX answers every download with a 302 to its content host, so
   on every ordinary run each package and each signature archive came from
   openvsx.eclipsecontent.org, an origin the check had never been told about.
   Watching the reviewed code against the real registry shows it: of five
   requests sent to open-vsx.org, four were answered by the other host.

   "Reject redirects", the first remedy offered, would have left the check
   unable to download anything. Redirects are followed by hand instead
   (redirect: 'manual'): each destination is checked BEFORE anything is sent to
   it, and passes only if it is the registry itself or a host named in
   CONTENT_ORIGINS — one entry, the content host — at most three redirects
   deep. The list is written down rather than learned from the answer, for the
   reason the key is: a host the check is merely told about is a host anyone
   who can answer for the registry could choose.

   The price is pinning's price, paid the same way. The day Open VSX moves its
   files, nothing changes for users and this check can no longer download
   anything. It then says where the registry redirects and what to change:
   "could not check", which is a warning in the release gate and a failed run
   in the daily watch after three tries. docs/EXTENSION-SIGNATURES.md has a
   section for that day.

   Nothing caught this because the stand-in registry in the suite served files
   directly: it did not behave like the registry it stood in for. It now
   answers a download with a redirect to a content host, as Open VSX does, and
   like fetch() it follows a redirect itself unless told not to — so a caller
   that forgets to say so is seen going where it is sent.

2. "The editor checks [.signature.p7s] exists." It does, but not in any code
   in this repository, and the verifier here does not; the reviewer could see
   only the half that says no such thing. The editor's downloader looks for
   the entry before it hands an archive to any verifier
   (downloadSignatureArchive in extensionDownloader.ts, Code-OSS 1.126) and
   discards an archive without it as a failed download. The doc now says who
   checks, where, and that the module does not read the entry; a case in the
   suite pins that an archive without it is not the module's to refuse.
   Nothing changed in behaviour for this one. A limit it brought out is
   written down too: the daily watch verifies with the module, so what only
   the editor asks of an archive is covered by `smoke`, at release time.

Run against open-vsx.org after the change: every request leaves in manual mode
and is answered by the host it was sent to; status ok, two packages verified.
With the content host taken off the list: "could not check", the message names
openvsx.eclipsecontent.org, and only open-vsx.org was contacted.

Tests: 56 to 59 cases. The three new ones fail on the reviewed script; the
other 56 pass on it. 19 mutations of the new code each fail a case. 50 suites
pass on macOS (Node 24) and in a Linux container (Node 18).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants