-
Notifications
You must be signed in to change notification settings - Fork 1
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
base: main
Are you sure you want to change the base?
Conversation
A new UI setting was introduced to allow users to run unsigned binaries without any input from the user. Defaults to false which means if a binary is unsigned we will ask the user what to do next.
I moved and modified the logic from CliManager.download to a separate http client based on okhttp and retrofit. The refactor will allow us to easily add new steps in the main download method, and also to easily download new resources. Long term we could also re-use the okhttp client to avoid setting twice the same boilerplate (proxy which is missing from CLIManager, hostname verification and other tls settings) between cli downloader and the rest client
From the same source where the cli binary was downloaded. Some of the previous classes like download result were updated to incorporate details like where the file was saved or whether a file was found on the remote
`allowUnsignedBinaryWithoutPrompt` was caching the initial value read from the store, which required a restart of Toolbox for the real value to reflect.
A pop-up dialog is displayed asking the user if he wants to run an unsigned cli version. The pop-up can be skipped if the user configures the `Allow unsigned binary execution without prompt`
Adds logic to verify the CLI against a detached GPG signature with the help of bouncycastle library
This is the key that validates if the gpg signature was tampered
Initially I thought about embedding it as a const string in the code but the string is too big, best to save it as resource file. The code changes are mostly related to loading the key from the file.
Signature verification some operations that are cpu bound that are light, like decoding and decompressing signature data, some medium CPU intensive operations like the cryptographic verifications and a couple of blocking IO operations: - reading the cli file - reading the signature file - reading the public key file These last should run on the IO thread to not block the main thread from drawing the screen. The cpu bound operation should run on the default thread.
previous implementation was selecting only the first key ring from the public key file which turns out to be wrong. Instead, we should keep all the key rings and search signature key id in all the key rings.
Otherwise, at the next Toolbox restart the signature will no longer be verified, and we run into the risk of running unsigned binaries.
`Files.readAllBytes()` uses direct buffers internally which can be filled up quickly when called repeatedly in coroutines, as these buffers are not released quickly by the GC. The scenario can be reproduced by trying to login a couple of times one after the other with signature verification failing each time. Instead, we can avoid memory issues by streaming the cli and feed only blocks of bytes into the signature calculation.
When there is no signature and the user allowed running of unsigned binaries without prompt
A major feature was added
src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have not ran it yet but looking good!
if (signatureResult.isNotDownloaded()) { | ||
context.logger.info("Trying to download signature file from releases.coder.com") | ||
signatureResult = withContext(Dispatchers.IO) { | ||
downloader.downloadReleasesSignature(showTextProgress) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For air-gapped folks, I wonder if even attempting to reach out externally by default could be seen as a security risk. I think possibly this should be gated behind an option?
And maybe it should be defaulted to false, or maybe it is sufficient to communicate ahead of time about the option if we default it to true.
Or it could be a prompt, maybe. "Got 404 trying to download signature from $coder_url. Try downloading signature from releases.coder.com?" Something like that.
Although, will we always bundle the signature files with coderd? If so, this block will never execute right? (Except for an error downloading the signature from coderd, although maybe we should abort when there are errors.) Edit: I suppose it would be ran if coderd is an older version without the signatures.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
will they also provide a public pgp key? will they continue to use detached gpg signatures? The code is now targeted for gpg signature verification.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Honestly not sure, maybe @jdomeracki-coder has an idea. If I understand correctly, the releases.coder.com
fallback is only necessary for verifying older versions, and I am not sure if air-gapped teams will want to bother hosting their own service just to provide signatures for older binaries.
If that is true, then maybe all we need is an option that toggles whether to reach out externally for signatures, so it can be skipped in the air-gapped case.
Mostly though I am just worried that security teams will see the Coder extension reaching out to our servers and get spooked. But idk if this is a realistic worry, I am not a security expert 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We've had a lengthy discussion on this subject (shared the details on Slack)
Long story short: due to air-gapped customer requirements this option should be disabled by default.
} | ||
} | ||
|
||
private fun getCoderPublicKeyRings(): List<PGPPublicKeyRing> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some folks have forked the CLI, should we provide a way they can add their own public keys to sign their own binaries or would we recommend they set the allowUnsignedBinaryWithoutPrompt
setting for that case? (Also assumes there is a way to remove or replace the signature files in coderd, and it also has implications for the fallback to releases.coder.com
).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could make it configurable but those customers would have to sign the forked CLI binaries on their end as well.
Based on a discussion with @deansheather, it could look like this:
- Configurable optional setting eg.
Binary signature public key path
- If above param would be specified, then the fallback to releases.coder.com wouldn't ever make sense (since the custom public key wouldn't fit the signatures)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is definitely going to be a problem with customers using forked CLIs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the time being we could provide an option to execute the binary even if the signature verification were to fail.
This is obviously not ideal but would prevent blocking those customers from using the plugin.
Long-term we'll most likely implement the custom signature mechanism described above.
} else { | ||
val acceptsUnsignedBinary = context.ui.showYesNoPopup( | ||
context.i18n.ptrl("Security Warning"), | ||
context.i18n.pnotr("Can't verify the integrity of the Coder CLI pulled from ${cliResult.source}"), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thinking out loud: could we somehow make use of verifyDownloadLink
here?
My thinking is, it uses an allow list, and it could be nice to allow the same list as we do for downloading the editors. This could even be used in place of allowUnsignedBinaryWithoutPrompt
. verifyDownloadLink
also resolves redirects so it shows the final URL (although maybe it should show both the original and final URLs).
Pressing "accept" could also add the URL to the allowlist for subsequent attempts (maybe it should be a third option like "Accept permanently" or something).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm... a couple of thoughts here:
- I removed
verifyDownloadLink
at some point as it was not used (and I just checked in the original branch it is indeed not used), so I might have to get back to you understand what was this supposed to do... apparently it had something to do with URI handling - I've added hostname verification logic in the http client, see
CoderCLIManager#createDownloadService
so partially your comment should be addressed. - At some point I proposed a more secure approach - to implement certificate pinning but I @jdomeracki-coder had some arguments against it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In Gateway it was used to validate the IDE download link since one could specify it as part of the URI (?ide_download_link=https://example.com/iu.tar.gz
for example).
But if it was removed because it is not possible to specify an IDE download link anymore, then no worries!
conn.sslSocketFactory = coderSocketFactory(settings.tls) | ||
conn.hostnameVerifier = CoderHostnameVerifier(settings.tls.altHostname) | ||
|
||
if (signatureResult.isNotDownloaded()) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I could see reaching out to releases.coder.com
if the signature was a 404, but should we be doing this if we got an error downloading the signature? Like a 502 or whatever? My thinking is that should be a hard failure (it would mean a problem with the deployment). Or a prompt asking whether to continue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@deansheather provided feedback that we should avoid reaching releases.coder.com and ask the user with a pop-up if they want to run an unverified binary or reach out to release.coder.com to attempt to get a signature.
This will enable us to obtain explicit consent from the user to access an external site when they are on a restricted network.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's a potential flowchart, @jdomeracki-coder to review.
Also on mermaid.live
flowchart TD
DownloadBin[Download binary from server]
DownloadServerSig[Download signature from server]
VerifySig[Verify retrieved signature]
PromptUserCoderCom[Prompt user 'Could not retrieve binary signature from Coder server, it may be on an older version. Would you like to retrieve signature from coder.com, run the binary as-is, or exit?']
DownloadCoderComSig[Download signature from releases.coder.com]
PromptUserRunAnyway[Prompt user 'Could not fetch any signatures, would you like to run the binary as-is or exit?']
PromptUserInvalidSig[Prompt user 'Could not verify the authenticity of binary, it may have been tampered with. Would you like to run it anyway?']
RunBinary[Run the binary]
Exit
DownloadBin --> DownloadServerSig
DownloadServerSig -->|200| VerifySig
DownloadServerSig -->|404 or error| PromptUserCoderCom
PromptUserCoderCom -->|User selects 'Retrieve from coder.com'| DownloadCoderComSig
PromptUserCoderCom -->|User selects 'Use as-is'| RunBinary
PromptUserCoderCom -->|User selects 'Exit'| Exit
DownloadCoderComSig -->|200| VerifySig
DownloadCoderComSig -->|404 or error| PromptUserRunAnyway
PromptUserRunAnyway -->|User selects 'Run anyway'| RunBinary
PromptUserRunAnyway -->|User selects 'Exit'| Exit
VerifySig -->|Invalid| PromptUserInvalidSig
PromptUserInvalidSig -->|User selects 'Run anyway'| RunBinary
PromptUserInvalidSig -->|User selects 'Exit'| Exit
VerifySig -->|Valid| RunBinary
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For most users, we will follow the green path with no prompt to the user.
sotce: here
---
config:
layout: dagre
---
flowchart TD
DownloadBin["Download binary from server"] --> DownloadServerSig["Download signature from server"]
DownloadServerSig -- 200 --> VerifySig["Verify retrieved signature"]
DownloadServerSig -- 404 or error --> PromptUserCoderCom@{ label: "Prompt user 'Could not retrieve binary signature from Coder server, it may be on an older version. Would you like to retrieve signature from coder.com, run the binary as-is, or exit?'" }
PromptUserCoderCom -- "User selects 'Retrieve from coder.com'" --> DownloadCoderComSig["Download signature from releases.coder.com"]
PromptUserCoderCom -- "User selects 'Use as-is'" --> RunBinary["Run the binary"]
PromptUserCoderCom -- User selects 'Exit' --> Exit["Exit"]
DownloadCoderComSig -- 200 --> VerifySig
DownloadCoderComSig -- 404 or error --> PromptUserRunAnyway@{ label: "Prompt user 'Could not fetch any signatures, would you like to run the binary as-is or exit?'" }
PromptUserRunAnyway -- User selects 'Run anyway' --> RunBinary
PromptUserRunAnyway -- User selects 'Exit' --> Exit
VerifySig -- Invalid --> PromptUserInvalidSig@{ label: "Prompt user 'Could not verify the authenticity of binary, it may have been tampered with. Would you like to run it anyway?'" }
PromptUserInvalidSig -- User selects 'Run anyway' --> RunBinary
PromptUserInvalidSig -- User selects 'Exit' --> Exit
VerifySig -- Valid --> RunBinary
PromptUserCoderCom@{ shape: rect}
PromptUserRunAnyway@{ shape: rect}
PromptUserInvalidSig@{ shape: rect}
style DownloadBin fill:#00C853
style DownloadServerSig fill:#00C853
style VerifySig fill:#00C853
style RunBinary fill:#00C853
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This flow is getting complicated, need some time to think it through
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Taking into consideration all constraints & requirements, I'm ok with the proposed flow.
Most of those prompts will only show up for a limited amount of time until customers upgrade their coder deployments (and could be bypassed using the optional settings)
src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt
Outdated
Show resolved
Hide resolved
And also disable the work in progress animations after a fatal error occurred. Most exceptions, especially the ones related to cli signature verification were suppressed and only displayed in the logs with no visual feedback.
The logic for matching the local CLI version with the deployment previously attempted to run the CLI with --version without first verifying that the binary existed. This commit improves that by first checking if the file exists, avoiding the unnecessary overhead of spawning a process for a non-existent binary.
…er-cli Retroactive cli signatures are now published at releases.coder.com/coder-cli/x.y.z/ where x.y.z is the major, minor and patch version of the deployment.
…blished We now have a lot of signatures published for a lot of older cli version which means some of the tests that were not expecting the fallback to activate are now in trouble. The simple fix is to "download" a very old version for which signatures will not be generated.
Instead of deriving the signature name from the cli name it is now coded into settings store just like the default cli name
It is already supported by java.net.URI
The download and signature verification steps are now slightly altered to first download the cli to a temporary location and only after the signature verification is successful or the user accepted the risk of running an unsigned binary - then and only then the temp cli is moved to its final location (actually it is just a rename). If the delete fails, this prevents the unsigned binary from being picked up as the cached binary on the next run.
Ask the user if he wants to accept the risk of running a potentially tampered CLI when signature verification failed (i.e. we downloaded the signatures but either it doesn't match or there were some error while computing the signature.
This PR introduces support for verifying the CLI binary using a detached PGP signature. Starting with version 2.24, Coder signs all CLI binaries. For clients using older versions or running TBX in air-gapped environments, unsigned CLIs can still be executed—either without a prompt or with an optional confirmation prompt.
In terms of code changes - the PR includes a big refactor around CLI downloading with most of the code refactored and extracted in various components that provide clean steps and result state in the main download method. Then the pgp verification logic was added on top, with some particularities: