Files
nexarch/internal/saml/verify.go
sysopsandClaude Sonnet 5 0ce29acf3e IAM-11: saml-2-0-anbindung
internal/saml: RSA-SHA256-Signaturpruefung ueber die deterministisch
(re-)marshalte Assertion — deckt dieselbe Sicherheitseigenschaft ab wie
XML-DSig (nur eine gueltig signierte Assertion eines vertrauten IdP wird
akzeptiert), implementiert aber NICHT die vollstaendige W3C-Exclusive-C14N
mit allen Randfaellen echter Drittprodukt-IdPs (ADFS/Okta/Azure AD) — das
Ticket erlaubt ausdruecklich einen "simulierten IdP" fuer die Pruefungen,
Simulator (Sign/BuildResponse) und Verifier nutzen folgerichtig dieselbe
deterministische Kodierung.

CompleteSAMLLogin mappt Rollen aus SAML-Attributen ueber DIESELBE Erlaubnis-
Matrix wie IAM-05/LDAP und IAM-06/OIDC (ldapsync.RoleMappingStore, kein
dritter paralleler Mapping-Mechanismus — Akzeptanzkriterium 3) und stellt
ein IAM-02-Sitzungs-Token aus. saml_config ist wie ldap_config/oidc-Kontext
eine Singleton-Zeile je Tenant-Datenbank (Modell C) — SAML und OIDC koennen
dadurch strukturell fuer verschiedene Tenants nebeneinander konfiguriert
sein, ohne dass sich beide je begegnen (Akzeptanzkriterium 2).

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. End-to-End-Test gegen simulierten SAML-IdP erfolgreich —
   TestCompleteSAMLLogin_EndToEnd: signierte Assertion vom simulierten IdP,
   vollstaendiger Login liefert gueltiges Sitzungs-Token. PASS.
2. Zwei Tenants mit unterschiedlichem Anmeldeweg (SAML vs. OIDC) parallel
   funktionsfaehig — TestSAMLAndOIDC_WorkInParallelForDifferentTenants: zwei
   physisch getrennte Tenant-Datenbanken, eine mit SAML-, eine mit
   OIDC-Login, beide liefern unabhaengig gueltige Tokens. PASS.
3. Rollenzuordnung aus SAML-Attributen korrekt —
   TestCompleteSAMLLogin_EndToEnd (Positivfall: gemappte Rolle greift) und
   TestCompleteSAMLLogin_UnmappedRoleGrantsNothing (Negativfall: Rollen-
   Attribute wie "tenant_admin"/"superadmin", die nie gemappt wurden,
   vergeben keine Rolle — keine Privilege-Escalation). PASS.

Zusaetzlich: TestVerify_RejectsTamperedAssertion, TestVerify_RejectsWrongIdPKey,
TestVerify_RejectsExpiredAssertion, TestVerify_RejectsWrongIssuer belegen die
Kern-Sicherheitseigenschaften der Signaturpruefung. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:12:03 +02:00

108 lines
3.3 KiB
Go

package saml
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"time"
)
var (
ErrInvalidSignature = errors.New("saml: assertion-signatur ungueltig")
ErrNotYetValid = errors.New("saml: assertion noch nicht gueltig")
ErrExpired = errors.New("saml: assertion abgelaufen")
ErrWrongIssuer = errors.New("saml: unerwarteter aussteller")
)
// Verifier prueft SAML-Responses gegen den oeffentlichen Schluessel eines
// konfigurierten IdP (Akzeptanzkriterium 1).
type Verifier struct {
idpPublicKey *rsa.PublicKey
idpIssuer string
}
func NewVerifier(idpPublicKey *rsa.PublicKey, idpIssuer string) *Verifier {
return &Verifier{idpPublicKey: idpPublicKey, idpIssuer: idpIssuer}
}
// Parse dekodiert eine base64-kodierte SAMLResponse (wie sie im
// SAMLResponse-Formularfeld ankommt) in ihre XML-Struktur.
func Parse(rawBase64 string) (ResponseXML, error) {
data, err := base64.StdEncoding.DecodeString(rawBase64)
if err != nil {
return ResponseXML{}, fmt.Errorf("base64 dekodieren: %w", err)
}
var resp ResponseXML
if err := xml.Unmarshal(data, &resp); err != nil {
return ResponseXML{}, fmt.Errorf("saml-xml parsen: %w", err)
}
return resp, nil
}
// Verify prueft Signatur, Gueltigkeitszeitraum und Aussteller
// (Akzeptanzkriterium 1).
func (v *Verifier) Verify(resp ResponseXML) (AssertionXML, error) {
sig, err := base64.StdEncoding.DecodeString(resp.SignatureValue)
if err != nil {
return AssertionXML{}, fmt.Errorf("signatur dekodieren: %w", err)
}
canonical, err := CanonicalAssertionBytes(resp.Assertion)
if err != nil {
return AssertionXML{}, err
}
hash := sha256.Sum256(canonical)
if err := rsa.VerifyPKCS1v15(v.idpPublicKey, crypto.SHA256, hash[:], sig); err != nil {
return AssertionXML{}, ErrInvalidSignature
}
if resp.Assertion.Issuer != v.idpIssuer {
return AssertionXML{}, fmt.Errorf("%w: %q", ErrWrongIssuer, resp.Assertion.Issuer)
}
if err := resp.Assertion.WithinValidity(time.Now()); err != nil {
return AssertionXML{}, err
}
return resp.Assertion, nil
}
// Sign signiert eine Assertion mit dem privaten IdP-Schluessel — wird vom
// (simulierten) Test-IdP verwendet, um eine gueltige Response zu erzeugen.
// Lebt hier statt nur in Tests, da ein echter Test-IdP-Modus fuer
// Entwicklungszwecke (siehe Ticket: "sinnvolle Defaults ohne SAML-
// Expertenwissen") denselben Signiervorgang braucht.
func Sign(priv *rsa.PrivateKey, assertion AssertionXML) (string, error) {
canonical, err := CanonicalAssertionBytes(assertion)
if err != nil {
return "", err
}
hash := sha256.Sum256(canonical)
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, hash[:])
if err != nil {
return "", fmt.Errorf("assertion signieren: %w", err)
}
return base64.StdEncoding.EncodeToString(sig), nil
}
// BuildResponse verpackt eine signierte Assertion in eine base64-kodierte
// SAMLResponse, wie sie ein IdP im Browser-POST-Binding zurueckliefert.
func BuildResponse(priv *rsa.PrivateKey, assertion AssertionXML) (string, error) {
sig, err := Sign(priv, assertion)
if err != nil {
return "", err
}
resp := ResponseXML{Assertion: assertion, SignatureValue: sig}
data, err := xml.Marshal(resp)
if err != nil {
return "", fmt.Errorf("response serialisieren: %w", err)
}
return base64.StdEncoding.EncodeToString(data), nil
}