feat: TLS storage backend + config for lacme integration (#178)

Storage layer for mTLS certificate management via lacme:

- Migration 026: tls_account_keys, tls_ca, tls_certificates tables
- 8 protocol methods on StorageBackend (save/load account keys, CA,
  certs; list/delete certs)
- SQLite and PostgreSQL implementations with dialect-specific upserts
- StorageStore adapter bridging lacme's Store protocol to turnstone
  storage (bytes↔str PEM conversion, CertBundle↔dict mapping)
- Settings registry: tls.enabled (bool), tls.acme_directory (string)
- Config.toml: [redis] TLS and [database] SSL passthrough params
- lacme>=1.0.1 as optional [tls] dependency
- 20 unit tests covering storage CRUD + adapter + crypto roundtrip
This commit is contained in:
Patrick Buckley
2026-03-25 16:07:05 -07:00
committed by GitHub
parent e87f8e19c2
commit 274c97135e
11 changed files with 866 additions and 4 deletions
+6 -1
View File
@@ -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
+226
View File
@@ -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
+124
View File
@@ -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
+24 -1
View File
@@ -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",
}
},
)
+105
View File
@@ -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:
+43
View File
@@ -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:
+31
View File
@@ -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),
)
+107
View File
@@ -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:
@@ -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")
+127
View File
@@ -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"]),
)
Generated
+20 -2
View File
@@ -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"