Discovery
Back to browse

Keeper - embeddable secret store for Go

Embeddable Go secret store using Argon2id and XChaCha20-Poly1305 by default, with four security levels, audit chains, and crash-safe rotation. Vault when Vault is overkill.

5 min readView source ↗

Keeper is what you reach for when HashiCorp Vault is overkill but os.Getenv is irresponsible. It's an embeddable Go secret store with serious cryptographic posture - Argon2id key derivation, XChaCha20-Poly1305 authenticated encryption, four security levels per bucket, tamper-evident audit chains, crash-safe rotation - all backed by an embedded bbolt database with no external service required.

It ships as three things you can use independently:

  • A Go library - embed the secret store directly in your process.
  • An HTTP handler (x/keephandler) - mount keeper endpoints on any net/http mux, with hooks, guards, and pluggable response encoders for access control and audit logging.
  • A CLI (cmd/keeper) - terminal interface with a persistent REPL session, no-echo secret entry, and zero shell-history exposure.

Designed as the secret-management foundation for the Agbero load balancer, but it has no Agbero dependency and works in any Go project.

Four security levels

Every bucket has an immutable BucketSecurityPolicy that governs how its Data Encryption Key (DEK) is protected. The four levels exist because real applications need different security postures for different secrets:

LevelPasswordOnly - DEK derived from the master key via HKDF-SHA256 with a domain-separated info string per bucket. Buckets unlock automatically when UnlockDatabase is called with the correct master passphrase. No per-bucket credential at runtime. Right for secrets the process needs at startup without human interaction.

LevelAdminWrapped - randomly generated 32-byte DEK unique to the bucket. For each authorised admin, a Key Encryption Key (KEK) is derived from HKDF(masterKey || adminCred, dekSalt) and used to wrap the DEK. The bucket is inaccessible until an admin calls UnlockBucket with their credential. The master passphrase alone cannot decrypt the bucket. Revoking one admin doesn't affect any other admin's wrapped copy.

LevelHSM - DEK generated at CreateBucket time and immediately wrapped by a caller-supplied HSMProvider. The provider performs wrap and unwrap; keeper never handles the raw DEK after handing it over. Master key rotation does not re-encrypt these buckets. A built-in SoftHSM is available for testing (do not use in production).

LevelRemote - identical key-management behaviour to LevelHSM, but the HSMProvider is implemented by pkg/remote.Provider, a configurable HTTPS adapter that delegates wrap/unwrap to a remote KMS over TLS. Pre-built configurations exist for HashiCorp Vault Transit, AWS KMS, and GCP Cloud KMS. Configure TLSClientCert and TLSClientKey for mutual TLS in production.

You can mix levels freely within the same scheme. vault://system might be LevelPasswordOnly (auto-unlocked at startup), while vault://admin is LevelAdminWrapped (requires an explicit credential). Schemes are URI prefixes you register; security levels are bucket properties set at creation and immutable thereafter.

The cryptographic decisions worth knowing

The README spells out its design choices explicitly. The non-obvious ones:

Argon2id master key derivation with t=3, m=64 MiB, p=4 produces a 32-byte master key. A verification hash is stored at first derivation and compared via crypto/subtle.ConstantTimeCompare on every subsequent unlock - a mismatch returns ErrInvalidPassphrase without leaking timing information.

The KDF salt is stored unencrypted by design. It must be readable before UnlockDatabase to derive the master key - encrypting it with a key derived from the master would be circular. A KDF salt isn't a secret; its purpose is uniqueness.

KEK derivation uses HKDF, not a second Argon2 pass. The master key was already produced by a high-cost KDF; a second Argon2 invocation would add hundreds of milliseconds of latency to every UnlockBucket call with no security benefit. HKDF-SHA256 operates in microseconds.

Defence-in-depth on LevelAdminWrapped: an attacker who compromises only the database obtains the wrapped DEK and HKDF salt but can't derive the KEK without the master key. An attacker who compromises only the master key can't unwrap any admin-wrapped DEK without the admin credential.

Metadata encryption. Secret metadata (creation time, update time, access count, version) is encrypted with a key derived from the bucket DEK via HKDF. For non-LevelPasswordOnly buckets, this means an attacker with read access to the DB file can't even learn access patterns or timestamps.

Timing side-channel awareness. XChaCha20-Poly1305 processes the full ciphertext before returning an auth error. The fallback decrypt path takes the same wall-clock time regardless of which key succeeds. No timing leak of migration state.

Two derived metadata keys. policyEncKey (HKDF info "keeper-policy-enc-v1") encrypts BucketSecurityPolicy values and the rotation WAL. auditEncKey (HKDF info "keeper-audit-enc-v1") encrypts the Scheme, Namespace, and Details fields of every audit event. Both keys are cleared from memory on Lock().

Cipher choice is configurable. Default is XChaCha20-Poly1305. AES-256-GCM is available for FIPS-required environments. The user's choice flows through to all metadata encryption automatically.

Why the audit chain matters

Tamper-evident audit chains are the part most homegrown secret stores skip. Keeper's chain means: any modification to the audit history is detectable cryptographically. You can ship the encrypted audit log to a SIEM, an attacker can't go back and erase their tracks without invalidating the chain, and the chain itself is encrypted at rest with auditEncKey.

Combined with the metadata-encryption story, this means an attacker with file-system access to the database still can't learn:

  • Which secrets exist in LevelAdminWrapped/LevelHSM/LevelRemote buckets.
  • When each secret was created, last accessed, or rotated.
  • Which admins authenticated and when.
  • The contents of any audit event detail field.

When to reach for it

  • Go services that need a hardened secret store but a Vault deployment is the wrong shape.
  • Edge / embedded / single-binary deployments where running a separate secret-management process isn't feasible.
  • Multi-tenant apps where you want per-tenant DEKs and admin-credential-required buckets.
  • CLIs that handle credentials and need a persistent REPL with no-echo entry and no shell-history exposure.

When not to

  • Non-Go projects. Keeper is a Go library.
  • Multi-process secret sharing with frequent writes from independent processes - bbolt is single-writer.
  • Workloads needing the rich AC/policy machinery of Vault. Keeper is a focused secret store, not a full PAM.

Trade-offs

The dependency story is intentionally tight: bbolt for storage, the standard crypto packages, msgpack for serialisation, memguard for the SoftHSM wrapping key. Everything else is keeper code.

bbolt is single-writer single-process by design. If you need multi-process access, that's a different tool.

The CLI's persistent REPL session is a real ergonomic win - secrets stay in process memory while you do work, instead of being prompted for on every operation. Combined with no-echo entry and no shell-history side effect, it's the cleanest CLI secret-handling experience in any Go project I've used.

The Argon2id parameters (t=3, m=64 MiB, p=4) are calibrated for "secure enough that brute force is impractical" without being so heavy that startup latency becomes painful. For your specific deployment you can tune them; the defaults are reasonable for most cases.

The SoftHSM is documented as test/CI only. The README is firm about not using it in production - take that seriously.

For Go infrastructure work where you need real cryptographic posture without the operational overhead of running Vault, this is the library to standardise on.

Recent discussion

From the wider web

Featured in

Related entries