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.
156 lines
4.9 KiB
Python
156 lines
4.9 KiB
Python
# 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"
|