steve okoth / ~/portfolio

← all work

case · 04

Janus — a self-hosted secrets manager

janus · single-tenant secrets manager

Solo build · shipped · v0.1.0 → v0.3.0

Doppler's ergonomics, Vault's crypto, your server. One Go binary, one Postgres, keys that never leave in plaintext — shipped through three tagged releases and a clean white-box audit.

  • AES-256-GCM
  • Cloud KMS
  • Go
  • PostgreSQL
  • Shamir SSS
  • Svelte 5
year
2026
outcome
163-endpoint API · 100% crypto coverage · 0-critical white-box audit · v0.3.0 in 26 days
repo
github.com/steveokay/janus-secrets

Problem

Secrets management is one of the few things where “just use the SaaS” has a real cost: Doppler and its peers are excellent, but you’re handing your most sensitive material to someone else’s multi-tenant service and paying per seat to do it. The self-hosted alternative — Vault — is powerful and operationally heavy: a cluster, a seal ceremony, a storage backend, a learning curve.

I wanted the middle: Doppler’s project / environment / config ergonomics and run-style injection, Vault’s cryptographic seriousness, and none of the SaaS or the cluster. One Go binary plus one PostgreSQL, fully self-hosted, where the keys never leave the server in plaintext.

That system now exists. Three tagged releases (v0.1.0 → v0.3.0), 1,250 commits in under a month, signed multi-arch binaries and containers, and a five-perspective white-box audit that found zero criticals.

Constraints

  1. No single stored value can decrypt a secret. A leaked database, on its own, must be useless.
  2. The server starts sealed. The master key is not in memory and every secret operation fails until an operator unseals it — no key material sits on disk in plaintext waiting to be read.
  3. The store is crypto-blind. It persists opaque ciphertext and never holds a key or a plaintext. Encryption lives entirely above it.
  4. Standard-library crypto only. Go’s crypto/* and x/crypto, plus cloud KMS used as a service and a vendored copy of HashiCorp’s Shamir implementation. Exactly two third-party exceptions were ever admitted — audited libraries for OIDC/JOSE and WebAuthn verification — each debated, approved, and recorded in the project’s engineering rules before a line landed.
  5. Single-tenant on purpose. No organizations, no multi-tenancy, no HA/Raft, no HSM. Owning your own instance is the feature; the non-goals are load-bearing — and they survived all three releases intact.

Architecture

Everything sits on a three-level key hierarchy behind a seal boundary, over a crypto-blind Postgres store:

protect path · sealed, memory-only

master key

256-bit · never persisted

↓ wraps

project KEK

one per project

↓ wraps

DEK

aes-256-gcm · per version

↓ encrypts

secret ciphertext

+ random nonce · AAD-bound

unseal · one of

shamir

k-of-n · 3-of-5

cloud KMS

aws · gcp · azure

KCV rejects a wrong-but-well-formed key before use. AAD binds every wrapped key and ciphertext to its row.

crypto-blind store · postgresql 16

wrapped project KEKs · wrapped DEKs + nonce · secret ciphertext

never a plaintext · never the master key

envelope-encryption hierarchy
  • Envelope encryption, three levels. A 256-bit master key (root KEK) exists only in server memory after unseal, never persisted in plaintext. It wraps per-project KEKs, stored wrapped. Each project KEK wraps a per-secret-version DEK (AES-256-GCM); nonces are random and stored beside the ciphertext. Every wrapped key is bound to its storage location with authenticated additional data (AAD), so a ciphertext copied to a different row fails to decrypt — this defeats wrapped-key-swap attacks.
  • Two ways to unseal, one interface. Shamir — the master key is split k-of-n (default 3-of-5), and operators submit shares until the threshold is met. Cloud KMS auto-unseal — the master key is wrapped by an AWS KMS, GCP KMS, or Azure Key Vault key and recovered with a single decrypt at startup. A key check value — a known constant encrypted under the master key — lets unseal reject a wrong-but-well-formed key before it is ever used.
  • Doppler-shaped data model. Project → Environment → Config → Secrets, with two-level versioning: immutable config versions for diff and rollback, plus per-secret value history. Config inheritance, cross-project secret references, an env→env promotion pipeline with four-eyes approval, and trash/restore all build on the same model.
  • Above the crypto: the whole product. A /v1/ REST API (163 documented endpoints), a janus CLI whose flagship is run — inject secrets as env vars into a subprocess, with --watch restarting it on a version bump — and a Svelte 5 SPA embedded in the binary via go:embed, covering the entire API surface. Feature engines for transit encryption, scheduled rotation (6 rotators), one-way sync to 8 providers with drift detection, and dynamic Postgres credentials with a lease manager.
  • Identity grew to org scale. Argon2id passwords with TOTP, WebAuthn passkeys (including passwordless), OIDC, scoped service tokens with per-token IP allowlists, and multi-issuer workload federation — GitHub Actions, GitLab, Buildkite, CircleCI, and Kubernetes service accounts. Deny-by-default RBAC with IdP-driven groups, delegated project creation, break-glass elevation, and an access matrix answering “who can write prod?” and “what can this person reach?”

Result

From first commit (2026-07-02) to v0.3.0 (2026-07-28): 26 days, 1,250 commits, 52 SQL migrations, ~118K lines of Go across 704 files, all solo.

  • 100% statement coverage on the crypto package, enforced in CI from the first milestone to the last release — including explicit tamper, nonce-reuse, and secret-leak tests. A leak test asserts no plaintext ever reaches logs or error messages; it never stopped being true.
  • A five-perspective white-box audit of the finished system found 0 criticals. The single high was re-examined and shown non-exploitable (documented in code rather than “fixed” with unreachable paths); four mediums were fixed — TOTP replay, session rotation on password change, a break-glass privilege-persistence edge, and systemic SSRF closed with one shared hardened dialer applied to every operator-configured outbound call.
  • The audit log is a tamper-evident hash chain with an operational story — signed checkpoints so verified prefixes can be pruned without breaking verification, at-least-once JSONL shipping to webhook/syslog for SIEM ingestion, and project-scoped audit read so a team reviews its own trail without seeing every other team’s.
  • 376 Go test files, plus the tests that earn their keep: Playwright E2E over the destructive and security screens (verified to fail against pre-fix handlers), 8 fuzz targets asserting invariants — one of which found and hardened a latent fail-open in federation claim matching — and integration tests against real PostgreSQL.
  • Releases are supply-chain honest: cosign keyless signatures, syft SBOMs, SLSA build provenance for binaries and images, multi-arch GHCR containers, Dependabot across all 7 module directories, a published threat model that names its non-defenses, and a disclosure policy.
  • Operable by strangers: Prometheus metrics, a 24-panel Grafana dashboard whose every expression was evaluated against a live Prometheus before shipping, scheduled encrypted backups to S3-compatible storage with a restore-rehearsal command, native TLS/ACME, a Helm chart, and janus doctor — 19 preflight checks born from a real misconfiguration incident.

Client surface: Go, TypeScript, and Python SDKs with lease auto-renew, and a Terraform provider — projects, configs, secrets, tokens — with drift detection that is deliberately metadata-only, because the value-free API cannot compare plaintext and the provider doesn’t pretend it can.

What I’d do differently

Test that every enum variant survives the persistence path, the day it ships. The sync engine grew from 2 providers to 8 — but migration 11 had pinned the provider column with a CHECK (provider IN ('github','k8s')) constraint, and nothing widened it. Six providers shipped in code, fully tested at the engine layer, and could not be persisted at all. The same class of bug had already bitten the rotation table. It was found weeks later, by unrelated drift-detection work. The fix was one migration plus a store test that creates a target for all eight — the lesson is that the store test should have existed at provider number three, and that CREATE-time CHECK constraints are frozen enums your features will outgrow.

Make the verifier measure what the user experiences, not what the DOM reports. The mobile layout shipped broken — on a phone, the sidebar consumed 60% of the screen and the rest was clipped with no way to scroll — while the screenshot harness reported everything green, because it measured the document and the document was fine; the clipping happened inside a grid item. The rebuilt harness measures the content area, and the one-line root cause (min-width: 0 on a grid child) fixed ten screens whose overflow scrolling had silently never engaged. Both lessons are the same lesson: a green check that asserts the wrong thing is worse than no check, because it ends the search.