caddycertkeeper

package module
v0.9.0 Latest Latest
Warning

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

Go to latest
Published: Nov 12, 2026 License: Apache-2.0 Imports: 34 Imported by: 0

README

caddycertkeeper

CI

S3 storage for Caddy and CertMagic. It keeps certificates, private keys and ACME accounts in an S3-compatible bucket, shares them between Caddy instances, and can encrypt them before they leave Caddy.

caddycertkeeper is a fork of techknowlogick/certmagic-s3 and a drop-in replacement for it: the same plugin, just better maintained. As of November 2026 it is the most actively maintained version of this plugin, with fixes that have not landed anywhere else.

xcaddy build --with github.com/AndrianBdn/caddycertkeeper

Switching from certmagic-s3

Change the build, not the config or the bucket:

- xcaddy build --with github.com/techknowlogick/certmagic-s3
+ xcaddy build --with github.com/AndrianBdn/caddycertkeeper
  • The Caddy module is still caddy.storage.s3, so existing Caddyfiles and JSON configs keep working. Build only one of the two plugins into a Caddy binary.
  • Every option keeps its name and meaning.
  • The bucket layout, the locks and the encryption format are unchanged, so each plugin reads what the other wrote. The exception is the new base64: key format, which certmagic-s3 does not know.

What you will notice after the switch:

  • Expired certificates get deleted. The first storage clean removes certificates that expired more than two weeks ago, with their private keys. certmagic-s3 never removed them.
  • A different ACME account may be picked. With no account email configured and several accounts in the bucket, CertMagic now uses the most recently used one, as it does on disk.
  • Config mistakes are errors. A Caddyfile option with no value, with too many values, or an unknown word on its own line fails with the line number. A bare insecure used to do nothing; write insecure true. A JSON config without bucket fails too.
  • host works in JSON. A JSON config with host and no endpoint now talks to that host. Before, host was ignored there and requests went to AWS.

What is fixed and added

Compared to certmagic-s3 v1.5.0:

  • Secrets stay out of the log. CertMagic logs its storage on every storage clean. With certmagic-s3 that line contains access_key, secret_key and encryption_key in plain text (certmagic-s3#28).
  • Folder listings work. certmagic-s3 answered every folder listing with all the files below it, so expired certificates were never cleaned up and ECH configs were regenerated on every reload (certmagic-s3#26). Listing, Stat, Exists and Delete now behave like CertMagic's own file storage.
  • Key rotation. encryption_key takes the current key followed by old ones, and objects are re-encrypted as they are read. See Rotating the key.
  • Stronger keys. A key can be 32 random bytes written as base64:..., not only 32 characters of text. See Generating a key.
  • Keys from a file or from AWS SSM Parameter Store, so the key is not in the config. See Key in a file and Key in SSM Parameter Store.
  • Config mistakes are reported, not silently skipped.
  • insecure only turns off certificate verification. It no longer drops the proxy settings and timeouts of the HTTP client.
  • Current dependencies (Caddy v2.11.9), a working lint config, and CI.

Configuration

Only bucket is required.

Option Meaning
bucket Bucket name.
region AWS region. Defaults to us-east-1.
prefix Prefix of every object key. Defaults to acme.
endpoint Endpoint URL of an S3-compatible provider. Turns on use_path_style.
use_path_style true for path-style URLs.
insecure true to skip TLS certificate verification.
access_key, secret_key Static credentials.
profile AWS shared config profile.
role_arn IAM role to assume.
encryption_key Encryption key, optionally followed by old keys. See Encryption.
encryption_key_file Files holding the encryption keys.
encryption_key_ssm SSM parameters holding the encryption keys.
host Deprecated, use endpoint. Giving both is an error.

Without access_key/secret_key or profile, the AWS SDK's default credential chain is used: environment variables, shared config, and the ECS task or EC2 instance role.

Without an encryption key option, objects are stored in plain text.

{
  storage s3 {
    bucket "my-certificates"
    region "eu-central-1"
    prefix "caddy-certs"
    encryption_key_ssm "/caddy/cert-encryption-key"
  }
}

An S3-compatible provider with static credentials:

{
  storage s3 {
    endpoint "https://minio.example.com"
    bucket "my-certificates"
    access_key "minioadmin"
    secret_key "minioadmin"
    encryption_key "base64:<output of openssl rand -base64 32>"
  }
}
JSON config

The options have the same names. Old keys go in old_encryption_keys, and encryption_key_file and encryption_key_ssm are lists.

Three defaults differ from the Caddyfile. They are the same as in certmagic-s3, so that existing JSON setups keep finding their objects:

  • An empty prefix means the root of the bucket, not acme.
  • A custom endpoint does not turn on use_path_style. Set it yourself if the provider needs it.
  • An empty region is taken from the environment or the shared AWS config, and is us-east-1 only when nothing names one.

Encryption

With a key, every object is sealed with NaCl secretbox (XSalsa20-Poly1305) under a fresh random nonce before it leaves Caddy.

The key comes from one of encryption_key, encryption_key_file or encryption_key_ssm; they cannot be combined. Each takes the current key first, optionally followed by old keys. Keys are read when Caddy starts or reloads its config.

Generating a key

Generate the key, do not invent it:

echo "base64:$(openssl rand -base64 32)"

The output is a key of 32 random bytes. Use it as the value of encryption_key, as the content of a key file, or as the value of an SSM parameter. Both base64 alphabets are accepted, with or without padding, and the value needs no quoting in a Caddyfile.

32-character keys from certmagic-s3

A certmagic-s3 key is exactly 32 characters, and those characters are the key as they are. They are not decoded from hex or base64, and not stretched from a passphrase. Such keys keep working here, but a key is only as strong as it is random:

How the key was made Randomness
base64: with openssl rand -base64 32 256 bits
32 characters from openssl rand -base64 24 192 bits
32 characters from openssl rand -hex 16 128 bits
32 characters someone made up far too little

The generated ones are all beyond guessing. A made-up phrase is not: anyone who obtains the bucket's contents can try phrases offline. If your key is one of those, rotate to a generated one.

certmagic-s3 cannot read base64: keys. Once objects are under one, only caddycertkeeper can read the bucket.

Rotating the key

Put the new key first and keep the old one after it:

encryption_key "base64:<new key>" "<old key>"

The first key encrypts everything that is written, and every key is tried when reading. An object that is read and turns out to be under an old key is stored again under the current one, and each rewrite is logged:

{"level":"info","logger":"caddy.storage.s3","msg":"re-encrypted object under the current encryption key","key":"caddy-certs/acme/.../admin.key"}

The rewrite is a conditional write (If-Match on the version that was read). It needs no lock and never replaces what another instance stored in the meantime.

Instances that share a bucket need two rollouts, because an instance that only has the old key cannot read what another one wrote under the new key:

  1. Roll out <old> <new> to every instance. All of them read both keys and still write under the old one.
  2. Roll out <new> <old> to every instance. All of them write under the new key.

Until step 2 has reached every instance, the ones still on step 1 may rewrite objects back under the old key. That settles once all are on step 2.

Remove the old key when rewrites have stopped appearing in the log. Objects Caddy never reads, such as the certificate of a site that is no longer configured, stay under the old key until they are cleaned up as expired.

Key in a file
encryption_key_file "/run/secrets/caddy-cert-key"
  • The file holds one key, written as for encryption_key. A line ending at the end of the file is ignored.
  • A file of exactly 32 bytes is used as the key itself, so the raw output of openssl rand -out <file> 32 works as well.
  • Only the user Caddy runs as should be able to read the file (chmod 600).
Key in SSM Parameter Store
encryption_key_ssm "/caddy/cert-encryption-key"

Each parameter holds one key, written as for encryption_key, preferably as a SecureString:

aws ssm put-parameter --type SecureString --name /caddy/cert-encryption-key \
  --value "base64:$(openssl rand -base64 32)"
  • Parameters are named by name or ARN, and read with the region and credentials configured for the bucket. A parameter given by ARN is read from the region in its ARN.
  • A version or label works too, which makes rotation a matter of writing a new version: encryption_key_ssm "/caddy/cert-encryption-key:2" "/caddy/cert-encryption-key:1".
  • The identity Caddy runs as needs ssm:GetParameter on the parameters, and kms:Decrypt if the SecureString uses a customer managed KMS key.
  • Keys are only read from AWS itself, over verified TLS. endpoint, insecure and endpoint overrides such as AWS_ENDPOINT_URL do not apply to SSM.
  • With keys in SSM, insecure covers the bucket connection only, so the credentials that can read the keys are fetched over verified connections too.

S3-compatible services

The service must support:

  • v4 signatures
  • GetObject, PutObject, DeleteObject, HeadObject and ListObjectsV2
  • conditional writes on PutObject (If-None-Match: * and If-Match), which the cross-instance lock and key rotation rely on

Development

make test       # go test ./...
make test-race  # with the race detector
make lint       # golangci-lint v2
make fmt        # gofumpt

CI runs the tests with the race detector on the two current Go releases, and the linter. There is nothing to build or release: the plugin is compiled into Caddy by xcaddy.

Credits & thanks

caddycertkeeper stands on other people's work, and it would not exist without them:

  • @techknowlogick, who maintained certmagic-s3 for years, moved it to the AWS SDK and kept it working across many Caddy and CertMagic releases. This fork starts from certmagic-s3 at commit 6c26852 (October 2026), with all of that history behind it.
  • @thomersch, who wrote the original Certmagic Storage Backend for Generic S3 Providers, including the client-side encryption this plugin still uses.
  • Everyone who reported bugs and sent fixes to both projects, among them the people behind the storage-clean fix and the rewrite of the cross-instance lock.

Thank you. The fork exists only to ship fixes faster, not because anything was wrong with the work it builds on.

License

Apache 2.0, the same as the projects it is forked from. The changes made in this fork are listed under What is fixed and added.

Documentation

Index

Constants

View Source
const (
	// NonceSize is the size of the nonce every encrypted object starts with.
	NonceSize = 24
)

Variables

View Source
var (
	// LockExpiration is how long a lock may go without being refreshed
	// before other instances treat its holder as gone and take it over.
	LockExpiration = 2 * time.Minute
	// LockRefreshInterval is how often a held lock is rewritten to show
	// its holder is still alive. It must be well below LockExpiration.
	LockRefreshInterval = 5 * time.Second
	// LockPollInterval is how often a waiting instance checks the lock.
	LockPollInterval = 1 * time.Second
	// LockTimeout bounds how long Lock keeps retrying storage errors. It
	// does not bound waiting for a live lock: Lock waits for that until the
	// lock is released, goes stale, or ctx is done.
	LockTimeout = 15 * time.Second
)
View Source
var ErrInvalidKey = errors.New("invalid key")

Functions

This section is empty.

Types

type CleartextIO

type CleartextIO struct{}

CleartextIO stores objects as they are.

func (CleartextIO) Open

func (CleartextIO) Open(stored []byte) ([]byte, bool, error)

func (CleartextIO) Seal

func (CleartextIO) Seal(plain []byte) []byte

type IO

type IO interface {
	// Seal returns what is stored for plain.
	Seal(plain []byte) []byte
	// Open returns the data that stored was made from, and whether it took
	// an old key to get it.
	Open(stored []byte) (plain []byte, underOldKey bool, err error)
}

IO turns the data of an object into what is stored for it, and back.

type S3

type S3 struct {
	Logger *zap.Logger

	// S3
	Client       *s3sdk.Client
	Host         string `json:"host"`
	Endpoint     string `json:"endpoint"`
	Insecure     bool   `json:"insecure"`
	Bucket       string `json:"bucket"`
	Region       string `json:"region"`
	AccessKey    string `json:"access_key"`
	SecretKey    string `json:"secret_key"`
	Profile      string `json:"profile"`
	RoleARN      string `json:"role_arn"`
	Prefix       string `json:"prefix"`
	UsePathStyle bool   `json:"use_path_style,omitempty"`

	// EncryptionKey is optional. If you do not wish to encrypt your certificates and keys inside the S3 bucket, leave it empty.
	// It encrypts everything written and is tried first when reading. It is written either as "base64:"
	// followed by the base64 of the 32 bytes of the key, or as exactly 32 bytes of text, which are then the
	// key as they are. The key is used directly: nothing stretches a weak one.
	EncryptionKey string `json:"encryption_key"`

	// OldEncryptionKeys are former values of EncryptionKey. They are never used to encrypt; when EncryptionKey
	// does not decrypt an object, each of them is tried in order. This makes changing EncryptionKey a rolling
	// change: instances that have the new key keep reading what was written under the old ones.
	OldEncryptionKeys []string `json:"old_encryption_keys,omitempty"`

	// EncryptionKeySSM reads the encryption keys from AWS Systems Manager Parameter Store instead of the
	// config: each entry is the name or ARN of a parameter (a SecureString, ideally) whose value is one
	// key, written as in EncryptionKey. The first parameter's key encrypts; the others are old keys, tried in order when reading.
	// The parameters are read once, when the storage is provisioned, with the region and credentials the
	// S3 client uses. It cannot be combined with EncryptionKey.
	EncryptionKeySSM []string `json:"encryption_key_ssm,omitempty"`

	// EncryptionKeyFile reads the encryption keys from files instead of the config: each entry is the path
	// of a file holding one key: its 32 raw bytes, or the key as written in EncryptionKey. The first
	// file's key encrypts; the others are old keys, tried in order when reading. The files are read once,
	// when the storage is provisioned. It cannot be combined with EncryptionKey or EncryptionKeySSM.
	EncryptionKeyFile []string `json:"encryption_key_file,omitempty"`
	// contains filtered or unexported fields
}

func (*S3) CaddyModule

func (s3 *S3) CaddyModule() caddy.ModuleInfo

func (*S3) CertMagicStorage

func (s3 *S3) CertMagicStorage() (certmagic.Storage, error)

CertMagicStorage converts s to a certmagic.Storage instance.

func (*S3) Delete

func (s3 *S3) Delete(ctx context.Context, key string) error

Delete removes key and, as certmagic requires of a storage, every key under it: a key can be a "directory" of other keys, such as a site's folder of certificate assets or an ECH config's folder.

func (*S3) Exists

func (s3 *S3) Exists(ctx context.Context, key string) bool

Exists reports whether key is an object or a "directory" of other keys.

func (*S3) GoString

func (s3 *S3) GoString() string

GoString does the same for %#v.

func (*S3) List

func (s3 *S3) List(ctx context.Context, path string, recursive bool) ([]string, error)

List returns the keys under path: all of them when recursive, otherwise only the ones directly under it, where a "directory" of further keys is listed once, as a key itself. certmagic and Caddy walk the storage level by level with non-recursive listings.

func (*S3) Load

func (s3 *S3) Load(ctx context.Context, key string) ([]byte, error)

func (*S3) Lock

func (s3 *S3) Lock(ctx context.Context, key string) error

Lock acquires the lock for key. It blocks until the lock is free or stale, or ctx is done, and gives up if storage keeps failing for longer than LockTimeout.

func (*S3) MarshalLogObject

func (s3 *S3) MarshalLogObject(enc zapcore.ObjectEncoder) error

MarshalLogObject is what a logger writes for this storage. certmagic logs its storage whole (zap.Any("storage", storage)) on every storage clean; without this method zap falls back to reflection and writes every field, credentials and encryption keys included.

func (*S3) Provision

func (s3 *S3) Provision(ctx caddy.Context) error

func (*S3) Stat

func (s3 *S3) Stat(ctx context.Context, key string) (certmagic.KeyInfo, error)

Stat describes key: an object is a terminal key, and a key that other keys lie under is a "directory".

func (*S3) Store

func (s3 *S3) Store(ctx context.Context, key string, value []byte) error

func (*S3) String

func (s3 *S3) String() string

String keeps credentials and encryption keys out of anything that formats the storage with %v, %+v or %s.

func (*S3) Unlock

func (s3 *S3) Unlock(ctx context.Context, key string) error

Unlock releases the lock for key if this instance still holds it. A lock another instance has since taken over is left in place.

func (*S3) UnmarshalCaddyfile

func (s3 *S3) UnmarshalCaddyfile(d *caddyfile.Dispenser) error

UnmarshalCaddyfile reads the storage block:

s3 {
    <option> <value>
    ...
}

type SecretBoxIO

type SecretBoxIO struct {
	// contains filtered or unexported fields
}

SecretBoxIO stores an object as a random nonce followed by the object sealed with NaCl secretbox under that nonce.

func NewSecretBoxIO

func NewSecretBoxIO(current [keySize]byte, old ...[keySize]byte) *SecretBoxIO

func (*SecretBoxIO) Open

func (sb *SecretBoxIO) Open(stored []byte) ([]byte, bool, error)

func (*SecretBoxIO) Seal

func (sb *SecretBoxIO) Seal(plain []byte) []byte

Jump to

Keyboard shortcuts

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