140 lines
4.6 KiB
Python
140 lines
4.6 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import time
|
|||
|
|
|
|||
|
|
from nostr_sdk import (
|
|||
|
|
Event,
|
|||
|
|
EventBuilder,
|
|||
|
|
Keys,
|
|||
|
|
Kind,
|
|||
|
|
PublicKey,
|
|||
|
|
Tag,
|
|||
|
|
UnwrappedGift,
|
|||
|
|
nip04_decrypt,
|
|||
|
|
nip04_encrypt,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
from yuxi.utils.logging_config import logger
|
|||
|
|
|
|||
|
|
|
|||
|
|
class NostrCryptoError(Exception):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def normalize_pubkey(raw: str) -> str:
|
|||
|
|
if not raw or not raw.strip():
|
|||
|
|
return ""
|
|||
|
|
raw = raw.strip()
|
|||
|
|
if raw.startswith("nostr:"):
|
|||
|
|
raw = raw[6:]
|
|||
|
|
if raw.startswith("npub1"):
|
|||
|
|
try:
|
|||
|
|
return PublicKey.from_bech32(raw).to_hex()
|
|||
|
|
except Exception:
|
|||
|
|
return ""
|
|||
|
|
if raw.startswith("nprofile1"):
|
|||
|
|
try:
|
|||
|
|
from nostr_sdk import Profile
|
|||
|
|
|
|||
|
|
profile = Profile.from_bech32(raw)
|
|||
|
|
return profile.public_key().to_hex()
|
|||
|
|
except Exception:
|
|||
|
|
return ""
|
|||
|
|
if len(raw) == 64 and all(c in "0123456789abcdef" for c in raw):
|
|||
|
|
return raw
|
|||
|
|
if raw.startswith("nsec1"):
|
|||
|
|
try:
|
|||
|
|
keys = Keys.parse(raw)
|
|||
|
|
return keys.public_key().to_hex()
|
|||
|
|
except Exception:
|
|||
|
|
return ""
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
|
|||
|
|
class NostrCrypto:
|
|||
|
|
def __init__(self, nsec_or_hex: str | None = None):
|
|||
|
|
if nsec_or_hex:
|
|||
|
|
try:
|
|||
|
|
self._keys = Keys.parse(nsec_or_hex)
|
|||
|
|
except Exception as e:
|
|||
|
|
msg = f"Nostr 私钥解析失败: {e}。请检查配置中的 private_key (nsec/hex) 是否正确。"
|
|||
|
|
logger.error(msg)
|
|||
|
|
raise NostrCryptoError(msg) from e
|
|||
|
|
else:
|
|||
|
|
self._keys = Keys.generate()
|
|||
|
|
logger.warning(
|
|||
|
|
"未配置 Nostr 私钥,已自动生成临时密钥。npub: %s。生产环境请配置正确的 private_key",
|
|||
|
|
self._keys.public_key().to_bech32(),
|
|||
|
|
)
|
|||
|
|
self._public_key = self._keys.public_key()
|
|||
|
|
self._secret_key = self._keys.secret_key()
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def npub(self) -> str:
|
|||
|
|
return self._public_key.to_bech32()
|
|||
|
|
|
|||
|
|
def pubkey_hex(self) -> str:
|
|||
|
|
return self._public_key.to_hex()
|
|||
|
|
|
|||
|
|
def nsec(self) -> str:
|
|||
|
|
return self._secret_key.to_bech32()
|
|||
|
|
|
|||
|
|
def encrypt_nip04(self, plaintext: str, receiver_pubkey_hex: str) -> str:
|
|||
|
|
receiver_pk = PublicKey.from_hex(receiver_pubkey_hex)
|
|||
|
|
return nip04_encrypt(self._secret_key, receiver_pk, plaintext)
|
|||
|
|
|
|||
|
|
def decrypt_nip04(self, ciphertext: str, sender_pubkey_hex: str) -> str:
|
|||
|
|
sender_pk = PublicKey.from_hex(sender_pubkey_hex)
|
|||
|
|
return nip04_decrypt(self._secret_key, sender_pk, ciphertext)
|
|||
|
|
|
|||
|
|
async def encrypt_nip17(self, plaintext: str, receiver_pubkey_hex: str) -> str:
|
|||
|
|
receiver_pk = PublicKey.from_hex(receiver_pubkey_hex)
|
|||
|
|
rumor = EventBuilder.private_msg_rumor(receiver_pk, plaintext).build(self._public_key)
|
|||
|
|
sealed = await EventBuilder.seal(self._keys, receiver_pk, rumor)
|
|||
|
|
return sealed.sign_with_keys(self._keys).as_json()
|
|||
|
|
|
|||
|
|
async def decrypt_nip17(self, wrapped_json: str) -> str:
|
|||
|
|
event = Event.from_json(wrapped_json)
|
|||
|
|
unwrapped = UnwrappedGift.from_gift_wrap(event)
|
|||
|
|
if unwrapped is None:
|
|||
|
|
raise NostrCryptoError("NIP-17 解密失败: Gift Wrap 解封返回空")
|
|||
|
|
rumor = unwrapped.rumor()
|
|||
|
|
if rumor is None:
|
|||
|
|
raise NostrCryptoError("NIP-17 解密失败: Rumor 为空")
|
|||
|
|
return rumor.content()
|
|||
|
|
|
|||
|
|
def build_and_sign_event(self, kind: int, content: str, tags: list[list[str]]) -> dict:
|
|||
|
|
builder = EventBuilder(Kind(kind), content)
|
|||
|
|
parsed_tags = []
|
|||
|
|
for tag in tags:
|
|||
|
|
try:
|
|||
|
|
parsed_tags.append(Tag.parse(tag))
|
|||
|
|
except Exception as e:
|
|||
|
|
raise NostrCryptoError(f"Tag 解析失败: {tag}, 错误: {e}") from e
|
|||
|
|
if parsed_tags:
|
|||
|
|
builder = builder.tags(parsed_tags)
|
|||
|
|
builder = builder.custom_created_at(int(time.time()))
|
|||
|
|
event = builder.sign_with_keys(self._keys)
|
|||
|
|
return json.loads(event.as_json())
|
|||
|
|
|
|||
|
|
def verify_event(self, raw_event: dict) -> bool:
|
|||
|
|
try:
|
|||
|
|
event = Event.from_json(json.dumps(raw_event))
|
|||
|
|
event.verify()
|
|||
|
|
return True
|
|||
|
|
except Exception:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def verify_event_strict(self, raw_event: dict) -> bool:
|
|||
|
|
try:
|
|||
|
|
event = Event.from_json(json.dumps(raw_event))
|
|||
|
|
event_id = event.id()
|
|||
|
|
expected_id = raw_event.get("id", "")
|
|||
|
|
if event_id.to_hex() != expected_id and event_id.to_bech32() != expected_id:
|
|||
|
|
return False
|
|||
|
|
event.verify()
|
|||
|
|
return True
|
|||
|
|
except Exception:
|
|||
|
|
return False
|