Skip to content

Commit 7fc6398

Browse files
authored
chore: enable tunnel binary verification (#36)
- Enables assembly version verification - Enables authenticode verification - Adds local machine registry config options to enable/disable either of these Closes #41 Closes #45
1 parent a57c8fb commit 7fc6398

26 files changed

+629
-58
lines changed

App/App.csproj

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
</PropertyGroup>
3131

3232
<ItemGroup>
33-
<Content Include="coder.ico" />
33+
<Content Include="coder.ico" />
3434
</ItemGroup>
3535

3636
<ItemGroup>

Installer/Installer.csproj

+19-19
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

3-
<PropertyGroup>
4-
<AssemblyName>Coder.Desktop.Installer</AssemblyName>
5-
<RootNamespace>Coder.Desktop.Installer</RootNamespace>
6-
<OutputType>Exe</OutputType>
7-
<TargetFramework>net481</TargetFramework>
8-
<LangVersion>13.0</LangVersion>
9-
</PropertyGroup>
3+
<PropertyGroup>
4+
<AssemblyName>Coder.Desktop.Installer</AssemblyName>
5+
<RootNamespace>Coder.Desktop.Installer</RootNamespace>
6+
<OutputType>Exe</OutputType>
7+
<TargetFramework>net481</TargetFramework>
8+
<LangVersion>13.0</LangVersion>
9+
</PropertyGroup>
1010

11-
<ItemGroup>
12-
<None Remove="*.msi" />
13-
<None Remove="*.exe" />
14-
<None Remove="*.wxs" />
15-
<None Remove="*.wixpdb" />
16-
<None Remove="*.wixobj" />
17-
</ItemGroup>
11+
<ItemGroup>
12+
<None Remove="*.msi" />
13+
<None Remove="*.exe" />
14+
<None Remove="*.wxs" />
15+
<None Remove="*.wixpdb" />
16+
<None Remove="*.wixobj" />
17+
</ItemGroup>
1818

19-
<ItemGroup>
20-
<PackageReference Include="WixSharp_wix4" Version="2.6.0" />
21-
<PackageReference Include="WixSharp_wix4.bin" Version="2.6.0" />
22-
<PackageReference Include="CommandLineParser" Version="2.9.1" />
23-
</ItemGroup>
19+
<ItemGroup>
20+
<PackageReference Include="WixSharp_wix4" Version="2.6.0" />
21+
<PackageReference Include="WixSharp_wix4.bin" Version="2.6.0" />
22+
<PackageReference Include="CommandLineParser" Version="2.9.1" />
23+
</ItemGroup>
2424
</Project>

Installer/Program.cs

+6-2
Original file line numberDiff line numberDiff line change
@@ -250,13 +250,17 @@ private static int BuildMsiPackage(MsiOptions opts)
250250
programFiles64Folder.AddDir(installDir);
251251
project.AddDir(programFiles64Folder);
252252

253-
// Add registry values that are consumed by the manager.
253+
// Add registry values that are consumed by the manager. Note that these
254+
// should not be changed. See Vpn.Service/Program.cs and
255+
// Vpn.Service/ManagerConfig.cs for more details.
254256
project.AddRegValues(
255257
new RegValue(RegistryHive, RegistryKey, "Manager:ServiceRpcPipeName", "Coder.Desktop.Vpn"),
256258
new RegValue(RegistryHive, RegistryKey, "Manager:TunnelBinaryPath",
257259
$"[INSTALLFOLDER]{opts.VpnDir}\\coder-vpn.exe"),
258260
new RegValue(RegistryHive, RegistryKey, "Manager:LogFileLocation",
259-
@"[INSTALLFOLDER]coder-desktop-service.log"));
261+
@"[INSTALLFOLDER]coder-desktop-service.log"),
262+
new RegValue(RegistryHive, RegistryKey, "Manager:TunnelBinarySignatureSigner", "Coder Technologies Inc."),
263+
new RegValue(RegistryHive, RegistryKey, "Manager:TunnelBinaryAllowVersionMismatch", "false"));
260264

261265
// Note: most of this control panel info will not be visible as this
262266
// package is usually hidden in favor of the bootstrapper showing

README.md

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Coder Desktop for Windows
2+
3+
This repo contains the C# source code for Coder Desktop for Windows. You can
4+
download the latest version from the GitHub releases.
5+
6+
### Contributing
7+
8+
You will need:
9+
10+
- Visual Studio 2022
11+
- .NET desktop development
12+
- WinUI application development
13+
- Windows 10 SDK (10.0.19041.0)
14+
- Wix Toolset 5.0.2 (if building the installer)
15+
16+
It's also recommended to use JetBrains Rider (or VS + ReSharper) for a better
17+
experience.
18+
19+
### License
20+
21+
The Coder Desktop for Windows source is licensed under the GNU Affero General
22+
Public License v3.0 (AGPL-3.0).
23+
24+
Some vendored files in this repo are licensed separately. The license for these
25+
files can be found in the same directory as the files.
26+
27+
The binary distributions of Coder Desktop for Windows have some additional
28+
license disclaimers that can be found in
29+
[scripts/files/License.txt](scripts/files/License.txt) or during installation.

