diff --git a/.env.example b/.env.example index d54b97629..526e885ec 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,9 @@ MINT_INFO_TOS_URL="https://mint.host/tos" # Increment derivation path to rotate to a new keyset # Example: m/0'/0'/0' -> m/0'/0'/1' +# NOTE: With automatic keyset rotation enabled (default), the mint manages this +# automatically in the database on startup and during execution. You do NOT +# need to manually increment MINT_DERIVATION_PATH in your .env file after a rotation. MINT_DERIVATION_PATH="m/0'/0'/0'" # Multiple derivation paths and units. Unit is parsed from the derivation path. @@ -56,6 +59,13 @@ MINT_DERIVATION_PATH="m/0'/0'/0'" # e.g. for 100 ppk: up to 10 inputs = 1 sat / 1 cent fee, for up to 20 inputs = 2 sat / 2 cent fee MINT_INPUT_FEE_PPK=100 +# Automatic keyset rotations +# When enabled (default: TRUE), active keysets are automatically rotated after the configured +# interval (default: 90 days / 7,776,000 seconds). The old keyset is deactivated but remains +# usable for redeeming existing proofs, while a new active keyset is generated. +# MINT_KEYSET_ROTATION_ENABLED=TRUE +# MINT_KEYSET_ROTATION_INTERVAL_SECONDS=7776000 + # To use SQLite, choose a directory to store the database MINT_DATABASE=data/mint # To use PostgreSQL, set the connection string diff --git a/README.md b/README.md index 54f32276a..5b54b2ccd 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,26 @@ poetry run mint For testing, you can use Nutshell without a Lightning backend by setting `MINT_BACKEND_BOLT11_SAT=FakeWallet` in the `.env` file. +### Automatic Keyset Rotations + +Nutshell supports automatic keyset rotations to ensure active keysets are regularly rotated. This behavior is **enabled by default** with a default rotation interval of **90 days**. + +When a keyset rotation occurs: +1. A new keyset is activated (by automatically incrementing the derivation path counter, e.g., `m/0'/0'/0'` -> `m/0'/0'/1'`). +2. The old keyset is set to inactive but remains usable for redeeming existing ecash proofs. +3. The mint automatically recovers the latest active keyset from the database on subsequent restarts. You do **not** need to manually update `MINT_DERIVATION_PATH` in your `.env` file. + +#### Configuration +You can customize or disable automatic keyset rotations in your `.env`: + +```bash +# Enable or disable automatic rotations (default: TRUE) +MINT_KEYSET_ROTATION_ENABLED=TRUE + +# Set the rotation interval in seconds (default: 7776000 for 90 days) +MINT_KEYSET_ROTATION_INTERVAL_SECONDS=7776000 +``` + ### NUT-19 Caching with Redis To cache HTTP responses ([NUT-19](https://github.com/cashubtc/nuts/blob/main/19.md)), you can either install Redis manually or use the docker compose file in `docker/redis/docker-compose.yaml` to start Redis in a container. diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 5723fe519..fdf036e85 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -83,6 +83,18 @@ class MintSettings(CashuSettings): description="Interval (in seconds) for running regular tasks like the invoice checker.", ) + mint_keyset_rotation_enabled: bool = Field( + default=True, + title="Keyset rotation enabled", + description="Whether to automatically rotate keysets when they exceed the interval.", + ) + mint_keyset_rotation_interval_seconds: int = Field( + default=7776000, + gt=0, + title="Keyset rotation interval", + description="The interval in seconds after which active keysets are automatically rotated.", + ) + mint_retry_exponential_backoff_base_delay: int = Field(default=1) mint_retry_exponential_backoff_max_delay: int = Field(default=10) diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 2421789ea..69aeb0a03 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -874,6 +874,13 @@ async def store_keyset( keyset: MintKeyset, conn: Optional[Connection] = None, ) -> None: + if not keyset.valid_from: + keyset.valid_from = db.timestamp_now_str() + if not keyset.valid_to: + keyset.valid_to = db.timestamp_now_str() + if not keyset.first_seen: + keyset.first_seen = db.timestamp_now_str() + await (conn or db).execute( f""" INSERT INTO {db.table_with_schema("keysets")} @@ -886,13 +893,9 @@ async def store_keyset( "encrypted_seed": keyset.encrypted_seed, "seed_encryption_method": keyset.seed_encryption_method, "derivation_path": keyset.derivation_path, - "valid_from": db.to_timestamp( - keyset.valid_from or db.timestamp_now_str() - ), - "valid_to": db.to_timestamp(keyset.valid_to or db.timestamp_now_str()), - "first_seen": db.to_timestamp( - keyset.first_seen or db.timestamp_now_str() - ), + "valid_from": db.to_timestamp(keyset.valid_from), + "valid_to": db.to_timestamp(keyset.valid_to), + "first_seen": db.to_timestamp(keyset.first_seen), "active": True, "version": keyset.version, "unit": keyset.unit.name, @@ -1032,7 +1035,6 @@ async def update_keyset( "version": keyset.version, "unit": keyset.unit.name, "input_fee_ppk": keyset.input_fee_ppk, - "balance": keyset.balance, "final_expiry": keyset.final_expiry, # NEW: Update final expiry }, ) diff --git a/cashu/mint/keysets.py b/cashu/mint/keysets.py index 0d9bf5fa6..8aef98703 100644 --- a/cashu/mint/keysets.py +++ b/cashu/mint/keysets.py @@ -1,4 +1,6 @@ import base64 +import datetime +import time from typing import Dict, List, Optional from loguru import logger @@ -11,6 +13,9 @@ class LedgerKeysets(SupportsKeysets, SupportsSeed, SupportsDb): + keyset: MintKeyset + derivation_path: str + # ------- KEYS ------- def maybe_update_derivation_path(self, derivation_path: str) -> str: @@ -111,7 +116,12 @@ async def rotate_next_keyset( await self.crud.update_keyset(keyset=selected_keyset, db=self.db) self.keysets[selected_keyset.id] = selected_keyset - logger.debug(f"Keyset {keyset.id} was de-activated") + if self.keyset and self.keyset.id == selected_keyset.id: + self.keyset = new_keyset + self.derivation_path = new_keyset.derivation_path + logger.info(f"Updated default keyset to {new_keyset.id} with derivation path {new_keyset.derivation_path}") + + logger.debug(f"Keyset {selected_keyset.id} was de-activated") return new_keyset async def activate_keyset( @@ -245,6 +255,66 @@ async def inactivate_base64_keysets(self) -> None: self.keysets[keyset.id] = keyset await self.crud.update_keyset(keyset=keyset, db=self.db) + def _parse_valid_from(self, keyset: MintKeyset) -> float: + # Handles multiple types for keyset.valid_from because database drivers return + # different types (PostgreSQL returns datetime.datetime, SQLite stores/returns + # stringified timestamp integers/floats), while test mocks or JSON payloads + # may supply formatted datetime strings. + if not keyset.valid_from: + raise ValueError("keyset.valid_from is None") + try: + if isinstance(keyset.valid_from, datetime.datetime): + return keyset.valid_from.timestamp() + else: + return float(keyset.valid_from) + except (ValueError, TypeError): + return datetime.datetime.strptime( + keyset.valid_from, "%Y-%m-%d %H:%M:%S" + ).timestamp() + + def should_rotate_keyset(self, keyset: MintKeyset) -> bool: + if not keyset.active or not keyset.valid_from: + return False + try: + valid_from_ts = self._parse_valid_from(keyset) + except Exception: + logger.warning(f"Could not parse valid_from: {keyset.valid_from}. Forcing rotation.") + return True + + return (time.time() - valid_from_ts) >= settings.mint_keyset_rotation_interval_seconds + + async def rotate_keysets_if_needed(self) -> None: + if not settings.mint_keyset_rotation_enabled: + return + + active_keysets = [k for k in self.keysets.values() if k.active] + for keyset in active_keysets: + if self.should_rotate_keyset(keyset): + logger.warning( + f"Active keyset {keyset.id} for unit {keyset.unit.name} is older than " + f"the configured rotation interval ({settings.mint_keyset_rotation_interval_seconds}s). " + f"Rotating now." + ) + try: + new_final_expiry = None + if keyset.final_expiry is not None: + try: + valid_from_ts = self._parse_valid_from(keyset) + except Exception: + valid_from_ts = time.time() + active_duration = int(time.time() - valid_from_ts) + new_final_expiry = keyset.final_expiry + active_duration + + new_keyset = await self.rotate_next_keyset( + unit=keyset.unit, + max_order=len(keyset.amounts), + input_fee_ppk=keyset.input_fee_ppk, + final_expiry=new_final_expiry, + ) + logger.info(f"Successfully rotated keyset {keyset.id} -> {new_keyset.id} for unit {keyset.unit.name}") + except Exception as e: + logger.error(f"Failed to automatically rotate keyset {keyset.id}: {e}") + def get_keyset(self, keyset_id: Optional[str] = None) -> Dict[int, str]: """Returns a dictionary of hex public keys of a specific keyset for each supported amount""" if keyset_id and keyset_id not in self.keysets: diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 8c0c21bc1..338b3b7ae 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -153,6 +153,7 @@ async def startup_ledger(self) -> None: async def _startup_keysets(self) -> None: await self.init_keysets() + await self.rotate_keysets_if_needed() for derivation_path in settings.mint_derivation_path_list: derivation_path = self.maybe_update_derivation_path(derivation_path) await self.activate_keyset(derivation_path=derivation_path) @@ -166,6 +167,7 @@ async def _run_regular_tasks(self) -> None: while True: try: await self._check_pending_proofs_and_melt_quotes() + await self.rotate_keysets_if_needed() await asyncio.sleep(settings.mint_regular_tasks_interval_seconds) except Exception as e: logger.error(f"Ledger regular task failed: {e}") diff --git a/tests/mint/test_mint_automatic_rotations.py b/tests/mint/test_mint_automatic_rotations.py new file mode 100644 index 000000000..1c27235e5 --- /dev/null +++ b/tests/mint/test_mint_automatic_rotations.py @@ -0,0 +1,276 @@ +import asyncio +import datetime +import time + +import pytest + +from cashu.core.settings import settings +from cashu.mint.ledger import Ledger + + +@pytest.fixture(autouse=True) +def disable_global_ledger_rotation(): + from cashu.mint.startup import ledger as global_ledger + + original_method = global_ledger.rotate_keysets_if_needed + + async def noop_rotate(*args, **kwargs): + pass + + global_ledger.rotate_keysets_if_needed = noop_rotate + yield + global_ledger.rotate_keysets_if_needed = original_method + + +@pytest.mark.asyncio +async def test_should_rotate_keyset_behavior(ledger: Ledger): + # Get any active keyset + keyset = next(k for k in ledger.keysets.values() if k.active) + + # By default, freshly created keyset should not rotate + assert not ledger.should_rotate_keyset(keyset) + + # If keyset is inactive, it should never rotate + keyset.active = False + assert not ledger.should_rotate_keyset(keyset) + keyset.active = True + + # If valid_from is mocked in the far past, it should rotate + original_interval = settings.mint_keyset_rotation_interval_seconds + settings.mint_keyset_rotation_interval_seconds = 2592000 # 30 days + try: + original_valid_from = keyset.valid_from + keyset.valid_from = ( + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=31) + ).strftime("%Y-%m-%d %H:%M:%S") + assert ledger.should_rotate_keyset(keyset) + finally: + # Restore + keyset.valid_from = original_valid_from + settings.mint_keyset_rotation_interval_seconds = original_interval + + +@pytest.mark.asyncio +async def test_automatic_keyset_rotation_flow(ledger: Ledger): + # Cancel background tasks to avoid race conditions with manual triggering + for task in ledger.regular_tasks: + task.cancel() + ledger.regular_tasks = [] + + # Set a very short rotation interval + original_interval = settings.mint_keyset_rotation_interval_seconds + original_enabled = settings.mint_keyset_rotation_enabled + + try: + settings.mint_keyset_rotation_enabled = True + settings.mint_keyset_rotation_interval_seconds = 1 + + # Keep track of active keysets before rotation + active_keysets_before = { + k.unit: k for k in ledger.keysets.values() if k.active + } + assert len(active_keysets_before) > 0 + + # Wait to exceed the 1 second interval + await asyncio.sleep(1.5) + + # Trigger automatic rotation check + await ledger.rotate_keysets_if_needed() + + # Get active keysets after rotation + active_keysets_after = { + k.unit: k for k in ledger.keysets.values() if k.active + } + + for unit, old_keyset in active_keysets_before.items(): + new_keyset = active_keysets_after[unit] + # Verify a new keyset has been created and it differs from the old one + assert old_keyset.id != new_keyset.id + + # Verify the old keyset is now inactive in memory and DB + assert not old_keyset.active + db_old_keysets = await ledger.crud.get_keyset( + db=ledger.db, id=old_keyset.id + ) + assert len(db_old_keysets) == 1 + assert not db_old_keysets[0].active + + # Verify new keyset is active in memory and DB + assert new_keyset.active + db_new_keysets = await ledger.crud.get_keyset( + db=ledger.db, id=new_keyset.id + ) + assert len(db_new_keysets) == 1 + assert db_new_keysets[0].active + + # Verify key parameters are preserved + assert new_keyset.input_fee_ppk == old_keyset.input_fee_ppk + assert len(new_keyset.amounts) == len(old_keyset.amounts) + + # Verify derivation path counter has incremented + old_path = old_keyset.derivation_path.split("/") + new_path = new_keyset.derivation_path.split("/") + assert old_path[:-1] == new_path[:-1] + assert ( + int(new_path[-1].replace("'", "")) + - int(old_path[-1].replace("'", "")) + == 1 + ) + + # If the rotated unit matches the default keyset's unit, verify that + # the default keyset and derivation path are updated on the ledger + if unit == ledger.keyset.unit: + assert ledger.keyset.id == new_keyset.id + assert ledger.derivation_path == new_keyset.derivation_path + + finally: + # Restore settings + settings.mint_keyset_rotation_interval_seconds = original_interval + settings.mint_keyset_rotation_enabled = original_enabled + + +@pytest.mark.asyncio +async def test_automatic_keyset_rotation_disabled(ledger: Ledger): + # Cancel background tasks to avoid race conditions with manual triggering + for task in ledger.regular_tasks: + task.cancel() + ledger.regular_tasks = [] + + # Keep track of active keyset + keyset = next(k for k in ledger.keysets.values() if k.active) + + original_interval = settings.mint_keyset_rotation_interval_seconds + original_enabled = settings.mint_keyset_rotation_enabled + + try: + settings.mint_keyset_rotation_enabled = False + settings.mint_keyset_rotation_interval_seconds = 1 + + # Wait to exceed interval + await asyncio.sleep(1.5) + + # Trigger check (should do nothing since disabled) + await ledger.rotate_keysets_if_needed() + + # Get active keyset for the same unit + active_keysets = [ + k + for k in ledger.keysets.values() + if k.active and k.unit == keyset.unit + ] + assert len(active_keysets) == 1 + assert active_keysets[0].id == keyset.id + assert active_keysets[0].active + + finally: + settings.mint_keyset_rotation_interval_seconds = original_interval + settings.mint_keyset_rotation_enabled = original_enabled + + +@pytest.mark.asyncio +async def test_automatic_keyset_rotation_preserves_grace_period(ledger: Ledger): + # Cancel background tasks to avoid race conditions with manual triggering + for task in ledger.regular_tasks: + task.cancel() + ledger.regular_tasks = [] + + # Get any active keyset + keyset = next(k for k in ledger.keysets.values() if k.active) + + original_interval = settings.mint_keyset_rotation_interval_seconds + original_enabled = settings.mint_keyset_rotation_enabled + + try: + settings.mint_keyset_rotation_enabled = True + settings.mint_keyset_rotation_interval_seconds = 1 + + # Set a mock final_expiry on the old keyset + keyset.final_expiry = 2000000000 + + # Generate the timestamp 5 seconds ago using the database's format to avoid timezone shifts + past_ts = int(time.time() - 5) + keyset.valid_from = ledger.db.timestamp_from_seconds(past_ts) + + # Trigger automatic rotation check + await ledger.rotate_keysets_if_needed() + + # Retrieve the new active keyset for this unit + new_keyset = next( + k + for k in ledger.keysets.values() + if k.active and k.unit == keyset.unit + ) + + # Verify a rotation occurred + assert keyset.id != new_keyset.id + + # Expected new final_expiry should be original final_expiry (2000000000) + active_duration (approx 5) + assert new_keyset.final_expiry is not None + assert 2000000004 <= new_keyset.final_expiry <= 2000000008 + + finally: + # Restore settings + settings.mint_keyset_rotation_interval_seconds = original_interval + settings.mint_keyset_rotation_enabled = original_enabled + + +@pytest.mark.asyncio +async def test_automatic_keyset_rotation_background(ledger: Ledger): + # Keep track of original settings + original_interval = settings.mint_keyset_rotation_interval_seconds + original_enabled = settings.mint_keyset_rotation_enabled + original_tasks_interval = settings.mint_regular_tasks_interval_seconds + + try: + # Set configuration so that background tasks and keyset rotations run very frequently + settings.mint_keyset_rotation_enabled = True + settings.mint_keyset_rotation_interval_seconds = 1 + settings.mint_regular_tasks_interval_seconds = 2 # Check every 2 seconds + + # Cancel existing regular tasks so we can restart with the new interval + for task in ledger.regular_tasks: + task.cancel() + ledger.regular_tasks = [] + + # Start a new regular tasks loop with the updated 2-second interval + ledger.regular_tasks.append(asyncio.create_task(ledger._run_regular_tasks())) + + # Keep track of active keysets before background rotation + active_keysets_before = { + k.unit: k for k in ledger.keysets.values() if k.active + } + assert len(active_keysets_before) > 0 + + # Wait to exceed the rotation interval and allow the background task to run + # Keyset rotation interval is 1s, and task runs every 2s, so 2.5s is plenty of time + # for exactly one background rotation to run and complete. + await asyncio.sleep(2.5) + + # Get active keysets after background rotation + active_keysets_after = { + k.unit: k for k in ledger.keysets.values() if k.active + } + + # Verify background rotation occurred successfully + for unit, old_keyset in active_keysets_before.items(): + new_keyset = active_keysets_after[unit] + assert old_keyset.id != new_keyset.id + assert not old_keyset.active + assert new_keyset.active + + # If the rotated unit matches the default keyset's unit, verify that + # the default keyset and derivation path are updated on the ledger + if unit == ledger.keyset.unit: + assert ledger.keyset.id == new_keyset.id + assert ledger.derivation_path == new_keyset.derivation_path + + finally: + # Restore settings and restart original tasks loop + settings.mint_keyset_rotation_interval_seconds = original_interval + settings.mint_keyset_rotation_enabled = original_enabled + settings.mint_regular_tasks_interval_seconds = original_tasks_interval + + for task in ledger.regular_tasks: + task.cancel() + ledger.regular_tasks = [] + ledger.regular_tasks.append(asyncio.create_task(ledger._run_regular_tasks()))