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 +58 -0
- wsfed_caep/cli.py +149 -0
- wsfed_caep/config.py +153 -0
- wsfed_caep/encryption.py +128 -0
- wsfed_caep/errors.py +13 -0
- wsfed_caep/http_client.py +82 -0
- wsfed_caep/metadata.py +153 -0
- wsfed_caep/relying_party.py +195 -0
- wsfed_caep/replay.py +30 -0
- wsfed_caep/signature.py +157 -0
- wsfed_caep/ssf/__init__.py +27 -0
- wsfed_caep/ssf/events.py +103 -0
- wsfed_caep/ssf/jws.py +116 -0
- wsfed_caep/ssf/receiver.py +310 -0
- wsfed_caep/ssf/session_index.py +78 -0
- wsfed_caep/token.py +293 -0
- wsfed_caep/x509util.py +144 -0
- wsfed_caep/xmlutil.py +172 -0
- wsfed_caep-0.1.0.dist-info/METADATA +100 -0
- wsfed_caep-0.1.0.dist-info/RECORD +24 -0
- wsfed_caep-0.1.0.dist-info/WHEEL +5 -0
- wsfed_caep-0.1.0.dist-info/entry_points.txt +2 -0
- wsfed_caep-0.1.0.dist-info/licenses/LICENSE +21 -0
- wsfed_caep-0.1.0.dist-info/top_level.txt +1 -0
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))
|
wsfed_caep/encryption.py
ADDED
|
@@ -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)
|