feat(vault): add master key migration and DEK rotation
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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
77
README.md
77
README.md
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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{})
|
||||
|
||||
451
routes/routes.go
451
routes/routes.go
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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" .}}
|
||||
|
||||
135
templates/repo/vault/key_migrate.tmpl
Normal file
135
templates/repo/vault/key_migrate.tmpl
Normal 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" .}}
|
||||
Reference in New Issue
Block a user