From 2caf5841bad957be783c5dfcbf3fada3e26c9e35 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:06:38 -0400 Subject: [PATCH 1/5] Check the VSIX signature before a signed installer installs it 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 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 Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza --- .github/workflows/release.yml | 28 +- .../PlanViewer.Ssms.Installer.csproj | 8 + src/PlanViewer.Ssms.Installer/Program.cs | 239 ++++++++++++++++-- 3 files changed, 245 insertions(+), 30 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61ec404b..022caa7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -165,10 +165,10 @@ jobs: # approval times out, the release still goes out with the unsigned # files (as it did before these were signed) and a warning annotation # says so. The one exception is in the replace step: if it cannot put - # the unsigned files back after a failed copy, the job stops before - # anything is published. Each SignPath request needs a manual approval, - # so a release run now waits for two: the App first, then these two - # files. ── + # the unsigned files back after a failed copy or a failed signature + # check, the job stops before anything is published. Each SignPath + # request needs a manual approval, so a release run now waits for two: + # the App first, then these two files. ── - name: Stage SSMS files for signing if: steps.ssms.outputs.BUILT == 'true' continue-on-error: true @@ -221,9 +221,12 @@ jobs: shell: pwsh run: | # Copy both files or neither, so one signed file never ships beside an - # unsigned one. If a copy fails, put the unsigned pair back from - # ssms-unsigned/. If that fails too, the step fails and the job stops - # before the release is created, so a mixed pair is never published. + # unsigned one. The signed installer must also accept the signed VSIX: + # --verify-only runs the installer's signature check and installs + # nothing. If a copy or that check fails, put the unsigned pair back + # from ssms-unsigned/. If that fails too, the step fails and the job + # stops before the release is created, so a mixed pair is never + # published. $names = 'InstallSsmsExtension.exe', 'PlanViewer.Ssms.vsix' $missing = @($names | Where-Object { -not (Test-Path "signed/ssms/$_") }) if ($missing.Count -gt 0) { @@ -233,13 +236,20 @@ jobs: foreach ($name in $names) { Copy-Item "signed/ssms/$name" "releases/$name" -Force -ErrorAction Stop } + & releases/InstallSsmsExtension.exe --verify-only releases/PlanViewer.Ssms.vsix + if ($LASTEXITCODE -ne 0) { + throw "the installer rejected the signed VSIX with exit code $LASTEXITCODE (the reason is in the log above)" + } Write-Host 'Replaced the SSMS extension and installer with the signed files.' } catch { - $copyError = $_.Exception.Message + $problem = $_.Exception.Message foreach ($name in $names) { Copy-Item "ssms-unsigned/$name" "releases/$name" -Force -ErrorAction Stop } - Write-Host "::warning::Could not copy the signed SSMS files into releases/ ($copyError). PlanViewer.Ssms.vsix and InstallSsmsExtension.exe shipped unsigned." + # The failed check left a non-zero exit code, and the shell ends the + # script with `exit $LASTEXITCODE`. The failure is handled, so reset it. + $global:LASTEXITCODE = 0 + Write-Host "::warning::Could not use the signed SSMS files: $problem. PlanViewer.Ssms.vsix and InstallSsmsExtension.exe shipped unsigned." } } diff --git a/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj b/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj index 3cf66e1e..831e2811 100644 --- a/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj +++ b/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj @@ -8,4 +8,12 @@ app.manifest + + + + + + + + diff --git a/src/PlanViewer.Ssms.Installer/Program.cs b/src/PlanViewer.Ssms.Installer/Program.cs index 237bf81d..363d1023 100644 --- a/src/PlanViewer.Ssms.Installer/Program.cs +++ b/src/PlanViewer.Ssms.Installer/Program.cs @@ -1,7 +1,12 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.IO.Packaging; using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; namespace PlanViewer.Ssms.Installer { @@ -13,8 +18,15 @@ static readonly (string Label, string VsixInstallerPath)[] SsmsVersions = ("SSMS 21", @"C:\Program Files\Microsoft SQL Server Management Studio 21\Common7\IDE\VSIXInstaller.exe"), }; + // Relationship types of the OPC package signature: origin, signature and certificate. + const string SignatureRelationshipPrefix = "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/"; + const string CertificateRelationship = SignatureRelationshipPrefix + "certificate"; + static int Main(string[] args) { + if (args.Length > 0 && string.Equals(args[0], "--verify-only", StringComparison.OrdinalIgnoreCase)) + return VerifyOnly(args.Length > 1 ? args[1] : null); + Console.WriteLine("==========================================="); Console.WriteLine(" Performance Studio — SSMS Extension"); Console.WriteLine("==========================================="); @@ -41,39 +53,87 @@ static int Main(string[] args) return 1; } + string tempDir = null; bool anyFailed = false; - foreach (var (label, installerPath) in installed) + try { - Console.WriteLine($"Found {label} — installing..."); - - var psi = new ProcessStartInfo + var installerCert = GetSigner(Assembly.GetExecutingAssembly().Location); + if (installerCert == null) + { + Console.WriteLine("This installer is not signed, so the VSIX signature is not checked."); + Console.WriteLine(); + } + else { - FileName = installerPath, - Arguments = $"/admin \"{vsixPath}\"", - UseShellExecute = false, - }; + // A signed installer installs only a VSIX signed with the same certificate. + // The check runs on a private copy, and that copy is the file that gets installed. + string error; + try + { + tempDir = Path.Combine(Path.GetTempPath(), "PlanViewerSsms-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var copy = Path.Combine(tempDir, Path.GetFileName(vsixPath)); + File.Copy(vsixPath, copy); + vsixPath = copy; + error = CheckVsix(copy, installerCert); + } + catch (Exception ex) + { + error = $"the installer failed to copy the file for the check ({ex.Message})"; + } + + if (error != null) + { + DeleteFolder(tempDir); + Console.WriteLine($"ERROR: {Path.GetFileName(vsixPath)} failed the signature check: {error}."); + Console.WriteLine("Nothing was installed."); + Console.WriteLine("Download InstallSsmsExtension.exe and PlanViewer.Ssms.vsix again from the same release, and keep them in one folder."); + Console.WriteLine("You can also double-click PlanViewer.Ssms.vsix to install it."); + WaitForKey(); + return 1; + } - try + Console.WriteLine("The VSIX is signed with the same certificate as this installer."); + Console.WriteLine(); + } + + foreach (var (label, installerPath) in installed) { - var proc = Process.Start(psi); - proc.WaitForExit(); + Console.WriteLine($"Found {label} — installing..."); + + var psi = new ProcessStartInfo + { + FileName = installerPath, + Arguments = $"/admin \"{vsixPath}\"", + UseShellExecute = false, + }; - if (proc.ExitCode == 0) + try { - Console.WriteLine($" OK — installed into {label}. Restart SSMS to activate."); + var proc = Process.Start(psi); + proc.WaitForExit(); + + if (proc.ExitCode == 0) + { + Console.WriteLine($" OK — installed into {label}. Restart SSMS to activate."); + } + else + { + Console.WriteLine($" FAILED (exit code {proc.ExitCode})."); + anyFailed = true; + } } - else + catch (Exception ex) { - Console.WriteLine($" FAILED (exit code {proc.ExitCode})."); + Console.WriteLine($" FAILED: {ex.Message}"); anyFailed = true; } + Console.WriteLine(); } - catch (Exception ex) - { - Console.WriteLine($" FAILED: {ex.Message}"); - anyFailed = true; - } - Console.WriteLine(); + } + finally + { + DeleteFolder(tempDir); } if (anyFailed) @@ -88,6 +148,143 @@ static int Main(string[] args) return 0; } + // Checks the VSIX against this installer's certificate without installing anything. + static int VerifyOnly(string vsixPath) + { + if (vsixPath == null || !File.Exists(vsixPath)) + { + Console.WriteLine("ERROR: --verify-only needs the path of a .vsix file."); + return 1; + } + + var installerCert = GetSigner(Assembly.GetExecutingAssembly().Location); + if (installerCert == null) + { + Console.WriteLine("This installer is not signed, so the VSIX signature is not checked."); + return 0; + } + + var error = CheckVsix(vsixPath, installerCert); + if (error != null) + { + Console.WriteLine($"ERROR: {Path.GetFileName(vsixPath)} failed the signature check: {error}."); + return 1; + } + + Console.WriteLine("The VSIX is signed with the same certificate as this installer."); + return 0; + } + + // The Authenticode certificate of the installer, or null when the installer is not signed. + // This reads the certificate only. It does not validate the signature. + internal static X509Certificate GetSigner(string exePath) + { + try { return X509Certificate.CreateFromSignedFile(exePath); } + catch (CryptographicException) { return null; } + } + + // Returns null when the VSIX passes, or a short reason when it does not. The VSIX passes when it has + // exactly one valid signature, made with installerCert, that covers everything in the package. + internal static string CheckVsix(string vsixPath, X509Certificate installerCert) + { + try + { + using (var package = Package.Open(vsixPath, FileMode.Open, FileAccess.Read)) + { + var manager = new PackageDigitalSignatureManager(package); + if (manager.Signatures.Count == 0) + return "the file is not signed"; + if (manager.Signatures.Count > 1) + return "the file has more than one signature"; + + var result = manager.VerifySignatures(false); + if (result != VerifyResult.Success) + return $"the signature is not valid ({result})"; + + var signature = manager.Signatures[0]; + if (signature.Signer == null || signature.Signer.GetCertHashString() != installerCert.GetCertHashString()) + return "the file is signed with a different certificate than this installer"; + + var uncovered = FindUncovered(package, manager, signature); + if (uncovered.Count > 0) + return "the signature does not cover " + string.Join(", ", uncovered.Take(3)) + (uncovered.Count > 3 ? $" and {uncovered.Count - 3} more" : ""); + + return null; + } + } + catch (Exception ex) + { + return $"the installer failed to read the file ({ex.Message})"; + } + } + + // Lists the parts and relationships that the signature does not cover. Its own parts (origin, + // signature, certificates) and its own relationships are left out. Signers differ in how they + // sign relationship parts: some sign the whole part, some select single relationships. + static List FindUncovered(Package package, PackageDigitalSignatureManager manager, PackageDigitalSignature signature) + { + var signed = new HashSet(signature.SignedParts.Select(PartKey)); + var own = new HashSet { PartKey(manager.SignatureOrigin), PartKey(signature.SignaturePart.Uri) }; + foreach (var rel in signature.SignaturePart.GetRelationshipsByType(CertificateRelationship)) + { + var certUri = PackUriHelper.ResolvePartUri(signature.SignaturePart.Uri, rel.TargetUri); + if (IsCertificate(package.GetPart(certUri))) + own.Add(PartKey(certUri)); + } + + var uncovered = new List(); + var parts = package.GetParts().Where(p => !PackUriHelper.IsRelationshipPartUri(p.Uri)).ToList(); + foreach (var part in parts) + { + if (!own.Contains(PartKey(part.Uri)) && !signed.Contains(PartKey(part.Uri))) + uncovered.Add(part.Uri.ToString()); + } + + var selected = new HashSet(signature.SignedRelationshipSelectors.SelectMany(s => s.Select(package)).Select(RelationshipKey)); + var signedWhole = new HashSet(signature.SignedParts + .Where(PackUriHelper.IsRelationshipPartUri) + .Select(u => PartKey(PackUriHelper.GetSourcePartUriFromRelationshipPartUri(u)))); + foreach (var rel in package.GetRelationships().Concat(parts.SelectMany(p => p.GetRelationships()))) + { + if (!rel.RelationshipType.StartsWith(SignatureRelationshipPrefix, StringComparison.Ordinal) + && !selected.Contains(RelationshipKey(rel)) + && !signedWhole.Contains(PartKey(rel.SourceUri))) + uncovered.Add($"relationship {rel.Id} of {rel.SourceUri}"); + } + + return uncovered; + } + + // Part names are not case-sensitive and may be escaped. "/" is the package itself, the source of package relationships. + static string PartKey(Uri partUri) => Uri.UnescapeDataString(partUri.ToString()).ToUpperInvariant(); + + static string RelationshipKey(PackageRelationship rel) => rel.SourceUri + " " + rel.Id; + + static bool IsCertificate(PackagePart part) + { + try + { + using (var stream = part.GetStream()) + using (var bytes = new MemoryStream()) + { + stream.CopyTo(bytes); + new X509Certificate(bytes.ToArray()); + return true; + } + } + catch (CryptographicException) + { + return false; + } + } + + static void DeleteFolder(string path) + { + if (path == null) + return; + try { Directory.Delete(path, true); } catch { } + } + static string FindVsix(string[] args) { // 1. Explicit argument From fb89b3f2ece2d5f541f7cb67b31b398b212c713a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:09:02 -0400 Subject: [PATCH 2/5] Say in the README that a signed installer checks the VSIX signature Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index fee83e4b..25f0dcb2 100644 --- a/README.md +++ b/README.md @@ -393,6 +393,8 @@ A VSIX extension that adds **"Open in Performance Studio"** to the execution pla 4. The installer auto-detects SSMS 21 and/or SSMS 22 and installs into both 5. Restart SSMS to activate the extension +Release builds are signed. A signed installer installs only a `PlanViewer.Ssms.vsix` that has the same signature, so use the two files from the same release. You can also double-click `PlanViewer.Ssms.vsix` to install the extension without the installer. + ### First run On first use, if Performance Studio isn't found automatically, the extension will prompt you to locate `PlanViewer.App.exe`. The path is saved to the registry (`HKCU\SOFTWARE\DarlingData\SQLPerformanceStudio\InstallPath`) so you only need to do this once. From 33164ba1d8438e062a973c4af6a3d566427deb79 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:54:24 -0400 Subject: [PATCH 3/5] Check every ZIP entry of the VSIX against the signature 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 Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza --- .../PlanViewer.Ssms.Installer.csproj | 5 +- src/PlanViewer.Ssms.Installer/Program.cs | 179 ++++++++++++++---- 2 files changed, 138 insertions(+), 46 deletions(-) diff --git a/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj b/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj index 831e2811..c14f793c 100644 --- a/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj +++ b/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj @@ -10,10 +10,7 @@ - - - - + diff --git a/src/PlanViewer.Ssms.Installer/Program.cs b/src/PlanViewer.Ssms.Installer/Program.cs index 363d1023..909627ee 100644 --- a/src/PlanViewer.Ssms.Installer/Program.cs +++ b/src/PlanViewer.Ssms.Installer/Program.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.IO.Compression; using System.IO.Packaging; using System.Linq; using System.Reflection; @@ -18,9 +19,17 @@ static readonly (string Label, string VsixInstallerPath)[] SsmsVersions = ("SSMS 21", @"C:\Program Files\Microsoft SQL Server Management Studio 21\Common7\IDE\VSIXInstaller.exe"), }; - // Relationship types of the OPC package signature: origin, signature and certificate. + // Relationship types and content type of the OPC package signature. const string SignatureRelationshipPrefix = "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/"; + const string OriginRelationship = SignatureRelationshipPrefix + "origin"; const string CertificateRelationship = SignatureRelationshipPrefix + "certificate"; + const string CertificateContentType = "application/vnd.openxmlformats-package.digital-signature-certificate"; + + // The one ZIP entry that OPC does not treat as a part. + const string ContentTypesEntry = "[Content_Types].xml"; + + // The source of the relationships of the package itself. + static readonly Uri PackageRoot = new Uri("/", UriKind.Relative); static int Main(string[] args) { @@ -184,12 +193,14 @@ internal static X509Certificate GetSigner(string exePath) } // Returns null when the VSIX passes, or a short reason when it does not. The VSIX passes when it has - // exactly one valid signature, made with installerCert, that covers everything in the package. + // exactly one valid signature, made with installerCert, and the signature covers every entry in the ZIP file. internal static string CheckVsix(string vsixPath, X509Certificate installerCert) { try { - using (var package = Package.Open(vsixPath, FileMode.Open, FileAccess.Read)) + // One read of the file feeds both views of it: the OPC package and the raw ZIP entries. + var file = File.ReadAllBytes(vsixPath); + using (var package = Package.Open(new MemoryStream(file), FileMode.Open, FileAccess.Read)) { var manager = new PackageDigitalSignatureManager(package); if (manager.Signatures.Count == 0) @@ -201,11 +212,13 @@ internal static string CheckVsix(string vsixPath, X509Certificate installerCert) if (result != VerifyResult.Success) return $"the signature is not valid ({result})"; + // The whole certificate must match, not only its thumbprint. var signature = manager.Signatures[0]; - if (signature.Signer == null || signature.Signer.GetCertHashString() != installerCert.GetCertHashString()) + var certificate = installerCert.GetRawCertData(); + if (signature.Signer == null || !signature.Signer.GetRawCertData().SequenceEqual(certificate)) return "the file is signed with a different certificate than this installer"; - var uncovered = FindUncovered(package, manager, signature); + var uncovered = FindUncovered(file, package, manager, signature, certificate); if (uncovered.Count > 0) return "the signature does not cover " + string.Join(", ", uncovered.Take(3)) + (uncovered.Count > 3 ? $" and {uncovered.Count - 3} more" : ""); @@ -218,66 +231,148 @@ internal static string CheckVsix(string vsixPath, X509Certificate installerCert) } } - // Lists the parts and relationships that the signature does not cover. Its own parts (origin, - // signature, certificates) and its own relationships are left out. Signers differ in how they - // sign relationship parts: some sign the whole part, some select single relationships. - static List FindUncovered(Package package, PackageDigitalSignatureManager manager, PackageDigitalSignature signature) + // Lists what the signature does not cover. The list starts from the raw ZIP entries, not from the parts that + // OPC finds. Names are compared exactly, so names that differ only in case are different names. Every 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 that holds exactly the certificate of the installer. + // A relationship part is covered when it is signed whole, or when the signature selects every relationship + // in it. The origin relationship of the package is the one exception: some signers add it after signing, so + // it needs no cover. The relationships of the origin part and the signature part may point only to entries + // in this list. An entry is never classified by parsing what is in it. The only reads are the checks that the + // origin part is empty and that a certificate part is the certificate of the installer. + static List FindUncovered(byte[] file, Package package, PackageDigitalSignatureManager manager, PackageDigitalSignature signature, byte[] certificate) { - var signed = new HashSet(signature.SignedParts.Select(PartKey)); - var own = new HashSet { PartKey(manager.SignatureOrigin), PartKey(signature.SignaturePart.Uri) }; + var signedNames = new HashSet(signature.SignedParts.Select(EntryName), StringComparer.Ordinal); + var originName = EntryName(manager.SignatureOrigin); + var signatureName = EntryName(signature.SignaturePart.Uri); + + // A certificate part is the target of a certificate relationship from the signature part and has the certificate content type. + var certificateNames = new HashSet(StringComparer.Ordinal); foreach (var rel in signature.SignaturePart.GetRelationshipsByType(CertificateRelationship)) { - var certUri = PackUriHelper.ResolvePartUri(signature.SignaturePart.Uri, rel.TargetUri); - if (IsCertificate(package.GetPart(certUri))) - own.Add(PartKey(certUri)); + if (rel.TargetMode != TargetMode.Internal) + continue; + var target = PackUriHelper.ResolvePartUri(signature.SignaturePart.Uri, rel.TargetUri); + if (package.PartExists(target) && string.Equals(package.GetPart(target).ContentType, CertificateContentType, StringComparison.OrdinalIgnoreCase)) + certificateNames.Add(EntryName(target)); + } + + // Relationship parts. Signers differ in how they cover them: some sign the whole part, some select + // single relationships. + var allowed = new HashSet(signedNames, StringComparer.Ordinal) { ContentTypesEntry, signatureName }; + var selected = new HashSet(signature.SignedRelationshipSelectors.SelectMany(s => s.Select(package)).Select(RelationshipKey)); + var sources = new List<(Uri Uri, IEnumerable Relationships)> { (PackageRoot, package.GetRelationships()) }; + foreach (var part in package.GetParts().Where(p => !PackUriHelper.IsRelationshipPartUri(p.Uri))) + sources.Add((part.Uri, part.GetRelationships())); + + var relationshipFindings = new List(); + var explained = new HashSet(StringComparer.Ordinal); + foreach (var source in sources) + { + var sourceName = EntryName(source.Uri); + var relationshipsName = EntryName(PackUriHelper.GetRelationshipPartUri(source.Uri)); + if (sourceName == originName || sourceName == signatureName) + { + // These two relationship parts are unsigned. Where their relationships point is checked below. + allowed.Add(relationshipsName); + continue; + } + + if (signedNames.Contains(relationshipsName)) + continue; + + var covered = true; + foreach (var rel in source.Relationships) + { + var isOrigin = sourceName.Length == 0 && rel.RelationshipType == OriginRelationship && rel.TargetMode == TargetMode.Internal + && EntryName(PackUriHelper.ResolvePartUri(source.Uri, rel.TargetUri)) == originName; + if (isOrigin || selected.Contains(RelationshipKey(rel))) + continue; + relationshipFindings.Add($"relationship {rel.Id} of {rel.SourceUri}"); + covered = false; + } + + if (covered) + allowed.Add(relationshipsName); + else + explained.Add(relationshipsName); } var uncovered = new List(); - var parts = package.GetParts().Where(p => !PackUriHelper.IsRelationshipPartUri(p.Uri)).ToList(); - foreach (var part in parts) + var accepted = new HashSet(StringComparer.Ordinal); + using (var zip = new ZipArchive(new MemoryStream(file), ZipArchiveMode.Read)) { - if (!own.Contains(PartKey(part.Uri)) && !signed.Contains(PartKey(part.Uri))) - uncovered.Add(part.Uri.ToString()); + // Names that differ only in case are one file when the VSIX is unpacked on Windows. + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in zip.Entries) + { + var name = entry.FullName; + if (!seen.Add(name)) + uncovered.Add(Display(name) + " (a second entry with the same name)"); + else if (allowed.Contains(name) + || (name == originName && HasContent(entry, new byte[0])) + || (certificateNames.Contains(name) && HasContent(entry, certificate))) + accepted.Add(name); + else if (!explained.Contains(name)) + uncovered.Add(Display(name)); + } } - var selected = new HashSet(signature.SignedRelationshipSelectors.SelectMany(s => s.Select(package)).Select(RelationshipKey)); - var signedWhole = new HashSet(signature.SignedParts - .Where(PackUriHelper.IsRelationshipPartUri) - .Select(u => PartKey(PackUriHelper.GetSourcePartUriFromRelationshipPartUri(u)))); - foreach (var rel in package.GetRelationships().Concat(parts.SelectMany(p => p.GetRelationships()))) + uncovered.Sort(StringComparer.Ordinal); + uncovered.AddRange(relationshipFindings); + + foreach (var partUri in new[] { manager.SignatureOrigin, signature.SignaturePart.Uri }) { - if (!rel.RelationshipType.StartsWith(SignatureRelationshipPrefix, StringComparison.Ordinal) - && !selected.Contains(RelationshipKey(rel)) - && !signedWhole.Contains(PartKey(rel.SourceUri))) - uncovered.Add($"relationship {rel.Id} of {rel.SourceUri}"); + if (!package.PartExists(partUri)) + continue; + foreach (var rel in package.GetPart(partUri).GetRelationships()) + { + if (rel.TargetMode != TargetMode.Internal || !accepted.Contains(EntryName(PackUriHelper.ResolvePartUri(rel.SourceUri, rel.TargetUri)))) + uncovered.Add($"relationship {rel.Id} of {rel.SourceUri}"); + } } return uncovered; } - // Part names are not case-sensitive and may be escaped. "/" is the package itself, the source of package relationships. - static string PartKey(Uri partUri) => Uri.UnescapeDataString(partUri.ToString()).ToUpperInvariant(); + // The name of the ZIP entry that holds a part: the part name without its leading slash. + // The package itself has the name "". + static string EntryName(Uri partUri) + { + var name = partUri.OriginalString; + if (!name.StartsWith("/", StringComparison.Ordinal)) + throw new InvalidDataException($"the part name {name} is not valid"); + return name.Substring(1); + } static string RelationshipKey(PackageRelationship rel) => rel.SourceUri + " " + rel.Id; - static bool IsCertificate(PackagePart part) + // True when the entry holds exactly these bytes. It reads no more than one byte past the expected length. + static bool HasContent(ZipArchiveEntry entry, byte[] expected) { - try - { - using (var stream = part.GetStream()) - using (var bytes = new MemoryStream()) - { - stream.CopyTo(bytes); - new X509Certificate(bytes.ToArray()); - return true; - } - } - catch (CryptographicException) + using (var stream = entry.Open()) { - return false; + var actual = new byte[expected.Length + 1]; + var length = 0; + int read; + while (length < actual.Length && (read = stream.Read(actual, length, actual.Length - length)) > 0) + length += read; + return length == expected.Length && actual.Take(length).SequenceEqual(expected); } } + // An entry name as it appears in a message, like a part name. Control characters and long names are cut down. + static string Display(string entryName) + { + var shown = new string(entryName.Select(c => char.IsControl(c) ? '?' : c).ToArray()); + return "/" + (shown.Length > 80 ? shown.Substring(0, 80) + "..." : shown); + } + static void DeleteFolder(string path) { if (path == null) From b22fea570bf809271b4a83fa0e7ca2faf0569b3f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:39:52 -0400 Subject: [PATCH 4/5] Ship the license as LICENSE.txt inside the VSIX 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 Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza --- src/PlanViewer.Ssms/PlanViewer.Ssms.csproj | 12 ++++++++++-- src/PlanViewer.Ssms/source.extension.vsixmanifest | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj b/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj index 36d13890..50b97fd0 100644 --- a/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj +++ b/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj @@ -65,8 +65,13 @@ true - - LICENSE + + + LICENSE.txt true PreserveNewest @@ -87,4 +92,7 @@ + + + diff --git a/src/PlanViewer.Ssms/source.extension.vsixmanifest b/src/PlanViewer.Ssms/source.extension.vsixmanifest index c1d98b24..7be4b082 100644 --- a/src/PlanViewer.Ssms/source.extension.vsixmanifest +++ b/src/PlanViewer.Ssms/source.extension.vsixmanifest @@ -9,7 +9,7 @@ Performance Studio for SSMS Adds "Open in Performance Studio" to the execution plan right-click menu in SSMS. Extracts the plan XML and opens it in Performance Studio for advanced analysis. Compatible with SSMS 21 and SSMS 22. https://github.com/erikdarlingdata/PerformanceStudio - LICENSE + LICENSE.txt Resources\PerformanceStudioIcon.png SQL Server, Execution Plan, Performance, SSMS From 020642e78acd1119899bcd8cdb2d5d6d877a8898 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:39:53 -0400 Subject: [PATCH 5/5] Compare the signer certificate before verifying the signature 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 Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza --- src/PlanViewer.Ssms.Installer/Program.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/PlanViewer.Ssms.Installer/Program.cs b/src/PlanViewer.Ssms.Installer/Program.cs index 909627ee..1a23719c 100644 --- a/src/PlanViewer.Ssms.Installer/Program.cs +++ b/src/PlanViewer.Ssms.Installer/Program.cs @@ -208,16 +208,21 @@ internal static string CheckVsix(string vsixPath, X509Certificate installerCert) if (manager.Signatures.Count > 1) return "the file has more than one signature"; - var result = manager.VerifySignatures(false); - if (result != VerifyResult.Success) - return $"the signature is not valid ({result})"; - - // The whole certificate must match, not only its thumbprint. + // Compare the certificate first, so that a file from another signer is rejected before the + // signature is verified. The whole certificate must match, not only its thumbprint. + // The verification below uses this same Signer. A signature that holds no certificate has + // nothing to compare, and VerifySignatures reports it as CertificateRequired. var signature = manager.Signatures[0]; var certificate = installerCert.GetRawCertData(); - if (signature.Signer == null || !signature.Signer.GetRawCertData().SequenceEqual(certificate)) + if (signature.Signer == null) + return $"the signature is not valid ({VerifyResult.CertificateRequired})"; + if (!signature.Signer.GetRawCertData().SequenceEqual(certificate)) return "the file is signed with a different certificate than this installer"; + var result = manager.VerifySignatures(false); + if (result != VerifyResult.Success) + return $"the signature is not valid ({result})"; + var uncovered = FindUncovered(file, package, manager, signature, certificate); if (uncovered.Count > 0) return "the signature does not cover " + string.Join(", ", uncovered.Take(3)) + (uncovered.Count > 3 ? $" and {uncovered.Count - 3} more" : "");