[rdct] vcs: implement pinentry application for requesting GPG key secret (IJPL-149731)

GitOrigin-RevId: a06ba8c87946fb3a9b2818996e81e81a4ed90408
This commit is contained in:
Dmitry Zhuravlev
2024-07-05 11:35:40 +02:00
committed by intellij-monorepo-bot
parent 40ee9543ab
commit f7d1a73f2f
12 changed files with 970 additions and 6 deletions

View File

@@ -642,6 +642,9 @@
description="Max amount of entry to process from Git reflog to parse recently checkout branches"/>
<registryKey key="git.show.recent.checkout.branches" defaultValue="5"
description="Max count of recently checkout branches to show (e.g. in branches tree popup)"/>
<registryKey key="git.commit.gpg.signing.enable.embedded.pinentry" defaultValue="false"
restartRequired="true"
description="Enable embedded pinentry application for unlock GPG private key while Git perform commit signing. For remote dev (unix backend) and WSL."/>
<search.projectOptionsTopHitProvider implementation="git4idea.config.GitOptionsTopHitProvider"/>
<vcs name="Git" vcsClass="git4idea.GitVcs" displayName="Git" administrativeAreaName=".git"/>
@@ -821,6 +824,7 @@
isInCommitToolWindow="true"/>
<projectService serviceImplementation="git4idea.stash.GitStashTracker"/>
<postStartupActivity implementation="git4idea.stash.ui.GitStashStartupActivity"/>
<postStartupActivity implementation="git4idea.commit.signing.GpgAgentConfiguratorStartupActivity"/>
<vcs.consoleFolding implementation="git4idea.console.GitConsoleFolding"/>
<console.folding implementation="git4idea.console.GitProgressOutputConsoleFolding"/>

View File

@@ -761,6 +761,9 @@ settings.configure.sign.gpg.loading.table.text=Loading\u2026
gpg.error.see.documentation.link.text=See GPG setup guide
gpg.jb.manual.link=Set_up_GPG_commit_signing
gpg.pinentry.title=Unlock GPG Private Key
gpg.pinentry.default.description=Please enter the passphrase to unlock the GPG private key:
clone.dialog.checking.git.version=Checking Git version\u2026
push.dialog.push.tags=Push &tags
push.dialog.push.tags.combo.current.branch=Current Branch

View File

