From 00f0229906b07b663a517bb4f64a2f97948d4796 Mon Sep 17 00:00:00 2001 From: Isaac Parenteau Date: Wed, 2 Sep 2026 17:09:08 -0500 Subject: [PATCH] Updated to jdk21, updated libraries and added unit tests --- .gitignore | 1 + Jenkinsfile | 263 ++++++------------ pom.xml | 112 ++++++-- .../net/locusworks/crypto/AESKeySpec.java | 16 +- .../crypto/EncryptionKeyFactory.java | 31 ++- .../java/net/locusworks/crypto/KeyFile.java | 18 +- src/main/java/net/locusworks/crypto/RSA.java | 2 +- .../locusworks/crypto/SSHEncodedKeySpec.java | 16 +- .../tests/ConfigurationManagerTest.java | 99 +++++++ .../crypto/tests/KeyAndRsaTest.java | 140 ++++++++++ 10 files changed, 453 insertions(+), 245 deletions(-) create mode 100644 src/test/java/net/locusworks/crypto/tests/ConfigurationManagerTest.java create mode 100644 src/test/java/net/locusworks/crypto/tests/KeyAndRsaTest.java diff --git a/.gitignore b/.gitignore index 24cdadc..769ad1a 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,4 @@ local.properties .scala_dependencies .worksheet +.idea diff --git a/Jenkinsfile b/Jenkinsfile index 3b19af3..8ad0292 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,186 +1,101 @@ -#!groovy +pipeline { + agent any -// Required Jenkins plugins: -// https://wiki.jenkins-ci.org/display/JENKINS/Timestamper -// https://wiki.jenkins-ci.org/display/JENKINS/Static+Code+Analysis+Plug-ins -// https://wiki.jenkins-ci.org/display/JENKINS/Checkstyle+Plugin ? -// https://wiki.jenkins-ci.org/display/JENKINS/FindBugs+Plugin -// https://wiki.jenkins-ci.org/display/JENKINS/PMD+Plugin ? -// https://wiki.jenkins-ci.org/display/JENKINS/DRY+Plugin ? -// https://wiki.jenkins-ci.org/display/JENKINS/Task+Scanner+Plugin -// https://wiki.jenkins-ci.org/display/JENKINS/Javadoc+Plugin -// https://wiki.jenkins-ci.org/display/JENKINS/JaCoCo+Plugin ? + options { + buildDiscarder(logRotator(numToKeepStr: '5')) + disableConcurrentBuilds() + skipDefaultCheckout(true) + timestamps() + } - -init() - -def branch_name -def branch_name_base -def build_number -def build_url -def git_commit -def job_name -def tag -def version -def build_type -def display_name - -def init() { - - // Keep the 5 most recent builds - properties([[$class: 'BuildDiscarderProperty', strategy: [$class: 'LogRotator', numToKeepStr: '5']]]) - - build_number = env.BUILD_NUMBER - build_url = env.BUILD_URL - job_name = "${env.JOB_NAME}" - branch_name = env.BRANCH_NAME - branch_name_docker = branch_name.replaceAll(/\//,'.') - persist = "/var/lib/jenkins/PERSIST/${branch_name_docker}" - - // execute the branch type specific pipeline code - try { - - if (branch_name.indexOf('release/')==0) build_type='release' - if (branch_name.indexOf('feature/')==0) build_type='feature' - if (branch_name.indexOf('develop')==0) build_type='develop' - if (branch_name.indexOf('hotfix')==0) build_type='hotfix' - if (branch_name.indexOf('bugfix')==0) build_type='bugfix' - if (branch_name.indexOf('master')==0) build_type='master' - - // common pipeline elements - node('master') { - Initialize() - SetVersion(build_type) - print_vars() // after SetVersion - all variables now defined - set_result('INPROGRESS') - Build() // builds database via flyway migration - } - - if (branch_name.indexOf('develop')==0) { - node('master') { - Deploy(); - } - } else if (branch_name.indexOf('release/')==0) { - node('master') { - Deploy(); + stages { + stage('Checkout') { + steps { + checkout scm } } - node('master') { - set_result('SUCCESS') + stage('Set version') { + steps { + script { + String branch = env.BRANCH_NAME + if (!branch) { + error('BRANCH_NAME is unavailable; this pipeline must run as a multibranch job') + } + + env.DEPLOY_BUILD = 'false' + + // A release branch supplies major.minor; Jenkins supplies the patch + // number so release/2.0 build 1 becomes 2.0.1-RELEASE. + def releaseMatch = branch =~ /^release\/(\d+\.\d+)$/ + if (releaseMatch.matches()) { + env.BUILD_VERSION = "${releaseMatch[0][1]}.${env.BUILD_NUMBER}-RELEASE" + env.DEPLOY_BUILD = 'true' + } else if (branch == 'develop') { + env.BUILD_VERSION = "develop.${env.BUILD_NUMBER}-SNAPSHOT" + env.DEPLOY_BUILD = 'true' + } else { + String safeBranch = branch.replaceAll(/[^A-Za-z0-9._-]/, '-') + env.BUILD_VERSION = "0.0.${env.BUILD_NUMBER}-${safeBranch}-SNAPSHOT" + } + currentBuild.displayName = env.BUILD_VERSION + } + + withMaven(maven: 'maven-3.9.16', globalMavenSettingsConfig: 'locusworks-settings') { + sh 'mvn -B org.codehaus.mojo:versions-maven-plugin:2.21.0:set -DgenerateBackupPoms=false -DnewVersion="$BUILD_VERSION"' + } + } } - } catch (err) { - node() { - set_result('FAILURE') + stage('Prepare OWASP cache') { + steps { + lock(resource: 'owasp-nvd-cache') { + withMaven(maven: 'maven-3.9.16', globalMavenSettingsConfig: 'locusworks-settings') { + sh 'mkdir -p "$HOME/.cache/dependency-check"' + sh 'mvn -B org.owasp:dependency-check-maven:13.0.0:update-only -DdataDirectory="$HOME/.cache/dependency-check"' + } + } + } } - throw err - } -} -def Build() { - stage ('build') { - mvn "install -DskipTests=true -Dbuild.revision=${git_commit}" - step([$class: 'ArtifactArchiver', artifacts: '**/target/*.jar', fingerprint: true]) - } -} - -def Initialize() { - stage ('initialize') { - - // get new code - checkout scm - - git_commit = getSha1() - } -} - -def Deploy() { - stage ('deploy') { - mvn "deploy -DskipTests=true -Dbuild.number=${build_number} -Dbuild.revision=${git_commit}" - } -} - -def getSha1() { - sha1 = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() - echo "sha1 is ${sha1}" - return sha1 -} - -def mvn(args) { - withMaven( - maven: 'maven-3.6.1', - globalMavenSettingsConfig: 'locusworks-settings' - ) { - - sh "mvn ${args}" - - } -} - -def mvn_initial(args) { - mvn(args) -} - -def set_result(status) { - if ( status == 'SUCCESS' ) { - currentBuild.result = status - notify_bitbucket('SUCCESSFUL') - } else if ( status == 'FAILURE' ) { - currentBuild.result = status - notify_bitbucket('FAILED') - } else if ( status == 'INPROGRESS' ) { - notify_bitbucket('INPROGRESS') - } else { - error ("unknown status") - } - - // save in persistence file for access the status page - // make sure the directory exists first - sh "mkdir -p $persist && echo $status > $persist/build.result" -} - -def notify_bitbucket(state) { -} - -def print_vars() { - echo "build_number = ${build_number}" - echo "build_url = ${build_url}" - echo "job_name = ${job_name}" - echo "branch_name = ${branch_name}" - echo "branch_name_base = ${branch_name_base}" - echo "build_type = ${build_type}" - echo "display_name = ${currentBuild.displayName}" - echo "version = ${version}" - echo "git_commit = ${git_commit}" - -} - -def SetVersion( v ) { - stage ('set version') { - echo "set version ${v}" - branch_name_base = (branch_name =~ /([^\/]+$)/)[0][0] - if ( v == 'release' ) { - // for release branches, where the branch is named "release/1.2.3", - // derive the version and display name derive from the numeric suffix and append the build number - // 3.2.1.100 - version = branch_name_base + "." + build_number + "-RELEASE"; - //version = branch_name.substring('release/'.length()) + "." + build_number - currentBuild.displayName = version - } else if (v == 'develop') { - version = branch_name_base + "." + build_number + "-SNAPSHOT"; - currentBuild.displayName = version - } else { - // for all other branches the version number is 0 with an appended build number - // and for the display name use the jenkins default #n and add the branch name - // #101 - feature/user/foo - //version = '0.' + build_number - version = branch_name_base + "." + build_number - currentBuild.displayName = "#" + build_number + " - " + branch_name_base + stage('Build and test') { + steps { + withMaven(maven: 'maven-3.9.16', globalMavenSettingsConfig: 'locusworks-settings') { + sh 'mvn -B clean verify -DdataDirectory="$HOME/.cache/dependency-check" -DautoUpdate=false -Dbuild.revision="$GIT_COMMIT"' + } + } + post { + always { + junit allowEmptyResults: true, testResults: '**/target/surefire-reports/*.xml' + } + success { + archiveArtifacts artifacts: '**/target/*.jar', fingerprint: true + archiveArtifacts artifacts: '**/target/site/jacoco/**', fingerprint: true + } + } + } + + stage('Deploy') { + when { + expression { env.DEPLOY_BUILD == 'true' } + } + steps { + withMaven(maven: 'maven-3.9.16', globalMavenSettingsConfig: 'locusworks-settings') { + sh 'mvn -B deploy -DskipTests -Ddependency-check.skip=true -Dbuild.number="$BUILD_NUMBER" -Dbuild.revision="$GIT_COMMIT"' + } + } + } + } + + post { + success { + echo "Published build ${env.BUILD_VERSION} successfully" + } + failure { + echo "Build ${env.BUILD_VERSION ?: env.BUILD_NUMBER} failed" + } + cleanup { + deleteDir() } - display_name = currentBuild.displayName - mvn_initial "versions:set -DnewVersion=${version}" } } - -return this \ No newline at end of file diff --git a/pom.xml b/pom.xml index 825861a..4404d99 100644 --- a/pom.xml +++ b/pom.xml @@ -19,26 +19,70 @@ - 1.8 - 1.8 - 4.1 + 21 + 21 + UTF-8 https://nexus.locusworks.net + 5.1.2 + 6.1.3 + 5.23.0 + 0.8.15 + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + + prepare-agent + + + + report + verify + + report + + + + coverage-check + verify + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.75 + + + + + + + + org.apache.maven.plugins maven-surefire-plugin - 3.0.0-M5 + 3.5.6 - always + @{argLine} -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar org.apache.maven.plugins maven-compiler-plugin - 3.8.1 + 3.15.0 ${maven.compiler.source} ${maven.compiler.target} @@ -51,7 +95,14 @@ org.owasp dependency-check-maven - 6.3.1 + 13.0.0 + + true + true + true + true + e2eb1036-9b5b-4df9-95e7-0888be947011 + @@ -90,42 +141,43 @@ - - org.junit.jupiter - junit-jupiter-api - 5.8.0 - test - + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + - commons-io - commons-io - 2.11.0 - + commons-io + commons-io + 2.22.0 + - commons-codec - commons-codec - 1.15 - + commons-codec + commons-codec + 1.22.1 + test + - com.google.guava - guava - 30.1.1-jre - + com.google.guava + guava + 33.7.1-jre + - org.apache.commons - commons-lang3 - 3.12.0 - + org.apache.commons + commons-lang3 + 3.20.0 + @@ -168,4 +220,4 @@ - \ No newline at end of file + diff --git a/src/main/java/net/locusworks/crypto/AESKeySpec.java b/src/main/java/net/locusworks/crypto/AESKeySpec.java index 9ccb7f6..3daea62 100644 --- a/src/main/java/net/locusworks/crypto/AESKeySpec.java +++ b/src/main/java/net/locusworks/crypto/AESKeySpec.java @@ -32,15 +32,11 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.security.spec.EncodedKeySpec; import java.security.spec.InvalidKeySpecException; -import java.util.Arrays; -import java.util.Base64; -import java.util.Iterator; +import java.util.Base64; import org.apache.commons.io.IOUtils; -import static com.google.common.collect.Iterators.get; -import static com.google.common.collect.Iterators.size; -import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkArgument; public class AESKeySpec extends EncodedKeySpec { @@ -54,10 +50,10 @@ public class AESKeySpec extends EncodedKeySpec { try { byte[] data = this.getEncoded(); InputStream stream = new ByteArrayInputStream(data); - Iterator parts = Arrays.asList(IOUtils.toString(stream, StandardCharsets.UTF_8).split(" ")).iterator(); - - checkArgument(size(parts) == 2 && AES_MARKER.equals(get(parts, 0)), "Bad format, should be: aes-seed AAB3..."); - stream = new ByteArrayInputStream(Base64.getDecoder().decode(String.valueOf(get(parts, 1)))); + String[] parts = IOUtils.toString(stream, StandardCharsets.UTF_8).split(" "); + + checkArgument(parts.length == 2 && AES_MARKER.equals(parts[0]), "Bad format, should be: aes-seed AAB3..."); + stream = new ByteArrayInputStream(Base64.getDecoder().decode(parts[1])); String marker = IOUtils.toString(stream, StandardCharsets.UTF_8); return new AESKey(marker); } catch (Exception ex) { diff --git a/src/main/java/net/locusworks/crypto/EncryptionKeyFactory.java b/src/main/java/net/locusworks/crypto/EncryptionKeyFactory.java index 31ea1f6..1e5e522 100644 --- a/src/main/java/net/locusworks/crypto/EncryptionKeyFactory.java +++ b/src/main/java/net/locusworks/crypto/EncryptionKeyFactory.java @@ -36,11 +36,7 @@ import java.security.PublicKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.KeySpec; -import sun.security.jca.GetInstance; -import sun.security.jca.GetInstance.Instance; - -@SuppressWarnings("restriction") -public class EncryptionKeyFactory extends KeyFactory { +public class EncryptionKeyFactory extends KeyFactory { protected EncryptionKeyFactory(KeyFactorySpi keyFacSpi, Provider provider, String algorithm) { super(keyFacSpi, provider, algorithm); @@ -56,11 +52,24 @@ public class EncryptionKeyFactory extends KeyFactory { public PublicKey generatePublicKey(KeySpec keySpec) throws InvalidKeySpecException { keySpec = keySpec instanceof SSHEncodedKeySpec ? ((SSHEncodedKeySpec)keySpec).convertToRSAPubKeySpec() : keySpec; return super.generatePublic(keySpec); - } - - public static EncryptionKeyFactory getInstance(String algorithm) throws NoSuchAlgorithmException { - Instance instance = GetInstance.getInstance("KeyFactory", KeyFactorySpi.class, algorithm); - return new EncryptionKeyFactory((KeyFactorySpi)instance.impl, instance.provider, algorithm); - } + } + + public static EncryptionKeyFactory getInstance(String algorithm) throws NoSuchAlgorithmException { + KeyFactory keyFactory = KeyFactory.getInstance(algorithm); + Provider provider = keyFactory.getProvider(); + Provider.Service service = provider.getService("KeyFactory", algorithm); + if (service == null) { + throw new NoSuchAlgorithmException( + String.format("KeyFactory %s is not available from provider %s", algorithm, provider.getName())); + } + + Object implementation = service.newInstance(null); + if (!(implementation instanceof KeyFactorySpi)) { + throw new NoSuchAlgorithmException( + String.format("Provider %s returned an invalid KeyFactory implementation for %s", + provider.getName(), algorithm)); + } + return new EncryptionKeyFactory((KeyFactorySpi)implementation, provider, algorithm); + } } diff --git a/src/main/java/net/locusworks/crypto/KeyFile.java b/src/main/java/net/locusworks/crypto/KeyFile.java index 684eaec..85d90c3 100644 --- a/src/main/java/net/locusworks/crypto/KeyFile.java +++ b/src/main/java/net/locusworks/crypto/KeyFile.java @@ -28,7 +28,6 @@ package net.locusworks.crypto; import java.io.File; -import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStreamWriter; @@ -96,14 +95,15 @@ public class KeyFile implements AutoCloseable { throw new IllegalArgumentException(String.format("Unable to find file with name %s. Please check path", fileName)); } - String contentStr = IOUtils.toString(new FileInputStream(keyFile), StandardCharsets.UTF_8); + String contentStr = Files.readString(keyFile.toPath(), StandardCharsets.UTF_8); - boolean rsaFormat = !contentStr.startsWith("ssh-rsa") && !contentStr.startsWith("aes-seed"); - if (rsaFormat) { - contentStr = contentStr.replace("-----.*", ""); - } - - contentStr = contentStr.replace("\\r?\\n", ""); + boolean rsaFormat = !contentStr.startsWith("ssh-rsa") && !contentStr.startsWith("aes-seed"); + if (rsaFormat) { + contentStr = contentStr.replaceAll("-----BEGIN RSA .*?-----|-----END RSA .*?-----", ""); + contentStr = contentStr.replaceAll("\\s", ""); + } else { + contentStr = contentStr.trim(); + } byte[] content = rsaFormat ? Base64.getDecoder().decode(contentStr): contentStr.getBytes(StandardCharsets.UTF_8); @@ -144,7 +144,7 @@ public class KeyFile implements AutoCloseable { dos.writeInt(item.length); dos.write(item); } - data = String.format("ssh-rsa", dos.base64Encoded(), this.description); + data = String.format("ssh-rsa %s %s", dos.base64Encoded(), getDescription()); IOUtils.write(data, Files.newOutputStream(Paths.get(fileName)), StandardCharsets.UTF_8); } break; diff --git a/src/main/java/net/locusworks/crypto/RSA.java b/src/main/java/net/locusworks/crypto/RSA.java index 2ddeb59..32ea943 100644 --- a/src/main/java/net/locusworks/crypto/RSA.java +++ b/src/main/java/net/locusworks/crypto/RSA.java @@ -51,7 +51,7 @@ import net.locusworks.crypto.KeyFile.EncryptionType; public class RSA { private static final String ENCRYPTION_TYPE = "RSA"; - private static final String ENCRYPTION_ALGORITHM = "RSA/ECB/PKCS10PADDING"; + private static final String ENCRYPTION_ALGORITHM = "RSA/ECB/PKCS1Padding"; private static final String PROVIDER = "SunJCE"; private static final String RANDOM_ALGORITHM = "SHA1PRNG"; diff --git a/src/main/java/net/locusworks/crypto/SSHEncodedKeySpec.java b/src/main/java/net/locusworks/crypto/SSHEncodedKeySpec.java index 929d388..7300af5 100644 --- a/src/main/java/net/locusworks/crypto/SSHEncodedKeySpec.java +++ b/src/main/java/net/locusworks/crypto/SSHEncodedKeySpec.java @@ -35,15 +35,11 @@ import java.nio.charset.StandardCharsets; import java.security.spec.EncodedKeySpec; import java.security.spec.InvalidKeySpecException; import java.security.spec.RSAPublicKeySpec; -import java.util.Arrays; -import java.util.Base64; -import java.util.Iterator; +import java.util.Base64; import org.apache.commons.io.IOUtils; -import static com.google.common.collect.Iterators.get; -import static com.google.common.collect.Iterators.size; -import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkArgument; public class SSHEncodedKeySpec extends EncodedKeySpec { @@ -58,10 +54,10 @@ public class SSHEncodedKeySpec extends EncodedKeySpec { try { byte[] data = this.getEncoded(); InputStream stream = new ByteArrayInputStream(data); - Iterator parts = Arrays.asList(IOUtils.toString(stream, StandardCharsets.UTF_8).split(" ")).iterator(); - - checkArgument(size(parts) >= 2 && SSH_MARKER.equals(get(parts, 0)), "Bad format, should be: ssh-rsa AAB3..."); - stream = new ByteArrayInputStream(Base64.getDecoder().decode(String.valueOf(get(parts, 1)))); + String[] parts = IOUtils.toString(stream, StandardCharsets.UTF_8).split(" "); + + checkArgument(parts.length >= 2 && SSH_MARKER.equals(parts[0]), "Bad format, should be: ssh-rsa AAB3..."); + stream = new ByteArrayInputStream(Base64.getDecoder().decode(parts[1])); String marker = new String(readLengthFirst(stream)); checkArgument(SSH_MARKER.equals(marker), "Looking for marker %s but received %s", SSH_MARKER, marker); BigInteger publicExponent = new BigInteger(readLengthFirst(stream)); diff --git a/src/test/java/net/locusworks/crypto/tests/ConfigurationManagerTest.java b/src/test/java/net/locusworks/crypto/tests/ConfigurationManagerTest.java new file mode 100644 index 0000000..dccda1f --- /dev/null +++ b/src/test/java/net/locusworks/crypto/tests/ConfigurationManagerTest.java @@ -0,0 +1,99 @@ +/* + * Project: Crypto, File: ConfigurationManagerTest.java + * Copyright 2019 Locusworks LLC. All rights reserved. + */ +package net.locusworks.crypto.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Properties; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import net.locusworks.crypto.configuration.ConfigurationManager; +import net.locusworks.crypto.configuration.PersistableRequest; + +class ConfigurationManagerTest { + + @TempDir + Path tempDir; + + @Test + void initializationCreatesConfigurationFromDefaultsAndReportsProgress() throws Exception { + TestManager manager = new TestManager(); + List messages = new java.util.ArrayList<>(); + manager.initialize(tempDir, messages); + + assertEquals("localhost", manager.getPropertyValue("dbHost")); + assertEquals("fallback", manager.getPropertyValue("missing", "fallback")); + assertEquals(null, manager.getPropertyValue("missing")); + assertEquals(4, manager.getConfiguration().size()); + assertTrue(Files.exists(tempDir.resolve("test.properties"))); + assertTrue(messages.stream().anyMatch(message -> message.startsWith("Loading config file:"))); + assertTrue(messages.stream().anyMatch(message -> message.contains("Added new configuration items"))); + } + + @Test + void saveAndReloadReconcilesAddedAndRemovedConfiguration() throws Exception { + TestManager manager = new TestManager(); + List messages = new java.util.ArrayList<>(); + manager.initialize(tempDir, messages); + + Properties changed = new Properties(); + changed.setProperty("dbHost", "example.test"); + changed.setProperty("obsolete", "remove me"); + manager.saveToConf(changed); + + assertEquals("example.test", manager.getPropertyValue("dbHost")); + assertFalse(manager.getConfiguration().containsKey("obsolete")); + assertEquals(4, manager.getConfiguration().size()); + assertTrue(messages.stream().anyMatch(message -> message.startsWith("Saved config file:"))); + } + + @Test + void persistableRequestUpdatesPlainAndEncryptedValues() throws Exception { + TestManager manager = new TestManager(); + manager.initialize(tempDir, new java.util.ArrayList<>()); + + Request request = new Request("database.test", "secret", "ignored"); + manager.saveConfiguration(request, Set.of("dbHost", "logLevel"), Set.of("logLevel")); + + assertEquals("database.test", manager.getPropertyValue("dbHost")); + assertFalse("secret".equals(manager.getPropertyValue("logLevel"))); + assertEquals("secret", manager.decrypt(manager.getPropertyValue("logLevel"))); + assertThrows(IOException.class, + () -> manager.saveConfiguration(request, new HashSet<>(), null)); + } + + private static final class TestManager extends ConfigurationManager { + void initialize(Path baseDir, List messages) throws IOException { + init(baseDir.toString(), "test.properties", "fixed test seed".getBytes(), messages::add); + } + + String decrypt(String value) { + return aes.decrypt(value); + } + } + + private static final class Request implements PersistableRequest { + private final String dbHost; + private final String logLevel; + private final String ignored; + + Request(String dbHost, String logLevel, String ignored) { + this.dbHost = dbHost; + this.logLevel = logLevel; + this.ignored = ignored; + } + } +} diff --git a/src/test/java/net/locusworks/crypto/tests/KeyAndRsaTest.java b/src/test/java/net/locusworks/crypto/tests/KeyAndRsaTest.java new file mode 100644 index 0000000..db304cf --- /dev/null +++ b/src/test/java/net/locusworks/crypto/tests/KeyAndRsaTest.java @@ -0,0 +1,140 @@ +/* + * Project: Crypto, File: KeyAndRsaTest.java + * Copyright 2019 Locusworks LLC. All rights reserved. + */ +package net.locusworks.crypto.tests; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.util.Base64; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import net.locusworks.crypto.AESKey; +import net.locusworks.crypto.AESKeySpec; +import net.locusworks.crypto.EncryptionKeyFactory; +import net.locusworks.crypto.KeyFile; +import net.locusworks.crypto.RSA; +import net.locusworks.crypto.SSHEncodedKeySpec; +import net.locusworks.crypto.utils.DataOutputStreamHelper; + +class KeyAndRsaTest { + + @TempDir + Path tempDir; + + @Test + void aesKeyAndSpecRoundTrip() throws Exception { + AESKey key = new AESKey("repeatable seed"); + assertEquals("aes", key.getAlgorithm()); + assertEquals("aes-seed", key.getFormat()); + assertArrayEquals("repeatable seed".getBytes(StandardCharsets.UTF_8), key.getEncoded()); + + String encoded = "aes-seed " + Base64.getEncoder().encodeToString(key.getEncoded()); + AESKey decoded = new AESKeySpec(encoded.getBytes(StandardCharsets.UTF_8)).generateKey(); + assertArrayEquals(key.getEncoded(), decoded.getEncoded()); + assertEquals("aes", new AESKeySpec(encoded.getBytes(StandardCharsets.UTF_8)).getFormat()); + assertThrows(InvalidKeySpecException.class, + () -> new AESKeySpec("invalid".getBytes(StandardCharsets.UTF_8)).generateKey()); + } + + @Test + void encryptionKeyFactoryGeneratesRsaKeysAndRejectsUnknownAlgorithms() throws Exception { + KeyPair pair = RSA.generateKeyPair(1024); + EncryptionKeyFactory factory = EncryptionKeyFactory.getInstance("RSA"); + assertEquals(pair.getPrivate(), factory.generatePrivateKey( + new java.security.spec.PKCS8EncodedKeySpec(pair.getPrivate().getEncoded()))); + assertEquals(pair.getPublic(), factory.generatePublicKey( + new java.security.spec.X509EncodedKeySpec(pair.getPublic().getEncoded()))); + assertThrows(NoSuchAlgorithmException.class, + () -> EncryptionKeyFactory.getInstance("not-an-algorithm")); + } + + @Test + void rsaEncryptsDecryptsAndValidatesKeySize() { + KeyPair pair = RSA.generateKeyPair(1024); + String encrypted = RSA.encrypt(pair.getPublic(), "hello RSA"); + assertEquals("hello RSA", RSA.decrypt(pair.getPrivate(), encrypted)); + assertEquals(("hello RSA".getBytes(StandardCharsets.UTF_8).length + 11) * 8, + RSA.calculateRequiredKeyLength("hello RSA")); + assertThrows(RuntimeException.class, + () -> RSA.encrypt(pair.getPublic(), "x".repeat(200))); + } + + @Test + void pemKeyFilesRoundTripAndExposeDescriptions() { + KeyPair pair = RSA.generateKeyPair(1024); + Path privateFile = tempDir.resolve("private.pem"); + Path publicFile = tempDir.resolve("public.pem"); + + assertTrue(RSA.writePrivateKey(pair, privateFile.toString(), "PRIVATE KEY")); + assertTrue(RSA.writePublicKey(pair, publicFile.toString(), "PUBLIC KEY")); + assertEquals(pair.getPrivate(), KeyFile.read(privateFile.toString()).getKey()); + assertEquals(pair.getPublic(), KeyFile.read(publicFile.toString()).getKey()); + + KeyFile privateKey = new KeyFile(pair.getPrivate()); + KeyFile publicKey = new KeyFile(pair.getPublic(), "server key"); + assertEquals("PRIVATE KEY", privateKey.getDescription()); + assertEquals("server key", publicKey.getDescription()); + publicKey.setDescription("changed"); + assertEquals("changed", publicKey.getDescription()); + assertEquals(pair.getPublic(), publicKey.getKey()); + assertThrows(RuntimeException.class, () -> KeyFile.read(tempDir.resolve("missing").toString())); + } + + @Test + void aesAndSshKeyFilesRoundTrip() throws Exception { + AESKey aesKey = new AESKey("file seed"); + Path aesFile = tempDir.resolve("aes.key"); + try (KeyFile keyFile = new KeyFile(aesKey)) { + keyFile.write(aesFile.toString()); + } + assertArrayEquals(aesKey.getEncoded(), KeyFile.read(aesFile.toString()).getKey().getEncoded()); + + KeyPair pair = RSA.generateKeyPair(1024); + Path sshFile = tempDir.resolve("id_rsa.pub"); + assertTrue(RSA.writePublicKey(pair, sshFile.toString(), "test key", true)); + assertEquals(pair.getPublic(), KeyFile.read(sshFile.toString()).getKey()); + String sshText = Files.readString(sshFile, StandardCharsets.UTF_8); + assertTrue(sshText.startsWith("ssh-rsa ")); + assertTrue(sshText.endsWith(" test key")); + } + + @Test + void dataOutputHelperProvidesBytesTextAndBase64() throws Exception { + try (DataOutputStreamHelper output = new DataOutputStreamHelper()) { + output.write("data".getBytes(StandardCharsets.UTF_8)); + assertArrayEquals("data".getBytes(StandardCharsets.UTF_8), output.toByteArray()); + assertEquals("data", output.toString()); + assertEquals(Base64.getEncoder().encodeToString("data".getBytes(StandardCharsets.UTF_8)), + output.base64Encoded()); + } + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStreamHelper output = new DataOutputStreamHelper(bytes); + output.writeByte(7); + assertFalse(output.toByteArray().length == 0); + output.close(); + assertArrayEquals(new byte[0], output.toByteArray()); + output.close(); + } + + @Test + void invalidSshKeyIsRejected() { + SSHEncodedKeySpec spec = new SSHEncodedKeySpec("invalid ssh key".getBytes(StandardCharsets.UTF_8)); + assertEquals(null, spec.getFormat()); + assertThrows(InvalidKeySpecException.class, spec::convertToRSAPubKeySpec); + } +}