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

@ -12,13 +12,12 @@ Configure the extension from the "Settings" page in the top right menu when logg
### Configuration Options:
| 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 |
| 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) |
| handle_missed_events | Number of seconds to look back for processing events missed while offline. Setting it to 0 disables this functionality. | 0 |
| 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 |
| 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) |
| handle_missed_events | Number of seconds to look back for processing events missed while offline. Setting it to 0 disables this functionality. | 0 |
> [!WARNING]
>
@ -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`.
### 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.

View file

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

49
crud.py
View file

@ -8,14 +8,22 @@ from .models import (
CreateNWCKey,
DeleteNWC,
GetBudgetsNWC,
GetNWC,
GetWalletNWC,
NWCBudget,
NWCKey,
NWCNewBudget,
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")
@ -73,7 +81,7 @@ async def delete_nwc(data: DeleteNWC) -> None:
async def get_wallet_nwcs(data: GetWalletNWC) -> List[NWCKey]:
expires = int(time.time()) if not data.include_expired else -1
expires = int(time.time()) if not data.include_expired else -1
# hardening #
assert_valid_wallet_id(data.wallet)
@ -94,7 +102,7 @@ async def get_wallet_nwcs(data: GetWalletNWC) -> List[NWCKey]:
async def get_nwc(data: GetNWC) -> Optional[NWCKey]:
expires = int(time.time()) if not data.include_expired else -1
expires = int(time.time()) if not data.include_expired else -1
# hardening #
assert_valid_pubkey(data.pubkey)
@ -174,7 +182,9 @@ async def get_budgets_nwc(data: GetBudgetsNWC) -> Optional[NWCBudget]:
"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 #
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())
budgets = await get_budgets_nwc(GetBudgetsNWC(
pubkey=data.pubkey
))
budgets = await get_budgets_nwc(GetBudgetsNWC(pubkey=data.pubkey))
in_budget = True
for budget in budgets:
last_cycle, next_cycle = budget.get_timestamp_range()
@ -206,18 +214,25 @@ async def tracked_spend_nwc(data: TrackedSpendNWC, action):
# ## #
tot_spent_in_range_msats = (
next(iter((await db.fetchone(
"""
next(
iter(
(
await db.fetchone(
"""
SELECT SUM(amount_msats) FROM nwcprovider.spent
WHERE pubkey = :pubkey AND created_at >=
:last_cycle AND created_at < :next_cycle
""",
{
"pubkey": data.pubkey,
"last_cycle": last_cycle,
"next_cycle": next_cycle,
},
)).values())) or 0
{
"pubkey": data.pubkey,
"last_cycle": last_cycle,
"next_cycle": next_cycle,
},
)
).values()
)
)
or 0
)
# hardening #

View file

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

16
nwcp.py
View file

@ -59,19 +59,24 @@ class MainSubscription:
if now - event["created_at"] > expire:
del self.events[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:
logger.debug("Garbage collected " + str(len(deleted_ids)) + " events")
class Config:
arbitrary_types_allowed = True
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
relay = "nostrclient"
if relay == "nostrclient":
@ -145,7 +150,6 @@ class NWCServiceProvider:
self.sub.gc(self.handle_missed_events)
await asyncio.sleep(60)
def get_supported_methods(self):
"""
Returns the list of supported methods by this service provider.
@ -270,7 +274,7 @@ class NWCServiceProvider:
"kinds": [23194],
"#p": [self.public_key_hex],
# 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()
# Create responses subscription (needed to track previosly responded requests)

View file

@ -3,38 +3,43 @@ from loguru import logger
ENABLE_HARDENING = True
def panic(reason:str):
def panic(reason: str):
if not ENABLE_HARDENING:
return
logger.error(f"hardening: {reason}")
raise ValueError(f"hardening: {reason}")
# Throw if string contains any non-printable characters
def assert_printable(v:str):
def assert_printable(v: str):
if not ENABLE_HARDENING:
return
if not isinstance(v, str):
panic("not a string "+str(v))
panic("not a string " + str(v))
if not v.isprintable():
panic("string contains non-printable characters")
# 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:
return
if not isinstance(v, int):
panic("number is not a valid 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:
return
assert_valid_int(v)
if v < 0:
panic("number is not positive")
# Check if number is a valid sats amount
def assert_valid_sats(v:int):
def assert_valid_sats(v: int):
if not ENABLE_HARDENING:
return
assert_valid_positive_int(v)
@ -42,8 +47,9 @@ def assert_valid_sats(v:int):
if v >= max_sats_value:
panic("sats amount looks too high")
# Check if number is a valid msats amount
def assert_valid_msats(v:int):
def assert_valid_msats(v: int):
if not ENABLE_HARDENING:
return
assert_valid_positive_int(v)
@ -53,27 +59,29 @@ def assert_valid_msats(v:int):
# Check if string is a valid sha256 hash
def assert_valid_sha256(v:str):
def assert_valid_sha256(v: str):
if not ENABLE_HARDENING:
return
assert_printable(v)
if len(v) != 64 or not all(c in "0123456789abcdef" for c in v):
panic("string is not a valid sha256 hash")
# 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 = [
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", # empty string
"36a9e7f1c95b82ffb99743e0c5c4ce95d83c9a430aac59f84ef3cbfab6145068", # 1 space string
"c1c4b7fbd3e146bb14ec6258e5231c1ec703590721ff1e321b179a62b5857c9c", # None
"cdca0b9bb2325fc8ed7eba7734a3a1f876d919221399b6587ae7d26305adee9d", # True
"f9e08f8b038b1b401497f17da3adc120667ac742bf035657869a6ca1cd180e69", # False
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", # empty string
"36a9e7f1c95b82ffb99743e0c5c4ce95d83c9a430aac59f84ef3cbfab6145068", # 1 space string
"c1c4b7fbd3e146bb14ec6258e5231c1ec703590721ff1e321b179a62b5857c9c", # None
"cdca0b9bb2325fc8ed7eba7734a3a1f876d919221399b6587ae7d26305adee9d", # True
"f9e08f8b038b1b401497f17da3adc120667ac742bf035657869a6ca1cd180e69", # False
]
if v in bad_hashes:
panic("bad hash detected")
# Check if valid nostr pubkey
def assert_valid_pubkey(v:str):
def assert_valid_pubkey(v: str):
if not ENABLE_HARDENING:
return
assert_valid_sha256(v)
@ -81,7 +89,7 @@ def assert_valid_pubkey(v:str):
# Check if valid wallet id
def assert_valid_wallet_id(v:str):
def assert_valid_wallet_id(v: str):
if not ENABLE_HARDENING:
return
assert_printable(v)
@ -90,7 +98,7 @@ def assert_valid_wallet_id(v:str):
# Check if valid timestamp in seconds
def assert_valid_timestamp_seconds(v:int):
def assert_valid_timestamp_seconds(v: int):
if not ENABLE_HARDENING:
return
assert_valid_positive_int(v)
@ -112,7 +120,7 @@ def assert_valid_expiration_seconds(v: int):
# Check if string is within sane parameters
def assert_sane_string(v:str):
def assert_sane_string(v: str):
if not ENABLE_HARDENING:
return
assert_printable(v)
@ -121,35 +129,37 @@ def assert_sane_string(v:str):
# Check if string is a non-empty string
def assert_non_empty_string(v:str):
def assert_non_empty_string(v: str):
if not ENABLE_HARDENING:
return
assert_printable(v)
if len(v.strip()) == 0:
panic("string is empty")
# Assert valid json
def assert_valid_json(v:str):
def assert_valid_json(v: str):
if not ENABLE_HARDENING:
return
assert_non_empty_string(v)
try:
import json
json.loads(v)
except:
panic("string is not valid json")
# Check if string is a valid bolt11 invoice
def assert_valid_bolt11(invoice:str):
def assert_valid_bolt11(invoice: str):
if not ENABLE_HARDENING:
return
assert_printable(invoice)
# Check if boolean
def assert_boolean(v:bool):
def assert_boolean(v: bool):
if not ENABLE_HARDENING:
return
if not isinstance(v, bool):
panic("not a boolean")

View file

@ -1,86 +1,87 @@
window.app = Vue.createApp({
el: "#vue",
mixins: [windowMixin],
delimiters: ["${", "}"],
data: function () {
return {
entries: [],
columns: [
{
name: "key",
required: true,
label: "Key",
align: "left",
field: (row) => row.key,
sortable: true,
},
{
name: "value",
required: true,
label: "Value",
align: "left",
field: (row) => row.value,
sortable: true,
},
],
};
},
el: "#vue",
mixins: [windowMixin],
delimiters: ["${", "}"],
data: function () {
return {
entries: [],
columns: [
{
name: "key",
required: true,
label: "Key",
align: "left",
field: (row) => row.key,
sortable: true,
},
{
name: "value",
required: true,
label: "Value",
align: "left",
field: (row) => row.value,
sortable: true,
},
],
};
},
methods: {
fetchConfig() {
this.entries = [];
LNbits.api
.request(
"GET",
"/nwcprovider/api/v1/config",
this.g.user.wallets[0].adminkey,
)
.then((response) => {
const newEntries = [];
for (const [key, value] of Object.entries(response.data)) {
newEntries.push({
key: key,
value: value,
});
}
this.entries.length = 0;
this.entries.push(...newEntries);
})
.catch(function (error) {
console.error("Error fetching config:", error);
});
},
async saveConfig() {
const data = {};
for (const entry of this.entries) {
data[entry.key] = entry.value;
}
try {
const response = await LNbits.api.request(
"POST",
"/nwcprovider/api/v1/config",
this.g.user.wallets[0].adminkey,
data,
);
Quasar.Notify.create({
type: 'positive',
message: 'Config saved!',
});
Quasar.Notify.create({
type: 'warning',
message: 'You need to restart the server for the changes to take effect!',
});
} catch (error) {
Quasar.Notify.create({
type: 'negative',
message: "Error saving config: "+String(error),
})
console.error("Error saving config:", error);
}
},
methods: {
fetchConfig() {
this.entries = [];
LNbits.api
.request(
"GET",
"/nwcprovider/api/v1/config",
this.g.user.wallets[0].adminkey,
)
.then((response) => {
const newEntries = [];
for (const [key, value] of Object.entries(response.data)) {
newEntries.push({
key: key,
value: value,
});
}
this.entries.length = 0;
this.entries.push(...newEntries);
})
.catch(function (error) {
console.error("Error fetching config:", error);
});
},
async saveConfig() {
const data = {};
for (const entry of this.entries) {
data[entry.key] = entry.value;
}
try {
const response = await LNbits.api.request(
"POST",
"/nwcprovider/api/v1/config",
this.g.user.wallets[0].adminkey,
data,
);
Quasar.Notify.create({
type: "positive",
message: "Config saved!",
});
Quasar.Notify.create({
type: "warning",
message:
"You need to restart the server for the changes to take effect!",
});
} catch (error) {
Quasar.Notify.create({
type: "negative",
message: "Error saving config: " + String(error),
});
console.error("Error saving config:", error);
}
},
},
created: function () {
this.fetchConfig();
},
});
created: function () {
this.fetchConfig();
},
});

View file

@ -1,389 +1,386 @@
window.app = Vue.createApp({
el: "#vue",
mixins: [windowMixin],
delimiters: ["${", "}"],
data: function () {
return {
selectedWallet: null,
nodePermissions: [],
nwcEntries: [],
nwcsTable: {
columns: [
{
name: "description",
align: "left",
label: "Description",
field: "description",
},
{ name: "status", align: "left", label: "Status", field: "status" },
{
name: "last_used",
align: "left",
label: "Last used",
field: "last_used",
},
{
name: "created_at",
align: "left",
label: "Created",
field: "created_at",
},
{
name: "expires_at",
align: "left",
label: "Expires",
field: "expires_at",
},
],
pagination: {
rowsPerPage: 10,
window.app = Vue.createApp({
el: "#vue",
mixins: [windowMixin],
delimiters: ["${", "}"],
data: function () {
return {
selectedWallet: null,
nodePermissions: [],
nwcEntries: [],
nwcsTable: {
columns: [
{
name: "description",
align: "left",
label: "Description",
field: "description",
},
},
connectDialog: {
show: false,
data: {},
},
pairingDialog: {
show: false,
data: {
pairingUrl: "",
{ name: "status", align: "left", label: "Status", field: "status" },
{
name: "last_used",
align: "left",
label: "Last used",
field: "last_used",
},
},
pairingQrDialog: {
show: false,
data: {
pairingUrl: "",
{
name: "created_at",
align: "left",
label: "Created",
field: "created_at",
},
{
name: "expires_at",
align: "left",
label: "Expires",
field: "expires_at",
},
],
pagination: {
rowsPerPage: 10,
},
connectionInfoDialog: {
show: false,
data: {},
},
connectDialog: {
show: false,
data: {},
},
pairingDialog: {
show: false,
data: {
pairingUrl: "",
},
};
},
},
pairingQrDialog: {
show: false,
data: {
pairingUrl: "",
},
},
connectionInfoDialog: {
show: false,
data: {},
},
};
},
methods: {
showConnectDialog() {
const wallet = this.getWallet();
if (!wallet) {
methods: {
showConnectDialog() {
const wallet = this.getWallet();
if (!wallet) {
Quasar.Notify.create({
type: "negative",
message: "Please select a wallet first",
});
return;
} else {
this.connectDialog.show = true;
}
},
openConnectionInfoDialog(data) {
this.connectionInfoDialog.data = data;
this.connectionInfoDialog.show = true;
},
closeConnectionInfoDialog() {
this.connectionInfoDialog.show = false;
},
openPairingUrl() {
const url = this.pairingDialog.data.pairingUrl;
if (url) window.open(url, "_blank");
},
go(url) {
window.open(url, "_blank");
},
async copyPairingUrl() {
const url = this.pairingDialog.data.pairingUrl;
if (url) {
try {
await navigator.clipboard.writeText(url);
Quasar.Notify.create({
type: 'negative',
message: 'Please select a wallet first',
type: "positive",
message: "URL copied to clipboard",
});
} catch (err) {
Quasar.Notify.create({
type: "negative",
message: "Failed to copy URL.",
});
return;
} else {
this.connectDialog.show = true;
}
},
openConnectionInfoDialog(data) {
this.connectionInfoDialog.data = data;
this.connectionInfoDialog.show = true;
},
closeConnectionInfoDialog() {
this.connectionInfoDialog.show = false;
},
openPairingUrl() {
const url = this.pairingDialog.data.pairingUrl;
if (url) window.open(url, "_blank");
},
go(url) {
window.open(url, "_blank");
},
async copyPairingUrl() {
const url = this.pairingDialog.data.pairingUrl;
if (url) {
}
},
showPairingQR() {
this.pairingQrDialog.data.pairingUrl = this.pairingDialog.data.pairingUrl;
this.pairingQrDialog.show = true;
},
closePairingQrDialog() {
this.pairingQrDialog.show = false;
},
loadConnectDialogData() {
this.connectDialog.data = {
description: "",
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 7,
neverExpires: true,
permissions: [],
budgets: [],
};
for (const permission of this.nodePermissions) {
this.connectDialog.data.permissions.push({
key: permission.key,
name: permission.name,
value: permission.value,
});
}
},
deleteBudget(index) {
this.connectDialog.data.budgets.splice(index, 1);
},
addBudget() {
this.connectDialog.data.budgets.push({
budget_sats: 1000,
used_budget_sats: 0,
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
expiration: "never",
});
},
closeConnectDialog() {
this.connectDialog.show = false;
this.loadConnectDialogData();
},
getWallet: function () {
let wallet = undefined;
for (let i = 0; i < this.g.user.wallets.length; i++) {
if (this.g.user.wallets[i].id == this.selectedWallet) {
wallet = this.g.user.wallets[i];
break;
}
}
return wallet;
},
async generateKeyPair() {
while (!window.NobleSecp256k1) {
await new Promise((resolve) => setTimeout(resolve, 1));
}
const privKeyBytes = window.NobleSecp256k1.utils.randomPrivateKey();
const pubKeyBytes = window.NobleSecp256k1.getPublicKey(privKeyBytes);
const out = {
privKeyBytes: privKeyBytes,
pubKeyBytes: pubKeyBytes,
privKey: window.NobleSecp256k1.etc.bytesToHex(privKeyBytes),
pubKey: window.NobleSecp256k1.etc.bytesToHex(pubKeyBytes.slice(1)),
};
return out;
},
deleteNWC: async function (pubkey) {
Quasar.Dialog.create({
title: "Confirm Deletion",
message: "Are you sure you want to delete this connection?",
cancel: true,
persistent: true,
})
.onOk(async () => {
try {
await navigator.clipboard.writeText(url);
const wallet = this.getWallet();
const response = await LNbits.api.request(
"DELETE",
`/nwcprovider/api/v1/nwc/${pubkey}`,
wallet.adminkey,
);
this.loadNwcs();
Quasar.Notify.create({
type: "positive",
message: "URL copied to clipboard"
});
} catch (err) {
Quasar.Notify.create({
type: "negative",
message: "Failed to copy URL."
message: "Deleted successfully",
});
} catch (error) {
LNbits.utils.notifyApiError(error);
}
}
},
showPairingQR() {
this.pairingQrDialog.data.pairingUrl =
this.pairingDialog.data.pairingUrl;
this.pairingQrDialog.show = true;
},
closePairingQrDialog() {
this.pairingQrDialog.show = false;
},
loadConnectDialogData() {
this.connectDialog.data = {
description: "",
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 7,
neverExpires: true,
permissions: [],
budgets: [],
};
for (const permission of this.nodePermissions) {
this.connectDialog.data.permissions.push({
key: permission.key,
name: permission.name,
value: permission.value,
})
.onCancel(() => {
// User canceled the operation
});
},
loadNwcs: async function () {
const wallet = this.getWallet();
if (!wallet) {
this.nwcs = [];
return;
}
try {
const response = await LNbits.api.request(
"GET",
"/nwcprovider/api/v1/nwc?include_expired=true&calculate_spent_budget=true",
wallet.adminkey,
);
this.nwcs = response.data;
} catch (error) {
this.nwcs = [];
}
try {
const response = await LNbits.api.request(
"GET",
"/nwcprovider/api/v1/permissions",
wallet.adminkey,
);
const permissions = [];
for (const [key, value] of Object.entries(response.data)) {
permissions.push({
key: key,
name: value.name,
value: value.default,
});
}
},
deleteBudget(index) {
this.connectDialog.data.budgets.splice(index, 1);
},
addBudget() {
this.connectDialog.data.budgets.push({
budget_sats: 1000,
used_budget_sats: 0,
this.nodePermissions = permissions;
} catch (error) {
Lnbits.utils.notifyApiError(error);
}
this.loadConnectDialogData();
const newTableEntries = [];
for (const nwc of this.nwcs) {
const t = Quasar.date.formatDate(
new Date(nwc.data.created_at * 1000),
"YYYY-MM-DD HH:mm",
);
const e =
nwc.data.expires_at > 0
? Quasar.date.formatDate(
new Date(nwc.data.expires_at * 1000),
"YYYY-MM-DD HH:mm",
)
: "Never";
const l = Quasar.date.formatDate(
new Date(nwc.data.last_used * 1000),
"YYYY-MM-DD HH:mm",
);
const nwcTableEntry = {
description: nwc.data.description,
created_at: t,
expires_at: e,
last_used: l,
pubkey: nwc.data.pubkey,
permissions: nwc.data.permissions,
budgets: [],
status: "Active",
};
if (
nwc.data.expires_at > 0 &&
nwc.data.expires_at < new Date().getTime() / 1000
) {
nwcTableEntry.status = "Expired";
}
for (const budget of nwc.budgets) {
const createdAt = Quasar.date.formatDate(
new Date(budget.created_at * 1000),
"YYYY-MM-DD HH:mm",
);
let refreshWindow = budget.refresh_window;
if (refreshWindow <= 0) {
refreshWindow = "Never";
} else if (refreshWindow == 60 * 60 * 24) {
refreshWindow = "Daily";
} else if (refreshWindow == 60 * 60 * 24 * 7) {
refreshWindow = "Weekly";
} else if (refreshWindow == 60 * 60 * 24 * 30) {
refreshWindow = "Monthly";
} else if (refreshWindow == 60 * 60 * 24 * 365) {
refreshWindow = "Yearly";
}
nwcTableEntry.budgets.push({
budget_sats: budget.budget_msats / 1000,
used_budget_sats: budget.used_budget_msats / 1000,
created_at: createdAt,
refresh_window: refreshWindow,
});
}
newTableEntries.push(nwcTableEntry);
}
this.nwcEntries = newTableEntries;
},
closePairingDialog() {
this.pairingDialog.show = false;
},
async showPairingDialog(secret) {
let response = await LNbits.api.request(
"GET",
"/nwcprovider/api/v1/pairing/{SECRET}",
);
response = response.data;
response = response.replace("{SECRET}", secret);
this.pairingDialog.data.pairingUrl = response;
this.pairingDialog.show = true;
},
async confirmConnectDialog() {
const keyPair = await this.generateKeyPair();
// timestamp
let expires_at = 0;
if (!this.connectDialog.data.neverExpires) {
expires_at =
new Date(this.connectDialog.data.expires_at).getTime() / 1000;
}
const data = {
permissions: [],
description: this.connectDialog.data.description,
expires_at: expires_at,
budgets: [],
};
for (const permission of this.connectDialog.data.permissions) {
if (permission.value) data.permissions.push(permission.key);
}
for (const budget of this.connectDialog.data.budgets) {
const budget_msats = budget.budget_sats * 1000;
let refresh_window = 0;
switch (budget.expiry) {
case "Daily":
refresh_window = 60 * 60 * 24;
break;
case "Weekly":
refresh_window = 60 * 60 * 24 * 7;
break;
case "Monthly":
refresh_window = 60 * 60 * 24 * 30;
break;
case "Yearly":
refresh_window = 60 * 60 * 24 * 365;
break;
case "Never":
refresh_window = 0;
break;
}
data.budgets.push({
budget_msats: budget_msats,
refresh_window: refresh_window,
created_at:
new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
expiration: "never",
});
},
closeConnectDialog() {
this.connectDialog.show = false;
this.loadConnectDialogData();
},
getWallet: function () {
let wallet = undefined;
for (let i = 0; i < this.g.user.wallets.length; i++) {
if (this.g.user.wallets[i].id == this.selectedWallet) {
wallet = this.g.user.wallets[i];
break;
}
}
return wallet;
},
async generateKeyPair() {
while (!window.NobleSecp256k1) {
await new Promise((resolve) => setTimeout(resolve, 1));
}
const privKeyBytes = window.NobleSecp256k1.utils.randomPrivateKey();
const pubKeyBytes = window.NobleSecp256k1.getPublicKey(privKeyBytes);
const out = {
privKeyBytes: privKeyBytes,
pubKeyBytes: pubKeyBytes,
privKey: window.NobleSecp256k1.etc.bytesToHex(privKeyBytes),
pubKey: window.NobleSecp256k1.etc.bytesToHex(pubKeyBytes.slice(1)),
};
return out;
},
deleteNWC: async function (pubkey) {
Quasar
.Dialog.create({
title: "Confirm Deletion",
message: "Are you sure you want to delete this connection?",
cancel: true,
persistent: true,
})
.onOk(async () => {
try {
const wallet = this.getWallet();
const response = await LNbits.api.request(
"DELETE",
`/nwcprovider/api/v1/nwc/${pubkey}`,
wallet.adminkey,
);
this.loadNwcs();
Quasar.Notify.create({
type: "positive",
message: "Deleted successfully"
});
} catch (error) {
LNbits.utils.notifyApiError(error);
}
})
.onCancel(() => {
// User canceled the operation
});
},
loadNwcs: async function () {
const wallet = this.getWallet();
if (!wallet) {
this.nwcs = [];
}
const wallet = this.getWallet();
try {
const response = await LNbits.api.request(
"PUT",
"/nwcprovider/api/v1/nwc/" + keyPair.pubKey,
wallet.adminkey,
data,
);
this.closeConnectDialog();
if (
!response.data ||
!response.data.data ||
!response.data.data.pubkey
) {
LNbits.utils.notifyApiError("Error creating nwc pairing");
return;
}
try {
const response = await LNbits.api.request(
"GET",
"/nwcprovider/api/v1/nwc?include_expired=true&calculate_spent_budget=true",
wallet.adminkey,
);
this.nwcs = response.data;
} catch (error) {
this.nwcs = [];
}
try {
const response = await LNbits.api.request(
"GET",
"/nwcprovider/api/v1/permissions",
wallet.adminkey,
);
const permissions = [];
for (const [key, value] of Object.entries(response.data)) {
permissions.push({
key: key,
name: value.name,
value: value.default,
});
}
this.nodePermissions = permissions;
} catch (error) {
Lnbits.utils.notifyApiError(error);
}
this.loadConnectDialogData();
const newTableEntries = [];
for (const nwc of this.nwcs) {
const t = Quasar.date.formatDate(
new Date(nwc.data.created_at * 1000),
"YYYY-MM-DD HH:mm",
);
const e =
nwc.data.expires_at > 0
? Quasar.date.formatDate(
new Date(nwc.data.expires_at * 1000),
"YYYY-MM-DD HH:mm",
)
: "Never";
const l = Quasar.date.formatDate(
new Date(nwc.data.last_used * 1000),
"YYYY-MM-DD HH:mm",
);
const nwcTableEntry = {
description: nwc.data.description,
created_at: t,
expires_at: e,
last_used: l,
pubkey: nwc.data.pubkey,
permissions: nwc.data.permissions,
budgets: [],
status: "Active",
};
if (
nwc.data.expires_at > 0 &&
nwc.data.expires_at < new Date().getTime() / 1000
) {
nwcTableEntry.status = "Expired";
}
for (const budget of nwc.budgets) {
const createdAt = Quasar.date.formatDate(
new Date(budget.created_at * 1000),
"YYYY-MM-DD HH:mm",
);
let refreshWindow = budget.refresh_window;
if (refreshWindow <= 0) {
refreshWindow = "Never";
} else if (refreshWindow == 60 * 60 * 24) {
refreshWindow = "Daily";
} else if (refreshWindow == 60 * 60 * 24 * 7) {
refreshWindow = "Weekly";
} else if (refreshWindow == 60 * 60 * 24 * 30) {
refreshWindow = "Monthly";
} else if (refreshWindow == 60 * 60 * 24 * 365) {
refreshWindow = "Yearly";
}
nwcTableEntry.budgets.push({
budget_sats: budget.budget_msats / 1000,
used_budget_sats: budget.used_budget_msats / 1000,
created_at: createdAt,
refresh_window: refreshWindow,
});
}
newTableEntries.push(nwcTableEntry);
}
this.nwcEntries = newTableEntries;
},
closePairingDialog() {
this.pairingDialog.show = false;
},
async showPairingDialog(secret) {
let response = await LNbits.api.request(
"GET",
"/nwcprovider/api/v1/pairing/{SECRET}",
);
response = response.data;
response = response.replace("{SECRET}", secret);
this.pairingDialog.data.pairingUrl = response;
this.pairingDialog.show = true;
},
async confirmConnectDialog() {
const keyPair = await this.generateKeyPair();
// timestamp
let expires_at = 0;
if (!this.connectDialog.data.neverExpires) {
expires_at =
new Date(this.connectDialog.data.expires_at).getTime() / 1000;
}
const data = {
permissions: [],
description: this.connectDialog.data.description,
expires_at: expires_at,
budgets: [],
};
for (const permission of this.connectDialog.data.permissions) {
if (permission.value) data.permissions.push(permission.key);
}
for (const budget of this.connectDialog.data.budgets) {
const budget_msats = budget.budget_sats * 1000;
let refresh_window = 0;
switch (budget.expiry) {
case "Daily":
refresh_window = 60 * 60 * 24;
break;
case "Weekly":
refresh_window = 60 * 60 * 24 * 7;
break;
case "Monthly":
refresh_window = 60 * 60 * 24 * 30;
break;
case "Yearly":
refresh_window = 60 * 60 * 24 * 365;
break;
case "Never":
refresh_window = 0;
break;
}
data.budgets.push({
budget_msats: budget_msats,
refresh_window: refresh_window,
created_at:
new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
});
}
const wallet = this.getWallet();
try {
const response = await LNbits.api.request(
"PUT",
"/nwcprovider/api/v1/nwc/" + keyPair.pubKey,
wallet.adminkey,
data,
);
this.closeConnectDialog();
if (
!response.data ||
!response.data.data ||
!response.data.data.pubkey
) {
LNbits.utils.notifyApiError("Error creating nwc pairing");
return;
}
this.showPairingDialog(keyPair.privKey);
} catch (error) {
LNbits.utils.notifyApiError(error);
}
this.loadNwcs();
},
},
created: function () {
this.showPairingDialog(keyPair.privKey);
} catch (error) {
LNbits.utils.notifyApiError(error);
}
this.loadNwcs();
},
watch: {
selectedWallet(newValue, oldValue) {
this.loadNwcs();
},
},
created: function () {
this.loadNwcs();
},
watch: {
selectedWallet(newValue, oldValue) {
this.loadNwcs();
},
});
},
});

View file

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

View file

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

View file

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