2
0

13 Commits

Author SHA1 Message Date
4dc9c34bcc fix(plugin): use DriverName instead of Dialect for DB detection
All checks were successful
Build and Release / Tests (push) Successful in 1m11s
Build and Release / Lint (push) Successful in 1m41s
Build and Release / Create Release (push) Successful in 0s
Replace x.Dialect().URI().DBType with x.DriverName() for more reliable database driver detection. Add support for 'pgx' and 'sqlite' driver variants alongside existing 'postgres' and 'sqlite3'. Improve logging with driver information and error messages for better migration debugging.
2026-02-08 11:16:07 -05:00
b824b8e3be feat(vault): add database migration system for vault plugin
All checks were successful
Build and Release / Tests (push) Successful in 1m40s
Build and Release / Lint (push) Successful in 1m41s
Build and Release / Create Release (push) Successful in 0s
Implements explicit column migration logic to handle schema upgrades that xorm.Sync() doesn't reliably perform. Adds encryption_mode column migration for vault_secret table to support lockbox (E2E) encryption. Includes database-agnostic column existence checks and ALTER TABLE statement generation for PostgreSQL, MySQL, SQLite, and MSSQL.
2026-02-08 10:38:39 -05:00
e9b109c464 docs: add comprehensive API reference documentation
All checks were successful
Build and Release / Tests (push) Successful in 1m26s
Build and Release / Lint (push) Successful in 1m59s
Build and Release / Create Release (push) Has been skipped
Creates new API.md with complete API reference including:
- Architecture and encryption model documentation
- Installation and configuration guides
- Authentication methods and API endpoints
- Go package API documentation
- Error codes and code examples
- License tier information

Also updates README.md to reference the new API documentation.
2026-02-07 09:25:38 -05:00
69f71cef7e docs: add lockbox end-to-end encryption documentation
Documents the new Lockbox feature for client-side encryption including:
- Feature comparison table between standard and lockbox modes
- Encryption scheme details (Argon2id + AES-256-GCM)
- API usage examples for Go, TypeScript, and Python SDKs
- Raw API endpoint documentation for manual encryption
2026-02-07 09:19:32 -05:00
fcb720736b feat(vault): add lockbox encryption mode to secrets
All checks were successful
Build and Release / Tests (push) Successful in 1m11s
Build and Release / Lint (push) Successful in 1m33s
Build and Release / Create Release (push) Successful in 0s
Added encryption_mode field to secrets supporting "standard" (server-side) and "lockbox" (client-side E2E) modes. Updated API to validate lockbox format (lockbox:v1:salt:ciphertext). Enhanced UI to display lock icons and badges for lockbox secrets. Lockbox secrets show locked state in web UI, requiring CLI/SDK for decryption.
2026-02-07 02:14:26 -05:00
c38fe412f8 fix(vault): prevent dirty form warnings on key management forms
All checks were successful
Build and Release / Tests (push) Successful in 1m9s
Build and Release / Lint (push) Successful in 1m33s
Build and Release / Create Release (push) Has been skipped
2026-02-06 22:40:51 -05:00
3a920ce90c feat(vault): add confirmation modals for destructive operations
All checks were successful
Build and Release / Tests (push) Successful in 1m5s
Build and Release / Lint (push) Successful in 1m38s
Build and Release / Create Release (push) Successful in 0s
Replaced inline confirm() dialogs with proper modal dialogs for key migration, DEK rotation, token revocation, and version rollback operations. Improves UX and provides better context for destructive actions.
2026-02-06 22:22:08 -05:00
c4d74c5682 feat(vault): add master key migration and DEK rotation
All checks were successful
Build and Release / Tests (push) Successful in 1m9s
Build and Release / Lint (push) Successful in 1m30s
Build and Release / Create Release (push) Successful in 1s
Implemented master key migration to re-encrypt vault DEKs when the master key changes. Added support for migrating single repositories or instance-wide. Implemented DEK rotation for Enterprise licenses to periodically rotate data encryption keys. Added new UI templates and API endpoints for key management operations with comprehensive error handling.
2026-02-06 21:47:45 -05:00
f00027eb7c feat(vault): add encryption key mismatch detection and error handling
All checks were successful
Build and Release / Tests (push) Successful in 1m2s
Build and Release / Lint (push) Successful in 1m35s
Build and Release / Create Release (push) Successful in 1s
Added support for hex-encoded master keys (64 hex chars = 32 bytes) in crypto manager with fallback to raw bytes. Implemented comprehensive error handling for encryption/decryption failures across all vault endpoints (API and web). Created dedicated error template with user-friendly guidance for resolving key mismatch issues.
2026-02-06 19:18:18 -05:00
e88d9f2e82 ci(i18n): configure authentication for private Go modules
All checks were successful
Build and Release / Tests (push) Successful in 1m3s
Build and Release / Lint (push) Successful in 1m28s
Build and Release / Create Release (push) Successful in 0s
Sets up git credential configuration and Go environment variables (GOPRIVATE, GONOSUMDB) to enable fetching private modules from git.marketally.com and code.gitcaddy.com during builds. Uses RELEASE_TOKEN secret for authentication and disables public proxy for private repositories.
2026-02-04 14:01:06 -05:00
2aaf7223f1 feat(i18n): add vault key configuration error messages
Some checks failed
Build and Release / Tests (push) Failing after 21s
Build and Release / Lint (push) Failing after 21s
Build and Release / Create Release (push) Has been skipped
Adds English locale strings for vault encryption key warnings including fallback key usage, decryption failures, and encryption errors. Provides user-friendly explanations and remediation steps for each error scenario.
2026-02-04 13:55:08 -05:00
d9c35526bc feat(crypto): add key source tracking and fallback detection
Some checks failed
Build and Release / Lint (push) Failing after 24s
Build and Release / Tests (push) Failing after 23s
Build and Release / Create Release (push) Has been skipped
Adds tracking of master key source (app.ini, env var, file, or Gitea SECRET_KEY fallback) and exposes methods to check if fallback key is in use. This enables better visibility into which key configuration is active and helps identify when the system is using the less secure fallback option.
2026-02-04 13:47:33 -05:00
04d0d02962 chore(ci): update Go dependencies
All checks were successful
Build and Release / Tests (push) Successful in 1m37s
Build and Release / Lint (push) Successful in 1m53s
Build and Release / Create Release (push) Successful in 0s
Updates multiple dependencies including minio-go (v7.0.95 -> v7.0.98), klauspost/compress (v1.18.0 -> v1.18.2), tinylib/msgp (v1.4.0 -> v1.6.1), and various golang.org/x packages (crypto, net, sync, sys, text, mod, tools). Adds klauspost/crc32 v1.3.0 and go.yaml.in/yaml/v3 v3.0.4.
2026-01-26 00:58:05 -05:00
19 changed files with 3367 additions and 107 deletions

View File

@@ -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:

View File

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

1803
API.md Normal file
View File

File diff suppressed because it is too large Load Diff

101
README.md
View File

@@ -23,6 +23,30 @@ GitCaddy Vault is a commercial module compiled directly into GitCaddy Server tha
| `certificate` | TLS/SSL certificates |
| `ssh-key` | SSH private keys |
### Lockbox (End-to-End Encryption)
Lockbox provides optional client-side encryption where the server **never sees your plaintext secrets**. Perfect for highly sensitive data where you don't want even the server administrator to have access.
| Feature | Standard Mode | Lockbox Mode |
|---------|--------------|--------------|
| Server sees plaintext | Yes | No |
| Passphrase required | No | Yes |
| Recovery if passphrase lost | Yes | No |
| Web UI viewing | Yes | CLI/SDK only |
**How Lockbox Works:**
1. Client encrypts secret with your passphrase using Argon2id + AES-256-GCM
2. Encrypted blob is sent to server in `lockbox:v1:...` format
3. Server wraps the blob with repository DEK (double encryption)
4. On retrieval, server unwraps DEK layer, returns lockbox blob
5. Client decrypts with your passphrase
**Encryption Scheme:**
- Key derivation: Argon2id (time=1, memory=64MB, parallelism=4)
- Cipher: AES-256-GCM with 12-byte nonce
- Salt: 16 bytes random per secret
- Format: `lockbox:v1:<base64(salt)>:<base64(nonce||ciphertext||tag)>`
### Security Architecture
```
@@ -336,6 +360,83 @@ Set `GITCADDY_DEV_MODE=1` to skip license validation during development:
export GITCADDY_DEV_MODE=1
```
## Key Management
### Master Key Configuration
The vault uses a master Key Encryption Key (KEK) to encrypt repository-level Data Encryption Keys (DEKs). Configure the master key using one of these methods (in priority order):
1. **app.ini** (recommended for production):
```ini
[vault]
MASTER_KEY = <64-character-hex-string>
```
2. **Environment variable**:
```bash
export GITCADDY_VAULT_KEY="<64-character-hex-string>"
```
3. **Key file**:
```bash
export GITCADDY_VAULT_KEY_FILE="/etc/gitcaddy/vault.key"
```
4. **Fallback** (not recommended): If none of the above are set, Gitea's `SECRET_KEY` is used as a fallback.
**Generate a secure master key:**
```bash
openssl rand -hex 32
```
### Key Migration
If you change your master key or need to migrate from the fallback key to a dedicated master key, use the Key Migration feature.
**When to use key migration:**
- You changed the `MASTER_KEY` in app.ini and existing secrets are now inaccessible
- Secrets were created using the fallback key before a dedicated master key was configured
- You see "Encryption Key Mismatch" errors when accessing vault secrets
**Web UI:**
1. Navigate to Repository > Vault > Key Migration (admin only)
2. Enter the old master key (the previous `MASTER_KEY` or Gitea's `SECRET_KEY`)
3. Choose the migration scope (this repository or all repositories)
4. Click "Start Migration"
**API:**
```bash
curl -X POST \
-H "Authorization: Bearer $VAULT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"old_key": "<previous-master-key-or-secret-key>",
"repo_id": 0
}' \
https://gitcaddy.example.com/owner/repo/-/vault/api/migrate-key
```
The `old_key` can be:
- A 64-character hex string (will be decoded to 32 bytes)
- Raw text (will be used as-is, padded/truncated to 32 bytes)
Set `repo_id` to `0` to migrate the current repository, or specify a repo ID for a specific repository. Instance admins can migrate all repositories at once.
### DEK Rotation (Enterprise)
For enhanced security, Enterprise license holders can rotate the Data Encryption Key (DEK) for a repository. This generates a new DEK and re-encrypts all secret versions.
**Web UI:**
1. Navigate to Repository > Vault > Key Migration
2. Click "Rotate DEK" in the DEK Rotation section
**API:**
```bash
curl -X POST \
-H "Authorization: Bearer $VAULT_TOKEN" \
https://gitcaddy.example.com/owner/repo/-/vault/api/rotate-key
```
## Security Considerations
1. **Key Management** - The master KEK should be stored securely (HSM, KMS, or secure environment variable)

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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,54 @@
"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.",
"vault.confirm_migrate_fallback": "This will migrate all vault secrets from the fallback key (Gitea SECRET_KEY) to your dedicated MASTER_KEY. This operation cannot be undone. Continue?"
}

View File

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

View File

@@ -37,6 +37,9 @@ type VaultSecret struct {
CreatedBy int64 `xorm:"NOT NULL"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"`
// Encryption mode: "standard" (server-side) or "lockbox" (client E2E encrypted)
EncryptionMode string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'standard'"`
// Soft delete
DeletedUnix timeutil.TimeStamp `xorm:"INDEX"` // 0 = active, >0 = soft deleted
DeletedBy int64

113
plugin.go
View File

@@ -5,6 +5,7 @@ package vault
import (
"context"
"fmt"
"git.marketally.com/gitcaddy/gitcaddy-vault/crypto"
"git.marketally.com/gitcaddy/gitcaddy-vault/license"
@@ -98,7 +99,106 @@ func (p *VaultPlugin) RegisterModels() []any {
// Migrate runs database migrations for this plugin
func (p *VaultPlugin) Migrate(ctx context.Context, x *xorm.Engine) error {
return x.Sync(p.RegisterModels()...)
// First, sync tables (works for fresh installations)
if err := x.Sync(p.RegisterModels()...); err != nil {
return err
}
// Then run explicit column migrations for upgrades
// xorm.Sync() doesn't reliably add columns to existing tables
return runColumnMigrations(ctx, x)
}
// runColumnMigrations adds any missing columns to existing tables.
// This handles the case where xorm.Sync() fails to add new columns.
func runColumnMigrations(ctx context.Context, x *xorm.Engine) error {
migrations := []struct {
table string
column string
columnType string
defaultVal string
}{
// v1.0.31: Add encryption_mode column for lockbox (E2E) secrets
{"vault_secret", "encryption_mode", "VARCHAR(20)", "'standard'"},
}
for _, m := range migrations {
exists, err := columnExists(x, m.table, m.column)
if err != nil {
return err
}
if !exists {
log.Info("Vault migration: adding column %s.%s", m.table, m.column)
sql := buildAddColumnSQL(x, m.table, m.column, m.columnType, m.defaultVal)
if _, err := x.Exec(sql); err != nil {
return fmt.Errorf("failed to add column %s.%s: %w", m.table, m.column, err)
}
}
}
return nil
}
// columnExists checks if a column exists in a table
func columnExists(x *xorm.Engine, table, column string) (bool, error) {
driverName := x.DriverName()
log.Info("Vault migration: checking column %s.%s (driver: %s)", table, column, driverName)
var sql string
switch driverName {
case "postgres", "pgx":
sql = fmt.Sprintf(`SELECT 1 FROM information_schema.columns WHERE table_name = '%s' AND column_name = '%s'`, table, column)
case "mysql":
sql = fmt.Sprintf(`SELECT 1 FROM information_schema.columns WHERE table_name = '%s' AND column_name = '%s'`, table, column)
case "sqlite3", "sqlite":
// SQLite uses PRAGMA
results, err := x.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false, err
}
for _, row := range results {
if string(row["name"]) == column {
return true, nil
}
}
return false, nil
case "mssql":
sql = fmt.Sprintf(`SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('%s') AND name = '%s'`, table, column)
default:
// Fallback: try information_schema (works for postgres/mysql)
log.Warn("Vault migration: unknown driver '%s', using information_schema fallback", driverName)
sql = fmt.Sprintf(`SELECT 1 FROM information_schema.columns WHERE table_name = '%s' AND column_name = '%s'`, table, column)
}
results, err := x.Query(sql)
if err != nil {
log.Error("Vault migration: column check failed: %v", err)
return false, err
}
exists := len(results) > 0
log.Info("Vault migration: column %s.%s exists: %v", table, column, exists)
return exists, nil
}
// buildAddColumnSQL builds the appropriate ALTER TABLE statement for the database type
func buildAddColumnSQL(x *xorm.Engine, table, column, columnType, defaultVal string) string {
driverName := x.DriverName()
log.Info("Vault migration: building ALTER TABLE for driver: %s", driverName)
switch driverName {
case "postgres", "pgx":
return fmt.Sprintf(`ALTER TABLE "%s" ADD COLUMN IF NOT EXISTS "%s" %s NOT NULL DEFAULT %s`, table, column, columnType, defaultVal)
case "mysql":
// MySQL doesn't have IF NOT EXISTS for columns, but we already checked
return fmt.Sprintf("ALTER TABLE `%s` ADD COLUMN `%s` %s NOT NULL DEFAULT %s", table, column, columnType, defaultVal)
case "sqlite3", "sqlite":
return fmt.Sprintf(`ALTER TABLE "%s" ADD COLUMN "%s" %s NOT NULL DEFAULT %s`, table, column, columnType, defaultVal)
case "mssql":
return fmt.Sprintf(`ALTER TABLE [%s] ADD [%s] %s NOT NULL DEFAULT %s`, table, column, columnType, defaultVal)
default:
// Fallback to postgres-style (most compatible)
log.Warn("Vault migration: unknown driver '%s', using postgres-style ALTER TABLE", driverName)
return fmt.Sprintf(`ALTER TABLE "%s" ADD COLUMN IF NOT EXISTS "%s" %s NOT NULL DEFAULT %s`, table, column, columnType, defaultVal)
}
}
// RegisterRepoWebRoutes adds vault routes under /{owner}/{repo}/vault
@@ -144,6 +244,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)

View File

@@ -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
@@ -47,6 +49,7 @@ type SecretResponse struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Type string `json:"type"`
EncryptionMode string `json:"encryption_mode,omitempty"` // "standard" or "lockbox"
CurrentVersion int `json:"current_version"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
@@ -113,11 +116,12 @@ type TokenInfoResponse struct {
// CreateSecretRequest is the request body for creating a secret
type CreateSecretRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" binding:"required"`
Comment string `json:"comment"` // Used when updating existing secret
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" binding:"required"`
Comment string `json:"comment"` // Used when updating existing secret
EncryptionMode string `json:"encryption_mode"` // "standard" (default) or "lockbox"
}
// UpdateSecretRequest is the request body for updating a secret
@@ -150,6 +154,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 +208,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 +238,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{
@@ -248,10 +274,15 @@ func requireRepoWrite(ctx *context.APIContext) bool {
}
func secretToResponse(s *models.VaultSecret) SecretResponse {
encMode := s.EncryptionMode
if encMode == "" {
encMode = "standard"
}
return SecretResponse{
Name: s.Name,
Description: s.Description,
Type: string(s.Type),
EncryptionMode: encMode,
CurrentVersion: s.CurrentVersion,
CreatedAt: int64(s.CreatedUnix),
UpdatedAt: int64(s.UpdatedUnix),
@@ -342,7 +373,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 +431,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 +489,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 +530,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
}
@@ -533,6 +571,28 @@ func apiPutSecret(lic *license.Manager) http.HandlerFunc {
return
}
// Validate encryption mode
encryptionMode := req.EncryptionMode
if encryptionMode == "" {
encryptionMode = "standard"
}
if encryptionMode != "standard" && encryptionMode != "lockbox" {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_encryption_mode",
"message": "encryption_mode must be 'standard' or 'lockbox'",
})
return
}
// Validate lockbox format: must start with "lockbox:v1:"
if encryptionMode == "lockbox" && !strings.HasPrefix(req.Value, "lockbox:v1:") {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_lockbox_format",
"message": "Lockbox secrets must be pre-encrypted with format 'lockbox:v1:<salt>:<ciphertext>'",
})
return
}
// Check if secret exists
existing, err := services.GetSecret(ctx, ctx.Repo.Repository.ID, name)
if err == services.ErrSecretNotFound {
@@ -545,11 +605,12 @@ func apiPutSecret(lic *license.Manager) http.HandlerFunc {
limits = &info.Limits
}
secret, err := services.CreateSecret(ctx, ctx.Repo.Repository.ID, services.CreateSecretOptions{
Name: req.Name,
Description: req.Description,
Type: req.Type,
Value: req.Value,
CreatorID: ctx.Doer.ID,
Name: req.Name,
Description: req.Description,
Type: req.Type,
Value: req.Value,
EncryptionMode: encryptionMode,
CreatorID: ctx.Doer.ID,
}, limits)
if err != nil {
switch err {
@@ -563,6 +624,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 +661,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 +686,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 +741,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 +796,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 +852,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 +911,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 +938,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 +995,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 +1029,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 +1113,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 +1160,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 +1249,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 +1258,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 +1422,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 +1484,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 +1509,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 +1568,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 +1601,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 +1622,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 +1648,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 +1708,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 +1728,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 +1766,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 +1799,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 +1861,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 +1987,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 +2018,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 +2038,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 +2087,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 +2119,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 +2201,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 +2233,271 @@ func webRevokeToken(lic *license.Manager) http.HandlerFunc {
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
}
}
// ============================================================================
// Key Management Web Handlers
// ============================================================================
func webKeyMigratePage(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
// Must be repo admin
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
ctx.Data["Title"] = ctx.Tr("vault.key_migration_title")
ctx.Data["PageIsVault"] = true
ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin()
// Check if user is instance admin (can migrate all repos)
ctx.Data["IsInstanceAdmin"] = ctx.Doer != nil && ctx.Doer.IsAdmin
// Check license tier for DEK rotation
info := lic.Info()
ctx.Data["IsEnterprise"] = info != nil && info.Tier == "enterprise"
// Check if a dedicated MASTER_KEY is configured (not using fallback)
// If so, show the one-click migration from fallback option
ctx.Data["HasDedicatedMasterKey"] = crypto.HasDedicatedMasterKey()
ctx.Data["KeySource"] = crypto.KeySource()
ctx.HTML(http.StatusOK, tplVaultKeyMigrate)
}
}
func webMigrateKey(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
// Must be repo admin
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
oldKeyStr := r.FormValue("old_key")
scope := r.FormValue("scope")
if oldKeyStr == "" {
ctx.Flash.Error(ctx.Tr("vault.old_key_required"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/migrate-key")
return
}
// Parse the old key (try hex first, then raw)
var oldKeyBytes []byte
if len(oldKeyStr) == 64 {
decoded, err := hexDecode(oldKeyStr)
if err == nil {
oldKeyBytes = decoded
}
}
if oldKeyBytes == nil {
oldKeyBytes = []byte(oldKeyStr)
}
// Ensure key is 32 bytes
if len(oldKeyBytes) < 32 {
padded := make([]byte, 32)
copy(padded, oldKeyBytes)
oldKeyBytes = padded
} else if len(oldKeyBytes) > 32 {
oldKeyBytes = oldKeyBytes[:32]
}
// Determine scope
var repoID int64
if scope == "all" && ctx.Doer != nil && ctx.Doer.IsAdmin {
repoID = 0 // Migrate all repos
} else {
repoID = ctx.Repo.Repository.ID // Just this repo
}
results, err := services.MigrateRepoKeys(ctx, services.MigrateRepoKeyOptions{
OldKey: oldKeyBytes,
RepoID: repoID,
UserID: ctx.Doer.ID,
IPAddress: ctx.RemoteAddr(),
})
ctx.Data["Title"] = ctx.Tr("vault.key_migration_title")
ctx.Data["PageIsVault"] = true
ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin()
ctx.Data["IsInstanceAdmin"] = ctx.Doer != nil && ctx.Doer.IsAdmin
info := lic.Info()
ctx.Data["IsEnterprise"] = info != nil && info.Tier == "enterprise"
if err != nil {
ctx.Data["MigrationError"] = err.Error()
ctx.HTML(http.StatusOK, tplVaultKeyMigrate)
return
}
// Build result summary
successCount := 0
failedCount := 0
var failedRepos []map[string]any
for _, result := range results {
if result.Success {
successCount++
} else {
failedCount++
failedRepos = append(failedRepos, map[string]any{
"RepoID": result.RepoID,
"Error": result.Error,
})
}
}
ctx.Data["MigrationResult"] = map[string]any{
"SuccessCount": successCount,
"FailedCount": failedCount,
"FailedRepos": failedRepos,
}
ctx.HTML(http.StatusOK, tplVaultKeyMigrate)
}
}
func webMigrateFromFallback(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
// Must be repo admin
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
// Must have a dedicated MASTER_KEY configured
if !crypto.HasDedicatedMasterKey() {
ctx.Flash.Error(ctx.Tr("vault.no_dedicated_master_key"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/migrate-key")
return
}
// Get the fallback key (Gitea's SECRET_KEY)
fallbackKey := crypto.GetFallbackKey()
if fallbackKey == nil {
ctx.Flash.Error(ctx.Tr("vault.no_fallback_key"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/migrate-key")
return
}
// Determine scope
scope := r.FormValue("scope")
var repoID int64
if scope == "all" && ctx.Doer != nil && ctx.Doer.IsAdmin {
repoID = 0 // Migrate all repos
} else {
repoID = ctx.Repo.Repository.ID // Just this repo
}
results, err := services.MigrateRepoKeys(ctx, services.MigrateRepoKeyOptions{
OldKey: fallbackKey,
RepoID: repoID,
UserID: ctx.Doer.ID,
IPAddress: ctx.RemoteAddr(),
})
if err != nil {
ctx.Flash.Error(err.Error())
ctx.Redirect(ctx.Repo.RepoLink + "/vault/migrate-key")
return
}
// Count results
successCount := 0
failedCount := 0
for _, result := range results {
if result.Success {
successCount++
} else {
failedCount++
}
}
if failedCount == 0 {
ctx.Flash.Success(ctx.Tr("vault.migration_from_fallback_success", successCount))
} else {
ctx.Flash.Warning(ctx.Tr("vault.migration_partial_success", successCount, failedCount))
}
ctx.Redirect(ctx.Repo.RepoLink + "/vault")
}
}
func webRotateKey(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
// Must be repo admin
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
// Check enterprise license
info := lic.Info()
if info == nil || info.Tier != "enterprise" {
ctx.Flash.Error(ctx.Tr("vault.dek_rotation_enterprise_only"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/migrate-key")
return
}
err := services.RotateRepoKey(ctx, services.RotateRepoKeyOptions{
RepoID: ctx.Repo.Repository.ID,
UserID: ctx.Doer.ID,
IPAddress: ctx.RemoteAddr(),
})
if err != nil {
if isKeyMismatchError(err) {
ctx.Flash.Error(ctx.Tr("vault.key_mismatch_message"))
} else {
ctx.Flash.Error(err.Error())
}
ctx.Redirect(ctx.Repo.RepoLink + "/vault/migrate-key")
return
}
ctx.Flash.Success(ctx.Tr("vault.dek_rotation_complete"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault")
}
}

View File

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

View File

@@ -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" .}}

View File

@@ -0,0 +1,173 @@
{{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 ignore-dirty" id="migrate-fallback-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 show-modal" data-modal="#migrate-fallback-modal" type="button">
{{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 ignore-dirty" id="migrate-manual-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 show-modal" data-modal="#migrate-manual-modal" type="button">
{{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 ignore-dirty" id="rotate-dek-form" action="{{.RepoLink}}/vault/rotate-key" method="post">
{{.CsrfTokenHtml}}
<button class="ui button show-modal" data-modal="#rotate-dek-modal" type="button">
{{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}}
{{if .HasDedicatedMasterKey}}
<div class="ui small modal" id="migrate-fallback-modal">
<div class="header">{{svg "octicon-sync"}} {{ctx.Locale.Tr "vault.migrate_from_fallback"}}</div>
<div class="content">
<p>{{ctx.Locale.Tr "vault.confirm_migrate_fallback"}}</p>
</div>
<div class="actions">
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
<button class="ui primary button" onclick="document.getElementById('migrate-fallback-form').submit();">{{ctx.Locale.Tr "vault.start_migration"}}</button>
</div>
</div>
{{end}}
<div class="ui small modal" id="migrate-manual-modal">
<div class="header">{{svg "octicon-key"}} {{ctx.Locale.Tr "vault.manual_migration"}}</div>
<div class="content">
<p>{{ctx.Locale.Tr "vault.confirm_migrate"}}</p>
</div>
<div class="actions">
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
<button class="ui primary button" onclick="document.getElementById('migrate-manual-form').submit();">{{ctx.Locale.Tr "vault.start_migration"}}</button>
</div>
</div>
{{if and .IsRepoAdmin .IsEnterprise}}
<div class="ui small modal" id="rotate-dek-modal">
<div class="header">{{svg "octicon-sync"}} {{ctx.Locale.Tr "vault.dek_rotation_title"}}</div>
<div class="content">
<p>{{ctx.Locale.Tr "vault.confirm_rotate"}}</p>
</div>
<div class="actions">
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
<button class="ui primary button" onclick="document.getElementById('rotate-dek-form').submit();">{{ctx.Locale.Tr "vault.rotate_dek"}}</button>
</div>
</div>
{{end}}
{{template "repo/vault/layout_footer" .}}

View File

@@ -65,8 +65,11 @@
<tr{{if .IsDeleted}} class="negative"{{end}}>
<td>
<a href="{{$.RepoLink}}/vault/secrets/{{.Name}}">
{{svg "octicon-key" 16}} <strong>{{.Name}}</strong>
{{if eq .EncryptionMode "lockbox"}}{{svg "octicon-lock" 16}}{{else}}{{svg "octicon-key" 16}}{{end}} <strong>{{.Name}}</strong>
</a>
{{if eq .EncryptionMode "lockbox"}}
<span class="ui tiny blue label" data-tooltip="End-to-end encrypted">{{svg "octicon-shield-lock" 10}} Lock-Box</span>
{{end}}
{{if .Description}}
<br><small class="text grey">{{.Description}}</small>
{{end}}

View File

@@ -119,9 +119,9 @@
<td>{{.UsedCount}}</td>
<td class="right aligned">
{{if not .IsRevoked}}
<form class="ui inline" action="{{$.RepoLink}}/vault/tokens/{{.ID}}/revoke" method="post">
<form class="ui inline" id="revoke-form-{{.ID}}" action="{{$.RepoLink}}/vault/tokens/{{.ID}}/revoke" method="post">
{{$.CsrfTokenHtml}}
<button class="ui tiny red button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "vault.confirm_revoke_token"}}');">
<button class="ui tiny red button show-modal" type="button" data-modal="#revoke-token-modal" data-token-id="{{.ID}}">
{{svg "octicon-x" 14}} {{ctx.Locale.Tr "vault.revoke"}}
</button>
</form>
@@ -142,6 +142,17 @@
{{end}}
</div>
<div class="ui small modal" id="revoke-token-modal">
<div class="header">{{svg "octicon-x"}} {{ctx.Locale.Tr "vault.revoke"}}</div>
<div class="content">
<p>{{ctx.Locale.Tr "vault.confirm_revoke_token"}}</p>
</div>
<div class="actions">
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
<button class="ui red button" id="revoke-confirm">{{ctx.Locale.Tr "vault.revoke"}}</button>
</div>
</div>
<script>
document.getElementById('new-token-btn')?.addEventListener('click', function() {
document.getElementById('new-token-form').style.display = 'block';
@@ -157,5 +168,20 @@ document.getElementById('copy-token')?.addEventListener('click', function() {
const input = document.getElementById('new-token-value');
navigator.clipboard.writeText(input.value);
});
// Revoke token modal
(function() {
let currentTokenId = null;
document.querySelectorAll('[data-modal="#revoke-token-modal"]').forEach(btn => {
btn.addEventListener('click', function() {
currentTokenId = this.getAttribute('data-token-id');
});
});
document.getElementById('revoke-confirm')?.addEventListener('click', function() {
if (currentTokenId) {
document.getElementById('revoke-form-' + currentTokenId).submit();
}
});
})();
</script>
{{template "repo/vault/layout_footer" .}}

View File

@@ -52,10 +52,10 @@
{{svg "octicon-diff" 14}} {{ctx.Locale.Tr "vault.compare"}}
</a>
{{if and $.CanWrite (ne .Version $.Secret.CurrentVersion)}}
<form class="ui inline tw-inline" action="{{$.RepoLink}}/vault/secrets/{{$.Secret.Name}}/rollback" method="post">
<form class="ui inline tw-inline" id="rollback-form-{{.Version}}" action="{{$.RepoLink}}/vault/secrets/{{$.Secret.Name}}/rollback" method="post">
{{$.CsrfTokenHtml}}
<input type="hidden" name="version" value="{{.Version}}">
<button class="ui button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "vault.confirm_rollback" .Version}}');">
<button class="ui button show-modal" type="button" data-modal="#rollback-modal" data-version="{{.Version}}">
{{svg "octicon-history" 14}} {{ctx.Locale.Tr "vault.rollback"}}
</button>
</form>
@@ -75,4 +75,33 @@
</div>
{{end}}
</div>
{{if .CanWrite}}
<div class="ui small modal" id="rollback-modal">
<div class="header">{{svg "octicon-history"}} {{ctx.Locale.Tr "vault.rollback"}}</div>
<div class="content">
<p id="rollback-message"></p>
</div>
<div class="actions">
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
<button class="ui primary button" id="rollback-confirm">{{ctx.Locale.Tr "vault.rollback"}}</button>
</div>
</div>
<script>
(function() {
let currentVersion = null;
document.querySelectorAll('[data-modal="#rollback-modal"]').forEach(btn => {
btn.addEventListener('click', function() {
currentVersion = this.getAttribute('data-version');
document.getElementById('rollback-message').textContent = '{{ctx.Locale.Tr "vault.confirm_rollback" 0}}'.replace('0', currentVersion);
});
});
document.getElementById('rollback-confirm')?.addEventListener('click', function() {
if (currentVersion) {
document.getElementById('rollback-form-' + currentVersion).submit();
}
});
})();
</script>
{{end}}
{{template "repo/vault/layout_footer" .}}

View File

@@ -3,7 +3,12 @@
<div class="tw-flex tw-items-start tw-justify-between tw-gap-4">
<div>
<h4 class="ui header tw-mb-0">
{{if eq .Secret.EncryptionMode "lockbox"}}
{{svg "octicon-lock" 20}} {{.Secret.Name}}
<span class="ui blue label" data-tooltip="End-to-end encrypted. Use CLI/SDK to decrypt.">{{svg "octicon-shield-lock" 12}} Lock-Box</span>
{{else}}
{{svg "octicon-key" 20}} {{.Secret.Name}}
{{end}}
{{if .Secret.IsDeleted}}
<span class="ui red label">{{ctx.Locale.Tr "vault.deleted"}}</span>
{{end}}
@@ -50,7 +55,52 @@
</div>
</div>
{{if .CanWrite}}
{{if eq .Secret.EncryptionMode "lockbox"}}
<!-- Lockbox secret - show locked state -->
<div class="ui segment tw-text-center">
<div class="tw-py-8">
{{svg "octicon-lock" 64 "tw-text-blue-500"}}
<h3 class="ui header tw-mt-4">Lock-Box Protected</h3>
<p class="tw-text-secondary tw-max-w-md tw-mx-auto">
This secret is end-to-end encrypted. The server cannot decrypt it.
Use the CLI or SDK with your passphrase to access the value.
</p>
<div class="ui divider"></div>
<div class="ui secondary segment tw-text-left tw-max-w-lg tw-mx-auto">
<p class="tw-font-semibold tw-mb-2">CLI Usage:</p>
<code class="tw-block tw-p-2 tw-bg-gray-100 dark:tw-bg-gray-800 tw-rounded tw-font-mono tw-text-sm">
vault-resolve get --lockbox {{.Secret.Name}}
</code>
<p class="tw-font-semibold tw-mb-2 tw-mt-4">Go SDK:</p>
<code class="tw-block tw-p-2 tw-bg-gray-100 dark:tw-bg-gray-800 tw-rounded tw-font-mono tw-text-sm">
value, err := client.GetLockbox(ctx, "{{.Secret.Name}}", passphrase)
</code>
</div>
{{if .CanWrite}}
<div class="tw-mt-4">
{{if .Secret.IsDeleted}}
<button class="ui green button" type="button" onclick="document.getElementById('restore-form').submit();">
{{svg "octicon-history" 16}} {{ctx.Locale.Tr "vault.restore"}}
</button>
{{else}}
<button class="ui red button show-modal" type="button" data-modal="#delete-secret-modal">
{{svg "octicon-trash" 16}} {{ctx.Locale.Tr "vault.delete"}}
</button>
{{end}}
</div>
{{if .Secret.IsDeleted}}
<form id="restore-form" class="tw-hidden" action="{{.RepoLink}}/vault/secrets/{{.Secret.Name}}/restore" method="post">
{{.CsrfTokenHtml}}
</form>
{{else}}
<form id="delete-form" class="tw-hidden" action="{{.RepoLink}}/vault/secrets/{{.Secret.Name}}/delete" method="post">
{{.CsrfTokenHtml}}
</form>
{{end}}
{{end}}
</div>
</div>
{{else if .CanWrite}}
<!-- Editor view for users who can write -->
<div class="ui segment">
<h5 class="ui header">{{ctx.Locale.Tr "vault.edit_secret"}}</h5>
@@ -92,7 +142,7 @@
{{svg "octicon-history" 16}} {{ctx.Locale.Tr "vault.restore"}}
</button>
{{else}}
<button class="ui red button" type="button" onclick="if(confirm('{{ctx.Locale.Tr "vault.confirm_delete"}}')) document.getElementById('delete-form').submit();">
<button class="ui red button show-modal" type="button" data-modal="#delete-secret-modal">
{{svg "octicon-trash" 16}} {{ctx.Locale.Tr "vault.delete"}}
</button>
{{end}}
@@ -125,8 +175,8 @@
});
})();
</script>
{{else}}
<!-- Read-only view for users who cannot write -->
{{else if ne .Secret.EncryptionMode "lockbox"}}
<!-- Read-only view for users who cannot write (standard secrets only) -->
<div class="ui segment">
<div class="tw-flex tw-items-center tw-justify-between tw-mb-4">
<h5 class="ui header tw-mb-0">{{ctx.Locale.Tr "vault.secret_value"}}</h5>
@@ -238,4 +288,17 @@
</table>
</div>
{{end}}
{{if not .Secret.IsDeleted}}
<div class="ui small modal" id="delete-secret-modal">
<div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "vault.delete_secret"}}</div>
<div class="content">
<p>{{ctx.Locale.Tr "vault.confirm_delete"}}</p>
</div>
<div class="actions">
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
<button class="ui red button" onclick="document.getElementById('delete-form').submit();">{{ctx.Locale.Tr "vault.delete"}}</button>
</div>
</div>
{{end}}
{{template "repo/vault/layout_footer" .}}