case · 04
Janus — a self-hosted secrets manager
Doppler's ergonomics, Vault's crypto, your server. One Go binary, one Postgres, keys that never leave in plaintext.
- 2026
- 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
- No single stored value can decrypt a secret. A leaked database, on its own, must be useless.
- 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.
- The store is crypto-blind. It persists opaque ciphertext and never holds a key or a plaintext. Encryption lives entirely above it.
- Standard-library crypto only. Go’s
crypto/*andx/crypto, plus AWS KMS used as a service and a vendored copy of HashiCorp’s Shamir implementation. No third-party crypto primitives to trust. - 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
project KEK
DEK
secret ciphertext
unseal · one of
shamir
cloud KMS
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
- 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
Unsealerinterface — 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 CI —
go vet,govulncheck, andgosecall clean; the Go toolchain is pinned (toolchain go1.26.4) as an explicit security floor to clear two stdlibcrypto/x509advisories.
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.