# Link Shield PHP Replica (Military Grade)

A hardened PHP replica of the **Link Shield** system from Oracle Messenger. This version adds military-grade security upgrades including HKDF key derivation, HMAC-authenticated tokens, constant-time validation, trusted proxy IP resolution, Redis-backed abuse guarding, SQLite WAL audit logging with HMAC integrity, circuit breakers, JWT admin sessions, and Prometheus-compatible metrics.

## Architecture

This replica maps the original Node.js modules into PHP classes, plus new hardened infrastructure:

| JS Module | PHP Class | Purpose |
|-----------|-----------|---------|
| `placeholder-engine.js` | `PlaceholderEngine` | Placeholder substitution for email templates |
| `link-shield-config.js` | `LinkShieldConfig` | Configuration normalization, **HKDF + AES-256-GCM + HMAC** token crypto, gateway URL builder |
| `link-shield-provider-detector.js` | `LinkShieldProviderDetector` | MX record-based email provider classification with **in-memory cache** |
| `link-shield-captcha.js` | `LinkShieldCaptcha` | Turnstile / reCAPTCHA verification with **circuit breaker** support |
| `link-shield-rewriter.js` | `LinkShieldRewriter` | HTML and plain-text link rewriting into shielded URLs |
| `link-shield-server.js` | `LinkShieldGateway` | HTTP request handler with **Redis abuse guard**, **SQLite audit log**, **JWT admin auth**, **metrics** |
| — | `TrustedProxyResolver` | **CIDR-based trusted proxy allowlist** preventing X-Forwarded-For spoofing |
| — | `CircuitBreaker` | Fail-fast circuit breaker for external CAPTCHA verification |
| — | `JwtAuth` | Minimal HS256 JWT issuer/verifier with scope enforcement |
| — | `MetricsCollector` | Prometheus text exposition format metrics |
| — | `RedisAbuseStore` | Atomic Redis-backed rate limiting, lockouts, and denylist |
| — | `SqliteEventStore` | WAL-backed append-only audit log with optional **HMAC integrity** |
| — | `SqliteUsageStore` | Atomic SQLite-backed token usage tracking with reservation |

## Requirements

- PHP 8.1+ (uses typed properties and `enum`-ready syntax)
- OpenSSL extension with AES-256-GCM support (PHP 7.1+)

## Installation

No Composer dependencies are required. Simply include the `src/` files or autoload the namespace `LinkShield`.

```php
require_once 'src/PlaceholderEngine.php';
require_once 'src/LinkShieldConfig.php';
require_once 'src/LinkShieldProviderDetector.php';
require_once 'src/LinkShieldCaptcha.php';
require_once 'src/LinkShieldRewriter.php';
require_once 'src/LinkShieldGateway.php';
```

## Quick Start

### 1. Generate an encryption key

```php
use LinkShield\LinkShieldConfig;

$key = LinkShieldConfig::generateEncryptionKeyB64();
echo $key;
```

### 2. Protect a URL for a recipient

```php
use LinkShield\LinkShieldRewriter;

$protectedUrl = LinkShieldRewriter::protectUrlForRecipient([
    'url' => 'https://example.com/offer',
    'recipientEmail' => 'user@example.com',
    'config' => [
        'linkShield' => [
            'enabled' => true,
            'gateway' => [
                'enabled' => true,
                'domain' => 'shield.example.com',
                'basePath' => '/ls',
            ],
            'token' => [
                'encryptionKeyB64' => $key,
                'ttlSeconds' => 604800,
            ],
        ],
    ],
    'campaignId' => 'campaign-1',
]);

echo $protectedUrl;
// => https://shield.example.com/ls/<encrypted-token>
```

### 3. Rewrite links in HTML

```php
$result = LinkShieldRewriter::rewriteHtmlLinksForRecipient([
    'html' => '<a href="https://example.com">Click here</a>',
    'recipientEmail' => 'user@example.com',
    'config' => $config,
]);

echo $result['html'];
echo $result['rewritten']; // number of rewritten links
echo $result['skipped'];   // number of skipped links
```

### 4. Run the gateway

Use the provided `public/index.php` with PHP's built-in server:

```bash
php -S localhost:8088 -t public
```

Or integrate `LinkShieldGateway::route()` into your existing framework router.

## Military-Grade Features

### 1. HKDF + HMAC Token Authentication
`LinkShieldConfig::sealToken()` now derives separate encryption and MAC keys via HKDF-SHA256 (RFC 5869). Tokens use a `v2.` prefix and include an HMAC-SHA256 over the ciphertext components. `openToken()` verifies the HMAC in constant time using `hash_equals()` and returns `null` on any failure to prevent timing side-channels.

### 2. Trusted Proxy IP Resolution
`TrustedProxyResolver` validates `X-Forwarded-For` against a CIDR allowlist (e.g., `10.0.0.0/8`). If the direct `REMOTE_ADDR` is not trusted, forwarded headers are ignored entirely, preventing IP spoofing and bypass of rate limits.

### 3. Redis-Backed Abuse Guard
`RedisAbuseStore` replaces file-based rate limiting with atomic Redis `ZADD`/`ZREMRANGEBYSCORE` operations. Supports instantaneous denylist checks, failure tracking, and lockouts without file lock contention.

### 4. SQLite WAL Audit Log with HMAC Integrity
`SqliteEventStore` writes events to SQLite with WAL mode for concurrency. Each row includes an optional HMAC-SHA256. The `query()` method verifies integrity and reports `tampered` rows. Automatic retention pruning and row limits prevent unbounded growth.

### 5. Circuit Breaker for CAPTCHA
`CircuitBreaker` wraps CAPTCHA verification. After 3 consecutive failures/timeouts, the breaker opens and returns `captcha_service_unavailable` instantly, preventing PHP-FPM worker exhaustion during provider outages.

### 6. JWT Admin Authentication
When `admin.enabled` is true, `handleStats()` and `handleMonitor()` require a valid HS256 JWT with the correct scope (`linkshield:read:stats` or `linkshield:read:monitor`). Pass the JWT via `?jwt=` query parameter or `Authorization: Bearer` header.

### 7. Prometheus Metrics
When `metrics.enabled` is true, the gateway exposes counters and histograms at `/ls/metrics` in Prometheus text format. Metrics include `linkshield_requests_total`, `linkshield_denies_total`, `linkshield_redirects_total`, and `linkshield_redirect_duration_seconds`.

