Skip to content

Commit

Permalink
Merge pull request #1 from inductiveautomation/master
Browse files Browse the repository at this point in the history
from main branch
  • Loading branch information
benmusson authored Aug 5, 2024
2 parents 57a4acb + a3f4a6c commit 81e986e
Show file tree
Hide file tree
Showing 21 changed files with 1,310 additions and 291 deletions.
5 changes: 1 addition & 4 deletions generator/generator-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,7 @@ tasks {
named<KotlinCompile>("compileTestKotlin") {
// don't try compiling resources that somehow end up in the test compilation path when we add the integration
// test suite
sourceSets {
exclude("**/resources/**/*.groovy")
exclude("**/resources/**/*.kts")
}
exclude("**/resources/**/*.kts")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ data class GeneratorConfig constructor(
* generated, as it is assumed the plugin will be established via 'includeBuild' in settings.gradle
* pluginManagement.
*/
val modulePluginVersion: String = "0.1.1",
val modulePluginVersion: String = "0.2.0",

/**
* If signing the module should be required, set to false. Set to true by default to allow building the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,34 @@ ignitionModule {
*
* Example Value:
* moduleDependencies = [
"com.inductiveautomation.vision": "CD",
"com.inductiveautomation.opcua": "G"
* "com.inductiveautomation.vision": "CD",
* "com.inductiveautomation.opcua": "G"
* ]
*/
moduleDependencies = [ : ]

/*
* Add required module dependencies here, following the examples, with scope being one or more of G, C or D,
* for (G)ateway, (D)esigner, Vision (C)lient.
*
* Example:
* moduleDependencySpecs {
* register("com.inductiveautomation.vision") {
* scope = "GCD"
* required = true
* }
* // register("com.another.mod") { ...
* }
*
* If any of module's required module dependencies are not present, the
* gateway will fault on loading the module.
*
* NOTE: For modules targeting Ignition 8.3 and later. Use `moduleDependencies` for 8.1 and earlier.
* This property will only add the "required" flag if {requiredIgnitionVersion} is at least 8.3
*
*/
moduleDependencySpecs { }

/*
* Map of fully qualified hook class to the shorthand scope. Only one scope per hook class.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,28 @@ ignitionModule {
*/
moduleDependencies.set(mapOf<String, String>())

/*
* Add required module dependencies here, following the examples, with scope being one or more of G, C or D,
* for (G)ateway, (D)esigner, Vision (C)lient.
*
* Example:
* moduleDependencySpecs {
* register("com.inductiveautomation.vision") {
* scope = "GCD"
* required = true
* }
* // register("com.another.mod") { ...
* }
*
* If any of module's required module dependencies are not present, the
* gateway will fault on loading the module.
*
* NOTE: For modules targeting Ignition 8.3 and later. Use `moduleDependencies` for 8.1 and earlier.
* This property will only add the "required" flag if {requiredIgnitionVersion} is at least 8.3
*
*/
moduleDependencySpecs { }

/*
* Map of fully qualified hook class to the shorthand scope. Only one scope may apply to a class, and each scope
* must have no more than single class registered. You may omit scope registrations if they do not apply.
Expand Down
110 changes: 68 additions & 42 deletions gradle-module-plugin/README.md

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions gradle-module-plugin/SIGNING_VIA_HSM.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
*Signing modules via PKCS#11/HSM keystores is an incubating feature. See the note about the `certFile` setting below. The initial implementation was validated against a YubiKey 5 NFC, using the `9a` (PIV Authentication) instead of the `9c` (Digital Signature) slot. This is a workaround to the [repeated PIN challenge on every signing operation inherent to that slot](https://forum.inductiveautomation.com/t/ckr-user-not-logged-in-error-when-signing-zip-file-with-sectigo-usb-etoken/58107). In a future release this plugin should be able to support slot `9c`.*

# Signing Via Hardware Security Module
It's possible to sign a module using a PKCS#11 keystore, which is usually a hardware token such as a YubiKey. (There are software PKCS#11 keystores as well, though they are more common for testing in that they do not have the physical attributes that make for secure HSMs.)

This is an alternative to PKCS#12 file-base keystores that typically reside in the subdirectory of a user's home directory.

## Prerequesites
You should have the following:

* A hardware (or software) PKCS#11 compliant keystore that supports the [Java `SunPKCS11` provider](https://docs.oracle.com/en/java/javase/17/security/pkcs11-reference-guide1.html#GUID-6DA72F34-6C6A-4F7D-ADBA-5811576A9331).
* A PKCS#11 driver (or module) locally on your build workstation or on a build server, depending on the use case. Your HSM may be compatible with the [OpenSC Minidriver](https://github.com/OpenSC/OpenSC/wiki), which supports a wide variety of hardware tokens. Note that you may not get full support for `SunPKCS11` provider unless you use a driver from the HSM vendor. For example, YubiKey provides the [YKCS11 driver](https://developers.yubico.com/yubico-piv-tool/YKCS11/) as part of its `yubico-piv-tool` installation.
* The `SunPKCS11` provider requires you have a PKCS#11 configuration file specifying, among other things, the location of that driver on your filesystem. You can see examples of such files for [YubiKeys on Windows](gradle-module-plugin/src/functionalTest/resources/certs/pkcs11-yk5-win.cfg) and for [OpenSC on Linux](gradle-module-plugin/src/functionalTest/resources/certs/pkcs11.cfg) in this repository.
* Unless your HSM already contains your signing key(s), you may need a management application such as `yubico-piv-tool` or YubiKey Manager to generate a keypair on your device. You may also be able to use lower-level tools like `pkcs11-tool` that is bundled with the OpenSC tool suite or `keytool` that comes with Java.

## Steps
The first few steps here are heavily dependent on your HSM's support of generic key management tools like OpenSC `pkcs11-tool` or Java `keytool`, or alternatively the vendor's key management tool.

It is often the case however that even if you cannot generate (or import) a keypair with the generic tools, you may be able to list key information from the HSM with those tools.

1. Generate or import a SHA256 with RSA-type keypair using either the generic or vendor-specific key management tool. (Currently, the `module-signer` library called by the plugin looks [only for private keys with the `SHA256withRSA` algorithm type](https://github.com/inductiveautomation/module-signer/blob/master/src/main/java/com/inductiveautomation/ignitionsdk/ModuleSigner.java/#L95). Support for different key algorithms is in the planning stage.)
2. If you do not already have a cert file for the key on the filesystem, use the key management to export it from the keystore onto the filesystem. For purposes of module signing, the cert can be self-signed. It need not come from a Certificate Authority. In the future the plugin may be able to retrieve the cert from the HSM directly.
3. Retrieve the key alias using one of the key management tools, ideally via `keytool -list`. Note that your HSM may contain multiple private keys even if you only generated or imported a single signing key in step 1. With some HSMs cases you may be able to specify the key alias yourself during generation or import, and in other cases the HSM or the vendor key manager may hardcode it. Either way you need to note that alias for the next step.
4. Whether in a `gradle.properties` file or via command flags, sign your module as follows. We'll use command flags for clarity. Note how we do *not* pass `keystoreFile` as the private signing key is in the HSM. However PKCS#11 keystores almost universally require a PIN to "unlock" them, so `keystorePassword` is still required.

```bash
$ gradlew :signModule \
--certAlias modsigning \
--certFile ~/.ssl/modsigning.cert \
--certPassword $CERT_PASSWORD_OR_PIN \
--keystorePassword $KS_PASSWORD_OR_PIN \
--pkcs11CfgFile $PATH_OF_PKCS11_CFG_FILE
```
2 changes: 1 addition & 1 deletion gradle-module-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ repositories {
}

group = "io.ia.sdk"
version = "0.1.2-SNAPSHOT"
version = "0.3.0"

configurations {
val functionalTestImplementation by registering {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,6 @@ package io.ia.sdk.gradle.modl

import io.ia.ignition.module.generator.api.GeneratorConfig
import io.ia.ignition.module.generator.api.GeneratorConfigBuilder
import io.ia.sdk.gradle.modl.api.Constants
import io.ia.sdk.gradle.modl.api.Constants.ALIAS_FLAG
import io.ia.sdk.gradle.modl.api.Constants.CERT_FILE_FLAG
import io.ia.sdk.gradle.modl.api.Constants.CERT_PW_FLAG
import io.ia.sdk.gradle.modl.api.Constants.KEYSTORE_FILE_FLAG
import io.ia.sdk.gradle.modl.api.Constants.KEYSTORE_PW_FLAG
import io.ia.sdk.gradle.modl.util.nameToDirName
import org.gradle.testkit.runner.BuildResult
import org.gradle.testkit.runner.GradleRunner
import org.junit.Rule
Expand All @@ -18,24 +11,29 @@ import java.nio.file.Files
import java.nio.file.Path

data class SigningResources(
val keystore: Path,
val certFile: Path,
val keystore: Path?,
val certFile: Path?,
val pkcs11Cfg: Path?,
val signPropFile: Path?
)

open class BaseTest {
companion object {
val CLIENT_DEP = "// add client scoped dependencies here"
val DESIGNER_DEP = "// add designer scoped dependencies here"
val GW_DEP = "// add gateway scoped dependencies here"
val COMMON_DEP = "// add common scoped dependencies here"
val SIGN_PROPS = "signing.properties"
const val CLIENT_DEP = "// add client scoped dependencies here"
const val DESIGNER_DEP = "// add designer scoped dependencies here"
const val GW_DEP = "// add gateway scoped dependencies here"
const val COMMON_DEP = "// add common scoped dependencies here"
val SIGNING_PROPERTY_ENTRIES = """
${Constants.SIGNING_PROPERTIES[ALIAS_FLAG]}=selfsigned
${Constants.SIGNING_PROPERTIES[KEYSTORE_FILE_FLAG]}=./keystore.jks
${Constants.SIGNING_PROPERTIES[KEYSTORE_PW_FLAG]}=password
${Constants.SIGNING_PROPERTIES[CERT_FILE_FLAG]}=./certificate.pem
${Constants.SIGNING_PROPERTIES[CERT_PW_FLAG]}=password
ignition.signing.certAlias=selfsigned
ignition.signing.keystorePassword=password
ignition.signing.certFile=./certificate.pem
ignition.signing.certPassword=password
""".trimIndent()
val KEYSTORE_PROPERTY_ENTRIES = """
ignition.signing.keystoreFile=./keystore.jks
""".trimIndent()
val PKCS11_PROPERTY_ENTRIES = """
ignition.signing.pkcs11CfgFile=./pkcs11.cfg
""".trimIndent()
}

Expand All @@ -50,55 +48,97 @@ open class BaseTest {
return name
}

protected fun prepSigningResourcesForModuleName(
parentDirectory: Path,
moduleName: String,
withPropFile: Boolean = true
): SigningResources {
val projectDir = nameToDirName(moduleName)
return prepareSigningTestResources(parentDirectory.resolve(projectDir), withPropFile)
}

// writes a gradle.properties file containing the signing credentials needed for signing a module using test
// resources
protected fun writeSigningCredentials(targetDirectory: Path): Path {
protected fun writeSigningCredentials(
targetDirectory: Path,
keystoreProps: String,
writeBoilerplateProps: Boolean = true
): Path {
val gradleProps: Path = targetDirectory.resolve("gradle.properties")
val gradlePropsFl: File = gradleProps.toFile()
val props = buildString {
// add a trailing EOL if necessary, then common props
if (
gradlePropsFl.exists() &&
!gradlePropsFl.readText().matches(Regex("""\R$"""))
) {
append("\n")
}
if (writeBoilerplateProps) {
append("$SIGNING_PROPERTY_ENTRIES\n")
}

if (Files.exists(gradleProps)) {
val content = gradleProps.toFile().readText(Charsets.UTF_8)
gradleProps.toFile().writeText(content + "\n" + SIGNING_PROPERTY_ENTRIES)
} else {
gradleProps.toFile().writeText(SIGNING_PROPERTY_ENTRIES, Charsets.UTF_8)
// this could be file-based or PKCS#11 HSM-based keystore props
append(keystoreProps)
}

// uncomment if you need a little debugging
// println("props:\n$props")
gradlePropsFl.appendText(props)

return gradleProps
}

fun moduleDirName(moduleName: String): String {
return moduleName.replace(" ", "-").lowercase()
}

// returns the path to the 'signing.properties' file
// file-based keystore
protected fun prepareSigningTestResources(targetDirectory: Path, withPropFile: Boolean = true): SigningResources {
Files.createDirectories(targetDirectory)
val paths = writeResourceFiles(
targetDirectory,
listOf("certificate.pem", "keystore.jks")
)

val propFile =
if (withPropFile)
writeSigningCredentials(targetDirectory, KEYSTORE_PROPERTY_ENTRIES)
else null
return SigningResources(
certFile = paths[0] as Path,
keystore = paths[1] as Path,
pkcs11Cfg = null,
signPropFile = propFile,
)
}

// PKCS#11 HSM-based keystore
protected fun preparePKCS11SigningTestResources(
targetDirectory: Path,
withPropFile: Boolean = true
): SigningResources {
val paths = writeResourceFiles(
targetDirectory,
listOf("certificate.pem", "pkcs11.cfg")
)

val propFile =
if (withPropFile)
writeSigningCredentials(targetDirectory, PKCS11_PROPERTY_ENTRIES)
else null
return SigningResources(
certFile = paths[0] as Path,
keystore = null,
pkcs11Cfg = paths[1] as Path,
signPropFile = propFile,
)
}

protected fun writeResourceFiles(targetDir: Path, resources: List<String>): List<Path?> {
Files.createDirectories(targetDir)

val paths: List<Path?> = listOf("certificate.pem", "keystore.jks").map { resourcePath ->
ClassLoader.getSystemResourceAsStream("certs/$resourcePath").let { inputStream ->
return resources.map { resource ->
ClassLoader.getSystemResourceAsStream("certs/$resource").use { inputStream ->
if (inputStream == null) {
throw Exception("Failed to read test resource 'certs/$resourcePath")
throw Exception("Failed to read test resource 'certs/$resource")
}
val writeTo = targetDirectory.resolve(resourcePath)
val writeTo = targetDir.resolve(resource)
inputStream.copyTo(writeTo.toFile().outputStream(), 1024)

writeTo
}
}

return SigningResources(
certFile = paths[0] as Path,
keystore = paths[1] as Path,
signPropFile = if (withPropFile) writeSigningCredentials(targetDirectory) else null
)
}

open fun config(name: String, scope: String, pkg: String): GeneratorConfig {
Expand All @@ -116,16 +156,24 @@ open class BaseTest {
.build()
}

open fun runTask(projectDir: File, taskArgs: List<String>): BuildResult {
val runner = GradleRunner.create()
runner.forwardOutput()
runner.withPluginClasspath()
runner.withArguments(taskArgs)
runner.withProjectDir(projectDir)
return runner.build()
}

open fun runTask(projectDir: File, task: String): BuildResult {
return runTask(projectDir, listOf(task))
}
open fun runTask(projectDir: File, taskArgs: List<String>): BuildResult =
setupRunner(projectDir, taskArgs).build()

open fun runTask(projectDir: File, task: String): BuildResult =
setupRunner(projectDir, listOf(task)).build()

open fun runTaskAndFail(
projectDir: File,
taskArgs: List<String>
): BuildResult = setupRunner(projectDir, taskArgs).buildAndFail()

private fun setupRunner(
projectDir: File,
taskArgs: List<String>
): GradleRunner =
GradleRunner.create()
.forwardOutput()
.withPluginClasspath()
.withArguments(taskArgs)
.withProjectDir(projectDir)
}
Loading

0 comments on commit 81e986e

Please sign in to comment.