Feature Specification: Secret rotation
Created: 2026-10-22
Status: Partially implemented
Input: Reconstructed from the secrets contracts, the openbao component, the database roster, the resources and the components of core at 1f4c46e3d.
User Scenarios & Testing (mandatory)
A deployment has three kinds of secrets:
- Engine credentials: an OpenBao secrets engine holds them and changes them on a schedule. The consumers read them through an OpenBao agent.
- Generated secrets: a
generateFilesinstance makes them once and uploads them to the KV store of OpenBao. The upload does not write over a value that exists. Thus a generated secret changes only when an operator forces a new upload of it, or when its KV entry is lost and the next deploy makes a new value. - Stored secrets: a person encrypts them in the repository with agenix (the
secretsresource), or keeps them outside the deployment. A person rotates them by hand, as the guide of thesecretsresource tells.
Some generated secrets encrypt data that the offsite backups hold. A backup restores only with the key that encrypted it.
User Story 1 - Know how each secret rotates (Priority: P1)
An operator or an auditor wants to know, for each secret, how it changes and which backups need it. This specification is that list.
Why this priority: a rotation of a key that a backup needs makes that backup unreadable. An operator must know this before a rotation.
Independent Test: the table in Key Entities agrees with the generators and the engine roles that it names.
Acceptance Scenarios:
- Given a secret in the table, When an operator reads its row, Then the row tells the rotation (schedule, manual or never), the backups that depend on it, and how the consumers get the new value.
User Story 2 - Rotate the engine credentials on a schedule (Priority: P1)
The static roles of the PostgreSQL, valkey and LDAP engines rotate daily. The agent of the consumer renders the new value and reloads or restarts the consumer.
Why this priority: a long-lived database or directory password that leaks stays usable for at most one rotation period.
Independent Test: an integration test of the PostgreSQL rotation, and a service test of valkey.
Acceptance Scenarios:
- Given a PostgreSQL static role, When the engine rotates it, Then the agent renders the new password and restarts the units that the consumer names, and the consumer connects with the new password.
- Given the LDAP role of the
autheliabind, When a bind fails because of invalid credentials, Then the LDAP engine rotates the role.
User Story 3 - Restore a backup after a change of a key that it depends on (Priority: P2)
The keys that a backup depends on do not rotate. An operator can change such a key by hand, for example by a forced upload. The backups from before the change must then stay usable for as long as the retention keeps them. KV keeps the old value, so the operator gets it back with a rollback.
Why this priority: a restore with a different key gives data that the application cannot read, and the error comes only later, when the application reads the data.
Independent Test: a service test of mastodon changes a key in KV, checks that the restore stops and names the version of the key, rolls the key back, and checks that the restore works.
Acceptance Scenarios:
- Given a backup from before a key change, When the operator restores it, Then the restore stops before it changes data, and names each key that is different and the KV version that the backup needs.
- Given the operator rolled each key back to the version that the backup needs, When the operator restores again, Then the restore works.
Requirements (mandatory)
Functional Requirements
- FR-001: An engine static role MUST rotate on the schedule of its roster (daily). The agent MUST render the new value and reload or restart the units that the consumer names. A role with a sidecar (
pod-roles) MUST restart its service with the optionrestartof the secret. - FR-002: A
generateFilesupload MUST NOT write over a value that exists in KV. Only a forced upload changes a generated secret. - FR-003: A secret that encrypts data in a backup MUST NOT change on a deploy, a redeploy or a recreate of its node.
- FR-004: The repository password of each restic backup MUST be a generated secret. A change MUST add a key for the new password to each repository before the backups read the new password, and MUST remove the key of the old password after it. The operator does these steps by hand, as the hosting provider guide tells. A restic job opens its repository only with its current password.
- FR-005: A key that a backup depends on MUST NOT rotate on a schedule. Such a key encrypts data in the backup, or data that the backup holds. A key that only gives access to the store of a backup, such as a
garageS3 key, is not such a key: a backup records no digest of it, and a restore does not compare it. Each backup MUST record a digest and the KV version of each such key. A restore MUST stop on a snapshot whose keys are different from the keys of the node, and MUST name the KV version that the snapshot needs. - FR-006: KV MUST keep the old versions of a key that a backup depends on, and MUST refuse a write to such a key that does not give the current version (check-and-set).
- FR-007: The
pixelfedapp key MUST have a list of old app keys beside it, which is empty by default, andpixelfedMUST also decrypt with each key in that list. A change MUST add the old key to the list beforepixelfedreads the new key, and MUST remove the old key from the list afterpixelfedencrypts its data again with the new key. The operator does these steps by hand, as the hosting provider guide tells. - FR-008: The Grafana secret key MUST have a list of old secret keys beside it, which is empty by default. The Grafana database MUST record the digest of the key that encrypts its data keys. Before Grafana starts with a key that has a different digest, the node MUST encrypt the data keys again with the new key, with the old key from the list. When the list does not hold the old key, Grafana MUST NOT start. A change MUST add the old key to the list before Grafana reads the new key, and MUST remove the old key from the list after Grafana starts with the new key. The operator does these steps by hand, as the hosting provider guide tells.
- FR-009: A
garageS3 key that the reconciler of a garage node holds MUST change by hand, with a new access key ID and a new secret in KV. The next run of the reconciler MUST delete the key of the old ID and MUST give the new key the same grants. Between the write to KV and the time that the reconciler and each consumer read the new pair, a consumer can have a pair thatgaragerefuses. The operator does these steps by hand, as the hosting provider guide tells. - FR-010: A
garageS3 key of the operator graph MUST rotate at the first apply of each month (UTC), and at the next apply after the operator increments the settinggarageKeyGenerationof the deployment. The name of the key ingarageMUST carry the month and the generation, so the apply makes a new key beside the old key. The apply MUST give the new key the grants of the old key and MUST write the new pair to KV before it waits. It MUST wait for the drain time (garage_key_drain_seconds, default 600 seconds, about two times the interval at which an agent reads KV), and MUST delete the old key and its grants after the wait. During the wait,garageMUST accept the old pair and the new pair. A job that reads the pair when it starts, for example a backup, and that runs for longer than the wait, loses access when the apply deletes the old key. - FR-011: A
garageS3 key that theensure-garageunit of a node with its own garage imports MUST change by hand, with a new access key ID and a new secret in KV. The next run ofensure-garageMUST import the new key and MUST delete each other key with the same name. Between this run and the time that each consumer reads the new pair, a consumer can have a pair thatgaragerefuses. The operator does these steps by hand, as the hosting provider guide tells. - FR-012: The DKIM key of a group MUST change by a new selector. The group data MUST give the list of selectors and the selector that the relay signs with, which MUST be in the list. The relay MUST have a key for each selector in the list, and a deploy MUST publish a TXT record for each selector in the list. A deploy MUST NOT change the key of a selector that has a key. The operator MUST NOT make the relay sign with a selector before DNS has its record, because the DMARC record has
sp=reject. The operator does these steps by hand, as the hosting provider guide tells.
Key Entities
The credentials register of the technical design (core/docs/md/technical-design/credentials.md) is the source for the list of the credentials and for the state of their rotation. This table adds the backups that depend on a secret and how the consumers get a new value.
"Manual" means: the secret changes only with a forced upload. "Agent" means: the OpenBao agent renders the value and reloads or restarts the units that the consumer names.
| Secret | Owner | Rotation | Backups that depend on it | Consumer refresh |
|---|---|---|---|---|
| PostgreSQL static roles | openbao |
daily | none | agent |
valkey user app, per namespace |
openbao; the user starts without a password |
daily | none | agent |
valkey user default, per namespace |
valkey |
manual | none | the instance applies the password when it starts |
LDAP bind of authelia |
openbao |
daily, and on a bind with invalid credentials | none | agent |
| PostgreSQL admin password | postgresql resource |
manual | none | agent |
lldap admin password, per group |
ldap resource |
manual | none | the provisioning of lldap |
lldap seed, per group |
ldap resource |
manual | the lldap database (the OPAQUE password data) |
agent |
lldap JWT secret, per group |
ldap resource |
manual | the lldap database (the OPAQUE password data) |
agent |
authelia instance: JWT, storage encryption key, HMAC, issuer key, client secrets |
oidc resource |
manual | the authelia database (the storage encryption key) |
a change of the database password reloads authelia |
| Grafana secret key and admin password | telemetry |
manual, with FR-008 for the secret key | the Grafana database and its backups (the secret key) | agent, then grafana-secret-key and grafana-admin-password before Grafana starts |
| old Grafana secret keys | telemetry; the first value is an empty list |
manual, with FR-008 | none | grafana-secret-key reads them at each start of Grafana |
mastodon keys: secret key base, the three Active Record encryption keys, VAPID keypair |
mastodon |
manual | the mastodon database (the encrypted columns) |
the directory setup of mastodon |
pixelfed app key |
pixelfed |
manual, with FR-007 | the pixelfed database (the values that Laravel encrypts) |
agent, then the data setup of pixelfed |
old pixelfed app keys |
pixelfed; the first value is an empty list |
manual, with FR-007 | none | agent, then the data setup of pixelfed |
pixelfed OAuth keypair |
pixelfed |
manual | none; a change ends the OAuth tokens that it signed | agent; Passport reads the files at each use |
peertube secret |
peertube |
manual | the peertube database (the two-factor secrets) |
a restart of peertube |
garage S3 keys that the reconciler of a garage node holds |
garage resource |
manual, with FR-009 | none (the key gives access to the bucket, encrypts no data) | agent; a restic or rclone job reads the pair at each start |
garage S3 keys of the operator graph |
garage resource |
monthly, and at a new garageKeyGeneration, with FR-010 |
none (as the row above) | agent; a job reads the pair at each start |
garage S3 keys of ensure-garage |
garage resource |
manual, with FR-011 | none (as the row above) | agent, or the environment of the consumer |
garage admin token and RPC secret |
garage resource |
manual | none | a restart of garage |
| restic passwords of the application and cache backups | the operator applications, valkey |
manual, with FR-004 | the restic repository | agent |
| media backup password and salt, per application | the operator applications | manual | the media backup (rclone crypt) |
agent |
| SMTP submission passwords, smarthost | smtp resource |
manual | none | agent |
| DKIM keypair | smtp resource |
manual, by selector, with FR-012 | none | agent |
| TSIG secret | the DNS wiring | manual | none | agent |
| DNSSEC key | the DNS wiring | never (unlimited lifetime) | none; the bind backup does not hold it |
agent |
| OpenBao PKI root CA | openbao |
never (valid for 10 years, leaf certificates for at most 30 days) | none | n/a |
| SPIRE CA | spire |
the default CA lifetime of SPIRE | none | SPIRE |
windmill superadmin and API token |
windmill |
manual; the API token is made again when it is not valid; the database role daily | none | the bootstrap of windmill |
netbox secret key, peppers, API token |
netbox |
manual; the token is made again when it is not valid | none | agent |
panel secret key and authorization token |
panel |
manual | none | agent |
agenix secrets (forgejo, runner token, security PGP key) |
secrets resource |
by hand | none | rebuild |
| CD Incus client certificate | CD | by hand, each year | none | by hand |
| state encryption key of the state backend | terraform-backend, on the node |
never | the states, and their offsite backup | n/a |
| age recipients of the bootstrap backups | the hosting provider, in the repository | by hand | the backups of the unseal key and of the state encryption key | rebuild; the next backup encrypts again |
| restic password of the bootstrap backups | openbao, terraform-backend |
never (fixed and public) | the raft snapshot, the states | rebuild |
| OpenBao unseal key | openbao, sealed to the TPM of the node, and in the bootstrap backup |
never | the raft snapshot | n/a |
| OpenBao root token | openbao, made from the unseal key, never on disk |
made for each run of the OpenBao initialization and each request for a root token, revoked at its end | none | n/a |
| OpenBao scoped tokens | openbao, on the node |
made again by the initialization when not valid; renewed daily | none | the next run of the initialization |
Success Criteria (mandatory)
Measurable Outcomes
- SC-001: Each secret that a
generateFilesinstance or an engine role makes has a row in the table. - SC-002: The PostgreSQL rotation tests, the service tests of
valkeyandmastodon, andtest-monitoring-storage(a change of the Grafana secret key) pass. - SC-003:
integrations-operator-garagepasses. It rotates a key by a new generation and by a new period, and shows that the old pair and the new pair in KV have access to the bucket during the wait, and thatgaragerefuses the old pair after the apply.
Assumptions
- KV keeps older versions of a value, and an older version stays readable after a forced upload. When neither the entry nor the mount sets a limit of versions, KV keeps only a small default number of versions, and the secrets mount sets no limit. Thus the OpenBao node sets a large limit of versions on each key that a backup depends on, and makes the writes to such a key give the current version (check-and-set). The upload of generated secrets and a rollback of a key give it.
- The backups keep data for the retention periods of the application backups.
Known Deviations
- The
lldapseed cannot change, also not by hand:lldapderives the OPAQUE data of each password from it, so a new seed makes each password of the live database invalid too. - The DKIM key does not rotate automatically. Needs: A monthly switch on the relay node that adds a selector, waits until DNS has its record, then signs with it.
- The DNSSEC key does not rotate. Needs: A DS procedure: the DS record is at the registrar or the parent zone, which is part of the registrar work.
- The restic password of the bootstrap backups is fixed and public. Age protects only the unseal key and the state encryption key in these backups.
- A change of the age recipients of the bootstrap backups does not revoke a recipient that it removes. The backups from before the change hold the unseal key and the state encryption key encrypted to that recipient, and these keys do not rotate.
- A raft snapshot restore gives back the engine credentials of the time of the snapshot. The roles that rotated after the snapshot rotate again within seconds, and the consumers get the new values when their agents log in again, which can take about an hour.