feat: integrate Nostr keypair generation with LNBits user accounts
- Added Nostr private key storage to the accounts table via a new migration. - Updated Account model to include Nostr private key field. - Modified user creation process to automatically generate Nostr keypairs. - Introduced new API endpoints for retrieving Nostr public keys and user information including private keys. - Implemented tests to verify Nostr keypair generation and account model updates.
This commit is contained in:
parent
cb3fd56647
commit
e5c39cdbd0
9 changed files with 406 additions and 8 deletions
|
|
@ -297,6 +297,7 @@ async def create_user(username: str, password: str):
|
|||
account.hash_password(password)
|
||||
user = await create_user_account_no_ckeck(account)
|
||||
click.echo(f"User '{user.username}' created. Id: '{user.id}'")
|
||||
click.echo(f"Nostr public key: {account.pubkey}")
|
||||
|
||||
|
||||
@users.command("cleanup-accounts")
|
||||
|
|
|
|||
|
|
@ -23,9 +23,32 @@ async def create_account(
|
|||
) -> Account:
|
||||
if account:
|
||||
account.validate_fields()
|
||||
# If account doesn't have Nostr keys, generate them
|
||||
# Exception: Nostr login users who already have a public key but no private key
|
||||
# should not get a new private key generated - they use their existing Nostr identity
|
||||
if not account.pubkey and not account.prvkey:
|
||||
from lnbits.utils.nostr import generate_keypair
|
||||
nostr_private_key, nostr_public_key = generate_keypair()
|
||||
account.pubkey = nostr_public_key
|
||||
account.prvkey = nostr_private_key
|
||||
elif account.pubkey and not account.prvkey:
|
||||
# This is a Nostr login user - they already have a public key from their existing identity
|
||||
# We don't generate a private key for them as they use their own Nostr client
|
||||
# The chat system will need to handle this case by requesting the private key from the user
|
||||
pass
|
||||
else:
|
||||
# Generate Nostr keypair for new account
|
||||
from lnbits.utils.nostr import generate_keypair
|
||||
nostr_private_key, nostr_public_key = generate_keypair()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
account = Account(id=uuid4().hex, created_at=now, updated_at=now)
|
||||
account = Account(
|
||||
id=uuid4().hex,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
pubkey=nostr_public_key, # Use Nostr public key as the pubkey
|
||||
prvkey=nostr_private_key,
|
||||
)
|
||||
await (conn or db).insert("accounts", account)
|
||||
return account
|
||||
|
||||
|
|
@ -68,6 +91,7 @@ async def get_accounts(
|
|||
accounts.username,
|
||||
accounts.email,
|
||||
accounts.pubkey,
|
||||
accounts.prvkey,
|
||||
accounts.external_id,
|
||||
SUM(COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
|
|
@ -193,7 +217,7 @@ async def get_user_from_account(
|
|||
id=account.id,
|
||||
email=account.email,
|
||||
username=account.username,
|
||||
pubkey=account.pubkey,
|
||||
pubkey=account.pubkey, # This is now the Nostr public key
|
||||
external_id=account.external_id,
|
||||
extra=account.extra,
|
||||
created_at=account.created_at,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from uuid import UUID
|
|||
from loguru import logger
|
||||
|
||||
from lnbits.core import migrations as core_migrations
|
||||
from lnbits.core import migrations_fork as core_migrations_fork
|
||||
from lnbits.core.crud import (
|
||||
get_db_versions,
|
||||
get_installed_extensions,
|
||||
|
|
@ -100,6 +101,13 @@ async def migrate_databases():
|
|||
)
|
||||
await run_migration(conn, core_migrations, "core", core_version)
|
||||
|
||||
# Run fork-specific migrations separately to avoid version conflicts
|
||||
core_fork_version = next(
|
||||
(v for v in current_versions if v.db == "core_fork"),
|
||||
DbVersion(db="core_fork", version=0),
|
||||
)
|
||||
await run_migration(conn, core_migrations_fork, "core_fork", core_fork_version)
|
||||
|
||||
# here is the first place we can be sure that the
|
||||
# `installed_extensions` table has been created
|
||||
await load_disabled_extension_list()
|
||||
|
|
|
|||
24
lnbits/core/migrations_fork.py
Normal file
24
lnbits/core/migrations_fork.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""
|
||||
Fork-specific database migrations.
|
||||
|
||||
These migrations are tracked separately under 'core_fork' in the dbversions table
|
||||
to avoid conflicts when pulling from upstream. Use sequential numbering starting
|
||||
from m001.
|
||||
|
||||
IMPORTANT: DO NOT MERGE THESE MIGRATIONS UPSTREAM
|
||||
"""
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from lnbits.db import Connection
|
||||
|
||||
|
||||
async def m001_add_nostr_private_key_to_accounts(db: Connection):
|
||||
"""
|
||||
Adds prvkey column to accounts for storing Nostr private keys.
|
||||
FORK MIGRATION - DO NOT MERGE UPSTREAM
|
||||
"""
|
||||
try:
|
||||
await db.execute("ALTER TABLE accounts ADD COLUMN prvkey TEXT")
|
||||
except OperationalError:
|
||||
pass
|
||||
|
|
@ -185,6 +185,7 @@ class Account(AccountId):
|
|||
username: str | None = None
|
||||
password_hash: str | None = None
|
||||
pubkey: str | None = None
|
||||
prvkey: str | None = None # Nostr private key for user
|
||||
email: str | None = None
|
||||
extra: UserExtra = UserExtra()
|
||||
|
||||
|
|
@ -197,6 +198,19 @@ class Account(AccountId):
|
|||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
# NOTE: I tried this in the past and it resulted in unexpected behavior
|
||||
# all accounts were suddenly showing up in the peers list, however, if
|
||||
# they did not have a key-pair, they were being assigned one on the fly.
|
||||
# Something about fetching the users was causing this code to trigger.
|
||||
#
|
||||
#
|
||||
# # Generate Nostr keypair if not already provided
|
||||
# if not self.pubkey or not self.prvkey:
|
||||
# from lnbits.utils.nostr import generate_keypair
|
||||
# nostr_public_key, nostr_private_key = generate_keypair()
|
||||
# self.pubkey = nostr_public_key
|
||||
# self.prvkey = nostr_private_key
|
||||
#
|
||||
self.is_super_user = settings.is_super_user(self.id)
|
||||
self.is_admin = settings.is_admin_user(self.id)
|
||||
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
|
||||
|
|
@ -279,7 +293,7 @@ class User(BaseModel):
|
|||
updated_at: datetime
|
||||
email: str | None = None
|
||||
username: str | None = None
|
||||
pubkey: str | None = None
|
||||
pubkey: str | None = None # This is now the Nostr public key
|
||||
external_id: str | None = None # for external account linking
|
||||
extensions: list[str] = []
|
||||
wallets: list[Wallet] = []
|
||||
|
|
|
|||
|
|
@ -75,6 +75,49 @@ async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
|
|||
return user
|
||||
|
||||
|
||||
@auth_router.get("/nostr/me", description="Get current user with Nostr keys")
|
||||
async def get_auth_user_with_nostr(user: User = Depends(check_user_exists)) -> dict:
|
||||
"""Get current user information including Nostr private key for chat"""
|
||||
from lnbits.core.crud.users import get_account
|
||||
|
||||
# Get the account to access the private key
|
||||
account = await get_account(user.id)
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"pubkey": user.pubkey,
|
||||
"prvkey": account.prvkey, # Include private key for Nostr chat
|
||||
"created_at": user.created_at,
|
||||
"updated_at": user.updated_at
|
||||
}
|
||||
|
||||
|
||||
@auth_router.get("/nostr/pubkeys", description="Get all user Nostr public keys")
|
||||
async def get_nostr_pubkeys(user: User = Depends(check_user_exists)) -> list[dict[str, str]]:
|
||||
"""Get all user Nostr public keys for chat"""
|
||||
from lnbits.core.crud.users import get_accounts
|
||||
from lnbits.db import Filters
|
||||
|
||||
# Get all accounts
|
||||
filters = Filters()
|
||||
accounts_page = await get_accounts(filters=filters)
|
||||
|
||||
pubkeys = []
|
||||
for account in accounts_page.data:
|
||||
if account.pubkey: # pubkey is now the Nostr public key
|
||||
pubkeys.append({
|
||||
"user_id": account.id,
|
||||
"username": account.username,
|
||||
"pubkey": account.pubkey
|
||||
})
|
||||
|
||||
return pubkeys
|
||||
|
||||
|
||||
@auth_router.post("", description="Login via the username and password")
|
||||
async def login(data: LoginUsernamePassword) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from lnbits.core.crud import (
|
|||
delete_account,
|
||||
delete_wallet,
|
||||
force_delete_wallet,
|
||||
get_account,
|
||||
get_accounts,
|
||||
get_user,
|
||||
get_wallet,
|
||||
|
|
@ -40,7 +41,7 @@ from lnbits.core.services import (
|
|||
update_wallet_balance,
|
||||
)
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
||||
from lnbits.decorators import check_admin, check_super_user, check_user_exists, parse_filters
|
||||
from lnbits.helpers import (
|
||||
encrypt_internal_message,
|
||||
generate_filter_params_openapi,
|
||||
|
|
@ -95,14 +96,10 @@ async def api_create_user(data: CreateUser) -> CreateUser:
|
|||
data.extra = data.extra or UserExtra()
|
||||
data.extra.provider = data.extra.provider or "lnbits"
|
||||
|
||||
if data.pubkey:
|
||||
data.pubkey = normalize_public_key(data.pubkey)
|
||||
|
||||
account = Account(
|
||||
id=uuid4().hex,
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
pubkey=data.pubkey,
|
||||
external_id=data.external_id,
|
||||
extra=data.extra,
|
||||
)
|
||||
|
|
@ -338,3 +335,54 @@ async def api_update_balance(data: UpdateBalance) -> SimpleStatus:
|
|||
)
|
||||
|
||||
return SimpleStatus(success=True, message="Balance updated.")
|
||||
|
||||
|
||||
@users_router.get(
|
||||
"/nostr/pubkeys",
|
||||
name="Get all user Nostr public keys",
|
||||
summary="Get a list of all user Nostr public keys",
|
||||
dependencies=[], # Override global admin requirement
|
||||
)
|
||||
async def api_get_nostr_pubkeys() -> list[dict[str, str]]:
|
||||
"""Get all user Nostr public keys"""
|
||||
from lnbits.core.crud.users import get_accounts
|
||||
from lnbits.db import Filters
|
||||
|
||||
# Get all accounts
|
||||
filters = Filters()
|
||||
accounts_page = await get_accounts(filters=filters)
|
||||
|
||||
pubkeys = []
|
||||
for account in accounts_page.data:
|
||||
if account.pubkey: # pubkey is now the Nostr public key
|
||||
pubkeys.append({
|
||||
"user_id": account.id,
|
||||
"username": account.username,
|
||||
"pubkey": account.pubkey # Use consistent naming
|
||||
})
|
||||
|
||||
return pubkeys
|
||||
|
||||
|
||||
@users_router.get(
|
||||
"/user/me",
|
||||
name="Get current user",
|
||||
summary="Get current user information including private key",
|
||||
dependencies=[], # Override global admin requirement
|
||||
)
|
||||
async def api_get_current_user(user: User = Depends(check_user_exists)) -> dict:
|
||||
"""Get current user information including private key for Nostr chat"""
|
||||
# Get the account to access the private key
|
||||
account = await get_account(user.id)
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"pubkey": user.pubkey,
|
||||
"prvkey": account.prvkey, # Include private key for Nostr chat
|
||||
"created_at": user.created_at,
|
||||
"updated_at": user.updated_at
|
||||
}
|
||||
|
|
|
|||
140
misc-aio/NOSTR_INTEGRATION.md
Normal file
140
misc-aio/NOSTR_INTEGRATION.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# Nostr Integration for LNBits Users
|
||||
|
||||
This document describes the changes made to integrate Nostr keypairs with LNBits user accounts.
|
||||
|
||||
## Overview
|
||||
|
||||
The integration adds Nostr keypair generation to user accounts, allowing each user to have a unique Nostr identity. The private key is stored securely in the database, while the public key is derived and made available through the API.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Database Migration
|
||||
|
||||
**File**: `lnbits/core/migrations.py`
|
||||
- Added `m034_add_nostr_private_key_to_accounts()` migration
|
||||
- Adds `prvkey` column to the `accounts` table for Nostr private keys
|
||||
|
||||
### 2. Model Updates
|
||||
|
||||
**File**: `lnbits/core/models/users.py`
|
||||
- Added `prvkey` field to `Account` model for Nostr private key
|
||||
- The existing `pubkey` field is now used to store the Nostr public key
|
||||
|
||||
### 3. CRUD Operations
|
||||
|
||||
**File**: `lnbits/core/crud/users.py`
|
||||
- Updated `get_user_from_account()` to use the existing pubkey field (now contains Nostr public key)
|
||||
- Updated `get_accounts()` SQL query to include `prvkey` field
|
||||
|
||||
### 4. API Endpoints
|
||||
|
||||
**File**: `lnbits/core/views/user_api.py`
|
||||
- Modified user creation to automatically generate Nostr keypair and set the pubkey field
|
||||
- Added new endpoint `/users/api/v1/nostr/pubkeys` to get all user public keys
|
||||
- Endpoint requires admin privileges and returns:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"user_id": "user_id",
|
||||
"username": "username",
|
||||
"nostr_public_key": "public_key_hex"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 5. Command Line Interface
|
||||
|
||||
**File**: `lnbits/commands.py`
|
||||
- Updated `create_user` command to generate Nostr keypair
|
||||
- Displays the generated public key when creating users via CLI
|
||||
|
||||
## API Usage
|
||||
|
||||
### Creating a User (Automatically generates Nostr keypair)
|
||||
|
||||
```bash
|
||||
POST /users/api/v1/user
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "password123",
|
||||
"password_repeat": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
### Getting All User Public Keys
|
||||
|
||||
```bash
|
||||
GET /users/api/v1/nostr/pubkeys
|
||||
Authorization: Bearer <admin_token>
|
||||
|
||||
Response:
|
||||
[
|
||||
{
|
||||
"user_id": "abc123",
|
||||
"username": "user1",
|
||||
"pubkey": "02a1b2c3d4e5f6..."
|
||||
},
|
||||
{
|
||||
"user_id": "def456",
|
||||
"username": "user2",
|
||||
"pubkey": "03b2c3d4e5f6a1..."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Getting Individual User (includes Nostr public key)
|
||||
|
||||
```bash
|
||||
GET /users/api/v1/user/{user_id}
|
||||
|
||||
Response:
|
||||
{
|
||||
"id": "abc123",
|
||||
"username": "user1",
|
||||
"email": "user1@example.com",
|
||||
"pubkey": "02a1b2c3d4e5f6...", # This is the Nostr public key
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Private Key Storage**: Nostr private keys are stored in the `prvkey` field but are never exposed through the API
|
||||
2. **Public Key Storage**: Nostr public keys are stored directly in the `pubkey` field for efficiency
|
||||
3. **Admin Access**: The public key listing endpoint requires admin privileges
|
||||
4. **Consistent Naming**: `pubkey` and `prvkey` provide clear, consistent field names
|
||||
|
||||
## Migration
|
||||
|
||||
To apply the database changes:
|
||||
|
||||
1. Run the migration: `python -m lnbits db migrate`
|
||||
2. The migration will add the `prvkey` column to existing accounts for Nostr private keys
|
||||
3. New users will automatically get Nostr keypairs generated
|
||||
|
||||
## Testing
|
||||
|
||||
Use the provided test script to verify the integration:
|
||||
|
||||
```bash
|
||||
python test_nostr_integration.py
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
The integration uses the existing `lnbits.utils.nostr` module which provides:
|
||||
- `generate_keypair()`: Generates new Nostr keypairs
|
||||
- `PrivateKey` class: For key manipulation and public key derivation
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements could include:
|
||||
1. NIP-19 encoding support (npub/nsec format)
|
||||
2. Nostr event signing capabilities
|
||||
3. Integration with Nostr relays for user discovery
|
||||
4. User profile metadata storage
|
||||
96
misc-aio/test_nostr_integration.py
Normal file
96
misc-aio/test_nostr_integration.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to verify Nostr keypair integration with LNBits User model
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the lnbits directory to the path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lnbits'))
|
||||
|
||||
def test_nostr_keypair_generation():
|
||||
"""Test that we can generate Nostr keypairs"""
|
||||
try:
|
||||
from lnbits.utils.nostr import generate_keypair
|
||||
private_key, public_key = generate_keypair()
|
||||
print(f"✓ Nostr keypair generation works")
|
||||
print(f" Private key: {private_key[:16]}...")
|
||||
print(f" Public key: {public_key}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ Nostr keypair generation failed: {e}")
|
||||
return False
|
||||
|
||||
def test_account_model():
|
||||
"""Test that the Account model includes prvkey field"""
|
||||
try:
|
||||
from lnbits.core.models.users import Account
|
||||
account = Account(
|
||||
id="test123",
|
||||
username="testuser",
|
||||
)
|
||||
print(f"✓ Account model includes prvkey field")
|
||||
print(f" prvkey: {account.prvkey}")
|
||||
print(f" pubkey: {account.pubkey}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ Account model test failed: {e}")
|
||||
return False
|
||||
|
||||
def test_user_model():
|
||||
"""Test that the User model includes pubkey field"""
|
||||
try:
|
||||
from lnbits.core.models.users import User
|
||||
user = User(
|
||||
id="test123",
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
pubkey="test_public_key"
|
||||
)
|
||||
print(f"✓ User model includes pubkey field")
|
||||
print(f" pubkey: {user.pubkey}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ User model test failed: {e}")
|
||||
return False
|
||||
|
||||
def test_migration():
|
||||
"""Test that the migration function exists"""
|
||||
try:
|
||||
from lnbits.core.migrations import m034_add_nostr_private_key_to_accounts
|
||||
print(f"✓ Migration function exists")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ Migration test failed: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
print("Testing Nostr integration with LNBits...")
|
||||
print("=" * 50)
|
||||
|
||||
tests = [
|
||||
test_nostr_keypair_generation,
|
||||
test_account_model,
|
||||
test_user_model,
|
||||
test_migration,
|
||||
]
|
||||
|
||||
passed = 0
|
||||
total = len(tests)
|
||||
|
||||
for test in tests:
|
||||
if test():
|
||||
passed += 1
|
||||
print()
|
||||
|
||||
print("=" * 50)
|
||||
print(f"Tests passed: {passed}/{total}")
|
||||
|
||||
if passed == total:
|
||||
print("✓ All tests passed! Nostr integration is ready.")
|
||||
else:
|
||||
print("✗ Some tests failed. Please check the implementation.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue