Check the VSIX signature before a signed SSMS installer installs it - #612
Merged
Merged
Conversation
A signed InstallSsmsExtension.exe now installs only a PlanViewer.Ssms.vsix that has the same certificate. The check needs exactly one signature that verifies, the same thumbprint as the installer's Authenticode certificate, and coverage of every part and relationship except the signature's own. An unsigned installer skips the check and prints one line, so dev builds and unsigned releases keep working. The installer copies the VSIX into a new temp folder, checks the copy, installs the copy, then deletes the folder. --verify-only <vsix> runs the same checks, installs nothing and never waits for a key. The release workflow runs it on the signed pair. If it fails, the step puts the unsigned pair back and writes a warning, and the release continues. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
The VSIX check now starts from the raw ZIP entries instead of the parts that OPC lists. It passes only when every entry is one of these, matched by exact name (names that differ only in case are different names): - a part that the signature signs - [Content_Types].xml - a relationship part that the signature covers, or that belongs to the origin part or the signature part - the origin part, when it is signed or empty - the signature part - a certificate part that has the certificate content type, is linked from the signature part, and holds exactly the installer certificate Relationships of the origin part and the signature part may point only to those entries. A second entry with the same name, in any case, fails. No entry is classified by parsing what is in it. The signer is compared with the installer certificate by its full raw data, not by its thumbprint. Only the origin relationship of the package is exempt from relationship cover. Before, every relationship with a digital-signature type was exempt. The project now references System.IO.Compression. The InternalsVisibleTo entry is removed, because the test project it named does not exist. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
The VSIX held the license as an entry named LICENSE, with no extension. Its content type came only from an Override entry in [Content_Types].xml. OpenVsixSignTool rewrites that file without the override. The entry then stopped being a part, stayed unsigned, and the installer check rejected the signed VSIX. An entry named LICENSE.txt gets its content type from the Default entry for the txt extension, and the tool keeps that entry. The Link metadata sets only the folder of a file in the VSIX, not its name, so it cannot rename LICENSE. A VSSDK build with Link set to LICENSE.txt fails with VSSDK1310. The StageVsixLicense target instead copies the LICENSE file of the repo to obj as LICENSE.txt, and the VSIX takes that copy. The LICENSE file of the repo does not change. The manifest License element now names LICENSE.txt. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
CheckVsix compared the raw certificate of the signer with the certificate of the installer after VerifySignatures. It now compares first, so a VSIX from another signer is rejected before the expensive verification. The verification uses the same Signer, so an accepted file is still verified with the certificate that was compared. A signature that holds no certificate returns the same reason as before: the signature is not valid (CertificateRequired). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
|
Review: no blocking problems found in the diff.
|
This was referenced Sep 29, 2026
Merged
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 changed
A signed
InstallSsmsExtension.exenow installs only aPlanViewer.Ssms.vsixthat has the same certificate. The VSIX must also hold no ZIP entry outside the list below.The check passes when all of these steps pass. It runs them in this order and stops at the first failure:
The check starts from the raw ZIP entries, not from the parts that OPC lists. It compares names exactly, so names that differ only in case are different names. A second entry with the same name, in any case, fails. Each entry must be one of these:
[Content_Types].xml.A relationship part is covered when the signature signs it whole or selects every relationship in it. The origin relationship of the package is the one exception, because some signers add it after signing.
Relationships of the origin part and the signature part can point only to entries in that list. Any other relationship from them fails the check.
The check never decides what an entry is by parsing what is in it. It reads an entry in two places only. One is to see that an unsigned origin part is empty. The other is to compare a certificate part with the installer's certificate bytes.
If the installer has no Authenticode signature, it skips the check and prints one line. Dev builds and releases where signing failed work as before.
A signed installer copies the VSIX into a new folder under the temp path. It checks the copy, installs the copy, then deletes the folder. On a failed check it prints which check failed. It tells the user to download both files again from the same release and keep them in one folder. It also says that double-clicking the VSIX installs it. It installs nothing and returns 1 through the existing key wait.
--verify-only <vsix>runs the same self-check and VSIX check. It never starts VSIXInstaller, never waits for a key, and returns 0 or 1.In
release.yml, the step "Replace unsigned SSMS files with signed" now runsreleases/InstallSsmsExtension.exe --verify-only releases/PlanViewer.Ssms.vsixafter both signed files are copied. If it returns non-zero, the step puts the unsigned pair back fromssms-unsigned/and writes a::warning::. The step then resets$LASTEXITCODE, because the pwsh wrapper ends each script withexit $LASTEXITCODE. A failed restore still stops the job, as before.The project also references WindowsBase and System.IO.Compression. The README has one new line about the rule.
The license file in the VSIX
The VSIX now holds the license as
LICENSE.txt. Before, it held an entry namedLICENSEwith no extension.An entry with no extension gets its content type from an
Overrideentry in[Content_Types].xml. OpenVsixSignTool rewrites that file and leaves the override out. TheLICENSEentry then stops being a part and stays unsigned. The check rejects the signed VSIX, and the release falls back to the unsigned pair. An entry namedLICENSE.txtgets its content type from theDefaultentry for thetxtextension, and the tool keeps that entry.In
PlanViewer.Ssms.csproj, theStageVsixLicensetarget copies theLICENSEfile of the repo toobj\Release\LICENSE.txtbefore the build. The VSIX takes that copy. The<License>element ofsource.extension.vsixmanifestnamesLICENSE.txt. TheLICENSEfile of the repo does not change.The
Linkmetadata cannot rename the file. The VSSDK usesLinkonly for the folder of the file in the VSIX, and it takes the file name from the source file. A build withLinkset toLICENSE.txton theLICENSEfile fails withVSSDK1310, so the build needs the copy.How the signers and OPC show up
I tested this before writing the coverage rule.
Package.GetParts()lists only the entries that have a content type. An entry without one is not a part. The check therefore works from the ZIP entries.Package.GetParts()returns the.relsparts. CallingGetRelationships()on one throws, so the check reads relationships from the source part or the package..relspart as whole parts. They appear inSignedParts. Both embed the certificate in the signature part, so the package has no certificate part.[Content_Types].xmlwithout the override for/LICENSE. In the VSIX of v1.27.0 that file stays in the ZIP file, is not a part and is not signed. The rewritten file keeps theDefaultentries, soLICENSE.txtstays a part and the tool signs it..relspart appears inSignedPartsonly if you pass it. It can also sign single relationships, which appear inSignedRelationshipSelectors. It can write the certificate to its own part. With this API the package-level.relspart is often not signed, because the origin relationship is added after signing..relspart is signed whole or a selector covers it. Before, it also accepted every relationship with a digital-signature type. Now only the origin relationship of the package and the relationships of the origin part and the signature part are exempt.SignatureOrigin,SignaturePartand the certificate relationship. It does not match file names, because the tools name the origin part differently (origin.psdorandorigin.psdsor)./Dir/My%20File.TXTis the entryDir/My%20File.TXT.Tests
All of these ran in a throwaway harness outside the repo. The harness compiles the installer's
Program.csitself, so the project needs noInternalsVisibleTo. The installer andVSIXInstaller.exewere never run. Every test certificate was in memory or in a PFX file in a scratch folder. None went into a certificate store.Check cases, 89 of 89 pass. Each case asserts which check failed, not only that it failed. Where a case tests an entry rule, the signature still verifies, so the entry rule is what rejects the file. I ran all 89 again after the certificate compare moved before the verification. Every case gives the same reason, so no assertion changed.
Earlier cases, 36 of 36 pass. 33 of the 35 earlier cases are unchanged:
.relsparts, Id selectors and Type selectors..relspart was signed whole.GetSignerreturns nothing for the unsigned build and the right certificate for a copy signed with the test certificate.Two earlier cases used OpenVsixSignTool output, which has an unsigned
LICENSEentry. That case now expects "does not cover /LICENSE". A copy signed afterLICENSEwas removed passes, and it is the base of the added-part case.New cases for each kind of entry, 33 of 33 pass:
..or a backslash all fail.[Content_Types].xmlrenamed to another case.[Content_Types].xmlthat changes the content type of a signed part fails with "not valid".[Content_Types].xmlin another case fails, and so do two non-part entries that differ only in case.Sweep, 20 of 20 pass. I ran the check over 20 package files that were built for earlier versions of the check.
The built VSIX. I built it with the command that
release.ymlruns:msbuild src/PlanViewer.Ssms/PlanViewer.Ssms.csproj -restore -t:Build -p:Configuration=Release -p:DeployExtension=false. I used the MSBuild of the Visual Studio 2022 Build Tools. The Release build gives no warnings. ARebuildfrom a clean tree works, and so does a build with-p:Configuration=Debug.LICENSE.txtand no entry namedLICENSE. The bytes ofLICENSE.txtequal theLICENSEfile of the repo.<License>element of the built manifest isLICENSE.txt.[Content_Types].xmlhas<Default Extension="txt" ContentType="text/plain" />and noOverrideentry.Signing cases on the built VSIX, 9 of 9 pass:
[Content_Types].xmlkeeps thetxtdefault.Flow cases, 19 of 19 pass. These ran a copy of
Program.cswith the two VSIXInstaller paths pointing at a fake installer and without the admin manifest:--verify-onlyexits 0 or 1, installs nothing and never waits for a key, including with no path, a missing file and an unsigned installer.I built the copy again from the current
Program.csfor the last run.Workflow cases, 16 of 16 pass. I ran the extracted run block through the same wrapper GitHub uses, with a stand-in exe:
$LASTEXITCODEreset exits 1, which shows the reset is needed.Both
dotnet build src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj -c Releaseand-c Debugfinish with 0 warnings and 0 errors. The console messages and this body passplain-english/check.py.Limits
--verify-onlystep in the release run is the check for that. If the installer rejects the signed pair, the release ships the unsigned pair with a warning, and the release still goes out.LICENSEentry, because that entry had no extension and only an override gave it a content type. The VSIX now holdsLICENSE.txt, and OpenVsixSignTool, the Microsoft VsixSignTool and the WPF API all sign every entry of it. If SignPath leaves an entry out, the step falls back to the unsigned pair.[Content_Types].xml, the signature part and the relationship parts of the origin part and the signature part are unsigned. The check limits their names and, for the relationship parts, where the relationships point. It does not inspect their other content.ZipArchivereads it. The check does not inspect bytes outside those entries.CreateFromSignedFile. It does not validate the chain or the signature. Windows does that when it asks for admin.--verify-onlyprints the skip line and returns 0. The release step therefore cannot tell an unsigned pair from a signed one. It only catches a signed pair that does not match.🤖 Generated with Claude Code
https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza