/ src / library / AcmeChallengeStore.php
<?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);
    }
}
X