Skip to content

Commit 8faa3c9

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/signatureSha256 parameters to mktplcRef - Add --with-signature and --force flags to update script
1 parent 54caed8 commit 8faa3c9

4 files changed

Lines changed: 254 additions & 4 deletions

File tree

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
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 attributes are intentionally excluded to maintain compatibility
5+
# with existing fetchVsixFromVscodeMarketplace usage.
6+
#
7+
# For signature fetching, use the separate signatureFetchArgs attribute set.
18
{
29
publisher,
310
name,
411
version,
512
arch ? "",
613
sha256 ? "",
714
hash ? "",
15+
# Signature hash - accepted but not included in main fetchurl attrs
16+
signatureSha256 ? "",
17+
signatureHash ? "",
818
}:
919
let
1020
archurl = (if arch == "" then "" else "?targetPlatform=${arch}");
21+
baseUrl = "https://${publisher}.gallery.vsassets.io/_apis/public/gallery/publisher/${publisher}/extension/${name}/${version}/assetbyname";
1122
in
1223
{
13-
url = "https://${publisher}.gallery.vsassets.io/_apis/public/gallery/publisher/${publisher}/extension/${name}/${version}/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage${archurl}";
24+
# VSIX package fetch arguments (for fetchurl)
25+
url = "${baseUrl}/Microsoft.VisualStudio.Services.VSIXPackage${archurl}";
1426
inherit sha256 hash;
1527
# The `*.vsix` file is in the end a simple zip file. Force it using .vsix extension
1628
# 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: 49 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,13 @@ 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+
++ lib.optional (signatureArchive != null) verifyVsixSignatureSetupHook
87+
++ nativeBuildInputs;
88+
89+
# Pass signature archive path to the verification hook
90+
inherit signatureArchive;
7591

7692
installPhase =
7793
args.installPhase or ''
@@ -88,6 +104,35 @@ let
88104
fetchVsixFromVscodeMarketplace =
89105
mktplcExtRef: fetchurl (import ./mktplcExtRefToFetchArgs.nix mktplcExtRef);
90106

107+
# Fetch signature archive for cryptographic verification
108+
fetchSignatureFromVscodeMarketplace =
109+
mktplcExtRef:
110+
let
111+
publisher = mktplcExtRef.publisher;
112+
name = mktplcExtRef.name;
113+
version = mktplcExtRef.version;
114+
arch = mktplcExtRef.arch or "";
115+
archurl = if arch == "" then "" else "?targetPlatform=${arch}";
116+
baseUrl = "https://${publisher}.gallery.vsassets.io/_apis/public/gallery/publisher/${publisher}/extension/${name}/${version}/assetbyname";
117+
118+
signatureHash = mktplcExtRef.signatureHash or "";
119+
signatureSha256 = mktplcExtRef.signatureSha256 or "";
120+
hasSignatureHash = signatureHash != "" || signatureSha256 != "";
121+
in
122+
if hasSignatureHash then
123+
fetchurl (
124+
{
125+
url = "${baseUrl}/Microsoft.VisualStudio.Services.VsixSignature${archurl}";
126+
name = "${publisher}-${name}.sigzip";
127+
}
128+
// lib.optionalAttrs (signatureHash != "") { hash = signatureHash; }
129+
// lib.optionalAttrs (signatureSha256 != "" && signatureHash == "") {
130+
sha256 = signatureSha256;
131+
}
132+
)
133+
else
134+
null;
135+
91136
buildVscodeMarketplaceExtension = lib.extendMkDerivation {
92137
constructDrv = buildVscodeExtension;
93138
excludeDrvArgNames = [
@@ -112,6 +157,7 @@ let
112157
vscodeExtPublisher = mktplcRef.publisher;
113158
vscodeExtName = mktplcRef.name;
114159
vscodeExtUniqueId = "${mktplcRef.publisher}.${mktplcRef.name}";
160+
signatureArchive = fetchSignatureFromVscodeMarketplace mktplcRef;
115161
};
116162
};
117163

@@ -122,6 +168,8 @@ let
122168
"sha256"
123169
"hash"
124170
"arch"
171+
"signatureSha256"
172+
"signatureHash"
125173
];
126174

127175
mktplcExtRefToExtDrv =

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

Lines changed: 146 additions & 2 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 = (
@@ -159,6 +171,37 @@ def _get_nix_vscode_extension_src_hash(self, system: str) -> str:
159171
sha256,
160172
])
161173

174+
def _fetch_signature_hash(self, version: str, target_platform: Optional[str] = None) -> Optional[str]:
175+
"""
176+
Fetches the signature hash for an extension version from the VS Code Marketplace.
177+
Returns the SRI hash or None if fetch fails.
178+
"""
179+
platform_suffix = f"?targetPlatform={target_platform}" if target_platform else ""
180+
url = (
181+
f"https://{self.extension_publisher}.gallery.vsassets.io/_apis/public/gallery/"
182+
f"publisher/{self.extension_publisher}/extension/{self.extension_name}/{version}/"
183+
f"assetbyname/Microsoft.VisualStudio.Services.VsixSignature{platform_suffix}"
184+
)
185+
try:
186+
sha256 = self.execute_command(["nix-prefetch-url", url])
187+
sri_hash = self.execute_command([
188+
"nix",
189+
"--extra-experimental-features",
190+
"nix-command",
191+
"hash",
192+
"convert",
193+
"--to",
194+
"sri",
195+
"--hash-algo",
196+
"sha256",
197+
sha256,
198+
])
199+
logger.info(f"Fetched signature hash: {sri_hash}")
200+
return sri_hash
201+
except subprocess.CalledProcessError:
202+
logger.warning("Failed to fetch signature hash")
203+
return None
204+
162205
def get_target_platform(self, nix_system: str) -> str:
163206
"""
164207
Retrieves the VS Code targetPlatform variable based on the Nix system.
@@ -339,6 +382,93 @@ def repl(m):
339382
)
340383
Path(self.override_filename).write_text(updated_content, encoding="utf-8")
341384

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

0 commit comments

Comments
 (0)