added NIP-44 registration
This commit is contained in:
parent
be93d74d68
commit
1c9d7ea3f1
3 changed files with 166 additions and 4 deletions
84
src/jirorian/crypto.py
Normal file
84
src/jirorian/crypto.py
Normal 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")
|
||||
|
|
@ -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:
|
||||
|
|
@ -28,9 +41,47 @@ def _get_tag(event: dict, name: str) -> str | None:
|
|||
|
||||
async def _handle_event(event: dict):
|
||||
pubkey = event.get("pubkey")
|
||||
if not 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue