AGENTS/plans/2_20/encryption-key-rotation.md
Status: phase 1 + phase 2 implemented. Keys live only in a separate file (
encryption.keys_file) — no inlineencryption_keyssection in the main config; the option key's legacy flat field isoption_encryption; rotation applies without restarting (atomic keyset + SIGHUP + a file watcher).Phase 2 (built): a content-addressed key-ID keyset replaced the
primary/secondary+ trial-decryption model. The keys file is a registry (inlinekeys:map and/or akeys_folder:of key files) plusactive:pointers per purpose; the key id is stamped into the ciphertext (<id>:<ct>) so decryption is a direct lookup, not a trial; the id is derived from the key material (a fingerprint,util/keyid.go), not operator-chosen. Un-prefixed (pre-feature) ciphertext still decrypts via the legacy fallback. The "as built" sections below describe phase 1's keyring; the "Target design" section is now the shipped phase-2 model.
Semaphore encrypts the most sensitive data it holds with a single
AES-256-GCM key, AccessKeyEncryption, which did double duty:
services/server/access_key_serializer_local.go.jwt_signing_key) —
util/jwt.go.Three weaknesses motivated the work:
SEMAPHORE_ACCESS_KEY_ENCRYPTION environment variable — reachable via
/proc/<pid>/environ, child processes, docker inspect, crash dumps, logs.vault rekey --old-key <old> — offline, all-or-nothing,
old key passed by hand, no window where both keys are valid.The implementation:
encryption.keys_file),
the Docker/K8s secret-mount pattern, kept out of the main config.access_key (DB Access Key secrets) and
option_key (DB options / the JWT signing key), each independently rotatable.access_key_encryption, option_encryption) for backward compatibility.The design mirrors how GitLab solved the same problem (see Background).
/etc/gitlab/gitlab-secrets.json (Omnibus), config/secrets.yml
(source), or a dedicated *-rails-secret Kubernetes Secret (Helm) — never in
gitlab.rb/values.yaml. Our encryption.keys_file is the same idea, with
per-key file: references so each key can be its own mount.previous_versions model and is exactly Keyring.Primary +
Keyring.Secondary.db_key_base,
otp_key_base, secret_key_base, …) so a leak/rotation of one does not touch
the others. Our access_key vs option_key split is the same principle.gitlab:two_factor:rotate_key)
→ our vault rekey with --backup/--rollback.gitlab:doctor:secrets) → our vault check.encryption section// util/config.go — ConfigType
AccessKeyEncryption string `json:"access_key_encryption,omitempty" env:"SEMAPHORE_ACCESS_KEY_ENCRYPTION,sensitive"`
OptionEncryption string `json:"option_encryption,omitempty" env:"SEMAPHORE_OPTION_ENCRYPTION,sensitive"`
Encryption *EncryptionConfig `json:"encryption,omitempty"`
keys *keyringStore // unexported runtime state (atomic, hot-swappable)
type EncryptionConfig struct {
KeysFile string `json:"keys_file,omitempty" env:"SEMAPHORE_ENCRYPTION_KEYS_FILE"`
KeysPollInterval string `json:"keys_poll_interval,omitempty" env:"SEMAPHORE_ENCRYPTION_KEYS_POLL_INTERVAL" default:"15s"`
}
access_key_encryption — legacy single access key (no rotation). The
original field, kept for backward compatibility.option_encryption — legacy single option key (no rotation): the old
single-key scheme for DB options (the JWT signing key), the option-key
counterpart of access_key_encryption. When unset, options fall back to the
access key.encryption.keys_file — path to the only file that carries the keyrings
(with rotation). Watched for changes; edits apply without a restart.encryption.keys_poll_interval — how often keys_file is polled (Go duration,
default 15s); 0 disables polling (SIGHUP still reloads). Accessed via
Config.EncryptionKeysFile() / Config.EncryptionKeysPollInterval().(No separate keys_poll_enabled flag: polling is off automatically when no
keys_file is set, and keys_poll_interval: 0 disables it explicitly while
keeping SIGHUP.)
EncryptionKeysConfig (YAML or JSON)The whole content of encryption.keys_file is an EncryptionKeysConfig. It is
parsed via YAML (a superset of JSON), so both formats work regardless of file
extension — important for Kubernetes secret mounts, whose path usually has no
.yaml/.yml extension (readEncryptionKeysConfigFile).
type KeySource struct { // a single key: inline OR from a file (mutually exclusive)
Value string `json:"value,omitempty"`
File string `json:"file,omitempty"`
}
type Keyring struct { // active key + retired keys (decryption-only) → rotation
Primary KeySource `json:"primary,omitempty"`
Secondary []KeySource `json:"secondary,omitempty"`
}
type EncryptionKeysConfig struct {
AccessKey *Keyring `json:"access_key,omitempty"` // DB Access Key secrets
OptionKey *Keyring `json:"option_key,omitempty"` // DB options (jwt_signing_key)
}
Example encryption_keys.yaml:
access_key:
primary: { file: /run/secrets/access_key }
secondary:
- { file: /run/secrets/access_key_old } # decryption-only, during rotation
option_key:
primary: { file: /run/secrets/option_key }
KeySource carries no env tag (a shared type cannot bind two different env
vars via reflection); the env entry points are the flat fields. File is a path
and is not ,sensitive (redaction is about values).
Per keyring, highest wins (structured wins, flat is a fallback — no "mismatch = error", because that would block hot rotation):
encryption.keys_file → access_key.primary →
else access_key_encryption flat.encryption.keys_file → option_key.primary →
else option_encryption flat → else fall back to the access keyring.resolveKeyring(structured, flat, name) and
resolveEncryptionKeysFrom(enc, flatAccess, flatOption) implement this; the
option keyring is left nil when it has no key material so optionRing() falls
back to the access keyring.
Runtime keyrings hang off the existing Config struct behind atomic pointers so
they can be replaced during rotation without locking the hot path:
// util/keyring.go
type runtimeKeyring struct { primary string; secondary []string } // immutable once built
type keyringStore struct {
access atomic.Pointer[runtimeKeyring]
option atomic.Pointer[runtimeKeyring]
reloadMu sync.Mutex // serializes reloads; never touches the read path
}
ConfigType methods (all read via lock-free Load()):
EncryptAccessSecret(plaintext) / AccessSecretDecryptKeys() (primary +
secondaries, for the serializer's decrypt loop) / AccessSecretPrimaryKey().EncryptOption(plaintext) / DecryptOption(ciphertext) / OptionDecryptKeys()
(option candidates then access candidates as a migration fallback, deduped)
/ OptionPrimaryKey() / OptionOwnDecryptKeys().OptionSlot(ciphertext) — diagnostics for vault check
(option:primary, option:secondary[i], access-fallback (migrate), or
primary/secondary[i] when no separate option key, or FAILED).Decryption tries the primary, then each secondary; a GCM auth-tag failure means
"wrong key, try next". util/encryption.go primitives are unchanged (empty key
= base64 passthrough = "encryption disabled").
services/server/access_key_serializer_local.go → access keyring:
SerializeSecret → EncryptAccessSecret; DeserializeSecret →
deserializeSecretWithKeys(AccessSecretDecryptKeys()). DeserializeSecret2(key, singleKey) is retained for the rekey --old-key path and tests.util/jwt.go → option keyring: encryptJWTKey → EncryptOption;
decryptJWTKey → DecryptOption (so a JWT key written under the old access
key still loads).Keys are read once at boot, then re-read on demand and swapped atomically:
resolveEncryptionKeys() (startup, in ConfigInit before validateConfig) —
reads encryption.keys_file (or nil → flat fields), validates, stores. Invalid
keys panic (fail fast at boot).ReloadEncryptionKeys() — force reload (used by SIGHUP). Re-reads the file,
validates, atomically swaps. Leaves the active keyrings untouched on any
error.ReloadEncryptionKeysIfChanged() (bool, error) — same, but swaps only when the
resolved keys actually differ (compare via keyringsEqual). Used by the watcher
so identical re-reads are no-ops.loadEncryptionKeysSource() — returns the EncryptionKeysConfig from
encryption.keys_file, or nil when unset (legacy flat fields then apply).
There is no inline encryption_keys section in the main config; hot rotation is
done via the dedicated file.Triggers, wired in cli/cmd/root.go:runService via watchEncryptionKeyReload():
ReloadEncryptionKeys() (immediate force reload).Config.EncryptionKeysPollInterval(), default 15s) →
ReloadEncryptionKeysIfChanged(). Started only when a keys_file is set and
the interval is positive (keys_poll_interval: 0 → SIGHUP-only). Polling-by-
content (not fsnotify) is robust to the atomic-rename / symlink-swap that
Kubernetes Secret/ConfigMap mounts use, and adds no dependency. It detects both
structural edits to the keys file and content changes to the key files it
references.vault rekey runs as a separate process against the same DB, so it already
works while the server runs; the missing piece — now built — was hot-swapping the
server's in-memory keyring. The running JWT signer holds the decrypted ECDSA key
in memory, so rotating the encryption key does not disturb it; vault rekey
re-encrypts the at-rest blob.
# 1. edit encryption_keys.yaml (or the mounted key files):
# access_key.primary = new key, access_key.secondary = [old key]
# 2. within ≤15s (or `kill -HUP <pid>` for immediate apply) the new primary
# encrypts new writes; old data still decrypts via the secondary
semaphore vault rekey # 3. re-encrypt access keys + jwt_signing_key to the primary
semaphore vault check # 4. wait until everything reports the primary
# 5. remove access_key.secondary from the file → applied automatically
RekeyAccessKeys(oldKey) (services/server/access_key_encryption_svc.go):
oldKey optional — empty decrypts via the access keyring (primary +
secondaries), non-empty uses the explicit single key (legacy --old-key).
Re-encrypts under the access primary; external storages skipped.util.RekeyJWTSigningKey(store, oldKey) — fixes a real bug: the old rekey
ignored the jwt_signing_key option. It now decrypts the option (option keyring
→ access fallback → optional oldKey) and re-encrypts under the option primary,
performing the access→option migration.cli/cmd/vault_rekey.go — flags --old-key (optional), --backup <file>
(JSONL snapshot of access-key ciphertexts before re-encrypting), --rollback <file> (restore). Runs RekeyAccessKeys then RekeyJWTSigningKey.cli/cmd/vault_check.go — read-only. Per access key: decrypts with the primary
only, else the full keyring → reports primary / secondary (rekey pending) /
FAILED. For the JWT option: util.CheckJWTSigningKey → OptionSlot label.
Non-zero exit on any failure. The access-fallback marker tells the operator
the JWT option is not migrated yet (don't drop the access key).Phase 1's primary/secondary + trial-decryption works but has three limits:
decryption is O(number of keys) (try each until the GCM tag verifies); you cannot
tell which key encrypted a given row without trying; and you cannot prove a
key is unused before retiring it. Phase 2 fixes all three by stamping a key id
into the ciphertext and looking the key up directly. This is the model Vault (key
versions), JWKS (kid), and cloud KMS use.
The keys file is a registry of keys plus pointers to the active key per
purpose. The registry is an inline keys: map and/or a keys_folder: of key
files (both combine); active: names the encrypting key by label or by filename
(*_file, relative to keys_folder):
# inline map
keys:
k_2026_06: { value: "<base64 key>" } # or file: /run/secrets/k1
k_2026_01: { file: /run/secrets/old }
active:
access_key: k_2026_06 # label into keys; encrypts NEW access-key secrets
option_key: k_2026_01
# OR folder of key files (one regular file = one key, labelled by filename)
keys_folder: /run/secrets/enc-keys
active:
access_key_file: access_key_primary.txt # filename in keys_folder (relative)
option_key_file: option_key_primary.txt
active.* names the key used for new encryption (the old "primary").active while some rows
still reference it). In the folder model, retired keys stay as files.keys_folder skips dot-prefixed entries (Kubernetes' ..data/..2024_*) and
follows symlinks (loadKeysFolder in util/config.go), so it works with
mounted secrets.New ciphertext is "<key_id>:" + base64(nonce||ciphertext). The id rides inside
the existing secret string, so no DB column is added anywhere (access keys,
the jwt_signing_key option, future encrypted fields). On decrypt: split on the
first :, look up key_id in the registry, decrypt directly. Backward compat:
ciphertext with no id: prefix is legacy (phase-1 / pre-feature) and decrypts
via the existing path (flat key / trial), gaining a prefix on its next rewrite or
vault rekey.
The id is a fingerprint of the key, e.g. base64url(SHA-256(key))[:8 bytes]
(KCV or HMAC-SHA256(key, "semaphore-key-id") are equivalent conservative
choices). Why derived, not operator-assigned:
<fp> not found")
instead of decrypting wrong.x5t#S256. The codebase already does this for the JWT
key: computeKID = base64url(SHA-256(public key)) in pkg/jwt/signer.go.The entropy caveat (a hash can confirm a guessed key) is moot: keys are validated as 16/24/32-byte high-entropy material, so enumeration is infeasible.
keys:, point active.access_key at it, reload.
New writes carry the new id; old rows keep their old id and still decrypt.GROUP BY key_id query, not a trial. vault rekey re-stamps
rows to the active id; vault check becomes "count rows per key id" (exact, no
decryption attempts).secondary list maps to "keys present but not
active"; rows written by phase 1 have no id prefix and decrypt via the legacy
path until re-stamped.util/keyring.go: replace the runtimeKeyring{primary, secondary} with a
registry map[id]key + active-id per purpose; add keyID(material) (the
fingerprint) and encode/decode of the id:ct envelope.activeID + ":" + ct; decrypt sites parse the id and look up
(fall back to legacy/trial when no prefix).util/config.go: parse the keys: + active: file shape (the per-key
value|file KeySource is unchanged; ids are computed, labels resolve active).vault rekey/vault check: switch to id-based reporting and re-stamping.encryption.keys_file → access_key_encryption (and
SEMAPHORE_ACCESS_KEY_ENCRYPTION) drives the access keyring exactly as before.jwt_signing_key (encrypted under the access key) keeps
decrypting with no operator action: DecryptOption falls back to the access
keyring; vault rekey migrates it to the option primary.vault rekey --old-key <old> unchanged.t.TempDir(), reset util.Config between cases)util/keyring_test.go
OptionSlot labels.resolveKeySource (value/file/mutual-exclusion/missing/empty); resolveKeyring
(structured wins over flat, flat fallback, secondaries).resolveEncryptionKeysFrom: nil config, flat option key (old single-key
scheme, no rotation), structured-option wins over flat, file-backed keys,
invalid key errors.TestReloadEncryptionKeys (rotate via the keys file; new primary applies;
old decrypts via secondary; invalid reload rejected, keyring untouched).TestEncryptionKeysFile_DedicatedFileRotation /
TestEncryptionKeysFile_ReferencedKeyFilesYAML (dedicated JSON/YAML file;
referenced key-file content change detected; IfChanged no-op when unchanged).TestReloadEncryptionKeys_ConcurrentReadsAreRaceFree (8 goroutines
encrypt/decrypt during 50 reloads) — passes under -race.TestOptionEncryptionFlatKey (flat option key end-to-end via global resolve).util/jwt_test.go — RekeyJWTSigningKey migrates access→option;
CheckJWTSigningKey slots; no-op when no key stored.services/server/AccessKey_test.go — RekeyAccessKeys re-encrypts to the new
primary (explicit --old-key path); external storages skipped.access_key_encryption: secrets + JWT signer work; no
behaviour change (EncryptOption == access keyring).access_key.primary.file / option_key.primary.file at
0400 files; keys never appear in the process environment.vault rekey →
vault check → drop secondary), no restart.vault check slots track it.option_encryption flat: options use a distinct single key, no rotation,
OptionSlot = option:primary.vault rekey --backup, then --rollback.| Risk | Mitigation |
|---|---|
| Operator sets a separate option key and the JWT signer fails to load | DecryptOption falls back to the access keyring automatically; vault check marks the fallback so the operator runs vault rekey. |
| Cross-keyring fallback weakens access/option separation | Not a new exposure (JWT is already access-decryptable today); temporary, closes after vault rekey, reported by vault check. |
| Trial-decryption cost grows with keyring size | Secondaries are a transient rotation aid; primary is tried first. Phase 2 (key-ID keyset, see Target design) removes the trial loop entirely. |
| Reload reads a half-written file | Validation runs before swap; on any error the active keyrings are left untouched. Operators should write atomically (rename), as K8s does. |
| Concurrent reloads (SIGHUP + poller) interleave | keyringStore.reloadMu serializes the compare-and-swap; reads stay lock-free. |
File path readable by the wrong users | Document 0400; file storage is preferred over env. |
| Sensitivity mislabel | File is not ,sensitive; Value and the flat fields are. |
KeySource — a KEK that never lands on disk.KeySource + multiple securecookie codecs to
CookieHash/CookieEncryption (separate blast radius).ReloadEncryptionKeys (the poll interval
is already configurable via encryption.keys_poll_interval).