Repository navigation
Commit a57e99f
committed
feat(extensions): a verifier for Open VSX signatures, with the registry'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).1 parent f8b6193 commit a57e99f
11 files changed
Lines changed: 906 additions & 7 deletions
File tree
- modules/extension-signature
- test
- fixtures
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
0 commit comments