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:
PIN encoded as varint
Currency byte ($ for USD cents, can be extended to other currencies as well)
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}")
Overview
In this PR I suggest a few changes in the encoding format of the lnurl data:
nonce
together with encrypted data and HMACOther changes:
random(9)
for every byte, now it'srandom(256)
)Encoding format
Suggested data encoding has the following format:
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.<len><nonce>
, in this implementation we use 8-byte nonce but it can be extended if required.<len><payload>
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 ashmac(key, "Data:" | payload)
.Payload
Payload is a simple XOR of the round key with actual data contains the following items:
$
for USD cents, can be extended to other currencies as well)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):