feat(PROJ-18): E-Mail Integritätsprüfung (SHA-256 Verifikation)

- 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 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-14 21:28:40 +01:00
parent 7e68c7ab02
commit 3c722d0987
7 changed files with 179 additions and 1 deletions
+44
View File
@@ -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("")
+2 -1
View File
@@ -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 |
<!-- Add features above this line -->
## Next Available ID: PROJ-18
## Next Available ID: PROJ-19
+18
View File
@@ -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
+13
View File
@@ -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,
})
}
+74
View File
@@ -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
}
+26
View File
@@ -83,6 +83,32 @@ function MailHeaderGrid({ mail }: { mail: MailDetail }) {
<span className="font-semibold">{mail.subject || "(kein Betreff)"}</span>
<span className="font-medium text-muted-foreground">Größe:</span>
<span>{formatBytes(mail.size)}</span>
{/* Verification status */}
<span className="font-medium text-muted-foreground">Integrität:</span>
<span>
{mail.verify_ok === true ? (
<span className="inline-flex items-center gap-1 text-green-600 text-sm font-medium">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Verifiziert
</span>
) : mail.verify_ok === false ? (
<span className="inline-flex items-center gap-1 text-red-600 text-sm font-medium">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Manipuliert!
</span>
) : (
<span className="inline-flex items-center gap-1 text-muted-foreground text-sm">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Noch nicht geprüft
</span>
)}
</span>
</div>
<button
+2
View File
@@ -112,6 +112,8 @@ export interface MailDetail {
body_plain?: string;
raw_headers: string;
attachments: MailAttachment[];
verify_ok: boolean | null;
verified_at: string | null;
}
export interface AuditEntry {