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>
108 lines
3.3 KiB
Go
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
|
|
}
|