UNPKG

criptoe

Version:

A simple wrapper for the crypto.subtle api. Focus on AES-GCM.

390 lines (385 loc) 16.9 kB
var l="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",y="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",b=r=>{let t=typeof Uint8Array>"u"?[]:new Uint8Array(256),o=l.length;for(let e=0;e<o;e++)t[r.charCodeAt(e)]=e;return t},m=b(l),p=b(y),w=/^[-A-Za-z0-9\-_]*$/,B=/^[-A-Za-z0-9+/]*={0,3}$/;function h(r,t){let o=r.length,e=r.length*.75,n,f=0,s,i,a,g;r[r.length-1]==="="&&(e--,r[r.length-2]==="="&&e--);let A=new ArrayBuffer(e),u=new Uint8Array(A),c=t?p:m;for(n=0;n<o;n+=4)s=c[r.charCodeAt(n)],i=c[r.charCodeAt(n+1)],a=c[r.charCodeAt(n+2)],g=c[r.charCodeAt(n+3)],u[f++]=s<<2|i>>4,u[f++]=(i&15)<<4|a>>2,u[f++]=(a&3)<<6|g&63;return A}function U(r,t){let o=new Uint8Array(r),e,n="",f=o.length,s=t?y:l;for(e=0;e<f;e+=3)n+=s[o[e]>>2],n+=s[(o[e]&3)<<4|o[e+1]>>4],n+=s[(o[e+1]&15)<<2|o[e+2]>>6],n+=s[o[e+2]&63];let i=f%3;return i===2?n=n.substring(0,n.length-1)+(t?"":"="):i===1&&(n=n.substring(0,n.length-2)+(t?"":"==")),n}function d(r,t){return new TextDecoder().decode(h(r,t))}function k(r,t){return U(new TextEncoder().encode(r),t)}function C(r,t){if(!(typeof r=="string"||r instanceof String))return !1;try{return t?w.test(r):B.test(r)}catch{return !1}}var S={toString:d,fromString:k,toArrayBuffer:h,fromArrayBuffer:U,validate:C}; /** Provides Sha256 hashing and AES-GCM encryption and decryption of strings. For Node.*/ class CripToe { /** * The message originally provided to the instance encoded into a Uint8Array. **/ encoded; /** * @param message - String to be encrypted or hashed. **/ constructor(message, opts) { if (message.length > 1260 && !opts?.silenceWarnings) { console.warn(`WARNING: The message supplied to ${this.constructor.name} is possibly too long for a URL.\nTests show that messages longer than 1,260 characters may exceed the maximum recommended length for a URL, which is 2,084 characters.\nlength:\n${message.length}\nmessage:\n${message}`); } this.#silenced = opts?.silenceWarnings || false; this.#message = message; this.encoded = new TextEncoder().encode(this.#message); // ENSURES THAT THE CIPHER IS ONLY GENERATED ONCE. this.#cipher = undefined; // GENERATES THE ENCRYPTION KEY ONLY ONCE AND ONLY WHEN NEEDED. // This method uses a generator function to allow for the key to only be // generated when needed and only once. Additionally, this method is // scalable to allow for password based keys. If that is needed one day. this.#cripKeyWalk = this.genCripKey( /*password ? password : undefined*/); this.#cripKeyWalk.next().then((key) => { this.#cripKey = key.value; }); // ENSURES THAT THE WRAP KEY IS ONLY GENERATED ONCE. // Requires that salt be provided. Salt is not provided here. Although, you // can use 'Cripto.random()' to generate salt. this.#wrappedKey = undefined; } // Copied base64 methods fromString = S.fromString; fromArrayBuffer = S.fromArrayBuffer; toString = S.toString; toArrayBuffer = S.toArrayBuffer; validate = S.validate; /** * Hashes any string into a Sha256 hash. By default will hash the mesage initially provided to the constructor. **/ async sha256(message) { if (!message && typeof this.message === "string") message = this.message; const encoded = message ? new TextEncoder().encode(message) : this.encoded; return this.CRYP.digest("SHA-256", encoded).then((hash) => { const hashArray = Array.from(new Uint8Array(hash)); const hashHex = hashArray .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); return hashHex; }); } /** * Encrypts the message into AES-GCM. * AES-GCM as opposed to AES-CBC or AES-CTR includes checks that the ciphertext has not been modified. **/ async encrypt(options) { if (!this.#cripKey) { this.#cripKey = await this.#cripKeyWalk.next().then((key) => key.value); } if (!this.#cipher) { const iv = this.#iv; const key = this.#cripKey; const promisedCipher = await this.CRYP.encrypt({ name: "AES-GCM", iv: iv, }, key, this.encoded); this.#cipher = promisedCipher; } if (options?.safeURL) { return { cipher: S.fromArrayBuffer(this.#cipher, Boolean("url")), initVector: S.fromArrayBuffer(this.#iv.buffer), // IMPORTANT: Doesn't need to be URL safe since it will be properly encoded by searchParams. This has been tested extensively. key: this.#cripKey, }; } else if (options?.toBase64) { return { cipher: S.fromArrayBuffer(this.#cipher), initVector: S.fromArrayBuffer(this.#iv.buffer), key: this.#cripKey, }; } else { return { cipher: this.#cipher, initVector: this.#iv, key: this.#cripKey, }; } } /** * Decrypts any AES-GCM encrypted data provided you have the necessary parameters. * * @param key - The Key used to initially encrypt. {@see CripToe.cripKey} * @param iv - The Initialization Vector or, nonce, used to salt the encryption. Provided as base64 string. * @param cipher - The encrypted data to be decrypted. Provided as base64 string. **/ async decrypt(cipher, key, initVector) { if (typeof cipher === "string") { if (S.validate(cipher, Boolean("url"))) { // The string is a base64URL string cipher = S.toArrayBuffer(cipher, Boolean("url")); } else if (S.validate(cipher)) { // The string is a base64 string cipher = S.toArrayBuffer(cipher); } else { throw new Error("The cipher is not in a recognizeable string. It should be in a base64 or base64URL string"); } } // The cipher is an ArrayBuffer. if (cipher.byteLength < 16) { throw new Error("Invalid ciphertext (missing authentication tag)"); } if (typeof initVector === "string") { if (S.validate(initVector)) { // The string is a base64 string initVector = S.toArrayBuffer(initVector); } else if (S.validate(initVector, Boolean("url"))) { // The string is a base64URL string initVector = S.toArrayBuffer(initVector, Boolean("url")); } else { throw new Error("The cipher is not in a recognizeable string. It should encoded into a base64 string. !!NOT!! a base64 url string."); } } if (!(initVector instanceof Uint8Array) && !(initVector instanceof ArrayBuffer)) throw new Error("InitVector should be BufferSource at this point."); if (initVector.byteLength !== 12) { throw new Error("IV must be 12 bytes for AES-GCM"); } if (!(key instanceof CryptoKey)) throw new Error("You must provide a valid encryption key to decrypt. It should be an instance of CryptoKey."); if (!key.usages.includes("decrypt")) { throw new Error("Key not authorized for decryption"); } try { const decrypted = await this.CRYP.decrypt({ name: "AES-GCM", iv: initVector, }, key, cipher); const decryptedText = new TextDecoder("utf-8").decode(decrypted); return decryptedText; } catch (e) { console.log(e); throw new Error(e); } } /** * Takes any given, (wrapped) key and unencrypts it with a provided wrapping key. The wrapping key is expected to be in JWK format. The unwrapped key then becomes the key used to encrypt and decrypt messages. NOTE: The unwrapped key and the wrapped key are stored in the instance and never returned out of it. Except for the first time a message is encrypted. * @param wrappedKeyString - The key to be unwrapped. Provided as a base64 string. * @param wrappingKeyString - The key used to wrap the secret key. Provided as a JSON Web Key (JWK) string. **/ async unwrapKey(wrappedKeyString, wrappingKeyString) { const wrappingKey = await this.#parseKey(wrappingKeyString); const wrappedKey = S.toArrayBuffer(wrappedKeyString); const unWrappedKey = await this.CRYP.unwrapKey("raw", wrappedKey, wrappingKey, { name: "AES-KW", }, { name: "AES-GCM", length: 256, }, true, ["encrypt", "decrypt"]); this.#wrappedKey = wrappedKey; this.#cripKey = unWrappedKey; return true; } /** * Wraps the key in JWK (Json Web Key) format using AES-KW. The benefit of AES-KW is that it doesn't require an Initialization Vector. See: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/wrapKey * Even if this function is called multiple times the wrapped key will only be generated once. * Subsequent calls will simply return the originally wrapped key. * * @param wrappingKey (JWK) - The key used to wrap the secret key. If not provided, a new key will be generated. * @param opts - Options for exporting the wrapped key: * - export: boolean - Whether to export the wrapped key. Will return the wrapped key and the wrapping key in an Object: * { * wrappingKey: string, * wrappedKey: ArrayBuffer * } * - safeURL: boolean - Whether to return the properties in the returned object as a special base64 encoding with special characters removed. To convert them back to standard base64 {@see CripToe.decodeUrlSafeBase64.} * - toBase64: boolean - Whether to return the properties in the returned object as a standard base64 encoding. to convert them back to an ArrayBuffer @see CripToe.base64ToArrayBuffer. **/ async wrapKey(opts, wrappingKeyBase64) { // Check for encryption key. if (!this.#cripKey) { this.#cripKey = await this.#cripKeyWalk.next().then((key) => key.value); } if (this.#wrappedKey) { throw new Error("The key has already been wrapped."); } // Generate a key to wrap the key. // Intentionally not using the same method for generating a key as the one used to encrypt. let wrappingKey; if (wrappingKeyBase64) { const rawWrappingKey = S.toArrayBuffer(wrappingKeyBase64); wrappingKey = await this.CRYP.importKey("raw", rawWrappingKey, { name: "AES-KW", length: 256, }, true, ["wrapKey", "unwrapKey"]); } else { wrappingKey = await this.CRYP.generateKey({ name: "AES-KW", length: 256, }, true, ["wrapKey", "unwrapKey"]); } const wrappedKey = await this.CRYP.wrapKey("raw", this.#cripKey, wrappingKey, { name: "AES-KW", length: 256, }); this.#wrappedKey = wrappedKey; const wrappingKeyRaw = await this.CRYP.exportKey("raw", wrappingKey); const wrappingKeyString = S.fromArrayBuffer(wrappingKeyRaw); const exported = { wrappingKey: wrappingKeyString, wrappedKey: this.#wrappedKey, }; if (opts?.export) { if (opts?.safeURL) { const safeURLExport = { wrappingKey: S.fromArrayBuffer(wrappingKeyRaw), wrappedKey: S.fromArrayBuffer(wrappedKey), }; return safeURLExport; } else if (opts?.toBase64) { const base64Export = { wrappingKey: wrappingKeyString, wrappedKey: S.fromArrayBuffer(wrappedKey), }; return base64Export; } else { return exported; } } else return exported; } /** * The message encrypted into base64. **/ get encrypted() { if (this.#cipher instanceof ArrayBuffer) return S.fromArrayBuffer(this.#cipher); else throw new Error("Not encrypted yet. You must call the 'encrypt' method before calling this property."); } /** * The Initial Vector, or nonce, used to salt the encryption. **/ get initVector() { return this.#iv; } /** * Converts the message from base64 to an array buffer. **/ get messageBuf() { if (this.validate(this.#message)) { const messageBuf = S.toArrayBuffer(this.#message); return messageBuf; } else return this.#message; } /** * The message originally provided to the instance for encryption. **/ get message() { return this.#message; } #isSupported = Boolean(crypto.subtle); #cipher; #cripKey; #cripKeyWalk; #wrappedKey; /** * The message originally provided to the instance for encryption. **/ #message; /** * Used to silence warnings. **/ #silenced; CRYP = (() => { if (this.#isSupported) { const cryp = crypto.subtle; if (cryp instanceof SubtleCrypto) return cryp; else throw new Error("SubtleCrypto is not available."); } else throw new Error("You are not in a supported environment."); })(); async #parseKey(keyBase64) { const rawKey = S.toArrayBuffer(keyBase64); if (rawKey.byteLength !== 32) throw new Error("Invalid AES-KW key: Must be 32 bytes long."); return await this.CRYP.importKey("raw", rawKey, { name: "AES-KW", length: 256, }, true, ["wrapKey", "unwrapKey"]); } get random() { if (this.#isSupported) { return crypto.getRandomValues(new Uint8Array(intArrLength)); } else throw new Error("You are not in a supported environment."); } static random = () => { if (typeof process === "object" && process + "" === "[object process]") { return crypto.getRandomValues(new Uint8Array(intArrLength)); } else throw new Error("You are not in a supported environment."); }; /** * Intentional dupe of 'get random()'. To avoid accidentally reusing an initVector **/ #iv = (() => { if (this.#isSupported) { return crypto.getRandomValues(new Uint8Array(intArrLength)); } else throw new Error("You are not in a supported environment."); })(); /**The key used to encrypt and decrypt the message.**/ async *genCripKey(password) { yield undefined; if (!password) { return await this.CRYP.generateKey({ name: "AES-GCM", length: 256, }, true, ["encrypt", "decrypt"]); } else { const derivedKey = await this.CRYP.deriveKey({ name: "PBKDF2", salt: crypto.getRandomValues(new Uint8Array(16)), iterations: 100000, hash: "SHA-256", }, await this.CRYP.importKey("raw", new TextEncoder().encode(password), { name: "PBKDF2" }, false, ["deriveKey"]), { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]); return derivedKey; } } } /** * Used to determine the length of Uint8Array's for random values. **/ const intArrLength = 12; function isBase64(str) { const notBase64 = /[^A-Z0-9+\/=]/i; const len = str.length; if (!len || len % 4 !== 0 || notBase64.test(str)) { return false; } const firstPaddingChar = str.indexOf("="); return (firstPaddingChar === -1 || firstPaddingChar === len - 1 || (firstPaddingChar === len - 2 && str[len - 1] === "=")); } function isBase64URL(str) { const notBase64 = /[^A-Z0-9-_]/i; const len = str.length; const strBuf = S.toArrayBuffer(str); const base64Str = S.fromArrayBuffer(strBuf); if (isBase64(base64Str)) { return true; } if (!len || len % 4 !== 0 || notBase64.test(str)) { return false; } else return true; } const isTruthy = (x) => !!x; export { CripToe as default, isBase64, isBase64URL, isTruthy }; //# sourceMappingURL=CripToe.js.map