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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 19 additions & 19 deletions
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

Lines changed: 6 additions & 2 deletions
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

Lines changed: 29 additions & 0 deletions
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

Lines changed: 118 additions & 16 deletions
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

Lines changed: 30 additions & 0 deletions
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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*.go
2+
*.pfx
Lines changed: 68 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy