[Refactor] Use GPG instead of using legacy java keystores

This commit is contained in:
2024-04-06 22:31:14 +02:00
parent 4ac1450cce
commit 0b344e20b6
9 changed files with 376 additions and 146 deletions

View File

@@ -32,6 +32,10 @@ dependencies {
implementation gradleApi()
shadeMe "org.apache.commons:commons-lang3:${commons_lang}"
shadeMe "org.bouncycastle:bcprov-jdk18on:${bouncy}"
shadeMe "org.bouncycastle:bcpg-jdk18on:${bouncy}"
shadeMe "commons-io:commons-io:${commons_io}"
shadeMe "commons-codec:commons-codec:${commons_codec}"
compileOnly "org.projectlombok:lombok:${lombok}"
annotationProcessor "org.projectlombok:lombok:${lombok}"

View File

@@ -4,4 +4,7 @@ version_patch=0
# Dependencies
lombok=1.18.30
commons_lang=3.14.0
commons_lang=3.14.0
commons_io=2.16.0
commons_codec=1.16.1
bouncy=1.77

View File

@@ -6,12 +6,19 @@ A Simple Gradle plugin to help you sign your jars.
### Getting Started
To get started, you will need keystore. If you already have this, you can skip this part.
To get started, you will a GPG key. If you already have this, you can skip it.
In a terminal, or in command line, run the following command:
In a terminal, or in command line, run the following commands:
```bash
keytool -genkey -alias YOUR_ALIAS_HERE -keyalg RSA -keysize 2048 -keystore keystore.jks
# generate the keys
gpg --gen-key
#export the private key with the specified id to a file
gpg --output {private key file name and path} --armor --export-secret-keys {key-id}
#export the public key with the specified id to a file
gpg --output {public key file name and path} --armor --export {key-id}
```
Answer the required questions, and your file will be generated once completed.
@@ -53,39 +60,35 @@ Finally, add the following to `build.gradle` file:
```groovy
import dev.firstdark.keymaster.tasks.SignJarTask
// This is optional. These values can be configured on the task
keymaster {
// GPG Password
gpgPassword = "123456"
// GPG Key file, or String.
gpgKey = System.getenv("GPG_KEY")
// Generate a .sig file for signed jars, to be used for verification
generateSignature = true
}
// Register a custom task to sign your jar
tasks.register('signJar', SignJarTask) {
// Depend on the task used to build your project
dependsOn jar
// The input artifact. This can be a Task, File or File Name
artifactInput = jar
// Optional. Set the output name of the signed jar. This defaults to the artifactInput file name, and will overwrite it
outputFileName = "testsign"
// The password of your key
keyPass = "123456"
// Your key alias
keyStoreAlias = "testalias"
// Your keystore password
keyStorePass = "123456"
// Your keystore location
keyStore = "/home/hypherionsa/dummystore.jks"
}
// GPG Private key file or string. Not required when the extension is used
gpgKey = System.getenv("GPG_KEY")
// Example of signing another jar
tasks.register('signDummyJar', SignJarTask) {
dependsOn createDummyJar
artifactInput = createDummyJar
// GPG Private Key password. Not required when extension is used
gpgPassword = "123456"
keyPass = "123456"
keyStoreAlias = "testalias"
keyStorePass = "123456"
keyStore = "/home/hypherionsa/dummystore.jks"
// Should the task generate a .sig file. Defaults to true, and not required when extension is used
generateSignature = false
}
```
@@ -126,8 +129,18 @@ Finally, add the following to `build.gradle.kts` file:
import dev.firstdark.keymaster.tasks.SignJarTask
import org.gradle.kotlin.dsl.register
// This is optional. These values can be configured on the task
extensions.configure<KeymasterExtension>("keymaster") {
// GPG Password
gpgPassword = "123456"
// GPG Key file, or String.
gpgKey = System.getenv("GPG_KEY")
// Generate a .sig file for signed jars, to be used for verification
generateSignature = true
}
// Register a custom task to sign your jar
val signJar by tasks.register<SignJarTask>("signJar") {
tasks.register("signJar", SignJarTask::class) {
// Depend on the task used to build your project
dependsOn(tasks.jar)
@@ -137,28 +150,14 @@ val signJar by tasks.register<SignJarTask>("signJar") {
// Optional. Set the output name of the signed jar. This defaults to the artifactInput file name, and will overwrite it
outputFileName = "testsign"
// The password of your key
keyPass = "123456"
// GPG Private key file or string. Not required when the extension is used
gpgKey = System.getenv("GPG_KEY")
// Your key alias
keyStoreAlias = "testalias"
// GPG Private Key password. Not required when extension is used
gpgPassword = "123456"
// Your keystore password
keyStorePass = "123456"
// Your keystore location
keyStore = "/home/hypherionsa/dummystore.jks"
}
// Example of signing another jar
val signDummyJar by tasks.register<SignJarTask>("signDummyJar") {
dependsOn(tasks.createDummyJar)
artifactInput = tasks.createDummyJar
keyPass = "123456"
keyStoreAlias = "testalias"
keyStorePass = "123456"
keyStore = "/home/hypherionsa/dummystore.jks"
// Should the task generate a .sig file. Defaults to true, and not required when extension is used
generateSignature = false
}
```

View File

@@ -0,0 +1,35 @@
/*
* This file is part of KeyMaster, licensed under the MIT License (MIT).
*
* Copyright (c) 2024 HypherionSA and Contributors
*
*/
package dev.firstdark.keymaster.plugin;
import lombok.Getter;
import lombok.Setter;
import org.gradle.api.Project;
import org.gradle.api.provider.Property;
/**
* @author HypherionSA
* Plugin Extension for sharing common configs between multiple tasks
*/
@Getter
@Setter
public class KeyMasterGradleExtension {
// Default properties. These are overridden by the values specified on the task
private final Property<String> gpgKey;
private final Property<String> gpgPassword;
private final Property<Boolean> generateSignature;
private final Property<String> outputDirectory;
public KeyMasterGradleExtension(Project project) {
this.gpgKey = project.getObjects().property(String.class);
this.gpgPassword = project.getObjects().property(String.class);
this.generateSignature = project.getObjects().property(Boolean.class).convention(true);
this.outputDirectory = project.getObjects().property(String.class).convention(project.getBuildDir() + "/libs");
}
}

View File

@@ -1,3 +1,9 @@
/*
* This file is part of KeyMaster, licensed under the MIT License (MIT).
*
* Copyright (c) 2024 HypherionSA and Contributors
*
*/
package dev.firstdark.keymaster.plugin;
import org.gradle.api.Plugin;
@@ -6,12 +12,13 @@ import org.jetbrains.annotations.NotNull;
/**
* @author HypherionSA
* Main plugin class. Mostly a dummy for this plugin
* Main plugin class.
*/
public class KeyMasterGradlePlugin implements Plugin<Project> {
@Override
public void apply(@NotNull Project target) {
target.getLogger().info("KeyMaster Plugin is activated");
target.getExtensions().create("keymaster", KeyMasterGradleExtension.class);
}
}

View File

@@ -1,21 +1,33 @@
/*
* This file is part of KeyMaster, licensed under the MIT License (MIT).
*
* Copyright (c) 2024 HypherionSA and Contributors
*
*/
package dev.firstdark.keymaster.tasks;
import dev.firstdark.keymaster.plugin.KeyMasterGradleExtension;
import dev.firstdark.keymaster.utils.PluginUtils;
import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
import org.gradle.api.Project;
import org.gradle.api.provider.Provider;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openpgp.*;
import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder;
import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder;
import org.gradle.api.GradleException;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.Optional;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.bundling.AbstractArchiveTask;
import org.gradle.jvm.tasks.Jar;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.IOException;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
import java.security.Security;
import static dev.firstdark.keymaster.utils.PluginUtils.isNullOrBlank;
import static dev.firstdark.keymaster.utils.PluginUtils.resolveFile;
/**
* @author HypherionSA
@@ -31,14 +43,20 @@ public class SignJarTask extends Jar {
private String outputFileName = "signed.jar";
// KeyStore values
private String keyStorePass;
private String keyStore;
private String keyStoreAlias;
private String keyPass;
private String gpgKey;
private String gpgPassword;
private Boolean generateSignature = true;
// Set the output directory. Defaults to build/libs
private String outputDirectory = getProject().getBuildDir() + "/libs";
@Nullable
private final KeyMasterGradleExtension extension;
public SignJarTask() {
extension = getProject().getExtensions().findByType(KeyMasterGradleExtension.class);
}
@Input
public Object getArtifactInput() {
return resolveFile(getProject(), this.artifactInput);
@@ -50,28 +68,40 @@ public class SignJarTask extends Jar {
}
@Input
public String getKeyStorePass() {
return this.keyStorePass;
@Optional
@Nullable
public String getGpgKey() {
if (!isNullOrBlank(gpgKey))
return gpgKey;
if (extension != null && !isNullOrBlank(extension.getGpgKey().getOrNull()))
return extension.getGpgKey().get();
return null;
}
@Input
public String getKeyStore() {
return this.keyStore;
}
@Optional
@Nullable
public String getGpgPassword() {
if (!isNullOrBlank(gpgPassword))
return gpgPassword;
@Input
public String getKeyStoreAlias() {
return this.keyStoreAlias;
}
if (extension != null && !isNullOrBlank(extension.getGpgPassword().getOrNull()))
return extension.getGpgPassword().get();
@Input
public String getKeyPass() {
return this.keyPass;
return null;
}
@Input
public String getOutputDirectory() {
return this.outputDirectory;
if (!isNullOrBlank(outputDirectory))
return outputDirectory;
if (extension != null && !isNullOrBlank(extension.getOutputDirectory().getOrNull()))
return extension.getOutputDirectory().get();
return outputDirectory;
}
@OutputFile
@@ -79,6 +109,14 @@ public class SignJarTask extends Jar {
return new File(outputDirectory, outputFileName);
}
@Input
public Boolean getGenerateSignature() {
if (extension != null && extension.getGenerateSignature().isPresent())
return extension.getGenerateSignature().get();
return generateSignature;
}
/**
* Main Task Logic
*/
@@ -86,14 +124,14 @@ public class SignJarTask extends Jar {
@TaskAction
public void doTask() {
// Check that input is supplied
if (artifactInput == null) {
if (getArtifactInput() == null) {
getProject().getLogger().error("Input cannot be null!");
return;
}
// Check that all the required values are supplied
if (isNullOrBlank(keyPass) || isNullOrBlank(keyStore) || isNullOrBlank(keyStoreAlias) || isNullOrBlank(keyStorePass)) {
getLogger().error("Please provide all required parameters: keyStore, keyStoreAlias, keyStorePass, keyPass");
if (isNullOrBlank(getGpgKey()) || isNullOrBlank(getGpgPassword())) {
getLogger().error("Please provide all required parameters: keyStore, keyPass");
return;
}
@@ -101,10 +139,11 @@ public class SignJarTask extends Jar {
File tempDir = new File(getProject().getBuildDir(), "signing");
tempDir.mkdirs();
// Try to sign the jar
try {
processArtifact(artifactInput, tempDir);
processArtifact(getArtifactInput(), tempDir);
} catch (Exception e) {
getLogger().error("Failed to sign artifact {}", artifactInput, e);
getLogger().error("Failed to sign artifact {}", getArtifactInput(), e);
}
// Remove the temp working dir
@@ -118,36 +157,35 @@ public class SignJarTask extends Jar {
* @throws IOException This is mostly thrown when a file copy error occurs
*/
@SuppressWarnings("ResultOfMethodCallIgnored")
private void processArtifact(Object input, File tempDir) throws IOException {
private void processArtifact(Object input, File tempDir) throws IOException, PGPException {
// Set up the input file
File inputFile = resolveFile(getProject(), input);
// Check if the output file is specified. If not, default to the input file
outputFileName = outputFileName.equalsIgnoreCase("signed.jar") ? inputFile.getName() : outputFileName + ".jar";
outputFileName = getOutputFileName().equalsIgnoreCase("signed.jar") ? inputFile.getName() : getOutputFileName() + ".jar";
// Copy the original input file to the temporary processing folder
File tempInput = new File(tempDir, inputFile.getName());
Files.copy(inputFile.toPath(), tempInput.toPath(), StandardCopyOption.REPLACE_EXISTING);
// Create a temporary output jar
File tempOutput = new File(tempDir, outputFileName);
File tempOutput = new File(tempDir, getOutputFileName());
File sigTempFile = new File(tempDir, tempOutput.getName() + ".sig");
File outputSigFile = new File(getOutputFile().getParentFile(), getOutputFile().getName() + ".sig");
// Configure the jar signing
Map<String, Object> map = new HashMap<>();
map.put("alias", keyStoreAlias);
map.put("storePass", keyStorePass);
map.put("jar", tempInput.getAbsolutePath());
map.put("signedJar", tempOutput.getAbsolutePath());
map.put("keypass", keyPass);
map.put("keyStore", resolveFile(getProject(), keyStore).getAbsolutePath());
// SIGN IT
getProject().getAnt().invokeMethod("signjar", map);
// Sign the damn thing
signGPG(tempInput, sigTempFile, tempOutput);
// Copy the signed jar to the libs folder
Files.copy(tempOutput.toPath(), getOutputFile().toPath(), StandardCopyOption.REPLACE_EXISTING);
getProject().getLogger().lifecycle("Signed " + getOutputFile().getName() + " successfully");
// Copy Signature File
if (getGenerateSignature()) {
Files.copy(sigTempFile.toPath(), outputSigFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
sigTempFile.delete();
}
getProject().getLogger().lifecycle("Signed {} successfully", getOutputFile().getName());
// Cleanup the temporary files
tempOutput.delete();
@@ -155,45 +193,58 @@ public class SignJarTask extends Jar {
}
/**
* Helper method to check if a supplied string is null or empty
* @param s The string to test
* @return True if null or empty
* Main Signing logic. This handles reading the GPG keys, and doing the actual signing
* @param inputFile The jar to be signed
* @param signatureFile The GPG private key file or string
* @param signedOutputFile The output, signed jar file
* @throws IOException Thrown when a file error occurs
* @throws PGPException Thrown when a signature error occurs
*/
private boolean isNullOrBlank(String s) {
if (s == null)
return true;
private void signGPG(File inputFile, File signatureFile, File signedOutputFile) throws IOException, PGPException {
// Load Bouncy Castle
BouncyCastleProvider provider = new BouncyCastleProvider();
Security.addProvider(provider);
return StringUtils.isBlank(s);
}
/**
* Resolve an Object to a File
* @param project The project the file potentially belongs to
* @param obj The object to process
* @return A File object, ready to use
*/
private File resolveFile(Project project, Object obj) {
if (obj == null) {
throw new NullPointerException("Null Path");
// Load the GPG private key
byte[] keyBytes = PluginUtils.resolvePrivateKey(getGpgKey());
if (keyBytes == null) {
throw new GradleException("Could not read GPG private key. Signing will fail");
}
if (obj instanceof Provider) {
Provider<?> p = (Provider<?>) obj;
obj = p.get();
}
// Process the private key
try (ByteArrayInputStream keyInputStream = new ByteArrayInputStream(keyBytes);
FileOutputStream sigOutputStream = new FileOutputStream(signatureFile);
FileOutputStream signedOutputStream = new FileOutputStream(signedOutputFile)) {
if (obj instanceof File) {
return (File) obj;
}
PGPSecretKey secretKey = PluginUtils.readSecretKey(keyInputStream);
PGPPrivateKey privateKey = secretKey.extractPrivateKey(new JcePBESecretKeyDecryptorBuilder().setProvider(provider).build(getGpgPassword().toCharArray()));
if (obj instanceof AbstractArchiveTask) {
return ((AbstractArchiveTask)obj).getArchiveFile().get().getAsFile();
}
PGPSignatureGenerator signature = new PGPSignatureGenerator(
new JcaPGPContentSignerBuilder(secretKey.getPublicKey().getAlgorithm(), PGPUtil.SHA1)
.setProvider(provider));
signature.init(PGPSignature.BINARY_DOCUMENT, privateKey);
if (obj instanceof String) {
return new File(obj.toString());
}
// Write signature to output .sig file
if (getGenerateSignature()) {
try (FileInputStream inputStream = new FileInputStream(inputFile)) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
signature.update(buffer, 0, bytesRead);
}
return project.file(obj);
signature.generate().encode(sigOutputStream);
}
}
// Copy the signed content to the output signed jar file
try (FileInputStream inputStream = new FileInputStream(inputFile)) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
signedOutputStream.write(buffer, 0, bytesRead);
}
}
}
}
}

View File

@@ -0,0 +1,135 @@
/*
* This file is part of KeyMaster, licensed under the MIT License (MIT).
*
* Copyright (c) 2024 HypherionSA and Contributors
*
*/
package dev.firstdark.keymaster.utils;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.bouncycastle.openpgp.*;
import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator;
import org.gradle.api.GradleException;
import org.gradle.api.Project;
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.bundling.AbstractArchiveTask;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
/**
* @author HypherionSA
* Helper Methods used in the plugin
*/
public class PluginUtils {
/**
* Helper method to load PGP secrets from the user specified input
* @param input The InputStream of the file/string to process
* @return The signing key
* @throws IOException Thrown when a file error occurs
* @throws PGPException Thrown when a signature error occurs
*/
public static PGPSecretKey readSecretKey(InputStream input) throws IOException, PGPException {
PGPSecretKeyRingCollection pgpSec = new PGPSecretKeyRingCollection(
PGPUtil.getDecoderStream(input), new JcaKeyFingerprintCalculator());
Iterator<PGPSecretKeyRing> keyRingIter = pgpSec.getKeyRings();
while (keyRingIter.hasNext()) {
PGPSecretKeyRing keyRing = keyRingIter.next();
Iterator<PGPSecretKey> keyIter = keyRing.getSecretKeys();
while (keyIter.hasNext()) {
PGPSecretKey key = keyIter.next();
if (key.isSigningKey()) {
return key;
}
}
}
throw new IllegalArgumentException("Can't find signing key in key ring.");
}
/**
* Helper method to read a GPG private key from either a file, or String
* @param input File or String to process
* @return The read key bytes, or null
*/
public static byte[] resolvePrivateKey(String input) {
File f = new File(input);
if (f.exists() && f.isFile()) {
try {
input = FileUtils.readFileToString(f, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new GradleException("Failed to read GPG Private key", e);
}
}
if (!isNullOrBlank(input)) {
if (input.startsWith("-----")) {
String[] parts = input.split("\n");
StringBuilder sb = new StringBuilder();
for (String part : parts) {
if (!part.startsWith("-----")) {
sb.append(part);
}
}
return Base64.decodeBase64(sb.toString());
} else {
return input.getBytes(StandardCharsets.UTF_8);
}
}
return null;
}
/**
* Helper method to check if a supplied string is null or empty
* @param s The string to test
* @return True if null or empty
*/
public static boolean isNullOrBlank(String s) {
if (s == null)
return true;
return StringUtils.isBlank(s);
}
/**
* Resolve an Object to a File
* @param project The project the file potentially belongs to
* @param obj The object to process
* @return A File object, ready to use
*/
public static File resolveFile(Project project, Object obj) {
if (obj == null) {
throw new NullPointerException("Null Path");
}
if (obj instanceof Provider) {
Provider<?> p = (Provider<?>) obj;
obj = p.get();
}
if (obj instanceof File) {
return (File) obj;
}
if (obj instanceof AbstractArchiveTask) {
return ((AbstractArchiveTask)obj).getArchiveFile().get().getAsFile();
}
if (obj instanceof String) {
return new File(obj.toString());
}
return project.file(obj);
}
}

View File

@@ -16,6 +16,16 @@ dependencies {
}
// This is optional. These values can be configured on the task
keymaster {
// GPG Password
gpgPassword = "123456"
// GPG Key file, or String.
gpgKey = System.getenv("GPG_KEY")
// Generate a .sig file for signed jars, to be used for verification
generateSignature = true
}
tasks.register('createDummyJar', Jar) {
// Configure the JAR task to have no files
from {}
@@ -33,26 +43,12 @@ tasks.register('signJar', SignJarTask) {
// Optional. Set the output name of the signed jar. This defaults to the artifactInput file name, and will overwrite it
outputFileName = "testsign"
// The password of your key
keyPass = "123456"
// GPG Private key file or string. Not required when the extension is used
gpgKey = System.getenv("GPG_KEY")
// Your key alias
keyStoreAlias = "testalias"
// GPG Private Key password. Not required when extension is used
gpgPassword = "123456"
// Your keystore password
keyStorePass = "123456"
// Your keystore location
keyStore = "/home/hypherionsa/dummystore.jks"
}
// Example of signing another jar
tasks.register('signDummyJar', SignJarTask) {
dependsOn createDummyJar
artifactInput = createDummyJar
keyPass = "123456"
keyStoreAlias = "testalias"
keyStorePass = "123456"
keyStore = "/home/hypherionsa/dummystore.jks"
}
// Should the task generate a .sig file. Defaults to true, and not required when extension is used
generateSignature = false
}

Binary file not shown.