From c592716f1fcf3c40639e8a501b64056c103ff017 Mon Sep 17 00:00:00 2001
From: Maria Merkel
Date: Sat, 22 Apr 2023 17:33:15 +0200
Subject: [PATCH] Added support for Google Cloud KMS via HashiCorp Vault (#144)
---
README.md | 3 +-
docs/index.html | 19 ++-
.../src/main/java/net/jsign/JsignCLI.java | 3 +-
.../src/main/java/net/jsign/KeyStoreType.java | 28 ++++
.../jca/HashiCorpVaultSigningService.java | 146 ++++++++++++++++++
.../java/net/jsign/KeyStoreBuilderTest.java | 35 +++++
.../share/bash-completion/completions/jsign | 2 +-
jsign/src/deb/data/usr/share/man/man1/jsign.1 | 13 ++
8 files changed, 244 insertions(+), 5 deletions(-)
create mode 100644 jsign-core/src/main/java/net/jsign/jca/HashiCorpVaultSigningService.java
diff --git a/README.md b/README.md
index b21c32f6..ff15ab6c 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ Jsign is free to use and licensed under the [Apache License version 2.0](https:/
* Keystores supported:
* PKCS#12, JKS and JCEKS files
* PKCS#11 hardware tokens ([YubiKey](https://www.yubico.com), [Nitrokey](https://www.nitrokey.com), etc)
- * Cloud key management systems ([AWS KMS](https://aws.amazon.com/kms/), [Azure Key Vault](https://azure.microsoft.com/services/key-vault/), [DigiCert ONE](https://one.digicert.com), [Google Cloud KMS](https://cloud.google.com/security-key-management), [SSL.com eSigner](https://www.ssl.com/esigner/))
+ * Cloud key management systems ([AWS KMS](https://aws.amazon.com/kms/), [Azure Key Vault](https://azure.microsoft.com/services/key-vault/), [DigiCert ONE](https://one.digicert.com), [Google Cloud KMS](https://cloud.google.com/security-key-management), [SSL.com eSigner](https://www.ssl.com/esigner/), [HashiCorp Vault](https://www.vaultproject.io/))
* Private key formats: PVK and PEM (PKCS#1 and PKCS#8), encrypted or not
* Certificates: PKCS#7 in PEM and DER format
* Build tools integration (Maven, Gradle, Ant)
@@ -46,6 +46,7 @@ See https://ebourg.github.io/jsign for more information.
* Nitrokey support has been improved with automatic PKCS#11 configuration using the new `NITROKEY` storetype
* Smart cards are now supported with the new `OPENSC` storetype
* OpenPGP cards are now supported with the new `OPENPGP` storetype
+* Google Cloud KMS via HashiCorp Vault is now supported with the new `HASHICORPVAULT` storetype (contributed by Maria Merkel)
* The Maven plugin can now use passwords defined in the Maven settings.xml file
* The "X.509 Certificate for PIV Authentication" on a Yubikey (slot 9a) is now automatically detected
* SHA-1 signing with Azure Key Vault is now possible (contributed by Andrij Abyzov)
diff --git a/docs/index.html b/docs/index.html
index d1743537..5921abeb 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -65,7 +65,7 @@ Features
- PKCS#12, JKS and JCEKS files
- PKCS#11 hardware tokens (YubiKey, Nitrokey, etc)
- - Cloud key management systems (AWS KMS, Azure Key Vault, DigiCert ONE, Google Cloud KMS, SSL.com eSigner)
+ - Cloud key management systems (AWS KMS, Azure Key Vault, DigiCert ONE, Google Cloud KMS, SSL.com eSigner, HashiCorp Vault)
Private key formats: PVK and PEM (PKCS#1 and PKCS#8), encrypted or not
Certificates: PKCS#7 in PEM and DER format
@@ -180,6 +180,7 @@ Attributes
DIGICERTONE
: DigiCert ONE Secure Software Manager
ESIGNER
: SSL.com eSigner
GOOGLECLOUD
: Google Cloud KMS
+ HASHICORPVAULT
: Google Cloud KMS via HashiCorp Vault
No, automatically detected for file based keystores. |
@@ -448,6 +449,7 @@ Command Line Tool
- DIGICERTONE: DigiCert ONE Secure Software Manager
- ESIGNER: SSL.com eSigner
- GOOGLECLOUD: Google Cloud KMS
+ - HASHICORPVAULT: Google Cloud KMS via HashiCorp Vault
-a,--alias <NAME> The alias of the certificate used for signing in the keystore.
--keypass <PASSWORD> The password of the private key. When using a keystore,
this parameter can be omitted if the keystore shares the
@@ -600,6 +602,19 @@ Example using Google Cloud KMS:
--alias test --certfile full-chain.pem application.exe
+Example using Google Cloud KMS via HashiCorp Vault:
+
+Google Cloud KMS stores only the private key, the certificate must be provided separately. The keystore parameter
+references the URL of the HashiCorp Vault secrets engine, consisting of the Vault server URL, the API version v1 and
+the secrets engine path. The alias specifies the name of the key in Vault and the key version in Google Cloud separated
+by a colon character.
+
+
+ jsign --storetype HASHICORPVAULT --storepass <vault-token> \
+ --keystore https://vault.example.com/v1/gcpkms \
+ --alias test:1 --certfile full-chain.pem application.exe
+
+
API
@@ -649,7 +664,7 @@ Credits
PVK parsing is based on the pvktool by Stephen N Henson.
MSI signing was possible thanks to the work done by the osslsigncode and Apache POI projects.
-Jsign includes contributions from Emmanuel Bourg, Florent Daigniere, Michael Szediwy, Michael Peterson, Markus Kilås, Erwin Tratar, Björn Kautler and Joseph Lee.
+Jsign includes contributions from Emmanuel Bourg, Florent Daigniere, Michael Szediwy, Michael Peterson, Markus Kilås, Erwin Tratar, Björn Kautler, Joseph Lee and Maria Merkel.
Contact
diff --git a/jsign-cli/src/main/java/net/jsign/JsignCLI.java b/jsign-cli/src/main/java/net/jsign/JsignCLI.java
index a38e486b..ae7c56fd 100644
--- a/jsign-cli/src/main/java/net/jsign/JsignCLI.java
+++ b/jsign-cli/src/main/java/net/jsign/JsignCLI.java
@@ -68,7 +68,8 @@ public static void main(String... args) {
+ "- AZUREKEYVAULT: Azure Key Vault key management system\n"
+ "- DIGICERTONE: DigiCert ONE Secure Software Manager\n"
+ "- ESIGNER: SSL.com eSigner\n"
- + "- GOOGLECLOUD: Google Cloud KMS\n").build());
+ + "- GOOGLECLOUD: Google Cloud KMS\n"
+ + "- HASHICORPVAULT: Google Cloud KMS via HashiCorp Vault\n").build());
options.addOption(Option.builder("a").hasArg().longOpt(PARAM_ALIAS).argName("NAME").desc("The alias of the certificate used for signing in the keystore.").build());
options.addOption(Option.builder().hasArg().longOpt(PARAM_KEYPASS).argName("PASSWORD").desc("The password of the private key. When using a keystore, this parameter can be omitted if the keystore shares the same password.").build());
options.addOption(Option.builder().hasArg().longOpt(PARAM_KEYFILE).argName("FILE").desc("The file containing the private key. PEM and PVK files are supported. ").type(File.class).build());
diff --git a/jsign-core/src/main/java/net/jsign/KeyStoreType.java b/jsign-core/src/main/java/net/jsign/KeyStoreType.java
index a9a5d35b..1621a67a 100644
--- a/jsign-core/src/main/java/net/jsign/KeyStoreType.java
+++ b/jsign-core/src/main/java/net/jsign/KeyStoreType.java
@@ -38,6 +38,7 @@
import net.jsign.jca.DigiCertOneSigningService;
import net.jsign.jca.ESignerSigningService;
import net.jsign.jca.GoogleCloudSigningService;
+import net.jsign.jca.HashiCorpVaultSigningService;
import net.jsign.jca.OpenPGPCardSigningService;
import net.jsign.jca.SigningServiceJcaProvider;
@@ -327,6 +328,33 @@ Provider getProvider(KeyStoreBuilder params) {
}
}));
}
+ },
+
+ /** HashiCorp Vault secrets engine (GCP only) */
+ HASHICORPVAULT(false, false, false) {
+ @Override
+ void validate(KeyStoreBuilder params) {
+ if (params.keystore() == null) {
+ throw new IllegalArgumentException("keystore " + params.parameterName() + " must specify the HashiCorp Vault secrets engine URL");
+ }
+ if (params.storepass() == null) {
+ throw new IllegalArgumentException("storepass " + params.parameterName() + " must specify the HashiCorp Vault token");
+ }
+ if (params.certfile() == null) {
+ throw new IllegalArgumentException("certfile " + params.parameterName() + " must be set");
+ }
+ }
+
+ @Override
+ Provider getProvider(KeyStoreBuilder params) {
+ return new SigningServiceJcaProvider(new HashiCorpVaultSigningService(params.keystore(), params.storepass(), alias -> {
+ try {
+ return CertificateUtils.loadCertificateChain(params.certfile());
+ } catch (IOException | CertificateException e) {
+ throw new RuntimeException("Failed to load the certificate from " + params.certfile(), e);
+ }
+ }));
+ }
};
diff --git a/jsign-core/src/main/java/net/jsign/jca/HashiCorpVaultSigningService.java b/jsign-core/src/main/java/net/jsign/jca/HashiCorpVaultSigningService.java
new file mode 100644
index 00000000..7796ef4a
--- /dev/null
+++ b/jsign-core/src/main/java/net/jsign/jca/HashiCorpVaultSigningService.java
@@ -0,0 +1,146 @@
+/**
+ * Copyright 2023 Maria Merkel
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.jsign.jca;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.KeyStoreException;
+import java.security.UnrecoverableKeyException;
+import java.security.cert.Certificate;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import com.cedarsoftware.util.io.JsonWriter;
+
+import net.jsign.DigestAlgorithm;
+
+/**
+ * Signing service using the HashiCorp Vault API.
+ *
+ * @see HashiCorp Vault API - Google Cloud KMS Secrets Engine
+ * @since 5.0
+ */
+public class HashiCorpVaultSigningService implements SigningService {
+
+ private final Function certificateStore;
+
+ /** Cache of private keys indexed by id */
+ private final Map keys = new HashMap<>();
+
+ private final RESTClient client;
+
+ /**
+ * Creates a new HashiCorp Vault signing service.
+ *
+ * @param engineURL the URL of the HashiCorp Vault secrets engine
+ * @param token the HashiCorp Vault token
+ * @param certificateStore provides the certificate chain for the keys
+ */
+ public HashiCorpVaultSigningService(String engineURL, String token, Function certificateStore) {
+ this.certificateStore = certificateStore;
+ this.client = new RESTClient(engineURL.endsWith("/") ? engineURL : engineURL + "/", conn -> conn.setRequestProperty("Authorization", "Bearer " + token));
+ }
+
+ @Override
+ public String getName() {
+ return "HashiCorpVault";
+ }
+
+ /**
+ * Returns the list of key names available in the secrets engine.
+ *
+ * NOTE: This will return the key name only, not the key name and version.
+ * HashiCorp Vault does not provide a function to retrieve the key version.
+ * The key version will need to be appended to the key name when using the key.
+ *
+ * @return list of key names
+ */
+ @Override
+ public List aliases() throws KeyStoreException {
+ List aliases;
+
+ try {
+ Map response = client.get("keys?list=true");
+ String[] keys = ((Map) response.get("data")).get("keys");
+ aliases = Arrays.asList(keys);
+ } catch (IOException e) {
+ throw new KeyStoreException(e);
+ }
+
+ return aliases;
+ }
+
+ @Override
+ public Certificate[] getCertificateChain(String alias) throws KeyStoreException {
+ return certificateStore.apply(alias);
+ }
+
+ @Override
+ public SigningServicePrivateKey getPrivateKey(String alias, char[] password) throws UnrecoverableKeyException {
+ if (keys.containsKey(alias)) {
+ return keys.get(alias);
+ }
+
+ if (!alias.contains(":")) {
+ throw new UnrecoverableKeyException("Unable to fetch HashiCorp Vault Google Cloud private key '" + alias + "' (missing key version)");
+ }
+
+ String algorithm;
+
+ try {
+ Map response = client.get("keys/" + alias.substring(0, alias.indexOf(":")));
+ algorithm = ((Map) response.get("data")).get("algorithm");
+ } catch (IOException e) {
+ throw (UnrecoverableKeyException) new UnrecoverableKeyException("Unable to fetch HashiCorp Vault Google Cloud private key '" + alias + "'").initCause(e);
+ }
+
+ algorithm = algorithm.substring(0, algorithm.indexOf("_")).toUpperCase();
+
+ SigningServicePrivateKey key = new SigningServicePrivateKey(alias, algorithm);
+ keys.put(alias, key);
+ return key;
+ }
+
+ @Override
+ public byte[] sign(SigningServicePrivateKey privateKey, String algorithm, byte[] data) throws GeneralSecurityException {
+ DigestAlgorithm digestAlgorithm = DigestAlgorithm.of(algorithm.substring(0, algorithm.toLowerCase().indexOf("with")));
+ data = digestAlgorithm.getMessageDigest().digest(data);
+
+ String alias = privateKey.getId();
+ String keyName = alias.substring(0, alias.indexOf(":"));
+ String keyVersion = alias.substring(alias.indexOf(":") + 1);
+
+ Map request = new HashMap<>();
+ request.put("key_version", keyVersion);
+ request.put("digest", Base64.getEncoder().encodeToString(data));
+
+ try {
+ Map args = new HashMap<>();
+ args.put(JsonWriter.TYPE, "false");
+ Map response = client.post("sign/" + keyName, JsonWriter.objectToJson(request, args));
+ String signature = ((Map) response.get("data")).get("signature");
+
+ return Base64.getDecoder().decode(signature);
+ } catch (IOException e) {
+ throw new GeneralSecurityException(e);
+ }
+ }
+}
diff --git a/jsign-core/src/test/java/net/jsign/KeyStoreBuilderTest.java b/jsign-core/src/test/java/net/jsign/KeyStoreBuilderTest.java
index 7246f7bc..e33a0a5b 100644
--- a/jsign-core/src/test/java/net/jsign/KeyStoreBuilderTest.java
+++ b/jsign-core/src/test/java/net/jsign/KeyStoreBuilderTest.java
@@ -267,6 +267,41 @@ public void testBuildGoogleCloud() throws Exception {
assertNotNull("keystore", keystore);
}
+ @Test
+ public void testBuildHashiCorpVault() throws Exception {
+ KeyStoreBuilder builder = new KeyStoreBuilder().storetype(HASHICORPVAULT);
+
+ try {
+ builder.build();
+ fail("Exception not thrown");
+ } catch (IllegalArgumentException e) {
+ assertEquals("message", "keystore parameter must specify the HashiCorp Vault secrets engine URL", e.getMessage());
+ }
+
+ builder.keystore("https://vault.example.com:8200/v1/gcpkms/");
+
+ try {
+ builder.build();
+ fail("Exception not thrown");
+ } catch (IllegalArgumentException e) {
+ assertEquals("message", "storepass parameter must specify the HashiCorp Vault token", e.getMessage());
+ }
+
+ builder.storepass("0123456789ABCDEF");
+
+ try {
+ builder.build();
+ fail("Exception not thrown");
+ } catch (IllegalArgumentException e) {
+ assertEquals("message", "certfile parameter must be set", e.getMessage());
+ }
+
+ builder.certfile("keystores/jsign-test-certificate.pem");
+
+ KeyStore keystore = builder.build();
+ assertNotNull("keystore", keystore);
+ }
+
@Test
public void testBuildJKS() throws Exception {
KeyStoreBuilder builder = new KeyStoreBuilder().storetype(JKS);
diff --git a/jsign/src/deb/data/usr/share/bash-completion/completions/jsign b/jsign/src/deb/data/usr/share/bash-completion/completions/jsign
index 5108aacf..6bfe22f9 100644
--- a/jsign/src/deb/data/usr/share/bash-completion/completions/jsign
+++ b/jsign/src/deb/data/usr/share/bash-completion/completions/jsign
@@ -22,7 +22,7 @@ _jsign()
return 0
;;
--storetype)
- COMPREPLY=( $( compgen -W 'JKS JCEKS PKCS12 PKCS11 AWS AZUREKEYVAULT DIGICERTONE ESIGNER GOOGLECLOUD YUBIKEY NITROKEY OPENPGP OPENSC' -- "$cur" ) )
+ COMPREPLY=( $( compgen -W 'JKS JCEKS PKCS12 PKCS11 AWS AZUREKEYVAULT DIGICERTONE ESIGNER GOOGLECLOUD HASHICORPVAULT YUBIKEY NITROKEY OPENPGP OPENSC' -- "$cur" ) )
return 0
;;
--storepass|-a|--alias|--keypass|-t|--tsaurl|-r|--tsretries|-w|--tsretrywait|-n|--name|-u|--url|-e|--encoding)
diff --git a/jsign/src/deb/data/usr/share/man/man1/jsign.1 b/jsign/src/deb/data/usr/share/man/man1/jsign.1
index 62ab6422..36456b19 100644
--- a/jsign/src/deb/data/usr/share/man/man1/jsign.1
+++ b/jsign/src/deb/data/usr/share/man/man1/jsign.1
@@ -64,6 +64,8 @@ The type of the keystore:
.br
- GOOGLECLOUD : Google Cloud KMS
.br
+- HASHICORPVAULT : Google Cloud KMS via HashiCorp Vault
+.br
This option is not required for file based keystores (JKS, JCEKS and PKCS12).
.TP
@@ -292,6 +294,17 @@ jsign --storetype GOOGLECLOUD --storepass \\
--keystore projects/first-rain-123/locations/global/keyRings/mykeyring \\
--alias test --certfile full-chain.pem application.exe
+Signing with Google Cloud KMS via HashiCorp Vault:
+
+Google Cloud KMS stores only the private key, the certificate must be provided separately. The keystore parameter
+references the URL of the HashiCorp Vault secrets engine, consisting of the Vault server URL, the API version v1
+and the secrets engine path. The alias specifies the name of the key in Vault and the key version in Google Cloud
+separated by a colon character.
+
+jsign --storetype HASHICORPVAULT --storepass \\
+ --keystore https://vault.example.com/v1/gcpkms \\
+ --alias test:1 --certfile full-chain.pem application.exe
+
.SH REPORTING BUGS
Bugs and suggestions can be reported to the project home page: https://ebourg.github.io/jsign