<?php
/*
 * Copyright 2008-2010 GuardTime AS
 *
 * This file is part of the GuardTime PHP SDK.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * @package tsp
 */

/**
 * Publications file object used in timestamp verification.
 *
 * Publications file contains all publications released, and hashes of all
 * public keys used to sign timestamps.
 *
 * Publications file should not be trusted unless its signature is
 * verified successfully.
 *
 * @package tsp
 */
class GTPublicationsFile {

    const VERSION = 1;
    const VERSION_POS = 0;

    const PUBLICATION_BLOCK_BEGIN_POS = 10;
    const PUBLICATION_CELL_SIZE_POS = 14;
    const PUBLICATION_COUNT_POS = 16;

    const PUBLICATION_KEY_BLOCK_BEGIN_POS = 20;
    const PUBLICATION_KEY_CELL_SIZE_POS = 24;
    const PUBLICATION_KEY_COUNT_POS = 26;

    const PUBLICATION_REFERENCES_BLOCK_BEGIN_POS = 28;

    const SIGNATURE_BLOCK_BEGIN_POS = 32;

    const HEADER_SIZE = 36;
    const TIME_SIZE = 8;

    private $content;

    private $verified;
    private $verificationResult;

    private $publicationBlockBegin;
    private $publicationCellSize;
    private $publicationCount;

    private $publicKeyBlockBegin;
    private $publicKeyCellSize;
    private $publicKeyCount;
    private $publicKeys;

    private $publicationReferenceBlockBegin;

    private $signatureBlockBegin;

    /**
     * Constructs a new GTPublicationsFile from the given bytes.
     *
     * @throws GTException
     * @param  array $bytes byte array containing encoded publications file
     */
    public function __construct(array $bytes) {

        if (empty($bytes)) {
            throw new GTException("Parameter bytes is required");
        }

        $this->content = $bytes;

        // get header
        if (count($this->content) < self::HEADER_SIZE) {
            throw new GTException("Invalid publications file length: " + count($this->content));
        }

        // get and check version
        $version = GTUtil::readShort($this->content, self::VERSION_POS);

        if ($version != self::VERSION) {
            throw new GTException("Unsupported publications file version: " . $version);
        }

        // get and check publication block params
        $publicationBlockBegin = GTUtil::readInt($this->content, self::PUBLICATION_BLOCK_BEGIN_POS);

        if ($publicationBlockBegin != self::HEADER_SIZE) {
            throw new GTException("Invalid publications block offset: " . $publicationBlockBegin);
        }

        $publicationCellSize = GTUtil::readShort($this->content, self::PUBLICATION_CELL_SIZE_POS);
        $publicationCount = GTUtil::readInt($this->content, self::PUBLICATION_COUNT_POS);

        // get and check public key block params
        $publicKeyBlockBegin = GTUtil::readInt($this->content, self::PUBLICATION_KEY_BLOCK_BEGIN_POS);

        if ($publicKeyBlockBegin != $publicationBlockBegin + ($publicationCellSize * $publicationCount)) {
            throw new GTException("Invalid publications block offset: " . $publicKeyBlockBegin);
        }

        $publicKeyCellSize = GTUtil::readShort($this->content, self::PUBLICATION_KEY_CELL_SIZE_POS);
        $publicKeyCount = GTUtil::readShort($this->content, self::PUBLICATION_KEY_COUNT_POS);

        // get and check publication references' block params
        $publicationReferenceBlockBegin = GTUtil::readInt($this->content, self::PUBLICATION_REFERENCES_BLOCK_BEGIN_POS);

        if ($publicationReferenceBlockBegin >= count($this->content)) {
            throw new GTException("Invalid publication reference block offset: " . $publicationReferenceBlockBegin);
        }

        // get and check signature block params
        $signatureBlockBegin = GTUtil::readInt($this->content, self::SIGNATURE_BLOCK_BEGIN_POS);

        if ($signatureBlockBegin >= count($this->content)) {
            throw new GTException("Invalid signature block offset: " . $signatureBlockBegin);
        }

        $this->publicationBlockBegin = $publicationBlockBegin;
        $this->publicationCellSize = $publicationCellSize;
        $this->publicationCount = $publicationCount;

        $this->publicKeyBlockBegin = $publicKeyBlockBegin;
        $this->publicKeyCellSize = $publicKeyCellSize;
        $this->publicKeyCount = $publicKeyCount;

        $this->publicationReferenceBlockBegin = $publicationReferenceBlockBegin;
        $this->signatureBlockBegin = $signatureBlockBegin;

        $this->verified = false;
        $this->verificationResult = new GTVerificationResult();

    }

    /**
     * Gets the encoded publications file.
     *
     * @return array byte array containing the encoded publications file
     */
    public function getEncoded() {
        return $this->content;
    }

    /**
     * Saves this publications file to the specified file.
     *
     * @param  string $file file name
     * @return void
     */
    public function save($file) {
        GTUtil::write($file, $this->getEncoded());
    }

    /**
     * Loads a publications file.
     *
     * @static
     * @param  string $file file name
     * @return GTPublicationsFile loaded content
     */
    public static function load($file) {

        $bytes = GTUtil::read($file);

        return new GTPublicationsFile($bytes);
    }

    /**
     * Checks if this publications file contains the given publication.
     *
     * @param  string $publicationString Base-32 encoded publication
     * @return bool true if this publications file contains the publication, false otherwise
     */
    public function contains($publicationString) {

        $publicationId = self::getPublicationId($publicationString);

        if ($this->getPublicationById($publicationId) == $publicationString) {
            return true;

        } else {
            return false;
        }

    }

    /**
     * Gets the earliest publications time.
     *
     * @return string first publication time
     */
    public function getFirstPublicationTime() {

        $result = GTUtil::readLong($this->content, $this->publicationBlockBegin);
        $result = $result->getValue();

        return $result;

    }

    /**
     * Gets the latest publication time.
     *
     * @return string last publication time
     */
    public function getLastPublicationTime() {

        $result = GTUtil::readLong($this->content, $this->publicationBlockBegin + ($this->publicationCellSize * ($this->publicationCount - 1)));
        $result = $result->getValue();

        return $result;
    }

    /**
     * Retrieves publication by given publicationId.
     *
     * @param  int $publicationId publication id
     * @return null|string base32 encoded publication if found, null otherwise
     */
    public function getPublicationById($publicationId) {

        $low = 0;
        $high = $this->publicationCount - 1;

        while ($low <= $high) {

            $index = $low + (int) (($high - $low) / 2);
            $offset = $this->publicationBlockBegin + ($index * $this->publicationCellSize);

            $id = GTUtil::readLong($this->content, $offset);
            $id = $id->getValue();

            if ($id > $publicationId) {

                $high = $index - 1;

            } else if ($id < $publicationId) {

                $low = $index + 1;

            } else {

                return $this->getEncodedPublication($offset);
            }
        }

        return null;

    }

    /**
     * Gets publication by given publicationTime.
     *
     * @param  int $publicationTime publication time
     * @return null|string base32 encoded publication if found, null otherwise
     */
    public function getPublicationByTime($publicationTime) {
        return $this->getPublicationById($publicationTime);
    }

    /**
     * Returns the number of publications in this publication file.
     *
     * @return int number of publications in this publication file
     */
    public function getPublicationCount() {
        return $this->publicationCount;
    }

    /**
     * Returns an array of all publications contained in this publications file.
     *
     * @return array publications in this publications file
     */
    public function getPublicationList() {

        $result = array();
        $offset = $this->publicationBlockBegin;

        for ($i = 0; $i < $this->publicationCount; $i++) {
            array_push($result, $this->getEncodedPublication($offset));
            $offset += $this->publicationCellSize;
        }

        return $result;
    }

    /**
     * Checks if this publications file contains hash of the given public key.
     *
     * @throws GTException
     * @param  GTDataHash $publicKey public key hash
     * @return bool true if this publications file contains the specified public key hash, false otherwise
     *
     * @see GTDataHash
     */
    public function containsPublicKey(GTDataHash $publicKey) {

        if (!$publicKey instanceof GTDataHash) {
            throw new GTException("publicKey must be an instance of GTDataHash");
        }

        foreach ($this->getPublicKeyList() as $hash) {

            if ($hash->getHashedMessage() == $publicKey->getHashedMessage() &&
                $hash->getHashAlgorithm()->getOid() == $publicKey->getHashAlgorithm()->getOid()) {
                return true;
            }

        }

        return false;
    }

    /**
     * Gets the number of public keys contained in this publications file.
     *
     * @return int number of public keys contained in this publications file
     */
    public function getPublicKeyCount() {
        return $this->publicKeyCount;
    }

    /**
     * Returns an array of all public key hashes contained in this publications file.
     *
     * @return array public key hashed contained in this publications file.
     *
     * @see GTDataHash
     */
    public function getPublicKeyList() {

        if ($this->publicKeys === null) {
            $this->publicKeys = array();

            $offset = $this->publicKeyBlockBegin;

            for ($i = 0; $i < $this->publicKeyCount; $i++) {
                array_push($this->publicKeys, $this->getPublicKeyHash($offset));
                $offset += $this->publicKeyCellSize;
            }
        }

        return $this->publicKeys;

    }

    /**
     * Verifies publications file signature.
     *
     *
     * @param null|string|array $cainfo if cainfo is null the bundled root certificate is used, otherwise the
     * path specified in cainfo is used for root certificated.
     *
     * @return GTVerificationResult publications file verification result
     *
     * @see openssl_verify
     */
    public function verify($cainfo = null) {

        if ($cainfo === null) {
            $cainfo = dirname(__FILE__) . '/GTPublicationsFile.pem';
        }

        if ($this->verified && $this->verificationResult->getCainfo() === $cainfo) {
            return $this->verificationResult;
        }

        $this->verified = false;
        $this->verificationResult = new GTVerificationResult();

        // extract content info
        $bytes = array_slice($this->content, $this->signatureBlockBegin,
                count($this->content) - $this->signatureBlockBegin);

        // extract data
        $data = array_slice($this->content, 0, $this->signatureBlockBegin);

        $contentInfo = new CMSContentInfo();
        $contentInfo->decode(ASN1DER::decode($bytes));

        $signedData = $contentInfo->getContent();

        try {
            $this->verifySignature($signedData, $data);
            $this->verifyCertificates($signedData->getCertificates(), $cainfo);

        } catch (GTException $e) {
            $this->verificationResult->updateErrors(GTVerificationResult::PUBFILE_SIGNATURE_FAILURE);
        }

        $this->verified = true;
        $this->verificationResult->updateStatus(GTVerificationResult::PUBFILE_SIGNATURE_VERIFIED);

        return $this->verificationResult;

    }

    /**
     * Verifies publications file signature.
     *
     * @throws GTException thrown when signature verification fails
     * @param  CMSSignedData $signedData signed data
     * @param  array $data data byes
     * @return void
     */
    private function verifySignature(CMSSignedData $signedData, array $data) {

        // check digest algorithm
        $digestAlgorithms = $signedData->getDigestAlgorithms();

        if (count($digestAlgorithms) != 1) {
            throw new GTException("Publications file signature algorithm check failed");
        }

        $digestAlgorithm = $digestAlgorithms[0];

        if ($digestAlgorithm->getAlgorithm() != GTHashAlgorithm::getByName('SHA256')->getOid()) {
            throw new GTException("Unsupported publications file signature algorithm: " . $digestAlgorithm->getAlgorithm());
        }

        if (is_null($signedData->getSignerInfo())) {
            throw new GTException("Invalid publications file, no signatures");
        }

        if (count($signedData->getCertificates()) < 1) {
            throw new GTException("Invalid publications file, no certificates");
        }

        $signature = $signedData->getSignerInfo()->getSignature();
        $certificates = $signedData->getCertificates();

        $certificate = new X509Certificate($certificates[0]);

        if (!$certificate->verifySignature($data, $signature)) {
            throw new GTException("Publications file signature signer info's verification failed");
        }
    }

    /**
     * Verifies publications file certificates.
     *
     * @throws GTException thrown when certificate verification fails
     * @param  array $certificates array of raw certificate bytes
     * @param  string|array $cainfo path to root certificates to use for verification
     * @return void
     *
     * @see openssl_verify
     */
    private function verifyCertificates(array $certificates, $cainfo) {

        $signingCertificate = null;
        $validCertificates = array();

        $now = time();

        foreach ($certificates as $certificate) {

            $certificate = new X509Certificate($certificate);

            $params = $certificate->getParameters();

            if ($params["validFrom_time_t"] > $now) {
                continue; // not yet valid
            }

            if ($params["validTo_time_t"] < $now) {
                continue; // no longer valid
            }

            if (isset($params['subject']['emailAddress']) && $params['subject']['emailAddress'] == 'publications@guardtime.com') {
                $signingCertificate = $certificate;
            } else {

                array_push($validCertificates, $certificate);
            }

        }

        if ($signingCertificate === null) {
            throw new GTException("No valid signing certificate found in publications file");
        }

        if (!$signingCertificate->isValid(X509_PURPOSE_ANY, $validCertificates, $cainfo)) {
            throw new GTException("Certificate verification failed");
        }
    }

    /**
     * Gets publicationId from the given publicationString.
     *
     * @static
     * @throws GTException
     * @param  string $publicationString base32 encoded publication
     * @return string publication id
     */
    public static function getPublicationId($publicationString) {

        if ($publicationString == null) {
            throw new GTException("Invalid publication: null");
        }

        $bytes = GTBase32::decode($publicationString);

        $result = GTUtil::readLong($bytes, 0);
        $result = $result->getValue();

        return $result;
    }

    /**
     * Extracts encoded publication from the given offset.
     *
     * @param  int $offset offset to start reading publication from
     * @return string base32 encoded publication
     */
    private function getEncodedPublication($offset) {

        $hashAlgorithm = GTHashAlgorithm::getByGtid($this->content[$offset + self::TIME_SIZE]);

        $bytes = array_slice($this->content, $offset, self::TIME_SIZE + 1 + $hashAlgorithm->getLength());
        $bytes = GTUtil::addCrc32($bytes);

        return GTBase32::encodeWithDashes($bytes);

    }

    /**
     * Extracts public key hash from the given offset.
     *
     * @param  int $offset offset to start reading public key hash from
     * @return GTDataHash public key hash
     */
    private function getPublicKeyHash($offset) {

        $offset += self::TIME_SIZE;

        $hashAlgorithm = GTHashAlgorithm::getByGtid($this->content[$offset]);
        $hashLength = $hashAlgorithm->getLength() + 1;

        $bytes = array_slice($this->content, $offset, $hashLength);

        return GTDataHash::getInstance($bytes);
    }

}

?>
