Key management
Keys live in named rings: default for seals, the ledger and checkpoints; http for message signatures; and your own — a financial ring with stronger keys, say, chosen per seal with ring(). A key id resolves only in its own ring, and the algorithm always comes from the key — never from a stored row or a received parameter.
| Driver | Keys come from | Rotation |
|---|---|---|
| config (default ring) | SENTINEL_KEY_ID / SENTINEL_KEY (+ SENTINEL_PREVIOUS_KEYS for verify-only keys) | sentinel:key:rotate prints the new environment lines |
| database (http ring) | sentinel_keys rows: AES-256-GCM under a key derived from APP_KEY for envelopes only (never the application encrypter), every field bound — an edited, copied or planted row is rejected | sentinel:key:rotate stores the new key; the old one becomes verify-only |
chain | The drivers in order (the first signing key wins) | per driver |
Algorithms and material
| Algorithm | Material | Seals | HTTP |
|---|---|---|---|
hmac-sha256 | Root secret 32–1024 bytes; HKDF 32-byte subkeys | yes | yes (raw shared secret) |
hmac-sha384 | Root ≥ 32 bytes; 48-byte subkeys | yes | — |
hmac-sha512 | Root ≥ 32 bytes; 64-byte subkeys | yes | — |
ed25519 | 64-byte libsodium secret key / 32-byte public key | yes | yes |
ecdsa-p256-sha256 | PKCS#8 / SPKI PEM, P-256 | yes | yes |
ecdsa-p384-sha384 | PKCS#8 / SPKI PEM, P-384 | yes | yes |
Material is always base64:<standard base64> — a bare string is refused, which blocks passphrases. HMAC roots are 32–1024 random bytes (never PEM, never one repeated byte); Ed25519 is the 64-byte secret key and/or the 32-byte public key; ECDSA is the base64 of the PEM text, with the curve checked against the algorithm. Material never appears in exceptions, logs, events, about, var_dump() or serialized data.
The API
use RoundlyConsulting\Sentinel\Enums\Algorithm;
use RoundlyConsulting\Sentinel\Enums\KeyDestination;
Sentinel::keys()->rings(); // ['default', 'http']
Sentinel::keys()->all(); // KeyInfo of every key in every ring — never material
Sentinel::keys()->ring()->current(); // KeyInfo of the default ring's signing key
$ring = Sentinel::keys()->ring('http'); // KeyRingHandle; a kid of another ring is unknown here
$ring->name(); // 'http'
$ring->find('acme-2026-10'); // ?KeyInfo
$ring->all();
$ring->generate(Algorithm::Ed25519, keyId: 'acme-2026-11', activatesAt: now()->addWeek()); // stored, pending until then
$ring->import('acme-2026-10', Algorithm::EcdsaP256Sha256, $pem, owner: $partner);
$ring->rotate(); // RotationResult: current, previous (now verify-only)
$ring->revoke('acme-2026-10', reason: 'Partner offboarded', actor: $admin);
$ring->retire('acme-2025-10');
$lines = Sentinel::keys()->ring()->generate(Algorithm::HmacSha256, destination: KeyDestination::Config)->envSnippet;- The defaults differ on purpose. generate() stores the key in the ring’s database store (KeyDestination::Database; a ring without one throws KeyDriverException::readOnly), while KeyDestination::Config returns the environment lines in envSnippet and stores nothing. sentinel:key:generate does the opposite: environment lines unless --database.
- rotate() rotates in the store the current signing key lives in; the previous key’s signs_until is set to the new key’s activation, so both overlap without a gap.
- revoke() and retire() change database keys only. A config key throws KeyDriverException::notStoredInDatabase: revoke it by adding ring:kid to SENTINEL_REVOKED_KEYS, retire it by removing it from the ring’s verify-only list — sentinel:key:revoke and sentinel:key:retire print exactly that instead of failing.
- A revoke reason is required (1–1 000 characters); retire() ends the key’s verification period now, so seals still on it report RetiredKey.
use RoundlyConsulting\Sentinel\DataTransferObjects\GenerateKeyRequest;
use RoundlyConsulting\Sentinel\DataTransferObjects\RevokeKeyRequest;
use RoundlyConsulting\Sentinel\DataTransferObjects\RotateKeyRequest;
$key = Sentinel::generateKey(new GenerateKeyRequest('http', Algorithm::Ed25519, keyId: 'acme-2027-01', owner: $partner));
$rotation = Sentinel::rotateKey(new RotateKeyRequest('http'));
Sentinel::revokeKey(new RevokeKeyRequest('http', 'acme-2027-01', 'Partner offboarded', $admin));
Sentinel::retireKey('http', 'acme-2025-10');
Sentinel::listKeys('http'); // null = every ring
Sentinel::findKey('http', 'acme-2027-01'); // ?KeyInfo
Sentinel::currentKey(); // null = keys.default_ringStatuses
Statuses follow NIST SP 800-57 — pending → active → verify-only → retired, plus revoked — and the effective status is computed in this order:
| Status | When | Signs | Verifies |
|---|---|---|---|
Revoked | ring:kid in keys.revoked (always wins), or revoked | no | no |
Retired | verifies_until passed, or retired | no | no (ledger evidence still verifies) |
Pending | activates_at in the future | no | no |
VerifyOnly | signs_until passed, a previous config key, or public-only material | no | yes |
Active | otherwise | yes (with private/secret material) | yes |
SENTINEL_REVOKED_KEYS (ring:kid,…) revokes a key whatever its driver says — a restored database row can never un-revoke it.
Importing keys
Onboard a partner at runtime — no deploy, the key bound to the partner model:
$ring->import('acme-2026-10', Algorithm::Ed25519, $publicKeyPemOrBase64, owner: $partner, label: 'ACME');
Sentinel::importKey(new ImportKeyRequest('http', 'acme-2026-10', Algorithm::Ed25519, $pem, owner: $partner));php artisan sentinel:key:import acme-2026-10 --ring=http --algorithm=ed25519 --file=acme.pem \
--owner-type=partner --owner-id=7import() stores existing material in the ring’s database store: a partner’s public key (Ed25519 raw or SPKI PEM, ECDSA PEM) or an agreed HMAC secret, bound to the owner model and verify-only — it never signs, not as the ring’s current key and not through Http::withSignature() — unless imported with signing: true (your own key pair moving from the environment into the database; private material is refused without it). The status is bound into the encrypted envelope, the kid must be free in the whole ring, and KeyImported is dispatched.
Rotation
- Database ring: rotate() creates a new active key (optionally activating later with activatesAt); the previous key becomes verify-only when the new one activates. Old seals stay Intact; new seals use the new kid.
- Config ring: sentinel:key:rotate prints the new SENTINEL_KEY_ID / SENTINEL_KEY and the updated SENTINEL_PREVIOUS_KEYS, with the old key verify-only.
- Then move old seals to the new key with sentinel:reseal — it never launders, and --from-key= limits it to one old kid.
- Retire a key once nothing uses it (sentinel:key:retire refuses while seals on any ledger connection still do, unless --force); revoke a compromised key at once.
Config and database drivers
The config driver keeps keys out of the database — the recommended setup for seals:
SENTINEL_KEY_ID=default-20261002-k3f9qa
SENTINEL_ALGORITHM=hmac-sha256
SENTINEL_KEY="base64:…"
SENTINEL_PUBLIC_KEY="base64:…" # asymmetric keys; alone = verify-only
SENTINEL_PREVIOUS_KEYS="default-20250901-a1b2c3|hmac-sha256|base64:…" # verify-only, comma-separatedThe database driver stores sentinel_keys rows whose authoritative envelope is AES-256-GCM under a key derived from APP_KEY for envelopes only (rotation through APP_PREVIOUS_KEYS supported). It binds every field — ring, kid, algorithm, material, public key, status, dates, owner — so a row whose plain columns disagree with its envelope raises KeyIntegrityException, fires KeyIntegrityViolated and is treated as unknown. Database keys are only as safe as APP_KEY, so prefer the config driver for the default ring. chain reads its drivers in order — the first signing key wins.
Verify-only nodes
Servers that must verify but never seal configure the public half only (SENTINEL_KEY_ID, SENTINEL_ALGORITHM, SENTINEL_PUBLIC_KEY, no SENTINEL_KEY) and SENTINEL_AUTO_SEAL=false. They verify everything; an explicit seal() throws NoSigningKeyException.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.