### 8. HTTPS Enforcement & Security Headers
The gateway enforces HTTPS in production, emits `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, and a configurable `Content-Security-Policy`.

### 9. Generic Error Responses
All token validation failures return identical HTML bodies with a `X-Request-ID` correlation header. No implementation details (format error vs MAC failure vs decryption failure) are leaked.

## Configuration Reference

### `enabled` (bool)
Master switch for Link Shield.

### `gateway`
- `enabled` — Whether the gateway HTTP handler is active.
- `domain` — Public domain of the gateway (e.g. `shield.example.com`).
- `port` — Local listening port (used for local development).
- `basePath` — URL path prefix (default `/ls`).
- `monitorToken` — Secret token for accessing `/ls/stats` and `/ls/monitor`.

### `token`
- `encryptionKeyB64` — 32-byte AES-256-GCM key in Base64.
- `ttlSeconds` — Token expiry time (default 7 days).
- `singleUse` — If true, the token becomes permanently dead after one successful redirect.

### `limits`
- `enabled` — Enable usage tracking and enforcement.
- `maxSuccessfulRedirects` — Max successful redirects per token (default 1).
- `maxTotalAttempts` — Ceiling for all attempts (success + denied); 0 = unlimited.
- `usageMaxEntries` — Hard cap on token usage rows (default 100,000).

### `allowlist`
- `mode` — `perRecipientToken` or `off`.
- `enforceRecipientMatch` — Requires the query email to match the token-bound email hash.
- `appendRecipientProof` — Appends `?e64=<base64url(email)>` to shielded URLs.

### `routing`
- `mode` — `default` (use token URL) or `provider` (dynamic based on MX lookup).
- `urls` — Provider-specific destination URLs: `gsuit`, `o365`, `webmail`.

### `abuse`
- `enabled` — Enable rate limiting, lockout, and denylist.
- `rateLimit.windowSeconds` — Rolling window for per-IP attempts.
- `rateLimit.maxAttemptsPerIp` — Max attempts within the window.
- `lockout.windowSeconds` — Failure counting window.
- `lockout.maxFailures` — Failures before IP lockout.
- `lockout.lockSeconds` — Duration of the lockout.
- `denylist.enabled` — Persistent denylist stored on disk.
- `denylist.ttlSeconds` — Denylist entry TTL; 0 = permanent.

### `logging`
- `enabled` — NDJSON event logging.
- `retentionDays` — Rotated log retention.
- `maxBytesPerFile` — Soft cap before log rotation.
- `storeFileName` — Events filename.

### `captcha`
- `enabled` — Serve CAPTCHA challenge on GET before allowing POST redirect.
- `provider` — `turnstile` or `recaptcha`.
- `mode` — `managed` | `non-interactive` | `invisible` (Turnstile) or `v2-checkbox` | `v2-invisible` | `v3` (reCAPTCHA).
- `siteKey` / `secretKey` — Provider credentials.
- `action` — Action name for score-based checks.
- `scoreThreshold` — Minimum score for reCAPTCHA v3 (default 0.5).
- `circuitBreaker` — Circuit breaker settings for CAPTCHA provider outages.
  - `enabled` — Enable fail-fast behavior (default `true`).
  - `failureThreshold` — Consecutive failures before opening (default `3`).
  - `timeoutSeconds` — Seconds before half-open test (default `30`).

### `security`
- `enforceHttps` — Reject non-TLS requests at application layer (default `true`).
- `trustedProxyNetworks` — Array of trusted CIDRs for `X-Forwarded-For` (default RFC1918 + localhost).
- `maxTokenBytes` — Maximum token size in bytes before rejection (default `4096`).
- `hstsMaxAge` — `Strict-Transport-Security` max-age in seconds (default `31536000`).
- `contentSecurityPolicy` — CSP string for interstitial and challenge pages.

### `admin`
- `enabled` — Enable JWT-based admin auth for stats/monitor (default `false`).
- `jwtSecret` — HS256 secret (minimum 32 bytes). Generate with `bin2hex(random_bytes(32))`.
- `jwtTtlSeconds` — Admin session expiry (default `300`).
- `allowedScopes` — Array of permitted JWT scopes.

### `metrics`
- `enabled` — Expose Prometheus metrics endpoint (default `false`).
- `endpoint` — Metrics path relative to `basePath` (default `/metrics`).

## Class & Function Reference

### `PlaceholderEngine`

Shared placeholder helpers used by the mailer and gateway.

- `buildStandardPlaceholders(array $options): array`
  Builds a map of placeholders (`{email}`, `{date}`, `{domain_main}`, `[RECIPIENT]`, etc.) from recipient email, SMTP config, sender name, and current time.

- `applyStandardPlaceholders(string $input, array $options): string`
  Replaces all placeholders in a string.

- `applyLinkPlaceholders(string $input, array $options): string`
  Same as above but excludes link placeholders to avoid recursion.

### `LinkShieldConfig`

Configuration and cryptography.

- `defaultLinkShieldConfig(): array`
  Returns the full default configuration tree.

- `normalizeLinkShieldConfig(array $input, array $options): array`
  Deep-merges user config with defaults, sanitizes all fields, and optionally generates secrets.

- `generateEncryptionKeyB64(): string`
  Generates a new 256-bit Base64 key.

- `isValidEncryptionKeyB64(string $keyB64): bool`
  Validates that the decoded key is exactly 32 bytes.

- `sealToken(array $payload, string $keyB64): string`
  Encrypts a JSON payload with AES-256-GCM and returns `iv.tag.ciphertext` in Base64url.

- `openToken(string $token, string $keyB64): array`
  Decrypts a token and returns the payload array.

- `buildGatewayBaseUrl(array $cfg): string`
  Constructs the public gateway URL from domain, port, and basePath.

- `normalizeProviderRoutingUrls(array $urls, array $options): array`
  Normalizes and sanitizes provider routing URLs (accepts aliases like `gsuite` -> `gsuit`).

- `normalizeHostedDeliveryProfile(array $profile): array`
  Normalizes installer delivery profile fields.

### `LinkShieldProviderDetector`

- `normalizeEmail(string $input): string`
- `emailDomain(string $input): string`
- `providerLabel(string $provider): string`
- `routeTargetForProvider(string $provider, array $urls): string`
- `classifyMxHostnames(array $hostnames): string`
  Returns `gsuit`, `o365`, or `webmail` based on MX hostnames.

- `getProviderByMx(string $domain, array $options): string`
  Performs a DNS MX lookup and classifies the result.

- `classifyRecipientEmail(string $email, array $options): array`
  Returns `{email, domain, provider, label}`.

### `LinkShieldCaptcha`

- `normalizeCaptchaSettings(array $input): array`
  Normalizes CAPTCHA configuration.

- `buildCaptchaChallengePage(array $args): string`
  Generates a self-contained HTML challenge page with CSP, provider-specific widget markup, and inline JavaScript.

- `verifyCaptchaToken(array $args): array`
  Verifies a CAPTCHA response token with the provider's site-verify endpoint. Returns `{ok, reason, result}`.

- `verifyCaptchaForm(array $args): array`
  Reads the form body and verifies the CAPTCHA response field automatically.

- `siteVerifyRequest(array $args): array`
  Makes the HTTPS POST to Turnstile or reCAPTCHA verify endpoint.

- `buildCaptchaPassCookie(array $args): string`
  Builds an HMAC-signed pass cookie so verified users can skip repeated challenges.

- `isCaptchaPassCookieValid(array $req, array $args): bool`
  Validates the pass cookie using `hash_equals`.

- `readFormBody(): array`
  Reads and parses `php://input` into an associative array (max 64KB).

