diff --git a/src/Sign.Core/DataFormatSigners/ClickOnceSigner.cs b/src/Sign.Core/DataFormatSigners/ClickOnceSigner.cs index 4dec8194..2873ed69 100644 --- a/src/Sign.Core/DataFormatSigners/ClickOnceSigner.cs +++ b/src/Sign.Core/DataFormatSigners/ClickOnceSigner.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Xml; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileSystemGlobbing.Abstractions; using Microsoft.Extensions.Logging; @@ -126,8 +127,8 @@ await Parallel.ForEachAsync(files, _parallelOptions, async (file, state) => // Inner files are now signed // now look for the manifest file and sign that if we have one - - FileInfo? manifestFile = filteredFiles.SingleOrDefault(f => ".manifest".Equals(f.Extension, StringComparison.OrdinalIgnoreCase)); + var appManifestFromDeploymentManifest = GetApplicationManifestForDeploymentManifest(file); + FileInfo? manifestFile = filteredFiles.SingleOrDefault(f => f.Name.Equals(appManifestFromDeploymentManifest, StringComparison.OrdinalIgnoreCase)); string fileArgs = $@"-update ""{manifestFile}"" {args}"; @@ -273,5 +274,53 @@ public void CopySigningDependencies(FileInfo deploymentManifestFile, DirectoryIn } } } + + /// + /// Try and find the application manifest (.manifest) file from a clickonce application manifest (.application / .vsto + /// There might not be one, if the user is attempting to only re-sign the deployment manifest without touching other files. + /// This is necessary because there might be multiple *.manifest files present, e.g. if a DLL that's part of the clickonce + /// package ships its own assembly manifest which isn't a clickonce application manifest. + /// + /// + /// A string containing the file name of the Application manifest, or null if it couldn't be found. + /// + private string? GetApplicationManifestForDeploymentManifest(FileInfo deploymentManifest) + { + var xml = new XmlDocument(); + xml.Load(deploymentManifest.FullName); + // there should only be a single result here, if the file is a valid clickonce manifest. + var dependentAssemblies = xml.GetElementsByTagName("dependentAssembly"); + if (dependentAssemblies.Count != 1) + { + Logger.LogDebug(Resources.ApplicationManifestNotFound); + return null; + } + + var node = dependentAssemblies.Item(0); + if (node is null || node.Attributes is null) + { + Logger.LogDebug(Resources.ApplicationManifestNotFound); + return null; + } + + var applicationManifestFileNameAttribute = node.Attributes["codebase"]; + if (applicationManifestFileNameAttribute is null || string.IsNullOrEmpty(applicationManifestFileNameAttribute.Value)) + { + Logger.LogDebug(Resources.ApplicationManifestNotFound); + return null; + } + + // the codebase attribute can be a relative file path (e.g. Application Files\MyApp_1_0_0_0\MyApp.dll.manifest) or + // a URI (e.g. https://my.cdn.com/clickonce/MyApp/ApplicationFiles/MyApp_1_0_0_0/MyApp.dll.manifest) so we need to + // handle both cases and extract just the file name part. + // + // we only try and parse absolute uris, because a relative uri can just be treated like a file path for our purposes + if (Uri.TryCreate(applicationManifestFileNameAttribute.Value, UriKind.Absolute, out var uri)) + { + Path.GetFileName(uri.LocalPath); // works for http(s) and file:// uris + } + + return Path.GetFileName(applicationManifestFileNameAttribute.Value); + } } -} \ No newline at end of file +} diff --git a/src/Sign.Core/Resources.Designer.cs b/src/Sign.Core/Resources.Designer.cs index 2cd9c9a6..8fd38aa6 100644 --- a/src/Sign.Core/Resources.Designer.cs +++ b/src/Sign.Core/Resources.Designer.cs @@ -60,6 +60,15 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest.. + /// + internal static string ApplicationManifestNotFound { + get { + return ResourceManager.GetString("ApplicationManifestNotFound", resourceCulture); + } + } + /// /// Looks up a localized string similar to Signing SignTool job with {count} files.. /// diff --git a/src/Sign.Core/Resources.resx b/src/Sign.Core/Resources.resx index 4ca2c0db..e245e5a1 100644 --- a/src/Sign.Core/Resources.resx +++ b/src/Sign.Core/Resources.resx @@ -275,4 +275,7 @@ The algorithm selected is not supported. + + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + \ No newline at end of file diff --git a/src/Sign.Core/xlf/Resources.cs.xlf b/src/Sign.Core/xlf/Resources.cs.xlf index 10eedd92..91d1048e 100644 --- a/src/Sign.Core/xlf/Resources.cs.xlf +++ b/src/Sign.Core/xlf/Resources.cs.xlf @@ -2,6 +2,11 @@ + + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + + Signing SignTool job with {count} files. Podepisování úlohy SignTool s tímto počtem souborů: {count}. diff --git a/src/Sign.Core/xlf/Resources.de.xlf b/src/Sign.Core/xlf/Resources.de.xlf index 36fe225f..438f85b9 100644 --- a/src/Sign.Core/xlf/Resources.de.xlf +++ b/src/Sign.Core/xlf/Resources.de.xlf @@ -2,6 +2,11 @@ + + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + + Signing SignTool job with {count} files. Der SignTool-Auftrag wird mit {count} Dateien signiert. diff --git a/src/Sign.Core/xlf/Resources.es.xlf b/src/Sign.Core/xlf/Resources.es.xlf index 5f568dfa..449e1c8c 100644 --- a/src/Sign.Core/xlf/Resources.es.xlf +++ b/src/Sign.Core/xlf/Resources.es.xlf @@ -2,6 +2,11 @@ + + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + + Signing SignTool job with {count} files. Firmando el trabajo de SignTool con {count} archivos. diff --git a/src/Sign.Core/xlf/Resources.fr.xlf b/src/Sign.Core/xlf/Resources.fr.xlf index 69dcaead..16a6fdc7 100644 --- a/src/Sign.Core/xlf/Resources.fr.xlf +++ b/src/Sign.Core/xlf/Resources.fr.xlf @@ -2,6 +2,11 @@ + + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + + Signing SignTool job with {count} files. Signature du travail SignTool avec {count} fichiers. diff --git a/src/Sign.Core/xlf/Resources.it.xlf b/src/Sign.Core/xlf/Resources.it.xlf index 203f3a25..950c4155 100644 --- a/src/Sign.Core/xlf/Resources.it.xlf +++ b/src/Sign.Core/xlf/Resources.it.xlf @@ -2,6 +2,11 @@ + + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + + Signing SignTool job with {count} files. Firma del processo SignTool con {count} file. diff --git a/src/Sign.Core/xlf/Resources.ja.xlf b/src/Sign.Core/xlf/Resources.ja.xlf index db44e64d..d55bf2c1 100644 --- a/src/Sign.Core/xlf/Resources.ja.xlf +++ b/src/Sign.Core/xlf/Resources.ja.xlf @@ -2,6 +2,11 @@ + + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + + Signing SignTool job with {count} files. {count} 個のファイルを使用して SignTool ジョブに署名しています。 diff --git a/src/Sign.Core/xlf/Resources.ko.xlf b/src/Sign.Core/xlf/Resources.ko.xlf index cf25df6f..fe2390d1 100644 --- a/src/Sign.Core/xlf/Resources.ko.xlf +++ b/src/Sign.Core/xlf/Resources.ko.xlf @@ -2,6 +2,11 @@ + + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + + Signing SignTool job with {count} files. {count} 파일로 SignTool 작업에 서명하는 중입니다. diff --git a/src/Sign.Core/xlf/Resources.pl.xlf b/src/Sign.Core/xlf/Resources.pl.xlf index c9db68da..012cdd71 100644 --- a/src/Sign.Core/xlf/Resources.pl.xlf +++ b/src/Sign.Core/xlf/Resources.pl.xlf @@ -2,6 +2,11 @@ + + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + + Signing SignTool job with {count} files. Podpisywanie zadania SignTool przy użyciu {count} plików. diff --git a/src/Sign.Core/xlf/Resources.pt-BR.xlf b/src/Sign.Core/xlf/Resources.pt-BR.xlf index a405b988..077a9235 100644 --- a/src/Sign.Core/xlf/Resources.pt-BR.xlf +++ b/src/Sign.Core/xlf/Resources.pt-BR.xlf @@ -2,6 +2,11 @@ + + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + + Signing SignTool job with {count} files. Autenticando o trabalho SignTool com {count} arquivos. diff --git a/src/Sign.Core/xlf/Resources.ru.xlf b/src/Sign.Core/xlf/Resources.ru.xlf index 13aaacb1..0ad097de 100644 --- a/src/Sign.Core/xlf/Resources.ru.xlf +++ b/src/Sign.Core/xlf/Resources.ru.xlf @@ -2,6 +2,11 @@ + + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + + Signing SignTool job with {count} files. Задание подписывания SignTool с несколькими файлами ({count}). diff --git a/src/Sign.Core/xlf/Resources.tr.xlf b/src/Sign.Core/xlf/Resources.tr.xlf index aac32f14..d935eb59 100644 --- a/src/Sign.Core/xlf/Resources.tr.xlf +++ b/src/Sign.Core/xlf/Resources.tr.xlf @@ -2,6 +2,11 @@ + + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + + Signing SignTool job with {count} files. SignTool işi {count} dosya ile imzalanıyor. diff --git a/src/Sign.Core/xlf/Resources.zh-Hans.xlf b/src/Sign.Core/xlf/Resources.zh-Hans.xlf index cf8ecb11..405fe481 100644 --- a/src/Sign.Core/xlf/Resources.zh-Hans.xlf +++ b/src/Sign.Core/xlf/Resources.zh-Hans.xlf @@ -2,6 +2,11 @@ + + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + + Signing SignTool job with {count} files. 正在对包含 {count} 个文件的 SignTool 作业进行签名。 diff --git a/src/Sign.Core/xlf/Resources.zh-Hant.xlf b/src/Sign.Core/xlf/Resources.zh-Hant.xlf index 1fb97a2e..e39345aa 100644 --- a/src/Sign.Core/xlf/Resources.zh-Hant.xlf +++ b/src/Sign.Core/xlf/Resources.zh-Hant.xlf @@ -2,6 +2,11 @@ + + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + Did not find exactly 1 <dependentAssembly> element with a non-empty 'codebase' attribute within the deployment manifest. + + Signing SignTool job with {count} files. 正在簽署具有 {count} 個檔案的 SignTool 工作。 diff --git a/test/Sign.Core.Test/SignatureProviders/ClickOnceSignerTests.cs b/test/Sign.Core.Test/SignatureProviders/ClickOnceSignerTests.cs index c906f771..bf665eee 100644 --- a/test/Sign.Core.Test/SignatureProviders/ClickOnceSignerTests.cs +++ b/test/Sign.Core.Test/SignatureProviders/ClickOnceSignerTests.cs @@ -14,6 +14,22 @@ public sealed class ClickOnceSignerTests : IDisposable private readonly DirectoryService _directoryService = new(Mock.Of>()); private readonly ClickOnceSigner _signer; + private const string DeploymentManifestValidContent = @" + + + + + + + + + + + + + +"; + public ClickOnceSignerTests() { _signer = new ClickOnceSigner( @@ -215,7 +231,7 @@ public async Task SignAsync_WhenFilesIsClickOnceFile_Signs(string publisherName) FileInfo applicationFile = AddFile( containerSpy, temporaryDirectory.Directory, - string.Empty, + DeploymentManifestValidContent, "MyApp.application"); FileInfo dllDeployFile = AddFile( containerSpy, @@ -364,7 +380,7 @@ public async Task SignAsync_WhenFilesIsClickOnceFileWithoutContent_Signs() FileInfo applicationFile = AddFile( containerSpy, temporaryDirectory.Directory, - string.Empty, + DeploymentManifestValidContent, "MyApp.application"); SignOptions options = new( @@ -547,6 +563,147 @@ public void CopySigningDependencies_CopiesCorrectFiles() } } + [Fact] + public async Task SignAsync_WhenFilesIsClickOnce_DetectsCorrectManifest() + { + const string commonName = "Test certificate (DO NOT TRUST)"; + + using (TemporaryDirectory temporaryDirectory = new(_directoryService)) + { + FileInfo clickOnceFile = new( + Path.Combine( + temporaryDirectory.Directory.FullName, + $"{Path.GetRandomFileName()}.clickonce")); + + ContainerSpy containerSpy = new(clickOnceFile); + + FileInfo applicationFile = AddFile( + containerSpy, + temporaryDirectory.Directory, + DeploymentManifestValidContent, + "MyApp.application"); + // This is an incomplete manifest --- just enough to satisfy SignAsync(...)'s requirements. + FileInfo manifestFile = AddFile( + containerSpy, + temporaryDirectory.Directory, + @$" + + +", + "MyApp_1_0_0_0", "MyApp.dll.manifest"); + // A second, unrelated manifest file, which we want to ignore and not sign. + FileInfo secondManifestFile = AddFile( + containerSpy, + temporaryDirectory.Directory, + @$" + + +", + "MyApp_1_0_0_0", "Some.Dependency.dll.manifest"); + + + SignOptions options = new( + "ApplicationName", + "PublisherName", + "Description", + new Uri("https://description.test"), + HashAlgorithmName.SHA256, + HashAlgorithmName.SHA256, + new Uri("http://timestamp.test"), + matcher: null, + antiMatcher: null); + + using (X509Certificate2 certificate = CreateCertificate()) + using (RSA privateKey = certificate.GetRSAPrivateKey()!) + { + Mock signatureAlgorithmProvider = new(); + Mock certificateProvider = new(); + + certificateProvider.Setup(x => x.GetCertificateAsync(It.IsAny())) + .ReturnsAsync(certificate); + + signatureAlgorithmProvider.Setup(x => x.GetRsaAsync(It.IsAny())) + .ReturnsAsync(privateKey); + + Mock serviceProvider = new(); + AggregatingSignerSpy aggregatingSignerSpy = new(); + + serviceProvider.Setup(x => x.GetService(It.IsAny())) + .Returns(aggregatingSignerSpy); + + Mock mageCli = new(); + string expectedArgs = $"-update \"{manifestFile.FullName}\" -a sha256RSA -n \"{options.ApplicationName}\""; + mageCli.Setup(x => x.RunAsync( + It.Is(args => string.Equals(expectedArgs, args, StringComparison.Ordinal)))) + .ReturnsAsync(0); + + string publisher; + + if (string.IsNullOrEmpty(options.PublisherName)) + { + publisher = certificate.SubjectName.Name; + } + else + { + publisher = options.PublisherName; + } + + expectedArgs = $"-update \"{applicationFile.FullName}\" -a sha256RSA -n \"{options.ApplicationName}\" -pub \"{publisher}\" -appm \"{manifestFile.FullName}\" -SupportURL https://description.test/"; + mageCli.Setup(x => x.RunAsync( + It.Is(args => string.Equals(expectedArgs, args, StringComparison.Ordinal)))) + .ReturnsAsync(0); + + Mock manifestSigner = new(); + Mock fileMatcher = new(); + + manifestSigner.Setup( + x => x.Sign( + It.Is(fi => fi.Name == manifestFile.Name), + It.Is(c => ReferenceEquals(certificate, c)), + It.Is(rsa => ReferenceEquals(privateKey, rsa)), + It.Is(o => ReferenceEquals(options, o)))); + + manifestSigner.Setup( + x => x.Sign( + It.Is(fi => fi.Name == applicationFile.Name), + It.Is(c => ReferenceEquals(certificate, c)), + It.Is(rsa => ReferenceEquals(privateKey, rsa)), + It.Is(o => ReferenceEquals(options, o)))); + + ILogger logger = Mock.Of>(); + ClickOnceSigner signer = new( + signatureAlgorithmProvider.Object, + certificateProvider.Object, + serviceProvider.Object, + mageCli.Object, + manifestSigner.Object, + logger, + fileMatcher.Object); + + await signer.SignAsync(new[] { applicationFile }, options); + + // Verify that files have been renamed back. + foreach (FileInfo file in containerSpy.Files) + { + file.Refresh(); + + Assert.True(file.Exists); + } + + mageCli.VerifyAll(); + manifestSigner.VerifyAll(); + + // make sure we never tried to sign the second manifest file + manifestSigner.Verify( + x => x.Sign( + It.Is(fi => fi.Name == secondManifestFile.Name), + It.Is(c => ReferenceEquals(certificate, c)), + It.Is(rsa => ReferenceEquals(privateKey, rsa)), + It.Is(o => ReferenceEquals(options, o))), Times.Never()); + } + } + } + private static FileInfo AddFile( ContainerSpy containerSpy, DirectoryInfo directory, @@ -578,4 +735,4 @@ private static X509Certificate2 CreateCertificate() return request.CreateSelfSigned(now.AddMinutes(-5), now.AddMinutes(5)); } } -} \ No newline at end of file +}