From 1c9d7ea3f10c46bd3e7ad382600e6f3ce6db1d95 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 7 Jun 2026 18:42:18 +0300 Subject: [PATCH] added NIP-44 registration --- src/jirorian/crypto.py | 84 ++++++++++++++++++++++++++++++++++++++++++ src/jirorian/nostr.py | 59 +++++++++++++++++++++++++++-- src/jirorian/store.py | 27 ++++++++++++++ 3 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 src/jirorian/crypto.py diff --git a/src/jirorian/crypto.py b/src/jirorian/crypto.py new file mode 100644 index 0000000..8a12a0e --- /dev/null +++ b/src/jirorian/crypto.py @@ -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") diff --git a/src/jirorian/nostr.py b/src/jirorian/nostr.py index 9c9044f..5aa326e 100644 --- a/src/jirorian/nostr.py +++ b/src/jirorian/nostr.py @@ -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) diff --git a/src/jirorian/store.py b/src/jirorian/store.py index dc23528..61a1afe 100644 --- a/src/jirorian/store.py +++ b/src/jirorian/store.py @@ -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: