Nostr WoT

Документация

Всё, что нужно для интеграции Web of Trust в ваше приложение.

Прокси provisioning для LNbits

Небольшой сервис на Node, который стоит перед LNbits и даёт клиентам Nostr безопасную, узкую поверхность для создания кошельков, получения Lightning Address и управления разрешениями Nostr Wallet Connect. Эта страница — руководство по самостоятельному размещению: запустите свой экземпляр на своём LNbits.

Зачем вообще прокси

LNbits предоставляет полноценный административный API. Отдать его браузерному расширению означало бы доверить каждому клиенту власть над всем экземпляром. Прокси публикует только те несколько путей, которые кошельку действительно нужны, аутентифицирует владельцев по ключу Nostr вместо пароля и держит учётные данные суперпользователя LNbits на сервере, где им и место.

Собственным экземплярам LNbits нужен этот адаптер, чтобы интерфейс управления кошельками в расширении работал с ними. Ничто в расширении не привязано к одному оператору.

Архитектура

Запросы приходят на ваш обратный прокси по TLS и передаются сервису на петлевом интерфейсе. Сервис общается с LNbits по HTTP и напрямую читает две базы SQLite LNbits для выборок, которых нет в HTTP API.

text
Nostr client
     |  HTTPS
     v
Reverse proxy (TLS, zaps.example.com)
     |  HTTP, loopback
     v
LNbits proxy  :3003  --- reads ---> database.sqlite3
     |  HTTP                        ext_lnurlp.sqlite3
     v
LNbits  :5000
     |
     v
Lightning backend (phoenixd, LND, ...)

Сервис слушает только 127.0.0.1. Он никогда не доступен из интернета напрямую, поэтому обратный прокси перед ним обязателен, а не желателен.

Прямой доступ к базе — это то, как прокси связывает публичный ключ Nostr с кошельком LNbits и записывает платёжные ссылки Lightning Address. Оба файла должны быть доступны на чтение и запись пользователю, от имени которого работает сервис.

Требования

  • Node.js 24 или новее. Сервис использует встроенный модуль SQLite, который в более ранних версиях ещё экспериментальный.
  • Работающий экземпляр LNbits с установленным расширением lnurlp, а также расширением nwcprovider, если нужна поддержка Nostr Wallet Connect.
  • Lightning-бэкенд за LNbits. В эталонной установке используется phoenixd, но прокси безразлично, какой у вас.
  • Ключ API суперпользователя LNbits, с которым сервис создаёт аккаунты и кошельки. Этот ключ никогда не передаётся клиентам.

Установка и настройка

Склонируйте репозиторий, установите единственную зависимость времени выполнения и запустите сервис с конфигурацией в окружении.

терминал
$git clone https://github.com/nostr-wot/LNbits-proxy.git
$cd LNbits-proxy
$npm ci
$npm test

Переменные окружения

Работу сервиса определяют пять переменных. Обязательна только одна.

VariableDefaultDescription
LNBITS_URLhttp://127.0.0.1:5000Базовый URL вашего экземпляра LNbits.
LNBITS_ADMIN_KEYобязательнаяКлюч API суперпользователя LNbits. Без него сервис откажется создавать кошельки.
LNBITS_DB_PATH.../data/database.sqlite3Путь к основной базе SQLite LNbits.
LNURLP_DB_PATH.../data/ext_lnurlp.sqlite3Путь к базе SQLite расширения lnurlp.
PORT3003Порт петлевого интерфейса, который слушает сервис.

Смените домен до развёртывания

Публичный домен задан константой в исходном коде, а не переменной окружения. Проверка NIP-98 отклоняет любое подписанное событие, у которого тег u не совпадает в точности, поэтому неизменённая копия отклонит каждый запрос на создание кошелька на вашем домене. Впишите в константу в server.js своё имя хоста до запуска сервиса.

javascript
const DOMAIN = 'zaps.example.com';

Что предоставляет ваш экземпляр

Ниже перечислено всё, на что отвечает сервис. Любой путь вне этого списка возвращает 404, включая остальной API LNbits.

Создание кошелька

GET/api/provision/challenge

Выдаёт одноразовый случайный challenge. Без аутентификации. Challenge истекает через 60 секунд и расходуется при первом успешном использовании.

json
{
  "challenge": "7f3a…"
}

POST/api/provision

Создаёт кошелёк для публичного ключа Nostr или возвращает существующий. Ответ содержит ключи кошелька и помечен no-store.

Отправьте подписанное событие NIP-98 в поле event, при желании с именем кошелька.

json
{
  "event": {
    "kind": 27235,
    "…": "signed NIP-98 event"
  },
  "name": "My Wallet"
}
json
{
  "walletId": "…",
  "adminkey": "…",
  "inkey": "…",
  "lightningAddress": null
}

Lightning Address

POST/api/claim-username

Занимает имя пользователя Lightning Address для аутентифицированного ключа. Создаёт платёжную ссылку LNURL и записывает имя в аккаунт.

GET/api/lightning-address?pubkey={hex}

Находит Lightning Address, занятый публичным ключом. Без аутентификации. Параметр pubkey обязателен и должен содержать 64 шестнадцатеричных символа в нижнем регистре.

json
{
  "lightningAddress": "[email protected]"
}

POST/api/release-username

Освобождает занятое имя, делая его доступным другим и удаляя платёжную ссылку.

Nostr Wallet Connect

Управление разрешениями NWC в пределах одного кошелька. Каждый маршрут требует админский ключ API этого кошелька в заголовке X-Api-Key; ключи только для счетов отклоняются. Эти маршруты вообще не принимают строку запроса.

GET/api/nwc/connections

Перечисляет активные подключения с расходом бюджета, а также публичный ключ провайдера и релей. Возвращает пустой список, если человек не включил расширение.

json
{
  "connections": [],
  "provider": {
    "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
    "relay": "wss://relay.example.com"
  }
}

PUT/api/nwc/connections/{clientPubkey}

Регистрирует созданный клиентом публичный ключ как подключение. Повторный вызов с тем же ключом идемпотентен и возвращает 200, а не изменяет существующее разрешение.

json
{
  "name": "My phone",
  "dailyLimit": 10000,
  "days": 90
}
bash
curl -X PUT "https://zaps.example.com/api/nwc/connections/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \
  -H "X-Api-Key: $WALLET_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"My phone","dailyLimit":10000,"days":90}'

DELETE/api/nwc/connections/{clientPubkey}

Отзывает разрешение этого кошелька для клиентского публичного ключа. Уже отправленные платежи не отменяются.

Nostr Wallet Connect

Клиент создаёт собственный секрет и формирует строку сопряжения локально. Прокси никогда не получает, не хранит, не логирует и не возвращает секрет сопряжения и никогда не помещает его в URL.

Новые разрешения жёстко ограничены правами pay, lookup и info, с дневным бюджетом и сроком действия. LNbits остаётся источником истины по владению, ограничениям аккаунта и соблюдению бюджета.

Проксируемые пути LNbits

Две группы путей LNbits передаются без изменений. Всё остальное заблокировано.

Публичные пути LNURL, передаваемые с разрешающим CORS, потому что кошельки обращаются к ним с другого источника. Заголовок Host переписывается на ваш публичный домен, чтобы LNbits формировал корректные URL обратного вызова.

text
GET  /.well-known/lnurlp/{username}
GET  /lnurlp/api/v1/lnurl/cb/{id}

Аутентифицированные пути кошелька, требующие заголовок X-Api-Key; их использует раздел кошелька в расширении.

text
GET|POST  /api/v1/wallet
GET|POST  /api/v1/payments

Аутентификация NIP-98

Создание кошелька и любые изменения Lightning Address используют схему challenge-response с подписанным событием Nostr. Ни паролей, ни сессий.

  1. Запросите challenge.
  2. Составьте событие вида 27235, несущее challenge и именно тот запрос, который оно авторизует.
  3. Подпишите его ключом Nostr пользователя и отправьте в поле event тела POST.
  4. Сервис проверяет подпись Schnorr до того, как израсходовать challenge, поэтому неудачная попытка его не сжигает.

Обязательные теги

Все три тега обязательны и проверяются на точное совпадение. Тег u должен быть полным абсолютным URL вызываемого эндпоинта на вашем домене, а тег method должен совпадать с HTTP-методом.

javascript
const { challenge } = await fetch(
  "https://zaps.example.com/api/provision/challenge"
).then(r => r.json());

const event = await window.nostr.signEvent({
  kind: 27235,
  created_at: Math.floor(Date.now() / 1000),
  tags: [
    ["u", "https://zaps.example.com/api/provision"],
    ["method", "POST"],
    ["challenge", challenge],
  ],
  content: "",
});

const wallet = await fetch("https://zaps.example.com/api/provision", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ event, name: "My Wallet" }),
}).then(r => r.json());

Временные окна

Действуют два независимых окна: challenge истекает через 60 секунд после выдачи, а created_at события должен укладываться в 60 секунд от времени сервера. Клиент с сильно сбитыми часами получит отказ даже со свежим challenge.

Обратный прокси

Терминируйте TLS и передавайте на петлевой порт. Блок ниже — эталонная конфигурация.

nginx
server {
    listen 443 ssl;
    server_name zaps.example.com;

    ssl_certificate     /etc/letsencrypt/live/zaps.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/zaps.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3003;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
    }
}

server {
    listen 80;
    server_name zaps.example.com;
    return 301 https://$host$request_uri;
}

Восстановите реальный IP клиента, если впереди стоит CDN

Ограничение частоты опирается на самую правую запись X-Forwarded-For, которую добавляет ваш обратный прокси. Если домен обслуживает CDN вроде Cloudflare, эта запись — адрес узла CDN, а не посетителя, и все за одним узлом делят один счётчик: несколько запросов в минуту сразу на всех. Восстановите реальный адрес до того, как запрос дойдёт до сервиса.

nginx
# Inside the server block, before location /
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ... the rest of your CDN's published ranges
real_ip_header CF-Connecting-IP;
real_ip_recursive on;

Пропускайте этот блок, только если между интернетом и вашим обратным прокси ничего нет. Держите диапазоны адресов в соответствии с публикуемым списком вашей CDN.

Управление процессом

Подойдёт любой супервизор. В эталонной установке используется pm2: конфигурация передаётся в окружение процесса, а список процессов сохраняется, чтобы пережить перезагрузку.

терминал
$pm2 start server.js --name lnbits-proxy
$pm2 save
$pm2 startup
$pm2 logs lnbits-proxy

Передавайте ключ суперпользователя через супервизор или файл окружения, который никогда не попадает в репозиторий. В коде ему не место.

Мониторинг

Стоит держать два независимых наблюдателя: проверку состояния всей связки и сторожевой таймер для Lightning-бэкенда.

Сторожевой таймер Lightning-бэкенда

phoenixd может оказаться в состоянии, когда процесс жив и слушает порт, но его HTTP API не отвечает никогда, потому что цикл переподключения к LSP блокирует цикл событий. Кошельки сообщают о неудачных платежах, а systemctl считает сервис активным. Таймер, который опрашивает API и перезапускает после двух неудач подряд, устраняет это без ночных подъёмов.

ini
# /etc/systemd/system/check-phoenixd.timer
[Unit]
Description=Run phoenixd health check every 2 minutes

[Timer]
OnBootSec=60
OnUnitActiveSec=120
AccuracySec=10

[Install]
WantedBy=timers.target

Двойная проверка важна: одиночный таймаут обычно просто сбой связи, а перезапускать бэкенд на каждом сбое хуже самой неисправности.

Проверка состояния связки

Периодическая проверка, которая задействует LNbits, прокси, публичные пути LNURL и сквозную генерацию счёта, и шлёт письмо при смене состояния. Запускайте её из cron на том же хосте.

text
*/5 * * * * /usr/bin/node /srv/monitor/monitor.mjs >> /srv/monitor/cron.log 2>&1

Проверка состояния связки

Если ваша проверка создаёт настоящие счета, выделите ей отдельный кошелёк и адрес, задайте короткий срок действия и убирайте счета. Опрос адреса реального человека каждые несколько минут забивает его историю платежей счетами, которые никто никогда не оплатит.

Ограничения и валидация

Ограничения частоты

На адрес клиента в минуту. Запросы сверх лимита получают 429.

RouteRequests / minute
/api/nwc/connections60
/api/provision/challenge10
/api/provision5
/api/claim-username3
/api/release-username3

Имена пользователей Lightning Address

Имя должно быть длиной от 3 до 30 символов, из строчных букв и цифр с точками, дефисами и подчёркиваниями, начинаться и заканчиваться буквой или цифрой.

javascript
/^[a-z0-9][a-z0-9._-]{1,28}[a-z0-9]$/

Эти имена заблокированы:

text
admin  support  help  info  noreply
postmaster  webmaster  abuse  root  system

Параметры подключения NWC

  • Имя подключения: от 1 до 50 символов, без управляющих символов.
  • Дневной лимит: от 1 до 9 999 999 сатоши, в скользящем окне 24 часа.
  • Срок действия: от 1 до 365 дней.
  • Не более 50 активных подключений на кошелёк.

Тело запроса

64 КБ для создания кошелька и запросов Lightning Address, 4 КБ для создания подключения NWC.

Исходный код

Сервис — один файл на Node с единственной зависимостью времени выполнения. Прочитайте его прежде, чем запускать. github.com/nostr-wot/LNbits-proxy