Repository navigation
Security
This module is composed of six cooperative classes:
| Class | Responsibility |
|---|---|
Bangwe |
High-level facade: AEAD encryption/decryption, JWT creation/validation, key generation, key rotation. |
Cipher |
Lower-level symmetric encryption with pluggable ciphers (CBC or GCM), simple base64 output. |
Hasher |
Password hashing (bcrypt/argon2), timing-safe verification, rehash-on-login, cost calibration. |
Password / PasswordPolicy |
Value objects for password validation and strength scoring. |
CSRFToken / CookieOptions |
CSRF tokens (session + double-submit cookie). |
SQLInjectionChecker |
Lightweight heuristic scanner for suspicious SQL fragments. |
- Fail closed — misconfiguration throws, never silently falls back to weak defaults.
-
AEAD by default — only GCM/CCM ciphers accepted in
Bangwe, preventing tampering. - No silent truncation — encryption keys must be exactly 32 bytes (raw or base64-encoded).
-
Timing-safe — all comparisons use
hash_equals()/password_verify(). - Rotation-ready — decryption transparently tries previous keys.
-
PHP 8.4+ (readonly properties, enums,
matchused internally) - Extensions:
openssl,hash,json,mbstring - Composer package:
firebase/php-jwt
composer require firebase/php-jwtEnsure your PSR-4 autoloader maps Mlangeni\Machinjiri\Core\Security\* to the directory containing these files.
Goal: Do the most common security tasks correctly in the fewest lines of code.
Use Bangwe::encrypt() for any secret you need to store (API keys, session data, PII).
use Mlangeni\Machinjiri\Core\Security\Encryption\Bangwe;
$bangwe = new Bangwe();
// Encrypt — returns an array { data, iv, tag, v }
$payload = $bangwe->encrypt('my secret value');
// You can persist it as JSON
$json = $bangwe->encryptToJson('my secret value');
// => {"data":"...","iv":"...","tag":"...","v":"v1"}
// Decrypt later
$plain = $bangwe->decryptFromJson($json);
// => 'my secret value'First time? Generate an APP_KEY and put it in your .env:
echo Bangwe::generateKey();
// => base64:/4B7iQ8k.... (44 chars)APP_KEY=base64:/4B7iQ8k...
⚠️ Never change APP_KEY without a rotation plan (see 5.2). All data encrypted with the old key becomes undecryptable.
Never store plaintext or md5()/sha1() hashes. Use Hasher (or the Password value object if you want policy validation).
use Mlangeni\Machinjiri\Core\Security\Hashing\Hasher;
$hasher = new Hasher();
// Registration
$hash = $hasher->make($userSubmittedPassword);
// Store $hash in users.password_hash
// Login
if ($hasher->verify($userSubmittedPassword, $storedHash)) {
// Grant access
} else {
// Deny — but log the event, do not reveal *why*
}make() uses Argon2id by default (falls back to bcrypt if unavailable). Both are safe.
$bangwe = new Bangwe();
// Issue a token for a user
$token = $bangwe->createToken([
'sub' => $user->id, // subject = user id
'role' => 'admin',
]);
// Send $token to the client (Authorization: Bearer …)
// On each protected request:
try {
$claims = $bangwe->decodeToken($bearer);
$userId = $claims->sub;
} catch (SecurityException $e) {
// 401 Unauthorized
http_response_code(401);
echo "Invalid token: " . $e->getMessage();
}Set JWT_SECRET in .env — it must be at least 5 characters, but use 32+ bytes of entropy:
php -r 'echo base64_encode(random_bytes(32)), PHP_EOL;'use Mlangeni\Machinjiri\Core\Security\Tokens\CSRFToken;
$csrf = new CSRFToken($session, $cookie);
// In your form template:
echo $csrf->csrfField(); // <input type="hidden" name="_token" value="...">
// On submit:
if (!$csrf->validateToken($_POST['_token'] ?? null)) {
http_response_code(419);
exit('CSRF check failed');
}Goal: Configure the system correctly and use its more powerful features — rehashing, token refresh, double-submit cookies, and password policies.
All classes read from Container::getInstance()->configurations. You can provide config from config/encryption.php and config/hashing.php.
config/encryption.php
return [
'app_encryption_key' => getenv('APP_KEY'),
// Comma-separated list of previous keys during rotation
'encryption_key_previous' => getenv('APP_KEY_PREVIOUS'),
'jwt' => [
'jwt_cipher' => 'aes-256-gcm',
'jwt_secret' => getenv('JWT_SECRET'),
'jwt_algo' => 'HS256',
'jwt_expiration' => 3600,
'jwt_issuer' => 'https://api.example.com',
'jwt_audience' => 'example_api',
],
];config/hashing.php
return [
'default' => PASSWORD_ARGON2ID,
'algorithms' => [
PASSWORD_ARGON2ID => [
'algorithm' => PASSWORD_ARGON2ID,
'memory_cost' => 65536,
'time_cost' => 4,
'threads' => 2,
],
PASSWORD_BCRYPT => [
'algorithm' => PASSWORD_BCRYPT,
'cost' => 12,
],
],
];All configuration is validated at construction time — a typo in a cipher name or a missing key fails loudly instead of degrading to insecure defaults.
Use the Password value object when you want to enforce runtime rules and report all failure reasons at once:
use Mlangeni\Machinjiri\Core\Security\Password\Password;
use Mlangeni\Machinjiri\Core\Security\Password\PasswordPolicy;
$policy = new PasswordPolicy(
minLength: 12,
requireUppercase: true,
requireLowercase: true,
requireNumber: true,
requireSpecial: true,
);
$password = new Password($userInput, $policy);
if (!$password->validate()) {
foreach ($password->errors() as $msg) {
echo "• $msg\n";
}
// => Password must be at least 12 characters.
// => Password must contain at least one special character.
}
echo "Strength: " . $password->strength(); // 0–4
if ($password->validate()) {
$hash = $password->hash(); // hashes and discards plaintext
}Default policy (no arguments) requires ≥ 8 chars with uppercase/lowercase/number/special, blocks control chars, and rejects a small common-password blocklist.
// Refresh a token: re-issues with the same subject, new iat/exp/jti
$newToken = $bangwe->refreshToken($oldToken, newExpiration: 7200);
// Inspect claims WITHOUT verifying signature — for debugging only
$claims = $bangwe->getTokenClaimsUnverified($token);
// The preferred flow — verify, then refresh
try {
$newToken = $bangwe->refreshToken($nearExpiryToken);
} catch (SecurityException $e) {
// Token was invalid or already expired
}Security note:
getTokenClaimsUnverified()andinspectToken()are for diagnostics — never for authorization. An attacker can hand you a self-signed token withrole: "admin". Always usedecodeToken()orvalidateToken()for access decisions.
For stateless APIs or cross-domain SPAs, the CSRFToken class also supports the double-submit cookie pattern in addition to the session-backed flow:
// Client-side: also set a cookie with the token
$token = $csrf->generateTokenWithCookie();
// Server-side validation on POST:
if (!$csrf->validateTokenWithCookie($_POST['_token'])) {
http_response_code(419);
exit;
}The cookie must use Secure, HttpOnly, and SameSite=Lax (defaults in CookieOptions):
$options = new CookieOptions(
expires: time() + 3600,
path: '/',
secure: true,
httponly: true,
samesite: 'Lax',
);SameSite=None is only allowed with Secure=true — the constructor throws otherwise.
Algorithms and cost factors age. Use verifyAndRehash() at login to transparently upgrade hashes without user interaction:
$loginOk = $hasher->verifyAndRehash(
$userSubmittedPassword,
$row['password_hash'],
function (string $newHash) use ($pdo, $userId) {
$stmt = $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?');
$stmt->execute([$newHash, $userId]);
},
);
if (!$loginOk) {
// Auth failed
}- Login still succeeds if the rehash-persist step throws — the failure is logged and swallowed.
- The current algorithm/cost comes from
config/hashing.php, so bumpingcost: 12 → 13upgrades all users on their next login.
You can also trigger it proactively:
if ($hasher->needsRehash($storedHash)) {
// Queue a background job to reset password or upgrade
}Goal: Understand the internals so you can extend, audit, and safely operate this system in production.
Bangwe::encrypt() uses AES-256-GCM (by default):
Ciphertext = AES-256-GCM(plaintext, key, iv, aad="")
Tag = GCM authentication tag (16 bytes)
Output = { data: b64(ciphertext), iv: b64(iv), tag: b64(tag), v: "v1" }
Properties:
- AEAD (Authenticated Encryption with Associated Data) — tampering is detected on decrypt, not just at signature check.
-
Random IV per encryption — 96-bit (GCM standard), generated via
random_bytes(). -
Version field (
v) — the current value"v1"lets future versions migrate without breaking old ciphertexts. -
No AAD is set by default. If you have request context (e.g., user id) that should be bound to the ciphertext, extend the class to pass AAD to
openssl_encrypt().
Compare with the lower-level Cipher class:
| Feature | Bangwe::encrypt() |
Cipher::encrypt() |
|---|---|---|
| Ciphers allowed | GCM/CCM only | CBC or GCM (via config) |
| Output format | JSON array with v
|
Single base64 string |
| Key rotation | ✅ Multiple keys | ❌ Single key |
| IV/tag embedded | Separate fields | Framed: `IV |
| Use case | Application secrets | Framework internal |
Prefer
Bangwefor anything user-facing. UseCipheronly if you specifically need CBC for legacy compatibility (and be aware CBC lacks authentication).
You can rotate the encryption key without downtime by:
-
Generating a new key:
echo Bangwe::generateKey(); // base64:...
-
Moving the current key to
APP_KEY_PREVIOUS(supports a comma-separated list for multi-step rotations):APP_KEY=base64:NEW_KEY_HERE APP_KEY_PREVIOUS=base64:OLD_KEY_HERE
-
Deploying — new writes use the new key; existing ciphertexts decrypt because
Bangwe::decrypt()tries each key in order:private function decrypt(...) { // 1. Try current key // 2. Try each key in $previousEncryptionKeys // 3. Throw 20107 if all fail }
-
Re-encrypting at rest — run a background job that reads each encrypted row with the old key and writes it back with the new key.
-
Retiring the old key — once all data has been re-encrypted, remove
APP_KEY_PREVIOUS.
Behavioral notes:
- Invalid entries in
APP_KEY_PREVIOUSare silently skipped (they cannot decrypt anything useful). - The rotation process is non-destructive — decryption tries keys but never mutates data.
-
getConfigSummary()['key_rotation_keys']reports how many previous keys are loaded (for ops dashboards).
Cipher allows per-cipher configuration:
'ciphers' => [
'aes-256-gcm' => [
'cipher' => 'aes-256-gcm',
'secret' => getenv('APP_KEY'),
'iv_size' => 12,
'tag_size' => 16,
'aead' => true,
],
'aes-256-cbc' => [
'cipher' => 'aes-256-cbc',
'secret' => getenv('APP_KEY'),
'iv_size' => 16,
'aead' => false,
],
],Validation enforces:
-
iv_sizemust matchopenssl_cipher_iv_length(). - For AEAD,
tag_sizemust be in 12–16 bytes (NIST SP 800-38D). - Empty secrets are rejected.
Framing layout (single base64 string output):
AEAD: [ IV (12B) ][ Tag (16B) ][ Ciphertext ]
CBC: [ IV (16B) ][ Ciphertext ]
This is decided by supportsAead, which is derived from the config flag or -gcm in the cipher name. Do not change framing for a cipher already deployed — it would break all existing ciphertexts.
The Hasher class makes several security decisions worth understanding:
Bcrypt's 72-byte truncation. Bcrypt hashes only the first 72 bytes of input. This class defends against that by pre-hashing all bcrypt/default passwords:
private function prepare(string $password): string
{
$input = $password;
// 1. Pepper (if configured) — HMAC first
if ($this->pepper !== null) {
$input = hash_hmac('sha256', $input, $this->pepper, true);
}
// 2. Pre-hash — base64 keeps it printable, no nulls
if ($this->prehash) {
$input = base64_encode(hash('sha256', $input, true));
}
return $input;
}You cannot disable prehashing for bcrypt-family algorithms — enablePrehash(false) throws. This prevents an accidental reintroduction of the truncation bug.
Timing-safe verification of empty hashes. When $hashed === '' (e.g., user not found), the hasher still runs a full password_verify() against a valid dummy hash (TEST_HASH). This makes the presence of a user account indistinguishable from the absence of one by timing.
Pepper. An optional application-wide secret appended via HMAC-SHA256 before hashing:
$hasher->setPepper(getenv('HASH_PEPPER')); // ≥ 32 bytesThis is not a replacement for a per-user salt (which password_hash() adds automatically), but it protects against whole-database compromises where the attacker only gets users table hashes.
Cost calibration. Different hardware needs different cost factors. Calibrate to a target runtime:
$cost = Hasher::calibrateBcryptCost(targetMs: 250);
// e.g. 11 on a fast server, 13 on a slow one
// Then update config/hashing.php:
// PASSWORD_BCRYPT => ['cost' => $cost]The calibration loop warms up opcache, then measures costs 4–16. Hard-capped at 16 to prevent pathological runtimes.
SQLInjectionChecker is a defense-in-depth layer, not a replacement for parameterized queries:
$checker = new SQLInjectionChecker();
// Check a single string
if ($checker->checkString($_GET['q'])) {
// Reject or log
}
// Check an entire request body
$suspicious = $checker->checkArray($_POST);
if (!empty($suspicious)) {
// $suspicious['q'] = ['id'] means nested array $_POST['q']['id'] was flagged
}What it detects:
- SQL keywords (
UNION SELECT,DROP,EXEC) in suspicious contexts - Quote-based termination
- Comment sequences (
--,#,/* */) and; - Conditional logic (
IF,CASE WHEN) - Meta-character sequences (
||,&&,~~) - Hex literals (
0x…) - Schema probes (
information_schema,pg_catalog)
What it does NOT do:
- It does not understand SQL context —
name="union"in an English form will flag. - It does not parse or sanitize properly — its
sanitizeInput()method should be avoided; parameterization is the only reliable defense. - It does not cover NoSQL, LDAP, or shell injection — separate checkers required.
Recommended use: log flagged requests for security monitoring, or block obvious attack strings at the edge. Every query must still use prepared statements.
| Threat | Mitigation |
|---|---|
| Ciphertext tampering | AES-GCM authenticated encryption (Bangwe, Cipher AEAD mode) |
| IV reuse | Fresh random IV on every encrypt() call |
| Password brute-force | bcrypt/argon2 cost factors, optional pepper |
| Password truncation | Prehashing for bcrypt family |
| Rainbow tables | Per-hash salts (built into password_hash()) |
| Timing attacks |
hash_equals() / password_verify() throughout |
| CSRF | Session token + double-submit cookie, SameSite enforcement |
| Token forgery | HMAC signature (HS256) with JWT_SECRET
|
| Token replay (across apps) |
iss and aud claim validation |
| Key exposure (at-rest rotation) | Previous-key fallback decrypt |
- No input length validation beyond passwords — enforce request size limits at the web server.
- No MFA, session management, or rate limiting — layer those on top.
- No asymmetric JWT (RS256/ES256) — only HMAC symmetric signing is built in.
-
No XSS escaping beyond
csrfField()— usehtmlspecialchars()or a templating engine. - No SQL injection prevention — only heuristics. Parameterized queries are mandatory.
[HTTPS] → [WAF / rate limit] → [This module] → [Parameterized SQL] → [Least-privilege DB]
| Key | Type | Required | Description |
|---|---|---|---|
app_encryption_key |
string | ✅ | Base64-encoded 32 bytes, or raw 32 bytes. Used by Bangwe. |
encryption_key_previous |
string|array | ❌ | Comma-separated old keys for rotation. |
default |
string | ❌ | Default cipher for Cipher (default: aes-256-cbc). |
ciphers.<name>.secret |
string | ✅ | Key material (raw or base64-32B). |
ciphers.<name>.iv_size |
int | ❌ | Must match openssl_cipher_iv_length(). |
ciphers.<name>.tag_size |
int | ❌ | AEAD only; 12–16 bytes. |
ciphers.<name>.aead |
bool | ❌ | Explicitly mark as AEAD. |
| Key | Default | Description |
|---|---|---|
jwt_cipher |
aes-256-gcm |
AEAD cipher used for encryption (must be GCM/CCM). |
jwt_secret |
required | HMAC secret, ≥ 5 chars (recommend 32+ bytes of entropy). |
jwt_algo |
HS256 |
Firebase JWT algorithm. Only symmetric (HS*) is safe here. |
jwt_expiration |
3600 |
Default TTL in seconds. |
jwt_issuer |
app name/host |
iss claim; validated on decode. |
jwt_audience |
machinjiri_api |
aud claim; validated on decode. |
| Key | Default | Description |
|---|---|---|
default |
PASSWORD_ARGON2ID |
Default algorithm. |
algorithms.<PASSWORD_*>.cost |
12 |
bcrypt only, 4–31. |
algorithms.<PASSWORD_ARGON2*>.memory_cost |
65536 |
KiB (64 MiB). |
algorithms.<PASSWORD_ARGON2*>.time_cost |
4 |
Iterations. |
algorithms.<PASSWORD_ARGON2*>.threads |
1 |
Parallelism. |
APP_KEY=base64:...
APP_KEY_PREVIOUS=base64:...
JWT_SECRET=...
HASH_PEPPER=...// Construction
new Bangwe(?Container $container = null)
// Encryption
array encrypt(string $data)
string encryptToJson(string $data)
string decrypt(string $encryptedData, string $iv, string $tag)
string decryptFromJson(string $jsonString)
// JWT
string createToken(array $payload, array $headers = [], ?int $expiration = null)
object decodeToken(string $token)
bool validateToken(string $token)
object validateTokenOrFail(string $token)
string refreshToken(string $token, ?int $newExpiration = null)
array getTokenClaimsUnverified(string $token)
// Utilities
static string generateKey(int $length = 32)
static string randomString(int $length = 32)
static string randomToken(int $bytes = 32)
string hashPassword(string $password, array $options = [])
bool verifyPassword(string $password, #[\SensitiveParameter] string $hash)
array getConfigSummary()new Hasher(?Container, ?Logger)
string make(string $password)
bool verify(string $password, string $hashed)
bool verifyAndRehash(string $password, string $hashed, callable $persist)
bool needsRehash(string $hashed)
// Fluent setters
self setAlgorithm(mixed $algorithm)
self setCost(int $cost)
self setArgon2Options(int $memoryCost, int $timeCost, int $threads)
self enablePrehash(bool $enabled = true)
self setPepper(?string $pepper)
// Static
static int calibrateBcryptCost(int $targetMs = 250)new Password(string $password, ?PasswordPolicy, ?Hasher)
bool validate()
array errors() // string[]
int strength() // 0–4
string hash()
bool verify(string $hash)new CSRFToken(Session, Cookie, ?string, ?string, int $ttl, ?CookieOptions, ?int $maxFailures)
string generateToken()
string getToken()
string regenerate()
bool validateToken(?string $token = null)
string generateTokenWithCookie()
bool validateTokenWithCookie(string $token)
string csrfField(string $name = '_token')new SQLInjectionChecker(array $customPatterns = [])
bool checkString(string $input)
array checkArray(array $data)
string sanitizeInput(string $input) // ⚠️ avoid — use parameterized queriesSecurityException codes used by Bangwe (helpful for logging / i18n):
| Code | Meaning |
|---|---|
20101 |
Encryption key not configured |
20102 |
Key is neither base64-32B nor raw-32B |
20103 |
JWT secret not configured |
20104 |
Unsupported cipher |
20105 |
Failed to get IV length |
20106 |
Encryption failed |
20107 |
Decryption failed (all keys tried) |
20108 |
Invalid encrypted JSON format |
20109 |
JWT expired |
20110 |
JWT signature invalid |
20111 |
JWT malformed / domain error |
20112 |
Invalid token structure (not 3 parts) |
20113 |
Non-AEAD cipher configured |
20114 |
JSON encoding failed |
20115 |
Invalid base64 in payload |
20116 |
Invalid token payload encoding |
20117 |
Invalid token payload JSON |
20118 |
JWT issuer mismatch |
20119 |
JWT audience mismatch |
20120 |
base64:-prefixed key invalid |
20121 |
JWT secret too short |
Before shipping to production:
-
APP_KEYgenerated withBangwe::generateKey()and stored in secrets manager (not.envcommitted to git) -
JWT_SECRET≥ 32 bytes of random entropy -
HASH_PEPPERset to a random 32-byte value (optional but recommended) -
HASH_COSTcalibrated on production hardware (Hasher::calibrateBcryptCost()) - All cookies use
Secure=true,HttpOnly=true,SameSite=Lax|Strict - HTTPS enforced —
Securecookies and HSTS headers - All SQL uses prepared statements —
SQLInjectionCheckeris secondary - Rate limiting on
/login,/register, token refresh endpoints - JWT expiry short (≤ 1 hour) with refresh-token flow
- Rotation runbook documented for
APP_KEYchanges - Logging captures 201xx errors + CSRF failures for monitoring
- Backups of APP_KEY — losing it means losing all encrypted data
Q: Why does Bangwe::encrypt() return an array instead of a string?
A: GCM needs the IV and tag stored separately from the ciphertext (or framed, like Cipher does). Returning structured fields keeps each component clearly separated and lets future versions add fields without breaking parsers. Use encryptToJson() if you need a single string.
Q: Can I use RS256 (asymmetric) JWTs?
A: Not with this library's createToken() / decodeToken(). Only HMAC algorithms are supported because the same secret signs and verifies. For asymmetric you'd extend Bangwe and inject a public/private key pair.
Q: My passwords contain Unicode characters — are they safe?
A: Yes, but note that Password uses \p{Lu}/\p{Ll} (Unicode classes) for letter checks, while Hasher operates on bytes. A single emoji may count as multiple strlen() bytes. PasswordPolicy uses strlen() for maxLength, so a password of 24 emoji may be treated as 96 bytes and rejected — this is intentional (bcrypt's 72-byte limit).
Q: How do I invalidate a JWT before it expires?
A: JWTs are stateless — you can't revoke them by themselves. Options:
- Short TTL (e.g., 15 minutes) + refresh tokens
-
JTI blacklist — store
jtiof revoked tokens in Redis untilexp -
Session-bound JWT — include a
sidclaim that's checked against a session store
Q: Should I use Cipher or Bangwe for encrypting database fields?
A: Use Bangwe. It enforces AEAD, supports key rotation, and version-tags output. Only reach for Cipher if you need CBC for legacy interop — and know that CBC alone provides no tamper detection.
Q: My CSRF token validation keeps failing after login — why?
A: CSRFToken stores the token in the session. If login rotates the session ID, the token goes with it. The correct pattern is:
- Login succeeds →
$csrf->regenerate()→ send new token to client - Logout →
$csrf->regenerate()or destroy session
If you use the double-submit cookie pattern, the cookie is client-side and survives session rotation — but then you must regenerate the token on privilege change.
Q: What happens if I lose APP_KEY?
A: All data encrypted with it is unrecoverable. GCM has no recovery mechanism. Back it up in a secrets manager with high availability. Rotation (see 5.2) is the only safe way to change it.
Machinjiri Docs
- Home
- Learn
- Build
-
Tutorials
- Routing
- Controllers and Middleware
- Views and Templates
- Database ORM
- Eloquent Database ORM
- Errors and Exceptions
- Forms and Validation
- Http
- Events and Logging
- Services and DI
- Security
- Authentication
- Caching
- File System
- Date and Time
- Debugging
- Queue and Jobs
- Task Scheduling
- Transport (Mail, SMS)
- LDAP
- Notifications
- Webhooks
- UUID Generations
- Terminal
- Helper Function
- Reference