Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "buzzkit"
version = "0.1.4"
version = "0.2.0"
edition = "2021"
publish = false
description = "PyO3 bindings over Block's Buzz zero-I/O crates (buzz-core / buzz-sdk)"
Expand All @@ -19,8 +19,8 @@ pyo3 = { version = "0.24", features = ["abi3-py312"] }
# Event/Keys/EventBuilder types unify across crate boundaries.
nostr = { version = "0.44", features = ["nip44", "nip98"] }
# Pinned to a specific Buzz commit; bump deliberately to track their kind churn.
buzz-core = { git = "https://github.com/block/buzz", rev = "acfbb1bb6af54cb29cb152496ff43b8285dcb8cf", package = "buzz-core" }
buzz-sdk = { git = "https://github.com/block/buzz", rev = "acfbb1bb6af54cb29cb152496ff43b8285dcb8cf", package = "buzz-sdk" }
buzz-core = { git = "https://github.com/block/buzz", rev = "209536ade6c5ebf7fa82671d7ca0b74f599a40cc", package = "buzz-core" }
buzz-sdk = { git = "https://github.com/block/buzz", rev = "209536ade6c5ebf7fa82671d7ca0b74f599a40cc", package = "buzz-sdk" }
uuid = { version = "1", features = ["v4"] }
sha2 = "0.10"
hex = "0.4"
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,20 @@ claiming. After joining, `set_profile(...)` gives the agent a display name.
|---|---|
| `generate_keypair()` → `(nsec, npub, hex)` | new identity |
| `pubkey_from_secret(secret)` | derive `(npub, hex)` |
| `build_message_event` / `build_profile_event` / `build_auth_event` | build + sign events |
| `build_*_event` (message/reply, reaction, edit, delete, profile, user status, channel, presence…) | build + sign events |
| `compute_auth_tag` / `verify_auth_tag` / `verify_agent_profile` | NIP-OA owner attestation |
| `sign_nip98(secret, method, url, body)` | HTTP bridge auth header |
| `verify_event(json)` | check id + Schnorr signature |
| `BuzzClient.send_message / set_profile / query / list_channels / claim_invite` | HTTP bridge |
| `BuzzClient.connect / subscribe / subscribe_channel / publish / close` | WebSocket |
| `BuzzClient.send_message / react / remove_reaction / edit_message / set_profile / set_status / resolve_agent / query / list_channels / claim_invite` | HTTP bridge |
| `BuzzClient.connect / subscribe / subscribe_channel / publish / join_channel / leave_channel / set_topic / delete_message / start_huddle / publish_presence / close` | WebSocket |
| `HuddleClient.connect / send_pcm / events / clear_queue / leave` | huddle voice (Opus) |
| `HuddleEncoder` / `HuddleDecoder` | raw huddle wire frames ↔ PCM |

Threaded replies: `send_message(..., reply_to=<event-id>)` (add
`reply_root=` for nested replies). Reconnect note: the relay closes with
code **1012** on graceful restart — check `BuzzClient.close_code` in your
reconnect loop and dedupe replayed events by id.

## Build from source

Requires a Rust toolchain and [maturin](https://www.maturin.rs).
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "maturin"

[project]
name = "buzzkit"
version = "0.1.4"
version = "0.2.0"
description = "Python bindings + async client for Block's Buzz (Nostr) protocol, backed by Rust buzz-core/buzz-sdk"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
36 changes: 35 additions & 1 deletion python/buzzkit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,37 +9,55 @@

from __future__ import annotations

from importlib.metadata import version as _version

from ._native import (
HUDDLE_FRAME_SAMPLES,
HUDDLE_PROTOCOL_VERSION,
HUDDLE_SAMPLE_RATE,
KIND_ADD_MEMBER,
KIND_AUTH,
KIND_CREATE_CHANNEL,
KIND_DELETE_MESSAGE,
KIND_DELETION,
KIND_EDIT_METADATA,
KIND_HTTP_AUTH,
KIND_HUDDLE_ENDED,
KIND_HUDDLE_PARTICIPANT_JOINED,
KIND_HUDDLE_PARTICIPANT_LEFT,
KIND_HUDDLE_STARTED,
KIND_LEAVE_CHANNEL,
KIND_MANAGED_AGENT,
KIND_MESSAGE_EDIT,
KIND_PRESENCE_UPDATE,
KIND_REACTION,
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
KIND_USER_STATUS,
HuddleDecoder,
HuddleEncoder,
build_auth_event,
build_create_channel_event,
build_delete_message_event,
build_edit_event,
build_huddle_started_event,
build_join_channel_event,
build_leave_event,
build_message_event,
build_presence_event,
build_profile_event,
build_reaction_event,
build_remove_reaction_event,
build_set_topic_event,
build_user_status_event,
compute_auth_tag,
generate_keypair,
pubkey_from_secret,
sign_nip98,
verify_auth_tag,
verify_event,
)
from .agents import verify_agent_profile
from .client import BuzzClient
from .huddle import (
HuddleAudio,
Expand All @@ -50,7 +68,7 @@
HuddlePeerLeft,
)

__version__ = "0.1.3"
__version__ = _version("buzzkit")

__all__ = [
"HUDDLE_FRAME_SAMPLES",
Expand All @@ -59,15 +77,22 @@
"KIND_ADD_MEMBER",
"KIND_AUTH",
"KIND_CREATE_CHANNEL",
"KIND_DELETE_MESSAGE",
"KIND_DELETION",
"KIND_EDIT_METADATA",
"KIND_HTTP_AUTH",
"KIND_HUDDLE_ENDED",
"KIND_HUDDLE_PARTICIPANT_JOINED",
"KIND_HUDDLE_PARTICIPANT_LEFT",
"KIND_HUDDLE_STARTED",
"KIND_LEAVE_CHANNEL",
"KIND_MANAGED_AGENT",
"KIND_MESSAGE_EDIT",
"KIND_PRESENCE_UPDATE",
"KIND_REACTION",
"KIND_STREAM_MESSAGE",
"KIND_STREAM_MESSAGE_V2",
"KIND_USER_STATUS",
"BuzzClient",
"HuddleAudio",
"HuddleClient",
Expand All @@ -79,14 +104,23 @@
"HuddlePeerLeft",
"build_auth_event",
"build_create_channel_event",
"build_delete_message_event",
"build_edit_event",
"build_huddle_started_event",
"build_join_channel_event",
"build_leave_event",
"build_message_event",
"build_presence_event",
"build_profile_event",
"build_reaction_event",
"build_remove_reaction_event",
"build_set_topic_event",
"build_user_status_event",
"compute_auth_tag",
"generate_keypair",
"pubkey_from_secret",
"sign_nip98",
"verify_agent_profile",
"verify_auth_tag",
"verify_event",
]
45 changes: 43 additions & 2 deletions python/buzzkit/_native.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,37 @@ def pubkey_from_secret(secret: str) -> tuple[str, str]:
"""Return ``(npub, pubkey_hex)`` for a secret (hex or ``nsec…``)."""

def build_message_event(
secret: str, channel_id: str, content: str, mentions: list[str] | None = ...
secret: str,
channel_id: str,
content: str,
mentions: list[str] | None = ...,
reply_to: str | None = ...,
reply_root: str | None = ...,
) -> str:
"""Build + sign a channel message (kind 9); returns NIP-01 event JSON."""
"""Build + sign a channel message (kind 9); returns NIP-01 event JSON.

``reply_to`` threads the message; ``reply_root`` marks a nested reply.
"""

def build_reaction_event(secret: str, target_event_id: str, emoji: str) -> str:
"""Build + sign a reaction (kind 7) to an event."""

def build_remove_reaction_event(secret: str, reaction_event_id: str) -> str:
"""Build + sign a deletion (kind 5) of one of our own reactions."""

def build_edit_event(secret: str, channel_id: str, target_event_id: str, new_content: str) -> str:
"""Build + sign a message edit (kind 40003)."""

def build_delete_message_event(
secret: str, channel_id: str, target_event_id: str, reason: str | None = ...
) -> str:
"""Build + sign a message delete tombstone (kind 9005)."""

def build_set_topic_event(secret: str, channel_id: str, topic: str) -> str:
"""Build + sign a channel topic change (kind 9002)."""

def build_leave_event(secret: str, channel_id: str) -> str:
"""Build + sign a channel leave request (kind 9022)."""

def build_profile_event(
secret: str,
Expand Down Expand Up @@ -48,6 +76,9 @@ def build_huddle_started_event(
def build_presence_event(secret: str, status: str = ...) -> str:
"""Build + sign a presence event (kind 20001); status online/away/offline."""

def build_user_status_event(secret: str, text: str, emoji: str | None = ...) -> str:
"""Build + sign a NIP-38 user status event (kind 30315, ``d:general``)."""

def build_auth_event(
secret: str, challenge: str, relay_url: str, auth_tag: str | None = ...
) -> str:
Expand All @@ -56,6 +87,9 @@ def build_auth_event(
def compute_auth_tag(owner_secret: str, agent_pubkey_hex: str, conditions: str = ...) -> str:
"""Compute a NIP-OA owner-attestation tag JSON attesting an agent pubkey."""

def verify_auth_tag(auth_tag_json: str, agent_pubkey_hex: str) -> str:
"""Verify a NIP-OA auth tag against an agent pubkey; returns the owner hex."""

def sign_nip98(secret: str, method: str, url: str, body: bytes | None = ...) -> str:
"""Return an ``Authorization: Nostr <base64>`` header value (NIP-98)."""

Expand Down Expand Up @@ -85,14 +119,21 @@ class HuddleDecoder:
def remove_peer(self, peer_index: int) -> None:
"""Forget a peer's decoder state (indexes are recycled by the relay)."""

KIND_DELETION: int
KIND_REACTION: int
KIND_STREAM_MESSAGE: int
KIND_EDIT_METADATA: int
KIND_DELETE_MESSAGE: int
KIND_LEAVE_CHANNEL: int
KIND_MESSAGE_EDIT: int
KIND_PRESENCE_UPDATE: int
KIND_AUTH: int
KIND_HTTP_AUTH: int
KIND_STREAM_MESSAGE_V2: int
KIND_ADD_MEMBER: int
KIND_CREATE_CHANNEL: int
KIND_MANAGED_AGENT: int
KIND_USER_STATUS: int
KIND_HUDDLE_STARTED: int
KIND_HUDDLE_PARTICIPANT_JOINED: int
KIND_HUDDLE_PARTICIPANT_LEFT: int
Expand Down
91 changes: 91 additions & 0 deletions python/buzzkit/agents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""NIP-OA agent ownership verification.

Mirrors the reference algorithm from upstream buzz-cli (#3178): an agent's
kind-0 profile asserts ownership only when it carries *exactly one* `auth`
tag whose Schnorr signature verifies against the claimed owner AND whose
conditions (`kind=…`, `created_at<…`, `created_at>…`) apply to the profile
event itself.

Pure functions — no I/O. :meth:`BuzzClient.resolve_agent` does the relay side.
"""

from __future__ import annotations

import json
import string
from typing import Any

from . import _native

_HEX_LOWER = set(string.digits + "abcdef")

#: Possible return values of :func:`verify_agent_profile`, mirroring upstream.
VERIFICATIONS = (
"verified",
"missing_auth",
"multiple_auth_tags",
"invalid_auth",
"owner_mismatch",
"condition_mismatch",
"invalid_agent_pubkey",
)


def _auth_tags(event: dict[str, Any]) -> list[list]:
tags = event.get("tags")
if not isinstance(tags, list):
return []
return [t for t in tags if isinstance(t, list) and t and t[0] == "auth"]


def _conditions_apply(conditions: str, event: dict[str, Any]) -> bool:
"""Do the auth tag's conditions hold for this event? Empty clauses hold."""
kind = event.get("kind")
created_at = event.get("created_at")
if not isinstance(kind, int) or not isinstance(created_at, int):
return False
for clause in conditions.split("&"):
if clause.startswith("kind="):
ok = clause[len("kind=") :] == str(kind)
elif clause.startswith("created_at<"):
bound = clause[len("created_at<") :]
ok = bound.isdigit() and created_at < int(bound)
elif clause.startswith("created_at>"):
bound = clause[len("created_at>") :]
ok = bound.isdigit() and created_at > int(bound)
else:
ok = clause == ""
if not ok:
return False
return True


def verify_agent_profile(profile_event: dict[str, Any], owner_pubkey_hex: str) -> str:
"""Verify that an agent's kind-0 profile is attested by ``owner_pubkey_hex``.

Returns one of :data:`VERIFICATIONS`; only ``"verified"`` asserts
ownership. ``profile_event`` is the parsed NIP-01 event dict.
"""
agent_pubkey = profile_event.get("pubkey")
if (
not isinstance(agent_pubkey, str)
or len(agent_pubkey) != 64
or not set(agent_pubkey) <= _HEX_LOWER
):
return "invalid_agent_pubkey"
tags = _auth_tags(profile_event)
if not tags:
return "missing_auth"
if len(tags) > 1:
return "multiple_auth_tags"
tag = tags[0]
try:
owner = _native.verify_auth_tag(json.dumps(tag), agent_pubkey)
except ValueError as e:
# from_hex accepted 64 lowercase hex that is not a curve point
return "invalid_agent_pubkey" if "invalid agent pubkey" in str(e) else "invalid_auth"
if owner != owner_pubkey_hex:
return "owner_mismatch"
if not _conditions_apply(tag[2], profile_event):
return "condition_mismatch"
return "verified"
Loading
Loading