secnull,
a CMS that signs its own work.
A Go single-binary CMS for publishing Ed25519-signed security audits. Strict CSP nonces, Argon2id+TOTP login, SHA-256-chained audit log.
secnull (cmd/secnullcms) is a publication whose credibility depends on the publisher's posture. The CMS itself is single-binary, pure-Go SQLite (no CGO), and refuses to serve any audit whose hash does not match the signed manifest at content/integrity.json. Every editorial action ends up in an append-only SHA-256-chained audit log.
ITech scope
- Single static binary, cross-compiled to
linux/amd64,linux/arm64, andwindows/amd64from one Go module. Zero runtime dependencies beyond the OS — no PHP, no Node, no system libxml — so the 14.1 MB on the path is the entire deployable surface. - Pure-Go SQLite via
modernc.org/sqlite.CGO_ENABLED=0is enforced at build time, which means containers behave identically to bare-metal builds and there are no glibc-version surprises across Alpine, Debian-slim, or distroless bases. - Strict Content-Security-Policy with a per-request nonce. Every inline
<script>or<style>in a template must consume the request-context nonce; if a refactor drops it, the browser blocks the asset and the violation is captured indata/audit.logwith the offending route and request id. - Ed25519 article signatures verified against
content/integrity.jsonon every boot. Admin login is Argon2id (memory-hard, defaultm=64 MiB, t=3, p=2) plus a TOTP second factor enrolled through theadmin-bootstrapsubcommand — never via a web flow, so an unattended bootstrap is impossible. __Host--prefixed session cookies are enforced at config-validation time; the binary refuses to boot if the prefix is missing. On top of that, the middleware chain layers an admin allowlist, an origin check, and double-submit CSRF, so an admin session is bound to both the cookie and the request origin.- Static-export mode (
make export) renders the public site to./distas plain HTML. Suitable for serving from any HTTP server, an S3 bucket, or behind a CDN — but the audit-log chain remains verifiable only when the binary itself is on the path. Static export is for read-only mirrors, not for the canonical surface.
IIArchitecture
Subcommand dispatch happens before flag parsing. main.go switches on os.Args[1] for serve, keygen, sign, verify, verify-audit, admin-bootstrap, and export, then routes to a per-subcommand handler in internal/subcommands/. Adding a subcommand means editing two files in lockstep — the dispatch arm in main.go and the implementation in internal/subcommands/<name>.go. The order matters: an earlier design that mixed flag parsing with subcommand selection produced order-of-operations bugs around shared flags like -config, and the explicit pre-parse split closes that class of bug.
Content loads once at startup, then optionally hot-reloads via an mtime-polling watcher running on a 500 ms tick. fsnotify was rejected for cross-platform parity reasons: Windows ReadDirectoryChangesW and Linux inotify disagree about atomic-rename publishing semantics, so a single poll loop avoids the divergence. If signature enforcement is on, any manifest mismatch aborts startup with a non-zero exit code — make sign-content is mandatory after every audit edit, and CI refuses to merge a content change whose signature has not been refreshed.
Routing uses Go 1.22 method-prefixed mux patterns ("GET /audits/{slug}"), so HTTP method is part of the route table rather than branched at handler time. The middleware chain order is load-bearing and tested explicitly: requestID → recoverer → accessLog → rate-limit → securityHeaders → adminAllowlist → originCheck → csrf → mux. Public read routes intentionally bypass the rate limiter so a search-engine crawl can’t starve a live reader; admin routes never bypass it. Reordering rate-limit and securityHeaders — for example, to "let throttling happen sooner" — would let a 429 response leak headers that should never be served, which is why both unit tests and a fuzz harness pin the order.
IIIState surfaces
data/secnull.db— SQLite admin / session store. Holds users, sessions, per-account lockout counters, and CSRF tokens. Wiping this resets only the operator side of the world; published audits survive untouched.data/audit.log— append-only event log. Each entry is a JSON object whoseprev_hashfield is SHA-256 over the serialized previous entry, so the file is a hash chain rooted at a genesis record.verify-auditwalks the chain end to end; a single tampered byte breaks verification and is caught before the next signed publish.data/overrides.json— editorial overrides (retractions, errata, "do not republish" flags) layered over signed content at render time. Overrides are themselves audit-logged with operator id and reason, so an unexplained retraction is recoverable from the log without needing a separate paper trail.content/audits/*.md+content/integrity.json— signed publication tree. The manifest is an Ed25519 signature over the SHA-256 of every audit file plus its metadata. Signing-key rotation is a deliberate operator action invoked throughkeygen, never an automatic refresh, and the public verification key is published alongside the binary release.
IVOperating posture
The day-2 surface is small on purpose. An operator runs secnullcms admin-bootstrap exactly once per environment to seed the Argon2id+TOTP credential, after which every subsequent admin action goes through the web admin behind the middleware chain. Audit edits are made in the content tree on a workstation, signed with secnullcms sign, committed, and deployed; the deploy itself is a static binary refresh plus a content-tree sync. There is no admin SPA shipped to the browser — the editorial UI is server-rendered HTML, and any JavaScript on it is gated by the per-request CSP nonce, which is also why CSP reports go through the same audit log as everything else.
Failure modes are loud rather than silent. A signature mismatch on boot is fatal, not a warning — the process exits with a non-zero code so a supervisor like systemd or the Windows Service Control Manager surfaces the fault. A CSP violation is captured to audit.log with the offending element, the route, and the request id. A failed Argon2id verification increments a per-account lockout counter whose next-attempt window backs off quadratically; the counter is stored in the same secnull.db as the session store, which keeps the recovery story coherent across restarts.
VSurface
What callers see is small: a single 14.1 MB binary on the path. secnullcms serve brings the public site up; secnullcms admin-bootstrap enrols the first operator with Argon2id and a TOTP secret; secnullcms sign updates content/integrity.json after an audit edit; secnullcms verify and verify-audit validate the signed manifest and walk the SHA-256-chained log end to end. The HTTP surface is intentionally narrow — public read routes plus an admin allowlist gated by origin check and CSRF. There is no plugin API, no embedded admin SPA, and no client-side framework, which is why the binary is small enough to ship as a single artifact and the supply chain is auditable in one go.sum.
VIConstraints
The constraints are deliberate, not residual. Pure-Go SQLite avoids CGO so containers behave identically to bare-metal builds; the ‘0 CGO deps’ plate above is a contract enforced by the build, not a number observed after the fact. Subcommand dispatch happens before flag parsing because mixing the two created order-of-operations bugs around shared flags like -config in the previous design, and pre-parse separation closes that whole class of bug. The middleware order is explicit and tested — reordering rate-limit and securityHeaders, for example, would let a 429 response leak headers that should never be served, and both unit tests and a fuzz harness pin the order. Static-export mode renders the public site to ./dist for serving from any HTTP server, but the audit-log chain is only verifiable when the binary itself is on the path; static export is a read-only mirror, not the canonical surface.