mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
bfa32dde5a
Webhook signing secrets were stored plaintext in the webhooks.secret column and echoed back in every API response. Encrypt them at rest (reusing the existing AES-256-GCM store helpers, same pattern as TOTP secrets) and return the raw secret ONLY in the creation response; list responses now mask it and expose a has_secret flag instead. - store: encrypt on CreateWebhook, decrypt on Get/ListWebhooks so the dispatcher still signs with the plaintext secret. Reuses the secret column with the "enc:" prefix — no new column/migration. Keyless self-host stays a no-op fallback (encrypt returns plaintext; decrypt passes legacy rows through unchanged). - BackfillEncryptWebhookSecrets encrypts pre-existing plaintext rows on startup once a key is configured (idempotent), mirroring the TOTP backfill. - model: add HasSecret so masked responses still signal presence. - handlers: mask secret on list; document raw-only-on-create. - tests: encrypt-at-rest round-trip + HMAC validity, list decrypt, plaintext backfill/back-compat, and the API mask-except-on-create contract. Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
255 lines
8.0 KiB
Go
255 lines
8.0 KiB
Go
package store
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
)
|
|
|
|
const encryptedPrefix = "enc:"
|
|
|
|
// SetEncryptionKey configures the store's AES-256 encryption key for
|
|
// encrypting sensitive fields (e.g., TOTP secrets) at rest.
|
|
// The key must be exactly 32 bytes (256 bits). If empty, encryption is disabled
|
|
// and secrets are stored in plaintext (with a warning logged at startup).
|
|
func (s *Store) SetEncryptionKey(key []byte) {
|
|
s.encryptionKey = key
|
|
}
|
|
|
|
// HasEncryptionKey reports whether an encryption key is configured.
|
|
func (s *Store) HasEncryptionKey() bool {
|
|
return len(s.encryptionKey) == 32
|
|
}
|
|
|
|
// encrypt encrypts plaintext using AES-256-GCM and returns a base64-encoded
|
|
// ciphertext prefixed with "enc:" to distinguish from plaintext values.
|
|
func (s *Store) encrypt(plaintext string) (string, error) {
|
|
if !s.HasEncryptionKey() {
|
|
return plaintext, nil // No key — store as plaintext
|
|
}
|
|
if plaintext == "" {
|
|
return "", nil
|
|
}
|
|
|
|
block, err := aes.NewCipher(s.encryptionKey)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create cipher: %w", err)
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create GCM: %w", err)
|
|
}
|
|
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return "", fmt.Errorf("generate nonce: %w", err)
|
|
}
|
|
|
|
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
|
return encryptedPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil
|
|
}
|
|
|
|
// decrypt decrypts a value that was encrypted with encrypt().
|
|
// If the value doesn't have the "enc:" prefix, it's treated as plaintext
|
|
// (backward compatibility with pre-encryption data).
|
|
func (s *Store) decrypt(value string) (string, error) {
|
|
if value == "" {
|
|
return "", nil
|
|
}
|
|
|
|
// Not encrypted — return as-is (plaintext from before encryption was enabled)
|
|
if !strings.HasPrefix(value, encryptedPrefix) {
|
|
return value, nil
|
|
}
|
|
|
|
if !s.HasEncryptionKey() {
|
|
return "", fmt.Errorf("encrypted value found but no encryption key configured")
|
|
}
|
|
|
|
encoded := strings.TrimPrefix(value, encryptedPrefix)
|
|
data, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decode base64: %w", err)
|
|
}
|
|
|
|
block, err := aes.NewCipher(s.encryptionKey)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create cipher: %w", err)
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create GCM: %w", err)
|
|
}
|
|
|
|
nonceSize := gcm.NonceSize()
|
|
if len(data) < nonceSize {
|
|
return "", fmt.Errorf("ciphertext too short")
|
|
}
|
|
|
|
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decrypt: %w", err)
|
|
}
|
|
|
|
return string(plaintext), nil
|
|
}
|
|
|
|
// BackfillEncryptTOTPSecrets encrypts any plaintext TOTP secrets in the database.
|
|
// Idempotent: skips secrets that already have the "enc:" prefix.
|
|
// Called on startup when an encryption key is first configured.
|
|
func (s *Store) BackfillEncryptTOTPSecrets() (int, error) {
|
|
if !s.HasEncryptionKey() {
|
|
return 0, nil
|
|
}
|
|
|
|
rows, err := s.db.Query(s.q(`SELECT id, totp_secret FROM users WHERE totp_secret != '' AND totp_secret NOT LIKE 'enc:%'`))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("query plaintext secrets: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
type row struct {
|
|
id, secret string
|
|
}
|
|
var toEncrypt []row
|
|
for rows.Next() {
|
|
var r row
|
|
if err := rows.Scan(&r.id, &r.secret); err != nil {
|
|
return 0, fmt.Errorf("scan row: %w", err)
|
|
}
|
|
toEncrypt = append(toEncrypt, r)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
for _, r := range toEncrypt {
|
|
encrypted, err := s.encrypt(r.secret)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("encrypt secret for user %s: %w", r.id, err)
|
|
}
|
|
if _, err := s.db.Exec(s.q(`UPDATE users SET totp_secret = ? WHERE id = ?`), encrypted, r.id); err != nil {
|
|
return 0, fmt.Errorf("update secret for user %s: %w", r.id, err)
|
|
}
|
|
}
|
|
|
|
return len(toEncrypt), nil
|
|
}
|
|
|
|
// webhookSecretsEncryptedFlag marks that the one-time legacy migration of
|
|
// pre-encryption webhook secrets has run. Stored in platform_settings.
|
|
const webhookSecretsEncryptedFlag = "webhook_secrets_encrypted_v1"
|
|
|
|
// EncryptWebhookSecretsAtRest encrypts plaintext webhook HMAC secrets (BUG-2057).
|
|
// Called on startup when an encryption key is configured. It handles two
|
|
// populations without ever corrupting genuine ciphertext:
|
|
//
|
|
// - First run (flag unset): webhook-secret encryption is new in this release,
|
|
// so every existing secret in the DB is plaintext — even one that
|
|
// coincidentally starts with the reserved "enc:" marker. Encrypt them ALL,
|
|
// then persist the flag. Because this runs exactly once, ciphertext written
|
|
// by later encrypted creates is never re-wrapped under a rotated key.
|
|
// - Steady state (flag set): only unprefixed plaintext is encrypted (a
|
|
// webhook created while the instance was keyless). "enc:" values are always
|
|
// genuine ciphertext now — handleCreateWebhook rejects "enc:"-prefixed
|
|
// secrets — so they are left alone and a bad key fails loud via decrypt().
|
|
//
|
|
// Idempotent and safe to run on every boot.
|
|
func (s *Store) EncryptWebhookSecretsAtRest() (int, error) {
|
|
if !s.HasEncryptionKey() {
|
|
// Keyless: nothing to encrypt, and the flag stays unset so the full
|
|
// legacy migration still runs if a key is configured later.
|
|
return 0, nil
|
|
}
|
|
|
|
migrated, err := s.GetPlatformSetting(webhookSecretsEncryptedFlag)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("read webhook-secret migration flag: %w", err)
|
|
}
|
|
firstRun := migrated != "1"
|
|
|
|
query := `SELECT id, secret FROM webhooks WHERE secret != '' AND secret NOT LIKE 'enc:%'`
|
|
if firstRun {
|
|
// Every pre-migration secret is plaintext — include "enc:"-prefixed ones.
|
|
query = `SELECT id, secret FROM webhooks WHERE secret != ''`
|
|
}
|
|
|
|
rows, err := s.db.Query(s.q(query))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("query webhook secrets: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
type row struct {
|
|
id, secret string
|
|
}
|
|
var toEncrypt []row
|
|
for rows.Next() {
|
|
var r row
|
|
if err := rows.Scan(&r.id, &r.secret); err != nil {
|
|
return 0, fmt.Errorf("scan row: %w", err)
|
|
}
|
|
// First-run only: an "enc:" value that already decrypts under the
|
|
// current key is genuine ciphertext — skip it so it isn't double-
|
|
// encrypted. A decrypt failure means legacy plaintext that merely looks
|
|
// prefixed (no ciphertext under a different key can exist before the
|
|
// migration has ever run), so fall through and encrypt it.
|
|
if firstRun && strings.HasPrefix(r.secret, encryptedPrefix) {
|
|
if _, derr := s.decrypt(r.secret); derr == nil {
|
|
continue
|
|
}
|
|
}
|
|
toEncrypt = append(toEncrypt, r)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
rows.Close() // release the read before opening the write transaction (SQLite)
|
|
|
|
if len(toEncrypt) == 0 && !firstRun {
|
|
return 0, nil
|
|
}
|
|
|
|
// Apply every row update AND the completion flag in one transaction. A crash
|
|
// mid-migration must not leave some rows encrypted while the flag is unset —
|
|
// otherwise a later key change would mis-read those rows as legacy plaintext
|
|
// and double-encrypt them into nested ciphertext (BUG-2057 review follow-up).
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("begin webhook-secret migration: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
for _, r := range toEncrypt {
|
|
encrypted, err := s.encrypt(r.secret)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("encrypt secret for webhook %s: %w", r.id, err)
|
|
}
|
|
if _, err := tx.Exec(s.q(`UPDATE webhooks SET secret = ? WHERE id = ?`), encrypted, r.id); err != nil {
|
|
return 0, fmt.Errorf("update secret for webhook %s: %w", r.id, err)
|
|
}
|
|
}
|
|
|
|
if firstRun {
|
|
if _, err := tx.Exec(s.q(`
|
|
INSERT INTO platform_settings (key, value, updated_at) VALUES (?, ?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
`), webhookSecretsEncryptedFlag, "1", now()); err != nil {
|
|
return 0, fmt.Errorf("persist webhook-secret migration flag: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, fmt.Errorf("commit webhook-secret migration: %w", err)
|
|
}
|
|
|
|
return len(toEncrypt), nil
|
|
}
|