mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-03-22 15:19:59 +07:00
[rdct] vcs: implement pinentry application for requesting GPG key secret (IJPL-149731)
GitOrigin-RevId: a06ba8c87946fb3a9b2818996e81e81a4ed90408
This commit is contained in:
committed by
intellij-monorepo-bot
parent
40ee9543ab
commit
f7d1a73f2f
@@ -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"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
51
plugins/git4idea/rt/src/git4idea/gpg/CryptoUtils.java
Normal file
51
plugins/git4idea/rt/src/git4idea/gpg/CryptoUtils.java
Normal 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);
|
||||
}
|
||||
}
|
||||
158
plugins/git4idea/rt/src/git4idea/gpg/PinentryApp.java
Normal file
158
plugins/git4idea/rt/src/git4idea/gpg/PinentryApp.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
168
plugins/git4idea/src/git4idea/commit/signing/PinentryService.kt
Normal file
168
plugins/git4idea/src/git4idea/commit/signing/PinentryService.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user