@@ -0,0 +1,51 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.gpg;
import javax.crypto.Cipher;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
public final class CryptoUtils {
public static KeyPair generateKeyPair() throws NoSuchAlgorithmException {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
return keyPairGenerator.generateKeyPair();
}
public static String publicKeyToString(PublicKey key) {
byte[] keyBytes = key.getEncoded();
if (keyBytes == null) return null;
return Base64.getEncoder().encodeToString(keyBytes);
}
public static PublicKey stringToPublicKey(String keyStr) {
byte[] keyBytes = Base64.getDecoder().decode(keyStr);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory;
try {
keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePublic(keySpec);
}
catch (GeneralSecurityException | IllegalArgumentException e) {
return null;
}
}
public static String encrypt(String payload, PrivateKey privateKey) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
return Base64.getEncoder().encodeToString(cipher.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
}
public static String decrypt(String base64EncryptedMessage, PublicKey publicKey) throws GeneralSecurityException {
byte[] encryptedMessage = Base64.getDecoder().decode(base64EncryptedMessage);
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.DECRYPT_MODE, publicKey);
byte[] decryptedBytes = cipher.doFinal(encryptedMessage);
return new String(decryptedBytes, StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,158 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.gpg;
import externalApp.ExternalApp;
import java.io.*;
import java.net.Socket;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.PublicKey;
public final class PinentryApp implements ExternalApp {
public static void main(String[] args) throws IOException, URISyntaxException {
boolean shouldLog = isLogEnabled(args);
File logFile = getCurrentDir().resolve("pinentry-app.log").toFile();
File exceptionsLogFile = getCurrentDir().resolve("pinentry-app-exceptions.log").toFile();
try (FileWriter exceptionsWriter = shouldLog ? new FileWriter(exceptionsLogFile, StandardCharsets.UTF_8) : null) {
//noinspection UseOfSystemOutOrSystemErr
try (FileWriter logWriter = shouldLog ? new FileWriter(logFile, StandardCharsets.UTF_8) : null;
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out, StandardCharsets.UTF_8))) {
writer.write("OK Pleased to meet you\n");
writer.flush();
String keyDescription = null;
while (true) {
String line = reader.readLine();
if (shouldLog) {
logWriter.write(line + "\n");
}
if (line.startsWith("SETDESC")) {
keyDescription = line;
writer.write("OK\n");
}
else if (line.startsWith("OPTION")
|| line.startsWith("GETINFO")
|| line.startsWith("SET")) {
writer.write("OK\n");
}
else if (line.startsWith("GETPIN")) {
try {
String pinentryUserData = System.getenv("PINENTRY_USER_DATA");
if (pinentryUserData == null) {
pinentryUserData = "";
}
String[] pinentryData = pinentryUserData.split(":");
if (pinentryData.length != 3) {
if (shouldLog) {
exceptionsWriter
.write("Cannot locate address (<public-key>:<host>:<port>) from env variable PINENTRY_USER_DATA. Got " + pinentryUserData + "\n");
}
throw new Exception();
}
PublicKey publicKey;
String host;
int port;
try {
String publicKeyStr = pinentryData[0];
publicKey = CryptoUtils.stringToPublicKey(publicKeyStr);
host = pinentryData[1];
port = Integer.parseInt(pinentryData[2]);
}
catch (Exception e) {
if (shouldLog) {
exceptionsWriter.write("Cannot parse env variable PINENTRY_USER_DATA. Got " + pinentryUserData + "\n");
exceptionsWriter.write(getStackTrace(e) + "\n");
}
throw e;
}
try (Socket clientSocket = new Socket(host, port);
BufferedWriter socketWriter =
new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream(), StandardCharsets.UTF_8));
BufferedReader socketReader =
new BufferedReader(new InputStreamReader(clientSocket.getInputStream(), StandardCharsets.UTF_8))) {
String request = keyDescription != null ? "GETPIN " + keyDescription + "\n" : "GETPIN\n";
socketWriter.write(request);
socketWriter.flush();
String response = socketReader.readLine();
if (response.startsWith("D ")) {
String passphrase = CryptoUtils.decrypt(response.replace("D ", ""), publicKey);
writer.write("D " + passphrase + "\n");
writer.write("OK\n");
}
else {
writer.write("ERR 83886179 unknown command<" + response + ">\n");
}
}
}
catch (Exception e) {
writer.write("ERR 83886179 exception\n");
}
}
else if (line.startsWith("BYE")) {
writer.write("OK closing connection\n");
writer.flush();
break;
}
else {
writer.write("ERR 83886179 unknown command <" + line + ">\n");
}
writer.flush();
if (shouldLog) {
logWriter.flush();
exceptionsWriter.flush();
}
}
}
catch (IOException e) {
if (shouldLog) {
exceptionsWriter.write("Exception occurred: \n");
exceptionsWriter.write(getStackTrace(e));
exceptionsWriter.flush();
}
}
}
}
private static boolean isLogEnabled(String[] args) {
for (String arg : args) {
if (arg.equals("--log")) {
return true;
}
}
return false;
}
private static Path getCurrentDir() throws URISyntaxException {
URI jarPath = PinentryApp.class.getProtectionDomain().getCodeSource().getLocation().toURI();
return Paths.get(jarPath).getParent();
}
private static String getStackTrace(Exception e) {
StringBuilder sb = new StringBuilder(1000);
StackTraceElement[] st = e.getStackTrace();
sb.append(e.getClass().getName()).append(": ").append(e.getMessage()).append("\n");
for (StackTraceElement element : st) {
sb.append("\t at ").append(element.toString()).append("\n");
}
return sb.toString();
}
}

View File

