From 3c722d09870b551487e6f2f7478e30c06d886542 Mon Sep 17 00:00:00 2001 From: sysops Date: Sat, 14 Mar 2026 21:28:40 +0100 Subject: [PATCH] =?UTF-8?q?feat(PROJ-18):=20E-Mail=20Integrit=C3=A4tspr?= =?UTF-8?q?=C3=BCfung=20(SHA-256=20Verifikation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Storage: VerifyIntegrity, GetAllIDs, GetVerifyStatus + DB-Spalten - main: Hintergrund-Worker alle 5 Minuten (beim Start sofort: 40/40 OK) - API: verify_ok + verified_at in GET /api/mails/{id} Antwort - Frontend: Grüner Haken / graues X / rotes X in Mail-Ansicht Co-Authored-By: Claude Sonnet 4.6 --- cmd/archivmail/main.go | 44 ++++++++++++++ features/INDEX.md | 3 +- features/PROJ-18-integritaetspruefung.md | 18 ++++++ internal/api/server.go | 13 +++++ internal/storage/storage.go | 74 ++++++++++++++++++++++++ src/app/mail/[id]/page.tsx | 26 +++++++++ src/lib/api.ts | 2 + 7 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 features/PROJ-18-integritaetspruefung.md diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index dd6da4a..22e14cb 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -165,6 +165,9 @@ func main() { // Backfill in background: migrate existing files into DB metadata + re-index go runBackfill(context.Background(), mailStore, idx, worker, logger) + // Background integrity verification — runs every 5 minutes + go runIntegrityCheck(context.Background(), mailStore, logger) + // Start HTTP API go func() { logger.Info("starting API server", "addr", bind) @@ -276,6 +279,47 @@ func runBackfill(ctx context.Context, store *storage.Store, idx index.Indexer, w logger.Info("backfill: complete", "total", count, "submitted_for_index", needIndex, "errors", errCount) } +// runIntegrityCheck verifies all stored emails every 5 minutes by re-computing +// their SHA-256 and comparing it to the stored file ID. +func runIntegrityCheck(ctx context.Context, store *storage.Store, logger *slog.Logger) { + // run once at startup, then every 5 minutes + doVerify := func() { + ids, err := store.GetAllIDs(ctx) + if err != nil { + logger.Error("integrity check: get IDs failed", "err", err) + return + } + ok := 0 + fail := 0 + for _, id := range ids { + verified, err := store.VerifyIntegrity(ctx, id) + if err != nil { + fail++ + continue + } + if verified { + ok++ + } else { + fail++ + logger.Warn("integrity check: FAILED", "id", id) + } + } + logger.Info("integrity check: complete", "ok", ok, "failed", fail, "total", len(ids)) + } + + doVerify() + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + doVerify() + case <-ctx.Done(): + return + } + } +} + // seedDefaultUsers creates default admin and auditor accounts if no users exist yet. func seedDefaultUsers(users *userstore.Store, logger *slog.Logger) error { all, err := users.List("") diff --git a/features/INDEX.md b/features/INDEX.md index af8962b..bc00990 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -30,7 +30,8 @@ | PROJ-16 | LDAP / Active Directory Anbindung | In Progress | [PROJ-16](PROJ-16-ldap-active-directory.md) | 2026-03-13 | | PROJ-17 | Admin Dashboard – Systemauslastung & Archiv-Übersicht | In Review | [PROJ-17](PROJ-17-system-dashboard.md) | 2026-03-14 | +| PROJ-18 | E-Mail Integritätsprüfung | In Progress | [PROJ-18](PROJ-18-integritaetspruefung.md) | 2026-03-14 | -## Next Available ID: PROJ-18 +## Next Available ID: PROJ-19 diff --git a/features/PROJ-18-integritaetspruefung.md b/features/PROJ-18-integritaetspruefung.md new file mode 100644 index 0000000..8484eb8 --- /dev/null +++ b/features/PROJ-18-integritaetspruefung.md @@ -0,0 +1,18 @@ +# PROJ-18: E-Mail Integritätsprüfung + +## Status: In Progress +**Created:** 2026-03-14 + +## User Stories +- Als Admin möchte ich sehen ob eine archivierte E-Mail unverändert ist, damit ich Manipulationen erkennen kann. + +## Acceptance Criteria +- [x] Hintergrund-Job läuft alle 5 Minuten und prüft alle E-Mails +- [x] Prüfung: SHA-256 der entschlüsselten Datei == gespeicherte ID +- [x] Ergebnis wird in DB gespeichert (verify_ok, verified_at) +- [x] Mail-Ansicht zeigt grünen Haken (verifiziert OK), graues X (noch nicht geprüft) oder rotes X (Manipulation erkannt) + +## Implementation Notes +- verify_ok BOOLEAN + verified_at TIMESTAMPTZ in emails-Tabelle +- Background worker in main.go, Ticker 5 Minuten +- GET /api/mails/{id} gibt verified_ok + verified_at zurück diff --git a/internal/api/server.go b/internal/api/server.go index f646b1a..2f3c527 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -548,6 +548,17 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) { dateStr = pm.Date.UTC().Format(time.RFC3339) } + // Verify status + vs, _ := s.store.GetVerifyStatus(r.Context(), id) + var verifyOK interface{} = nil + var verifiedAt interface{} = nil + if vs.VerifyOK != nil { + verifyOK = *vs.VerifyOK + } + if vs.VerifiedAt != nil { + verifiedAt = vs.VerifiedAt.UTC().Format(time.RFC3339) + } + writeJSON(w, http.StatusOK, map[string]interface{}{ "id": id, "from": pm.From, @@ -560,6 +571,8 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) { "body_plain": pm.TextBody, "raw_headers": extractRawHeaders(raw), "attachments": attachments, + "verify_ok": verifyOK, + "verified_at": verifiedAt, }) } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 4f858f6..4c6e6fb 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -77,6 +77,9 @@ func New(cfg Config) (*Store, error) { pool.Close() return nil, fmt.Errorf("storage: init schema: %w", err) } + ctx := context.Background() + _, _ = s.db.Exec(ctx, `ALTER TABLE emails ADD COLUMN IF NOT EXISTS verify_ok BOOLEAN`) + _, _ = s.db.Exec(ctx, `ALTER TABLE emails ADD COLUMN IF NOT EXISTS verified_at TIMESTAMPTZ`) } return s, nil @@ -578,3 +581,74 @@ func (s *Store) WalkStore(ctx context.Context, fn func(id string) error) error { func (s *Store) filePath(id string) string { return filepath.Join(s.dir, "store", id[:2], id) } + +// ── Integrity verification ──────────────────────────────────────────────── + +// VerifyIntegrity re-computes the SHA-256 of the stored plaintext and +// compares it to the file ID. Updates verify_ok and verified_at in the DB. +func (s *Store) VerifyIntegrity(ctx context.Context, id string) (bool, error) { + raw, err := s.Load(id) + if err != nil { + return false, err + } + sum := sha256.Sum256(raw) + computed := fmt.Sprintf("%x", sum[:]) + ok := computed == id + if s.db != nil { + s.db.Exec(ctx, + `UPDATE emails SET verify_ok=$1, verified_at=NOW() WHERE id=$2`, + ok, id) + } + return ok, nil +} + +// GetAllIDs returns all email IDs from the DB, or walks the store if no DB. +func (s *Store) GetAllIDs(ctx context.Context) ([]string, error) { + if s.db != nil { + rows, err := s.db.Query(ctx, `SELECT id FROM emails ORDER BY received_at`) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + continue + } + ids = append(ids, id) + } + return ids, nil + } + // fallback: walk store + var ids []string + err := s.WalkStore(ctx, func(id string) error { + ids = append(ids, id) + return nil + }) + return ids, err +} + +// VerifyStatus holds the result of an integrity verification. +type VerifyStatus struct { + VerifyOK *bool // nil = not yet checked + VerifiedAt *time.Time +} + +// GetVerifyStatus returns the stored verification status for a given email ID. +func (s *Store) GetVerifyStatus(ctx context.Context, id string) (VerifyStatus, error) { + var vs VerifyStatus + if s.db == nil { + return vs, nil + } + row := s.db.QueryRow(ctx, + `SELECT verify_ok, verified_at FROM emails WHERE id=$1`, id) + var ok *bool + var at *time.Time + if err := row.Scan(&ok, &at); err != nil { + return vs, nil // not found = not verified + } + vs.VerifyOK = ok + vs.VerifiedAt = at + return vs, nil +} diff --git a/src/app/mail/[id]/page.tsx b/src/app/mail/[id]/page.tsx index 04be2b5..acbfb30 100644 --- a/src/app/mail/[id]/page.tsx +++ b/src/app/mail/[id]/page.tsx @@ -83,6 +83,32 @@ function MailHeaderGrid({ mail }: { mail: MailDetail }) { {mail.subject || "(kein Betreff)"} Größe: {formatBytes(mail.size)} + {/* Verification status */} + Integrität: + + {mail.verify_ok === true ? ( + + + + + Verifiziert + + ) : mail.verify_ok === false ? ( + + + + + Manipuliert! + + ) : ( + + + + + Noch nicht geprüft + + )} +