File size: 6,552 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 | """
tests/test_saml_flow.py
End-to-end SAML flow: SP generates AuthnRequest, IdP builds Response,
SP validates through the full NAND gate pipeline and WORM audit.
"""
import os
import tempfile
import pytest
from crypto.keys import generate_key_pair
from saml.sp.authn_request import AuthnRequestBuilder, AuthnRequestConfig
from saml.idp.response_builder import ResponseBuilder, ResponseConfig
from saml.sp.assertion_consumer import AssertionConsumer, SAMLValidationError
from nand.gate import NANDGate
from audit.worm import WORMAuditChain
from audit.replay import ReplayStore, ReplayDetectedError
SP_ENTITY = "https://vault-live.local/sp"
IDP_ENTITY = "https://vault-live.local/idp"
ACS_URL = "https://vault-live.local/sp/acs"
SSO_URL = "https://vault-live.local/idp/sso"
VALID_ATTRS = {
'Role': ['VaultUser'],
'Email': ['alice@example.com'],
'SessionIndex': ['idx_001'],
}
@pytest.fixture(scope='module')
def idp_kp():
return generate_key_pair(IDP_ENTITY)
@pytest.fixture(scope='module')
def sp_kp():
return generate_key_pair(SP_ENTITY)
@pytest.fixture(scope='module')
def idp_builder(idp_kp):
cfg = ResponseConfig(idp_entity_id=IDP_ENTITY, sp_entity_id=SP_ENTITY, acs_url=ACS_URL)
return ResponseBuilder(cfg, idp_kp.private_key, idp_kp.certificate)
@pytest.fixture
def gate():
constraints_path = os.path.join(os.path.dirname(__file__), '..', 'nand', 'constraints.xml')
return NANDGate(os.path.normpath(constraints_path))
@pytest.fixture
def tmp_audit():
with tempfile.NamedTemporaryFile(suffix='.jsonl', delete=False) as f:
path = f.name
yield WORMAuditChain(path)
os.unlink(path)
@pytest.fixture
def consumer(idp_kp, sp_kp, gate, tmp_audit):
return AssertionConsumer(
idp_certificate=idp_kp.certificate,
sp_private_key=sp_kp.private_key,
sp_entity_id=SP_ENTITY,
acs_url=ACS_URL,
nand_gate=gate,
replay_store=ReplayStore(ttl_seconds=300),
audit_chain=tmp_audit,
)
@pytest.fixture
def sp_builder(sp_kp):
cfg = AuthnRequestConfig(
sp_entity_id=SP_ENTITY,
idp_sso_url=SSO_URL,
acs_url=ACS_URL,
private_key=sp_kp.private_key,
certificate=sp_kp.certificate,
)
return AuthnRequestBuilder(cfg)
# ββ Tests βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_authn_request_builds_and_signs(sp_builder):
signed = sp_builder.build_and_sign()
assert signed.request_id.startswith('_')
assert signed.redirect_url.startswith(SSO_URL)
assert len(signed.post_value) > 0
def test_happy_path_full_flow(idp_builder, consumer):
built = idp_builder.build('alice', VALID_ATTRS, '_req001')
result = consumer.receive(built.post_value)
assert result.valid is True
assert result.subject == 'alice'
assert len(result.semantic_hash) == 64
assert result.attributes['Role'] == ['VaultUser']
def test_replay_attack_rejected(idp_builder, consumer):
built = idp_builder.build('alice', VALID_ATTRS, '_req_replay')
consumer.receive(built.post_value) # first time β ok
# Second time β same assertion_id β replay
with pytest.raises(ReplayDetectedError):
consumer.receive(built.post_value)
def test_nand_gate_rejects_superadmin(idp_builder, consumer):
bad_attrs = {
'Role': ['SuperAdmin'],
'SessionIndex': ['idx_admin'],
}
built = idp_builder.build('badactor', bad_attrs, '_req_admin')
result = consumer.receive(built.post_value)
assert result.valid is False
def test_entropy_overflow_rejected(idp_builder, consumer):
# Single attribute with many distinct values β H = ln(5) > 0.20
overflow_attrs = {'Role': ['v0', 'v1', 'v2', 'v3', 'v4'], 'SessionIndex': ['s']}
built = idp_builder.build('entropy_user', overflow_attrs, '_req_entropy')
result = consumer.receive(built.post_value)
assert result.valid is False
assert 'entropy-exceeded' in (result.rejection_reason or '')
def test_tampered_assertion_rejected(idp_builder, sp_kp, gate, tmp_audit):
"""Mutate an attribute value after signing β signature must fail."""
from xml.etree import ElementTree as ET
from crypto.xml_dsig import SignatureVerificationError
import base64
built = idp_builder.build('alice', VALID_ATTRS, '_req_tamper')
raw = base64.b64decode(built.post_value)
root = ET.fromstring(raw)
# Mutate a NameID value
ns = 'urn:oasis:names:tc:SAML:2.0:assertion'
nameid = root.find(f".//{{{ns}}}NameID")
if nameid is not None:
nameid.text = 'TAMPERED'
tampered_b64 = base64.b64encode(
ET.tostring(root, encoding='unicode').encode('utf-8')
).decode('ascii')
fresh_consumer = AssertionConsumer(
idp_certificate=idp_builder.certificate,
sp_private_key=sp_kp.private_key,
sp_entity_id=SP_ENTITY,
acs_url=ACS_URL,
nand_gate=gate,
replay_store=ReplayStore(),
audit_chain=tmp_audit,
)
with pytest.raises((SignatureVerificationError, Exception)):
fresh_consumer.receive(tampered_b64)
def test_semantic_hash_is_deterministic(idp_builder, gate):
"""Same attrs + same metadata β same semantic hash."""
from nand.gate import NANDGate
r1 = gate.evaluate(VALID_ATTRS, '_id1', IDP_ENTITY, '2026-08-07T00:00:00Z')
r2 = gate.evaluate(VALID_ATTRS, '_id1', IDP_ENTITY, '2026-08-07T00:00:00Z')
assert r1.semantic_hash == r2.semantic_hash
def test_semantic_hash_differs_on_different_assertion_id(gate):
r1 = gate.evaluate(VALID_ATTRS, '_id1', IDP_ENTITY, '2026-08-07T00:00:00Z')
r2 = gate.evaluate(VALID_ATTRS, '_id2', IDP_ENTITY, '2026-08-07T00:00:00Z')
assert r1.semantic_hash != r2.semantic_hash
def test_worm_chain_grows_per_assertion(idp_builder, consumer, tmp_audit):
from audit.worm import WORMAuditChain
built1 = idp_builder.build('user1', VALID_ATTRS, '_req_worm1')
built2 = idp_builder.build('user2', VALID_ATTRS, '_req_worm2')
consumer.receive(built1.post_value)
consumer.receive(built2.post_value)
result = WORMAuditChain.verify_chain(tmp_audit._path)
assert result.valid is True
assert result.total_entries >= 2
|