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
100 lines
3.0 KiB
Go
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
|
|
}
|