-
Notifications
You must be signed in to change notification settings - Fork 91
/
ClickOnceSigner.cs
277 lines (228 loc) · 13.2 KB
/
ClickOnceSigner.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE.txt file in the project root for more information.
using System.Globalization;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileSystemGlobbing.Abstractions;
using Microsoft.Extensions.Logging;
namespace Sign.Core
{
internal sealed class ClickOnceSigner : RetryingSigner, IDataFormatSigner
{
private readonly Lazy<IAggregatingDataFormatSigner> _aggregatingSigner;
private readonly ICertificateProvider _certificateProvider;
private readonly ISignatureAlgorithmProvider _signatureAlgorithmProvider;
private readonly IMageCli _mageCli;
private readonly IManifestSigner _manifestSigner;
private readonly ParallelOptions _parallelOptions = new() { MaxDegreeOfParallelism = 4 };
private readonly IFileMatcher _fileMatcher;
// Dependency injection requires a public constructor.
public ClickOnceSigner(
ISignatureAlgorithmProvider signatureAlgorithmProvider,
ICertificateProvider certificateProvider,
IServiceProvider serviceProvider,
IMageCli mageCli,
IManifestSigner manifestSigner,
ILogger<IDataFormatSigner> logger,
IFileMatcher fileMatcher)
: base(logger)
{
ArgumentNullException.ThrowIfNull(signatureAlgorithmProvider, nameof(signatureAlgorithmProvider));
ArgumentNullException.ThrowIfNull(certificateProvider, nameof(certificateProvider));
ArgumentNullException.ThrowIfNull(serviceProvider, nameof(serviceProvider));
ArgumentNullException.ThrowIfNull(mageCli, nameof(mageCli));
ArgumentNullException.ThrowIfNull(manifestSigner, nameof(manifestSigner));
ArgumentNullException.ThrowIfNull(fileMatcher, nameof(fileMatcher));
_signatureAlgorithmProvider = signatureAlgorithmProvider;
_certificateProvider = certificateProvider;
_mageCli = mageCli;
_manifestSigner = manifestSigner;
_fileMatcher = fileMatcher;
// Need to delay this as it'd create a dependency loop if directly in the ctor
_aggregatingSigner = new Lazy<IAggregatingDataFormatSigner>(() => serviceProvider.GetService<IAggregatingDataFormatSigner>()!);
}
public bool CanSign(FileInfo file)
{
ArgumentNullException.ThrowIfNull(file, nameof(file));
return file.Extension.ToLowerInvariant() switch
{
".vsto" or ".application" => true,
_ => false
};
}
public async Task SignAsync(IEnumerable<FileInfo> files, SignOptions options)
{
ArgumentNullException.ThrowIfNull(files, nameof(files));
ArgumentNullException.ThrowIfNull(options, nameof(options));
Logger.LogInformation(Resources.ClickOnceSignatureProviderSigning, files.Count());
var args = "-a sha256RSA";
if (!string.IsNullOrWhiteSpace(options.ApplicationName))
{
args += $@" -n ""{options.ApplicationName}""";
}
Uri? timeStampUrl = options.TimestampService;
using (X509Certificate2 certificate = await _certificateProvider.GetCertificateAsync())
using (RSA rsaPrivateKey = await _signatureAlgorithmProvider.GetRsaAsync())
{
// This outer loop is for a deployment manifest file (.application/.vsto).
await Parallel.ForEachAsync(files, _parallelOptions, async (file, state) =>
{
// We need to be explicit about the order these files are signed in. The data files must be signed first
// Then the .manifest file
// Then the nested clickonce/vsto file
// finally the top-level clickonce/vsto file
// It's possible that there might not actually be a .manifest file or any data files if the user just
// wants to re-sign an existing deployment manifest because e.g. the update URL has changed but nothing
// else has. In that case we don't need to touch the other files and we can just sign the deployment manifest.
// Look for the data files first - these are .deploy files
// we need to rename them, sign, then restore the name
DirectoryInfo clickOnceDirectory = file.Directory!;
// get the files, _including_ the SignOptions, so that we only actually try to sign the files specified.
// this is useful if e.g. you don't want to sign third-party assemblies that your application depends on
// but you do still want to sign your own assemblies.
List<FileInfo> filteredFiles = GetFiles(clickOnceDirectory, options).ToList();
List<FileInfo> deployFilesToSign = filteredFiles
.Where(f => ".deploy".Equals(f.Extension, StringComparison.OrdinalIgnoreCase))
.ToList();
List<FileInfo> contentFiles = new();
RemoveDeployExtension(deployFilesToSign, contentFiles);
List<FileInfo> filesToSign = contentFiles.ToList(); // copy it since we may add setup.exe
IEnumerable<FileInfo> setupExe = filteredFiles.Where(f => ".exe".Equals(f.Extension, StringComparison.OrdinalIgnoreCase));
filesToSign.AddRange(setupExe);
// sign the inner files
await _aggregatingSigner.Value.SignAsync(filesToSign!, options);
// rename the rest of the deploy files since signing the manifest will need them.
// this uses the overload of GetFiles() that ignores file matching options because we
// require all files to be named correctly in order to generate valid manifests.
List<FileInfo> filesExceptFiltered = GetFiles(clickOnceDirectory).Except(filteredFiles, FileInfoComparer.Instance).ToList();
List<FileInfo> deployFiles = filesExceptFiltered
.Where(f => ".deploy".Equals(f.Extension, StringComparison.OrdinalIgnoreCase))
.ToList();
RemoveDeployExtension(deployFiles, contentFiles);
// at this point contentFiles has all deploy files renamed
// 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));
string fileArgs = $@"-update ""{manifestFile}"" {args}";
if (manifestFile is not null && !await SignAsync(fileArgs, manifestFile, rsaPrivateKey, certificate, options))
{
string message = string.Format(CultureInfo.CurrentCulture, Resources.SigningFailed, manifestFile.FullName);
throw new Exception(message);
}
string publisherParam = string.Empty;
if (string.IsNullOrEmpty(options.PublisherName))
{
string publisherName = certificate.SubjectName.Name;
// get the DN. it may be quoted
publisherParam = $@"-pub ""{publisherName.Replace("\"", "")}""";
}
else
{
publisherParam = $"-pub \"{options.PublisherName}\"";
}
// Now sign deployment manifest files (.application/.vsto).
// Order by desending length to put the inner one first
List<FileInfo> deploymentManifestFiles = filteredFiles
.Where(f => ".vsto".Equals(f.Extension, StringComparison.OrdinalIgnoreCase) ||
".application".Equals(f.Extension, StringComparison.OrdinalIgnoreCase))
.Select(f => new { file = f, f.FullName.Length })
.OrderByDescending(f => f.Length)
.Select(f => f.file)
.ToList();
foreach (FileInfo deploymentManifestFile in deploymentManifestFiles)
{
fileArgs = $@"-update ""{deploymentManifestFile.FullName}"" {args} {publisherParam}";
if (manifestFile is not null)
{
fileArgs += $@" -appm ""{manifestFile.FullName}""";
}
if (options.DescriptionUrl is not null)
{
fileArgs += $@" -SupportURL {options.DescriptionUrl.AbsoluteUri}";
}
if (!await SignAsync(fileArgs, deploymentManifestFile, rsaPrivateKey, certificate, options))
{
string message = string.Format(CultureInfo.CurrentCulture, Resources.SigningFailed, deploymentManifestFile.FullName);
throw new Exception(message);
}
}
// restore the .deploy files
foreach (FileInfo contentFile in contentFiles)
{
File.Move(contentFile.FullName, $"{contentFile.FullName}.deploy");
}
});
}
}
private static void RemoveDeployExtension(List<FileInfo> deployFilesToSign, List<FileInfo> contentFiles)
{
foreach (FileInfo deployFileToSign in deployFilesToSign)
{
// Rename to file without .deploy extension
// For example:
// * MyApp.dll.deploy => MyApp.dll
// * MyApp.exe.deploy => MyApp.exe
string contentFilePath = Path.Combine(
deployFileToSign.DirectoryName!,
Path.GetFileNameWithoutExtension(deployFileToSign.Name));
FileInfo contentFile = new(contentFilePath);
File.Move(deployFileToSign.FullName, contentFile.FullName);
contentFiles.Add(contentFile);
}
}
protected override async Task<bool> SignCoreAsync(string? args, FileInfo file, RSA rsaPrivateKey, X509Certificate2 certificate, SignOptions options)
{
int exitCode = await _mageCli.RunAsync(args);
if (exitCode == 0)
{
// Now add the signature
_manifestSigner.Sign(file, certificate, rsaPrivateKey, options);
return true;
}
Logger.LogError(Resources.SigningFailedWithError, exitCode);
return false;
}
private IEnumerable<FileInfo> GetFiles(DirectoryInfo clickOnceRoot)
{
return clickOnceRoot.EnumerateFiles("*", SearchOption.AllDirectories);
}
private IEnumerable<FileInfo> GetFiles(DirectoryInfo clickOnceRoot, SignOptions options)
{
IEnumerable<FileInfo> files;
if (options.Matcher is null)
{
// If not filtered, default to all
files = GetFiles(clickOnceRoot);
}
else
{
files = _fileMatcher.EnumerateMatches(new DirectoryInfoWrapper(clickOnceRoot), options.Matcher);
}
if (options.AntiMatcher is not null)
{
IEnumerable<FileInfo> antiFiles = _fileMatcher.EnumerateMatches(new DirectoryInfoWrapper(clickOnceRoot), options.AntiMatcher);
files = files.Except(antiFiles, FileInfoComparer.Instance).ToList();
}
return files;
}
public void CopySigningDependencies(FileInfo deploymentManifestFile, DirectoryInfo destination, SignOptions signOptions)
{
// copy _all_ files, ignoring matching options, because we need them to be available to generate
// valid manifests.
foreach (FileInfo file in GetFiles(deploymentManifestFile.Directory!))
{
// don't copy the file itself because that's already taken care of (and we don't want a duplicate copy with the 'real' name)
// lying around since it'll get copied back and overwrite the signed one.
if (file.FullName != deploymentManifestFile.FullName)
{
string relativeDestPath = Path.GetRelativePath(deploymentManifestFile.Directory!.FullName, file.FullName);
string fullDestPath = Path.Combine(destination.FullName, relativeDestPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullDestPath!)!);
file.CopyTo(fullDestPath, overwrite: true);
}
}
}
}
}