Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .changelog/merry-dogs-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pympp: minor
---

Added `RedisStore` and `SQLiteStore` backends to `mpp.stores` for replay protection, with optional extras (`pympp[redis]`, `pympp[sqlite]`). Added `store` parameter to `Mpp.__init__` and `Mpp.create()` that automatically wires the store into intents supporting replay protection.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ tempo = [
"pydantic>=2.0",
]
server = ["pydantic>=2.0", "python-dotenv>=1.0"]
redis = ["redis>=5.0"]
sqlite = ["aiosqlite>=0.20"]
mcp = ["mcp>=1.1.0"]
dev = [
"pytest>=8.0",
Expand All @@ -47,6 +49,7 @@ dev = [
"pyright>=1.1",
"build>=1.0",
"twine>=6.0",
"aiosqlite>=0.20",
]

[build-system]
Expand Down
1 change: 1 addition & 0 deletions src/mpp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,4 +399,5 @@ def success(

from . import _body_digest as BodyDigest # noqa: E402
from . import _expires as Expires # noqa: E402
from . import stores # noqa: E402
from .store import MemoryStore, Store # noqa: E402
21 changes: 21 additions & 0 deletions src/mpp/server/mpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from mpp.server.decorator import wrap_payment_handler
from mpp.server.method import transform_request
from mpp.server.verify import verify_or_challenge
from mpp.store import Store

if TYPE_CHECKING:
from mpp.server.method import Method
Expand Down Expand Up @@ -58,6 +59,7 @@ def __init__(
realm: str,
secret_key: str,
defaults: dict[str, Any] | None = None,
store: Store | None = None,
) -> None:
"""Initialize the payment handler.

Expand All @@ -67,30 +69,49 @@ def __init__(
secret_key: Server secret for HMAC-bound challenge IDs.
Enables stateless challenge verification.
defaults: Default request values merged with per-call request params.
store: Optional key-value store for replay protection.
When provided, automatically wired into intents that
accept a ``store`` (e.g., ``ChargeIntent``).
"""
self.method = method
self.realm = realm
self.secret_key = secret_key
self.defaults = defaults or {}

if store is not None:
self._wire_store(store)

def _wire_store(self, store: Store) -> None:
"""Inject *store* into intents that have a ``_store`` attribute set to None."""
intents = getattr(self.method, "intents", None)
if not isinstance(intents, dict):
return
for intent_obj in intents.values():
if hasattr(intent_obj, "_store") and intent_obj._store is None:
intent_obj._store = store

@classmethod
def create(
cls,
method: Method,
realm: str | None = None,
secret_key: str | None = None,
store: Store | None = None,
) -> Mpp:
"""Create an Mpp instance with smart defaults.

Args:
method: Payment method (e.g., tempo(currency=..., recipient=...)).
realm: Server realm. Auto-detected from environment if omitted.
secret_key: HMAC secret. Required unless `MPP_SECRET_KEY` is set.
store: Optional key-value store for replay protection.
Automatically wired into intents that accept a store.
"""
return cls(
method=method,
realm=detect_realm() if realm is None else realm,
secret_key=detect_secret_key() if secret_key is None else secret_key,
store=store,
)

async def charge(
Expand Down
24 changes: 24 additions & 0 deletions src/mpp/stores/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Concrete store backends for replay protection.

Available backends:

- ``MemoryStore`` – in-memory ``dict``, for development/testing.
- ``RedisStore`` – Redis/Valkey, for multi-instance production deployments.
- ``SQLiteStore`` – local SQLite file, for single-instance production deployments.
"""

from mpp.store import MemoryStore

__all__ = ["MemoryStore", "RedisStore", "SQLiteStore"]


def __getattr__(name: str): # type: ignore[reportReturnType]
if name == "RedisStore":
from mpp.stores.redis import RedisStore

return RedisStore
if name == "SQLiteStore":
from mpp.stores.sqlite import SQLiteStore

return SQLiteStore
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
62 changes: 62 additions & 0 deletions src/mpp/stores/redis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Redis-backed store for multi-instance deployments.

Uses ``redis-py`` (``redis.asyncio``) as the async driver. Install with::

pip install pympp[redis]

Example::

from redis.asyncio import from_url
from mpp.stores import RedisStore

store = RedisStore(await from_url("redis://localhost:6379"))
"""

from __future__ import annotations

from typing import Any


class RedisStore:
"""Async key-value store backed by Redis.

Each key is prefixed with ``key_prefix`` (default ``"mpp:"``) and
automatically expires after ``ttl_seconds`` (default 300 — 5 minutes).

``put_if_absent`` maps to ``SET key value NX EX ttl`` — a single atomic
Redis command with no TOCTOU race.
"""

def __init__(
self,
client: Any,
*,
key_prefix: str = "mpp:",
ttl_seconds: int = 300,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [SECURITY] Default Store TTL Re-enables Transaction Hash Replay After 5 Minutes

Both new production stores expire replay keys after 300 seconds by default. ChargeIntent._verify_hash() treats store.put_if_absent() as the one-time-spend source of truth but never validates transaction age from on-chain data. After TTL expiry, an attacker requests a fresh challenge and resubmits an old tx hash — the expired key is treated as new, the on-chain receipt still validates, and the server grants access again. A one-time payment becomes a renewable 5-minute lease.

Recommended Fix: Do not expire tx-hash replay entries by default. Use non-expiring keys (or a very long operator-chosen retention). If bounded retention is desired, first add an invariant that rejects transactions older than the TTL window based on block timestamp.

) -> None:
self._redis = client
self._prefix = key_prefix
self._ttl = ttl_seconds

def _key(self, key: str) -> str:
return f"{self._prefix}{key}"

async def get(self, key: str) -> Any | None:
return await self._redis.get(self._key(key))

async def put(self, key: str, value: Any) -> None:
await self._redis.set(self._key(key), value, ex=self._ttl)

async def delete(self, key: str) -> None:
await self._redis.delete(self._key(key))

async def put_if_absent(self, key: str, value: Any) -> bool:
"""Atomic ``SETNX`` with TTL.

Returns ``True`` when the key was new and the write succeeded,
``False`` when the key already existed (duplicate).
"""
result = await self._redis.set(
self._key(key), value, nx=True, ex=self._ttl
)
return result is not None
120 changes: 120 additions & 0 deletions src/mpp/stores/sqlite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""SQLite-backed store for single-instance production deployments.

Uses ``aiosqlite`` for async access to Python's built-in ``sqlite3``.
Install with::

pip install pympp[sqlite]

Example::

from mpp.stores import SQLiteStore

store = await SQLiteStore.create("mpp.db")
"""

from __future__ import annotations

import time
from typing import Any


class SQLiteStore:
"""Async key-value store backed by a local SQLite file.

Keys are stored in a ``kv`` table with optional TTL. Expired rows
are lazily pruned on ``get`` and ``put_if_absent``.

``put_if_absent`` uses ``INSERT OR IGNORE`` — a single atomic SQL
statement with no TOCTOU race.
"""

def __init__(
self,
db: Any,
*,
ttl_seconds: int = 300,
) -> None:
self._db = db
self._ttl = ttl_seconds

@classmethod
async def create(
cls,
path: str = "mpp.db",
*,
ttl_seconds: int = 300,
) -> SQLiteStore:
"""Open (or create) a SQLite database and initialize the schema.

Args:
path: Filesystem path for the database file.
Use ``":memory:"`` for an ephemeral in-memory database.
ttl_seconds: Seconds before a key expires (default 300).
"""
import aiosqlite

db = await aiosqlite.connect(path)
await db.execute(
"CREATE TABLE IF NOT EXISTS kv ("
" key TEXT PRIMARY KEY,"
" value TEXT NOT NULL,"
" expires_at REAL NOT NULL"
")"
)
await db.commit()
return cls(db, ttl_seconds=ttl_seconds)

async def close(self) -> None:
"""Close the underlying database connection."""
await self._db.close()

async def __aenter__(self) -> SQLiteStore:
return self

async def __aexit__(self, *args: Any) -> None:
await self.close()

def _expires_at(self) -> float:
return time.time() + self._ttl

async def get(self, key: str) -> Any | None:
now = time.time()
cursor = await self._db.execute(
"SELECT value FROM kv WHERE key = ? AND expires_at > ?",
(key, now),
)
row = await cursor.fetchone()
return row[0] if row else None

async def put(self, key: str, value: Any) -> None:
await self._db.execute(
"INSERT INTO kv (key, value, expires_at) VALUES (?, ?, ?)"
" ON CONFLICT(key) DO UPDATE SET value = excluded.value,"
" expires_at = excluded.expires_at",
(key, value, self._expires_at()),
)
await self._db.commit()

async def delete(self, key: str) -> None:
await self._db.execute("DELETE FROM kv WHERE key = ?", (key,))
await self._db.commit()

async def put_if_absent(self, key: str, value: Any) -> bool:
"""Atomic conditional insert.

Deletes any expired row for *key* first, then uses
``INSERT OR IGNORE`` so the write only succeeds when the
key does not already exist.

Returns ``True`` if the key was new, ``False`` if it existed.
"""
now = time.time()
await self._db.execute(
"DELETE FROM kv WHERE key = ? AND expires_at <= ?", (key, now)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Purge expired SQLite keys globally before inserts

put_if_absent only removes expired rows for the current key, but charge replay keys are transaction hashes and are almost always unique, so old rows are never revisited and never deleted. In production this makes TTL ineffective for storage reclamation, causing unbounded kv growth over time and eventual write/latency issues on long-lived servers. Expiry cleanup needs to target all expired rows (or run an equivalent global/batched vacuum path), not just the incoming key.

Useful? React with 👍 / 👎.

)
cursor = await self._db.execute(
"INSERT OR IGNORE INTO kv (key, value, expires_at) VALUES (?, ?, ?)",
(key, value, self._expires_at()),
)
await self._db.commit()
return cursor.rowcount > 0
70 changes: 70 additions & 0 deletions tests/test_stores_redis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Tests for RedisStore."""

from __future__ import annotations

from unittest.mock import AsyncMock

import pytest

from mpp.stores.redis import RedisStore


@pytest.fixture
def mock_redis():
return AsyncMock()


@pytest.fixture
def store(mock_redis):
return RedisStore(mock_redis, ttl_seconds=300)


class TestRedisStore:
@pytest.mark.asyncio
async def test_get_returns_value(self, store, mock_redis) -> None:
mock_redis.get.return_value = b"some-value"
result = await store.get("foo")
assert result == b"some-value"
mock_redis.get.assert_awaited_once_with("mpp:foo")

@pytest.mark.asyncio
async def test_get_returns_none_when_missing(self, store, mock_redis) -> None:
mock_redis.get.return_value = None
result = await store.get("missing")
assert result is None

@pytest.mark.asyncio
async def test_put(self, store, mock_redis) -> None:
await store.put("key1", "val1")
mock_redis.set.assert_awaited_once_with("mpp:key1", "val1", ex=300)

@pytest.mark.asyncio
async def test_delete(self, store, mock_redis) -> None:
await store.delete("key1")
mock_redis.delete.assert_awaited_once_with("mpp:key1")

@pytest.mark.asyncio
async def test_put_if_absent_returns_true_when_key_absent(self, store, mock_redis) -> None:
mock_redis.set.return_value = True # Redis SET NX returns True on success
result = await store.put_if_absent("new-key", "val")
assert result is True
mock_redis.set.assert_awaited_once_with("mpp:new-key", "val", nx=True, ex=300)

@pytest.mark.asyncio
async def test_put_if_absent_returns_false_when_key_exists(self, store, mock_redis) -> None:
mock_redis.set.return_value = None # Redis SET NX returns None on conflict
result = await store.put_if_absent("existing", "val")
assert result is False

@pytest.mark.asyncio
async def test_key_prefix(self, mock_redis) -> None:
store = RedisStore(mock_redis, key_prefix="custom:")
mock_redis.get.return_value = b"x"
await store.get("abc")
mock_redis.get.assert_awaited_once_with("custom:abc")

@pytest.mark.asyncio
async def test_custom_ttl(self, mock_redis) -> None:
store = RedisStore(mock_redis, ttl_seconds=60)
await store.put("k", "v")
mock_redis.set.assert_awaited_once_with("mpp:k", "v", ex=60)
Loading