Files
DECNET/tests/services/test_ntlmssp_parser.py
anti f2b3393669 chore: relicense to AGPL-3.0-or-later and add SPDX headers
Replaces LICENSE (GPLv3 -> AGPLv3) and prepends
`SPDX-License-Identifier: AGPL-3.0-or-later` to every source file
across decnet/, decnet_web/, tests/, scripts/, and tools/.

Rationale: closes the GPLv3 ASP loophole so any party operating a
modified DECNET as a network service must offer their modified
source. Personal copyright (Samuel Paschuan) + inbound=outbound
contributions make a future unilateral relicense infeasible.

- LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt)
- COPYRIGHT: project copyright notice
- tools/add_spdx_headers.py: idempotent header injector
  (shebang- and PEP 263-aware)

Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh).
No behavior change; comments only.
2026-05-22 21:04:16 -04:00

156 lines
4.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: AGPL-3.0-or-later
"""NTLMSSP Type 3 parser tests.
Builds Type 3 buffers field-by-field per MS-NLMP §2.2.1.3 and asserts
the parser returns the universal Credential SD shape. Shared
infrastructure for SMB and RDP-NLA cred capture.
"""
from __future__ import annotations
import base64
import importlib.util
import struct
from pathlib import Path
import pytest
def _load_ntlmssp():
repo = Path(__file__).resolve().parents[2]
path = repo / "decnet" / "templates" / "_shared" / "ntlmssp.py"
spec = importlib.util.spec_from_file_location("_ntlmssp_under_test", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="module")
def ntlmssp():
return _load_ntlmssp()
def _build_type3(
*,
username: str,
domain: str,
nt_response: bytes,
unicode: bool = True,
) -> bytes:
"""Build a syntactically-valid NTLMSSP Type 3 message."""
if unicode:
u = username.encode("utf-16-le")
d = domain.encode("utf-16-le")
flags = 0x00000001 # NEGOTIATE_UNICODE
else:
u = username.encode("ascii")
d = domain.encode("ascii")
flags = 0x00000000
# Layout: 8 sig + 4 type + 6×8 field records + 4 flags = 64 bytes
# of header, then payload (concat of nt_response, domain, username).
header_size = 64
nt_off = header_size
dom_off = nt_off + len(nt_response)
user_off = dom_off + len(d)
hdr = bytearray(header_size)
hdr[0:8] = b"NTLMSSP\x00"
struct.pack_into("<I", hdr, 8, 3) # message type 3
# LmChallengeResponse (unused — empty)
struct.pack_into("<HHI", hdr, 12, 0, 0, 0)
# NtChallengeResponse
struct.pack_into("<HHI", hdr, 20, len(nt_response), len(nt_response), nt_off)
# DomainName
struct.pack_into("<HHI", hdr, 28, len(d), len(d), dom_off)
# UserName
struct.pack_into("<HHI", hdr, 36, len(u), len(u), user_off)
# Workstation (unused)
struct.pack_into("<HHI", hdr, 44, 0, 0, 0)
# EncryptedRandomSessionKey (unused)
struct.pack_into("<HHI", hdr, 52, 0, 0, 0)
# NegotiateFlags
struct.pack_into("<I", hdr, 60, flags)
return bytes(hdr) + nt_response + d + u
def test_parse_type3_ntlmv2(ntlmssp):
"""NTLMv2 NTChallengeResponse is variable-length (>= 28 bytes in
practice). Parser flags this as secret_kind=ntlmssp_v2."""
nt_response = b"\xab" * 16 + b"\x01\x01\x00\x00" + b"\x00" * 28 # ~48 bytes
blob = _build_type3(
username="alice", domain="ACME", nt_response=nt_response,
)
cred = ntlmssp.parse_type3(blob)
assert cred is not None
assert cred["username"] == "alice"
assert cred["domain"] == "ACME"
assert cred["principal"] == "ACME\\alice"
assert cred["secret_kind"] == "ntlmssp_v2"
assert base64.b64decode(cred["secret_b64"]) == nt_response
def test_parse_type3_ntlmv1(ntlmssp):
"""NTLMv1 NTChallengeResponse is exactly 24 bytes."""
nt_response = b"\xcd" * 24
blob = _build_type3(
username="bob", domain="WORKGROUP", nt_response=nt_response,
)
cred = ntlmssp.parse_type3(blob)
assert cred["secret_kind"] == "ntlmssp_v1"
assert cred["principal"] == "WORKGROUP\\bob"
def test_parse_type3_no_domain(ntlmssp):
nt_response = b"\xff" * 24
blob = _build_type3(
username="lonely", domain="", nt_response=nt_response,
)
cred = ntlmssp.parse_type3(blob)
assert cred["domain"] == ""
assert cred["principal"] == "lonely"
def test_parse_type3_oem_strings(ntlmssp):
"""Older clients without NEGOTIATE_UNICODE send ASCII strings."""
nt_response = b"\x11" * 24
blob = _build_type3(
username="ascii_user",
domain="WIN2000",
nt_response=nt_response,
unicode=False,
)
cred = ntlmssp.parse_type3(blob)
assert cred["username"] == "ascii_user"
assert cred["domain"] == "WIN2000"
def test_parse_type3_rejects_non_signature(ntlmssp):
assert ntlmssp.parse_type3(b"NotNtlmssp") is None
assert ntlmssp.parse_type3(b"") is None
# Right magic but wrong message type:
blob = bytearray(64)
blob[0:8] = b"NTLMSSP\x00"
struct.pack_into("<I", blob, 8, 1) # Type 1, not 3
assert ntlmssp.parse_type3(bytes(blob)) is None
def test_parse_type3_rejects_anonymous(ntlmssp):
"""Empty NT response (anonymous bind) → no credential to record."""
blob = _build_type3(username="", domain="", nt_response=b"")
assert ntlmssp.parse_type3(blob) is None
def test_find_ntlmssp_inside_outer_blob(ntlmssp):
"""SPNEGO-wrapped Type 3 — caller can locate the signature first
and slice from there. Tests the find_ntlmssp helper."""
nt_response = b"\xee" * 32
inner = _build_type3(
username="x", domain="y", nt_response=nt_response,
)
outer = b"\x60\x82\x01\x00" + b"\x00" * 16 + inner + b"\xff" * 8
off = ntlmssp.find_ntlmssp(outer)
assert off >= 0
cred = ntlmssp.parse_type3(outer[off:])
assert cred["username"] == "x"