Skip to content

Crypto & secrets

Tiger holds two install-wide secrets, and the design principle is the same for both: the secret lives in local.ini (or a secrets manager), never in the database and never in the repo. A stolen database is then useless on its own. The two secrets do different jobs, so they rotate differently — and both rotate with no downtime and no forced resets.

Secret Config key Protects Kind
Encryption key tiger.crypto.key reversible secrets at rest (TOTP seeds, OAuth tokens) two-way
Pepper tiger.security.pepper password + short-code hashes one-way

Tiger_Crypto — encrypt reversible secrets at rest

Some auth factors store a secret that has to be recovered, not just verified — a TOTP shared secret (needed to recompute the expected code), an OAuth refresh token. A hash won't do, so these are encrypted before they hit user_credential.secret.

Tiger_Crypto uses libsodium's crypto_secretbox (XSalsa20-Poly1305), bundled in PHP 8.1+ — authenticated (tamper-evident) with a random per-message nonce, base64-wrapped for a text column:

$blob  = Tiger_Crypto::encrypt($totpSecret);   // base64 "nonce . ciphertext"
$plain = Tiger_Crypto::decrypt($blob);          // throws if no key matches
if (Tiger_Crypto::isConfigured()) { /* gate a feature that needs the key */ }

Encryption always uses the current key; decrypt() tries the current key then any retired keys, so a rotation window Just Works. decrypt() throws on malformed input or when no configured key authenticates — callers treat any throw as "secret unusable."

Tiger_Security — the password/code pepper

Per-record salts are already handled: password_hash mints a random salt per password. A pepper is the different half — one install-wide secret HMAC'd into a value before it's hashed, kept out of the DB. Without it, a stolen user_credential table can't even begin to be cracked, and it lifts short low-entropy codes (a 6-digit OTP, a recovery code) out of offline brute-force range.

$hash = password_hash(Tiger_Security::prehashPassword($plain), PASSWORD_BCRYPT);
// verify: try each pepper form (current, retired, then legacy raw)
foreach (Tiger_Security::passwordVerifiers($plain) as $candidate) {
    if (password_verify($candidate, $storedHash)) { /* match */ }
}

$codeHash = Tiger_Security::hashCode($otp, 'recovery');           // context domain-separates
$ok       = Tiger_Security::codeMatches($otp, 'recovery', $codeHash);

The password is HMAC-then-base64'd (44 chars — under bcrypt's 72-byte limit and NUL-safe, so long passwords aren't truncated). With no pepper configured, every method degrades to exactly the legacy behavior (password_hash($p) / hash('sha256', $code)), so an existing install is unaffected until you add one. (You rarely call these directly — the shipped auth flows do; see Auth & sessions.)

Provisioning secrets at install

Both secrets are minted randomly at install into local.ini — one command, idempotent, and it never rotates an existing value:

vendor/bin/tiger install:secrets

This runs Tiger_Install::provisionSecrets(), which writes any missing tiger.crypto.key / tiger.security.pepper under [production] in local.ini (creating the file if needed). It's the one place secrets are minted: install:admin and a web/cPanel setup form call the same method right after writing the DB creds, so the founding password is peppered from its very first hash. local.ini is gitignored (see configuration for the config cascade).

Zero-downtime rotation

The compliance-friendly answer to "rotate secrets every 90 days" — no maintenance window, no forced resets. During a rotation the old secret is kept as a retired fallback (tiger.crypto.key_retired / tiger.security.pepper_retired, comma-separated — multiple retired secrets are supported for overlapping rotations), and it's fail-safe: the old secret is only dropped once you've confirmed the migration finished.

The encryption key (reversible → re-encrypted eagerly, losslessly):

vendor/bin/tiger crypto:rotate-key   # current key → retired, mint a new current
vendor/bin/tiger crypto:rekey        # re-encrypt every stored secret under the new key
vendor/bin/tiger secrets:drop-retired crypto   # once rekey is done

The pepper (one-way → migrates lazily): a hash can't be re-peppered without the plaintext, so it migrates as users sign in — verify tries current-then-retired and re-hashes to the current pepper on any non-current match:

vendor/bin/tiger security:rotate-pepper   # current pepper → retired, mint a new current
# passwords re-pepper on each user's NEXT login (force-reset any stragglers)
vendor/bin/tiger secrets:drop-retired pepper   # once traffic has turned everyone over

secrets:drop-retired accepts crypto, pepper, or all. Because the retired secret stays available until you explicitly drop it, nothing breaks mid-rotation and a botched rotation can't lock anyone out.

Keys and peppers live only in local.ini / a secrets manager — never the database (that's where the ciphertext is) and never the repo. Rotating is safe; deleting a live secret is not — it strands every value hashed or encrypted under it.