Skip to content

Commit 81e645c

Browse files
committed
chore: tests for authenticode and version checks
1 parent ee33e47 commit 81e645c

File tree

12 files changed

+224
-20
lines changed

12 files changed

+224
-20
lines changed

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: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,23 +27,29 @@ public class AuthenticodeDownloadValidatorTest
2727
[CancelAfter(30_000)]
2828
public void Unsigned(CancellationToken ct)
2929
{
30-
// TODO: this
30+
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello.exe");
31+
var ex = Assert.ThrowsAsync<Exception>(() =>
32+
AuthenticodeDownloadValidator.Coder.ValidateAsync(testBinaryPath, ct));
33+
Assert.That(ex.Message, Does.Contain("File is not signed and trusted with an Authenticode signature: State=Unsigned, StateReason=None"));
3134
}
3235

3336
[Test(Description = "Test an untrusted binary")]
3437
[CancelAfter(30_000)]
3538
public void Untrusted(CancellationToken ct)
3639
{
37-
// TODO: this
40+
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello-self-signed.exe");
41+
var ex = Assert.ThrowsAsync<Exception>(() =>
42+
AuthenticodeDownloadValidator.Coder.ValidateAsync(testBinaryPath, ct));
43+
Assert.That(ex.Message, Does.Contain("File is not signed and trusted with an Authenticode signature: State=Unsigned, StateReason=UntrustedRoot"));
3844
}
3945

4046
[Test(Description = "Test an binary with a detached signature (catalog file)")]
4147
[CancelAfter(30_000)]
4248
public void DifferentCertTrusted(CancellationToken ct)
4349
{
44-
// notepad.exe uses a catalog file for its signature.
50+
// rundll32.exe uses a catalog file for its signature.
4551
var ex = Assert.ThrowsAsync<Exception>(() =>
46-
AuthenticodeDownloadValidator.Coder.ValidateAsync(@"C:\Windows\System32\notepad.exe", ct));
52+
AuthenticodeDownloadValidator.Coder.ValidateAsync(@"C:\Windows\System32\rundll32.exe", ct));
4753
Assert.That(ex.Message,
4854
Does.Contain("File is not signed with an embedded Authenticode signature: Kind=Catalog"));
4955
}
@@ -52,15 +58,19 @@ public void DifferentCertTrusted(CancellationToken ct)
5258
[CancelAfter(30_000)]
5359
public void DifferentCertUntrusted(CancellationToken ct)
5460
{
55-
// TODO: this
61+
// dotnet.exe is signed by .NET. During tests we can be pretty sure
62+
// this is installed.
63+
var ex = Assert.ThrowsAsync<Exception>(() =>
64+
AuthenticodeDownloadValidator.Coder.ValidateAsync(@"C:\Program Files\dotnet\dotnet.exe", ct));
65+
Assert.That(ex.Message, Does.Contain("File is signed by an unexpected certificate: ExpectedName='Coder Technologies Inc.', ActualName='.NET"));
5666
}
5767

5868
[Test(Description = "Test a binary signed by Coder's certificate")]
5969
[CancelAfter(30_000)]
6070
public async Task CoderSigned(CancellationToken ct)
6171
{
62-
// TODO: this
63-
await Task.CompletedTask;
72+
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello-versioned-signed.exe");
73+
await AuthenticodeDownloadValidator.Coder.ValidateAsync(testBinaryPath, ct);
6474
}
6575
}
6676

@@ -71,22 +81,57 @@ public class AssemblyVersionDownloadValidatorTest
7181
[CancelAfter(30_000)]
7282
public void NoVersion(CancellationToken ct)
7383
{
74-
// TODO: this
84+
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello.exe");
85+
var ex = Assert.ThrowsAsync<Exception>(() =>
86+
new AssemblyVersionDownloadValidator(1, 2, 3, 4).ValidateAsync(testBinaryPath, ct));
87+
Assert.That(ex.Message, Does.Contain("File ProductVersion is empty or null"));
7588
}
7689

77-
[Test(Description = "Version mismatch")]
90+
[Test(Description = "Invalid version on binary")]
7891
[CancelAfter(30_000)]
79-
public void VersionMismatch(CancellationToken ct)
92+
public void InvalidVersion(CancellationToken ct)
8093
{
81-
// TODO: this
94+
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello-invalid-version.exe");
95+
var ex = Assert.ThrowsAsync<Exception>(() =>
96+
new AssemblyVersionDownloadValidator(1, 2, 3, 4).ValidateAsync(testBinaryPath, ct));
97+
Assert.That(ex.Message, Does.Contain("File ProductVersion '1-2-3-4' is not a valid version string"));
98+
}
99+
100+
[Test(Description = "Version mismatch with full version check")]
101+
[CancelAfter(30_000)]
102+
public void VersionMismatchFull(CancellationToken ct)
103+
{
104+
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello-versioned-signed.exe");
105+
106+
// Try changing each version component one at a time
107+
var expectedVersions = new[] { 1, 2, 3, 4 };
108+
for (var i = 0; i < 4; i++)
109+
{
110+
var testVersions = (int[])expectedVersions.Clone();
111+
testVersions[i]++; // Increment this component to make it wrong
112+
113+
var ex = Assert.ThrowsAsync<Exception>(() =>
114+
new AssemblyVersionDownloadValidator(
115+
testVersions[0], testVersions[1], testVersions[2], testVersions[3]
116+
).ValidateAsync(testBinaryPath, ct));
117+
118+
Assert.That(ex.Message, Does.Contain(
119+
$"File ProductVersion does not match expected version: Actual='1.2.3.4', Expected='{string.Join(".", testVersions)}'"));
120+
}
82121
}
83122

84-
[Test(Description = "Version match")]
123+
[Test(Description = "Version match with and without partial version check")]
85124
[CancelAfter(30_000)]
86125
public async Task VersionMatch(CancellationToken ct)
87126
{
88-
// TODO: this
89-
await Task.CompletedTask;
127+
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello-versioned-signed.exe");
128+
129+
// Test with just major.minor
130+
await new AssemblyVersionDownloadValidator(1, 2).ValidateAsync(testBinaryPath, ct);
131+
// Test with major.minor.patch
132+
await new AssemblyVersionDownloadValidator(1, 2, 3).ValidateAsync(testBinaryPath, ct);
133+
// Test with major.minor.patch.build
134+
await new AssemblyVersionDownloadValidator(1, 2, 3, 4).ValidateAsync(testBinaryPath, ct);
90135
}
91136
}
92137

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,23 @@
1212
<IsTestProject>true</IsTestProject>
1313
</PropertyGroup>
1414

15+
<ItemGroup>
16+
<None Remove="testdata\hello.go" />
17+
<None Remove="testdata\winres.json" />
18+
<None Update="testdata\hello.exe">
19+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
20+
</None>
21+
<None Update="testdata\hello-invalid-version.exe">
22+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
23+
</None>
24+
<None Update="testdata\hello-self-signed.exe">
25+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
26+
</None>
27+
<None Update="testdata\hello-versioned-signed.exe">
28+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
29+
</None>
30+
</ItemGroup>
31+
1532
<ItemGroup>
1633
<PackageReference Include="coverlet.collector" Version="6.0.4">
1734
<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 binariess 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!"
Binary file not shown.
1020 KB
Binary file not shown.
Binary file not shown.

Tests.Vpn.Service/testdata/hello.exe

1010 KB
Binary file not shown.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"RT_MANIFEST": {
3+
"#1": {
4+
"0409": {
5+
"identity": {},
6+
"description": "",
7+
"minimum-os": "win7",
8+
"execution-level": "",
9+
"ui-access": false,
10+
"auto-elevate": false,
11+
"dpi-awareness": "system",
12+
"disable-theming": false,
13+
"disable-window-filtering": false,
14+
"high-resolution-scrolling-aware": false,
15+
"ultra-high-resolution-scrolling-aware": false,
16+
"long-path-aware": false,
17+
"printer-driver-isolation": false,
18+
"gdi-scaling": false,
19+
"segment-heap": false,
20+
"use-common-controls-v6": false
21+
}
22+
}
23+
},
24+
"RT_VERSION": {
25+
"#1": {
26+
"0409": {
27+
"fixed": {
28+
"file_version": "1.2.3.4",
29+
"product_version": "1.2.3.4"
30+
},
31+
"info": {
32+
"0409": {
33+
"FileDescription": "Coder",
34+
"FileVersion": "1.2.3.4",
35+
"LegalCopyright": "Copyright 2025 Coder Technologies Inc.",
36+
"OriginalFilename": "coder.exe",
37+
"ProductName": "Coder",
38+
"ProductVersion": "1.2.3.4"
39+
}
40+
}
41+
}
42+
}
43+
}
44+
}

Vpn.Service/Downloader.cs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,13 @@ public async Task ValidateAsync(string path, CancellationToken ct = default)
6060

6161
if (fileSigInfo.State != SignatureState.SignedAndTrusted)
6262
throw new Exception(
63-
$"File is not signed and trusted with an Authenticode signature: State={fileSigInfo.State}");
63+
$"File is not signed and trusted with an Authenticode signature: State={fileSigInfo.State}, StateReason={fileSigInfo.StateReason}");
6464

6565
// Coder will only use embedded signatures because we are downloading
6666
// individual binaries and not installers which can ship catalog files.
6767
if (fileSigInfo.Kind != SignatureKind.Embedded)
6868
throw new Exception($"File is not signed with an embedded Authenticode signature: Kind={fileSigInfo.Kind}");
6969

70-
// TODO: check that it's an extended validation certificate
71-
7270
var actualName = fileSigInfo.SigningCertificate.GetNameInfo(X509NameType.SimpleName, false);
7371
if (actualName != _expectedName)
7472
throw new Exception(
@@ -86,8 +84,8 @@ public class AssemblyVersionDownloadValidator : IDownloadValidator
8684
private readonly Version _expectedVersion;
8785

8886
// ReSharper disable once ConvertToPrimaryConstructor
89-
public AssemblyVersionDownloadValidator(int expectedMajor, int expectedMinor, int expectedBuild,
90-
int expectedRevision)
87+
public AssemblyVersionDownloadValidator(int expectedMajor, int expectedMinor, int expectedBuild = -1,
88+
int expectedRevision = -1)
9189
{
9290
_expectedMajor = expectedMajor;
9391
_expectedMinor = expectedMinor;
@@ -122,7 +120,7 @@ public Task ValidateAsync(string path, CancellationToken ct = default)
122120
(_expectedBuild != -1 && productVersion.Build != _expectedBuild) ||
123121
(_expectedRevision != -1 && productVersion.Revision != _expectedRevision))
124122
throw new Exception(
125-
$"File ProductVersion is '{info.ProductVersion}', but expected '{_expectedVersion}'");
123+
$"File ProductVersion does not match expected version: Actual='{info.ProductVersion}', Expected='{_expectedVersion}'");
126124

127125
return Task.CompletedTask;
128126
}

scripts/Publish.ps1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ if ($LASTEXITCODE -ne 0) { throw "Failed to build Vpn.Service" }
122122
$appPublishDir = Join-Path $buildPath "app"
123123
$msbuildBinary = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe
124124
if ($LASTEXITCODE -ne 0) { throw "Failed to find MSBuild" }
125+
if (-not (Test-Path $msbuildBinary)) { throw "Failed to find MSBuild at $msbuildBinary" }
125126
& $msbuildBinary .\App\App.csproj /p:Configuration=Release /p:Platform=$arch /p:OutputPath=$appPublishDir
126127
if ($LASTEXITCODE -ne 0) { throw "Failed to build App" }
127128

0 commit comments

Comments
 (0)