-
Notifications
You must be signed in to change notification settings - Fork 9
feat: add RedisStore, SQLiteStore, and wire store into Mpp.create() #98
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
de9f826
b9cf62d
602d6ea
4d5adb7
50c2248
853d157
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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}") |
| 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, | ||
| ) -> 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 | ||
| 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) | ||
|
||
| ) | ||
| 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 | ||
| 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) |
There was a problem hiding this comment.
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()treatsstore.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.