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/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.
diff --git a/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj b/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj
index 3cf66e1e..c14f793c 100644
--- a/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj
+++ b/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj
@@ -8,4 +8,9 @@
app.manifest
+
+
+
+
+
diff --git a/src/PlanViewer.Ssms.Installer/Program.cs b/src/PlanViewer.Ssms.Installer/Program.cs
index 237bf81d..1a23719c 100644
--- a/src/PlanViewer.Ssms.Installer/Program.cs
+++ b/src/PlanViewer.Ssms.Installer/Program.cs
@@ -1,7 +1,13 @@
using System;
+using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
+using System.IO.Compression;
+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 +19,23 @@ static readonly (string Label, string VsixInstallerPath)[] SsmsVersions =
("SSMS 21", @"C:\Program Files\Microsoft SQL Server Management Studio 21\Common7\IDE\VSIXInstaller.exe"),
};
+ // 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)
{
+ 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 +62,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})";
+ }
- try
+ 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;
+ }
+
+ 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...");
- if (proc.ExitCode == 0)
+ var psi = new ProcessStartInfo
{
- Console.WriteLine($" OK — installed into {label}. Restart SSMS to activate.");
+ FileName = installerPath,
+ Arguments = $"/admin \"{vsixPath}\"",
+ UseShellExecute = false,
+ };
+
+ try
+ {
+ 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 +157,234 @@ 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, and the signature covers every entry in the ZIP file.
+ internal static string CheckVsix(string vsixPath, X509Certificate installerCert)
+ {
+ try
+ {
+ // 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)
+ return "the file is not signed";
+ if (manager.Signatures.Count > 1)
+ return "the file has more than one signature";
+
+ // 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)
+ 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" : "");
+
+ return null;
+ }
+ }
+ catch (Exception ex)
+ {
+ return $"the installer failed to read the file ({ex.Message})";
+ }
+ }
+
+ // 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 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))
+ {
+ 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 accepted = new HashSet(StringComparer.Ordinal);
+ using (var zip = new ZipArchive(new MemoryStream(file), ZipArchiveMode.Read))
+ {
+ // 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));
+ }
+ }
+
+ uncovered.Sort(StringComparer.Ordinal);
+ uncovered.AddRange(relationshipFindings);
+
+ foreach (var partUri in new[] { manager.SignatureOrigin, signature.SignaturePart.Uri })
+ {
+ 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;
+ }
+
+ // 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;
+
+ // 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)
+ {
+ using (var stream = entry.Open())
+ {
+ 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)
+ return;
+ try { Directory.Delete(path, true); } catch { }
+ }
+
static string FindVsix(string[] args)
{
// 1. Explicit argument
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
truePreserveNewest
@@ -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 SSMSAdds "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.txtResources\PerformanceStudioIcon.pngSQL Server, Execution Plan, Performance, SSMS