Skip to content

Commit 0773310

Browse files
authored
impl: add support for disabling CLI signature verification (#564)
* impl: add new configurable option to disable CLI signature verification These options are configurable from the Settings page there is no available shortcut on the main plugin page to discourage the quick disable of CLI verification * impl: hide configurable fallback if signature verification is disabled The main plugin screen has a quick shortcut for setting whether the user wants to fallback on releases.coder.com for signatures if they are not provided by the main deployment. This checkbox should not be visible if the user wants to disable signature verification altogether. * impl: skip signature validation Signature validation is skipped if the user configured the `disableSignatureVerification` to true. * chore: update changelog * chore: next version is 2.22.1 * doc: developer facing documentation for CLI signature verification * chore: fix UTs
1 parent 274ee1f commit 0773310

File tree

11 files changed

+161
-49
lines changed

11 files changed

+161
-49
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
## Unreleased
66

7+
### Added
8+
9+
- support for skipping CLI signature verification
10+
711
## 2.22.0 - 2025-07-25
812

913
### Added

CONTRIBUTING.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,70 @@ There are three ways to get into a workspace:
1616

1717
Currently the first two will configure SSH but the third does not yet.
1818

19+
## GPG Signature Verification
20+
21+
The Coder Gateway plugin starting with version *2.22.0* implements a comprehensive GPG signature verification system to
22+
ensure the authenticity and integrity of downloaded Coder CLI binaries. This security feature helps protect users from
23+
running potentially malicious or tampered binaries.
24+
25+
### How It Works
26+
27+
1. **Binary Download**: When connecting to a Coder deployment, the plugin downloads the appropriate Coder CLI binary for
28+
the user's operating system and architecture from the deployment's `/bin/` endpoint.
29+
30+
2. **Signature Download**: After downloading the binary, the plugin attempts to download the corresponding `.asc`
31+
signature file from the same location. The signature file is named according to the binary (e.g.,
32+
`coder-linux-amd64.asc` for `coder-linux-amd64`).
33+
34+
3. **Fallback Signature Sources**: If the signature is not available from the deployment, the plugin can optionally fall
35+
back to downloading signatures from `releases.coder.com`. This is controlled by the `fallbackOnCoderForSignatures`
36+
setting.
37+
38+
4. **GPG Verification**: The plugin uses the BouncyCastle library shipped with Gateway app to verify the detached GPG
39+
signature against the downloaded binary using Coder's trusted public key.
40+
41+
5. **User Interaction**: If signature verification fails or signatures are unavailable, the plugin presents security
42+
warnings
43+
to users, allowing them to accept the risk and continue or abort the operation.
44+
45+
### Verification Process
46+
47+
The verification process involves several components:
48+
49+
- **`GPGVerifier`**: Handles the core GPG signature verification logic using BouncyCastle
50+
- **`VerificationResult`**: Represents the outcome of verification (Valid, Invalid, Failed, SignatureNotFound)
51+
- **`CoderDownloadService`**: Manages downloading both binaries and their signatures
52+
- **`CoderCLIManager`**: Orchestrates the download and verification workflow
53+
54+
### Configuration Options
55+
56+
Users can control signature verification behavior through plugin settings:
57+
58+
- **`disableSignatureVerification`**: When enabled, skips all signature verification. This is useful for clients running
59+
custom CLI builds, or
60+
customers with old deployment versions that don't have a signature published on `releases.coder.com`.
61+
- **`fallbackOnCoderForSignatures`**: When enabled, allows downloading signatures from `releases.coder.com` if not
62+
available from the deployment
63+
64+
### Security Considerations
65+
66+
- The plugin embeds Coder's trusted public key in the plugin resources
67+
- Verification uses detached signatures, which are more secure than attached signatures
68+
- Users are warned about security risks when verification fails
69+
- The system gracefully handles cases where signatures are unavailable
70+
- All verification failures are logged for debugging purposes
71+
72+
### Error Handling
73+
74+
The system handles various failure scenarios:
75+
76+
- **Missing signatures**: Prompts user to accept risk or abort
77+
- **Invalid signatures**: Warns user about potential tampering and prompts user to accept risk or abort
78+
- **Verification failures**: Prompts user to accept risk or abort
79+
80+
This signature verification system ensures that users can trust the Coder CLI binaries they download through the plugin,
81+
protecting against supply chain attacks and ensuring binary integrity.
82+
1983
## Development
2084

2185
To manually install a local build:

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ pluginGroup=com.coder.gateway
55
artifactName=coder-gateway
66
pluginName=Coder
77
# SemVer format -> https://semver.org
8-
pluginVersion=2.22.0
8+
pluginVersion=2.22.1
99
# See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
1010
# for insight into build numbers and IntelliJ Platform versions.
1111
pluginSinceBuild=243.26574

src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@ import com.intellij.openapi.components.service
88
import com.intellij.openapi.options.BoundConfigurable
99
import com.intellij.openapi.ui.DialogPanel
1010
import com.intellij.openapi.ui.ValidationInfo
11+
import com.intellij.ui.components.JBCheckBox
1112
import com.intellij.ui.components.JBTextField
1213
import com.intellij.ui.dsl.builder.AlignX
14+
import com.intellij.ui.dsl.builder.Cell
1315
import com.intellij.ui.dsl.builder.RowLayout
1416
import com.intellij.ui.dsl.builder.bindSelected
1517
import com.intellij.ui.dsl.builder.bindText
1618
import com.intellij.ui.dsl.builder.panel
19+
import com.intellij.ui.dsl.builder.selected
1720
import com.intellij.ui.layout.ValidationInfoBuilder
21+
import com.intellij.ui.layout.not
1822
import java.net.URL
1923
import java.nio.file.Path
2024

@@ -60,22 +64,27 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") {
6064
.bindText(state::binaryDirectory)
6165
.comment(CoderGatewayBundle.message("gateway.connector.settings.binary-destination.comment"))
6266
}.layout(RowLayout.PARENT_GRID)
63-
row {
64-
cell() // For alignment.
65-
checkBox(CoderGatewayBundle.message("gateway.connector.settings.enable-binary-directory-fallback.title"))
66-
.bindSelected(state::enableBinaryDirectoryFallback)
67-
.comment(
68-
CoderGatewayBundle.message("gateway.connector.settings.enable-binary-directory-fallback.comment"),
69-
)
70-
}.layout(RowLayout.PARENT_GRID)
71-
row {
72-
cell() // For alignment.
73-
checkBox(CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.title"))
74-
.bindSelected(state::fallbackOnCoderForSignatures)
75-
.comment(
76-
CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"),
77-
)
78-
}.layout(RowLayout.PARENT_GRID)
67+
group {
68+
lateinit var signatureVerificationCheckBox: Cell<JBCheckBox>
69+
row {
70+
cell() // For alignment.
71+
signatureVerificationCheckBox =
72+
checkBox(CoderGatewayBundle.message("gateway.connector.settings.disable-signature-validation.title"))
73+
.bindSelected(state::disableSignatureVerification)
74+
.comment(
75+
CoderGatewayBundle.message("gateway.connector.settings.disable-signature-validation.comment"),
76+
)
77+
}.layout(RowLayout.PARENT_GRID)
78+
row {
79+
cell() // For alignment.
80+
checkBox(CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.title"))
81+
.bindSelected(state::fallbackOnCoderForSignatures)
82+
.comment(
83+
CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"),
84+
)
85+
}.visibleIf(signatureVerificationCheckBox.selected.not())
86+
.layout(RowLayout.PARENT_GRID)
87+
}
7988
row(CoderGatewayBundle.message("gateway.connector.settings.header-command.title")) {
8089
textField().resizableColumn().align(AlignX.FILL)
8190
.bindText(state::headerCommand)
@@ -122,7 +131,10 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") {
122131
textArea().resizableColumn().align(AlignX.FILL)
123132
.bindText(state::sshConfigOptions)
124133
.comment(
125-
CoderGatewayBundle.message("gateway.connector.settings.ssh-config-options.comment", CODER_SSH_CONFIG_OPTIONS),
134+
CoderGatewayBundle.message(
135+
"gateway.connector.settings.ssh-config-options.comment",
136+
CODER_SSH_CONFIG_OPTIONS
137+
),
126138
)
127139
}.layout(RowLayout.PARENT_GRID)
128140
row(CoderGatewayBundle.message("gateway.connector.settings.setup-command.title")) {
@@ -162,7 +174,7 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") {
162174
.bindText(state::defaultIde)
163175
.comment(
164176
"The default IDE version to display in the IDE selection dropdown. " +
165-
"Example format: CL 2023.3.6 233.15619.8",
177+
"Example format: CL 2023.3.6 233.15619.8",
166178
)
167179
}
168180
row(CoderGatewayBundle.message("gateway.connector.settings.check-ide-updates.heading")) {

src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,11 @@ class CoderCLIManager(
174174
else -> result as DownloadResult.Downloaded
175175
}
176176
}
177+
if (settings.disableSignatureVerification) {
178+
downloader.commit()
179+
logger.info("Skipping over CLI signature verification, it is disabled by the user")
180+
return true
181+
}
177182

178183
var signatureResult = withContext(Dispatchers.IO) {
179184
downloader.downloadSignature(showTextProgress)

src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,12 @@ open class CoderSettingsState(
6565
open var enableBinaryDirectoryFallback: Boolean = false,
6666

6767
/**
68-
* Controls whether we fall back release.coder.com
68+
* Controls whether we verify the cli signature
69+
*/
70+
open var disableSignatureVerification: Boolean = false,
71+
72+
/**
73+
* Controls whether we fall back release.coder.com if signature validation is enabled
6974
*/
7075
open var fallbackOnCoderForSignatures: Boolean = false,
7176

@@ -109,7 +114,7 @@ open class CoderSettingsState(
109114
// Default version of IDE to display in IDE selection dropdown
110115
open var defaultIde: String = "",
111116
// Whether to check for IDE updates.
112-
open var checkIDEUpdates: Boolean = true,
117+
open var checkIDEUpdates: Boolean = true
113118
)
114119

115120
/**
@@ -137,7 +142,7 @@ open class CoderSettings(
137142
// Overrides the default environment (for tests).
138143
private val env: Environment = Environment(),
139144
// Overrides the default binary name (for tests).
140-
private val binaryName: String? = null,
145+
private val binaryName: String? = null
141146
) {
142147
val tls = CoderTLSSettings(state)
143148

@@ -160,6 +165,12 @@ open class CoderSettings(
160165
val enableBinaryDirectoryFallback: Boolean
161166
get() = state.enableBinaryDirectoryFallback
162167

168+
/**
169+
* Controls whether we verify the cli signature
170+
*/
171+
val disableSignatureVerification: Boolean
172+
get() = state.disableSignatureVerification
173+
163174
/**
164175
* Controls whether we fall back release.coder.com
165176
*/

src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ class CoderWorkspacesStepView :
306306
CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"),
307307
)
308308

309-
}.layout(RowLayout.PARENT_GRID)
309+
}.visible(state.disableSignatureVerification.not()).layout(RowLayout.PARENT_GRID)
310310
row {
311311
scrollCell(
312312
toolbar.createPanel().apply {

src/main/resources/messages/CoderGatewayBundle.properties

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,10 @@ gateway.connector.settings.enable-binary-directory-fallback.title=Fall back to d
7575
gateway.connector.settings.enable-binary-directory-fallback.comment=Checking this \
7676
box will allow the plugin to fall back to the data directory when the CLI \
7777
directory is not writable.
78-
78+
gateway.connector.settings.disable-signature-validation.title=Disable Coder CLI signature verification
79+
gateway.connector.settings.disable-signature-validation.comment=Useful if you run an unsigned fork for the binary
7980
gateway.connector.settings.fallback-on-coder-for-signatures.title=Fall back on releases.coder.com for signatures
8081
gateway.connector.settings.fallback-on-coder-for-signatures.comment=Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment
81-
8282
gateway.connector.settings.header-command.title=Header command
8383
gateway.connector.settings.header-command.comment=An external command that \
8484
outputs additional HTTP headers added to all requests. The command must \

src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ internal class CoderCLIManagerTest {
124124
CoderSettings(
125125
CoderSettingsState(
126126
dataDirectory = tmpdir.resolve("cli-data-dir").toString(),
127-
binaryDirectory = tmpdir.resolve("cli-bin-dir").toString(),
127+
binaryDirectory = tmpdir.resolve("cli-bin-dir").toString()
128128
),
129129
)
130130
val url = URL("http://localhost")

src/test/kotlin/com/coder/gateway/sdk/CoderRestClientTest.kt

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -229,31 +229,44 @@ class CoderRestClientTest {
229229
// Nothing, so no resources.
230230
emptyList(),
231231
// One workspace with an agent, but no resources.
232-
listOf(TestWorkspace(DataGen.workspace("ws1", agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")))),
232+
listOf(
233+
TestWorkspace(
234+
DataGen.workspace(
235+
"ws1",
236+
agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")
237+
)
238+
)
239+
),
233240
// One workspace with an agent and resources that do not match the agent.
234241
listOf(
235242
TestWorkspace(
236-
workspace = DataGen.workspace("ws1", agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")),
237-
resources =
238-
listOf(
239-
DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"),
240-
DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"),
243+
workspace = DataGen.workspace(
244+
"ws1",
245+
agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")
241246
),
247+
resources =
248+
listOf(
249+
DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"),
250+
DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"),
251+
),
242252
),
243253
),
244254
// Multiple workspaces but only one has resources.
245255
listOf(
246256
TestWorkspace(
247-
workspace = DataGen.workspace("ws1", agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")),
257+
workspace = DataGen.workspace(
258+
"ws1",
259+
agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")
260+
),
248261
resources = emptyList(),
249262
),
250263
TestWorkspace(
251264
workspace = DataGen.workspace("ws2"),
252265
resources =
253-
listOf(
254-
DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"),
255-
DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"),
256-
),
266+
listOf(
267+
DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"),
268+
DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"),
269+
),
257270
),
258271
TestWorkspace(
259272
workspace = DataGen.workspace("ws3"),
@@ -272,7 +285,8 @@ class CoderRestClientTest {
272285
val matches = resourceEndpoint.find(exchange.requestURI.path)
273286
if (matches != null) {
274287
val templateVersionId = UUID.fromString(matches.destructured.toList()[0])
275-
val ws = workspaces.firstOrNull { it.workspace.latestBuild.templateVersionID == templateVersionId }
288+
val ws =
289+
workspaces.firstOrNull { it.workspace.latestBuild.templateVersionID == templateVersionId }
276290
if (ws != null) {
277291
val body =
278292
moshi.adapter<List<WorkspaceResource>>(
@@ -326,7 +340,8 @@ class CoderRestClientTest {
326340
val buildMatch = buildEndpoint.find(exchange.requestURI.path)
327341
if (buildMatch != null) {
328342
val workspaceId = UUID.fromString(buildMatch.destructured.toList()[0])
329-
val json = moshi.adapter(CreateWorkspaceBuildRequest::class.java).fromJson(exchange.requestBody.source().buffer())
343+
val json = moshi.adapter(CreateWorkspaceBuildRequest::class.java)
344+
.fromJson(exchange.requestBody.source().buffer())
330345
if (json == null) {
331346
val response = Response("No body", "No body for create workspace build request")
332347
val body = moshi.adapter(Response::class.java).toJson(response).toByteArray()
@@ -396,8 +411,8 @@ class CoderRestClientTest {
396411
CoderSettings(
397412
CoderSettingsState(
398413
tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString(),
399-
tlsAlternateHostname = "localhost",
400-
),
414+
tlsAlternateHostname = "localhost"
415+
)
401416
)
402417
val user = DataGen.user()
403418
val (srv, url) = mockTLSServer("self-signed")
@@ -422,8 +437,8 @@ class CoderRestClientTest {
422437
CoderSettings(
423438
CoderSettingsState(
424439
tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString(),
425-
tlsAlternateHostname = "fake.example.com",
426-
),
440+
tlsAlternateHostname = "fake.example.com"
441+
)
427442
)
428443
val (srv, url) = mockTLSServer("self-signed")
429444
val client = CoderRestClient(URL(url), "token", settings)
@@ -441,8 +456,8 @@ class CoderRestClientTest {
441456
val settings =
442457
CoderSettings(
443458
CoderSettingsState(
444-
tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString(),
445-
),
459+
tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString()
460+
)
446461
)
447462
val (srv, url) = mockTLSServer("no-signing")
448463
val client = CoderRestClient(URL(url), "token", settings)
@@ -461,7 +476,7 @@ class CoderRestClientTest {
461476
CoderSettings(
462477
CoderSettingsState(
463478
tlsCAPath = Path.of("src/test/fixtures/tls", "chain-root.crt").toString(),
464-
),
479+
)
465480
)
466481
val user = DataGen.user()
467482
val (srv, url) = mockTLSServer("chain")
@@ -505,7 +520,8 @@ class CoderRestClientTest {
505520
"bar",
506521
true,
507522
object : ProxySelector() {
508-
override fun select(uri: URI): List<Proxy> = listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", srv2.address.port)))
523+
override fun select(uri: URI): List<Proxy> =
524+
listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", srv2.address.port)))
509525

510526
override fun connectFailed(
511527
uri: URI,

0 commit comments

Comments
 (0)