diff --git a/.gitattributes b/.gitattributes index 097f9f9..a762216 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ +## Copyright 2024, Usman Saleem. +## SPDX-License-Identifier: (Apache-2.0 OR MIT) # # https://help.github.com/articles/dealing-with-line-endings/ # diff --git a/.gitignore b/.gitignore index 1f93dfc..ab5e240 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +## Copyright 2024, Usman Saleem. +## SPDX-License-Identifier: (Apache-2.0 OR MIT) + # Ignore Gradle project-specific cache directory .gradle diff --git a/README.md b/README.md index 409a2f9..5846c0e 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,42 @@ # Besu Plugin - PKCS11 SoftHSM -A [Besu plugin](https://besu.hyperledger.org/private-networks/reference/plugin-api-interfaces) that shows how to -integrate with HSM with PKCS11 interface. SoftHSM is used as a test HSM. +A [Besu plugin][1] that provides a custom security module to load the [node key][2] from an HSM, such as [SoftHSM][3], +using PKCS11 libraries. + +[1]: +[2]: +[3]: ![GitHub Actions Workflow Status](https://github.com/usmansaleem/besu-pkcs11-plugin/actions/workflows/ci.yml/badge.svg?branch=main) ![GitHub Release](https://img.shields.io/github/v/release/usmansaleem/besu-pkcs11-plugin?include_prereleases) ## Build Instructions +You can either use pre-built jar from Assets section in [releases](https://github.com/usmansaleem/besu-pkcs11-plugin/releases) +or build it yourself. + +> [!NOTE] +> This project requires Java 21 or later. If it is not available, the gradle build will attempt to download one and use it. + +- Check [Besu releases](https://github.com/hyperledger/besu/releases) for latest stable version and update it in +[`gradle/libs.versions.toml`](gradle/libs.versions.toml). For example: + +```toml +[versions] +besu = "24.6.0" +``` + +- Build the plugin: + ```shell ./gradlew clean build ``` +The plugin jar will be available at `build/libs/besu-pkcs11-plugin-.jar`. + ## Usage -Drop the `jar` in the `/plugins` folder under Besu installation. This plugin will expose following additional cli -options: +Drop the `besu-pkcs11-plugin-.jar` in the `/plugins` folder under Besu installation. This plugin will expose +following additional cli options: `TBA` ## Linux SoftHSM Setup diff --git a/build.gradle.kts b/build.gradle.kts index 2158784..cdc4109 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +// Copyright 2024, Usman Saleem. +// SPDX-License-Identifier: (Apache-2.0 OR MIT) import org.jreleaser.model.Active import org.jreleaser.model.Distribution import org.jreleaser.model.UpdateSection @@ -14,16 +16,30 @@ project.group = "info.usmans.tools" repositories { // Use Maven Central for resolving dependencies. mavenCentral() + + // For Besu plugin dependencies + maven { + url = uri("https://hyperledger.jfrog.io/artifactory/besu-maven/") + content { includeGroupByRegex("org\\.hyperledger\\.besu($|\\..*)") } + } } dependencies { - // Use JUnit Jupiter for testing. - testImplementation(libs.junit.jupiter) + // This project jar is not supposed to be used as compilation dependency. + // `api` is used here to distinguish between dependencies which should be used IF it is to be used + // as a dependency during compiling some other library that depends on this project. + api(libs.besu.plugin.api) + api(libs.bcprov) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + // https://github.com/google/auto/tree/main/service + annotationProcessor(libs.google.auto.service) + implementation(libs.google.auto.service.annotations) + implementation(libs.slf4j.api) + implementation(libs.picocli) - // This dependency is exported to consumers, that is to say found on their compile classpath. - api(libs.bcprov) + // testing dependencies + testImplementation(libs.junit.jupiter) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } // Apply a specific Java toolchain to ease working on different environments. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 36fba47..f12ca91 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,9 @@ # https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format [versions] +besu = "24.6.0" +picocli = "4.7.5" # same version as in Besu +google-auto-service = "1.1.1" junit-jupiter = "5.10.2" spotless = "6.25.0" slf4j = "2.0.13" @@ -12,10 +15,14 @@ jgitver = "0.10.0-rc03" jreleaser = "1.13.1" [libraries] -junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } +besu-plugin-api = {module = "org.hyperledger.besu:plugin-api", version.ref = "besu"} +google-auto-service = { module = "com.google.auto.service:auto-service", version.ref = "google-auto-service" } +google-auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "google-auto-service" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncy-castle" } +picocli = {module = "info.picocli:picocli", version.ref = "picocli"} +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } [plugins] spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/settings.gradle.kts b/settings.gradle.kts index a227ff7..b64939b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,5 @@ +// Copyright 2024, Usman Saleem. +// SPDX-License-Identifier: (Apache-2.0 OR MIT) plugins { // Apply the foojay-resolver plugin to allow automatic download of JDKs id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" diff --git a/src/main/java/info/usmans/besu/plugin/softhsm/BesuHSMPlugin.java b/src/main/java/info/usmans/besu/plugin/softhsm/BesuHSMPlugin.java deleted file mode 100644 index ca6796f..0000000 --- a/src/main/java/info/usmans/besu/plugin/softhsm/BesuHSMPlugin.java +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright 2024, Usman Saleem. -// SPDX-License-Identifier: (Apache-2.0 OR MIT) -package info.usmans.besu.plugin.softhsm; - -public class BesuHSMPlugin {} diff --git a/src/main/java/info/usmans/besu/plugin/softhsm/Pkcs11HsmPlugin.java b/src/main/java/info/usmans/besu/plugin/softhsm/Pkcs11HsmPlugin.java new file mode 100644 index 0000000..18f095f --- /dev/null +++ b/src/main/java/info/usmans/besu/plugin/softhsm/Pkcs11HsmPlugin.java @@ -0,0 +1,67 @@ +// Copyright 2024, Usman Saleem. +// SPDX-License-Identifier: (Apache-2.0 OR MIT) +package info.usmans.besu.plugin.softhsm; + +import com.google.auto.service.AutoService; +import org.hyperledger.besu.plugin.BesuContext; +import org.hyperledger.besu.plugin.BesuPlugin; +import org.hyperledger.besu.plugin.services.PicoCLIOptions; +import org.hyperledger.besu.plugin.services.SecurityModuleService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A Besu plugin that provides a custom security module to load the node key from an HSM using + * PKCS11 libraries. + */ +@AutoService(BesuPlugin.class) +public class Pkcs11HsmPlugin implements BesuPlugin { + static final String SECURITY_MODULE_NAME = "pkcs11-hsm"; + private static final Logger LOG = LoggerFactory.getLogger(Pkcs11HsmPlugin.class); + private final Pkcs11PluginCliOptions cliParams = new Pkcs11PluginCliOptions(); + + @Override + public void register(final BesuContext besuContext) { + LOG.info("Registering plugin ..."); + registerCliOptions(besuContext); + registerSecurityModule(besuContext); + } + + /** + * Registers {@code Pkcs11PluginCliOptions} with {@code PicoCLIOptions} service provided by {@code + * BesuContext}. + * + * @param besuContext An instance of {@code BesuContext} + */ + private void registerCliOptions(final BesuContext besuContext) { + besuContext + .getService(PicoCLIOptions.class) + .orElseThrow(() -> new IllegalStateException("Expecting PicoCLIOptions to be present")) + .addPicoCLIOptions(SECURITY_MODULE_NAME, cliParams); + } + + /** + * Registers {@code Pkcs11SecurityModule} with the {@code SecurityModuleService} service provided + * by {@code BesuContext}. + * + * @param besuContext An instance of {@code BesuContext} + */ + private void registerSecurityModule(final BesuContext besuContext) { + // lazy-init our security module implementation during register phase + besuContext + .getService(SecurityModuleService.class) + .orElseThrow( + () -> new IllegalStateException("Expecting SecurityModuleService to be present")) + .register(SECURITY_MODULE_NAME, () -> new Pkcs11SecurityModuleService(cliParams)); + } + + @Override + public void start() { + LOG.debug("Starting plugin ..."); + } + + @Override + public void stop() { + LOG.debug("Stopping plugin ..."); + } +} diff --git a/src/main/java/info/usmans/besu/plugin/softhsm/Pkcs11PluginCliOptions.java b/src/main/java/info/usmans/besu/plugin/softhsm/Pkcs11PluginCliOptions.java new file mode 100644 index 0000000..d616716 --- /dev/null +++ b/src/main/java/info/usmans/besu/plugin/softhsm/Pkcs11PluginCliOptions.java @@ -0,0 +1,71 @@ +// Copyright 2024, Usman Saleem. +// SPDX-License-Identifier: (Apache-2.0 OR MIT) +package info.usmans.besu.plugin.softhsm; + +import static info.usmans.besu.plugin.softhsm.Pkcs11HsmPlugin.SECURITY_MODULE_NAME; + +import java.nio.file.Path; +import picocli.CommandLine.Option; + +/** Represents cli options that are required by the Besu PKCS11-SoftHSM plugin. */ +public class Pkcs11PluginCliOptions { + @Option( + names = "--plugin-" + SECURITY_MODULE_NAME + "-config-path", + description = "Path to the PKCS11 configuration file", + required = true, + paramLabel = "") + private Path pkcs11ConfigPath; + + @Option( + names = "--plugin-" + SECURITY_MODULE_NAME + "-password-path", + description = "Path to the file that contains password or PIN to access PKCS11 token", + required = true, + paramLabel = "") + private Path pkcs11PasswordPath; + + @Option( + names = "--plugin-" + SECURITY_MODULE_NAME + "-key-alias", + description = "Alias or label of the private key that is stored in the HSM", + required = true, + paramLabel = "") + private String privateKeyAlias; + + /** Default constructor. Performs no initialization. */ + public Pkcs11PluginCliOptions() {} + + /** + * Constructor that initializes the PKCS11 configuration file path. + * + * @param pkcs11ConfigPath the path to the PKCS11 configuration file + */ + public Pkcs11PluginCliOptions(final Path pkcs11ConfigPath) { + this.pkcs11ConfigPath = pkcs11ConfigPath; + } + + /** + * Returns the path to the PKCS11 configuration file. + * + * @return the path to the PKCS11 configuration file + */ + public Path getPkcs11ConfigPath() { + return pkcs11ConfigPath; + } + + /** + * Returns the path to the file that contains the password or PIN to access the PKCS11 token. + * + * @return the path to the file that contains the password or PIN to access the PKCS11 token + */ + public Path getPkcs11PasswordPath() { + return pkcs11PasswordPath; + } + + /** + * Returns the alias or label of the private key that is stored in the HSM. + * + * @return the alias or label of the private key that is stored in the HSM + */ + public String getPrivateKeyAlias() { + return privateKeyAlias; + } +} diff --git a/src/main/java/info/usmans/besu/plugin/softhsm/Pkcs11SecurityModuleService.java b/src/main/java/info/usmans/besu/plugin/softhsm/Pkcs11SecurityModuleService.java new file mode 100644 index 0000000..75bc3d8 --- /dev/null +++ b/src/main/java/info/usmans/besu/plugin/softhsm/Pkcs11SecurityModuleService.java @@ -0,0 +1,184 @@ +// Copyright 2024, Usman Saleem. +// SPDX-License-Identifier: (Apache-2.0 OR MIT) +package info.usmans.besu.plugin.softhsm; + +import static info.usmans.besu.plugin.softhsm.SignatureUtil.extractRAndSFromDERSignature; + +import java.io.IOException; +import java.nio.file.Files; +import java.security.Key; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.Provider; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECParameterSpec; +import javax.crypto.KeyAgreement; +import org.apache.tuweni.bytes.Bytes32; +import org.hyperledger.besu.plugin.services.securitymodule.SecurityModule; +import org.hyperledger.besu.plugin.services.securitymodule.SecurityModuleException; +import org.hyperledger.besu.plugin.services.securitymodule.data.PublicKey; +import org.hyperledger.besu.plugin.services.securitymodule.data.Signature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** A PKCS11 based implementation of Besu SecurityModule interface. */ +public class Pkcs11SecurityModuleService implements SecurityModule { + private static final Logger LOG = LoggerFactory.getLogger(Pkcs11SecurityModuleService.class); + private final Pkcs11PluginCliOptions cliParams; + private Provider provider; + private KeyStore keyStore; + private PrivateKey privateKey; + private ECPublicKey ecPublicKey; + private ECParameterSpec secp256k1Param; + + public Pkcs11SecurityModuleService(final Pkcs11PluginCliOptions cliParams) { + LOG.debug("Creating Pkcs11SecurityModuleService ..."); + this.cliParams = cliParams; + validateCliOptions(); + loadPkcs11Provider(); + loadPkcs11Keystore(); + loadPkcs11PrivateKey(); + loadPkcs11PublicKey(); + } + + private void validateCliOptions() { + if (cliParams.getPkcs11ConfigPath() == null) { + throw new SecurityModuleException("PKCS11 configuration file path is not provided"); + } + if (cliParams.getPkcs11PasswordPath() == null) { + throw new SecurityModuleException("PKCS11 password file path is not provided"); + } + if (cliParams.getPrivateKeyAlias() == null) { + throw new SecurityModuleException("PKCS11 private key alias is not provided"); + } + } + + private void loadPkcs11Provider() { + // initialize PKCS11 provider + LOG.info("Initializing PKCS11 provider ..."); + + try { + provider = + Security.getProvider("SUNPKCS11").configure(cliParams.getPkcs11ConfigPath().toString()); + Security.addProvider(provider); + } catch (final Exception e) { + throw new SecurityModuleException( + "Error encountered while loading SunPKCS11 provider with configuration: " + + cliParams.getPkcs11ConfigPath().toString(), + e); + } + } + + private void loadPkcs11Keystore() { + LOG.info("Loading PKCS11 keystore ..."); + final char[] charArray; + try { + charArray = Files.readString(cliParams.getPkcs11PasswordPath()).toCharArray(); + } catch (final IOException e) { + throw new SecurityModuleException( + "Error reading file: " + cliParams.getPkcs11PasswordPath(), e); + } + + try { + keyStore = KeyStore.getInstance("PKCS11", provider); + keyStore.load(null, charArray); + } catch (final Exception e) { + throw new SecurityModuleException("Error loading PKCS11 keystore", e); + } + } + + private void loadPkcs11PrivateKey() { + LOG.info("Loading private key ..."); + final Key key; + try { + key = keyStore.getKey(cliParams.getPrivateKeyAlias(), new char[0]); + } catch (final Exception e) { + throw new SecurityModuleException( + "Error loading private key for alias: " + cliParams.getPrivateKeyAlias(), e); + } + + if (!(key instanceof PrivateKey)) { + throw new SecurityModuleException( + "Loaded key is not a PrivateKey for alias: " + cliParams.getPrivateKeyAlias()); + } + + privateKey = (PrivateKey) key; + } + + private void loadPkcs11PublicKey() { + LOG.info("Loading public key ..."); + final Certificate certificate; + try { + certificate = keyStore.getCertificate(cliParams.getPrivateKeyAlias()); + if (certificate == null) { + throw new SecurityModuleException( + "Certificate not found for private key alias: " + cliParams.getPrivateKeyAlias()); + } + } catch (final Exception e) { + throw new SecurityModuleException( + "Error while loading certificate for private key alias: " + + cliParams.getPrivateKeyAlias(), + e); + } + + final java.security.PublicKey publicKey; + try { + publicKey = certificate.getPublicKey(); + } catch (final Exception e) { + throw new SecurityModuleException( + "Error while loading public key for alias: " + cliParams.getPrivateKeyAlias(), e); + } + + if (!(publicKey instanceof ECPublicKey)) { + throw new SecurityModuleException( + "Public Key is not a valid ECPublicKey for alias: " + cliParams.getPrivateKeyAlias()); + } + ecPublicKey = (ECPublicKey) publicKey; + // we could use a constant, for now we will get it from the public key + secp256k1Param = ecPublicKey.getParams(); + } + + @Override + public Signature sign(Bytes32 dataHash) throws SecurityModuleException { + try { + // Java classes generate ASN1 encoded signature, + // Besu needs P1363 i.e. R and S of the signature + final java.security.Signature signature = + java.security.Signature.getInstance("SHA256WithECDSA", provider); + signature.initSign(privateKey); + signature.update(dataHash.toArray()); + final byte[] sigBytes = signature.sign(); + return extractRAndSFromDERSignature(sigBytes); + } catch (final Exception e) { + if (e instanceof SecurityModuleException) { + throw (SecurityModuleException) e; + } + throw new SecurityModuleException("Error initializing signature", e); + } + } + + @Override + public PublicKey getPublicKey() throws SecurityModuleException { + return ecPublicKey::getW; + } + + @Override + public Bytes32 calculateECDHKeyAgreement(PublicKey theirKey) throws SecurityModuleException { + LOG.debug("Calculating ECDH key agreement ..."); + // convert Besu PublicKey (which wraps ECPoint) to java.security.PublicKey + java.security.PublicKey theirPublicKey = + SignatureUtil.eCPointToPublicKey(theirKey.getW(), secp256k1Param, provider); + + // generate ECDH Key Agreement + try { + final KeyAgreement keyAgreement = KeyAgreement.getInstance("ECDH", provider); + keyAgreement.init(privateKey); + keyAgreement.doPhase(theirPublicKey, true); + return Bytes32.wrap(keyAgreement.generateSecret()); + } catch (final Exception e) { + throw new SecurityModuleException("Error calculating ECDH key agreement", e); + } + } +} diff --git a/src/main/java/info/usmans/besu/plugin/softhsm/SignatureUtil.java b/src/main/java/info/usmans/besu/plugin/softhsm/SignatureUtil.java new file mode 100644 index 0000000..135fdce --- /dev/null +++ b/src/main/java/info/usmans/besu/plugin/softhsm/SignatureUtil.java @@ -0,0 +1,88 @@ +// Copyright 2024, Usman Saleem. +// SPDX-License-Identifier: (Apache-2.0 OR MIT) +package info.usmans.besu.plugin.softhsm; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.Provider; +import java.security.PublicKey; +import java.security.spec.ECParameterSpec; +import java.security.spec.ECPoint; +import java.security.spec.ECPublicKeySpec; +import java.security.spec.InvalidKeySpecException; +import java.util.NoSuchElementException; +import org.bouncycastle.asn1.ASN1InputStream; +import org.bouncycastle.asn1.ASN1Integer; +import org.bouncycastle.asn1.DLSequence; +import org.hyperledger.besu.plugin.services.securitymodule.SecurityModuleException; +import org.hyperledger.besu.plugin.services.securitymodule.data.Signature; + +/** Helper class to provide signature utility methods. */ +public class SignatureUtil { + /** + * Uses Bouncycastle to decode DER signature. A DER signature format is SEQUENCE := {r INTEGER, s + * INTEGER} + * + * @param der DER encoded byte[] + * @return Array of BigInteger containing R and S. + */ + public static Signature extractRAndSFromDERSignature(final byte[] der) { + try (final ASN1InputStream asn1InputStream = new ASN1InputStream(der)) { + final DLSequence seq = (DLSequence) asn1InputStream.readObject(); + if (seq == null) { + throw new SecurityModuleException("Unexpected end of ASN.1 stream."); + } + + final ASN1Integer r = (ASN1Integer) seq.getObjectAt(0); + final ASN1Integer s = (ASN1Integer) seq.getObjectAt(1); + + return new SignatureImpl(r.getPositiveValue(), s.getPositiveValue()); + } catch (final Exception e) { + throw new SecurityModuleException(e); + } + } + + /** + * Converts ECPoint to PublicKey using PKCS11 provider. + * + * @param theirECPoint ECPoint of other party + * @param secp256k1ParamSpec SECP256K1 parameter spec + * @param pkcs11Provider PKCS11 provider + * @return PublicKey of other party generated from ECPoint + * @throws SecurityModuleException wrapping cause of original exception = + */ + public static PublicKey eCPointToPublicKey( + final ECPoint theirECPoint, + final ECParameterSpec secp256k1ParamSpec, + final Provider pkcs11Provider) + throws SecurityModuleException { + try { + return KeyFactory.getInstance("EC", pkcs11Provider) + .generatePublic(new ECPublicKeySpec(theirECPoint, secp256k1ParamSpec)); + } catch (InvalidKeySpecException | NoSuchElementException | NoSuchAlgorithmException e) { + throw new SecurityModuleException("Unexpected error converting ECPoint to PublicKey", e); + } + } + + /** Static inner class to represent a signature. */ + static class SignatureImpl implements Signature { + private final BigInteger r; + private final BigInteger s; + + public SignatureImpl(final BigInteger r, final BigInteger s) { + this.r = r; + this.s = s; + } + + @Override + public BigInteger getR() { + return r; + } + + @Override + public BigInteger getS() { + return s; + } + } +}