Skip to content

Commit 1c6b76e

Browse files
vscode-extensions: add optional cryptographic signature verification
Add infrastructure to verify VS Code extension signatures using Microsoft's bundled vsce-sign tool. Extensions can opt-in by adding signatureHash to their mktplcRef, which fetches and verifies the signature before unpacking. This honors the same signing contract that VS Code itself enforces, adding another layer of verification alongside hash locking. Changes: - Add verify-vsix-signature-setup-hook.sh for signature verification - Add fetchSignatureFromVscodeMarketplace to vscode-utils.nix - Add signatureHash parameter to mktplcRef - Add --with-signature and --force flags to update script - Document signatureHash in README.md
1 parent 54caed8 commit 1c6b76e

5 files changed

Lines changed: 249 additions & 17 deletions

File tree

pkgs/applications/editors/vscode/extensions/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
* Use `hash` instead of `sha256`.
1717

18+
* Add `signatureHash` to enable cryptographic signature verification (optional). Use `--with-signature` flag with the update script to fetch it automatically.
19+
1820
* On `meta` field:
1921
- add a `changelog`.
2022
- `description` should mention it is a Visual Studio Code extension.

pkgs/applications/editors/vscode/extensions/mktplcExtRefToFetchArgs.nix

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
1+
# Convert marketplace reference to fetch arguments for VSIX package
2+
#
3+
# This file returns ONLY the attributes needed for fetchurl of the VSIX package.
4+
# Signature-related attribute (signatureHash) are accepted but excluded from
5+
# the returned fetchurl attrs - use fetchSignatureFromVscodeMarketplace instead.
16
{
27
publisher,
38
name,
49
version,
510
arch ? "",
611
sha256 ? "",
712
hash ? "",
13+
signatureHash ? "",
814
}:
915
let
1016
archurl = (if arch == "" then "" else "?targetPlatform=${arch}");
17+
baseUrl = "https://${publisher}.gallery.vsassets.io/_apis/public/gallery/publisher/${publisher}/extension/${name}/${version}/assetbyname";
1118
in
1219
{
13-
url = "https://${publisher}.gallery.vsassets.io/_apis/public/gallery/publisher/${publisher}/extension/${name}/${version}/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage${archurl}";
20+
# VSIX package fetch arguments (for fetchurl)
21+
url = "${baseUrl}/Microsoft.VisualStudio.Services.VSIXPackage${archurl}";
1422
inherit sha256 hash;
1523
# The `*.vsix` file is in the end a simple zip file. Force it using .vsix extension
1624
# so that existing `unpackVsixSetupHook` hooks takes care of the unpacking.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Verify VSIX signature using VS Code's bundled vsce-sign binary
2+
# This hook runs at the start of unpackPhase before sources are processed.
3+
# At this point, $src points to the main source (the VSIX file).
4+
5+
_verifyVsixSignaturePreUnpack() {
6+
# Only verify if signatureArchive is provided
7+
if [[ -z "${signatureArchive:-}" ]]; then
8+
return 0
9+
fi
10+
11+
# $src should be the VSIX file path
12+
if [[ -z "${src:-}" ]]; then
13+
echo "WARNING: \$src is not set, cannot verify signature" >&2
14+
return 0
15+
fi
16+
17+
local vsceSign="@vscode@/lib/vscode/resources/app/node_modules/@vscode/vsce-sign/bin/vsce-sign"
18+
19+
if [[ ! -x "$vsceSign" ]]; then
20+
echo "WARNING: vsce-sign not found at $vsceSign - signature verification skipped" >&2
21+
return 0
22+
fi
23+
24+
echo "Verifying VSIX signature..."
25+
local exitCode=0
26+
"$vsceSign" verify --package "$src" --signaturearchive "$signatureArchive" || exitCode=$?
27+
28+
if [[ $exitCode -eq 0 ]]; then
29+
echo "Signature verification: PASSED"
30+
return 0
31+
fi
32+
33+
echo "Signature verification: FAILED (exit code: $exitCode)" >&2
34+
case $exitCode in
35+
30) echo "ERROR: Package integrity check failed - contents don't match signature" >&2 ;;
36+
31) echo "ERROR: Invalid signature format" >&2 ;;
37+
35) echo "ERROR: Package has been tampered with" >&2 ;;
38+
36) echo "ERROR: Certificate is not trusted" >&2 ;;
39+
37) echo "ERROR: Certificate has been revoked" >&2 ;;
40+
*) echo "ERROR: Unknown verification error" >&2 ;;
41+
esac
42+
return 1
43+
}
44+
45+
# Run signature verification before unpacking
46+
preUnpackHooks+=(_verifyVsixSignaturePreUnpack)

pkgs/applications/editors/vscode/extensions/vscode-utils.nix

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,18 @@ let
1818
unzip = "${unzip}/bin/unzip";
1919
};
2020
} ./unpack-vsix-setup-hook.sh;
21+
22+
verifyVsixSignatureSetupHook = makeSetupHook {
23+
name = "verify-vsix-signature-setup-hook";
24+
substitutions = {
25+
vscode = vscode;
26+
};
27+
} ./verify-vsix-signature-setup-hook.sh;
2128
buildVscodeExtension = lib.extendMkDerivation {
2229
constructDrv = stdenv.mkDerivation;
2330
excludeDrvArgNames = [
2431
"vscodeExtUniqueId"
32+
"signatureArchive"
2533
];
2634
extendDrvArgs =
2735
finalAttrs:
@@ -32,6 +40,8 @@ let
3240
vscodeExtPublisher,
3341
vscodeExtName,
3442
vscodeExtUniqueId,
43+
# Pre-fetched signature archive for cryptographic verification
44+
signatureArchive ? null,
3545
configurePhase ? ''
3646
runHook preConfigure
3747
runHook postConfigure
@@ -71,7 +81,14 @@ let
7181
# This cannot be removed, it is used by some extensions.
7282
installPrefix = "share/vscode/extensions/${vscodeExtUniqueId}";
7383

74-
nativeBuildInputs = [ unpackVsixSetupHook ] ++ nativeBuildInputs;
84+
nativeBuildInputs = [
85+
unpackVsixSetupHook
86+
]
87+
++ lib.optional (signatureArchive != null) verifyVsixSignatureSetupHook
88+
++ nativeBuildInputs;
89+
90+
# Pass signature archive path to the verification hook
91+
inherit signatureArchive;
7592

7693
installPhase =
7794
args.installPhase or ''
@@ -88,6 +105,25 @@ let
88105
fetchVsixFromVscodeMarketplace =
89106
mktplcExtRef: fetchurl (import ./mktplcExtRefToFetchArgs.nix mktplcExtRef);
90107

108+
# Fetch signature archive for cryptographic verification
109+
fetchSignatureFromVscodeMarketplace =
110+
mktplcExtRef:
111+
let
112+
inherit (mktplcExtRef) publisher name version;
113+
arch = mktplcExtRef.arch or "";
114+
archurl = if arch == "" then "" else "?targetPlatform=${arch}";
115+
baseUrl = "https://${publisher}.gallery.vsassets.io/_apis/public/gallery/publisher/${publisher}/extension/${name}/${version}/assetbyname";
116+
signatureHash = mktplcExtRef.signatureHash or "";
117+
in
118+
if signatureHash != "" then
119+
fetchurl {
120+
url = "${baseUrl}/Microsoft.VisualStudio.Services.VsixSignature${archurl}";
121+
name = "${publisher}-${name}.sigzip";
122+
hash = signatureHash;
123+
}
124+
else
125+
null;
126+
91127
buildVscodeMarketplaceExtension = lib.extendMkDerivation {
92128
constructDrv = buildVscodeExtension;
93129
excludeDrvArgNames = [
@@ -112,6 +148,7 @@ let
112148
vscodeExtPublisher = mktplcRef.publisher;
113149
vscodeExtName = mktplcRef.name;
114150
vscodeExtUniqueId = "${mktplcRef.publisher}.${mktplcRef.name}";
151+
signatureArchive = fetchSignatureFromVscodeMarketplace mktplcRef;
115152
};
116153
};
117154

@@ -122,6 +159,7 @@ let
122159
"sha256"
123160
"hash"
124161
"arch"
162+
"signatureHash"
125163
];
126164

127165
mktplcExtRefToExtDrv =

pkgs/by-name/vs/vscode-extension-update/vscode_extension_update.py

Lines changed: 153 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ def __init__(self):
3939
self.parser.add_argument(
4040
"--commit", action="store_true", help="commit the updated package"
4141
)
42+
self.parser.add_argument(
43+
"--with-signature",
44+
action="store_true",
45+
help="fetch and add signatureHash for cryptographic verification",
46+
)
47+
self.parser.add_argument(
48+
"--force",
49+
action="store_true",
50+
help="force update even if version is the same (useful for hash-only updates)",
51+
)
4252
self.args = self.parser.parse_args()
4353
self.attribute_path = self.args.attribute
4454
if not self.attribute_path:
@@ -49,6 +59,8 @@ def __init__(self):
4959
self.override_filename = self.args.override_filename
5060
self.allow_pre_release = self.args.pre_release
5161
self.commit = self.args.commit
62+
self.with_signature = self.args.with_signature
63+
self.force = self.args.force
5264
self.extension_publisher = self._get_nix_vscode_extension_publisher()
5365
self.extension_name = self._get_nix_vscode_extension_name()
5466
self.extension_marketplace_id = (
@@ -132,19 +144,10 @@ def _has_platform_source(self) -> bool:
132144
source_url = self._get_nix_attribute(f"{self.attribute_path}.src.url")
133145
return "targetPlatform=" in source_url
134146

135-
def _get_nix_vscode_extension_src_hash(self, system: str) -> str:
136-
url = self.execute_command([
137-
"nix",
138-
"--extra-experimental-features",
139-
"nix-command",
140-
"eval",
141-
"--raw",
142-
"-f",
143-
".",
144-
f"{self.attribute_path}.src.url",
145-
"--system",
146-
system,
147-
])
147+
def _prefetch_url_sri(self, url: str) -> str:
148+
"""
149+
Fetches a URL and returns its SRI hash.
150+
"""
148151
sha256 = self.execute_command(["nix-prefetch-url", url])
149152
return self.execute_command([
150153
"nix",
@@ -159,6 +162,40 @@ def _get_nix_vscode_extension_src_hash(self, system: str) -> str:
159162
sha256,
160163
])
161164

165+
def _get_nix_vscode_extension_src_hash(self, system: str) -> str:
166+
url = self.execute_command([
167+
"nix",
168+
"--extra-experimental-features",
169+
"nix-command",
170+
"eval",
171+
"--raw",
172+
"-f",
173+
".",
174+
f"{self.attribute_path}.src.url",
175+
"--system",
176+
system,
177+
])
178+
return self._prefetch_url_sri(url)
179+
180+
def _fetch_signature_hash(self, version: str, target_platform: Optional[str] = None) -> Optional[str]:
181+
"""
182+
Fetches the signature hash for an extension version from the VS Code Marketplace.
183+
Returns the SRI hash or None if fetch fails.
184+
"""
185+
platform_suffix = f"?targetPlatform={target_platform}" if target_platform else ""
186+
url = (
187+
f"https://{self.extension_publisher}.gallery.vsassets.io/_apis/public/gallery/"
188+
f"publisher/{self.extension_publisher}/extension/{self.extension_name}/{version}/"
189+
f"assetbyname/Microsoft.VisualStudio.Services.VsixSignature{platform_suffix}"
190+
)
191+
try:
192+
sri_hash = self._prefetch_url_sri(url)
193+
logger.info(f"Fetched signature hash: {sri_hash}")
194+
return sri_hash
195+
except subprocess.CalledProcessError:
196+
logger.warning("Failed to fetch signature hash")
197+
return None
198+
162199
def get_target_platform(self, nix_system: str) -> str:
163200
"""
164201
Retrieves the VS Code targetPlatform variable based on the Nix system.
@@ -339,6 +376,93 @@ def repl(m):
339376
)
340377
Path(self.override_filename).write_text(updated_content, encoding="utf-8")
341378

379+
def update_signature_hash(self, signature_hash: str) -> None:
380+
"""
381+
Adds or updates signatureHash in the mktplcRef block for this extension.
382+
Handles both inline extensions in default.nix and standalone extension files.
383+
"""
384+
if not self.override_filename:
385+
logger.warning("No override filename set, cannot update signature hash")
386+
return
387+
388+
content = Path(self.override_filename).read_text(encoding="utf-8")
389+
target_name = self.attribute_path.removeprefix("vscode-extensions.")
390+
391+
# Try to find the extension block by name (inline in default.nix)
392+
pattern = re.compile(
393+
rf"{re.escape(target_name)}\s*=\s*buildVscodeMarketplaceExtension\s*\{{",
394+
re.MULTILINE,
395+
)
396+
ext_match = pattern.search(content)
397+
search_start = ext_match.end() if ext_match else 0
398+
399+
# If not found as inline, the file is likely a standalone extension file
400+
# In that case, search from the beginning for mktplcRef
401+
if not ext_match:
402+
# Verify this looks like a standalone extension file
403+
standalone_pattern = re.compile(
404+
r'(vscode-utils\.)?buildVscodeMarketplaceExtension\s*\{',
405+
re.MULTILINE,
406+
)
407+
if not standalone_pattern.search(content):
408+
logger.warning(f"Could not find extension block for {target_name}")
409+
return
410+
411+
# Find the mktplcRef block
412+
mktplc_pattern = re.compile(r'mktplcRef\s*=\s*\{', re.MULTILINE)
413+
mktplc_match = mktplc_pattern.search(content, search_start)
414+
if not mktplc_match:
415+
logger.warning("Could not find mktplcRef block")
416+
return
417+
418+
# Find the end of mktplcRef block
419+
brace_start = mktplc_match.end() - 1
420+
count = 0
421+
pos = brace_start
422+
while pos < len(content):
423+
if content[pos] == "{":
424+
count += 1
425+
elif content[pos] == "}":
426+
count -= 1
427+
if count == 0:
428+
break
429+
pos += 1
430+
431+
if count != 0:
432+
logger.warning("Braces mismatch in mktplcRef block")
433+
return
434+
435+
block_end = pos
436+
block_text = content[brace_start:block_end + 1]
437+
438+
# Check if signatureHash already exists
439+
sig_pattern = re.compile(r'(\s*)(signatureHash\s*=\s*")([^"]+)(";)')
440+
sig_match = sig_pattern.search(block_text)
441+
442+
if sig_match:
443+
# Update existing signatureHash
444+
new_block_text = sig_pattern.sub(
445+
rf'\g<1>signatureHash = "{signature_hash}";',
446+
block_text
447+
)
448+
logger.info(f"Updated existing signatureHash")
449+
else:
450+
# Find the hash line to insert after
451+
hash_pattern = re.compile(r'([ \t]*)(hash|sha256)\s*=\s*"[^"]+";')
452+
hash_match = hash_pattern.search(block_text)
453+
if hash_match:
454+
indent = hash_match.group(1)
455+
insert_pos = hash_match.end()
456+
new_line = f'\n{indent}signatureHash = "{signature_hash}";'
457+
new_block_text = block_text[:insert_pos] + new_line + block_text[insert_pos:]
458+
logger.info(f"Added signatureHash after hash line")
459+
else:
460+
logger.warning("Could not find hash line in mktplcRef block")
461+
return
462+
463+
updated_content = content[:brace_start] + new_block_text + content[block_end + 1:]
464+
Path(self.override_filename).write_text(updated_content, encoding="utf-8")
465+
342466
def run_nix_update(self, new_version: str, system: str) -> None:
343467
"""
344468
Builds and executes the nix-update command.
@@ -420,11 +544,25 @@ def run(self):
420544
f"<{self.new_version}",
421545
])
422546
except subprocess.CalledProcessError:
423-
logger.info("Already up to date or new version is older!")
424-
sys.exit(0)
547+
if not self.force:
548+
logger.info("Already up to date or new version is older!")
549+
sys.exit(0)
550+
logger.info(f"Force mode: re-fetching even though version unchanged ({self.current_version})")
425551
for i, system in enumerate(self.nix_vscode_extension_platforms):
426552
version = self.new_version if i == 0 else "skip"
427553
self.run_nix_update(version, system)
554+
# Fetch and add signature hash if requested
555+
if self.with_signature:
556+
logger.info("Fetching signature hash...")
557+
# Use the first platform's target platform if extension is platform-specific
558+
target_platform = None
559+
if self._has_platform_source():
560+
target_platform = self.get_target_platform(self.nix_vscode_extension_platforms[0])
561+
signature_hash = self._fetch_signature_hash(self.new_version, target_platform)
562+
if signature_hash:
563+
self.update_signature_hash(signature_hash)
564+
else:
565+
logger.warning("Could not fetch signature hash, skipping")
428566
if self.commit:
429567
self.execute_command(["git", "add", self.override_filename])
430568
self.execute_command([

0 commit comments

Comments
 (0)