1/**
2 * Encaspsulates AES-CBC encryption using SubtleCrypto
3 *
4 * @link https://gist.github.com/chrisveness/43bcda93af9f646d083fad678071b90a
5 */
6class SubtleAES {
7
8    iterations = 10000; // OpenSSL default
9
10    /**
11     * Encrypts plaintext using AES-CBC with Pkdf2 derived key
12     *
13     * @param   {String} plaintext Plaintext to be encrypted
14     * @param   {String} password Password to use to encrypt plaintext
15     * @returns {Promise<String>} Encrypted ciphertext base64 encoded
16     */
17    async encrypt(plaintext, password) {
18
19        const salt = this.randomSalt();
20        const {hash, iv} = await this.derivePkdf2(password, salt, 'SHA-256', this.iterations);
21
22        const alg = {name: 'AES-CBC', iv: iv};
23        const key = await crypto.subtle.importKey('raw', hash, alg, false, ['encrypt']);
24
25        const cipher = await crypto.subtle.encrypt(alg, key, new TextEncoder().encode(plaintext));
26
27        return this.createOpenSSLCryptString(salt, new Uint8Array(cipher));
28    }
29
30    /**
31     * Decrypts OpenSSL ciphertext
32     *
33     * @param {String} ciphertext Base64 encoded ciphertext to be decrypted.
34     * @param {String} password Password to use to decrypt ciphertext
35     * @param {boolean} legacy
36     * @returns {Promise<String>} Decrypted plaintext.
37     */
38    async decrypt(ciphertext, password, legacy = false) {
39        const {salt, cipher} = this.parseOpenSSLCryptString(ciphertext);
40
41        let hash, iv;
42        if (legacy) {
43            ({hash, iv} = this.deriveMd5(password, salt));
44        } else {
45            ({hash, iv} = await this.derivePkdf2(password, salt, 'SHA-256', this.iterations));
46        }
47
48        const alg = {name: 'AES-CBC', iv: iv};
49        const key = await crypto.subtle.importKey('raw', hash, alg, false, ['decrypt']);
50
51        try {
52            const plainBuffer = await crypto.subtle.decrypt(alg, key, cipher);
53            return new TextDecoder().decode(plainBuffer);
54        } catch (e) {
55            throw new Error('Decrypt failed');
56        }
57    }
58
59    /**
60     * Decrypt trying modern and legacy variants
61     *
62     * @param {String} ciphertext Base64 encoded ciphertext to be decrypted.
63     * @param {String} password Password to use to decrypt ciphertext
64     * @returns {Promise<String>} Decrypted plaintext.
65     */
66    async autodecrypt(ciphertext, password) {
67        try {
68            return await this.decrypt(ciphertext, password);
69        } catch (e) {
70            //ignore
71        }
72        return await this.decrypt(ciphertext, password, true);
73    }
74
75    /**
76     * Generate a random salt
77     *
78     * @return {Uint8Array}
79     */
80    randomSalt() {
81        return crypto.getRandomValues(new Uint8Array(8));
82    }
83
84    /**
85     * Parse a base64 string created by openssl enc
86     *
87     * @param str
88     * @return {{cipher: Uint8Array, salt: Uint8Array}}
89     */
90    parseOpenSSLCryptString(str) {
91        const ostring = atob(str);
92
93        if (ostring.slice(0, 8) !== 'Salted__') {
94            throw new Error('Input seems not to be created by OpenSSL compatible enc mechanism');
95        }
96
97        return {
98            salt: new Uint8Array(Array.from(ostring.slice(8, 16)).map(ch => ch.charCodeAt(0))),
99            cipher: new Uint8Array(Array.from(ostring.slice(16)).map(ch => ch.charCodeAt(0))),
100        }
101    }
102
103    /**
104     * Create the openssl enc compatible string
105     *
106     * @param {Uint8Array} salt
107     * @param {Uint8Array} cipher
108     * @return {string}
109     */
110    createOpenSSLCryptString(salt, cipher) {
111        const concat = new Uint8Array([
112            0x53, 0x61, 0x6c, 0x74, 0x65, 0x64, 0x5f, 0x5f, // Salted__
113            ...salt,
114            ...cipher,
115        ]);
116
117        // base64 string
118        return btoa(String.fromCharCode.apply(null, concat));
119    }
120
121    /**
122     * Use PKDF2 to derive the IV and key
123     *
124     * @param {string} strPassword The clear text password
125     * @param {Uint8Array} salt    The salt
126     * @param {string} hash        The Hash model, e.g. ["SHA-256" | "SHA-512"]
127     * @param {int} iterations     Number of iterations
128     * @return {Promise<{hash: Uint8Array, iv: Uint8Array}>}
129     * @link https://stackoverflow.com/q/67993979
130     */
131    async derivePkdf2(strPassword, salt, hash, iterations) {
132        const password = new TextEncoder().encode(strPassword);
133
134        const ik = await window.crypto.subtle.importKey("raw", password, {name: "PBKDF2"}, false, ["deriveBits"]);
135        const dk = await window.crypto.subtle.deriveBits(
136            {
137                name: "PBKDF2",
138                hash: hash,
139                salt: salt,
140                iterations: iterations
141            },
142            ik,
143            48 * 8
144        );  // Bytes to bits
145        const buffer = new Uint8Array(dk);
146
147        return {
148            hash: buffer.slice(0, 32),
149            iv: buffer.slice(32, 48),
150        }
151    }
152
153    /**
154     * The old, legacy method to derive key and IV from the password
155     *
156     * @param {string} strPassword The clear text password
157     * @param {Uint8Array} salt The salt
158     * @return {{iv: Uint8Array, hash: Uint8Array}}
159     * @link https://security.stackexchange.com/a/242567
160     */
161    deriveMd5(strPassword, salt) {
162        const password = new TextEncoder().encode(strPassword);
163
164        const D1 = md5.array(new Uint8Array([...password, ...salt]));
165        const D2 = md5.array(new Uint8Array([...D1, ...password, ...salt]));
166        const D3 = md5.array(new Uint8Array([...D2, ...password, ...salt]));
167
168        return {
169            hash: new Uint8Array([...D1, ...D2]),
170            iv: new Uint8Array(D3),
171        }
172    }
173}
174