Compare commits
No commits in common. "bae881587cc601b8a2d49bdfc87b1b64f41445d2" and "266b16834b4a81273af34551977bd22e1fa13af7" have entirely different histories.
bae881587c
...
266b16834b
8 changed files with 0 additions and 936 deletions
|
|
@ -482,12 +482,6 @@ def register_async_tasks() -> None:
|
||||||
create_permanent_task(purge_audit_data)
|
create_permanent_task(purge_audit_data)
|
||||||
create_permanent_task(collect_exchange_rates_data)
|
create_permanent_task(collect_exchange_rates_data)
|
||||||
|
|
||||||
# nostr transport (RPC over nostr relays)
|
|
||||||
if settings.nostr_transport_enabled:
|
|
||||||
from lnbits.core.services.nostr_transport import start_nostr_transport
|
|
||||||
|
|
||||||
create_permanent_task(start_nostr_transport)
|
|
||||||
|
|
||||||
# server logs for websocket
|
# server logs for websocket
|
||||||
if settings.lnbits_admin_ui:
|
if settings.lnbits_admin_ui:
|
||||||
server_log_task = initialize_server_websocket_logger()
|
server_log_task = initialize_server_websocket_logger()
|
||||||
|
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
"""
|
|
||||||
Nostr transport layer for LNbits.
|
|
||||||
|
|
||||||
Enables LNbits API access over nostr relays using NIP-44 encrypted
|
|
||||||
kind-21000 events, modeled after Lightning.pub's architecture.
|
|
||||||
No port forwarding, DNS, or SSL required.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
Set these in .env:
|
|
||||||
NOSTR_TRANSPORT_ENABLED=true
|
|
||||||
NOSTR_TRANSPORT_PRIVATE_KEY=<hex_private_key>
|
|
||||||
NOSTR_TRANSPORT_RELAYS=["wss://relay.damus.io"]
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from lnbits.settings import settings
|
|
||||||
from lnbits.utils.nostr import generate_keypair, normalize_private_key
|
|
||||||
|
|
||||||
from .dispatcher import dispatch, register_default_rpcs
|
|
||||||
from .models import NostrRpcRequest, NostrRpcResponse
|
|
||||||
from .relay_pool import NostrTransportPool
|
|
||||||
|
|
||||||
_pool: NostrTransportPool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
async def start_nostr_transport():
|
|
||||||
"""
|
|
||||||
Main entry point. Called from app.py via create_permanent_task().
|
|
||||||
Initializes the relay pool, registers RPC endpoints, and starts
|
|
||||||
listening for incoming nostr events.
|
|
||||||
"""
|
|
||||||
global _pool
|
|
||||||
|
|
||||||
if not settings.nostr_transport_enabled:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Resolve keypair
|
|
||||||
private_key_hex = settings.nostr_transport_private_key
|
|
||||||
if not private_key_hex:
|
|
||||||
logger.info("Nostr transport: no private key configured, generating one...")
|
|
||||||
private_key_hex, public_key_hex = generate_keypair()
|
|
||||||
settings.nostr_transport_private_key = private_key_hex
|
|
||||||
settings.nostr_transport_public_key = public_key_hex
|
|
||||||
logger.info(
|
|
||||||
f"Nostr transport: generated keypair. "
|
|
||||||
f"Public key (share this): {public_key_hex}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
private_key_hex = normalize_private_key(private_key_hex)
|
|
||||||
# Derive public key from private key
|
|
||||||
import secp256k1
|
|
||||||
|
|
||||||
privkey = secp256k1.PrivateKey(bytes.fromhex(private_key_hex), True)
|
|
||||||
public_key_hex = privkey.pubkey.serialize()[1:].hex()
|
|
||||||
settings.nostr_transport_public_key = public_key_hex
|
|
||||||
|
|
||||||
relay_urls = settings.nostr_transport_relays
|
|
||||||
if not relay_urls:
|
|
||||||
logger.warning("Nostr transport: no relays configured, disabling")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Nostr transport: starting with pubkey {public_key_hex[:16]}... "
|
|
||||||
f"on {len(relay_urls)} relay(s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Register RPC endpoints
|
|
||||||
register_default_rpcs()
|
|
||||||
|
|
||||||
# Create and start relay pool
|
|
||||||
_pool = NostrTransportPool(
|
|
||||||
private_key_hex=private_key_hex,
|
|
||||||
public_key_hex=public_key_hex,
|
|
||||||
relay_urls=relay_urls,
|
|
||||||
event_callback=_handle_event,
|
|
||||||
)
|
|
||||||
await _pool.start()
|
|
||||||
|
|
||||||
# Keep alive until shutdown
|
|
||||||
while not _pool._is_shutting_down():
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
await stop_nostr_transport()
|
|
||||||
|
|
||||||
|
|
||||||
async def stop_nostr_transport():
|
|
||||||
"""Clean shutdown of the nostr transport."""
|
|
||||||
global _pool
|
|
||||||
if _pool:
|
|
||||||
await _pool.stop()
|
|
||||||
_pool = None
|
|
||||||
logger.info("Nostr transport: stopped")
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_event(sender_pubkey: str, plaintext: str):
|
|
||||||
"""
|
|
||||||
Handle a decrypted incoming event.
|
|
||||||
Parse as RPC request, dispatch, and send response.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = json.loads(plaintext)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.warning("Nostr transport: received non-JSON event content")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Parse request
|
|
||||||
try:
|
|
||||||
request = NostrRpcRequest(**data)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Nostr transport: invalid RPC request: {e}")
|
|
||||||
# Try to send error response if we have a request_id
|
|
||||||
request_id = data.get("request_id", "unknown")
|
|
||||||
response = NostrRpcResponse(
|
|
||||||
status="ERROR",
|
|
||||||
request_id=request_id,
|
|
||||||
error=f"Invalid request: {e}",
|
|
||||||
)
|
|
||||||
if _pool:
|
|
||||||
await _pool.send_response(sender_pubkey, response.json())
|
|
||||||
return
|
|
||||||
|
|
||||||
# Validate authIdentifier matches sender (Lightning.pub pattern)
|
|
||||||
# In our case, the sender pubkey from the event IS the auth identity
|
|
||||||
|
|
||||||
# Dispatch
|
|
||||||
response = await dispatch(request, sender_pubkey)
|
|
||||||
|
|
||||||
# Send response
|
|
||||||
if _pool:
|
|
||||||
await _pool.send_response(sender_pubkey, response.json())
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
"""
|
|
||||||
Nostr pubkey -> LNbits account/wallet authorization.
|
|
||||||
|
|
||||||
Maps a nostr event sender's pubkey to the appropriate LNbits
|
|
||||||
authorization context, following the same patterns as the HTTP
|
|
||||||
decorators in lnbits/decorators.py.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from lnbits.core.crud.users import get_account_by_pubkey
|
|
||||||
from lnbits.core.crud.wallets import get_wallet
|
|
||||||
from lnbits.core.models import Account, UserExtra
|
|
||||||
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
|
|
||||||
from lnbits.core.services.users import create_user_account
|
|
||||||
from lnbits.settings import settings
|
|
||||||
|
|
||||||
|
|
||||||
async def resolve_nostr_auth(
|
|
||||||
pubkey: str,
|
|
||||||
wallet_id: str | None = None,
|
|
||||||
key_type_str: str | None = None,
|
|
||||||
) -> WalletTypeInfo | Account | None:
|
|
||||||
"""
|
|
||||||
Resolve a nostr pubkey + optional wallet_id + key_type to an
|
|
||||||
LNbits authorization context.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
WalletTypeInfo - for wallet-scoped operations (payments, etc.)
|
|
||||||
Account - for account-level operations (create wallet, etc.)
|
|
||||||
None - for public endpoints (decode, payment status)
|
|
||||||
"""
|
|
||||||
if wallet_id is None and key_type_str is None:
|
|
||||||
# Public endpoint -- no auth needed, but still resolve account
|
|
||||||
# if pubkey is provided (for audit/logging)
|
|
||||||
account = await get_account_by_pubkey(pubkey)
|
|
||||||
if account:
|
|
||||||
return account
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Resolve account from pubkey
|
|
||||||
account = await get_account_by_pubkey(pubkey)
|
|
||||||
if not account:
|
|
||||||
if not settings.new_accounts_allowed:
|
|
||||||
raise PermissionError("Account creation is disabled.")
|
|
||||||
# Auto-create account (same as nostr_login in auth_api.py)
|
|
||||||
account = Account(
|
|
||||||
id=uuid4().hex,
|
|
||||||
pubkey=pubkey,
|
|
||||||
extra=UserExtra(provider="nostr"),
|
|
||||||
)
|
|
||||||
user = await create_user_account(account)
|
|
||||||
logger.info(f"Nostr transport: auto-created account for {pubkey[:16]}...")
|
|
||||||
account = Account(
|
|
||||||
id=user.id,
|
|
||||||
pubkey=pubkey,
|
|
||||||
extra=UserExtra(provider="nostr"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: upstream LNbits (dev) has account.activated field
|
|
||||||
# if not account.activated:
|
|
||||||
# raise PermissionError("Account is not activated.")
|
|
||||||
|
|
||||||
# Account-level operation (no wallet specified)
|
|
||||||
if wallet_id is None:
|
|
||||||
return account
|
|
||||||
|
|
||||||
# Wallet-scoped operation
|
|
||||||
wallet = await get_wallet(wallet_id)
|
|
||||||
if not wallet:
|
|
||||||
raise ValueError(f"Wallet not found: {wallet_id}")
|
|
||||||
|
|
||||||
if wallet.user != account.id:
|
|
||||||
raise PermissionError("Wallet does not belong to this account.")
|
|
||||||
|
|
||||||
key_type = KeyType.admin if key_type_str == "admin" else KeyType.invoice
|
|
||||||
return WalletTypeInfo(key_type=key_type, wallet=wallet)
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
"""
|
|
||||||
NIP-44 v2 encryption/decryption for nostr transport.
|
|
||||||
|
|
||||||
Implements the NIP-44 v2 spec using secp256k1 (ECDH) and
|
|
||||||
pycryptodome (ChaCha20, HMAC-SHA256, HKDF), all already in LNbits' deps.
|
|
||||||
|
|
||||||
Also re-exports sign_event() and verify_event() from lnbits.utils.nostr.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
from math import floor, log2
|
|
||||||
|
|
||||||
import secp256k1
|
|
||||||
from Cryptodome.Cipher import ChaCha20
|
|
||||||
|
|
||||||
from lnbits.utils.nostr import sign_event, verify_event # noqa: F401
|
|
||||||
|
|
||||||
_NIP44_SALT = b"nip44-v2"
|
|
||||||
_VERSION = 2
|
|
||||||
|
|
||||||
|
|
||||||
def get_conversation_key(
|
|
||||||
private_key_hex: str, public_key_hex: str
|
|
||||||
) -> bytes:
|
|
||||||
"""
|
|
||||||
Compute NIP-44 v2 conversation key via ECDH + HKDF-extract.
|
|
||||||
|
|
||||||
The public key must be 32-byte x-only (as in nostr). We prepend 0x02
|
|
||||||
to make it a valid compressed SEC1 point for secp256k1.
|
|
||||||
"""
|
|
||||||
# x-only pubkey -> compressed pubkey (assume even y)
|
|
||||||
pubkey_bytes = b"\x02" + bytes.fromhex(public_key_hex)
|
|
||||||
pubkey = secp256k1.PublicKey(pubkey_bytes, True)
|
|
||||||
|
|
||||||
# ECDH: multiply pubkey by privkey scalar, get shared point
|
|
||||||
# tweak_mul multiplies the point by a scalar, returning a new PublicKey
|
|
||||||
shared_point = pubkey.tweak_mul(bytes.fromhex(private_key_hex))
|
|
||||||
# serialize(compressed=True) gives 0x02/0x03 + 32-byte x coordinate
|
|
||||||
# strip the prefix byte to get the raw x coordinate
|
|
||||||
shared_x = shared_point.serialize()[1:]
|
|
||||||
|
|
||||||
# HKDF-extract with salt="nip44-v2"
|
|
||||||
return _hkdf_extract(salt=_NIP44_SALT, ikm=shared_x)
|
|
||||||
|
|
||||||
|
|
||||||
def nip44_encrypt(
|
|
||||||
plaintext: str, sender_privkey_hex: str, recipient_pubkey_hex: str
|
|
||||||
) -> str:
|
|
||||||
"""Encrypt a plaintext string using NIP-44 v2."""
|
|
||||||
conversation_key = get_conversation_key(sender_privkey_hex, recipient_pubkey_hex)
|
|
||||||
nonce = os.urandom(32)
|
|
||||||
return _encrypt(plaintext, conversation_key, nonce)
|
|
||||||
|
|
||||||
|
|
||||||
def nip44_decrypt(
|
|
||||||
payload: str, recipient_privkey_hex: str, sender_pubkey_hex: str
|
|
||||||
) -> str:
|
|
||||||
"""Decrypt a NIP-44 v2 payload."""
|
|
||||||
conversation_key = get_conversation_key(recipient_privkey_hex, sender_pubkey_hex)
|
|
||||||
return _decrypt(payload, conversation_key)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Internal implementation ---
|
|
||||||
|
|
||||||
|
|
||||||
def _hkdf_extract(salt: bytes, ikm: bytes) -> bytes:
|
|
||||||
"""HKDF-extract (RFC 5869) with SHA-256."""
|
|
||||||
return hmac.new(salt, ikm, hashlib.sha256).digest()
|
|
||||||
|
|
||||||
|
|
||||||
def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
|
|
||||||
"""HKDF-expand (RFC 5869) with SHA-256."""
|
|
||||||
hash_len = 32 # SHA-256
|
|
||||||
n = (length + hash_len - 1) // hash_len
|
|
||||||
okm = b""
|
|
||||||
t = b""
|
|
||||||
for i in range(1, n + 1):
|
|
||||||
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
|
|
||||||
okm += t
|
|
||||||
return okm[:length]
|
|
||||||
|
|
||||||
|
|
||||||
def _calc_padded_len(unpadded_len: int) -> int:
|
|
||||||
"""NIP-44 v2 padding length calculation."""
|
|
||||||
if unpadded_len <= 32:
|
|
||||||
return 32
|
|
||||||
next_power = 1 << (floor(log2(unpadded_len - 1)) + 1)
|
|
||||||
if next_power <= 256:
|
|
||||||
chunk = 32
|
|
||||||
else:
|
|
||||||
chunk = next_power // 8
|
|
||||||
return chunk * (floor((unpadded_len - 1) / chunk) + 1)
|
|
||||||
|
|
||||||
|
|
||||||
def _pad(plaintext: str) -> bytes:
|
|
||||||
"""Convert plaintext to NIP-44 v2 padded byte array."""
|
|
||||||
unpadded = plaintext.encode("utf-8")
|
|
||||||
unpadded_len = len(unpadded)
|
|
||||||
if unpadded_len < 1 or unpadded_len > 65535:
|
|
||||||
raise ValueError(f"Invalid plaintext length: {unpadded_len}")
|
|
||||||
padded_len = _calc_padded_len(unpadded_len)
|
|
||||||
prefix = struct.pack(">H", unpadded_len) # big-endian uint16
|
|
||||||
suffix = b"\x00" * (padded_len - unpadded_len)
|
|
||||||
return prefix + unpadded + suffix
|
|
||||||
|
|
||||||
|
|
||||||
def _unpad(padded: bytes) -> str:
|
|
||||||
"""Convert NIP-44 v2 padded byte array back to plaintext."""
|
|
||||||
unpadded_len = struct.unpack(">H", padded[:2])[0]
|
|
||||||
if unpadded_len == 0:
|
|
||||||
raise ValueError("Invalid padding: zero length")
|
|
||||||
unpadded = padded[2 : 2 + unpadded_len]
|
|
||||||
if len(unpadded) != unpadded_len:
|
|
||||||
raise ValueError("Invalid padding: length mismatch")
|
|
||||||
expected_padded_total = 2 + _calc_padded_len(unpadded_len)
|
|
||||||
if len(padded) != expected_padded_total:
|
|
||||||
raise ValueError("Invalid padding: wrong padded size")
|
|
||||||
return unpadded.decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _get_message_keys(conversation_key: bytes, nonce: bytes) -> tuple[bytes, bytes, bytes]:
|
|
||||||
"""Derive chacha_key, chacha_nonce, hmac_key from conversation_key and nonce."""
|
|
||||||
keys = _hkdf_expand(conversation_key, nonce, 76)
|
|
||||||
chacha_key = keys[0:32]
|
|
||||||
chacha_nonce = keys[32:44]
|
|
||||||
hmac_key = keys[44:76]
|
|
||||||
return chacha_key, chacha_nonce, hmac_key
|
|
||||||
|
|
||||||
|
|
||||||
def _encrypt(plaintext: str, conversation_key: bytes, nonce: bytes) -> str:
|
|
||||||
"""Encrypt using NIP-44 v2."""
|
|
||||||
chacha_key, chacha_nonce, hmac_key = _get_message_keys(conversation_key, nonce)
|
|
||||||
padded = _pad(plaintext)
|
|
||||||
|
|
||||||
# ChaCha20 encrypt (RFC 8439, counter=0)
|
|
||||||
cipher = ChaCha20.new(key=chacha_key, nonce=chacha_nonce)
|
|
||||||
ciphertext = cipher.encrypt(padded)
|
|
||||||
|
|
||||||
# HMAC-SHA256 over nonce + ciphertext
|
|
||||||
mac = hmac.new(hmac_key, nonce + ciphertext, hashlib.sha256).digest()
|
|
||||||
|
|
||||||
# Assemble: version(1) + nonce(32) + ciphertext(variable) + mac(32)
|
|
||||||
payload = bytes([_VERSION]) + nonce + ciphertext + mac
|
|
||||||
return base64.b64encode(payload).decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def _decrypt(payload_b64: str, conversation_key: bytes) -> str:
|
|
||||||
"""Decrypt a NIP-44 v2 payload."""
|
|
||||||
if not payload_b64:
|
|
||||||
raise ValueError("Empty payload")
|
|
||||||
if payload_b64[0] == "#":
|
|
||||||
raise ValueError("Unknown encoding version")
|
|
||||||
|
|
||||||
plen = len(payload_b64)
|
|
||||||
if plen < 132 or plen > 87472:
|
|
||||||
raise ValueError(f"Invalid payload size: {plen}")
|
|
||||||
|
|
||||||
data = base64.b64decode(payload_b64)
|
|
||||||
dlen = len(data)
|
|
||||||
if dlen < 99 or dlen > 65603:
|
|
||||||
raise ValueError(f"Invalid data size: {dlen}")
|
|
||||||
|
|
||||||
version = data[0]
|
|
||||||
if version != _VERSION:
|
|
||||||
raise ValueError(f"Unknown version: {version}")
|
|
||||||
|
|
||||||
nonce = data[1:33]
|
|
||||||
ciphertext = data[33 : dlen - 32]
|
|
||||||
mac = data[dlen - 32 : dlen]
|
|
||||||
|
|
||||||
# Derive keys
|
|
||||||
chacha_key, chacha_nonce, hmac_key = _get_message_keys(conversation_key, nonce)
|
|
||||||
|
|
||||||
# Verify MAC (constant-time)
|
|
||||||
expected_mac = hmac.new(hmac_key, nonce + ciphertext, hashlib.sha256).digest()
|
|
||||||
if not hmac.compare_digest(mac, expected_mac):
|
|
||||||
raise ValueError("Invalid MAC")
|
|
||||||
|
|
||||||
# Decrypt
|
|
||||||
cipher = ChaCha20.new(key=chacha_key, nonce=chacha_nonce)
|
|
||||||
padded = cipher.decrypt(ciphertext)
|
|
||||||
|
|
||||||
return _unpad(padded)
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
"""
|
|
||||||
RPC dispatcher for the nostr transport.
|
|
||||||
|
|
||||||
Maps rpc_name strings to LNbits service functions, handles auth
|
|
||||||
resolution, and returns serialized responses. Modeled after
|
|
||||||
Lightning.pub's nostr_transport.ts auto-generated dispatcher.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from lnbits.core.models import Account
|
|
||||||
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
|
|
||||||
|
|
||||||
from .auth import resolve_nostr_auth
|
|
||||||
from .models import NostrRpcRequest, NostrRpcResponse
|
|
||||||
|
|
||||||
# Auth level constants
|
|
||||||
AUTH_NONE = "none"
|
|
||||||
AUTH_ACCOUNT = "account"
|
|
||||||
AUTH_INVOICE_KEY = "invoice_key"
|
|
||||||
AUTH_ADMIN_KEY = "admin_key"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RpcEndpoint:
|
|
||||||
handler: Callable[..., Awaitable[Any]]
|
|
||||||
auth_level: str # AUTH_NONE, AUTH_ACCOUNT, AUTH_INVOICE_KEY, AUTH_ADMIN_KEY
|
|
||||||
|
|
||||||
|
|
||||||
# Global RPC registry
|
|
||||||
_RPC_REGISTRY: dict[str, RpcEndpoint] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def register_rpc(
|
|
||||||
name: str,
|
|
||||||
handler: Callable[..., Awaitable[Any]],
|
|
||||||
auth_level: str = AUTH_INVOICE_KEY,
|
|
||||||
):
|
|
||||||
"""Register an RPC handler."""
|
|
||||||
_RPC_REGISTRY[name] = RpcEndpoint(handler=handler, auth_level=auth_level)
|
|
||||||
|
|
||||||
|
|
||||||
async def dispatch(request: NostrRpcRequest, sender_pubkey: str) -> NostrRpcResponse:
|
|
||||||
"""
|
|
||||||
Route an RPC request to the appropriate handler.
|
|
||||||
|
|
||||||
1. Look up rpc_name in registry
|
|
||||||
2. Resolve auth from sender pubkey
|
|
||||||
3. Validate auth level
|
|
||||||
4. Call handler
|
|
||||||
5. Return response
|
|
||||||
"""
|
|
||||||
endpoint = _RPC_REGISTRY.get(request.rpc_name)
|
|
||||||
if not endpoint:
|
|
||||||
return NostrRpcResponse(
|
|
||||||
status="ERROR",
|
|
||||||
request_id=request.request_id,
|
|
||||||
error=f"Unknown RPC: {request.rpc_name}",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Resolve auth
|
|
||||||
auth_ctx = await _resolve_auth(
|
|
||||||
endpoint.auth_level, sender_pubkey, request.wallet_id, request.key_type
|
|
||||||
)
|
|
||||||
|
|
||||||
# Call handler
|
|
||||||
result = await endpoint.handler(auth_ctx, request)
|
|
||||||
return NostrRpcResponse(
|
|
||||||
status="OK",
|
|
||||||
request_id=request.request_id,
|
|
||||||
data=result,
|
|
||||||
)
|
|
||||||
except (PermissionError, ValueError) as e:
|
|
||||||
return NostrRpcResponse(
|
|
||||||
status="ERROR",
|
|
||||||
request_id=request.request_id,
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Nostr transport: RPC error in {request.rpc_name}: {e}")
|
|
||||||
return NostrRpcResponse(
|
|
||||||
status="ERROR",
|
|
||||||
request_id=request.request_id,
|
|
||||||
error="Internal error",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_auth(
|
|
||||||
auth_level: str,
|
|
||||||
sender_pubkey: str,
|
|
||||||
wallet_id: str | None,
|
|
||||||
key_type_str: str | None,
|
|
||||||
) -> WalletTypeInfo | Account | None:
|
|
||||||
"""Resolve and validate auth for the given level."""
|
|
||||||
if auth_level == AUTH_NONE:
|
|
||||||
return None
|
|
||||||
|
|
||||||
auth_ctx = await resolve_nostr_auth(sender_pubkey, wallet_id, key_type_str)
|
|
||||||
|
|
||||||
if auth_level == AUTH_ACCOUNT:
|
|
||||||
if auth_ctx is None:
|
|
||||||
raise PermissionError("Authentication required.")
|
|
||||||
return auth_ctx
|
|
||||||
|
|
||||||
if auth_level in (AUTH_INVOICE_KEY, AUTH_ADMIN_KEY):
|
|
||||||
if not isinstance(auth_ctx, WalletTypeInfo):
|
|
||||||
raise PermissionError("Wallet authentication required (provide wallet_id).")
|
|
||||||
if auth_level == AUTH_ADMIN_KEY and auth_ctx.key_type != KeyType.admin:
|
|
||||||
raise PermissionError("Admin key required for this operation.")
|
|
||||||
return auth_ctx
|
|
||||||
|
|
||||||
raise PermissionError(f"Unknown auth level: {auth_level}")
|
|
||||||
|
|
||||||
|
|
||||||
# --- Default RPC handlers ---
|
|
||||||
|
|
||||||
|
|
||||||
def register_default_rpcs():
|
|
||||||
"""Register the core LNbits wallet RPCs."""
|
|
||||||
register_rpc("create_invoice", _handle_create_invoice, AUTH_INVOICE_KEY)
|
|
||||||
register_rpc("pay_invoice", _handle_pay_invoice, AUTH_ADMIN_KEY)
|
|
||||||
register_rpc("get_payment", _handle_get_payment, AUTH_NONE)
|
|
||||||
register_rpc("list_payments", _handle_list_payments, AUTH_INVOICE_KEY)
|
|
||||||
register_rpc("get_wallet", _handle_get_wallet, AUTH_INVOICE_KEY)
|
|
||||||
register_rpc("create_wallet", _handle_create_wallet, AUTH_ACCOUNT)
|
|
||||||
register_rpc("decode_payment", _handle_decode_payment, AUTH_NONE)
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_create_invoice(
|
|
||||||
auth: WalletTypeInfo, request: NostrRpcRequest
|
|
||||||
) -> dict:
|
|
||||||
from lnbits.core.models import CreateInvoice
|
|
||||||
from lnbits.core.services.payments import create_payment_request
|
|
||||||
|
|
||||||
body = request.body or {}
|
|
||||||
invoice_data = CreateInvoice(
|
|
||||||
out=False,
|
|
||||||
amount=body.get("amount", 0),
|
|
||||||
memo=body.get("memo"),
|
|
||||||
unit=body.get("unit", "sat"),
|
|
||||||
expiry=body.get("expiry"),
|
|
||||||
extra=body.get("extra"),
|
|
||||||
webhook=body.get("webhook"),
|
|
||||||
)
|
|
||||||
payment = await create_payment_request(auth.wallet.id, invoice_data)
|
|
||||||
return payment.dict()
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_pay_invoice(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict:
|
|
||||||
from lnbits.core.services.payments import pay_invoice
|
|
||||||
|
|
||||||
body = request.body or {}
|
|
||||||
bolt11 = body.get("bolt11", "")
|
|
||||||
if not bolt11:
|
|
||||||
raise ValueError("bolt11 is required")
|
|
||||||
|
|
||||||
payment = await pay_invoice(
|
|
||||||
wallet_id=auth.wallet.id,
|
|
||||||
payment_request=bolt11,
|
|
||||||
max_sat=body.get("max_sat"),
|
|
||||||
extra=body.get("extra"),
|
|
||||||
description=body.get("description", ""),
|
|
||||||
)
|
|
||||||
return payment.dict()
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_get_payment(
|
|
||||||
auth: None, request: NostrRpcRequest
|
|
||||||
) -> dict | None:
|
|
||||||
from lnbits.core.crud.payments import get_standalone_payment
|
|
||||||
|
|
||||||
body = request.body or {}
|
|
||||||
query = request.query or {}
|
|
||||||
payment_hash = body.get("payment_hash") or query.get("payment_hash", "")
|
|
||||||
if not payment_hash:
|
|
||||||
raise ValueError("payment_hash is required")
|
|
||||||
|
|
||||||
payment = await get_standalone_payment(payment_hash)
|
|
||||||
if not payment:
|
|
||||||
return None
|
|
||||||
return payment.dict()
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_list_payments(
|
|
||||||
auth: WalletTypeInfo, request: NostrRpcRequest
|
|
||||||
) -> list[dict]:
|
|
||||||
from lnbits.core.crud.payments import get_payments
|
|
||||||
|
|
||||||
query = request.query or {}
|
|
||||||
payments = await get_payments(
|
|
||||||
wallet_id=auth.wallet.id,
|
|
||||||
complete=query.get("complete", False),
|
|
||||||
pending=query.get("pending", False),
|
|
||||||
outgoing=query.get("outgoing", False),
|
|
||||||
incoming=query.get("incoming", False),
|
|
||||||
limit=query.get("limit"),
|
|
||||||
offset=query.get("offset"),
|
|
||||||
)
|
|
||||||
return [p.dict() for p in payments]
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_get_wallet(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict:
|
|
||||||
return {
|
|
||||||
"id": auth.wallet.id,
|
|
||||||
"name": auth.wallet.name,
|
|
||||||
"balance": auth.wallet.balance_msat,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_create_wallet(auth: Account, request: NostrRpcRequest) -> dict:
|
|
||||||
from lnbits.core.crud.wallets import create_wallet
|
|
||||||
|
|
||||||
body = request.body or {}
|
|
||||||
wallet = await create_wallet(
|
|
||||||
user_id=auth.id,
|
|
||||||
wallet_name=body.get("name", "Nostr Wallet"),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"id": wallet.id,
|
|
||||||
"name": wallet.name,
|
|
||||||
"adminkey": wallet.adminkey,
|
|
||||||
"inkey": wallet.inkey,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_decode_payment(auth: None, request: NostrRpcRequest) -> dict:
|
|
||||||
from lnbits import bolt11
|
|
||||||
|
|
||||||
body = request.body or {}
|
|
||||||
payment_request = body.get("payment_request", "")
|
|
||||||
if not payment_request:
|
|
||||||
raise ValueError("payment_request is required")
|
|
||||||
|
|
||||||
invoice = bolt11.decode(payment_request)
|
|
||||||
return {
|
|
||||||
"payment_hash": invoice.payment_hash,
|
|
||||||
"amount_msat": invoice.amount_msat,
|
|
||||||
"description": invoice.description,
|
|
||||||
"description_hash": invoice.description_hash,
|
|
||||||
"payee": invoice.payee,
|
|
||||||
"date": invoice.date,
|
|
||||||
"expiry": invoice.expiry,
|
|
||||||
"min_final_cltv_expiry": invoice.min_final_cltv_expiry,
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class NostrRpcRequest(BaseModel):
|
|
||||||
"""
|
|
||||||
Incoming RPC request over nostr transport.
|
|
||||||
Mirrors Lightning.pub's NostrRequest type from nostr_transport.ts.
|
|
||||||
"""
|
|
||||||
|
|
||||||
rpc_name: str
|
|
||||||
wallet_id: str | None = None
|
|
||||||
key_type: str | None = None # "admin" or "invoice"
|
|
||||||
request_id: str
|
|
||||||
body: dict | None = None
|
|
||||||
query: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class NostrRpcResponse(BaseModel):
|
|
||||||
"""
|
|
||||||
Outgoing RPC response over nostr transport.
|
|
||||||
Mirrors Lightning.pub's response format: {status, requestId, ...data}.
|
|
||||||
"""
|
|
||||||
|
|
||||||
status: str # "OK" or "ERROR"
|
|
||||||
request_id: str
|
|
||||||
data: Any | None = None
|
|
||||||
error: str | None = None
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
"""
|
|
||||||
Nostr relay connection pool for the transport layer.
|
|
||||||
|
|
||||||
Manages WebSocket connections to multiple nostr relays, subscribes for
|
|
||||||
incoming RPC events (kind 21000) tagged with the LNbits instance's
|
|
||||||
pubkey, validates and decrypts them, and publishes encrypted responses.
|
|
||||||
|
|
||||||
Follows the reconnection pattern from lnbits/wallets/nwc.py and the
|
|
||||||
relay pool design from Lightning.pub's nostrPool.ts.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
|
|
||||||
import secp256k1
|
|
||||||
from loguru import logger
|
|
||||||
from websockets import connect as ws_connect
|
|
||||||
|
|
||||||
from lnbits.settings import settings
|
|
||||||
from lnbits.utils.nostr import json_dumps, sign_event, verify_event
|
|
||||||
|
|
||||||
from .crypto import nip44_decrypt, nip44_encrypt
|
|
||||||
|
|
||||||
EVENT_KIND = 21000
|
|
||||||
# Events older than this are deduplication-expired (seconds)
|
|
||||||
_HANDLED_EVENT_TTL = 7200 # 2 hours
|
|
||||||
|
|
||||||
|
|
||||||
class NostrTransportPool:
|
|
||||||
"""
|
|
||||||
Manages relay connections for the nostr transport layer.
|
|
||||||
|
|
||||||
Subscribes for kind-21000 events tagged with our pubkey,
|
|
||||||
decrypts them, and forwards to a callback. Publishes encrypted
|
|
||||||
response events back to the sender.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
private_key_hex: str,
|
|
||||||
public_key_hex: str,
|
|
||||||
relay_urls: list[str],
|
|
||||||
event_callback: Callable[..., Awaitable[None]],
|
|
||||||
):
|
|
||||||
self.private_key_hex = private_key_hex
|
|
||||||
self.public_key_hex = public_key_hex
|
|
||||||
self.relay_urls = relay_urls
|
|
||||||
self.event_callback = event_callback
|
|
||||||
|
|
||||||
# secp256k1 key objects for signing
|
|
||||||
self._privkey = secp256k1.PrivateKey(bytes.fromhex(private_key_hex), True)
|
|
||||||
|
|
||||||
# Deduplication: event_id -> timestamp
|
|
||||||
self._handled_events: dict[str, float] = {}
|
|
||||||
|
|
||||||
# Active relay tasks and websockets
|
|
||||||
self._relay_tasks: list[asyncio.Task] = []
|
|
||||||
self._relay_ws: dict[str, object] = {} # relay_url -> ws
|
|
||||||
|
|
||||||
self._shutdown = False
|
|
||||||
|
|
||||||
def _is_shutting_down(self) -> bool:
|
|
||||||
return self._shutdown or not settings.lnbits_running
|
|
||||||
|
|
||||||
async def start(self):
|
|
||||||
"""Connect to all configured relays."""
|
|
||||||
logger.info(
|
|
||||||
f"Nostr transport: connecting to {len(self.relay_urls)} relay(s) "
|
|
||||||
f"as {self.public_key_hex[:16]}..."
|
|
||||||
)
|
|
||||||
for relay_url in self.relay_urls:
|
|
||||||
task = asyncio.create_task(self._connect_to_relay(relay_url))
|
|
||||||
self._relay_tasks.append(task)
|
|
||||||
|
|
||||||
# Periodic cleanup of handled events cache
|
|
||||||
self._cleanup_task = asyncio.create_task(self._cleanup_handled_events())
|
|
||||||
|
|
||||||
async def stop(self):
|
|
||||||
"""Shut down all relay connections."""
|
|
||||||
self._shutdown = True
|
|
||||||
for task in self._relay_tasks:
|
|
||||||
task.cancel()
|
|
||||||
self._cleanup_task.cancel()
|
|
||||||
# Close websockets
|
|
||||||
for url, ws in self._relay_ws.items():
|
|
||||||
try:
|
|
||||||
await ws.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._relay_ws.clear()
|
|
||||||
logger.info("Nostr transport: all relays disconnected")
|
|
||||||
|
|
||||||
async def send_response(self, recipient_pubkey: str, content: str):
|
|
||||||
"""Encrypt and publish a response event to the sender."""
|
|
||||||
encrypted = nip44_encrypt(content, self.private_key_hex, recipient_pubkey)
|
|
||||||
|
|
||||||
event = {
|
|
||||||
"kind": EVENT_KIND,
|
|
||||||
"created_at": int(time.time()),
|
|
||||||
"tags": [["p", recipient_pubkey]],
|
|
||||||
"content": encrypted,
|
|
||||||
}
|
|
||||||
event = sign_event(event, self.public_key_hex, self._privkey)
|
|
||||||
|
|
||||||
event_json = json.dumps(["EVENT", event])
|
|
||||||
# Publish to all connected relays
|
|
||||||
sent = False
|
|
||||||
for url, ws in list(self._relay_ws.items()):
|
|
||||||
try:
|
|
||||||
await ws.send(event_json)
|
|
||||||
sent = True
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Nostr transport: failed to send to {url}: {e}")
|
|
||||||
if not sent:
|
|
||||||
logger.error("Nostr transport: could not send response to any relay")
|
|
||||||
|
|
||||||
# --- Internal ---
|
|
||||||
|
|
||||||
async def _connect_to_relay(self, relay_url: str):
|
|
||||||
"""Connect to a single relay with auto-reconnect."""
|
|
||||||
while not self._is_shutting_down():
|
|
||||||
try:
|
|
||||||
async with ws_connect(relay_url) as ws:
|
|
||||||
self._relay_ws[relay_url] = ws
|
|
||||||
logger.info(f"Nostr transport: connected to {relay_url}")
|
|
||||||
|
|
||||||
# Subscribe for events tagged with our pubkey
|
|
||||||
sub_id = f"lnbits_transport_{self.public_key_hex[:8]}"
|
|
||||||
sub_filter = {
|
|
||||||
"kinds": [EVENT_KIND],
|
|
||||||
"#p": [self.public_key_hex],
|
|
||||||
"since": int(time.time()),
|
|
||||||
}
|
|
||||||
await ws.send(json.dumps(["REQ", sub_id, sub_filter]))
|
|
||||||
|
|
||||||
# Receive loop
|
|
||||||
while not self._is_shutting_down():
|
|
||||||
try:
|
|
||||||
reply = await ws.recv()
|
|
||||||
if isinstance(reply, bytes):
|
|
||||||
reply = reply.decode("utf-8")
|
|
||||||
await self._on_message(relay_url, reply)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(
|
|
||||||
f"Nostr transport: recv error on {relay_url}: {e}"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
logger.debug(f"Nostr transport: connection to {relay_url} closed")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Nostr transport: error connecting to {relay_url}: {e}")
|
|
||||||
|
|
||||||
self._relay_ws.pop(relay_url, None)
|
|
||||||
if not self._is_shutting_down():
|
|
||||||
logger.debug(f"Nostr transport: reconnecting to {relay_url} in 5s...")
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
|
|
||||||
async def _on_message(self, relay_url: str, message: str):
|
|
||||||
"""Handle an incoming message from a relay."""
|
|
||||||
try:
|
|
||||||
msg = json.loads(message)
|
|
||||||
if msg[0] == "EVENT":
|
|
||||||
await self._on_event(relay_url, msg[2])
|
|
||||||
elif msg[0] == "OK":
|
|
||||||
ok = msg[2] if len(msg) > 2 else True
|
|
||||||
if not ok:
|
|
||||||
reason = msg[3] if len(msg) > 3 else "unknown"
|
|
||||||
logger.warning(
|
|
||||||
f"Nostr transport: event rejected by {relay_url}: {reason}"
|
|
||||||
)
|
|
||||||
elif msg[0] == "EOSE":
|
|
||||||
logger.debug(f"Nostr transport: EOSE from {relay_url}")
|
|
||||||
elif msg[0] == "NOTICE":
|
|
||||||
logger.info(f"Nostr transport: notice from {relay_url}: {msg[1]}")
|
|
||||||
elif msg[0] == "CLOSED":
|
|
||||||
logger.warning(
|
|
||||||
f"Nostr transport: subscription closed by {relay_url}: "
|
|
||||||
f"{msg[2] if len(msg) > 2 else 'no reason'}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Nostr transport: error parsing message from {relay_url}: {e}")
|
|
||||||
|
|
||||||
async def _on_event(self, relay_url: str, event: dict):
|
|
||||||
"""Process an incoming nostr event."""
|
|
||||||
event_id = event.get("id", "")
|
|
||||||
|
|
||||||
# Deduplicate
|
|
||||||
if event_id in self._handled_events:
|
|
||||||
return
|
|
||||||
self._handled_events[event_id] = time.time()
|
|
||||||
|
|
||||||
# Validate event kind
|
|
||||||
if event.get("kind") != EVENT_KIND:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Verify signature
|
|
||||||
if not verify_event(event):
|
|
||||||
logger.warning(f"Nostr transport: invalid signature on event {event_id[:16]}")
|
|
||||||
return
|
|
||||||
|
|
||||||
sender_pubkey = event.get("pubkey", "")
|
|
||||||
if not sender_pubkey:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Decrypt content
|
|
||||||
try:
|
|
||||||
plaintext = nip44_decrypt(
|
|
||||||
event["content"], self.private_key_hex, sender_pubkey
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
f"Nostr transport: decryption failed for event {event_id[:16]}: {e}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Forward to callback
|
|
||||||
try:
|
|
||||||
await self.event_callback(sender_pubkey, plaintext)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Nostr transport: error in event callback for {event_id[:16]}: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _cleanup_handled_events(self):
|
|
||||||
"""Periodically clean up the deduplication cache."""
|
|
||||||
while not self._is_shutting_down():
|
|
||||||
await asyncio.sleep(600) # every 10 minutes
|
|
||||||
now = time.time()
|
|
||||||
expired = [
|
|
||||||
eid
|
|
||||||
for eid, ts in self._handled_events.items()
|
|
||||||
if now - ts > _HANDLED_EVENT_TTL
|
|
||||||
]
|
|
||||||
for eid in expired:
|
|
||||||
del self._handled_events[eid]
|
|
||||||
if expired:
|
|
||||||
logger.debug(
|
|
||||||
f"Nostr transport: cleaned {len(expired)} expired event IDs"
|
|
||||||
)
|
|
||||||
|
|
@ -758,15 +758,6 @@ class NostrAuthSettings(LNbitsSettings):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class NostrTransportSettings(LNbitsSettings):
|
|
||||||
nostr_transport_enabled: bool = Field(default=False)
|
|
||||||
nostr_transport_relays: list[str] = Field(
|
|
||||||
default=["wss://relay.damus.io", "wss://relay.primal.net"]
|
|
||||||
)
|
|
||||||
nostr_transport_private_key: str = Field(default="")
|
|
||||||
nostr_transport_public_key: str = Field(default="")
|
|
||||||
|
|
||||||
|
|
||||||
class GoogleAuthSettings(LNbitsSettings):
|
class GoogleAuthSettings(LNbitsSettings):
|
||||||
google_client_id: str = Field(default="")
|
google_client_id: str = Field(default="")
|
||||||
google_client_secret: str = Field(default="")
|
google_client_secret: str = Field(default="")
|
||||||
|
|
@ -888,7 +879,6 @@ class EditableSettings(
|
||||||
AuditSettings,
|
AuditSettings,
|
||||||
AuthSettings,
|
AuthSettings,
|
||||||
NostrAuthSettings,
|
NostrAuthSettings,
|
||||||
NostrTransportSettings,
|
|
||||||
GoogleAuthSettings,
|
GoogleAuthSettings,
|
||||||
GitHubAuthSettings,
|
GitHubAuthSettings,
|
||||||
KeycloakAuthSettings,
|
KeycloakAuthSettings,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue