diff --git a/pyproject.toml b/pyproject.toml index 980e024a..a2d4b5cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,8 @@ anthropic = ["anthropic>=0.39"] postgres = ["psycopg[binary]>=3.2"] ddg = ["ddgs>=9.0"] discord = ["discord.py>=2.4", "redis>=7.2"] -all = ["turnstone[mq,console,sim,anthropic,postgres,discord,ddg]"] +tls = ["lacme>=1.0.1"] +all = ["turnstone[mq,console,sim,anthropic,postgres,discord,ddg,tls]"] [project.scripts] turnstone = "turnstone.cli:main" @@ -169,6 +170,10 @@ ignore_missing_imports = true module = ["ddgs", "ddgs.*"] ignore_missing_imports = true +[[tool.mypy.overrides]] +module = ["lacme", "lacme.*"] +ignore_missing_imports = true + [[tool.mypy.overrides]] module = ["turnstone.channels.discord.*"] disallow_subclassing_any = false diff --git a/tests/test_tls_storage.py b/tests/test_tls_storage.py new file mode 100644 index 00000000..c655c001 --- /dev/null +++ b/tests/test_tls_storage.py @@ -0,0 +1,226 @@ +"""Tests for TLS storage backend and lacme Store adapter.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime + +import pytest + +from turnstone.core.storage import get_storage, init_storage, reset_storage + + +@pytest.fixture(autouse=True) +def _storage(tmp_path): + """Initialize ephemeral SQLite storage for each test.""" + reset_storage() + db = str(tmp_path / "test.db") + init_storage("sqlite", path=db) + yield + reset_storage() + + +# ── Account keys ────────────────────────────────────────────────────────────── + + +def test_save_and_load_account_key(): + s = get_storage() + s.save_tls_account_key( + "default", + "-----BEGIN EC PRIVATE KEY-----\nfake\n-----END EC PRIVATE KEY-----", + ) + result = s.load_tls_account_key("default") + assert result is not None + assert "EC PRIVATE KEY" in result + + +def test_load_account_key_missing(): + s = get_storage() + assert s.load_tls_account_key("nonexistent") is None + + +def test_save_account_key_upsert(): + s = get_storage() + s.save_tls_account_key("default", "key-v1") + s.save_tls_account_key("default", "key-v2") + assert s.load_tls_account_key("default") == "key-v2" + + +# ── CA ──────────────────────────────────────────────────────────────────────── + + +def test_save_and_load_ca(): + s = get_storage() + s.save_tls_ca("Turnstone CA", "cert-pem-data", "key-pem-data") + result = s.load_tls_ca("Turnstone CA") + assert result is not None + assert result["cert_pem"] == "cert-pem-data" + assert result["key_pem"] == "key-pem-data" + assert result["name"] == "Turnstone CA" + + +def test_load_ca_missing(): + s = get_storage() + assert s.load_tls_ca("nonexistent") is None + + +def test_save_ca_upsert(): + s = get_storage() + s.save_tls_ca("CA", "cert-v1", "key-v1") + s.save_tls_ca("CA", "cert-v2", "key-v2") + result = s.load_tls_ca("CA") + assert result["cert_pem"] == "cert-v2" + assert result["key_pem"] == "key-v2" + + +# ── Certificates ────────────────────────────────────────────────────────────── + + +def test_save_and_load_cert(): + s = get_storage() + s.save_tls_cert( + domain="node-1.internal", + cert_pem="cert-data", + fullchain_pem="fullchain-data", + key_pem="key-data", + issued_at="2026-03-25T00:00:00", + expires_at="2026-03-27T00:00:00", + meta=json.dumps({"domains": ["node-1.internal", "10.0.1.5"]}), + ) + result = s.load_tls_cert("node-1.internal") + assert result is not None + assert result["domain"] == "node-1.internal" + assert result["cert_pem"] == "cert-data" + assert result["fullchain_pem"] == "fullchain-data" + assert result["key_pem"] == "key-data" + assert result["issued_at"] == "2026-03-25T00:00:00" + assert result["expires_at"] == "2026-03-27T00:00:00" + meta = json.loads(result["meta"]) + assert meta["domains"] == ["node-1.internal", "10.0.1.5"] + + +def test_load_cert_missing(): + s = get_storage() + assert s.load_tls_cert("nonexistent") is None + + +def test_save_cert_upsert(): + s = get_storage() + s.save_tls_cert("d", "c1", "f1", "k1", "2026-01-01", "2026-01-02") + s.save_tls_cert("d", "c2", "f2", "k2", "2026-02-01", "2026-02-02") + result = s.load_tls_cert("d") + assert result["cert_pem"] == "c2" + assert result["issued_at"] == "2026-02-01" + + +def test_list_certs_empty(): + s = get_storage() + assert s.list_tls_certs() == [] + + +def test_list_certs(): + s = get_storage() + s.save_tls_cert("alpha.internal", "c", "f", "k", "2026-01-01", "2026-01-02") + s.save_tls_cert("beta.internal", "c", "f", "k", "2026-01-01", "2026-01-02") + certs = s.list_tls_certs() + assert len(certs) == 2 + assert certs[0]["domain"] == "alpha.internal" # sorted by domain + assert certs[1]["domain"] == "beta.internal" + + +def test_delete_cert(): + s = get_storage() + s.save_tls_cert("d", "c", "f", "k", "2026-01-01", "2026-01-02") + assert s.delete_tls_cert("d") is True + assert s.load_tls_cert("d") is None + + +def test_delete_cert_missing(): + s = get_storage() + assert s.delete_tls_cert("nonexistent") is False + + +# ── StorageStore adapter ────────────────────────────────────────────────────── + + +@pytest.fixture +def store_adapter(): + """Create a StorageStore backed by the test database.""" + from turnstone.core.tls_store import StorageStore + + return StorageStore(get_storage()) + + +def test_adapter_save_load_ca(store_adapter): + store_adapter.save_ca("test-ca", b"cert-pem", b"key-pem") + result = store_adapter.load_ca("test-ca") + assert result is not None + cert_pem, key_pem = result + assert cert_pem == b"cert-pem" + assert key_pem == b"key-pem" + + +def test_adapter_load_ca_missing(store_adapter): + assert store_adapter.load_ca("missing") is None + + +def test_adapter_save_load_cert(store_adapter): + lacme = pytest.importorskip("lacme") + now = datetime.now(UTC) + bundle = lacme.CertBundle( + domain="test.internal", + domains=("test.internal", "10.0.1.1"), + cert_pem=b"cert", + fullchain_pem=b"fullchain", + key_pem=b"key", + issued_at=now, + expires_at=now, + ) + store_adapter.save_cert(bundle) + loaded = store_adapter.load_cert("test.internal") + assert loaded is not None + assert loaded.domain == "test.internal" + assert loaded.domains == ("test.internal", "10.0.1.1") + assert loaded.cert_pem == b"cert" + assert loaded.fullchain_pem == b"fullchain" + assert loaded.key_pem == b"key" + + +def test_adapter_list_certs(store_adapter): + lacme = pytest.importorskip("lacme") + now = datetime.now(UTC) + for name in ["alpha", "beta"]: + bundle = lacme.CertBundle( + domain=f"{name}.internal", + domains=(f"{name}.internal",), + cert_pem=b"c", + fullchain_pem=b"f", + key_pem=b"k", + issued_at=now, + expires_at=now, + ) + store_adapter.save_cert(bundle) + certs = store_adapter.list_certs() + assert len(certs) == 2 + assert certs[0].domain == "alpha.internal" + + +def test_adapter_load_cert_missing(store_adapter): + assert store_adapter.load_cert("missing") is None + + +def test_adapter_account_key_roundtrip(store_adapter): + """Test account key save/load with real cryptography objects.""" + pytest.importorskip("lacme") + from cryptography.hazmat.primitives.asymmetric import ec + + key = ec.generate_private_key(ec.SECP256R1()) + store_adapter.save_account_key(key) + loaded = store_adapter.load_account_key() + assert loaded is not None + # Verify it's a usable EC key + assert loaded.key_size == key.key_size + + +def test_adapter_account_key_missing(store_adapter): + assert store_adapter.load_account_key() is None diff --git a/turnstone.example.toml b/turnstone.example.toml new file mode 100644 index 00000000..a6fd71b2 --- /dev/null +++ b/turnstone.example.toml @@ -0,0 +1,124 @@ +# turnstone.toml — shared bootstrap configuration +# +# This file is read once at startup. Values here are overridden by +# environment variables, which are in turn overridden by CLI flags. +# +# All sections are optional. Missing sections use binary defaults. +# Config file location precedence: +# 1. --config flag +# 2. $TURNSTONE_CONFIG env var +# 3. ~/.config/turnstone/config.toml + +# --- LLM API (turnstone, node, eval) --- + +[api] +# base_url = "" # API endpoint; empty = binary default +# api_key = "" # env: OPENAI_API_KEY or ANTHROPIC_API_KEY + +# --- Default Model (turnstone, node, eval) --- + +[model] +# name = "" # Model ID; empty = provider default (gpt-5 / claude-sonnet-4) +# temperature = 0.0 # 0 = provider default +# reasoning_effort = "" # "low", "medium", "high", "max" +# context_window = 0 # 0 = auto-detect from provider capabilities +# max_tokens = 0 # 0 = provider default + +# --- Named Models (turnstone, node, eval) --- +# Define model aliases with per-model overrides. Useful for local model +# servers or mixing providers. Reference by name with --model flag. +# +# [models.local] +# name = "llama-3-70b" +# provider = "openai" +# base_url = "http://localhost:8000/v1" +# context_window = 8192 +# +# [models.local.capabilities] +# supports_vision = false +# supports_web_search = false +# +# [models.claude] +# name = "claude-opus-4-6" +# provider = "anthropic" + +# --- Database (turnstone, node, console) --- + +[database] +# url = "" # postgres://user:pass@host/db or /path/to.db + # env: TURNSTONE_DB_URL +# SSL params (passed through to SQLAlchemy connection): +# sslmode = "prefer" # disable, allow, prefer, require, verify-ca, verify-full +# sslrootcert = "" # path to CA cert for verify-ca/verify-full +# sslcert = "" # path to client cert (mTLS) +# sslkey = "" # path to client key (mTLS) + +# --- Redis (bridge, console, channel) --- + +[redis] +# url = "" # redis://host:6379/0 or rediss://host:6380/0 + # env: TURNSTONE_REDIS_URL +# TLS params (passed through to Redis connection): +# tls = false # enable TLS (also auto-enabled by rediss:// scheme) +# tls_ca = "" # path to CA cert +# tls_cert = "" # path to client cert (mTLS) +# tls_key = "" # path to client key (mTLS) + +# --- Auth (node, console) --- + +[auth] +# enabled = true # env: TURNSTONE_AUTH_ENABLED +# jwt_secret = "" # HS256 signing secret (min 32 bytes recommended) + # env: TURNSTONE_JWT_SECRET +# token = "" # Static config token for full access + # env: TURNSTONE_AUTH_TOKEN + +# --- Logging (turnstone, node, console) --- + +[log] +# level = "" # "debug", "info", "warn", "error" + # empty = binary default (warn for CLI, info for servers) + # env: TURNSTONE_LOG_LEVEL +# json = false # JSON output; auto-enabled when stderr is not a TTY + +# --- Session (turnstone, node) --- + +[session] +# instructions = "" # Default system message +# compact_max_tokens = 32768 # Max tokens for context compaction summary +# auto_compact_pct = 0.8 # Trigger compaction at this % of context window + +# --- Tools (turnstone, node) --- + +[tools] +# timeout = 120 # Tool execution timeout in seconds +# skip_permissions = false # Auto-approve all tool calls + +# --- Judge (turnstone, node) --- + +[judge] +# enabled = true # Enable intent validation +# confidence_threshold = 0.7 # Minimum confidence for heuristic verdicts +# output_guard = true # Scan tool output for security signals +# redact_secrets = true # Redact detected credentials in output + +# --- Memory (turnstone, node) --- + +[memory] +# relevance_k = 5 # Top-K memories for context injection +# fetch_limit = 50 # Max memories to fetch for ranking +# max_content = 32768 # Max memory content size in chars +# nudge_cooldown = 300 # Min seconds between metacognitive nudges +# nudges = true # Enable memory nudges + +# --- MCP (turnstone, node) --- + +[mcp] +# config_path = "" # Path to MCP servers config file (JSON) +# refresh_interval = 14400 # Refresh interval in seconds (default: 4h) + +# --- Server (node, console) --- + +[server] +# max_workstreams = 50 # Maximum concurrent workstreams per node + # env: TURNSTONE_MAX_WORKSTREAMS diff --git a/turnstone/core/settings_registry.py b/turnstone/core/settings_registry.py index 37b6a63f..6569dc3c 100644 --- a/turnstone/core/settings_registry.py +++ b/turnstone/core/settings_registry.py @@ -528,6 +528,29 @@ def _build_registry() -> dict[str, SettingDef]: "information from conversations into long-term memory. This helps the AI " "remember context across separate conversations.", ), + # -- tls ---------------------------------------------------------------- + SettingDef( + "tls.enabled", + "bool", + False, + "Enable mTLS for inter-service communication", + "tls", + restart_required=True, + help="When enabled, the console runs an internal Certificate Authority and " + "ACME server. All cluster services (servers, bridge, channels) auto-provision " + "short-lived certificates for mutual TLS. Requires lacme: pip install turnstone[tls]", + ), + SettingDef( + "tls.acme_directory", + "str", + "", + "External ACME CA URL for the console's frontend HTTPS cert", + "tls", + restart_required=True, + help="Set to a public ACME directory URL (e.g. https://acme-v02.api.letsencrypt.org/" + "directory) to get a publicly trusted certificate for the console's HTTPS endpoint. " + "Leave empty to self-issue from the internal CA (use when behind a reverse proxy).", + ), ] return {d.key: d for d in defs} @@ -543,7 +566,7 @@ BOOTSTRAP_SECTIONS: frozenset[str] = frozenset( "auth", "bridge", "console", - } + }, ) diff --git a/turnstone/core/storage/_postgresql.py b/turnstone/core/storage/_postgresql.py index 40a2b38e..545de596 100644 --- a/turnstone/core/storage/_postgresql.py +++ b/turnstone/core/storage/_postgresql.py @@ -30,6 +30,9 @@ from turnstone.core.storage._schema import ( skill_versions, structured_memories, system_settings, + tls_account_keys, + tls_ca, + tls_certificates, tool_policies, usage_events, user_roles, @@ -2805,6 +2808,108 @@ class PostgreSQLBackend: conn.commit() return result.rowcount + # -- TLS / ACME ------------------------------------------------------------ + + def save_tls_account_key(self, key_id: str, key_pem: str) -> None: + from sqlalchemy.dialects.postgresql import insert as pg_insert + + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + stmt = pg_insert(tls_account_keys).values(id=key_id, key_pem=key_pem, created=now) + stmt = stmt.on_conflict_do_update( + index_elements=["id"], + set_={"key_pem": key_pem}, + ) + with self._engine.connect() as conn: + conn.execute(stmt) + conn.commit() + + def load_tls_account_key(self, key_id: str) -> str | None: + with self._engine.connect() as conn: + row = conn.execute( + sa.select(tls_account_keys.c.key_pem).where(tls_account_keys.c.id == key_id) + ).first() + return row[0] if row else None + + def save_tls_ca(self, name: str, cert_pem: str, key_pem: str) -> None: + from sqlalchemy.dialects.postgresql import insert as pg_insert + + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + stmt = pg_insert(tls_ca).values(name=name, cert_pem=cert_pem, key_pem=key_pem, created=now) + stmt = stmt.on_conflict_do_update( + index_elements=["name"], + set_={"cert_pem": cert_pem, "key_pem": key_pem}, + ) + with self._engine.connect() as conn: + conn.execute(stmt) + conn.commit() + + def load_tls_ca(self, name: str) -> dict[str, Any] | None: + with self._engine.connect() as conn: + row = conn.execute(sa.select(tls_ca).where(tls_ca.c.name == name)).first() + if not row: + return None + return _row_to_dict(row) + + def save_tls_cert( + self, + domain: str, + cert_pem: str, + fullchain_pem: str, + key_pem: str, + issued_at: str, + expires_at: str, + meta: str | None = None, + ) -> None: + from sqlalchemy.dialects.postgresql import insert as pg_insert + + stmt = pg_insert(tls_certificates).values( + domain=domain, + cert_pem=cert_pem, + fullchain_pem=fullchain_pem, + key_pem=key_pem, + issued_at=issued_at, + expires_at=expires_at, + meta=meta, + ) + stmt = stmt.on_conflict_do_update( + index_elements=["domain"], + set_={ + "cert_pem": cert_pem, + "fullchain_pem": fullchain_pem, + "key_pem": key_pem, + "issued_at": issued_at, + "expires_at": expires_at, + "meta": meta, + }, + ) + with self._engine.connect() as conn: + conn.execute(stmt) + conn.commit() + + def load_tls_cert(self, domain: str) -> dict[str, Any] | None: + with self._engine.connect() as conn: + row = conn.execute( + sa.select(tls_certificates).where(tls_certificates.c.domain == domain) + ).first() + if not row: + return None + return _row_to_dict(row) + + def list_tls_certs(self) -> list[dict[str, Any]]: + with self._engine.connect() as conn: + rows = conn.execute( + sa.select(tls_certificates).order_by(tls_certificates.c.domain) + ).fetchall() + return [_row_to_dict(r) for r in rows] + + def delete_tls_cert(self, domain: str) -> bool: + with self._engine.connect() as conn: + result = conn.execute( + sa.delete(tls_certificates).where(tls_certificates.c.domain == domain) + ) + conn.commit() + return result.rowcount > 0 + # -- Lifecycle ------------------------------------------------------------- def close(self) -> None: diff --git a/turnstone/core/storage/_protocol.py b/turnstone/core/storage/_protocol.py index e07e01d6..7b27727f 100644 --- a/turnstone/core/storage/_protocol.py +++ b/turnstone/core/storage/_protocol.py @@ -952,6 +952,49 @@ class StorageBackend(Protocol): """Delete an MCP server definition. Returns True if existed.""" ... + # -- TLS / ACME (lacme Store) ---------------------------------------------- + + def save_tls_account_key(self, key_id: str, key_pem: str) -> None: + """Persist an ACME account private key.""" + ... + + def load_tls_account_key(self, key_id: str) -> str | None: + """Load an ACME account key PEM by ID. Returns None if not found.""" + ... + + def save_tls_ca(self, name: str, cert_pem: str, key_pem: str) -> None: + """Persist a CA root certificate and key.""" + ... + + def load_tls_ca(self, name: str) -> dict[str, Any] | None: + """Load CA cert+key by name. Returns dict with cert_pem, key_pem or None.""" + ... + + def save_tls_cert( + self, + domain: str, + cert_pem: str, + fullchain_pem: str, + key_pem: str, + issued_at: str, + expires_at: str, + meta: str | None = None, + ) -> None: + """Persist an issued certificate (upsert by domain).""" + ... + + def load_tls_cert(self, domain: str) -> dict[str, Any] | None: + """Load certificate by domain. Returns dict or None.""" + ... + + def list_tls_certs(self) -> list[dict[str, Any]]: + """List all stored certificates.""" + ... + + def delete_tls_cert(self, domain: str) -> bool: + """Delete a certificate by domain. Returns True if existed.""" + ... + # -- Lifecycle ------------------------------------------------------------- def close(self) -> None: diff --git a/turnstone/core/storage/_schema.py b/turnstone/core/storage/_schema.py index 4aadaf61..badbf44b 100644 --- a/turnstone/core/storage/_schema.py +++ b/turnstone/core/storage/_schema.py @@ -547,3 +547,34 @@ oidc_pending_states = sa.Table( sa.Column("audience", sa.Text, nullable=False), sa.Column("created_at", sa.Text, nullable=False), ) + +# ── TLS / ACME (lacme integration) ────────────────────────────────────────── + +tls_account_keys = sa.Table( + "tls_account_keys", + metadata, + sa.Column("id", sa.Text, primary_key=True), + sa.Column("key_pem", sa.Text, nullable=False), + sa.Column("created", sa.Text, nullable=False), +) + +tls_ca = sa.Table( + "tls_ca", + metadata, + sa.Column("name", sa.Text, primary_key=True), + sa.Column("cert_pem", sa.Text, nullable=False), + sa.Column("key_pem", sa.Text, nullable=False), + sa.Column("created", sa.Text, nullable=False), +) + +tls_certificates = sa.Table( + "tls_certificates", + metadata, + sa.Column("domain", sa.Text, primary_key=True), + sa.Column("cert_pem", sa.Text, nullable=False), + sa.Column("fullchain_pem", sa.Text, nullable=False), + sa.Column("key_pem", sa.Text, nullable=False), + sa.Column("issued_at", sa.Text, nullable=False), + sa.Column("expires_at", sa.Text, nullable=False), + sa.Column("meta", sa.Text, nullable=True), +) diff --git a/turnstone/core/storage/_sqlite.py b/turnstone/core/storage/_sqlite.py index 6c430d13..6b2805d5 100644 --- a/turnstone/core/storage/_sqlite.py +++ b/turnstone/core/storage/_sqlite.py @@ -30,6 +30,9 @@ from turnstone.core.storage._schema import ( skill_versions, structured_memories, system_settings, + tls_account_keys, + tls_ca, + tls_certificates, tool_policies, usage_events, user_roles, @@ -2854,6 +2857,110 @@ class SQLiteBackend: conn.commit() return result.rowcount + # -- TLS / ACME ------------------------------------------------------------ + + def save_tls_account_key(self, key_id: str, key_pem: str) -> None: + from sqlalchemy.dialects.sqlite import insert as sqlite_insert + + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + stmt = sqlite_insert(tls_account_keys).values(id=key_id, key_pem=key_pem, created=now) + stmt = stmt.on_conflict_do_update( + index_elements=["id"], + set_={"key_pem": key_pem}, + ) + with self._engine.connect() as conn: + conn.execute(stmt) + conn.commit() + + def load_tls_account_key(self, key_id: str) -> str | None: + with self._engine.connect() as conn: + row = conn.execute( + sa.select(tls_account_keys.c.key_pem).where(tls_account_keys.c.id == key_id) + ).first() + return row[0] if row else None + + def save_tls_ca(self, name: str, cert_pem: str, key_pem: str) -> None: + from sqlalchemy.dialects.sqlite import insert as sqlite_insert + + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + stmt = sqlite_insert(tls_ca).values( + name=name, cert_pem=cert_pem, key_pem=key_pem, created=now + ) + stmt = stmt.on_conflict_do_update( + index_elements=["name"], + set_={"cert_pem": cert_pem, "key_pem": key_pem}, + ) + with self._engine.connect() as conn: + conn.execute(stmt) + conn.commit() + + def load_tls_ca(self, name: str) -> dict[str, Any] | None: + with self._engine.connect() as conn: + row = conn.execute(sa.select(tls_ca).where(tls_ca.c.name == name)).first() + if not row: + return None + return _row_to_dict(row) + + def save_tls_cert( + self, + domain: str, + cert_pem: str, + fullchain_pem: str, + key_pem: str, + issued_at: str, + expires_at: str, + meta: str | None = None, + ) -> None: + from sqlalchemy.dialects.sqlite import insert as sqlite_insert + + stmt = sqlite_insert(tls_certificates).values( + domain=domain, + cert_pem=cert_pem, + fullchain_pem=fullchain_pem, + key_pem=key_pem, + issued_at=issued_at, + expires_at=expires_at, + meta=meta, + ) + stmt = stmt.on_conflict_do_update( + index_elements=["domain"], + set_={ + "cert_pem": cert_pem, + "fullchain_pem": fullchain_pem, + "key_pem": key_pem, + "issued_at": issued_at, + "expires_at": expires_at, + "meta": meta, + }, + ) + with self._engine.connect() as conn: + conn.execute(stmt) + conn.commit() + + def load_tls_cert(self, domain: str) -> dict[str, Any] | None: + with self._engine.connect() as conn: + row = conn.execute( + sa.select(tls_certificates).where(tls_certificates.c.domain == domain) + ).first() + if not row: + return None + return _row_to_dict(row) + + def list_tls_certs(self) -> list[dict[str, Any]]: + with self._engine.connect() as conn: + rows = conn.execute( + sa.select(tls_certificates).order_by(tls_certificates.c.domain) + ).fetchall() + return [_row_to_dict(r) for r in rows] + + def delete_tls_cert(self, domain: str) -> bool: + with self._engine.connect() as conn: + result = conn.execute( + sa.delete(tls_certificates).where(tls_certificates.c.domain == domain) + ) + conn.commit() + return result.rowcount > 0 + # -- Lifecycle ------------------------------------------------------------- def close(self) -> None: diff --git a/turnstone/core/storage/migrations/versions/026_tls_certificates.py b/turnstone/core/storage/migrations/versions/026_tls_certificates.py new file mode 100644 index 00000000..0697d273 --- /dev/null +++ b/turnstone/core/storage/migrations/versions/026_tls_certificates.py @@ -0,0 +1,53 @@ +"""TLS certificate storage for lacme ACME integration. + +Three tables for the lacme Store protocol: +- tls_account_keys: ACME account private keys +- tls_ca: CA root certificate and key +- tls_certificates: Issued service certificates + +Revision ID: 026 +Revises: 025 +Create Date: 2026-03-25 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "026" +down_revision = "025" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "tls_account_keys", + sa.Column("id", sa.Text, primary_key=True), + sa.Column("key_pem", sa.Text, nullable=False), + sa.Column("created", sa.Text, nullable=False), + ) + + op.create_table( + "tls_ca", + sa.Column("name", sa.Text, primary_key=True), + sa.Column("cert_pem", sa.Text, nullable=False), + sa.Column("key_pem", sa.Text, nullable=False), + sa.Column("created", sa.Text, nullable=False), + ) + + op.create_table( + "tls_certificates", + sa.Column("domain", sa.Text, primary_key=True), + sa.Column("cert_pem", sa.Text, nullable=False), + sa.Column("fullchain_pem", sa.Text, nullable=False), + sa.Column("key_pem", sa.Text, nullable=False), + sa.Column("issued_at", sa.Text, nullable=False), + sa.Column("expires_at", sa.Text, nullable=False), + sa.Column("meta", sa.Text, nullable=True), + ) + + +def downgrade() -> None: + op.drop_table("tls_certificates") + op.drop_table("tls_ca") + op.drop_table("tls_account_keys") diff --git a/turnstone/core/tls_store.py b/turnstone/core/tls_store.py new file mode 100644 index 00000000..b823dc17 --- /dev/null +++ b/turnstone/core/tls_store.py @@ -0,0 +1,127 @@ +"""lacme Store adapter backed by turnstone's storage backend. + +Bridges lacme's Store protocol to turnstone's StorageBackend, keeping +all TLS state (account keys, CA, certificates) in the shared database +rather than the filesystem. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from turnstone.core.storage._protocol import StorageBackend + + +def _parse_utc(iso: str) -> datetime: + """Parse an ISO timestamp, assuming UTC if naive.""" + dt = datetime.fromisoformat(iso) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt + + +def _ensure_lacme() -> Any: + """Import lacme, raising a clear error if not installed.""" + try: + import lacme + except ImportError: + raise ImportError( + "lacme is required for TLS support. Install with: pip install turnstone[tls]", + ) from None + return lacme + + +class StorageStore: + """lacme Store implementation backed by turnstone's database. + + Implements the 7-method Store protocol that lacme's CertificateAuthority + and Client use for persistence. + """ + + def __init__(self, storage: StorageBackend) -> None: + self._storage = storage + + # -- Account key ----------------------------------------------------------- + + def save_account_key(self, key: Any) -> None: + """Persist the ACME account private key.""" + from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + ) + + key_pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode() + self._storage.save_tls_account_key("default", key_pem) + + def load_account_key(self) -> Any | None: + """Load the ACME account private key, or None.""" + from cryptography.hazmat.primitives.serialization import load_pem_private_key + + pem = self._storage.load_tls_account_key("default") + if pem is None: + return None + return load_pem_private_key(pem.encode(), password=None) + + # -- CA -------------------------------------------------------------------- + + def save_ca(self, name: str, cert_pem: bytes, key_pem: bytes) -> None: + """Persist a CA root certificate and key.""" + self._storage.save_tls_ca(name, cert_pem.decode(), key_pem.decode()) + + def load_ca(self, name: str) -> tuple[bytes, bytes] | None: + """Load CA cert+key by name. Returns (cert_pem, key_pem) or None.""" + row = self._storage.load_tls_ca(name) + if row is None: + return None + return row["cert_pem"].encode(), row["key_pem"].encode() + + # -- Certificates ---------------------------------------------------------- + + def save_cert(self, bundle: Any) -> Any: + """Persist an issued certificate bundle.""" + meta = json.dumps({"domains": list(bundle.domains)}) + self._storage.save_tls_cert( + domain=bundle.domain, + cert_pem=bundle.cert_pem.decode(), + fullchain_pem=bundle.fullchain_pem.decode(), + key_pem=bundle.key_pem.decode(), + issued_at=bundle.issued_at.isoformat(), + expires_at=bundle.expires_at.isoformat(), + meta=meta, + ) + return bundle + + def load_cert(self, domain: str) -> Any | None: + """Load a certificate bundle by domain.""" + row = self._storage.load_tls_cert(domain) + if row is None: + return None + return self._row_to_bundle(row) + + def list_certs(self) -> list[Any]: + """List all stored certificate bundles.""" + rows = self._storage.list_tls_certs() + return [self._row_to_bundle(r) for r in rows] + + def delete_cert(self, domain: str) -> None: + """Delete a stored certificate bundle by domain.""" + self._storage.delete_tls_cert(domain) + + def _row_to_bundle(self, row: dict[str, Any]) -> Any: + """Convert a storage row dict to a lacme CertBundle.""" + lacme = _ensure_lacme() + meta = json.loads(row.get("meta") or "{}") + domains = tuple(meta.get("domains", [row["domain"]])) + return lacme.CertBundle( + domain=row["domain"], + domains=domains, + cert_pem=row["cert_pem"].encode(), + fullchain_pem=row["fullchain_pem"].encode(), + key_pem=row["key_pem"].encode(), + issued_at=_parse_utc(row["issued_at"]), + expires_at=_parse_utc(row["expires_at"]), + ) diff --git a/uv.lock b/uv.lock index da94ed1d..6391c728 100644 --- a/uv.lock +++ b/uv.lock @@ -982,6 +982,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "lacme" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/3f/0371df0b8c5d92697234893ec90168983d86a6608cadae01f287b9093aa9/lacme-1.0.1.tar.gz", hash = "sha256:d90f919f7eb98e6569d05acc9e8e86006540356d9c1321cb7dd868352965201f", size = 196508, upload-time = "2026-03-25T21:42:25.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/e4/fe7d02c95f20c4212683456da605125a28463d3838a77cbcab4234c63015/lacme-1.0.1-py3-none-any.whl", hash = "sha256:3ceb598c4044378d55f91ca661d5fab1366739711ca05f8e3f8143904a04a1a9", size = 70062, upload-time = "2026-03-25T21:42:24.023Z" }, +] + [[package]] name = "librt" version = "0.8.1" @@ -2347,6 +2360,7 @@ all = [ { name = "croniter" }, { name = "ddgs" }, { name = "discord-py" }, + { name = "lacme" }, { name = "psycopg", extra = ["binary"] }, { name = "redis" }, ] @@ -2383,6 +2397,9 @@ test = [ { name = "pytest" }, { name = "pytest-cov" }, ] +tls = [ + { name = "lacme" }, +] [package.metadata] requires-dist = [ @@ -2395,6 +2412,7 @@ requires-dist = [ { name = "discord-py", marker = "extra == 'discord'", specifier = ">=2.4" }, { name = "httpx", specifier = ">=0.28" }, { name = "httpx-sse", specifier = ">=0.4" }, + { name = "lacme", marker = "extra == 'tls'", specifier = ">=1.0.1" }, { name = "mcp", specifier = ">=1.6" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" }, { name = "openai", specifier = ">=2.24" }, @@ -2413,11 +2431,11 @@ requires-dist = [ { name = "sse-starlette", specifier = ">=2.0" }, { name = "starlette", specifier = ">=0.45" }, { name = "structlog", specifier = ">=24.1" }, - { name = "turnstone", extras = ["mq", "console", "sim", "anthropic", "postgres", "discord", "ddg"], marker = "extra == 'all'" }, + { name = "turnstone", extras = ["mq", "console", "sim", "anthropic", "postgres", "discord", "ddg", "tls"], marker = "extra == 'all'" }, { name = "types-redis", marker = "extra == 'dev'", specifier = ">=4.6" }, { name = "uvicorn", specifier = ">=0.34" }, ] -provides-extras = ["test", "dev", "mq", "console", "sim", "anthropic", "postgres", "ddg", "discord", "all"] +provides-extras = ["test", "dev", "mq", "console", "sim", "anthropic", "postgres", "ddg", "discord", "tls", "all"] [[package]] name = "types-cffi"