FEAT: Preparation work for on-disk encryption and certificate stores #13
20 changed files with 416 additions and 44 deletions
3
.gitattributes
vendored
3
.gitattributes
vendored
|
@ -1,4 +1,5 @@
|
||||||
/gradlew text eol=lf
|
/gradlew text eol=lf
|
||||||
*.bat text eol=crlf
|
*.bat text eol=crlf
|
||||||
*.jar binary
|
*.jar binary
|
||||||
*.java text eol=lf
|
*.java text eol=lf
|
||||||
|
*.config text eol=lf
|
|
@ -50,6 +50,7 @@ dependencies {
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
testImplementation("org.springframework.security:spring-security-test")
|
testImplementation("org.springframework.security:spring-security-test")
|
||||||
testImplementation("org.assertj:assertj-core")
|
testImplementation("org.assertj:assertj-core")
|
||||||
|
testImplementation("org.mockito:mockito-core")
|
||||||
|
|
||||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
}
|
}
|
||||||
|
@ -58,5 +59,6 @@ tasks.withType<Test> {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
|
|
||||||
systemProperty("spring.profiles.active", "test")
|
systemProperty("spring.profiles.active", "test")
|
||||||
|
jvmArgs("-XX:+EnableDynamicAgentLoading") // DynamicAgentLoading for byteBuddy within Mockito
|
||||||
testLogging.showStandardStreams = true
|
testLogging.showStandardStreams = true
|
||||||
}
|
}
|
||||||
|
|
2
lombok.config
Normal file
2
lombok.config
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value
|
||||||
|
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Lazy
|
10
src/main/java/de/mlessmann/certassist/Constants.java
Normal file
10
src/main/java/de/mlessmann/certassist/Constants.java
Normal 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_";
|
||||||
|
}
|
49
src/main/java/de/mlessmann/certassist/models/Passphrase.java
Normal file
49
src/main/java/de/mlessmann/certassist/models/Passphrase.java
Normal 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;
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
package de.mlessmann.certassist.openssl;
|
||||||
|
|
||||||
|
public interface CertificatePasswordProvider {
|
||||||
|
String generateNewPassword();
|
||||||
|
|
||||||
|
String getPasswordFor(String certificateFingerprint);
|
||||||
|
|
||||||
|
void setPasswordFor(String certificateFingerprint, String password);
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -1,6 +1,5 @@
|
||||||
package de.mlessmann.certassist.openssl;
|
package de.mlessmann.certassist.openssl;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
@ -8,10 +7,6 @@ import lombok.Data;
|
||||||
@Builder
|
@Builder
|
||||||
public class CertificateRequest {
|
public class CertificateRequest {
|
||||||
|
|
||||||
@Builder.Default
|
|
||||||
@Deprecated
|
|
||||||
private String oid = UUID.randomUUID().toString();
|
|
||||||
|
|
||||||
private RequestType type;
|
private RequestType type;
|
||||||
private String commonName;
|
private String commonName;
|
||||||
private String trustingAuthority;
|
private String trustingAuthority;
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
package de.mlessmann.certassist.openssl;
|
package de.mlessmann.certassist.openssl;
|
||||||
|
|
||||||
|
import static de.mlessmann.certassist.Constants.CERTASSIST_TMP_PREFIX;
|
||||||
|
|
||||||
import de.mlessmann.certassist.ExecutableResolver;
|
import de.mlessmann.certassist.ExecutableResolver;
|
||||||
import de.mlessmann.certassist.except.CommandLineOperationException;
|
import de.mlessmann.certassist.except.CommandLineOperationException;
|
||||||
import de.mlessmann.certassist.except.UnresolvableCLIDependency;
|
import de.mlessmann.certassist.except.UnresolvableCLIDependency;
|
||||||
|
@ -46,6 +48,8 @@ public class OpenSSLCertificateCreator {
|
||||||
);
|
);
|
||||||
|
|
||||||
private final ExecutableResolver executableResolver;
|
private final ExecutableResolver executableResolver;
|
||||||
|
private final CertificatePasswordProvider passwordProvider;
|
||||||
|
private final CertificateProvider certificateProvider;
|
||||||
|
|
||||||
private static String buildSubjectArg(CertificateRequest request) {
|
private static String buildSubjectArg(CertificateRequest request) {
|
||||||
String certSubject = OPENSSL_CERT_SUBJECT_TEMPLATE
|
String certSubject = OPENSSL_CERT_SUBJECT_TEMPLATE
|
||||||
|
@ -70,28 +74,38 @@ public class OpenSSLCertificateCreator {
|
||||||
throws CommandLineOperationException, InterruptedException {
|
throws CommandLineOperationException, InterruptedException {
|
||||||
Path tmpDir;
|
Path tmpDir;
|
||||||
try {
|
try {
|
||||||
tmpDir = Files.createTempDirectory("certassist");
|
tmpDir = Files.createTempDirectory(CERTASSIST_TMP_PREFIX);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new CommandLineOperationException("Could not create temporary directory for certificate creation", e);
|
throw new CommandLineOperationException("Could not create temporary directory for certificate creation", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
Path keyFile = createKeyfile(request, tmpDir.resolve("root.key"));
|
String certPassword = passwordProvider.generateNewPassword();
|
||||||
Path rootCert = createCertificate(request, keyFile, tmpDir.resolve("root.crt"));
|
Path keyFile = createKeyfile(request, tmpDir.resolve("certificate.key"), certPassword);
|
||||||
if (
|
if (
|
||||||
request.getType() == RequestType.ROOT_AUTHORITY || request.getType() == RequestType.STANDALONE_CERTIFICATE
|
request.getType() == RequestType.ROOT_AUTHORITY || request.getType() == RequestType.STANDALONE_CERTIFICATE
|
||||||
) {
|
) {
|
||||||
String fingerprint = getCertificateFingerprint(rootCert);
|
Path certificate = createCertificate(request, keyFile, tmpDir.resolve("certificate.crt"), certPassword);
|
||||||
return new OpenSSLCertificateResult(tmpDir, rootCert, keyFile, fingerprint);
|
String fingerprint = getCertificateFingerprint(certificate);
|
||||||
|
passwordProvider.setPasswordFor(fingerprint, certPassword);
|
||||||
|
return new OpenSSLCertificateResult(tmpDir, certificate, keyFile, fingerprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
Path childKey = createKeyfile(request, tmpDir.resolve("child.key"));
|
try (var certAuthority = certificateProvider.requestCertificateUsage(request.getTrustingAuthority())) {
|
||||||
Path unsignedCert = createSigningRequest(request, childKey, tmpDir.resolve("child.csr"));
|
Path unsignedCert = createSigningRequest(request, keyFile, tmpDir.resolve("child.csr"), certPassword);
|
||||||
Path signedCert = signCertificate(request, rootCert, keyFile, unsignedCert);
|
Path signedCert = signCertificate(
|
||||||
String fingerPrint = getCertificateFingerprint(signedCert);
|
request,
|
||||||
return new OpenSSLCertificateResult(tmpDir, signedCert, childKey, fingerPrint);
|
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 {
|
throws CommandLineOperationException, InterruptedException {
|
||||||
Path keyFile = outFile.toAbsolutePath();
|
Path keyFile = outFile.toAbsolutePath();
|
||||||
log.atDebug().log("Writing new certificate key to {}", keyFile);
|
log.atDebug().log("Writing new certificate key to {}", keyFile);
|
||||||
|
@ -107,7 +121,7 @@ public class OpenSSLCertificateCreator {
|
||||||
"env:KEY_PASS",
|
"env:KEY_PASS",
|
||||||
Integer.toString(request.getRequestedKeyLength())
|
Integer.toString(request.getRequestedKeyLength())
|
||||||
)
|
)
|
||||||
.environment("KEY_PASS", request.getOid())
|
.environment("KEY_PASS", filePassword)
|
||||||
.redirectOutput(Slf4jStream.ofCaller().asDebug())
|
.redirectOutput(Slf4jStream.ofCaller().asDebug())
|
||||||
.redirectError(Slf4jStream.ofCaller().asError())
|
.redirectError(Slf4jStream.ofCaller().asError())
|
||||||
.start();
|
.start();
|
||||||
|
@ -120,7 +134,7 @@ public class OpenSSLCertificateCreator {
|
||||||
return keyFile;
|
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 {
|
throws CommandLineOperationException, InterruptedException {
|
||||||
log.atDebug().log("Writing new certificate file {}", outFile);
|
log.atDebug().log("Writing new certificate file {}", outFile);
|
||||||
|
|
||||||
|
@ -147,7 +161,7 @@ public class OpenSSLCertificateCreator {
|
||||||
"-subj",
|
"-subj",
|
||||||
certSubject
|
certSubject
|
||||||
)
|
)
|
||||||
.environment("KEY_PASS", request.getOid())
|
.environment("KEY_PASS", certPassword)
|
||||||
.redirectOutput(Slf4jStream.ofCaller().asDebug())
|
.redirectOutput(Slf4jStream.ofCaller().asDebug())
|
||||||
.redirectError(Slf4jStream.ofCaller().asError())
|
.redirectError(Slf4jStream.ofCaller().asError())
|
||||||
.start();
|
.start();
|
||||||
|
@ -160,7 +174,7 @@ public class OpenSSLCertificateCreator {
|
||||||
return outFile;
|
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 {
|
throws CommandLineOperationException, InterruptedException {
|
||||||
log.atDebug().log("Writing new certificate signing request file {}", outFile);
|
log.atDebug().log("Writing new certificate signing request file {}", outFile);
|
||||||
|
|
||||||
|
@ -184,7 +198,7 @@ public class OpenSSLCertificateCreator {
|
||||||
"-subj",
|
"-subj",
|
||||||
certSubject
|
certSubject
|
||||||
)
|
)
|
||||||
.environment("KEY_PASS", request.getOid())
|
.environment("KEY_PASS", certPassword)
|
||||||
.redirectOutput(Slf4jStream.ofCaller().asDebug())
|
.redirectOutput(Slf4jStream.ofCaller().asDebug())
|
||||||
.redirectError(Slf4jStream.ofCaller().asError())
|
.redirectError(Slf4jStream.ofCaller().asError())
|
||||||
.start();
|
.start();
|
||||||
|
@ -211,8 +225,13 @@ public class OpenSSLCertificateCreator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Path signCertificate(CertificateRequest request, Path caCert, Path caKey, Path csrFile)
|
private Path signCertificate(
|
||||||
throws CommandLineOperationException, InterruptedException {
|
CertificateRequest request,
|
||||||
|
Path caCert,
|
||||||
|
Path caKey,
|
||||||
|
Path csrFile,
|
||||||
|
String certPassword
|
||||||
|
) throws CommandLineOperationException, InterruptedException {
|
||||||
Path outFile = csrFile.resolveSibling(csrFile.getFileName().toString().replace(".csr", ".crt"));
|
Path outFile = csrFile.resolveSibling(csrFile.getFileName().toString().replace(".csr", ".crt"));
|
||||||
log.atDebug().log("Writing new signed certificate file {}", outFile);
|
log.atDebug().log("Writing new signed certificate file {}", outFile);
|
||||||
Path extFile = csrFile.resolveSibling(csrFile.getFileName().toString().replace(".csr", ".ext"));
|
Path extFile = csrFile.resolveSibling(csrFile.getFileName().toString().replace(".csr", ".ext"));
|
||||||
|
|
|
@ -6,14 +6,12 @@ import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import lombok.AccessLevel;
|
import lombok.AccessLevel;
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
|
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
|
||||||
@Getter
|
public class OpenSSLCertificateResult implements CertificateUsage {
|
||||||
public class OpenSSLCertificateResult implements AutoCloseable {
|
|
||||||
|
|
||||||
private final Path tmpDir;
|
private final Path tmpDir;
|
||||||
private final Path certificatePath;
|
private final Path certificatePath;
|
||||||
|
@ -21,9 +19,28 @@ public class OpenSSLCertificateResult implements AutoCloseable {
|
||||||
private final String certificateFingerPrint;
|
private final String certificateFingerPrint;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() throws IOException {
|
public Path certificatePath() {
|
||||||
log.info("Cleaning up temporary output directory {}", tmpDir);
|
return certificatePath;
|
||||||
Files.walkFileTree(tmpDir, Set.of(), Integer.MAX_VALUE, new DeleteRecursiveFileVisitor());
|
}
|
||||||
Files.deleteIfExists(tmpDir);
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,4 +5,6 @@ import org.springframework.data.repository.CrudRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface CertificateRepository extends CrudRepository<Certificate, String> {}
|
public interface CertificateRepository extends CrudRepository<Certificate, String> {
|
||||||
|
Certificate findByFingerprintIs(String fingerprint);
|
||||||
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -47,8 +47,8 @@ public class CertificateCreationService {
|
||||||
certificateRequest
|
certificateRequest
|
||||||
);
|
);
|
||||||
) {
|
) {
|
||||||
certificate.setPrivateKey(Files.readAllBytes(certificateCreatorResult.getPrivateKeyPath()));
|
certificate.setPrivateKey(Files.readAllBytes(certificateCreatorResult.certificateKeyPath()));
|
||||||
certificate.setCert(Files.readAllBytes(certificateCreatorResult.getCertificatePath()));
|
certificate.setCert(Files.readAllBytes(certificateCreatorResult.certificatePath()));
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
throw new IllegalStateException("Interrupted exception", e);
|
throw new IllegalStateException("Interrupted exception", e);
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,27 +1,33 @@
|
||||||
package de.mlessmann.certassist;
|
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.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.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class TestOpenSSLCertificateCreator {
|
class TestOpenSSLCertificateCreator {
|
||||||
|
|
||||||
private OpenSSLCertificateCreator openSSLCertificateCreator;
|
private CertificatePasswordProvider passwordProvider;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
ExecutableResolver executableResolver = new ExecutableResolver();
|
passwordProvider = mock(CertificatePasswordProvider.class);
|
||||||
openSSLCertificateCreator = new OpenSSLCertificateCreator(executableResolver);
|
when(passwordProvider.generateNewPassword()).thenReturn("ABC-123");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testCertificateCreation() throws Exception {
|
void testCertificateCreation() throws Exception {
|
||||||
|
CertificateProvider certificateProvider = mock(CertificateProvider.class);
|
||||||
|
ExecutableResolver executableResolver = new ExecutableResolver();
|
||||||
|
var certificateCreator = new OpenSSLCertificateCreator(
|
||||||
|
executableResolver,
|
||||||
|
passwordProvider,
|
||||||
|
certificateProvider
|
||||||
|
);
|
||||||
|
|
||||||
CertificateRequest certRequest = CertificateRequest
|
CertificateRequest certRequest = CertificateRequest
|
||||||
.builder()
|
.builder()
|
||||||
.commonName("test.home")
|
.commonName("test.home")
|
||||||
|
@ -30,9 +36,27 @@ class TestOpenSSLCertificateCreator {
|
||||||
.extension(CertificateRequestExtension.builder().alternativeNames("test2.home", "test3.home"))
|
.extension(CertificateRequestExtension.builder().alternativeNames("test2.home", "test3.home"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
try (var cert = openSSLCertificateCreator.createCertificate(certRequest)) {
|
try (var cert = certificateCreator.createCertificate(certRequest)) {
|
||||||
assertThat(openSSLCertificateCreator.verifyCertificate(cert.getCertificatePath())).isEqualTo(true);
|
assertThat(certificateCreator.verifyCertificate(cert.certificatePath())).isEqualTo(true);
|
||||||
System.out.println("Certificate created: " + cert);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue