Skip to content

Security

Precious Lyson edited this page Nov 13, 2026 · 2 revisions

1. Overview

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.

Key design principles

  • 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.

2. Installation & Requirements

Requirements

  • PHP 8.4+ (readonly properties, enums, match used internally)
  • Extensions: openssl, hash, json, mbstring
  • Composer package: firebase/php-jwt
composer require firebase/php-jwt

Autoload

Ensure your PSR-4 autoloader maps Mlangeni\Machinjiri\Core\Security\* to the directory containing these files.


3. Beginner Track

Goal: Do the most common security tasks correctly in the fewest lines of code.

3.1 Encrypting a string

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.

3.2 Hashing & verifying passwords

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.

3.3 Creating & verifying JWTs

$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;'

3.4 CSRF protection in forms

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');
}

4. Intermediate Track

Goal: Configure the system correctly and use its more powerful features — rehashing, token refresh, double-submit cookies, and password policies.

4.1 Configuration

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.

4.2 Password policies & strength

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.

4.3 Token refresh & claim inspection

// 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() and inspectToken() are for diagnostics — never for authorization. An attacker can hand you a self-signed token with role: "admin". Always use decodeToken() or validateToken() for access decisions.

4.4 Double-submit cookie pattern

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.

4.5 Password hash upgrades

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 bumping cost: 12 → 13 upgrades 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
}

5. Advanced Track

Goal: Understand the internals so you can extend, audit, and safely operate this system in production.

5.1 AEAD encryption internals

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 Bangwe for anything user-facing. Use Cipher only if you specifically need CBC for legacy compatibility (and be aware CBC lacks authentication).

5.2 Key rotation

You can rotate the encryption key without downtime by:

  1. Generating a new key:

    echo Bangwe::generateKey(); // base64:...
  2. 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
  3. 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
    }
  4. 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.

  5. Retiring the old key — once all data has been re-encrypted, remove APP_KEY_PREVIOUS.

Behavioral notes:

  • Invalid entries in APP_KEY_PREVIOUS are 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).

5.3 Custom ciphers & framing

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_size must match openssl_cipher_iv_length().
  • For AEAD, tag_size must 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.

5.4 Hasher internals & calibration

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 bytes

This 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.

5.5 SQL injection heuristics

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.


6. Security Model & Threat Considerations

What this system defends against

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

What it explicitly does not do

  • 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() — use htmlspecialchars() or a templating engine.
  • No SQL injection prevention — only heuristics. Parameterized queries are mandatory.

Recommended defense layers

[HTTPS] → [WAF / rate limit] → [This module] → [Parameterized SQL] → [Least-privilege DB]

7. Configuration Reference

config/encryption.php

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.

config/encryption.php → jwt.*

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.

config/hashing.php

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.

Environment variables

APP_KEY=base64:...
APP_KEY_PREVIOUS=base64:...
JWT_SECRET=...
HASH_PEPPER=...

8. API Quick Reference

Bangwe

// 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()

Hasher

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)

Password

new Password(string $password, ?PasswordPolicy, ?Hasher)
bool   validate()
array  errors()             // string[]
int    strength()           // 0–4
string hash()
bool   verify(string $hash)

CSRFToken

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')

SQLInjectionChecker

new SQLInjectionChecker(array $customPatterns = [])
bool  checkString(string $input)
array checkArray(array $data)
string sanitizeInput(string $input)  // ⚠️ avoid — use parameterized queries

9. Error Codes

SecurityException 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

10. Operational Checklist

Before shipping to production:

  • APP_KEY generated with Bangwe::generateKey() and stored in secrets manager (not .env committed to git)
  • JWT_SECRET ≥ 32 bytes of random entropy
  • HASH_PEPPER set to a random 32-byte value (optional but recommended)
  • HASH_COST calibrated on production hardware (Hasher::calibrateBcryptCost())
  • All cookies use Secure=true, HttpOnly=true, SameSite=Lax|Strict
  • HTTPS enforced — Secure cookies and HSTS headers
  • All SQL uses prepared statements — SQLInjectionChecker is secondary
  • Rate limiting on /login, /register, token refresh endpoints
  • JWT expiry short (≤ 1 hour) with refresh-token flow
  • Rotation runbook documented for APP_KEY changes
  • Logging captures 201xx errors + CSRF failures for monitoring
  • Backups of APP_KEY — losing it means losing all encrypted data

11. FAQ

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:

  1. Short TTL (e.g., 15 minutes) + refresh tokens
  2. JTI blacklist — store jti of revoked tokens in Redis until exp
  3. Session-bound JWT — include a sid claim 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:

  1. Login succeeds → $csrf->regenerate() → send new token to client
  2. 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.


Clone this wiki locally