// Package sigcheck implementiert ARC-10: Signatur- und // Zeitstempelerhalt für S/MIME-signierte Mails (BSI TR-03125, Modul // B.4). Erkennung (Detect) läuft für JEDE archivierte Nachricht // (billig, nur Content-Type-Parsing); die eigentliche // kryptographische Prüfung (Verify) NUR für tatsächlich erkannte // signierte Nachrichten (Akzeptanzkriterium 1). // // Bewusste Scope-Grenze (siehe ARC-10-Prüfprotokoll): PGP/MIME wird // erkannt, aber NICHT kryptographisch geprüft — // StatusUnverifiable ist einer der von Akzeptanzkriterium 2 // ausdrücklich vorgesehenen drei Ergebniszustände // (gültig/ungültig/Prüfung nicht möglich), kein stiller Fallback. // Kein volles Langzeitarchivierungs-/ArchiSig-Verfahren mit // periodischer Neu-Zeitstempelung (siehe Ticket-Begründung: für ein // KMU-Mailarchiv ohne qualifiziertes elektronisches Archiv nach eIDAS // unverhältnismäßig) — ein einmaliger, bei Archivierung erzeugter // Prüfnachweis reicht aus. package sigcheck import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "mime" "net/mail" "strings" "time" "go.mozilla.org/pkcs7" ) // Kind ist die erkannte Signaturart (Akzeptanzkriterium 1). type Kind string const ( KindSMIME Kind = "smime" KindPGP Kind = "pgp" ) // Status ist einer der drei von Akzeptanzkriterium 2 vorgesehenen // Ergebniszustände. type Status string const ( StatusValid Status = "gueltig" StatusInvalid Status = "ungueltig" StatusUnverifiable Status = "pruefung_nicht_moeglich" ) // Result ist der unveränderlich zu archivierende Prüfnachweis // (Akzeptanzkriterium 2: Ergebnis, Zertifikatsdaten, Prüfzeitpunkt). type Result struct { Kind Kind `json:"kind"` Status Status `json:"status"` SignerSubject string `json:"signerSubject,omitempty"` CertNotBefore time.Time `json:"certNotBefore,omitempty"` CertNotAfter time.Time `json:"certNotAfter,omitempty"` CheckedAt time.Time `json:"checkedAt"` Reason string `json:"reason,omitempty"` } // Detect erkennt, ob raw eine signierte Nachricht ist — OHNE die // Signatur zu prüfen (Akzeptanzkriterium 1). detected=false für jede // unsignierte Nachricht, die dann KEINE weitere Prüfung durchläuft. func Detect(raw []byte) (kind Kind, detected bool, err error) { msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { return "", false, err } contentType := msg.Header.Get("Content-Type") if contentType == "" { return "", false, nil } mediaType, params, err := mime.ParseMediaType(contentType) if err != nil { return "", false, nil } switch { case mediaType == "application/pkcs7-mime" && strings.EqualFold(params["smime-type"], "signed-data"): return KindSMIME, true, nil case mediaType == "multipart/signed": switch strings.ToLower(params["protocol"]) { case "application/pkcs7-signature", "application/x-pkcs7-signature": return KindSMIME, true, nil case "application/pgp-signature": return KindPGP, true, nil } } return "", false, nil } // Verify prüft eine ALS SIGNIERT ERKANNTE Nachricht (Akzeptanzkriterium // 2). now ist der Prüfzeitpunkt — im Regelbetrieb time.Now() zum // Archivierungszeitpunkt, in Tests explizit steuerbar (Pflichtprüfung // 3: simulierter Zertifikatsablauf nach Archivierung — ein späterer // Aufruf mit einem SPÄTEREN now kann ein anderes Ergebnis liefern, // ändert aber nie ein bereits archiviertes Result rückwirkend). func Verify(raw []byte, kind Kind, now time.Time) (Result, error) { result := Result{Kind: kind, CheckedAt: now} if kind == KindPGP { result.Status = StatusUnverifiable result.Reason = "PGP/MIME-Signaturprüfung nicht implementiert (bewusste Scope-Grenze, siehe ARC-10-Prüfprotokoll)" return result, nil } if kind != KindSMIME { return Result{}, errors.New("sigcheck: unbekannte signaturart") } msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { result.Status = StatusUnverifiable result.Reason = "nachricht nicht lesbar: " + err.Error() return result, nil } body, err := io.ReadAll(msg.Body) if err != nil { result.Status = StatusUnverifiable result.Reason = "body nicht lesbar: " + err.Error() return result, nil } decoded, err := decodePKCS7Body(msg.Header.Get("Content-Transfer-Encoding"), body) if err != nil { result.Status = StatusUnverifiable result.Reason = "pkcs7-daten nicht dekodierbar: " + err.Error() return result, nil } p7, err := pkcs7.Parse(decoded) if err != nil { result.Status = StatusUnverifiable result.Reason = "pkcs7-struktur nicht lesbar: " + err.Error() return result, nil } if len(p7.Certificates) > 0 { result.SignerSubject = p7.Certificates[0].Subject.String() result.CertNotBefore = p7.Certificates[0].NotBefore result.CertNotAfter = p7.Certificates[0].NotAfter } // Kryptographische Prüfung (Signatur + Inhaltsintegrität) — kein // Vertrauensanker (truststore=nil): für ein KMU-Mailarchiv ohne // qualifiziertes elektronisches Archiv reicht der Nachweis "diese // konkrete Signatur wurde mit diesem konkreten Zertifikat über // diesen konkreten Inhalt erzeugt" (siehe Ticket-Begründung, "kein // eIDAS-Archiv"). if err := p7.Verify(); err != nil { result.Status = StatusInvalid result.Reason = err.Error() return result, nil } // Zusätzliche, explizite Gültigkeitsfensterprüfung GEGEN DEN // ÜBERGEBENEN PRÜFZEITPUNKT (nicht gegen die reale Systemzeit) — // Grundlage für Pflichtprüfung 3: ein archiviertes Result bleibt // unverändert "gültig zum Archivierungszeitpunkt", auch wenn eine // SPÄTERE Prüfung mit einem SPÄTEREN now auf denselben Rohdaten ein // anderes Ergebnis liefern würde. if !result.CertNotBefore.IsZero() && !result.CertNotAfter.IsZero() { if now.Before(result.CertNotBefore) || now.After(result.CertNotAfter) { result.Status = StatusInvalid result.Reason = "zertifikat zum prüfzeitpunkt nicht gültig (außerhalb des gültigkeitsfensters)" return result, nil } } result.Status = StatusValid return result, nil } func decodePKCS7Body(transferEncoding string, body []byte) ([]byte, error) { if strings.EqualFold(strings.TrimSpace(transferEncoding), "base64") { cleaned := strings.Map(func(r rune) rune { if r == '\r' || r == '\n' || r == ' ' || r == '\t' { return -1 } return r }, string(body)) return base64.StdEncoding.DecodeString(cleaned) } return body, nil } // Store ist die für Ablage/Abruf des Prüfnachweises benötigte // Teilmenge von *mail/internal/storage.Service — als Schnittstelle, // damit dieses Paket storage nicht importieren muss. type Store interface { Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (checksum string, err error) GetVerified(ctx context.Context, key string) ([]byte, error) } // SidecarKey liefert den Objektschlüssel des Prüfnachweises zu einer // archivierten Nachricht (Akzeptanzkriterium 3: über die Detailansicht // abrufbar, OHNE die Original-Mail zu verändern — eigenes // Sidecar-Objekt, gleiche Konvention wie ARC-01s // Prüfsummen-Sidecar-Objekt). func SidecarKey(archiveKey string) string { return archiveKey + ".sigcheck.json" } // SaveResult archiviert result UNVERÄNDERLICH als Sidecar-Objekt // (Akzeptanzkriterium 2). func SaveResult(ctx context.Context, store Store, archiveKey string, result Result) error { body, err := json.Marshal(result) if err != nil { return fmt.Errorf("sigcheck: ergebnis serialisieren: %w", err) } if _, err := store.Put(ctx, SidecarKey(archiveKey), bytes.NewReader(body), int64(len(body)), "application/json"); err != nil { return fmt.Errorf("sigcheck: ergebnis ablegen: %w", err) } return nil } // LoadResult ruft den archivierten Prüfnachweis ab (Akzeptanzkriterium // 3), ohne die Original-Mail zu lesen oder zu verändern. func LoadResult(ctx context.Context, store Store, archiveKey string) (Result, error) { raw, err := store.GetVerified(ctx, SidecarKey(archiveKey)) if err != nil { return Result{}, fmt.Errorf("sigcheck: ergebnis abrufen: %w", err) } var result Result if err := json.Unmarshal(raw, &result); err != nil { return Result{}, fmt.Errorf("sigcheck: ergebnis parsen: %w", err) } return result, nil }