2
0

1 Commits

Author SHA1 Message Date
f00027eb7c feat(vault): add encryption key mismatch detection and error handling
All checks were successful
Build and Release / Tests (push) Successful in 1m2s
Build and Release / Lint (push) Successful in 1m35s
Build and Release / Create Release (push) Successful in 1s
Added support for hex-encoded master keys (64 hex chars = 32 bytes) in crypto manager with fallback to raw bytes. Implemented comprehensive error handling for encryption/decryption failures across all vault endpoints (API and web). Created dedicated error template with user-friendly guidance for resolving key mismatch issues.
2026-02-06 19:18:18 -05:00
4 changed files with 116 additions and 3 deletions

View File

@@ -7,6 +7,7 @@ import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"errors"
"io"
"os"
@@ -93,7 +94,17 @@ func (m *Manager) loadFromSettings() []byte {
if keyStr == "" {
return nil
}
log.Info("Vault master key loaded from app.ini [vault] section")
// Try to hex-decode the key (expected format: 64 hex chars = 32 bytes)
if len(keyStr) == 64 {
if decoded, err := hex.DecodeString(keyStr); err == nil {
log.Info("Vault master key loaded from app.ini [vault] section (hex-decoded)")
return decoded
}
}
// Fall back to raw bytes if not valid hex
log.Info("Vault master key loaded from app.ini [vault] section (raw)")
return []byte(keyStr)
}
@@ -102,6 +113,14 @@ func (m *Manager) loadFromEnv() []byte {
if keyStr == "" {
return nil
}
// Try to hex-decode the key (expected format: 64 hex chars = 32 bytes)
if len(keyStr) == 64 {
if decoded, err := hex.DecodeString(keyStr); err == nil {
return decoded
}
}
return []byte(keyStr)
}
@@ -131,7 +150,16 @@ func (m *Manager) loadFromFile() []byte {
}
// Trim whitespace
return []byte(strings.TrimSpace(string(key)))
keyStr := strings.TrimSpace(string(key))
// Try to hex-decode the key (expected format: 64 hex chars = 32 bytes)
if len(keyStr) == 64 {
if decoded, err := hex.DecodeString(keyStr); err == nil {
return decoded
}
}
return []byte(keyStr)
}
func (m *Manager) loadFromGiteaSecret() []byte {

View File

@@ -157,5 +157,13 @@
"vault.run_compare": "Compare",
"vault.unified_diff": "Unified Diff",
"vault.back_to_versions": "Back to Versions",
"vault.compare_same_version": "Cannot compare a version with itself"
"vault.compare_same_version": "Cannot compare a version with itself",
"vault.key_mismatch_title": "Encryption Key Mismatch",
"vault.key_mismatch_message": "The vault encryption key has changed since these secrets were created.",
"vault.key_mismatch_explanation": "Secrets in this vault were encrypted with a different master key than what is currently configured. This can happen if the MASTER_KEY in app.ini was changed, or if secrets were created before the key was set (using the fallback key).",
"vault.key_mismatch_solutions_title": "Possible Solutions",
"vault.key_mismatch_solution_1": "Restore the original MASTER_KEY that was used when secrets were created",
"vault.key_mismatch_solution_2": "If using the fallback key previously, temporarily remove MASTER_KEY from [vault] section to use the original key",
"vault.key_mismatch_solution_3": "Contact your administrator to migrate secrets to the new key",
"vault.key_mismatch_admin_note": "Admin: Check app.ini [vault] MASTER_KEY setting and compare with the key used when secrets were originally created."
}

View File

@@ -38,6 +38,7 @@ const (
tplVaultCompare templates.TplName = "repo/vault/compare"
tplVaultAudit templates.TplName = "repo/vault/audit"
tplVaultTokens templates.TplName = "repo/vault/tokens"
tplVaultKeyError templates.TplName = "repo/vault/key_error"
)
// API Response types
@@ -225,6 +226,19 @@ func getWebContext(r *http.Request) *context.Context {
return context.GetWebContext(r.Context())
}
// isKeyMismatchError returns true if the error indicates an encryption key mismatch
func isKeyMismatchError(err error) bool {
return err == services.ErrEncryptionFailed || err == services.ErrDecryptionFailed
}
// showKeyMismatchError renders the key error template for encryption/decryption failures
func showKeyMismatchError(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("vault.key_mismatch_title")
ctx.Data["PageIsVault"] = true
ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin()
ctx.HTML(http.StatusConflict, tplVaultKeyError)
}
func requireRepoAdmin(ctx *context.APIContext) bool {
if !ctx.Repo.IsAdmin() {
ctx.JSON(http.StatusForbidden, map[string]any{
@@ -458,6 +472,13 @@ func apiGetSecret(lic *license.Manager) http.HandlerFunc {
value, err := services.GetSecretValue(ctx, ctx.Repo.Repository.ID, name, version)
if err != nil {
log.Error("Failed to decrypt secret %s: %v", name, err)
if isKeyMismatchError(err) {
ctx.JSON(http.StatusConflict, map[string]any{
"error": "key_mismatch",
"message": "Encryption key mismatch. The vault master key may have changed since secrets were created.",
})
return
}
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "decryption_failed",
"message": "Failed to decrypt secret",
@@ -563,6 +584,11 @@ func apiPutSecret(lic *license.Manager) http.HandlerFunc {
"error": "limit_reached",
"message": "Secret limit reached for this tier. Upgrade your license for more secrets.",
})
case services.ErrEncryptionFailed, services.ErrDecryptionFailed:
ctx.JSON(http.StatusConflict, map[string]any{
"error": "key_mismatch",
"message": "Encryption key mismatch. The vault master key may not be configured correctly.",
})
default:
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
@@ -595,6 +621,13 @@ func apiPutSecret(lic *license.Manager) http.HandlerFunc {
UpdaterID: userID,
})
if err != nil {
if isKeyMismatchError(err) {
ctx.JSON(http.StatusConflict, map[string]any{
"error": "key_mismatch",
"message": "Encryption key mismatch. The vault master key may have changed since secrets were created.",
})
return
}
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
@@ -838,6 +871,11 @@ func apiRollbackSecret(lic *license.Manager) http.HandlerFunc {
"error": "version_not_found",
"message": "Version not found",
})
case services.ErrEncryptionFailed, services.ErrDecryptionFailed:
ctx.JSON(http.StatusConflict, map[string]any{
"error": "key_mismatch",
"message": "Encryption key mismatch. The vault master key may have changed since secrets were created.",
})
default:
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
@@ -1292,6 +1330,10 @@ func webViewSecret(lic *license.Manager) http.HandlerFunc {
value, err := services.GetSecretValue(ctx, ctx.Repo.Repository.ID, name, 0)
if err != nil {
log.Error("Failed to decrypt secret %s: %v", name, err)
if isKeyMismatchError(err) {
showKeyMismatchError(ctx)
return
}
ctx.ServerError("GetSecretValue", err)
return
}
@@ -1380,6 +1422,10 @@ func webUpdateSecret(lic *license.Manager) http.HandlerFunc {
UpdaterID: ctx.Doer.ID,
})
if err != nil {
if isKeyMismatchError(err) {
showKeyMismatchError(ctx)
return
}
ctx.Flash.Error(ctx.Tr("vault.error_update_failed"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
return
@@ -1483,6 +1529,8 @@ func webCreateSecret(lic *license.Manager) http.HandlerFunc {
ctx.Data["description"] = description
ctx.Flash.Error(ctx.Tr("vault.error_secret_limit"))
ctx.HTML(http.StatusOK, tplVaultNew)
case services.ErrEncryptionFailed, services.ErrDecryptionFailed:
showKeyMismatchError(ctx)
default:
ctx.ServerError("CreateSecret", err)
}
@@ -1791,6 +1839,8 @@ func webRollbackSecret(lic *license.Manager) http.HandlerFunc {
case services.ErrVersionNotFound:
ctx.Flash.Error(ctx.Tr("vault.error_version_not_found"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
case services.ErrEncryptionFailed, services.ErrDecryptionFailed:
showKeyMismatchError(ctx)
default:
ctx.ServerError("RollbackSecret", err)
}

View File

@@ -0,0 +1,27 @@
{{template "repo/vault/layout_head" (dict "ctxData" . "pageClass" "repository vault key-error")}}
<div class="ui placeholder segment tw-text-center">
<div class="ui icon header">
{{svg "octicon-key" 48}}
<h2>{{ctx.Locale.Tr "vault.key_mismatch_title"}}</h2>
</div>
<div class="ui warning message" style="text-align: left; max-width: 600px; margin: 1em auto;">
<div class="header">{{ctx.Locale.Tr "vault.key_mismatch_message"}}</div>
<p style="margin-top: 0.5em;">{{ctx.Locale.Tr "vault.key_mismatch_explanation"}}</p>
</div>
<div class="ui info message" style="text-align: left; max-width: 600px; margin: 1em auto;">
<div class="header">{{ctx.Locale.Tr "vault.key_mismatch_solutions_title"}}</div>
<ul class="ui list" style="margin-top: 0.5em;">
<li>{{ctx.Locale.Tr "vault.key_mismatch_solution_1"}}</li>
<li>{{ctx.Locale.Tr "vault.key_mismatch_solution_2"}}</li>
<li>{{ctx.Locale.Tr "vault.key_mismatch_solution_3"}}</li>
</ul>
</div>
{{if .IsRepoAdmin}}
<div class="ui divider"></div>
<p class="ui small text grey">{{ctx.Locale.Tr "vault.key_mismatch_admin_note"}}</p>
{{end}}
</div>
{{template "repo/vault/layout_footer" .}}