@@ -1,4 +1,4 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.commands;
import com.intellij.externalProcessAuthHelper.*;
@@ -17,7 +17,10 @@ import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.util.EnvironmentUtil;
import externalApp.nativessh.NativeSshAskPassAppHandler;
import git4idea.GitUtil;
import git4idea.commit.signing.GpgAgentConfigurator;
import git4idea.commit.signing.PinentryService;
import git4idea.config.*;
import git4idea.config.gpg.GitGpgConfigUtilsKt;
import git4idea.http.GitAskPassAppHandler;
import git4idea.repo.GitProjectConfigurationCache;
import git4idea.repo.GitRepository;
@@ -65,6 +68,7 @@ public final class GitHandlerAuthenticationManager implements AutoCloseable {
GitUtil.tryRunOrClose(manager, () -> {
manager.prepareHttpAuth();
manager.prepareNativeSshAuth();
manager.prepareGpgAgentAuth();
boolean useCredentialHelper = GitVcsApplicationSettings.getInstance().isUseCredentialHelper();
boolean isConfigCommand = handler.getCommand() == GitCommand.CONFIG;
@@ -177,6 +181,35 @@ public final class GitHandlerAuthenticationManager implements AutoCloseable {
}
}
private void prepareGpgAgentAuth() throws IOException {
if (!GpgAgentConfigurator.isEnabled(myHandler.myExecutable)) {
return;
}
Project project = myHandler.project();
VirtualFile root = myHandler.getExecutableContext().getRoot();
if (project == null || root == null) {
return;
}
GitCommand command = myHandler.getCommand();
boolean needGpgSigning =
(command == GitCommand.COMMIT || command == GitCommand.TAG || command == GitCommand.MERGE) &&
GitGpgConfigUtilsKt.isGpgSignEnabled(project, root);
if (needGpgSigning) {
PinentryService.PinentryData pinentryData = PinentryService.getInstance(project).startSession();
if (pinentryData != null) {
myHandler.addCustomEnvironmentVariable(PinentryService.PINENTRY_USER_DATA_ENV, pinentryData.toString());
myHandler.addListener(new GitHandlerListener() {
@Override
public void processTerminated(int exitCode) {
PinentryService.getInstance(project).stopSession();
}
});
}
}
}
private void addHandlerPathToEnvironment(@NotNull String env,
@NotNull ExternalProcessHandlerService<?> service) throws IOException {
GitExecutable executable = myHandler.getExecutable();

View File

@@ -1,10 +1,11 @@
// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.commands;
import com.intellij.externalProcessAuthHelper.ScriptGeneratorImpl;
import com.intellij.openapi.util.text.StringUtil;
import externalApp.ExternalApp;
import externalApp.nativessh.NativeSshAskPassAppHandler;
import git4idea.commit.signing.PinentryService;
import git4idea.config.GitExecutable;
import git4idea.editor.GitRebaseEditorAppHandler;
import git4idea.http.GitAskPassAppHandler;
@@ -42,7 +43,8 @@ public class GitScriptGenerator extends ScriptGeneratorImpl {
GitAskPassAppHandler.IJ_ASK_PASS_HANDLER_ENV,
GitAskPassAppHandler.IJ_ASK_PASS_PORT_ENV,
GitRebaseEditorAppHandler.IJ_EDITOR_HANDLER_ENV,
GitRebaseEditorAppHandler.IJ_EDITOR_PORT_ENV);
GitRebaseEditorAppHandler.IJ_EDITOR_PORT_ENV,
PinentryService.PINENTRY_USER_DATA_ENV);
sb.append("export WSLENV=");
sb.append(StringUtil.join(envs, it -> it + "/w", ":"));
sb.append("\n");

View File

@@ -0,0 +1,293 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.commit.signing
import com.intellij.execution.ExecutionException
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.process.CapturingProcessHandler
import com.intellij.idea.AppMode
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
import com.intellij.openapi.util.SystemInfo
import com.intellij.openapi.util.io.FileUtil
import com.intellij.openapi.util.io.NioFiles
import com.intellij.openapi.util.registry.Registry
import com.intellij.util.SystemProperties
import com.intellij.util.application
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.intellij.util.io.write
import git4idea.commands.GitScriptGenerator
import git4idea.commit.signing.GpgAgentPathsLocator.Companion.GPG_AGENT_CONF_BACKUP_FILE_NAME
import git4idea.commit.signing.GpgAgentPathsLocator.Companion.GPG_AGENT_CONF_FILE_NAME
import git4idea.commit.signing.GpgAgentPathsLocator.Companion.GPG_HOME_DIR
import git4idea.commit.signing.GpgAgentPathsLocator.Companion.PINENTRY_LAUNCHER_FILE_NAME
import git4idea.commit.signing.PinentryService.Companion.PINENTRY_USER_DATA_ENV
import git4idea.config.GitExecutable
import git4idea.config.GitExecutableListener
import git4idea.config.GitExecutableManager
import git4idea.gpg.PinentryApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.intellij.lang.annotations.Language
import org.jetbrains.annotations.VisibleForTesting
import java.io.IOException
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.copyTo
import kotlin.io.path.exists
import kotlin.io.path.readLines
private val LOG = logger<GpgAgentConfigurator>()
@Service(Service.Level.PROJECT)
internal class GpgAgentConfigurator(private val project: Project, cs: CoroutineScope): Disposable {
companion object {
const val GPG_AGENT_PINENTRY_PROGRAM_CONF_KEY = "pinentry-program"
@JvmStatic
fun isEnabled(executable: GitExecutable): Boolean {
return Registry.`is`("git.commit.gpg.signing.enable.embedded.pinentry", false) &&
((AppMode.isRemoteDevHost() && SystemInfo.isUnix)
|| executable is GitExecutable.Wsl
|| application.isUnitTestMode)
}
}
init {
val connection = application.messageBus.connect(this)
connection.subscribe(GitExecutableManager.TOPIC, GitExecutableListener { cs.launch { configure() }})
}
suspend fun configure() {
withContext(Dispatchers.IO) { doConfigure() }
}
private fun createPathLocator(): GpgAgentPathsLocator {
return MacAndUnixGpgAgentPathsLocator()
}
private fun createGpgAgentExecutor(): GpgAgentCommandExecutor {
return LocalGpgAgentCommandExecutor()
}
@VisibleForTesting
internal fun doConfigure(pathLocator: GpgAgentPathsLocator? = null) {
val executable = GitExecutableManager.getInstance().getExecutable(project)
if (!isEnabled(executable)) return
val gpgAgentPaths = pathLocator?.resolvePaths() ?: createPathLocator().resolvePaths() ?: return
val gpgAgentConf = gpgAgentPaths.gpgAgentConf
var needBackup = gpgAgentConf.exists()
if (!needBackup) {
LOG.debug("Cannot locate $gpgAgentConf, creating new")
gpgAgentConf.write("$GPG_AGENT_PINENTRY_PROGRAM_CONF_KEY ${gpgAgentPaths.gpgPinentryAppLauncherConfigPath}")
restartAgent()
needBackup = false
}
val config = readConfig(gpgAgentConf)
if (config.content.isEmpty()) {
LOG.debug("Empty $gpgAgentConf, skipping pinentry program configuration")
return
}
if (needBackup) {
val gpgAgentConfBackup = gpgAgentPaths.gpgAgentConfBackup
if (gpgAgentConfBackup.exists()) {
LOG.debug("$gpgAgentConfBackup already exist, skipping configuration backup")
}
else if (backupExistingConfig(gpgAgentPaths, config)) {
changePinentryProgram(gpgAgentPaths, config)
restartAgent()
}
}
//always regenerate the launcher to be up to date (e.g., java.home could be changed between versions)
generatePinentryLauncher(executable, gpgAgentPaths)
}
private fun readConfig(gpgAgentConf: Path): GpgAgentConfig {
val config = mutableMapOf<String, String>()
try {
gpgAgentConf.readLines().forEach { line ->
val (key, value) = line.split(' ')
config[key] = value
}
}
catch (e: IOException) {
LOG.error("Cannot read $gpgAgentConf", e)
return GpgAgentConfig(gpgAgentConf, emptyMap())
}
return GpgAgentConfig(gpgAgentConf, config)
}
private fun generatePinentryLauncher(executable: GitExecutable, gpgAgentPaths: GpgAgentPaths) {
val gpgAgentConfBackup = gpgAgentPaths.gpgAgentConfBackup
val pinentryFallback = when {
gpgAgentConfBackup.exists() -> readConfig(gpgAgentConfBackup).content[GPG_AGENT_PINENTRY_PROGRAM_CONF_KEY]
else -> null
}
if (pinentryFallback.isNullOrBlank()) {
LOG.debug("Pinentry fallback not found in $gpgAgentConfBackup. Skip pinentry script generation.")
}
PinentryShellScriptLauncherGenerator(executable)
.generate(project, gpgAgentPaths, pinentryFallback)
}
private fun backupExistingConfig(gpgAgentPaths: GpgAgentPaths, config: GpgAgentConfig): Boolean {
val pinentryAppLauncherConfigPath = gpgAgentPaths.gpgPinentryAppLauncherConfigPath
if (config.content[GPG_AGENT_PINENTRY_PROGRAM_CONF_KEY] == pinentryAppLauncherConfigPath) {
return false
}
val gpgAgentConf = gpgAgentPaths.gpgAgentConf
val gpgAgentConfBackup = gpgAgentPaths.gpgAgentConfBackup
try {
gpgAgentConf.copyTo(gpgAgentConfBackup, overwrite = true)
}
catch (e: IOException) {
LOG.warn("Cannot backup config $gpgAgentConf to $gpgAgentConfBackup", e)
return false
}
return true
}
private fun changePinentryProgram(gpgAgentPaths: GpgAgentPaths, config: GpgAgentConfig) {
val pinentryAppLauncherConfigPath = gpgAgentPaths.gpgPinentryAppLauncherConfigPath
val (configPath, configContent) = config
try {
FileUtil.writeToFile(configPath.toFile(), configContent.map { (key, value) ->
if (key == GPG_AGENT_PINENTRY_PROGRAM_CONF_KEY) {
"$key $pinentryAppLauncherConfigPath"
}
else {
"$key $value"
}
}.joinToString(separator = "\n"))
}
catch (e: IOException) {
LOG.error("Cannot change config $configPath", e)
}
}
private fun restartAgent() {
try {
val output = createGpgAgentExecutor().execute("gpg-connect-agent", "reloadagent /bye").lastOrNull()
if (output == "OK") {
LOG.debug("Gpg Agent restarted successfully")
}
else {
LOG.warn("Gpg Agent restart failed, restart manually to apply config changes")
}
}
catch (e: ExecutionException) {
LOG.warn("Gpg Agent restart failed, restart manually to apply config changes", e)
}
}
override fun dispose() {}
}
internal interface GpgAgentCommandExecutor {
@RequiresBackgroundThread
fun execute(command: String, vararg params: String): List<String>
}
private class LocalGpgAgentCommandExecutor : GpgAgentCommandExecutor {
override fun execute(command: String, vararg params: String): List<String> {
return CapturingProcessHandler
.Silent(GeneralCommandLine(command).withParameters(*params))
.runProcess(10000, true).stdoutLines
}
}
internal interface GpgAgentPathsLocator {
companion object {
const val GPG_HOME_DIR = ".gnupg"
const val GPG_AGENT_CONF_FILE_NAME = "gpg-agent.conf"
const val GPG_AGENT_CONF_BACKUP_FILE_NAME = "gpg-agent.conf.bak"
const val PINENTRY_LAUNCHER_FILE_NAME = "pinentry-ide.sh"
}
fun resolvePaths(): GpgAgentPaths?
}
private class MacAndUnixGpgAgentPathsLocator : GpgAgentPathsLocator {
override fun resolvePaths(): GpgAgentPaths? {
try {
val gpgAgentHome = Paths.get(SystemProperties.getUserHome(), GPG_HOME_DIR)
val gpgAgentConf = gpgAgentHome.resolve(GPG_AGENT_CONF_FILE_NAME)
val gpgAgentConfBackup = gpgAgentHome.resolve(GPG_AGENT_CONF_BACKUP_FILE_NAME)
val gpgPinentryAppLauncher = gpgAgentHome.resolve(PINENTRY_LAUNCHER_FILE_NAME)
return GpgAgentPaths(gpgAgentHome, gpgAgentConf, gpgAgentConfBackup,
gpgPinentryAppLauncher, gpgPinentryAppLauncher.toAbsolutePath().toString())
}
catch (e: InvalidPathException) {
LOG.warn("Cannot resolve path", e)
return null
}
}
}
internal interface PinentryLauncherGenerator {
val executable: GitExecutable
fun getScriptTemplate(fallbackPinentryPath: String?): String
fun generate(project: Project, gpgAgentPaths: GpgAgentPaths, fallbackPinentryPath: String?): Boolean {
val path = gpgAgentPaths.gpgPinentryAppLauncher
try {
FileUtil.writeToFile(path.toFile(), getScriptTemplate(fallbackPinentryPath))
NioFiles.setExecutable(path)
}
catch (e: IOException) {
LOG.warn("Cannot generate $path", e)
return false
}
return true
}
fun getCommandLineParameters(): Array<String> {
return if (LOG.isDebugEnabled) arrayOf("--log") else emptyArray()
}
}
internal class PinentryShellScriptLauncherGenerator(override val executable: GitExecutable) :
GitScriptGenerator(executable), PinentryLauncherGenerator {
@Language("Shell Script")
override fun getScriptTemplate(fallbackPinentryPath: String?): String {
if (fallbackPinentryPath == null) {
return """|#!/bin/sh
|${addParameters(*getCommandLineParameters()).commandLine(PinentryApp::class.java, false)}
""".trimMargin()
}
return """|#!/bin/sh
|if [ -n "${'$'}$PINENTRY_USER_DATA_ENV" ]; then
| ${addParameters(*getCommandLineParameters()).commandLine(PinentryApp::class.java, false)}
|else
| exec $fallbackPinentryPath "$@"
|fi
""".trimMargin()
}
}
internal data class GpgAgentPaths(
val gpgAgentHome: Path,
val gpgAgentConf: Path,
val gpgAgentConfBackup: Path,
val gpgPinentryAppLauncher: Path,
val gpgPinentryAppLauncherConfigPath: String,
)
private data class GpgAgentConfig(val path: Path, val content: Map<String, String>)
private class GpgAgentConfiguratorStartupActivity : ProjectActivity {
override suspend fun execute(project: Project) {
project.service<GpgAgentConfigurator>().configure()
}
}

View File

@@ -0,0 +1,168 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.commit.signing
import com.intellij.openapi.application.EDT
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.util.NlsSafe
import com.intellij.util.net.NetUtils
import git4idea.gpg.CryptoUtils
import git4idea.i18n.GitBundle
import kotlinx.coroutines.*
import org.jetbrains.annotations.TestOnly
import java.io.IOException
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketException
import java.security.KeyPair
import java.security.NoSuchAlgorithmException
@Service(Service.Level.PROJECT)
internal class PinentryService(private val cs: CoroutineScope) {
private var serverSocket: ServerSocket? = null
private var keyPair: KeyPair? = null
private var passwordUiRequester: PasswordUiRequester = DefaultPasswordUiRequester()
@TestOnly
internal fun setUiRequester(requester: PasswordUiRequester) {
passwordUiRequester = requester
}
@Synchronized
fun startSession(): PinentryData? {
val publicKeyStr: String?
try {
val pair = CryptoUtils.generateKeyPair()
publicKeyStr = CryptoUtils.publicKeyToString(pair.public)
if (publicKeyStr == null) {
LOG.warn("Cannot serialize public key")
return null
}
keyPair = pair
}
catch (e: NoSuchAlgorithmException) {
LOG.warn("Cannot generate key pair", e)
return null
}
val address = startServer() ?: return null
return PinentryData(publicKeyStr, address)
}
@Synchronized
fun stopSession() {
stopServer()
keyPair = null
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun startServer(): Address? {
val port = try {
NetUtils.findAvailableSocketPort()
}
catch (e: IOException) {
LOG.warn("Cannot find available port to start", e)
return null
}
val host = NetUtils.getLocalHostString()
serverSocket = ServerSocket(port)
cs.launch(Dispatchers.IO.limitedParallelism(1)) {
serverSocket.use { serverSocket ->
while (isActive) {
try {
val clientSocket = serverSocket?.accept()
if (clientSocket != null) {
launch(Dispatchers.IO) { handleClient(clientSocket) }
}
}
catch (e: SocketException) {
if (serverSocket?.isClosed == true) break
LOG.warn("Socket exception", e)
}
}
}
}
cs.launch {
while (isActive) {
delay(100L)
}
}.invokeOnCompletion {
stopSession()
}
return Address(host, port)
}
private fun stopServer() {
try {
serverSocket?.use(ServerSocket::close)
}
catch (e: IOException) {
LOG.warn("Cannot stop server", e)
}
}
private suspend fun handleClient(clientConnection: Socket) {
clientConnection.use { connection ->
connection.getInputStream().bufferedReader().use { reader ->
connection.getOutputStream().bufferedWriter().use { writer ->
val requestLine = reader.readLine()
val request = requestLine.split(' ', limit = 3)
if (request.getOrNull(0) == "GETPIN") {
val description = request.getOrNull(2)?.replace("%0A", "\n")?.replace("%22", "\"")
val passphrase = withContext(Dispatchers.EDT) {
passwordUiRequester.requestPassword(description)
}
val privateKey = keyPair?.private
if (passphrase != null && privateKey != null) {
val encryptedPassphrase = CryptoUtils.encrypt(passphrase, privateKey)
writer.write("D $encryptedPassphrase\n")
writer.write("OK\n")
}
else {
writer.write("ERR 83886178 cancel\n")
}
writer.flush()
}
}
}
}
}
internal fun interface PasswordUiRequester {
fun requestPassword(description: @NlsSafe String?): String?
}
private class DefaultPasswordUiRequester() : PasswordUiRequester {
override fun requestPassword(description: @NlsSafe String?): String? {
return Messages.showPasswordDialog(
if (description != null) description else GitBundle.message("gpg.pinentry.default.description"),
GitBundle.message("gpg.pinentry.title"),
)
}
}
data class Address(val host: String, val port: Int) {
override fun toString(): String = "$host:$port"
}
data class PinentryData(val publicKey: String, val address: Address) {
override fun toString(): String = "$publicKey:$address"
}
companion object {
private val LOG = logger<PinentryService>()
const val PINENTRY_USER_DATA_ENV = "PINENTRY_USER_DATA"
@JvmStatic
fun getInstance(project: Project): PinentryService = project.service()
}
}

View File

@@ -1,4 +1,4 @@
// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.config.gpg
import com.intellij.openapi.diagnostic.logger
@@ -7,6 +7,7 @@ import com.intellij.openapi.project.Project
import com.intellij.openapi.util.NlsSafe
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.vcs.VcsException
import com.intellij.openapi.vfs.VirtualFile
import git4idea.commands.GitImpl
import git4idea.config.GitConfigUtil
import git4idea.repo.GitRepository
@@ -18,8 +19,8 @@ import kotlinx.coroutines.withContext
@Throws(VcsException::class)
private fun readGitGpgConfig(repository: GitRepository): RepoConfig {
// TODO: "tag.gpgSign" ?
val isEnabled = GitConfigUtil.getBooleanValue(GitConfigUtil.getValue(repository.project, repository.root, GitConfigUtil.GPG_COMMIT_SIGN))
if (isEnabled != true) return RepoConfig(null)
val isEnabled = isGpgSignEnabled(repository.project, repository.root)
if (!isEnabled) return RepoConfig(null)
val keyValue = GitConfigUtil.getValue(repository.project, repository.root, GitConfigUtil.GPG_COMMIT_SIGN_KEY)
if (keyValue == null) return RepoConfig(null)
return RepoConfig(GpgKey(keyValue.trim()))
@@ -79,6 +80,16 @@ private fun checkKeyCapabilities(capabilities: String): Boolean {
return capabilities.contains("s") || capabilities.contains("S") // can Sign
}
fun isGpgSignEnabled(project: Project, root: VirtualFile): Boolean {
try {
return GitConfigUtil.getBooleanValue(GitConfigUtil.getValue(project, root, GitConfigUtil.GPG_COMMIT_SIGN)) == true
}
catch (e: VcsException) {
logger<GitConfigUtil>().warn("Cannot get gpg.commitSign config value", e)
return false
}
}
@Throws(VcsException::class)
fun writeGitGpgConfig(repository: GitRepository, gpgKey: GpgKey?) {
if (gpgKey != null) {

View File

@@ -0,0 +1,31 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.commit.signing
import com.intellij.testFramework.UsefulTestCase
import com.intellij.util.net.NetUtils
import git4idea.gpg.CryptoUtils
import kotlin.random.Random
class PinentryDataEncryptTest : UsefulTestCase() {
fun `test encrypt and decrypt`() {
val password = PinentryTestUtil.generatePassword(Random.nextInt(2, 200))
val keyPair = CryptoUtils.generateKeyPair()
val encryptedPassword = CryptoUtils.encrypt(password, keyPair.private)
val decryptedPassword = CryptoUtils.decrypt(encryptedPassword, keyPair.public)
assertEquals(password, decryptedPassword)
}
fun `test public key serialization`() {
val publicKey = CryptoUtils.generateKeyPair().public
val address = PinentryService.Address(NetUtils.getLocalHostString(), NetUtils.findAvailableSocketPort())
val pinentryData = PinentryService.PinentryData(CryptoUtils.publicKeyToString(publicKey), address).toString()
val keyToDeserialize = pinentryData.split(':')[0]
val deserializedKey = CryptoUtils.stringToPublicKey(keyToDeserialize)
assertEquals(publicKey, deserializedKey)
}
}

View File

@@ -0,0 +1,163 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.commit.signing
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.process.CapturingProcessAdapter
import com.intellij.execution.process.CapturingProcessHandler
import com.intellij.execution.process.ProcessEvent
import com.intellij.execution.process.ProcessOutput
import com.intellij.execution.process.ProcessOutputTypes
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.NlsSafe
import com.intellij.openapi.util.io.FileUtil
import com.intellij.openapi.util.io.IoTestUtil
import com.intellij.util.io.createDirectories
import git4idea.commit.signing.GpgAgentConfigurator.Companion.GPG_AGENT_PINENTRY_PROGRAM_CONF_KEY
import git4idea.commit.signing.GpgAgentPathsLocator.Companion.GPG_AGENT_CONF_BACKUP_FILE_NAME
import git4idea.commit.signing.GpgAgentPathsLocator.Companion.GPG_AGENT_CONF_FILE_NAME
import git4idea.commit.signing.GpgAgentPathsLocator.Companion.GPG_HOME_DIR
import git4idea.commit.signing.GpgAgentPathsLocator.Companion.PINENTRY_LAUNCHER_FILE_NAME
import git4idea.config.GitExecutableManager
import git4idea.test.GitSingleRepoTest
import org.junit.Assume.assumeTrue
import java.io.BufferedWriter
import java.io.OutputStreamWriter
import java.net.BindException
import java.nio.charset.StandardCharsets
import kotlin.random.Random
class PinentryExecutionTest : GitSingleRepoTest() {
override fun setUp() {
super.setUp()
val enabled = GpgAgentConfigurator.isEnabled(GitExecutableManager.getInstance().getExecutable(project))
assumeTrue("GpgAgentConfigurator should be enabled", enabled);
}
fun `test pinentry communication without gpg agent configuration`() {
IoTestUtil.assumeUnix()
val pathLocator = TestGpgPathLocator()
val paths = pathLocator.resolvePaths()!!
project.service<GpgAgentConfigurator>().doConfigure(pathLocator)
requestPasswordAndAssert(paths)
}
fun `test pinentry communication with existing gpg agent configuration`() {
IoTestUtil.assumeUnix()
val pathLocator = TestGpgPathLocator()
val paths = pathLocator.resolvePaths()!!
FileUtil.writeToFile(paths.gpgAgentConf.toFile(), "$GPG_AGENT_PINENTRY_PROGRAM_CONF_KEY /usr/local/bin/pinentry")
project.service<GpgAgentConfigurator>().doConfigure(pathLocator)
requestPasswordAndAssert(paths)
}
fun `test pinentry launcher structure`() {
val pathLocator = TestGpgPathLocator()
val paths = pathLocator.resolvePaths()!!
project.service<GpgAgentConfigurator>().doConfigure(pathLocator)
var scriptContent = FileUtil.loadFile(paths.gpgPinentryAppLauncher.toFile())
assertScriptContentStructure(scriptContent)
FileUtil.delete(paths.gpgPinentryAppLauncher.toFile())
FileUtil.delete(paths.gpgAgentConf.toFile())
FileUtil.delete(paths.gpgAgentConfBackup.toFile())
FileUtil.writeToFile(paths.gpgAgentConf.toFile(), "$GPG_AGENT_PINENTRY_PROGRAM_CONF_KEY /usr/local/bin/pinentry")
project.service<GpgAgentConfigurator>().doConfigure(pathLocator)
scriptContent = FileUtil.loadFile(paths.gpgPinentryAppLauncher.toFile())
assertScriptContentStructure(scriptContent)
}
private fun assertScriptContentStructure(scriptContent: String) {
assertTrue(scriptContent.isNotBlank())
assertFalse(scriptContent.contains("\r"))
for (line in scriptContent.lines()) {
assertFalse(line.isBlank())
val firstTwoChars = line.take(2)
assertTrue(firstTwoChars.all { it.isWhitespace() }
|| firstTwoChars.all { !it.isWhitespace() })
}
}
private fun requestPasswordAndAssert(paths: GpgAgentPaths) {
PinentryService.getInstance(project).use { pinentryData ->
val keyPassword = PinentryTestUtil.generatePassword(Random.nextInt(2, 200))
setUiRequester { keyPassword }
val output = requestPassword(paths, pinentryData)
val passwordPrefix = "D "
val receivedPassword = output.find { it.startsWith(passwordPrefix) }?.substringAfter(passwordPrefix)
assertEquals("Received $output", keyPassword, receivedPassword)
}
}
private fun PinentryService.use(block: PinentryService.(PinentryService.PinentryData) -> Unit) {
try {
val pinentryData = startSession()
assertNotNull(pinentryData)
block(pinentryData!!)
}
catch (e: BindException) {
logger<PinentryExecutionTest>().warn(e)
}
finally {
stopSession()
}
}
private fun requestPassword(paths: GpgAgentPaths, pinentryData: PinentryService.PinentryData?): List<@NlsSafe String> {
val cmd = GeneralCommandLine(paths.gpgPinentryAppLauncherConfigPath)
.withEnvironment(PinentryService.PINENTRY_USER_DATA_ENV, pinentryData.toString())
val output = object : CapturingProcessHandler.Silent(cmd) {
override fun createProcessAdapter(processOutput: ProcessOutput): CapturingProcessAdapter? {
return object : CapturingProcessAdapter(processOutput) {
val writer = BufferedWriter(OutputStreamWriter(process.outputStream, StandardCharsets.UTF_8))
override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) {
super.onTextAvailable(event, outputType)
val receivedText = event.text
if (receivedText != null && outputType == ProcessOutputTypes.STDOUT) {
replyOn(receivedText)
}
}
private fun replyOn(text: String) {
if (text.startsWith("OK")) {
writer.write("GETPIN\n")
writer.flush()
}
if (text.startsWith("D")) {
writer.write("BYE\n")
writer.flush()
}
}
override fun processTerminated(event: ProcessEvent) {
writer.use { super.processTerminated(event) }
}
}
}
}.runProcess(10000, true).stdoutLines
return output
}
private inner class TestGpgPathLocator : GpgAgentPathsLocator {
override fun resolvePaths(): GpgAgentPaths? {
val gpgAgentHome = projectNioRoot.resolve(GPG_HOME_DIR).createDirectories()
val gpgAgentConf = gpgAgentHome.resolve(GPG_AGENT_CONF_FILE_NAME)
val gpgAgentConfBackup = gpgAgentHome.resolve(GPG_AGENT_CONF_BACKUP_FILE_NAME)
val gpgPinentryAppLauncher = gpgAgentHome.resolve(PINENTRY_LAUNCHER_FILE_NAME)
return GpgAgentPaths(gpgAgentHome, gpgAgentConf, gpgAgentConfBackup,
gpgPinentryAppLauncher, gpgPinentryAppLauncher.toAbsolutePath().toString())
}
}
}

View File

@@ -0,0 +1,47 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.commit.signing
import java.security.SecureRandom
object PinentryTestUtil {
private const val UPPERCASE_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
private const val LOWERCASE_LETTERS = "abcdefghijklmnopqrstuvwxyz"
private const val DIGITS = "0123456789"
private const val SPECIAL_CHARACTERS = "!@#$%^&*()-_=+[]{}|;:,.<>?/"
const val ALL_CHARACTERS = UPPERCASE_LETTERS + LOWERCASE_LETTERS + DIGITS + SPECIAL_CHARACTERS
private val RANDOM = SecureRandom()
fun generatePassword(length: Int): String {
val password = StringBuilder(length)
//fill first 4 char
password.append(UPPERCASE_LETTERS[RANDOM.nextInt(UPPERCASE_LETTERS.length)])
password.append(LOWERCASE_LETTERS[RANDOM.nextInt(LOWERCASE_LETTERS.length)])
password.append(DIGITS[RANDOM.nextInt(DIGITS.length)])
password.append(SPECIAL_CHARACTERS[RANDOM.nextInt(SPECIAL_CHARACTERS.length)])
//fill last characters randomly
repeat(length - 4) {
password.append(ALL_CHARACTERS[RANDOM.nextInt(ALL_CHARACTERS.length)])
}
require(password.length == length)
return password.toString().shuffle()
}
private fun String.shuffle(): String {
val characters = this.toCharArray()
for (i in characters.indices) {
val randomIndex = RANDOM.nextInt(characters.size)
val temp = characters[i]
characters[i] = characters[randomIndex]
characters[randomIndex] = temp
}
return String(characters)
}
}