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:
@@ -165,6 +165,9 @@ func main() {
|
|||||||
// Backfill in background: migrate existing files into DB metadata + re-index
|
// Backfill in background: migrate existing files into DB metadata + re-index
|
||||||
go runBackfill(context.Background(), mailStore, idx, worker, logger)
|
go runBackfill(context.Background(), mailStore, idx, worker, logger)
|
||||||
|
|
||||||
|
// Background integrity verification — runs every 5 minutes
|
||||||
|
go runIntegrityCheck(context.Background(), mailStore, logger)
|
||||||
|
|
||||||
// Start HTTP API
|
// Start HTTP API
|
||||||
go func() {
|
go func() {
|
||||||
logger.Info("starting API server", "addr", bind)
|
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)
|
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.
|
// seedDefaultUsers creates default admin and auditor accounts if no users exist yet.
|
||||||
func seedDefaultUsers(users *userstore.Store, logger *slog.Logger) error {
|
func seedDefaultUsers(users *userstore.Store, logger *slog.Logger) error {
|
||||||
all, err := users.List("")
|
all, err := users.List("")
|
||||||
|
|||||||
+2
-1
@@ -30,7 +30,8 @@
|
|||||||
| PROJ-16 | LDAP / Active Directory Anbindung | In Progress | [PROJ-16](PROJ-16-ldap-active-directory.md) | 2026-03-13 |
|
| 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-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 -->
|
<!-- Add features above this line -->
|
||||||
|
|
||||||
## Next Available ID: PROJ-18
|
## Next Available ID: PROJ-19
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -548,6 +548,17 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
|
|||||||
dateStr = pm.Date.UTC().Format(time.RFC3339)
|
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{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"id": id,
|
"id": id,
|
||||||
"from": pm.From,
|
"from": pm.From,
|
||||||
@@ -560,6 +571,8 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
|
|||||||
"body_plain": pm.TextBody,
|
"body_plain": pm.TextBody,
|
||||||
"raw_headers": extractRawHeaders(raw),
|
"raw_headers": extractRawHeaders(raw),
|
||||||
"attachments": attachments,
|
"attachments": attachments,
|
||||||
|
"verify_ok": verifyOK,
|
||||||
|
"verified_at": verifiedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ func New(cfg Config) (*Store, error) {
|
|||||||
pool.Close()
|
pool.Close()
|
||||||
return nil, fmt.Errorf("storage: init schema: %w", err)
|
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
|
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 {
|
func (s *Store) filePath(id string) string {
|
||||||
return filepath.Join(s.dir, "store", id[:2], id)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,6 +83,32 @@ function MailHeaderGrid({ mail }: { mail: MailDetail }) {
|
|||||||
<span className="font-semibold">{mail.subject || "(kein Betreff)"}</span>
|
<span className="font-semibold">{mail.subject || "(kein Betreff)"}</span>
|
||||||
<span className="font-medium text-muted-foreground">Größe:</span>
|
<span className="font-medium text-muted-foreground">Größe:</span>
|
||||||
<span>{formatBytes(mail.size)}</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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ export interface MailDetail {
|
|||||||
body_plain?: string;
|
body_plain?: string;
|
||||||
raw_headers: string;
|
raw_headers: string;
|
||||||
attachments: MailAttachment[];
|
attachments: MailAttachment[];
|
||||||
|
verify_ok: boolean | null;
|
||||||
|
verified_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditEntry {
|
export interface AuditEntry {
|
||||||
|
|||||||
Reference in New Issue
Block a user