steve okoth / ~/portfolio

← all work

case · 04

Janus — a self-hosted secrets manager

janus · single-tenant secrets manager

Solo build · in progress · crypto + store layers merged

Doppler's ergonomics, Vault's crypto, your server. One Go binary, one Postgres, keys that never leave in plaintext.

  • AES-256-GCM
  • AWS KMS
  • Go
  • PostgreSQL
  • Shamir SSS
year
2026
outcome
3-level envelope encryption · Shamir 3-of-5 + KMS auto-unseal · 100% crypto coverage

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.

Honest status. This is an in-progress build. The cryptographic core and the storage layer are complete, tested, and merged; the encryption-orchestration service, API, and CLI are not built yet. The numbers below are the ones I can stand behind today — nothing is stubbed to look finished.

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 AWS KMS used as a service and a vendored copy of HashiCorp’s Shamir implementation. No third-party crypto primitives to trust.
  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.

Architecture

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 · auto-unseal

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 a cloud KMS key (AWS first) 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. The schema, migrations, and typed repositories run against real PostgreSQL, and the store never sees a key or a plaintext.

Result

What’s real and verifiable today — the cryptographic core (Milestone 1) and the storage layer (Milestone 2) are implemented, reviewed, and merged:

  • 100% statement coverage on the crypto package, enforced in CI, including explicit tamper, nonce-reuse, and secret-leak tests. A leak test asserts no plaintext ever reaches logs or error messages.
  • AAD-bound envelope encryption across the full master → project → DEK hierarchy, with the key-check-value gate on unseal.
  • Both unseal paths shipped from day one behind a common Unsealer interface — Shamir k-of-n and AWS KMS auto-unseal.
  • A crypto-blind store tested against real PostgreSQL via testcontainers (skips cleanly without Docker), with two-level versioning, soft-delete, and a concurrency test proving contiguous versions under FOR UPDATE.
  • Security gates green in CIgo vet, govulncheck, and gosec all clean; the Go toolchain is pinned (toolchain go1.26.4) as an explicit security floor to clear two stdlib crypto/x509 advisories.

Not built yet — and labeled as such: the CRUD / encryption-orchestration service, auth (passwords, service tokens, OIDC), the RBAC engine, the hash-chained audit log, the REST API, and the run-injecting CLI. The foundation persists versioned, pre-encrypted data; it is not yet an end-to-end secrets manager.

What I’d do differently

Cut a thin vertical slice before going deep on the crypto. I built the cryptographic core to 100% coverage and the store layer to a real-Postgres test bar before wiring a single end-to-end path — which is defensible for a security product, where the crypto is the part you cannot get wrong later. But it means there’s still nothing to click, and the Project → Config → Secret data model hasn’t yet met real usage. Next time I’d carve one honest slice — set a secret, seal, unseal, read it back — early, then harden outward from a design I’d already pressure-tested, rather than hardening the base of a system whose top I hadn’t exercised.

Make the seal-config store crash-durable sooner. The bootstrap seal-config store writes atomically but isn’t yet crash-durable; a Postgres-backed store is scheduled to land with the storage milestone. It’s a known, tracked gap — but a secrets manager is exactly the wrong place to carry “durable enough for now” on a security-relevant path longer than you have to.