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 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