Merge pull request #354 from naturallaw777/arena/019fabb1-sovran-systemsos

security: harden Lightning Wallet Connections (NWC)
This commit is contained in:
Sovran Systems
2026-07-28 21:54:23 -05:00
committed by GitHub
6 changed files with 339 additions and 11 deletions
+75
View File
@@ -0,0 +1,75 @@
"""
Structured audit logging for NWC wallet operations.
Writes append-only JSON lines to /var/log/sovran-nwc-audit.log.
Log file is owned by albyhub:albyhub with mode 0600.
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from typing import Any
logger = logging.getLogger(__name__)
AUDIT_LOG_PATH = "/var/log/sovran-nwc-audit.log"
_AUDIT_LOCK = threading.Lock()
_initialized = False
def _ensure_log_file() -> None:
"""Ensure audit log file exists with correct permissions."""
global _initialized
if _initialized:
return
with _AUDIT_LOCK:
if _initialized:
return
try:
# Create directory if needed
os.makedirs(os.path.dirname(AUDIT_LOG_PATH), exist_ok=True)
# Create file if it doesn't exist
if not os.path.exists(AUDIT_LOG_PATH):
with open(AUDIT_LOG_PATH, "w") as f:
pass
# Set restrictive permissions
os.chmod(AUDIT_LOG_PATH, 0o600)
# Try to set ownership to albyhub user (best effort)
try:
import pwd
import grp
albyhub_uid = pwd.getpwnam("albyhub").pw_uid
albyhub_gid = grp.getgrnam("albyhub").gr_gid
os.chown(AUDIT_LOG_PATH, albyhub_uid, albyhub_gid)
except Exception:
pass # Best effort; may not have permissions
_initialized = True
except Exception as exc:
logger.warning("Failed to initialize audit log: %s", exc)
def audit_log(event: str, **fields: Any) -> None:
"""Write a structured audit log entry.
Args:
event: Event type identifier (e.g., "wallet_created", "invoice_issued")
**fields: Additional key-value fields to include in the log entry
"""
_ensure_log_file()
entry = {
"ts": time.time(),
"event": event,
**fields,
}
try:
with _AUDIT_LOCK:
with open(AUDIT_LOG_PATH, "a") as f:
f.write(json.dumps(entry, separators=(",", ":")) + "\n")
except Exception as exc:
logger.error("Failed to write audit log: %s", exc)
+118 -1
View File
@@ -14,6 +14,7 @@ import json
import logging import logging
import os import os
import re import re
import secrets
import threading import threading
import time import time
import urllib.error import urllib.error
@@ -21,6 +22,8 @@ import urllib.parse
import urllib.request import urllib.request
from typing import Any from typing import Any
from . import nwc_audit as _audit_mod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Constants ────────────────────────────────────────────────────── # ── Constants ──────────────────────────────────────────────────────
@@ -198,6 +201,12 @@ class AlbyHubManager:
offset += page_size offset += page_size
return results return results
# ── Audit log helper ───────────────────────────────────────────
def _audit(self, event: str, **fields: Any) -> None:
"""Emit structured audit log entry."""
_audit_mod.audit_log(event, **fields)
# ── Startup / Auth ───────────────────────────────────────────── # ── Startup / Auth ─────────────────────────────────────────────
def _read_unlock_password(self) -> str: def _read_unlock_password(self) -> str:
@@ -514,6 +523,19 @@ class AlbyHubManager:
"Do not recreate this wallet." "Do not recreate this wallet."
) )
# Audit log: wallet created
self._audit(
"wallet_created",
wallet_id=str(app_id) if app_id else "unknown",
name=name,
alias=alias,
access_preset=access_preset,
spending_limit_sats=spending_limit_sats,
lightning_address=wallet_meta.get("lightning_address"),
funding_attempted=funding_result["attempted"],
funding_success=funding_result["success"],
)
return { return {
"wallet": wallet_meta, "wallet": wallet_meta,
"pairing_uri": pairing_uri, # returned once on create only "pairing_uri": pairing_uri, # returned once on create only
@@ -626,6 +648,16 @@ class AlbyHubManager:
"Drain verification failed: final balance does not match expected dust.", "Drain verification failed: final balance does not match expected dust.",
) )
# Audit log: wallet drained
self._audit(
"wallet_drained",
wallet_id=str(app_id),
name=app.get("name", ""),
alias=app.get("alias", ""),
drained_sats=drained_sats,
dust_msat=expected_dust_msat,
)
return { return {
"ok": True, "ok": True,
"drained_sats": drained_sats, "drained_sats": drained_sats,
@@ -679,6 +711,16 @@ class AlbyHubManager:
f"/api/apps/{urllib.parse.quote(pubkey, safe='')}", f"/api/apps/{urllib.parse.quote(pubkey, safe='')}",
) )
# Audit log: wallet deleted
self._audit(
"wallet_deleted",
wallet_id=str(app_id),
name=app.get("name", ""),
alias=app.get("alias", ""),
drained_sats=drain_result.get("drained_sats", 0),
dust_msat=remaining_msat,
)
return { return {
"ok": True, "ok": True,
"drained_sats": drain_result.get("drained_sats", 0), "drained_sats": drain_result.get("drained_sats", 0),
@@ -720,6 +762,15 @@ class AlbyHubManager:
"Invoice attribution mismatch: returned appId does not match.", "Invoice attribution mismatch: returned appId does not match.",
) )
# Audit log: invoice issued via API
self._audit(
"invoice_issued",
app_id=app_id,
amount_msat=amount_msat,
amount_sat=amount_msat // 1000,
invoice_prefix=invoice[:50] + "..." if len(invoice) > 50 else invoice,
)
return invoice return invoice
def find_app_by_alias(self, alias: str) -> dict | None: def find_app_by_alias(self, alias: str) -> dict | None:
@@ -731,6 +782,72 @@ class AlbyHubManager:
return a return a
return None return None
def rotate_wallet_secret(self, identifier: str) -> dict:
"""Rotate the NWC pairing secret for a wallet connection.
Revokes the old Nostr key and generates a new pairing URI.
Returns the new pairing URI (shown ONCE).
"""
app = self._find_managed_app(identifier)
if app is None:
raise AlbyHubError("wallet_not_found", "Wallet connection not found.")
app_id = int(app["id"])
app_pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
if not app_pubkey:
raise AlbyHubError(
"app_pubkey_missing",
"Cannot rotate secret: app public key not available.",
)
# Call Alby Hub's rotate secret endpoint (if available)
# Alby Hub may not have this endpoint yet; fall back to re-creating the app
# For now, we'll delete and re-create with same metadata
# This is a safe operation since we drain first
name = app.get("name", "")
alias = app.get("alias", "")
scopes = app.get("scopes") or []
max_amount = app.get("maxAmountSat") or 0
metadata = app.get("metadata") or {}
# Drain first
self.drain_wallet(identifier)
# Delete old app
self._authenticated_request(
"DELETE",
f"/api/apps/{urllib.parse.quote(app_pubkey, safe='')}",
)
# Create new app with same parameters
create_body: dict = {
"name": name,
"scopes": scopes,
"isolated": True,
"budgetRenewal": "never",
"maxAmountSat": max_amount,
"metadata": metadata,
}
resp = self._authenticated_request("POST", "/api/apps", body=create_body)
new_pairing_uri: str = resp.get("pairingUri") or resp.get("pairing_uri") or ""
new_app_id = resp.get("id")
# Audit log: secret rotated
self._audit(
"wallet_secret_rotated",
old_wallet_id=str(app_id),
new_wallet_id=str(new_app_id) if new_app_id else "unknown",
name=name,
alias=alias,
)
return {
"wallet_id": str(new_app_id) if new_app_id else "",
"pairing_uri": new_pairing_uri,
"message": "New NWC connection secret generated. Save it now — it will not be shown again.",
}
def health(self) -> dict: def health(self) -> dict:
"""Return a basic health summary.""" """Return a basic health summary."""
try: try:
@@ -761,4 +878,4 @@ def get_manager() -> AlbyHubManager:
with _manager_lock: with _manager_lock:
if _manager is None: if _manager is None:
_manager = AlbyHubManager() _manager = AlbyHubManager()
return _manager return _manager
+83 -8
View File
@@ -18,11 +18,14 @@ import json
import logging import logging
import os import os
import re import re
import time
import urllib.parse import urllib.parse
from collections import defaultdict
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from . import nwc_hub_manager as _mgr_mod from . import nwc_hub_manager as _mgr_mod
from . import nwc_audit as _audit_mod
if TYPE_CHECKING: if TYPE_CHECKING:
from .nwc_hub_manager import AlbyHubManager from .nwc_hub_manager import AlbyHubManager
@@ -37,6 +40,11 @@ DOMAIN_FILE = "/var/lib/domains/lightning"
NWC_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$") NWC_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
# Rate limiting configuration
RATE_LIMIT_WINDOW_SEC = 60
RATE_LIMIT_MAX_REQUESTS = 30
_rate_limit_buckets: dict[str, list[float]] = defaultdict(list)
# ── Helpers ─────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────
@@ -44,15 +52,34 @@ def _read_domain() -> str | None:
try: try:
with open(DOMAIN_FILE, "r") as fh: with open(DOMAIN_FILE, "r") as fh:
raw = fh.read(256).strip().lower() raw = fh.read(256).strip().lower()
# Basic validation: must look like a hostname # Strict FQDN validation: must be a valid hostname with at least one dot
if re.match(r"^[a-z0-9][a-z0-9.\-]{1,253}$", raw): # Reject localhost, IP addresses, and single-label names
return raw if not re.match(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$", raw):
return None
# Explicitly reject local/reserved names
if raw in {"localhost", "localhost.localdomain", "local"}:
return None
return raw
except OSError: except OSError:
pass pass
return None return None
def _lnurl_discovery(alias: str, manager: "AlbyHubManager") -> tuple[dict, int]: def _check_rate_limit(client_ip: str) -> bool:
"""Check and update rate limit bucket for client IP. Returns True if allowed."""
now = time.monotonic()
bucket = _rate_limit_buckets[client_ip]
# Prune old entries
cutoff = now - RATE_LIMIT_WINDOW_SEC
while bucket and bucket[0] < cutoff:
bucket.pop(0)
if len(bucket) >= RATE_LIMIT_MAX_REQUESTS:
return False
bucket.append(now)
return True
def _lnurl_discovery(alias: str, manager: "AlbyHubManager", client_ip: str = "") -> tuple[dict, int]:
alias = alias.strip().lower() alias = alias.strip().lower()
if not NWC_ALIAS_RE.match(alias): if not NWC_ALIAS_RE.match(alias):
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404 return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
@@ -82,6 +109,16 @@ def _lnurl_discovery(alias: str, manager: "AlbyHubManager") -> tuple[dict, int]:
description = meta.get("lnurl_description") or f"Pay {alias}" description = meta.get("lnurl_description") or f"Pay {alias}"
metadata = json.dumps([["text/plain", description]], separators=(",", ":")) metadata = json.dumps([["text/plain", description]], separators=(",", ":"))
# Audit log: LNURL discovery
_audit_mod.audit_log(
"lnurl_discovery",
alias=alias,
domain=domain,
client_ip=client_ip,
min_sendable_msat=min_sendable,
max_sendable_msat=max_sendable,
)
return { return {
"tag": "payRequest", "tag": "payRequest",
"callback": callback, "callback": callback,
@@ -93,9 +130,9 @@ def _lnurl_discovery(alias: str, manager: "AlbyHubManager") -> tuple[dict, int]:
def _lnurl_callback( def _lnurl_callback(
alias: str, amount_str: str | None, manager: "AlbyHubManager" alias: str, amount_str: str | None, manager: "AlbyHubManager", client_ip: str = ""
) -> tuple[dict, int]: ) -> tuple[dict, int]:
payload, status_code = _lnurl_discovery(alias, manager) payload, status_code = _lnurl_discovery(alias, manager, client_ip)
if status_code != 200: if status_code != 200:
return payload, status_code return payload, status_code
@@ -144,6 +181,16 @@ def _lnurl_callback(
except _mgr_mod.AlbyHubError: except _mgr_mod.AlbyHubError:
return {"status": "ERROR", "reason": "Invoice creation failed"}, 502 return {"status": "ERROR", "reason": "Invoice creation failed"}, 502
# Audit log: Invoice generated via LNURL
_audit_mod.audit_log(
"lnurl_invoice_created",
alias=alias,
amount_msat=amount_msat,
amount_sat=amount_msat // 1000,
client_ip=client_ip,
invoice_prefix=invoice[:50] + "..." if len(invoice) > 50 else invoice,
)
return {"pr": invoice, "routes": []}, 200 return {"pr": invoice, "routes": []}, 200
@@ -167,10 +214,38 @@ def _make_handler(manager: "AlbyHubManager") -> type:
self.end_headers() self.end_headers()
self.wfile.write(raw) self.wfile.write(raw)
def _get_client_ip(self) -> str:
# Check X-Forwarded-For header (set by Caddy)
forwarded = self.headers.get("X-Forwarded-For")
if forwarded:
# Take the first IP in the chain
return forwarded.split(",")[0].strip()
# Fallback to direct connection IP
return self.client_address[0]
def _check_rate_limit(self) -> bool:
client_ip = self._get_client_ip()
if not _check_rate_limit(client_ip):
self._send_json(429, {
"status": "ERROR",
"reason": "Rate limit exceeded. Please slow down."
})
_audit_mod.audit_log(
"rate_limit_exceeded",
client_ip=client_ip,
path=self.path,
)
return False
return True
def do_GET(self) -> None: # noqa: N802 def do_GET(self) -> None: # noqa: N802
if not self._check_rate_limit():
return
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
path = parsed.path path = parsed.path
qs = urllib.parse.parse_qs(parsed.query) qs = urllib.parse.parse_qs(parsed.query)
client_ip = self._get_client_ip()
# /.well-known/lnurlp/{alias} # /.well-known/lnurlp/{alias}
m = re.fullmatch( m = re.fullmatch(
@@ -178,7 +253,7 @@ def _make_handler(manager: "AlbyHubManager") -> type:
) )
if m: if m:
alias = urllib.parse.unquote(m.group(1)) alias = urllib.parse.unquote(m.group(1))
payload, code = _lnurl_discovery(alias, self._manager) payload, code = _lnurl_discovery(alias, self._manager, client_ip)
self._send_json(code, payload) self._send_json(code, payload)
return return
@@ -200,7 +275,7 @@ def _make_handler(manager: "AlbyHubManager") -> type:
return return
else: else:
amount_str = amount_values[0] amount_str = amount_values[0]
payload, code = _lnurl_callback(alias, amount_str, self._manager) payload, code = _lnurl_callback(alias, amount_str, self._manager, client_ip)
self._send_json(code, payload) self._send_json(code, payload)
return return
@@ -36,6 +36,9 @@ def main(argv: list[str] | None = None) -> int:
addr_show = addr_sub.add_parser("show") addr_show = addr_sub.add_parser("show")
addr_show.add_argument("alias") addr_show.add_argument("alias")
rotate = sub.add_parser("rotate")
rotate.add_argument("wallet")
sub.add_parser("health") sub.add_parser("health")
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -80,6 +83,19 @@ def main(argv: list[str] | None = None) -> int:
_print(result) _print(result)
return 0 return 0
if args.cmd == "rotate":
try:
result = manager.rotate_wallet_secret(args.wallet)
except _mgr_mod.AlbyHubError as exc:
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
return 1
_print({
"wallet_id": result.get("wallet_id", ""),
"pairing_uri": result.get("pairing_uri", ""),
"message": result.get("message", "New NWC connection secret generated. Save it now — it will not be shown again."),
})
return 0
if args.cmd == "create": if args.cmd == "create":
alias = args.alias.strip().lower() alias = args.alias.strip().lower()
if not _nwc_validate_alias(alias): if not _nwc_validate_alias(alias):
+38
View File
@@ -4618,6 +4618,44 @@ async def api_nwc_drain_wallet(wallet_identifier: str):
return result return result
@app.post("/api/nwc/wallets/{wallet_identifier}/rotate-secret")
async def api_nwc_rotate_wallet_secret(wallet_identifier: str):
"""Rotate the NWC pairing secret for a wallet connection.
Revokes the old Nostr key and generates a new pairing URI.
Returns the new pairing URI (shown ONCE).
"""
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
None,
_nwc_mgr.get_manager().rotate_wallet_secret,
wallet_identifier,
)
except _nwc_mgr.AlbyHubError as exc:
code_map = {
"wallet_not_found": 404,
"pending_transactions": 409,
"negative_balance": 409,
}
status = code_map.get(exc.code, 502)
return _nwc_error(status, exc.code, exc.args[0])
pairing_uri: str = result.get("pairing_uri", "")
pairing_qrcode: str | None = None
if pairing_uri:
pairing_qrcode = _generate_qr_base64(pairing_uri)
response: dict = {
"wallet_id": result.get("wallet_id", ""),
"pairing_uri": pairing_uri,
"message": result.get("message", "New NWC connection secret generated. Save it now — it will not be shown again."),
}
if pairing_qrcode:
response["pairing_qrcode"] = pairing_qrcode
return JSONResponse(status_code=200, content=response)
@app.post("/api/nwc/addresses/{alias}/test") @app.post("/api/nwc/addresses/{alias}/test")
async def api_nwc_test(alias: str): async def api_nwc_test(alias: str):
normalized_alias = alias.strip().lower() normalized_alias = alias.strip().lower()
+9 -2
View File
@@ -19,12 +19,16 @@ let
NWC_LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}"; NWC_LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}";
NWC_LND_CERT_FILE = lndCertPath; NWC_LND_CERT_FILE = lndCertPath;
NWC_LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon"; NWC_LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
NWC_RELAY = lib.mkIf config.sovran_systemsOS.features.haven
"wss://haven.${config.networking.domain}/nostr"
"wss://relay.getalby.com,wss://relay2.getalby.com";
}; };
wrappedNwcWallet = lib.hiPrio (pkgs.writeShellScriptBin "nwc-wallet" '' wrappedNwcWallet = lib.hiPrio (pkgs.writeShellScriptBin "nwc-wallet" ''
export NWC_ALBY_HUB_API_BASE='${pythonManagerEnvironment.NWC_ALBY_HUB_API_BASE}' export NWC_ALBY_HUB_API_BASE='${pythonManagerEnvironment.NWC_ALBY_HUB_API_BASE}'
export NWC_LND_ADDRESS='${pythonManagerEnvironment.NWC_LND_ADDRESS}' export NWC_LND_ADDRESS='${pythonManagerEnvironment.NWC_LND_ADDRESS}'
export NWC_LND_CERT_FILE='${pythonManagerEnvironment.NWC_LND_CERT_FILE}' export NWC_LND_CERT_FILE='${pythonManagerEnvironment.NWC_LND_CERT_FILE}'
export NWC_LND_MACAROON_FILE='${pythonManagerEnvironment.NWC_LND_MACAROON_FILE}' export NWC_LND_MACAROON_FILE='${pythonManagerEnvironment.NWC_LND_MACAROON_FILE}'
export NWC_RELAY='${pythonManagerEnvironment.NWC_RELAY}'
exec ${config.services.sovranHub.webPackage}/bin/nwc-wallet "$@" exec ${config.services.sovranHub.webPackage}/bin/nwc-wallet "$@"
''); '');
@@ -108,7 +112,10 @@ lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
WORK_DIR = "/var/lib/albyhub"; WORK_DIR = "/var/lib/albyhub";
DATABASE_URI = "/var/lib/albyhub/nwc.db"; DATABASE_URI = "/var/lib/albyhub/nwc.db";
PORT = toString albyHubPort; PORT = toString albyHubPort;
RELAY = "wss://relay.getalby.com,wss://relay2.getalby.com"; # Use private Nostr relay if Haven is enabled, otherwise default to Alby's public relays
RELAY = lib.mkIf config.sovran_systemsOS.features.haven
"wss://haven.${config.networking.domain}/nostr"
"wss://relay.getalby.com,wss://relay2.getalby.com";
AUTO_LINK_ALBY_ACCOUNT = "false"; AUTO_LINK_ALBY_ACCOUNT = "false";
SEND_EVENTS_TO_ALBY = "false"; SEND_EVENTS_TO_ALBY = "false";
LOG_TO_FILE = "false"; LOG_TO_FILE = "false";
@@ -170,4 +177,4 @@ lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
needsDDNS = true; needsDDNS = true;
} }
]; ];
} }