| 
<?phpdeclare(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;
 }
 }
 
 |