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 json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
import coincurve
|
import coincurve
|
||||||
from websockets.asyncio.client import connect
|
from websockets.asyncio.client import connect
|
||||||
|
|
||||||
from . import store
|
from . import store
|
||||||
|
from .crypto import nip44_decrypt
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
REGISTRATION_KIND = 5392
|
REGISTRATION_KIND = 5392
|
||||||
|
MAX_EVENT_AGE = 60
|
||||||
RECONNECT_DELAY = 10
|
RECONNECT_DELAY = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _service_privkey() -> str:
|
||||||
|
return os.environ["SERVICE_NOSTR_PRIVKEY"]
|
||||||
|
|
||||||
|
|
||||||
def _service_pubkey() -> str:
|
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()
|
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:
|
def _get_tag(event: dict, name: str) -> str | None:
|
||||||
for tag in event.get("tags", []):
|
for tag in event.get("tags", []):
|
||||||
if len(tag) >= 2 and tag[0] == name:
|
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):
|
async def _handle_event(event: dict):
|
||||||
pubkey = event.get("pubkey")
|
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
|
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)
|
store.whitelist_pubkey(pubkey, app)
|
||||||
logger.info("Whitelisted %s... (app: %s)", pubkey[:16], app)
|
logger.info("Whitelisted %s... (app: %s)", pubkey[:16], app)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,19 @@ def init_db():
|
||||||
uploaded_at TEXT NOT NULL
|
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(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_uploads_pubkey_date ON uploads(pubkey, uploaded_at)"
|
"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:
|
def _status(pubkey: str) -> str | None:
|
||||||
|
|
@ -69,6 +79,23 @@ def count_uploads_today(pubkey: str) -> int:
|
||||||
return row[0] if row else 0
|
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):
|
def record_upload(pubkey: str):
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
with sqlite3.connect(DB_PATH) as conn:
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue