Files
paliad/internal/services/caldav_crypto.go
m b56ef660df feat(termine): Phase F — Termine (appointments) + CalDAV sync
Ship the appointments feature with bidirectional CalDAV synchronisation.
Closes KanzlAI audit §1.3 by encrypting CalDAV passwords at rest with
AES-256-GCM; plaintext credentials never touch the DB or API responses.

Backend
- `internal/services/termin_service.go`: CRUD with per-row visibility.
  Personal Termine (akte_id NULL) visible only to created_by; Akte-attached
  Termine follow AkteService.GetByID. Every Akte-attached mutation appends
  an akten_events row for the audit trail.
- `internal/services/caldav_service.go` (+ caldav_client.go, caldav_ical.go,
  caldav_crypto.go): per-user goroutine, 60s tick, push VEVENT + pull with
  UID/ETag reconciliation. Last-write-wins on conflict; conflicts on
  Akte-attached Termine append to akten_events.
- CALDAV_ENCRYPTION_KEY env var (32-byte AES-256, base64). Server refuses
  to start with malformed key; unset key leaves CalDAV disabled and all
  /api/caldav-config* endpoints return 501.
- Migration 013: paliad.user_caldav_config (password_encrypted bytea) +
  paliad.caldav_sync_log (last-5 per user). RLS: user owns their row only.
- HTTP handlers: GET/POST/PATCH/DELETE /api/termine, GET
  /api/akten/{id}/termine, /api/caldav-config CRUD + /test + /log.

Frontend
- Termine list / detail / new / kalender pages (Bun TSX + per-page client
  TS), calendar month grid with type-coloured dots and click-popup.
- Einstellungen/CalDAV settings page: URL/user/password (write-only),
  test-connection button, status card, sync log table, delete button that
  purges credentials.
- Akten detail "Termine" tab replaces the Phase D placeholder — inline
  add-termin form + list.
- Sidebar: Termine entry activated; new "Einstellungen" group with CalDAV.
- DE/EN i18n complete for every new surface.

Security posture
- AES-GCM with 12-byte random nonce prepended to ciphertext
- Password field has `json:"-"` on the model; API never returns it
- Frontend always sends password via write-only <input type=password>
- DeleteConfig purges the encrypted blob from the primary row
- TestConnection without stored creds requires explicit password

t-paliad-010
2026-04-17 11:59:49 +02:00

100 lines
3.0 KiB
Go

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
}