criptoe
Version:
A simple wrapper for the crypto.subtle api. Focus on AES-GCM.
390 lines (385 loc) • 16.9 kB
JavaScript
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