Skip to content

impl: verify cli signature #148

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4227ebd
impl: new UI setting for running unsigned binary execution
fioan89 Jul 8, 2025
021e53a
chore: refactor CLI downloading logic
fioan89 Jul 9, 2025
fb6e784
impl: support for downloading the cli signature
fioan89 Jul 9, 2025
dbf8560
impl: support for downloading the releases.coder.com signature
fioan89 Jul 10, 2025
6754300
fix: read fresh values from the config store
fioan89 Jul 10, 2025
8d768ee
impl: prompt user when running unsigned binaries
fioan89 Jul 10, 2025
ea3e379
fix: used proper result to verify if signature is downloaded
fioan89 Jul 10, 2025
3668d46
chore: compact code and run signature download on the IO thread
fioan89 Jul 10, 2025
a476364
chore: add support for bouncycastle
fioan89 Jul 10, 2025
45a72fb
chore: update i18n bundle with new strings related to signature verif…
fioan89 Jul 10, 2025
ad44346
impl: verify gpg signed cli binaries
fioan89 Jul 10, 2025
4cd5148
impl: embed the pgp public key as a plugin resource
fioan89 Jul 11, 2025
fbe68de
impl: load the public key from a resource file
fioan89 Jul 11, 2025
270b949
impl: run the signature verification on the IO thread
fioan89 Jul 11, 2025
d5ae289
fix: find the key id in multiple key rings
fioan89 Jul 11, 2025
96663e6
fix: remove the cli if it is not properly signed
fioan89 Jul 11, 2025
6a79995
fix: avoid out of memory when verifying signatures
fioan89 Jul 11, 2025
5fcb4b9
fix: don't run signature verification
fioan89 Jul 11, 2025
3543377
chore: fix UTs
fioan89 Jul 11, 2025
0a5de76
Merge branch 'main' into impl-verify-cli-signature
fioan89 Jul 11, 2025
97dbc8d
chore: next version is 0.5.0
fioan89 Jul 11, 2025
27066d8
fix: more UTs
fioan89 Jul 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

- support for matching workspace agent in the URI via the agent name
- support for checking if CLI is signed

### Removed

Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ dependencies {
ksp(libs.moshi.codegen)
implementation(libs.retrofit)
implementation(libs.retrofit.moshi)
implementation(libs.bundles.bouncycastle)
testImplementation(kotlin("test"))
testImplementation(libs.mokk)
testImplementation(libs.bundles.toolbox.plugin.api)
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
version=0.4.0
version=0.5.0
group=com.coder.toolbox
name=coder-toolbox
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ gettext = "0.7.0"
plugin-structure = "3.310"
mockk = "1.14.4"
detekt = "1.23.7"
bouncycastle = "1.81"

[libraries]
toolbox-core-api = { module = "com.jetbrains.toolbox:core-api", version.ref = "toolbox-plugin-api" }
Expand All @@ -34,10 +35,13 @@ retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.re
plugin-structure = { module = "org.jetbrains.intellij.plugins:structure-toolbox", version.ref = "plugin-structure" }
mokk = { module = "io.mockk:mockk", version.ref = "mockk" }
marketplace-client = { module = "org.jetbrains.intellij:plugin-repository-rest-client", version.ref = "marketplace-client" }
bouncycastle-bcpg = { module = "org.bouncycastle:bcpg-jdk18on", version.ref = "bouncycastle" }
bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" }

[bundles]
serialization = ["serialization-core", "serialization-json", "serialization-json-okio"]
toolbox-plugin-api = ["toolbox-core-api", "toolbox-ui-api", "toolbox-remote-dev-api"]
bouncycastle = ["bouncycastle-bcpg", "bouncycastle-bcprov"]

[plugins]
kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
Expand Down
273 changes: 141 additions & 132 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.coder.toolbox.cli.downloader

import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.HeaderMap
import retrofit2.http.Streaming
import retrofit2.http.Url

/**
* Retrofit API for downloading CLI
*/
interface CoderDownloadApi {
@GET
@Streaming
suspend fun downloadCli(
@Url url: String,
@Header("If-None-Match") eTag: String? = null,
@HeaderMap headers: Map<String, String> = emptyMap(),
@Header("Accept-Encoding") acceptEncoding: String = "gzip",
): Response<ResponseBody>

@GET
suspend fun downloadSignature(
@Url url: String,
@HeaderMap headers: Map<String, String> = emptyMap()
): Response<ResponseBody>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package com.coder.toolbox.cli.downloader

import com.coder.toolbox.CoderToolboxContext
import com.coder.toolbox.cli.ex.ResponseException
import com.coder.toolbox.util.OS
import com.coder.toolbox.util.getHeaders
import com.coder.toolbox.util.getOS
import com.coder.toolbox.util.sha1
import com.coder.toolbox.util.withLastSegment
import okhttp3.ResponseBody
import retrofit2.Response
import java.io.FileInputStream
import java.net.HttpURLConnection.HTTP_NOT_FOUND
import java.net.HttpURLConnection.HTTP_NOT_MODIFIED
import java.net.HttpURLConnection.HTTP_OK
import java.net.URI
import java.net.URL
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.util.zip.GZIPInputStream
import kotlin.io.path.name
import kotlin.io.path.notExists

/**
* Handles the download steps of Coder CLI
*/
class CoderDownloadService(
private val context: CoderToolboxContext,
private val downloadApi: CoderDownloadApi,
private val deploymentUrl: URL,
forceDownloadToData: Boolean,
) {
val remoteBinaryURL: URL = context.settingsStore.binSource(deploymentUrl)
val localBinaryPath: Path = context.settingsStore.binPath(deploymentUrl, forceDownloadToData)

suspend fun downloadCli(buildVersion: String, showTextProgress: (String) -> Unit): DownloadResult {
val eTag = calculateLocalETag()
if (eTag != null) {
context.logger.info("Found existing binary at $localBinaryPath; calculated hash as $eTag")
}
val response = downloadApi.downloadCli(
url = remoteBinaryURL.toString(),
eTag = eTag?.let { "\"$it\"" },
headers = getRequestHeaders()
)

return when (response.code()) {
HTTP_OK -> {
context.logger.info("Downloading binary to $localBinaryPath")
response.saveToDisk(localBinaryPath, showTextProgress, buildVersion)?.makeExecutable()
DownloadResult.Downloaded(remoteBinaryURL, localBinaryPath)
}

HTTP_NOT_MODIFIED -> {
context.logger.info("Using cached binary at $localBinaryPath")
showTextProgress("Using cached binary")
DownloadResult.Skipped
}

else -> {
throw ResponseException(
"Unexpected response from $remoteBinaryURL",
response.code()
)
}
}
}

private fun calculateLocalETag(): String? {
return try {
if (localBinaryPath.notExists()) {
return null
}
sha1(FileInputStream(localBinaryPath.toFile()))
} catch (e: Exception) {
context.logger.warn(e, "Unable to calculate hash for $localBinaryPath")
null
}
}

private fun getRequestHeaders(): Map<String, String> {
return if (context.settingsStore.headerCommand.isNullOrBlank()) {
emptyMap()
} else {
getHeaders(deploymentUrl, context.settingsStore.headerCommand)
}
}

private fun Response<ResponseBody>.saveToDisk(
localPath: Path,
showTextProgress: (String) -> Unit,
buildVersion: String? = null
): Path? {
val responseBody = this.body() ?: return null
Files.deleteIfExists(localPath)
Files.createDirectories(localPath.parent)

val outputStream = Files.newOutputStream(
localPath,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING
)
val contentEncoding = this.headers()["Content-Encoding"]
val sourceStream = if (contentEncoding?.contains("gzip", ignoreCase = true) == true) {
GZIPInputStream(responseBody.byteStream())
} else {
responseBody.byteStream()
}

val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytesRead: Int
var totalRead = 0L
// caching this because the settings store recomputes it every time
val binaryName = localPath.name
sourceStream.use { source ->
outputStream.use { sink ->
while (source.read(buffer).also { bytesRead = it } != -1) {
sink.write(buffer, 0, bytesRead)
totalRead += bytesRead
showTextProgress(
"$binaryName $buildVersion - ${totalRead.toHumanReadableSize()} downloaded"
)
}
}
}
return localBinaryPath
}


private fun Path.makeExecutable() {
if (getOS() != OS.WINDOWS) {
context.logger.info("Making $this executable...")
this.toFile().setExecutable(true)
}
}

private fun Long.toHumanReadableSize(): String {
if (this < 1024) return "$this B"

val kb = this / 1024.0
if (kb < 1024) return String.format("%.1f KB", kb)

val mb = kb / 1024.0
if (mb < 1024) return String.format("%.1f MB", mb)

val gb = mb / 1024.0
return String.format("%.1f GB", gb)
}

suspend fun downloadSignature(showTextProgress: (String) -> Unit): DownloadResult {
return downloadSignature(remoteBinaryURL, showTextProgress)
}

private suspend fun downloadSignature(url: URL, showTextProgress: (String) -> Unit): DownloadResult {
val defaultCliNameWithoutExt = context.settingsStore.defaultCliBinaryNameByOsAndArch.split('.').first()
val signatureName = "$defaultCliNameWithoutExt.asc"

val signatureURL = url.withLastSegment(signatureName)
val localSignaturePath = localBinaryPath.parent.resolve(signatureName)
context.logger.info("Downloading signature from $signatureURL")

val response = downloadApi.downloadSignature(
url = signatureURL.toString(),
headers = getRequestHeaders()
)

return when (response.code()) {
HTTP_OK -> {
response.saveToDisk(localSignaturePath, showTextProgress)
DownloadResult.Downloaded(signatureURL, localSignaturePath)
}

HTTP_NOT_FOUND -> {
context.logger.warn("Signature file not found at $signatureURL")
DownloadResult.NotFound
}

else -> {
DownloadResult.Failed(
ResponseException(
"Failed to download signature from $signatureURL",
response.code()
)
)
}
}

}

suspend fun downloadReleasesSignature(showTextProgress: (String) -> Unit): DownloadResult {
return downloadSignature(URI.create("https://releases.coder.com/bin").toURL(), showTextProgress)
}
}
23 changes: 23 additions & 0 deletions src/main/kotlin/com/coder/toolbox/cli/downloader/DownloadResult.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.coder.toolbox.cli.downloader

import java.net.URL
import java.nio.file.Path


/**
* Result of a download operation
*/
sealed class DownloadResult {
object Skipped : DownloadResult()
object NotFound : DownloadResult()
data class Downloaded(val source: URL, val dst: Path) : DownloadResult()
data class Failed(val error: Exception) : DownloadResult()

fun isSkipped(): Boolean = this is Skipped

fun isNotFoundOrFailed(): Boolean = this is NotFound || this is Failed

fun isDownloaded(): Boolean = this is Downloaded

fun isNotDownloaded(): Boolean = this !is Downloaded
}
2 changes: 2 additions & 0 deletions src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ class ResponseException(message: String, val code: Int) : Exception(message)
class SSHConfigFormatException(message: String) : Exception(message)

class MissingVersionException(message: String) : Exception(message)

class UnsignedBinaryExecutionDeniedException(message: String?) : Exception(message)
Loading
Loading