This commit is contained in:
Riccardo Balbo 2025-02-23 16:52:03 +00:00
commit 44d3e28182
11 changed files with 630 additions and 638 deletions

View file

@ -13,13 +13,12 @@ Configure the extension from the "Settings" page in the top right menu when logg
### Configuration Options: ### Configuration Options:
| Key | Description | Default | | Key | Description | Default |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| relay | URL of the nostr relay for dispatching and receiving NWC events. Use public relays or a custom one. Specify `nostrclient` to connect to the [nostrclient extension](https://github.com/lnbits/nostrclient). | nostrclient | | relay | URL of the nostr relay for dispatching and receiving NWC events. Use public relays or a custom one. Specify `nostrclient` to connect to the [nostrclient extension](https://github.com/lnbits/nostrclient). | nostrclient |
| provider_key | Nostr secret key of the NWC Service Provider. | Random key generated on install | | provider_key | Nostr secret key of the NWC Service Provider. | Random key generated on install |
| relay_alias | Relay URL to display in pairing URLs. Set if different from `relay`. | Empty (uses the `relay` value) | | relay_alias | Relay URL to display in pairing URLs. Set if different from `relay`. | Empty (uses the `relay` value) |
| handle_missed_events | Number of seconds to look back for processing events missed while offline. Setting it to 0 disables this functionality. | 0 | | handle_missed_events | Number of seconds to look back for processing events missed while offline. Setting it to 0 disables this functionality. | 0 |
> [!WARNING] > [!WARNING]
> >
> Do not change `handle_missed_events` from its default value of `0` unless you fully understand its implications. > Do not change `handle_missed_events` from its default value of `0` unless you fully understand its implications.
@ -28,7 +27,6 @@ Configure the extension from the "Settings" page in the top right menu when logg
> >
> For this reason, unless you are trying to tackle this specific issue, it is recommended to leave this setting at `0`. > For this reason, unless you are trying to tackle this specific issue, it is recommended to leave this setting at `0`.
### Using Nostrclient ### Using Nostrclient
The extension is preconfigured to connect to the nostrclient extension. Install it on the same LNbits instance and configure it to expose public websocket endpoints. Refer to the [nostrclient documentation](https://github.com/lnbits/nostrclient) for more information. The extension is preconfigured to connect to the nostrclient extension. Install it on the same LNbits instance and configure it to expose public websocket endpoints. Refer to the [nostrclient documentation](https://github.com/lnbits/nostrclient) for more information.

View file

@ -32,6 +32,7 @@ def nwcprovider_stop():
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
def nwcprovider_start(): def nwcprovider_start():
task = create_permanent_unique_task("ext_nwcprovider", handle_nwc) task = create_permanent_unique_task("ext_nwcprovider", handle_nwc)
scheduled_tasks.append(task) scheduled_tasks.append(task)

33
crud.py
View file

@ -8,14 +8,22 @@ from .models import (
CreateNWCKey, CreateNWCKey,
DeleteNWC, DeleteNWC,
GetBudgetsNWC, GetBudgetsNWC,
GetNWC,
GetWalletNWC, GetWalletNWC,
NWCBudget, NWCBudget,
NWCKey, NWCKey,
NWCNewBudget,
TrackedSpendNWC, TrackedSpendNWC,
GetNWC,
NWCNewBudget
) )
from .paranoia import assert_valid_wallet_id, assert_valid_pubkey, assert_sane_string, assert_valid_timestamp_seconds, assert_valid_expiration_seconds, assert_valid_msats, assert_valid_positive_int from .paranoia import (
assert_sane_string,
assert_valid_expiration_seconds,
assert_valid_msats,
assert_valid_positive_int,
assert_valid_pubkey,
assert_valid_timestamp_seconds,
assert_valid_wallet_id,
)
db = Database("ext_nwcprovider") db = Database("ext_nwcprovider")
@ -174,7 +182,9 @@ async def get_budgets_nwc(data: GetBudgetsNWC) -> Optional[NWCBudget]:
"next_cycle": next_cycle, "next_cycle": next_cycle,
}, },
) )
tot_spent_in_range_msats = next(iter(tot_spent_in_range_msats.values())) or 0 tot_spent_in_range_msats = (
next(iter(tot_spent_in_range_msats.values())) or 0
)
# hardening # # hardening #
assert_valid_msats(tot_spent_in_range_msats) assert_valid_msats(tot_spent_in_range_msats)
@ -193,9 +203,7 @@ async def tracked_spend_nwc(data: TrackedSpendNWC, action):
# ## # # ## #
created_at = int(time.time()) created_at = int(time.time())
budgets = await get_budgets_nwc(GetBudgetsNWC( budgets = await get_budgets_nwc(GetBudgetsNWC(pubkey=data.pubkey))
pubkey=data.pubkey
))
in_budget = True in_budget = True
for budget in budgets: for budget in budgets:
last_cycle, next_cycle = budget.get_timestamp_range() last_cycle, next_cycle = budget.get_timestamp_range()
@ -206,7 +214,10 @@ async def tracked_spend_nwc(data: TrackedSpendNWC, action):
# ## # # ## #
tot_spent_in_range_msats = ( tot_spent_in_range_msats = (
next(iter((await db.fetchone( next(
iter(
(
await db.fetchone(
""" """
SELECT SUM(amount_msats) FROM nwcprovider.spent SELECT SUM(amount_msats) FROM nwcprovider.spent
WHERE pubkey = :pubkey AND created_at >= WHERE pubkey = :pubkey AND created_at >=
@ -217,7 +228,11 @@ async def tracked_spend_nwc(data: TrackedSpendNWC, action):
"last_cycle": last_cycle, "last_cycle": last_cycle,
"next_cycle": next_cycle, "next_cycle": next_cycle,
}, },
)).values())) or 0 )
).values()
)
)
or 0
) )
# hardening # # hardening #

View file

@ -6,8 +6,6 @@ from typing import Any, Dict, List, Optional
from pydantic import BaseModel from pydantic import BaseModel
from .nwcp import NWCServiceProvider
class NWCKey(BaseModel): class NWCKey(BaseModel):
pubkey: str pubkey: str
@ -29,8 +27,6 @@ class NWCKey(BaseModel):
return cls(**row) return cls(**row)
class NWCBudget(BaseModel): class NWCBudget(BaseModel):
id: int id: int
pubkey: str pubkey: str
@ -111,4 +107,3 @@ class NWCRegistrationRequest(BaseModel):
class NWCGetResponse(BaseModel): class NWCGetResponse(BaseModel):
data: NWCKey data: NWCKey
budgets: List[NWCBudget] budgets: List[NWCBudget]

16
nwcp.py
View file

@ -59,19 +59,24 @@ class MainSubscription:
if now - event["created_at"] > expire: if now - event["created_at"] > expire:
del self.events[event_id] del self.events[event_id]
deleted_ids.append(event_id) deleted_ids.append(event_id)
self.responses = [event_id for event_id in self.responses if event_id not in deleted_ids] self.responses = [
event_id for event_id in self.responses if event_id not in deleted_ids
]
if len(deleted_ids) > 0: if len(deleted_ids) > 0:
logger.debug("Garbage collected " + str(len(deleted_ids)) + " events") logger.debug("Garbage collected " + str(len(deleted_ids)) + " events")
class Config: class Config:
arbitrary_types_allowed = True arbitrary_types_allowed = True
class NWCServiceProvider: class NWCServiceProvider:
def __init__(self, private_key: Optional[str] = None, relay: Optional[str] = None, handle_missed_events: int = 0): def __init__(
self,
private_key: Optional[str] = None,
relay: Optional[str] = None,
handle_missed_events: int = 0,
):
if not relay: # Connect to nostrclient if not relay: # Connect to nostrclient
relay = "nostrclient" relay = "nostrclient"
if relay == "nostrclient": if relay == "nostrclient":
@ -145,7 +150,6 @@ class NWCServiceProvider:
self.sub.gc(self.handle_missed_events) self.sub.gc(self.handle_missed_events)
await asyncio.sleep(60) await asyncio.sleep(60)
def get_supported_methods(self): def get_supported_methods(self):
""" """
Returns the list of supported methods by this service provider. Returns the list of supported methods by this service provider.
@ -270,7 +274,7 @@ class NWCServiceProvider:
"kinds": [23194], "kinds": [23194],
"#p": [self.public_key_hex], "#p": [self.public_key_hex],
# Since the last handle_missed_events seconds (handles reboots) # Since the last handle_missed_events seconds (handles reboots)
"since": int(time.time()) - self.handle_missed_events "since": int(time.time()) - self.handle_missed_events,
} }
self.sub.requests_sub_id = self._get_new_subid() self.sub.requests_sub_id = self._get_new_subid()
# Create responses subscription (needed to track previosly responded requests) # Create responses subscription (needed to track previosly responded requests)

