Source code for netgear_switch.protocols.nsdp.auth

"""NSDP write authentication (v1 XOR and v2 salted challenge-response).

Two schemes exist; a switch advertises which via the ``AUTH_V2_ENCPASS``
(0x0014) read: value 1 = v1, value 0x10 = v2.

**v1** (older Plus firmware): the admin password is sent in a ``PASSWORD``
(0x000A) TLV "encrypted" by a repeating XOR against the 19-byte key
``NtgrSmartSwitchRock``. XOR is its own inverse, so ``encode_password_v1`` both
encodes an outgoing and decodes an incoming PASSWORD TLV.

**v2** (newer firmware, incl. GS110EMX fw 1.0.2.8 -- LIVE-VERIFIED): a
challenge-response. The client reads a fresh 4-byte salt from ``AUTH_V2_SALT``
(0x0017, which rotates on every read), then writes an 8-byte token in
``AUTH_V2_PASSWORD`` (0x001A) alongside the config change. The token is NOT a
hash -- it is an 8-byte XOR fold of the 20-byte password, the 4-byte salt and
the switch's own 6-byte MAC (the MAC from the salt read's response header).
``auth_v2_password`` is transcribed from the reverse-engineered
``AuthV2Password`` in CursedHardware/go-nsdp (in turn from yaamai/go-nsdp); its
"each output byte XORs three password bytes" shape is exactly the weakness NCC
Group documented (CVE-2020-35221) for this scheme. Verified two ways: it
reproduces go-nsdp's own ``TestAuthV2Password`` vector byte-for-byte
(password="password", mac 12:34:56:78:9a:bc, salt 12:34:56:78 ->
c4:af:7c:00:a6:c4:1a:7d — see tests/protocols/nsdp/test_auth.py), and a real
GS110EMX accepts a WRITE carrying ``[config…, AUTH_V2_PASSWORD=fold]``.

Investigation evidence (GS110EMX @ 10.1.5.25/.26/.27, fw 1.0.2.8, 2026-07-29):

* The token is NOT any hash. Before finding go-nsdp, md5(merge(pw, salt)) and
  md5(pw+salt)/md5(salt+pw) were tried live, with the salt rendered as a decimal
  string (both endiannesses), a hex string, and raw bytes, and the payload sent
  as raw-16, 32-hex-ASCII, and each of those XOR'd with ``NtgrSmartSwitchRock``.
  EVERY one was rejected error 13. The switch's WEB UI *does* use
  ``md5(merge(password, rand))`` (rand = a decimal nonce; confirmed by a
  successful HTTP login), but NSDP's 0x001A token is the unrelated XOR fold here
  — the two auth paths do not share the transform.
* AUTH_V2_ENCPASS (0x0014) returns 0x00000010 on this SKU (v2); a v1 unit
  returns 1. AUTH_V2_SALT (0x0017) is a 4-byte value that rotates on EVERY read.
  AUTH_V2_PASSWORD (0x001A) is write-only (a READ of it returns error 3).
* Write structure matters: the 0x001A token must come FIRST, then the config
  TLVs (``[0x001A, config…]``) -- this is what authenticates (error 0). Sending
  it LAST is rejected error 13; a malformed/wrong-length token leading the
  packet was seen to return error 4. Broadcast vs switch-MAC targeting and a
  real vs dummy client MAC do NOT matter -- auth-first works with the library's
  broadcast/dummy framing.
* Lockout (belongs in the mock; see faces/nsdp.py): READS always work, even
  while writes are locked. Wrong-token writes return error 13 for the first few
  (4 consecutive seen at ~1.2 s spacing), then escalate to error 14, then the
  switch goes SILENT to writes (no reply) for a long cooldown (>10 min observed;
  each fresh failed attempt appears to restart the window). The exact failure
  count before 14 varied 2-5 across units, i.e. it is rate/time-based, not a
  clean counter.
"""

from __future__ import annotations

V1_KEY = b"NtgrSmartSwitchRock"

# AUTH_V2_ENCPASS (0x0014) values that select the write-auth scheme.
ENCPASS_V1 = 0x01
ENCPASS_V2 = 0x10


[docs] def encode_password_v1(password: str) -> bytes: """Repeating-XOR the ASCII password with ``NtgrSmartSwitchRock`` (its own inverse). No padding/truncation rule applies -- the ciphertext is the password's own length.""" pw = password.encode("ascii") return bytes(b ^ V1_KEY[i % len(V1_KEY)] for i, b in enumerate(pw))
[docs] def auth_v2_password(password: str, switch_mac: bytes, salt: bytes) -> bytes: """Compute the 8-byte NSDP v2 auth token for a write. ``switch_mac`` is the device's own 6-byte MAC (the server MAC echoed in the ``AUTH_V2_SALT`` read response); ``salt`` is that read's fresh 4-byte value. The password is taken as a 20-byte key (ASCII, zero-padded / truncated to 20 -- the web UI caps the field at 20 chars). Transcribed byte-for-byte from CursedHardware/go-nsdp ``AuthV2Password``. """ if len(salt) != 4: raise ValueError(f"NSDP v2 salt must be 4 bytes, got {len(salt)}") if len(switch_mac) != 6: raise ValueError(f"NSDP v2 switch MAC must be 6 bytes, got {len(switch_mac)}") key = bytearray(20) pw = password.encode("ascii")[:20] key[: len(pw)] = pw s, m, k = salt, switch_mac, key return bytes( [ s[3] ^ s[2] ^ m[1] ^ m[5] ^ k[0] ^ k[1] ^ k[2], s[3] ^ s[1] ^ m[4] ^ m[0] ^ k[3] ^ k[4] ^ k[5], s[0] ^ s[2] ^ m[3] ^ m[2] ^ k[6] ^ k[7] ^ k[8], s[0] ^ s[1] ^ m[4] ^ m[5] ^ k[9] ^ k[10] ^ k[11], s[3] ^ s[2] ^ m[1] ^ m[5] ^ k[12] ^ k[13] ^ k[14], s[3] ^ s[1] ^ m[4] ^ m[0] ^ k[15] ^ k[16] ^ k[17], s[0] ^ s[2] ^ m[3] ^ m[2] ^ k[18] ^ k[19] ^ k[0], s[0] ^ s[1] ^ m[4] ^ m[5] ^ k[1] ^ k[3] ^ k[5], ] )
[docs] def encpass_is_v2(value: bytes) -> bool: """Decide the write-auth scheme from an ``AUTH_V2_ENCPASS`` (0x0014) value. v2 iff the advertised value is 0x10 (observed 0x00000010 on a GS110EMX); any other value (notably 1) means legacy v1 XOR. An absent/empty value is treated as v1 -- the historical default. """ if not value: return False return int.from_bytes(value, "big") == ENCPASS_V2