Skip to content

Commit ad44346

Browse files
committed
impl: verify gpg signed cli binaries
Adds logic to verify the CLI against a detached GPG signature with the help of bouncycastle library
1 parent 45a72fb commit ad44346

File tree

4 files changed

+163
-7
lines changed

4 files changed

+163
-7
lines changed

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

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import com.coder.toolbox.cli.downloader.DownloadResult.Downloaded
77
import com.coder.toolbox.cli.ex.MissingVersionException
88
import com.coder.toolbox.cli.ex.SSHConfigFormatException
99
import com.coder.toolbox.cli.ex.UnsignedBinaryExecutionDeniedException
10+
import com.coder.toolbox.cli.gpg.GPGVerifier
11+
import com.coder.toolbox.cli.gpg.VerificationResult.Failed
12+
import com.coder.toolbox.cli.gpg.VerificationResult.Invalid
1013
import com.coder.toolbox.sdk.v2.models.Workspace
1114
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
1215
import com.coder.toolbox.util.CoderHostnameVerifier
@@ -132,6 +135,7 @@ class CoderCLIManager(
132135
private val forceDownloadToData: Boolean = false,
133136
) {
134137
private val downloader = createDownloadService()
138+
private val gpgVerifier = GPGVerifier(context)
135139

136140
val remoteBinaryURL: URL = context.settingsStore.binSource(deploymentURL)
137141
val localBinaryPath: Path = context.settingsStore.binPath(deploymentURL, forceDownloadToData)
@@ -169,20 +173,20 @@ class CoderCLIManager(
169173
}
170174
}
171175

172-
var signatureDownloadResult = withContext(Dispatchers.IO) {
176+
var signatureResult = withContext(Dispatchers.IO) {
173177
downloader.downloadSignature(showTextProgress)
174178
}
175179

176-
if (signatureDownloadResult.isNotDownloaded()) {
180+
if (signatureResult.isNotDownloaded()) {
177181
context.logger.info("Trying to download signature file from releases.coder.com")
178-
signatureDownloadResult = withContext(Dispatchers.IO) {
182+
signatureResult = withContext(Dispatchers.IO) {
179183
downloader.downloadReleasesSignature(showTextProgress)
180184
}
181185
}
182186

183187
// if we could not find any signature and the user wants to explicitly
184188
// confirm whether we run an unsigned cli
185-
if (signatureDownloadResult.isNotDownloaded()) {
189+
if (signatureResult.isNotDownloaded()) {
186190
if (context.settingsStore.allowUnsignedBinaryWithoutPrompt) {
187191
context.logger.warn("Running unsigned CLI from ${cliResult.source}")
188192
} else {
@@ -196,15 +200,30 @@ class CoderCLIManager(
196200
if (acceptsUnsignedBinary) {
197201
return true
198202
} else {
199-
// remove the cli, otherwise next time the user tries to login the cached cli is picked up
203+
// remove the cli, otherwise next time the user tries to login the cached cli is picked up,
200204
// and we don't verify cached cli signatures
201205
Files.delete(cliResult.dst)
202206
throw UnsignedBinaryExecutionDeniedException("Running unsigned CLI from ${cliResult.source} was denied by the user")
203207
}
204208
}
205209
}
206210

207-
return cliResult.isDownloaded()
211+
// we have the cli, and signature is downloaded, let's verify the signature
212+
signatureResult = signatureResult as Downloaded
213+
gpgVerifier.verifySignature(cliResult.dst, signatureResult.dst).let { result ->
214+
when {
215+
result.isValid() -> return true
216+
result.isInvalid() -> {
217+
val reason = (result as Invalid).reason
218+
throw UnsignedBinaryExecutionDeniedException(
219+
"Signature of ${cliResult.dst} is invalid." + reason?.let { " Reason: $it" }.orEmpty()
220+
)
221+
}
222+
223+
result.signatureIsNotFound() -> throw UnsignedBinaryExecutionDeniedException("Can't verify signature of ${cliResult.dst} because ${signatureResult.dst} does not exist")
224+
else -> throw UnsignedBinaryExecutionDeniedException((result as Failed).error.message)
225+
}
226+
}
208227
}
209228

210229
/**

src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ class SSHConfigFormatException(message: String) : Exception(message)
66

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

9-
class UnsignedBinaryExecutionDeniedException(message: String) : Exception(message)
9+
class UnsignedBinaryExecutionDeniedException(message: String?) : Exception(message)
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package com.coder.toolbox.cli.gpg
2+
3+
import com.coder.toolbox.CoderToolboxContext
4+
import com.coder.toolbox.cli.gpg.VerificationResult.Failed
5+
import com.coder.toolbox.cli.gpg.VerificationResult.Invalid
6+
import com.coder.toolbox.cli.gpg.VerificationResult.SignatureNotFound
7+
import com.coder.toolbox.cli.gpg.VerificationResult.Valid
8+
import org.bouncycastle.bcpg.ArmoredInputStream
9+
import org.bouncycastle.openpgp.PGPException
10+
import org.bouncycastle.openpgp.PGPPublicKeyRing
11+
import org.bouncycastle.openpgp.PGPPublicKeyRingCollection
12+
import org.bouncycastle.openpgp.PGPSignatureList
13+
import org.bouncycastle.openpgp.PGPUtil
14+
import org.bouncycastle.openpgp.jcajce.JcaPGPObjectFactory
15+
import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator
16+
import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentVerifierBuilderProvider
17+
import java.io.ByteArrayInputStream
18+
import java.nio.file.Files
19+
import java.nio.file.Path
20+
21+
class GPGVerifier(
22+
private val context: CoderToolboxContext,
23+
) {
24+
25+
fun verifySignature(
26+
cli: Path,
27+
signature: Path,
28+
): VerificationResult {
29+
return try {
30+
if (!Files.exists(signature)) {
31+
context.logger.warn("Signature file not found, skipping verification")
32+
return SignatureNotFound
33+
}
34+
35+
val signatureBytes = Files.readAllBytes(signature)
36+
val cliBytes = Files.readAllBytes(cli)
37+
38+
val publicKeyRing = getCoderPublicKeyRing()
39+
return verifyDetachedSignature(
40+
cliBytes = cliBytes,
41+
signatureBytes = signatureBytes,
42+
publicKeyRing = publicKeyRing
43+
)
44+
} catch (e: Exception) {
45+
context.logger.error(e, "GPG signature verification failed")
46+
Failed(e)
47+
}
48+
}
49+
50+
private fun getCoderPublicKeyRing(): PGPPublicKeyRing {
51+
return try {
52+
getDefaultCoderPublicKeyRing()
53+
} catch (e: Exception) {
54+
throw PGPException("Failed to load Coder public GPG key", e)
55+
}
56+
}
57+
58+
private fun getDefaultCoderPublicKeyRing(): PGPPublicKeyRing {
59+
val coderPublicKey = """
60+
-----BEGIN PGP PUBLIC KEY BLOCK-----
61+
62+
# Replace this with Coder's actual public key
63+
64+
-----END PGP PUBLIC KEY BLOCK-----
65+
""".trimIndent()
66+
67+
return loadPublicKeyRing(coderPublicKey.toByteArray())
68+
}
69+
70+
/**
71+
* Verify a detached GPG signature
72+
*/
73+
fun verifyDetachedSignature(
74+
cliBytes: ByteArray,
75+
signatureBytes: ByteArray,
76+
publicKeyRing: PGPPublicKeyRing
77+
): VerificationResult {
78+
try {
79+
val signatureInputStream = ArmoredInputStream(ByteArrayInputStream(signatureBytes))
80+
val pgpObjectFactory = JcaPGPObjectFactory(signatureInputStream)
81+
val signatureList = pgpObjectFactory.nextObject() as? PGPSignatureList
82+
?: throw PGPException("Invalid signature format")
83+
84+
if (signatureList.isEmpty) {
85+
return Invalid("No signatures found in signature file")
86+
}
87+
88+
val signature = signatureList[0]
89+
val publicKey = publicKeyRing.getPublicKey(signature.keyID)
90+
?: throw PGPException("Public key not found for signature")
91+
92+
signature.init(JcaPGPContentVerifierBuilderProvider(), publicKey)
93+
signature.update(cliBytes)
94+
95+
val isValid = signature.verify()
96+
context.logger.info("GPG signature verification result: $isValid")
97+
if (isValid) {
98+
return Valid
99+
}
100+
return Invalid()
101+
} catch (e: Exception) {
102+
context.logger.error(e, "GPG signature verification failed")
103+
return Failed(e)
104+
}
105+
}
106+
107+
/**
108+
* Load public key ring from bytes
109+
*/
110+
fun loadPublicKeyRing(publicKeyBytes: ByteArray): PGPPublicKeyRing {
111+
return try {
112+
val keyInputStream = ArmoredInputStream(ByteArrayInputStream(publicKeyBytes))
113+
val keyRingCollection = PGPPublicKeyRingCollection(
114+
PGPUtil.getDecoderStream(keyInputStream),
115+
JcaKeyFingerprintCalculator()
116+
)
117+
keyRingCollection.keyRings.next()
118+
} catch (e: Exception) {
119+
throw PGPException("Failed to load public key ring", e)
120+
}
121+
}
122+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.coder.toolbox.cli.gpg
2+
3+
/**
4+
* Result of signature verification
5+
*/
6+
sealed class VerificationResult {
7+
object Valid : VerificationResult()
8+
data class Invalid(val reason: String? = null) : VerificationResult()
9+
data class Failed(val error: Exception) : VerificationResult()
10+
object SignatureNotFound : VerificationResult()
11+
12+
fun isValid(): Boolean = this == Valid
13+
fun isInvalid(): Boolean = this is Invalid
14+
fun signatureIsNotFound(): Boolean = this == SignatureNotFound
15+
}

0 commit comments

Comments
 (0)