mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 01:53:33 +00:00
d0518216c5
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.
145 lines
3.9 KiB
Go
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
|
|
}
|