(Grav GitSync) Automatic Commit from GitSync
This commit is contained in:
+190
@@ -0,0 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Support. While redistributing the Work or
|
||||
Derivative Works thereof, You may choose to offer, and charge a
|
||||
fee for, acceptance of support, warranty, indemnity, or other
|
||||
liability obligations and/or rights consistent with this License.
|
||||
However, in accepting such obligations, You may act only on Your
|
||||
own behalf and on Your sole responsibility, not on behalf of any
|
||||
other Contributor, and only if You agree to indemnify, defend,
|
||||
and hold each Contributor harmless for any liability incurred by,
|
||||
or claims asserted against, such Contributor by reason of your
|
||||
accepting any such warranty or support.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026 Trilby Media
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,60 @@
|
||||
# cap-php
|
||||
|
||||
PHP port of the [Cap](https://github.com/tiagozip/cap) proof-of-work captcha server.
|
||||
|
||||
Wire-compatible with the official [`@cap.js/widget`](https://www.npmjs.com/package/@cap.js/widget), so the unmodified JS widget can talk to a PHP-backed endpoint.
|
||||
|
||||
- SHA-256 proof-of-work — no tracking, no third-party calls, no API keys
|
||||
- Small (~500 LOC), no runtime dependencies beyond ext-json / ext-hash
|
||||
- Pluggable storage: in-memory, filesystem, or any PSR-16 cache
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
composer require trilbymedia/cap-php
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```php
|
||||
use TrilbyMedia\Cap\Cap;
|
||||
use TrilbyMedia\Cap\Config;
|
||||
use TrilbyMedia\Cap\Storage\FilesystemStorage;
|
||||
|
||||
$storage = new FilesystemStorage('/var/lib/cap');
|
||||
$cap = new Cap(new Config(
|
||||
challengeStorage: $storage,
|
||||
tokenStorage: $storage,
|
||||
));
|
||||
|
||||
// In your /challenge endpoint:
|
||||
$result = $cap->createChallenge();
|
||||
// echo json_encode($result);
|
||||
|
||||
// In your /redeem endpoint (body: {"token":"...","solutions":[...]}):
|
||||
$result = $cap->redeemChallenge($token, $solutions);
|
||||
// echo json_encode($result);
|
||||
|
||||
// When validating a form submission that carried a cap token:
|
||||
if ($cap->validateToken($submittedToken)) {
|
||||
// success
|
||||
}
|
||||
```
|
||||
|
||||
## Defaults
|
||||
|
||||
| Option | Default | Meaning |
|
||||
| -------------------- | -------- | ------------------------------------------- |
|
||||
| challengeCount | 50 | Number of sub-challenges per captcha |
|
||||
| challengeSize | 32 | Salt length (hex chars) |
|
||||
| challengeDifficulty | 4 | Target prefix length (hex chars) |
|
||||
| expiresMs | 600 000 | Challenge TTL (10 min) |
|
||||
| token TTL | 20 min | Validation token TTL (not configurable) |
|
||||
|
||||
## Protocol compatibility
|
||||
|
||||
The PRNG and hashing are bit-exact with upstream `server/index.js` — verified by `tests/fixtures/prng-vectors.json`, a set of vectors generated directly from the upstream JS implementation.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0, matching upstream.
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "trilbymedia/cap-php",
|
||||
"description": "PHP port of the Cap proof-of-work captcha server. Wire-compatible with @cap.js/widget.",
|
||||
"type": "library",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["captcha", "proof-of-work", "cap", "cap.js", "pow", "sha256", "security"],
|
||||
"homepage": "https://github.com/trilbymedia/cap-php",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Trilby Media",
|
||||
"homepage": "https://trilby.media"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"ext-json": "*",
|
||||
"ext-hash": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"psr/simple-cache": "^2.0 || ^3.0"
|
||||
},
|
||||
"suggest": {
|
||||
"psr/simple-cache": "To use Psr16Storage with any PSR-16 cache implementation."
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"TrilbyMedia\\Cap\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"TrilbyMedia\\Cap\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit",
|
||||
"test-coverage": "phpunit --coverage-html coverage"
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TrilbyMedia\Cap;
|
||||
|
||||
/**
|
||||
* Cap proof-of-work captcha server.
|
||||
*
|
||||
* Wire-compatible with the official @cap.js/widget. The client widget
|
||||
* POSTs to two endpoints you expose:
|
||||
*
|
||||
* POST /challenge → Cap::createChallenge() response
|
||||
* POST /redeem → Cap::redeemChallenge($body['token'], $body['solutions'])
|
||||
*
|
||||
* When the form is submitted, validate the token the widget put in the
|
||||
* form with Cap::validateToken().
|
||||
*/
|
||||
final class Cap
|
||||
{
|
||||
private const CLEANUP_INTERVAL_MS = 300_000; // 5 min
|
||||
|
||||
private int $lastCleanupMs = 0;
|
||||
|
||||
public function __construct(private readonly Config $config) {}
|
||||
|
||||
/**
|
||||
* Generate a new challenge.
|
||||
*
|
||||
* @return array{challenge: array{c:int,s:int,d:int}, token?: string, expires: int}
|
||||
*/
|
||||
public function createChallenge(?ChallengeOptions $opts = null): array
|
||||
{
|
||||
$this->lazyCleanup();
|
||||
|
||||
$challenge = [
|
||||
'c' => $opts?->challengeCount ?? $this->config->challengeCount,
|
||||
's' => $opts?->challengeSize ?? $this->config->challengeSize,
|
||||
'd' => $opts?->challengeDifficulty ?? $this->config->challengeDifficulty,
|
||||
];
|
||||
$expiresMs = $opts?->expiresMs ?? $this->config->expiresMs;
|
||||
$expires = $this->nowMs() + $expiresMs;
|
||||
|
||||
if ($opts?->store === false) {
|
||||
return ['challenge' => $challenge, 'expires' => $expires];
|
||||
}
|
||||
|
||||
$token = $this->randomHex(25);
|
||||
$this->config->challengeStorage->storeChallenge($token, $challenge + ['expires' => $expires]);
|
||||
|
||||
return ['challenge' => $challenge, 'token' => $token, 'expires' => $expires];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify solutions against a stored challenge and issue a verification token.
|
||||
*
|
||||
* @param int[] $solutions
|
||||
* @return array{success: bool, token?: string, expires?: int, message?: string}
|
||||
*/
|
||||
public function redeemChallenge(string $token, array $solutions): array
|
||||
{
|
||||
foreach ($solutions as $s) {
|
||||
if (!is_int($s)) {
|
||||
return ['success' => false, 'message' => 'Invalid body'];
|
||||
}
|
||||
}
|
||||
if ($token === '') {
|
||||
return ['success' => false, 'message' => 'Invalid body'];
|
||||
}
|
||||
|
||||
$this->lazyCleanup();
|
||||
|
||||
$data = $this->config->challengeStorage->readChallenge($token);
|
||||
$this->config->challengeStorage->deleteChallenge($token);
|
||||
|
||||
if ($data === null || ($data['expires'] ?? 0) < $this->nowMs()) {
|
||||
return ['success' => false, 'message' => 'Challenge invalid or expired'];
|
||||
}
|
||||
|
||||
$count = $data['c'];
|
||||
$size = $data['s'];
|
||||
$diff = $data['d'];
|
||||
|
||||
if (count($solutions) < $count) {
|
||||
return ['success' => false, 'message' => 'Invalid solution'];
|
||||
}
|
||||
|
||||
for ($i = 1; $i <= $count; $i++) {
|
||||
$salt = Prng::generate($token . $i, $size);
|
||||
$target = Prng::generate($token . $i . 'd', $diff);
|
||||
$hash = hash('sha256', $salt . (string)$solutions[$i - 1]);
|
||||
if (!str_starts_with($hash, $target)) {
|
||||
return ['success' => false, 'message' => 'Invalid solution'];
|
||||
}
|
||||
}
|
||||
|
||||
$vertoken = $this->randomHex(15);
|
||||
$id = $this->randomHex(8);
|
||||
$expires = $this->nowMs() + $this->config->tokenTtlMs;
|
||||
$key = $id . ':' . hash('sha256', $vertoken);
|
||||
|
||||
$this->config->tokenStorage->storeToken($key, $expires);
|
||||
|
||||
return ['success' => true, 'token' => $id . ':' . $vertoken, 'expires' => $expires];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a verification token returned from redeemChallenge.
|
||||
* By default, the token is consumed (deleted) on successful validation.
|
||||
*/
|
||||
public function validateToken(string $token, bool $keepToken = false): bool
|
||||
{
|
||||
$this->lazyCleanup();
|
||||
|
||||
if ($token === '' || !str_contains($token, ':')) {
|
||||
return false;
|
||||
}
|
||||
$parts = explode(':', $token, 2);
|
||||
if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') {
|
||||
return false;
|
||||
}
|
||||
[$id, $vertoken] = $parts;
|
||||
|
||||
$key = $id . ':' . hash('sha256', $vertoken);
|
||||
$expires = $this->config->tokenStorage->readToken($key);
|
||||
|
||||
if ($expires === null || $expires <= $this->nowMs()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$keepToken) {
|
||||
$this->config->tokenStorage->deleteToken($key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually run cleanup of expired challenges and tokens.
|
||||
*/
|
||||
public function cleanup(): void
|
||||
{
|
||||
$this->config->challengeStorage->deleteExpiredChallenges();
|
||||
$this->config->tokenStorage->deleteExpiredTokens();
|
||||
$this->lastCleanupMs = $this->nowMs();
|
||||
}
|
||||
|
||||
private function lazyCleanup(): void
|
||||
{
|
||||
if ($this->config->disableAutoCleanup) {
|
||||
return;
|
||||
}
|
||||
$now = $this->nowMs();
|
||||
if ($now - $this->lastCleanupMs > self::CLEANUP_INTERVAL_MS) {
|
||||
$this->cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private function nowMs(): int
|
||||
{
|
||||
return (int)(microtime(true) * 1000);
|
||||
}
|
||||
|
||||
private function randomHex(int $bytes): string
|
||||
{
|
||||
return bin2hex(random_bytes($bytes));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TrilbyMedia\Cap;
|
||||
|
||||
/**
|
||||
* Per-call overrides for Cap::createChallenge(). Any field left null
|
||||
* inherits the value configured on the Cap instance.
|
||||
*/
|
||||
final class ChallengeOptions
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ?int $challengeCount = null,
|
||||
public readonly ?int $challengeSize = null,
|
||||
public readonly ?int $challengeDifficulty = null,
|
||||
public readonly ?int $expiresMs = null,
|
||||
public readonly ?bool $store = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TrilbyMedia\Cap;
|
||||
|
||||
use TrilbyMedia\Cap\Storage\ChallengeStorageInterface;
|
||||
use TrilbyMedia\Cap\Storage\TokenStorageInterface;
|
||||
|
||||
/**
|
||||
* Immutable configuration for a Cap instance.
|
||||
*/
|
||||
final class Config
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ChallengeStorageInterface $challengeStorage,
|
||||
public readonly TokenStorageInterface $tokenStorage,
|
||||
public readonly int $challengeCount = 50,
|
||||
public readonly int $challengeSize = 32,
|
||||
public readonly int $challengeDifficulty = 4,
|
||||
public readonly int $expiresMs = 600_000,
|
||||
public readonly int $tokenTtlMs = 1_200_000,
|
||||
public readonly bool $disableAutoCleanup = false,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TrilbyMedia\Cap;
|
||||
|
||||
/**
|
||||
* Deterministic PRNG that must be bit-exact with the upstream cap.js
|
||||
* implementation in server/index.js. Used to regenerate the salt/target
|
||||
* pairs for each sub-challenge from the challenge token.
|
||||
*
|
||||
* JS uses 32-bit unsigned/int32 semantics. In PHP (64-bit) we emulate
|
||||
* that with explicit `& 0xFFFFFFFF` masks after every arithmetic op.
|
||||
*/
|
||||
final class Prng
|
||||
{
|
||||
private const UINT32_MASK = 0xFFFFFFFF;
|
||||
private const FNV_OFFSET = 0x811C9DC5; // 2166136261
|
||||
|
||||
public static function generate(string $seed, int $length): string
|
||||
{
|
||||
if ($length <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$state = self::fnv1a($seed);
|
||||
$result = '';
|
||||
|
||||
while (strlen($result) < $length) {
|
||||
$state = self::next($state);
|
||||
$result .= str_pad(dechex($state), 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
return substr($result, 0, $length);
|
||||
}
|
||||
|
||||
private static function fnv1a(string $str): int
|
||||
{
|
||||
$hash = self::FNV_OFFSET;
|
||||
$len = strlen($str);
|
||||
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$hash = ($hash ^ ord($str[$i])) & self::UINT32_MASK;
|
||||
|
||||
$s1 = ($hash << 1) & self::UINT32_MASK;
|
||||
$s4 = ($hash << 4) & self::UINT32_MASK;
|
||||
$s7 = ($hash << 7) & self::UINT32_MASK;
|
||||
$s8 = ($hash << 8) & self::UINT32_MASK;
|
||||
$s24 = ($hash << 24) & self::UINT32_MASK;
|
||||
|
||||
$hash = ($hash + $s1 + $s4 + $s7 + $s8 + $s24) & self::UINT32_MASK;
|
||||
}
|
||||
|
||||
return $hash;
|
||||
}
|
||||
|
||||
private static function next(int $state): int
|
||||
{
|
||||
$state = ($state ^ (($state << 13) & self::UINT32_MASK)) & self::UINT32_MASK;
|
||||
$state = ($state ^ ($state >> 17)) & self::UINT32_MASK;
|
||||
$state = ($state ^ (($state << 5) & self::UINT32_MASK)) & self::UINT32_MASK;
|
||||
|
||||
return $state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TrilbyMedia\Cap\Storage;
|
||||
|
||||
/**
|
||||
* In-memory storage. Useful for tests and single-request flows.
|
||||
* Not persistent across requests; do not use in production.
|
||||
*/
|
||||
final class ArrayStorage implements ChallengeStorageInterface, TokenStorageInterface
|
||||
{
|
||||
/** @var array<string, array{c:int,s:int,d:int,expires:int}> */
|
||||
private array $challenges = [];
|
||||
|
||||
/** @var array<string, int> */
|
||||
private array $tokens = [];
|
||||
|
||||
public function storeChallenge(string $token, array $challenge): void
|
||||
{
|
||||
$this->challenges[$token] = $challenge;
|
||||
}
|
||||
|
||||
public function readChallenge(string $token): ?array
|
||||
{
|
||||
return $this->challenges[$token] ?? null;
|
||||
}
|
||||
|
||||
public function deleteChallenge(string $token): void
|
||||
{
|
||||
unset($this->challenges[$token]);
|
||||
}
|
||||
|
||||
public function deleteExpiredChallenges(): void
|
||||
{
|
||||
$now = (int)(microtime(true) * 1000);
|
||||
foreach ($this->challenges as $k => $v) {
|
||||
if ($v['expires'] < $now) {
|
||||
unset($this->challenges[$k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function storeToken(string $key, int $expiresMs): void
|
||||
{
|
||||
$this->tokens[$key] = $expiresMs;
|
||||
}
|
||||
|
||||
public function readToken(string $key): ?int
|
||||
{
|
||||
return $this->tokens[$key] ?? null;
|
||||
}
|
||||
|
||||
public function deleteToken(string $key): void
|
||||
{
|
||||
unset($this->tokens[$key]);
|
||||
}
|
||||
|
||||
public function deleteExpiredTokens(): void
|
||||
{
|
||||
$now = (int)(microtime(true) * 1000);
|
||||
foreach ($this->tokens as $k => $v) {
|
||||
if ($v < $now) {
|
||||
unset($this->tokens[$k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TrilbyMedia\Cap\Storage;
|
||||
|
||||
/**
|
||||
* Storage for outstanding challenges (token → {c, s, d, expires}).
|
||||
* Challenges are typically short-lived (10 min default).
|
||||
*/
|
||||
interface ChallengeStorageInterface
|
||||
{
|
||||
/**
|
||||
* @param array{c:int,s:int,d:int,expires:int} $challenge
|
||||
*/
|
||||
public function storeChallenge(string $token, array $challenge): void;
|
||||
|
||||
/**
|
||||
* @return array{c:int,s:int,d:int,expires:int}|null
|
||||
*/
|
||||
public function readChallenge(string $token): ?array;
|
||||
|
||||
public function deleteChallenge(string $token): void;
|
||||
|
||||
public function deleteExpiredChallenges(): void;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TrilbyMedia\Cap\Storage;
|
||||
|
||||
/**
|
||||
* File-backed storage. Challenges and tokens each live in their own JSON file.
|
||||
* Simple and dependency-free; good default for small deployments.
|
||||
*
|
||||
* Not optimized for concurrency — use Psr16Storage with a real cache
|
||||
* (APCu, Redis, etc.) for production workloads.
|
||||
*/
|
||||
final class FilesystemStorage implements ChallengeStorageInterface, TokenStorageInterface
|
||||
{
|
||||
private string $challengesFile;
|
||||
private string $tokensFile;
|
||||
|
||||
public function __construct(string $directory)
|
||||
{
|
||||
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||
throw new \RuntimeException("Cannot create storage directory: {$directory}");
|
||||
}
|
||||
$this->challengesFile = rtrim($directory, '/') . '/challenges.json';
|
||||
$this->tokensFile = rtrim($directory, '/') . '/tokens.json';
|
||||
}
|
||||
|
||||
public function storeChallenge(string $token, array $challenge): void
|
||||
{
|
||||
$all = $this->read($this->challengesFile);
|
||||
$all[$token] = $challenge;
|
||||
$this->write($this->challengesFile, $all);
|
||||
}
|
||||
|
||||
public function readChallenge(string $token): ?array
|
||||
{
|
||||
$all = $this->read($this->challengesFile);
|
||||
/** @var array{c:int,s:int,d:int,expires:int}|null */
|
||||
return $all[$token] ?? null;
|
||||
}
|
||||
|
||||
public function deleteChallenge(string $token): void
|
||||
{
|
||||
$all = $this->read($this->challengesFile);
|
||||
if (isset($all[$token])) {
|
||||
unset($all[$token]);
|
||||
$this->write($this->challengesFile, $all);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteExpiredChallenges(): void
|
||||
{
|
||||
$now = (int)(microtime(true) * 1000);
|
||||
$all = $this->read($this->challengesFile);
|
||||
$changed = false;
|
||||
foreach ($all as $k => $v) {
|
||||
if (($v['expires'] ?? 0) < $now) {
|
||||
unset($all[$k]);
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
if ($changed) {
|
||||
$this->write($this->challengesFile, $all);
|
||||
}
|
||||
}
|
||||
|
||||
public function storeToken(string $key, int $expiresMs): void
|
||||
{
|
||||
$all = $this->read($this->tokensFile);
|
||||
$all[$key] = $expiresMs;
|
||||
$this->write($this->tokensFile, $all);
|
||||
}
|
||||
|
||||
public function readToken(string $key): ?int
|
||||
{
|
||||
$all = $this->read($this->tokensFile);
|
||||
return isset($all[$key]) ? (int)$all[$key] : null;
|
||||
}
|
||||
|
||||
public function deleteToken(string $key): void
|
||||
{
|
||||
$all = $this->read($this->tokensFile);
|
||||
if (isset($all[$key])) {
|
||||
unset($all[$key]);
|
||||
$this->write($this->tokensFile, $all);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteExpiredTokens(): void
|
||||
{
|
||||
$now = (int)(microtime(true) * 1000);
|
||||
$all = $this->read($this->tokensFile);
|
||||
$changed = false;
|
||||
foreach ($all as $k => $v) {
|
||||
if ((int)$v < $now) {
|
||||
unset($all[$k]);
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
if ($changed) {
|
||||
$this->write($this->tokensFile, $all);
|
||||
}
|
||||
}
|
||||
|
||||
private function read(string $file): array
|
||||
{
|
||||
if (!is_file($file)) {
|
||||
return [];
|
||||
}
|
||||
$contents = @file_get_contents($file);
|
||||
if ($contents === false || $contents === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($contents, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function write(string $file, array $data): void
|
||||
{
|
||||
$tmp = $file . '.tmp' . bin2hex(random_bytes(4));
|
||||
file_put_contents($tmp, json_encode($data, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||
rename($tmp, $file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TrilbyMedia\Cap\Storage;
|
||||
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
|
||||
/**
|
||||
* Backs Cap storage with any PSR-16 cache (APCu, Redis, Memcached,
|
||||
* Grav cache, etc.). Recommended for production.
|
||||
*
|
||||
* Note: PSR-16 has no "list all keys" primitive, so deleteExpired*()
|
||||
* is a no-op — cache backends should evict via their own TTL.
|
||||
*/
|
||||
final class Psr16Storage implements ChallengeStorageInterface, TokenStorageInterface
|
||||
{
|
||||
public function __construct(
|
||||
private CacheInterface $cache,
|
||||
private string $challengePrefix = 'cap_c_',
|
||||
private string $tokenPrefix = 'cap_t_',
|
||||
) {}
|
||||
|
||||
public function storeChallenge(string $token, array $challenge): void
|
||||
{
|
||||
$ttl = max(1, (int)ceil(($challenge['expires'] - (int)(microtime(true) * 1000)) / 1000));
|
||||
$this->cache->set($this->key($this->challengePrefix, $token), $challenge, $ttl);
|
||||
}
|
||||
|
||||
public function readChallenge(string $token): ?array
|
||||
{
|
||||
$val = $this->cache->get($this->key($this->challengePrefix, $token));
|
||||
return is_array($val) ? $val : null;
|
||||
}
|
||||
|
||||
public function deleteChallenge(string $token): void
|
||||
{
|
||||
$this->cache->delete($this->key($this->challengePrefix, $token));
|
||||
}
|
||||
|
||||
public function deleteExpiredChallenges(): void
|
||||
{
|
||||
// Cache backend handles expiry via TTL.
|
||||
}
|
||||
|
||||
public function storeToken(string $key, int $expiresMs): void
|
||||
{
|
||||
$ttl = max(1, (int)ceil(($expiresMs - (int)(microtime(true) * 1000)) / 1000));
|
||||
$this->cache->set($this->key($this->tokenPrefix, $key), $expiresMs, $ttl);
|
||||
}
|
||||
|
||||
public function readToken(string $key): ?int
|
||||
{
|
||||
$val = $this->cache->get($this->key($this->tokenPrefix, $key));
|
||||
return is_int($val) ? $val : null;
|
||||
}
|
||||
|
||||
public function deleteToken(string $key): void
|
||||
{
|
||||
$this->cache->delete($this->key($this->tokenPrefix, $key));
|
||||
}
|
||||
|
||||
public function deleteExpiredTokens(): void
|
||||
{
|
||||
// Cache backend handles expiry via TTL.
|
||||
}
|
||||
|
||||
private function key(string $prefix, string $raw): string
|
||||
{
|
||||
// PSR-16 caps keys at 64 chars and reserves some characters; we hash
|
||||
// for safety across implementations, then truncate so prefix+hash
|
||||
// never exceeds the limit. 232+ bits of entropy is well beyond what
|
||||
// we need for a cache key and well clear of birthday-bound concerns.
|
||||
$room = max(8, 64 - strlen($prefix));
|
||||
return $prefix . substr(hash('sha256', $raw), 0, $room);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TrilbyMedia\Cap\Storage;
|
||||
|
||||
/**
|
||||
* Storage for redeemed verification tokens (key → expiresMs).
|
||||
* Key is formatted "{id}:{sha256(vertoken)}".
|
||||
*/
|
||||
interface TokenStorageInterface
|
||||
{
|
||||
public function storeToken(string $key, int $expiresMs): void;
|
||||
|
||||
public function readToken(string $key): ?int;
|
||||
|
||||
public function deleteToken(string $key): void;
|
||||
|
||||
public function deleteExpiredTokens(): void;
|
||||
}
|
||||
Reference in New Issue
Block a user