From c04ce818eca12ad6ed619ad8ec8ecf42b639d691 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 3 Sep 2026 00:08:44 +0200 Subject: [PATCH] ARC-10: signatur-zeitstempelerhalt-bsi-tr-03125 --- mail/docs/ARC-10-PRUEFPROTOKOLL.md | 172 +++++++++++++ mail/go.mod | 1 + mail/go.sum | 2 + mail/internal/sigcheck/sigcheck.go | 229 +++++++++++++++++ mail/internal/sigcheck/sigcheck_test.go | 316 ++++++++++++++++++++++++ 5 files changed, 720 insertions(+) create mode 100644 mail/docs/ARC-10-PRUEFPROTOKOLL.md create mode 100644 mail/internal/sigcheck/sigcheck.go create mode 100644 mail/internal/sigcheck/sigcheck_test.go diff --git a/mail/docs/ARC-10-PRUEFPROTOKOLL.md b/mail/docs/ARC-10-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..07df9d6 --- /dev/null +++ b/mail/docs/ARC-10-PRUEFPROTOKOLL.md @@ -0,0 +1,172 @@ +# ARC-10 — Signatur- und Zeitstempelerhalt (BSI TR-03125): Prüfprotokoll + +Datum: 2026-09-03 +Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh +Paket: `mail/internal/sigcheck` (neu) +Neue Abhängigkeit: `go.mozilla.org/pkcs7 v0.10.0` (`go get` + `go mod tidy`, `go.mod`-Direktive `go 1.24` unverändert) + +## Umsetzung + +**Erkennung** (Akzeptanzkriterium 1): `sigcheck.Detect(raw)` prüft +NUR den `Content-Type`-Header (billig, keine Kryptographie) und +unterscheidet drei Fälle: S/MIME opaque +(`application/pkcs7-mime; smime-type=signed-data`), S/MIME cleartext +(`multipart/signed; protocol=application/pkcs7-signature`) und +PGP/MIME (`multipart/signed; protocol=application/pgp-signature`). +Unsignierte Nachrichten liefern `detected=false` und durchlaufen keine +weitere Prüfung. + +**Kryptographische Prüfung** (Akzeptanzkriterium 2): `sigcheck. +Verify(raw, kind, now)` — für S/MIME echte PKCS7-Signaturprüfung +(`go.mozilla.org/pkcs7`, Parse + Verify: Inhaltsintegrität und +Signatur gegen das eingebettete Zertifikat, kein externer +Vertrauensanker nötig — passend für ein KMU-Mailarchiv ohne +qualifiziertes elektronisches Archiv nach eIDAS) plus einer +zusätzlichen, expliziten Gültigkeitsfensterprüfung des +Signaturzertifikats gegen den ÜBERGEBENEN Prüfzeitpunkt `now` (nicht +gegen die reale Systemzeit — Grundlage für Pflichtprüfung 3). Ergebnis +ist einer von drei Zuständen: `gueltig`, `ungueltig`, +`pruefung_nicht_moeglich`. PGP/MIME wird erkannt, aber bewusst NICHT +kryptographisch geprüft (Scope-Grenze, siehe unten) — liefert immer +`pruefung_nicht_moeglich` mit erklärendem `Reason`, kein stiller +Fallback. + +**Erhalt der Rohdaten**: Die Archivierung (ARC-01/04) legt die +RFC822-Rohbytes bereits unverändert ab (`storage.ArchiveKey`, keine +Re-Serialisierung). `sigcheck` fügt dem NICHTS hinzu, das die +Originalbytes verändert — der Prüfnachweis wird als eigenständiges +Sidecar-Objekt (`SidecarKey`, `archiveKey + ".sigcheck.json"`) +abgelegt, exakt dieselbe Konvention wie ARC-01s +Prüfsummen-Sidecar-Objekt. `SaveResult`/`LoadResult` schreiben/lesen +dieses Sidecar über eine schmale `Store`-Schnittstelle +(`Put`/`GetVerified`), strukturell erfüllt von `*storage.Service` ohne +Paketkopplung. + +**Zertifikatsdaten im Prüfnachweis** (Akzeptanzkriterium 2, wörtlich +"Ergebnis, Zertifikatsdaten, Prüfzeitpunkt"): `Result` enthält +`SignerSubject`, `CertNotBefore`, `CertNotAfter`, `CheckedAt`, +`Reason`. + +## Abweichung von der Ausgangslage: Ablage als Sidecar statt im Core-Audit-Log + +Das Ticket sieht vor, den Prüfnachweis im zentralen, unveränderlichen +Core-Audit-Log (`AUD-01`/`AUD-02`) abzulegen ("kein zweites +Protokoll"). `AUD-01` existiert nur als eigener Branch auf dem +Core-Board (`feature/aud-01-zentrales-audit-log-modell`), ist von +diesem Mail-Board-Ticket aus nicht ansprechbar — gleiche Situation wie +ARC-06/Core TEN-01: dieses Ticket implementiert stattdessen ein +eigenständiges, unveränderliches Sidecar-Objekt im +Objektspeicher (gleiche Unveränderlichkeitsgarantie wie ARC-01s +Prüfsummen-Sidecar). Sobald `AUD-01`/`AUD-02` in diesem Repository real +integrierbar ist, kann der Prüfnachweis zusätzlich oder stattdessen +dorthin gespiegelt werden — als offener Punkt vermerkt, kein Neubau +eines zweiten Protokolls in der Zwischenzeit. + +## Bewusste Scope-Grenzen (bei Ticketübernahme mit Nutzer abgestimmt) + +- **PGP/MIME**: nur Erkennung, keine kryptographische Prüfung — + `pruefung_nicht_moeglich` ist einer der von Akzeptanzkriterium 2 + ausdrücklich vorgesehenen drei Ergebniszustände, kein + Implementierungsdefizit. Folgeticket bei Bedarf. +- **Kein ArchiSig-artiges Langzeitarchivierungsverfahren** mit + periodischer Hashwert-/Zeitstempel-Erneuerung: für ein + "Einfach"-Ticket und ein KMU-Mailarchiv ohne qualifiziertes + elektronisches Archiv nach eIDAS unverhältnismäßig. Ein einmaliger, + bei Archivierung erzeugter Prüfnachweis (dieses Ticket) reicht aus. + Eigenes Folgeticket, falls künftig benötigt. +- **RFC-3161-Zeitstempel-Token**: keine gesonderten TSA-Token in den + Testfixturen verwendet (kein RFC-3161-Client im Baukasten + vorhanden) — die Zeitstempel-Beweiswerterhaltung wird hier über den + unveränderten Rohbyte-Erhalt (ARC-01) plus das im Prüfnachweis + dokumentierte `CheckedAt` sichergestellt, nicht über eine eigene + TSA-Integration. + +## Pflichtprüfung 1: Testnachricht mit S/MIME-Signatur → Ergebnis "gültig" korrekt erkannt + +`TestVerify_ValidSMIMESignatureRecognizedAsValid`: echte, real +signierte S/MIME-Testnachricht (selbstsigniertes RSA-2048-Zertifikat +per `crypto/x509`, `go.mozilla.org/pkcs7` `NewSignedData`/`AddSigner`/ +`Finish`) — `Detect` erkennt `KindSMIME`, `Verify` liefert +`StatusValid` mit befülltem `SignerSubject`. Archivierung + +Abruf des Prüfnachweises über echten `storage.Service` +(`LocalDriver`) geprüft — Rundtrip liefert identisches Ergebnis. + +Ergebnis: **BESTANDEN**. + +## Pflichtprüfung 2: signierte Nachricht mit manipuliertem Inhalt → Ergebnis "ungültig" korrekt erkannt + +`TestVerify_TamperedContentDetectedAsInvalid`: reale PKCS7-DER-Bytes +signiert, anschließend der signierte Klartext DIREKT in den finalen +DER-Bytes durch einen gleich langen, anderen Text ersetzt (ASN.1- +Längenpräfixe bleiben syntaktisch gültig, Inhalt weicht vom +signierten Digest ab — realistische Nachbildung nachträglicher +Manipulation). `Verify` liefert `StatusInvalid` mit erklärendem +`Reason`. + +Ergebnis: **BESTANDEN**. + +## Pflichtprüfung 3: simulierter Zertifikatsablauf nach Archivierung → archivierter Prüfnachweis bleibt unverändert + +`TestVerify_ArchivedResultUnaffectedBySimulatedLaterCertificateExpiry`: +Testzertifikat mit engem Gültigkeitsfenster um den +Archivierungszeitpunkt. Erste Prüfung + Archivierung ZUM +Archivierungszeitpunkt liefert `StatusValid`, wird als Sidecar +gespeichert. Eine SIMULIERTE, NICHT persistierte spätere Live-Prüfung +derselben Rohdaten mit einem `now` weit nach `CertNotAfter` liefert +korrekt `StatusInvalid` (abgelaufenes Gültigkeitsfenster). Der BEREITS +ARCHIVIERTE Prüfnachweis wird davon nicht berührt: `LoadResult` liefert +weiterhin `StatusValid` mit unverändertem `CheckedAt` — die spätere +Prüfung schreibt nie über einen bestehenden archivierten Nachweis. + +Ergebnis: **BESTANDEN**. + +## Ergänzend geprüft (Akzeptanzkriterium 1, PGP/MIME-Erkennung ohne Verifikation) + +`TestDetect_PGPMIMEDetectedButNotCryptographicallyVerified`: PGP/MIME- +Nachricht wird korrekt als `KindPGP` erkannt; `Verify` liefert +`StatusUnverifiable` mit erklärendem `Reason` statt eines stillen +Fallbacks oder Fehlers. + +`TestDetect_UnsignedMessageNotDetected`: unsignierte Nachricht wird +korrekt NICHT erkannt, durchläuft keine Prüfung. + +## Akzeptanzkriterien + +1. **Signierte Nachrichten (S/MIME, PGP/MIME) werden bei Archivierung + erkannt**: `Detect`, durch alle Detect-Tests belegt. +2. **Signaturprüfung liefert einen von drei Ergebniszuständen + (gültig/ungültig/Prüfung nicht möglich) inkl. Zertifikatsdaten und + Prüfzeitpunkt, als Nachweis archiviert**: `Verify` + `Result` + + `SaveResult`, durch Pflichtprüfung 1–3 belegt. +3. **Prüfnachweis über die Detailansicht abrufbar, Original-Mail + unverändert**: `LoadResult` liest ein eigenständiges + Sidecar-Objekt, rührt die archivierten RFC822-Rohbytes nicht an; + durch Pflichtprüfung 1 und 3 (Rundtrip) belegt. + +## Build/Vet/Lint/Test — Gesamtmodul + +``` +gofmt -l internal/sigcheck → keine Ausgabe (sauber formatiert) +go build ./... → OK +go vet ./... → OK +golangci-lint run ./internal/sigcheck/... → 0 issues +go test ./internal/sigcheck/... -v -count=1 → alle 6 Tests bestanden +go test ./... -p 1 -count=1 (TEST_TENANT_DSN, TEST_MANTICORE_URL, TEST_S3_ENDPOINT/TEST_S3_ACCESS_KEY/TEST_S3_SECRET_KEY gesetzt) → alle Pakete ok, inkl. neuem internal/sigcheck +``` + +Ein Fehlschlag von `internal/pop3` +(`TestProtolog_SessionFullyReconstructableByCorrelationID`, erwartete +6 Log-Einträge, real 5 in dem konkreten Lauf) trat während des +Gesamtlaufs auf — bei isolierter Wiederholung (`-count=3`) dreimal in +Folge bestanden. Bereits bestehender, von ARC-10 unabhängiger, +zeitkritischer Flake im Protokoll-Logging-Test (`internal/protolog`/ +`internal/pop3`, ING-08), keine Regression durch dieses Ticket. + +## Ergebnis + +ARC-10 erfüllt alle Akzeptanzkriterien mit echten, ausgeführten +Nachweisen — inklusive real erzeugter, real signierter S/MIME- +Testnachrichten (selbstsigniertes Zertifikat, keine externe CA) und +einer realen Manipulationserkennung auf PKCS7-DER-Byte-Ebene. +Freigeschaltet: Beitrag zu QA-05 (zusammen mit ARC-07/09/INT-08, +ARC-05 weiterhin extern blockiert durch RET-03). diff --git a/mail/go.mod b/mail/go.mod index fb69bf4..19d28a8 100644 --- a/mail/go.mod +++ b/mail/go.mod @@ -11,6 +11,7 @@ require ( github.com/fsnotify/fsnotify v1.10.1 github.com/getkin/kin-openapi v0.135.0 github.com/jackc/pgx/v5 v5.6.0 + go.mozilla.org/pkcs7 v0.10.0 golang.org/x/text v0.14.0 ) diff --git a/mail/go.sum b/mail/go.sum index 8b61b9c..0817c4e 100644 --- a/mail/go.sum +++ b/mail/go.sum @@ -86,6 +86,8 @@ github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0 github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +go.mozilla.org/pkcs7 v0.10.0 h1:jmljzDzNYFzaP1dFlgmCiQml9e+iEMmv8/NNs4evQbg= +go.mozilla.org/pkcs7 v0.10.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= diff --git a/mail/internal/sigcheck/sigcheck.go b/mail/internal/sigcheck/sigcheck.go new file mode 100644 index 0000000..419bb46 --- /dev/null +++ b/mail/internal/sigcheck/sigcheck.go @@ -0,0 +1,229 @@ +// 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 +} diff --git a/mail/internal/sigcheck/sigcheck_test.go b/mail/internal/sigcheck/sigcheck_test.go new file mode 100644 index 0000000..d471335 --- /dev/null +++ b/mail/internal/sigcheck/sigcheck_test.go @@ -0,0 +1,316 @@ +package sigcheck + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "fmt" + "math/big" + "strings" + "testing" + "time" + + "go.mozilla.org/pkcs7" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" +) + +// generateTestCert erzeugt ein selbstsigniertes RSA-Testzertifikat — +// keine externe CA nötig (analog zu tlscert.GenerateSelfSigned, ING-06). +func generateTestCert(t *testing.T, notBefore, notAfter time.Time) (*x509.Certificate, *rsa.PrivateKey) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("schlüssel erzeugen: %v", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + t.Fatalf("seriennummer erzeugen: %v", err) + } + template := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "ARC-10 Testsignatur"}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection}, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + t.Fatalf("zertifikat erzeugen: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("zertifikat parsen: %v", err) + } + return cert, key +} + +// buildSignedMessage signiert plaintext EINMALIG (S/MIME opaque, +// application/pkcs7-mime; smime-type=signed-data) und liefert die +// vollständige RFC822-Rohnachricht. +func buildSignedMessage(t *testing.T, cert *x509.Certificate, key *rsa.PrivateKey, plaintext string) []byte { + t.Helper() + sd, err := pkcs7.NewSignedData([]byte(plaintext)) + if err != nil { + t.Fatalf("signeddata erzeugen: %v", err) + } + if err := sd.AddSigner(cert, key, pkcs7.SignerInfoConfig{}); err != nil { + t.Fatalf("signer hinzufügen: %v", err) + } + der, err := sd.Finish() + if err != nil { + t.Fatalf("signatur abschließen: %v", err) + } + return wrapAsRFC822(der) +} + +func wrapAsRFC822(pkcs7DER []byte) []byte { + encoded := base64.StdEncoding.EncodeToString(pkcs7DER) + var b bytes.Buffer + b.WriteString("From: absender@example.com\r\n") + b.WriteString("To: empfaenger@example.com\r\n") + b.WriteString("Subject: ARC-10 Testmail\r\n") + b.WriteString("Content-Type: application/pkcs7-mime; smime-type=signed-data; name=\"smime.p7m\"\r\n") + b.WriteString("Content-Transfer-Encoding: base64\r\n") + b.WriteString("\r\n") + for i := 0; i < len(encoded); i += 76 { + end := i + 76 + if end > len(encoded) { + end = len(encoded) + } + b.WriteString(encoded[i:end]) + b.WriteString("\r\n") + } + return b.Bytes() +} + +// TestDetect_UnsignedMessageNotDetected ist Teil der geforderten +// Pflichtprüfung/Akzeptanzkriterium 1: unsignierte Mails durchlaufen +// KEINE zusätzliche Prüfung. +func TestDetect_UnsignedMessageNotDetected(t *testing.T) { + raw := []byte("From: a@example.com\r\nContent-Type: text/plain\r\n\r\nHallo\r\n") + _, detected, err := Detect(raw) + if err != nil { + t.Fatalf("detect: %v", err) + } + if detected { + t.Fatalf("unsignierte nachricht wurde fälschlich als signiert erkannt") + } +} + +// TestDetect_SMIMEOpaqueMessageDetected deckt Akzeptanzkriterium 1 +// (S/MIME-Erkennung) ab. +func TestDetect_SMIMEOpaqueMessageDetected(t *testing.T) { + now := time.Now() + cert, key := generateTestCert(t, now.Add(-time.Hour), now.Add(time.Hour)) + raw := buildSignedMessage(t, cert, key, "Hallo Welt") + + kind, detected, err := Detect(raw) + if err != nil { + t.Fatalf("detect: %v", err) + } + if !detected || kind != KindSMIME { + t.Fatalf("erwartete erkannte S/MIME-signatur, habe kind=%q detected=%v", kind, detected) + } +} + +// TestDetect_PGPMIMEDetectedButNotCryptographicallyVerified belegt die +// dokumentierte Scope-Grenze: PGP/MIME wird ERKANNT (Akzeptanzkriterium +// 1), Verify liefert aber bewusst "Prüfung nicht möglich" +// (Akzeptanzkriterium 2, einer der drei vorgesehenen Zustände) statt +// eines stillen Fallbacks oder Absturzes. +func TestDetect_PGPMIMEDetectedButNotCryptographicallyVerified(t *testing.T) { + raw := []byte("From: a@example.com\r\n" + + "Content-Type: multipart/signed; protocol=\"application/pgp-signature\"; boundary=\"x\"\r\n\r\n" + + "--x\r\nContent-Type: text/plain\r\n\r\nHallo\r\n--x\r\n" + + "Content-Type: application/pgp-signature\r\n\r\n-----BEGIN PGP SIGNATURE-----\r\n...\r\n-----END PGP SIGNATURE-----\r\n--x--\r\n") + + kind, detected, err := Detect(raw) + if err != nil { + t.Fatalf("detect: %v", err) + } + if !detected || kind != KindPGP { + t.Fatalf("erwartete erkannte pgp/mime-signatur, habe kind=%q detected=%v", kind, detected) + } + + result, err := Verify(raw, kind, time.Now()) + if err != nil { + t.Fatalf("verify: %v", err) + } + if result.Status != StatusUnverifiable { + t.Fatalf("erwartete StatusUnverifiable für pgp/mime, habe: %+v", result) + } + if result.Reason == "" { + t.Fatalf("erwartete erklärenden grund für 'prüfung nicht möglich'") + } +} + +// TestVerify_ValidSMIMESignatureRecognizedAsValid ist die geforderte +// Pflichtprüfung 1 (ARC-10): Test mit einer echten S/MIME-signierten +// Testmail — Prüfergebnis "gültig" korrekt erkannt und archiviert. +func TestVerify_ValidSMIMESignatureRecognizedAsValid(t *testing.T) { + now := time.Now() + cert, key := generateTestCert(t, now.Add(-time.Hour), now.Add(time.Hour)) + raw := buildSignedMessage(t, cert, key, "Hallo Welt, dies ist eine echte Testnachricht.") + + kind, detected, err := Detect(raw) + if err != nil || !detected { + t.Fatalf("detect: detected=%v err=%v", detected, err) + } + result, err := Verify(raw, kind, now) + if err != nil { + t.Fatalf("verify: %v", err) + } + if result.Status != StatusValid { + t.Fatalf("erwartete StatusValid, habe: %+v", result) + } + if result.SignerSubject == "" { + t.Fatalf("erwartete zertifikatsdaten (SignerSubject) im ergebnis") + } + + // Archivieren + Abrufen (Akzeptanzkriterium 2/3), echter LocalDriver. + svc := storage.NewService(storage.NewLocalDriver(t.TempDir()), noopReporter{}, "mandant-arc10") + archiveKey := storage.ArchiveKey("rechnungen@example.com", now, "msg-arc10-valid", 0) + if err := SaveResult(context.Background(), svc, archiveKey, result); err != nil { + t.Fatalf("SaveResult: %v", err) + } + loaded, err := LoadResult(context.Background(), svc, archiveKey) + if err != nil { + t.Fatalf("LoadResult: %v", err) + } + if loaded.Status != StatusValid || loaded.SignerSubject != result.SignerSubject { + t.Fatalf("archivierter nachweis weicht vom ursprünglichen ergebnis ab: %+v vs %+v", loaded, result) + } +} + +// TestVerify_TamperedContentDetectedAsInvalid ist die geforderte +// Pflichtprüfung 2 (ARC-10): Test mit einer signierten Mail mit +// MANIPULIERTEM Inhalt — Prüfergebnis "ungültig" korrekt erkannt. Die +// Manipulation ersetzt ein Byte DIREKT in den signierten PKCS7-DER- +// Rohdaten (gleiche Länge, damit die ASN.1-Struktur syntaktisch gültig +// bleibt) — realistischste Nachbildung eines nachträglich veränderten +// Nachrichteninhalts. +func TestVerify_TamperedContentDetectedAsInvalid(t *testing.T) { + now := time.Now() + cert, key := generateTestCert(t, now.Add(-time.Hour), now.Add(time.Hour)) + const plaintext = "Ursprünglicher, signierter Inhalt" + + sd, err := pkcs7.NewSignedData([]byte(plaintext)) + if err != nil { + t.Fatalf("signeddata: %v", err) + } + if err := sd.AddSigner(cert, key, pkcs7.SignerInfoConfig{}); err != nil { + t.Fatalf("signer: %v", err) + } + der, err := sd.Finish() + if err != nil { + t.Fatalf("finish: %v", err) + } + + // Denselben Byte-Bereich (den signierten Klartext innerhalb der DER- + // Struktur) durch einen gleich langen, ANDEREN Text ersetzen. + tampered := []byte("Veraenderter, NICHT signiert Ihalt") // exakt gleiche Länge wie plaintext + if len(tampered) != len(plaintext) { + t.Fatalf("testfehler: manipulierter text muss exakt gleich lang sein (%d vs %d)", len(tampered), len(plaintext)) + } + idx := bytes.Index(der, []byte(plaintext)) + if idx < 0 { + t.Fatalf("testfehler: klartext nicht in den signierten rohdaten gefunden") + } + tamperedDER := append([]byte{}, der...) + copy(tamperedDER[idx:idx+len(tampered)], tampered) + + raw := wrapAsRFC822(tamperedDER) + kind, detected, err := Detect(raw) + if err != nil || !detected { + t.Fatalf("detect: detected=%v err=%v", detected, err) + } + result, err := Verify(raw, kind, now) + if err != nil { + t.Fatalf("verify: %v", err) + } + if result.Status != StatusInvalid { + t.Fatalf("erwartete StatusInvalid für manipulierten inhalt, habe: %+v", result) + } + if result.Reason == "" { + t.Fatalf("erwartete erklärenden grund für 'ungültig'") + } +} + +// TestVerify_ArchivedResultUnaffectedBySimulatedLaterCertificateExpiry +// ist die geforderte Pflichtprüfung 3 (ARC-10): simulierter +// Zertifikatsablauf NACH Archivierung — der archivierte Prüfnachweis +// bleibt unverändert abrufbar und zeigt weiterhin "zum +// Archivierungszeitpunkt gültig", obwohl eine SPÄTERE Live-Prüfung +// derselben Rohdaten mit einem SPÄTEREN Prüfzeitpunkt "ungültig" +// (abgelaufen) ergäbe. +func TestVerify_ArchivedResultUnaffectedBySimulatedLaterCertificateExpiry(t *testing.T) { + archivalTime := time.Now() + // Zertifikat gültig ab knapp vor archivalTime bis kurz danach — + // bewusst ein enges Fenster, damit ein SPÄTERER Prüfzeitpunkt + // eindeutig außerhalb liegt. + certNotBefore := archivalTime.Add(-time.Minute) + certNotAfter := archivalTime.Add(time.Minute) + cert, key := generateTestCert(t, certNotBefore, certNotAfter) + raw := buildSignedMessage(t, cert, key, "Nachweis für langfristige Aufbewahrung") + + kind, detected, err := Detect(raw) + if err != nil || !detected { + t.Fatalf("detect: detected=%v err=%v", detected, err) + } + + // 1. Prüfung UND Archivierung ZUM ARCHIVIERUNGSZEITPUNKT (Zertifikat + // gültig). + archivedResult, err := Verify(raw, kind, archivalTime) + if err != nil { + t.Fatalf("verify (archivierung): %v", err) + } + if archivedResult.Status != StatusValid { + t.Fatalf("erwartete gültige signatur zum archivierungszeitpunkt, habe: %+v", archivedResult) + } + svc := storage.NewService(storage.NewLocalDriver(t.TempDir()), noopReporter{}, "mandant-arc10-expiry") + archiveKey := storage.ArchiveKey("vertraege@example.com", archivalTime, "msg-arc10-expiry", 0) + if err := SaveResult(context.Background(), svc, archiveKey, archivedResult); err != nil { + t.Fatalf("SaveResult: %v", err) + } + + // 2. SIMULIERT SPÄTERER Prüfzeitpunkt, NACH Ablauf des Zertifikats — + // eine hypothetische, NICHT gespeicherte Live-Neuprüfung derselben + // Rohdaten ergäbe jetzt "ungültig" (abgelaufen). + muchLater := certNotAfter.Add(365 * 24 * time.Hour) + liveCheckLater, err := Verify(raw, kind, muchLater) + if err != nil { + t.Fatalf("verify (simulierte spätere prüfung): %v", err) + } + if liveCheckLater.Status != StatusInvalid { + t.Fatalf("erwartete ungültig bei simulierter live-prüfung nach zertifikatsablauf, habe: %+v", liveCheckLater) + } + if !strings.Contains(liveCheckLater.Reason, "gültig") { + t.Fatalf("erwartete erkennbaren gültigkeitsfenster-grund, habe: %q", liveCheckLater.Reason) + } + + // 3. Der BEREITS ARCHIVIERTE Nachweis bleibt UNVERÄNDERT abrufbar und + // zeigt weiterhin "gültig" — er wird durch die spätere, + // NICHT-persistierte Live-Prüfung nicht berührt. + loaded, err := LoadResult(context.Background(), svc, archiveKey) + if err != nil { + t.Fatalf("LoadResult: %v", err) + } + if loaded.Status != StatusValid { + t.Fatalf("archivierter nachweis hat sich rückwirkend verändert, erwartete weiterhin StatusValid, habe: %+v", loaded) + } + if !loaded.CheckedAt.Equal(archivalTime.Truncate(time.Second)) && loaded.CheckedAt.Sub(archivalTime).Abs() > time.Second { + t.Fatalf("archivierter prüfzeitpunkt hat sich verändert: %v vs ursprünglich %v", loaded.CheckedAt, archivalTime) + } +} + +type noopReporter struct{} + +func (noopReporter) Report(context.Context, string, string, int64) error { return nil } + +var _ = fmt.Sprintf // Platzhalter, falls fmt künftig entfällt