Skip to content

Check the VSIX signature before a signed SSMS installer installs it - #612

Merged
erikdarlingdata merged 5 commits into
devfrom
fix/installer-vsix-signature
Sep 29, 2026
Merged

erikdarlingdata merged 5 commits into
devfrom
fix/installer-vsix-signature

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

What changed

A signed InstallSsmsExtension.exe now installs only a PlanViewer.Ssms.vsix that 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 VSIX has exactly one signature.
  • The signer's certificate has the same raw bytes as the installer's Authenticode certificate. The check compares the whole certificate, not the thumbprint. This step runs before the signature verification, so a VSIX from another signer is rejected before the verification.
  • The signature verifies. The verification uses the same signer certificate that the previous step compared.
  • Every entry in the ZIP file is one of the kinds in the next list.

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:

  • 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 whose bytes are exactly the installer's certificate. The part must have the certificate content type, and the signature part must link to it with a certificate relationship.

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 runs releases/InstallSsmsExtension.exe --verify-only releases/PlanViewer.Ssms.vsix after both signed files are copied. If it returns non-zero, the step puts the unsigned pair back from ssms-unsigned/ and writes a ::warning::. The step then resets $LASTEXITCODE, because the pwsh wrapper ends each script with exit $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 named LICENSE with no extension.

An entry with no extension gets its content type from an Override entry in [Content_Types].xml. OpenVsixSignTool rewrites that file and leaves the override out. The LICENSE entry 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 named LICENSE.txt gets its content type from the Default entry for the txt extension, and the tool keeps that entry.

In PlanViewer.Ssms.csproj, the StageVsixLicense target copies the LICENSE file of the repo to obj\Release\LICENSE.txt before the build. The VSIX takes that copy. The <License> element of source.extension.vsixmanifest names LICENSE.txt. The LICENSE file of the repo does not change.

The Link metadata cannot rename the file. The VSSDK uses Link only for the folder of the file in the VSIX, and it takes the file name from the source file. A build with Link set to LICENSE.txt on the LICENSE file fails with VSSDK1310, 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 .rels parts. Calling GetRelationships() on one throws, so the check reads relationships from the source part or the package.
  • Microsoft VsixSignTool and OpenVsixSignTool sign the origin part and every .rels part as whole parts. They appear in SignedParts. Both embed the certificate in the signature part, so the package has no certificate part.
  • OpenVsixSignTool also rewrites [Content_Types].xml without 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 the Default entries, so LICENSE.txt stays a part and the tool signs it.
  • The WPF signing API signs the parts you pass in. A .rels part appears in SignedParts only if you pass it. It can also sign single relationships, which appear in SignedRelationshipSelectors. It can write the certificate to its own part. With this API the package-level .rels part is often not signed, because the origin relationship is added after signing.
  • The check accepts a relationship when its .rels part 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.
  • The check finds the signature's own parts through SignatureOrigin, SignaturePart and the certificate relationship. It does not match file names, because the tools name the origin part differently (origin.psdor and origin.psdsor).
  • The ZIP entry name of a part is its part name without the leading slash. Escapes stay as they are, so /Dir/My%20File.TXT is the entry Dir/My%20File.TXT.
  • OPC refuses some files before the coverage rule runs. Examples are two parts with one name and a certificate relationship to a part with another content type. Those fail with "failed to read the file".

Tests

All of these ran in a throwaway harness outside the repo. The harness compiles the installer's Program.cs itself, so the project needs no InternalsVisibleTo. The installer and VSIXInstaller.exe were 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:

  • The same certificate passes for a VSIX signed by Microsoft VsixSignTool or the WPF API. For the WPF API I varied where the certificate sits. I also varied how relationships are signed: whole .rels parts, Id selectors and Type selectors.
  • The unsigned v1.27.0 release VSIX fails with "not signed".
  • A different certificate fails, for two signers.
  • An unsigned part added after signing fails in the coverage check, for three signers. The signature still verifies at that point.
  • A part linked as a certificate that does not hold the certificate fails the same way.
  • A signed part changed, changed at zip level, or removed after signing fails with "not valid".
  • A relationship added after signing fails in the coverage check when only selectors sign relationships. It fails as "not valid" when the .rels part was signed whole.
  • Two signatures fail with the matching message. So do a file that is not a zip, a missing file and a zip that is not a package.
  • A part name with a space and mixed case passes.
  • GetSigner returns 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 LICENSE entry. That case now expects "does not cover /LICENSE". A copy signed after LICENSE was removed passes, and it is the base of the added-part case.

New cases for each kind of entry, 33 of 33 pass:

  • An entry that OPC does not list as a part, a directory entry, and entry names with .. or a backslash all fail.
  • A signed part renamed to another case fails. So does [Content_Types].xml renamed to another case.
  • A [Content_Types].xml that changes the content type of a signed part fails with "not valid".
  • A second [Content_Types].xml in another case fails, and so do two non-part entries that differ only in case.
  • OPC refuses two more files before the coverage rule runs. One has a second entry in another case next to a signed part. The other has a duplicate entry of a signed part. Both fail with "failed to read the file".
  • An origin part that is not signed and holds bytes fails. An origin part that holds bytes and is signed passes. An empty unsigned origin part passes.
  • A second certificate part with another certificate fails. So does a part with other bytes.
  • The installer's certificate bytes fail in three cases. The part has another content type, or the signature part does not link to it, or the bytes have one byte added.
  • A certificate part that lost its content type fails. OPC refuses that file before the coverage rule runs.
  • A relationship part for a part that does not exist fails. So does a relationship part named in another case.
  • A certificate-type relationship on an ordinary part fails, and so does a second origin relationship on the package.
  • A relationship from the origin part or the signature part fails when its target is external, does not exist or is not signed.
  • A signature part with a changed byte fails with "not valid".
  • A certificate that has the signer's thumbprint but other bytes fails with "different certificate". The same certificate as raw bytes passes.

Sweep, 20 of 20 pass. I ran the check over 20 package files that were built for earlier versions of the check.

  • Ten untouched signed packages pass. Five are copies of one signed VSIX. Five pass with the certificate that signed them.
  • The other ten fail. They have an extra or duplicate entry, an edited origin relationship, or a certificate part that is not the installer's certificate.
  • Some of those ten fail in OPC before the coverage rule runs. The new cases above rebuild the same shapes in a form that reaches the rule.

The built VSIX. I built it with the command that release.yml runs: 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. A Rebuild from a clean tree works, and so does a build with -p:Configuration=Debug.

  • The VSIX holds LICENSE.txt and no entry named LICENSE. The bytes of LICENSE.txt equal the LICENSE file of the repo.
  • The <License> element of the built manifest is LICENSE.txt.
  • [Content_Types].xml has <Default Extension="txt" ContentType="text/plain" /> and no Override entry.

Signing cases on the built VSIX, 9 of 9 pass:

  • The unsigned VSIX fails with "not signed".
  • A copy signed with the WPF API passes. The certificate can be in its own part or in the signature part.
  • A copy signed with OpenVsixSignTool passes. Its rewritten [Content_Types].xml keeps the txt default.
  • A copy signed with the Microsoft VsixSignTool passes.
  • A copy signed with another certificate fails with "different certificate".
  • A file with another certificate and a changed signed part fails with "different certificate". Its signature does not verify, so this case shows that the certificate compare runs first.
  • A file with the right certificate and the same change fails with "not valid (InvalidSignature)".
  • A signature that holds no certificate fails with "not valid (CertificateRequired)", the same reason as before the change.

Flow cases, 19 of 19 pass. These ran a copy of Program.cs with the two VSIXInstaller paths pointing at a fake installer and without the admin manifest:

  • A signed installer with a good VSIX installs a copy from a new temp folder. The copy has the same bytes, and the folder is gone afterwards.
  • A signed installer with an unsigned or wrongly signed VSIX installs nothing and prints the messages. It waits for a key, exits 1 and leaves no temp folder.
  • An unsigned installer prints the skip line and installs the original path.
  • --verify-only exits 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.cs for 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:

  • Verify passes: signed files stay, step exits 0.
  • Verify fails: unsigned pair restored, warning written, step exits 0.
  • The same script without the $LASTEXITCODE reset exits 1, which shows the reset is needed.
  • Verify fails and the restore fails: the step fails.
  • The exe cannot start, or exits with a crash code: handled like a failed check.
  • A signed file is missing: the existing warning, unchanged.

Both dotnet build src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj -c Release and -c Debug finish with 0 warnings and 0 errors. The console messages and this body pass plain-english/check.py.

Limits

  • I have no sample of SignPath's signature, so its exact shape is untested. The --verify-only step 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.
  • A signer that leaves an entry out of the signature makes the check fail. OpenVsixSignTool did this for the old LICENSE entry, because that entry had no extension and only an override gave it a content type. The VSIX now holds LICENSE.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.
  • A signature that writes other certificates to their own parts, such as a chain, fails the check. The check accepts only a certificate part that holds the installer's certificate.
  • A ZIP entry that is not in the list fails, including a directory entry.
  • [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.
  • The entry list comes from the ZIP central directory, as ZipArchive reads it. The check does not inspect bytes outside those entries.
  • The installer reads its own certificate with CreateFromSignedFile. It does not validate the chain or the signature. Windows does that when it asks for admin.
  • On an unsigned installer, --verify-only prints 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.
  • The copy is not held open between the check and the install. I did not add a lock, because I have no safe way to test whether VSIXInstaller accepts one.

🤖 Generated with Claude Code

https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza

erikdarlingdata and others added 5 commits September 28, 2026 20:06
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
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
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 29, 2026 01:45
@erikdarlingdata
erikdarlingdata merged commit 3198bc7 into dev Sep 29, 2026
5 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/installer-vsix-signature branch September 29, 2026 01:45
@claude

claude Bot commented Sep 29, 2026

Copy link
Copy Markdown

Review: no blocking problems found in the diff.

  • Test coverage (the main gap): CheckVsix and FindUncovered in src/PlanViewer.Ssms.Installer/Program.cs carry a lot of security-relevant logic. That covers case-colliding ZIP entries, unsigned relationship parts, extra certificate parts, and the origin-relationship exception. None of it has tests. The only end-to-end check is the release-time --verify-only run, and it only covers the happy path with a real SignPath signature. A few fixtures would catch regressions. Useful ones are a VSIX with an extra unsigned entry, a VSIX signed with another cert, an unsigned VSIX, and a tampered part. The installer targets .NET Framework, so a small test project or a script that builds these packages with System.IO.Packaging would work.
  • Unsigned-installer path: an unsigned installer (nightly or local build) skips the check and installs any VSIX. That is intended and printed. Just confirm that nightly artifacts are never signed inconsistently, since a signed installer paired with an unsigned VSIX now hard-fails.
  • Version, NoWarn, Core/Web linking, TRY_CAST: none of these apply to this PR.
  • LICENSE.txt staging: $(IntermediateOutputPath) is used in the Content Include before the Microsoft.Common targets import. It resolves because MSBuild evaluates properties before items. A clean build of both Debug and Release is worth checking once, to confirm the file lands in the VSIX as LICENSE.txt.
  • Installer-side handling of the release check: $global:LASTEXITCODE = 0 after the handled failure is correct for the pwsh step's implicit exit $LASTEXITCODE.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant