新增 Nostr 渠道扩展,支持在 Yuxi 平台中集成 Nostr 去中心化社交协议。 包含以下功能模块: - bus: 事件总线与中继通信 - account: 账户管理 - key_utils: 密钥工具 - gateway: SSE/WebSocket 网关接入 - outbound: 外发消息管理 - gift_wrap: Gift Wrap 加密 - nip44: NIP-44 加密协议 - config_schema: 配置模式 - defaults: 默认配置 - profile_core: 用户资料核心 - profile_publisher: 资料发布 - event_utils: 事件工具 - deletion: 事件删除 - reactions: 表情反应 - metrics: 指标监控 - seen_tracker: 已读追踪 - session_route: 会话路由 - state_store: 状态存储
129 lines
4.1 KiB
Python
129 lines
4.1 KiB
Python
import base64
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import struct
|
|
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
|
|
from cryptography.hazmat.primitives.hashes import SHA256
|
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
from coincurve import PublicKey
|
|
|
|
NIP44_V2_VERSION = 0x02
|
|
NIP44_V2_SALT = b"nip44-v2"
|
|
MIN_PADDING_SIZE = 32
|
|
MAX_PADDING_SIZE = 65535
|
|
|
|
|
|
def _get_shared_x(sk_bytes: bytes, pk_hex: str) -> bytes:
|
|
pk = PublicKey(bytes.fromhex("02" + pk_hex))
|
|
shared_point = pk.multiply(sk_bytes)
|
|
return shared_point.format(compressed=False)[1:33]
|
|
|
|
|
|
def get_conversation_key(sk_bytes: bytes, pk_hex: str) -> bytes:
|
|
shared_x = _get_shared_x(sk_bytes, pk_hex)
|
|
hkdf = HKDF(
|
|
algorithm=SHA256(),
|
|
length=32,
|
|
salt=NIP44_V2_SALT,
|
|
info=b"",
|
|
)
|
|
return hkdf.derive(shared_x)
|
|
|
|
|
|
def get_message_keys(conversation_key: bytes, nonce: bytes) -> tuple[bytes, bytes, bytes]:
|
|
hkdf = HKDF(
|
|
algorithm=SHA256(),
|
|
length=76,
|
|
salt=None,
|
|
info=nonce,
|
|
)
|
|
keys = hkdf.derive(conversation_key)
|
|
chacha_key = keys[0:32]
|
|
chacha_nonce = keys[32:48]
|
|
hmac_key = keys[48:76]
|
|
return chacha_key, chacha_nonce, hmac_key
|
|
|
|
|
|
def _calc_padding_len(plaintext_len: int) -> int:
|
|
if plaintext_len <= 0:
|
|
return MIN_PADDING_SIZE
|
|
if plaintext_len > MAX_PADDING_SIZE:
|
|
raise ValueError(f"plaintext too long: {plaintext_len}")
|
|
if plaintext_len <= 32:
|
|
return 32
|
|
next_power = 1
|
|
while next_power < plaintext_len:
|
|
next_power <<= 1
|
|
chunk = next_power // 8
|
|
if chunk < 32:
|
|
chunk = 32
|
|
padded = ((plaintext_len - 1) // chunk + 1) * chunk
|
|
return padded
|
|
|
|
|
|
def pad(plaintext: str) -> bytes:
|
|
data = plaintext.encode("utf-8")
|
|
length = len(data)
|
|
padded_len = _calc_padding_len(length)
|
|
padding = padded_len - length
|
|
result = struct.pack("<H", length) + data + b"\x00" * padding
|
|
return result
|
|
|
|
|
|
def unpad(padded: bytes) -> str:
|
|
if len(padded) < 2:
|
|
raise ValueError("padded data too short")
|
|
length = struct.unpack("<H", padded[:2])[0]
|
|
if length > len(padded) - 2:
|
|
raise ValueError(f"invalid padding length: {length}")
|
|
data = padded[2:2 + length]
|
|
return data.decode("utf-8")
|
|
|
|
|
|
def _chacha20_encrypt(key: bytes, nonce: bytes, plaintext: bytes) -> bytes:
|
|
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None)
|
|
encryptor = cipher.encryptor()
|
|
return encryptor.update(plaintext) + encryptor.finalize()
|
|
|
|
|
|
def _chacha20_decrypt(key: bytes, nonce: bytes, ciphertext: bytes) -> bytes:
|
|
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None)
|
|
decryptor = cipher.decryptor()
|
|
return decryptor.update(ciphertext) + decryptor.finalize()
|
|
|
|
|
|
def _hmac_sha256(key: bytes, data: bytes) -> bytes:
|
|
return hmac.new(key, data, hashlib.sha256).digest()
|
|
|
|
|
|
def nip44_encrypt(sk_bytes: bytes, pk_hex: str, plaintext: str) -> str:
|
|
conversation_key = get_conversation_key(sk_bytes, pk_hex)
|
|
nonce = bytes([NIP44_V2_VERSION]) + os.urandom(31)
|
|
chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce)
|
|
padded = pad(plaintext)
|
|
ciphertext = _chacha20_encrypt(chacha_key, chacha_nonce, padded)
|
|
mac = _hmac_sha256(hmac_key, nonce + ciphertext)
|
|
payload = nonce + ciphertext + mac
|
|
return base64.b64encode(payload).decode()
|
|
|
|
|
|
def nip44_decrypt(sk_bytes: bytes, pk_hex: str, payload_b64: str) -> str:
|
|
payload = base64.b64decode(payload_b64)
|
|
if len(payload) < 64:
|
|
raise ValueError("payload too short")
|
|
version = payload[0]
|
|
if version != NIP44_V2_VERSION:
|
|
raise ValueError(f"unsupported nip44 version: {version}")
|
|
nonce = payload[:32]
|
|
ciphertext = payload[32:-32]
|
|
mac = payload[-32:]
|
|
conversation_key = get_conversation_key(sk_bytes, pk_hex)
|
|
chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce)
|
|
expected_mac = _hmac_sha256(hmac_key, nonce + ciphertext)
|
|
if not hmac.compare_digest(expected_mac, mac):
|
|
raise ValueError("invalid mac")
|
|
padded = _chacha20_decrypt(chacha_key, chacha_nonce, ciphertext)
|
|
return unpad(padded)
|