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

  • 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