From 0ca6e6dad6f608466995e434327b0bc14d644bad Mon Sep 17 00:00:00 2001 From: Esta Nagy Date: Mon, 13 Mar 2023 20:47:21 +0100 Subject: [PATCH] Certificate API - Lifetime action policy execution (#499) - Adds new parameter to time shift endpoints to allow regeneration of certificates - Regenerates certificates if time shift requires it - Performs automatic certificate renewals during time shift based on lifetime actions - Rotate non-reusable keys during automatic certificate renewals - Updates timestamps of backing keys/secrets based on calculated validity start/end of certificate - Updates client to support new time shift parameter - Covers new functionality with UT/IT - Adds new end-2-end tests - Updates documentation Resolves #481 {minor} Signed-off-by: Esta Nagy --- README.md | 5 +- .../controller/VaultManagementController.java | 26 ++- .../lowkeyvault/openapi/Examples.java | 4 + .../certificate/CertificateVaultFake.java | 2 + .../ReadOnlyLifetimeActionPolicy.java | 3 +- .../impl/CertificateGenerator.java | 6 +- .../impl/CertificateLifetimeActionPolicy.java | 13 +- .../impl/CertificateVaultFakeImpl.java | 80 +++++++ .../impl/KeyVaultCertificateEntity.java | 128 ++++++++++- .../common/impl/BaseLifetimePolicy.java | 17 +- .../common/impl/BaseVaultFakeImpl.java | 4 + .../service/key/impl/KeyRotationPolicy.java | 7 +- .../secret/impl/KeyVaultSecretEntity.java | 6 +- .../secret/impl/SecretVaultFakeImpl.java | 3 + .../lowkeyvault/service/vault/VaultFake.java | 2 +- .../service/vault/VaultService.java | 2 +- .../service/vault/impl/VaultFakeImpl.java | 9 +- .../service/vault/impl/VaultServiceImpl.java | 4 +- .../VaultManagementControllerTest.java | 7 +- .../CertificateLifetimeActionPolicyTest.java | 26 ++- ...VaultCertificateEntityIntegrationTest.java | 57 +++++ .../impl/KeyVaultCertificateEntityTest.java | 97 +++++++++ .../secret/impl/SecretVaultFakeImplTest.java | 3 + .../impl/VaultFakeImplIntegrationTest.java | 200 ++++++++++++++++++ .../service/vault/impl/VaultFakeImplTest.java | 4 +- .../vault/impl/VaultServiceImplTest.java | 4 +- .../http/management/TimeShiftContext.java | 14 +- .../impl/LowkeyVaultManagementClientImpl.java | 4 + .../LowkeyVaultManagementClientImplTest.java | 26 +++ .../hook/MissionOutlineDefinition.java | 2 +- .../steps/CertificateStepDefAssertion.java | 10 + .../steps/CertificatesStepDefs.java | 5 + .../lowkeyvault/steps/ManagementStepDefs.java | 1 + .../certificates/CreateCertificates.feature | 7 +- .../certificates/RenewCertificates.feature | 49 +++++ 35 files changed, 769 insertions(+), 68 deletions(-) create mode 100644 lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultFakeImplIntegrationTest.java create mode 100644 lowkey-vault-docker/src/test/resources/com/github/nagyesta/lowkeyvault/certificates/RenewCertificates.feature diff --git a/README.md b/README.md index d46eed58..7e42a766 100644 --- a/README.md +++ b/README.md @@ -179,9 +179,7 @@ Lowkey Vault is far from supporting all Azure Key Vault features. The list suppo Certificate API features are work in progress, many Lowkey Vault features might not work or are known to be broken, for example but not limited to the following: -- Certificate lifetime policy does not have any effect - Import and export ignores certificates -- Time shift is not supported for certificates ### Management API @@ -220,5 +218,6 @@ not work or are known to be broken, for example but not limited to the following # Limitations - Some encryption/signature algorithms are not supported. Please refer to the ["Features"](#features) section for the up-to-date list of supported algorithms. -- Certificate Vault features are not supported at the moment +- Only self-signed certificates are supported by the certificate API. +- Time shift cannot renew/recreate deleted certificates. Please consider performing deletions after time shift as a work around. - Recovery options cannot be configured for vaults created during start-up diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/controller/VaultManagementController.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/controller/VaultManagementController.java index 61064931..dda603bb 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/controller/VaultManagementController.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/controller/VaultManagementController.java @@ -286,12 +286,17 @@ public ResponseEntity aliasUpdate(@RequestParam final URI baseUri, schema = @Schema(implementation = ErrorModel.class) ))}, parameters = { - @Parameter(name = "seconds", example = ONE, description = "The number of seconds we want to shift.")}, + @Parameter(name = "seconds", example = ONE, description = "The number of seconds we want to shift."), + @Parameter(name = "regenerateCertificates", example = FALSE, + description = "Whether we allow regeneration of certificates to let their validity match the new time-frame.")}, requestBody = @RequestBody(content = @Content(mediaType = MimeTypeUtils.APPLICATION_JSON_VALUE))) @PutMapping(value = "/time/all", params = {"seconds"}) - public ResponseEntity timeShiftAll(@RequestParam final int seconds) { - log.info("Received request to shift time of ALL vaults by {} seconds.", seconds); - vaultService.timeShift(seconds); + public ResponseEntity timeShiftAll( + @RequestParam final int seconds, + @RequestParam(required = false, defaultValue = "false") final boolean regenerateCertificates) { + log.info("Received request to shift time of ALL vaults by {} seconds, regenerate certificates: {}.", + seconds, regenerateCertificates); + vaultService.timeShift(seconds, regenerateCertificates); return ResponseEntity.noContent().build(); } @@ -319,12 +324,17 @@ public ResponseEntity timeShiftAll(@RequestParam final int seconds) { ))}, parameters = { @Parameter(name = "seconds", example = ONE, description = "The number of seconds we want to shift."), - @Parameter(name = "baseUri", example = BASE_URI, description = "The base URI of the vault we want to shift.")}, + @Parameter(name = "baseUri", example = BASE_URI, description = "The base URI of the vault we want to shift."), + @Parameter(name = "regenerateCertificates", example = FALSE, + description = "Whether we allow regeneration of certificates to let their validity match the new time-frame.")}, requestBody = @RequestBody(content = @Content(mediaType = MimeTypeUtils.APPLICATION_JSON_VALUE))) @PutMapping(value = "/time", params = {"baseUri", "seconds"}) - public ResponseEntity timeShiftSingle(@RequestParam final URI baseUri, @RequestParam final int seconds) { - log.info("Received request to shift time of vault with uri: {} by {} seconds.", baseUri, seconds); - vaultService.findByUriIncludeDeleted(baseUri).timeShift(seconds); + public ResponseEntity timeShiftSingle( + @RequestParam final URI baseUri, @RequestParam final int seconds, + @RequestParam(required = false, defaultValue = "false") final boolean regenerateCertificates) { + log.info("Received request to shift time of vault with uri: {}, by {} seconds, regenerate certificates: {}.", + baseUri, seconds, regenerateCertificates); + vaultService.findByUriIncludeDeleted(baseUri).timeShift(seconds, regenerateCertificates); return ResponseEntity.noContent().build(); } } diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/openapi/Examples.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/openapi/Examples.java index 6524625b..b9584179 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/openapi/Examples.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/openapi/Examples.java @@ -17,6 +17,10 @@ public final class Examples { * Example alias base URIs in a collection. */ public static final String ALIASES = "[\"" + ALIAS1 + "\",\"" + ALIAS2 + "\"]"; + /** + * The literal false as a String. + */ + public static final String FALSE = "false"; /** * The literal 1 as a String. */ diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/CertificateVaultFake.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/CertificateVaultFake.java index 514c10bc..9d300b6e 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/CertificateVaultFake.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/CertificateVaultFake.java @@ -16,4 +16,6 @@ public interface CertificateVaultFake LifetimeActionPolicy lifetimeActionPolicy(@NonNull CertificateEntityId certificateEntityId); void setLifetimeActionPolicy(@NonNull LifetimeActionPolicy lifetimeActionPolicy); + + void regenerateCertificates(); } diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/ReadOnlyLifetimeActionPolicy.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/ReadOnlyLifetimeActionPolicy.java index ad491ebb..09b443c4 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/ReadOnlyLifetimeActionPolicy.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/ReadOnlyLifetimeActionPolicy.java @@ -5,6 +5,7 @@ import java.time.OffsetDateTime; import java.util.List; import java.util.Map; +import java.util.function.Function; public interface ReadOnlyLifetimeActionPolicy { @@ -20,5 +21,5 @@ public interface ReadOnlyLifetimeActionPolicy { void validate(int validityMonths); - List missedRenewalDays(OffsetDateTime validityStart, OffsetDateTime expiry); + List missedRenewalDays(OffsetDateTime validityStart, Function createdToExpiryFunction); } diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateGenerator.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateGenerator.java index d3f6b714..d541f2b8 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateGenerator.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateGenerator.java @@ -55,7 +55,7 @@ public CertificateGenerator(@NonNull final VaultFake vault, @NonNull final Versi } public X509Certificate generateCertificate( - @NonNull final CertificateCreationInput input) throws CryptoException { + @NonNull final ReadOnlyCertificatePolicy input) throws CryptoException { try { final ReadOnlyAsymmetricKeyVaultKeyEntity readOnlyKeyVaultKey = vault.keyVaultFake().getEntities() .getEntity(kid, ReadOnlyAsymmetricKeyVaultKeyEntity.class); @@ -94,7 +94,7 @@ public PKCS10CertificationRequest generateCertificateSigningRequest( } } - private X509Certificate generateCertificate(final CertificateCreationInput input, final KeyPair keyPair) + private X509Certificate generateCertificate(final ReadOnlyCertificatePolicy input, final KeyPair keyPair) throws IOException, OperatorCreationException, CertificateException { final X509v3CertificateBuilder builder = createCertificateBuilder(input, keyPair); @@ -130,7 +130,7 @@ private ExtendedKeyUsage convertUsageExtensions(final ReadOnlyCertificatePolicy } private X509v3CertificateBuilder createCertificateBuilder( - final CertificateCreationInput input, final KeyPair keyPair) throws IOException { + final ReadOnlyCertificatePolicy input, final KeyPair keyPair) throws IOException { final X500Name subject = generateSubject(input.getSubject()); final X509v3CertificateBuilder certificate = new JcaX509v3CertificateBuilder( subject, generateSerial(), input.certNotBefore(), input.certExpiry(), subject, keyPair.getPublic()); diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateLifetimeActionPolicy.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateLifetimeActionPolicy.java index b45da5a3..0673178c 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateLifetimeActionPolicy.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateLifetimeActionPolicy.java @@ -11,6 +11,7 @@ import java.time.OffsetDateTime; import java.util.List; import java.util.Map; +import java.util.function.Function; public class CertificateLifetimeActionPolicy extends BaseLifetimePolicy implements LifetimeActionPolicy { @@ -45,12 +46,14 @@ public void validate(final int validityMonths) { } @Override - public List missedRenewalDays(final OffsetDateTime validityStart, final OffsetDateTime expiry) { + public List missedRenewalDays(final OffsetDateTime validityStart, + final Function createdToExpiryFunction) { Assert.isTrue(isAutoRenew(), "Cannot have missed renewals without an \"AutoRenew\" lifetime action."); - final long triggersAfterDays = lifetimeActions.get(CertificateLifetimeActionActivity.AUTO_RENEW) - .triggersAfterDays(validityStart, expiry); - final OffsetDateTime startPoint = findTriggerTimeOffset(validityStart, triggersAfterDays); - return collectMissedTriggerDays(triggersAfterDays, startPoint); + final CertificateLifetimeActionTrigger trigger = lifetimeActions.get(CertificateLifetimeActionActivity.AUTO_RENEW); + final Function triggerAfterDaysFunction = s -> trigger + .triggersAfterDays(s, createdToExpiryFunction.apply(s)); + final OffsetDateTime startPoint = findTriggerTimeOffset(validityStart, triggerAfterDaysFunction); + return collectMissedTriggerDays(triggerAfterDaysFunction, startPoint); } } diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateVaultFakeImpl.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateVaultFakeImpl.java index 46fd74fc..c74b0d93 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateVaultFakeImpl.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateVaultFakeImpl.java @@ -6,14 +6,20 @@ import com.github.nagyesta.lowkeyvault.service.certificate.ReadOnlyKeyVaultCertificateEntity; import com.github.nagyesta.lowkeyvault.service.certificate.id.CertificateEntityId; import com.github.nagyesta.lowkeyvault.service.certificate.id.VersionedCertificateEntityId; +import com.github.nagyesta.lowkeyvault.service.common.ReadOnlyVersionedEntityMultiMap; import com.github.nagyesta.lowkeyvault.service.common.impl.BaseVaultFakeImpl; +import com.github.nagyesta.lowkeyvault.service.key.ReadOnlyKeyVaultKeyEntity; import com.github.nagyesta.lowkeyvault.service.key.id.KeyEntityId; +import com.github.nagyesta.lowkeyvault.service.key.id.VersionedKeyEntityId; +import com.github.nagyesta.lowkeyvault.service.key.impl.KeyVaultKeyEntity; import com.github.nagyesta.lowkeyvault.service.secret.id.SecretEntityId; import com.github.nagyesta.lowkeyvault.service.vault.VaultFake; import lombok.NonNull; +import java.time.OffsetDateTime; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.function.Function; public class CertificateVaultFakeImpl extends BaseVaultFakeImpl p.timeShift(offsetSeconds)); + performPastRenewals(); + } + + private void performPastRenewals() { + purgeDeletedPolicies(); + lifetimeActionPolicies.values().stream() + .filter(LifetimeActionPolicy::isAutoRenew) + .filter(l -> getEntities().containsEntity(l.getId())) + .forEach(this::performMissedRenewalsOfPolicy); + } + + private void performMissedRenewalsOfPolicy(final LifetimeActionPolicy lifetimeActionPolicy) { + final CertificateEntityId certificateEntityId = lifetimeActionPolicy.getId(); + final VersionedCertificateEntityId latestVersionOfEntity = getEntities().getLatestVersionOfEntity(certificateEntityId); + final ReadOnlyKeyVaultCertificateEntity readOnlyEntity = getEntities().getReadOnlyEntity(latestVersionOfEntity); + final Function createdToExpiryFunction = s -> s + .plusMonths(readOnlyEntity.getPolicy().getValidityMonths()); + lifetimeActionPolicy.missedRenewalDays(readOnlyEntity.getCreated(), createdToExpiryFunction) + .forEach(renewalTime -> simulatePointInTimeRotation(certificateEntityId, renewalTime)); + } + + private void simulatePointInTimeRotation(final CertificateEntityId certificateEntityId, final OffsetDateTime renewalTime) { + final ReadOnlyKeyVaultCertificateEntity latest = latestReadOnlyCertificateVersion(certificateEntityId); + final CertificatePolicy input = new CertificatePolicy(latest.getPolicy()); + input.setValidityStart(renewalTime); + final VersionedKeyEntityId kid = rotateIfNeededAndGetLastKeyId(input); + final VersionedCertificateEntityId id = generateIdOfNewCertificateEntity(input, kid); + final KeyVaultCertificateEntity entity = new KeyVaultCertificateEntity(input, kid, id, vaultFake()); + addVersion(entity.getId(), entity); + } + + private VersionedCertificateEntityId generateIdOfNewCertificateEntity( + final ReadOnlyCertificatePolicy input, final VersionedKeyEntityId kid) { + final VersionedCertificateEntityId id; + if (input.isReuseKeyOnRenewal()) { + id = new VersionedCertificateEntityId(vaultFake().baseUri(), input.getName()); + } else { + id = new VersionedCertificateEntityId(vaultFake().baseUri(), input.getName(), kid.version()); + } + return id; + } + + private VersionedKeyEntityId rotateIfNeededAndGetLastKeyId(final ReadOnlyCertificatePolicy input) { + final ReadOnlyVersionedEntityMultiMap entities = vaultFake() + .keyVaultFake().getEntities(); + final VersionedKeyEntityId versionedKeyEntityId; + if (input.isReuseKeyOnRenewal()) { + final String lastVersion = entities.getVersions(new KeyEntityId(vaultFake().baseUri(), input.getName())).getLast(); + versionedKeyEntityId = new VersionedKeyEntityId(vaultFake().baseUri(), input.getName(), lastVersion); + } else { + versionedKeyEntityId = vaultFake().keyVaultFake().rotateKey(new KeyEntityId(vaultFake().baseUri(), input.getName())); + //update timestamps + final OffsetDateTime notBefore = input.getValidityStart(); + final OffsetDateTime expiry = notBefore.plusMonths(input.getValidityMonths()); + vaultFake().keyVaultFake().setExpiry(versionedKeyEntityId, notBefore, expiry); + final KeyVaultKeyEntity entity = vaultFake() + .keyVaultFake().getEntities().getEntity(versionedKeyEntityId, KeyVaultKeyEntity.class); + entity.setManaged(true); + entity.setCreatedOn(notBefore); + entity.setUpdatedOn(notBefore); + } + return versionedKeyEntityId; + } + @Override public void delete(@NonNull final CertificateEntityId entityId) { super.delete(entityId); @@ -87,6 +161,12 @@ public void setLifetimeActionPolicy(@NonNull final LifetimeActionPolicy lifetime } } + @Override + public void regenerateCertificates() { + this.getEntitiesInternal().forEachEntity(entity -> entity.regenerateCertificate(this.vaultFake())); + this.getDeletedEntitiesInternal().forEachEntity(entity -> entity.regenerateCertificate(this.vaultFake())); + } + private void purgeDeletedPolicies() { keepNamesReadyForRemoval(lifetimeActionPolicies.keySet()) .forEach(lifetimeActionPolicies::remove); diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/KeyVaultCertificateEntity.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/KeyVaultCertificateEntity.java index 7255da4d..739c072e 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/KeyVaultCertificateEntity.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/KeyVaultCertificateEntity.java @@ -10,8 +10,10 @@ import com.github.nagyesta.lowkeyvault.service.key.id.VersionedKeyEntityId; import com.github.nagyesta.lowkeyvault.service.secret.id.SecretEntityId; import com.github.nagyesta.lowkeyvault.service.secret.id.VersionedSecretEntityId; +import com.github.nagyesta.lowkeyvault.service.secret.impl.KeyVaultSecretEntity; import com.github.nagyesta.lowkeyvault.service.vault.VaultFake; import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; import org.bouncycastle.pkcs.PKCS10CertificationRequest; import org.springframework.util.Assert; @@ -21,8 +23,11 @@ import java.security.cert.X509Certificate; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Date; import java.util.Optional; +@Slf4j public class KeyVaultCertificateEntity extends KeyVaultBaseEntity implements ReadOnlyKeyVaultCertificateEntity { @@ -31,12 +36,19 @@ public class KeyVaultCertificateEntity private final VersionedKeyEntityId kid; private final VersionedSecretEntityId sid; - private final X509Certificate certificate; - private final ReadOnlyCertificatePolicy originalCertificateData; + private X509Certificate certificate; + private ReadOnlyCertificatePolicy originalCertificateData; private final String originalCertificateContents; private final CertificatePolicy policy; - private final PKCS10CertificationRequest csr; + private PKCS10CertificationRequest csr; + /** + * Constructor for certificate creation. + * + * @param name The name of the certificate entity. + * @param input The input parameters. + * @param vault The vault we need to use. + */ public KeyVaultCertificateEntity(@NonNull final String name, @NonNull final CertificateCreationInput input, @org.springframework.lang.NonNull final VaultFake vault) { @@ -56,14 +68,26 @@ public KeyVaultCertificateEntity(@NonNull final String name, final CertificateGenerator certificateGenerator = new CertificateGenerator(vault, this.kid); this.certificate = certificateGenerator.generateCertificate(input); this.csr = certificateGenerator.generateCertificateSigningRequest(name, this.certificate); - this.sid = generateSecret(input, vault, this.certificate, this.kid); + final VersionedSecretEntityId secretEntityId = new VersionedSecretEntityId(vault.baseUri(), input.getName(), this.kid.version()); + this.sid = generateSecret(this.policy, vault, this.certificate, this.kid, secretEntityId); this.setNotBefore(input.getValidityStart()); this.setExpiry(input.getValidityStart().plusMonths(input.getValidityMonths())); this.setEnabled(true); this.originalCertificateContents = vault.secretVaultFake().getEntities().getReadOnlyEntity(this.sid).getValue(); this.originalCertificateData = new CertificatePolicy(input); + //update timestamps of certificate as the constructor can run for more than a second + this.setCreatedOn(now()); + this.setUpdatedOn(now()); } + + /** + * Constructor for certificate import. + * + * @param name The name of the certificate entity. + * @param input The input parameters. + * @param vault The vault we need to use. + */ public KeyVaultCertificateEntity(@NonNull final String name, @NonNull final CertificateImportInput input, @org.springframework.lang.NonNull final VaultFake vault) { @@ -91,24 +115,65 @@ public KeyVaultCertificateEntity(@NonNull final String name, final CertificateGenerator certificateGenerator = new CertificateGenerator(vault, this.kid); this.certificate = certificate; this.csr = certificateGenerator.generateCertificateSigningRequest(name, this.certificate); - this.sid = generateSecret(policy, vault, this.certificate, this.kid); + final VersionedSecretEntityId secretEntityId = new VersionedSecretEntityId(vault.baseUri(), input.getName(), this.kid.version()); + this.sid = generateSecret(this.policy, vault, this.certificate, this.kid, secretEntityId); this.setNotBefore(policy.getValidityStart()); this.setExpiry(policy.getValidityStart().plusMonths(policy.getValidityMonths())); this.setEnabled(true); this.originalCertificateContents = vault.secretVaultFake().getEntities().getReadOnlyEntity(this.sid).getValue(); this.originalCertificateData = new CertificatePolicy(originalCertificateData); + //update timestamps of certificate as the constructor can run for more than a second + this.setCreatedOn(now()); + this.setUpdatedOn(now()); + } + + /** + * Constructor for certificate renewal. + * + * @param input The input parameters defining how the certificate should look like. + * @param kid The Id of the key entity version we need to use. + * @param id The Id of the new certificate entity. + * @param vault The vault we are using. + */ + public KeyVaultCertificateEntity(@NonNull final ReadOnlyCertificatePolicy input, + @NonNull final VersionedKeyEntityId kid, + @NonNull final VersionedCertificateEntityId id, + @org.springframework.lang.NonNull final VaultFake vault) { + super(vault); + Assert.state(vault.keyVaultFake().getEntities().containsEntity(kid), + "Key must exist to be able to renew certificate using it. " + kid.asUriNoVersion(vault.baseUri())); + Assert.state(vault.secretVaultFake().getEntities().containsName(input.getName()), + "A version of the Secret must exist to be able to generate a new version using name: " + input.getName()); + this.policy = new CertificatePolicy(input); + this.kid = kid; + //use the provided id, it might be different from the key id in case the key is reused. + this.id = id; + final CertificateGenerator certificateGenerator = new CertificateGenerator(vault, this.kid); + this.certificate = certificateGenerator.generateCertificate(input); + this.csr = certificateGenerator.generateCertificateSigningRequest(input.getName(), this.certificate); + final VersionedSecretEntityId secretEntityId = new VersionedSecretEntityId(vault.baseUri(), input.getName(), id.version()); + this.sid = generateSecret(this.policy, vault, this.certificate, this.kid, secretEntityId); + this.setNotBefore(input.getValidityStart()); + final OffsetDateTime expiry = input.getValidityStart().plusMonths(input.getValidityMonths()); + this.setExpiry(expiry); + this.setEnabled(true); + this.originalCertificateContents = vault.secretVaultFake().getEntities().getReadOnlyEntity(this.sid).getValue(); + this.originalCertificateData = new CertificatePolicy(input); + //update timestamps of certificate + this.setCreatedOn(input.getValidityStart()); + this.setUpdatedOn(input.getValidityStart()); } private VersionedSecretEntityId generateSecret(final ReadOnlyCertificatePolicy input, final VaultFake vault, final Certificate certificate, - final VersionedKeyEntityId kid) { + final VersionedKeyEntityId kid, + final VersionedSecretEntityId sid) { final KeyPair key = vault.keyVaultFake().getEntities().getEntity(kid, ReadOnlyAsymmetricKeyVaultKeyEntity.class).getKey(); final String value = input.getContentType().asBase64CertificatePackage(certificate, key); - final OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); - final OffsetDateTime expiry = now.plusMonths(input.getValidityMonths()); - final VersionedSecretEntityId secretId = new VersionedSecretEntityId(vault.baseUri(), input.getName(), kid.version()); - return vault.secretVaultFake().createSecretVersionForCertificate(secretId, value, input.getContentType(), now, expiry); + final OffsetDateTime start = input.getValidityStart(); + final OffsetDateTime expiry = start.plusMonths(input.getValidityMonths()); + return vault.secretVaultFake().createSecretVersionForCertificate(sid, value, input.getContentType(), start, expiry); } private VersionedKeyEntityId generateKeyPair(final ReadOnlyCertificatePolicy input, final VaultFake vault) { @@ -194,4 +259,47 @@ public byte[] getEncodedCertificateSigningRequest() { throw new CryptoException("Failed to obtain encoded certificate signing request: " + getId().toString(), e); } } + + @Override + public void timeShift(final int offsetSeconds) { + super.timeShift(offsetSeconds); + //reset expiry as it is measured in months while timeShift is using seconds + //it is better to stay consistent with the behavior of the certificates + this.setExpiry(this.getNotBefore().orElse(this.getCreated()).plusMonths(this.policy.getValidityMonths())); + } + + public void regenerateCertificate(final VaultFake vault) { + if (this.getDeletedDate().isPresent()) { + log.warn("Deleted certificate is regeneration is skipped for: {}", id); + } else if (validityStartDateNoLongerAccurate()) { + log.debug("Regenerating certificate: {}", id); + final CertificatePolicy updated = new CertificatePolicy(originalCertificateData); + updated.setValidityStart(getCreated()); + regenerateCertificateData(vault, updated); + updateSecretValueWithNewCertificate(vault, updated); + } else { + log.debug("Validity start date is still accurate certificate won't be changed: {}", id); + } + } + + private void updateSecretValueWithNewCertificate(final VaultFake vault, final CertificatePolicy updated) { + final ReadOnlyAsymmetricKeyVaultKeyEntity key = vault.keyVaultFake().getEntities() + .getEntity(kid, ReadOnlyAsymmetricKeyVaultKeyEntity.class); + final KeyVaultSecretEntity secret = vault.secretVaultFake().getEntities().getEntity(this.sid, KeyVaultSecretEntity.class); + secret.setValue(updated.getContentType().asBase64CertificatePackage(certificate, key.getKey())); + } + + private void regenerateCertificateData(final VaultFake vaultFake, final CertificatePolicy updated) { + final CertificateGenerator certificateGenerator = new CertificateGenerator(vaultFake, this.kid); + this.certificate = certificateGenerator.generateCertificate(updated); + this.csr = certificateGenerator.generateCertificateSigningRequest(this.id.id(), this.certificate); + this.originalCertificateData = updated; + } + + private boolean validityStartDateNoLongerAccurate() { + final OffsetDateTime validityStart = this.getNotBefore().orElse(this.getCreated()); + final Date desiredStartOfValidity = Date.from(validityStart.truncatedTo(ChronoUnit.DAYS).toInstant()); + final Date existingStartOfValidity = certificate.getNotBefore(); + return !desiredStartOfValidity.equals(existingStartOfValidity); + } } diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/common/impl/BaseLifetimePolicy.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/common/impl/BaseLifetimePolicy.java index 97ae1d4a..665923f1 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/common/impl/BaseLifetimePolicy.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/common/impl/BaseLifetimePolicy.java @@ -10,6 +10,7 @@ import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; +import java.util.function.Function; @Data public class BaseLifetimePolicy implements TimeAware { @@ -37,17 +38,23 @@ protected void markUpdate() { updatedOn = OffsetDateTime.now(); } - protected List collectMissedTriggerDays(final long triggerAfterDays, final OffsetDateTime startPoint) { + protected List collectMissedTriggerDays( + final Function triggerAfterDaysFunction, + final OffsetDateTime startPoint) { final OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); final List rotationTimes = new ArrayList<>(); - for (int i = 1; startPoint.plusDays(i * triggerAfterDays).isBefore(now); i++) { - rotationTimes.add(startPoint.plusDays(i * triggerAfterDays)); + OffsetDateTime latestDay = startPoint; + while (latestDay.plusDays(triggerAfterDaysFunction.apply(latestDay)).isBefore(now)) { + latestDay = latestDay.plusDays(triggerAfterDaysFunction.apply(latestDay)); + rotationTimes.add(latestDay); } return List.copyOf(rotationTimes); } - protected OffsetDateTime findTriggerTimeOffset(final OffsetDateTime entityCreation, final long triggerAfterDays) { - final OffsetDateTime relativeToLifetimeActionPolicy = createdOn.minusDays(triggerAfterDays); + protected OffsetDateTime findTriggerTimeOffset( + final OffsetDateTime entityCreation, + final Function triggerAfterDaysFunction) { + final OffsetDateTime relativeToLifetimeActionPolicy = createdOn.minusDays(triggerAfterDaysFunction.apply(createdOn)); OffsetDateTime startPoint = entityCreation; if (entityCreation.isBefore(relativeToLifetimeActionPolicy)) { startPoint = relativeToLifetimeActionPolicy; diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/common/impl/BaseVaultFakeImpl.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/common/impl/BaseVaultFakeImpl.java index 946db4fb..97ab2ec6 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/common/impl/BaseVaultFakeImpl.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/common/impl/BaseVaultFakeImpl.java @@ -134,6 +134,10 @@ protected VersionedEntityMultiMap getEntitiesInternal() { return entities; } + protected VersionedEntityMultiMap getDeletedEntitiesInternal() { + return deletedEntities; + } + protected VaultFake vaultFake() { return vaultFake; } diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/key/impl/KeyRotationPolicy.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/key/impl/KeyRotationPolicy.java index 503e0aa9..ad40f376 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/key/impl/KeyRotationPolicy.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/key/impl/KeyRotationPolicy.java @@ -3,6 +3,7 @@ import com.github.nagyesta.lowkeyvault.model.v7_3.key.constants.LifetimeActionType; import com.github.nagyesta.lowkeyvault.service.common.impl.BaseLifetimePolicy; import com.github.nagyesta.lowkeyvault.service.key.LifetimeAction; +import com.github.nagyesta.lowkeyvault.service.key.LifetimeActionTrigger; import com.github.nagyesta.lowkeyvault.service.key.RotationPolicy; import com.github.nagyesta.lowkeyvault.service.key.constants.LifetimeActionTriggerType; import com.github.nagyesta.lowkeyvault.service.key.id.KeyEntityId; @@ -39,9 +40,9 @@ public boolean isAutoRotate() { @Override public List missedRotations(@NonNull final OffsetDateTime keyCreation) { Assert.isTrue(isAutoRotate(), "Cannot have missed rotations without a \"rotate\" lifetime action."); - final long rotateAfterDays = lifetimeActions.get(LifetimeActionType.ROTATE).getTrigger().rotateAfterDays(expiryTime); - final OffsetDateTime startPoint = findTriggerTimeOffset(keyCreation, rotateAfterDays); - return collectMissedTriggerDays(rotateAfterDays, startPoint); + final LifetimeActionTrigger trigger = lifetimeActions.get(LifetimeActionType.ROTATE).getTrigger(); + final OffsetDateTime startPoint = findTriggerTimeOffset(keyCreation, s -> trigger.rotateAfterDays(expiryTime)); + return collectMissedTriggerDays(s -> trigger.rotateAfterDays(expiryTime), startPoint); } @Override diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/secret/impl/KeyVaultSecretEntity.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/secret/impl/KeyVaultSecretEntity.java index 7c3ea183..63e6bdc3 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/secret/impl/KeyVaultSecretEntity.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/secret/impl/KeyVaultSecretEntity.java @@ -10,7 +10,7 @@ public class KeyVaultSecretEntity extends KeyVaultBaseEntity implements ReadOnlyKeyVaultSecretEntity { - private final String value; + private String value; private final String contentType; private final VersionedSecretEntityId id; @@ -30,6 +30,10 @@ public String getValue() { return value; } + public void setValue(final String value) { + this.value = value; + } + @Override public String getContentType() { return contentType; diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/secret/impl/SecretVaultFakeImpl.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/secret/impl/SecretVaultFakeImpl.java index 1bde756f..e9dacead 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/secret/impl/SecretVaultFakeImpl.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/secret/impl/SecretVaultFakeImpl.java @@ -52,6 +52,9 @@ public VersionedSecretEntityId createSecretVersionForCertificate( setExpiry(secretEntityId, notBefore, expiry); setManaged(secretEntityId, true); setEnabled(secretEntityId, true); + final KeyVaultSecretEntity secretEntity = getEntities().getEntity(secretEntityId, KeyVaultSecretEntity.class); + secretEntity.setCreatedOn(notBefore); + secretEntity.setUpdatedOn(notBefore); return secretEntityId; } } diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/VaultFake.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/VaultFake.java index bc2e1b40..13f60143 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/VaultFake.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/VaultFake.java @@ -42,5 +42,5 @@ public interface VaultFake { void recover(); - void timeShift(int offsetSeconds); + void timeShift(int offsetSeconds, boolean regenerateCertificates); } diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/VaultService.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/VaultService.java index da8928b9..a5878e97 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/VaultService.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/VaultService.java @@ -26,7 +26,7 @@ public interface VaultService { boolean purge(URI uri); - void timeShift(int offsetSeconds); + void timeShift(int offsetSeconds, boolean regenerateCertificates); VaultFake updateAlias(URI baseUri, URI add, URI remove); } diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultFakeImpl.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultFakeImpl.java index 1a174125..972fe65c 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultFakeImpl.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultFakeImpl.java @@ -10,6 +10,7 @@ import com.github.nagyesta.lowkeyvault.service.vault.VaultFake; import lombok.EqualsAndHashCode; import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; import org.springframework.util.Assert; import java.net.URI; @@ -18,6 +19,7 @@ import java.util.Optional; import java.util.Set; +@Slf4j @EqualsAndHashCode(onlyExplicitlyIncluded = true, doNotUseGetters = true) public class VaultFakeImpl implements VaultFake { @@ -140,7 +142,7 @@ public void recover() { } @Override - public void timeShift(final int offsetSeconds) { + public void timeShift(final int offsetSeconds, final boolean regenerateCertificates) { Assert.isTrue(offsetSeconds > 0, "Offset must be positive."); createdOn = createdOn.minusSeconds(offsetSeconds); deletedOn = Optional.ofNullable(deletedOn) @@ -148,5 +150,10 @@ public void timeShift(final int offsetSeconds) { .orElse(null); keyVaultFake().timeShift(offsetSeconds); secretVaultFake().timeShift(offsetSeconds); + certificateVaultFake().timeShift(offsetSeconds); + if (regenerateCertificates) { + log.info("Regenerating certificates of vault: {}", baseUri()); + certificateVaultFake().regenerateCertificates(); + } } } diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultServiceImpl.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultServiceImpl.java index aa1f2ecd..05267ebb 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultServiceImpl.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultServiceImpl.java @@ -106,10 +106,10 @@ public boolean purge(final URI uri) { } @Override - public void timeShift(final int offsetSeconds) { + public void timeShift(final int offsetSeconds, final boolean regenerateCertificates) { Assert.isTrue(offsetSeconds > 0, "Offset must be positive."); log.info("Performing time shift with {} seconds for all vaults.", offsetSeconds); - vaultFakes.forEach(vaultFake -> vaultFake.timeShift(offsetSeconds)); + vaultFakes.forEach(vaultFake -> vaultFake.timeShift(offsetSeconds, regenerateCertificates)); purgeExpired(); } diff --git a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/controller/VaultManagementControllerTest.java b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/controller/VaultManagementControllerTest.java index af145a85..bd3829de 100644 --- a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/controller/VaultManagementControllerTest.java +++ b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/controller/VaultManagementControllerTest.java @@ -231,11 +231,11 @@ void testTimeShiftGlobalShouldCallServiceWhenCalled() { //given //when - final ResponseEntity actual = underTest.timeShiftAll(NUMBER_OF_SECONDS_IN_10_MINUTES); + final ResponseEntity actual = underTest.timeShiftAll(NUMBER_OF_SECONDS_IN_10_MINUTES, false); //then Assertions.assertEquals(HttpStatus.NO_CONTENT, actual.getStatusCode()); - verify(vaultService).timeShift(eq(NUMBER_OF_SECONDS_IN_10_MINUTES)); + verify(vaultService).timeShift(eq(NUMBER_OF_SECONDS_IN_10_MINUTES), eq(false)); verifyNoMoreInteractions(vaultService); } @@ -246,7 +246,8 @@ void testTimeShiftSingleShouldCallServiceWhenCalled() { when(vaultService.findByUriIncludeDeleted(eq(HTTPS_DEFAULT_LOWKEY_VAULT))).thenReturn(vaultFakeActive); //when - final ResponseEntity actual = underTest.timeShiftSingle(HTTPS_DEFAULT_LOWKEY_VAULT, NUMBER_OF_SECONDS_IN_10_MINUTES); + final ResponseEntity actual = underTest + .timeShiftSingle(HTTPS_DEFAULT_LOWKEY_VAULT, NUMBER_OF_SECONDS_IN_10_MINUTES, true); //then Assertions.assertEquals(HttpStatus.NO_CONTENT, actual.getStatusCode()); diff --git a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateLifetimeActionPolicyTest.java b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateLifetimeActionPolicyTest.java index dddc38e0..1b297bfc 100644 --- a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateLifetimeActionPolicyTest.java +++ b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/CertificateLifetimeActionPolicyTest.java @@ -20,17 +20,16 @@ import static com.github.nagyesta.lowkeyvault.service.certificate.CertificateLifetimeActionActivity.AUTO_RENEW; import static com.github.nagyesta.lowkeyvault.service.certificate.CertificateLifetimeActionActivity.EMAIL_CONTACTS; import static com.github.nagyesta.lowkeyvault.service.certificate.CertificateLifetimeActionTriggerType.DAYS_BEFORE_EXPIRY; +import static com.github.nagyesta.lowkeyvault.service.certificate.impl.CertificateCreationInput.DEFAULT_VALIDITY_MONTHS; +import static java.time.temporal.ChronoUnit.DAYS; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; class CertificateLifetimeActionPolicyTest { - - private static final int DAYS_120_MONTHS = 3600; - private static final int DAYS_90_MONTHS = 3300; - private static final int DAYS_10_MONTHS = 300; - private static final int DAYS_2_MONTHS = 60; - private static final OffsetDateTime DATE_100_MONTHS_AGO = NOW.minusDays(DAYS_120_MONTHS); - private static final OffsetDateTime DATE_88_MONTHS_AGO = NOW.minusDays(3240); + private static final int DAYS_60 = 60; + private static final int MONTHS_100 = 100; + private static final OffsetDateTime DATE_100_MONTHS_AGO = NOW.minusMonths(MONTHS_100); + public static final int VALIDITY_MONTHS = 12; public static Stream nullProvider() { return Stream.builder() @@ -132,21 +131,26 @@ void testValidateShouldCallValidateOfAllTriggersWhenCalled() { void testMissedRenewalDaysShouldReturnMissedRenewalDatesWhenCalledOnPolicyWithMissedRenewals() { //given final CertificateLifetimeActionTrigger emailTrigger = mock(CertificateLifetimeActionTrigger.class); - final CertificateLifetimeActionTrigger renewTrigger = new CertificateLifetimeActionTrigger(DAYS_BEFORE_EXPIRY, DAYS_2_MONTHS); + final CertificateLifetimeActionTrigger renewTrigger = new CertificateLifetimeActionTrigger(DAYS_BEFORE_EXPIRY, DAYS_60); final Map actions = Map .of(EMAIL_CONTACTS, emailTrigger, AUTO_RENEW, renewTrigger); final CertificateLifetimeActionPolicy underTest = new CertificateLifetimeActionPolicy(UNVERSIONED_CERT_ENTITY_ID_1, actions); underTest.setCreatedOn(DATE_100_MONTHS_AGO); //when - final List actual = underTest.missedRenewalDays(DATE_100_MONTHS_AGO, DATE_88_MONTHS_AGO); + final List actual = underTest.missedRenewalDays(DATE_100_MONTHS_AGO, s -> s.plusMonths(DEFAULT_VALIDITY_MONTHS)); + final long firstRenewal = DAYS.between(nextRenewal(DATE_100_MONTHS_AGO), NOW); //then - final List expected = Stream.iterate(DAYS_90_MONTHS, a -> a - DAYS_10_MONTHS) - .limit(DAYS_120_MONTHS / DAYS_10_MONTHS) + final List expected = Stream.iterate(firstRenewal, a -> DAYS.between(nextRenewal(NOW.minusDays(a)), NOW)) + .limit(MONTHS_100 / VALIDITY_MONTHS + 1) .map(NOW::minusDays) .collect(Collectors.toList()); Assertions.assertIterableEquals(expected, actual); verifyNoInteractions(emailTrigger); } + + private static OffsetDateTime nextRenewal(final OffsetDateTime start) { + return start.plusMonths(VALIDITY_MONTHS).minusDays(DAYS_60); + } } diff --git a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/KeyVaultCertificateEntityIntegrationTest.java b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/KeyVaultCertificateEntityIntegrationTest.java index af1f3c9e..cb8ab6c9 100644 --- a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/KeyVaultCertificateEntityIntegrationTest.java +++ b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/KeyVaultCertificateEntityIntegrationTest.java @@ -14,6 +14,7 @@ import org.springframework.util.MimeTypeUtils; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Objects; import java.util.Set; import java.util.TreeSet; @@ -279,4 +280,60 @@ void testCreateConstructorShouldSetOriginalCertificateWhenCalledWithValidInput() Assertions.assertEquals(currentPolicy, originalPolicy); Assertions.assertNotNull(actual.getOriginalCertificateContents()); } + + @Test + void testRegenerateCertificateShouldRegenerateCertificateWhenTheValidityIsNoLongerAccurate() { + //given + final CertificateCreationInput input = CertificateCreationInput.builder() + .validityStart(NOW) + .subject("CN=" + LOCALHOST) + .name(CERT_NAME_1) + .enableTransparency(false) + .certAuthorityType(SELF_SIGNED) + .contentType(CertContentType.PKCS12) + .keyCurveName(KeyCurveName.P_521) + .keyType(KeyType.EC) + .validityMonths(TWO_YEARS_IN_MONTHS) + .build(); + + final VaultFake vault = new VaultFakeImpl(HTTPS_LOCALHOST_8443); + final KeyVaultCertificateEntity underTest = new KeyVaultCertificateEntity(CERT_NAME_1, input, vault); + underTest.timeShift((int) Duration.ofDays(1).toSeconds()); + final X509Certificate original = (X509Certificate) underTest.getCertificate(); + + //when + underTest.regenerateCertificate(vault); + + //then + final X509Certificate actual = (X509Certificate) underTest.getCertificate(); + Assertions.assertNotEquals(original, actual); + } + + @Test + void testRegenerateCertificateShouldNotRegenerateCertificateWhenTheValidityIsStillAccurate() { + //given + final CertificateCreationInput input = CertificateCreationInput.builder() + .validityStart(NOW) + .subject("CN=" + LOCALHOST) + .name(CERT_NAME_1) + .enableTransparency(false) + .certAuthorityType(SELF_SIGNED) + .contentType(CertContentType.PKCS12) + .keyCurveName(KeyCurveName.P_521) + .keyType(KeyType.EC) + .validityMonths(TWO_YEARS_IN_MONTHS) + .build(); + + final VaultFake vault = new VaultFakeImpl(HTTPS_LOCALHOST_8443); + final KeyVaultCertificateEntity underTest = new KeyVaultCertificateEntity(CERT_NAME_1, input, vault); + underTest.timeShift((int) Duration.ofHours(1).toSeconds()); + final X509Certificate original = (X509Certificate) underTest.getCertificate(); + + //when + underTest.regenerateCertificate(vault); + + //then + final X509Certificate actual = (X509Certificate) underTest.getCertificate(); + Assertions.assertSame(original, actual); + } } diff --git a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/KeyVaultCertificateEntityTest.java b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/KeyVaultCertificateEntityTest.java index 0617809d..7c8aff50 100644 --- a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/KeyVaultCertificateEntityTest.java +++ b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/certificate/impl/KeyVaultCertificateEntityTest.java @@ -28,6 +28,7 @@ import static com.github.nagyesta.lowkeyvault.TestConstants.*; import static com.github.nagyesta.lowkeyvault.TestConstantsCertificates.CERT_NAME_1; import static com.github.nagyesta.lowkeyvault.TestConstantsCertificates.VERSIONED_CERT_ENTITY_ID_1_VERSION_1; +import static com.github.nagyesta.lowkeyvault.TestConstantsKeys.VERSIONED_KEY_ENTITY_ID_1_VERSION_1; import static com.github.nagyesta.lowkeyvault.TestConstantsUri.HTTPS_LOCALHOST_8443; import static com.github.nagyesta.lowkeyvault.service.certificate.impl.CertAuthorityType.UNKNOWN; import static org.mockito.ArgumentMatchers.eq; @@ -49,6 +50,24 @@ public static Stream nullProvider() { .build(); } + public static Stream nullRenewalProvider() { + final VersionedKeyEntityId kid = VERSIONED_KEY_ENTITY_ID_1_VERSION_1; + final VersionedCertificateEntityId cid = VERSIONED_CERT_ENTITY_ID_1_VERSION_1; + final VaultFake vaultFake = mock(VaultFake.class); + final CertificateCreationInput input = CertificateCreationInput.builder().build(); + return Stream.builder() + .add(Arguments.of(null, null, null, null)) + .add(Arguments.of(input, null, null, null)) + .add(Arguments.of(null, kid, null, null)) + .add(Arguments.of(null, null, cid, null)) + .add(Arguments.of(null, null, null, vaultFake)) + .add(Arguments.of(null, kid, cid, vaultFake)) + .add(Arguments.of(input, null, cid, vaultFake)) + .add(Arguments.of(input, kid, null, vaultFake)) + .add(Arguments.of(input, kid, cid, null)) + .build(); + } + @ParameterizedTest @MethodSource("nullProvider") void testConstructorShouldThrowExceptionWhenCalledWithNulls( @@ -62,6 +81,20 @@ void testConstructorShouldThrowExceptionWhenCalledWithNulls( //then + exception } + @ParameterizedTest + @MethodSource("nullRenewalProvider") + void testRenewalConstructorShouldThrowExceptionWhenCalledWithNulls( + final ReadOnlyCertificatePolicy input, final VersionedKeyEntityId kid, + final VersionedCertificateEntityId id, final VaultFake vault) { + //given + + + //when + Assertions.assertThrows(IllegalArgumentException.class, () -> new KeyVaultCertificateEntity(input, kid, id, vault)); + + //then + exception + } + @SuppressWarnings("unchecked") @Test void testConstructorShouldThrowExceptionWhenCalledWithAlreadyUsedKeyName() { @@ -289,4 +322,68 @@ void testGetEncodedCertificateShouldWrapExceptionWhenErrorIsCaught() { //then + exception } + + @SuppressWarnings("unchecked") + @Test + void testRenewalConstructorShouldThrowExceptionWhenCalledWithNotExistingKeyId() { + //given + final VersionedKeyEntityId kid = VERSIONED_KEY_ENTITY_ID_1_VERSION_1; + final VersionedCertificateEntityId id = VERSIONED_CERT_ENTITY_ID_1_VERSION_1; + final CertificateCreationInput input = CertificateCreationInput.builder().name(id.id()).build(); + + final ReadOnlyVersionedEntityMultiMap keyMap + = mock(ReadOnlyVersionedEntityMultiMap.class); + when(keyMap.containsEntity(eq(kid))).thenReturn(false); + + final KeyVaultFake keyFake = mock(KeyVaultFake.class); + when(keyFake.getEntities()).thenReturn(keyMap); + + final VaultFake vault = mock(VaultFake.class); + when(vault.baseUri()).thenReturn(id.vault()); + when(vault.keyVaultFake()).thenReturn(keyFake); + + //when + Assertions.assertThrows(IllegalStateException.class, () -> new KeyVaultCertificateEntity(input, kid, id, vault)); + + //then + exception + verify(vault).keyVaultFake(); + verify(keyFake).getEntities(); + verify(keyMap).containsEntity(eq(kid)); + } + + @SuppressWarnings("unchecked") + @Test + void testRenewalConstructorShouldThrowExceptionWhenNoMatchingSecretNameFound() { + //given + final VersionedKeyEntityId kid = VERSIONED_KEY_ENTITY_ID_1_VERSION_1; + final VersionedCertificateEntityId id = VERSIONED_CERT_ENTITY_ID_1_VERSION_1; + final CertificateCreationInput input = CertificateCreationInput.builder().name(id.id()).build(); + + final ReadOnlyVersionedEntityMultiMap keyMap + = mock(ReadOnlyVersionedEntityMultiMap.class); + when(keyMap.containsEntity(eq(kid))).thenReturn(true); + final KeyVaultFake keyFake = mock(KeyVaultFake.class); + when(keyFake.getEntities()).thenReturn(keyMap); + + final ReadOnlyVersionedEntityMultiMap secretMap + = mock(ReadOnlyVersionedEntityMultiMap.class); + when(keyMap.containsName(eq(id.id()))).thenReturn(false); + final SecretVaultFake secretFake = mock(SecretVaultFake.class); + when(secretFake.getEntities()).thenReturn(secretMap); + + final VaultFake vault = mock(VaultFake.class); + when(vault.baseUri()).thenReturn(id.vault()); + when(vault.keyVaultFake()).thenReturn(keyFake); + when(vault.secretVaultFake()).thenReturn(secretFake); + + //when + Assertions.assertThrows(IllegalStateException.class, () -> new KeyVaultCertificateEntity(input, kid, id, vault)); + + //then + exception + verify(vault).keyVaultFake(); + verify(keyFake).getEntities(); + verify(keyMap).containsEntity(eq(kid)); + verify(secretFake).getEntities(); + verify(secretMap).containsName(eq(id.id())); + } } diff --git a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/secret/impl/SecretVaultFakeImplTest.java b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/secret/impl/SecretVaultFakeImplTest.java index 4598a944..1074fdfe 100644 --- a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/secret/impl/SecretVaultFakeImplTest.java +++ b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/secret/impl/SecretVaultFakeImplTest.java @@ -197,5 +197,8 @@ void testCreateSecretVersionForCertificateShouldSetValuesAndManagedFlagWhenCalle Assertions.assertEquals(expiry, entity.getExpiry().orElse(null)); Assertions.assertTrue(entity.isEnabled()); Assertions.assertTrue(entity.isManaged()); + //created and updated must be set to the same value as not before in case of new certificate backing secrets + Assertions.assertEquals(notBefore, entity.getCreated()); + Assertions.assertEquals(notBefore, entity.getUpdated()); } } diff --git a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultFakeImplIntegrationTest.java b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultFakeImplIntegrationTest.java new file mode 100644 index 00000000..bd6f06f5 --- /dev/null +++ b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultFakeImplIntegrationTest.java @@ -0,0 +1,200 @@ +package com.github.nagyesta.lowkeyvault.service.vault.impl; + +import com.github.nagyesta.lowkeyvault.model.v7_2.common.constants.RecoveryLevel; +import com.github.nagyesta.lowkeyvault.model.v7_2.key.constants.KeyCurveName; +import com.github.nagyesta.lowkeyvault.model.v7_2.key.constants.KeyType; +import com.github.nagyesta.lowkeyvault.service.certificate.CertificateLifetimeActionTrigger; +import com.github.nagyesta.lowkeyvault.service.certificate.CertificateVaultFake; +import com.github.nagyesta.lowkeyvault.service.certificate.ReadOnlyKeyVaultCertificateEntity; +import com.github.nagyesta.lowkeyvault.service.certificate.id.VersionedCertificateEntityId; +import com.github.nagyesta.lowkeyvault.service.certificate.impl.CertContentType; +import com.github.nagyesta.lowkeyvault.service.certificate.impl.CertificateCreationInput; +import com.github.nagyesta.lowkeyvault.service.certificate.impl.CertificateLifetimeActionPolicy; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.github.nagyesta.lowkeyvault.TestConstantsCertificates.CERT_NAME_1; +import static com.github.nagyesta.lowkeyvault.TestConstantsUri.HTTPS_LOCALHOST; +import static com.github.nagyesta.lowkeyvault.service.certificate.CertificateLifetimeActionActivity.AUTO_RENEW; +import static com.github.nagyesta.lowkeyvault.service.certificate.CertificateLifetimeActionTriggerType.DAYS_BEFORE_EXPIRY; +import static com.github.nagyesta.lowkeyvault.service.certificate.impl.CertificateCreationInput.DEFAULT_VALIDITY_MONTHS; +import static java.time.temporal.ChronoUnit.DAYS; +import static java.time.temporal.ChronoUnit.MONTHS; + +class VaultFakeImplIntegrationTest { + + private static final int INT_800 = 800; + private static final int SECONDS_IN_1_DAY = 24 * 3600; + private static final int SECONDS_IN_800_DAYS = INT_800 * SECONDS_IN_1_DAY; + public static final int EXPECTED_VERSIONS_AFTER_RENEWAL = 3; + + @Test + void testTimeShiftShouldCreateNewVersionsWhenAutoRotateIsTriggeredWithActiveCertificates() { + //given + final VaultFakeImpl underTest = new VaultFakeImpl(HTTPS_LOCALHOST); + final CertificateVaultFake certificateVaultFake = underTest.certificateVaultFake(); + final OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + final OffsetDateTime approxNow = now.plusMinutes(1); + final VersionedCertificateEntityId originalCertId = certificateVaultFake + .createCertificateVersion(CERT_NAME_1, CertificateCreationInput.builder() + .contentType(CertContentType.PEM) + .name(CERT_NAME_1) + .keyType(KeyType.EC) + .validityStart(approxNow) + .validityMonths(DEFAULT_VALIDITY_MONTHS) + .keyCurveName(KeyCurveName.P_521) + .subject("CN=localhost") + .build()); + final int triggerThresholdDays = 1; + certificateVaultFake.setLifetimeActionPolicy(new CertificateLifetimeActionPolicy( + originalCertId, Map.of(AUTO_RENEW, new CertificateLifetimeActionTrigger(DAYS_BEFORE_EXPIRY, triggerThresholdDays)) + )); + + //when + underTest.timeShift(SECONDS_IN_800_DAYS, true); + + //then + final Deque versions = underTest.certificateVaultFake().getEntities().getVersions(originalCertId); + final List entities = versions.stream() + .map(v -> new VersionedCertificateEntityId(originalCertId.vault(), originalCertId.id(), v)) + .map(certificateVaultFake.getEntities()::getReadOnlyEntity) + .collect(Collectors.toList()); + Assertions.assertEquals(EXPECTED_VERSIONS_AFTER_RENEWAL, entities.size()); + final ReadOnlyKeyVaultCertificateEntity recreatedOriginal = entities.get(0); + final ReadOnlyKeyVaultCertificateEntity firstRenewal = entities.get(1); + final ReadOnlyKeyVaultCertificateEntity secondRenewal = entities.get(2); + assertTimestampsAreAdjustedAsExpected(approxNow, recreatedOriginal, INT_800); + final OffsetDateTime firstRenewalDay = recreatedOriginal.getExpiry().map(v -> v.minusDays(triggerThresholdDays)).orElseThrow(); + assertTimestampsAreAdjustedAsExpected(approxNow, firstRenewal, DAYS.between(firstRenewalDay, approxNow)); + final OffsetDateTime secondRenewalDay = firstRenewal.getExpiry().map(v -> v.minusDays(triggerThresholdDays)).orElseThrow(); + assertTimestampsAreAdjustedAsExpected(approxNow, secondRenewal, DAYS.between(secondRenewalDay, approxNow)); + Assertions.assertNotEquals(recreatedOriginal.getKid(), firstRenewal.getKid()); + Assertions.assertNotEquals(recreatedOriginal.getKid(), secondRenewal.getKid()); + } + + @Test + void testTimeShiftShouldCreateNewVersionsUsingSameKeyWhenAutoRotateIsTriggeredWithActiveCertificatesAllowingKeyReuse() { + //given + final VaultFakeImpl underTest = new VaultFakeImpl(HTTPS_LOCALHOST); + final CertificateVaultFake certificateVaultFake = underTest.certificateVaultFake(); + final OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + final OffsetDateTime approxNow = now.plusMinutes(1); + final VersionedCertificateEntityId originalCertId = certificateVaultFake + .createCertificateVersion(CERT_NAME_1, CertificateCreationInput.builder() + .contentType(CertContentType.PEM) + .name(CERT_NAME_1) + .keyType(KeyType.EC) + .validityStart(approxNow) + .validityMonths(DEFAULT_VALIDITY_MONTHS) + .keyCurveName(KeyCurveName.P_521) + .subject("CN=localhost") + .reuseKeyOnRenewal(true) + .build()); + final int triggerThresholdDays = 1; + certificateVaultFake.setLifetimeActionPolicy(new CertificateLifetimeActionPolicy( + originalCertId, Map.of(AUTO_RENEW, new CertificateLifetimeActionTrigger(DAYS_BEFORE_EXPIRY, triggerThresholdDays)) + )); + + //when + underTest.timeShift(SECONDS_IN_800_DAYS, true); + + //then + final Deque versions = underTest.certificateVaultFake().getEntities().getVersions(originalCertId); + final List entities = versions.stream() + .map(v -> new VersionedCertificateEntityId(originalCertId.vault(), originalCertId.id(), v)) + .map(certificateVaultFake.getEntities()::getReadOnlyEntity) + .collect(Collectors.toList()); + Assertions.assertEquals(EXPECTED_VERSIONS_AFTER_RENEWAL, entities.size()); + final ReadOnlyKeyVaultCertificateEntity recreatedOriginal = entities.get(0); + final ReadOnlyKeyVaultCertificateEntity firstRenewal = entities.get(1); + final ReadOnlyKeyVaultCertificateEntity secondRenewal = entities.get(2); + assertTimestampsAreAdjustedAsExpected(approxNow, recreatedOriginal, INT_800); + final OffsetDateTime firstRenewalDay = recreatedOriginal.getExpiry().map(v -> v.minusDays(triggerThresholdDays)).orElseThrow(); + assertTimestampsAreAdjustedAsExpected(approxNow, firstRenewal, DAYS.between(firstRenewalDay, approxNow)); + final OffsetDateTime secondRenewalDay = firstRenewal.getExpiry().map(v -> v.minusDays(triggerThresholdDays)).orElseThrow(); + assertTimestampsAreAdjustedAsExpected(approxNow, secondRenewal, DAYS.between(secondRenewalDay, approxNow)); + Assertions.assertEquals(recreatedOriginal.getKid(), firstRenewal.getKid()); + Assertions.assertEquals(recreatedOriginal.getKid(), secondRenewal.getKid()); + } + + @Test + void testTimeShiftShouldNotCreateNewVersionsWhenAutoRotateIsTriggeredWithDeletedCertificatesAsTheyArePurged() { + //given + final VaultFakeImpl underTest = new VaultFakeImpl(HTTPS_LOCALHOST, + RecoveryLevel.RECOVERABLE_AND_PURGEABLE, RecoveryLevel.MAX_RECOVERABLE_DAYS_INCLUSIVE); + final CertificateVaultFake certificateVaultFake = underTest.certificateVaultFake(); + final OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + final OffsetDateTime approxNow = now.plusMinutes(1); + final VersionedCertificateEntityId originalCertId = certificateVaultFake + .createCertificateVersion(CERT_NAME_1, CertificateCreationInput.builder() + .contentType(CertContentType.PEM) + .name(CERT_NAME_1) + .keyType(KeyType.EC) + .validityStart(approxNow) + .validityMonths(DEFAULT_VALIDITY_MONTHS) + .keyCurveName(KeyCurveName.P_521) + .subject("CN=localhost") + .build()); + final int triggerThresholdDays = 1; + certificateVaultFake.setLifetimeActionPolicy(new CertificateLifetimeActionPolicy( + originalCertId, Map.of(AUTO_RENEW, new CertificateLifetimeActionTrigger(DAYS_BEFORE_EXPIRY, triggerThresholdDays)) + )); + certificateVaultFake.delete(originalCertId); + + //when + underTest.timeShift(SECONDS_IN_800_DAYS, true); + + //then + final boolean exists = underTest.certificateVaultFake().getDeletedEntities().containsName(originalCertId.id()); + Assertions.assertFalse(exists); + } + + @Test + void testTimeShiftShouldNotCreateNewVersionsWhenAutoRotateIsTriggeredWithDeletedCertificatesEvenIfNotPurged() { + //given + final VaultFakeImpl underTest = new VaultFakeImpl(HTTPS_LOCALHOST, + RecoveryLevel.RECOVERABLE_AND_PURGEABLE, RecoveryLevel.MAX_RECOVERABLE_DAYS_INCLUSIVE); + final CertificateVaultFake certificateVaultFake = underTest.certificateVaultFake(); + final OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + final OffsetDateTime approxNow = now.plusMinutes(1); + final VersionedCertificateEntityId originalCertId = certificateVaultFake + .createCertificateVersion(CERT_NAME_1, CertificateCreationInput.builder() + .contentType(CertContentType.PEM) + .name(CERT_NAME_1) + .keyType(KeyType.EC) + .validityStart(approxNow) + .validityMonths(DEFAULT_VALIDITY_MONTHS) + .keyCurveName(KeyCurveName.P_521) + .subject("CN=localhost") + .build()); + final int triggerThresholdDays = 1; + certificateVaultFake.setLifetimeActionPolicy(new CertificateLifetimeActionPolicy( + originalCertId, Map.of(AUTO_RENEW, new CertificateLifetimeActionTrigger(DAYS_BEFORE_EXPIRY, triggerThresholdDays)) + )); + certificateVaultFake.delete(originalCertId); + + //when + underTest.timeShift(SECONDS_IN_1_DAY, true); + + //then + final Deque versions = underTest.certificateVaultFake().getDeletedEntities().getVersions(originalCertId); + Assertions.assertIterableEquals(Set.of(originalCertId.version()), versions); + } + + private static void assertTimestampsAreAdjustedAsExpected( + final OffsetDateTime approxNow, final ReadOnlyKeyVaultCertificateEntity recreatedOriginal, final long expectedCreationDaysAgo) { + Assertions.assertEquals(expectedCreationDaysAgo, DAYS.between(recreatedOriginal.getCreated(), approxNow)); + Assertions.assertEquals(expectedCreationDaysAgo, DAYS.between(recreatedOriginal.getUpdated(), approxNow)); + Assertions.assertEquals(expectedCreationDaysAgo, DAYS.between(recreatedOriginal.getNotBefore().orElseThrow(), approxNow)); + Assertions.assertEquals(DEFAULT_VALIDITY_MONTHS, MONTHS + .between(recreatedOriginal.getNotBefore().orElseThrow(), recreatedOriginal.getExpiry().orElseThrow())); + } +} diff --git a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultFakeImplTest.java b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultFakeImplTest.java index 6b9ec2d5..8d1775fc 100644 --- a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultFakeImplTest.java +++ b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultFakeImplTest.java @@ -282,7 +282,7 @@ void testTimeShiftShouldThrowExceptionWhenCalledWithNegativeOrZero(final int val RecoveryLevel.RECOVERABLE_AND_PROTECTED_SUBSCRIPTION, RecoveryLevel.MAX_RECOVERABLE_DAYS_INCLUSIVE); //when - Assertions.assertThrows(IllegalArgumentException.class, () -> underTest.timeShift(value)); + Assertions.assertThrows(IllegalArgumentException.class, () -> underTest.timeShift(value, false)); //then + exception } @@ -298,7 +298,7 @@ void testTimeShiftShouldReduceTimeStampsWhenCalledWithPositive() { final OffsetDateTime deletedOriginal = underTest.getDeletedOn(); //when - underTest.timeShift(NUMBER_OF_SECONDS_IN_10_MINUTES); + underTest.timeShift(NUMBER_OF_SECONDS_IN_10_MINUTES, false); //then Assertions.assertEquals(createdOriginal.minusSeconds(NUMBER_OF_SECONDS_IN_10_MINUTES), underTest.getCreatedOn()); diff --git a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultServiceImplTest.java b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultServiceImplTest.java index b5e49c22..1df1c4e3 100644 --- a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultServiceImplTest.java +++ b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/service/vault/impl/VaultServiceImplTest.java @@ -333,7 +333,7 @@ void testTimeShiftShouldThrowExceptionWhenCalledWithNegativeOrZero(final int val final VaultServiceImpl underTest = new VaultServiceImpl(); //when - Assertions.assertThrows(IllegalArgumentException.class, () -> underTest.timeShift(value)); + Assertions.assertThrows(IllegalArgumentException.class, () -> underTest.timeShift(value, false)); //then + exception } @@ -346,7 +346,7 @@ void testTimeShiftShouldBeForwardedToEachVaultWhenCalledWithPositive() { final OffsetDateTime createdOriginal = vaultFake.getCreatedOn(); //when - underTest.timeShift(TestConstants.NUMBER_OF_SECONDS_IN_10_MINUTES); + underTest.timeShift(TestConstants.NUMBER_OF_SECONDS_IN_10_MINUTES, false); //then Assertions.assertEquals(createdOriginal.minusSeconds(TestConstants.NUMBER_OF_SECONDS_IN_10_MINUTES), vaultFake.getCreatedOn()); diff --git a/lowkey-vault-client/src/main/java/com/github/nagyesta/lowkeyvault/http/management/TimeShiftContext.java b/lowkey-vault-client/src/main/java/com/github/nagyesta/lowkeyvault/http/management/TimeShiftContext.java index 584340a9..da733e2e 100644 --- a/lowkey-vault-client/src/main/java/com/github/nagyesta/lowkeyvault/http/management/TimeShiftContext.java +++ b/lowkey-vault-client/src/main/java/com/github/nagyesta/lowkeyvault/http/management/TimeShiftContext.java @@ -12,11 +12,14 @@ public final class TimeShiftContext { private final int seconds; + private final boolean regenerateCertificates; + private final URI vaultBaseUri; - private TimeShiftContext(final int seconds, final URI vaultBaseUri) { + private TimeShiftContext(final int seconds, final URI vaultBaseUri, final boolean regenerateCertificates) { this.seconds = seconds; this.vaultBaseUri = vaultBaseUri; + this.regenerateCertificates = regenerateCertificates; } public static TimeShiftContextBuilder builder() { @@ -30,6 +33,8 @@ public static final class TimeShiftContextBuilder { private int seconds; private URI vaultBaseUri; + private boolean regenerateCertificates; + TimeShiftContextBuilder() { } @@ -53,13 +58,18 @@ public TimeShiftContextBuilder addDays(final int days) { return addHours(HOURS_PER_DAY * days); } + public TimeShiftContextBuilder regenerateCertificates() { + this.regenerateCertificates = true; + return this; + } + public TimeShiftContextBuilder vaultBaseUri(@NonNull final URI vaultBaseUri) { this.vaultBaseUri = vaultBaseUri; return this; } public TimeShiftContext build() { - return new TimeShiftContext(seconds, vaultBaseUri); + return new TimeShiftContext(seconds, vaultBaseUri, regenerateCertificates); } } } diff --git a/lowkey-vault-client/src/main/java/com/github/nagyesta/lowkeyvault/http/management/impl/LowkeyVaultManagementClientImpl.java b/lowkey-vault-client/src/main/java/com/github/nagyesta/lowkeyvault/http/management/impl/LowkeyVaultManagementClientImpl.java index 75b984a6..4c52151e 100644 --- a/lowkey-vault-client/src/main/java/com/github/nagyesta/lowkeyvault/http/management/impl/LowkeyVaultManagementClientImpl.java +++ b/lowkey-vault-client/src/main/java/com/github/nagyesta/lowkeyvault/http/management/impl/LowkeyVaultManagementClientImpl.java @@ -44,6 +44,7 @@ public final class LowkeyVaultManagementClientImpl implements LowkeyVaultManagem private static final String ALIAS_URI_ADD_QUERY_PARAM = "add"; private static final String ALIAS_URI_REMOVE_QUERY_PARAM = "remove"; private static final String SECONDS_QUERY_PARAM = "seconds"; + private static final String REGENERATE_CERTS_QUERY_PARAM = "regenerateCertificates"; private final String vaultUrl; private final HttpClient instance; private final ObjectReader objectReader; @@ -144,6 +145,9 @@ public boolean purge(@NonNull final URI baseUri) { public void timeShift(@NonNull final TimeShiftContext context) { final Map parameters = new TreeMap<>(); parameters.put(SECONDS_QUERY_PARAM, Integer.toString(context.getSeconds())); + if (context.isRegenerateCertificates()) { + parameters.put(REGENERATE_CERTS_QUERY_PARAM, Boolean.TRUE.toString()); + } final Optional optionalURI = Optional.ofNullable(context.getVaultBaseUri()); optionalURI.ifPresent(uri -> parameters.put(BASE_URI_QUERY_PARAM, uri.toString())); final String path = optionalURI.map(u -> MANAGEMENT_VAULT_TIME_PATH).orElse(MANAGEMENT_VAULT_TIME_ALL_PATH); diff --git a/lowkey-vault-client/src/test/java/com/github/nagyesta/lowkeyvault/http/management/impl/LowkeyVaultManagementClientImplTest.java b/lowkey-vault-client/src/test/java/com/github/nagyesta/lowkeyvault/http/management/impl/LowkeyVaultManagementClientImplTest.java index 0e702ec8..3f3094c7 100644 --- a/lowkey-vault-client/src/test/java/com/github/nagyesta/lowkeyvault/http/management/impl/LowkeyVaultManagementClientImplTest.java +++ b/lowkey-vault-client/src/test/java/com/github/nagyesta/lowkeyvault/http/management/impl/LowkeyVaultManagementClientImplTest.java @@ -574,6 +574,32 @@ void testTimeShiftShouldSucceedWhenCalledWithUriAndTime() { verify(response).getBodyAsString(eq(StandardCharsets.UTF_8)); } + @Test + void testTimeShiftShouldSucceedWhenCalledWithTimeAndRegenerateFlag() { + //given + final HttpResponse response = mock(HttpResponse.class); + when(httpClient.send(httpRequestArgumentCaptor.capture())).thenReturn(Mono.just(response)); + when(response.getBodyAsString(eq(StandardCharsets.UTF_8))).thenReturn(Mono.empty()); + when(response.getStatusCode()).thenReturn(HttpStatus.SC_NO_CONTENT); + final TimeShiftContext context = TimeShiftContext.builder() + .regenerateCertificates() + .addSeconds(1) + .build(); + + //when + underTest.timeShift(context); + + //then + verify(httpClient, atMostOnce()).send(any()); + final HttpRequest request = httpRequestArgumentCaptor.getValue(); + Assertions.assertEquals("/management/vault/time/all", request.getUrl().getPath()); + Assertions.assertEquals("regenerateCertificates=true&seconds=1", request.getUrl().getQuery()); + Assertions.assertEquals(HttpMethod.PUT, request.getHttpMethod()); + Assertions.assertEquals(APPLICATION_JSON, request.getHeaders().getValue(HttpHeaders.CONTENT_TYPE)); + verify(response).getStatusCode(); + verify(response).getBodyAsString(eq(StandardCharsets.UTF_8)); + } + @Test void testExportActiveShouldReturnFullResponseWhenCalledOnRunningServer() { //given diff --git a/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/hook/MissionOutlineDefinition.java b/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/hook/MissionOutlineDefinition.java index 658b9f85..a3260e4d 100644 --- a/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/hook/MissionOutlineDefinition.java +++ b/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/hook/MissionOutlineDefinition.java @@ -51,7 +51,7 @@ protected Map> defineOutline() { }); Stream.of("CreateVault", "KeyRotate", "KeyImport", "KeyEncrypt", "KeySign", "CertificateImport", - "CertificateGetPolicy", "RSA", "EC", "OCT") + "CertificateGetPolicy", "CertificateTimeShift", "RSA", "EC", "OCT") .forEach(tag -> { final MissionHealthCheckMatcher matcher = matcher().dependencyWith(tag).extractor(extractor).build(); final MissionHealthCheckEvaluator tagPercentage = percentageBasedEvaluator(matcher) diff --git a/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/steps/CertificateStepDefAssertion.java b/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/steps/CertificateStepDefAssertion.java index 69734624..088758b7 100644 --- a/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/steps/CertificateStepDefAssertion.java +++ b/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/steps/CertificateStepDefAssertion.java @@ -84,6 +84,16 @@ public void theDownloadedTypeCertificateStoreExpiresOnExpiry( certificate.getNotAfter().toInstant().truncatedTo(ChronoUnit.DAYS)); } + @And("the downloaded {certContentType} certificate store expires in {int} months - {int} days") + public void theDownloadedTypeCertificateStoreExpiresInMonthsMinusDays( + final CertificateContentType contentType, final int months, final int days) throws Exception { + final String value = secretContext.getLastResult().getValue(); + final X509Certificate certificate = getX509Certificate(contentType, value); + final OffsetDateTime expiry = OffsetDateTime.now().minusDays(days).plusMonths(months); + assertEquals(expiry.toInstant().truncatedTo(ChronoUnit.DAYS), + certificate.getNotAfter().toInstant().truncatedTo(ChronoUnit.DAYS)); + } + @And("the downloaded {certContentType} certificate store content matches store from {fileName} using {password} as password") public void theDownloadedTypeCertificateStoreContentMatchesStoreFromFileNameUsingPassword( final CertificateContentType contentType, final String resource, final String password) throws Exception { diff --git a/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/steps/CertificatesStepDefs.java b/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/steps/CertificatesStepDefs.java index b1534ded..f5cb333c 100644 --- a/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/steps/CertificatesStepDefs.java +++ b/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/steps/CertificatesStepDefs.java @@ -124,6 +124,11 @@ public void theKeyIsSetToBeEnabledStatus(final boolean enabledStatus) { context.getPolicy().setEnabled(enabledStatus); } + @Given("the certificate is set to expire in {int} months") + public void theKeyIsSetToBeEnabledStatus(final int expiryMonths) { + context.getPolicy().setValidityInMonths(expiryMonths); + } + @When("a certificate named {name} is imported from the resource named {fileName} using {password} as password") public void aCertificateIsImportedWithNameFromTheResourceUsingPassword( final String name, final String resource, final String password) throws IOException { diff --git a/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/steps/ManagementStepDefs.java b/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/steps/ManagementStepDefs.java index 47a901f0..1a81e159 100644 --- a/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/steps/ManagementStepDefs.java +++ b/lowkey-vault-docker/src/test/java/com/github/nagyesta/lowkeyvault/steps/ManagementStepDefs.java @@ -101,6 +101,7 @@ public void theTimeOfTheVaultNamedVaultNameIsShiftedByDays(final String vaultNam final String vaultUrl = vaultNameToUrl(vaultName); context.getClient().timeShift(TimeShiftContext.builder() .vaultBaseUri(URI.create(vaultUrl)) + .regenerateCertificates() .addDays(timeShiftDays) .build()); } diff --git a/lowkey-vault-docker/src/test/resources/com/github/nagyesta/lowkeyvault/certificates/CreateCertificates.feature b/lowkey-vault-docker/src/test/resources/com/github/nagyesta/lowkeyvault/certificates/CreateCertificates.feature index 394de1f4..75b52b64 100644 --- a/lowkey-vault-docker/src/test/resources/com/github/nagyesta/lowkeyvault/certificates/CreateCertificates.feature +++ b/lowkey-vault-docker/src/test/resources/com/github/nagyesta/lowkeyvault/certificates/CreateCertificates.feature @@ -73,9 +73,10 @@ Feature: Certificate creation And the downloaded certificate store has a certificate with CN=localhost as subject Examples: - | api | certName | triggerValue | triggerType | action | type | - | 7.3 | 73-createRsaCertPemAction | 20 | days before expiry | EmailContacts | PEM | - | 7.3 | 73-createRsaCertPkcsAction | 75 | percent lifetime | AutoRenew | PKCS12 | + | api | certName | triggerValue | triggerType | action | type | + | 7.3 | 73-createRsaCertPemAction | 20 | days before expiry | EmailContacts | PEM | + | 7.3 | 73-createRsaCertPkcsAction | 75 | percent lifetime | AutoRenew | PKCS12 | + | 7.3 | 73-createRsaCertPemRenewAction | 75 | percent lifetime | AutoRenew | PEM | @Certificate @CertificateCreate @EC Scenario Outline: EC_CERT_CREATE_02 Single versions of EC certificates can be created using lifetime actions diff --git a/lowkey-vault-docker/src/test/resources/com/github/nagyesta/lowkeyvault/certificates/RenewCertificates.feature b/lowkey-vault-docker/src/test/resources/com/github/nagyesta/lowkeyvault/certificates/RenewCertificates.feature new file mode 100644 index 00000000..081742aa --- /dev/null +++ b/lowkey-vault-docker/src/test/resources/com/github/nagyesta/lowkeyvault/certificates/RenewCertificates.feature @@ -0,0 +1,49 @@ +Feature: Certificate renewal/recreation + + @Certificate @CertificateCreate @CertificateTimeShift @RSA + Scenario Outline: RSA_CERT_TIME_SHIFT_01 Single versions of RSA certificates can be recreated or renewed with time shift + Given certificate API version is used + And a vault is created with name certs-time-shift-rsa- + And a certificate client is created with the vault named certs-time-shift-rsa- + And a certificate is prepared with subject + And the certificate is set to expire in months + And the lifetime action trigger is set to AutoRenew when 1 days before expiry reached + And the certificate is set to be enabled + And the certificate is set to use an RSA key with 2048 and without HSM + And the certificate is created with name + When the time of the vault named certs-time-shift-rsa- is shifted by days + Then the certificate is enabled + And the certificate secret named is downloaded + And the downloaded secret contains a certificate + And the downloaded certificate store expires in months - days + And the downloaded certificate store has a certificate with as subject + + + Examples: + | api | index | certName | type | subject | expiryMonths | shiftDays | adjustmentDays | adjustmentMonths | + | 7.3 | 1 | 73-recreateRsaCert | PEM | CN=localhost | 20 | 100 | 100 | 20 | + | 7.3 | 2 | 73-renewRsaCert | PEM | CN=example.com | 5 | 360 | 362 | 15 | + + @Certificate @CertificateCreate @CertificateTimeShift @EC + Scenario Outline: EC_CERT_TIME_SHIFT_01 Single versions of EC certificates can be recreated or renewed with time shift + Given certificate API version is used + And a vault is created with name certs-time-shift-ec- + And a certificate client is created with the vault named certs-time-shift-ec- + And a certificate is prepared with subject + And the certificate is set to expire in months + And the lifetime action trigger is set to AutoRenew when 1 days before expiry reached + And the certificate is set to be enabled + And the certificate is set to use an EC key with P-521 and without HSM + And the certificate is created with name + When the time of the vault named certs-time-shift-ec- is shifted by days + Then the certificate is enabled + And the certificate secret named is downloaded + And the downloaded secret contains a certificate + And the downloaded certificate store expires in months - days + And the downloaded certificate store has a certificate with as subject + + + Examples: + | api | index | certName | type | subject | expiryMonths | shiftDays | adjustmentDays | adjustmentMonths | + | 7.3 | 1 | 73-recreateEcCert | PEM | CN=localhost | 20 | 100 | 100 | 20 | + | 7.3 | 2 | 73-renewEcCert | PEM | CN=example.com | 5 | 360 | 362 | 15 |