Skip to content

Commit 1639f4f

Browse files
authored
Add backing signature support (#67)
1 parent 4588432 commit 1639f4f

File tree

10 files changed

+579
-23
lines changed

10 files changed

+579
-23
lines changed

uma/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,15 @@
5151
parse_pay_req_response,
5252
parse_pay_request,
5353
verify_pay_req_response_signature,
54+
verify_pay_req_response_backing_signatures,
5455
verify_pay_request_signature,
56+
verify_pay_request_backing_signatures,
5557
verify_post_transaction_callback_signature,
5658
verify_uma_invoice_signature,
5759
verify_uma_lnurlp_query_signature,
60+
verify_uma_lnurlp_query_backing_signatures,
5861
verify_uma_lnurlp_response_signature,
62+
verify_uma_lnurlp_response_backing_signatures,
5963
)
6064
from uma.uma_invoice_creator import IUmaInvoiceCreator
6165
from uma.urls import is_domain_local

uma/__tests__/test_uma.py

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,14 @@
4141
parse_post_transaction_callback,
4242
parse_pubkey_response,
4343
verify_pay_request_signature,
44+
verify_pay_request_backing_signatures,
4445
verify_pay_req_response_signature,
46+
verify_pay_req_response_backing_signatures,
4547
verify_post_transaction_callback_signature,
4648
verify_uma_lnurlp_query_signature,
49+
verify_uma_lnurlp_query_backing_signatures,
4750
verify_uma_lnurlp_response_signature,
51+
verify_uma_lnurlp_response_backing_signatures,
4852
verify_uma_invoice_signature,
4953
)
5054
from uma.uma_invoice_creator import IUmaInvoiceCreator
@@ -146,6 +150,65 @@ def test_pay_request_create_and_parse() -> None:
146150
)
147151

148152

153+
def test_sign_and_verify_payreq_backing_signatures() -> None:
154+
sender_private_key = generate_key()
155+
receiver_private_key = generate_key()
156+
backing_vasp_private_key = generate_key()
157+
receiver_pubkey_response = _create_pubkey_response(
158+
receiver_private_key, receiver_private_key
159+
)
160+
payer_identifier = "[email protected]"
161+
payer_compliance_data = create_compliance_payer_data(
162+
signing_private_key=sender_private_key.secret,
163+
receiver_encryption_pubkey=receiver_pubkey_response.get_encryption_pubkey(),
164+
payer_identifier=payer_identifier,
165+
travel_rule_info=None,
166+
payer_kyc_status=KycStatus.VERIFIED,
167+
payer_utxos=["abcdef12345"],
168+
payer_node_pubkey="dummy_node_key",
169+
utxo_callback="/api/lnurl/utxocallback?txid=1234",
170+
)
171+
172+
pay_request = create_pay_request(
173+
receiving_currency_code="USD",
174+
is_amount_in_receiving_currency=True,
175+
amount=1000,
176+
payer_identifier=payer_identifier,
177+
uma_major_version=1,
178+
payer_name=None,
179+
payer_email=None,
180+
payer_compliance=payer_compliance_data,
181+
)
182+
pay_request_without_backing_signature = parse_pay_request(pay_request.to_json())
183+
184+
# append backing signature
185+
backing_domain = "backingvasp.com"
186+
pay_request_without_backing_signature.append_backing_signature(
187+
backing_vasp_private_key.secret, backing_domain
188+
)
189+
pay_request_with_backing_signature = parse_pay_request(
190+
pay_request_without_backing_signature.to_json()
191+
)
192+
193+
# verify backing signature
194+
compliance = compliance_from_payer_data(
195+
none_throws(pay_request_with_backing_signature.payer_data)
196+
)
197+
assert compliance is not None
198+
assert compliance.backing_signatures is not None
199+
assert len(compliance.backing_signatures) == 1
200+
public_key_cache = InMemoryPublicKeyCache()
201+
backing_vasp_pubkey_response = _create_pubkey_response(
202+
backing_vasp_private_key, backing_vasp_private_key
203+
)
204+
public_key_cache.add_public_key_for_vasp(
205+
backing_domain, backing_vasp_pubkey_response
206+
)
207+
verify_pay_request_backing_signatures(
208+
pay_request_with_backing_signature, public_key_cache
209+
)
210+
211+
149212
def test_lnurlp_query_missing_params() -> None:
150213
url = "https://vasp2.com/.well-known/lnurlp/bob?nonce=12345&vaspDomain=vasp1.com&umaVersion=1.0&isSubjectToTravelRule=true&timestamp=12345678"
151214
assert not is_uma_lnurlp_query(url)
@@ -539,6 +602,83 @@ def test_pay_req_with_locked_sending_amount() -> None:
539602
)
540603

