<?php

declare(strict_types=1);

/*
 * Copyright (c) 2017-2023 François Kooman <fkooman@tuxed.net>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

namespace fkooman\OAuth\Server;

use fkooman\OAuth\Server\Exception\SignerException;
use SodiumException;

class Signer implements SignerInterface
{
    private const TOKEN_VERSION = 'v7';
    private const KEY_VERSION = 'k7';
    private const KEY_ID_LENGTH = 16;
    private const KEY_ID_REGEXP = '/^[a-zA-Z0-9_-]{' . self::KEY_ID_LENGTH . '}$/';

    private string $keyId;
    private string $secKey;
    private ?PublicKeySourceInterface $publicKeySource;

    public function __construct(string $secretKey, ?PublicKeySourceInterface $publicKeySource = null)
    {
        [$this->keyId, $this->secKey] = self::parseSecretKey($secretKey);
        $this->publicKeySource = $publicKeySource;
    }

    public function sign(string $tokenPayload): string
    {
        $eStringToSign = Base64UrlSafe::encodeUnpadded($tokenPayload);
        $eTokenSignature = Base64UrlSafe::encodeUnpadded(
            sodium_crypto_sign_detached(
                self::TOKEN_VERSION . '.' . $this->keyId . '.' . $eStringToSign,
                $this->secKey
            )
        );

        return self::TOKEN_VERSION . '.' . $this->keyId . '.' . $eStringToSign . '.' . $eTokenSignature;
    }

    public function verify(string $signedToken): string
    {
        $splitSignedToken = explode('.', $signedToken, 4);
        if (4 !== \count($splitSignedToken)) {
            throw new SignerException('invalid token (invalid format)');
        }
        [$tokenVersion, $keyId, $eTokenPayload, $eTokenSignature] = $splitSignedToken;
        if (self::TOKEN_VERSION !== $tokenVersion) {
            throw new SignerException('invalid token (invalid version)');
        }
        if (1 !== preg_match(self::KEY_ID_REGEXP, $keyId)) {
            throw new SignerException('invalid token (invalid key id)');
        }
        $tokenSignature = self::decode($eTokenSignature);
        if (SODIUM_CRYPTO_SIGN_BYTES !== strlen($tokenSignature)) {
            throw new SignerException('invalid token (invalid signature length)');
        }
        $publicKey = $this->determinePublicKey($keyId);
        $verifyResult = sodium_crypto_sign_verify_detached(
            $tokenSignature,
            implode(
                '.',
                [
                    $tokenVersion,
                    $keyId,
                    $eTokenPayload,
                ]
            ),
            $publicKey
        );
        if (!$verifyResult) {
            throw new SignerException('invalid token (invalid signature)');
        }

        return self::decode($eTokenPayload);
    }

    public static function generateSecretKey(): string
    {
        $keyId = Base64UrlSafe::encodeUnpadded(random_bytes((self::KEY_ID_LENGTH / 4) * 3));
        $eSecKey = Base64UrlSafe::encodeUnpadded(sodium_crypto_sign_secretkey(sodium_crypto_sign_keypair()));

        return self::KEY_VERSION . '.sec.' . $keyId . '.' . $eSecKey;
    }

    /**
     * Extract public key from signer key.
     */
    public static function publicKeyFromSecretKey(string $secretKey): string
    {
        [$keyId, $secKey] = self::parseSecretKey($secretKey);
        $ePubKey = Base64UrlSafe::encodeUnpadded(sodium_crypto_sign_publickey_from_secretkey($secKey));

        return self::KEY_VERSION . '.pub.' . $keyId . '.' . $ePubKey;
    }

    private function determinePublicKey(string $keyId): string
    {
        if ($keyId === $this->keyId) {
            // our own local key was used
            return sodium_crypto_sign_publickey_from_secretkey($this->secKey);
        }
        if (null === $this->publicKeySource) {
            // we do not have an additional source to retrieve public keys
            // from, behave exactly like before we implemented the public key
            // source
            throw new SignerException(sprintf('invalid token (unexpected key id "%s")', $keyId));
        }
        if (null === $pubKey = $this->publicKeySource->fromKeyId($keyId)) {
            // we could not obtain a public key with this Key ID from the
            // external source
            throw new SignerException(sprintf('invalid token (public key for key id "%s" not available)', $keyId));
        }
        [$retrievedKeyId, $retrievedPublicKey] = self::parsePublicKey($pubKey);

        // make sure we got the expected Key ID
        if ($keyId !== $retrievedKeyId) {
            throw new SignerException(sprintf('invalid token (unexpected key id "%s")', $keyId));
        }

        return $retrievedPublicKey;
    }

    /**
     * We MUST make sure decoding failures will lead to invalid key/token and
     * not an uncaught SodiumException that bubbles up.
     */
    private static function decode(string $s): string
    {
        try {
            return Base64UrlSafe::decode($s);
        } catch (SodiumException $e) {
            throw new SignerException('invalid token/key encoding');
        }
    }

    /**
     * @return array{0:string,1:string}
     */
    private static function parseSecretKey(string $secretKey): array
    {
        $splitSecretKey = explode('.', $secretKey, 4);
        if (4 !== \count($splitSecretKey)) {
            throw new SignerException('invalid key (invalid format)');
        }
        [$keyVersion, $keyType, $keyId, $eSecKey] = $splitSecretKey;
        if (self::KEY_VERSION !== $keyVersion) {
            throw new SignerException('invalid key (invalid version)');
        }
        if ('sec' !== $keyType) {
            throw new SignerException('invalid key (not a secret key)');
        }
        if (1 !== preg_match(self::KEY_ID_REGEXP, $keyId)) {
            throw new SignerException('invalid key (invalid key id)');
        }
        $secKey = self::decode($eSecKey);
        if (SODIUM_CRYPTO_SIGN_SECRETKEYBYTES !== strlen($secKey)) {
            throw new SignerException('invalid key (invalid secret key length)');
        }

        return [$keyId, $secKey];
    }

    /**
     * @return array{0:string,1:string}
     */
    private static function parsePublicKey(string $publicKey): array
    {
        $splitPublicKey = explode('.', $publicKey, 4);
        if (4 !== \count($splitPublicKey)) {
            throw new SignerException('invalid key (invalid format)');
        }
        [$keyVersion, $keyType, $keyId, $ePubKey] = $splitPublicKey;
        if (self::KEY_VERSION !== $keyVersion) {
            throw new SignerException('invalid key (invalid version)');
        }
        if ('pub' !== $keyType) {
            throw new SignerException('invalid key (not a public key)');
        }
        if (1 !== preg_match(self::KEY_ID_REGEXP, $keyId)) {
            throw new SignerException('invalid key (invalid key id)');
        }
        $pubKey = self::decode($ePubKey);
        if (SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES !== strlen($pubKey)) {
            throw new SignerException('invalid key (invalid public key length)');
        }

        return [$keyId, $pubKey];
    }
}
