py-mspack-decoder 0.1.1__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.
@@ -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
+ ]
pac_decoder/binary.py ADDED
@@ -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]
pac_decoder/claims.py ADDED
@@ -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}
@@ -0,0 +1,229 @@
1
+ """Bounded MS-XCA decompression using only the Python standard library.
2
+
3
+ Algorithms: NONE (0), LZNT1 (2), XPRESS/LZ77 (3), XPRESS_HUFF (4).
4
+ The caller must provide the expected output size from CLAIMS_SET_METADATA.
5
+ See SOURCES.md in the repository root for protocol references.
6
+ """
7
+ import struct
8
+ from .binary import PACDecodeError, MAX_PAC_BYTES
9
+
10
+ MAX_CLAIMS_BYTES = 4 * 1024 * 1024
11
+
12
+
13
+ class _Input:
14
+ def __init__(self, data: bytes):
15
+ self.data = data
16
+ self.pos = 0
17
+
18
+ def read(self, size: int) -> bytes:
19
+ if size < 0 or size > len(self.data) - self.pos:
20
+ raise PACDecodeError("truncated compressed claims")
21
+ result = self.data[self.pos:self.pos + size]
22
+ self.pos += size
23
+ return result
24
+
25
+ def u8(self) -> int:
26
+ return self.read(1)[0]
27
+
28
+ def u16(self) -> int:
29
+ return struct.unpack('<H', self.read(2))[0]
30
+
31
+ def u32(self) -> int:
32
+ return struct.unpack('<I', self.read(4))[0]
33
+
34
+
35
+ def _match(out: bytearray, distance: int, length: int, limit: int,
36
+ window_start: int = 0) -> None:
37
+ if distance < 1 or distance > len(out) - window_start:
38
+ raise PACDecodeError("invalid compression back-reference")
39
+ if length < 3 or length > limit - len(out):
40
+ raise PACDecodeError("decompressed claims exceed expected size")
41
+ period = bytes(out[len(out) - distance:])
42
+ if length <= distance:
43
+ out.extend(period[:length])
44
+ else:
45
+ repetitions, remainder = divmod(length, distance)
46
+ out.extend(period * repetitions)
47
+ out.extend(period[:remainder])
48
+
49
+
50
+ def _lznt1(data: bytes, expected: int) -> bytes:
51
+ src, out = _Input(data), bytearray()
52
+ while src.pos < len(data):
53
+ header = src.u16()
54
+ if header == 0:
55
+ if any(data[src.pos:]):
56
+ raise PACDecodeError("nonzero data after LZNT1 terminator")
57
+ break
58
+ if header & 0x7000 != 0x3000:
59
+ raise PACDecodeError("invalid LZNT1 chunk signature")
60
+ chunk = _Input(src.read((header & 0x0FFF) + 1))
61
+ start = len(out)
62
+ limit = min(expected, start + 4096)
63
+ if not header & 0x8000:
64
+ if len(chunk.data) > limit - len(out):
65
+ raise PACDecodeError("oversized uncompressed LZNT1 chunk")
66
+ out.extend(chunk.data)
67
+ continue
68
+ while chunk.pos < len(chunk.data):
69
+ flags = chunk.u8()
70
+ for bit in range(8):
71
+ if chunk.pos == len(chunk.data):
72
+ break
73
+ if flags & (1 << bit):
74
+ token = chunk.u16()
75
+ shift, mask = 12, 0x0FFF
76
+ position = len(out) - start - 1
77
+ while position >= 16:
78
+ mask >>= 1
79
+ shift -= 1
80
+ position >>= 1
81
+ _match(out, (token >> shift) + 1, (token & mask) + 3,
82
+ limit, start)
83
+ else:
84
+ if len(out) >= limit:
85
+ raise PACDecodeError("oversized LZNT1 chunk")
86
+ out.append(chunk.u8())
87
+ return bytes(out)
88
+
89
+
90
+ def _xpress(data: bytes, expected: int) -> bytes:
91
+ src, out = _Input(data), bytearray()
92
+ flags = bits = 0
93
+ pending_nibble = None
94
+ while len(out) < expected:
95
+ if bits == 0:
96
+ flags, bits = src.u32(), 32
97
+ bits -= 1
98
+ if not flags & (1 << bits):
99
+ out.append(src.u8())
100
+ continue
101
+ token = src.u16()
102
+ distance, length = (token >> 3) + 1, token & 7
103
+ if length == 7:
104
+ if pending_nibble is None:
105
+ packed = src.u8()
106
+ length, pending_nibble = packed & 15, packed >> 4
107
+ else:
108
+ length, pending_nibble = pending_nibble, None
109
+ if length == 15:
110
+ length = src.u8()
111
+ if length == 255:
112
+ length = src.u16()
113
+ if length == 0:
114
+ length = src.u32()
115
+ if length < 22:
116
+ raise PACDecodeError("invalid extended XPRESS length")
117
+ length -= 22
118
+ length += 15
119
+ length += 7
120
+ _match(out, distance, length + 3, expected)
121
+ if src.pos != len(data):
122
+ if not (bits == 0 and data[src.pos:] == b'\xff' * 4):
123
+ raise PACDecodeError("trailing XPRESS input")
124
+ return bytes(out)
125
+
126
+
127
+ class _Bits:
128
+ """MS-XCA's two-word look-ahead, sharing input with long match lengths."""
129
+
130
+ def __init__(self, src: _Input):
131
+ self.src = src
132
+ self.register = (src.u16() << 16) | src.u16()
133
+ self.extra = 16
134
+
135
+ def take(self, count: int) -> int:
136
+ if not 0 <= count <= 15:
137
+ raise PACDecodeError("invalid Huffman bit count")
138
+ value = self.register >> (32 - count) if count else 0
139
+ self.register = (self.register << count) & 0xFFFFFFFF
140
+ self.extra -= count
141
+ if self.extra < 0:
142
+ self.register |= self.src.u16() << -self.extra
143
+ self.extra += 16
144
+ return value
145
+
146
+
147
+ def _huffman_table(raw: bytes):
148
+ lengths = [n for byte in raw for n in (byte & 15, byte >> 4)]
149
+ table = []
150
+ for length in range(1, 16):
151
+ for symbol, nbits in enumerate(lengths):
152
+ if nbits != length:
153
+ continue
154
+ count = 1 << (15 - length)
155
+ if count > 32768 - len(table):
156
+ raise PACDecodeError("oversubscribed Huffman tree")
157
+ table.extend([symbol] * count)
158
+ if len(table) != 32768:
159
+ raise PACDecodeError("incomplete Huffman tree")
160
+ return lengths, table
161
+
162
+
163
+ def _xpress_huff(data: bytes, expected: int) -> bytes:
164
+ src, out = _Input(data), bytearray()
165
+ while len(out) < expected:
166
+ lengths, table = _huffman_table(src.read(256))
167
+ bits = _Bits(src)
168
+ block_end = min(expected, len(out) + 65536)
169
+ while len(out) < block_end:
170
+ symbol = table[bits.register >> 17]
171
+ bits.take(lengths[symbol])
172
+ if symbol < 256:
173
+ out.append(symbol)
174
+ continue
175
+ code = symbol - 256
176
+ distance_bits, length = code >> 4, code & 15
177
+ if length == 15:
178
+ length = src.u8()
179
+ if length == 255:
180
+ length = src.u16()
181
+ if length == 0:
182
+ length = src.u32()
183
+ if length < 15:
184
+ raise PACDecodeError("invalid extended Huffman length")
185
+ length -= 15
186
+ length += 15
187
+ distance = (1 << distance_bits) + bits.take(distance_bits)
188
+ _match(out, distance, length + 3, expected)
189
+ if src.pos != len(data):
190
+ symbol = table[bits.register >> 17]
191
+ if symbol != 256:
192
+ raise PACDecodeError("missing Huffman end marker")
193
+ bits.take(lengths[symbol])
194
+ if src.pos != len(data):
195
+ raise PACDecodeError("trailing Huffman input")
196
+ return bytes(out)
197
+
198
+
199
+ def decompress_claims(data: bytes, format_id: int, expected_size: int) -> bytes:
200
+ """Return exactly expected_size bytes or raise PACDecodeError.
201
+
202
+ This is a decoder, not an authentication function; never use its result
203
+ to trust an unsigned PAC.
204
+ """
205
+ if (not isinstance(data, bytes) or len(data) > MAX_PAC_BYTES
206
+ or type(expected_size) is not int
207
+ or not 0 <= expected_size <= MAX_CLAIMS_BYTES):
208
+ raise PACDecodeError("invalid claims decompression size")
209
+ if expected_size == 0:
210
+ # Avoid interpreting a nonempty compressed stream when no output is
211
+ # expected (the XPRESS-Huffman decoder would have no symbol table).
212
+ if format_id not in (0, 2, 3, 4):
213
+ raise PACDecodeError("unsupported claims compression format")
214
+ if data:
215
+ raise PACDecodeError("nonempty compressed input for zero-size claims")
216
+ return b''
217
+ if format_id == 0:
218
+ result = data
219
+ elif format_id == 2:
220
+ result = _lznt1(data, expected_size)
221
+ elif format_id == 3:
222
+ result = _xpress(data, expected_size)
223
+ elif format_id == 4:
224
+ result = _xpress_huff(data, expected_size)
225
+ else:
226
+ raise PACDecodeError("unsupported claims compression format")
227
+ if len(result) != expected_size:
228
+ raise PACDecodeError("claims decompression size mismatch")
229
+ return result
@@ -0,0 +1,97 @@
1
+ """Read an authenticated MS-PAC from a completed Kerberos GSSAPI acceptor.
2
+
3
+ This module does not import python-gssapi at import time. GSSAPI performs the
4
+ PAC verification; this parser must never be given unchecked attributes.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from . import PACDecodeError, decode_pac
9
+ from .binary import MAX_PAC_BYTES
10
+
11
+ _KERBEROS_MECH = "1.2.840.113554.1.2.2"
12
+
13
+
14
+ class PACAttributeError(PACDecodeError):
15
+ """An acceptor context has no usable, authenticated PAC name attribute."""
16
+
17
+
18
+ def _attribute_value(name: object, key: str, *, required: bool) -> bytes | None:
19
+ """Extract a *single* authenticated, complete RFC 6680 name attribute."""
20
+ try:
21
+ attributes = name.attributes
22
+ if attributes is None:
23
+ raise PACAttributeError("GSSAPI name attributes are unavailable")
24
+ # The public Name.attributes mapping may raise a native GSSAPI error
25
+ # rather than KeyError when fetching a missing attribute. Enumerate
26
+ # first so the absence of an optional attribute is unambiguous.
27
+ keys = list(attributes.keys())
28
+ if key in keys:
29
+ lookup_key = key
30
+ elif key.encode("ascii") in keys:
31
+ lookup_key = key.encode("ascii")
32
+ else:
33
+ if required:
34
+ raise PACAttributeError(f"missing GSSAPI name attribute: {key}")
35
+ return None
36
+ attribute = attributes[lookup_key]
37
+ except PACAttributeError:
38
+ raise
39
+ except Exception as exc:
40
+ raise PACAttributeError(f"unable to obtain GSSAPI name attribute: {key}") from exc
41
+ if not getattr(attribute, "authenticated", False):
42
+ raise PACAttributeError(f"unauthenticated GSSAPI name attribute: {key}")
43
+ if not getattr(attribute, "complete", False):
44
+ raise PACAttributeError(f"incomplete GSSAPI name attribute: {key}")
45
+ try:
46
+ values = list(attribute.values)
47
+ except (AttributeError, TypeError) as exc:
48
+ raise PACAttributeError(f"invalid GSSAPI name attribute values: {key}") from exc
49
+ if len(values) != 1 or type(values[0]) is not bytes:
50
+ raise PACAttributeError(f"expected exactly one binary GSSAPI attribute: {key}")
51
+ if not 0 < len(values[0]) <= MAX_PAC_BYTES:
52
+ raise PACAttributeError(f"invalid GSSAPI name attribute size: {key}")
53
+ return values[0]
54
+
55
+ def decode_completed_context(context: object, *, errors: list[int] | None = None,
56
+ require_logon: bool = True,
57
+ ignore_invalid_claims: bool = False,
58
+ include_resource_groups: bool = True) -> dict:
59
+ """Decode the PAC of a completed **Kerberos acceptor** context.
60
+ Rejects incomplete contexts, non-Kerberos mechanisms, unauthenticated or
61
+ incomplete PAC attributes and malformed or ambiguous PAC structures.
62
+ ``require_logon=True`` ensures the identity and group set are complete.
63
+ No PAC cryptography is implemented in this package; the verification is
64
+ delegated to the underlying GSSAPI / Kerberos implementation.
65
+
66
+ ``ignore_invalid_claims`` can preserve valid identity and groups when an
67
+ optional client/device claims buffer is malformed. Never authorize based
68
+ on claims whose decoded value is None.
69
+ ``include_resource_groups=False`` excludes only PAC_LOGON_INFO
70
+ resource groups from top-level authorization lists. Device groups
71
+ and ExtraSids are unaffected.
72
+ """
73
+ if not getattr(context, "complete", False):
74
+ raise PACAttributeError("GSSAPI security context is not complete")
75
+ if getattr(context, "locally_initiated", None) is not False:
76
+ raise PACAttributeError("expected a GSSAPI acceptor, not initiator")
77
+ try:
78
+ mechanism = context.mech
79
+ except Exception as exc:
80
+ raise PACAttributeError("unable to determine negotiated GSSAPI mechanism") from exc
81
+ if getattr(mechanism, "dotted_form", None) != _KERBEROS_MECH:
82
+ raise PACAttributeError("GSSAPI mechanism is not Kerberos")
83
+ try:
84
+ name = context.initiator_name
85
+ except Exception as exc:
86
+ raise PACAttributeError("GSSAPI initiator name is unavailable") from exc
87
+ if name is None:
88
+ raise PACAttributeError("GSSAPI initiator name is unavailable")
89
+ pac = _attribute_value(name, "urn:mspac:", required=True)
90
+ upn = _attribute_value(name, "urn:mspac:upn-dns-info", required=False)
91
+ return decode_pac(pac, upn_dns_attribute=upn,
92
+ errors=errors, strict=True, require_logon=require_logon,
93
+ ignore_invalid_claims=ignore_invalid_claims,
94
+ include_resource_groups=include_resource_groups)
95
+
96
+
97
+ __all__ = ["PACAttributeError", "decode_completed_context"]
pac_decoder/logon.py ADDED
@@ -0,0 +1,247 @@
1
+ """Specialized NDR32 decoders for PAC_LOGON_INFO and PAC_DEVICE_INFO."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .binary import PACDecodeError, filetime_to_iso, MAX_PAC_ENTRIES, MAX_GROUP_MEMBERSHIPS, MAX_SID_SUBAUTHS
6
+ from .ndr import ndr_serialized_pointer
7
+
8
+ _ENABLED = 0x00000004
9
+ _DENY_ONLY = 0x00000010
10
+ _NETLOGON_EXTRA_SIDS = 0x00000020
11
+ _NETLOGON_RESOURCE_GROUPS = 0x00000200
12
+
13
+
14
+ def _include(flags: int) -> bool:
15
+ return bool(flags & _ENABLED) and not bool(flags & _DENY_ONLY)
16
+
17
+
18
+ def _valid_rid(rid: int) -> bool:
19
+ return 0 < rid <= 0xFFFFFFFF
20
+
21
+
22
+ def _append_rid(domain_sid: str, rid: int) -> str:
23
+ if domain_sid.count("-") >= MAX_SID_SUBAUTHS + 2:
24
+ raise PACDecodeError("SID exceeds maximum subauthorities after appending RID")
25
+ return f"{domain_sid}-{rid}"
26
+
27
+
28
+ def _rid_sids(domain_sid: str | None, memberships: list[tuple[int, int]]) -> list[str]:
29
+ if not domain_sid:
30
+ if memberships:
31
+ raise PACDecodeError("group RIDs without domain SID")
32
+ return []
33
+ if any(not _valid_rid(rid) for rid, _ in memberships):
34
+ raise PACDecodeError("invalid group RID")
35
+ if memberships and domain_sid.count("-") >= MAX_SID_SUBAUTHS + 2:
36
+ raise PACDecodeError("SID exceeds maximum subauthorities after appending RID")
37
+ return [f"{domain_sid}-{rid}" for rid, flags in memberships
38
+ if _include(flags)]
39
+
40
+
41
+ def _extra_sids(entries: list[tuple[str | None, int]]) -> list[str]:
42
+ return [sid for sid, flags in entries if sid and _include(flags)]
43
+
44
+
45
+ def _dedupe(*sublists: list[str]) -> list[str]:
46
+ unique = []
47
+ seen = set()
48
+ for sublist in sublists:
49
+ for value in sublist:
50
+ if value not in seen:
51
+ seen.add(value)
52
+ unique.append(value)
53
+ if len(unique) > MAX_GROUP_MEMBERSHIPS:
54
+ raise PACDecodeError("too many combined authorization groups")
55
+ return unique
56
+
57
+
58
+ def decode_logon_info(data: bytes, *, include_resource_groups: bool = True) -> dict:
59
+ """Decode KERB_VALIDATION_INFO, returning *no* session keys or secrets.
60
+
61
+ Results must only be used for authorization if the enclosing GSSAPI PAC
62
+ attribute was authenticated and this function returned successfully.
63
+ ``group_memberships`` preserves raw per-entry attributes; ``groups`` is
64
+ the filtered authorization list. The primary group has no PAC attributes.
65
+ ``include_resource_groups=False`` filters only ResourceGroupIds from
66
+ ``groups`` and ``resource_groups``, not ``extra_sids`` or raw metadata.
67
+ """
68
+ if type(include_resource_groups) is not bool:
69
+ raise TypeError("include_resource_groups must be a boolean")
70
+ r = ndr_serialized_pointer(data)
71
+ dates = [r.filetime() for _ in range(6)]
72
+ first_names = [r.unicode_header() for _ in range(6)]
73
+ logon_count, bad_password_count = r.u16(), r.u16()
74
+ rid, primary_gid, group_count, group_ref, user_flags = (
75
+ r.u32(), r.u32(), r.u32(), r.u32(), r.u32()
76
+ )
77
+ r.bytes(16) # UserSessionKey: intentionally ignored.
78
+ server_name_hdr, domain_name_hdr = r.unicode_header(), r.unicode_header()
79
+ domain_sid_ref = r.u32()
80
+ r.bytes(8) # LMKey / Reserved1: never expose.
81
+ account_flags = r.u32()
82
+ reserved3 = [r.u32() for _ in range(7)]
83
+ sid_count, extra_ref, resource_domain_ref, resource_count, resource_ref = (
84
+ r.u32(), r.u32(), r.u32(), r.u32(), r.u32()
85
+ )
86
+ if (sid_count or extra_ref) and not (user_flags & _NETLOGON_EXTRA_SIDS):
87
+ raise PACDecodeError("ExtraSids without NETLOGON_EXTRA_SIDS flag")
88
+ if ((resource_domain_ref or resource_count or resource_ref)
89
+ and not (user_flags & _NETLOGON_RESOURCE_GROUPS)):
90
+ raise PACDecodeError("resource groups without NETLOGON_RESOURCE_GROUPS flag")
91
+ names = [r.unicode_value(h) for h in first_names]
92
+ memberships = r.group_array(group_ref, group_count)
93
+ server_name = r.unicode_value(server_name_hdr)
94
+ domain_name = r.unicode_value(domain_name_hdr)
95
+ domain_sid = r.sid_pointer(domain_sid_ref)
96
+ extra_entries = r.sid_and_attributes_array(extra_ref, sid_count)
97
+ resource_domain_sid = r.sid_pointer(resource_domain_ref)
98
+ resource_entries = r.group_array(resource_ref, resource_count)
99
+ if memberships and not domain_sid:
100
+ raise PACDecodeError("group RIDs without account domain SID")
101
+ if resource_entries and not resource_domain_sid:
102
+ raise PACDecodeError("resource RIDs without resource domain SID")
103
+ if len(r.data) - r.offset > 7:
104
+ raise PACDecodeError("unconsumed LOGON_INFO payload")
105
+ if not domain_sid:
106
+ raise PACDecodeError("LOGON_INFO missing account domain SID")
107
+ if not _valid_rid(primary_gid):
108
+ raise PACDecodeError("LOGON_INFO invalid primary group RID")
109
+ if rid == 0 and not extra_entries:
110
+ raise PACDecodeError("LOGON_INFO zero user RID without account ExtraSID")
111
+ normal_groups = _rid_sids(domain_sid, memberships)
112
+ account_extra = extra_entries[0][0] if rid == 0 and extra_entries else None
113
+ group_extras = extra_entries[1:] if rid == 0 else extra_entries
114
+ extra_sids = _dedupe(_extra_sids(group_extras))
115
+ resource_groups = _dedupe(_rid_sids(resource_domain_sid, resource_entries))
116
+ primary_sid = _append_rid(domain_sid, primary_gid)
117
+ user_sid = _append_rid(domain_sid, rid) if _valid_rid(rid) else None
118
+ if rid == 0 and extra_entries:
119
+ user_sid = account_extra
120
+ if not user_sid:
121
+ raise PACDecodeError("LOGON_INFO missing account SID")
122
+ groups = _dedupe(
123
+ [primary_sid], normal_groups, extra_sids,
124
+ resource_groups if include_resource_groups else []
125
+ )
126
+ denied = {f"{domain_sid}-{item}" for item, flags in memberships
127
+ if domain_sid and flags & _DENY_ONLY}
128
+ denied.update(sid for sid, flags in group_extras if sid and flags & _DENY_ONLY)
129
+ if include_resource_groups:
130
+ denied.update(f"{resource_domain_sid}-{item}" for item, flags in resource_entries
131
+ if resource_domain_sid and flags & _DENY_ONLY)
132
+ groups = [sid for sid in groups if sid not in denied]
133
+ extra_sids = [sid for sid in extra_sids if sid not in denied]
134
+ resource_groups = ([sid for sid in resource_groups if sid not in denied]
135
+ if include_resource_groups else [])
136
+ return {
137
+ "sid": user_sid,
138
+ "sam_name": names[0],
139
+ "rid": rid,
140
+ "domain_sid": domain_sid,
141
+ "domain_name": domain_name,
142
+ "logon_server": server_name,
143
+ "full_name": names[1],
144
+ "logon_script": names[2],
145
+ "profile_path": names[3],
146
+ "home_directory": names[4],
147
+ "home_drive": names[5],
148
+ "groups": groups,
149
+ "group_memberships": (
150
+ [{"sid": f"{domain_sid}-{group_rid}", "attributes": attrs,
151
+ "source": "account_domain"} for group_rid, attrs in memberships]
152
+ + [{"sid": sid, "attributes": attrs, "source": "extra"}
153
+ for sid, attrs in group_extras]
154
+ + [{"sid": f"{resource_domain_sid}-{group_rid}", "attributes": attrs,
155
+ "source": "resource"} for group_rid, attrs in resource_entries]
156
+ ),
157
+ "primary_group_rid": primary_gid,
158
+ "extra_sids": extra_sids,
159
+ "resource_groups": resource_groups,
160
+ "group_count": len(groups),
161
+ "account_flags": account_flags,
162
+ "user_flags": user_flags,
163
+ "sub_auth_status": reserved3[0],
164
+ "last_logon": filetime_to_iso(dates[0]),
165
+ "logoff_time": filetime_to_iso(dates[1]),
166
+ "kickoff_time": filetime_to_iso(dates[2]),
167
+ "password_last_set": filetime_to_iso(dates[3]),
168
+ "password_can_change": filetime_to_iso(dates[4]),
169
+ "password_must_change": filetime_to_iso(dates[5]),
170
+ "last_successful_logon": filetime_to_iso(reserved3[1] | (reserved3[2] << 32)),
171
+ "last_failed_logon": filetime_to_iso(reserved3[3] | (reserved3[4] << 32)),
172
+ "failed_logon_count": reserved3[5],
173
+ "logon_count": logon_count,
174
+ "bad_password_count": bad_password_count,
175
+ }
176
+
177
+
178
+ def decode_device_info(data: bytes) -> dict:
179
+ """Decode the PAC_DEVICE_INFO account, extra SID and domain-group lists."""
180
+ r = ndr_serialized_pointer(data)
181
+ rid, primary_gid, domain_sid_ref, group_count, group_ref = (
182
+ r.u32(), r.u32(), r.u32(), r.u32(), r.u32()
183
+ )
184
+ sid_count, sid_ref, domain_count, domains_ref = (
185
+ r.u32(), r.u32(), r.u32(), r.u32()
186
+ )
187
+ domain_sid = r.sid_pointer(domain_sid_ref)
188
+ normal = r.group_array(group_ref, group_count)
189
+ extras = r.sid_and_attributes_array(sid_ref, sid_count)
190
+ domains = r.domain_groups_array(domains_ref, domain_count)
191
+ if normal and not domain_sid:
192
+ raise PACDecodeError("device group RIDs without domain SID")
193
+ if any(groups and not sid for sid, groups in domains):
194
+ raise PACDecodeError("device resource RIDs without domain SID")
195
+ if len(r.data) - r.offset > 7:
196
+ raise PACDecodeError("unconsumed DEVICE_INFO payload")
197
+ if not domain_sid or not _valid_rid(rid) or not _valid_rid(primary_gid):
198
+ raise PACDecodeError("DEVICE_INFO missing or invalid account SID/group")
199
+ group_sids = _rid_sids(domain_sid, normal)
200
+ extra_sids = _dedupe(_extra_sids(extras))
201
+ resource_sids = _dedupe(*[_rid_sids(sid, groups) for sid, groups in domains])
202
+ primary_sid = _append_rid(domain_sid, primary_gid)
203
+ device_sid = _append_rid(domain_sid, rid)
204
+ return {
205
+ "sid": device_sid,
206
+ "rid": rid,
207
+ "domain_sid": domain_sid,
208
+ "primary_group_rid": primary_gid,
209
+ "extra_sids": extra_sids,
210
+ "resource_groups": resource_sids,
211
+ "group_memberships": (
212
+ [{"sid": f"{domain_sid}-{group_rid}", "attributes": attrs,
213
+ "source": "account_domain"} for group_rid, attrs in normal]
214
+ + [{"sid": sid, "attributes": attrs, "source": "extra"}
215
+ for sid, attrs in extras]
216
+ + [{"sid": f"{resource_domain_sid}-{group_rid}", "attributes": attrs,
217
+ "source": "resource"} for resource_domain_sid, entries in domains
218
+ for group_rid, attrs in entries]
219
+ ),
220
+ "groups": _dedupe(
221
+ [primary_sid] if primary_sid else [], group_sids, extra_sids, resource_sids
222
+ ),
223
+ }
224
+
225
+
226
+ def decode_delegation_info(data: bytes) -> dict:
227
+ """Decode the S4U_DELEGATION_INFO target and transited service names."""
228
+ r = ndr_serialized_pointer(data)
229
+ proxy_header = r.unicode_header()
230
+ count, services_ref = r.u32(), r.u32()
231
+ if count > MAX_PAC_ENTRIES:
232
+ raise PACDecodeError("excessive transited services")
233
+ proxy = r.unicode_value(proxy_header)
234
+ if services_ref:
235
+ maximum = r.u32()
236
+ if maximum != count or count > (len(r.data) - r.offset) // 8:
237
+ raise PACDecodeError("invalid transited services array")
238
+ headers = [r.unicode_header() for _ in range(count)]
239
+ services = [r.unicode_value(header) for header in headers]
240
+ elif count:
241
+ raise PACDecodeError("null transited services pointer with nonzero count")
242
+ else:
243
+ services = []
244
+ return {
245
+ "delegation_proxy_target": proxy,
246
+ "delegation_transited_services": [name for name in services if name],
247
+ }
pac_decoder/ndr.py ADDED
@@ -0,0 +1,135 @@
1
+ """Small, strictly bounded NDR32 reader for MS-PAC structures.
2
+
3
+ This is *not* a generic DCE/RPC unmarshalling implementation. Only the
4
+ pointer/array/string forms used by PAC_LOGON_INFO and PAC_DEVICE_INFO are
5
+ supported. Incoming lengths are bounded before allocation or iteration.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import struct
11
+ from .binary import PACDecodeError, MAX_PAC_ENTRIES, MAX_PAC_GROUPS, MAX_SID_SUBAUTHS
12
+
13
+
14
+ class NDRReader:
15
+ def __init__(self, data: bytes):
16
+ self.data = memoryview(data)
17
+ self.offset = 0
18
+
19
+ def align(self, n: int) -> None:
20
+ self.offset = (self.offset + n - 1) & ~(n - 1)
21
+ if self.offset > len(self.data):
22
+ raise PACDecodeError("NDR alignment exceeds buffer")
23
+
24
+ def bytes(self, size: int, alignment: int = 1) -> bytes:
25
+ self.align(alignment)
26
+ if size < 0 or size > len(self.data) - self.offset:
27
+ raise PACDecodeError("truncated NDR payload")
28
+ result = bytes(self.data[self.offset:self.offset + size])
29
+ self.offset += size
30
+ return result
31
+
32
+ def u16(self) -> int:
33
+ return struct.unpack("<H", self.bytes(2, 2))[0]
34
+
35
+ def u32(self) -> int:
36
+ return struct.unpack("<I", self.bytes(4, 4))[0]
37
+
38
+ def filetime(self) -> int:
39
+ low = self.u32()
40
+ return low | (self.u32() << 32)
41
+
42
+ def unicode_header(self) -> tuple[int, int, int]:
43
+ length, max_length, referent = self.u16(), self.u16(), self.u32()
44
+ if length & 1 or max_length & 1 or length > max_length:
45
+ raise PACDecodeError("invalid RPC_UNICODE_STRING length")
46
+ if length and not referent:
47
+ raise PACDecodeError("nonempty null RPC_UNICODE_STRING")
48
+ return length, max_length, referent
49
+
50
+ def unicode_value(self, header: tuple[int, int, int]) -> str | None:
51
+ length, max_length, referent = header
52
+ if not referent:
53
+ return None
54
+ maximum, offset, actual = self.u32(), self.u32(), self.u32()
55
+ if offset > maximum or actual > maximum - offset:
56
+ raise PACDecodeError("invalid NDR string conformance")
57
+ if maximum > len(self.data) // 2 or actual > len(self.data) // 2:
58
+ raise PACDecodeError("oversized NDR string")
59
+ if length > actual * 2 or length > max_length:
60
+ raise PACDecodeError("NDR string length mismatch")
61
+ raw = self.bytes(actual * 2, 2)
62
+ try:
63
+ return raw[:length].decode("utf-16-le").rstrip("\x00") or None
64
+ except UnicodeDecodeError as exc:
65
+ raise PACDecodeError("invalid NDR Unicode string") from exc
66
+
67
+ def group_array(self, referent: int, count: int) -> list[tuple[int, int]]:
68
+ if count > MAX_PAC_GROUPS:
69
+ raise PACDecodeError("excessive PAC group count")
70
+ if not referent:
71
+ if count:
72
+ raise PACDecodeError("null PAC group pointer with nonzero count")
73
+ return []
74
+ n = self.u32()
75
+ if n != count or count > (len(self.data) - self.offset) // 8:
76
+ raise PACDecodeError("invalid PAC group array count")
77
+ return [(self.u32(), self.u32()) for _ in range(count)]
78
+
79
+ def sid(self) -> str:
80
+ max_count = self.u32()
81
+ revision, count = self.bytes(2)
82
+ authority = int.from_bytes(self.bytes(6), "big")
83
+ if revision != 1 or not 1 <= count <= MAX_SID_SUBAUTHS or max_count != count:
84
+ raise PACDecodeError("invalid NDR RPC_SID")
85
+ parts = [self.u32() for _ in range(count)]
86
+ return f"S-{revision}-{authority}" + "".join(f"-{x}" for x in parts)
87
+
88
+ def sid_pointer(self, referent: int) -> str | None:
89
+ return self.sid() if referent else None
90
+
91
+ def sid_and_attributes_array(self, referent: int, count: int) -> list[tuple[str | None, int]]:
92
+ if count > MAX_PAC_GROUPS:
93
+ raise PACDecodeError("excessive extra SID count")
94
+ if not referent:
95
+ if count:
96
+ raise PACDecodeError("null ExtraSids pointer with nonzero count")
97
+ return []
98
+ maximum = self.u32()
99
+ if maximum != count or count > (len(self.data) - self.offset) // 8:
100
+ raise PACDecodeError("invalid ExtraSids array count")
101
+ entries = [(self.u32(), self.u32()) for _ in range(count)]
102
+ if any(not ptr for ptr, _ in entries):
103
+ raise PACDecodeError("null SID in ExtraSids array")
104
+ return [(self.sid_pointer(ptr), flags) for ptr, flags in entries]
105
+
106
+ def domain_groups_array(
107
+ self, referent: int, count: int
108
+ ) -> list[tuple[str | None, list[tuple[int, int]]]]:
109
+ if count > MAX_PAC_ENTRIES:
110
+ raise PACDecodeError("excessive domain group count")
111
+ if not referent:
112
+ if count:
113
+ raise PACDecodeError("null domain group pointer with nonzero count")
114
+ return []
115
+ maximum = self.u32()
116
+ if maximum != count or count > (len(self.data) - self.offset) // 12:
117
+ raise PACDecodeError("invalid domain group array count")
118
+ entries = [(self.u32(), self.u32(), self.u32()) for _ in range(count)]
119
+ return [
120
+ (self.sid_pointer(sid_ref), self.group_array(group_ref, n))
121
+ for sid_ref, n, group_ref in entries
122
+ ]
123
+
124
+
125
+ def ndr_serialized_pointer(data: bytes) -> NDRReader:
126
+ """Extract a Microsoft NDR32 type-serialization envelope and root pointer."""
127
+ if len(data) < 20 or data[:4] != b"\x01\x10\x08\x00":
128
+ raise PACDecodeError("unsupported NDR type-serialization header")
129
+ object_length = struct.unpack_from("<I", data, 8)[0]
130
+ if object_length < 4 or object_length > len(data) - 16:
131
+ raise PACDecodeError("invalid NDR object length")
132
+ reader = NDRReader(data[16:16 + object_length])
133
+ if not reader.u32():
134
+ raise PACDecodeError("null NDR root pointer")
135
+ return reader
@@ -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,12 @@
1
+ pac_decoder/__init__.py,sha256=w-ZHIgijNEefKc-9xZlpW387g8oTIxaQ2KZ60wniues,11038
2
+ pac_decoder/binary.py,sha256=a2Xg3alh7FLWdq21XzpO9T59aOK7-fr4mD4kmsq69SY,6301
3
+ pac_decoder/claims.py,sha256=cOaS_KHjMSERXQOYDdEChKpGCpNZIYLCGRxCyEp-zZc,5764
4
+ pac_decoder/compression.py,sha256=8u5uhXox_KiSpMVSGsp3e02rJvG16MF4OrsfnL8O_NM,8434
5
+ pac_decoder/gssapi_helpers.py,sha256=jmLlEiyhyqfM_wPItThZYlftMd-YWFpCSUWGsFpMZ6w,4687
6
+ pac_decoder/logon.py,sha256=goERew9wnMqhpPOg6FZRbQ-Xm3kjDP6a5ZcHAcyqZME,11218
7
+ pac_decoder/ndr.py,sha256=4FEXoAupHKKNWw3f40VFbqSwMjUI3aMPR45dK1Fc3BM,5823
8
+ py_mspack_decoder-0.1.1.dist-info/licenses/LICENSE,sha256=-pUD-qeYToufnRHt_nAzuNAkd1LbnqD_VIJahR5YrFw,1072
9
+ py_mspack_decoder-0.1.1.dist-info/METADATA,sha256=bri5YIS-0FOXf0caV-jSsGgHU4JWPkt-I3aOzvApoGk,2411
10
+ py_mspack_decoder-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ py_mspack_decoder-0.1.1.dist-info/top_level.txt,sha256=dzmBVQdhS1wx8GzugnotZmJgc9-Oi6PTb29S7P4HEy8,12
12
+ py_mspack_decoder-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ pac_decoder