diff --git a/migrations/versions/c6f8d2e4a1b3_add_secrets_table.py b/migrations/versions/c6f8d2e4a1b3_add_secrets_table.py new file mode 100644 index 00000000..f867dd5f --- /dev/null +++ b/migrations/versions/c6f8d2e4a1b3_add_secrets_table.py @@ -0,0 +1,42 @@ +"""add secrets table + +Revision ID: c6f8d2e4a1b3 +Revises: b5e7c9d1f3a2 +Create Date: 2026-06-24 00:00:00.000000 + +Creates the node-level singleton secret store (issue #553). Schema only; moving +any legacy plaintext into the encrypted/hashed columns happens at bootstrap, +where the live ROUTSTR_SECRET_KEY is available. +""" + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +revision = "c6f8d2e4a1b3" +down_revision = "b5e7c9d1f3a2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "secrets", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "admin_password_hash", + sqlmodel.sql.sqltypes.AutoString(), + nullable=True, + ), + sa.Column( + "encrypted_nsec", + sqlmodel.sql.sqltypes.AutoString(), + nullable=True, + ), + sa.Column("updated_at", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("secrets") diff --git a/routstr/core/db.py b/routstr/core/db.py index 24236a74..827cc24f 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -443,6 +443,21 @@ class RoutstrFee(SQLModel, table=True): # type: ignore payout_started_at: int | None = Field(default=None) +class Secret(SQLModel, table=True): # type: ignore + """Node-level secrets, stored encrypted/hashed at rest (singleton, id=1). + + The asymmetric column names document the encoding: ``_hash`` is one-way + (scrypt, verify only) while ``encrypted_`` is reversible (Fernet). Per-provider + upstream keys live on ``upstream_providers``, not here. See ``routstr.core.vault``. + """ + + __tablename__ = "secrets" + id: int = Field(default=1, primary_key=True) + admin_password_hash: str | None = Field(default=None) + encrypted_nsec: str | None = Field(default=None) + updated_at: int | None = Field(default=None) + + class CliToken(SQLModel, table=True): # type: ignore """Long-lived authorization token for CLI/agent use against admin endpoints.""" @@ -481,6 +496,52 @@ async def get_routstr_fee(session: AsyncSession) -> RoutstrFee: return fee +async def get_secret(session: AsyncSession) -> Secret: + secret = await session.get(Secret, 1) + if secret is None: + secret = Secret(id=1) + session.add(secret) + try: + await session.commit() + except IntegrityError: + # Another worker created the singleton row between our read and + # insert (multiple workers booting against one shared DB). Roll back + # and read the row they committed instead of failing startup. + await session.rollback() + secret = await session.get(Secret, 1) + if secret is None: + raise + return secret + await session.refresh(secret) + return secret + + +async def set_admin_password(session: AsyncSession, password: str) -> None: + """Store the admin password as a one-way hash on the Secret singleton.""" + from .vault import hash_password + + secret = await get_secret(session) + secret.admin_password_hash = hash_password(password) + secret.updated_at = int(time.time()) + session.add(secret) + await session.commit() + + +async def set_nsec(session: AsyncSession, nsec: str) -> None: + """Store the node's nsec, Fernet-encrypted, on the Secret singleton. + + An empty string clears it (the node then holds no Nostr identity and signs + no events). + """ + from .vault import encrypt + + secret = await get_secret(session) + secret.encrypted_nsec = encrypt(nsec) if nsec else None + secret.updated_at = int(time.time()) + session.add(secret) + await session.commit() + + async def reset_routstr_fee(session: AsyncSession, paid_msats: int) -> bool: """Checkpoint a fee payout before making the external payment.""" stmt = ( diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..8112487d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +"""Shared pytest configuration for the whole suite. + +A fixed, valid ``ROUTSTR_SECRET_KEY`` is set before any app import so that +secret encryption is deterministic across the suite and the mandatory-key +fail-fast does not break app-boot tests. Tests that need a different key (or an +absent one) override this per-test via ``monkeypatch``. +""" + +import os + +# Valid Fernet keys; KEY_A is the suite default, KEY_B is for wrong-key tests. +TEST_SECRET_KEY = "l_Tkp-7xmjcQ-IFhr6qhILrU8HPRbEmYMrfSbo_5srU=" +TEST_SECRET_KEY_ALT = "_Teyrky_iToeDK51Tj1FsI9MJ340_cqKGmeher-a7MQ=" + +os.environ.setdefault("ROUTSTR_SECRET_KEY", TEST_SECRET_KEY) diff --git a/tests/integration/test_secret_model.py b/tests/integration/test_secret_model.py new file mode 100644 index 00000000..77133d52 --- /dev/null +++ b/tests/integration/test_secret_model.py @@ -0,0 +1,93 @@ +"""Tests for the ``Secret`` singleton model (issue #553). + +Specifies the node-level secret store: a single row (``id=1``, like +``RoutstrFee``) holding the one-way admin-password hash and the encrypted nsec. +``get_secret`` is get-or-create, so callers always get the singleton without +worrying whether it has been initialised yet. Encoding of the values themselves +lives in ``routstr.core.vault``; here we only assert the row persists and stays +a singleton. +""" + +import time +from typing import Any + +import pytest +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from routstr.core.db import Secret, get_secret + + +@pytest.mark.asyncio +async def test_get_secret_creates_singleton( + integration_session: AsyncSession, +) -> None: + secret = await get_secret(integration_session) + assert secret.id == 1 + # Fresh row carries no secret material yet. + assert secret.admin_password_hash is None + assert secret.encrypted_nsec is None + assert secret.updated_at is None + + +@pytest.mark.asyncio +async def test_get_secret_is_idempotent( + integration_session: AsyncSession, +) -> None: + first = await get_secret(integration_session) + second = await get_secret(integration_session) + assert first.id == second.id == 1 + rows = (await integration_session.exec(select(Secret))).all() + assert len(rows) == 1 + + +@pytest.mark.asyncio +async def test_secret_fields_round_trip( + integration_session: AsyncSession, +) -> None: + secret = await get_secret(integration_session) + secret.admin_password_hash = "scrypt:16384:8:1:c2FsdA==:aGFzaA==" + secret.encrypted_nsec = "fernet:v1:gAAAAA" + secret.updated_at = int(time.time()) + integration_session.add(secret) + await integration_session.commit() + + integration_session.expunge_all() + reloaded = await get_secret(integration_session) + assert reloaded.admin_password_hash == "scrypt:16384:8:1:c2FsdA==:aGFzaA==" + assert reloaded.encrypted_nsec == "fernet:v1:gAAAAA" + assert reloaded.updated_at is not None + + +@pytest.mark.asyncio +async def test_get_secret_tolerates_concurrent_first_insert( + integration_engine: Any, + integration_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # A second worker wins the race and commits the singleton row first. + async with AsyncSession(integration_engine, expire_on_commit=False) as other: + other.add(Secret(id=1, admin_password_hash="scrypt:from-other-worker")) + await other.commit() + + # Reproduce the race window: our session's first read still sees no row, so + # it attempts to INSERT a duplicate id=1. The real IntegrityError that follows + # must be recovered (roll back, re-read) rather than crashing startup. + real_get = integration_session.get + calls = {"n": 0} + + async def stale_first_read(model: Any, pk: Any) -> Any: + calls["n"] += 1 + if calls["n"] == 1: + return None + return await real_get(model, pk) + + monkeypatch.setattr(integration_session, "get", stale_first_read) + + secret = await get_secret(integration_session) + + # Recovered the other worker's row; no crash, still a single row. + assert secret.id == 1 + assert secret.admin_password_hash == "scrypt:from-other-worker" + rows = (await integration_session.exec(select(Secret))).all() + assert len(rows) == 1