Master key management
Security: master key management
Every secret this package stores in a recoverable form is sealed by the SecretBox
(XChaCha20-Poly1305-IETF via libsodium) under one crypto master key,
CBOX_ID_CRYPTO_KEY. That covers private signing keys, TOTP secrets (subjects and
operators), vaulted downstream credentials, SSO connection configs and their client
secrets, pull-directory credentials, outbound SCIM connection secrets, webhook and
inline-hook signing secrets, and legacy-login handler secrets. Each ciphertext is
bound to its row by an AEAD context, so a value lifted from one row does not open as
another.
Signing keys rotate separately (cbox-id:keys:rotate); this page is about the master
key that seals them.
The envelope names its key
Since 1.22 a sealed value looks like:
v1.<key-id>.<base64url(nonce ‖ ciphertext ‖ tag)>
<key-id> is a 16-hex-character derived id: the first 64 bits of
HMAC-SHA256 keyed with the master key over a fixed label. It is stable (the same key
always has the same id, on every host), it says nothing about the key, and nothing
has to be stored to keep it in sync. Opening a value uses exactly the key it names. A
value naming a key that is not configured fails with a message that says which key to
add back.
Values written by 1.21 and earlier carry no prefix (they are plain base64url, which
can never contain a .). They keep opening forever: every configured key is tried,
current first, and only the AEAD tag decides — a wrong key cannot produce a
plaintext. Both formats are pinned by frozen-envelope tests against fixed keys.
Configuration
| Key | Env | Meaning |
|---|---|---|
cbox-id.crypto.key |
CBOX_ID_CRYPTO_KEY |
The current key. Everything new is sealed under it. Base64, 32 bytes, optional base64: prefix. |
cbox-id.crypto.previous_keys |
CBOX_ID_CRYPTO_PREVIOUS_KEYS |
Keys kept only to open values sealed before a rotation. Comma-separated, same form. Nothing is ever sealed under them. |
A malformed previous key refuses to boot with a message naming its position; the current key re-listed among the previous ones is ignored.
Rotating the master key
- Generate a new key:
php -r "echo base64_encode(random_bytes(32)).PHP_EOL;". - Swap: make the new key
CBOX_ID_CRYPTO_KEYand move the old one intoCBOX_ID_CRYPTO_PREVIOUS_KEYS. Deploy. From this moment new secrets are sealed under the new key and old ones still open. - Rewrap:
php artisan cbox-id:crypto:rewrap. It re-seals every registered column onto the current key, in bounded chunks, with progress output.--dry-runcounts without writing;--column=table.columnlimits the run. - Check:
php artisan cbox-id:doctorwarns while anything is still under a previous key, and tells you when the previous keys are no longer needed. - Drop the old key from
CBOX_ID_CRYPTO_PREVIOUS_KEYSonly after the doctor says so — and keep it in your offline backup for as long as you keep database backups taken before the rotation.
The rewrap is safe to interrupt and re-run. It only ever selects values not yet under the current key, walks each table by its key (so it never re-reads a value it could not open), and writes a value back only if the row still holds exactly what it read — a secret its owner rotated mid-run is never overwritten. It runs across every environment at once: a master key is deployment-wide, so its rotation is too.
A value no configured key opens is left untouched and reported by row id; the command exits non-zero. Do not drop a previous key while that is the case.
What else the master key touches
The keyed OTP hasher derives its HMAC key from the current master key. One-time codes already sent when you swap keys stop verifying; they live for minutes, so a person asks for a new one. Nothing else is derived from it.
Your own sealed columns
If your application seals its own data with the SecretBox, register the column so a
rotation covers it — anything unregistered keeps its old key and becomes unreadable
when that key is dropped:
use Cbox\Id\Kernel\Crypto\Contracts\SealedColumns;
use Cbox\Id\Kernel\Crypto\ValueObjects\SealedColumn;
$this->callAfterResolving(SealedColumns::class, static function (SealedColumns $columns): void {
// Sealed with context 'acme:api-credential:'.$row->id
$columns->register(new SealedColumn('acme_api_credentials', 'secret_encrypted', 'acme:api-credential:'));
});
The context must be a fixed prefix plus one column of the same row (contextColumn,
default id) — the shape every package secret uses.
Honest scope
- Custody is yours. The package never stores the key; where it lives (env, a secrets manager, a KMS that renders it into the environment) and who can read it is an operator decision. Losing it makes every sealed secret unrecoverable.
- Rotation re-seals; it does not re-key the data's meaning. A compromised master key means every secret it sealed may have been read. Rotate the master key AND the secrets themselves (signing keys, webhook secrets, connection secrets) after a compromise.
- No KMS/HSM implementation ships.
SecretBoxis the swap point. A host that binds its ownSecretBoxkeeps the package's rewrap working only for envelopes the configured keys can open.