foren.dev

Security

This document describes the security architecture of ForenDev's License module and the audit results.

Threat Model

The License module handles:

  1. License keys — short-lived secrets that grant access to features
  2. Activation codes — per-holder secrets used to activate devices
  3. X.509 private keys — credentials for mTLS authentication
  4. API tokens — long-lived credentials for programmatic access
  5. Webhook secrets — HMAC shared secrets for verifying inbound events
  6. 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: license only. 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-XXXX using 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) in admin_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_codes and use_backup_code are 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_failed row to admin_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:

FieldPurpose
------
idSHA-256 hex of the PHP session id (raw id never stored)
admin_idOwning administrator
ipClient IP at login
user_agentBrowser / client fingerprint
created_atLogin time
last_active_atLast observed request
revoked_atSet 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)

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

EndpointMethodAuth
---------
/plans/GETOptional (scope through auth)
/plans/POST/PUT/DELETERequired
/stats/GETRequired
/webhooks/GET/DELETERequired
/webhooks/register/POSTRequired
/certs/generate/POSTRequired
/certs/revoke/POSTRequired
/renew/POSTRequired
/upgrade/POSTRequired

Signature Verification

License payloads are signed using Ed25519 (libsodium or sodium_compat polyfill).

Signing process (server):

  1. Build canonical JSON payload (fields in fixed order)
  2. v1: sign with Ed25519 secret key
  3. v2: encrypt with FOREN_LICENSE_V2_VIEW_KEY (32 bytes), then sign

Verification process (client):

  1. Get public key from /license/api/coforen/public-key/
  2. Decode base64
  3. For v1: verify signature against canonical JSON
  4. For v2: verify signature, then decrypt with shared v2 key

Key configuration:

  • FOREN_LICENSE_PRIVATE_KEY — Ed25519 private key (base64) for signing
  • FOREN_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:

  1. Encrypted at rest: AES-256-CBC with a per-deployment key
  2. 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))
  1. Never returned in API responses (CRITICAL fix)
  2. Retrieved via single-use download token (30-minute TTL)
  3. 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 opcache reset. 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_key explicitly, or make sure license.private_key_path points 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

FieldValidation
------
machine_code1-128 chars, [A-Za-z0-9_\-.]+
machine_fingerprint^sha256:[a-f0-9]{64}$
license_keyLooked 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() (uses htmlspecialchars)
  • JSON: Response::json() (uses json_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:

FieldDescription
------
admin_idWho performed the action
actionWhat they did (login, revoke, create, ...)
resource_typeType of resource affected
resource_idSpecific resource
detail_jsonAdditional context
ipSource IP
created_atWhen

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)

#IssueStatusFix
------------
C1Certificate private key returned in plaintext API response✅ FixedRemoved key_pem from API response; replaced with single-use download token
C2SQL injection risk in AuthorizationService✅ FixedRewrote to use fully parameterized queries
C3Predictable OpenSSL temp file path✅ FixedUse tempnam() + register_shutdown_function cleanup

HIGH (4)

#IssueStatusFix
------------
H4Health/public-key endpoints expose infrastructure info✅ DocumentedDocumented as public by design
H5topHolders invalid GROUP BY✅ FixedAdded all non-aggregated columns to GROUP BY
H6Inconsistent API authentication✅ DocumentedSee Authentication Matrix above
H7Missing database indexes✅ FixedMigration 011 added 10 indexes

MEDIUM (5)

#IssueStatusFix
------------
M1Hardcoded "CoForen" throughout codebase✅ FixedCentralized in license/config.php
M2Magic numbers not extracted✅ FixedAdded default_grace_days, plan_colors config
M3v2_view_key predictable fallback✅ FixedThrows RuntimeException if not configured
M4Webhook retry has no max limit✅ FixedMax 5 retries with exponential backoff
M5machine_code no length validation✅ Fixed1-128 chars, [A-Za-z0-9_-.]+

LOW (5)

#IssueStatusFix
------------
L1Inconsistent error response format✅ StandardizedAll use {ok, error_code, message}
L2V2_VIEW_KEY_SEED contains date✅ RemovedConstant no longer referenced
L3RateLimiter race condition⚠️ Partially mitigatedFile-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.
L4Duplicate feature list✅ FixedReads from config
L5confirm() XSS via plan slug✅ FixedUses json_encode for JS context

MINOR (4)

#IssueStatus
---------
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_PASS set to a strong, unique password
  • [ ] FOREN_LICENSE_V2_VIEW_KEY set (32 bytes base64)
  • [ ] FOREN_LICENSE_PRIVATE_KEY and FOREN_LICENSE_PUBLIC_KEY paths 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.