Tests.Vpn.Service/DownloaderTest.cs

+118-16
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System.Reflection;
12
using System.Security.Cryptography;
3+
using System.Security.Cryptography.X509Certificates;
24
using System.Text;
35
using Coder.Desktop.Vpn.Service;
46
using Microsoft.Extensions.Logging.Abstractions;
@@ -27,40 +29,102 @@ public class AuthenticodeDownloadValidatorTest
2729
[CancelAfter(30_000)]
2830
public void Unsigned(CancellationToken ct)
2931
{
30-
// TODO: this
32+
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello.exe");
33+
var ex = Assert.ThrowsAsync<Exception>(() =>
34+
AuthenticodeDownloadValidator.Coder.ValidateAsync(testBinaryPath, ct));
35+
Assert.That(ex.Message,
36+
Does.Contain(
37+
"File is not signed and trusted with an Authenticode signature: State=Unsigned, StateReason=None"));
3138
}
3239

3340
[Test(Description = "Test an untrusted binary")]
3441
[CancelAfter(30_000)]
3542
public void Untrusted(CancellationToken ct)
3643
{
37-
// TODO: this
44+
var testBinaryPath =
45+
Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello-self-signed.exe");
46+
var ex = Assert.ThrowsAsync<Exception>(() =>
47+
AuthenticodeDownloadValidator.Coder.ValidateAsync(testBinaryPath, ct));
48+
Assert.That(ex.Message,
49+
Does.Contain(
50+
"File is not signed and trusted with an Authenticode signature: State=Unsigned, StateReason=UntrustedRoot"));
3851
}
3952

4053
[Test(Description = "Test an binary with a detached signature (catalog file)")]
4154
[CancelAfter(30_000)]
4255
public void DifferentCertTrusted(CancellationToken ct)
4356
{
44-
// notepad.exe uses a catalog file for its signature.
57+
// rundll32.exe uses a catalog file for its signature.
4558
var ex = Assert.ThrowsAsync<Exception>(() =>
46-
AuthenticodeDownloadValidator.Coder.ValidateAsync(@"C:\Windows\System32\notepad.exe", ct));
59+
AuthenticodeDownloadValidator.Coder.ValidateAsync(@"C:\Windows\System32\rundll32.exe", ct));
4760
Assert.That(ex.Message,
4861
Does.Contain("File is not signed with an embedded Authenticode signature: Kind=Catalog"));
4962
}
5063

51-
[Test(Description = "Test a binary signed by a different certificate")]
64+
[Test(Description = "Test a binary signed by a non-EV certificate")]
65+
[CancelAfter(30_000)]
66+
public void NonEvCert(CancellationToken ct)
67+
{
68+
// dotnet.exe is signed by .NET. During tests we can be pretty sure
69+
// this is installed.
70+
var ex = Assert.ThrowsAsync<Exception>(() =>
71+
AuthenticodeDownloadValidator.Coder.ValidateAsync(@"C:\Program Files\dotnet\dotnet.exe", ct));
72+
Assert.That(ex.Message,
73+
Does.Contain(
74+
"File is not signed with an Extended Validation Code Signing certificate"));
75+
}
76+
77+
[Test(Description = "Test a binary signed by an EV certificate with a different name")]
5278
[CancelAfter(30_000)]
53-
public void DifferentCertUntrusted(CancellationToken ct)
79+
public void EvDifferentCertName(CancellationToken ct)
5480
{
55-
// TODO: this
81+
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata",
82+
"hello-versioned-signed.exe");
83+
var ex = Assert.ThrowsAsync<Exception>(() =>
84+
new AuthenticodeDownloadValidator("Acme Corporation").ValidateAsync(testBinaryPath, ct));
85+
Assert.That(ex.Message,
86+
Does.Contain(
87+
"File is signed by an unexpected certificate: ExpectedName='Acme Corporation', ActualName='Coder Technologies Inc.'"));
5688
}
5789

