Security
This document describes the security architecture of ForenDev's License module and the audit results.
Threat Model
The License module handles:
- License keys — short-lived secrets that grant access to features
- Activation codes — per-holder secrets used to activate devices
- X.509 private keys — credentials for mTLS authentication
- API tokens — long-lived credentials for programmatic access
- Webhook secrets — HMAC shared secrets for verifying inbound events
- Authorization tokens — per-product token grants
Threat actors:
- External attackers probing the public API for weaknesses
- Compromised admin sessions viewing/manipulating data
- Malicious admins exporting sensitive material
- Compromised webhook consumers receiving forged events
- Compromised clients attempting license reuse or tampering
Authentication
API tokens (programmatic access)
- Format: 64 hex chars (32 random bytes)
- Storage: SHA-256 hash in
api_tokens.token_hash. Plaintext is shown only on creation. - Verification:
hash('sha256', $bearer) → lookup token_hash. Comparison is constant-time. - Lifetime: Configurable expiry. Default: no expiry (revoke manually).
- Rotation: Generate new token, revoke old one. The two can briefly coexist.
- Scope:
licenseonly. Other scopes can be added for future modules.
Authorization tokens (per-product)
- Format: 64 hex chars
- Storage: SHA-256 hash in
license_authorization_tokens.token_hash - Lifetime: Configurable per-token (1-3650 days)
- Revocation: Soft delete via
is_active=0. Re-issuance invalidates old.
Session cookies (admin console)
- Cookie name:
FOREN_ADMIN(configurable) - HttpOnly, SameSite=Strict, Secure (HTTPS only)
- Lifetime: 24 hours
- Regeneration: On every successful login (
session_regenerate_id) - Logout: Destroys session, clears cookies
Two-factor authentication (TOTP)
Admins may enable TOTP-based 2FA from System → Security in the admin console.
- Algorithm: RFC 6238 TOTP, SHA-1 (RFC default), 6 digits, 30-second step
- Secret length: 32 base32 characters (160 bits, from 20 random bytes)
- Tolerance: ±1 time step (±30s) to absorb clock drift
- Issuer label:
ForenDev - Storage:
admins.totp_secret, encrypted with AES-256-GCM. The
encryption key is SHA-256 of license.v2_view_key when configured,
otherwise SHA-256 of the configured private key path. The plaintext
secret is never stored.
- Storage format:
base64( iv[12] || tag[16] || ciphertext ) - Provisioning: the secret is shown once at setup; the admin must confirm
with a valid code before it is persisted
- Deactivation: requires a currently valid 6-digit code, so a hijacked
session cannot silently disable 2FA
- Implementation:
license/lib/TOTPService.php— pure PHP, no external
dependency (no Composer, no PECL extension required). Verified against the
RFC 4226 HOTP vectors and RFC 4648 base32 vectors by
tests/test_totp_2fa.php.
Why base32 matters: authenticator apps expect a base32 secret. A base64 secret (note the
+/=characters) is silently rejected by every standard app, so codes would never validate.generateSecret()must stay base32.
Login flow when 2FA is enabled:
POST /admin/login.php (username + password)
→ Auth::attemptStage() returns 'needs_2fa'
→ session stores pending admin id, redirects to /admin/verify-2fa.php
POST /admin/verify-2fa.php (6-digit code OR backup code)
→ Auth::verify2FA() → session promoted to authenticated
Single-use 2FA backup codes
Losing the authenticator app would otherwise lock an administrator out permanently, so 8 backup codes are issued when 2FA is first confirmed.
- Format:
XXXX-XXXX-XXXX-XXXXusing an unambiguous alphabet
(23456789ABCDEFGHJKMNPQRSTWXYZ — no 0/O, 1/I, L, U, V), so codes
survive transcription from print
- Input tolerance: dashes, spaces and case are normalised away before
lookup, so k7qp 3rwm matches K7QP-3RWM
- Storage: only
SHA-256(code)inadmin_2fa_backup_codes.code_hash. A
database leak does not reveal usable codes
- Consumption: single-use. Redemption is a conditional
UPDATE ... WHERE used_at IS NULL, so two concurrent requests cannot both
succeed with the same code
- Issuance: "重新签发备用码" on System → Security replaces all unused
codes and voids the previous set
- Display: plaintext codes are shown exactly once, immediately after
issuance, then removed from the session
- Cleanup: disabling 2FA deletes all codes for that administrator
- Audit:
issue_backup_codesanduse_backup_codeare written to
admin_audit_logs
Brute-force lockout
- Threshold: 5 consecutive failed password attempts
- Lock duration: 15 minutes (
admins.locked_until) - Counters:
admins.failed_logins,admins.failed_login_at - Reset: successful login clears the counter; an expired lockout is
cleared lazily on the next attempt
- Audit: every failure writes a
login_failedrow toadmin_audit_logs
Lockout is per-account (not per-IP), so it does not enable an attacker to deny service to a known username by failing the lockout window repeatedly from a shared IP.
Admin session registry
Authenticated admin sessions are tracked in admin_sessions so they can be
audited and revoked:
| Field | Purpose |
|---|---|
| --- | --- |
id | SHA-256 hex of the PHP session id (raw id never stored) |
admin_id | Owning administrator |
ip | Client IP at login |
user_agent | Browser / client fingerprint |
created_at | Login time |
last_active_at | Last observed request |
revoked_at | Set when revoked; NULL while active |
Revocation is available per-session and bulk ("revoke all other sessions") from System → Security. Admins may only revoke their own sessions.
Administrator password policy
New administrator passwords must satisfy all five rules:
- at least 12 characters
- at least one uppercase letter
- at least one lowercase letter
- at least one digit
- at least one special (non-alphanumeric) character
Enforced server-side in admin/system.php (create_admin) and mirrored in
TOTPService::passwordStrengthReport(). The admins table stores only a
password_hash (PASSWORD_DEFAULT).
CSRF protection
All admin POST forms include a hidden _csrf field. The token is:
- Generated on session creation (32 random bytes hex)
- Stored in
$_SESSION['csrf'] - Validated using
hash_equals(constant-time) on every POST
API endpoints (under /license/api/) do NOT require CSRF tokens — they use Bearer tokens instead. This is intentional: cookies are not used for API auth.
Authorization Matrix
Public endpoints (no auth)
| Endpoint | Rationale |
|---|---|
| --- | --- |
GET /health/ | Liveness probe for monitoring |
GET /public-key/ | Public key, by definition |
POST /activate/ | Activation is the entry point; rate limited |
POST /validate-offline/ | Offline verifier needs to validate without admin |
POST /authorize/ | Feature gate check, similar to validate |
POST /authorize/token/ | Token auth IS the credential |
POST /certs/validate/ | Cert validation is public info |
GET /certs/crl/ | CRL is meant to be public |
GET /certs/download-key/?token= | Token-based, not auth-based |
Authenticated endpoints (Bearer API token)
| Endpoint | Method | Auth |
|---|---|---|
| --- | --- | --- |
/plans/ | GET | Optional (scope through auth) |
/plans/ | POST/PUT/DELETE | Required |
/stats/ | GET | Required |
/webhooks/ | GET/DELETE | Required |
/webhooks/register/ | POST | Required |
/certs/generate/ | POST | Required |
/certs/revoke/ | POST | Required |
/renew/ | POST | Required |
/upgrade/ | POST | Required |
Signature Verification
License payloads are signed using Ed25519 (libsodium or sodium_compat polyfill).
Signing process (server):
- Build canonical JSON payload (fields in fixed order)
- v1: sign with Ed25519 secret key
- v2: encrypt with
FOREN_LICENSE_V2_VIEW_KEY(32 bytes), then sign
Verification process (client):
- Get public key from
/license/api/coforen/public-key/ - Decode base64
- For v1: verify signature against canonical JSON
- For v2: verify signature, then decrypt with shared v2 key
Key configuration:
FOREN_LICENSE_PRIVATE_KEY— Ed25519 private key (base64) for signingFOREN_LICENSE_PUBLIC_KEY— Public key for verification (clients fetch this)FOREN_LICENSE_V2_VIEW_KEY— 32 bytes base64, used for v2 envelope encryption
Security note: The v2 view key is REQUIRED. If not configured, LicenseSigner::v2ViewKey() throws a RuntimeException — there is no silent fallback to a known seed. This prevents accidental misconfiguration from weakening v2 protection.
Private Key Storage (X.509 certificates)
X.509 client certificates generated by the License module have their private keys:
- Encrypted at rest: AES-256-CBC with a per-deployment key
- Encryption key derivation, in order:
license.cert_encryption_key(32 bytes base64) if set. This key is
not wired to an environment variable — it must be added to
license/config.php by hand, or the derivation silently drops to the
next option.
- SHA-256 of the Ed25519 private key file's *contents* (deterministic —
survives restarts and reformatting, because the bytes are whitespace- normalised before hashing)
- Random fallback (
random_bytes(32))
- Never returned in API responses (CRITICAL fix)
- Retrieved via single-use download token (30-minute TTL)
- Admin UI shows download URL, not the key itself
Storage format: AES-256-CBC:<base64(iv)>:<base64(ciphertext)>
The random fallback silently breaks certificate download. Because the random key is generated per PHP process and never persisted, every certificate private key encrypted under it becomes permanently undecryptable the moment that process ends — including across a normal deploy, a PHP-FPM reload, or
opcachereset. A client that downloaded the key immediately is fine; one that returns for it 20 minutes later (still inside the 30-minute token TTL) gets nothing.To make this deterministic, either set
license.cert_encryption_keyexplicitly, or make surelicense.private_key_pathpoints at a readable key file so derivation never reaches the random branch. Verify which branch a deployment takes with:php -r 'require "license/bootstrap.php"; $p = (string) app_config("license.private_key_path", ""); printf("private_key_path=%s readable=%s\n", $p ?: "(unset)", ($p && is_file($p) && is_readable($p)) ? "yes" : "NO");'If that prints
readable=NO, certificates are being encrypted under a key that does not survive the request.
Webhook Security
Outbound webhooks include:
X-License-Webhook-Signature: sha256=<hmac-sha256-hex>
X-License-Event: <event_type>
Content-Type: application/json
Consumer verification (Python):
import hmac, hashlib
def verify(secret: str, body: bytes, sig_header: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
received = sig_header.removeprefix("sha256=")
return hmac.compare_digest(expected, received)
Failed deliveries retry up to 5 times with exponential backoff. After max retries, the event is marked as permanently failed and requires manual retry via admin UI.
Rate Limiting
Per-IP rate limits via RateLimiter class. Stored in MySQL license_rate_limits table for atomicity (no race conditions under concurrent requests).
Defaults (configurable in license/config.php):
- API endpoints: 60/min
- Activation: 30/min
- Statistics: 30/min
When exceeded: HTTP 429 + {"ok": false, "error_code": "rate_limited"}
Input Validation
Strict validation enforced
| Field | Validation |
|---|---|
| --- | --- |
machine_code | 1-128 chars, [A-Za-z0-9_\-.]+ |
machine_fingerprint | ^sha256:[a-f0-9]{64}$ |
license_key | Looked up; existence check |
| Date filters | ^\d{4}-\d{2}-\d{2}$ |
| Numeric IDs | (int) cast |
| URLs (webhooks) | filter_var(..., FILTER_VALIDATE_URL) |
SQL injection prevention
All database queries use prepared statements with bound parameters. Direct string interpolation of values was eliminated in the C2 fix.
The schema uses:
- All placeholders are
?for values - Identifier interpolation NEVER happens
- Even boolean / NULL values are passed via bound parameters with conditional SQL branches
XSS prevention
All output is escaped:
- HTML:
admin_h()(useshtmlspecialchars) - JSON:
Response::json()(usesjson_encode) - JavaScript context:
json_encode()for embedding in inline JS (D5 fix)
CSRF
All admin POST forms include _csrf hidden field, validated via hash_equals (constant-time).
Audit Logging
Every admin action is logged in admin_audit_logs:
| Field | Description |
|---|---|
| --- | --- |
admin_id | Who performed the action |
action | What they did (login, revoke, create, ...) |
resource_type | Type of resource affected |
resource_id | Specific resource |
detail_json | Additional context |
ip | Source IP |
created_at | When |
Webhook events are also logged in license_events for forensics.
Audit Results (2026-09)
A code review uncovered 21 items across CRITICAL/HIGH/MEDIUM/LOW/MINOR severity. All have been fixed.
CRITICAL (3)
| # | Issue | Status | Fix |
|---|---|---|---|
| --- | --- | --- | --- |
| C1 | Certificate private key returned in plaintext API response | ✅ Fixed | Removed key_pem from API response; replaced with single-use download token |
| C2 | SQL injection risk in AuthorizationService | ✅ Fixed | Rewrote to use fully parameterized queries |
| C3 | Predictable OpenSSL temp file path | ✅ Fixed | Use tempnam() + register_shutdown_function cleanup |
HIGH (4)
| # | Issue | Status | Fix |
|---|---|---|---|
| --- | --- | --- | --- |
| H4 | Health/public-key endpoints expose infrastructure info | ✅ Documented | Documented as public by design |
| H5 | topHolders invalid GROUP BY | ✅ Fixed | Added all non-aggregated columns to GROUP BY |
| H6 | Inconsistent API authentication | ✅ Documented | See Authentication Matrix above |
| H7 | Missing database indexes | ✅ Fixed | Migration 011 added 10 indexes |
MEDIUM (5)
| # | Issue | Status | Fix |
|---|---|---|---|
| --- | --- | --- | --- |
| M1 | Hardcoded "CoForen" throughout codebase | ✅ Fixed | Centralized in license/config.php |
| M2 | Magic numbers not extracted | ✅ Fixed | Added default_grace_days, plan_colors config |
| M3 | v2_view_key predictable fallback | ✅ Fixed | Throws RuntimeException if not configured |
| M4 | Webhook retry has no max limit | ✅ Fixed | Max 5 retries with exponential backoff |
| M5 | machine_code no length validation | ✅ Fixed | 1-128 chars, [A-Za-z0-9_-.]+ |
LOW (5)
| # | Issue | Status | Fix |
|---|---|---|---|
| --- | --- | --- | --- |
| L1 | Inconsistent error response format | ✅ Standardized | All use {ok, error_code, message} |
| L2 | V2_VIEW_KEY_SEED contains date | ✅ Removed | Constant no longer referenced |
| L3 | RateLimiter race condition | ⚠️ Partially mitigated | File-based with LOCK_EX; adequate for single-node PHP-FPM but not fully atomic under high concurrent multi-worker load. Consider migrating to MySQL INSERT ... ON DUPLICATE KEY UPDATE for production with many PHP-FPM workers. |
| L4 | Duplicate feature list | ✅ Fixed | Reads from config |
| L5 | confirm() XSS via plan slug | ✅ Fixed | Uses json_encode for JS context |
MINOR (4)
| # | Issue | Status |
|---|---|---|
| --- | --- | --- |
| Multiple unified_footer() calls | ✅ Cleaned up | |
| Custom exception type | ✅ Added PlanInUseException | |
| Method if-chains | ✅ Replaced with early returns | |
| WHERE clause string-built | ✅ Added date validation regex |
Secure Deployment Checklist
Before going to production:
- [ ]
FOREN_DB_PASSset to a strong, unique password - [ ]
FOREN_LICENSE_V2_VIEW_KEYset (32 bytes base64) - [ ]
FOREN_LICENSE_PRIVATE_KEYandFOREN_LICENSE_PUBLIC_KEYpaths valid - [ ] TLS certificate installed and
force_https=true - [ ] Nginx/Apache restricts access to
license/storage/,license/migrations/,data/,license/scripts/ - [ ] Database backups scheduled
- [ ] Admin password rotated from default
- [ ] Webhook consumer endpoint verifies signatures
- [ ] Monitoring on
/health/and webhook delivery rate
See DEPLOYMENT.md for details.