py-mspack-decoder 0.1.1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Simon Fonteneau
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-mspack-decoder
3
+ Version: 0.1.1
4
+ Summary: Pure-Python Microsoft Kerberos PAC decoder
5
+ Author: Simon Fonteneau
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Provides-Extra: gssapi
11
+ Requires-Dist: gssapi>=1.8; extra == "gssapi"
12
+ Dynamic: license-file
13
+
14
+ # py-mspack-decoder
15
+
16
+ Pure-Python decoder for Microsoft Kerberos PAC structures, including compressed claims (LZNT1, XPRESS and XPRESS Huffman). No third-party dependencies for PAC **decoding**.
17
+
18
+ **AI-assisted development:** Most of the implementation was written with AI coding tools under human direction. It has not undergone an independent security audit.
19
+
20
+
21
+
22
+ ## Minimal Flask + Kerberos example
23
+
24
+ See [examples/flask_gssapi.py](examples/flask_gssapi.py). It accepts an HTTP `Authorization: Negotiate` token using the service keytab, obtains a completed **Kerberos acceptor** context, and calls:
25
+
26
+ ```python
27
+ from pac_decoder.gssapi_helpers import decode_completed_context
28
+
29
+ pac_info = decode_completed_context(ctx)
30
+ print(pac_info["sid"], pac_info["groups"])
31
+ ```
32
+
33
+ The helper requires a completed Kerberos acceptor context and exactly one `urn:mspac:` attribute flagged `authenticated` and `complete` by GSSAPI. It then strictly decodes the PAC. The underlying GSSAPI/Kerberos implementation, **not this package**, verifies PAC authenticity; successful authentication alone is not enough if the authenticated PAC attribute is missing.
34
+
35
+ ### Run the example
36
+
37
+ The server must have a valid `HTTP/<hostname>@REALM` principal in its keytab, and MIT Kerberos must support the authenticated `urn:mspac:` attribute.
38
+
39
+ ```bash
40
+ export KRB5_KTNAME=/path/to/http.keytab
41
+ export KERBEROS_HOSTNAME=service.example.com
42
+ flask --app examples/flask_gssapi.py run --host 127.0.0.1
43
+ ```
44
+
45
+ From a Kerberos-authenticated client, over your HTTPS reverse proxy:
46
+
47
+ ```bash
48
+ curl --negotiate -u : https://service.example.com/pac
49
+ ```
50
+
51
+ This is intentionally a **one-round-trip Kerberos HTTP** example. It rejects negotiations needing several GSSAPI exchanges instead of attempting to resume a lost context. Use TLS, a trusted reverse proxy and a production WSGI server for a real service. The endpoint returns identity information only as a demonstration; do not expose it publicly. PAC group membership reflects ticket issuance time, not necessarily current AD state.
52
+
53
+ ## License
54
+
55
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,42 @@
1
+ # py-mspack-decoder
2
+
3
+ Pure-Python decoder for Microsoft Kerberos PAC structures, including compressed claims (LZNT1, XPRESS and XPRESS Huffman). No third-party dependencies for PAC **decoding**.
4
+
5
+ **AI-assisted development:** Most of the implementation was written with AI coding tools under human direction. It has not undergone an independent security audit.
6
+
7
+
8
+
9
+ ## Minimal Flask + Kerberos example
10
+
11
+ See [examples/flask_gssapi.py](examples/flask_gssapi.py). It accepts an HTTP `Authorization: Negotiate` token using the service keytab, obtains a completed **Kerberos acceptor** context, and calls:
12
+
13
+ ```python
14
+ from pac_decoder.gssapi_helpers import decode_completed_context
15
+
16
+ pac_info = decode_completed_context(ctx)
17
+ print(pac_info["sid"], pac_info["groups"])
18
+ ```
19
+
20
+ The helper requires a completed Kerberos acceptor context and exactly one `urn:mspac:` attribute flagged `authenticated` and `complete` by GSSAPI. It then strictly decodes the PAC. The underlying GSSAPI/Kerberos implementation, **not this package**, verifies PAC authenticity; successful authentication alone is not enough if the authenticated PAC attribute is missing.
21
+
22
+ ### Run the example
23
+
24
+ The server must have a valid `HTTP/<hostname>@REALM` principal in its keytab, and MIT Kerberos must support the authenticated `urn:mspac:` attribute.
25
+
26
+ ```bash
27
+ export KRB5_KTNAME=/path/to/http.keytab
28
+ export KERBEROS_HOSTNAME=service.example.com
29
+ flask --app examples/flask_gssapi.py run --host 127.0.0.1
30
+ ```
31
+
32
+ From a Kerberos-authenticated client, over your HTTPS reverse proxy:
33
+
34
+ ```bash
35
+ curl --negotiate -u : https://service.example.com/pac
36
+ ```
37
+
38
+ This is intentionally a **one-round-trip Kerberos HTTP** example. It rejects negotiations needing several GSSAPI exchanges instead of attempting to resume a lost context. Use TLS, a trusted reverse proxy and a production WSGI server for a real service. The endpoint returns identity information only as a demonstration; do not expose it publicly. PAC group membership reflects ticket issuance time, not necessarily current AD state.
39
+
40
+ ## License
41
+
42
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "py-mspack-decoder"
7
+ version = "0.1.1"
8
+ description = "Pure-Python Microsoft Kerberos PAC decoder"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "Simon Fonteneau" }]
13
+ dependencies = []
14
+
15
+ [project.optional-dependencies]
16
+ gssapi = ["gssapi>=1.8"]
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,220 @@
1
+ """Pure-Python, dependency-free Microsoft Privilege Attribute Certificate decoding.
2
+
3
+ Security contract:
4
+ * ``decode_pac`` is a parser and does NOT authenticate PACs or signatures.
5
+ * For authorization, use ``decode_completed_context`` after GSSAPI has verified
6
+ the PAC name attribute, or otherwise establish the trust of the raw bytes.
7
+ * The default strict mode rejects ambiguous or malformed recognized buffers,
8
+ rather than returning an incomplete authorization identity.
9
+ * ``strict=False`` is for offline diagnostics ONLY, never authorization.
10
+ """
11
+
12
+ from __future__ import annotations
13
+ from .binary import (
14
+ PACDecodeError, decode_attributes_info, decode_client_info,
15
+ decode_sid, decode_signature_type, decode_upn_dns_info, parse_pac,
16
+ )
17
+ from .claims import decode_claims_info
18
+ from .logon import decode_device_info, decode_logon_info, decode_delegation_info
19
+
20
+ BUFFER_NAMES = {
21
+ 1: "logon_info", 2: "credential_info", 6: "server_checksum",
22
+ 7: "kdc_checksum", 10: "client_info", 11: "constrained_delegation",
23
+ 12: "upn_dns_info", 13: "client_claims_info", 14: "device_info",
24
+ 15: "device_claims_info", 16: "ticket_checksum", 17: "attributes_info",
25
+ 18: "requestor_sid", 19: "full_checksum",
26
+ }
27
+ SIG_NAMES = {6: "server", 7: "kdc", 16: "ticket", 19: "full"}
28
+ # [MS-PAC] 2.4 explicitly requires recipients to ignore additional instances
29
+ # of these known buffer types. PAC_REQUESTOR_SID (18) is deliberately omitted:
30
+ # the published type entry requires one but does not specify how to handle
31
+ # additional instances. Do not silently choose between different requestors.
32
+ # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-pac/3341cfa2-6ef5-42e0-b7bc-4544884bf399
33
+ _IGNORABLE_DUPLICATE_TYPES = frozenset({
34
+ 1, 2, 6, 7, 10, 11, 12, 13, 14, 15, 16, 17, 19,
35
+ })
36
+ _EMPTY = (
37
+ "sid", "sam_name", "upn", "dns_domain", "upn_dns_flags", "upn_constructed",
38
+ "rid", "domain_sid", "domain_name", "logon_server", "full_name",
39
+ "groups", "group_memberships", "primary_group_rid", "extra_sids", "resource_groups",
40
+ "group_count", "account_flags", "user_flags", "sub_auth_status",
41
+ "last_logon", "last_successful_logon", "last_failed_logon",
42
+ "logoff_time", "kickoff_time", "password_last_set",
43
+ "password_can_change", "password_must_change", "logon_count",
44
+ "bad_password_count", "failed_logon_count", "home_directory",
45
+ "home_drive", "logon_script", "profile_path", "client_name",
46
+ "client_auth_time", "requestor_sid", "pac_requested",
47
+ "pac_given_implicitly", "pac_attributes_flags", "delegation_proxy_target",
48
+ "delegation_transited_services", "device_info", "client_claims_present",
49
+ "device_claims_present", "client_claims", "device_claims",
50
+ "credential_info_present", "pac_signature_types", "pac_version",
51
+ "pac_size", "pac_buffer_types", "pac_buffer_type_ids",
52
+ )
53
+
54
+
55
+ def empty_pac_info() -> dict:
56
+ return dict.fromkeys(_EMPTY)
57
+
58
+
59
+ def _same_name(left: str | None, right: str | None) -> bool:
60
+ """SAM account names are case-insensitive; UPNs are separate identifiers."""
61
+ return left is None or right is None or left.casefold() == right.casefold()
62
+
63
+
64
+ def _same_upn_info(left: dict, right: dict) -> bool:
65
+ """Do not silently prefer a separately supplied GSS attribute over the PAC."""
66
+ for key in ("sid", "sam_name", "upn", "dns_domain"):
67
+ a, b = left.get(key), right.get(key)
68
+ if isinstance(a, str) and isinstance(b, str):
69
+ if a.casefold() != b.casefold():
70
+ return False
71
+ elif a != b:
72
+ return False
73
+ return left["upn_dns_flags"] == right["upn_dns_flags"]
74
+
75
+
76
+ def decode_pac(raw_pac: bytes, upn_dns_attribute: bytes | None = None,
77
+ *, errors: list[int] | None = None, strict: bool = True,
78
+ require_logon: bool = False,
79
+ ignore_invalid_claims: bool = False,
80
+ include_resource_groups: bool = True) -> dict:
81
+ """Decode a raw MS-PAC as a dictionary; **does not authenticate it**.
82
+ In strict mode (default), malformed recognized buffers, conflicting
83
+ identity buffers, and missing/invalid LOGON_INFO (when
84
+ ``require_logon=True``) raise PACDecodeError. For buffer types that MS-PAC
85
+ 2.4 explicitly says may be duplicated, only the FIRST occurrence is
86
+ interpreted; later ones are ignored. Duplicate PAC_REQUESTOR_SID buffers
87
+ remain an error because MS-PAC does not specify an ignore rule for them.
88
+ Unknown buffer types are retained as metadata, not interpreted.
89
+ No partial result is returned.
90
+ ``strict=False`` is ONLY for offline diagnostics. In this mode malformed
91
+ buffers are skipped and their type IDs are recorded in ``errors``. Its
92
+ output MUST NOT be used for authorization. This is intentionally explicit
93
+ to preserve the older diagnostic API while making normal use fail closed.
94
+ ``upn_dns_attribute`` may ONLY come from an *independently authenticated*
95
+ and complete GSSAPI name attribute. The library can't establish trust
96
+ when caller-provided bytes are passed directly to this function.
97
+
98
+ ``ignore_invalid_claims=True`` is an explicit compatibility mode: only
99
+ malformed optional client/device claims (types 13 and 15) are skipped,
100
+ never identity, logon, group, or other buffers. Missing claims must not
101
+ be treated as successful claims-based authorization.
102
+ ``include_resource_groups=False`` excludes only PAC_LOGON_INFO
103
+ resource groups from top-level ``groups`` and ``resource_groups``.
104
+ It does not change raw ``group_memberships``, PAC_DEVICE_INFO, or
105
+ ``extra_sids`` (which may contain resource-domain SIDs).
106
+ """
107
+ if (type(strict) is not bool or type(require_logon) is not bool
108
+ or type(ignore_invalid_claims) is not bool
109
+ or type(include_resource_groups) is not bool):
110
+ raise TypeError("PAC decoder options must be booleans")
111
+ pac = parse_pac(raw_pac)
112
+ info = empty_pac_info()
113
+ info["pac_size"] = pac.size
114
+ info["pac_version"] = pac.version
115
+ info["pac_buffer_type_ids"] = [buf.type_id for buf in pac.buffers]
116
+ info["pac_buffer_types"] = [
117
+ BUFFER_NAMES.get(buf.type_id, f"unknown_{buf.type_id}")
118
+ for buf in pac.buffers
119
+ ]
120
+ present = set(info["pac_buffer_type_ids"])
121
+ info["client_claims_present"] = 13 in present
122
+ info["device_claims_present"] = 15 in present
123
+ info["credential_info_present"] = 2 in present
124
+ info["pac_signature_types"] = {}
125
+
126
+ seen = set()
127
+ buffers = {}
128
+ for buf in pac.buffers:
129
+ if buf.type_id in seen:
130
+ # The first occurrence always wins; an invalid FIRST occurrence
131
+ # must still fail strict parsing (never fall back to a later one).
132
+ # parse_pac() already validated the directory spans of ALL buffers.
133
+ if (strict and buf.type_id in BUFFER_NAMES
134
+ and buf.type_id not in _IGNORABLE_DUPLICATE_TYPES):
135
+ raise PACDecodeError(
136
+ f"duplicate PAC buffer without defined ignore semantics: "
137
+ f"{BUFFER_NAMES[buf.type_id]} ({buf.type_id})"
138
+ )
139
+ continue
140
+ seen.add(buf.type_id)
141
+ buffers[buf.type_id] = buf.data
142
+
143
+ # Decode BOTH versions of UPN_DNS_INFO, if present. Do not allow a
144
+ # separately authenticated attribute to overwrite identity silently.
145
+ inline = buffers.get(12)
146
+ try:
147
+ if inline is not None:
148
+ info.update(decode_upn_dns_info(inline))
149
+ if upn_dns_attribute is not None:
150
+ separate = decode_upn_dns_info(upn_dns_attribute)
151
+ if inline is not None and not _same_upn_info(info, separate):
152
+ raise PACDecodeError("conflicting PAC and GSSAPI UPN_DNS_INFO")
153
+ if inline is None:
154
+ info.update(separate)
155
+ except PACDecodeError as exc:
156
+ if strict:
157
+ raise PACDecodeError(f"invalid PAC UPN_DNS_INFO: {exc}") from exc
158
+ if errors is not None:
159
+ errors.append(12)
160
+ # Do not expose even the original UPN fields on an inconsistent parse.
161
+ for key in ("sid", "sam_name", "upn", "dns_domain", "upn_dns_flags", "upn_constructed"):
162
+ info[key] = None
163
+
164
+ for t, data in buffers.items():
165
+ if t == 12:
166
+ continue
167
+ try:
168
+ if t == 1:
169
+ logon = decode_logon_info(data, include_resource_groups=include_resource_groups)
170
+ if not logon.get("sid") or not isinstance(logon.get("groups"), list):
171
+ raise PACDecodeError("LOGON_INFO missing account SID or group list")
172
+ if info["sid"] is not None and info["sid"] != logon["sid"]:
173
+ raise PACDecodeError("conflicting account SIDs in PAC identity buffers")
174
+ if not _same_name(info["sam_name"], logon["sam_name"]):
175
+ raise PACDecodeError("conflicting SAM account names in PAC identity buffers")
176
+ upn_sam = info["sam_name"]
177
+ info.update(logon)
178
+ if upn_sam is not None:
179
+ info["sam_name"] = upn_sam
180
+ elif t == 10:
181
+ info.update(decode_client_info(data))
182
+ elif t == 11:
183
+ info.update(decode_delegation_info(data))
184
+ elif t == 14:
185
+ info["device_info"] = decode_device_info(data)
186
+ elif t == 17:
187
+ info.update(decode_attributes_info(data))
188
+ elif t == 18:
189
+ info["requestor_sid"] = decode_sid(data)
190
+ elif t in SIG_NAMES:
191
+ info["pac_signature_types"][SIG_NAMES[t]] = decode_signature_type(data)
192
+ elif t in (13, 15):
193
+ key = "client_claims" if t == 13 else "device_claims"
194
+ # Samba and Windows may include a zero-length claims buffer
195
+ # when there are no claims. There is no NDR envelope to parse.
196
+ # Keep *_claims_present=True and decoded claims=None.
197
+ if data:
198
+ info[key] = decode_claims_info(data)
199
+ except PACDecodeError as exc:
200
+ if strict and not (ignore_invalid_claims and t in (13, 15)):
201
+ raise PACDecodeError(
202
+ f"invalid PAC {BUFFER_NAMES.get(t, f'buffer {t}')} ({t}): {exc}"
203
+ ) from exc
204
+ if errors is not None:
205
+ errors.append(t)
206
+ # In diagnostic mode, never leak authorization identity from an
207
+ # invalid LOGON_INFO (even if UPN_DNS_INFO was decoded earlier).
208
+ if t == 1:
209
+ for field in ("groups", "group_memberships", "extra_sids", "resource_groups", "group_count"):
210
+ info[field] = None
211
+ if require_logon and (1 not in buffers or info["sid"] is None or info["groups"] is None):
212
+ raise PACDecodeError("valid PAC LOGON_INFO required")
213
+ return info
214
+
215
+
216
+ __all__ = [
217
+ "PACDecodeError", "parse_pac", "decode_sid", "decode_upn_dns_info",
218
+ "decode_logon_info", "decode_device_info", "decode_delegation_info",
219
+ "decode_pac", "empty_pac_info", "decode_claims_info",
220
+ ]
@@ -0,0 +1,170 @@
1
+ """Dependency-free binary primitives for Microsoft MS-PAC.
2
+
3
+ No Kerberos validation is performed here. Only pass PAC data obtained from an
4
+ *authenticated and complete* GSSAPI name attribute.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timedelta, timezone
11
+ import struct
12
+
13
+ MAX_PAC_BYTES = 4 * 1024 * 1024
14
+ MAX_PAC_ENTRIES = 16_384
15
+ MAX_PAC_GROUPS = 65_535 # Samba-style per-array group/SID bound.
16
+ MAX_GROUP_MEMBERSHIPS = 200_000 # Maximum number of distinct authorization group SIDs.
17
+ MAX_SID_SUBAUTHS = 15
18
+
19
+
20
+ class PACDecodeError(ValueError):
21
+ """Malformed, truncated or unsupported PAC encoding."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class PACBuffer:
26
+ type_id: int
27
+ data: bytes
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class PAC:
32
+ version: int
33
+ size: int
34
+ buffers: tuple[PACBuffer, ...]
35
+
36
+
37
+ def parse_pac(raw: bytes) -> PAC:
38
+ """Validate and extract an MS-PAC PACTYPE (not an ASN.1 wrapper)."""
39
+ if not isinstance(raw, bytes) or not 8 <= len(raw) <= MAX_PAC_BYTES:
40
+ raise PACDecodeError("invalid PAC length or input type")
41
+ n, version = struct.unpack_from("<II", raw)
42
+ if n > MAX_PAC_ENTRIES or n > (len(raw) - 8) // 16:
43
+ raise PACDecodeError("invalid PAC directory size")
44
+ if version != 0:
45
+ raise PACDecodeError("unsupported PAC version")
46
+ directory_end = 8 + 16 * n
47
+ entries = []
48
+ ranges = []
49
+ for i in range(n):
50
+ type_id, size, offset = struct.unpack_from("<IIQ", raw, 8 + 16 * i)
51
+ if offset % 8 or offset < directory_end or offset > len(raw):
52
+ raise PACDecodeError("invalid PAC buffer offset")
53
+ if size > len(raw) - offset:
54
+ raise PACDecodeError("PAC buffer exceeds packet")
55
+ if size:
56
+ ranges.append((offset, offset + size))
57
+ entries.append((type_id, offset, size))
58
+ ranges.sort()
59
+ for previous, current in zip(ranges, ranges[1:]):
60
+ if previous[1] > current[0]:
61
+ raise PACDecodeError("overlapping PAC buffers")
62
+ buffers = tuple(PACBuffer(t, raw[o:o + n]) for t, o, n in entries)
63
+ return PAC(version=version, size=len(raw), buffers=buffers)
64
+
65
+
66
+ def decode_sid(raw: bytes) -> str:
67
+ """Decode an exact self-relative binary SID, rejecting malformed SIDs."""
68
+ if len(raw) < 12:
69
+ raise PACDecodeError("SID too short")
70
+ revision, count = raw[0], raw[1]
71
+ if revision != 1 or not 1 <= count <= MAX_SID_SUBAUTHS:
72
+ raise PACDecodeError("unsupported SID revision or subauthority count")
73
+ if len(raw) != 8 + count * 4:
74
+ raise PACDecodeError("SID size mismatch")
75
+ authority = int.from_bytes(raw[2:8], "big")
76
+ subs = struct.unpack_from("<" + "I" * count, raw, 8)
77
+ return f"S-{revision}-{authority}" + "".join(f"-{n}" for n in subs)
78
+
79
+
80
+ def filetime_to_iso(value: int) -> str | None:
81
+ """Convert Windows FILETIME, including sentinel values used by AD."""
82
+ if not 0 < value < 0x7FFFFFFFFFFFFFFF:
83
+ return None
84
+ try:
85
+ instant = datetime(1601, 1, 1, tzinfo=timezone.utc) + timedelta(
86
+ microseconds=value // 10
87
+ )
88
+ except (ValueError, OverflowError):
89
+ return None
90
+ return instant.isoformat(timespec="seconds").replace("+00:00", "Z")
91
+
92
+
93
+ def decode_upn_dns_info(data: bytes) -> dict:
94
+ """UPN_DNS_INFO, including optional SAM and SID (PAC buffer type 12)."""
95
+ if not isinstance(data, bytes) or len(data) < 12:
96
+ raise PACDecodeError("truncated or invalid UPN_DNS_INFO")
97
+ upn_length, upn_offset, dns_length, dns_offset, flags = struct.unpack_from(
98
+ "<HHHHI", data
99
+ )
100
+ extended = bool(flags & 0x2)
101
+ header_size = 20 if extended else 12
102
+ if len(data) < header_size:
103
+ raise PACDecodeError("truncated extended UPN_DNS_INFO")
104
+
105
+ def read_text(length: int, offset: int) -> str | None:
106
+ if not length:
107
+ return None
108
+ if length & 1 or offset < header_size or length > len(data) - offset:
109
+ raise PACDecodeError("invalid UPN_DNS_INFO UTF-16 span")
110
+ try:
111
+ return data[offset:offset + length].decode("utf-16-le") or None
112
+ except UnicodeDecodeError as exc:
113
+ raise PACDecodeError("invalid UPN_DNS_INFO UTF-16") from exc
114
+
115
+ result = {
116
+ "upn": read_text(upn_length, upn_offset),
117
+ "dns_domain": read_text(dns_length, dns_offset),
118
+ "upn_dns_flags": flags,
119
+ "upn_constructed": bool(flags & 1),
120
+ "sam_name": None,
121
+ "sid": None,
122
+ }
123
+ if extended:
124
+ sam_length, sam_offset, sid_length, sid_offset = struct.unpack_from(
125
+ "<HHHH", data, 12
126
+ )
127
+ result["sam_name"] = read_text(sam_length, sam_offset)
128
+ if sid_length:
129
+ if sid_offset < header_size or sid_length > len(data) - sid_offset:
130
+ raise PACDecodeError("invalid UPN_DNS_INFO SID span")
131
+ result["sid"] = decode_sid(data[sid_offset:sid_offset + sid_length])
132
+ return result
133
+
134
+
135
+ def decode_client_info(data: bytes) -> dict:
136
+ if len(data) < 10:
137
+ raise PACDecodeError("truncated PAC_CLIENT_INFO")
138
+ filetime, length = struct.unpack_from("<QH", data)
139
+ if length & 1 or length > len(data) - 10:
140
+ raise PACDecodeError("invalid client name length")
141
+ try:
142
+ name = data[10:10 + length].decode("utf-16-le").rstrip("\x00")
143
+ except UnicodeDecodeError as exc:
144
+ raise PACDecodeError("invalid client name UTF-16") from exc
145
+ return {"client_name": name or None, "client_auth_time": filetime_to_iso(filetime)}
146
+
147
+
148
+ def decode_attributes_info(data: bytes) -> dict:
149
+ if len(data) < 8:
150
+ raise PACDecodeError("truncated PAC_ATTRIBUTES_INFO")
151
+ nbits = struct.unpack_from("<I", data)[0]
152
+ if nbits > 256:
153
+ raise PACDecodeError("unsupported PAC flag length")
154
+ needed = ((nbits + 31) // 32) * 4
155
+ if needed > len(data) - 4:
156
+ raise PACDecodeError("truncated PAC_ATTRIBUTES_INFO flags")
157
+ flags = int.from_bytes(data[4:4 + needed], "little")
158
+ if nbits:
159
+ flags &= (1 << nbits) - 1
160
+ return {
161
+ "pac_attributes_flags": flags,
162
+ "pac_requested": bool(flags & 1),
163
+ "pac_given_implicitly": bool(flags & 2),
164
+ }
165
+
166
+
167
+ def decode_signature_type(data: bytes) -> int:
168
+ if len(data) < 4:
169
+ raise PACDecodeError("truncated PAC_SIGNATURE_DATA")
170
+ return struct.unpack_from("<i", data)[0]
@@ -0,0 +1,130 @@
1
+ """MS-ADTS CLAIMS_SET_METADATA and CLAIMS_SET, bounded NDR32 decoding.
2
+
3
+ No claims are returned unless the complete structure, including deferred
4
+ pointers and every value, was decoded successfully. No cryptography here.
5
+ """
6
+ import struct
7
+ from .binary import PACDecodeError, MAX_PAC_ENTRIES, MAX_PAC_BYTES
8
+ from .ndr import ndr_serialized_pointer
9
+ from .compression import decompress_claims, MAX_CLAIMS_BYTES
10
+
11
+ _TYPE_NAMES = {1: 'int64', 2: 'uint64', 3: 'string', 6: 'boolean'}
12
+ _SOURCE_NAMES = {1: 'ad', 2: 'certificate'}
13
+
14
+
15
+ def _array(r, pointer: int, count: int, *, bound=MAX_PAC_ENTRIES) -> bool:
16
+ if not 0 <= count <= bound:
17
+ raise PACDecodeError('excessive claims array count')
18
+ if not pointer:
19
+ if count:
20
+ raise PACDecodeError('nonempty null claims array')
21
+ return False
22
+ if r.u32() != count:
23
+ raise PACDecodeError('claims conformance count mismatch')
24
+ return True
25
+
26
+
27
+ def _blob(r, pointer: int, count: int, *, bound=MAX_CLAIMS_BYTES) -> bytes:
28
+ return r.bytes(count) if _array(r, pointer, count, bound=bound) else b''
29
+
30
+
31
+ def _string(r) -> str:
32
+ maximum, offset, actual = r.u32(), r.u32(), r.u32()
33
+ if (not actual or offset != 0 or actual > maximum
34
+ or maximum > MAX_CLAIMS_BYTES // 2):
35
+ raise PACDecodeError('invalid claims string bounds')
36
+ raw = r.bytes(actual * 2, 2)
37
+ if raw[-2:] != b'\0\0':
38
+ raise PACDecodeError('unterminated claims string')
39
+ try:
40
+ result = raw[:-2].decode('utf-16-le')
41
+ except UnicodeDecodeError as exc:
42
+ raise PACDecodeError('invalid UTF-16 claim') from exc
43
+ if '\0' in result:
44
+ raise PACDecodeError('embedded NUL in claim string')
45
+ return result
46
+
47
+
48
+ def _finish(r) -> None:
49
+ if len(r.data) - r.offset > 7:
50
+ raise PACDecodeError('unconsumed claims payload')
51
+
52
+
53
+ def decode_claims_set(data: bytes) -> list[dict]:
54
+ """Return source-tagged claim arrays from a serialized CLAIMS_SET."""
55
+ if not isinstance(data, bytes) or len(data) > MAX_CLAIMS_BYTES:
56
+ raise PACDecodeError('invalid claims input')
57
+ r = ndr_serialized_pointer(data)
58
+ count, arrays_ptr = r.u32(), r.u32()
59
+ r.u16()
60
+ reserved_size, reserved_ptr = r.u32(), r.u32()
61
+ arrays = []
62
+ total_entries = total_values = 0
63
+ if _array(r, arrays_ptr, count):
64
+ headers = [(r.u16(), r.u32(), r.u32()) for _ in range(count)]
65
+ for source, claim_count, entries_ptr in headers:
66
+ total_entries += claim_count
67
+ if total_entries > MAX_PAC_ENTRIES:
68
+ raise PACDecodeError('excessive total claims count')
69
+ entries = []
70
+ if _array(r, entries_ptr, claim_count):
71
+ fixed = []
72
+ for _ in range(claim_count):
73
+ id_ptr, typ, discriminant = r.u32(), r.u16(), r.u16()
74
+ if typ != discriminant or typ not in _TYPE_NAMES:
75
+ raise PACDecodeError('unsupported claims union')
76
+ n, value_ptr = r.u32(), r.u32()
77
+ total_values += n
78
+ if not id_ptr or not n or total_values > MAX_PAC_ENTRIES:
79
+ raise PACDecodeError('invalid claim identifier/value count')
80
+ fixed.append((typ, n, value_ptr))
81
+ for typ, n, value_ptr in fixed:
82
+ identifier = _string(r)
83
+ if not identifier:
84
+ raise PACDecodeError('empty claim identifier')
85
+ if not _array(r, value_ptr, n):
86
+ raise PACDecodeError('null claim value array')
87
+ if typ == 3:
88
+ refs = [r.u32() for _ in range(n)]
89
+ if not all(refs):
90
+ raise PACDecodeError('null claim string pointer')
91
+ values = [_string(r) for _ in refs]
92
+ else:
93
+ raw = r.bytes(n * 8, 8)
94
+ values = list(struct.unpack('<' + ('q' if typ == 1 else 'Q') * n, raw))
95
+ if typ == 6:
96
+ if any(value not in (0, 1) for value in values):
97
+ raise PACDecodeError('invalid boolean claim value')
98
+ values = [bool(value) for value in values]
99
+ entries.append({'id': identifier, 'type': _TYPE_NAMES[typ],
100
+ 'type_id': typ, 'values': values})
101
+ arrays.append({'source': _SOURCE_NAMES.get(source, 'unknown'),
102
+ 'source_id': source, 'claims': entries})
103
+ _blob(r, reserved_ptr, reserved_size)
104
+ _finish(r)
105
+ return arrays
106
+
107
+
108
+ def decode_claims_info(data: bytes) -> dict:
109
+ """Decode PAC buffers 13/15 (serialized CLAIMS_SET_METADATA).
110
+
111
+ Shape: {compression_format, uncompressed_size, arrays: [{source,
112
+ source_id, claims: [{id, type, type_id, values}]}]}. Unknown source IDs are
113
+ preserved numerically, never mislabeled as AD or certificate claims.
114
+ """
115
+ if not isinstance(data, bytes) or len(data) > MAX_PAC_BYTES:
116
+ raise PACDecodeError('invalid claims metadata input')
117
+ r = ndr_serialized_pointer(data)
118
+ size, claims_ptr, compression = r.u32(), r.u32(), r.u16()
119
+ uncompressed_size = r.u32()
120
+ r.u16()
121
+ reserved_size, reserved_ptr = r.u32(), r.u32()
122
+ if not claims_ptr or size == 0:
123
+ raise PACDecodeError('missing encoded CLAIMS_SET')
124
+ compressed = _blob(r, claims_ptr, size, bound=MAX_PAC_BYTES)
125
+ _blob(r, reserved_ptr, reserved_size)
126
+ _finish(r)
127
+ plain = decompress_claims(compressed, compression, uncompressed_size)
128
+ arrays = decode_claims_set(plain)
129
+ return {'compression_format': compression,
130
+ 'uncompressed_size': uncompressed_size, 'arrays': arrays}