Files
pad/internal/store/encryption.go
T
xarmian d0518216c5 feat: add cloud infrastructure for hosted Pad (PLAN-427)
Add the foundation for running Pad as a hosted service at app.getpad.dev.
Same binary in cloud mode with a thin sidecar for OAuth and Stripe.

Cloud mode (PAD_CLOUD=true):
- PAD_CLOUD flag with cloud secret for sidecar communication
- Account-level billing: plan field on users, CheckLimit enforcement
- Free/Pro tiers with configurable limits stored in platform_settings
- Three-tier limit resolution: user overrides → DB defaults → hardcoded fallback
- Plan enforcement on workspace, item, member, webhook, and token creation

Authentication & security:
- OAuth login endpoint (POST /api/v1/auth/oauth-login) with cloud secret gate
- Verified email requirement for OAuth, 2FA bypass protection
- Cloud secret rotation support (comma-separated keys)
- TOTP secret encryption at rest (AES-256-GCM via PAD_ENCRYPTION_KEY)
- Rate limiting on OAuth login endpoint
- Bootstrap disabled in cloud mode
- Password max length enforcement (128 chars)
- Config file written with 0600 permissions

Admin & billing:
- Admin user management API (list, detail, update plan/overrides)
- Configurable plan limits API (GET/PATCH /api/v1/admin/limits)
- Platform stats endpoint
- Admin plan endpoint for sidecar to set user plans
- GDPR: account deletion and data export endpoints

Console UI (cloud mode only):
- /console — workspace list with owned/shared sections
- /console/new — create workspace wizard with slug preview
- /console/settings — profile, password, API tokens
- /console/billing — plan status, upgrade/manage links
- /console/admin — user management, plan overrides, limits editor
- OAuth buttons (GitHub/Google) on login page in cloud mode

Auto-create default workspace on signup in cloud mode.
Migration 035: plan, plan_expires_at, stripe_customer_id, plan_overrides on users.
2026-04-12 17:43:40 +00:00

145 lines
3.9 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
}