Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

IGN-7871 PKCS#11 signing #43

Merged
merged 11 commits into from Mar 22, 2024

Conversation

brianeray
Copy link
Collaborator

@brianeray brianeray commented Feb 20, 2024

馃摉 Background

The signModule custom task in the gradle-module-plugin (a.k.a. io.ia.sdk.modl) supports PKCS#12 file-based Java keystores for signing modules. Certain software supply chain best practices recommend using PKCS#11-based hardware security modules (HSMs) for more secure keystore implementations. The private keys never leave the HSM, and the HSM itself signs the module digests.

鈿欙笍 Summary

Enhance the signModule task to allow for signing via HSM and other PKCS#11-compliant keystores. (Which need not be hardware tokens like YubiKeys, but may be cloud-based or other keystores implemented in software that are typically not local to the build machine.)

More specifically, add this capability via new property/option pkcs11CfgFile.

馃摑 Reviewer Notes

Use the Hide whitespace setting on the Files changed tab to eliminate a bunch of the delinting noise.

Because we call down to module-signer to do the actual signing, and because its filter is narrowed to keys of type SHA256withRSA, the key in the keystore must be that type. (This constraint predates the current enhancement.) At some point we'll likely enhance module-signer to support more key algorithms such as Elliptic Curve and whatever post-quantum algorithms ultimately pass muster with NIST and related entities.

On initial PR submission the version is 0.2.0-SNAPSHOT. After the PR is approved and any other validation--preferably some some testing by IA and partner developers--the PR will emerge from Draft status and the version will bump to 0.2.0. We may have to hand-crank publication to the Gradle Plugin repository as I'm not sure whether the release.yml workflow is working.

馃И QA Notes

This was integration tested against a YubiKey 5 NFC with the wrinkle that the slot 9a authorization key was used, not the 9c signing key. There are notes in SignModuleTest as to why this workaround is necessary. (At some point we might be able to sign with the "correct" YK5 slot but it appears to require some tricky callback code.)

Fixes IGN-7871 (/issues/41)

Add HSM/hardware token keystore capability via new property
`pkcs11CfgFile`. Unlike filesystem-based `keystoreFile`, the signing
key is stored in a device such as a YubiKey, with driver and other
`SunPKCS11` provider properties specified in a configuration file for
the `signModule` task.

Other changes include refactoring existing tests in `SignModuleTest`,
Spotless delinting in `gradle-module-plugin`, and a few minor test
and typo fixes.

NOTE 1: This is currently versioned at `0.2.0-SNAPSHOT`. After the PR
is approved and any other validation--preferably some some testing by
IA and partner developers--this will bump to `0.2.0`.

NOTE 2: This was integration tested against a YubiKey 5 NFC with the
wrinkle that the slot `9a` authorization key was used, not the `9c`
signing key. There are notes in `SignModuleTest` as to why this
workaround is necessary. (At some point we might be able to sign with
the "correct" YK5 slot but it appears to require some tricky callback
code.)

NOTE 3: Because we call down to `module-signer` to do the actual
signing, and because its filter is narrowed to keys of type
`SHA256withRSA`, the key in the keystore must be that type. (This
constraint predates the current enhancement.) At some point we'll
likely enhance `module-signer` to support more key algorithms such
as Elliptic Curve and whatever post-quantum algorithms ultimately pass
muster with NIST and related entities.
ignition.signing.certAlias=selfsigned
ignition.signing.keystorePassword=password
ignition.signing.certFile=./certificate.pem
ignition.signing.certPassword=password
Copy link
Collaborator Author

@brianeray brianeray Feb 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No DRY/re-use on the constant values because 1) we want to test those constants too, not just the logic, and 2) clarify test inputs for the reader--More BDD-oriented.

val keystore: Path,
val certFile: Path,
val keystore: Path?,
val certFile: Path?,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Laying the groundwork for fetching the cert file straight from the HSM. Not for all use cases, but some.

@@ -50,25 +48,36 @@ open class BaseTest {
return name
}

