javascriptwebcrypto-api

Problem while sign > serialize > deserialize > verify using web crypto


As a part of my JS code, using web crypto, I wanted to sign a string, serialize it and later deserialize it and verify it. For this I created these custom functions createHexTokenFromStrClaim and verifyHexTokenAndGetStrClaim. However my code is not behaving as expected.

Minimal working code:

const keyUsages = ["sign","verify"];
const algo = {
    name: "HMAC",
    hash: {name: "SHA-512"}
};

class Util{
    static text_encoder=new TextEncoder();
    static text_decoder=new TextDecoder();
    static characters  = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';

    static hexEncode(str) {
        let hex = '';
        for (let i = 0; i < str.length; i++) {
            let charCode = str.charCodeAt(i);
            if (charCode < 128) {
            hex += charCode.toString(16).padStart(2, '0');
            } else if (charCode < 2048) {
            hex += ((charCode >> 6) | 0xC0).toString(16).padStart(2, '0');
            hex += ((charCode & 0x3F) | 0x80).toString(16).padStart(2, '0');
            } else {
            hex += ((charCode >> 12) | 0xE0).toString(16).padStart(2, '0');
            hex += (((charCode >> 6) & 0x3F) | 0x80).toString(16).padStart(2, '0');
            hex += ((charCode & 0x3F) | 0x80).toString(16).padStart(2, '0');
            }
        }
        return hex;
    }

    static hexDecode(hex) {
        let str = '';
        for (let i = 0; i < hex.length; i += 2) {
            const byte = parseInt(hex.substr(i, 2), 16);
            str += String.fromCharCode(byte);
        }
        return decodeURIComponent(escape(str)); // Handle UTF-8 encoding
    }
    
    static async createHexTokenFromStrClaim(algo,str_claim,crypto_key){
        const hex_str_claim= Util.hexEncode(str_claim);
        const uint = Util.text_encoder.encode(hex_str_claim);
        const ab = uint.buffer;

        const sign = await crypto.subtle.sign(algo,crypto_key,ab);
        const des = Util.text_decoder.decode(sign);
        console.log(new Uint8Array(sign));
        
        const hex_sign=Util.hexEncode(des);

        return hex_str_claim+"."+hex_sign;
      }
    
    static async verifyHexTokenAndGetStrClaim(algo,hex_token,crypto_key){
        let hex_str_claim,hex_sign;
        [hex_str_claim,hex_sign]=hex_token.split(".");
        
        if(hex_str_claim && hex_sign){
            const uint = Util.text_encoder.encode(hex_str_claim);
            const ab = uint.buffer;

            const des = Util.hexDecode(hex_sign);
            const sign = Util.text_encoder.encode(des);
            console.log(sign);

            const r = await crypto.subtle.verify(algo,crypto_key,sign.buffer,ab);
            if(r){
                return Util.hexDecode(hex_str_claim);
            }
        }

        return;
    }
}

(async()=>{
    const crypto_key = await crypto.subtle.generateKey(algo,true,keyUsages);
    const message = "Hello world こんにちは、元気ですか ईश्वरेण सह एकतां प्राप्तुं एषः महान् दिवसः अस्ति";
    const token  = await Util.createHexTokenFromStrClaim(algo,message,crypto_key);
    console.log(token);

    const claim = await Util.verifyHexTokenAndGetStrClaim(algo,token,crypto_key);
    console.log(claim);
})();

Hex Encoder is used as encoding like base64 introduces special characters , which causes issues with other part of the code.

Problem: Verification seems to be failing. Specifically in verifyHexTokenAndGetStrClaim the sign is coming completely different after encoding it using const sign = Util.text_encoder.encode(des);

Can't figure out what I am doing wrong. Have checked most of the resources about encoding and decoding. Have checked most of the resource for signing and verifying.


Solution

  • Your hexEncode() method hex encodes a string using the UTF-8 encoding. You apply this to the message, but also to the UTF-8 decoded signature/HMAC.
    While the former is of course not a problem, the latter is wrong and the cause of the problem, because the UTF-8 decoding/encoding of an HMAC irreversibly corrupts it (keep in mind that an HMAC, just like a hash, a ciphertext etc. is not UTF-8 compliant).
    You can immediately recognize the corruption by outputting hex_sign, which contains multiple 0xefbfbd sequences. This is the UTF-8 encoding of the replacement character used for non-UTF8-compatible byte sequences during the UTF-8 decoding.
    The fix is to directly hex encode the raw HMAC without taking the (wrong) detour via the UTF-8 decoding/encoding.

    You also sign the hex encoded message. This is not wrong, but actually unnecessary, it is more efficient to sign the raw message.

    If your code is modified accordingly, verification works:

    const keyUsages = ["sign","verify"];
    const algo = {
        name: "HMAC",
        hash: {name: "SHA-512"}
    };
    
    class Util{
        static text_encoder = new TextEncoder();
        static text_decoder = new TextDecoder();
        
        static ab2hex(ab) { 
            return Array.prototype.map.call(new Uint8Array(ab), x => ('00' + x.toString(16)).slice(-2)).join('');
        }
    
        static hex2ab(hex) {
            return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(function (h) {return parseInt(h, 16)}));
        }
        
        static async createHexTokenFromStrClaim(algo, str_claim, crypto_key) {  
            const claim = Util.text_encoder.encode(str_claim);
            const sign = await crypto.subtle.sign(algo, crypto_key, claim); // more efficient: sign the raw message
            const sign_hex = Util.ab2hex(sign); // Fix: hex encode the signature directly
            const claim_hex = Util.ab2hex(claim) 
            return claim_hex + "." + sign_hex;
        }
        
        static async verifyHexTokenAndGetStrClaim(algo, hex_token, crypto_key) {
            let claim_hex, hex_sign;
            [claim_hex, hex_sign] = hex_token.split(".");        
            if(claim_hex && hex_sign) {
                const claim = Util.hex2ab(claim_hex);
                const sign = Util.hex2ab(hex_sign); // hex decode the signature
                const r = await crypto.subtle.verify(algo, crypto_key, sign, claim); // verify the raw message
                if(r) {
                    return Util.text_decoder.decode(claim);
                }
            }
            return;
        }
    }
    
    (async() => {
        const crypto_key = await crypto.subtle.generateKey(algo, true, keyUsages);
        const message = "Hello world こんにちは、元気ですか ईश्वरेण सह एकतां प्राप्तुं एषः महान् दिवसः अस्ति";
        const token = await Util.createHexTokenFromStrClaim(algo, message, crypto_key);
        const claim = await Util.verifyHexTokenAndGetStrClaim(algo, token, crypto_key);
        console.log("token:", token);
        console.log("claim:", claim);
    })();