added NIP-44 registration

This commit is contained in:
randogoth 2026-06-07 18:42:18 +03:00
parent be93d74d68
commit 1c9d7ea3f1
3 changed files with 166 additions and 4 deletions

84
src/jirorian/crypto.py Normal file
View file

@ -0,0 +1,84 @@
"""
NIP-44 v2 decryption.
Implements only the decrypt path needed server-side. Encryption lives
in the Flutter client. Validate against the official NIP-44 test vectors
before trusting in production.
"""
import base64
import hashlib
import hmac as _hmac
import math
import struct
import coincurve
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import ChaCha20
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
def _ecdh_x(privkey_hex: str, pubkey_hex: str) -> bytes:
"""secp256k1 ECDH → 32-byte x-coordinate of the shared point."""
privkey = bytes.fromhex(privkey_hex)
pubkey = coincurve.PublicKey(bytes.fromhex("02" + pubkey_hex))
return pubkey.multiply(privkey).format(compressed=True)[1:]
def conversation_key(privkey_hex: str, pubkey_hex: str) -> bytes:
shared_x = _ecdh_x(privkey_hex, pubkey_hex)
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b"nip44-v2",
).derive(shared_x)
def _message_keys(conv_key: bytes, nonce: bytes) -> tuple[bytes, bytes, bytes]:
keys = HKDF(
algorithm=hashes.SHA256(),
length=76,
salt=nonce,
info=b"",
).derive(conv_key)
return keys[:32], keys[32:44], keys[44:76]
def _calc_padded_len(n: int) -> int:
if n <= 32:
return 32
next_power = 1 << math.ceil(math.log2(n))
chunk = 32 if next_power <= 256 else next_power // 8
return chunk * math.ceil(n / chunk)
def _unpad(padded: bytes) -> bytes:
length = struct.unpack(">H", padded[:2])[0]
return padded[2:2 + length]
def nip44_decrypt(payload: str, privkey_hex: str, pubkey_hex: str) -> str:
"""Decrypt a NIP-44 v2 base64 payload. Returns plaintext string."""
raw = base64.b64decode(payload)
if raw[0] != 2:
raise ValueError(f"Unsupported NIP-44 version: {raw[0]}")
nonce = raw[1:33]
ciphertext = raw[33:-32]
mac = raw[-32:]
conv = conversation_key(privkey_hex, pubkey_hex)
chacha_key, chacha_nonce, hmac_key = _message_keys(conv, nonce)
expected_mac = _hmac.new(hmac_key, nonce + ciphertext, hashlib.sha256).digest()
if not _hmac.compare_digest(mac, expected_mac):
raise ValueError("HMAC verification failed")
# cryptography's ChaCha20 nonce: 4-byte counter (LE) + 12-byte nonce
algo = ChaCha20(key=chacha_key, nonce=b"\x00\x00\x00\x00" + chacha_nonce)
decryptor = Cipher(algo, mode=None).decryptor()
padded = decryptor.update(ciphertext)
return _unpad(padded).decode("utf-8")

View file

@ -2,23 +2,36 @@ import asyncio
import json
import logging
import os
import time
import coincurve
from websockets.asyncio.client import connect
from . import store
from .crypto import nip44_decrypt
logger = logging.getLogger(__name__)
REGISTRATION_KIND = 5392
MAX_EVENT_AGE = 60
RECONNECT_DELAY = 10
def _service_privkey() -> str:
return os.environ["SERVICE_NOSTR_PRIVKEY"]
def _service_pubkey() -> str:
privkey_bytes = bytes.fromhex(os.environ["SERVICE_NOSTR_PRIVKEY"])
privkey_bytes = bytes.fromhex(_service_privkey())
return coincurve.PublicKey.from_valid_secret(privkey_bytes).format(compressed=True)[1:].hex()
def _app_secrets() -> dict[str, str]:
"""Load per-app shared secrets from JIRORIAN_APP_SECRETS (JSON)."""
raw = os.environ.get("JIRORIAN_APP_SECRETS", "{}")
return json.loads(raw)
def _get_tag(event: dict, name: str) -> str | None:
for tag in event.get("tags", []):
if len(tag) >= 2 and tag[0] == name:
@ -27,10 +40,48 @@ def _get_tag(event: dict, name: str) -> str | None:
async def _handle_event(event: dict):
pubkey = event.get("pubkey")
if not pubkey:
pubkey = event.get("pubkey")
event_id = event.get("id")
content = event.get("content", "")
created_at = event.get("created_at", 0)
if not pubkey or not event_id:
return
app = _get_tag(event, "app") or "unknown"
# 1. Freshness check — reject stale events.
if abs(time.time() - created_at) > MAX_EVENT_AGE:
logger.debug("Rejected stale registration event from %s...", pubkey[:16])
return
# 2. Replay check — reject already-used event IDs.
if store.is_event_used(event_id):
logger.debug("Rejected replayed event %s...", event_id[:16])
return
# 3. Decrypt NIP-44 content and validate app secret.
app = _get_tag(event, "app")
if not app:
logger.debug("Registration event missing 'app' tag from %s...", pubkey[:16])
return
secrets = _app_secrets()
expected = secrets.get(app)
if expected is None:
logger.debug("Unknown app '%s' in registration from %s...", app, pubkey[:16])
return
try:
decrypted = nip44_decrypt(content, _service_privkey(), pubkey)
except Exception as exc:
logger.debug("NIP-44 decryption failed for %s...: %s", pubkey[:16], exc)
return
if decrypted != expected:
logger.warning("Wrong app secret for '%s' from %s...", app, pubkey[:16])
return
# 4. All checks passed — burn event ID and whitelist.
store.burn_event_id(event_id)
store.whitelist_pubkey(pubkey, app)
logger.info("Whitelisted %s... (app: %s)", pubkey[:16], app)

View file

@ -24,9 +24,19 @@ def init_db():
uploaded_at TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS used_event_ids (
event_id TEXT PRIMARY KEY,
burned_at TEXT NOT NULL
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_uploads_pubkey_date ON uploads(pubkey, uploaded_at)"
)
# Prune event IDs older than 2 minutes — they can never be replayed anyway.
conn.execute(
"DELETE FROM used_event_ids WHERE burned_at < datetime('now', '-2 minutes')"
)
def _status(pubkey: str) -> str | None:
@ -69,6 +79,23 @@ def count_uploads_today(pubkey: str) -> int:
return row[0] if row else 0
def is_event_used(event_id: str) -> bool:
with sqlite3.connect(DB_PATH) as conn:
row = conn.execute(
"SELECT 1 FROM used_event_ids WHERE event_id = ?", (event_id,)
).fetchone()
return row is not None
def burn_event_id(event_id: str):
now = datetime.now(timezone.utc).isoformat()
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
"INSERT OR IGNORE INTO used_event_ids (event_id, burned_at) VALUES (?, ?)",
(event_id, now),
)
def record_upload(pubkey: str):
now = datetime.now(timezone.utc).isoformat()
with sqlite3.connect(DB_PATH) as conn: