Skip to content
7 changes: 7 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,10 @@
#
# Review this file periodically to ensure suppressions are still valid.
# ─────────────────────────────────────────────────────────────────

# RestSecretStoreTest rotateKek tests — dummy KEK rotation fixture values (not real secrets)
06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:404
06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:413
06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:423
06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:427
06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:445
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,14 @@ private String vaultApiKey(String apiKey, String agentName) {
return apiKey;
}

// Already a vault reference — use it directly, don't re-vault (supports legacy
// ${eddivault:...})
if (SecretReference.isVaultReference(apiKey)
&& SecretReference.compiledPattern().matcher(apiKey).matches()) {
LOGGER.infof("API key for agent '%s' is already a vault reference — using as-is.", agentName);
return apiKey;
}

if (!secretProvider.isAvailable()) {
LOGGER.warn("Secrets Vault is not configured — API key will be stored in plaintext. "
+ "Set EDDI_VAULT_MASTER_KEY to enable encrypted storage.");
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/ai/labs/eddi/secrets/ISecretProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,22 @@ public interface ISecretProvider {
*/
int rotateDek(String tenantId) throws SecretProviderException;

/**
* Reset the vault for a specific tenant. Deletes ALL secrets and the DEK,
* allowing the vault to start fresh with the current master key.
* <p>
* This is a <b>destructive</b> operation — all encrypted secrets for the tenant
* will be permanently deleted. This bypasses DEK decryption entirely, making it
* safe to call even when the master key has changed.
*
* @param tenantId
* the tenant to reset
* @return the number of secrets that were deleted
* @throws SecretProviderException
* if the reset fails
*/
int resetTenant(String tenantId) throws SecretProviderException;

/**
* Check if the secret provider is properly configured and operational.
*
Expand Down
86 changes: 76 additions & 10 deletions src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
import java.util.*;
import java.util.AbstractMap.SimpleEntry;

import static ai.labs.eddi.utils.LogSanitizer.sanitize;

/**
* Production-grade {@link ISecretProvider} using envelope encryption with
* persistent storage.
Expand Down Expand Up @@ -391,30 +393,94 @@
}
}

@Override
public int resetTenant(String tenantId) throws SecretProviderException {
ensureAvailable();

try {
// Delete all secrets first, then the DEK
var secrets = persistence.listSecretsByTenant(tenantId);
int deletedCount = 0;

for (var secret : secrets) {
if (persistence.deleteSecret(tenantId, secret.getKeyName())) {
deletedCount++;
}
}
persistence.deleteDek(tenantId);

LOGGER.infof("[VAULT] Tenant '%s' reset: %d secret(s) deleted, DEK removed.", sanitize(tenantId), deletedCount);
return deletedCount;
} catch (PersistenceException e) {
throw new SecretProviderException("Failed to reset vault for tenant " + sanitize(tenantId), e);
}
}

// === Private helpers ===

private byte[] getOrCreateDek(String tenantId) throws SecretProviderException {
try {
var dekOpt = persistence.findDek(tenantId);
if (dekOpt.isPresent()) {
EncryptedDek encryptedDek = dekOpt.get();
return EnvelopeCrypto.decryptDek(encryptedDek.getEncryptedDek(), encryptedDek.getIv(), kek);
try {
return EnvelopeCrypto.decryptDek(encryptedDek.getEncryptedDek(), encryptedDek.getIv(), kek);
} catch (EnvelopeCrypto.CryptoException e) {
return handleDekDecryptionFailure(tenantId, e);
}
}

// Generate a new DEK for this tenant
byte[] newDek = EnvelopeCrypto.generateDek();
EnvelopeCrypto.EncryptionResult encResult = EnvelopeCrypto.encryptDek(newDek, kek);

EncryptedDek dek = new EncryptedDek(UUID.randomUUID().toString(), tenantId, encResult.ciphertext(), encResult.iv(), Instant.now());

persistence.upsertDek(dek);
LOGGER.infof("Generated new DEK for tenant: %s", tenantId);
return newDek;
return generateAndPersistDek(tenantId);
} catch (PersistenceException e) {
throw new SecretProviderException("Persistence failure while managing DEK for tenant " + tenantId, e);
}
}

/**
* Handles the case where an existing DEK cannot be decrypted — typically
* because EDDI_VAULT_MASTER_KEY changed since the DEK was created.
* <p>
* Never auto-recovers. Always fails with a clear, actionable error so the user
* can choose the appropriate recovery path.
*/
private byte[] handleDekDecryptionFailure(String tenantId, EnvelopeCrypto.CryptoException cause) throws SecretProviderException {
int secretCount;
try {
secretCount = persistence.listSecretsByTenant(tenantId).size();
} catch (PersistenceException e) {
secretCount = -1; // unknown
}

String secretInfo = secretCount == 0
? "No secrets are stored for this tenant, so no data would be lost by resetting."
: secretCount > 0
? secretCount + " secret(s) are stored for this tenant and would be permanently lost if you reset."
: "Unable to determine how many secrets are stored for this tenant.";

throw new SecretProviderException(
"Cannot decrypt the Data Encryption Key (DEK) for tenant '" + tenantId + "'. "
+ "This means the EDDI_VAULT_MASTER_KEY has changed since the DEK was created. "
+ secretInfo + " "
+ "Recovery options: "
+ "(1) Set EDDI_VAULT_MASTER_KEY back to the original value and restart. "
+ "(2) If you have both old and new keys, use POST /secretstore/secrets/admin/rotate-kek "
+ "to migrate all encrypted data to the new key. "
+ "(3) To start fresh (deletes all secrets for this tenant), use "
+ "POST /secretstore/secrets/" + tenantId + "/reset to clear the vault for this tenant.",
cause);
}

private byte[] generateAndPersistDek(String tenantId) {
byte[] newDek = EnvelopeCrypto.generateDek();
EnvelopeCrypto.EncryptionResult encResult = EnvelopeCrypto.encryptDek(newDek, kek);

EncryptedDek dek = new EncryptedDek(UUID.randomUUID().toString(), tenantId, encResult.ciphertext(), encResult.iv(), Instant.now());

persistence.upsertDek(dek);
LOGGER.infof("Generated new DEK for tenant: %s", tenantId);
return newDek;
}

private void ensureAvailable() throws SecretProviderException {
if (!available) {
throw new SecretProviderException("Secrets Vault is not available. Set EDDI_VAULT_MASTER_KEY environment variable.");
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/ai/labs/eddi/secrets/rest/IRestSecretStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,28 @@ public interface IRestSecretStore {
+ "EDDI_VAULT_MASTER_KEY environment variable and restart the application.")
Response rotateKek(KekRotationRequest body);

/**
* Reset the vault for a specific tenant. Deletes ALL secrets and the DEK for
* the tenant, allowing the vault to start fresh with the current master key.
* <p>
* This is a destructive operation — all encrypted secrets for the tenant will
* be permanently deleted. Use this when the master key has changed and the old
* key is not available.
*
* @param tenantId
* the tenant to reset
* @return 200 with details of what was deleted
*/
@POST
@Path("/{tenantId}/reset")
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed("eddi-admin")
@Operation(summary = "Reset vault for a tenant",
description = "Deletes ALL secrets and the Data Encryption Key for the tenant. "
+ "Use this when the master key has changed and recovery is not possible. "
+ "WARNING: This permanently destroys all encrypted secrets for the tenant.")
Response resetTenant(@PathParam("tenantId") String tenantId);

/**
* Request body for storing a secret. Includes the plaintext value, an optional
* description, and an optional allowed-agents list.
Expand Down
41 changes: 35 additions & 6 deletions src/main/java/ai/labs/eddi/secrets/rest/RestSecretStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ public Response storeSecret(String tenantId, String keyName, SecretRequest body)
return Response.status(Response.Status.CREATED).entity(responseRef).build();
}
} catch (ISecretProvider.SecretProviderException e) {
LOGGER.errorf("Failed to store secret: %s/%s — %s", sanitize(tenantId), sanitize(keyName), e.getMessage());
LOGGER.error("Failed to store secret: " + sanitize(tenantId) + "/" + sanitize(keyName), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "Failed to store secret")).build();
}
}
Expand All @@ -142,7 +142,7 @@ public Response deleteSecret(String tenantId, String keyName) {
} catch (ISecretProvider.SecretNotFoundException e) {
return Response.status(Response.Status.NOT_FOUND).entity(Map.of("error", "Secret not found")).build();
} catch (ISecretProvider.SecretProviderException e) {
LOGGER.errorf("Failed to delete secret: %s/%s — %s", sanitize(tenantId), sanitize(keyName), e.getMessage());
LOGGER.error("Failed to delete secret: " + sanitize(tenantId) + "/" + sanitize(keyName), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "Failed to delete secret")).build();
}
}
Expand All @@ -165,7 +165,7 @@ public Response getSecretMetadata(String tenantId, String keyName) {
} catch (ISecretProvider.SecretNotFoundException e) {
return Response.status(Response.Status.NOT_FOUND).entity(Map.of("error", "Secret not found")).build();
} catch (ISecretProvider.SecretProviderException e) {
LOGGER.errorf("Failed to get secret metadata: %s/%s — %s", sanitize(tenantId), sanitize(keyName), e.getMessage());
LOGGER.error("Failed to get secret metadata: " + sanitize(tenantId) + "/" + sanitize(keyName), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "Failed to get metadata")).build();
}
}
Expand All @@ -184,7 +184,7 @@ public Response listSecrets(String tenantId) {
try {
return Response.ok(secretProvider.listKeys(tenantId)).build();
} catch (ISecretProvider.SecretProviderException e) {
LOGGER.errorf("Failed to list secrets: %s — %s", sanitize(tenantId), e.getMessage());
LOGGER.error("Failed to list secrets for tenant: " + sanitize(tenantId), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "Failed to list secrets")).build();
}
}
Expand Down Expand Up @@ -218,7 +218,7 @@ public Response rotateDek(String tenantId) {
return Response.ok(Map.of("tenantId", tenantId, "secretsReEncrypted", count, "message",
"DEK rotated successfully. " + count + " secrets re-encrypted.")).build();
} catch (ISecretProvider.SecretProviderException e) {
LOGGER.errorf("Failed to rotate DEK for tenant %s: %s", sanitize(tenantId), e.getMessage());
LOGGER.error("Failed to rotate DEK for tenant: " + sanitize(tenantId), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "DEK rotation failed: " + e.getMessage())).build();
}
}
Expand Down Expand Up @@ -249,8 +249,37 @@ public Response rotateKek(KekRotationRequest body) {
return Response.ok(Map.of("deksReEncrypted", count, "message", "KEK rotated successfully. " + count + " DEKs re-encrypted. "
+ "IMPORTANT: Update the EDDI_VAULT_MASTER_KEY environment variable to the new key and restart.")).build();
} catch (ISecretProvider.SecretProviderException e) {
LOGGER.errorf("Failed to rotate KEK: %s", e.getMessage());
LOGGER.error("Failed to rotate KEK", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "KEK rotation failed: " + e.getMessage())).build();
}
}

@Override
public Response resetTenant(String tenantId) {
var unavailable = vaultUnavailableResponse();
if (unavailable.isPresent())
return unavailable.get();

try {
validateId(tenantId, "tenantId");
} catch (IllegalArgumentException e) {
return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", e.getMessage())).build();
}

try {
int deletedSecrets = secretProvider.resetTenant(tenantId);
secretResolver.invalidateAll();
return Response.ok(Map.of(
"tenantId", tenantId,
"secretsDeleted", deletedSecrets,
"message", "Vault reset for tenant '" + tenantId + "'. "
+ deletedSecrets + " secret(s) deleted, DEK removed. "
+ "The next secret store operation will generate a fresh DEK with the current master key."))
.build();
} catch (ISecretProvider.SecretProviderException e) {
LOGGER.error("Failed to reset vault for tenant: " + sanitize(tenantId), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Vault reset failed: " + e.getMessage())).build();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,13 @@ public int rotateDek(String tenantId) {
return 0;
}

@Override
public int resetTenant(String tenantId) {
int before = store.size();
store.entrySet().removeIf(e -> e.getKey().startsWith(tenantId + ":"));
return before - store.size();
}
Comment thread
Copilot marked this conversation as resolved.

@Override
public boolean isAvailable() {
return true;
Expand Down
Loading
Loading