Repository navigation
Conversation
…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.
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The registry watcher must prevent cross-origin redirects before safely processing untrusted registry responses.
Review effort: Balanced
Findings: 1
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.
… 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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


What was wrong
Installing any extension from Open VSX in a released LevelCode ends in:
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.
a57e99fmodules/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.5da84f0scripts/extension-signature.mjsinstalls 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) andregistry(Open VSX). Docs.d4d58f25c9bce0.signature.p7sentry (the editor, not this module).Three things about the design:
keys.jsoninside the app.strip-proprietary.mjsworks. 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.docs/EXTENSION-SIGNATURES.mdhas 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
fetchfollow redirects anywhere. Open VSX answers every download with a redirect toopenvsx.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_ORIGINSpass, 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:
checksmoke(the app installs an extension)install'Untrusted', nothing installedWith the module in place, Claude Code, EditorConfig and GitLens each install with
Success. Executed: truein 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
build-macos.shhas 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.smokeon the built app inrelease.yml, and the daily workflow (a schedule only runs from the default branch).smokefails a build only when the app itself reports a signature failure; anything else is a warning.checkconfirms 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-verificationbuilds both apps without drafting a release, and runssmokeon each.Decisions that are yours
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.NotSigned). That is the editor's rule for a gallery set inproduct.json, not something this PR adds, but it becomes visible now. None of the 762 sampled was unsigned.NOTICE). They are how the suite shows, offline, that the pinned key verifies what the registry really serves.For the next release notes
"extensions.verifySignature": falseto get around the old refusal is still unverified until they remove it.Not in this PR