Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.49% covered (warning)
86.49%
32 / 37
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
PublicGatekeeperService
86.11% covered (warning)
86.11%
31 / 36
50.00% covered (danger)
50.00%
3 / 6
17.77
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generateToken
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 verifyToken
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 decodeVerifiedPayload
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 resolveSecretKey
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 resolveFileKey
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Automation\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11/**
12 * Public DMZ Ingestion Gatekeeper Service.
13 *
14 * Generates and cryptographically verifies HMAC-SHA256 signed tokens
15 * for public click-tracking, email confirmations, and external webhooks.
16 *
17 * @package App\Modules\Automation\Application\Service
18 */
19final readonly class PublicGatekeeperService
20{
21    /**
22     * PublicGatekeeperService constructor.
23     *
24     * @param string $secretKey HMAC signing secret key.
25     */
26    public function __construct(
27        private string $secretKey = ''
28    ) {
29    }
30
31    /**
32     * Generates a tamper-proof signed token for public links.
33     *
34     * @param int         $workflowId Target workflow ID.
35     * @param int|null    $recordId   Target record ID.
36     * @param string      $actionKey  Action or event identifier.
37     * @param int         $ttlSeconds Time to live in seconds (default 7 days).
38     * @return string URL-safe signed token string.
39     */
40    public function generateToken(
41        int    $workflowId,
42        ?int   $recordId,
43        string $actionKey,
44        int    $ttlSeconds = 604800
45    ): string {
46        $payload = [
47            'wid'   => $workflowId,
48            'rid'   => $recordId,
49            'act'   => $actionKey,
50            'exp'   => time() + $ttlSeconds,
51            'nonce' => bin2hex(random_bytes(8)),
52        ];
53
54        $json = (string) json_encode($payload);
55        $encodedPayload = rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
56        $signature = hash_hmac('sha256', $encodedPayload, $this->resolveSecretKey());
57
58        return $encodedPayload . '.' . $signature;
59    }
60
61    /**
62     * Verifies cryptographic signature and expiration of an incoming public token.
63     *
64     * @param string $token Incoming signed token.
65     * @return array<string, mixed>|null Validated payload or null if invalid or expired.
66     */
67    public function verifyToken(string $token): ?array
68    {
69        $parts = explode('.', $token, 2);
70        if (count($parts) !== 2) {
71            return null;
72        }
73
74        return $this->decodeVerifiedPayload($parts[0], $parts[1]);
75    }
76
77    /**
78     * Validates cryptographic signature and decodes JSON payload.
79     *
80     * @param string $encodedPayload Base64url-encoded payload.
81     * @param string $signature      HMAC-SHA256 signature.
82     * @return array<string, mixed>|null Decoded payload or null if invalid/expired.
83     */
84    private function decodeVerifiedPayload(string $encodedPayload, string $signature): ?array
85    {
86        $expectedSignature = hash_hmac('sha256', $encodedPayload, $this->resolveSecretKey());
87        if (!hash_equals($expectedSignature, $signature)) {
88            return null;
89        }
90
91        $decodedJson = base64_decode(strtr($encodedPayload, '-_', '+/'), true);
92        $payload = $decodedJson !== false ? json_decode($decodedJson, true) : null;
93        if (!is_array($payload) || !isset($payload['exp'], $payload['wid']) || (int) $payload['exp'] < time()) {
94            return null;
95        }
96
97        return $payload;
98    }
99
100    /**
101     * Resolves gatekeeper HMAC secret key from explicit parameter, environment, or app.key file.
102     *
103     * @return string Resolved secret key.
104     */
105    private function resolveSecretKey(): string
106    {
107        if ($this->secretKey !== '') {
108            return $this->secretKey;
109        }
110
111        $envSecret = getenv('GATEKEEPER_SECRET');
112        if (is_string($envSecret) && $envSecret !== '') {
113            return $envSecret;
114        }
115
116        return $this->resolveFileKey() ?? 'ammonly_gatekeeper_fallback_secret';
117    }
118
119    private function resolveFileKey(): ?string
120    {
121        $keyFile = dirname(__DIR__, 6) . '/storage/app.key';
122        if (is_file($keyFile)) {
123            $fileKey = trim((string) file_get_contents($keyFile));
124            if ($fileKey !== '') {
125                return $fileKey;
126            }
127        }
128
129        return null;
130    }
131}