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
102 changes: 102 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Changelog

All notable changes to `le-agent-sdk` are documented here.

This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Entries begin at 0.4.0; for earlier history see the
[commit log](https://github.com/refined-element/le-agent-sdk-python/commits/master).

## [0.4.0] - 2026-07-17

Security release. Fixes signature verification silently passing, two payment-budget
bypasses, and unverified relay events — **upgrading is recommended**.

This release also replaces the package's crypto dependency, which is why it is a
minor bump rather than a patch: see [Dependencies](#dependencies) below.

### Dependencies

- **Replaces the `secp256k1` dependency with `coincurve`, which ships prebuilt
wheels.** `secp256k1` required a native build (libsecp256k1 plus a C toolchain)
and failed to install on Windows entirely; even where a build was possible it
frequently produced an install where `import secp256k1` failed, which is what the
verification bug below turned into a silent security hole. `coincurve` provides
the same BIP-340 Schnorr primitives over the same curve with no build step.

For most users this is transparent — `pip install le-agent-sdk` simply starts
working where it previously did not. Two things to be aware of:

- If your project imported `secp256k1` itself and relied on this package to pull
it in, it is no longer installed transitively. Declare it directly.
- The signature wire format is unchanged. Events signed by 0.3.x verify under
0.4.0 and vice versa; the curve, key encoding, and BIP-340 semantics are
identical, only the binding differs. This is covered by cross-implementation
tests against the .NET SDK and the BIP-340 published vectors.

### Fixed

- Fixes signature verification silently passing when the crypto backend is
unavailable — upgrade recommended.
`NostrEvent.verify()` returned `True` for any event whose ID matched when the
native secp256k1 library could not be imported. The event ID is a plain SHA-256
over public fields, so it is attacker-computable and proves nothing about
authenticity — forged capability advertisements and forged attestations under any
pubkey were accepted. Verification now raises `CryptoBackendUnavailableError`
instead of passing. Because the old dependency could not be installed at all on
some platforms, this affected real deployments rather than only misconfigured
ones — and the dependency swap above removes the condition for nearly all of them.
- Fixes `pay_and_access()` ignoring `max_amount_sats`, and budget checks being
skipped for invoices whose amount could not be read — both allowed payments
above the configured limit; upgrade recommended.
`pay_and_access()` never consulted the limit at all, so a client constructed with
`max_amount_sats=100` would pay a 10,000,000-sat invoice. Separately, an invoice
whose amount could not be determined was treated as "no limit applies" and paid.
An amount that cannot be determined is now refused whenever a limit is configured.
- Fixes the BOLT-11 amount parser reading an amount from the invoice data part.
The pattern was not anchored to the human-readable part, so an amountless (i.e.
unbounded) invoice whose data happened to contain `<digits><multiplier>1` was
reported as a small amount and passed the budget check. Amounts are now read only
from the human-readable part, and rounded up rather than truncated so a budget
check is never given an under-reported value.
- Incoming relay events are now signature-verified before use. `discover()`,
`get_attestations()` and `listen_requests()` passed raw relay JSON straight into
the models. Relay lists are caller-configurable and results are merged across
relays, so a single malicious relay could inject events attributed to any pubkey.
Events failing verification are dropped and logged; other relays' results are
unaffected.
- The `User-Agent` sent by `L402ProducerClient` reported `0.1.0` on every release
since 0.1.0. It now tracks the package version.

### Added

- `CryptoBackendUnavailableError`, exported from the package root. Subclasses
`RuntimeError`, so existing `except RuntimeError` handlers continue to work.
`Secp256k1UnavailableError` is kept as an alias of it.
- Cross-implementation wire-compatibility tests: events signed by the .NET SDK
(via NBitcoin.Secp256k1) are committed as fixtures and verified on every run,
alongside the BIP-340 published test vectors.

### Changed

- `pay_and_access()` accepts a `max_amount_sats` argument to override the
instance-level limit for a single call, matching `access()`.

### Upgrade notes

- `pip install le-agent-sdk` no longer needs a C toolchain. If you previously
installed build dependencies (libsecp256k1, build-essential, Visual C++ Build
Tools) solely for this package, they are no longer required.
- If the crypto backend is not importable in your environment, verification now
raises where it previously returned `True`. Any code path that reads events from
relays is affected. This is intentional: the previous result was not a weaker
check, it was no check.
- Callers relying on unknown-amount invoices being paid while `max_amount_sats` is
set will now see `ValueError`. Either set no limit (explicitly opting out of
budget enforcement) or use invoices with an explicit amount.

### Note on 0.3.3

An earlier cut of this work was staged as 0.3.3 and was never published to PyPI.
Its contents are released here as 0.4.0; no 0.3.3 artifact exists. The
`Secp256k1UnavailableError` name originated in that unreleased cut, so no released
version ever exported it — it is aliased anyway for anyone tracking the branch.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ Discover, request, and settle agent-to-agent services over Nostr with L402 Light
pip install le-agent-sdk
```

> **0.4.0 fixes signature verification silently passing when the crypto backend is
> unavailable, plus two payment-budget bypasses. Upgrading is recommended** — see
> the [changelog](CHANGELOG.md).

> **0.4.0 replaces the `secp256k1` dependency with [`coincurve`](https://pypi.org/project/coincurve/).**
> `secp256k1` required a native build and could not be installed on Windows at all;
> `coincurve` ships prebuilt wheels, so installation no longer needs a C toolchain.
> The signature wire format is unchanged — events signed by 0.3.x still verify,
> which is covered by cross-implementation tests against the .NET SDK and the
> BIP-340 published vectors. If your own code imported `secp256k1` and relied on
> this package to pull it in, declare it directly.

Signing, key derivation, and signature verification all need the crypto backend.
If it is not importable they raise `CryptoBackendUnavailableError` (aliased as
`Secp256k1UnavailableError`) rather than degrading to a weaker check — in
particular, verification never reports an unverifiable event as authentic.

## Quick Start

### Provider: Publish a Service
Expand Down
14 changes: 7 additions & 7 deletions examples/demo_full_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def generate_throwaway_privkey() -> str:
def mock_pubkey_from_privkey(privkey_hex: str) -> str:
"""Derive a deterministic mock 'pubkey' from a privkey using SHA-256.

This is NOT a real Nostr pubkey derivation (which requires secp256k1).
This is NOT a real Nostr pubkey derivation (which requires coincurve).
It is used only in --mock mode to produce consistent, deterministic IDs.
"""
return hashlib.sha256(bytes.fromhex(privkey_hex)).hexdigest()
Expand Down Expand Up @@ -109,7 +109,7 @@ def mock_create_event(
# ---------------------------------------------------------------------------

async def run_mock_demo() -> None:
"""Run the full ASA loop with mock data -- no relay, no secp256k1 needed."""
"""Run the full ASA loop with mock data -- no relay, no crypto backend needed."""

print("\n" + "#" * 70)
print("# NOSTRWOLFE E2E DEMO -- MOCK MODE")
Expand Down Expand Up @@ -373,12 +373,12 @@ async def run_live_demo(relay_url: str) -> None:
print(f"# Relay: {relay_url}")
print("#" + "#" * 69 + "\n")

# Check for secp256k1
# Check for the BIP-340 crypto backend
try:
from le_agent_sdk.nostr.event import _HAS_SECP256K1
if not _HAS_SECP256K1:
print("WARNING: secp256k1 not installed. Events will be unsigned.")
print("Install with: pip install secp256k1\n")
from le_agent_sdk.nostr.event import _HAS_COINCURVE
if not _HAS_COINCURVE:
print("WARNING: coincurve not installed. Events will be unsigned.")
print("Install with: pip install coincurve\n")
except ImportError:
pass

Expand Down
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "le-agent-sdk"
version = "0.3.2"
version = "0.4.0"
description = "Python SDK for Lightning Enable Agent Service Agreements"
requires-python = ">=3.10"
license = "MIT"
Expand All @@ -26,7 +26,10 @@ classifiers = [
dependencies = [
"httpx>=0.24.0",
"websockets>=11.0",
"secp256k1>=0.14.0",
# BIP-340 Schnorr over secp256k1. coincurve ships prebuilt wheels for the
# platforms we support; the `secp256k1` package it replaced in 0.4.0 required
# a native build toolchain and could not be installed on Windows at all.
"coincurve>=18.0",
"l402-requests>=0.1.0",
]

Expand Down
12 changes: 10 additions & 2 deletions src/le_agent_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
from le_agent_sdk.models.request import AgentServiceRequest
from le_agent_sdk.models.agreement import AgentServiceAgreement
from le_agent_sdk.models.attestation import AgentAttestation
from le_agent_sdk.nostr.event import NostrEvent
from le_agent_sdk.nostr.event import (
CryptoBackendUnavailableError,
NostrEvent,
Secp256k1UnavailableError,
)
from le_agent_sdk.nostr.relay import RelayClient
from le_agent_sdk.nostr.tags import TagParser
from le_agent_sdk.l402.client import L402Client, L402ProducerClient
Expand All @@ -17,11 +21,15 @@
"AgentServiceAgreement",
"AgentAttestation",
"NostrEvent",
"CryptoBackendUnavailableError",
# Pre-0.4.0 alias of CryptoBackendUnavailableError, kept for callers
# catching it by name.
"Secp256k1UnavailableError",
"RelayClient",
"TagParser",
"L402Client",
"L402ProducerClient",
"AgentManager",
]

__version__ = "0.3.2"
__version__ = "0.4.0"
55 changes: 52 additions & 3 deletions src/le_agent_sdk/agent/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@
from __future__ import annotations

import asyncio
import logging
from typing import Any, AsyncIterator, Optional

from le_agent_sdk.l402.client import L402Client, L402ProducerClient
from le_agent_sdk.models.agreement import AgentServiceAgreement
from le_agent_sdk.models.attestation import AgentAttestation
from le_agent_sdk.models.capability import AgentCapability
from le_agent_sdk.models.request import AgentServiceRequest
from le_agent_sdk.nostr.event import NostrEvent
from le_agent_sdk.nostr.event import CryptoBackendUnavailableError, NostrEvent
from le_agent_sdk.nostr.relay import RelayClient
from le_agent_sdk.nostr.tags import TagParser

logger = logging.getLogger(__name__)


class AgentManager:
"""Main entry point for agent operations.
Expand Down Expand Up @@ -64,6 +67,39 @@ def pubkey(self) -> str:
self._pubkey = NostrEvent.pubkey_from_private_key(self.private_key)
return self._pubkey

@staticmethod
def _is_event_authentic(event: dict[str, Any]) -> bool:
"""Check a relay-supplied event's signature before trusting its contents.

Relay URLs are caller-configurable and results are merged across relays,
so without this a single malicious or compromised relay could inject
events attributed to any pubkey — forged capability ads, forged
attestations inflating an agent's reputation.

A verification failure drops only the offending event: one bad relay in
the pool must not be able to fail an otherwise good query. A RuntimeError
(crypto backend unavailable) is left to propagate — that is an environment
fault affecting every event, and silently returning zero results would
misrepresent it as "nothing found".
"""
if NostrEvent.verify(event):
return True

logger.warning(
"Dropping Nostr event %.16s... (kind=%s) from pubkey %.16s...: "
"signature verification failed. The relay may be malicious or "
"misbehaving.",
event.get("id", "<no id>"),
event.get("kind", "<no kind>"),
event.get("pubkey", "<no pubkey>"),
)
return False

@classmethod
def _filter_authentic(cls, events: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Keep only events with a valid signature under their claimed pubkey."""
return [event for event in events if cls._is_event_authentic(event)]

async def _publish_to_relays(self, event: dict[str, Any]) -> str:
"""Publish an event to all configured relays.

Expand Down Expand Up @@ -183,7 +219,10 @@ async def discover(
)

raw_events = await self._query_relays([nostr_filter], timeout=timeout)
return [AgentCapability.from_nostr_event(e) for e in raw_events]
return [
AgentCapability.from_nostr_event(e)
for e in self._filter_authentic(raw_events)
]

async def publish_capability(self, capability: AgentCapability) -> str:
"""Publish a capability advertisement to relays.
Expand Down Expand Up @@ -296,7 +335,14 @@ async def listen_requests(
if event_id and event_id not in seen_ids:
seen_ids.add(event_id)
reconnect_attempts = 0 # Reset on successful message
if not self._is_event_authentic(event_data):
continue
yield AgentServiceRequest.from_nostr_event(event_data)
except CryptoBackendUnavailableError:
# Environment fault, not a relay fault: reconnecting cannot
# fix a missing crypto backend. Surface it immediately
# instead of burning the reconnect budget on it.
raise
except Exception:
reconnect_attempts += 1
if reconnect_attempts > max_reconnect_attempts:
Expand Down Expand Up @@ -521,7 +567,10 @@ async def get_attestations(
)

raw_events = await self._query_relays([nostr_filter], timeout=timeout)
return [AgentAttestation.from_nostr_event(e) for e in raw_events]
return [
AgentAttestation.from_nostr_event(e)
for e in self._filter_authentic(raw_events)
]

async def get_reputation_score(
self,
Expand Down
Loading
Loading