Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4d74c5682 | |||
| f00027eb7c | |||
| e88d9f2e82 | |||
| 2aaf7223f1 | |||
| d9c35526bc | |||
| 04d0d02962 |
@@ -12,7 +12,9 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GOPROXY: https://proxy.golang.org,direct
|
||||
GOPROXY: direct
|
||||
GOPRIVATE: git.marketally.com,code.gitcaddy.com
|
||||
GONOSUMDB: git.marketally.com,code.gitcaddy.com
|
||||
GO_VERSION: "1.25"
|
||||
|
||||
jobs:
|
||||
@@ -29,6 +31,10 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure git for private modules
|
||||
run: |
|
||||
git config --global url."https://token:${{ secrets.RELEASE_TOKEN }}@git.marketally.com/".insteadOf "https://git.marketally.com/"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
@@ -58,6 +64,10 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure git for private modules
|
||||
run: |
|
||||
git config --global url."https://token:${{ secrets.RELEASE_TOKEN }}@git.marketally.com/".insteadOf "https://git.marketally.com/"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -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)
|
||||
|
||||
111
crypto/crypto.go
111
crypto/crypto.go
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
@@ -27,7 +28,9 @@ var (
|
||||
|
||||
// Manager handles encryption operations using the master KEK
|
||||
type Manager struct {
|
||||
masterKey []byte
|
||||
masterKey []byte
|
||||
usingFallback bool // true when using Gitea SECRET_KEY as fallback
|
||||
keySource string
|
||||
}
|
||||
|
||||
// NewManager creates a new crypto manager
|
||||
@@ -39,14 +42,27 @@ func NewManager() *Manager {
|
||||
func (m *Manager) LoadMasterKey() error {
|
||||
// Priority: app.ini [vault] > env var > file > gitea secret key
|
||||
key := m.loadFromSettings()
|
||||
if key != nil {
|
||||
m.keySource = "app.ini [vault] MASTER_KEY"
|
||||
}
|
||||
if key == nil {
|
||||
key = m.loadFromEnv()
|
||||
if key != nil {
|
||||
m.keySource = "GITCADDY_VAULT_KEY environment variable"
|
||||
}
|
||||
}
|
||||
if key == nil {
|
||||
key = m.loadFromFile()
|
||||
if key != nil {
|
||||
m.keySource = "key file"
|
||||
}
|
||||
}
|
||||
if key == nil {
|
||||
key = m.loadFromGiteaSecret()
|
||||
if key != nil {
|
||||
m.keySource = "Gitea SECRET_KEY (fallback)"
|
||||
m.usingFallback = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(key) == 0 {
|
||||
@@ -64,7 +80,7 @@ func (m *Manager) LoadMasterKey() error {
|
||||
}
|
||||
|
||||
m.masterKey = key
|
||||
log.Info("Vault master key loaded successfully")
|
||||
log.Info("Vault master key loaded successfully (source: %s)", m.keySource)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -78,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)
|
||||
}
|
||||
|
||||
@@ -87,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)
|
||||
}
|
||||
|
||||
@@ -116,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 {
|
||||
@@ -133,6 +176,56 @@ func (m *Manager) HasMasterKey() bool {
|
||||
return len(m.masterKey) > 0
|
||||
}
|
||||
|
||||
// IsUsingFallbackKey returns true if the master key was loaded from Gitea's SECRET_KEY
|
||||
// rather than an explicit vault-specific key configuration.
|
||||
func (m *Manager) IsUsingFallbackKey() bool {
|
||||
return m.usingFallback
|
||||
}
|
||||
|
||||
// KeySource returns a human-readable description of where the master key was loaded from.
|
||||
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) {
|
||||
@@ -239,6 +332,16 @@ func HasMasterKey() bool {
|
||||
return defaultManager.HasMasterKey()
|
||||
}
|
||||
|
||||
// IsUsingFallbackKey checks if the default manager is using Gitea's SECRET_KEY as fallback
|
||||
func IsUsingFallbackKey() bool {
|
||||
return defaultManager.IsUsingFallbackKey()
|
||||
}
|
||||
|
||||
// KeySource returns the key source of the default manager
|
||||
func KeySource() string {
|
||||
return defaultManager.KeySource()
|
||||
}
|
||||
|
||||
// EncryptWithMasterKey encrypts using the default manager
|
||||
func EncryptWithMasterKey(plaintext []byte) ([]byte, error) {
|
||||
return defaultManager.EncryptWithMasterKey(plaintext)
|
||||
|
||||
20
go.mod
20
go.mod
@@ -104,8 +104,9 @@ require (
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
@@ -117,7 +118,7 @@ require (
|
||||
github.com/microsoft/go-mssqldb v1.9.3 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.95 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.98 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
@@ -141,7 +142,7 @@ require (
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||
github.com/tinylib/msgp v1.4.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/unknwon/com v1.0.1 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
@@ -155,13 +156,14 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/image v0.30.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
|
||||
48
go.sum
48
go.sum
@@ -407,11 +407,13 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
||||
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/kljensen/snowball v0.6.0/go.mod h1:27N7E8fVU5H68RlUmnWwZCfxgt4POBJfENGMvNRhldw=
|
||||
@@ -463,8 +465,8 @@ github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
|
||||
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
|
||||
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
|
||||
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||
github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
|
||||
github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
@@ -613,8 +615,8 @@ github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKN
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
||||
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||
github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8=
|
||||
github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/tstranex/u2f v1.0.0 h1:HhJkSzDDlVSVIVt7pDJwCHQj67k7A5EeBgPmeD+pVsQ=
|
||||
github.com/tstranex/u2f v1.0.0/go.mod h1:eahSLaqAS0zsIEv80+vXT7WanXs7MQQDg3j3wGBSayo=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
@@ -666,6 +668,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
|
||||
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
@@ -678,8 +682,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||
@@ -692,8 +696,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -707,8 +711,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -720,8 +724,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -746,8 +750,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -757,8 +761,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -769,8 +773,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -781,8 +785,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
"vault.config_error_title": "Vault Not Configured",
|
||||
"vault.config_error_message": "The vault encryption key has not been configured. Secrets cannot be encrypted or decrypted.",
|
||||
"vault.config_error_fix": "Add MASTER_KEY to the [vault] section in app.ini or set the GITCADDY_VAULT_KEY environment variable.",
|
||||
"vault.fallback_key_warning_title": "Vault Using Fallback Encryption Key",
|
||||
"vault.fallback_key_warning_message": "The vault is currently using Gitea's SECRET_KEY for encryption because no dedicated vault key has been configured. If the SECRET_KEY is ever changed or lost, all vault secrets will become permanently unreadable.",
|
||||
"vault.fallback_key_warning_fix": "To fix this, copy the current SECRET_KEY value and set it as MASTER_KEY in the [vault] section of app.ini, or set the GITCADDY_VAULT_KEY environment variable. This ensures vault encryption remains stable even if the SECRET_KEY changes.",
|
||||
"vault.decryption_error_title": "Vault Decryption Failed",
|
||||
"vault.decryption_error_message": "Unable to decrypt vault secrets. The encryption key may have been changed or is incorrect.",
|
||||
"vault.decryption_error_fix": "Verify that the MASTER_KEY in the [vault] section of app.ini (or the GITCADDY_VAULT_KEY environment variable) matches the key that was used when the secrets were originally created.",
|
||||
"vault.encryption_error_message": "Unable to encrypt the secret value. The vault encryption key may not be configured correctly.",
|
||||
"vault.secret_name": "Name",
|
||||
"vault.secret_type": "Type",
|
||||
"vault.secret_value": "Secret Value",
|
||||
@@ -150,5 +157,53 @@
|
||||
"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.",
|
||||
"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{})
|
||||
|
||||
11
plugin.go
11
plugin.go
@@ -144,6 +144,17 @@ func (p *VaultPlugin) ConfigurationError() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsUsingFallbackKey returns true if the vault is using Gitea's SECRET_KEY
|
||||
// as the encryption key instead of an explicit vault-specific key.
|
||||
func (p *VaultPlugin) IsUsingFallbackKey() bool {
|
||||
return crypto.IsUsingFallbackKey()
|
||||
}
|
||||
|
||||
// KeySource returns a human-readable description of where the master key was loaded from.
|
||||
func (p *VaultPlugin) KeySource() string {
|
||||
return crypto.KeySource()
|
||||
}
|
||||
|
||||
// Ensure VaultPlugin implements all required interfaces
|
||||
var (
|
||||
_ plugins.Plugin = (*VaultPlugin)(nil)
|
||||
|
||||
593
routes/routes.go
593
routes/routes.go
@@ -31,13 +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"
|
||||
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
|
||||
@@ -150,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))
|
||||
@@ -198,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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -225,6 +236,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{
|
||||
@@ -342,7 +366,7 @@ func apiListSecrets(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getRepoContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Repository context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -400,7 +424,7 @@ func apiGetSecret(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getRepoContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Repository context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -458,6 +482,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",
|
||||
@@ -492,7 +523,7 @@ func apiPutSecret(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getRepoContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Repository context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -563,6 +594,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 +631,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(),
|
||||
@@ -613,7 +656,7 @@ func apiDeleteSecret(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getRepoContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Repository context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -668,7 +711,7 @@ func apiRestoreSecret(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getRepoContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Repository context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -723,7 +766,7 @@ func apiListVersions(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getRepoContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Repository context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -779,7 +822,7 @@ func apiRollbackSecret(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getRepoContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Repository context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -838,6 +881,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",
|
||||
@@ -860,7 +908,7 @@ func apiGetAudit(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getRepoContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Repository context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -917,7 +965,7 @@ func apiListTokens(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getRepoContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Repository context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -951,7 +999,7 @@ func apiCreateToken(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getRepoContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Repository context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -1035,7 +1083,7 @@ func apiRevokeToken(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getRepoContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Repository context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -1082,7 +1130,7 @@ func apiGetTokenInfo(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getRepoContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Repository context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -1171,7 +1219,7 @@ func apiRotateKey(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getRepoContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Repository context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -1180,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)
|
||||
// ============================================================================
|
||||
@@ -1205,11 +1392,16 @@ func webListSecrets(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getWebContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Doer == nil {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.Tr("vault.secrets")
|
||||
ctx.Data["PageIsVaultSecrets"] = true
|
||||
|
||||
@@ -1262,11 +1454,16 @@ func webViewSecret(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getWebContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Doer == nil {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
name := chi.URLParam(r, "name")
|
||||
|
||||
secret, err := services.GetSecret(ctx, ctx.Repo.Repository.ID, name)
|
||||
@@ -1282,6 +1479,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
|
||||
}
|
||||
@@ -1337,11 +1538,16 @@ func webUpdateSecret(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getWebContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Doer == nil {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.Repo.CanWrite(unit.TypeCode) {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
@@ -1365,6 +1571,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
|
||||
@@ -1382,7 +1592,7 @@ func webNewSecretForm(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getWebContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -1408,11 +1618,16 @@ func webCreateSecret(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getWebContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Doer == nil {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.Repo.CanWrite(unit.TypeCode) {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
@@ -1463,6 +1678,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)
|
||||
}
|
||||
@@ -1481,11 +1698,16 @@ func webDeleteSecret(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getWebContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Doer == nil {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.Repo.CanWrite(unit.TypeCode) {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
@@ -1514,7 +1736,7 @@ func webRestoreSecret(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getWebContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -1547,11 +1769,16 @@ func webListVersions(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getWebContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Doer == nil {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
name := chi.URLParam(r, "name")
|
||||
|
||||
secret, err := services.GetSecret(ctx, ctx.Repo.Repository.ID, name)
|
||||
@@ -1604,7 +1831,7 @@ func webCompareVersions(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getWebContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -1730,11 +1957,16 @@ func webRollbackSecret(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getWebContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Doer == nil {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.Repo.CanWrite(unit.TypeCode) {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
@@ -1756,6 +1988,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)
|
||||
}
|
||||
@@ -1774,7 +2008,7 @@ func webViewAudit(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getWebContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -1823,7 +2057,7 @@ func webListTokens(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getWebContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -1855,11 +2089,16 @@ func webCreateToken(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getWebContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Doer == nil {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.Repo.IsAdmin() {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
@@ -1932,7 +2171,7 @@ func webRevokeToken(lic *license.Manager) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := getWebContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -1964,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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
30
templates/repo/vault/key_error.tmpl
Normal file
30
templates/repo/vault/key_error.tmpl
Normal file
@@ -0,0 +1,30 @@
|
||||
{{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>
|
||||
<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