541604

605+
def test_sign_and_verify_payreq_response_backing_signatures() -> None:
606+
sender_private_key = generate_key()
607+
receiver_private_key = generate_key()
608+
backing_vasp_private_key = generate_key()
609+
receiver_pubkey_response = _create_pubkey_response(
610+
receiver_private_key, receiver_private_key
611+
)
612+
currency_code = "USD"
613+
payer_identifier = "[email protected]"
614+
payee_identifier = "[email protected]"
615+
pay_request = create_pay_request(
616+
receiving_currency_code=currency_code,
617+
is_amount_in_receiving_currency=True,
618+
amount=1000,
619+
payer_identifier=payer_identifier,
620+
uma_major_version=1,
621+
payer_name=None,
622+
payer_email=None,
623+
payer_compliance=create_compliance_payer_data(
624+
signing_private_key=sender_private_key.secret,
625+
receiver_encryption_pubkey=receiver_pubkey_response.get_encryption_pubkey(),
626+
payer_identifier=payer_identifier,
627+
travel_rule_info="some TR info for VASP2",
628+
payer_kyc_status=KycStatus.VERIFIED,
629+
payer_utxos=["abcdef12345"],
630+
payer_node_pubkey="dummy_node_key",
631+
utxo_callback="/api/lnurl/utxocallback?txid=1234",
632+
),
633+
)
634+
response = create_pay_req_response(
635+
request=pay_request,
636+
invoice_creator=DummyUmaInvoiceCreator(),
637+
metadata=_create_metadata(),
638+
receiving_currency_code=currency_code,
639+
receiving_currency_decimals=2,
640+
msats_per_currency_unit=24_150,
641+
receiver_fees_msats=2_000,
642+
receiver_utxos=["abcdef12345"],
643+
receiver_node_pubkey="dummy_pub_key",
644+
utxo_callback="/api/lnurl/utxocallback?txid=1234",
645+
payee_identifier=payee_identifier,
646+
signing_private_key=receiver_private_key.secret,
647+
)
648+
response_without_backing_signature = parse_pay_req_response(response.to_json())
649+
650+
# append backing signature
651+
backing_domain = "backingvasp.com"
652+
response_without_backing_signature.append_backing_signature(
653+
backing_vasp_private_key.secret,
654+
backing_domain,
655+
payer_identifier,
656+
payee_identifier,
657+
)
658+
response_with_backing_signature = parse_pay_req_response(
659+
response_without_backing_signature.to_json()
660+
)
661+
662+
# verify backing signatures
663+
compliance = response_with_backing_signature.get_compliance()
664+
assert compliance is not None
665+
assert compliance.backing_signatures is not None
666+
assert len(compliance.backing_signatures) == 1
667+
public_key_cache = InMemoryPublicKeyCache()
668+
backing_vasp_pubkey_response = _create_pubkey_response(
669+
backing_vasp_private_key, backing_vasp_private_key
670+
)
671+
public_key_cache.add_public_key_for_vasp(
672+
backing_domain, backing_vasp_pubkey_response
673+
)
674+
verify_pay_req_response_backing_signatures(
675+
response_with_backing_signature,
676+
public_key_cache,
677+
payer_identifier,
678+
payee_identifier,
679+
)
680+
681+
542682
def _create_metadata() -> str:
543683
metadata = [
544684
["text/plain", "Pay to vasp2.com user $bob"],
@@ -741,6 +881,70 @@ def test_parse_v0_lnurlp_response() -> None:
741881
)
742882

743883

884+
def test_sign_and_verify_lnurlp_response_with_backing_signature() -> None:
885+
sender_private_key = generate_key()
886+
receiver_private_key = generate_key()
887+
backing_vasp_private_key = generate_key()
888+
payer_data_options = create_counterparty_data_options(
889+
{"name": False, "email": False, "compliance": True, "identifier": True}
890+
)
891+
currencies = [
892+
Currency(
893+
code="USD",
894+
name="US Dollar",
895+
symbol="$",
896+
millisatoshi_per_unit=34_150,
897+
min_sendable=1,
898+
max_sendable=10_000_000,
899+
decimals=2,
900+
uma_major_version=0,
901+
)
902+
]
903+
lnurlp_request_url = create_uma_lnurlp_request_url(
904+
signing_private_key=sender_private_key.secret,
905+
receiver_address="[email protected]",
906+
sender_vasp_domain="vasp1.com",
907+
is_subject_to_travel_rule=True,
908+
)
909+
lnurlp_request = parse_lnurlp_request(lnurlp_request_url)
910+
response = create_uma_lnurlp_response(
911+
request=lnurlp_request,
912+
signing_private_key=receiver_private_key.secret,
913+
requires_travel_rule_info=True,
914+
callback="https://vasp2.com/api/lnurl/payreq/$bob",
915+
encoded_metadata='["text/plain","Pay to Bob"]',
916+
min_sendable_sats=1,
917+
max_sendable_sats=10_000_000,
918+
payer_data_options=payer_data_options,
919+
currency_options=currencies,
920+
receiver_kyc_status=KycStatus.VERIFIED,
921+
)
922+
923+
# append backing signature
924+
response_without_backing_signature = parse_lnurlp_response(response.to_json())
925+
backing_domain = "backingvasp.com"
926+
response_without_backing_signature.append_backing_signature(
927+
backing_vasp_private_key.secret,
928+
backing_domain,
929+
)
930+
931+
# verify backing signature
932+
result_response = parse_lnurlp_response(
933+
response_without_backing_signature.to_json()
934+
)
935+
assert result_response.compliance is not None
936+
assert result_response.compliance.backing_signatures is not None
937+
assert len(result_response.compliance.backing_signatures) == 1
938+
public_key_cache = InMemoryPublicKeyCache()
939+
backing_vasp_pubkey_response = _create_pubkey_response(
940+
backing_vasp_private_key, backing_vasp_private_key
941+
)
942+
public_key_cache.add_public_key_for_vasp(
943+
backing_domain, backing_vasp_pubkey_response
944+
)
945+
verify_uma_lnurlp_response_backing_signatures(result_response, public_key_cache)
946+
947+
744948
def test_invalid_lnurlp_signature() -> None:
745949
private_key = generate_key()
746950
pubkey_response = _create_pubkey_response(private_key, private_key)
@@ -801,6 +1005,39 @@ def test_lnurlp_signature_too_old() -> None:
8011005
verify_uma_lnurlp_query_signature(lnurlp_request, pubkey_response, nonce_cache)
8021006

8031007

1008+
def test_sign_and_verify_lnurlp_request_with_backing_signature() -> None:
1009+
sender_private_key = generate_key()
1010+
backing_vasp_private_key = generate_key()
1011+
lnurlp_request_url = create_uma_lnurlp_request_url(
1012+
signing_private_key=sender_private_key.secret,
1013+
receiver_address="[email protected]",
1014+
sender_vasp_domain="vasp1.com",
1015+
is_subject_to_travel_rule=True,
1016+
)
1017+
1018+
# append backing signature
1019+
lnurlp_request = parse_lnurlp_request(lnurlp_request_url)
1020+
backing_domain = "backingvasp.com"
1021+
lnurlp_request.append_backing_signature(
1022+
backing_vasp_private_key.secret,
1023+
backing_domain,
1024+
)
1025+
lnurlp_request_url = lnurlp_request.encode_to_url()
1026+
1027+
# verify backing signature
1028+
lnurlp_request = parse_lnurlp_request(lnurlp_request_url)
1029+
public_key_cache = InMemoryPublicKeyCache()
1030+
backing_vasp_pubkey_response = _create_pubkey_response(
1031+
backing_vasp_private_key, backing_vasp_private_key
1032+
)
1033+
public_key_cache.add_public_key_for_vasp(
1034+
backing_domain, backing_vasp_pubkey_response
1035+
)
1036+
assert lnurlp_request.backing_signatures is not None
1037+
assert len(lnurlp_request.backing_signatures) == 1
1038+
verify_uma_lnurlp_query_backing_signatures(lnurlp_request, public_key_cache)
1039+
1040+
8041041
def test_high_signature_normalization() -> None:
8051042
pub_key_bytes = bytes.fromhex(
8061043
"047d37ce263a855ff49eb2a537a77a369a861507687bfde1df40062c8774488d644455a44baeb5062b79907d2e6f9692dd5b7bd7c37a3721ba21378d3594672063"

uma/protocol/backing_signature.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from dataclasses import dataclass
2+
3+
from uma.JSONable import JSONable
4+
5+
6+
@dataclass
7+
class BackingSignature(JSONable):
8+
domain: str
9+
"""
10+
The domain of the backing VASP that produced the signature. Public keys for this VASP will be fetched
11+
from this domain at /.well-known/lnurlpubkey and used to verify the signature.
12+
"""
13+
14+
signature: str
15+
"""
16+
Signature of the payload by a backing VASP that can attest to the authenticity of the message.
17+
"""

uma/protocol/lnurlp_request.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from dataclasses import dataclass
22
from datetime import datetime
3-
from typing import Optional
3+
from typing import Optional, List
44
from urllib.parse import urlencode
55

66
from uma.exceptions import InvalidRequestException
7+
from uma.protocol.backing_signature import BackingSignature
8+
from uma.signing_utils import sign_payload
79
from uma.type_utils import none_throws
810
from uma.urls import is_domain_local
911

@@ -45,6 +47,11 @@ class LnurlpRequest:
4547
The version of the UMA protocol that the sender is using.
4648
"""
4749

50+
backing_signatures: Optional[List[BackingSignature]] = None
51+
"""
52+
List of backing VASP signatures.
53+
"""
54+
4855
def encode_to_url(self) -> str:
4956
try:
5057
[identifier, host] = self.receiver_address.split("@")
@@ -55,6 +62,10 @@ def encode_to_url(self) -> str:
5562

5663
scheme = "http" if is_domain_local(host) else "https"
5764
base_url = f"{scheme}://{host}/.well-known/lnurlp/{identifier}"
65+
backing_signatures = [
66+
f"{sig.domain}:{sig.signature}" for sig in (self.backing_signatures or [])
67+
]
68+
5869
if not self.is_uma_request():
5970
return base_url
6071
params = {
@@ -65,6 +76,8 @@ def encode_to_url(self) -> str:
6576
"timestamp": int(none_throws(self.timestamp).timestamp()),
6677
"umaVersion": self.uma_version,
6778
}
79+
if backing_signatures:
80+
params["backingSignatures"] = ",".join(backing_signatures)
6881
return f"{base_url}?{urlencode(params)}"
6982

7083
def signable_payload(self) -> bytes:
@@ -85,3 +98,20 @@ def is_uma_request(self) -> bool:
8598
and self.timestamp is not None
8699
and self.is_subject_to_travel_rule is not None
87100
)
101+
102+
def append_backing_signature(self, signing_private_key: bytes, domain: str) -> None:
103+
"""
104+
Appends a backing signature to the lnurlp request.
105+
106+
Args:
107+
signing_private_key: The private key of the backing VASP which is used to sign the payload.
108+
domain: The domain of the backing VASP that produced the signature. Public keys for this VASP
109+
will be fetched from this domain at /.well-known/lnurlpubkey and used to verify the signature.
110+
"""
111+
payload = self.signable_payload()
112+
backing_signature = sign_payload(payload, signing_private_key)
113+
if self.backing_signatures is None:
114+
self.backing_signatures = []
115+
self.backing_signatures.append(
116+
BackingSignature(domain=domain, signature=backing_signature)
117+
)

0 commit comments

Comments
 (0)