Changeset 0.12.0 (#63)
This commit is contained in:
115
server/crypto/rekey.go
Normal file
115
server/crypto/rekey.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// Rekey re-encrypts all global/team API keys from oldKey to newKey.
|
||||
// Personal BYOK keys are unaffected (they're keyed to the user's UEK,
|
||||
// not the environment-derived key).
|
||||
//
|
||||
// Runs in a single transaction — all-or-nothing. On failure, no keys
|
||||
// are modified.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// switchboard vault rekey
|
||||
// ENCRYPTION_KEY = current/old key
|
||||
// NEW_ENCRYPTION_KEY = new key to re-encrypt with
|
||||
func Rekey(db *sql.DB, oldEnvKey, newEnvKey string) error {
|
||||
if oldEnvKey == "" {
|
||||
return fmt.Errorf("ENCRYPTION_KEY (current key) is required")
|
||||
}
|
||||
if newEnvKey == "" {
|
||||
return fmt.Errorf("NEW_ENCRYPTION_KEY is required")
|
||||
}
|
||||
if oldEnvKey == newEnvKey {
|
||||
return fmt.Errorf("ENCRYPTION_KEY and NEW_ENCRYPTION_KEY are identical — nothing to do")
|
||||
}
|
||||
|
||||
oldKey, err := DeriveKeyFromEnv(oldEnvKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive old key: %w", err)
|
||||
}
|
||||
|
||||
newKey, err := DeriveKeyFromEnv(newEnvKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive new key: %w", err)
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback() // no-op after commit
|
||||
|
||||
// Find all global/team keys that are encrypted
|
||||
rows, err := tx.Query(`
|
||||
SELECT id, api_key_enc, key_nonce
|
||||
FROM provider_configs
|
||||
WHERE key_scope IN ('global', 'team')
|
||||
AND api_key_enc IS NOT NULL
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query encrypted keys: %w", err)
|
||||
}
|
||||
|
||||
type rekeyRow struct {
|
||||
id string
|
||||
ciphertext []byte
|
||||
nonce []byte
|
||||
}
|
||||
|
||||
var toRekey []rekeyRow
|
||||
for rows.Next() {
|
||||
var r rekeyRow
|
||||
if err := rows.Scan(&r.id, &r.ciphertext, &r.nonce); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("failed to scan row: %w", err)
|
||||
}
|
||||
toRekey = append(toRekey, r)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("row iteration error: %w", err)
|
||||
}
|
||||
|
||||
if len(toRekey) == 0 {
|
||||
log.Println("No global/team encrypted keys found — nothing to rekey.")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("Rekeying %d provider config(s)...", len(toRekey))
|
||||
|
||||
// Decrypt with old key, re-encrypt with new key, update in place
|
||||
for _, r := range toRekey {
|
||||
plaintext, err := Decrypt(r.ciphertext, r.nonce, oldKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt config %s with old key: %w (is ENCRYPTION_KEY correct?)", r.id, err)
|
||||
}
|
||||
|
||||
newCiphertext, newNonce, err := Encrypt(plaintext, newKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to re-encrypt config %s: %w", r.id, err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
UPDATE provider_configs
|
||||
SET api_key_enc = $1, key_nonce = $2
|
||||
WHERE id = $3
|
||||
`, newCiphertext, newNonce, r.id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update config %s: %w", r.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit rekey transaction: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Successfully rekeyed %d provider config(s).", len(toRekey))
|
||||
log.Println("Update ENCRYPTION_KEY to the new value and restart the server.")
|
||||
return nil
|
||||
}
|
||||
46
server/crypto/status.go
Normal file
46
server/crypto/status.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// VaultStatusInfo holds vault health information for admin display.
|
||||
type VaultStatusInfo struct {
|
||||
EncryptionKeySet bool `json:"encryption_key_set"`
|
||||
EncryptedKeys int `json:"encrypted_keys"` // provider_configs with api_key_enc
|
||||
VaultUsers int `json:"vault_users"` // users with vault_set = true
|
||||
}
|
||||
|
||||
// VaultStatus gathers vault health metrics from the database.
|
||||
// Used by both the CLI (`switchboard vault status`) and the admin API endpoint.
|
||||
func VaultStatus(db *sql.DB, encryptionKey string) (*VaultStatusInfo, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database not available")
|
||||
}
|
||||
|
||||
info := &VaultStatusInfo{
|
||||
EncryptionKeySet: encryptionKey != "",
|
||||
}
|
||||
|
||||
// Count encrypted provider keys (global + team + personal)
|
||||
err := db.QueryRow(`
|
||||
SELECT COUNT(*) FROM provider_configs
|
||||
WHERE api_key_enc IS NOT NULL
|
||||
`).Scan(&info.EncryptedKeys)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count encrypted keys: %w", err)
|
||||
}
|
||||
|
||||
// Count users with active vaults
|
||||
err = db.QueryRow(`
|
||||
SELECT COUNT(*) FROM users
|
||||
WHERE vault_set = true
|
||||
`).Scan(&info.VaultUsers)
|
||||
if err != nil {
|
||||
// vault_set column might not exist on very old installs
|
||||
info.VaultUsers = 0
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
Reference in New Issue
Block a user