Skip to content

Instantly share code, notes, and snippets.

@fiatjaf
Last active August 13, 2022 07:35
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save fiatjaf/e3232bef6d59263b439b97b6d04d1bcc to your computer and use it in GitHub Desktop.
Save fiatjaf/e3232bef6d59263b439b97b6d04d1bcc to your computer and use it in GitHub Desktop.
hsm_secret and custom invoices with lnurl on c-lightning
venv
*.swo
*.swp
__pycache__

How to use this gist

You have two options:

  1. Read all the comments and source code
  2. Run stuff without knowing what it does
python extract_private_key_from_hsm_secret.py

This will print your node's private key. Take it and pass it to the next command:

python generate_custom_invoice_with_lnurl.py <privatekey>

This will print data for the custom invoice.

Please let me know if you didn't understand something or if anything is broken.


This gist uses slightly modified code from https://github.com/rustyrussell/lightning-payencode. Hopefully Rusty won't sue me.

# extracting the node's private key from the hsm_secret file
# first thing: read the $LIGHTNING_DIR/hsm_secret
# xxd -p ~/.lightning/hsm_secret | tr -d '\n' && echo ""
import sys
hex_value = "" or sys.argv[-1]
# if you have it hex-encoded (as given from the xxd line above)
if hex_value and len(hex_value) > 60:
# proceed to make it a binary string again
from binascii import unhexlify
hsm_secret = unhexlify(hex_value)
else:
# or read it directly
from os.path import expanduser
hsm_secret = open(expanduser("~/.lightning/hsm_secret"), "rb").read()
# to generate the node private key, you must apply this hkdf thing
# (which is a special way to an hmac) to id
import hashlib
from hkdf import Hkdf
salt = bytes([0]) or b"\x00"
key = Hkdf(salt, hsm_secret, hash=hashlib.sha256).expand(b"nodeid")
print(key.hex())
# we're done here.
# however, in the c-lightning code they say there's a ridiculously small chance of the
# key produced here not being valid to the secp256k1 parameters, so they test it
# and increase the salt until it is valid.
# if for some reason your key is not correct with salt 0 you can just increase it by 1
# and be fine (in the majority of cases you can just use salt 0 and be fine)
# how to test? I don't know, maybe secp256k1 will raise an exception if the key is wrong?
import secp256k1
i = 0
while True:
salt = bytes([i])
key = Hkdf(salt, hsm_secret, hash=hashlib.sha256).expand(b"nodeid")
try:
secp256k1.PrivateKey(key)
break
except:
i += 1
print(key.hex())
# maybe you want to be extra-sure. in that case you can check the public key generated
# from the private key obtained here against the public key your node is advertising
# to everybody.
from lightning import LightningRpc
from os.path import expanduser
ln = LightningRpc(expanduser("~/.lightning/lightning-rpc"))
i = 0
while True:
salt = bytes([i])
key = Hkdf(salt, hsm_secret, hash=hashlib.sha256).expand(b"nodeid")
try:
privkey = secp256k1.PrivateKey(key)
if privkey.pubkey.serialize().hex() == ln.getinfo()["id"]:
# success!
break
except:
i += 1
print(key.hex())
# to generate a custom invoice we'll be using code copied
# from rustyrussel's https://github.com/rustyrussell/lightning-payencode
# modifications include
# - deleting all decode-related code, as we're only interested in encoding;
# - deleting fallback address stuff, as we're not going to waste blockchain space;
# - adding support for the 'v' tag, for https://github.com/btcontract/lnurl-rfc
from lnaddr import lnencode, LnAddr
# first thing, generate a random preimage and hash
import random
import string
import hashlib
from binascii import unhexlify
preimage = "".join([random.choice(string.hexdigits) for _ in range(64)])
print("preimage", preimage)
payment_hash = hashlib.sha256(unhexlify(preimage)).hexdigest()
print("payment_hash", payment_hash)
# now we need the node private key in hex format
# get it using the other file, extract_private_key_from_hsm_secret.py
import sys
private_key = "" or sys.argv[-1]
# oh, the amount, this is important
amount_msat = 1000000
amount_btc = amount_msat / (1000 * 100000000)
invoice = LnAddr()
invoice.currency = "bc"
invoice.fallback = None
invoice.amount = amount_btc
invoice.paymenthash = unhexlify(payment_hash)
invoice.tags.append(("d", "some random invoice with an lnurl attached"))
invoice.tags.append(("x", "18640"))
invoice.tags.append(("v", "https://example.com/lnurl?tag=multipart"))
# finally encode the invoice, signed with the node key
bolt11 = lnencode(invoice, private_key)
print("bolt11", bolt11)
# now we must create this same invoice in the node, so it'll be ready
# for it if a payment comes
from os.path import expanduser
from lightning import LightningRpc
ln = LightningRpc(expanduser("~/.lightning/lightning-rpc"))
res = ln.invoice(
msatoshi=amount_msat, # should be the same amount -- however in msatoshis here
label="".join([random.choice(string.ascii_lowercase) for _ in range(8)]),
description="this will be ignored",
preimage=preimage, # this is important
)
# we can ignore the bolt11 generate here and give the previous one to the payer.
# the node will accept any payments that match the preimage/payment_hash and amount.
# but we can check if the payment_hash is correct here, if you want
print("payment_hash", res["payment_hash"])
#! /usr/bin/env python3
from bech32 import bech32_encode, CHARSET
from binascii import hexlify, unhexlify
from decimal import Decimal
import bitstring
import hashlib
import secp256k1
import time
# BOLT #11:
#
# A writer MUST encode `amount` as a positive decimal integer with no
# leading zeroes, SHOULD use the shortest representation possible.
def shorten_amount(amount):
""" Given an amount in bitcoin, shorten it
"""
# Convert to pico initially
amount = int(amount * 10 ** 12)
units = ["p", "n", "u", "m", ""]
for unit in units:
if amount % 1000 == 0:
amount //= 1000
else:
break
return str(amount) + unit
# Bech32 spits out array of 5-bit values. Shim here.
def u5_to_bitarray(arr):
ret = bitstring.BitArray()
for a in arr:
ret += bitstring.pack("uint:5", a)
return ret
def bitarray_to_u5(barr):
assert barr.len % 5 == 0
ret = []
s = bitstring.ConstBitStream(barr)
while s.pos != s.len:
ret.append(s.read(5).uint)
return ret
# Tagged field containing BitArray
def tagged(char, l):
# Tagged fields need to be zero-padded to 5 bits.
while l.len % 5 != 0:
l.append("0b0")
return (
bitstring.pack(
"uint:5, uint:5, uint:5",
CHARSET.find(char),
(l.len / 5) / 32,
(l.len / 5) % 32,
)
+ l
)
# Tagged field containing bytes
def tagged_bytes(char, l):
return tagged(char, bitstring.BitArray(l))
def lnencode(addr, privkey):
if addr.amount:
amount = Decimal(str(addr.amount))
# We can only send down to millisatoshi.
if amount * 10 ** 12 % 10:
raise ValueError(
"Cannot encode {}: too many decimal places".format(addr.amount)
)
amount = addr.currency + shorten_amount(amount)
else:
amount = addr.currency if addr.currency else ""
hrp = "ln" + amount
# Start with the timestamp
data = bitstring.pack("uint:35", addr.date)
# Payment hash
data += tagged_bytes("p", addr.paymenthash)
tags_set = set()
for k, v in addr.tags:
# BOLT #11:
#
# A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields,
if k in ("d", "h", "n", "x"):
if k in tags_set:
raise ValueError("Duplicate '{}' tag".format(k))
if k == "r":
route = bitstring.BitArray()
for step in v:
pubkey, channel, feebase, feerate, cltv = step
route.append(
bitstring.BitArray(pubkey)
+ bitstring.BitArray(channel)
+ bitstring.pack("intbe:32", feebase)
+ bitstring.pack("intbe:32", feerate)
+ bitstring.pack("intbe:16", cltv)
)
data += tagged("r", route)
elif k == "d":
data += tagged_bytes("d", v.encode())
elif k == "x":
# Get minimal length by trimming leading 5 bits at a time.
expirybits = bitstring.pack("intbe:64", v)[4:64]
while expirybits.startswith("0b00000"):
expirybits = expirybits[5:]
data += tagged("x", expirybits)
elif k == "h":
data += tagged_bytes("h", hashlib.sha256(v.encode("utf-8")).digest())
elif k == "n":
data += tagged_bytes("n", v)
elif k == "v": # lnurl
data += tagged_bytes("v", v.encode())
else:
# FIXME: Support unknown tags?
raise ValueError("Unknown tag {}".format(k))
tags_set.add(k)
# BOLT #11:
#
# A writer MUST include either a `d` or `h` field, and MUST NOT include
# both.
if "d" in tags_set and "h" in tags_set:
raise ValueError("Cannot include both 'd' and 'h'")
if not "d" in tags_set and not "h" in tags_set:
raise ValueError("Must include either 'd' or 'h'")
# We actually sign the hrp, then data (padded to 8 bits with zeroes).
privkey = secp256k1.PrivateKey(bytes(unhexlify(privkey)))
sig = privkey.ecdsa_sign_recoverable(
bytearray([ord(c) for c in hrp]) + data.tobytes()
)
# This doesn't actually serialize, but returns a pair of values :(
sig, recid = privkey.ecdsa_recoverable_serialize(sig)
data += bytes(sig) + bytes([recid])
return bech32_encode(hrp, bitarray_to_u5(data))
class LnAddr(object):
def __init__(
self, paymenthash=None, amount=None, currency="bc", tags=None, date=None
):
self.date = int(time.time()) if not date else int(date)
self.tags = [] if not tags else tags
self.unknown_tags = []
self.paymenthash = paymenthash
self.signature = None
self.pubkey = None
self.currency = currency
self.amount = amount
def __str__(self):
return "LnAddr[{}, amount={}{} tags=[{}]]".format(
hexlify(self.pubkey.serialize()).decode("utf-8"),
self.amount,
self.currency,
", ".join([k + "=" + str(v) for k, v in self.tags]),
)
secp256k1==0.13.2
bitstring==3.1.5
bech32==1.1.0
hkdf
pylightning
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment