Search code examples
javascriptphpphp-opensslwebcrypto-api

How to sign data in Javascript and verify the signature in PHP?


I have a simple string of text that needs to be signed inside a Javascript and verified on the server (PHP). To start my test I first created a key pair:

// Function to generate a new RSA key pair
async function generateKeyPair() {
    const keyPair = await window.crypto.subtle.generateKey(
        {
            name: "RSASSA-PKCS1-v1_5",
            modulusLength: 2048,
            publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 65537
            hash: { name: "SHA-256" },
        },
        true,
        ["sign", "verify"]
    );

    const publicKey = await window.crypto.subtle.exportKey("spki", keyPair.publicKey);
    const privateKey = await window.crypto.subtle.exportKey("pkcs8", keyPair.privateKey);

    return {
        privateKey: privateKey,
        publicKey: publicKey,
    };
}

I obtained a private and public key:

privateKey: "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDw4Ees4C+vsTUQodgWKIsj3Ni67RG3ny9xY1KjCaatu2o/ev5hS4yrxxWLZAFU9mt/rfNmzzby3mqlWPWm8Df91Mue6wNTsN2yMnHw+XvcvovCngSTH4H2zY+uAhEiG+u+vzGqbzxm0JB3ybX5kYEMK2iKoALq1ASJ781gyy7AsCf/Ck+OvIE4in1kNm4a5NUgbyuflWerMIB7FUQ7h/+XlLn3F2bvC1SWWxKsmQ/dF5fYpZaAV2KvVw2LMnkWdU536an9vxj5LZIJyzfNQv/foNGcUh8iT1tLe8jV4eYrAcqiLnG+iZFFc3X5F33WUILmRvCg11bec6ic1NwuY8eDAgMBAAECggEAQq7kSNCbgvj85sjXSHMa6ee2vDDrKblQ6gQEGYyPbyMmK8LB72951wg7R6Z80+eQJP2kF38gCCZYwcOZ5gg0h/nEEQ+gkSeyiCV886gtiRPbHxqdy5j6YrfPoe2Cjr3KCrllZ3h58UCl7fOShC+q2RKfU1ku1ZGyW/leEwDMxZy1PISHFHtmd43LdrkWgyNk4TIpNRzizx+gxNeyQUEZDfkUu4mFP/weWM26lLyaE+RkPqvFnLjXckvgno1bY8Hq2yywVkyvfBo0tQvVBtNP5WTEyOvNGylc46pnVBODrSUn5q4ZNdi56fd7WFPBFySAVQiA0uLgaWYOuBUGMyc5CQKBgQD+wcyYL2IgA5O2c6Jp9cJV9xQVvWEQ/sVUDJgRVhOxAqw9r2LmUe7tRnWYEI4Sz9g/ejFq8fkL+h2lQbGB+ZKlu5EVHrmfuYf3zo80QKMWC1XKbYnw0HKkMlOqxiMYyu6PqFX59icmcZ58k1m9h2br5f7GGGWAFY8yFgRUIUR6FwKBgQDyDSSbH7WoqUUhYNvY9wKUUYM8uZSAC1TPfuR/ZvAec3cZxMJyOnY88MPOh63vUMzTUt6AAyps2EFPa0UGuysevMaXSL+MAQQzDfnEC2KfeRqkVOKYPrjjjxIl5mQJCacpB7rdzLszmtJJ9G99lTqeGuVa3mhlJupqckYbbdO9dQKBgQD9zf4TMEHGO0oSX6nTfvCZzIrKDd6CnA/j6JgnzWXY2BzZZ75UUBSFd8j4MqYYv9FljEtnjKLd99VJKuW54/bh/rhQHkg4hRKdI8EwAaV49NoHzpG6xTExvKH2ZWfZ73M01DSzzzS57EBFRFgHpro3EvB8UxnsPY5oC99MIcijCQKBgBn16OguVXh6dyymS84QaBlqSK4ZpWC6VmVO0ckMTFKnxa1g2g4QUSAmHoonKTOSsfU0XSLTtBgqdY7EDYo0RuKsEoylQ84LSd0D8bbiFbjO71mStR7pE0Fs1eB0vmPtwhz3dEZXr/hP8Z/29II+oCPW9KRzWDUJIHk8OmK0u9IFAoGAEOWhm/zaXMNJ+oBcvBbCKTZ0XInzvV4SqhC6Bj9aC8wqCe5QKyKl9HglG9J+o3D+hIEcMXGvIv1KB3xDStCQQKcDOrD/8tGZtstSONaNzeGg0hUY9SKd7R2wMPEWufzccFE+zVG5hHUg+eQrnzXdXkG8hW1QxQxgoDkC3DNVsnE="
publicKey: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8OBHrOAvr7E1EKHYFiiLI9zYuu0Rt58vcWNSowmmrbtqP3r+YUuMq8cVi2QBVPZrf63zZs828t5qpVj1pvA3/dTLnusDU7DdsjJx8Pl73L6Lwp4Ekx+B9s2PrgIRIhvrvr8xqm88ZtCQd8m1+ZGBDCtoiqAC6tQEie/NYMsuwLAn/wpPjryBOIp9ZDZuGuTVIG8rn5VnqzCAexVEO4f/l5S59xdm7wtUllsSrJkP3ReX2KWWgFdir1cNizJ5FnVOd+mp/b8Y+S2SCcs3zUL/36DRnFIfIk9bS3vI1eHmKwHKoi5xvomRRXN1+Rd91lCC5kbwoNdW3nOonNTcLmPHgwIDAQAB"

This is the script I'm using to sign the data (string) using the privatekey:

// Function to sign the data
async function signRequestData(data, privateKey) {
    const encoder = new TextEncoder();
    const dataBuffer = encoder.encode(data);
    const hashBuffer = await window.crypto.subtle.digest("SHA-256", dataBuffer);

    const signatureBuffer = await window.crypto.subtle.sign(
        { name: "RSASSA-PKCS1-v1_5" },
        privateKey,
        hashBuffer
    );

    const signatureArray = new Uint8Array(signatureBuffer);
    const signatureBase64 = btoa(String.fromCharCode.apply(null, signatureArray));

    return signatureBase64;
}

Then I created a script to verify the data, still in Javascript, to validate what I was doing:

// Function to verify the signature
async function verifySignature(data, signatureBase64, publicKey) {
    const encoder = new TextEncoder();
    const dataBuffer = encoder.encode(data);
    const signatureArray = new Uint8Array(atob(signatureBase64).split("").map((c) => c.charCodeAt(0)));
    const hashBuffer = await window.crypto.subtle.digest("SHA-256", dataBuffer);
    const isSignatureValid = await window.crypto.subtle.verify(
        { name: "RSASSA-PKCS1-v1_5" },
        publicKey,
        signatureArray,
        hashBuffer
    );

    return isSignatureValid;
}

The script returned true so I moved on the next step, verifying the data in PHP:

    // Function to verify the signature
    public function verifySignature($data, $signatureBase64, $publicKey) {      
        // Import public key
        $publicKeyResource = openssl_pkey_get_public("-----BEGIN PUBLIC KEY-----" . "\n" . $publicKey . "\n" . "-----END PUBLIC KEY-----");

        if ($publicKeyResource === false) {
            // Handle error (unable to import public key)
            die("Error importing public key");
        }

        // Verify the signature
        $isSignatureValid = openssl_verify($data, $signatureBase64, $publicKeyResource, OPENSSL_ALGO_SHA256);

        // Free the public key resource
        openssl_free_key($publicKeyResource);

        return ($isSignatureValid === 1)
    }   

This script is never returning 1 indicating 'valid'. I'm not sure if the problem is how the keys are generated. I can generate the pair in PHP if this helps. @Topaco this is the whole question I was talking about.

edit: I added return openssl_error_string(); in case of 0 and here is the result: error:02000077:rsa routines::wrong signature length on a second run I got error:0480006C:PEM routines::no start line


Solution

  • In the JavaScript code, hashing is performed twice, once explicitly with the digest() function and once implicitly by the sign() function. As the double hashing is performed on the JavaScript side during both signing and verification, verification works on the JavaScript side.

    In contrast, the PHP code hashes once, namely implicitly in openssl_verify(), so that as a result of this different hashing strategy both codes are incompatible. For this reason, verification with the PHP code fails. To eliminate this incompatability, hashing must be carried out consistently.

    Since the double hashing is unnecessary, the JavaScript side should be adapted and the double hashing removed, i.e. in the JavaScript code dataBuffer should be passed directly to sign() and verify().

    In addition to this hashing issue, as already mentioned in the other answer, the Base64 encoded signature in the JavaScript code must be Base64 decoded in the PHP code.


    The fixed JavaScript code (with only single hashing) is:

    (async () => {
    
    var pkcs8DerB64 = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDw4Ees4C+vsTUQodgWKIsj3Ni67RG3ny9xY1KjCaatu2o/ev5hS4yrxxWLZAFU9mt/rfNmzzby3mqlWPWm8Df91Mue6wNTsN2yMnHw+XvcvovCngSTH4H2zY+uAhEiG+u+vzGqbzxm0JB3ybX5kYEMK2iKoALq1ASJ781gyy7AsCf/Ck+OvIE4in1kNm4a5NUgbyuflWerMIB7FUQ7h/+XlLn3F2bvC1SWWxKsmQ/dF5fYpZaAV2KvVw2LMnkWdU536an9vxj5LZIJyzfNQv/foNGcUh8iT1tLe8jV4eYrAcqiLnG+iZFFc3X5F33WUILmRvCg11bec6ic1NwuY8eDAgMBAAECggEAQq7kSNCbgvj85sjXSHMa6ee2vDDrKblQ6gQEGYyPbyMmK8LB72951wg7R6Z80+eQJP2kF38gCCZYwcOZ5gg0h/nEEQ+gkSeyiCV886gtiRPbHxqdy5j6YrfPoe2Cjr3KCrllZ3h58UCl7fOShC+q2RKfU1ku1ZGyW/leEwDMxZy1PISHFHtmd43LdrkWgyNk4TIpNRzizx+gxNeyQUEZDfkUu4mFP/weWM26lLyaE+RkPqvFnLjXckvgno1bY8Hq2yywVkyvfBo0tQvVBtNP5WTEyOvNGylc46pnVBODrSUn5q4ZNdi56fd7WFPBFySAVQiA0uLgaWYOuBUGMyc5CQKBgQD+wcyYL2IgA5O2c6Jp9cJV9xQVvWEQ/sVUDJgRVhOxAqw9r2LmUe7tRnWYEI4Sz9g/ejFq8fkL+h2lQbGB+ZKlu5EVHrmfuYf3zo80QKMWC1XKbYnw0HKkMlOqxiMYyu6PqFX59icmcZ58k1m9h2br5f7GGGWAFY8yFgRUIUR6FwKBgQDyDSSbH7WoqUUhYNvY9wKUUYM8uZSAC1TPfuR/ZvAec3cZxMJyOnY88MPOh63vUMzTUt6AAyps2EFPa0UGuysevMaXSL+MAQQzDfnEC2KfeRqkVOKYPrjjjxIl5mQJCacpB7rdzLszmtJJ9G99lTqeGuVa3mhlJupqckYbbdO9dQKBgQD9zf4TMEHGO0oSX6nTfvCZzIrKDd6CnA/j6JgnzWXY2BzZZ75UUBSFd8j4MqYYv9FljEtnjKLd99VJKuW54/bh/rhQHkg4hRKdI8EwAaV49NoHzpG6xTExvKH2ZWfZ73M01DSzzzS57EBFRFgHpro3EvB8UxnsPY5oC99MIcijCQKBgBn16OguVXh6dyymS84QaBlqSK4ZpWC6VmVO0ckMTFKnxa1g2g4QUSAmHoonKTOSsfU0XSLTtBgqdY7EDYo0RuKsEoylQ84LSd0D8bbiFbjO71mStR7pE0Fs1eB0vmPtwhz3dEZXr/hP8Z/29II+oCPW9KRzWDUJIHk8OmK0u9IFAoGAEOWhm/zaXMNJ+oBcvBbCKTZ0XInzvV4SqhC6Bj9aC8wqCe5QKyKl9HglG9J+o3D+hIEcMXGvIv1KB3xDStCQQKcDOrD/8tGZtstSONaNzeGg0hUY9SKd7R2wMPEWufzccFE+zVG5hHUg+eQrnzXdXkG8hW1QxQxgoDkC3DNVsnE=";
    var pkcs8Der = Uint8Array.from(window.atob(pkcs8DerB64), c => c.charCodeAt(0));
    
    var data = "The quick brown fox jumps over the lazy dog";
    var privateKey = await window.crypto.subtle.importKey("pkcs8", pkcs8Der, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["sign"]);
    var signature = await signRequestData(data, privateKey);
    console.log(signature);
    
    // Function to sign the data
    async function signRequestData(data, privateKey) {
        const encoder = new TextEncoder();
        const dataBuffer = encoder.encode(data);
        const signatureBuffer = await window.crypto.subtle.sign(
            { name: "RSASSA-PKCS1-v1_5" },
            privateKey,
            dataBuffer  // Fix: apply the unhashed data
        );
        const signatureArray = new Uint8Array(signatureBuffer);
        const signatureBase64 = btoa(String.fromCharCode.apply(null, signatureArray));
        return signatureBase64;
    }
    
    })();

    For the private key you specified and the message The quick brown fox jumps over the lazy dog, the following Base64 encoded signature results:

    Gd8BrZtcq54CZY6gwmvZpoazHzJiEQ8xOd6hNIHLC7o9NscZDyJ3XjFgpUG3WKZ6uBuuJpPl3GNS++VDcQqV3cBGh3mS6WNnQehnO6JnnDxvFb4FF8xJzvD87g9m2xHgS3XFnNtE+zNS0sKRPKgAhQY/T6FCYWDX0yWAGTxuuDd2kUB4XWQhQmH5/iMIsF+gpbwxUegtaj8R1fkL++np3cSGLQ9lMbDSPY7h8Fq1d98fSVQZ1ludKJpGY42l1U9z4Vg3xU5rP0wtSzcYhQUit+ZCtKINhU8RbZxkwMUVFpEoVONeRCYfBpMZB6VNYp0hHX8qqZLbki3QDdey52rQ7g==
    

    The PHP code remains unchanged apart from the Base64 decoding of the signature:

    <?php
    // Function to verify the signature
    function verifySignature($data, $signatureBase64, $publicKey) {      
        // Import public key
        $publicKeyResource = openssl_pkey_get_public("-----BEGIN PUBLIC KEY-----" . "\n" . $publicKey . "\n" . "-----END PUBLIC KEY-----");
    
        if ($publicKeyResource === false) {
            // Handle error (unable to import public key)
            die("Error importing public key");
        }
    
        // Verify the signature
        $isSignatureValid = openssl_verify($data, base64_decode($signatureBase64), $publicKeyResource, OPENSSL_ALGO_SHA256); // Fix: Base64 decode the signature
    
        // Free the public key resource
        openssl_free_key($publicKeyResource);
    
        return ($isSignatureValid === 1);
    }   
        
    $publicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8OBHrOAvr7E1EKHYFiiLI9zYuu0Rt58vcWNSowmmrbtqP3r+YUuMq8cVi2QBVPZrf63zZs828t5qpVj1pvA3/dTLnusDU7DdsjJx8Pl73L6Lwp4Ekx+B9s2PrgIRIhvrvr8xqm88ZtCQd8m1+ZGBDCtoiqAC6tQEie/NYMsuwLAn/wpPjryBOIp9ZDZuGuTVIG8rn5VnqzCAexVEO4f/l5S59xdm7wtUllsSrJkP3ReX2KWWgFdir1cNizJ5FnVOd+mp/b8Y+S2SCcs3zUL/36DRnFIfIk9bS3vI1eHmKwHKoi5xvomRRXN1+Rd91lCC5kbwoNdW3nOonNTcLmPHgwIDAQAB";
    $signatureBase64 = "Gd8BrZtcq54CZY6gwmvZpoazHzJiEQ8xOd6hNIHLC7o9NscZDyJ3XjFgpUG3WKZ6uBuuJpPl3GNS++VDcQqV3cBGh3mS6WNnQehnO6JnnDxvFb4FF8xJzvD87g9m2xHgS3XFnNtE+zNS0sKRPKgAhQY/T6FCYWDX0yWAGTxuuDd2kUB4XWQhQmH5/iMIsF+gpbwxUegtaj8R1fkL++np3cSGLQ9lMbDSPY7h8Fq1d98fSVQZ1ludKJpGY42l1U9z4Vg3xU5rP0wtSzcYhQUit+ZCtKINhU8RbZxkwMUVFpEoVONeRCYfBpMZB6VNYp0hHX8qqZLbki3QDdey52rQ7g==";
    $data = "The quick brown fox jumps over the lazy dog";
    print(verifySignature($data, $signatureBase64, $publicKey));
    ?>
    

    With these changes, verification with the PHP code is now successful.


    For the sake of completeness: If the JavaScript code is the reference and the double hashing is to be kept, the PHP code must also hash twice, e.g. by replacing $data with hash('sha256', $data, true).