package services import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "errors" "fmt" "io" "os" ) // CalDAV password encryption — AES-256-GCM with random per-message nonce. // // Storage layout (bytea column password_encrypted): // [12-byte nonce][ciphertext + 16-byte GCM tag] // // The key is read once at server startup from CALDAV_ENCRYPTION_KEY (a // 32-byte AES-256 key, base64-encoded). The server refuses to start if // the env var is set but malformed; if the var is absent the CalDAV // service is left disabled and all CalDAV endpoints return 501. // // Security posture (audit §1.3 fix): // - Plaintext passwords never enter the database // - Plaintext passwords never appear in API responses (json:"-" on the model) // - The frontend only writes; it can never read the password back // - Rotating the key requires re-encrypting all rows (out of scope for v1) // ErrCalDAVNoKey is returned when an encrypt/decrypt is attempted with no // CALDAV_ENCRYPTION_KEY configured. Handlers map this to 501. var ErrCalDAVNoKey = errors.New("CALDAV_ENCRYPTION_KEY not configured") // CalDAVCipher wraps an AES-GCM AEAD with the server's CalDAV key. type CalDAVCipher struct { gcm cipher.AEAD } // LoadCalDAVCipher reads the key from CALDAV_ENCRYPTION_KEY and returns a // ready-to-use cipher. Returns (nil, nil) when the env var is unset — the // caller treats this as "CalDAV disabled". func LoadCalDAVCipher() (*CalDAVCipher, error) { raw := os.Getenv("CALDAV_ENCRYPTION_KEY") if raw == "" { return nil, nil } key, err := base64.StdEncoding.DecodeString(raw) if err != nil { return nil, fmt.Errorf("CALDAV_ENCRYPTION_KEY must be base64: %w", err) } if len(key) != 32 { return nil, fmt.Errorf("CALDAV_ENCRYPTION_KEY must decode to 32 bytes (AES-256), got %d", len(key)) } block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("aes.NewCipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("cipher.NewGCM: %w", err) } return &CalDAVCipher{gcm: gcm}, nil } // Encrypt seals plaintext with a fresh random nonce and returns // nonce || ciphertext || GCM-tag. func (c *CalDAVCipher) Encrypt(plaintext string) ([]byte, error) { if c == nil { return nil, ErrCalDAVNoKey } nonce := make([]byte, c.gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, fmt.Errorf("nonce: %w", err) } ct := c.gcm.Seal(nil, nonce, []byte(plaintext), nil) out := make([]byte, 0, len(nonce)+len(ct)) out = append(out, nonce...) out = append(out, ct...) return out, nil } // Decrypt reverses Encrypt. Returns an error on tampering / wrong key / // short input. func (c *CalDAVCipher) Decrypt(blob []byte) (string, error) { if c == nil { return "", ErrCalDAVNoKey } ns := c.gcm.NonceSize() if len(blob) < ns+c.gcm.Overhead() { return "", errors.New("ciphertext too short") } nonce := blob[:ns] ct := blob[ns:] pt, err := c.gcm.Open(nil, nonce, ct, nil) if err != nil { return "", fmt.Errorf("gcm.Open: %w", err) } return string(pt), nil }