File size: 8,204 Bytes
ecf9cb9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 | """
saml/sp/assertion_consumer.py
ACS endpoint: receive Response, validate full chain, run NAND gate, emit audit.
"""
import base64
import hashlib
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
from xml.etree import ElementTree as ET
from crypto.xml_dsig import verify_signature, SignatureVerificationError
from nand.gate import NANDGate, GateResult
from audit.worm import WORMAuditChain, AuditRecord, attributes_hash
from audit.replay import ReplayStore, ReplayDetectedError
SAML_NS = "urn:oasis:names:tc:SAML:2.0:assertion"
SAMLP_NS = "urn:oasis:names:tc:SAML:2.0:protocol"
XENC_NS = "http://www.w3.org/2001/04/xmlenc#"
_SA = "{%s}" % SAML_NS
_SAP = "{%s}" % SAMLP_NS
_XE = "{%s}" % XENC_NS
STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"
CLOCK_SKEW_SECS = 300
class SAMLParseError(Exception): pass
class SAMLValidationError(Exception): pass
@dataclass
class ConsumeResult:
valid: bool
subject: str
attributes: dict
semantic_hash: str
assertion_id: str
rejection_reason: Optional[str] = None
class AssertionConsumer:
def __init__(
self,
idp_certificate,
sp_private_key,
sp_entity_id: str,
acs_url: str,
nand_gate: NANDGate,
replay_store: ReplayStore,
audit_chain: WORMAuditChain,
clock_skew: int = CLOCK_SKEW_SECS,
):
self.idp_cert = idp_certificate
self.sp_key = sp_private_key
self.sp_entity_id = sp_entity_id
self.acs_url = acs_url
self.gate = nand_gate
self.replay = replay_store
self.audit = audit_chain
self.clock_skew = clock_skew
def receive(self, saml_response_b64: str) -> ConsumeResult:
raw_xml = base64.b64decode(saml_response_b64)
root = self._parse_response(raw_xml)
self._validate_response(root)
assertion = self._extract_assertion(root)
self._validate_assertion(assertion)
subject = self._extract_subject(assertion)
attrs = self._extract_attributes(assertion)
assertion_id = assertion.get('ID', '_unknown')
issuer_el = assertion.find(_SA + 'Issuer')
issuer = issuer_el.text.strip() if issuer_el is not None else ''
issue_instant = assertion.get('IssueInstant', '')
# Replay check β use SubjectConfirmationData.NotOnOrAfter
not_on_or_after = self._get_not_on_or_after(assertion)
self.replay.check_and_store(assertion_id, not_on_or_after)
gate_result: GateResult = self.gate.evaluate(
attrs, assertion_id, issuer, issue_instant
)
event = 'ASSERTION_ACCEPTED' if gate_result.valid else 'ASSERTION_REJECTED'
record = AuditRecord(
timestamp=datetime.now(timezone.utc).isoformat(),
semantic_hash=gate_result.semantic_hash,
assertion_id=assertion_id,
issuer=issuer,
subject=subject,
gate_result=gate_result.valid,
entropy=gate_result.entropy,
attributes_hash=attributes_hash(attrs),
event=event,
)
self.audit.append(record)
return ConsumeResult(
valid=gate_result.valid,
subject=subject,
attributes=attrs,
semantic_hash=gate_result.semantic_hash,
assertion_id=assertion_id,
rejection_reason=gate_result.rejection_reason,
)
# ββ Internal validation βββββββββββββββββββββββββββββββββββββββββββββββββββ
def _parse_response(self, raw_xml: bytes) -> ET.Element:
try:
return ET.fromstring(raw_xml)
except ET.ParseError as e:
raise SAMLParseError(f"XML parse error: {e}")
def _validate_response(self, root: ET.Element) -> None:
# Status
status_code = root.find(f".//{_SAP}StatusCode")
if status_code is None or status_code.get('Value') != STATUS_SUCCESS:
raise SAMLValidationError("Response status is not Success")
# Destination
dest = root.get('Destination', '')
if dest and dest != self.acs_url:
raise SAMLValidationError(f"Destination mismatch: {dest} != {self.acs_url}")
# IssueInstant clock skew
issue_instant = root.get('IssueInstant', '')
if issue_instant:
self._check_clock_skew(issue_instant)
def _extract_assertion(self, root: ET.Element) -> ET.Element:
# Try encrypted first
enc = root.find(f".//{_SA}EncryptedAssertion")
if enc is not None:
from crypto.xml_encrypt import decrypt_assertion
return decrypt_assertion(enc, self.sp_key)
assertion = root.find(_SA + 'Assertion')
if assertion is None:
assertion = root.find(f".//{_SA}Assertion")
if assertion is None:
raise SAMLValidationError("No saml:Assertion found in Response")
return assertion
def _validate_assertion(self, assertion: ET.Element) -> None:
now = datetime.now(timezone.utc)
skew = timedelta(seconds=self.clock_skew)
# Conditions timing
conditions = assertion.find(_SA + 'Conditions')
if conditions is not None:
not_before_str = conditions.get('NotBefore', '')
not_after_str = conditions.get('NotOnOrAfter', '')
if not_before_str:
nb = self._parse_saml_datetime(not_before_str)
if now < nb - skew:
raise SAMLValidationError(f"Assertion not yet valid: NotBefore={not_before_str}")
if not_after_str:
na = self._parse_saml_datetime(not_after_str)
if now > na + skew:
raise SAMLValidationError(f"Assertion expired: NotOnOrAfter={not_after_str}")
# Audience
audience = assertion.findtext(f".//{_SA}Audience", '')
if audience and audience.strip() != self.sp_entity_id:
raise SAMLValidationError(f"Audience mismatch: {audience}")
# Signature
verify_signature(assertion, self.idp_cert)
def _extract_subject(self, assertion: ET.Element) -> str:
nameid = assertion.find(f".//{_SA}NameID")
return nameid.text.strip() if nameid is not None and nameid.text else ''
def _extract_attributes(self, assertion: ET.Element) -> dict:
attrs = {}
for attr in assertion.findall(f".//{_SA}Attribute"):
name = attr.get('Name', '')
values = [
v.text.strip() if v.text else ''
for v in attr.findall(_SA + 'AttributeValue')
]
if name:
attrs[name] = values
return attrs
def _get_not_on_or_after(self, assertion: ET.Element) -> str:
scd = assertion.find(f".//{_SA}SubjectConfirmationData")
if scd is not None:
return scd.get('NotOnOrAfter', '')
conditions = assertion.find(_SA + 'Conditions')
if conditions is not None:
return conditions.get('NotOnOrAfter', '')
return ''
def _check_clock_skew(self, issue_instant: str) -> None:
now = datetime.now(timezone.utc)
skew = timedelta(seconds=self.clock_skew)
try:
dt = self._parse_saml_datetime(issue_instant)
except ValueError:
return
if dt > now + skew:
raise SAMLValidationError(f"IssueInstant too far in future: {issue_instant}")
if dt < now - timedelta(hours=8):
raise SAMLValidationError(f"IssueInstant too old: {issue_instant}")
@staticmethod
def _parse_saml_datetime(s: str) -> datetime:
s = s.replace('Z', '+00:00')
return datetime.fromisoformat(s)
|