<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\library;
use seekquarry\yioop\configs as C;
/**
* On-disk map from an ACME HTTP-01 challenge token to its key
* authorization. During certificate issuance the ACME subsystem
* calls put() for each pending challenge; the secure launcher's
* port-80 acme-challenge route calls get() when the certificate
* authority fetches /.well-known/acme-challenge/<token>, and the
* subsystem calls clear() once the challenge is validated.
*
* Storing the mapping on disk rather than in memory lets the
* issuing process (which may be a media job or a CLI command) and
* the serving process (the running WebSite) be different processes:
* they share only the directory.
*
* The token arrives from an external HTTP request, so every method
* validates it against the base64url character set ACME tokens use
* before forming a path. A token containing anything else -- in
* particular a slash, backslash, or dot -- is rejected, so no
* request can escape the challenge directory.
*
* @author Chris Pollett chris@pollett.org
*/
class AcmeChallengeStore
{
/**
* Absolute path of the directory holding one file per pending
* challenge token.
* @var string
*/
private $dir;
/**
* @param string $dir directory for token files; defaults to the
* configured ACME_CHALLENGE_DIR
*/
public function __construct($dir = null)
{
$this->dir = ($dir === null) ? C\ACME_CHALLENGE_DIR : $dir;
}
/**
* Tests whether a token is a syntactically valid ACME token: a
* non-empty base64url string (letters, digits, hyphen, and
* underscore) with no path-significant characters. This is the
* single gate that keeps an externally supplied token from
* forming a path outside the challenge directory.
*
* @param string $token candidate token
* @return bool true if the token is safe to use as a filename
*/
public static function validToken($token)
{
return is_string($token) && $token !== "" &&
preg_match('#^[A-Za-z0-9_-]+$#', $token) === 1;
}
/**
* Ensures the challenge directory exists, creating it (and any
* missing parents) on first use.
*
* @return bool true if the directory exists or was created
*/
private function ensureDir()
{
if (is_dir($this->dir)) {
return true;
}
return @mkdir($this->dir, 0700, true) || is_dir($this->dir);
}
/**
* Absolute path of the file backing a token, or null if the
* token is invalid.
*
* @param string $token challenge token
* @return string|null path, or null when the token is rejected
*/
private function tokenPath($token)
{
if (!self::validToken($token)) {
return null;
}
return $this->dir . "/" . $token;
}
/**
* Records the key authorization for a challenge token so the
* acme-challenge route can serve it.
*
* @param string $token challenge token
* @param string $key_authorization the token's key
* authorization (token.thumbprint)
* @return bool true on success, false on an invalid token or a
* write failure
*/
public function put($token, $key_authorization)
{
$path = $this->tokenPath($token);
if ($path === null || !$this->ensureDir()) {
return false;
}
return @file_put_contents($path, $key_authorization) !== false;
}
/**
* Returns the key authorization stored for a token, or null if
* the token is invalid or no entry exists.
*
* @param string $token challenge token
* @return string|null the key authorization, or null
*/
public function get($token)
{
$path = $this->tokenPath($token);
if ($path === null || !is_file($path)) {
return null;
}
$value = @file_get_contents($path);
return ($value === false) ? null : $value;
}
/**
* Removes a token's entry once its challenge is validated.
* Returns true when no entry remains, whether it was deleted or
* was already absent.
*
* @param string $token challenge token
* @return bool true if no entry remains
*/
public function clear($token)
{
$path = $this->tokenPath($token);
if ($path === null) {
return false;
}
if (!is_file($path)) {
return true;
}
return @unlink($path);
}
}