### `LinkShieldRewriter`

- `protectUrlForRecipient(array $args): string`
  Encrypts a destination URL into a per-recipient shielded gateway link.

- `rewriteTextLinksForRecipient(array $args): array`
  Rewrites plain-text http(s) URLs. Returns `{text, rewritten, skipped}`.

- `rewriteHtmlLinksForRecipient(array $args): array`
  Uses `DOMDocument` to rewrite `<a href>` links. Returns `{html, rewritten, skipped}`.

- `rewriteHtmlTextLinksForRecipient(array $args): array`
  Walks the DOM and rewrites URLs inside text nodes that are not inside `<a>`, `<script>`, or `<style>`.

- `isAlreadyShielded(string $url, string $base): bool`
  Checks if a URL is already a gateway link.

- `maybeAppendRecipientProof(string $url, string $email, array $cfg): string`
  Appends `?e64=<base64url(email)>` when the setting is enabled.

### `LinkShieldGateway`

The main request handler. Since PHP is stateless per request, this class loads and persists state via file-backed stores with `flock` locking.

- `__construct(array $linkShieldCfg, array $opts)`
  Validates encryption key and monitor token, initializes `AbuseGuard`, `TokenUsageStore`, and event logger.

- `route(): void`
  Entry point. Routes the current request to:
  - `/ls/health` — JSON health check.
  - `/ls/` — Gateway home page.
  - `/ls/stats?t=<monitorToken>` — Aggregated usage statistics.
  - `/ls/monitor?t=<monitorToken>` — Live HTML monitor dashboard.
  - `/ls/<token>` — Interstitial GET or POST redirect handler.
  - `/ls/dl/...` — Installer delivery (returns 501 in this replica).

#### Internal routing behavior for `/ls/<token>`

1. **Bot UA check** — Known scanner user agents receive `403` immediately.
2. **Abuse check** — Rate limit / lockout / denylist enforcement.
3. **Token decryption** — AES-256-GCM `openToken`. Bad tokens increment failure counters.
4. **Recipient match** — If `enforceRecipientMatch` is on, the query `?e` or `?e64` must hash-match the signed `emh`.
5. **Expiry check** — Tokens expire after `ttlSeconds` plus a 60-second clock skew margin.
6. **Single-use / limit checks** — Consumed tokens return `410`.
7. **GET** — Serves the interstitial page. If CAPTCHA is enabled, serves the challenge page instead.
8. **POST** — Verifies CAPTCHA (if enabled), atomically reserves a success slot, applies provider-based dynamic routing if configured, and redirects with `302`.

#### File-backed stores

Because PHP does not keep in-memory state between requests, the replica uses:
- `link-shield-denylist.json` — Persistent denylist with optional TTL.
- `link-shield-rate.json` — Per-IP rolling request windows.
- `link-shield-fail.json` — Per-IP failure counters for lockout.
- `link-shield-lock.json` — Active IP lockout timestamps.
- `link-shield-usage.json` — Per-JTI token usage stats (attempts, success, downloads).
- `link-shield-events.ndjson` — Append-only NDJSON event log.

All writes use atomic `rename()` and `flock()` for concurrency safety.

### `TrustedProxyResolver`

Resolves the true client IP while preventing `X-Forwarded-For` spoofing.

- `__construct(array $trustedNetworks)` — CIDR strings or exact IPs to trust.
- `resolveClientIp(): string` — Returns validated client IP or `REMOTE_ADDR`.

### `CircuitBreaker`

Fail-fast wrapper for external service calls.

- `__construct(string $name, int $failureThreshold, int $timeoutSeconds)`
- `canExecute(): bool` — Returns `false` when breaker is OPEN.
- `recordSuccess(): void` / `recordFailure(): void` — State transitions.
- `getState(): string` — `CLOSED`, `OPEN`, or `HALF_OPEN`.

### `JwtAuth`

Minimal HS256 JWT issuer/verifier with no external dependencies.

- `__construct(string $secret, int $defaultTtlSeconds, string $issuer)`
- `issue(array $claims = []): string` — Create a JWT with embedded claims.
- `verify(string $token): ?array` — Validate signature, issuer, and expiry.
- `verifyWithScope(string $token, string $requiredScope): ?array` — Also checks scope claim.

### `MetricsCollector`

Prometheus text exposition metrics.

- `inc(string $name, array $labels = [], float $value = 1.0): void` — Counter increment.
- `set(string $name, float $value, array $labels = []): void` — Gauge set.
- `observe(string $name, float $value, array $labels = []): void` — Histogram observation.
- `expose(): string` — Full Prometheus text output.

### `RedisAbuseStore`

Atomic Redis-backed abuse protection.

- `__construct(?\Redis $redis, string $prefix = 'ls_abuse:')` — Falls back to in-memory if Redis unavailable.
- `isDenied(string $ip): bool`
- `addToDeny(string $ip, int $ttlMs = 0): void`
- `noteFailure(string $ip, int $windowMs, int $maxFailures, int $lockMs): void`
- `noteSuccess(string $ip): void`
- `checkRateLimit(string $ip, int $windowMs, int $maxAttempts): bool`
- `isLocked(string $ip): bool`
- `denySize(): int`
- `cleanup(): void`

### `SqliteEventStore`

WAL-backed audit log with HMAC integrity.

- `__construct(string $path, ?string $hmacKey, int $retentionDays, int $maxRows)`
- `append(array $event): void`
- `query(int $since, ?string $type, int $limit): array` — Returns rows with `_integrity` field (`ok` | `tampered`).
- `count(?string $type): int`

### `SqliteUsageStore`

Atomic token usage tracking with reservation.

- `__construct(string $path, int $maxEntries, int $maxAgeSeconds)`
- `get(string $jti): array`
- `noteAttempt(string $jti): void`
- `noteSuccess(string $jti): void`
- `noteDownload(string $jti): void`
- `tryReserveSuccess(string $jti, int $maxSuccess, bool $singleUse): bool` — SQLite transaction atomic.
- `delete(string $jti): void`
- `getTotals(): array`

## Security Notes

- The encryption key must be stored securely (environment variable, secrets manager, never committed). It is never used directly; HKDF-SHA256 derives separate encryption and MAC keys internally.
- The `monitorToken` is equivalent to an admin password; protect it accordingly. In military-grade mode, prefer JWT admin auth (`admin.enabled`) with short-lived scoped tokens.
- All redirect `Location` headers are stripped of CRLF sequences to prevent header injection.
- CAPTCHA challenge pages include a strict Content-Security-Policy.
- Pass cookies are HMAC-SHA256 signed and use `HttpOnly; SameSite=Lax; Secure`.
- Token validation is constant-time: `openToken()` returns `null` on any failure without distinguishing format, MAC, decryption, or JSON errors.
- `X-Forwarded-For` is only trusted when the direct `REMOTE_ADDR` matches the configured `trustedProxyNetworks` CIDR allowlist.
- HTTPS is enforced at the application layer when `security.enforceHttps` is enabled; HSTS is emitted automatically.

## Limitations vs Node.js Original

- **No built-in HTTP server**: PHP's model is request-based. Use the provided `public/index.php` with nginx, Apache, or `php -S`.
- **No periodic janitor**: The Node version runs a 6-hour cleanup timer. In PHP, Redis keys use TTLs and SQLite stores auto-prune on write. For heavy load, set up a cron to call `$abuse->cleanup()`.
- **Installer Delivery (`/dl/`)**: Not fully replicated. The PHP version returns `501 Not Implemented` for delivery paths.
- **Event log rotation**: SQLite WAL handles retention automatically. For external SIEM integration, ship `eventsStore->query()` results to your log aggregator.
- **Cheerio vs DOMDocument**: HTML rewriting uses PHP's native `DOMDocument` instead of Cheerio. Complex HTML edge cases may differ slightly.
- **Redis dependency optional**: If Redis is unavailable, `RedisAbuseStore` falls back to in-memory state (resets on process recycle). Production deployments should always provide a Redis connection.

## License

This replica is provided as-is for interoperability and educational purposes. Respect the original Oracle Messenger licensing terms.
