My learning diary

CryptoJS and URL

Like this Stack Overflow thread, I needed to encrypt some string which will become part of a URL. However, there was a slash in one of the encrypted strings, like c3ViamVjdHM/X2Q9MQ==. Feel free to decode this. It’s something I copied from the internet.

I thought there would be a lot of symbols in the Base64 alphabet at first (I was wrong). I didn’t want to deal with them and set up a giant substitution map, like a Map<string, string>. As such, I converted the encrypted string to hex and did the necessary reversal at decryption. It seemed inappropriate because it’s another dependency (see the enc import) and a possible performance bottleneck. (Afterwards, I went to validate my concerns. I found this Stack Overflow thread.)

Original code:

import { AES } from 'crypto-js';
import Utf8 from 'crypto-js/enc-utf8';

const passphrase = 'blah';

export const encrypt = (plaintext: string) => {
    return AES.encrypt(plaintext, passphrase).toString();
};

export const decrypt = (cipher: string) => {
    return AES.decrypt(cipher, passphrase).toString(Utf8);
};

My initial solution:

import { AES, enc } from 'crypto-js';
import Utf8 from 'crypto-js/enc-utf8';

const passphrase = 'blah';

export const encrypt = (plaintext: string) => {
    const encrypted = AES.encrypt(plaintext, passphrase).toString();
    const wordArray = enc.Base64.parse(encrypted);
    return enc.Hex.stringify(wordArray);
};

export const decrypt = (cipher: string) => {
    const wordArray = enc.Hex.parse(cipher);
    const toDecrypt = enc.Base64.stringify(wordArray);
    return AES.decrypt(toDecrypt, passphrase).toString(Utf8);
};

Feeling uncertain about my initial solution, I looked up the Base64 alphabet. +, / and = are the only non-alphanumeric symbols in the Base64 alphabet. That’s very manageable. I then transformed my solution into:

import { AES } from 'crypto-js';
import Utf8 from 'crypto-js/enc-utf8';

const passphrase = 'blah';
const substitutionsAfterEncryption: Map<string, string> = new Map([
    ['+', '-'],
    ['/', '_'],
    ['=', '~'],
]);
const substitutionsBeforeDecryption: Map<string, string> = new Map([
    ['-', '+'],
    ['_', '/'],
    ['=', '~'],
]);

export const encrypt = (plaintext: string) => {
    const encrypted = AES.encrypt(plaintext, passphrase).toString();
    return encrypted.replace(
        /[+/=]/g,
        (match: string) => substitutionsAfterEncryption.get(match) ?? match
    );
};

export const decrypt = (cipher: string) => {
    const toDecrypt = cipher.replace(
        /[-_~]/g,
        (match: string) => substitutionsBeforeDecryption.get(match) ?? match
    );
    return AES.decrypt(toDecrypt, passphrase).toString(Utf8);
};

I didn’t need to use replaceAll because the regex already has a g flag. But I also couldn’t use replaceAll. The TypeScript static analyser complained, “TS2339: Property ‘replaceAll’ does not exist on type ‘string’.” I really should take a look at and understand my product’s webpack configurations…

TypeScript also made other things a little difficult for me. I could use neither Array.from nor the spread operator (i.e. [...substitutionsAfterEncryption]) on Map<string, string>. This Stack Overflow thread provides a possible explanation. So there were no shortcuts for me to invert a map like how these ninjas in Stack Overflow do. Since there are only two keys in substitutionsAfterEncryption, I hardcoded substitutionsBeforeDecryption. I didn’t want to spend too much time on less critical optimisations.

Relevant posts