Skip to content

Commit f0b407a

Browse files
authored
Add async pubkey cache and key fetch method (#71)
* async interface for pubkey cache * try pinning some versions to avoid breaking python 3.8 * Revert "try pinning some versions to avoid breaking python 3.8" This reverts commit c83ebbb. * try python3.9+ * try pipfile * install async-timeout for python39 * pin asyncio for pyre
1 parent c220c2c commit f0b407a

File tree

9 files changed

+633
-26
lines changed

9 files changed

+633
-26
lines changed

.github/workflows/test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
runs-on: "ubuntu-22.04"
1212
strategy:
1313
matrix:
14-
python_version: ["3.8", "3.10"]
14+
python_version: ["3.9", "3.10"]
1515
steps:
1616
- name: "Checkout"
1717
uses: "actions/checkout@v3"

Pipfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ verify_ssl = true
44
name = "pypi"
55

66
[packages]
7+
# Pin to avoid pyre issues:
8+
aiohttp = "<3.10"
9+
async-timeout = "*"
710
bech32 = "*"
811
coincurve = "*"
912
cryptography = "*"
@@ -19,6 +22,7 @@ isort = "==5.11.4"
1922
pylint = "*"
2023
pyre-check = "*"
2124
pytest = "*"
25+
pytest-asyncio = "*"
2226
tomli = "*"
2327
wrapt = "*"
2428
# Need to lock this version to avoid breaking older python versions:

Pipfile.lock

Lines changed: 516 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

setup.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,7 @@
22

33
import setuptools
44

5-
setuptools.setup(include_package_data=True)
5+
setuptools.setup(
6+
include_package_data=True,
7+
python_requires=">=3.9",
8+
)

uma/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@
3434
)
3535
from uma.protocol.post_tx_callback import PostTransactionCallback, UtxoWithAmount
3636
from uma.protocol.pubkey_response import PubkeyResponse
37-
from uma.public_key_cache import InMemoryPublicKeyCache, IPublicKeyCache
37+
from uma.public_key_cache import (
38+
InMemoryPublicKeyCache,
39+
IPublicKeyCache,
40+
IAsyncPublicKeyCache,
41+
)
3842
from uma.signing_utils import sign_payload
3943
from uma.type_utils import none_throws
4044
from uma.uma import (
@@ -47,6 +51,7 @@
4751
create_uma_lnurlp_request_url,
4852
create_uma_lnurlp_response,
4953
fetch_public_key_for_vasp,
54+
fetch_public_key_for_vasp_async,
5055
generate_nonce,
5156
get_vasp_domain_from_uma_address,
5257
is_uma_lnurlp_query,

uma/__tests__/test_uma.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from uma.protocol.payer_data import compliance_from_payer_data
1919
from uma.nonce_cache import InMemoryNonceCache
2020
from uma.protocol.pubkey_response import PubkeyResponse
21-
from uma.public_key_cache import InMemoryPublicKeyCache
21+
from uma.public_key_cache import InMemoryPublicKeyCache, IAsyncPublicKeyCache
2222
from uma.type_utils import none_throws
2323
from uma.protocol.post_tx_callback import UtxoWithAmount
2424
from uma.protocol.v0.payreq import PayRequest as V0PayRequest
@@ -33,6 +33,7 @@
3333
create_pay_request,
3434
create_post_transaction_callback,
3535
fetch_public_key_for_vasp,
36+
fetch_public_key_for_vasp_async,
3637
is_uma_lnurlp_query,
3738
parse_lnurlp_request,
3839
parse_lnurlp_response,
@@ -77,6 +78,30 @@ def test_fetch_public_key() -> None:
7778
assert cache.fetch_public_key_for_vasp(vasp_domain) == expected_pubkey
7879

7980

81+
@pytest.mark.asyncio
82+
async def test_fetch_public_key_async() -> None:
83+
cache = TestAsyncPublicKeyCache()
84+
vasp_domain = "vasp2.com"
85+
timestamp = int((datetime.now(timezone.utc) + timedelta(hours=1)).timestamp())
86+
expected_pubkey = PubkeyResponse(
87+
signing_pubkey=secrets.token_bytes(16),
88+
encryption_pubkey=secrets.token_bytes(16),
89+
expiration_timestamp=datetime.fromtimestamp(timestamp, timezone.utc),
90+
encryption_cert_chain=None,
91+
signing_cert_chain=None,
92+
)
93+
url = "https://vasp2.com/.well-known/lnurlpubkey"
94+
95+
with patch(
96+
"uma.uma._run_http_get_async",
97+
return_value=json.dumps(expected_pubkey.to_dict()),
98+
) as mock:
99+
pubkey_response = await fetch_public_key_for_vasp_async(vasp_domain, cache)
100+
mock.assert_called_once_with(url)
101+
assert pubkey_response == expected_pubkey
102+
assert await cache.fetch_public_key_for_vasp(vasp_domain) == expected_pubkey
103+
104+
80105
def _create_pubkey_response(
81106
signing_private_key: PrivateKey, encryption_private_key: PrivateKey
82107
) -> PubkeyResponse:
@@ -1218,3 +1243,24 @@ def test_uma_invoice_signature() -> None:
12181243

12191244
assert verify_uma_invoice_signature(invoice, pubkey_response) is None
12201245
assert invoice.signature is not None
1246+
1247+
1248+
class TestAsyncPublicKeyCache(IAsyncPublicKeyCache):
1249+
def __init__(self) -> None:
1250+
self._cache = InMemoryPublicKeyCache()
1251+
1252+
async def fetch_public_key_for_vasp(
1253+
self, vasp_domain: str
1254+
) -> Optional[PubkeyResponse]:
1255+
return self._cache.fetch_public_key_for_vasp(vasp_domain)
1256+
1257+
async def add_public_key_for_vasp(
1258+
self, vasp_domain: str, public_key: PubkeyResponse
1259+
) -> None:
1260+
self._cache.add_public_key_for_vasp(vasp_domain, public_key)
1261+
1262+
async def remove_public_key_for_vasp(self, vasp_domain: str) -> None:
1263+
self._cache.remove_public_key_for_vasp(vasp_domain)
1264+
1265+
async def clear(self) -> None:
1266+
self._cache.clear()

uma/protocol/payreq_response.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,9 @@ def append_backing_signature(
226226
BackingSignature(domain=domain, signature=backing_signature)
227227
)
228228
compliance.backing_signatures = backing_signatures
229-
if self.payee_data is None:
230-
self.payee_data = {}
231-
self.payee_data["compliance"] = compliance.to_dict()
229+
payee_data = self.payee_data or {}
230+
payee_data["compliance"] = compliance.to_dict()
231+
self.payee_data = payee_data
232232

233233
def to_dict(self) -> Dict[str, Any]:
234234
resp = super().to_dict()

uma/public_key_cache.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,25 @@ def remove_public_key_for_vasp(self, vasp_domain: str) -> None:
5656

5757
def clear(self) -> None:
5858
self._cache.clear()
59+
60+
61+
class IAsyncPublicKeyCache(ABC):
62+
@abstractmethod
63+
async def fetch_public_key_for_vasp(
64+
self, vasp_domain: str
65+
) -> Optional[PubkeyResponse]:
66+
pass
67+
68+
@abstractmethod
69+
async def add_public_key_for_vasp(
70+
self, vasp_domain: str, public_key: PubkeyResponse
71+
) -> None:
72+
pass
73+
74+
@abstractmethod
75+
async def remove_public_key_for_vasp(self, vasp_domain: str) -> None:
76+
pass
77+
78+
@abstractmethod
79+
async def clear(self) -> None:
80+
pass

uma/uma.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from uuid import uuid4
1111

1212
import requests
13+
from aiohttp import ClientSession
1314
from coincurve.ecdsa import cdata_to_der, der_to_cdata, signature_normalize
1415
from coincurve.keys import PublicKey
1516
from cryptography.exceptions import InvalidSignature
@@ -52,7 +53,7 @@
5253
)
5354
from uma.protocol.post_tx_callback import PostTransactionCallback, UtxoWithAmount
5455
from uma.protocol.pubkey_response import PubkeyResponse
55-
from uma.public_key_cache import IPublicKeyCache
56+
from uma.public_key_cache import IPublicKeyCache, IAsyncPublicKeyCache
5657
from uma.signing_utils import sign_payload
5758
from uma.type_utils import none_throws
5859
from uma.uma_invoice_creator import IUmaInvoiceCreator
@@ -86,6 +87,27 @@ def fetch_public_key_for_vasp(
8687
return public_key
8788

8889

90+
async def fetch_public_key_for_vasp_async(
91+
vasp_domain: str, cache: IAsyncPublicKeyCache
92+
) -> PubkeyResponse:
93+
public_key = await cache.fetch_public_key_for_vasp(vasp_domain)
94+
if public_key:
95+
return public_key
96+
97+
scheme = "http://" if is_domain_local(vasp_domain) else "https://"
98+
url = scheme + vasp_domain + "/.well-known/lnurlpubkey"
99+
try:
100+
response_text = await _run_http_get_async(url)
101+
except Exception as ex:
102+
raise InvalidRequestException(
103+
f"Unable to fetch pubkey from {vasp_domain}. Make sure the vasp domain is correct."
104+
) from ex
105+
106+
public_key = PubkeyResponse.from_json(response_text)
107+
await cache.add_public_key_for_vasp(vasp_domain, public_key)
108+
return public_key
109+
110+
89111
def create_pubkey_response(
90112
signing_cert_chain: str,
91113
encryption_cert_chain: str,
@@ -124,6 +146,13 @@ def _run_http_get(url: str) -> str:
124146
return response.text
125147

126148

149+
async def _run_http_get_async(url: str) -> str:
150+
async with ClientSession() as session:
151+
async with session.get(url) as response:
152+
response.raise_for_status()
153+
return await response.text()
154+
155+
127156
def generate_nonce() -> str:
128157
return str(random.randint(0, 0xFFFFFFFF))
129158

0 commit comments

Comments
 (0)