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.
- py_mspack_decoder-0.1.1/LICENSE +21 -0
- py_mspack_decoder-0.1.1/PKG-INFO +55 -0
- py_mspack_decoder-0.1.1/README.md +42 -0
- py_mspack_decoder-0.1.1/pyproject.toml +19 -0
- py_mspack_decoder-0.1.1/setup.cfg +4 -0
- py_mspack_decoder-0.1.1/src/pac_decoder/__init__.py +220 -0
- py_mspack_decoder-0.1.1/src/pac_decoder/binary.py +170 -0
- py_mspack_decoder-0.1.1/src/pac_decoder/claims.py +130 -0
- py_mspack_decoder-0.1.1/src/pac_decoder/compression.py +229 -0
- py_mspack_decoder-0.1.1/src/pac_decoder/gssapi_helpers.py +97 -0
- py_mspack_decoder-0.1.1/src/pac_decoder/logon.py +247 -0
- py_mspack_decoder-0.1.1/src/pac_decoder/ndr.py +135 -0
- py_mspack_decoder-0.1.1/src/py_mspack_decoder.egg-info/PKG-INFO +55 -0
- py_mspack_decoder-0.1.1/src/py_mspack_decoder.egg-info/SOURCES.txt +15 -0
- py_mspack_decoder-0.1.1/src/py_mspack_decoder.egg-info/dependency_links.txt +1 -0
- py_mspack_decoder-0.1.1/src/py_mspack_decoder.egg-info/requires.txt +3 -0
- py_mspack_decoder-0.1.1/src/py_mspack_decoder.egg-info/top_level.txt +1 -0
|
@@ -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,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}
|