View file

@ -3,12 +3,14 @@ from loguru import logger
ENABLE_HARDENING = True ENABLE_HARDENING = True
def panic(reason: str): def panic(reason: str):
if not ENABLE_HARDENING: if not ENABLE_HARDENING:
return return
logger.error(f"hardening: {reason}") logger.error(f"hardening: {reason}")
raise ValueError(f"hardening: {reason}") raise ValueError(f"hardening: {reason}")
# Throw if string contains any non-printable characters # Throw if string contains any non-printable characters
def assert_printable(v: str): def assert_printable(v: str):
if not ENABLE_HARDENING: if not ENABLE_HARDENING:
@ -18,6 +20,7 @@ def assert_printable(v:str):
if not v.isprintable(): if not v.isprintable():
panic("string contains non-printable characters") panic("string contains non-printable characters")
# Check if number is valid int and not NaN # Check if number is valid int and not NaN
def assert_valid_int(v: int): def assert_valid_int(v: int):
if not ENABLE_HARDENING: if not ENABLE_HARDENING:
@ -25,6 +28,7 @@ def assert_valid_int(v:int):
if not isinstance(v, int): if not isinstance(v, int):
panic("number is not a valid int") panic("number is not a valid int")
# Check if number is valid positive int # Check if number is valid positive int
def assert_valid_positive_int(v: int): def assert_valid_positive_int(v: int):
if not ENABLE_HARDENING: if not ENABLE_HARDENING:
@ -33,6 +37,7 @@ def assert_valid_positive_int(v:int):
if v < 0: if v < 0:
panic("number is not positive") panic("number is not positive")
# Check if number is a valid sats amount # Check if number is a valid sats amount
def assert_valid_sats(v: int): def assert_valid_sats(v: int):
if not ENABLE_HARDENING: if not ENABLE_HARDENING:
@ -42,6 +47,7 @@ def assert_valid_sats(v:int):
if v >= max_sats_value: if v >= max_sats_value:
panic("sats amount looks too high") panic("sats amount looks too high")
# Check if number is a valid msats amount # Check if number is a valid msats amount
def assert_valid_msats(v: int): def assert_valid_msats(v: int):
if not ENABLE_HARDENING: if not ENABLE_HARDENING:
@ -60,6 +66,7 @@ def assert_valid_sha256(v:str):
if len(v) != 64 or not all(c in "0123456789abcdef" for c in v): if len(v) != 64 or not all(c in "0123456789abcdef" for c in v):
panic("string is not a valid sha256 hash") panic("string is not a valid sha256 hash")
# Check if value is an hash of an unexpected input (eg. empty strings, booleans etc) # Check if value is an hash of an unexpected input (eg. empty strings, booleans etc)
def assert_no_badhash(v: str): def assert_no_badhash(v: str):
bad_hashes = [ bad_hashes = [
@ -72,6 +79,7 @@ def assert_no_badhash(v:str):
if v in bad_hashes: if v in bad_hashes:
panic("bad hash detected") panic("bad hash detected")
# Check if valid nostr pubkey # Check if valid nostr pubkey
def assert_valid_pubkey(v: str): def assert_valid_pubkey(v: str):
if not ENABLE_HARDENING: if not ENABLE_HARDENING:
@ -128,6 +136,7 @@ def assert_non_empty_string(v:str):
if len(v.strip()) == 0: if len(v.strip()) == 0:
panic("string is empty") panic("string is empty")
# Assert valid json # Assert valid json
def assert_valid_json(v: str): def assert_valid_json(v: str):
if not ENABLE_HARDENING: if not ENABLE_HARDENING:
@ -135,10 +144,12 @@ def assert_valid_json(v:str):
assert_non_empty_string(v) assert_non_empty_string(v)
try: try:
import json import json
json.loads(v) json.loads(v)
except: except:
panic("string is not valid json") panic("string is not valid json")
# Check if string is a valid bolt11 invoice # Check if string is a valid bolt11 invoice
def assert_valid_bolt11(invoice: str): def assert_valid_bolt11(invoice: str):
if not ENABLE_HARDENING: if not ENABLE_HARDENING:
@ -152,4 +163,3 @@ def assert_boolean(v:bool):
return return
if not isinstance(v, bool): if not isinstance(v, bool):
panic("not a boolean") panic("not a boolean")

View file

@ -63,18 +63,19 @@ window.app = Vue.createApp({
data, data,
); );
Quasar.Notify.create({ Quasar.Notify.create({
type: 'positive', type: "positive",
message: 'Config saved!', message: "Config saved!",
}); });
Quasar.Notify.create({ Quasar.Notify.create({
type: 'warning', type: "warning",
message: 'You need to restart the server for the changes to take effect!', message:
"You need to restart the server for the changes to take effect!",
}); });
} catch (error) { } catch (error) {
Quasar.Notify.create({ Quasar.Notify.create({
type: 'negative', type: "negative",
message: "Error saving config: " + String(error), message: "Error saving config: " + String(error),
}) });
console.error("Error saving config:", error); console.error("Error saving config:", error);
} }
}, },

