<?php 
declare(strict_types=1); 
namespace ParagonIE\HPKPBuilder; 
 
use ParagonIE\ConstantTime\{ 
    Base64, 
    Base64UrlSafe, 
    Binary, 
    Hex 
}; 
 
/** 
 * Class HPKPBuilder 
 * 
 * Quickly and easily build HTTP Public-Key-Pinning headers for your PHP 
 * projects to mitigate the risk of MITM via rogue certificate authorities. 
 * 
 * @package ParagonIE\HPKPBuilder 
 */ 
class HPKPBuilder 
{ 
    /** 
     * @var string 
     */ 
    protected $compiled = ''; 
 
    /** 
     * @var array 
     */ 
    protected $config = []; 
 
    /** 
     * @var bool 
     */ 
    protected $needsCompile = true; 
 
    /** 
     * HPKPBuilder constructor 
     *. 
     * @param array $preloaded 
     */ 
    public function __construct(array $preloaded = []) 
    { 
        if (!empty($preloaded)) { 
            $this->config = $preloaded; 
        } 
    } 
 
    /** 
     * Add a hash directly. 
     * 
     * @param string $hash 
     * @param string $algo 
     * @return self 
     */ 
    public function addHash(string $hash, string $algo = 'sha256'): self 
    { 
        if (empty($this->config['hashes'])) { 
            $this->config['hashes'] = []; 
        } 
        $hash = $this->coerceBase64($hash, $algo); 
        $this->config['hashes'][] = [ 
            'algo' => $algo, 
            'hash' => $hash 
        ]; 
        $this->needsCompile = true; 
        return $this; 
    } 
 
    /** 
     * Compile the CSP header, store it in the protected $compiled property. 
     * 
     * @return self 
     */ 
    public function compile(): self 
    { 
        $includeSubs = $this->config['include-subdomains'] ?? false; 
        $hashes = $this->config['hashes'] ?? []; 
        $maxAge = $this->config['max-age'] ?? 5184000; 
        $reportOnly = $this->config['report-only'] ?? false; 
        $reportUri = $this->config['report-uri'] ?? null; 
        if (empty($hashes)) { 
            // Send nothing. 
            $this->compiled = ''; 
            return $this; 
        } 
 
        $header = ($reportOnly && !empty($reportUri)) 
            ? 'Public-Key-Pins-Report-Only: ' 
            : 'Public-Key-Pins: '; 
 
        foreach ($hashes as $h) { 
            $header .= 'pin-' . $h['algo'] . '='; 
            $header .= \json_encode($h['hash']); 
            $header .= '; '; 
        } 
        $header .= 'max-age=' . $maxAge; 
 
        if ($includeSubs) { 
            $header .= '; includeSubDomains'; 
        } 
        if ($reportUri) { 
            $header .= '; report-uri="' . $reportUri . '"'; 
        } 
 
        $this->compiled = $header; 
        $this->needsCompile = false; 
        return $this; 
    } 
 
    /** 
     * Load configuration from a JSON file. 
     * 
     * @param string $filename 
     * @return self 
     * @throws \Exception 
     */ 
    public static function fromFile(string $filename = ''): self 
    { 
        if (!file_exists($filename)) { 
            throw new \Exception($filename.' does not exist'); 
        } 
        $json = \file_get_contents($filename); 
        if (!\is_string($json)) { 
            throw new \Exception('Could not read file.'); 
        } 
        $array = \json_decode($json, true); 
        return new HPKPBuilder($array); 
    } 
 
    /** 
     * @return string 
     */ 
    public function getHeader(): string 
    { 
        if ($this->needsCompile) { 
            $this->compile(); 
        } 
        return $this->compiled; 
    } 
 
    /** 
     * @return string 
     */ 
    public function getJSON(): string 
    { 
        return \json_encode($this->config); 
    } 
 
    /** 
     * Add the includeSubdomains directive in the HPKP header? 
     * 
     * @param bool $includeSubs 
     * @return self 
     */ 
    public function includeSubdomains(bool $includeSubs = false): self 
    { 
        $this->config['include-subdomains'] = $includeSubs; 
        $this->needsCompile = true; 
        return $this; 
    } 
 
    /** 
     * Set the max-age parameter of the HPKP header 
     * 
     * @param int $maxAge 
     * @return self 
     */ 
    public function maxAge(int $maxAge = 5184000): self 
    { 
        $this->config['max-age'] = $maxAge; 
        $this->needsCompile = true; 
        return $this; 
    } 
 
    /** 
     * Send a Report-Only header? 
     * 
     * @param bool $reportOnly 
     * @return self 
     */ 
    public function reportOnly(bool $reportOnly = false): self 
    { 
        $this->config['report-only'] = $reportOnly; 
        $this->needsCompile = true; 
        return $this; 
    } 
 
    /** 
     * Set the report-uri parameter of the HPKP header 
     * 
     * @param string $reportURI 
     * @return self 
     */ 
    public function reportUri(string $reportURI): self 
    { 
        $this->config['report-uri'] = $reportURI; 
        $this->needsCompile = true; 
        return $this; 
    } 
 
    /** 
     * Send the HPKP header 
     * 
     * @return bool 
     */ 
    public function sendHPKPHeader(): bool 
    { 
        if (\headers_sent()) { 
            return false; 
        } 
        \header($this->getHeader()); 
        return true; 
    } 
 
    /** 
     * Coerce a string into base64 format. 
     * 
     * @param string $hash 
     * @param string $algo 
     * @return string 
     * @throws \Exception 
     */ 
    protected function coerceBase64(string $hash, string $algo = 'sha256'): string 
    { 
        switch ($algo) { 
            case 'sha256': 
                $limits = [ 
                    'raw' => 32, 
                    'hex' => 64, 
                    'pad_min' => 40, 
                    'pad_max' => 44 
                ]; 
                break; 
            default: 
                throw new \Exception( 
                    'Browsers currently only support sha256 public key pins.' 
                ); 
        } 
 
        $len = Binary::safeStrlen($hash); 
        if ($len === $limits['hex']) { 
            $hash = Base64::encode(Hex::decode($hash)); 
        } elseif ($len === $limits['raw']) { 
            $hash = Base64::encode($hash); 
        } elseif ($len > $limits['pad_min'] && $len < $limits['pad_max']) { 
            // Padding was stripped! 
            $hash .= \str_repeat('=', $len % 4); 
 
            // Base64UrlSsafe encoded. 
            if (\strpos($hash, '_') !== false || \strpos($hash, '-') !== false) { 
                $hash = (string) Base64UrlSafe::decode((string) $hash); 
            } else { 
                $hash = (string) Base64::decode((string) $hash); 
            } 
            $hash = (string) Base64::encode((string) $hash); 
        } 
        return $hash; 
    } 
} 
 
 |