FEAT: Preparation work for on-disk encryption and certificate stores #13

Merged
MarkL4YG merged 9 commits from feat/certEncryption into main 2024-11-22 08:48:02 +00:00
20 changed files with 416 additions and 44 deletions

1
.gitattributes vendored
View file

@ -2,3 +2,4 @@
*.bat text eol=crlf
*.jar binary
*.java text eol=lf
*.config text eol=lf

View file

@ -50,6 +50,7 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.assertj:assertj-core")
testImplementation("org.mockito:mockito-core")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
@ -58,5 +59,6 @@ tasks.withType<Test> {
useJUnitPlatform()
systemProperty("spring.profiles.active", "test")
jvmArgs("-XX:+EnableDynamicAgentLoading") // DynamicAgentLoading for byteBuddy within Mockito
testLogging.showStandardStreams = true
}

2
lombok.config Normal file
View file

@ -0,0 +1,2 @@
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Lazy

View file

@ -0,0 +1,10 @@
package de.mlessmann.certassist;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class Constants {
public static final String CERTASSIST_TMP_PREFIX = "certassist_";
}

View file

@ -0,0 +1,49 @@
package de.mlessmann.certassist.models;
import jakarta.persistence.*;
import java.time.ZonedDateTime;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
/**
* Database entity representing a passphrase.
* Unlike passwords which would be stored using BCrypt, passphrases are supposed to be reversible and are encrypted.
* @see de.mlessmann.certassist.service.PassphraseService
*/
@Entity
@Getter
@Table(uniqueConstraints = { @UniqueConstraint(columnNames = { "identifier" }) })
public class Passphrase {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Setter(AccessLevel.NONE)
private String id;
@Setter
@Column(nullable = false)
private String identifier;
@Setter
@Column(nullable = false)
private String encPassphrase;
@Setter
@Column(nullable = false)
private String salt;
@CreationTimestamp
@Column(nullable = false, updatable = false)
private ZonedDateTime created;
@UpdateTimestamp
@Column(nullable = false)
private ZonedDateTime updated;
@Setter
@Transient
private String passphrase;
}

View file

@ -0,0 +1,9 @@
package de.mlessmann.certassist.openssl;
public interface CertificatePasswordProvider {
String generateNewPassword();
String getPasswordFor(String certificateFingerprint);
void setPasswordFor(String certificateFingerprint, String password);
}

View file

@ -0,0 +1,11 @@
package de.mlessmann.certassist.openssl;
/**
* Accessor interface to the certificate storage.
*/
public interface CertificateProvider {
/**
* A stored certificate is needed (e.g. for command line operations when signing sub certificates).
*/
CertificateUsage requestCertificateUsage(String fingerprint);
}

View file

@ -1,6 +1,5 @@
package de.mlessmann.certassist.openssl;
import java.util.UUID;
import lombok.Builder;
import lombok.Data;
@ -8,10 +7,6 @@ import lombok.Data;
@Builder
public class CertificateRequest {
@Builder.Default
@Deprecated
private String oid = UUID.randomUUID().toString();
private RequestType type;
private String commonName;
private String trustingAuthority;

View file

@ -0,0 +1,17 @@
package de.mlessmann.certassist.openssl;
import java.nio.file.Path;
/**
* Instance of a certificate that is temporarily stored on disk to be available for use in command line calls.
* The instance implements AutoCloseable to enable cleanup after the stored files are no longer needed.
* @implSpec The files should be removed from disk when the instance is closed, UNLESS the provided paths are the permanent storage location for the certificate files.
*/
public interface CertificateUsage extends AutoCloseable {
Path certificatePath();
Path certificateKeyPath();
String fingerprint();
@Override
void close();
}

View file

@ -1,5 +1,7 @@
package de.mlessmann.certassist.openssl;
import static de.mlessmann.certassist.Constants.CERTASSIST_TMP_PREFIX;
import de.mlessmann.certassist.ExecutableResolver;
import de.mlessmann.certassist.except.CommandLineOperationException;
import de.mlessmann.certassist.except.UnresolvableCLIDependency;
@ -46,6 +48,8 @@ public class OpenSSLCertificateCreator {
);
private final ExecutableResolver executableResolver;
private final CertificatePasswordProvider passwordProvider;
private final CertificateProvider certificateProvider;
private static String buildSubjectArg(CertificateRequest request) {
String certSubject = OPENSSL_CERT_SUBJECT_TEMPLATE
@ -70,28 +74,38 @@ public class OpenSSLCertificateCreator {
throws CommandLineOperationException, InterruptedException {
Path tmpDir;
try {
tmpDir = Files.createTempDirectory("certassist");
tmpDir = Files.createTempDirectory(CERTASSIST_TMP_PREFIX);
} catch (IOException e) {
throw new CommandLineOperationException("Could not create temporary directory for certificate creation", e);
}
Path keyFile = createKeyfile(request, tmpDir.resolve("root.key"));
Path rootCert = createCertificate(request, keyFile, tmpDir.resolve("root.crt"));
String certPassword = passwordProvider.generateNewPassword();
Path keyFile = createKeyfile(request, tmpDir.resolve("certificate.key"), certPassword);
if (
request.getType() == RequestType.ROOT_AUTHORITY || request.getType() == RequestType.STANDALONE_CERTIFICATE
) {
String fingerprint = getCertificateFingerprint(rootCert);
return new OpenSSLCertificateResult(tmpDir, rootCert, keyFile, fingerprint);
Path certificate = createCertificate(request, keyFile, tmpDir.resolve("certificate.crt"), certPassword);
String fingerprint = getCertificateFingerprint(certificate);
passwordProvider.setPasswordFor(fingerprint, certPassword);
return new OpenSSLCertificateResult(tmpDir, certificate, keyFile, fingerprint);
}
Path childKey = createKeyfile(request, tmpDir.resolve("child.key"));
Path unsignedCert = createSigningRequest(request, childKey, tmpDir.resolve("child.csr"));
Path signedCert = signCertificate(request, rootCert, keyFile, unsignedCert);
String fingerPrint = getCertificateFingerprint(signedCert);
return new OpenSSLCertificateResult(tmpDir, signedCert, childKey, fingerPrint);
try (var certAuthority = certificateProvider.requestCertificateUsage(request.getTrustingAuthority())) {
Path unsignedCert = createSigningRequest(request, keyFile, tmpDir.resolve("child.csr"), certPassword);
Path signedCert = signCertificate(
request,
certAuthority.certificatePath(),
certAuthority.certificateKeyPath(),
unsignedCert,
certPassword
);
String fingerprint = getCertificateFingerprint(signedCert);
passwordProvider.setPasswordFor(fingerprint, certPassword);
return new OpenSSLCertificateResult(tmpDir, signedCert, keyFile, fingerprint);
}
}
private Path createKeyfile(CertificateRequest request, Path outFile)
private Path createKeyfile(CertificateRequest request, Path outFile, String filePassword)
throws CommandLineOperationException, InterruptedException {
Path keyFile = outFile.toAbsolutePath();
log.atDebug().log("Writing new certificate key to {}", keyFile);
@ -107,7 +121,7 @@ public class OpenSSLCertificateCreator {
"env:KEY_PASS",
Integer.toString(request.getRequestedKeyLength())
)
.environment("KEY_PASS", request.getOid())
.environment("KEY_PASS", filePassword)
.redirectOutput(Slf4jStream.ofCaller().asDebug())
.redirectError(Slf4jStream.ofCaller().asError())
.start();
@ -120,7 +134,7 @@ public class OpenSSLCertificateCreator {
return keyFile;
}
private Path createCertificate(CertificateRequest request, Path keyFile, Path outFile)
private Path createCertificate(CertificateRequest request, Path keyFile, Path outFile, String certPassword)
throws CommandLineOperationException, InterruptedException {
log.atDebug().log("Writing new certificate file {}", outFile);
@ -147,7 +161,7 @@ public class OpenSSLCertificateCreator {
"-subj",
certSubject
)
.environment("KEY_PASS", request.getOid())
.environment("KEY_PASS", certPassword)
.redirectOutput(Slf4jStream.ofCaller().asDebug())
.redirectError(Slf4jStream.ofCaller().asError())
.start();
@ -160,7 +174,7 @@ public class OpenSSLCertificateCreator {
return outFile;
}
private Path createSigningRequest(CertificateRequest request, Path keyFile, Path outFile)
private Path createSigningRequest(CertificateRequest request, Path keyFile, Path outFile, String certPassword)
throws CommandLineOperationException, InterruptedException {
log.atDebug().log("Writing new certificate signing request file {}", outFile);
@ -184,7 +198,7 @@ public class OpenSSLCertificateCreator {
"-subj",
certSubject
)
.environment("KEY_PASS", request.getOid())
.environment("KEY_PASS", certPassword)
.redirectOutput(Slf4jStream.ofCaller().asDebug())
.redirectError(Slf4jStream.ofCaller().asError())
.start();
@ -211,8 +225,13 @@ public class OpenSSLCertificateCreator {
}
}
private Path signCertificate(CertificateRequest request, Path caCert, Path caKey, Path csrFile)
throws CommandLineOperationException, InterruptedException {
private Path signCertificate(
CertificateRequest request,
Path caCert,
Path caKey,
Path csrFile,
String certPassword
) throws CommandLineOperationException, InterruptedException {
Path outFile = csrFile.resolveSibling(csrFile.getFileName().toString().replace(".csr", ".crt"));
log.atDebug().log("Writing new signed certificate file {}", outFile);
Path extFile = csrFile.resolveSibling(csrFile.getFileName().toString().replace(".csr", ".ext"));

View file

@ -6,14 +6,12 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
@Getter
public class OpenSSLCertificateResult implements AutoCloseable {
public class OpenSSLCertificateResult implements CertificateUsage {
private final Path tmpDir;
private final Path certificatePath;
@ -21,9 +19,28 @@ public class OpenSSLCertificateResult implements AutoCloseable {
private final String certificateFingerPrint;
@Override
public void close() throws IOException {
log.info("Cleaning up temporary output directory {}", tmpDir);
Files.walkFileTree(tmpDir, Set.of(), Integer.MAX_VALUE, new DeleteRecursiveFileVisitor());
Files.deleteIfExists(tmpDir);
public Path certificatePath() {
return certificatePath;
}
@Override
public Path certificateKeyPath() {
return privateKeyPath;
}
@Override
public String fingerprint() {
return certificateFingerPrint;
}
@Override
public void close() {
try {
log.info("Cleaning up temporary output directory {}", tmpDir);
Files.walkFileTree(tmpDir, Set.of(), Integer.MAX_VALUE, new DeleteRecursiveFileVisitor());
Files.deleteIfExists(tmpDir);
} catch (IOException e) {
log.error("Failed to clean up temporary output directory {}!", tmpDir, e);
}
}
}

View file

@ -5,4 +5,6 @@ import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CertificateRepository extends CrudRepository<Certificate, String> {}
public interface CertificateRepository extends CrudRepository<Certificate, String> {
Certificate findByFingerprintIs(String fingerprint);
}

View file

@ -0,0 +1,8 @@
package de.mlessmann.certassist.repositories;
import de.mlessmann.certassist.models.Passphrase;
import org.springframework.data.repository.CrudRepository;
public interface PassphraseRepository extends CrudRepository<Passphrase, String> {
Passphrase findByIdentifierEquals(String identifier);
}

View file

@ -47,8 +47,8 @@ public class CertificateCreationService {
certificateRequest
);
) {
certificate.setPrivateKey(Files.readAllBytes(certificateCreatorResult.getPrivateKeyPath()));
certificate.setCert(Files.readAllBytes(certificateCreatorResult.getCertificatePath()));
certificate.setPrivateKey(Files.readAllBytes(certificateCreatorResult.certificateKeyPath()));
certificate.setCert(Files.readAllBytes(certificateCreatorResult.certificatePath()));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted exception", e);

View file

@ -0,0 +1,27 @@
package de.mlessmann.certassist.service;
import de.mlessmann.certassist.openssl.CertificatePasswordProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CertificatePasswordProviderImpl implements CertificatePasswordProvider {
private final PassphraseService passphraseService;
@Override
public String generateNewPassword() {
return PassphraseService.generateNewPassword(16);
}
@Override
public String getPasswordFor(String certificateFingerprint) {
return passphraseService.readPassphrase("cert:" + certificateFingerprint).getPassphrase();
}
@Override
public void setPasswordFor(String certificateFingerprint, String password) {
passphraseService.storePassphrase("cert:" + certificateFingerprint, password);
}
}

View file

@ -0,0 +1,68 @@
package de.mlessmann.certassist.service;
import de.mlessmann.certassist.Constants;
import de.mlessmann.certassist.DeleteRecursiveFileVisitor;
import de.mlessmann.certassist.openssl.CertificateProvider;
import de.mlessmann.certassist.openssl.CertificateUsage;
import de.mlessmann.certassist.repositories.CertificateRepository;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@Slf4j
@RequiredArgsConstructor
public class CertificateProviderImpl implements CertificateProvider {
private static final OpenOption[] CREATE_TRUNCATE = new OpenOption[] {
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
};
private final CertificateRepository certificateRepository;
@Override
public CertificateUsage requestCertificateUsage(String fingerprint) {
var certFromDB = certificateRepository.findByFingerprintIs(fingerprint);
if (certFromDB == null) {
throw new IllegalArgumentException("Unknown fingerprint");
}
try {
Path tempDirectory = Files.createTempDirectory(Constants.CERTASSIST_TMP_PREFIX);
Files.write(tempDirectory.resolve("key.pem"), certFromDB.getPrivateKey(), CREATE_TRUNCATE);
Files.write(tempDirectory.resolve("cert.pem"), certFromDB.getCert(), CREATE_TRUNCATE);
return new ExtractedCert(tempDirectory, certFromDB.getFingerprint());
} catch (IOException e) {
// TODO: Better exception definitions
throw new RuntimeException("Unable to temporarily store certificate for use.", e);
}
}
@Slf4j
private record ExtractedCert(Path tempDir, String fingerprint) implements CertificateUsage {
@Override
public Path certificateKeyPath() {
return this.tempDir.resolve("key.pem");
}
@Override
public Path certificatePath() {
return this.tempDir.resolve("cert.pem");
}
@Override
public void close() {
try {
Files.walkFileTree(this.tempDir, new DeleteRecursiveFileVisitor());
Files.deleteIfExists(this.tempDir);
} catch (IOException e) {
log.error("Unable to clean up temporary directory: {}", this.tempDir, e);
}
}
}
}

View file

@ -0,0 +1,57 @@
package de.mlessmann.certassist.service;
import de.mlessmann.certassist.models.Passphrase;
import de.mlessmann.certassist.repositories.PassphraseRepository;
import java.security.SecureRandom;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.lang.NonNull;
import org.springframework.security.crypto.encrypt.Encryptors;
import org.springframework.security.crypto.keygen.KeyGenerators;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class PassphraseService {
private static final String CHARACTERS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{}|;:,.<>?";
public static String generateNewPassword(int length) {
SecureRandom random = new SecureRandom();
StringBuilder passphrase = new StringBuilder(length);
for (int i = 0; i < length; i++) {
passphrase.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length())));
}
return passphrase.toString();
}
@Value("${APP_KEY}")
private final String appKey;
private final PassphraseRepository passphraseRepository;
@NonNull
public Passphrase storePassphrase(@NonNull String identifier, @NonNull String passphrase) {
String salt = KeyGenerators.string().generateKey();
String encPassphrase = Encryptors.delux(appKey, salt).encrypt(passphrase);
var phrase = new Passphrase();
phrase.setIdentifier(identifier);
phrase.setSalt(salt);
phrase.setEncPassphrase(encPassphrase);
return passphraseRepository.save(phrase);
}
@NonNull
public Passphrase readPassphrase(String identifier) {
var passphrase = passphraseRepository.findByIdentifierEquals(identifier);
if (passphrase == null) {
throw new IllegalArgumentException("No passphrase found for identifier: " + identifier);
}
String unencPassphrase = Encryptors.delux(appKey, passphrase.getSalt()).decrypt(passphrase.getEncPassphrase());
passphrase.setPassphrase(unencPassphrase);
return passphrase;
}
}

View file

@ -1,27 +1,33 @@
package de.mlessmann.certassist;
import static org.assertj.core.api.Assertions.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
import de.mlessmann.certassist.openssl.CertificateRequest;
import de.mlessmann.certassist.openssl.*;
import de.mlessmann.certassist.openssl.CertificateRequest.RequestType;
import de.mlessmann.certassist.openssl.CertificateRequestExtension;
import de.mlessmann.certassist.openssl.CertificateSubject;
import de.mlessmann.certassist.openssl.OpenSSLCertificateCreator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class TestOpenSSLCertificateCreator {
private OpenSSLCertificateCreator openSSLCertificateCreator;
private CertificatePasswordProvider passwordProvider;
@BeforeEach
void setUp() {
ExecutableResolver executableResolver = new ExecutableResolver();
openSSLCertificateCreator = new OpenSSLCertificateCreator(executableResolver);
passwordProvider = mock(CertificatePasswordProvider.class);
when(passwordProvider.generateNewPassword()).thenReturn("ABC-123");
}
@Test
void testCertificateCreation() throws Exception {
CertificateProvider certificateProvider = mock(CertificateProvider.class);
ExecutableResolver executableResolver = new ExecutableResolver();
var certificateCreator = new OpenSSLCertificateCreator(
executableResolver,
passwordProvider,
certificateProvider
);
CertificateRequest certRequest = CertificateRequest
.builder()
.commonName("test.home")
@ -30,9 +36,27 @@ class TestOpenSSLCertificateCreator {
.extension(CertificateRequestExtension.builder().alternativeNames("test2.home", "test3.home"))
.build();
try (var cert = openSSLCertificateCreator.createCertificate(certRequest)) {
assertThat(openSSLCertificateCreator.verifyCertificate(cert.getCertificatePath())).isEqualTo(true);
try (var cert = certificateCreator.createCertificate(certRequest)) {
assertThat(certificateCreator.verifyCertificate(cert.certificatePath())).isEqualTo(true);
System.out.println("Certificate created: " + cert);
CertificateRequest childRequest = CertificateRequest
.builder()
.commonName("test.local")
.type(RequestType.NORMAL_CERTIFICATE)
.trustingAuthority(cert.fingerprint())
.subject(
CertificateSubject.builder().country("DE").state("SH").locality("HH").organization("Crazy-Cats")
)
.build();
var spiedCert = spy(cert);
doNothing().when(spiedCert).close();
when(certificateProvider.requestCertificateUsage(cert.fingerprint())).thenReturn(spiedCert);
try (var childCert = certificateCreator.createCertificate(childRequest)) {
System.out.println("Child certificate created: " + childCert);
assertThat(certificateCreator.verifyCertificate(childCert.certificatePath())).isEqualTo(true);
}
}
}
}

View file

@ -0,0 +1,20 @@
package de.mlessmann.certassist.config;
import org.springframework.context.EnvironmentAware;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
@Configuration
@Profile("test")
public class TestAppEnvConfig implements EnvironmentAware {
public static final String TEST_APP_KEY = "5i4Y)Ja0{YCl";
@Override
public void setEnvironment(Environment environment) {
if (environment.getProperty("APP_KEY") == null) {
System.setProperty("APP_KEY", TEST_APP_KEY);
}
}
}

View file

@ -0,0 +1,34 @@
package de.mlessmann.certassist.service;
import de.mlessmann.certassist.openssl.CertificatePasswordProvider;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.stereotype.Service;
@Service
@ConditionalOnMissingBean(CertificatePasswordProvider.class)
public class InMemoryCertificatePasswordProvider implements CertificatePasswordProvider {
private final Map<String, String> passwords = new ConcurrentHashMap<>();
private static final int PASSPHRASE_LENGTH = 16;
@Override
public String generateNewPassword() {
return PassphraseService.generateNewPassword(PASSPHRASE_LENGTH);
}
@Override
public String getPasswordFor(String certificateFingerprint) {
return Optional.ofNullable(passwords.get(certificateFingerprint)).orElseThrow();
}
@Override
public void setPasswordFor(String certificateFingerprint, String password) {
Objects.requireNonNull(certificateFingerprint);
Objects.requireNonNull(password);
passwords.put(certificateFingerprint, password);
}
}