5890
[Test(Description = "Test a binary signed by Coder's certificate")]
5991
[CancelAfter(30_000)]
6092
public async Task CoderSigned(CancellationToken ct)
6193
{
62-
// TODO: this
63-
await Task.CompletedTask;
94+
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata",
95+
"hello-versioned-signed.exe");
96+
await AuthenticodeDownloadValidator.Coder.ValidateAsync(testBinaryPath, ct);
97+
}
98+
99+
[Test(Description = "Test if the EV check works")]
100+
public void IsEvCert()
101+
{
102+
// To avoid potential API misuse the function is private.
103+
var method = typeof(AuthenticodeDownloadValidator).GetMethod("IsExtendedValidationCertificate",
104+
BindingFlags.NonPublic | BindingFlags.Static);
105+
Assert.That(method, Is.Not.Null, "Could not find IsExtendedValidationCertificate method");
106+
107+
// Call it with various certificates.
108+
var certs = new List<(string, bool)>
109+
{
110+
// EV:
111+
(Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "coder-ev.crt"), true),
112+
(Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "google-llc-ev.crt"), true),
113+
(Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "self-signed-ev.crt"), true),
114+
// Not EV:
115+
(Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "mozilla-corporation.crt"), false),
116+
(Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "self-signed.crt"), false),
117+
};
118+
119+
foreach (var (certPath, isEv) in certs)
120+
{
121+
var x509Cert = new X509Certificate2(certPath);
122+
var result = (bool?)method!.Invoke(null, [x509Cert]);
123+
Assert.That(result, Is.Not.Null,
124+
$"IsExtendedValidationCertificate returned null for {Path.GetFileName(certPath)}");
125+
Assert.That(result, Is.EqualTo(isEv),
126+
$"IsExtendedValidationCertificate returned wrong result for {Path.GetFileName(certPath)}");
127+
}
64128
}
65129
}
66130

@@ -71,22 +135,60 @@ public class AssemblyVersionDownloadValidatorTest
71135
[CancelAfter(30_000)]
72136
public void NoVersion(CancellationToken ct)
73137
{
74-
// TODO: this
138+
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello.exe");
139+
var ex = Assert.ThrowsAsync<Exception>(() =>
140+
new AssemblyVersionDownloadValidator(1, 2, 3, 4).ValidateAsync(testBinaryPath, ct));
141+
Assert.That(ex.Message, Does.Contain("File ProductVersion is empty or null"));
142+
}
143+
144+
[Test(Description = "Invalid version on binary")]
145+
[CancelAfter(30_000)]
146+
public void InvalidVersion(CancellationToken ct)
147+
{
148+
var testBinaryPath =
149+
Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello-invalid-version.exe");
150+
var ex = Assert.ThrowsAsync<Exception>(() =>
151+
new AssemblyVersionDownloadValidator(1, 2, 3, 4).ValidateAsync(testBinaryPath, ct));
152+
Assert.That(ex.Message, Does.Contain("File ProductVersion '1-2-3-4' is not a valid version string"));
75153
}
76154

77-
[Test(Description = "Version mismatch")]
155+
[Test(Description = "Version mismatch with full version check")]
78156
[CancelAfter(30_000)]
79-
public void VersionMismatch(CancellationToken ct)
157+
public void VersionMismatchFull(CancellationToken ct)
80158
{
81-
// TODO: this
159+
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata",
160+
"hello-versioned-signed.exe");
161+
162+
// Try changing each version component one at a time
163+
var expectedVersions = new[] { 1, 2, 3, 4 };
164+
for (var i = 0; i < 4; i++)
165+
{
166+
var testVersions = (int[])expectedVersions.Clone();
167+
testVersions[i]++; // Increment this component to make it wrong
168+
169+
var ex = Assert.ThrowsAsync<Exception>(() =>
170+
new AssemblyVersionDownloadValidator(
171+
testVersions[0], testVersions[1], testVersions[2], testVersions[3]
172+
).ValidateAsync(testBinaryPath, ct));
173+
174+
Assert.That(ex.Message, Does.Contain(
175+
$"File ProductVersion does not match expected version: Actual='1.2.3.4', Expected='{string.Join(".", testVersions)}'"));
176+
}
82177
}
83178

84-
[Test(Description = "Version match")]
179+
[Test(Description = "Version match with and without partial version check")]
85180
[CancelAfter(30_000)]
86181
public async Task VersionMatch(CancellationToken ct)
87182
{
88-
// TODO: this
89-
await Task.CompletedTask;
183+
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata",
184+
"hello-versioned-signed.exe");
185+
186+
// Test with just major.minor
187+
await new AssemblyVersionDownloadValidator(1, 2).ValidateAsync(testBinaryPath, ct);
188+
// Test with major.minor.patch
189+
await new AssemblyVersionDownloadValidator(1, 2, 3).ValidateAsync(testBinaryPath, ct);
190+
// Test with major.minor.patch.build
191+
await new AssemblyVersionDownloadValidator(1, 2, 3, 4).ValidateAsync(testBinaryPath, ct);
90192
}
91193
}
92194

Tests.Vpn.Service/Tests.Vpn.Service.csproj

+30
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,36 @@
1212
<IsTestProject>true</IsTestProject>
1313
</PropertyGroup>
1414

15+
<ItemGroup>
16+
<None Update="testdata\hello.exe">
17+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
18+
</None>
19+
<None Update="testdata\hello-invalid-version.exe">
20+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
21+
</None>
22+
<None Update="testdata\hello-self-signed.exe">
23+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
24+
</None>
25+
<None Update="testdata\hello-versioned-signed.exe">
26+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
27+
</None>
28+
<None Update="testdata\coder-ev.crt">
29+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
30+
</None>
31+
<None Update="testdata\google-llc-ev.crt">
32+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
33+
</None>
34+
<None Update="testdata\mozilla-corporation.crt">
35+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
36+
</None>
37+
<None Update="testdata\self-signed.crt">
38+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
39+
</None>
40+
<None Update="testdata\self-signed-ev.crt">
41+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
42+
</None>
43+
</ItemGroup>
44+
1545
<ItemGroup>
1646
<PackageReference Include="coverlet.collector" Version="6.0.4">
1747
<PrivateAssets>all</PrivateAssets>

Tests.Vpn.Service/testdata/.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*.go
2+
*.pfx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
$errorActionPreference = "Stop"
2+
3+
Set-Location $PSScriptRoot
4+
5+
# If hello.go does not exist, write it. We don't check it into the repo to avoid
6+
# GitHub showing that the repo contains Go code.
7+
if (-not (Test-Path "hello.go")) {
8+
$helloGo = @"
9+
package main
10+
11+
func main() {
12+
println("Hello, World!")
13+
}
14+
"@
15+
Set-Content -Path "hello.go" -Value $helloGo
16+
}
17+
18+
& go.exe build -ldflags '-w -s' -o hello.exe hello.go
19+
if ($LASTEXITCODE -ne 0) { throw "Failed to build hello.exe" }
20+
21+
# hello-invalid-version.exe is used for testing versioned binaries with an
22+
# invalid version.
23+
Copy-Item hello.exe hello-invalid-version.exe
24+
& go-winres.exe patch --in winres.json --delete --no-backup --product-version 1-2-3-4 --file-version 1-2-3-4 hello-invalid-version.exe
25+
if ($LASTEXITCODE -ne 0) { throw "Failed to patch hello-invalid-version.exe with go-winres" }
26+
27+
# hello-self-signed.exe is used for testing untrusted binaries.
28+
Copy-Item hello.exe hello-self-signed.exe
29+
$helloSelfSignedPath = (Get-Item hello-self-signed.exe).FullName
30+
31+
# Create a self signed certificate for signing and then delete it.
32+
$certStoreLocation = "Cert:\CurrentUser\My"
33+
$password = "password"
34+
$cert = New-SelfSignedCertificate `
35+
-CertStoreLocation $certStoreLocation `
36+
-DnsName coder.com `
37+
-Subject "CN=coder-desktop-windows-self-signed-cert" `
38+
-Type CodeSigningCert `
39+
-KeyUsage DigitalSignature `
40+
-NotAfter (Get-Date).AddDays(3650)
41+
$pfxPath = Join-Path $PSScriptRoot "cert.pfx"
42+
try {
43+
$securePassword = ConvertTo-SecureString -String $password -Force -AsPlainText
44+
Export-PfxCertificate -Cert $cert -FilePath $pfxPath -Password $securePassword
45+
46+
# Sign hello-self-signed.exe with the self signed certificate
47+
& "${env:ProgramFiles(x86)}\Windows Kits\10\bin\10.0.19041.0\x64\signtool.exe" sign /debug /f $pfxPath /p $password /tr "http://timestamp.digicert.com" /td sha256 /fd sha256 $helloSelfSignedPath
48+
if ($LASTEXITCODE -ne 0) { throw "Failed to sign hello-self-signed.exe with signtool" }
49+
} finally {
50+
if ($cert.Thumbprint) {
51+
Remove-Item -Path (Join-Path $certStoreLocation $cert.Thumbprint) -Force
52+
}
53+
if (Test-Path $pfxPath) {
54+
Remove-Item -Path $pfxPath -Force
55+
}
56+
}
57+
58+
# hello-versioned-signed.exe is used for testing versioned binaries and
59+
# binaries signed by a real EV certificate.
60+
Copy-Item hello.exe hello-versioned-signed.exe
61+
62+
& go-winres.exe patch --in winres.json --delete --no-backup --product-version 1.2.3.4 --file-version 1.2.3.4 hello-versioned-signed.exe
63+
if ($LASTEXITCODE -ne 0) { throw "Failed to patch hello-versioned-signed.exe with go-winres" }
64+
65+
# Then sign hello-versioned-signed.exe with the same EV cert as our real
66+
# binaries. Since this is a bit more complicated and requires some extra
67+
# permissions, we don't do this in the build script.
68+
Write-Host "Don't forget to sign hello-versioned-signed.exe with the EV cert!"

0 commit comments

Comments
 (0)