2
0

1 Commits

Author SHA1 Message Date
c4d74c5682 feat(vault): add master key migration and DEK rotation
All checks were successful
Build and Release / Tests (push) Successful in 1m9s
Build and Release / Lint (push) Successful in 1m30s
Build and Release / Create Release (push) Successful in 1s
Implemented master key migration to re-encrypt vault DEKs when the master key changes. Added support for migrating single repositories or instance-wide. Implemented DEK rotation for Enterprise licenses to periodically rotate data encryption keys. Added new UI templates and API endpoints for key management operations with comprehensive error handling.
2026-02-06 21:47:45 -05:00
9 changed files with 929 additions and 20 deletions

View File

@@ -1,7 +1,7 @@
{
"id": "note-1768964774362-3y9dna8sm",
"title": "working",
"content": "\u25CF Done. Updated all vault locale files with the new translation keys.\n\n Summary of what\u0027s now in gitcaddy-vault:\n 1. Templates: list.tmpl, view.tmpl, audit.tmpl, tokens.tmpl (copied)\n 2. Locale files: Added 20\u002B new translation keys to all locale files\n\n What stays in gitcaddy-server:\n - Router handlers (routers/web/repo/vault/vault.go) - these call the vault service\n - Service interface definitions (services/vault/vault.go)\n - Locale files also need the keys (for ctx.Locale.Tr calls in templates)\n\n\u0060\u0060\u0060\n Now you need to:\n 1. Commit and tag vault as v1.0.32\n 2. Update server go.mod to v1.0.32\n 3. Release server v3.1.10\n 4. Deploy to git-test\n\u0060\u0060\u0060",
"content": " Done. Updated all vault locale files with the new translation keys.\n\n Summary of what's now in gitcaddy-vault:\n 1. Templates: list.tmpl, view.tmpl, audit.tmpl, tokens.tmpl (copied)\n 2. Locale files: Added 20+ new translation keys to all locale files\n\n What stays in gitcaddy-server:\n - Router handlers (routers/web/repo/vault/vault.go) - these call the vault service\n - Service interface definitions (services/vault/vault.go)\n - Locale files also need the keys (for ctx.Locale.Tr calls in templates)\n\n```\n Now you need to:\n 1. Commit and tag vault as v1.0.32\n 2. Update server go.mod to v1.0.32\n 3. Release server v3.1.10\n 4. Deploy to git-test\n```\n\n● So:\n\n | Key | Value |\n |---------------------------|------------------------------------------------------------------|\n | Old (SECRET_KEY fallback) | bfe6e73282adba6545c2c9ba3542a332a80d62d1644b3b24c14b020a838c136f |\n | New (MASTER_KEY) | af1a7e9d7fe73258800b434ab5ffe7bf1ee3dff49740ef3cc3c5c014587acc08 |\n",
"createdAt": 1768964774360,
"updatedAt": 1768965391358
"updatedAt": 1770432337981
}

View File

@@ -336,6 +336,83 @@ Set `GITCADDY_DEV_MODE=1` to skip license validation during development:
export GITCADDY_DEV_MODE=1
```
## Key Management
### Master Key Configuration
The vault uses a master Key Encryption Key (KEK) to encrypt repository-level Data Encryption Keys (DEKs). Configure the master key using one of these methods (in priority order):
1. **app.ini** (recommended for production):
```ini
[vault]
MASTER_KEY = <64-character-hex-string>
```
2. **Environment variable**:
```bash
export GITCADDY_VAULT_KEY="<64-character-hex-string>"
```
3. **Key file**:
```bash
export GITCADDY_VAULT_KEY_FILE="/etc/gitcaddy/vault.key"
```
4. **Fallback** (not recommended): If none of the above are set, Gitea's `SECRET_KEY` is used as a fallback.
**Generate a secure master key:**
```bash
openssl rand -hex 32
```
### Key Migration
If you change your master key or need to migrate from the fallback key to a dedicated master key, use the Key Migration feature.
**When to use key migration:**
- You changed the `MASTER_KEY` in app.ini and existing secrets are now inaccessible
- Secrets were created using the fallback key before a dedicated master key was configured
- You see "Encryption Key Mismatch" errors when accessing vault secrets
**Web UI:**
1. Navigate to Repository > Vault > Key Migration (admin only)
2. Enter the old master key (the previous `MASTER_KEY` or Gitea's `SECRET_KEY`)
3. Choose the migration scope (this repository or all repositories)
4. Click "Start Migration"
**API:**
```bash
curl -X POST \
-H "Authorization: Bearer $VAULT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"old_key": "<previous-master-key-or-secret-key>",
"repo_id": 0
}' \
https://gitcaddy.example.com/owner/repo/-/vault/api/migrate-key
```
The `old_key` can be:
- A 64-character hex string (will be decoded to 32 bytes)
- Raw text (will be used as-is, padded/truncated to 32 bytes)
Set `repo_id` to `0` to migrate the current repository, or specify a repo ID for a specific repository. Instance admins can migrate all repositories at once.
### DEK Rotation (Enterprise)
For enhanced security, Enterprise license holders can rotate the Data Encryption Key (DEK) for a repository. This generates a new DEK and re-encrypts all secret versions.
**Web UI:**
1. Navigate to Repository > Vault > Key Migration
2. Click "Rotate DEK" in the DEK Rotation section
**API:**
```bash
curl -X POST \
-H "Authorization: Bearer $VAULT_TOKEN" \
https://gitcaddy.example.com/owner/repo/-/vault/api/rotate-key
```
## Security Considerations
1. **Key Management** - The master KEK should be stored securely (HSM, KMS, or secure environment variable)

View File

@@ -187,6 +187,45 @@ func (m *Manager) KeySource() string {
return m.keySource
}
// SetKey sets the master key directly (used for migration with old keys).
// The key must be exactly 32 bytes. If less, it will be padded. If more, it will be truncated.
func (m *Manager) SetKey(key []byte) {
if len(key) < 32 {
padded := make([]byte, 32)
copy(padded, key)
key = padded
} else if len(key) > 32 {
key = key[:32]
}
m.masterKey = key
m.keySource = "direct"
}
// GetFallbackKey returns Gitea's SECRET_KEY as bytes for migration purposes.
// This allows migrating secrets that were encrypted with the fallback key
// to a dedicated MASTER_KEY.
func GetFallbackKey() []byte {
if setting.SecretKey == "" {
return nil
}
key := []byte(setting.SecretKey)
// Ensure key is 32 bytes
if len(key) < 32 {
padded := make([]byte, 32)
copy(padded, key)
key = padded
} else if len(key) > 32 {
key = key[:32]
}
return key
}
// HasDedicatedMasterKey returns true if a dedicated MASTER_KEY is configured
// (not using the fallback SECRET_KEY)
func HasDedicatedMasterKey() bool {
return defaultManager.HasMasterKey() && !defaultManager.IsUsingFallbackKey()
}
// Encrypt encrypts plaintext using AES-256-GCM
// Returns: nonce || ciphertext || tag
func (m *Manager) Encrypt(plaintext []byte, key []byte) ([]byte, error) {

View File

@@ -165,5 +165,45 @@
"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."
"vault.key_mismatch_admin_note": "Admin: Check app.ini [vault] MASTER_KEY setting and compare with the key used when secrets were originally created.",
"vault.key_migration_title": "Key Migration",
"vault.key_migration_description": "Migrate vault secrets from an old encryption key to the current key",
"vault.key_migration_warning_title": "Important",
"vault.key_migration_warning": "This operation will re-encrypt your vault's data encryption keys using the current master key. Make sure you have the correct old key before proceeding.",
"vault.when_to_migrate": "When to use key migration",
"vault.migrate_reason_1": "You changed the MASTER_KEY in app.ini and existing secrets are now inaccessible",
"vault.migrate_reason_2": "Secrets were created using the fallback key (Gitea's SECRET_KEY) before a dedicated MASTER_KEY was configured",
"vault.migrate_reason_3": "You're seeing 'Encryption Key Mismatch' errors when accessing vault secrets",
"vault.old_master_key": "Old Master Key",
"vault.old_key_placeholder": "Enter the previous encryption key",
"vault.old_key_help": "Enter the old MASTER_KEY or Gitea's SECRET_KEY that was used when secrets were originally created. Can be hex-encoded (64 characters) or raw text.",
"vault.migration_scope": "Migration Scope",
"vault.migrate_this_repo": "This repository only",
"vault.migrate_all_repos": "All repositories (instance-wide)",
"vault.migration_scope_help": "Choose whether to migrate just this repository or all repositories with vault data",
"vault.confirm_migrate": "Are you sure you want to migrate the encryption keys? This cannot be undone.",
"vault.start_migration": "Start Migration",
"vault.migration_complete": "Migration Complete",
"vault.migration_success_count": "%d repository keys migrated successfully",
"vault.migration_failed_count": "%d repository keys failed to migrate",
"vault.migration_failed": "Migration Failed",
"vault.dek_rotation_title": "DEK Rotation (Enterprise)",
"vault.dek_rotation_description": "Generate a new Data Encryption Key and re-encrypt all secrets. This is a security best practice for periodic key rotation.",
"vault.dek_rotation_enterprise_only": "DEK rotation requires an Enterprise license.",
"vault.confirm_rotate": "Are you sure you want to rotate the encryption key? All secret versions will be re-encrypted.",
"vault.rotate_dek": "Rotate DEK",
"vault.key_management": "Key Management",
"vault.old_key_required": "Old master key is required",
"vault.dek_rotation_complete": "DEK rotation completed successfully",
"vault.migrate_from_fallback": "Migrate from Fallback Key",
"vault.migrate_from_fallback_description": "Your vault is using a dedicated MASTER_KEY, but existing secrets were encrypted with Gitea's SECRET_KEY (fallback). Click below to automatically migrate all secrets to your new master key.",
"vault.migrate_from_fallback_button": "Migrate All Secrets to MASTER_KEY",
"vault.no_dedicated_master_key": "No dedicated MASTER_KEY is configured. Cannot migrate from fallback.",
"vault.no_fallback_key": "Could not retrieve the fallback key.",
"vault.migration_from_fallback_success": "Successfully migrated %d repository keys to the new master key.",
"vault.migration_partial_success": "Migration completed: %d succeeded, %d failed.",
"vault.current_key_source": "Current key source",
"vault.one_click_migration": "One-Click Migration",
"vault.manual_migration": "Manual Migration",
"vault.manual_migration_description": "If your secrets were encrypted with a different key (not the fallback), enter it manually below."
}

View File

@@ -136,6 +136,39 @@ func RotateVaultRepoKey(ctx context.Context, key *VaultRepoKey, userID int64, en
return UpdateVaultRepoKey(ctx, key, "encrypted_dek", "previous_key_data", "key_version", "rotated_unix", "rotated_by")
}
// MigrateVaultRepoKey migrates a repo's DEK from one master key to another.
// This is used when the master KEK changes - the DEK itself stays the same,
// but it gets re-encrypted with the new master key.
// decryptWithOldKey decrypts the DEK using the old master key
// encryptWithNewKey encrypts the DEK using the new (current) master key
func MigrateVaultRepoKey(ctx context.Context, key *VaultRepoKey, decryptWithOldKey func([]byte) ([]byte, error), encryptWithNewKey func([]byte) ([]byte, error)) error {
// Decrypt DEK with old master key
dek, err := decryptWithOldKey(key.EncryptedDEK)
if err != nil {
return err
}
// Re-encrypt DEK with new master key
newEncryptedDEK, err := encryptWithNewKey(dek)
if err != nil {
return err
}
// Store old encrypted DEK for potential recovery
key.PreviousKeyData = key.EncryptedDEK
key.EncryptedDEK = newEncryptedDEK
key.KeyVersion++
return UpdateVaultRepoKey(ctx, key, "encrypted_dek", "previous_key_data", "key_version")
}
// GetAllVaultRepoKeys returns all vault repo keys (for migration purposes)
func GetAllVaultRepoKeys(ctx context.Context) ([]*VaultRepoKey, error) {
var keys []*VaultRepoKey
err := db.GetEngine(ctx).Find(&keys)
return keys, err
}
// DeleteVaultRepoKey deletes a vault key (use with caution - all secrets become unrecoverable)
func DeleteVaultRepoKey(ctx context.Context, repoID int64) error {
_, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Delete(&VaultRepoKey{})

View File

@@ -31,14 +31,15 @@ var Version = "dev"
// Template names for vault pages
const (
tplVaultList templates.TplName = "repo/vault/list"
tplVaultView templates.TplName = "repo/vault/view"
tplVaultNew templates.TplName = "repo/vault/new"
tplVaultVersions templates.TplName = "repo/vault/versions"
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"
tplVaultList templates.TplName = "repo/vault/list"
tplVaultView templates.TplName = "repo/vault/view"
tplVaultNew templates.TplName = "repo/vault/new"
tplVaultVersions templates.TplName = "repo/vault/versions"
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"
tplVaultKeyMigrate templates.TplName = "repo/vault/key_migrate"
)
// API Response types
@@ -151,6 +152,12 @@ func RegisterRepoWebRoutes(r plugins.PluginRouter, lic *license.Manager) {
r.Post("/tokens", webCreateToken(lic))
r.Post("/tokens/{id}/revoke", webRevokeToken(lic))
// Key management routes (admin only)
r.Get("/migrate-key", webKeyMigratePage(lic))
r.Post("/migrate-key", webMigrateKey(lic))
r.Post("/migrate-from-fallback", webMigrateFromFallback(lic))
r.Post("/rotate-key", webRotateKey(lic))
// Static secret routes first
r.Get("/secrets/new", webNewSecretForm(lic))
r.Post("/secrets/new", webCreateSecret(lic))
@@ -199,6 +206,9 @@ func RegisterRepoAPIRoutes(r plugins.PluginRouter, lic *license.Manager) {
// Key rotation (enterprise)
r.Post("/rotate-key", apiRotateKey(lic))
// Key migration (admin only - migrate from old master key to new)
r.Post("/migrate-key", apiMigrateKey(lic))
})
}
@@ -1218,20 +1228,159 @@ func apiRotateKey(lic *license.Manager) http.HandlerFunc {
return
}
// TODO: Implement key rotation
// This involves:
// 1. Generate new DEK
// 2. Re-encrypt all secret versions with new DEK
// 3. Update the repo key
// This should be done in a transaction
// Rotate the DEK for this repository
err := services.RotateRepoKey(ctx, services.RotateRepoKeyOptions{
RepoID: ctx.Repo.Repository.ID,
UserID: ctx.Doer.ID,
IPAddress: ctx.RemoteAddr(),
})
ctx.JSON(http.StatusNotImplemented, map[string]any{
"error": "not_implemented",
"message": "Key rotation coming soon",
if err != nil {
if isKeyMismatchError(err) {
ctx.JSON(http.StatusConflict, map[string]any{
"error": "key_mismatch",
"message": "Cannot rotate key: encryption key mismatch. The vault master key may have changed since secrets were created. Use /migrate-key first.",
})
return
}
log.Error("Failed to rotate key for repo %d: %v", ctx.Repo.Repository.ID, err)
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "rotation_failed",
"message": err.Error(),
})
return
}
ctx.JSON(http.StatusOK, map[string]any{
"message": "DEK rotation completed successfully",
})
}
}
// MigrateKeyRequest is the request body for key migration
type MigrateKeyRequest struct {
OldKey string `json:"old_key"` // The old master key (hex-encoded or raw)
RepoID int64 `json:"repo_id"` // Optional: specific repo to migrate (0 = all repos)
}
func apiMigrateKey(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
// Must be repo admin
if !requireRepoAdmin(ctx) {
return
}
var req MigrateKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_request",
"message": "Invalid JSON body",
})
return
}
if req.OldKey == "" {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "missing_old_key",
"message": "old_key is required",
})
return
}
// Parse the old key (try hex first, then raw)
var oldKeyBytes []byte
if len(req.OldKey) == 64 {
// Try hex decode
decoded, err := hexDecode(req.OldKey)
if err == nil {
oldKeyBytes = decoded
}
}
if oldKeyBytes == nil {
// Use as raw bytes
oldKeyBytes = []byte(req.OldKey)
}
// Ensure key is 32 bytes
if len(oldKeyBytes) < 32 {
padded := make([]byte, 32)
copy(padded, oldKeyBytes)
oldKeyBytes = padded
} else if len(oldKeyBytes) > 32 {
oldKeyBytes = oldKeyBytes[:32]
}
// If repo_id not specified, use current repo
repoID := req.RepoID
if repoID == 0 {
repoID = ctx.Repo.Repository.ID
}
results, err := services.MigrateRepoKeys(ctx, services.MigrateRepoKeyOptions{
OldKey: oldKeyBytes,
RepoID: repoID,
UserID: ctx.Doer.ID,
IPAddress: ctx.RemoteAddr(),
})
if err != nil {
log.Error("Failed to migrate keys: %v", err)
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "migration_failed",
"message": err.Error(),
})
return
}
// Check results
successCount := 0
failedCount := 0
var failedRepos []map[string]any
for _, result := range results {
if result.Success {
successCount++
} else {
failedCount++
failedRepos = append(failedRepos, map[string]any{
"repo_id": result.RepoID,
"error": result.Error,
})
}
}
ctx.JSON(http.StatusOK, map[string]any{
"message": "Key migration completed",
"success_count": successCount,
"failed_count": failedCount,
"failed_repos": failedRepos,
})
}
}
// hexDecode decodes a hex string to bytes
func hexDecode(s string) ([]byte, error) {
result := make([]byte, len(s)/2)
for i := 0; i < len(s); i += 2 {
var b byte
_, err := fmt.Sscanf(s[i:i+2], "%02x", &b)
if err != nil {
return nil, err
}
result[i/2] = b
}
return result, nil
}
// ============================================================================
// Web Handlers (HTML rendering)
// ============================================================================
@@ -2054,3 +2203,271 @@ func webRevokeToken(lic *license.Manager) http.HandlerFunc {
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
}
}
// ============================================================================
// Key Management Web Handlers
// ============================================================================
func webKeyMigratePage(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
// Must be repo admin
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
ctx.Data["Title"] = ctx.Tr("vault.key_migration_title")
ctx.Data["PageIsVault"] = true
ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin()
// Check if user is instance admin (can migrate all repos)
ctx.Data["IsInstanceAdmin"] = ctx.Doer != nil && ctx.Doer.IsAdmin
// Check license tier for DEK rotation
info := lic.Info()
ctx.Data["IsEnterprise"] = info != nil && info.Tier == "enterprise"
// Check if a dedicated MASTER_KEY is configured (not using fallback)
// If so, show the one-click migration from fallback option
ctx.Data["HasDedicatedMasterKey"] = crypto.HasDedicatedMasterKey()
ctx.Data["KeySource"] = crypto.KeySource()
ctx.HTML(http.StatusOK, tplVaultKeyMigrate)
}
}
func webMigrateKey(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
// Must be repo admin
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
oldKeyStr := r.FormValue("old_key")
scope := r.FormValue("scope")
if oldKeyStr == "" {
ctx.Flash.Error(ctx.Tr("vault.old_key_required"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/migrate-key")
return
}
// Parse the old key (try hex first, then raw)
var oldKeyBytes []byte
if len(oldKeyStr) == 64 {
decoded, err := hexDecode(oldKeyStr)
if err == nil {
oldKeyBytes = decoded
}
}
if oldKeyBytes == nil {
oldKeyBytes = []byte(oldKeyStr)
}
// Ensure key is 32 bytes
if len(oldKeyBytes) < 32 {
padded := make([]byte, 32)
copy(padded, oldKeyBytes)
oldKeyBytes = padded
} else if len(oldKeyBytes) > 32 {
oldKeyBytes = oldKeyBytes[:32]
}
// Determine scope
var repoID int64
if scope == "all" && ctx.Doer != nil && ctx.Doer.IsAdmin {
repoID = 0 // Migrate all repos
} else {
repoID = ctx.Repo.Repository.ID // Just this repo
}
results, err := services.MigrateRepoKeys(ctx, services.MigrateRepoKeyOptions{
OldKey: oldKeyBytes,
RepoID: repoID,
UserID: ctx.Doer.ID,
IPAddress: ctx.RemoteAddr(),
})
ctx.Data["Title"] = ctx.Tr("vault.key_migration_title")
ctx.Data["PageIsVault"] = true
ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin()
ctx.Data["IsInstanceAdmin"] = ctx.Doer != nil && ctx.Doer.IsAdmin
info := lic.Info()
ctx.Data["IsEnterprise"] = info != nil && info.Tier == "enterprise"
if err != nil {
ctx.Data["MigrationError"] = err.Error()
ctx.HTML(http.StatusOK, tplVaultKeyMigrate)
return
}
// Build result summary
successCount := 0
failedCount := 0
var failedRepos []map[string]any
for _, result := range results {
if result.Success {
successCount++
} else {
failedCount++
failedRepos = append(failedRepos, map[string]any{
"RepoID": result.RepoID,
"Error": result.Error,
})
}
}
ctx.Data["MigrationResult"] = map[string]any{
"SuccessCount": successCount,
"FailedCount": failedCount,
"FailedRepos": failedRepos,
}
ctx.HTML(http.StatusOK, tplVaultKeyMigrate)
}
}
func webMigrateFromFallback(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
// Must be repo admin
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
// Must have a dedicated MASTER_KEY configured
if !crypto.HasDedicatedMasterKey() {
ctx.Flash.Error(ctx.Tr("vault.no_dedicated_master_key"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/migrate-key")
return
}
// Get the fallback key (Gitea's SECRET_KEY)
fallbackKey := crypto.GetFallbackKey()
if fallbackKey == nil {
ctx.Flash.Error(ctx.Tr("vault.no_fallback_key"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/migrate-key")
return
}
// Determine scope
scope := r.FormValue("scope")
var repoID int64
if scope == "all" && ctx.Doer != nil && ctx.Doer.IsAdmin {
repoID = 0 // Migrate all repos
} else {
repoID = ctx.Repo.Repository.ID // Just this repo
}
results, err := services.MigrateRepoKeys(ctx, services.MigrateRepoKeyOptions{
OldKey: fallbackKey,
RepoID: repoID,
UserID: ctx.Doer.ID,
IPAddress: ctx.RemoteAddr(),
})
if err != nil {
ctx.Flash.Error(err.Error())
ctx.Redirect(ctx.Repo.RepoLink + "/vault/migrate-key")
return
}
// Count results
successCount := 0
failedCount := 0
for _, result := range results {
if result.Success {
successCount++
} else {
failedCount++
}
}
if failedCount == 0 {
ctx.Flash.Success(ctx.Tr("vault.migration_from_fallback_success", successCount))
} else {
ctx.Flash.Warning(ctx.Tr("vault.migration_partial_success", successCount, failedCount))
}
ctx.Redirect(ctx.Repo.RepoLink + "/vault")
}
}
func webRotateKey(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
// Must be repo admin
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
// Check enterprise license
info := lic.Info()
if info == nil || info.Tier != "enterprise" {
ctx.Flash.Error(ctx.Tr("vault.dek_rotation_enterprise_only"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/migrate-key")
return
}
err := services.RotateRepoKey(ctx, services.RotateRepoKeyOptions{
RepoID: ctx.Repo.Repository.ID,
UserID: ctx.Doer.ID,
IPAddress: ctx.RemoteAddr(),
})
if err != nil {
if isKeyMismatchError(err) {
ctx.Flash.Error(ctx.Tr("vault.key_mismatch_message"))
} else {
ctx.Flash.Error(err.Error())
}
ctx.Redirect(ctx.Repo.RepoLink + "/vault/migrate-key")
return
}
ctx.Flash.Success(ctx.Tr("vault.dek_rotation_complete"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault")
}
}

View File

@@ -460,3 +460,168 @@ func GetOrCreateRepoKey(ctx context.Context, repoID int64) (*models.VaultRepoKey
return key, nil
}
// MigrateRepoKeyOptions contains options for migrating a repo's key
type MigrateRepoKeyOptions struct {
OldKey []byte // The old master key (raw 32 bytes)
RepoID int64 // Specific repo to migrate (0 = all repos)
UserID int64
IPAddress string
}
// MigrateRepoKeyResult contains the result of a key migration
type MigrateRepoKeyResult struct {
RepoID int64
Success bool
Error string
}
// MigrateRepoKeys migrates repo DEKs from an old master key to the current master key.
// This is used when the master KEK changes - the DEKs themselves stay the same,
// but they get re-encrypted with the new master key.
func MigrateRepoKeys(ctx context.Context, opts MigrateRepoKeyOptions) ([]MigrateRepoKeyResult, error) {
var keys []*models.VaultRepoKey
var err error
if opts.RepoID > 0 {
key := &models.VaultRepoKey{RepoID: opts.RepoID}
has, err := db.GetEngine(ctx).Get(key)
if err != nil {
return nil, err
}
if !has {
return nil, ErrSecretNotFound
}
keys = []*models.VaultRepoKey{key}
} else {
keys, err = models.GetAllVaultRepoKeys(ctx)
if err != nil {
return nil, err
}
}
results := make([]MigrateRepoKeyResult, 0, len(keys))
// Create a temporary crypto manager with the old key
oldManager := crypto.NewManager()
oldManager.SetKey(opts.OldKey)
for _, key := range keys {
result := MigrateRepoKeyResult{RepoID: key.RepoID}
err := models.MigrateVaultRepoKey(ctx, key,
func(encrypted []byte) ([]byte, error) {
// Decrypt with old key
return oldManager.DecryptWithMasterKey(encrypted)
},
func(plaintext []byte) ([]byte, error) {
// Encrypt with current (new) key
return crypto.EncryptDEK(plaintext)
},
)
if err != nil {
result.Success = false
result.Error = err.Error()
} else {
result.Success = true
// Log the migration
_ = CreateAuditEntry(ctx, &models.VaultAuditEntry{
RepoID: key.RepoID,
Action: models.AuditActionRotateKey,
UserID: opts.UserID,
IPAddress: opts.IPAddress,
Success: true,
})
}
results = append(results, result)
}
return results, nil
}
// RotateRepoKeyOptions contains options for rotating a repo's DEK
type RotateRepoKeyOptions struct {
RepoID int64
UserID int64
IPAddress string
}
// RotateRepoKey rotates the DEK for a repository.
// This generates a new DEK and re-encrypts all secret versions.
func RotateRepoKey(ctx context.Context, opts RotateRepoKeyOptions) error {
// Get the repo key
key := &models.VaultRepoKey{RepoID: opts.RepoID}
has, err := db.GetEngine(ctx).Get(key)
if err != nil {
return err
}
if !has {
return ErrSecretNotFound
}
return db.WithTx(ctx, func(ctx context.Context) error {
err := models.RotateVaultRepoKey(ctx, key, opts.UserID,
// encryptDEK - encrypt with current master key
func(dek []byte) ([]byte, error) {
return crypto.EncryptDEK(dek)
},
// reEncryptSecrets - re-encrypt all secret versions
func(oldDEK, newDEK []byte) error {
// Get all secrets for this repo
var secrets []*models.VaultSecret
if err := db.GetEngine(ctx).Where("repo_id = ?", opts.RepoID).Find(&secrets); err != nil {
return err
}
for _, secret := range secrets {
// Get all versions
var versions []*models.VaultSecretVersion
if err := db.GetEngine(ctx).Where("secret_id = ?", secret.ID).Find(&versions); err != nil {
return err
}
for _, version := range versions {
// Decrypt with old DEK
plaintext, err := crypto.DecryptSecret(version.EncryptedValue, oldDEK)
if err != nil {
return fmt.Errorf("failed to decrypt version %d of secret %s: %w", version.Version, secret.Name, err)
}
// Encrypt with new DEK
newEncrypted, err := crypto.EncryptSecret(plaintext, newDEK)
if err != nil {
return fmt.Errorf("failed to encrypt version %d of secret %s: %w", version.Version, secret.Name, err)
}
// Update the version
version.EncryptedValue = newEncrypted
if _, err := db.GetEngine(ctx).ID(version.ID).Cols("encrypted_value").Update(version); err != nil {
return err
}
}
}
return nil
},
// decryptDEK - decrypt with current master key
func(encrypted []byte) ([]byte, error) {
return crypto.DecryptDEK(encrypted)
},
)
if err != nil {
return err
}
// Log the rotation
return CreateAuditEntry(ctx, &models.VaultAuditEntry{
RepoID: opts.RepoID,
Action: models.AuditActionRotateKey,
UserID: opts.UserID,
IPAddress: opts.IPAddress,
Success: true,
})
})
}

View File

@@ -22,6 +22,9 @@
{{if .IsRepoAdmin}}
<div class="ui divider"></div>
<p class="ui small text grey">{{ctx.Locale.Tr "vault.key_mismatch_admin_note"}}</p>
<a class="ui primary button" href="{{.RepoLink}}/vault/migrate-key">
{{svg "octicon-sync" 16}} {{ctx.Locale.Tr "vault.key_migration_title"}}
</a>
{{end}}
</div>
{{template "repo/vault/layout_footer" .}}

View File

@@ -0,0 +1,135 @@
{{template "repo/vault/layout_head" (dict "ctxData" . "pageClass" "repository vault key-migrate")}}
<div class="ui segment">
<h4 class="ui header">
{{svg "octicon-key" 20}} {{ctx.Locale.Tr "vault.key_migration_title"}}
<div class="sub header">{{ctx.Locale.Tr "vault.key_migration_description"}}</div>
</h4>
{{if .KeySource}}
<div class="ui label">
{{ctx.Locale.Tr "vault.current_key_source"}}: <strong>{{.KeySource}}</strong>
</div>
{{end}}
</div>
{{if .MigrationResult}}
<div class="ui {{if eq .MigrationResult.FailedCount 0}}positive{{else}}warning{{end}} message">
<div class="header">{{ctx.Locale.Tr "vault.migration_complete"}}</div>
<p>
{{ctx.Locale.Tr "vault.migration_success_count" .MigrationResult.SuccessCount}}
{{if gt .MigrationResult.FailedCount 0}}
<br>{{ctx.Locale.Tr "vault.migration_failed_count" .MigrationResult.FailedCount}}
{{end}}
</p>
{{if .MigrationResult.FailedRepos}}
<ul class="ui list">
{{range .MigrationResult.FailedRepos}}
<li>Repo ID {{.RepoID}}: {{.Error}}</li>
{{end}}
</ul>
{{end}}
</div>
{{end}}
{{if .MigrationError}}
<div class="ui negative message">
<div class="header">{{ctx.Locale.Tr "vault.migration_failed"}}</div>
<p>{{.MigrationError}}</p>
</div>
{{end}}
{{if .HasDedicatedMasterKey}}
<div class="ui segment">
<h5 class="ui header">
{{svg "octicon-zap" 16}} {{ctx.Locale.Tr "vault.one_click_migration"}}
</h5>
<div class="ui info message">
<p>{{ctx.Locale.Tr "vault.migrate_from_fallback_description"}}</p>
</div>
<form class="ui form" action="{{.RepoLink}}/vault/migrate-from-fallback" method="post">
{{.CsrfTokenHtml}}
<div class="field">
<select name="scope" class="ui dropdown">
<option value="repo">{{ctx.Locale.Tr "vault.migrate_this_repo"}}</option>
{{if .IsInstanceAdmin}}
<option value="all" selected>{{ctx.Locale.Tr "vault.migrate_all_repos"}}</option>
{{end}}
</select>
</div>
<button class="ui primary button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "vault.confirm_migrate"}}');">
{{svg "octicon-sync" 16}} {{ctx.Locale.Tr "vault.migrate_from_fallback_button"}}
</button>
</form>
</div>
<div class="ui horizontal divider">{{ctx.Locale.Tr "or"}}</div>
{{end}}
<div class="ui segment">
<h5 class="ui header">
{{svg "octicon-key" 16}} {{ctx.Locale.Tr "vault.manual_migration"}}
</h5>
{{if .HasDedicatedMasterKey}}
<p class="text grey">{{ctx.Locale.Tr "vault.manual_migration_description"}}</p>
{{else}}
<div class="ui warning message">
<div class="header">{{ctx.Locale.Tr "vault.key_migration_warning_title"}}</div>
<p>{{ctx.Locale.Tr "vault.key_migration_warning"}}</p>
</div>
<h5 class="ui header">{{ctx.Locale.Tr "vault.when_to_migrate"}}</h5>
<ul class="ui list">
<li>{{ctx.Locale.Tr "vault.migrate_reason_1"}}</li>
<li>{{ctx.Locale.Tr "vault.migrate_reason_2"}}</li>
<li>{{ctx.Locale.Tr "vault.migrate_reason_3"}}</li>
</ul>
<div class="ui divider"></div>
{{end}}
<form class="ui form" action="{{.RepoLink}}/vault/migrate-key" method="post">
{{.CsrfTokenHtml}}
<div class="required field">
<label>{{ctx.Locale.Tr "vault.old_master_key"}}</label>
<input type="password" name="old_key" placeholder="{{ctx.Locale.Tr "vault.old_key_placeholder"}}" required>
<p class="help">{{ctx.Locale.Tr "vault.old_key_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "vault.migration_scope"}}</label>
<select name="scope" class="ui dropdown">
<option value="repo">{{ctx.Locale.Tr "vault.migrate_this_repo"}}</option>
{{if .IsInstanceAdmin}}
<option value="all">{{ctx.Locale.Tr "vault.migrate_all_repos"}}</option>
{{end}}
</select>
<p class="help">{{ctx.Locale.Tr "vault.migration_scope_help"}}</p>
</div>
<button class="ui {{if .HasDedicatedMasterKey}}{{else}}primary {{end}}button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "vault.confirm_migrate"}}');">
{{svg "octicon-sync" 16}} {{ctx.Locale.Tr "vault.start_migration"}}
</button>
<a class="ui button" href="{{.RepoLink}}/vault">
{{ctx.Locale.Tr "cancel"}}
</a>
</form>
</div>
{{if .IsRepoAdmin}}
<div class="ui segment">
<h5 class="ui header">
{{svg "octicon-sync" 16}} {{ctx.Locale.Tr "vault.dek_rotation_title"}}
</h5>
<p>{{ctx.Locale.Tr "vault.dek_rotation_description"}}</p>
{{if .IsEnterprise}}
<form class="ui form" action="{{.RepoLink}}/vault/rotate-key" method="post">
{{.CsrfTokenHtml}}
<button class="ui button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "vault.confirm_rotate"}}');">
{{svg "octicon-sync" 16}} {{ctx.Locale.Tr "vault.rotate_dek"}}
</button>
</form>
{{else}}
<div class="ui info message">
{{ctx.Locale.Tr "vault.dek_rotation_enterprise_only"}}
</div>
{{end}}
</div>
{{end}}
{{template "repo/vault/layout_footer" .}}