feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
+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