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 existin
()
| 163 | // |
| 164 | // Idempotent and safe to run on every boot. |
| 165 | func (s *Store) EncryptWebhookSecretsAtRest() (int, error) { |
| 166 | if !s.HasEncryptionKey() { |
| 167 | // Keyless: nothing to encrypt, and the flag stays unset so the full |
| 168 | // legacy migration still runs if a key is configured later. |
| 169 | return 0, nil |
| 170 | } |
| 171 | |
| 172 | migrated, err := s.GetPlatformSetting(webhookSecretsEncryptedFlag) |
| 173 | if err != nil { |
| 174 | return 0, fmt.Errorf("read webhook-secret migration flag: %w", err) |
| 175 | } |
| 176 | firstRun := migrated != "1" |
| 177 | |
| 178 | query := `SELECT id, secret FROM webhooks WHERE secret != '' AND secret NOT LIKE 'enc:%'` |
| 179 | if firstRun { |
| 180 | // Every pre-migration secret is plaintext — include "enc:"-prefixed ones. |
| 181 | query = `SELECT id, secret FROM webhooks WHERE secret != ''` |
| 182 | } |
| 183 | |
| 184 | rows, err := s.db.Query(s.q(query)) |
| 185 | if err != nil { |
| 186 | return 0, fmt.Errorf("query webhook secrets: %w", err) |
| 187 | } |
| 188 | defer rows.Close() |
| 189 | |
| 190 | type row struct { |
| 191 | id, secret string |
| 192 | } |
| 193 | var toEncrypt []row |
| 194 | for rows.Next() { |
| 195 | var r row |
| 196 | if err := rows.Scan(&r.id, &r.secret); err != nil { |
| 197 | return 0, fmt.Errorf("scan row: %w", err) |
| 198 | } |
| 199 | // First-run only: an "enc:" value that already decrypts under the |
| 200 | // current key is genuine ciphertext — skip it so it isn't double- |
| 201 | // encrypted. A decrypt failure means legacy plaintext that merely looks |
| 202 | // prefixed (no ciphertext under a different key can exist before the |
| 203 | // migration has ever run), so fall through and encrypt it. |
| 204 | if firstRun && strings.HasPrefix(r.secret, encryptedPrefix) { |
| 205 | if _, derr := s.decrypt(r.secret); derr == nil { |
| 206 | continue |
| 207 | } |
| 208 | } |
| 209 | toEncrypt = append(toEncrypt, r) |
| 210 | } |
| 211 | if err := rows.Err(); err != nil { |
| 212 | return 0, err |
| 213 | } |
| 214 | rows.Close() // release the read before opening the write transaction (SQLite) |
| 215 | |
| 216 | if len(toEncrypt) == 0 && !firstRun { |
| 217 | return 0, nil |
| 218 | } |
| 219 | |
| 220 | // Apply every row update AND the completion flag in one transaction. A crash |
| 221 | // mid-migration must not leave some rows encrypted while the flag is unset — |
| 222 | // otherwise a later key change would mis-read those rows as legacy plaintext |