diff --git a/generator/generator-core/build.gradle.kts b/generator/generator-core/build.gradle.kts index 70f76d7..e892d90 100644 --- a/generator/generator-core/build.gradle.kts +++ b/generator/generator-core/build.gradle.kts @@ -47,10 +47,7 @@ tasks { named("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") } } diff --git a/generator/generator-core/src/main/kotlin/io/ia/ignition/module/generator/api/GeneratorConfig.kt b/generator/generator-core/src/main/kotlin/io/ia/ignition/module/generator/api/GeneratorConfig.kt index 425238f..18a948a 100644 --- a/generator/generator-core/src/main/kotlin/io/ia/ignition/module/generator/api/GeneratorConfig.kt +++ b/generator/generator-core/src/main/kotlin/io/ia/ignition/module/generator/api/GeneratorConfig.kt @@ -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 diff --git a/generator/generator-core/src/main/resources/templates/config/modlPluginConfig.groovy b/generator/generator-core/src/main/resources/templates/config/modlPluginConfig.groovy index da2cb27..d33a351 100644 --- a/generator/generator-core/src/main/resources/templates/config/modlPluginConfig.groovy +++ b/generator/generator-core/src/main/resources/templates/config/modlPluginConfig.groovy @@ -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. * diff --git a/generator/generator-core/src/main/resources/templates/config/modlPluginConfig.kts b/generator/generator-core/src/main/resources/templates/config/modlPluginConfig.kts index 83cad04..090882a 100644 --- a/generator/generator-core/src/main/resources/templates/config/modlPluginConfig.kts +++ b/generator/generator-core/src/main/resources/templates/config/modlPluginConfig.kts @@ -46,6 +46,28 @@ ignitionModule { */ moduleDependencies.set(mapOf()) + /* + * 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. diff --git a/gradle-module-plugin/README.md b/gradle-module-plugin/README.md index 926b93c..911e5a5 100644 --- a/gradle-module-plugin/README.md +++ b/gradle-module-plugin/README.md @@ -1,6 +1,6 @@ # Ignition Module Plugin for Gradle -The Ignition platform is an open/pluggable JVM based system that uses Ignition Modules to add functionality. As documented in the [Ignition SDK Programmer's Guide](https://docs.inductiveautomation.com/display/SE/Ignition+SDK+Programmers+Guide), an Ignition Module consists of an xml manifest, jar files, and additional resources and meta-information. +The Ignition platform is an open/pluggable JVM based system that uses Ignition Modules to add functionality. As documented in the [Ignition SDK Programmer's Guide](https://docs.inductiveautomation.com/display/SE/Ignition+SDK+Programmers+Guide), an Ignition Module consists of an xml manifest, jar files, and additional resources and meta-information. The Ignition Module Plugin for Gradle lets module developers use the [Gradle](https://www.gradle.org) build tool to create and sign functional modules (_.modl_ ) through a convenient DSL-based configuration model. @@ -12,7 +12,7 @@ The easiest way to get started with this plugin is to create a new module projec To apply the plugin to an existing project, simply follow the instructions on the plugin's [Gradle Plugin Repo Page](https://plugins.gradle.org/plugin/io.ia.sdk.modl), and then configure as described below. -1. Apply the plugin to a `build.gradle` or `build.gradle.kts` file. In the case of a multi-project build, apply to the root or parent project. *Note* that you should only apply the plugin to a single parent project in a multi-scope structure (e.g., one where you have separate source directories for `gateway` and `designer` code, for instance). If you have questions about structure, use the module generator to create well-structured examples. +1. Apply the plugin to a `build.gradle` or `build.gradle.kts` file. In the case of a multi-project build, apply to the root or parent project. *Note* that you should only apply the plugin to a single parent project in a multi-scope structure (e.g., one where you have separate source directories for `gateway` and `designer` code, for instance). If you have questions about structure, use the module generator to create well-structured examples. For current versions of gradle, simply add to your `build.gradle.kts`: @@ -32,44 +32,43 @@ plugins { } ``` -2. Configure your module through the `ignitionModule` configuration DSL. See DSL properties section below for details. +2. Configure your module through the `ignitionModule` configuration DSL. See DSL properties section below for details. -3. Configure your signing settings, either in a gradle.properties file, or as commandline flags. The required properties are defined in constants.kt, and used in the SignModule task. You may mix and match flags and properties (and flags will override properties), as long as all required values are configured. The only requirement is that option flags _must_ follow the gradle command to which they apply, which is the 'signModule' task in this case. The flags/properties are as follows, with usage examples: - >Note: builds prior to v0.1.0-SNAPSHOT-6 used a separate property file called signing.properties. Builds after that use gradle.properties files instead. +3. Configure your signing settings, either in a gradle.properties file, or as commandline flags. The required properties are defined in `Constants.kt`, and used in the `SignModule` task. You may mix and match flags and properties (and flags will override properties), as long as all required values are configured. The only requirement is that option flags _must_ follow the Gradle task to which they apply, which is `signModule`. The `keystoreFile` and `pkcs11CfgFile` settings are mutually exclusive, the former indicating a file-based keystore and the latter indicating the config file for a PKCS#11 HSM (such as a YubiKey) keystore. Use one or the other but not both. All flags/properties are as follows, with usage examples: - | Flag | Usage | gradle.properties entry | - |-------|--------|-------------------------| - | certAlias | gradlew signModule --certAlias=someAlias | ignition.signing.certAlias=someAlias | - | certFile | gradlew signModule --certFile=/path/to/cert | ignition.signing.certFile=/path/to/cert | - | certPassword | gradlew signModule --certPassword=mysecret | ignition.signing.certFile=mysecret | - | keystoreFile | gradlew signModule --keystoreFile=/path/to/keystore | ignition.signing.keystoreFile=/path/to/keystore | - | keystorePassword | gradlew signModule --keystorePassword=mysecret | ignition.signing.keystoreFile=mysecret | + | Flag | Usage | gradle.properties entry | + |-------|--------------------------------------------------------|----------------------------------------------------| + | certAlias | gradlew signModule --certAlias=someAlias | ignition.signing.certAlias=someAlias | + | certFile | gradlew signModule --certFile=/path/to/cert | ignition.signing.certFile=/path/to/cert | + | certPassword | gradlew signModule --certPassword=certPwdOrPIN | ignition.signing.certPassword=certPwdOrPIN | + | keystoreFile | gradlew signModule --keystoreFile=/path/to/keystore | ignition.signing.keystoreFile=/path/to/keystore | + | pkcs11CfgFile | gradlew signModule --pkcs11CfgFile=/path/to/pkcs11.cfg | ignition.signing.pkcs11CfgFile=/path/to/pkcs11.cfg | + | keystorePassword | gradlew signModule --keystorePassword=ksPwdOrPIN | ignition.signing.keystorePassword=ksPwdOrPIN | +4. When depending on artifacts (dependencies) from the Ignition SDK, they should be specified as `compileOnly` or `compileOnlyApi` dependencies as they will be provided by the Ignition platform at runtime. Dependencies that are applied with either the `modlApi` or `modlImplementation` _Configuration_ in any subproject of your module will be collected and included in the final modl file, including transitive dependencies. In general, behaviors of the _modl_ configuration follow those documented by the Gradle java-library plugin (e.g. - publishing, artifact uploading, transitive dependency handling, etc). Test-only dependencies should NOT be marked with any `modl` configuration. Test and Compile-time dependencies should be specified in accordance with the best practices described in Gradle's `java-library` [plugin documentation](https://docs.gradle.org/current/userguide/java_library_plugin.html). -4. When depending on artifacts (dependencies) from the Ignition SDK, they should be specified as `compileOnly` or `compileOnlyApi` dependencies as they will be provided by the Ignition platform at runtime. Dependencies that are applied with either the `modlApi` or `modlImplementation` _Configuration_ in any subproject of your module will be collected and included in the final modl file, including transitive dependencies. In general, behaviors of the _modl_ configuration follow those documented by the Gradle java-library plugin (e.g. - publishing, artifact uploading, transitive dependency handling, etc). Test-only dependencies should NOT be marked with any `modl` configuration. Test and Compile-time dependencies should be specified in accordance with the best practices described in Gradle's `java-library` [plugin documentation](https://docs.gradle.org/current/userguide/java_library_plugin.html). - -Choosing which [Configuration](https://docs.gradle.org/current/userguide/declaring_dependencies.html) to apply may have important but subtle impacts on your module, as well as your development/build environment. In general, the following rule of thumb is a good starting point: +Choosing which [Configuration](https://docs.gradle.org/current/userguide/declaring_dependencies.html) to apply may have important but subtle impacts on your module, as well as your development/build environment. In general, the following rule of thumb is a good starting point: | Configuration | Usage Suggestion | Included in Module? | Includes Transitive Dependencies In Module? | Exposes Transitive Dependencies to Artifact Consumers˟? | |-------|--------|-------------------------|----------|---------| -| compileOnly | Use for 'compile time only' dependencies, including ignition sdk dependencies. Similar to maven 'provided'. | No | No | No | -| compileOnlyApi | Use for 'compile time only' dependencies, including ignition sdk dependencies. Similar to maven 'provided'. | No | No | No | +| compileOnly | Use for 'compile time only' dependencies, including ignition sdk dependencies. Similar to maven 'provided'. | No | No | No | +| compileOnlyApi | Use for 'compile time only' dependencies, including ignition sdk dependencies. Similar to maven 'provided'. | No | No | No | | api | Project dependencies that do not explicitly get registered in the module DSL project scopes✝ | No | No | Yes | | implementation | Project dependencies that do not explicitly get registered in the module DSL project scopes✝ | No | No | No | | modlImplementation | Dependencies that are used in a module project's implementation, but are not part of a public API | Yes | Yes | No | | modlApi | Dependencies that are used in a module project and are exposed to dependents✝✝ | Yes | Yes | Yes | -> ✝ - api and implementation configurations and generally best reserved for internal/intra-project dependencies. Meaning, if you have a module with projects A,B,C, and D, where A is only a supporting library for D (aka - it is not registered as a 'projectScope', but is merely a dependency of D), then `api` or `implementation` would be appropriate. Choose `api` if D exposes A as part of it's Application Binary Interface (ABI). Otherwise, choose `implementation` if A is only used internally for the implementation of D.
-> ✝✝ - modlApi is a very uncommon use case, generally reserved only for modules which themselves expose an API that is to be extended by other modules. Examples of Inductive Automation modules that use this include Opc-Ua, which exposes an API for driver module implementations, or Perspective, which exposes an API for Component Authors through the SDK. If you do not support an SDK for your module, then you should probably use `modlImplementation`, as it will encourage better separation of concerns in your project.
-> ˟ 'Artifact Consumers' refers to projects that may depend on ('consume') the library (aka - gradle subproject) you are writing, or if published to an artifact repo, consumers of the maven artifact. Dependencies that are not part of a project's ABI should avoid being specified with `modlApi` to avoid leaking implementation details into the compile-time classpath of the consuming project.

-> **Maven Users**: If you're familiar with Maven's dependency scopes, you might initially find Gradle's handling of dependencies to be unnecessarily convoluted. This is a product of Gradle's powerful (but more complex) dependency management. We suggest reading the gradle docs on [Working With Dependencies](https://docs.gradle.org/current/userguide/core_dependency_management.html), followed by reading the [Java Library Plugin](https://docs.gradle.org/current/userguide/java_library_plugin.html#sec:java_library_separation) documentation. +> ✝ - api and implementation configurations and generally best reserved for internal/intra-project dependencies. Meaning, if you have a module with projects A,B,C, and D, where A is only a supporting library for D (aka - it is not registered as a 'projectScope', but is merely a dependency of D), then `api` or `implementation` would be appropriate. Choose `api` if D exposes A as part of it's Application Binary Interface (ABI). Otherwise, choose `implementation` if A is only used internally for the implementation of D.
+> ✝✝ - modlApi is a very uncommon use case, generally reserved only for modules which themselves expose an API that is to be extended by other modules. Examples of Inductive Automation modules that use this include Opc-Ua, which exposes an API for driver module implementations, or Perspective, which exposes an API for Component Authors through the SDK. If you do not support an SDK for your module, then you should probably use `modlImplementation`, as it will encourage better separation of concerns in your project.
+> ˟ 'Artifact Consumers' refers to projects that may depend on ('consume') the library (aka - gradle subproject) you are writing, or if published to an artifact repo, consumers of the maven artifact. Dependencies that are not part of a project's ABI should avoid being specified with `modlApi` to avoid leaking implementation details into the compile-time classpath of the consuming project.

+> **Maven Users**: If you're familiar with Maven's dependency scopes, you might initially find Gradle's handling of dependencies to be unnecessarily convoluted. This is a product of Gradle's powerful (but more complex) dependency management. We suggest reading the gradle docs on [Working With Dependencies](https://docs.gradle.org/current/userguide/core_dependency_management.html), followed by reading the [Java Library Plugin](https://docs.gradle.org/current/userguide/java_library_plugin.html#sec:java_library_separation) documentation. ### `ignitionModule` DSL Properties -Configuration for a module occurs through the `ignitionModule` extension DSL. See the source code `ModuleSettings.kt` for all options and descriptions. Example configuration in a groovy buildscript: +Configuration for a module occurs through the `ignitionModule` extension DSL. See the source code `ModuleSettings.kt` for all options and descriptions. Example configuration in a groovy buildscript: ```groovy ignitionModule { @@ -83,7 +82,7 @@ ignitionModule { */ fileName = "starlink-driver" /* - * Unique identifier for the module. Reverse domain convention is recommended (e.g.: com.mycompany.charting-module) + * Unique identifier for the module. Reverse domain convention is recommended (e.g.: com.mycompany.charting-module) */ id = "net.starlink.driver" @@ -91,7 +90,7 @@ ignitionModule { moduleDescription = "A short sentence describing what it does, but not much longer than this." /* - * Minimum version of Ignition required for the module to function correctly. This typically won't change over + * Minimum version of Ignition required for the module to function correctly. This typically won't change over * the course of a major Ignition (7.9, 8.0, etc) version, except for when the Ignition Platform adds/changes APIs * used by the module. */ @@ -111,13 +110,13 @@ ignitionModule { * * Example Value: * moduleDependencies = [ - "com.inductiveautomation.opcua": "G" + * "com.inductiveautomation.opcua": "G" * ] */ moduleDependencies = [ : ] // syntax for initializing an empty map in groovy /* - * Map of fully qualified hook class to the shorthand scope. Only one scope per hook class. + * Map of fully qualified hook class to the shorthand scope. Only one scope per hook class. * * Example entry: "com.myorganization.vectorizer.VectorizerDesignerHook": "D" */ @@ -126,16 +125,16 @@ ignitionModule { ] /** - * Optional map of arbitrary String to String entries. These will make it into the final _buildResult.json_, but - * are otherwise unused and have no impact on the module itself. These values may be useful for adding data to - * used by consumers of this build's output. For instance: CI and publication systems, integrity checking, etc. + * Optional map of arbitrary String to String entries. These will make it into the final _buildResult.json_, but + * are otherwise unused and have no impact on the module itself. These values may be useful for adding data to + * used by consumers of this build's output. For instance: CI and publication systems, integrity checking, etc. */ metaInfo.put("someKey", "Some arbitrary value useful to later use") metaInfo.put("publicationUrl", "1.2.3.4:8090") } ``` -Configuring in a kotlin buildscript is similar, except that you'll want to use the appropriate `set()` methods. Here is an example: +Configuring in a kotlin buildscript is similar, except that you'll want to use the appropriate `set()` methods. Here is an example: ```kotlin ignitionModule { @@ -149,19 +148,19 @@ ignitionModule { */ fileName.set("starlink-driver") /* - * Unique identifier for the module. Reverse domain convention is recommended (e.g.: com.mycompany.charting-module) + * Unique identifier for the module. Reverse domain convention is recommended (e.g.: com.mycompany.charting-module) */ id.set("net.starlink.driver") /* - * Version of the module. Here being set to the same version that gradle uses, up above in this file. + * Version of the module. Here being set to the same version that gradle uses, up above in this file. */ moduleVersion.set("${project.version}") moduleDescription.set("A short sentence describing what it does, but not much longer than this.") /* - * Minimum version of Ignition required for the module to function correctly. This typically won't change over + * Minimum version of Ignition required for the module to function correctly. This typically won't change over * the course of a major Ignition (7.9, 8.0, etc) version, except for when the Ignition Platform adds/changes APIs * used by the module. */ @@ -188,9 +187,36 @@ ignitionModule { "com.inductiveautomation.opcua" to "G" )) + /* + * 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 { + register("com.inductiveautomation.vision") { + scope = "CD" + required = true + } + } + /* - * 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. + * 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. * * Example entry: "com.myorganization.vectorizer.VectorizerDesignerHook" to "D" */ @@ -199,7 +225,7 @@ ignitionModule { )) /* - * Optional 'documentation' settings. Supply the files that would be desired to end up in the 'doc' dir of the + * Optional 'documentation' settings. Supply the files that would be desired to end up in the 'doc' dir of the * assembled module, and specify the path to the index.html file inside that folder. In this commented-out * example, the html files being collected are located in the module root project in `src/docs/` */ @@ -223,9 +249,9 @@ ignitionModule { The module plugin exposes a number of tasks that may be run on their own, and some which are bound to lifecycle tasks -provided by Gradle's [Base Plugin](https://docs.gradle.org/current/userguide/base_plugin.html). Some tasks apply +provided by Gradle's [Base Plugin](https://docs.gradle.org/current/userguide/base_plugin.html). Some tasks apply only to the root project (the project which is applying the plugin), while others are applied to one or more -subprojects. The following table is a brief reference: +subprojects. The following table is a brief reference: | Task | Scope | Description | @@ -240,7 +266,7 @@ subprojects. The following table is a brief reference: | deployModl | root project | deploys the built module file to an ignition gateway running in developer module upload mode ˟| > ˟ to enable the developer mode, add `-Dia.developer.moduleupload=true` to the 'Java Additional Parameters' in -> the `ignition.conf` file and restart the gateway. **This should only be done on secure development gateways, as it +> the `ignition.conf` file and restart the gateway. **This should only be done on secure development gateways, as it > opens a significant security risk on production gateways, in addition to instabilities that may result from your > in-development module.** @@ -283,10 +309,10 @@ the result. # Pre-Release API Changes -* v0.1.0-SNAPSHOT-6 - changed how credentials and files are specified for signing and publication. The keys are the same, but properties are now expected to exist in a gradle.properties file, or to be specified as runtime flags as described in the Usage section above. -* v0.1.0-SNAPSHOT-12 - added checksum generation and build report tasks. Split repo into separate builds using gradle build composition to better isolate changes. +* v0.1.0-SNAPSHOT-6 - changed how credentials and files are specified for signing and publication. The keys are the same, but properties are now expected to exist in a gradle.properties file, or to be specified as runtime flags as described in the Usage section above. +* v0.1.0-SNAPSHOT-12 - added checksum generation and build report tasks. Split repo into separate builds using gradle build composition to better isolate changes. * v0.1.0-SNAPSHOT-15 - fixed dependency collection, renamed 'AssembleModuleAssets' task class and associated task * v0.1.0-SNAPSHOT-16 - changed modlImplementation configuration to not resolve transitive dependencies * v0.1.0-SNAPSHOT-17 - modlImplementation and modlApi configurations have been replaced with a single `modlDependency` configuration to simplify dependency marking and avoid confusing differences between compile-time and modl runtime environments, -* v0.1.0-SNAPSHOT-18 - reverted to separate modlImplementation and modlApi configurations. However, modlImplementation retains the same resolution semantics as gradle's `implementation`, while also fully resolving `modlImplementation`'s transitive dependencies for inclusion into the modl. This change results in logical handling of dependencies in IDE/gradle environments, while also ensuring that needed dependencies are bundled in the module for use a runtime. +* v0.1.0-SNAPSHOT-18 - reverted to separate modlImplementation and modlApi configurations. However, modlImplementation retains the same resolution semantics as gradle's `implementation`, while also fully resolving `modlImplementation`'s transitive dependencies for inclusion into the modl. This change results in logical handling of dependencies in IDE/gradle environments, while also ensuring that needed dependencies are bundled in the module for use a runtime. * v0.1.1-SNAPSHOT-1 - added the `skipModlSigning` ModuleSetting property to support skipping the `signModl` task and populating the `build.json`'s filename prop with the unsigned module allowing development without sharing/needing the signing keys. diff --git a/gradle-module-plugin/SIGNING_VIA_HSM.md b/gradle-module-plugin/SIGNING_VIA_HSM.md new file mode 100644 index 0000000..750c07b --- /dev/null +++ b/gradle-module-plugin/SIGNING_VIA_HSM.md @@ -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 +``` diff --git a/gradle-module-plugin/build.gradle.kts b/gradle-module-plugin/build.gradle.kts index 29f5c33..b336aa2 100644 --- a/gradle-module-plugin/build.gradle.kts +++ b/gradle-module-plugin/build.gradle.kts @@ -20,7 +20,7 @@ repositories { } group = "io.ia.sdk" -version = "0.1.2-SNAPSHOT" +version = "0.3.0" configurations { val functionalTestImplementation by registering { diff --git a/gradle-module-plugin/src/functionalTest/kotlin/io/ia/sdk/gradle/modl/BaseTest.kt b/gradle-module-plugin/src/functionalTest/kotlin/io/ia/sdk/gradle/modl/BaseTest.kt index 62cc137..7bf66f0 100644 --- a/gradle-module-plugin/src/functionalTest/kotlin/io/ia/sdk/gradle/modl/BaseTest.kt +++ b/gradle-module-plugin/src/functionalTest/kotlin/io/ia/sdk/gradle/modl/BaseTest.kt @@ -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 @@ -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() } @@ -50,27 +48,35 @@ 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 } @@ -78,27 +84,61 @@ open class BaseTest { 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): List { + Files.createDirectories(targetDir) - val paths: List = 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 { @@ -116,16 +156,24 @@ open class BaseTest { .build() } - open fun runTask(projectDir: File, taskArgs: List): 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): 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 + ): BuildResult = setupRunner(projectDir, taskArgs).buildAndFail() + + private fun setupRunner( + projectDir: File, + taskArgs: List + ): GradleRunner = + GradleRunner.create() + .forwardOutput() + .withPluginClasspath() + .withArguments(taskArgs) + .withProjectDir(projectDir) } diff --git a/gradle-module-plugin/src/functionalTest/kotlin/io/ia/sdk/gradle/modl/task/SignModuleTest.kt b/gradle-module-plugin/src/functionalTest/kotlin/io/ia/sdk/gradle/modl/task/SignModuleTest.kt index 1eed532..6018174 100644 --- a/gradle-module-plugin/src/functionalTest/kotlin/io/ia/sdk/gradle/modl/task/SignModuleTest.kt +++ b/gradle-module-plugin/src/functionalTest/kotlin/io/ia/sdk/gradle/modl/task/SignModuleTest.kt @@ -1,89 +1,99 @@ package io.ia.sdk.gradle.modl.task import com.inductiveautomation.ignitionsdk.ZipMap +import com.inductiveautomation.ignitionsdk.ZipMapFile import io.ia.ignition.module.generator.ModuleGenerator import io.ia.ignition.module.generator.api.GeneratorConfigBuilder import io.ia.ignition.module.generator.api.GradleDsl import io.ia.sdk.gradle.modl.BaseTest import io.ia.sdk.gradle.modl.util.signedModuleName +import io.ia.sdk.gradle.modl.util.unsignedModuleName import org.gradle.testkit.runner.BuildResult +import org.gradle.testkit.runner.TaskOutcome import org.junit.Test import java.io.File +import java.nio.file.Path +import kotlin.test.Ignore +import kotlin.test.assertContains +import kotlin.test.assertEquals import kotlin.test.assertNotNull -import kotlin.test.assertNull import kotlin.test.assertTrue class SignModuleTest : BaseTest() { companion object { const val PATH_KEY = "" + const val MODULE_NAME = "I Was Signed" + // For a specific YubiKey 5; you may need to change this for another key + val PKCS11_HSM_SIGNING_PROPERTY_ENTRIES = """ + # Hack around YK5 (signing) slot 9c's second PIN challenge on + # signing following the initial keystore PIN challenge by using a + # cert in (auth) slot 9a. + # + #ignition.signing.certAlias=X.509 Certificate for Digital Signature + ignition.signing.certAlias=X.509 Certificate for PIV Authentication + ignition.signing.keystorePassword=123456 + ignition.signing.certFile=./pkcs11-yk5-win.crt + # + # Ignored for now, but may be used in future for slot 9c. + ignition.signing.certPassword=password + ignition.signing.pkcs11CfgFile=./pkcs11-yk5-win.cfg + """.trimIndent() + + const val SIG_PROPERTIES_FILENAME = "signatures.properties" + const val CERT_PKCS7_FILENAME = "certificates.p7b" } @Test fun `module built and signed successfully with gradle properties file`() { val parentDir: File = tempFolder.newFolder("module_built_and_signed_successfully") - val moduleName = "I Was Signed" val signingResourcesDestination = parentDir.toPath().resolve("i-was-signed") prepareSigningTestResources(signingResourcesDestination) - val config = GeneratorConfigBuilder() - .moduleName(moduleName) - .scopes("GCD") - .packageName("check.my.signage") - .parentDir(parentDir.toPath()) - .debugPluginConfig(true) - .allowUnsignedModules(false) - .settingsDsl(GradleDsl.GROOVY) - .rootPluginConfig( - """ - id("io.ia.sdk.modl") - """.trimIndent() - ) - .build() - - val projectDir = ModuleGenerator.generate(config) + val projectDir = generateModule(parentDir) - val result = runTask(projectDir.toFile(), "signModule") + runTask( + projectDir.toFile(), listOf("signModule", "--stacktrace") + ) val buildDir = projectDir.resolve("build") - val signedFileName = signedModuleName(moduleName) + val signedFileName = signedModuleName(MODULE_NAME) val signedFilePath = "${buildDir.toAbsolutePath()}/$signedFileName" val signed = File(signedFilePath) - // unzip and look for signatures file + // unzip and look for signatures properties file val zm = ZipMap(signed) - val file = zm.get("signatures.properties") + val sigPropsFile = zm[SIG_PROPERTIES_FILENAME] + assertTrue(signed.exists(), "Expected $signed to exist") + assertNotNull( + sigPropsFile, + "Expected $SIG_PROPERTIES_FILENAME in signed modl" + ) - assertTrue(signed.exists(), "signed file exists") - assertNotNull(file, "signatures.properties found in signed modl") - assertTrue(result.output.toString().contains("SUCCESSFUL")) + // and the cert file + val certFile = zm[CERT_PKCS7_FILENAME] + assertNotNull( + certFile, + "Expected $CERT_PKCS7_FILENAME in signed modl" + ) + + // If you want to dump file contents to stdout, uncomment this + // logZipMapFileText(SIG_PROPERTIES_FILENAME, sigPropsFile) + // logZipMapFileText(CERT_PKCS7_FILENAME, certFile) } @Test fun `module signed with cmdline flags`() { val parentDir: File = tempFolder.newFolder("module_signed_with_cmdline_flags") - val moduleName = "I Was Signed" val signingResourcesDestination = parentDir.toPath().resolve("i-was-signed") - val signResources = prepareSigningTestResources(signingResourcesDestination, false) - - val config = GeneratorConfigBuilder() - .moduleName(moduleName) - .scopes("GCD") - .packageName("check.my.signage") - .parentDir(parentDir.toPath()) - .debugPluginConfig(true) - .allowUnsignedModules(false) - .settingsDsl(GradleDsl.GROOVY) - .rootPluginConfig( - """ - id("io.ia.sdk.modl") - """.trimIndent() - ) - .build() + val signResources = prepareSigningTestResources( + signingResourcesDestination, + withPropFile = false, + ) - val projectDir = ModuleGenerator.generate(config) + val projectDir = generateModule(parentDir) val taskArgs = listOf( ":signModule", @@ -93,117 +103,548 @@ class SignModuleTest : BaseTest() { "--certAlias=selfsigned", "--certPassword=password", "--stacktrace", - "--info" ) runTask(projectDir.toFile(), taskArgs) val buildDir = projectDir.resolve("build") - val signedFileName = signedModuleName(moduleName) + val signedFileName = signedModuleName(MODULE_NAME) val signedFilePath = "${buildDir.toAbsolutePath()}/$signedFileName" val signed = File(signedFilePath) - // unzip and look for signatures file + // unzip and look for signatures properties file val zm = ZipMap(signed) - val file = zm.get("signatures.properties") + val sigPropsFile = zm[SIG_PROPERTIES_FILENAME] + assertTrue(signed.exists(), "Expected $signed to exist") + assertNotNull( + sigPropsFile, + "Expected $SIG_PROPERTIES_FILENAME in signed modl" + ) + + // and the cert file + val certFile = zm[CERT_PKCS7_FILENAME] + assertNotNull( + certFile, + "Expected $CERT_PKCS7_FILENAME in signed modl" + ) - assertTrue(signed.exists(), "signed file exists") - assertNotNull(file, "signatures.properties found in signed modl") + // If you want to dump file contents to stdout, uncomment this + // logZipMapFileText(SIG_PROPERTIES_FILENAME, sigPropsFile) + // logZipMapFileText(CERT_PKCS7_FILENAME, certFile) } @Test + // @Tag("IGN-7871") fun `module signing failed due to missing signing configuration properties`() { - val name = "I Was Signed" val dirName = currentMethodName() - val config = GeneratorConfigBuilder() - .moduleName(name) - .scopes("GCD") - .packageName("check.my.signage") - .parentDir(tempFolder.newFolder(dirName).toPath()) - .allowUnsignedModules(false) - .settingsDsl(GradleDsl.GROOVY) - .build() - val projectDir = ModuleGenerator.generate(config) - var result: BuildResult? = null - var msg: String = "" - try { - result = runTask(projectDir.toFile(), listOf("signModule", "--certAlias=something")) - } catch (e: Exception) { - msg = e.message.toString() - } + val projectDir = generateModule(tempFolder.newFolder(dirName)) + + val result: BuildResult = + runTaskAndFail( + projectDir.toFile(), + listOf("signModule", "--certAlias=something") + ) - val expected = "Some problems were found with the configuration of task ':signModule' (type 'SignModule')." + val out = result.output + assertNotNull(out, "Expected exception with message") - val output: String? = result?.output - assertNull(output, "Should have received output from build attempt") - assertNotNull(msg, "should have exception message") - assertTrue(msg.contains(expected), "Execution failed due to missing sign props") + assertContains(out, "Required certificate file location not found") + assertContains(out, "Specify via flag '--certFile='") + assertContains( + out, + "file as 'ignition.signing.certFile=" + ) } @Test - fun `module failed with missing keystore pw flags`() { + // @Tag("IGN-7871") + fun `module signed despite missing keystore pw flag`() { val dirName = currentMethodName() - val moduleName = "I Was Signed" val workingDir: File = tempFolder.newFolder(dirName) - val config = GeneratorConfigBuilder() - .moduleName(moduleName) - .scopes("GCD") - .packageName("check.my.signage") - .parentDir(workingDir.toPath()) - .debugPluginConfig(true) - .allowUnsignedModules(false) - .settingsDsl(GradleDsl.GROOVY) - .rootPluginConfig( - """ - id("io.ia.sdk.modl") - """.trimIndent() - ) - .build() + val projectDir = generateModule(workingDir) - val projectDir = ModuleGenerator.generate(config) - val signResources = prepareSigningTestResources(projectDir, false) + val signResources = prepareSigningTestResources( + projectDir, + withPropFile = false, + ) val taskArgs = listOf( ":signModule", "--keystoreFile=${signResources.keystore}", "--certFile=${signResources.certFile}", "--certAlias=selfsigned", - "--certPassword=password" + // --keystorePassword is not required by :signModule task for most + // types of keystores, though can add an integrity check on ks load + "--certPassword=password", + "--stacktrace", ) - var result: BuildResult? = null - var ex: Exception? = null - try { - result = runTask(projectDir.toFile(), taskArgs) - } catch (e: Exception) { - ex = e - } - val expectedError = Regex( - """> Task :signModule FAILED\RRequired keystore password not found. Specify via flag """ + - "'--keystorePassword=', or in gradle.properties file as 'ignition.signing.keystorePassword='" + val result: BuildResult = + runTask( + projectDir.toFile(), + taskArgs + ) + + val task = result.task(":signModule") + assertEquals(task?.outcome, TaskOutcome.SUCCESS) + + val buildDir = projectDir.resolve("build") + val signedFileName = signedModuleName(MODULE_NAME) + + val signed = File("${buildDir.toAbsolutePath()}/$signedFileName") + + // unzip and look for signatures properties file + val zm = ZipMap(signed) + val sigPropsFile = zm[SIG_PROPERTIES_FILENAME] + assertTrue(signed.exists(), "Expected $signed to exist") + assertNotNull( + sigPropsFile, + "Expected $SIG_PROPERTIES_FILENAME in signed modl" ) - assertNull(result, "build output will be null due to failure") - assertNotNull(ex, "Exception should be caught and not null") - assertNotNull(ex.message, "Exception should have message") - assertTrue(ex.message.toString().contains(expectedError), "expected error detected.") + + // and the cert file + val certFile = zm[CERT_PKCS7_FILENAME] + assertNotNull( + certFile, + "Expected $CERT_PKCS7_FILENAME in signed modl" + ) + + // If you want to dump file contents to stdout, uncomment this + // logZipMapFileText(SIG_PROPERTIES_FILENAME, sigPropsFile) + // logZipMapFileText(CERT_PKCS7_FILENAME, certFile) } @Test + // @Tag("IGN-7871") fun `module failed with missing cert pw flags`() { val dirName = currentMethodName() - val moduleName = "I Was Signed" val workingDir: File = tempFolder.newFolder(dirName) + val projectDir = generateModule(workingDir) + + val signResources = prepareSigningTestResources( + projectDir, + withPropFile = false, + ) + + val taskArgs = listOf( + ":signModule", + "--keystoreFile=${signResources.keystore}", + "--certFile=${signResources.certFile}", + "--certAlias=selfsigned", + "--keystorePassword=password", + // --certPassword is not strictly required by :signModule task + "--stacktrace", + ) + + val result: BuildResult = + runTaskAndFail( + projectDir.toFile(), + taskArgs + ) + + // Some keystores do not require a password to unlock a private key, + // but PKCS#12 file-based keystores do. + assertContains( + result.output, + "java.security.UnrecoverableKeyException: Get Key failed: null" + ) + } + + @Test + // @Tag("IGN-7871") + fun `module failed - file and pkcs11 keystore in gradle properties`() { + val dirName = currentMethodName() + val workingDir: File = tempFolder.newFolder(dirName) + + val projectDir = generateModule(workingDir) + + // These calls yield a gradle.properties file with both file- and + // PKCS#11-based keystore config--a conflict. + val signingResourcesDestination = + workingDir.toPath().resolve("i-was-signed") + writeResourceFiles( + signingResourcesDestination, + listOf("certificate.pem", "keystore.jks", "pkcs11.cfg") + ) + writeSigningCredentials( + signingResourcesDestination, + "$PKCS11_PROPERTY_ENTRIES\n$KEYSTORE_PROPERTY_ENTRIES" + ) + + val result: BuildResult = + runTaskAndFail( + projectDir.toFile(), + listOf("signModule", "--stacktrace") + ) + + val task = result.task(":signModule") + assertEquals(task?.outcome, TaskOutcome.FAILED) + assertContains( + result.output, + "'--keystoreFile' flag/'ignition.signing.keystoreFile' property " + + "in gradle.properties or " + + "'--pkcs11CfgFile' flag/'ignition.signing.pkcs11CfgFile' property " + + "in gradle.properties but not both" + ) + assertContains(result.output, "InvalidUserDataException") + } + + @Test + // @Tag("IGN-7871") + fun `module failed - file keystore in gradle properties, pkcs11 keystore on cmdline`() { + val dirName = currentMethodName() + val workingDir: File = tempFolder.newFolder(dirName) + + val projectDir = generateModule(workingDir) + + // Write file-based keystore + specify in gradle.properties. + val signingResourcesDestination = + workingDir.toPath().resolve("i-was-signed") + prepareSigningTestResources(signingResourcesDestination) + + // Also write PKCS#11 HSM config, which by itself is OK. + val pkcs11CfgPath = writeResourceFiles( + signingResourcesDestination, listOf("pkcs11.cfg") + ).first() + + // But specifying that file via option suggests there is an HSM + // keystore, which conflicts with the file-based keystore. + val taskArgs = listOf( + "signModule", + "--pkcs11CfgFile=$pkcs11CfgPath", + "--stacktrace", + ) + val result: BuildResult = + runTaskAndFail(projectDir.toFile(), taskArgs) + + val task = result.task(":signModule") + assertEquals(task?.outcome, TaskOutcome.FAILED) + assertContains( + result.output, + "'--keystoreFile' flag/'ignition.signing.keystoreFile' property " + + "in gradle.properties or " + + "'--pkcs11CfgFile' flag/'ignition.signing.pkcs11CfgFile' property " + + "in gradle.properties but not both" + ) + assertContains(result.output, "InvalidUserDataException") + } + + @Test + // @Tag("IGN-7871") + fun `module failed - file keystore on cmdline, pkcs11 keystore in gradle properties`() { + val dirName = currentMethodName() + val workingDir: File = tempFolder.newFolder(dirName) + + val projectDir = generateModule(workingDir) + + // Write PKCS#11 HSM config file + specify in gradle.properties. + val signingResourcesDestination = + workingDir.toPath().resolve("i-was-signed") + preparePKCS11SigningTestResources(signingResourcesDestination) + + // Also write file-based keystore, which by itself is OK. + val ksPath = writeResourceFiles( + signingResourcesDestination, listOf("keystore.jks") + ).first() + + // But specifying that file suggests there is a file keystore, + // which conflicts with the HSM keystore. + val taskArgs = listOf( + "signModule", + "--keystoreFile=$ksPath", + "--stacktrace", + ) + val result: BuildResult = + runTaskAndFail(projectDir.toFile(), taskArgs) + + val task = result.task(":signModule") + assertEquals(task?.outcome, TaskOutcome.FAILED) + assertContains( + result.output, + "'--keystoreFile' flag/'ignition.signing.keystoreFile' property " + + "in gradle.properties or " + + "'--pkcs11CfgFile' flag/'ignition.signing.pkcs11CfgFile' property " + + "in gradle.properties but not both" + ) + assertContains(result.output, "InvalidUserDataException") + } + + @Test + // @Tag("IGN-7871") + fun `skip signing - no need for signing properties`() { + val dirName = currentMethodName() + val workingDir: File = tempFolder.newFolder(dirName) + + val projectDir = generateModule( + workingDir, + skipSigning = true, // sets project extension prop > task prop + ) + + // We've written no signing properties nor passed any via task args + val result = runTask(projectDir.toFile(), ":signModule") + + val task = result.task(":signModule") + assertEquals(task?.outcome, TaskOutcome.SKIPPED) + assertContains(result.output!!, "Module Signing will be skipped") + + val buildDir = projectDir.resolve("build") + val unsignedFileName = unsignedModuleName(MODULE_NAME) + + val unsigned = File("${buildDir.toAbsolutePath()}/$unsignedFileName") + assertTrue(unsigned.exists(), "Expected $unsigned to exist") + } + + @Test + // @Tag("IGN-7871") + fun `module failed - file and pkcs11 keystore on cmdline`() { + val dirName = currentMethodName() + val workingDir: File = tempFolder.newFolder(dirName) + + val projectDir = generateModule(workingDir) + + // Write PKCS#11 HSM config file + write file-based keystore, which + // by itself is OK. + val signingResourcesDestination = + workingDir.toPath().resolve("i-was-signed") + val (ksPath, pkcs11Cfg) = writeResourceFiles( + signingResourcesDestination, + listOf("keystore.jks", "pkcs11.cfg", "certificate.pem") + ) + + // But specifying that file suggests there is a file keystore, + // which conflicts with the HSM keystore. + val taskArgs = listOf( + "signModule", + "--keystoreFile=$ksPath", + "--pkcs11CfgFile=$pkcs11Cfg", + "--keystorePassword=password", + "--certAlias=selfsigned", + "--certFile=./certificate.pem", + "--certPassword=password", + "--stacktrace", + ) + val result: BuildResult = + runTaskAndFail(projectDir.toFile(), taskArgs) + + val task = result.task(":signModule") + assertEquals(task?.outcome, TaskOutcome.FAILED) + assertContains( + result.output, + "'--keystoreFile' flag/'ignition.signing.keystoreFile' property " + + "in gradle.properties or " + + "'--pkcs11CfgFile' flag/'ignition.signing.pkcs11CfgFile' property " + + "in gradle.properties but not both" + ) + assertContains(result.output, "InvalidUserDataException") + } + + // This is a test with an actual PKCS#11-compliant YubiKey 5, on Windows. + // As such it is typically set to @Ignore. + @Test + @Ignore + // @Tag("integration") // break out into a test suite at some point + // @Tag("IGN-7871") + fun `integration - module signed with physical pkcs11 HSM in gradle properties`() { + val dirName = currentMethodName() + val workingDir: File = tempFolder.newFolder(dirName) + + val projectDir = generateModule(workingDir) + + // Write PKCS#11 config file and and cert file, and specify them in + // gradle.properties. + val signingResourcesDestination = + workingDir.toPath().resolve("i-was-signed") + writeResourceFiles( + signingResourcesDestination, + listOf("pkcs11-yk5-win.crt", "pkcs11-yk5-win.cfg") + ) + writeSigningCredentials( + targetDirectory = signingResourcesDestination, + keystoreProps = PKCS11_HSM_SIGNING_PROPERTY_ENTRIES, + writeBoilerplateProps = false, + ) + + val result: BuildResult = runTask( + projectDir.toFile(), + listOf("signModule", "--stacktrace") + ) + + val task = result.task(":signModule") + assertEquals(task?.outcome, TaskOutcome.SUCCESS) + + val buildDir = projectDir.resolve("build") + val signedFileName = signedModuleName(MODULE_NAME) + + val signed = File("${buildDir.toAbsolutePath()}/$signedFileName") + + // unzip and look for signatures properties file + val zm = ZipMap(signed) + val sigPropsFile = zm[SIG_PROPERTIES_FILENAME] + assertTrue(signed.exists(), "Expected $signed to exist") + assertNotNull( + sigPropsFile, + "Expected $SIG_PROPERTIES_FILENAME in signed modl" + ) + + // and the cert file + val certFile = zm[CERT_PKCS7_FILENAME] + assertNotNull( + certFile, + "Expected $CERT_PKCS7_FILENAME in signed modl" + ) + + // If you want to dump file contents to stdout, uncomment this + // logZipMapFileText(SIG_PROPERTIES_FILENAME, sigPropsFile) + // logZipMapFileText(CERT_PKCS7_FILENAME, certFile) + } + + // This is a test with an actual PKCS#11-compliant YubiKey 5, on Windows. + // As such it is typically set to @Ignore. + @Test + @Ignore + // @Tag("integration") // break out into a test suite at some point + // @Tag("IGN-7871") + fun `integration - module signed with physical pkcs11 HSM on cmdline`() { + val dirName = currentMethodName() + val workingDir: File = tempFolder.newFolder(dirName) + + val projectDir = generateModule(workingDir) + + // Write PKCS#11 config file and and cert file, and specify them in + // gradle.properties. + val signingResourcesDestination = + workingDir.toPath().resolve("i-was-signed") + val (certPath, _) = writeResourceFiles( + signingResourcesDestination, + listOf("pkcs11-yk5-win.crt", "pkcs11-yk5-win.cfg") + ) + + val taskArgs = listOf( + ":signModule", + // hack around YK5 (signing) slot 9c's second PIN challenge on top + // of initial keystore PIN challenge by using the cert in (auth) + // slot 9a + // "--certAlias=X.509 Certificate for Digital Signature", + "--certAlias=X.509 Certificate for PIV Authentication", + "--keystorePassword=123456", + "--certFile=$certPath", + // ignored for now, but may be used in future for slot 9c + "--certPassword=password", + "--pkcs11CfgFile=./pkcs11-yk5-win.cfg", + "--stacktrace", + ) + + val result: BuildResult = runTask( + projectDir.toFile(), + taskArgs + ) + + val task = result.task(":signModule") + assertEquals(task?.outcome, TaskOutcome.SUCCESS) + + val buildDir = projectDir.resolve("build") + val signedFileName = signedModuleName(MODULE_NAME) + + val signed = File("${buildDir.toAbsolutePath()}/$signedFileName") + + // unzip and look for signatures properties file + val zm = ZipMap(signed) + val sigPropsFile = zm[SIG_PROPERTIES_FILENAME] + assertTrue(signed.exists(), "Expected $signed to exist") + assertNotNull( + sigPropsFile, + "Expected $SIG_PROPERTIES_FILENAME in signed modl" + ) + + // and the cert file + val certFile = zm[CERT_PKCS7_FILENAME] + assertNotNull( + certFile, + "Expected $CERT_PKCS7_FILENAME in signed modl" + ) + + // If you want to dump file contents to stdout, uncomment this + // logZipMapFileText(SIG_PROPERTIES_FILENAME, sigPropsFile) + // logZipMapFileText(CERT_PKCS7_FILENAME, certFile) + } + + // Some HSM/PKCS#11 keystores handle unlocking the keystore/private keys + // outside of the call stack. Simulate this, somehow, if we can figure + // out a good way to do so. + @Test + @Ignore + // @Tag("IGN-7871") + fun `module signed with unprotected keystore and private key`() { + val dirName = currentMethodName() + val workingDir: File = tempFolder.newFolder(dirName) + + val projectDir = generateModule(workingDir) + + // Generate a keystore with no password protection. + val signingResourcesDestination = + workingDir.toPath().resolve("i-was-signed") + val (certPath, _) = writeResourceFiles( + signingResourcesDestination, + listOf("certificate.cer") + ) + + val taskArgs = listOf( + ":signModule", + "--certAlias=modsigning", + "--certFile=$certPath", + // some sort of keystoreFile or pkcs11CfgFile + "--stacktrace", + // note no passwords at all specified + ) + val result: BuildResult = runTask( + projectDir.toFile(), + taskArgs + ) + + val task = result.task(":signModule") + assertEquals(task?.outcome, TaskOutcome.SUCCESS) + + val buildDir = projectDir.resolve("build") + val signedFileName = signedModuleName(MODULE_NAME) + + val signed = File("${buildDir.toAbsolutePath()}/$signedFileName") + + // unzip and look for signatures properties file + val zm = ZipMap(signed) + val sigPropsFile = zm[SIG_PROPERTIES_FILENAME] + assertTrue(signed.exists(), "Expected $signed to exist") + assertNotNull( + sigPropsFile, + "Expected $SIG_PROPERTIES_FILENAME in signed modl" + ) + + // and the cert file + val certFile = zm[CERT_PKCS7_FILENAME] + assertNotNull( + certFile, + "Expected $CERT_PKCS7_FILENAME in signed modl" + ) + + // If you want to dump file contents to stdout, uncomment this + // logZipMapFileText(SIG_PROPERTIES_FILENAME, sigPropsFile) + // logZipMapFileText(CERT_PKCS7_FILENAME, certFile) + } + + private fun generateModule( + projDir: File, + skipSigning: Boolean = false, + ): Path { val config = GeneratorConfigBuilder() - .moduleName(moduleName) + .moduleName(MODULE_NAME) .scopes("GCD") .packageName("check.my.signage") - .parentDir(workingDir.toPath()) + .parentDir(projDir.toPath()) .debugPluginConfig(true) - .allowUnsignedModules(false) + .allowUnsignedModules(skipSigning) .settingsDsl(GradleDsl.GROOVY) .rootPluginConfig( """ @@ -212,31 +653,11 @@ class SignModuleTest : BaseTest() { ) .build() - val projectDir = ModuleGenerator.generate(config) - val signResources = prepareSigningTestResources(projectDir, false) + return ModuleGenerator.generate(config) + } - val taskArgs = listOf( - ":signModule", - "--keystoreFile=${signResources.keystore}", - "--certFile=${signResources.certFile}", - "--certAlias=selfsigned", - "--certPassword=password" - ) - var result: BuildResult? = null - var ex: Exception? = null - try { - result = runTask(projectDir.toFile(), taskArgs) - } catch (e: Exception) { - ex = e - } - - val expectedError = Regex( - """> Task :signModule FAILED\RRequired keystore password not found. Specify via flag """ + - "'--keystorePassword=', or in gradle.properties file as 'ignition.signing.keystorePassword='" - ) - assertNull(result, "build output will be null due to failure") - assertNotNull(ex, "Exception should be caught and not null") - assertNotNull(ex.message, "Exception should have message") - assertTrue(ex.message.toString().contains(expectedError), "expected error detected.") + private fun logZipMapFileText(key: String, file: ZipMapFile) { + println("$key text:") + println(file.bytes.toString(Charsets.UTF_8)) } } diff --git a/gradle-module-plugin/src/functionalTest/kotlin/io/ia/sdk/gradle/modl/task/WriteModuleXmlTest.kt b/gradle-module-plugin/src/functionalTest/kotlin/io/ia/sdk/gradle/modl/task/WriteModuleXmlTest.kt new file mode 100644 index 0000000..e00484f --- /dev/null +++ b/gradle-module-plugin/src/functionalTest/kotlin/io/ia/sdk/gradle/modl/task/WriteModuleXmlTest.kt @@ -0,0 +1,220 @@ +package io.ia.sdk.gradle.modl.task + +import io.ia.ignition.module.generator.ModuleGenerator +import io.ia.ignition.module.generator.api.GeneratorConfigBuilder +import io.ia.ignition.module.generator.api.GradleDsl +import io.ia.sdk.gradle.modl.BaseTest +import io.ia.sdk.gradle.modl.util.collapseXmlToOneLine +import org.gradle.testkit.runner.BuildResult +import org.gradle.testkit.runner.TaskOutcome +import java.io.File +import java.nio.file.Path +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +class WriteModuleXmlTest : BaseTest() { + companion object { + const val MODULE_NAME = "ModuleXmlTest" + const val PACKAGE_NAME = "module.xml.test" + const val DEPENDS = "io.ia.modl""" + ) + assertEquals( + Regex(DEPENDS).findAll(oneLineXml).toList().size, + 1 + ) + } + + @Test + // @Tag("IGN-9137") + fun `multiple module dependencies marked as required`() { + val dirName = currentMethodName() + val replacements = mapOf( + "moduleDependencySpecs { }" to + """ + moduleDependencySpecs { + register("io.ia.modl") { + scope = "GCD" + required = true + } + register("io.ia.otherModl") { + scope = "G" + required = true + } + } + """, + "requiredIgnitionVersion = \"8.0.10\"" to + "requiredIgnitionVersion = \"8.3.0\"" + ) + + val oneLineXml = generateXml(dirName, replacements) + + assertContains( + oneLineXml, + """io.ia.modl""" + ) + assertContains( + oneLineXml, + """io.ia.otherModl""" + ) + assertEquals( + Regex(DEPENDS).findAll(oneLineXml).toList().size, + 2 + ) + } + + @Test + // @Tag("IGN-9137") + fun `module dependencies via compact, eager DSL`() { + val dirName = currentMethodName() + + // This allows for streamlined, magical build scripts but there is a + // slight performance hit as the ModuleDependencySpecs are eagerly + // created during build script configuration as opposed to registered + // for lazy configuration only on demand. With `register` as in other + // tests here and per our guidance in the doc that _should_ only be + // when `writeModuleXml` task is fired. One can imagine use cases where + // that task is not fired and this eager instance creation is an + // unnecessary waste of CPU cycles. + val replacements = mapOf( + "moduleDependencySpecs { }" to + """ + moduleDependencySpecs { + "io.ia.modl" { + scope = "GCD" + required = true + } + "io.ia.otherModl" { + scope = "G" + required = true + } + } + """, + "requiredIgnitionVersion = \"8.0.10\"" to + "requiredIgnitionVersion = \"8.3.0\"" + ) + + val oneLineXml = generateXml( + dirName, + replacements, + // true, + ) + + assertContains( + oneLineXml, + """io.ia.modl""" + ) + assertContains( + oneLineXml, + """io.ia.otherModl""" + ) + assertEquals( + Regex(DEPENDS).findAll(oneLineXml).toList().size, + 2 + ) + } + + @Test + // @Tag("IGN-9137") + fun `legacy module dependencies not marked at all for requiredness`() { + val dirName = currentMethodName() + + val replacements = mapOf( + "moduleDependencies = [ : ]" to + "moduleDependencies = ['io.ia.modl': 'GCD']" + ) + + val oneLineXml = generateXml(dirName, replacements) + + assertContains( + oneLineXml, + """io.ia.modl""" + ) + assertEquals( + Regex(DEPENDS).findAll(oneLineXml).toList().size, + 1 + ) + } + + private fun generateModule( + projDir: File, + replacements: Map = mapOf(), + ): Path { + val config = GeneratorConfigBuilder() + .moduleName(MODULE_NAME) + .scopes("GCD") + .packageName(PACKAGE_NAME) + .parentDir(projDir.toPath()) + .customReplacements(replacements) + .debugPluginConfig(true) + .allowUnsignedModules(true) + .settingsDsl(GradleDsl.GROOVY) + .rootPluginConfig( + """ + id("io.ia.sdk.modl") + """.trimIndent() + ) + .build() + + return ModuleGenerator.generate(config) + } + + private fun generateXml( + dirName: String, + replacements: Map = mapOf(), + dumpBuildScript: Boolean = false, + ): String { + val projectDir = generateModule( + tempFolder.newFolder(dirName), + replacements, + ) + + if (dumpBuildScript) { + println("build script:") + println(projectDir.resolve("build.gradle").readText()) + } + + val result: BuildResult = runTask( + projectDir.toFile(), + listOf( + "writeModuleXml", + "--stacktrace", + ) + ) + + val task = result.task(":writeModuleXml") + assertEquals(task?.outcome, TaskOutcome.SUCCESS) + + // We could do real XML parsing here but this is just a test, + // quick-and-dirty should be fine. + return collapseXmlToOneLine( + projectDir.resolve("build/moduleContent/module.xml").readText() + ) + } +} diff --git a/gradle-module-plugin/src/functionalTest/kotlin/io/ia/sdk/gradle/modl/util/utils.kt b/gradle-module-plugin/src/functionalTest/kotlin/io/ia/sdk/gradle/modl/util/utils.kt index 23d5e75..cb56b68 100644 --- a/gradle-module-plugin/src/functionalTest/kotlin/io/ia/sdk/gradle/modl/util/utils.kt +++ b/gradle-module-plugin/src/functionalTest/kotlin/io/ia/sdk/gradle/modl/util/utils.kt @@ -11,3 +11,8 @@ fun signedModuleName(humanModuleName: String): String { fun nameToDirName(moduleName: String): String { return moduleName.split(" ").joinToString("-") { it.lowercase() } } + +// For when you don't need full-blown XML parsing just to test. Smoosh all +// tags together in one long line by knocking out indentation and newlines. +fun collapseXmlToOneLine(xml: String): String = + xml.replace(Regex("""^\s+"""), "").replace(Regex("""\R"""), "") diff --git a/gradle-module-plugin/src/functionalTest/resources/certs/pkcs11-yk5-win.cfg b/gradle-module-plugin/src/functionalTest/resources/certs/pkcs11-yk5-win.cfg new file mode 100644 index 0000000..5ef6e97 --- /dev/null +++ b/gradle-module-plugin/src/functionalTest/resources/certs/pkcs11-yk5-win.cfg @@ -0,0 +1,6 @@ +# Windows-friendly config for the YubiKey 5. The library path is typical for +# an install of the Yubico PIV Tool, which includes that PKCS#11 DLL and +# supporting DLLs. Adjust that path if you install the DLLs by different +# means and place them in a different directory. +name=YubiKey5 +library=C:\\Program Files\\Yubico\\Yubico PIV Tool\\bin\\libykcs11.dll diff --git a/gradle-module-plugin/src/functionalTest/resources/certs/pkcs11-yk5-win.crt b/gradle-module-plugin/src/functionalTest/resources/certs/pkcs11-yk5-win.crt new file mode 100644 index 0000000..8be1bcc --- /dev/null +++ b/gradle-module-plugin/src/functionalTest/resources/certs/pkcs11-yk5-win.crt @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICqjCCAZKgAwIBAgIUX1q+xNWuBJvIOLFtAWf/TwVVoxwwDQYJKoZIhvcNAQEL +BQAwDzENMAsGA1UEAwwEYnJheTAeFw0yNDAyMTQyMjI3MDdaFw0yNTAyMTQwMDAw +MDBaMA8xDTALBgNVBAMMBGJyYXkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQDcRYrMiF1ZrtZf2MEUUREDWDVCJdjT8iDgTL9+vbhQ6rrKr6hKLYnx8mrr +/ErqsHcZBpNNJiHIlYpk+CiYp/orulnFDOd4gXsgyCZuRfVdnm7SrZ26/OSW4let +coWqcVitC5oQp162b7w4Rg/Dh4NyDcp31AIi+IksMJy9h6YZM9uSfnFx9QLbbrWt +N3/ZnzvxX2TaEGaSVydH1ewBZ1DB9em3wQjpHCXySb0nqb2LcJM4/Hm02iOSccJb +dEqD5b3y9rklHkGgZQep1ZEmgc/2fStyyMh9Hp11yyiKN+jS6HECOFy6ni9W61oZ +ERyD/YYuGUi2vsBsaez1HIk87fWrAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAAM5 +4ypSAFXbCNTfJWnLoL/VAQ0NK69cw/RR1RDwbcXHtMgfMZCmcmgeLSbo8IXs3u77 +t3Wrs1DCFYKWx27/e3Weg4Oz9TOq8p/e8lHiKW9ul5Gy+5JW4hr5D9AN4bqiEMZm +xlA5ANKUampPjgdXw9Ssv+z2MlVyocGWM2B31nT8yf6Yz9ti/beT/oZBZq0tO9ok +iv5OSeBW2sMnD8Yk28yduLkaxBNyMA605oyvK0C87inYuUGrGLKUnDU9vt9+sQCE +cGNUlA4ag/tfEpZ6A6Qm1zHsFKrpPI98PwiOzwIjE84MXifWwHPZRgQb5JAwh8xD +hH/iJduNQo573Sh6mGI= +-----END CERTIFICATE----- diff --git a/gradle-module-plugin/src/functionalTest/resources/certs/pkcs11.cfg b/gradle-module-plugin/src/functionalTest/resources/certs/pkcs11.cfg new file mode 100644 index 0000000..348672d --- /dev/null +++ b/gradle-module-plugin/src/functionalTest/resources/certs/pkcs11.cfg @@ -0,0 +1,3 @@ +name = OpenSC +library = /usr/lib/x86_64-linux-gnu/opensc-pkcs11.so +description = OpenSC PKCS11 Provider diff --git a/gradle-module-plugin/src/functionalTest/resources/certs/signing.properties b/gradle-module-plugin/src/functionalTest/resources/certs/signing.properties deleted file mode 100644 index d9bd3ff..0000000 --- a/gradle-module-plugin/src/functionalTest/resources/certs/signing.properties +++ /dev/null @@ -1,5 +0,0 @@ -ignition.signing.keystoreFile=./keystore.jks -ignition.signing.keystorePassword=password -ignition.signing.certFile=./certificate.pem -ignition.signing.certPassword=password -ignition.signing.certAlias=selfsigned diff --git a/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/IgnitionModlPlugin.kt b/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/IgnitionModlPlugin.kt index 0d9d999..c44ea83 100644 --- a/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/IgnitionModlPlugin.kt +++ b/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/IgnitionModlPlugin.kt @@ -158,6 +158,7 @@ class IgnitionModlPlugin : Plugin { xmlTask.moduleName.set(settings.name) xmlTask.moduleVersion.set(settings.moduleVersion) xmlTask.moduleDependencies.set(settings.moduleDependencies) + xmlTask.moduleDependencySpecs.set(settings.moduleDependencySpecs.toSet()) xmlTask.requiredIgnitionVersion.set(settings.requiredIgnitionVersion) xmlTask.requiredFrameworkVersion.set(settings.requiredFrameworkVersion) xmlTask.requireFromPlatform.set(settings.requireFromPlatform) diff --git a/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/api/Constants.kt b/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/api/Constants.kt index 67c7282..49cd273 100644 --- a/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/api/Constants.kt +++ b/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/api/Constants.kt @@ -73,7 +73,8 @@ object Constants { const val CERT_PW_FLAG: String = "certPassword" /** - * The keystore file to be used for signing the module + * The keystore file to be used for signing the module. Not compatible + * with [PKCS11_CFG_FILE_FLAG]. */ const val KEYSTORE_FILE_FLAG: String = "keystoreFile" @@ -82,21 +83,27 @@ object Constants { */ const val KEYSTORE_PW_FLAG: String = "keystorePassword" + /** + * PKCS#11 config file, typically used for hardware token keystores and + * other HSMs. Not compatible with [KEYSTORE_FILE_FLAG]. + */ + const val PKCS11_CFG_FILE_FLAG: String = "pkcs11CfgFile" + /** * Map of CLI option flag `gradle --taskProperty=value` to namespaced gradle property equivalent. * * Examples: * - * * | Task Flag | Project Property | Property File (in gradle.properties, or as -P property)| * |------------------------------------------------------------| - *|gradle signModule --certAlias=someAlias | gradle signModule -Pignition.signning.certAlias=someAlias | ignition.signing.certAlias=someAlias| + * |gradle signModule --certAlias=someAlias | gradle signModule -Pignition.signning.certAlias=someAlias | ignition.signing.certAlias=someAlias| */ val SIGNING_PROPERTIES: Map = mapOf( ALIAS_FLAG to "$PROPERTY_NAMESPACE.$ALIAS_FLAG", CERT_FILE_FLAG to "$PROPERTY_NAMESPACE.$CERT_FILE_FLAG", CERT_PW_FLAG to "$PROPERTY_NAMESPACE.$CERT_PW_FLAG", KEYSTORE_FILE_FLAG to "$PROPERTY_NAMESPACE.$KEYSTORE_FILE_FLAG", - KEYSTORE_PW_FLAG to "$PROPERTY_NAMESPACE.$KEYSTORE_PW_FLAG" + KEYSTORE_PW_FLAG to "$PROPERTY_NAMESPACE.$KEYSTORE_PW_FLAG", + PKCS11_CFG_FILE_FLAG to "$PROPERTY_NAMESPACE.$PKCS11_CFG_FILE_FLAG", ) } diff --git a/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/extension/ModuleDependencySpec.kt b/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/extension/ModuleDependencySpec.kt new file mode 100644 index 0000000..272cc8b --- /dev/null +++ b/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/extension/ModuleDependencySpec.kt @@ -0,0 +1,13 @@ +package io.ia.sdk.gradle.modl.extension + +import org.gradle.api.Named +import org.gradle.api.tasks.Input +import java.io.Serializable + +abstract class ModuleDependencySpec : Named, Serializable { + @get:Input + var scope: String = "" + + @get:Input + var required: Boolean = false +} diff --git a/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/extension/ModuleSettings.kt b/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/extension/ModuleSettings.kt index 6eed92d..5c6024c 100644 --- a/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/extension/ModuleSettings.kt +++ b/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/extension/ModuleSettings.kt @@ -3,10 +3,12 @@ package io.ia.sdk.gradle.modl.extension import io.ia.sdk.gradle.modl.task.HashAlgorithm import io.ia.sdk.gradle.modl.task.ZipModule import io.ia.sdk.gradle.modl.util.capitalize +import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input const val EXTENSION_NAME = "ignitionModule" @@ -19,7 +21,7 @@ const val EXTENSION_NAME = "ignitionModule" * reflected in the module.xml file that is generated and placed in the root of your assembled module by the plugin * 2. It identifies the project or subprojects that are to be included by your */ -open class ModuleSettings @javax.inject.Inject constructor(objects: ObjectFactory) { +abstract class ModuleSettings @javax.inject.Inject constructor(objects: ObjectFactory) { /** * The 'name' of your module as is displayed in the Ignition Gateway configuration page when the module is installed. @@ -70,19 +72,49 @@ open class ModuleSettings @javax.inject.Inject constructor(objects: ObjectFactor /** * List of module dependencies, which declare one or more modules you are dependent on, as well as the scope in - * which you depend on them, key'd on the module ID of the module depended-on, with shorthand scope value of the + * which you depend on them, keyed on the module ID of the module depended-on, with shorthand scope value of the * scope in which the module is depended. * * ### Examples: * * _Groovy_ - * ` moduleDependencies = [ "com.inductiveautomation.vision" : "GCD"]` + * ` moduleDependencies = ["com.inductiveautomation.vision": "GCD"]` * * _Kotlin_ * ` moduleDependencies = mapOf("com.inductiveautomation.vision" to "GCD")` */ + @Deprecated("Use new moduleDependencySpecs") val moduleDependencies: MapProperty = objects.mapProperty(String::class.java, String::class.java) + /** + * New version of moduleDependencies for 8.3+ which allows for the addition of a "required" flag to be specified. + * Uses a builder to construct the property so the ModuleDependencySpec piece can be extrapolated away. + * Note: This required flag will only be put in the XML if requiredIgnitionVersion is explicitly set to 8.3+. + * + * ### Examples: + * + * _Groovy_ + * moduleDependencySpecs { + * register("com.inductiveautomation.vision") { + * scope = "GCD" + * required = true + * } + * // register("com.another.mod") { ... + * } + * + * _Kotlin_ + * moduleDependencySpecs { + * register("com.inductiveautomation.vision") { + * scope = "GCD" + * required = true + * } + * // register("com.another.mod") { ... + * } + */ + @get:Input + val moduleDependencySpecs: NamedDomainObjectContainer = + objects.domainObjectContainer(ModuleDependencySpec::class.java) + /** * Map of Ignition Scope to fully qualified hook class to, where scope is one of "C", "D", "G" for "vision Client", * "Designer", and "Gateway" respectively. diff --git a/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/task/SignModule.kt b/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/task/SignModule.kt index a9fb257..25c1c0e 100644 --- a/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/task/SignModule.kt +++ b/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/task/SignModule.kt @@ -8,7 +8,10 @@ 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.api.Constants.PKCS11_CFG_FILE_FLAG +import io.ia.sdk.gradle.modl.api.Constants.SIGNING_PROPERTIES import org.gradle.api.DefaultTask +import org.gradle.api.InvalidUserDataException import org.gradle.api.file.RegularFile import org.gradle.api.file.RegularFileProperty import org.gradle.api.model.ObjectFactory @@ -23,11 +26,13 @@ import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.options.Option import java.io.File +import java.io.FileNotFoundException import java.io.IOException import java.io.OutputStream import java.io.PrintStream import java.security.KeyStore -import java.security.interfaces.RSAPrivateKey +import java.security.PrivateKey +import java.security.Security import javax.inject.Inject /** @@ -37,6 +42,7 @@ open class SignModule @Inject constructor(_providers: ProviderFactory, _objects: companion object { const val ID = "signModule" private const val SKIP = "" // placeholder prop value for skipModuleSigning + private const val PKCS11_KS_TYPE = "PKCS11" } init { @@ -61,11 +67,16 @@ open class SignModule @Inject constructor(_providers: ProviderFactory, _objects: @get:Input @get:Optional - val keystorePath: Property = _objects.property(String::class.java).convention( - _providers.provider { - if (skipSigning.get()) SKIP else propOrLogError(KEYSTORE_FILE_FLAG, "keystore file location") - } - ) + val keystorePath: Property = + _objects.property(String::class.java).convention( + _providers.provider { + val propKey = + Constants.SIGNING_PROPERTIES[KEYSTORE_FILE_FLAG] as String + + if (skipSigning.get()) SKIP + else propFromProjectProps(propKey) // can be null + } + ) @Option( option = KEYSTORE_FILE_FLAG, @@ -76,6 +87,29 @@ open class SignModule @Inject constructor(_providers: ProviderFactory, _objects: keystorePath.set(path) } + @get:Input + @get:Optional + val pkcs11CfgPath: Property = + _objects.property(String::class.java).convention( + _providers.provider { + val propKey = + Constants.SIGNING_PROPERTIES[PKCS11_CFG_FILE_FLAG] as String + + if (skipSigning.get()) SKIP + else propFromProjectProps(propKey) // can be null + } + ) + + @Option( + option = PKCS11_CFG_FILE_FLAG, + description = + "Path PKCS#11 HSM config file used for signing. " + + "Resolves in the same manner as gradle's project.file('')" + ) + fun setPKCS11Path(path: String) { + pkcs11CfgPath.set(path) + } + /** * If set to true, resolving relative path signing asset files will first check for relative to the module * root (where the module plugin is declared), and if not found, will also try to resolve relative to the @@ -87,6 +121,7 @@ open class SignModule @Inject constructor(_providers: ProviderFactory, _objects: val allowMultiprojectFileResolution: Property = _objects.property(Boolean::class.java).convention(true) @get:InputFile + @get:Optional val keystore: Provider = keystorePath.zip(allowMultiprojectFileResolution) { path, allow -> var target = project.file(path) if (!target.exists() && allow && project != project.rootProject) { @@ -96,10 +131,30 @@ open class SignModule @Inject constructor(_providers: ProviderFactory, _objects: target } + @get:InputFile + @get:Optional + val pkcs11Cfg: Provider = + pkcs11CfgPath.zip(allowMultiprojectFileResolution) { path, allow -> + var target = project.file(path) + if (!target.exists() && allow && project != project.rootProject) { + logger.info( + "Failed to resolve PKCS#11 config file at $target, " + + "attempting root project resolution." + ) + target = project.rootProject.file(path) + } + target + } + @get:Input + @get:Optional val keystorePw: Property = _objects.property(String::class.java).convention( _providers.provider { - if (skipSigning.get()) SKIP else propOrLogError(KEYSTORE_PW_FLAG, "keystore password") + val propKey = + Constants.SIGNING_PROPERTIES[KEYSTORE_PW_FLAG] as String + + if (skipSigning.get()) SKIP + else propFromProjectProps(propKey) // can be null } ) @@ -153,16 +208,21 @@ open class SignModule @Inject constructor(_providers: ProviderFactory, _objects: } @get:Input + @get:Optional val certPw: Property = _objects.property(String::class.java).convention( _providers.provider { - if (skipSigning.get()) SKIP else propOrLogError(CERT_PW_FLAG, "certificate password") + val propKey = + Constants.SIGNING_PROPERTIES[CERT_PW_FLAG] as String + + if (skipSigning.get()) SKIP + else propFromProjectProps(propKey) // can be null } ) @Suppress("MemberVisibilityCanBePrivate") protected fun propOrLogError(flag: String, itemName: String): String { - val propKey = Constants.SIGNING_PROPERTIES[flag] - val propValue = project.properties[propKey] as String? + val propKey = Constants.SIGNING_PROPERTIES[flag] as String + val propValue = propFromProjectProps(propKey) if (propValue == null) { logger.error( "Required $itemName not found. Specify via flag '--$flag=', or in gradle.properties" + @@ -172,6 +232,9 @@ open class SignModule @Inject constructor(_providers: ProviderFactory, _objects: return propValue.toString() } + private fun propFromProjectProps(propKey: String): String? = + project.properties[propKey] as String? + @Option(option = CERT_PW_FLAG, description = "The password for the certificate used in signing.") fun setCertPw(pw: String) { certPw.set(pw) @@ -179,7 +242,9 @@ open class SignModule @Inject constructor(_providers: ProviderFactory, _objects: @Internal fun getKeyStore(): KeyStore { - project.logger.debug("Resolving keystore file...") + project.logger.debug("Resolving keystore...") + + // File-base keystore if (keystore.isPresent) { val keystoreFile = keystore.get() if (keystoreFile.exists()) { @@ -194,6 +259,26 @@ open class SignModule @Inject constructor(_providers: ProviderFactory, _objects: throw Exception("Signing key file ${keystoreFile.absolutePath} did not exist!") } } + + // PKCS#11 HSM (hardware key)-based keystore + if (pkcs11Cfg.isPresent) { + project.logger.debug( + "PKCS#11 config specified, using KeyStore instance type 'PKCS11'" + ) + val cfgFile = pkcs11Cfg.get() + val cfgPath = cfgFile.absolutePath + if (!cfgFile.exists()) { + throw FileNotFoundException( + "PKCS#11 configuration file [$cfgPath] does not exist." + ) + } + + val pvdr = Security.getProvider("SunPKCS11").configure(cfgPath) + Security.addProvider(pvdr) + return KeyStore.getInstance(PKCS11_KS_TYPE) + } + + // Backstop, we don't know what kind of keystore we have throw Exception("Failed to resolve keystore!") } @@ -208,17 +293,38 @@ open class SignModule @Inject constructor(_providers: ProviderFactory, _objects: logger.debug("Found unsigned module at ${unsignedModule.absolutePath}...") } + // If both file- and PKCS#11-based keystores are specified, fail. + // Mechanically that means these properties are both 1) set, 2) set + // to something other than the SKIP value, and 3) not explicitly set + // to null via property convention backstop logic. + val mutexPaths = listOf(keystorePath, pkcs11CfgPath) + if ( + mutexPaths.map(Property::getOrNull) + .none { it in listOf(SKIP, null) } + ) { + throw InvalidUserDataException( + "Signing failed, specify '--$KEYSTORE_FILE_FLAG' flag/" + + "'${SIGNING_PROPERTIES[KEYSTORE_FILE_FLAG]}' property in " + + "gradle.properties or '--$PKCS11_CFG_FILE_FLAG' flag/" + + "'${SIGNING_PROPERTIES[PKCS11_CFG_FILE_FLAG]}' property in " + + "gradle.properties but not both." + ) + } + + // Converseley if neither flavor of keystore is specified, also fail. + if (mutexPaths.all { it.getOrNull() == null }) { + throw InvalidUserDataException( + "Signing failed, specify '--$KEYSTORE_FILE_FLAG' flag/" + + "'${SIGNING_PROPERTIES[KEYSTORE_FILE_FLAG]}' property in " + + "gradle.properties or '--$PKCS11_CFG_FILE_FLAG' flag/" + + "'${SIGNING_PROPERTIES[PKCS11_CFG_FILE_FLAG]}' property in " + + "gradle.properties." + ) + } + logger.debug("Signed module will be named ${signed.get().asFile.absolutePath}") - signModule( - keystore.get(), - keystorePw.get(), - certFile.get(), - certPw.get(), - alias.get(), - unsignedModule, - signed.get().asFile - ) + signModule(unsignedModule) logger.info("Module built and signed at ${signed.get().asFile.absolutePath}") } @@ -227,29 +333,42 @@ open class SignModule @Inject constructor(_providers: ProviderFactory, _objects: */ @Suppress("MemberVisibilityCanBePrivate") @Throws(IOException::class) - protected fun signModule( - keyStoreFile: File, - keystorePassword: String, - cert: File, - certPassword: String, - certAlias: String, - unsignedModule: File, - outFile: File - ) { + protected fun signModule(unsignedModule: File) { + val keyStoreFile: File? = keystore.getOrNull() + val pkcs11CfgFile: File? = pkcs11Cfg.getOrNull() + val keystorePassword: String? = keystorePw.getOrNull() + val cert: File = certFile.get() + val certPassword: String? = certPw.getOrNull() + val certAlias: String = alias.get() + val outFile: File = signed.get().asFile + logger.debug( - "Signing module with keystoreFile: ${keyStoreFile.absolutePath}, " + - "keystorePassword: ${"*".repeat(keystorePassword.length)}, " + + "Signing module with keystoreFile: ${keyStoreFile?.absolutePath}, " + + "pkcs11CfgFile: ${pkcs11CfgFile?.absolutePath}, " + + "keystorePassword: ${"*".repeat(20)}, " + "cert: ${cert.absolutePath}, " + - "certPw: ${"*".repeat(certPassword.length)}, " + + "certPassword: ${"*".repeat(20)}, " + "certAlias: $certAlias" ) val keyStore: KeyStore = getKeyStore() - keyStore.load(keyStoreFile.inputStream(), keystorePassword.toCharArray()) + loadKeyStore(keyStore, keystorePassword, keyStoreFile) - val privateKey: RSAPrivateKey = keyStore.getKey(certAlias, certPassword.toCharArray()) as RSAPrivateKey + val privateKey: PrivateKey = keyStore.getKey( + certAlias, + certPassword?.toCharArray() + ) as PrivateKey ModuleSigner(privateKey, cert.inputStream()) .signModule(PrintStream(OutputStream.nullOutputStream()), unsignedModule, outFile) } + + private fun loadKeyStore(ks: KeyStore, ksPwd: String?, ksFile: File?) { + ksFile?.inputStream() + // if PKCS#11 HSM (hardware key) keystore, forgo the input stream + ?.takeUnless { ks.type == PKCS11_KS_TYPE } + .use { maybeStream -> + ks.load(maybeStream, ksPwd?.toCharArray()) + } + } } diff --git a/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/task/WriteModuleXml.kt b/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/task/WriteModuleXml.kt index cd28f52..e604a98 100644 --- a/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/task/WriteModuleXml.kt +++ b/gradle-module-plugin/src/main/kotlin/io/ia/sdk/gradle/modl/task/WriteModuleXml.kt @@ -1,6 +1,7 @@ package io.ia.sdk.gradle.modl.task import io.ia.sdk.gradle.modl.PLUGIN_TASK_GROUP +import io.ia.sdk.gradle.modl.extension.ModuleDependencySpec import io.ia.sdk.gradle.modl.model.ArtifactManifest import io.ia.sdk.gradle.modl.model.artifactManifestFromJson import org.gradle.api.DefaultTask @@ -79,9 +80,21 @@ open class WriteModuleXml @Inject constructor(_objects: ObjectFactory) : Default * Map of */ @get:Input + @get:Optional + @Deprecated("Use new moduleDependencySpecs") val moduleDependencies: MapProperty = _objects.mapProperty(String::class.java, String::class.java) + /** + * Structured replacement for [moduleDependencies], including moduleId, + * scope, and whether the gateway should tolerate whether each dependency + * loads or not prior to loading the current module. + */ + @get:Input + @get:Optional + val moduleDependencySpecs: SetProperty = + _objects.setProperty(ModuleDependencySpec::class.java) + @get:Input @get:Optional val docIndexPath: Property = _objects.property(String::class.java) @@ -151,10 +164,20 @@ open class WriteModuleXml @Inject constructor(_objects: ObjectFactory) : Default } } - moduleDependencies.get().forEach { moduleId, scope -> - "depends" { - attribute("scope", scope) - -moduleId + if (moduleDependencySpecs.isPresent && moduleDependencySpecs.get().isNotEmpty()) { + moduleDependencySpecs.get().forEach { dependency -> + "depends" { + attribute("scope", dependency.scope) + if (usemoduleDependencySpecs()) attribute("required", dependency.required) + -dependency.name + } + } + } else if (moduleDependencies.isPresent) { + moduleDependencies.get().forEach { moduleId, scope -> + "depends" { + attribute("scope", scope) + -moduleId + } } } @@ -188,6 +211,15 @@ open class WriteModuleXml @Inject constructor(_objects: ObjectFactory) : Default return modules.toString(PrintOptions(pretty = true, singleLineTextElements = true, useSelfClosingTags = false)) } + private fun usemoduleDependencySpecs(): Boolean { + if (!requiredIgnitionVersion.isPresent) return false + + val version = requiredIgnitionVersion.get().split(".").map { it.toInt() } + if (version[0] >= 9) { return true } + if (version[0] == 8 && version[1] >= 3) { return true } + return false + } + private fun manifests(): List { return artifactManifests.get().map { manifest ->