
Simple Secure Keeper for Secrets
Keeper is a cryptographic secret store for Go. It encrypts arbitrary byte payloads at rest using Argon2id key derivation and XChaCha20-Poly1305 (default) authenticated encryption, and stores them in an embedded bbolt database.
It ships as three things you can use independently:
x/keephandler) — mount keeper endpoints on any
net/http mux in one call, with pluggable hooks, guards, and response
encoders for access control and audit logging.cmd/keeper) — a terminal interface with a persistent REPL
session, no-echo secret entry, and zero shell-history exposure.Keeper was designed as the foundational secret management layer for the Agbero load balancer but has no dependency on Agbero and works in any Go project.
Keeper partitions secrets into buckets. Every bucket has an immutable
BucketSecurityPolicy that governs how its Data Encryption Key (DEK) is
protected. Four levels are available.
A scheme is a URI prefix that groups related buckets (vault://, certs://,
space://, or any name you register). A security level is a property of the
bucket policy set at creation and immutable thereafter.
You can mix security levels freely within the same scheme. For example,
vault://system might be LevelPasswordOnly (auto-unlocked at startup), while
vault://admin is LevelAdminWrapped (requires explicit credential).
The bucket DEK is derived from the master key using HKDF-SHA256 with a
domain-separated info string per bucket (keeper-bucket-dek-v1:scheme:namespace).
All LevelPasswordOnly buckets are unlocked automatically when
UnlockDatabase is called with the correct master passphrase. No per-bucket
credential is required at runtime. This level is appropriate for secrets the
process needs at startup without human interaction.
The bucket has a randomly generated 32-byte DEK unique to that bucket. The DEK
is never stored in plaintext. For each authorised admin a Key Encryption Key
(KEK) is derived from HKDF(masterKey‖adminCred, dekSalt) and used to wrap
the DEK via XChaCha20-Poly1305. The bucket is inaccessible until an admin calls
UnlockBucket with their credential. The master passphrase alone cannot
decrypt the bucket. Revoking one admin does not affect any other admin's wrapped
copy.
The bucket DEK is generated at CreateBucket time and immediately wrapped by
a caller-supplied HSMProvider. The provider performs the wrap and unwrap
operations — keeper never handles the raw DEK after handing it to the provider.
UnlockDatabase automatically calls the provider to unwrap and seed the
Envelope for all registered HSM buckets. Master key rotation does not
re-encrypt these buckets; the DEK is provider-controlled.
A built-in SoftHSM implementation backed by a memguard-protected wrapping
key is available in pkg/hsm for testing and CI environments. Do not use it
in production.
Identical to LevelHSM in key management behaviour, but the HSMProvider is
implemented by pkg/remote.Provider — a configurable HTTPS adapter that
delegates wrap and unwrap to any remote KMS service over TLS. Pre-built
configurations for HashiCorp Vault Transit, AWS KMS, and GCP Cloud KMS are
provided in pkg/remote. For production use, configure TLSClientCert and
TLSClientKey to enable mutual TLS authentication.
salt ← random 32 bytes, generated once, stored as a versioned SaltStore (unencrypted)
masterKey ← Argon2id(passphrase, salt, t=3, m=64 MiB, p=4) → 32 bytes
A verification hash is stored on first derivation:
verifyHash ← Argon2id(masterKey, "verification", t=1, m=64 MiB, p=4) → 32 bytes
Subsequent DeriveMaster calls recompute this hash and compare it with
crypto/subtle.ConstantTimeCompare. A mismatch returns ErrInvalidPassphrase.
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 is not a secret; its purpose is
uniqueness, not confidentiality.
Each plaintext value is encrypted with XChaCha20-Poly1305 using the bucket DEK:
nonce ← random 24 bytes
ciphertext ← XChaCha20-Poly1305.Seal(nonce, DEK, plaintext)
The stored record is a msgpack-encoded Secret struct containing the
ciphertext, encrypted metadata, and schema version. Authentication is implicit:
a ciphertext decrypted with the wrong key produces an AEAD authentication
failure before any plaintext is returned.
salt ← random 32 bytes, generated at bucket creation, stored in policy
ikm ← masterKey ‖ adminCredential
KEK ← HKDF-SHA256(ikm, salt, info="keeper-kek-v1") → 32 bytes
wrappedDEK ← XChaCha20-Poly1305.Seal(nonce, KEK, DEK)
The KEK is derived using HKDF rather than 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 approximately one microsecond.
Defense-in-depth: An attacker who compromises only the database obtains the
wrapped DEK and the HKDF salt but cannot derive the KEK without the master key.
An attacker who compromises only the master key cannot unwrap any
LevelAdminWrapped DEK without also knowing the admin credential.
Secret metadata (creation time, update time, access count, version) is encrypted separately from the ciphertext:
metaKey ← HKDF-SHA256(bucketDEK, nil, info="keeper-metadata-v1") → 32 bytes
encryptedMeta ← XChaCha20-Poly1305.Seal(nonce, metaKey, msgpack(metadata))
For LevelAdminWrapped, LevelHSM, and LevelRemote buckets this means
metadata is inaccessible without the bucket credential, preventing an attacker
with read access to the database file from learning access patterns or
timestamps.
Timing side-channel note: XChaCha20-Poly1305 processes the full ciphertext before returning an authentication error. The fallback decrypt path (new derived DEK → old master-key-as-DEK) takes the same wall-clock time regardless of which key succeeds. No timing side-channel leaks the migration state of a record.
All structural metadata is also encrypted at rest. Two keys are derived from
the master key at UnlockDatabase time:
policyEncKey ← HKDF-SHA256(masterKey, nil, info="keeper-policy-enc-v1") → 32 bytes
auditEncKey ← HKDF-SHA256(masterKey, nil, info="keeper-audit-enc-v1") → 32 bytes
policyEncKey encrypts: BucketSecurityPolicy values and the rotation WAL.
auditEncKey encrypts: the Scheme, Namespace, and Details fields of
every audit event.
Both keys are cleared from memory at Lock(). The cipher used for metadata
encryption is the same configurable crypt.Cipher interface used for secrets —
the user's cipher choice (AES-256-GCM for FIPS, XChaCha20-Poly1305 by default)
flows through automatically.
Wire format for all encrypted metadata blobs: