feat(demo): add story 1 — Sorano: Rock and Time

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
+167
View File
@@ -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,
) {}
}
+25
View File
@@ -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,
) {}
}
+65
View File
@@ -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]);
}
}
}
}
@@ -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);
}
}
@@ -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;
}