From c83121cf5fd3c8506cfba0dd928eb36b54b74e86 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Tue, 4 Aug 2026 08:18:11 -0300 Subject: [PATCH] fix(buzz): normalize npub entries in BUZZ_ALLOWED_USERS to hex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: The gateway's central allowlist check compared the inbound Buzz sender's 64-char hex pubkey against the raw BUZZ_ALLOWED_USERS entries. An operator who listed only their npub saw every message rejected with "Unauthorized user: " (gateway drops the message). npub entries are now decoded to hex before the comparison, so npub and hex forms of the same identity are equivalent. Root Cause: The Buzz adapter's own intake check already normalizes npub→hex via _normalize_user_ref when building _allowed_pubkeys, but the gateway applies BUZZ_ALLOWED_USERS centrally as well (authz_mixin._is_user_authorized via the platform registry's allowed_users_env). That central path did a raw string comparison of the allowlist entries against the hex user_id, so an npub-only entry never matched. Change: - gateway/authz_mixin.py: add a pure-stdlib bech32 npub→hex decoder (mirroring plugins/platforms/buzz/adapter.py) and normalize the buzz allowlist set in _is_user_authorized: each npub1… entry is decoded and its hex form added; hex entries pass through unchanged, so existing hex-only allowlists keep working. Comparison stays fail-closed for unrelated senders. - tests/gateway/test_buzz_authz.py: new tests covering npub-only, hex-only, mixed, uppercase-npub, and denial of unrelated users, plus unit tests for the decoder/helper. Verification: - 43 passed (test_buzz_authz.py + test_buzz_adapter.py + test_pairing_allowlist_bypass.py) - 9 passed (test_multiplex_profile_authz.py + test_buzz_websocket.py) Closes #78428 --- gateway/authz_mixin.py | 97 ++++++++++++++++++++++++ tests/gateway/test_buzz_authz.py | 125 +++++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 tests/gateway/test_buzz_authz.py diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index 312fc853f8..e80435d982 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -104,6 +104,91 @@ def _coerce_allow_set(raw) -> set[str]: return {part.strip() for part in str(raw).split(",") if part.strip()} +# --------------------------------------------------------------------------- +# Nostr npub → hex normalization (Buzz and future Nostr-based platforms). +# +# ``BUZZ_ALLOWED_USERS`` accepts either a 64-char hex pubkey or an ``npub1…`` +# bech32 string, but inbound event pubkeys are always hex. Without decoding, +# the central allowlist comparison string-matches the raw npub against the +# hex pubkey and an operator who listed only their npub sees every message +# rejected ("Unauthorized user: ", #78428). Pure stdlib; mirrors +# the decoder in plugins/platforms/buzz/adapter.py. +# --------------------------------------------------------------------------- + +_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def _bech32_polymod(values): + chk = 1 + generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] + for value in values: + top = chk >> 25 + chk = (chk & 0x1FFFFFF) << 5 ^ value + for i in range(5): + chk ^= generator[i] if ((top >> i) & 1) else 0 + return chk + + +def _bech32_hrp_expand(hrp: str): + return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp] + + +def _convertbits(data, frombits: int, tobits: int, pad: bool = True): + acc = 0 + bits = 0 + ret = [] + maxv = (1 << tobits) - 1 + for value in data: + if value < 0 or (value >> frombits): + return None + acc = (acc << frombits) | value + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if pad: + if bits: + ret.append((acc << (tobits - bits)) & maxv) + elif bits >= frombits or ((acc << (tobits - bits)) & maxv): + return None + return ret + + +def _npub_to_hex(npub: str) -> Optional[str]: + """Decode an ``npub1…`` bech32 string to a 64-char hex pubkey, else None.""" + npub = npub.strip().lower() + if not npub.startswith("npub1"): + return None + data_part = npub[len("npub1"):] + try: + data = [_BECH32_CHARSET.index(c) for c in data_part] + except ValueError: + return None + if _bech32_polymod(_bech32_hrp_expand("npub") + data) != 1: + return None + decoded = _convertbits(data[:-6], 5, 8, pad=False) + if decoded is None or len(decoded) != 32: + return None + return bytes(decoded).hex() + + +def _normalize_nostr_allow_entries(entries: set) -> set: + """Expand npub entries in an allowlist set to their hex pubkey form. + + Hex entries pass through unchanged; each valid ``npub1…`` entry is decoded + and its 64-char hex form added, so either form authorizes the same + identity (#78428). Invalid entries are kept as-is (they simply never + match an inbound hex pubkey). + """ + expanded = set(entries) + for entry in entries: + if entry.lower().startswith("npub1"): + hex_key = _npub_to_hex(entry) + if hex_key: + expanded.add(hex_key) + return expanded + + class GatewayAuthorizationMixin: """User/chat authorization methods for ``GatewayRunner``.""" @@ -864,6 +949,18 @@ class GatewayAuthorizationMixin: ): check_ids.add(source.user_name) + # Buzz (Nostr-based): BUZZ_ALLOWED_USERS accepts npub or hex, but + # inbound event pubkeys are always 64-char hex. Decode npub entries + # to hex so an operator who listed only their npub authorizes the + # same identity as the hex form (#78428). Hex entries pass through + # unchanged, so existing hex-only allowlists keep working. + if source.platform is not None and source.platform.value == "buzz": + allowed_ids = _normalize_nostr_allow_entries(allowed_ids) + if user_id.startswith("npub"): + hex_user = _npub_to_hex(user_id) + if hex_user: + check_ids.add(hex_user) + return bool(check_ids & allowed_ids) def _get_unauthorized_dm_behavior( diff --git a/tests/gateway/test_buzz_authz.py b/tests/gateway/test_buzz_authz.py new file mode 100644 index 0000000000..f664c5a9b1 --- /dev/null +++ b/tests/gateway/test_buzz_authz.py @@ -0,0 +1,125 @@ +"""Gateway authz tests: BUZZ_ALLOWED_USERS accepts npub or hex (#78428). + +Inbound Buzz events carry the sender as a 64-char hex pubkey, while +``BUZZ_ALLOWED_USERS`` historically accepted npubs too. The gateway's +central allowlist comparison must decode npub entries to hex at comparison +time, or an operator who listed only their npub is rejected with +"Unauthorized user: " (gateway drops the message). +""" + +import pytest + +from gateway.config import Platform +from gateway.platform_registry import PlatformEntry, platform_registry +from gateway.session import SessionSource + +# Chip's public identity (public information, not a secret) — the same pair +# used by tests/gateway/test_buzz_adapter.py. +SELF_PUBKEY = "9fd5c7ba6d3ef224da78f541e0fcb9c50f72cc63edb19aae76ac6a0474dfa860" +SELF_NPUB = "npub1nl2u0wnd8mezfknc74q7pl9ec58h9nrrakce4tnk434qgaxl4psqe5twr6" +OTHER_PUBKEY = "a" * 64 + +_BUZZ_PLATFORM = Platform("buzz") + +_AUTH_ENV_VARS = ( + "BUZZ_ALLOWED_USERS", + "BUZZ_ALLOW_ALL_USERS", + "GATEWAY_ALLOWED_USERS", + "GATEWAY_ALLOW_ALL_USERS", +) + + +@pytest.fixture(autouse=True) +def _isolate_env(monkeypatch): + for var in _AUTH_ENV_VARS: + monkeypatch.delenv(var, raising=False) + + +@pytest.fixture +def buzz_registered(): + """Register a minimal Buzz platform entry (allowed_users_env contract).""" + platform_registry.register( + PlatformEntry( + name="buzz", + label="Buzz", + adapter_factory=lambda cfg: None, + check_fn=lambda: True, + allowed_users_env="BUZZ_ALLOWED_USERS", + allow_all_env="BUZZ_ALLOW_ALL_USERS", + ) + ) + yield + platform_registry.unregister("buzz") + + +def _make_runner(): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.pairing_store = None + return runner + + +def _make_source(user_id: str, chat_type: str = "dm"): + return SessionSource( + platform=_BUZZ_PLATFORM, + chat_id="ccc2bc1a-7a82-5a8f-8c4e-57a070cbe7cd", + chat_type=chat_type, + user_id=user_id, + user_name="Chip", + is_bot=False, + ) + + +def test_npub_only_allowlist_authorizes_hex_identity(monkeypatch, buzz_registered): + """The reported bug: listing only the npub must authorize the hex pubkey.""" + monkeypatch.setenv("BUZZ_ALLOWED_USERS", SELF_NPUB) + runner = _make_runner() + assert runner._is_user_authorized(_make_source(SELF_PUBKEY)) is True + + +def test_hex_only_allowlist_still_authorizes(monkeypatch, buzz_registered): + """Existing hex-only allowlists keep working unchanged.""" + monkeypatch.setenv("BUZZ_ALLOWED_USERS", SELF_PUBKEY) + runner = _make_runner() + assert runner._is_user_authorized(_make_source(SELF_PUBKEY)) is True + + +def test_mixed_npub_and_hex_allowlist_authorizes(monkeypatch, buzz_registered): + """Both forms in one allowlist authorize the same identity.""" + monkeypatch.setenv("BUZZ_ALLOWED_USERS", f"{SELF_NPUB},{SELF_PUBKEY}") + runner = _make_runner() + assert runner._is_user_authorized(_make_source(SELF_PUBKEY)) is True + + +def test_npub_allowlist_still_denies_other_user(monkeypatch, buzz_registered): + """Normalization must not turn into fail-open for unrelated senders.""" + monkeypatch.setenv("BUZZ_ALLOWED_USERS", SELF_NPUB) + runner = _make_runner() + assert runner._is_user_authorized(_make_source(OTHER_PUBKEY)) is False + + +def test_uppercase_npub_allowlist_authorizes(monkeypatch, buzz_registered): + """npub entries are case-insensitive, like the adapter's own decoder.""" + monkeypatch.setenv("BUZZ_ALLOWED_USERS", SELF_NPUB.upper()) + runner = _make_runner() + assert runner._is_user_authorized(_make_source(SELF_PUBKEY)) is True + + +def test_normalize_nostr_allow_entries(): + from gateway.authz_mixin import _normalize_nostr_allow_entries + + expanded = _normalize_nostr_allow_entries({SELF_NPUB, OTHER_PUBKEY, "not-a-key"}) + assert SELF_PUBKEY in expanded + assert SELF_NPUB in expanded # original kept, harmless + assert OTHER_PUBKEY in expanded + assert "not-a-key" in expanded + + +def test_npub_to_hex_roundtrip(): + from gateway.authz_mixin import _npub_to_hex + + assert _npub_to_hex(SELF_NPUB) == SELF_PUBKEY + assert _npub_to_hex(SELF_PUBKEY) is None # hex input is not an npub + assert _npub_to_hex("npub1garbage!!") is None # invalid bech32 + assert _npub_to_hex("") is None