Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 107 additions & 10 deletions integrations/hermes/marmot/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,8 +415,88 @@ def group_state_change_sentence(change: str, detail: Optional[str] = None) -> st
return "The group state changed."


_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"


def _bech32_polymod(values: list[int]) -> int:
generators = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
chk = 1
for value in values:
top = chk >> 25
chk = ((chk & 0x1FFFFFF) << 5) ^ value
for i in range(5):
chk ^= generators[i] if ((top >> i) & 1) else 0
return chk


def _bech32_decode(bech: str) -> Tuple[Optional[str], Optional[list[int]]]:
if any(ord(ch) < 33 or ord(ch) > 126 for ch in bech):
return None, None
if bech.lower() != bech and bech.upper() != bech:
return None, None
bech = bech.lower()
pos = bech.rfind("1")
if pos < 1 or pos + 7 > len(bech):
return None, None
if any(ch not in _BECH32_CHARSET for ch in bech[pos + 1:]):
return None, None
hrp = bech[:pos]
data = [_BECH32_CHARSET.find(ch) for ch in bech[pos + 1:]]
expanded = [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
if _bech32_polymod(expanded + data) != 1:
return None, None
return hrp, data[:-6]


def _bech32_convertbits(data: list[int], frombits: int, tobits: int) -> Optional[list[int]]:
acc = 0
bits = 0
ret: list[int] = []
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)
# npub packs a 32-byte payload evenly, so no padding bits may remain.
if bits >= frombits or ((acc << (tobits - bits)) & maxv):
return None
return ret


def npub_to_hex(value: str) -> Optional[str]:
"""Decode a bech32 ``npub1...`` public key into 64-char lowercase hex.

Returns ``None`` for anything that is not a well-formed npub (wrong hrp,
bad checksum, wrong length) so callers can fall back to treating the input
as a raw hex id.
"""
hrp, data = _bech32_decode(value)
if hrp != "npub" or data is None:
return None
decoded = _bech32_convertbits(data, 5, 8)
if decoded is None or len(decoded) != 32:
return None
return bytes(decoded).hex()


def normalize_welcomer_id(entry: str | int) -> str:
return str(entry).strip().lower().removeprefix("0x")
"""Normalize a welcomer account reference to canonical lowercase hex.

Accepts either a 64-char hex account id or a bech32 ``npub1...`` key (the
form the installer's ``--allow-welcomer`` flag documents and the White
Noise app hands out). npub inputs are decoded to hex; anything else is
lowercased/stripped and returned for the caller's hex check.
"""
text = str(entry).strip()
if text[:5].lower() == "npub1":
decoded = npub_to_hex(text)
if decoded is not None:
return decoded
return text.lower().removeprefix("0x")


async def sync_allowlist(
Expand All @@ -425,11 +505,22 @@ async def sync_allowlist(
desired: Iterable[str | int],
) -> Dict[str, list[str]]:
"""Reconcile wn-agent's welcomer allowlist to exactly ``desired`` hex ids."""
want = {
normalize_welcomer_id(entry)
for entry in desired
if MARMOT_ACCOUNT_ID_HEX_RE.fullmatch(normalize_welcomer_id(entry))
}
want: set[str] = set()
dropped: list[str] = []
for entry in desired:
normalized = normalize_welcomer_id(entry)
if MARMOT_ACCOUNT_ID_HEX_RE.fullmatch(normalized):
want.add(normalized)
else:
dropped.append(str(entry))
if dropped:
logger.warning(
"Marmot welcomer allowlist: ignoring %d unparseable entr%s "
"(expected 64-char hex or npub1...): %s",
len(dropped),
"y" if len(dropped) == 1 else "ies",
", ".join(dropped),
)
current = await client.allowlist_list(account_id_hex)
have = {
normalize_welcomer_id(entry)
Expand All @@ -443,10 +534,16 @@ async def sync_allowlist(
if entry not in have:
await client.allowlist_add(account_id_hex, entry)
added.append(entry)
for entry in have:
if entry not in want:
await client.allowlist_remove(account_id_hex, entry)
removed.append(entry)
# Guard against wiping the entire allowlist when every desired entry failed
# to parse (e.g. an npub that could not be decoded). Removing everything on
# a parse error silently disables the agent, which then declines all group
# welcomes. A genuinely empty desired list (nothing dropped) still clears
# the allowlist as before.
if want or not dropped:
for entry in have:
if entry not in want:
await client.allowlist_remove(account_id_hex, entry)
removed.append(entry)
return {"added": added, "removed": removed}


Expand Down
55 changes: 55 additions & 0 deletions integrations/hermes/marmot/tests/test_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3009,6 +3009,61 @@ async def allowlist_remove(self, account_id_hex, welcomer_account_id_hex):
self.assertEqual(result["removed"], ["44" * 32])
self.assertEqual(sorted(current), sorted(["22" * 32, "33" * 32]))

async def test_normalize_welcomer_id_decodes_npub(self):
npub = "npub1dhuvgjv64n0ym4frpvjkv393nrlknrsnmqkcxm6vufluq290h99sd9u900"
expected = "6df8c4499aacde4dd5230b256644b198ff698e13d82d836f4ce27fc028afb94b"
self.assertEqual(self.adapter_module.normalize_welcomer_id(npub), expected)
# raw hex passes through (lowercased)
self.assertEqual(
self.adapter_module.normalize_welcomer_id(expected.upper()), expected
)
# a malformed npub is returned as-is (lowercased) for the caller's hex check
self.assertEqual(
self.adapter_module.normalize_welcomer_id("npub1invalid"), "npub1invalid"
)

async def test_sync_allowlist_accepts_npub(self):
npub = "npub1dhuvgjv64n0ym4frpvjkv393nrlknrsnmqkcxm6vufluq290h99sd9u900"
hexid = "6df8c4499aacde4dd5230b256644b198ff698e13d82d836f4ce27fc028afb94b"
current: list[str] = []

class FakeClient:
async def allowlist_list(self, account_id_hex):
return {"type": "allowlist", "welcomer_account_ids_hex": list(current)}

async def allowlist_add(self, account_id_hex, welcomer_account_id_hex):
current.append(welcomer_account_id_hex)

async def allowlist_remove(self, account_id_hex, welcomer_account_id_hex):
current.remove(welcomer_account_id_hex)

result = await self.adapter_module.sync_allowlist(
FakeClient(), "11" * 32, [npub]
)
self.assertEqual(result["added"], [hexid])
self.assertEqual(current, [hexid])

async def test_sync_allowlist_does_not_wipe_on_unparseable_desired(self):
# If every desired entry fails to parse (e.g. an npub that cannot be
# decoded), the existing hex entries must be preserved, not wiped.
current = ["33" * 32]

class FakeClient:
async def allowlist_list(self, account_id_hex):
return {"type": "allowlist", "welcomer_account_ids_hex": list(current)}

async def allowlist_add(self, account_id_hex, welcomer_account_id_hex):
current.append(welcomer_account_id_hex)

async def allowlist_remove(self, account_id_hex, welcomer_account_id_hex):
current.remove(welcomer_account_id_hex)

result = await self.adapter_module.sync_allowlist(
FakeClient(), "11" * 32, ["not-a-valid-key"]
)
self.assertEqual(result["removed"], [])
self.assertEqual(current, ["33" * 32])


class GroupInviteOnboardingTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
Expand Down