Use S3 object expiry for OCSP snapshots
This commit is contained in:
+4
-2
@@ -76,9 +76,11 @@ KCA_OCSP_S3_PREFIX=ocsp
|
||||
#KCA_OCSP_S3_ENDPOINT=http://localhost:9000
|
||||
KCA_OCSP_S3_FORCE_PATH_STYLE=false
|
||||
KCA_OCSP_PUBLISH_INTERVAL_SECONDS=1
|
||||
# Republish unchanged snapshots as a liveness heartbeat. Keep this comfortably
|
||||
# below the responder's s3.stale_grace_seconds (default 60).
|
||||
# Republish unchanged snapshots as a liveness heartbeat. Each object receives
|
||||
# an Expires header; the responder rejects it after that timestamp. Keep the
|
||||
# heartbeat comfortably below the object expiry.
|
||||
KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS=30
|
||||
KCA_OCSP_OBJECT_EXPIRY_SECONDS=60
|
||||
|
||||
# --- CORS (optional) -------------------------------------------------------
|
||||
#CORS_ALLOW_ORIGINS=
|
||||
|
||||
@@ -197,12 +197,24 @@ class Settings:
|
||||
ocsp_s3_force_path_style: bool
|
||||
ocsp_publish_interval_seconds: int
|
||||
ocsp_heartbeat_interval_seconds: int
|
||||
ocsp_object_expiry_seconds: int
|
||||
|
||||
@staticmethod
|
||||
def from_env() -> "Settings":
|
||||
raw_master = os.environ.get("KCA_MASTER_KEY")
|
||||
master_key_is_dev = not raw_master
|
||||
master_key = decode_master_key(raw_master) if raw_master else _DEV_MASTER_KEY
|
||||
ocsp_heartbeat_interval_seconds = max(
|
||||
1, int(_env("KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS", "30"))
|
||||
)
|
||||
ocsp_object_expiry_seconds = max(
|
||||
1, int(_env("KCA_OCSP_OBJECT_EXPIRY_SECONDS", "60"))
|
||||
)
|
||||
if ocsp_object_expiry_seconds <= ocsp_heartbeat_interval_seconds:
|
||||
raise RuntimeError(
|
||||
"KCA_OCSP_OBJECT_EXPIRY_SECONDS must be greater than "
|
||||
"KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS"
|
||||
)
|
||||
return Settings(
|
||||
database_url=os.environ.get("DATABASE_URL")
|
||||
or _database_url_from_parts(
|
||||
@@ -251,9 +263,8 @@ class Settings:
|
||||
ocsp_publish_interval_seconds=max(
|
||||
1, int(_env("KCA_OCSP_PUBLISH_INTERVAL_SECONDS", "1"))
|
||||
),
|
||||
ocsp_heartbeat_interval_seconds=max(
|
||||
1, int(_env("KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS", "30"))
|
||||
),
|
||||
ocsp_heartbeat_interval_seconds=ocsp_heartbeat_interval_seconds,
|
||||
ocsp_object_expiry_seconds=ocsp_object_expiry_seconds,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ from .models import Certificate, CertificateRevocation, OcspSnapshot, Vault
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA = "kca-ocsp-status/v1"
|
||||
SCHEMA = "kca-ocsp-status/v2"
|
||||
|
||||
|
||||
def _iso(value: Optional[datetime]) -> Optional[str]:
|
||||
@@ -73,7 +73,6 @@ async def snapshot_for_ca(ca: Certificate) -> Dict[str, Any]:
|
||||
"sha256": ca.fingerprint_sha256.lower(),
|
||||
"subject": cert_subject(ca),
|
||||
},
|
||||
"generated_at": _iso(datetime.now(timezone.utc)),
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
@@ -115,6 +114,8 @@ async def publish_snapshot(ca: Certificate, snapshot: Optional[Dict[str, Any]] =
|
||||
Body=body,
|
||||
ContentType="application/json",
|
||||
CacheControl="no-store",
|
||||
Expires=datetime.now(timezone.utc)
|
||||
+ timedelta(seconds=settings.ocsp_object_expiry_seconds),
|
||||
)
|
||||
|
||||
return key
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Environment configuration tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from kca.config import Settings
|
||||
|
||||
|
||||
class OcspExpirySettingsTest(unittest.TestCase):
|
||||
def test_defaults(self) -> None:
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
settings = Settings.from_env()
|
||||
self.assertEqual(30, settings.ocsp_heartbeat_interval_seconds)
|
||||
self.assertEqual(60, settings.ocsp_object_expiry_seconds)
|
||||
|
||||
def test_expiry_must_exceed_heartbeat(self) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS": "30",
|
||||
"KCA_OCSP_OBJECT_EXPIRY_SECONDS": "30",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "must be greater"):
|
||||
Settings.from_env()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -941,7 +941,8 @@ class OcspSnapshotTests(unittest.TestCase):
|
||||
payload = response.json()
|
||||
assert payload["enabled"] is False
|
||||
snapshot = payload["snapshot"]
|
||||
assert snapshot["schema"] == "kca-ocsp-status/v1"
|
||||
assert snapshot["schema"] == "kca-ocsp-status/v2"
|
||||
assert "generated_at" not in snapshot
|
||||
assert snapshot["issuer"]["sha256"] == ca["fingerprint_sha256"]
|
||||
entries = {entry["serial_hex"]: entry for entry in snapshot["entries"]}
|
||||
assert len(snapshot["entries"]) == 1
|
||||
@@ -1260,7 +1261,10 @@ class OcspSnapshotTests(unittest.TestCase):
|
||||
call = fake.calls[0]
|
||||
assert call["Bucket"] == "test-bucket"
|
||||
assert call["Key"] == f"ocsp/{ca['id']}.json"
|
||||
assert b"kca-ocsp-status/v1" in call["Body"]
|
||||
assert b"kca-ocsp-status/v2" in call["Body"]
|
||||
assert b"generated_at" not in call["Body"]
|
||||
expires_in = (call["Expires"] - datetime.now(timezone.utc)).total_seconds()
|
||||
assert 50 < expires_in <= 60
|
||||
row = await OcspSnapshot.get(ca_id=ca["id"])
|
||||
assert row.dirty is False
|
||||
assert row.published_at is not None
|
||||
|
||||
Reference in New Issue
Block a user