View file

@ -67,8 +67,8 @@
const wallet = this.getWallet(); const wallet = this.getWallet();
if (!wallet) { if (!wallet) {
Quasar.Notify.create({ Quasar.Notify.create({
type: 'negative', type: "negative",
message: 'Please select a wallet first', message: "Please select a wallet first",
}); });
return; return;
} else { } else {
@ -96,19 +96,18 @@
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
Quasar.Notify.create({ Quasar.Notify.create({
type: "positive", type: "positive",
message: "URL copied to clipboard" message: "URL copied to clipboard",
}); });
} catch (err) { } catch (err) {
Quasar.Notify.create({ Quasar.Notify.create({
type: "negative", type: "negative",
message: "Failed to copy URL." message: "Failed to copy URL.",
}); });
} }
} }
}, },
showPairingQR() { showPairingQR() {
this.pairingQrDialog.data.pairingUrl = this.pairingQrDialog.data.pairingUrl = this.pairingDialog.data.pairingUrl;
this.pairingDialog.data.pairingUrl;
this.pairingQrDialog.show = true; this.pairingQrDialog.show = true;
}, },
closePairingQrDialog() { closePairingQrDialog() {
@ -137,8 +136,7 @@
this.connectDialog.data.budgets.push({ this.connectDialog.data.budgets.push({
budget_sats: 1000, budget_sats: 1000,
used_budget_sats: 0, used_budget_sats: 0,
created_at: created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
expiration: "never", expiration: "never",
}); });
}, },
@ -171,8 +169,7 @@
return out; return out;
}, },
deleteNWC: async function (pubkey) { deleteNWC: async function (pubkey) {
Quasar Quasar.Dialog.create({
.Dialog.create({
title: "Confirm Deletion", title: "Confirm Deletion",
message: "Are you sure you want to delete this connection?", message: "Are you sure you want to delete this connection?",
cancel: true, cancel: true,
@ -189,7 +186,7 @@
this.loadNwcs(); this.loadNwcs();
Quasar.Notify.create({ Quasar.Notify.create({
type: "positive", type: "positive",
message: "Deleted successfully" message: "Deleted successfully",
}); });
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error); LNbits.utils.notifyApiError(error);

View file

@ -19,10 +19,20 @@ from loguru import logger
from .crud import get_config_nwc, get_nwc, tracked_spend_nwc from .crud import get_config_nwc, get_nwc, tracked_spend_nwc
from .execution_queue import execution_queue from .execution_queue import execution_queue
from .models import NWCKey, TrackedSpendNWC, GetNWC from .models import GetNWC, NWCKey, TrackedSpendNWC
from .nwcp import NWCServiceProvider from .nwcp import NWCServiceProvider
from .paranoia import (
assert_boolean,
assert_sane_string,
assert_valid_bolt11,
assert_valid_expiration_seconds,
assert_valid_msats,
assert_valid_positive_int,
assert_valid_pubkey,
assert_valid_sha256,
assert_valid_wallet_id,
)
from .permission import nwc_permissions from .permission import nwc_permissions
from .paranoia import assert_valid_wallet_id, assert_valid_pubkey, assert_boolean, assert_sane_string, assert_valid_expiration_seconds, assert_valid_msats, assert_valid_positive_int, assert_valid_bolt11, assert_valid_sha256
async def _check(nwc: Optional[NWCKey], method: str) -> Optional[Dict]: async def _check(nwc: Optional[NWCKey], method: str) -> Optional[Dict]:
@ -78,10 +88,7 @@ async def _process_invoice(
payment_hash = None payment_hash = None
try: try:
in_budget, payment_hash = await tracked_spend_nwc( in_budget, payment_hash = await tracked_spend_nwc(
TrackedSpendNWC( TrackedSpendNWC(pubkey=pubkey, amount_msats=amount_msats), execute_payment
pubkey=pubkey,
amount_msats=amount_msats
), execute_payment
) )
if not in_budget: if not in_budget:
error = { error = {
@ -121,19 +128,14 @@ async def _process_invoice(
async def _on_pay_invoice( async def _on_pay_invoice(
sp: NWCServiceProvider, sp: NWCServiceProvider, pubkey: str, payload: Dict
pubkey: str,
payload: Dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]: ) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
# hardening # # hardening #
assert_valid_pubkey(pubkey) assert_valid_pubkey(pubkey)
# ## # # ## #
nwc = await get_nwc(GetNWC( nwc = await get_nwc(GetNWC(pubkey=pubkey, refresh_last_used=True))
pubkey=pubkey,
refresh_last_used=True
))
error = await _check(nwc, "pay_invoice") error = await _check(nwc, "pay_invoice")
if error: if error:
return [(None, error, [])] return [(None, error, [])]
@ -167,9 +169,7 @@ async def _on_pay_invoice(
async def _on_multi_pay_invoice( async def _on_multi_pay_invoice(
sp: NWCServiceProvider, sp: NWCServiceProvider, pubkey: str, payload: Dict
pubkey: str,
payload: Dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]: ) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
# hardening # # hardening #
@ -228,9 +228,7 @@ async def _on_multi_pay_invoice(
async def _on_make_invoice( async def _on_make_invoice(
sp: NWCServiceProvider, sp: NWCServiceProvider, pubkey: str, payload: Dict
pubkey: str,
payload: Dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]: ) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
# hardening # # hardening #
@ -300,9 +298,7 @@ async def _on_make_invoice(
async def _on_lookup_invoice( async def _on_lookup_invoice(
sp: NWCServiceProvider, sp: NWCServiceProvider, pubkey: str, payload: Dict
pubkey: str,
payload: Dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]: ) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
# hardening # # hardening #
@ -339,7 +335,10 @@ async def _on_lookup_invoice(
is_settled = not payment.pending is_settled = not payment.pending
timestamp = int(payment.time.timestamp()) or int(invoice_data.date) timestamp = int(payment.time.timestamp()) or int(invoice_data.date)
expiry = int(payment.expiry.timestamp()) or timestamp + 3600 expiry = int(payment.expiry.timestamp()) or timestamp + 3600
preimage = payment.preimage or "0000000000000000000000000000000000000000000000000000000000000000" preimage = (
payment.preimage
or "0000000000000000000000000000000000000000000000000000000000000000"
)
res: Dict = { res: Dict = {
"type": "outgoing" if payment.is_out else "incoming", "type": "outgoing" if payment.is_out else "incoming",
"invoice": payment.bolt11, "invoice": payment.bolt11,
@ -362,9 +361,7 @@ async def _on_lookup_invoice(
async def _on_list_transactions( async def _on_list_transactions(
sp: NWCServiceProvider, sp: NWCServiceProvider, pubkey: str, payload: Dict
pubkey: str,
payload: Dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]: ) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
# hardening # # hardening #
assert_valid_pubkey(pubkey) assert_valid_pubkey(pubkey)
@ -435,9 +432,7 @@ async def _on_list_transactions(
async def _on_get_balance( async def _on_get_balance(
sp: NWCServiceProvider, sp: NWCServiceProvider, pubkey: str, payload: Dict
pubkey: str,
payload: Dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]: ) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
# hardening # # hardening #
@ -460,9 +455,7 @@ async def _on_get_balance(
async def _on_get_info( async def _on_get_info(
sp: NWCServiceProvider, sp: NWCServiceProvider, pubkey: str, payload: Dict
pubkey: str,
payload: Dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]: ) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
# hardening # # hardening #

View file

@ -852,7 +852,6 @@ async def test_budget_refresh():
await wallet1.close() await wallet1.close()
# Mostly AI generated pentests # Mostly AI generated pentests
@ -882,7 +881,7 @@ async def test_idor_vulnerability():
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get( resp = await client.get(
f"http://localhost:5002/nwcprovider/api/v1/nwc/{nwc_wallet1['pubkey']}", f"http://localhost:5002/nwcprovider/api/v1/nwc/{nwc_wallet1['pubkey']}",
headers={"X-Api-Key": wallets["wallet2"]["admin_key"]} headers={"X-Api-Key": wallets["wallet2"]["admin_key"]},
) )
assert resp.status_code == 500 assert resp.status_code == 500
assert "Pubkey has no associated wallet" in resp.text assert "Pubkey has no associated wallet" in resp.text
@ -896,7 +895,7 @@ async def test_sql_injection():
resp = await client.put( resp = await client.put(
f"http://localhost:5002/nwcprovider/api/v1/nwc/{malicious_pubkey}", f"http://localhost:5002/nwcprovider/api/v1/nwc/{malicious_pubkey}",
headers={"X-Api-Key": wallets["wallet1"]["admin_key"]}, headers={"X-Api-Key": wallets["wallet1"]["admin_key"]},
json={"permissions": ["pay"], "description": "test"} json={"permissions": ["pay"], "description": "test"},
) )
# Should be rejected by input validation # Should be rejected by input validation
assert resp.status_code == 400 assert resp.status_code == 400
@ -941,8 +940,14 @@ async def test_budget_bypass():
"wallet1", "wallet1",
"test_budget_bypass", "test_budget_bypass",
["pay", "invoice"], ["pay", "invoice"],
[{"budget_msats": 100000, "refresh_window": 3600, "created_at": int(time.time())}], [
0 {
"budget_msats": 100000,
"refresh_window": 3600,
"created_at": int(time.time()),
}
],
0,
) )
wallet = NWCWallet(nwc["pairing"]) wallet = NWCWallet(nwc["pairing"])
await wallet.start() await wallet.start()
@ -964,14 +969,16 @@ async def test_budget_bypass():
async def test_unauthorized_config(): async def test_unauthorized_config():
"""Test unauthorized access to config endpoint""" """Test unauthorized access to config endpoint"""
malicious_relay = "ws://attacker-relay.example" malicious_relay = "ws://attacker-relay.example"
async def set_config_nwc(key: str, value: str): async def set_config_nwc(key: str, value: str):
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.post( resp = await client.post(
"http://localhost:5002/nwcprovider/api/v1/config", "http://localhost:5002/nwcprovider/api/v1/config",
json={key: value}, json={key: value},
headers={"X-Api-Key": "lnbitsadmin"} # Assuming admin key headers={"X-Api-Key": "lnbitsadmin"}, # Assuming admin key
) )
assert resp.status_code == 401 assert resp.status_code == 401
await set_config_nwc("relay", malicious_relay) await set_config_nwc("relay", malicious_relay)

View file

@ -2,7 +2,7 @@ from http import HTTPStatus
from typing import Dict, List, Optional from typing import Dict, List, Optional
import secp256k1 import secp256k1
from fastapi import APIRouter, Depends, Request, Query from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from lnbits.core.models import WalletTypeInfo from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import check_admin, require_admin_key from lnbits.decorators import check_admin, require_admin_key
@ -18,35 +18,34 @@ from .crud import (
set_config_nwc, set_config_nwc,
) )
from .models import ( from .models import (
NWCRegistrationRequest,
GetBudgetsNWC,
GetWalletNWC,
NWCGetResponse,
CreateNWCKey, CreateNWCKey,
DeleteNWC, DeleteNWC,
GetBudgetsNWC,
GetNWC, GetNWC,
GetBudgetsNWC GetWalletNWC,
NWCGetResponse,
NWCRegistrationRequest,
)
from .paranoia import (
assert_boolean,
assert_sane_string,
assert_valid_pubkey,
assert_valid_wallet_id,
) )
from .permission import nwc_permissions from .permission import nwc_permissions
from .paranoia import assert_valid_wallet_id, assert_valid_pubkey, assert_sane_string, assert_boolean
nwcprovider_api_router = APIRouter() nwcprovider_api_router = APIRouter()
# Get supported permissions # Get supported permissions
@nwcprovider_api_router.get( @nwcprovider_api_router.get("/api/v1/permissions", status_code=HTTPStatus.OK)
"/api/v1/permissions",
status_code=HTTPStatus.OK
)
async def api_get_permissions() -> Dict: async def api_get_permissions() -> Dict:
return nwc_permissions return nwc_permissions
## Get nwc keys associated with the wallet ## Get nwc keys associated with the wallet
@nwcprovider_api_router.get( @nwcprovider_api_router.get(
"/api/v1/nwc", "/api/v1/nwc", status_code=HTTPStatus.OK, response_model=List[NWCGetResponse]
status_code=HTTPStatus.OK,
response_model=List[NWCGetResponse]
) )
async def api_get_nwcs( async def api_get_nwcs(
include_expired: bool = False, include_expired: bool = False,
@ -61,16 +60,12 @@ async def api_get_nwcs(
assert_boolean(calculate_spent_budget) assert_boolean(calculate_spent_budget)
# ## # # ## #
wallet_nwcs = GetWalletNWC( wallet_nwcs = GetWalletNWC(wallet=wallet_id, include_expired=include_expired)
wallet=wallet_id,
include_expired=include_expired
)
nwcs = await get_wallet_nwcs(wallet_nwcs) nwcs = await get_wallet_nwcs(wallet_nwcs)
out = [] out = []
for nwc in nwcs: for nwc in nwcs:
budgets_nwc = GetBudgetsNWC( budgets_nwc = GetBudgetsNWC(
pubkey=nwc.pubkey, pubkey=nwc.pubkey, calculate_spent=calculate_spent_budget
calculate_spent=calculate_spent_budget
) )
budgets = await get_budgets_nwc(budgets_nwc) budgets = await get_budgets_nwc(budgets_nwc)
res = NWCGetResponse(data=nwc, budgets=budgets) res = NWCGetResponse(data=nwc, budgets=budgets)
@ -80,14 +75,12 @@ async def api_get_nwcs(
# Get a nwc key # Get a nwc key
@nwcprovider_api_router.get( @nwcprovider_api_router.get(
"/api/v1/nwc/{pubkey}", "/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK, response_model=NWCGetResponse
status_code=HTTPStatus.OK,
response_model=NWCGetResponse
) )
async def api_get_nwc( async def api_get_nwc(
pubkey: str, pubkey: str,
include_expired: bool = False, include_expired: bool = False,
wallet: WalletTypeInfo = Depends(require_admin_key) wallet: WalletTypeInfo = Depends(require_admin_key),
) -> NWCGetResponse: ) -> NWCGetResponse:
wallet_id = wallet.wallet.id wallet_id = wallet.wallet.id
@ -97,27 +90,22 @@ async def api_get_nwc(
assert_valid_wallet_id(wallet_id) assert_valid_wallet_id(wallet_id)
# ## # # ## #
nwc = await get_nwc(GetNWC(pubkey=pubkey, wallet=wallet_id, include_expired=include_expired)) nwc = await get_nwc(
GetNWC(pubkey=pubkey, wallet=wallet_id, include_expired=include_expired)
)
if not nwc: if not nwc:
raise Exception("Pubkey has no associated wallet") raise Exception("Pubkey has no associated wallet")
res = NWCGetResponse(data=nwc, budgets=await get_budgets_nwc( res = NWCGetResponse(
GetBudgetsNWC( data=nwc, budgets=await get_budgets_nwc(GetBudgetsNWC(pubkey=pubkey))
pubkey=pubkey
) )
))
return res return res
# Get pairing url for given secret # Get pairing url for given secret
@nwcprovider_api_router.get( @nwcprovider_api_router.get(
"/api/v1/pairing/{secret}", "/api/v1/pairing/{secret}", status_code=HTTPStatus.OK, response_model=str
status_code=HTTPStatus.OK,
response_model=str
) )
async def api_get_pairing_url( async def api_get_pairing_url(req: Request, secret: str) -> str:
req: Request,
secret: str
) -> str:
# hardening # # hardening #
assert_sane_string(secret) assert_sane_string(secret)
@ -183,23 +171,15 @@ async def api_register_nwc(
budgets=data.budgets, budgets=data.budgets,
) )
) )
budgets = await get_budgets_nwc( budgets = await get_budgets_nwc(GetBudgetsNWC(pubkey=pubkey))
GetBudgetsNWC(
pubkey=pubkey
)
)
res = NWCGetResponse(data=nwc, budgets=budgets) res = NWCGetResponse(data=nwc, budgets=budgets)
return res return res
# Delete a nwc key # Delete a nwc key
@nwcprovider_api_router.delete( @nwcprovider_api_router.delete("/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK)
"/api/v1/nwc/{pubkey}",
status_code=HTTPStatus.OK
)
async def api_delete_nwc( async def api_delete_nwc(
pubkey: str, pubkey: str, wallet: WalletTypeInfo = Depends(require_admin_key)
wallet: WalletTypeInfo = Depends(require_admin_key)
): ):
wallet_id = wallet.wallet.id wallet_id = wallet.wallet.id
@ -208,20 +188,13 @@ async def api_delete_nwc(
assert_valid_wallet_id(wallet_id) assert_valid_wallet_id(wallet_id)
# ## # # ## #
await delete_nwc(DeleteNWC( await delete_nwc(DeleteNWC(pubkey=pubkey, wallet=wallet_id))
pubkey=pubkey, return JSONResponse(content={"message": f"NWC key {pubkey} deleted successfully."})
wallet=wallet_id
))
return JSONResponse(
content={"message": f"NWC key {pubkey} deleted successfully."}
)
# Get config # Get config
@nwcprovider_api_router.get( @nwcprovider_api_router.get(
"/api/v1/config", "/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)]
) )
async def api_get_all_config_nwc(): async def api_get_all_config_nwc():
config = await get_all_config_nwc() config = await get_all_config_nwc()
@ -243,9 +216,7 @@ async def api_get_config_nwc(key: str):
# Set config # Set config
@nwcprovider_api_router.post( @nwcprovider_api_router.post(
"/api/v1/config", "/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)]
) )
async def api_set_config_nwc(req: Request): async def api_set_config_nwc(req: Request):
data = await req.json() data = await req.json()