diff --git a/api/app/routes.py b/api/app/routes.py index a4761db..d50cb2a 100644 --- a/api/app/routes.py +++ b/api/app/routes.py @@ -28,7 +28,6 @@ from .config import ( from .db import get_db from .formatting import format_xmr_amount from .models import Invoice, ProfileHistory, User, Webhook, WebhookDelivery -from monero.address import Address, IntegratedAddress, SubAddress from .rates import get_wow_rate from .subaddress_allocator import MAX_SUBADDRESS_INDEX, create_subaddress_for_user from .schemas import ( @@ -358,28 +357,63 @@ def _resolve_invoice_amount( def _validate_payment_address_and_view_key(payment_address: str, view_key: str) -> None: + """Validate a Wownero primary address and its secret view key. + + Wownero uses 2-byte varint prefixes (unlike Monero's 1-byte), so the + standard monero Python library cannot parse them correctly. We do + base58-decode + keccak checksum + ed25519 view-key verification here. + """ + from binascii import hexlify, unhexlify + from monero import base58, ed25519 + from monero.keccak import keccak_256 + + # Wownero base58 prefix varints (first decoded byte) + _WOW_PRIMARY_BYTE0 = 178 # base58 prefix 4146 -> "Wo" + _WOW_SUBADDR_BYTE0 = 176 # base58 prefix 12208 -> "Ww" + _WOW_INTEGRATED_BYTE0 = 179 # base58 prefix 4147 + try: - address = Address(payment_address) - except ValueError as exc: - try: - SubAddress(payment_address) - except ValueError: - try: - IntegratedAddress(payment_address) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Primary address is invalid", - ) from exc - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Integrated addresses are not supported. Use the primary address.", - ) from exc + decoded = bytearray(unhexlify(base58.decode(payment_address))) + except Exception: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Primary address is invalid", + ) + + # Verify keccak-256 checksum (last 4 bytes) + if decoded[-4:] != keccak_256(decoded[:-4]).digest()[:4]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Primary address is invalid", + ) + + first_byte = decoded[0] + if first_byte == _WOW_SUBADDR_BYTE0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Subaddresses are not supported. Use the primary address.", - ) from exc - if not address.check_private_view_key(view_key): + ) + if first_byte == _WOW_INTEGRATED_BYTE0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Integrated addresses are not supported. Use the primary address.", + ) + if first_byte != _WOW_PRIMARY_BYTE0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Primary address is invalid", + ) + + # Wownero has 2-byte varint prefix -> keys start at offset 2 + pub_spend = decoded[2:34] + pub_view = decoded[34:66] + + # Derive public view key from the secret view key and compare + try: + derived_pub_view = ed25519.public_from_secret_hex(view_key) + if unhexlify(derived_pub_view) != bytes(pub_view): + raise ValueError("mismatch") + except Exception: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Secret view key does not match the primary address", diff --git a/api/app/subaddress_derivation.py b/api/app/subaddress_derivation.py index a53fc69..0a23ea1 100644 --- a/api/app/subaddress_derivation.py +++ b/api/app/subaddress_derivation.py @@ -3,16 +3,28 @@ from __future__ import annotations from binascii import hexlify, unhexlify import struct -from monero import base58, const, ed25519 -from monero.address import Address +from monero import base58, ed25519 from monero.keccak import keccak_256 -# Override monero library network constants for Wownero -from monero import const as _mc -_mc.NETS = ('main',) -_mc.MASTERADDR_NETBYTES = (53,) # Wownero mainnet address prefix -_mc.SUBADDR_NETBYTES = (63,) # Wownero subaddress prefix -_mc.INTADDRR_NETBYTES = (54,) # Wownero integrated address prefix +# Wownero network prefix bytes (first byte of varint-encoded base58 prefix). +# Primary: base58 prefix 4146 -> varint [0xb2, 0x20] -> "Wo" +# Subaddr: base58 prefix 12208 -> varint [0xb0, 0x5f] -> "Ww" +_WOW_PRIMARY_VARINT = bytes([0xb2, 0x20]) +_WOW_SUBADDR_VARINT = bytes([0xb0, 0x5f]) + + +def _decode_wow_address(payment_address: str) -> tuple[bytes, bytes]: + """Decode a Wownero primary address and return (pub_spend, pub_view) as raw bytes.""" + decoded = bytearray(unhexlify(base58.decode(payment_address))) + # Verify checksum + if decoded[-4:] != keccak_256(decoded[:-4]).digest()[:4]: + raise ValueError("Invalid checksum") + if decoded[0] != _WOW_PRIMARY_VARINT[0]: + raise ValueError(f"Not a Wownero primary address (netbyte {decoded[0]})") + # 2-byte varint prefix, then 32-byte spend key, 32-byte view key, 4-byte checksum + pub_spend = bytes(decoded[2:34]) + pub_view = bytes(decoded[34:66]) + return pub_spend, pub_view def derive_subaddress( @@ -22,9 +34,9 @@ def derive_subaddress( account_index: int, address_index: int, ) -> str: - address = Address(payment_address) + """Derive a Wownero subaddress from a primary address and secret view key.""" + pub_spend, _pub_view = _decode_wow_address(payment_address) view_key_bytes = unhexlify(view_key) - spend_pub_bytes = unhexlify(address.spend_key()) data = ( b"SubAddr\0" @@ -33,9 +45,9 @@ def derive_subaddress( ) m = ed25519.scalar_reduce(keccak_256(data).digest()) mG = ed25519.scalarmult_B(m) - D = ed25519.edwards_add(spend_pub_bytes, mG) + D = ed25519.edwards_add(pub_spend, mG) C = ed25519.scalarmult(view_key_bytes, D) - prefix = const.SUBADDR_NETBYTES[const.NETS.index(address.net)] - payload = bytearray([prefix]) + D + C + # Wownero subaddress uses 2-byte varint prefix + payload = bytearray(_WOW_SUBADDR_VARINT) + D + C checksum = keccak_256(payload).digest()[:4] return base58.encode(hexlify(payload + checksum))