protected fun prepSigningResourcesForModuleName(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code AFAICT.

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to try-catch on negative tests with buildAndFail.


@Test
// @Tag("IGN-7871")
fun `skip signing - no need for signing properties`() {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New test but on old use case. Just for more coverage.

// This is a test with an actual PKCS#11-compliant YubiKey 5, on Windows.
// As such it is typically set to @Ignore.
@Test
@Ignore
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment a few lines up. We can't stick a YubiKey up on GitHub. 馃槃

@@ -1,5 +0,0 @@
ignition.signing.keystoreFile=./keystore.jks
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test fixture from older alpha version of the plugin, IIUC.

listOf(SKIP, null).none { v -> p.getOrNull() == v }
}
) {
throw InvalidUserDataException(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you squint at the Gradle API doc, this is the closest of their exception types that matches this situation.

"keystorePassword: ${"*".repeat(keystorePassword.length)}, " +
"Signing module with keystoreFile: ${keyStoreFile?.absolutePath}, " +
"pkcs11CfgFile: ${pkcs11CfgFile?.absolutePath}, " +
"keystorePassword: ${"*".repeat(20)}, " +
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leak no info whatsoever about secrets.


val privateKey: RSAPrivateKey = keyStore.getKey(certAlias, certPassword.toCharArray()) as RSAPrivateKey
val privateKey: PrivateKey = keyStore.getKey(certAlias, certPassword.toCharArray()) as PrivateKey
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Laying the groundwork for other key algorithms.

@@ -194,6 +250,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) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and the whole getKeyStore function should arguably be refactored away in favor of module-signer's existing keystore + key-fetching logic.


ModuleSigner(privateKey, cert.inputStream())
.signModule(PrintStream(OutputStream.nullOutputStream()), unsignedModule, outFile)
}

private fun loadKeyStore(ks: KeyStore, ksPwd: String, ksFile: File?) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment above in getKeyStore about refactoring this away in favor of logic already in module-signer. Something for a future ticket.

Copy link
Contributor

@paul-griffith paul-griffith left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No real blockers - just some minor code cleanup opportunities I think

}

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason I thought Kotlin was not auto-casting to String before.

paul-griffith
paul-griffith previously approved these changes Feb 21, 2024
@brianeray
Copy link
Collaborator Author

brianeray commented Feb 21, 2024

Holding off on merging until

  1. I figure out how to publish a snapshot build that internal and external developers can kick around a bit.
  2. Clean up that PR description.
  3. "Promote" the version from 0.2.0-SNAPSHOT to release 0.2.0.
  4. Publish that release version.
  5. Maybe get Perry to take a look at it since he wrote most of this + filed the internal ticket.

Copy link
Member

@PerryAJ PerryAJ left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have not had a chance to test with hardware myself so won't 'approve' yet, but the changes look good to me; if we have additional confirmed testers, I'm a go.

An integration partner has a Digicert KeyLocker cloud keystore, which
requires passing explicitly down to the `:signModule` task neither
`keystorePassword` nor `certPassword`; unlocking is via other
means (env vars consumed by the vendor PKCS#11 module, IIUC).

This commit makes those options/properties optional, translating
their ommission as `null` down the call stacks.

Interestingly, omitting `keystorePassword` is not even a blocker for
PKCS#12 (file-based) keystores; it just skips an integrity check on
keystore load when no keystore password is specified.

NOTE: Plans are in place for `certFile` ommission as well. In many
cases the cert chain can be read straight from the keystore. That
will come in a future PR.
@@ -129,7 +129,7 @@ open class BaseTest {
Files.createDirectories(targetDir)

return resources.map { resource ->
ClassLoader.getSystemResourceAsStream("certs/$resource").let { inputStream ->
ClassLoader.getSystemResourceAsStream("certs/$resource").use { inputStream ->
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Close potential stream leak.

assertContains(
out,
"file as 'ignition.signing.keystorePassword=<value>"
)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer immediate fatalities.

@@ -188,25 +176,49 @@ class SignModuleTest : BaseTest() {
"--keystoreFile=${signResources.keystore}",
"--certFile=${signResources.certFile}",
"--certAlias=selfsigned",
// --keystorePassword is not required by :signModule task for most
// types of keystores, though can add an integrity check on ks load
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even PKCS#12 keystores don't care much about the keystore password on a simple KeyStore.load, only the cert password.

// outside of the call stack. Simulate this, somehow, if we can figure
// out a good way to do so.
@Test
@Ignore
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Iron this out in a future issue/ticket/PR.

unsignedModule,
signed.get().asFile
)
signModule(unsignedModule)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sonar was whining about the number of args. Plus all but unsignedModule are instance properties.

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())
ks.load(maybeStream, ksPwd?.toCharArray())
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null here is not a blocker even on PKCS#12 (only?) keystores with passwords; see the Javadoc for the impact.

@brianeray brianeray requested review from KathyApplebaum and removed request for kevinherron March 22, 2024 16:18
Copy link

@KathyApplebaum KathyApplebaum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@brianeray
Copy link
Collaborator Author

QAed by both an integration partner using DigiCert KeyLocker and by IA QA with a YubiKey 5 and a genuine, non self-signed cert.

@brianeray brianeray merged commit 579957f into inductiveautomation:master Mar 22, 2024
1 check passed
@brianeray brianeray deleted the IGN-7871-pkcs11 branch March 22, 2024 16:32
@brianeray
Copy link
Collaborator Author

See this IA forum post for instructions on using the new, 0.2.0 version of the plugin.

@brianeray
Copy link
Collaborator Author

Oddly enough we had a GitHub workflow that is still publishing to the Gradle Plugin Portal. 0.2.0 is up there now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants