wsfed-caep 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wsfed_caep/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """Server-side WS-Federation relying party that accepts SAML 2.0 assertions, and a CAEP and RISC receiver.
4
+
5
+ ``signals: false`` turns the receiver off. The relying party does not start one itself. The default is
6
+ on. Sign-in does not change, because WS-Federation does not send OAuth scopes. The flag only gates the
7
+ receiver and the requirement for transmitter metadata.
8
+
9
+ from wsfed_caep import RelyingParty
10
+
11
+ rp = RelyingParty.from_file("wsfed.json")
12
+ request = rp.login(relay_state="/")
13
+ who = rp.handle_reply({"method": method, "url": url, "body": body}, request_id)
14
+ """
15
+
16
+ from wsfed_caep.config import read_config_file, resolve_config
17
+ from wsfed_caep.errors import WsFedError
18
+ from wsfed_caep.metadata import IdpMetadata
19
+ from wsfed_caep.relying_party import RelyingParty
20
+ from wsfed_caep.replay import MemoryReplayCache
21
+ from wsfed_caep.ssf import (
22
+ CAEP_EVENTS,
23
+ DEFAULT_ENDING_EVENTS,
24
+ DEFAULT_EVENTS_REQUESTED,
25
+ EVENT,
26
+ RISC_EVENTS,
27
+ MemorySessionIndex,
28
+ SignalsReceiver,
29
+ subject_of,
30
+ tracked_from,
31
+ )
32
+ from wsfed_caep.ssf.session_index import DEFAULT_SUBJECT_ATTRIBUTE
33
+ from wsfed_caep.token import WsFedLogin
34
+ from wsfed_caep.x509util import KeyPair, create_self_signed_key_pair
35
+
36
+ __version__ = "0.1.0"
37
+
38
+ __all__ = [
39
+ "CAEP_EVENTS",
40
+ "DEFAULT_ENDING_EVENTS",
41
+ "DEFAULT_EVENTS_REQUESTED",
42
+ "DEFAULT_SUBJECT_ATTRIBUTE",
43
+ "EVENT",
44
+ "IdpMetadata",
45
+ "KeyPair",
46
+ "MemoryReplayCache",
47
+ "MemorySessionIndex",
48
+ "RISC_EVENTS",
49
+ "RelyingParty",
50
+ "SignalsReceiver",
51
+ "WsFedError",
52
+ "WsFedLogin",
53
+ "create_self_signed_key_pair",
54
+ "read_config_file",
55
+ "resolve_config",
56
+ "subject_of",
57
+ "tracked_from",
58
+ ]
wsfed_caep/cli.py ADDED
@@ -0,0 +1,149 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """wsfed-caep command line: generate-keys and metadata."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from wsfed_caep.config import resolve_config
13
+ from wsfed_caep.relying_party import RelyingParty
14
+ from wsfed_caep.x509util import create_self_signed_key_pair
15
+
16
+ USAGE = """Usage:
17
+ wsfed-caep generate-keys --config <wsfed.json> [--cn <name>] [--days <n>] [--bits <n>] [--force]
18
+ wsfed-caep generate-keys --key <file> --cert <file> [--cn <name>] [--days <n>] [--bits <n>] [--force]
19
+ A new RSA key and a self-signed certificate, written where the configuration's privateKey and
20
+ certificate point (or to --key and --cert). Refuses to replace existing files without --force.
21
+ The key decrypts encrypted assertions and can authenticate the Shared Signals receiver (mutual
22
+ TLS); WS-Federation itself signs nothing. The common name defaults to one naming the realm.
23
+
24
+ wsfed-caep metadata --config <wsfed.json> [--out <file>]
25
+ This relying party's federation metadata, from the configuration (and its certificate, when it
26
+ has one): give it to the identity provider. Written to --out, or to standard output."""
27
+
28
+
29
+ def parse_args(argv: list[str]) -> tuple[str | None, dict]:
30
+ if not argv:
31
+ return None, {}
32
+ command, rest = argv[0], argv[1:]
33
+ args: dict = {}
34
+ i = 0
35
+ while i < len(rest):
36
+ item = rest[i]
37
+ if not item.startswith("--"):
38
+ raise SystemExit(f"unexpected argument {item}\n\n{USAGE}")
39
+ key = item[2:]
40
+ if key in ("force", "help"):
41
+ args[key] = True
42
+ else:
43
+ if i + 1 >= len(rest) or rest[i + 1].startswith("--"):
44
+ raise SystemExit(f"--{key} needs a value\n\n{USAGE}")
45
+ args[key] = rest[i + 1]
46
+ i += 1
47
+ i += 1
48
+ return command, args
49
+
50
+
51
+ def _strip_file(value: str) -> str:
52
+ return value[5:] if value.startswith("file:") else value
53
+
54
+
55
+ def key_paths(args: dict) -> tuple[str, str, str]:
56
+ config_path = args.get("config") if isinstance(args.get("config"), str) else None
57
+ key = args.get("key") if isinstance(args.get("key"), str) else None
58
+ cert = args.get("cert") if isinstance(args.get("cert"), str) else None
59
+ common_name = args.get("cn") if isinstance(args.get("cn"), str) else None
60
+ if config_path:
61
+ raw = json.loads(Path(config_path).read_text(encoding="utf-8"))
62
+ base = Path(config_path).resolve().parent
63
+
64
+ def at(value, what):
65
+ if not value or "-----BEGIN " in value:
66
+ raise SystemExit(f"{config_path}'s {what} must be a file path for generate-keys to write to")
67
+ value = _strip_file(value)
68
+ path = Path(value)
69
+ return path if path.is_absolute() else base / path
70
+
71
+ if key is None and raw.get("privateKey"):
72
+ key = str(at(raw.get("privateKey"), "privateKey"))
73
+ if cert is None and raw.get("certificate"):
74
+ cert = str(at(raw.get("certificate"), "certificate"))
75
+ if not common_name and raw.get("realm"):
76
+ common_name = f"wsfed-caep-nodejs relying party {raw['realm']}"[:64]
77
+ if not key or not cert:
78
+ raise SystemExit(f"generate-keys needs --config, or --key and --cert\n\n{USAGE}")
79
+ return str(Path(key).resolve()), str(Path(cert).resolve()), common_name or "wsfed-caep-nodejs relying party"
80
+
81
+
82
+ def main(argv: list[str] | None = None, out=None) -> None:
83
+ if argv is None:
84
+ argv = sys.argv[1:]
85
+ if out is None:
86
+ out = sys.stdout.write
87
+ command, args = parse_args(list(argv))
88
+ if not command or args.get("help") or command == "help":
89
+ out(USAGE + "\n")
90
+ return
91
+ if command == "generate-keys":
92
+ key, cert, common_name = key_paths(args)
93
+ if not args.get("force") and (os.path.exists(key) or os.path.exists(cert)):
94
+ which = key if os.path.exists(key) else cert
95
+ raise SystemExit(
96
+ f"{which} already exists; pass --force to replace the key pair.\n"
97
+ "Replacing it means giving the identity provider the new metadata before it next encrypts to this relying party."
98
+ )
99
+ days = int(args["days"]) if isinstance(args.get("days"), str) else None
100
+ bits = int(args["bits"]) if isinstance(args.get("bits"), str) else None
101
+ if days is not None and days <= 0:
102
+ raise SystemExit("--days must be a positive whole number")
103
+ if bits is not None and bits not in (2048, 3072, 4096):
104
+ raise SystemExit("--bits must be 2048, 3072 or 4096")
105
+ pair = create_self_signed_key_pair(common_name, days=days or 1095, bits=bits or 3072)
106
+ Path(key).parent.mkdir(parents=True, exist_ok=True)
107
+ Path(cert).parent.mkdir(parents=True, exist_ok=True)
108
+ fd = os.open(key, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
109
+ try:
110
+ os.write(fd, pair.private_key_pem.encode())
111
+ finally:
112
+ os.close(fd)
113
+ Path(cert).write_text(pair.certificate_pem, encoding="utf-8")
114
+ os.chmod(cert, 0o644)
115
+ out(f"Wrote the private key {key} (mode 600)\n")
116
+ out(f"Wrote the certificate {cert}\n")
117
+ out(f" subject {pair.certificate.subject.rfc4514_string()}\n")
118
+ return
119
+ if command == "metadata":
120
+ config_path = args.get("config") if isinstance(args.get("config"), str) else None
121
+ if not config_path:
122
+ raise SystemExit(f"metadata needs --config\n\n{USAGE}")
123
+ raw = json.loads(Path(config_path).read_text(encoding="utf-8"))
124
+ raw.pop("idpCa", None)
125
+ xml = RelyingParty(resolve_config(raw, str(Path(config_path).resolve().parent)), None).metadata()
126
+ target = args.get("out") if isinstance(args.get("out"), str) else None
127
+ if target:
128
+ path = Path(target).resolve()
129
+ path.parent.mkdir(parents=True, exist_ok=True)
130
+ path.write_text(xml, encoding="utf-8")
131
+ out(f"Wrote the relying party metadata to {path}\n")
132
+ else:
133
+ out(xml)
134
+ return
135
+ raise SystemExit(f"unknown command {command}\n\n{USAGE}")
136
+
137
+
138
+ if __name__ == "__main__":
139
+ try:
140
+ main()
141
+ except SystemExit as err:
142
+ if err.code not in (None, 0):
143
+ sys.stderr.write(f"{err.code if isinstance(err.code, str) else err}\n" if False else (f"{err.code}\n" if isinstance(err.code, str) else ""))
144
+ if isinstance(err.code, str):
145
+ sys.exit(1)
146
+ raise
147
+ except Exception as err:
148
+ sys.stderr.write(f"{err}\n")
149
+ sys.exit(1)
wsfed_caep/config.py ADDED
@@ -0,0 +1,153 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """Configuration. signals defaults to on. false is recorded; true and omitted are left off the result."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ from urllib.parse import urlparse
11
+
12
+ from cryptography import x509
13
+ from cryptography.hazmat.primitives import serialization
14
+ from cryptography.hazmat.primitives.asymmetric import rsa
15
+
16
+ from wsfed_caep.errors import WsFedError
17
+
18
+
19
+ class ResolvedConfig:
20
+ def __init__(self, **kwargs):
21
+ self.__dict__.update(kwargs)
22
+
23
+
24
+ def _pem(value: str) -> bool:
25
+ return "-----BEGIN " in value
26
+
27
+
28
+ def _material(value: str, base_dir: str, what: str) -> str:
29
+ if value.startswith("env:"):
30
+ name = value[4:]
31
+ if not os.environ.get(name):
32
+ raise WsFedError("config", f"{what} environment variable {name} is not set")
33
+ value = os.environ[name]
34
+ if value.startswith("file:"):
35
+ value = value[5:]
36
+ if _pem(value):
37
+ return value
38
+ path = value if os.path.isabs(value) else str(Path(base_dir) / value)
39
+ try:
40
+ return Path(path).read_text(encoding="utf-8")
41
+ except OSError as err:
42
+ raise WsFedError("config", f"{what} is neither a PEM nor a readable file ({path})", err) from err
43
+
44
+
45
+ def _absolute_url(value, what: str) -> str:
46
+ if not isinstance(value, str) or not value.strip():
47
+ raise WsFedError("config", f"{what} is required")
48
+ try:
49
+ parsed = urlparse(value)
50
+ if parsed.scheme not in ("https", "http") or not parsed.netloc:
51
+ raise ValueError("not http(s)")
52
+ except Exception:
53
+ raise WsFedError("config", f"{what} must be an absolute http(s) URL, not {value}") from None
54
+ return value
55
+
56
+
57
+ def _flag(value, default: bool, what: str) -> bool:
58
+ if value is None:
59
+ return default
60
+ if not isinstance(value, bool):
61
+ raise WsFedError("config", f"{what} must be true or false")
62
+ return value
63
+
64
+
65
+ def _number(value) -> bool:
66
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
67
+
68
+
69
+ def _idp_metadata(value: str, base_dir: str) -> str:
70
+ value = value.strip()
71
+ if value.startswith("env:"):
72
+ name = value[4:]
73
+ if not os.environ.get(name):
74
+ raise WsFedError("config", f"idpMetadata environment variable {name} is not set")
75
+ value = os.environ[name].strip()
76
+ if value.startswith("file:"):
77
+ value = value[5:]
78
+ if value.startswith("http://") or value.startswith("https://") or value.startswith("<"):
79
+ return value
80
+ return value if os.path.isabs(value) else str(Path(base_dir) / value)
81
+
82
+
83
+ def resolve_config(config: dict, base_dir: str | None = None) -> ResolvedConfig:
84
+ if not isinstance(config, dict):
85
+ raise WsFedError("config", "the configuration must be an object")
86
+ base = base_dir or os.getcwd()
87
+ realm = config.get("realm")
88
+ if not isinstance(realm, str) or not realm.strip():
89
+ raise WsFedError("config", "realm is required")
90
+ reply_url = _absolute_url(config.get("replyUrl"), "replyUrl")
91
+ raw_metadata = config.get("idpMetadata")
92
+ if not isinstance(raw_metadata, str) or not raw_metadata.strip():
93
+ raise WsFedError("config", "idpMetadata is required")
94
+ skew = 60 if config.get("clockSkewSeconds") is None else config.get("clockSkewSeconds")
95
+ if not _number(skew) or skew < 0 or skew > 600 or skew != skew:
96
+ raise WsFedError("config", "clockSkewSeconds must be a number from 0 to 600")
97
+ signals = _flag(config.get("signals") if "signals" in config else None, True, "signals")
98
+ # A missing key and an explicit JSON null are both the default. A non-boolean is an error.
99
+ if "signals" in config:
100
+ signals = _flag(config.get("signals"), True, "signals")
101
+ out = ResolvedConfig(
102
+ realm=realm,
103
+ reply_url=reply_url,
104
+ idp_metadata=_idp_metadata(raw_metadata, base),
105
+ clock_skew_ms=int(skew * 1000) if isinstance(skew, float) and not isinstance(skew, int) else int(skew) * 1000,
106
+ want_assertions_encrypted=_flag(config.get("wantAssertionsEncrypted") if "wantAssertionsEncrypted" in config else None, False, "wantAssertionsEncrypted"),
107
+ allow_unsolicited=_flag(config.get("allowUnsolicited") if "allowUnsolicited" in config else None, False, "allowUnsolicited"),
108
+ request_token_type=_flag(config.get("requestTokenType") if "requestTokenType" in config else None, True, "requestTokenType"),
109
+ )
110
+ if "wantAssertionsEncrypted" in config:
111
+ out.want_assertions_encrypted = _flag(config.get("wantAssertionsEncrypted"), False, "wantAssertionsEncrypted")
112
+ if "allowUnsolicited" in config:
113
+ out.allow_unsolicited = _flag(config.get("allowUnsolicited"), False, "allowUnsolicited")
114
+ if "requestTokenType" in config:
115
+ out.request_token_type = _flag(config.get("requestTokenType"), True, "requestTokenType")
116
+ if not signals:
117
+ out.signals = False
118
+ has_key = config.get("privateKey") is not None
119
+ has_cert = config.get("certificate") is not None
120
+ if has_key != has_cert:
121
+ raise WsFedError("config", "privateKey and certificate go together: give both or neither")
122
+ if has_key and has_cert:
123
+ try:
124
+ private_key = serialization.load_pem_private_key(_material(config["privateKey"], base, "privateKey").encode(), password=None)
125
+ certificate = x509.load_pem_x509_certificate(_material(config["certificate"], base, "certificate").encode())
126
+ except WsFedError:
127
+ raise
128
+ except Exception as err:
129
+ raise WsFedError("config", f"the private key or certificate cannot be read: {err}", err) from err
130
+ if not isinstance(private_key, rsa.RSAPrivateKey):
131
+ raise WsFedError("config", f"the private key is {type(private_key).__name__}; an RSA key is required")
132
+ if certificate.public_key().public_numbers() != private_key.public_key().public_numbers():
133
+ raise WsFedError("config", "the certificate is not for the private key")
134
+ out.private_key = private_key
135
+ out.certificate = certificate
136
+ elif out.want_assertions_encrypted:
137
+ raise WsFedError("config", "wantAssertionsEncrypted needs privateKey and certificate: the assertion is encrypted to them")
138
+ if config.get("idpCa"):
139
+ out.idp_ca = _material(config["idpCa"], base, "idpCa")
140
+ if config.get("idpEntityId"):
141
+ out.idp_entity_id = config["idpEntityId"]
142
+ return out
143
+
144
+
145
+ def read_config_file(path: str, overrides: dict | None = None) -> ResolvedConfig:
146
+ overrides = overrides or {}
147
+ try:
148
+ parsed = json.loads(Path(path).read_text(encoding="utf-8"))
149
+ except (OSError, json.JSONDecodeError) as err:
150
+ raise WsFedError("config", f"{path} cannot be read as JSON: {err}", err) from err
151
+ defined = {key: value for key, value in overrides.items() if value is not None}
152
+ merged = {**parsed, **defined}
153
+ return resolve_config(merged, str(Path(path).resolve().parent))
@@ -0,0 +1,128 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """Decrypt an EncryptedAssertion or EncryptedID. RSA-OAEP wraps the content key; AES-GCM or AES-CBC holds the XML.
4
+ RSA PKCS#1 v1.5 is refused. An OAEP digest that differs from its MGF1 hash is refused."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+
10
+ from cryptography.hazmat.primitives import hashes
11
+ from cryptography.hazmat.primitives.asymmetric import padding
12
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
13
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
14
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
15
+ from lxml import etree
16
+
17
+ from wsfed_caep.errors import WsFedError
18
+ from wsfed_caep.xmlutil import NS, attr, child, descendants, is_el, text
19
+
20
+ CIPHERS = {
21
+ "http://www.w3.org/2009/xmlenc11#aes128-gcm": {"name": "aes-128-gcm", "gcm": True, "key_length": 16},
22
+ "http://www.w3.org/2009/xmlenc11#aes192-gcm": {"name": "aes-192-gcm", "gcm": True, "key_length": 24},
23
+ "http://www.w3.org/2009/xmlenc11#aes256-gcm": {"name": "aes-256-gcm", "gcm": True, "key_length": 32},
24
+ "http://www.w3.org/2001/04/xmlenc#aes128-cbc": {"name": "aes-128-cbc", "gcm": False, "key_length": 16},
25
+ "http://www.w3.org/2001/04/xmlenc#aes192-cbc": {"name": "aes-192-cbc", "gcm": False, "key_length": 24},
26
+ "http://www.w3.org/2001/04/xmlenc#aes256-cbc": {"name": "aes-256-cbc", "gcm": False, "key_length": 32},
27
+ }
28
+ OAEP_MGF1P = "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"
29
+ OAEP = "http://www.w3.org/2009/xmlenc11#rsa-oaep"
30
+ DIGESTS = {
31
+ "http://www.w3.org/2000/09/xmldsig#sha1": hashes.SHA1,
32
+ "http://www.w3.org/2001/04/xmlenc#sha256": hashes.SHA256,
33
+ "http://www.w3.org/2001/04/xmldsig-more#sha384": hashes.SHA384,
34
+ "http://www.w3.org/2001/04/xmlenc#sha512": hashes.SHA512,
35
+ }
36
+ DIGEST_NAMES = {
37
+ "http://www.w3.org/2000/09/xmldsig#sha1": "sha1",
38
+ "http://www.w3.org/2001/04/xmlenc#sha256": "sha256",
39
+ "http://www.w3.org/2001/04/xmldsig-more#sha384": "sha384",
40
+ "http://www.w3.org/2001/04/xmlenc#sha512": "sha512",
41
+ }
42
+ MGFS = {
43
+ "http://www.w3.org/2009/xmlenc11#mgf1sha1": "sha1",
44
+ "http://www.w3.org/2009/xmlenc11#mgf1sha256": "sha256",
45
+ "http://www.w3.org/2009/xmlenc11#mgf1sha384": "sha384",
46
+ "http://www.w3.org/2009/xmlenc11#mgf1sha512": "sha512",
47
+ }
48
+
49
+
50
+ def _fail(message: str, cause: BaseException | None = None) -> None:
51
+ raise WsFedError("decryption", message, cause)
52
+
53
+
54
+ def _b64(value: str) -> bytes:
55
+ return base64.b64decode("".join(value.split()))
56
+
57
+
58
+ def _unwrap_key(encrypted_key: etree._Element, key: RSAPrivateKey) -> bytes:
59
+ method = child(encrypted_key, NS["xenc"], "EncryptionMethod")
60
+ alg = attr(method, "Algorithm") if method is not None else ""
61
+ alg = alg or ""
62
+ if alg not in (OAEP_MGF1P, OAEP):
63
+ _fail(f"the content key is wrapped with {alg or '(none)'}; only RSA-OAEP is accepted")
64
+ digest_method = child(method, NS["ds"], "DigestMethod") if method is not None else None
65
+ digest_alg = attr(digest_method, "Algorithm") if digest_method is not None else None
66
+ digest_name = DIGEST_NAMES.get(digest_alg, "sha1") if digest_alg else "sha1"
67
+ if digest_alg and digest_alg not in DIGESTS:
68
+ _fail(f"the OAEP digest {digest_alg} is not supported")
69
+ mgf = "sha1"
70
+ if alg == OAEP and method is not None:
71
+ mgf_el = child(method, NS["xenc11"], "MGF")
72
+ mgf_alg = attr(mgf_el, "Algorithm") if mgf_el is not None else None
73
+ if mgf_alg:
74
+ if mgf_alg not in MGFS:
75
+ _fail(f"the OAEP mask generation function {mgf_alg} is not supported")
76
+ mgf = MGFS[mgf_alg]
77
+ if mgf != digest_name:
78
+ _fail(f"OAEP with digest {digest_name} and MGF1 {mgf} is not supported; node:crypto uses one hash for both")
79
+ cipher_value = descendants(encrypted_key, NS["xenc"], "CipherValue")
80
+ hash_new = DIGESTS[digest_alg] if digest_alg else hashes.SHA1
81
+ try:
82
+ return key.decrypt(
83
+ _b64(text(cipher_value[0]) if cipher_value else ""),
84
+ padding.OAEP(mgf=padding.MGF1(algorithm=hash_new()), algorithm=hash_new(), label=None),
85
+ )
86
+ except WsFedError:
87
+ raise
88
+ except Exception as err:
89
+ _fail("the content key could not be unwrapped with this relying party's key", err)
90
+ raise AssertionError("unreachable")
91
+
92
+
93
+ def decrypt_element(encrypted: etree._Element, key: RSAPrivateKey) -> str:
94
+ data = encrypted if is_el(encrypted, NS["xenc"], "EncryptedData") else child(encrypted, NS["xenc"], "EncryptedData")
95
+ if data is None:
96
+ _fail(f"<{etree.QName(encrypted).localname}> has no EncryptedData")
97
+ method = child(data, NS["xenc"], "EncryptionMethod")
98
+ alg = attr(method, "Algorithm") if method is not None else None
99
+ cipher = CIPHERS.get(alg or "")
100
+ if not cipher:
101
+ _fail(f"the content is encrypted with {alg or '(none)'}, which is not supported")
102
+ keys = descendants(encrypted, NS["xenc"], "EncryptedKey")
103
+ if not keys:
104
+ _fail(f"<{etree.QName(encrypted).localname}> carries no EncryptedKey")
105
+ content_key = _unwrap_key(keys[0], key)
106
+ if len(content_key) != cipher["key_length"]:
107
+ _fail(f"the content key is {len(content_key)} bytes; {cipher['name']} needs {cipher['key_length']}")
108
+ cipher_data = child(data, NS["xenc"], "CipherData")
109
+ value_el = child(cipher_data, NS["xenc"], "CipherValue") if cipher_data is not None else None
110
+ raw = _b64(text(value_el))
111
+ try:
112
+ if cipher["gcm"]:
113
+ if len(raw) < 12 + 16:
114
+ _fail("the ciphertext is too short")
115
+ return AESGCM(content_key).decrypt(raw[:12], raw[12:], None).decode("utf-8")
116
+ if len(raw) < 32 or len(raw) % 16:
117
+ _fail("the ciphertext is not a whole number of blocks")
118
+ decryptor = Cipher(algorithms.AES(content_key), modes.CBC(raw[:16])).decryptor()
119
+ plain = decryptor.update(raw[16:]) + decryptor.finalize()
120
+ pad = plain[-1]
121
+ if pad < 1 or pad > 16:
122
+ _fail("the decrypted content is not padded as XML Encryption pads it")
123
+ return plain[:-pad].decode("utf-8")
124
+ except WsFedError:
125
+ raise
126
+ except Exception as err:
127
+ _fail("the content could not be decrypted", err)
128
+ raise AssertionError("unreachable")
wsfed_caep/errors.py ADDED
@@ -0,0 +1,13 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """Failures this library reports. Applications treat them as a failed sign-in and log the code."""
4
+
5
+
6
+ class WsFedError(Exception):
7
+ """A config, metadata, message, xml, signature, validation, replay, decryption, or ssf failure."""
8
+
9
+ def __init__(self, code: str, message: str, cause: BaseException | None = None):
10
+ super().__init__(message)
11
+ self.code = code
12
+ if cause is not None:
13
+ self.__cause__ = cause
@@ -0,0 +1,82 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """Back channel: identity-provider metadata and the Shared Signals transmitter. TLS is verified."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import http.client
8
+ import socket
9
+ import ssl
10
+ import tempfile
11
+ import urllib.parse
12
+ from pathlib import Path
13
+
14
+ from wsfed_caep.errors import WsFedError
15
+
16
+ MAX_BYTES = 2 * 1024 * 1024
17
+ TIMEOUT_S = 15
18
+
19
+
20
+ def send(url: str, init: dict | None, tls: dict | None, code: str) -> dict:
21
+ init = init or {}
22
+ tls = tls or {}
23
+ parsed = urllib.parse.urlsplit(url)
24
+ if parsed.scheme not in ("https", "http"):
25
+ raise WsFedError(code, f"{url} is not an http(s) URL")
26
+ payload = None if init.get("body") is None else str(init["body"]).encode("utf-8")
27
+ headers = {str(k): str(v) for k, v in (init.get("headers") or {}).items()}
28
+ if payload is not None:
29
+ headers.setdefault("Content-Length", str(len(payload)))
30
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
31
+ path = parsed.path or "/"
32
+ if parsed.query:
33
+ path = f"{path}?{parsed.query}"
34
+ context = None
35
+ cert_path = key_path = None
36
+ try:
37
+ if parsed.scheme == "https":
38
+ context = ssl.create_default_context()
39
+ if tls.get("ca"):
40
+ context.load_verify_locations(cadata=tls["ca"])
41
+ if tls.get("cert") and tls.get("key"):
42
+ cert_file = tempfile.NamedTemporaryFile("w", delete=False, suffix=".pem")
43
+ key_file = tempfile.NamedTemporaryFile("w", delete=False, suffix=".pem")
44
+ cert_file.write(tls["cert"])
45
+ key_file.write(tls["key"])
46
+ cert_file.close()
47
+ key_file.close()
48
+ cert_path, key_path = cert_file.name, key_file.name
49
+ context.load_cert_chain(certfile=cert_path, keyfile=key_path)
50
+ conn: http.client.HTTPConnection = http.client.HTTPSConnection(parsed.hostname, port, timeout=TIMEOUT_S, context=context)
51
+ else:
52
+ conn = http.client.HTTPConnection(parsed.hostname, port, timeout=TIMEOUT_S)
53
+ try:
54
+ conn.request(init.get("method") or "GET", path, body=payload, headers=headers)
55
+ response = conn.getresponse()
56
+ chunks: list[bytes] = []
57
+ size = 0
58
+ while True:
59
+ chunk = response.read(65536)
60
+ if not chunk:
61
+ break
62
+ size += len(chunk)
63
+ if size > MAX_BYTES:
64
+ raise WsFedError(code, f"{url} answered more than {MAX_BYTES} bytes")
65
+ chunks.append(chunk)
66
+ ctype = response.getheader("content-type") or ""
67
+ return {"status": response.status, "content_type": ctype, "body": b"".join(chunks).decode("utf-8", "replace")}
68
+ finally:
69
+ conn.close()
70
+ except WsFedError:
71
+ raise
72
+ except (socket.timeout, TimeoutError) as err:
73
+ raise WsFedError(code, f"{url} did not answer within {TIMEOUT_S} s", err) from err
74
+ except Exception as err:
75
+ reason = getattr(err, "reason", None)
76
+ if isinstance(reason, (socket.timeout, TimeoutError)):
77
+ raise WsFedError(code, f"{url} did not answer within {TIMEOUT_S} s", err) from err
78
+ raise WsFedError(code, f"{url}: {err}", err) from err
79
+ finally:
80
+ for name in (cert_path, key_path):
81
+ if name:
82
+ Path(name).unlink(missing_ok=True)