Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement better xor_encrypt, update uBitcoin #16

Merged
merged 1 commit into from
Dec 21, 2021

Conversation

stepansnigirev
Copy link
Contributor

@stepansnigirev stepansnigirev commented Dec 20, 2021

Overview

In this PR I suggest a few changes in the encoding format of the lnurl data:

  • pack nonce together with encrypted data and HMAC
  • extend HMAC size to 8 bytes and cover everything with it
  • use base64url encoding instead of hex for shorter urls
  • make the whole thing more extendable (see below)

Other changes:

  • improves nonce randomness (it was using random(9) for every byte, now it's random(256))
  • updates uBitcoin to the latest version that now includes base64 urlsafe encoding

Encoding format

Suggested data encoding has the following format:

  • first byte tells what encryption scheme is used - it's set to 0x01 for XOR-encryption (that is ok for data smaller than the key size), later we can extend it with other encryption formats like AES-CBC-HMAC and what not.
  • next we encode the nonce in the form <len><nonce>, in this implementation we use 8-byte nonce but it can be extended if required.
  • next we have the encrypted payload in the form <len><payload>
  • finally we have 8-byte HMAC (or more if needed). HMAC covers all the data before.

Keys

Keys are derived from a shared secret. There are two keys - for encryption and for authentication.
Round secret for encryption is calculated as hmac(key, "Round secret:" | nonce), HMAC at the end is calculated as hmac(key, "Data:" | payload).

Payload

Payload is a simple XOR of the round key with actual data contains the following items:

Python decoding implementation

Resulting LNURL can be decoded with the following python script (using embit library here, but can be easily adopted to any other bitcoin library):

from embit import bech32
from embit import compact
import base64
from io import BytesIO
import hmac

def bech32_decode(bech):
    """tweaked version of bech32_decode that ignores length limitations"""
    if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or
            (bech.lower() != bech and bech.upper() != bech)):
        return
    bech = bech.lower()
    pos = bech.rfind('1')
    if pos < 1 or pos + 7 > len(bech):
        return
    if not all(x in bech32.CHARSET for x in bech[pos+1:]):
        return
    hrp = bech[:pos]
    data = [bech32.CHARSET.find(x) for x in bech[pos+1:]]
    encoding = bech32.bech32_verify_checksum(hrp, data)
    if encoding is None:
        return
    return bytes(bech32.convertbits(data[:-6], 5, 8, False))

USD_CENTS = b'$'

def xor_decrypt(key, blob):
    s = BytesIO(blob)
    variant = s.read(1)[0]
    if variant != 1:
        raise RuntimeError("Not implemented")
    # reading nonce
    l = s.read(1)[0]
    nonce = s.read(l)
    if len(nonce) != l:
        raise RuntimeError("Missing nonce bytes")
    if l < 8:
        raise RuntimeError("Nonce is too short")
    # reading payload
    l = s.read(1)[0]
    payload = s.read(l)
    if len(payload) > 32:
        raise RuntimeError("Payload is too long for this encryption method")
    if len(payload) != l:
        raise RuntimeError("Missing payload bytes")
    hmacval = s.read()
    expected = hmac.new(key, b"Data:" + blob[:-len(hmacval)], digestmod="sha256").digest()
    if len(hmacval) < 8:
        raise RuntimeError("HMAC is too short")
    if hmacval != expected[:len(hmacval)]:
        raise RuntimeError("HMAC is invalid")
    secret = hmac.new(key, b"Round secret:" + nonce, digestmod="sha256").digest()
    payload = bytearray(payload)
    for i in range(len(payload)):
        payload[i] = payload[i] ^ secret[i]
    s = BytesIO(payload)
    pin = compact.read_from(s)
    # currency
    currency = s.read(1)
    if currency != USD_CENTS:
        raise RuntimeError("Unsupported currency: %s" % currency)
    amount_in_cent = compact.read_from(s)
    if s.read():
        raise RuntimeError("Unexpected data")
    return pin, amount_in_cent

def extract_pin_and_amount(key, lnurl):
    # get normal url from lnurl
    url = bech32_decode(lnurl).decode()
    # get payload part
    payload = url.split("?p=")[1]
    # add padding
    if len(payload) % 4 > 0:
        payload += "="*(4-(len(payload)%4))
    # decode from urlsafe
    data = base64.urlsafe_b64decode(payload)
    pin, amount_in_cent = xor_decrypt(key, data)
    return pin, amount_in_cent/100

if __name__ == "__main__":
    # shared key
    key = b"Enrt4QzajadmSu6hbwTxFz"

    # two example LNURLs
    lnurlarr = [
        "LNURL1DP68GURN8GHJ7MR9VAJKUEPWD3HXY6T5WVHXXMMD9AKXUATJD3CX7UE0V9CXJTMKXGHKCMN4WFKZ7JJCF4595EPCD9G5V46K895KU4RNVGM8VJMR8ACR6S23VA58QMR0VER4Q335F3G4VCTCDFQKUS242C6XXCN88PF9WE66PAN3K5",
        "LNURL1DP68GURN8GHJ7MR9VAJKUEPWD3HXY6T5WVHXXMMD9AKXUATJD3CX7UE0V9CXJTMKXGHKCMN4WFKZ7JJCF4595EPCD9G5V46K895KU4RNVGM8VJMR8ACR6S23D999YNN4FAV5KJJ8WFQK2D332F2NVTTND4RY7425DUC97KRSX5MKKA89JNW",
    ]
    for lnurl in lnurlarr:
        pin, amount_in_usd = extract_pin_and_amount(key, lnurl)
        print(f"Pin: {pin}, amount: ${amount_in_usd}")

@arcbtc arcbtc merged commit 7707d2d into arcbtc:main Dec 21, 2021
@arcbtc
Copy link
Owner

arcbtc commented Dec 21, 2021

Merged, but took out currency byte on both encrypt and decrypt, as currency is stored server side in the pos record

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants