janus-secrets

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 24, 2026 License: Apache-2.0

README

Janus

A single-tenant, self-hosted secrets manager, deployed as one Go binary plus PostgreSQL. It combines ideas from Doppler (project/environment/config model, run injection), Vault (transit encryption, dynamic secrets, hash-chained audit), and AWS KMS (encrypt-as-a-service with key versioning).

Status: Phases 1–3 complete. All three build phases have shipped and are tested against real Postgres:

  • Phase 1 — Core: the cryptographic core (envelope encryption + unseal), the storage layer (Postgres persistence, migrations, two-level versioning), the encryption-orchestration service, the runnable server (init/unseal over HTTP, docker-compose stack), authentication (email/password sessions + scoped service tokens), deny-by-default RBAC, the hash-chained audit log (tamper-evident, fail-closed, verify/export), the secret-facing REST API (project/env/config CRUD + lifecycle, secret masked-list/reveal/write/delete, config version list/diff/rollback), config inheritance + secret references, and the secrets CLI (janus login/setup/secrets/run).
  • Phase 2 — Transit + UI: the Vault-style transit (encryption-as-a-service) engine; the Svelte SPA (embedded via go:embed, served same-origin) — unseal/login, project/env/config nav, the flagship secret editor, version diff, audit viewer, token/member management, transit console, and the operations console; OIDC human login and OIDC-federated CI machine identity; and the usage-metrics ("Reads 24h") dashboard.
  • Phase 3 — Rotation + dynamic: scheduled static rotation (Postgres + webhook), sync integrations (GitHub Actions + Kubernetes), and dynamic Postgres credentials with a lease manager.

Janus is usable end-to-end: docker compose up, create a project in the UI or CLI, set secrets, and janus run injects them into your process. What remains is polish and further feature work — see docs/roadmap.md for the current state summary and forward-looking roadmap.

Docs: full documentation lives under docs/ (see the docs index). New here? Start with the task-oriented how-to guides: getting started, injecting secrets, managing secrets, service tokens, GitHub Actions, Docker, Kubernetes, and production deployment (TLS termination, configuration, unseal, sizing, backups, and upgrades). The subsystem references cover architecture, cryptography, the data model & versioning, references & inheritance, operations, the CLI reference, the transit engine, OIDC login, CI federation, the web UI, and operations for rotation, sync, dynamic secrets, and backup & restore.

Why

Self-hosted secrets management that you fully own: no SaaS, no multi-tenancy, no per-seat pricing. One binary, one Postgres, your keys never leave your server in plaintext.

Design

Envelope encryption

A three-level key hierarchy, so no single stored value can decrypt secrets on its own:

  1. Master key (root KEK) — 256-bit, exists only in server memory after unseal, never persisted in plaintext.
  2. Project KEKs — one per project, wrapped by the master key, stored wrapped.
  3. DEKs — one per secret version (AES-256-GCM), wrapped by the project KEK. Nonces are random and stored alongside 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.

Unseal

The server starts sealed: the master key is not in memory and all secret operations fail until an operator unseals it. Two mechanisms ship from day one, behind a common Unsealer interface:

  • Shamir — the master key is split k-of-n (default 3-of-5, configurable). Operators submit shares interactively until the threshold is met.
  • Cloud KMS auto-unseal — the master key is wrapped by a cloud KMS key (AWS KMS first) and recovered automatically at startup with a single decrypt.

A key check value (a known constant encrypted under the master key) lets unseal reject a wrong-but-well-formed master key before it is ever used.

The server exposes the seal lifecycle under /v1/sys/ (health, seal-status, init, unseal, unseal/reset, seal); every other route returns 503 {"error":{"code":"sealed"}} until unsealed. init and unseal are unauthenticated by bootstrap necessity (the Vault model); POST /v1/sys/seal re-seals a running server and now requires the sys:seal permission.

Identity & access

Two authentication methods ship today: email + password (Argon2id, opaque Postgres-backed sessions via an HTTP-only cookie) for humans, and scoped service tokens (janus_svc_…, shown once at creation, only their HMAC is stored) for machines. A single Principal{Kind,ID,Name} is the seam that authorization, audit, and Phase-2 federation build on. The initial admin is created during the init ceremony (one-time password shown once).

Authorization is deny-by-default RBAC. Four roles — viewer ⊂ developer ⊂ admin ⊂ owner — bind to a user at instance, project, or environment scope, with top-down inheritance (an instance binding applies everywhere; a project binding applies to that project's environments and configs) and a most-permissive union across a user's bindings. Service tokens get only least-privilege secret/config capabilities at their exact scope and can never perform management or instance actions. Two safety rails: a delegation constraint (you cannot grant a role above your own) and a never-lock-out guard (the last instance owner cannot be removed, demoted, or disabled). The engine is a pure decision function (internal/authz); handlers enforce it explicitly, so the storage and secrets layers stay identity-free.

Audit log

Every authenticated request that performs a sensitive action writes an append-only, tamper-evident event: actor, action, resource path, result, IP, and timestamp, plus the SHA-256 of the previous event (a hash chain). The hash is canonical — domain-tagged, length-prefixed fields, a presence byte so NULL and "" never collide — and an event has no value field by construction, so a secret value can never be recorded. Each append serializes on a Postgres transaction advisory lock, so the chain stays contiguous under concurrency. Recording is synchronous and fail-closed: if the audit write fails, the request fails — no mutation is left unaudited. Services stay audit-blind; only the API layer records (a one-line s.record(...) per handler), with denied requests captured centrally. GET /v1/audit/verify walks the chain and reports integrity (the future UI's "chain verified" badge); GET /v1/audit/export streams the log as JSONL or CSV, filterable by time/actor/action/result, with each row carrying prev_hash+hash for offline verification. The engine (internal/audit) is pure and HTTP-free.

Transit (encryption as a service)

The first Phase-2 subsystem is live: a Vault-style transit engine (internal/transit) that encrypts, decrypts, signs, verifies, rewraps, and mints data keys against instance-scoped named keys whose material never leaves the server in plaintext — Janus holds the keys, your app holds the ciphertext, and Janus never persists the data you pass through it. Two key types (aes256-gcm for encrypt/decrypt/rewrap/datakey, ed25519 for sign/verify), key versioning (latest_version, min_decryption_version, deletion_allowed) with rotate, trim, and rewrap-forward, and a janus:v<N>:<base64> envelope. Each version's material is master-key-wrapped under a name+version AAD, so a copied version row fails to unwrap. Routes under /v1/transit/* are RBAC-enforced by three instance-scoped actions (transit:read/use/manage) and a new transit service-token scope so an app can call transit without reaching secrets; management ops are audited (recording the key name, never material) while high-frequency data-plane ops are not. See docs/transit.md.

OIDC & CI federation

Beyond passwords and service tokens, Janus supports OIDC for humans (Authorization Code + PKCE + state + nonce against a generic provider, tested against GitHub and Google; the client secret is master-key-wrapped and login is CSRF-hardened with a browser-bound state cookie) and OIDC-federated machine identity for CI: a GitHub Actions workflow exchanges its OIDC JWT for a short-lived, scoped janus_svc_ token via POST /v1/auth/oidc/federate, gated by admin-authored structured-claim trust bindings (repository required, exactly-one match, TTL ≤ 1h) — no long-lived secret in the CI system. Both are admin-configured under /v1/sys/oidc*. See docs/oidc.md and docs/ci-federation.md.

Web UI

A Svelte 5 + TypeScript + Vite SPA — the "Atrium" design, a banknote-engraving / archival-ledger aesthetic with hand-written CSS tokens (no Tailwind, no component library) — is built to static assets and embedded in the janus binary via go:embed, served same-origin by the Go server (no Node in production). It covers in-browser Shamir unseal and login, the project → environment → config tree, the flagship secret editor (masked list with origin badges, audited per-key/bulk reveal, a client-side dirty buffer, and batched "Save as vN"), config version diff, the audit viewer with chain-verify badge and export, token/member management, the transit key console, a usage dashboard ("Reads 24h") with a first-run onboarding checklist (create project → add secrets → mint token → janus run, self-checking and dismissible), and an operations console over the three Phase-3 engines (rotation, sync, dynamic leases — manage and act, not create). The visual system is dual-theme (daylight default + nightwatch dark) via CSS-variable tokens. Revealed plaintext and unseal shares live only in component state, never persisted or cached. See docs/web.md.

Rotation, sync & dynamic secrets (Phase 3)

Three engines extend Janus past static storage:

  • Static rotation (internal/rotation) — scheduled, webhook-notified secret rotation with a crash-safe persist → apply → commit sequence; Postgres (ALTER ROLE) and generic-webhook (HMAC) rotators ship first.
  • Sync integrations (internal/secretsync) — outbound one-way replication of a config's resolved secrets to GitHub Actions secrets (NaCl sealed-box) and Kubernetes Secrets (server-side apply, verified TLS), with keyed-HMAC change detection and a project-scoped resolver that blocks cross-project exfiltration.
  • Dynamic Postgres credentials (internal/dynamic) — Vault-style config-scoped dynamic roles from admin-authored creation/revocation SQL templates, with a lease manager (TTL, monotonic renewal capped at max-TTL, revoke-on-expiry, and a revoke-on-startup sweep for leases orphaned by a crash). The issued password is returned exactly once and never persisted or audited.

Each engine has a /v1/{rotation,sync,dynamic} API, a janus CLI surface, and runs its scheduler in-process on a JANUS_*_TICK interval.

Quickstart (dev)

make dev-up     # build, docker compose up, init a 1-of-1 dev seal, unseal
bin/janus seal-status

The dev seal stores its single share in .dev/janus-share (gitignored) — that share IS the master key; this flow is for local development only. Production uses a real k-of-n split: janus init prints the shares exactly once, and operators unseal with janus unseal (share read from stdin with echo off).

Server configuration is env-only:

Variable Meaning
JANUS_DATABASE_URL Postgres DSN (required)
JANUS_LISTEN_ADDR listen address, default :8200
JANUS_SEAL_TYPE shamir or awskms; required before first init, stored type is authoritative after
JANUS_AWS_KMS_KEY_ARN KMS key for awskms (plus standard AWS SDK env)
JANUS_ADDR CLI default server address
Data model

Doppler-style hierarchy: Project → Environment → Config → Secrets, with two-level versioning (immutable config versions for diff/rollback, plus per-secret value history). The schema, migrations, and repositories are built and tested — see docs/data-model.md. The store is crypto-blind: it persists opaque ciphertext and never holds a key or plaintext. Configs can inherit from a base config in the same environment (child wins per key) and secret values can embed references${projects.<project>.<env>.<config>.KEY} or local ${KEY} — resolved at read time, transitively, with cycle detection and strict per-target authorization. See docs/references.md.

CLI

The same janus binary is the client. A typical developer flow:

janus login --address http://localhost:8200      # email + password → session
cd my-service && janus setup                       # writes .janus.yaml (project/env/config)
janus secrets set DATABASE_URL=postgres://…        # one new config version
janus run -- ./my-service                          # inject secrets as env vars
janus secrets download --format env --output .env --plain

Machine/CI: set JANUS_TOKEN=janus_svc_… (a scoped service token minted with POST /v1/tokens) instead of logging in — it is sent as a bearer token and takes precedence over any stored session. Full command reference, the credential/address/binding precedence rules, the .janus.yaml format, and the run / --plain semantics are in docs/cli.md.

Tech stack

  • Server / CLI: Go (stdlib-first, minimal dependencies).
  • Crypto: Go stdlib crypto/* and golang.org/x/crypto only, plus AWS KMS (used as a service, not a crypto library) and a vendored copy of HashiCorp Vault's Shamir implementation (MPL-2.0). No third-party crypto primitives.
  • Storage: PostgreSQL 16+ via pgx, migrations with golang-migrate.
  • HTTP: net/http with chi, REST + JSON under /v1/ (sys, auth, OIDC, token, user, membership, audit, metrics, transit, and the secret routes — projects/environments/configs, configs/{cid}/secrets masked-list/reveal/write/delete, and versions list/diff/rollback — plus the Phase-3 rotation/sync/dynamic engines — all live).
  • AuthN/Z: Argon2id passwords, HMAC-SHA256 token hashing, opaque sessions, OIDC (Auth Code + PKCE) human login and OIDC-federated CI identity, and a pure deny-by-default RBAC engine.
  • CLI: cobra (server/ops: janus server/init/unseal/seal-status/seal/ migrate; secrets: janus login/logout/setup/secrets/run; Phase-3: janus rotation/sync/dynamic).
  • Web UI: Svelte 5 (runes) + TypeScript + Vite, hand-written CSS design system (no Tailwind, no component library), built to static assets and embedded in the binary via go:embed (no Node in production).
  • Deployment: multi-stage Dockerfile (build web → embed → build Go) + docker-compose (app + Postgres).

Repository layout

cmd/janus/           single binary: server + operator CLI + secrets CLI      ← implemented
                     (login/setup/secrets/run) + rotation/sync/dynamic, all cobra
internal/crypto/     envelope encryption, key hierarchy, unseal              ← implemented
internal/crypto/shamir/  vendored HashiCorp Shamir (MPL-2.0)
internal/store/      Postgres repositories, migrations, versioning           ← implemented
internal/secrets/    encryption orchestration + secrets CRUD                 ← implemented
internal/resolve/    config inheritance + secret-reference resolution        ← implemented
internal/api/        HTTP server + all /v1 routes (sys/auth/oidc/token/user/  ← implemented
                     member/audit/metrics/secret/version/transit/rotation/
                     sync/dynamic)
internal/auth/       passwords, sessions, service tokens, OIDC + federation   ← implemented
internal/authz/      RBAC engine (roles, scopes, enforcement)                ← implemented
internal/audit/      hash-chained audit log                                  ← implemented
internal/transit/    transit engine (encrypt/decrypt/sign/verify, versioning) ← implemented
internal/rotation/   scheduled static rotation (Postgres + webhook)          ← implemented
internal/secretsync/ sync to GitHub Actions + Kubernetes Secrets             ← implemented
internal/dynamic/    dynamic Postgres credentials + lease manager            ← implemented
internal/web/        //go:embed dist + SPA handler (CSP, deep-link fallback) ← implemented
migrations/          SQL migrations (000001–000012)
web/                 Svelte SPA (Vite + TS, hand-written CSS)                ← implemented
docs/                subsystem docs, design specs, implementation plans

Building and testing

Requires a recent Go toolchain. Postgres (for later milestones) is provided via docker-compose.

go build ./...                 # build
go test ./...                  # run all tests
go test -race ./internal/crypto/   # crypto tests with the race detector

make test                      # go test -race ./...
make build                     # build the server binary
make dev-up                    # full stack: compose up + dev init/unseal
docker compose up -d           # start Postgres + the janus server (sealed)
make migrate                   # apply the schema explicitly (server also auto-migrates)

The internal/store, internal/secrets, and internal/api integration tests run against a real PostgreSQL via testcontainers and require Docker; without it they skip (they do not fail). The pure-logic packages internal/crypto, internal/authz, and internal/audit are held to 100% statement coverage, enforced in CI; crypto includes tamper, nonce-reuse, and secret-leak tests, authz an exhaustive role→action matrix test, and audit hash-determinism, tamper, chain-break, and genesis tests. CI also runs go vet, govulncheck, and gosec. The Go toolchain is pinned to go1.26.5 (via a toolchain directive) as a security floor.

Security notes

  • AES-256-GCM for all symmetric encryption; random nonces, never reused.
  • Constant-time comparison for key-check, token, and MAC checks; only token HMACs are stored (never raw tokens), and session/token verification requires an unsealed server.
  • Zero plaintext secrets or key material in logs or error messages — enforced by leak tests at the crypto, secrets, HTTP, and audit layers (the request logger is structurally body-free; an audit Event has no value field, so a secret can never enter the audit log).
  • Every ciphertext's AAD binds it to its exact storage slot (project, config/key path, value version), so relocated or swapped ciphertext fails closed.
  • Seal config lives in Postgres (the file-based store remains for tests). The server runs TLS-less behind your own network for now — terminate TLS at a reverse proxy; native TLS is a later milestone.
  • POST /v1/sys/seal requires the sys:seal permission; init and unseal are unauthenticated by bootstrap necessity, matching the Vault model.
  • RBAC is deny-by-default; denied requests return a generic 403 forbidden that never leaks role names, bindings, or query internals (enforced by a leak test).
  • The audit log is append-only and hash-chained; recording is fail-closed (an audit-write failure fails the request), and GET /v1/audit/verify detects any field mutation, deletion, or reorder of past events.

Roadmap

Phase 1 — Core (usable Doppler replacement): crypto + unseal ✅ → store + migrations + versioning ✅ → CRUD service + encryption orchestration ✅ → server bootstrap (sys API + janus CLI) ✅ → auth (passwords, service tokens) ✅ → RBAC (roles, scopes, enforcement) ✅ → audit log (hash-chained, tamper-evident) ✅ → REST API ✅ → CLI with run ✅ → config inheritance + secret references ✅. Phase 1 complete.

Phase 2 — Transit + UI: transit/KMS engine ✅ (sub-project A — see docs/transit.md); SPA ✅ (sub-project B — embedded same-origin SPA: unseal/login, project/env/config nav, the flagship secret editor with audited reveal + batched Save-as-vN, config version diff, audit viewer, token/member management, transit console, dashboard, and the operations console — see docs/web.md); OIDC login + CI federation ✅ (sub-project C — docs/oidc.md, docs/ci-federation.md); usage metrics ✅ (sub-project D — "Reads 24h"). Phase 2 complete.

Phase 3 — Rotation + dynamic: scheduled static rotation (Postgres + webhook) ✅; sync integrations (GitHub Actions, Kubernetes) ✅; dynamic Postgres credentials with a lease manager ✅. Phase 3 complete.

All three build phases have shipped. Remaining work is polish and further feature depth — see the live state & roadmap and the open-items tracker.

Non-goals

HA/Raft clustering, PKI/certificate authority, SSH signing, HSM/PKCS#11, multi-tenancy/organizations, and FIPS certification claims are explicitly out of scope.

License

Janus is licensed under the Apache License, Version 2.0 — see LICENSE and NOTICE.

The vendored internal/crypto/shamir/ package is licensed under MPL-2.0 (see its LICENSE); its per-file headers are retained. MPL-2.0 is file-level copyleft and compatible with Apache-2.0 distribution.

Directories

Path Synopsis
cmd
janus command
Command janus is the Janus server and its operator CLI.
Command janus is the Janus server and its operator CLI.
internal
api
Package api is Janus's HTTP surface: the chi router, the /v1/sys/* seal lifecycle endpoints, the RequireUnsealed middleware, and the project-wide JSON error envelope.
Package api is Janus's HTTP surface: the chi router, the /v1/sys/* seal lifecycle endpoints, the RequireUnsealed middleware, and the project-wide JSON error envelope.
auditship
Package auditship streams the (value-free) audit log to an external SIEM destination — a webhook or a syslog collector — as newline-delimited JSON.
Package auditship streams the (value-free) audit log to an external SIEM destination — a webhook or a syslog collector — as newline-delimited JSON.
auth
Package auth is Janus's identity layer: Argon2id passwords, Postgres-backed sessions, and janus_svc_ service tokens, all HMAC-hashed at rest with a master-key-wrapped key.
Package auth is Janus's identity layer: Argon2id passwords, Postgres-backed sessions, and janus_svc_ service tokens, all HMAC-hashed at rest with a master-key-wrapped key.
backupsched
Package backupsched is Janus's scheduled encrypted-backup engine: on a configured interval it runs the existing key-preserving instance dump and uploads the resulting sealed artifact to S3-compatible object storage, applies retention (keep N most recent, prune the rest), records each attempt in backup_runs (value-free), and offers a restore-rehearsal that verifies the latest (or a named) backup restores WITHOUT touching the live instance.
Package backupsched is Janus's scheduled encrypted-backup engine: on a configured interval it runs the existing key-preserving instance dump and uploads the resulting sealed artifact to S3-compatible object storage, applies retention (keep N most recent, prune the rest), records each attempt in backup_runs (value-free), and offers a restore-rehearsal that verifies the latest (or a named) backup restores WITHOUT touching the live instance.
crypto
Package crypto implements Janus's envelope encryption: AES-256-GCM primitives, key wrapping with AAD binding, the in-memory keyring, and the Shamir and AWS KMS unseal mechanisms.
Package crypto implements Janus's envelope encryption: AES-256-GCM primitives, key wrapping with AAD binding, the in-memory keyring, and the Shamir and AWS KMS unseal mechanisms.
dynamic
Package dynamic is Janus's dynamic Postgres credentials engine: on-demand, short-lived database roles issued from admin-authored SQL templates, with a lease manager that revokes them on expiry (and reclaims crash-orphaned leases after unseal).
Package dynamic is Janus's dynamic Postgres credentials engine: on-demand, short-lived database roles issued from admin-authored SQL templates, with a lease manager that revokes them on expiry (and reclaims crash-orphaned leases after unseal).
editreq
Package editreq implements the four-eyes approval flow for edits to a protected config (configs.require_approval = true).
Package editreq implements the four-eyes approval flow for edits to a protected config (configs.require_approval = true).
masterkeys
Package masterkeys orchestrates rotation of the root master key.
Package masterkeys orchestrates rotation of the root master key.
metrics
Package metrics is a tiny, dependency-free Prometheus text-exposition layer.
Package metrics is a tiny, dependency-free Prometheus text-exposition layer.
notification
Package notification delivers outbound alerts (generic webhook, Slack) by tailing the value-free audit log and fanning matching events out to configured channels through a crash-safe delivery outbox.
Package notification delivers outbound alerts (generic webhook, Slack) by tailing the value-free audit log and fanning matching events out to configured channels through a crash-safe delivery outbox.
projectkeys
Package projectkeys rotates a project's KEK and lazily re-wraps its DEKs; never decrypts a secret value; zeroes key material after use.
Package projectkeys rotates a project's KEK and lazily re-wraps its DEKs; never decrypts a secret value; zeroes key material after use.
promote
Package promote moves selected secrets forward along a project's release pipeline.
Package promote moves selected secrets forward along a project's release pipeline.
resolve
Package resolve composes config inheritance and read-time secret references over two ports (RawReader, Authorizer).
Package resolve composes config inheritance and read-time secret references over two ports (RawReader, Authorizer).
rotation
Package rotation is Janus's static-rotation engine: scheduled rotation of an existing secret's value via a Postgres single-role reset or a generic HMAC-signed webhook, with crash-safe apply and optional notify webhooks.
Package rotation is Janus's static-rotation engine: scheduled rotation of an existing secret's value via a Postgres single-role reset or a generic HMAC-signed webhook, with crash-safe apply and optional notify webhooks.
secrets
Package secrets orchestrates internal/crypto and internal/store: it is the only component that holds an unsealed key or sees plaintext, and only transiently within a call.
Package secrets orchestrates internal/crypto and internal/store: it is the only component that holds an unsealed key or sees plaintext, and only transiently within a call.
secretsync
Package secretsync is Janus's outbound sync engine: scheduled one-way replication of a config's resolved secrets to external stores (GitHub Actions secrets, Kubernetes Secrets).
Package secretsync is Janus's outbound sync engine: scheduled one-way replication of a config's resolved secrets to external stores (GitHub Actions secrets, Kubernetes Secrets).
store
Package store is Janus's crypto-blind PostgreSQL persistence layer.
Package store is Janus's crypto-blind PostgreSQL persistence layer.
version
Package version carries build metadata injected at release time via -ldflags "-X github.com/steveokay/janus-secrets/internal/version.Version=…".
Package version carries build metadata injected at release time via -ldflags "-X github.com/steveokay/janus-secrets/internal/version.Version=…".
web
Package web embeds the built React SPA and serves it same-origin from the Go binary.
Package web embeds the built React SPA and serves it same-origin from the Go binary.
Package migrations embeds the SQL migration files so they ship inside the binary.
Package migrations embeds the SQL migration files so they ship inside the binary.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL