noise: Implement signature verification of chat messages

Since we don't pass the public key we just rely on the pubkey recovery and the
`checkmessage` interface to tell us whether it is a publicly known `node_id`
or not.
This commit is contained in:
Christian Decker
2020-01-19 15:14:09 +01:00
parent 3d3c7f75ba
commit 528aa8410b
4 changed files with 110 additions and 8 deletions

21
noise/README.org Normal file
View File

@@ -0,0 +1,21 @@
* Protocol
The protocol was heavily inspired by the [[https://github.com/joostjager/whatsat#protocol][WhatSat protocol]]:
| record type | length (bytes) | value |
|-------------+----------------+-----------------------------------------------------------------|
| 5482373484 | 32 | key send preimage |
| 34349334 | variable | chat message |
| 34349335 | 65 | compressed signature + recovery id |
| 34349339 | 33 | sender pubkey |
| 34349343 | 8 | timestamp in nano seconds since unix epoch (big endian encoded) |
The key differences are that we don't explicitly pass the sender pubkey, since
we can recover that from the signature itself, and we use the compressed 64
byte signature, instead of the DER encoded signature. This saves us 33 bytes
for the pubkey and ~7 bytes for the signature, but requires that we change the
TLV type for the signature (from ~34349337~ to ~34349335~).
The signature is computed by serializing all other TLV fields, hex-encoding
the resulting TLV payload, and signing it using ~lightning-cli signmessage~
returning the ~zbase32~ encoded signature. The signature consists of a 1 byte
recovery ID and the 64 byte raw signature.

View File

@@ -11,9 +11,13 @@ import logging
from collections import namedtuple
import shelve
from pyln.proto.onion import OnionPayload
import zbase32
plugin = Plugin()
TLV_KEYSEND_PREIMAGE = 5482373484
TLV_NOISE_MESSAGE = 34349334
TLV_NOISE_SIGNATURE = 34349335
class Message(object):
def __init__(self, sender, body, signature, payment=None, id=None):
@@ -22,6 +26,7 @@ class Message(object):
self.body = body
self.signature = signature
self.payment = payment
self.verified = None
def to_dict(self):
return {
@@ -30,6 +35,7 @@ class Message(object):
"body": self.body,
"signature": hexlify(self.signature).decode('ASCII'),
"payment": self.payment,
"verified": self.verified,
}
@@ -104,12 +110,16 @@ def deliver(node_id, payload, amt, max_attempts=5, payment_hash=None):
@plugin.async_method('sendmsg')
def sendmsg(node_id, msg, plugin, request, amt=1000, **kwargs):
payload = TlvPayload()
payload.add_field(34349334, msg.encode('UTF-8'))
payload.add_field(TLV_NOISE_MESSAGE, msg.encode('UTF-8'))
sigmsg = hexlify(payload.to_bytes()).decode('ASCII')
# Sign the message:
sig = plugin.rpc.signmessage(msg)['signature']
sig = unhexlify(sig)
payload.add_field(34349336, sig)
sig = plugin.rpc.signmessage(sigmsg)
sigcheck = plugin.rpc.checkmessage(sigmsg, sig['zbase'])
sig = zbase32.decode(sig['zbase'])
payload.add_field(TLV_NOISE_SIGNATURE, sig)
res = deliver(node_id, payload.to_bytes(), amt=amt)
request.set_result(res)
@@ -128,15 +138,23 @@ def recvmsg(plugin, request, last_id=None, **kwargs):
def on_htlc_accepted(onion, htlc, plugin, **kwargs):
payload = OnionPayload.from_hex(onion['payload'])
# TODO verify the signature to extract the sender
msg = Message(
id=len(plugin.messages),
sender="AAA",
sender=None,
body=payload.get(34349334).value,
signature=payload.get(34349336).value,
signature=payload.get(34349335).value,
payment=None)
# Filter out the signature so we can check it against the rest of the payload
sigpayload = TlvPayload()
sigpayload.fields = filter(lambda x: x.typenum != TLV_NOISE_SIGNATURE, payload.fields)
sigmsg = hexlify(sigpayload.to_bytes()).decode('ASCII')
zsig = zbase32.encode(msg.signature).decode('ASCII')
sigcheck = plugin.rpc.checkmessage(sigmsg, zsig)
msg.sender = sigcheck['pubkey']
msg.verified = sigcheck['verified']
plugin.messages.append(msg)
for r in plugin.receive_waiters:
r.set_result(msg.to_dict())

View File

@@ -1,6 +1,8 @@
from pyln.testing.fixtures import *
from pyln.testing.utils import wait_for
from pprint import pprint
import zbase32
plugin = os.path.join(os.path.dirname(__file__), 'noise.py')
@@ -20,6 +22,9 @@ def test_sendmsg_success(node_factory, executor):
# They should be the same :-)
assert(m1 == m2)
assert(m2['sender'] == l1.info['id'])
assert(m2['verified'] == True)
def test_sendmsg_retry(node_factory, executor):
opts = [{'plugin': plugin}, {}, {'fee-base': 10000}, {'plugin': plugin}]
@@ -52,3 +57,10 @@ def test_sendmsg_retry(node_factory, executor):
print(recv.result(10))
msg = l4.rpc.recvmsg(last_id=-1)
def test_zbase32():
zb32 = b'd75qtmgijm79rpooshmgzjwji9gj7dsdat8remuskyjp9oq1ugkaoj6orbxzhuo4njtyh96e3aq84p1tiuz77nchgxa1s4ka4carnbiy'
b = zbase32.decode(zb32)
enc = zbase32.encode(b)
assert(enc == zb32)

51
noise/zbase32.py Normal file
View File

@@ -0,0 +1,51 @@
import bitstring
zbase32_chars = b'ybndrfg8ejkmcpqxot1uwisza345h769'
zbase32_revchars = [
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 18, 255, 25, 26, 27, 30, 29, 7, 31, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 24, 1, 12, 3, 8, 5, 6, 28, 21, 9, 10, 255, 11, 2,
16, 13, 14, 4, 22, 17, 19, 255, 20, 15, 0, 23, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255
]
def bitarray_to_u5(barr):
assert len(barr) % 5 == 0
ret = []
s = bitstring.ConstBitStream(barr)
while s.pos != s.len:
ret.append(s.read(5).uint)
return ret
def u5_to_bitarray(arr):
ret = bitstring.BitArray()
for a in arr:
ret += bitstring.pack("uint:5", a)
return ret
def encode(b):
uint5s = bitarray_to_u5(b)
res = [zbase32_chars[c] for c in uint5s]
return bytes(res)
def decode(b):
if isinstance(b, str):
b = b.encode('ASCII')
uint5s = []
for c in b:
uint5s.append(zbase32_revchars[c])
dec = u5_to_bitarray(uint5s)
return dec.bytes