/**
* @namespace
*
*/
var NeptuneCrypto = {}
const hkdf = require("futoin-hkdf"); // wait what https://nodejs.org/api/crypto.html#cryptohkdfdigest-ikm-salt-info-keylen-callback
const crypto = require('node:crypto');
const { Neptune } = require("node:process");
const defaultCipherAlgorithm = "chacha20-poly1305" // P-good, although the slowest
const defaultHashAlgorithm = "sha256" // It works
const encryptedPrefix = "ncrypt::";
// A list of "supported" (we know the proper key lengths) ciphers for encrypting data
const encryptionCipherKeyLengths = { // algorithm: [keyLenght (bytes), iv/secondary (bytes)]
'chacha20-poly1305': [32, 12], // 256 bit key, 96 bit nonce
'chacha20': [32, 16], // 256 bit key, 96 bit nonce (nodejs implementation is broken, requires 16 bytes)
'aes-256-gcm': [32, 12], // 256 bit key, 128 bit iv (android freaked out, use 12bit)
'aes-256-cbc': [32, 12], // See above
'aes256': [32, 12], // See above
'aes-192-gcm': [24, 12],
'aes-192-cbc': [24, 12],
'aes192': [24, 12],
'aes-128-gcm': [16, 12],
'aes-128-cbc': [16, 12],
'aes128': [16, 12]
}
// Errors
class DataNotEncrypted extends Error {
constructor() {
let message = "Data not encrypted, cannot find encrypted prefix " + toString(encryptedPrefix);
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
class EncryptedDataSplitError extends Error {
/** @param {(string|number)} actualParts Number of splits in the data
* @param {(string|number)} requestedParts Number of splits required in the data */
constructor(actualParts, requestedParts) {
let message = "Encrypted data does not split properly (contains " + toString(actualParts) + " not " + toString(requestedParts) + " parts).";
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
class EncryptedDataInvalidVersion extends Error {
/** @param {(string|number)} version Reported version from encrypted data */
constructor(version) {
let message = "Invalid encrypted data version, no idea how to handle version: " + toString(version);
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
class UnsupportedCipher extends Error {
/** @param {string} requestedCipher Cipher requested but is not supported */
constructor(requestedCipher) {
let message = "Unsupported cipher: " + toString(requestedCipher) + ". Use chacha20, aes-256-gcm, or aes128.";
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
class MissingDecryptionKey extends Error {
constructor() {
super("Empty decryption key passed in with encrypted data.");
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
class InvalidDecryptionKey extends Error {
constructor() {
super("Provided key was unable to decrypt the data, wrong key.");
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
/**
* Errors thrown by NeptuneCrypto
*/
NeptuneCrypto.Errors = {
DataNotEncrypted: DataNotEncrypted,
EncryptedDataSplitError: EncryptedDataSplitError,
EncryptedDataInvalidVersion: EncryptedDataInvalidVersion,
UnsupportedCipher: UnsupportedCipher,
MissingDecryptionKey: MissingDecryptionKey,
InvalidDecryptionKey: InvalidDecryptionKey
}
// Methods
const convert = (str, from, to) => Buffer.from(str, from).toString(to)
/**
* AES Key
* @typedef {object} AESKey
* @property {Buffer} key The AES key itself
* @property {Buffer} iv The initialization vector
*/
/**
* HKDF options
* @typedef {object} HKDFOptions
* @property {string} [hashAlgorithm = "sha256"] Hashing algorithm used in deriving the key via HKDF
* @property {int} [keyLength = 32] AES key length (this can just be your primary key)
* @property {int} [ivLength = 16] IV length, needs to be 16 for any AES algorithm. If not needing a AES key, this can just be a secondary key (and ignored)
* @property {boolean} [uniqueIV = true] The IV generated is random. DO NOT SET THIS TO FALSE! Only set to false IF the IV must be shared and cannot be synced/transmitted
*/
/**
* Generates a random string. Does not touch the RNG seed, recommend you set that first.
* @param {int} len Length of random string
* @param {int} [minChar = 33] The lower character code. Must be >=33 (no control characters).
* @param {int} [maxChar = 128] The upper character code. Must be <=220, but weird stuff happens above 128 (standard ASCII)
*/
NeptuneCrypto.randomString = function(len, minChar, maxChar) {
var str = ""
if (maxChar == undefined || maxChar > 220)
maxChar = 128;
if (minChar == undefined || minChar < 33)
minChar = 33;
if (minChar>maxChar) {
minChar = minChar + maxChar;
maxChar = minChar - maxChar;
minChar = minChar - maxChar;
}
for (var i = 0; i<len; i++)
str += String.fromCharCode((Math.random()* (maxChar - minChar) +minChar));
return str;
}
/**
* Derive an encryption key from a shared secret
* @param {(string|int)} sharedSerect The shared secret
* @param {string} [salt = undefined] The shared or stored salt
* @param {HKDFOptions} [options] Additional things you can tweak (key length, iv length, hash algorithm)
* @return {AESKey} AES key and IV
*/
NeptuneCrypto.HKDF = function(sharedSecret, salt, options) {
if (typeof sharedSecret !== "string" && !Buffer.isBuffer(sharedSecret))
throw new TypeError("sharedSecret expected string got " + (typeof sharedSecret).toString());
if (salt !== undefined) {
if (typeof salt !== "string")
throw new TypeError("salt expected string got " + (typeof salt).toString());
} else {
salt = "mySalt1234";
}
if (options == undefined)
options = {}
if (sharedSecret == "")
throw new TypeError("sharedSecret expected non-empty string, got an empty string. Your passkey cannot be empty.");
var hashAlgorithm = "sha256";
var keyLength = 32;
var ivLength = 16; // pretty much everything
var uniqueIV = true;
if (options.hashAlgorithm !== undefined) {
if (typeof options.hashAlgorithm === "string") {
if (crypto.getHashes().includes(options.hashAlgorithm))
hashAlgorithm = options.hashAlgorithm;
else
throw new TypeError("Unsupported hash algorithm " + options.hashAlgorithm);
} else
throw new TypeError("options.hashAlgorithm expected string got " + (typeof options.hashAlgorithm).toString());
}
if (options.keyLength !== undefined) {
if (typeof options.keyLength !== "number")
throw new TypeError("options.keyLength expected number got " + (typeof options.keyLength).toString());
keyLength = options.keyLength;
}
if (options.ivLength !== undefined) {
if (typeof options.ivLength !== "number")
throw new TypeError("options.ivLength expected number got " + (typeof options.ivLength).toString());
ivLength = options.ivLength;
}
if (options.uniqueIV === false)
uniqueIV = false
let hashLength = hkdf.hash_length(hashAlgorithm);
let pseudoRandomKey = hkdf.extract(hashAlgorithm, hashLength, sharedSecret, salt); // Step 1
let expandedAesKey = hkdf.expand(hashAlgorithm, hashLength, pseudoRandomKey, keyLength); // Step 2 (for shared AES key)
var iv;
if (uniqueIV)
iv = Buffer.from(NeptuneCrypto.randomString(ivLength), 'utf8');
else
iv = hkdf.expand(hashAlgorithm, hashLength, pseudoRandomKey, ivLength); // use this ONLY when the IV needs to be shared between two separate clients
return {key: expandedAesKey, iv: iv};
}
// see: https://gist.github.com/btxtiger/e8eaee70d6e46729d127f1e384e755d6
/**
* @typedef {object} EncryptionOptions
* @property {string} [hashAlgorithm = "sha256"] Hashing algorithm used in deriving the key via HKDF
* @property {string} [cipherAlgorithm = ""] Encryption method. Can be: `chacha20-poly1305`, `aes-256-gcm`, `aes256` or anything else defined in `encryptionCipherKeyLengths`
*
*/
/**
* Encrypts plain text data using the requested algorithm and key. Uses HKDF to derive the actual encryption keys from your provided key.
*
* @throws {TypeError} Parameter types are incorrect
* @throws {UnsupportedCipher} Unsupported cipher requested
*
* @param {string} plainText Plain text you wish to encrypt
* @param {string} key Encryption key (we'll use HKDF to derive the actual key)
* @param {string} [salt] Encryption salt (passed to HKDF). If undefined, a random string of length 32 will be used
* @param {EncryptionOptions} [options] Misc options, such as the hash algorithm or cipher used.
* @return {string} Encrypted data
*/
NeptuneCrypto.encrypt = function(plainText, key, salt, options) {
var cipherAlgorithm = defaultCipherAlgorithm;;
if (options !== undefined) {
if (options.cipherAlgorithm !== undefined)
cipherAlgorithm = options.cipherAlgorithm;
}
if (typeof cipherAlgorithm !== "string")
throw new TypeError("cipherAlgorithm expected string got " + (typeof cipherAlgorithm).toString());
if (Buffer.isBuffer(plainText))
plainText = plainText.toString('utf8');
if (typeof plainText !== "string")
throw new TypeError("plainText expected string got " + (typeof plainText).toString());
if (typeof key !== "string" && !Buffer.isBuffer(key))
throw new TypeError("key expected string got " + (typeof key).toString());
if (salt !== undefined && salt !== null) {
if (typeof salt !== "string")
throw new TypeError("salt expected string got " + (typeof salt).toString());
} else {
salt = NeptuneCrypto.randomString(32);
}
if (options == undefined)
options = {}
let keyParameters = encryptionCipherKeyLengths[cipherAlgorithm];
if (keyParameters == undefined)
throw new UnsupportedCipher(cipherAlgorithm);
var useAAD = false;
var AAD;
var authTag;
if (cipherAlgorithm == "aes-256-gcm" || cipherAlgorithm == "chacha20-poly1305" || cipherAlgorithm == "aes-192-gcm" || cipherAlgorithm == "aes-128-gcm") {
useAAD = true;
if (options.AAD !== undefined) {
if (typeof options.AAD === "string")
AAD = Buffer.from(options.AAD)
}
if (AAD == undefined)
AAD = Buffer.from(NeptuneCrypto.randomString(16), 'utf8'); // 128 bit AAD
}
if (options.hashAlgorithm === undefined)
options.hashAlgorithm = defaultHashAlgorithm;
let encryptionKey = NeptuneCrypto.HKDF(key, salt, { hashAlgorithm: options.hashAlgorithm, keyLength: keyParameters[0], ivLength: keyParameters[1] });
let cipher = crypto.createCipheriv(cipherAlgorithm, encryptionKey.key, encryptionKey.iv, { authTagLength: 16 });
if (useAAD && AAD !== undefined)
cipher.setAAD(AAD);
let encryptedData = Buffer.concat([cipher.update(Buffer.from(plainText, 'utf8')), cipher.final()]);
if (useAAD)
authTag = cipher.getAuthTag();
// We'll store data like this:
// <prefix>::version:cipherAlgorithm:hashAlgorithm:salt:garbage:iv:encryptedData
// <prefix>::version:cipherAlgorithm:hashAlgorithm:salt:garbage:iv:encryptedData:additionalData:authTag
var ourOutput = encryptedPrefix;
ourOutput += "1:" // Version
ourOutput += cipherAlgorithm + ":" // Cipher algorithm
ourOutput += options.hashAlgorithm + ":" // HKDF hash algorithm
ourOutput += convert(salt, 'utf8', 'base64') + ":" // Salt
ourOutput += convert(NeptuneCrypto.randomString(16), "utf8", "base64") + ":" // Garbage, means nothing
ourOutput += encryptionKey.iv.toString('hex') + ":" // IV
ourOutput += encryptedData.toString('base64'); // The data.
if (useAAD) {
ourOutput += ":" + convert(AAD, 'utf8', 'base64') + ":";
ourOutput += authTag.toString('hex');
}
return ourOutput;
}
/**
* Decrypts data using the encrypted data and key.\
* Encrypted data needs to be in a special format, `ncrypt::version:a:b:c:d:e:f` with `:g:i` added at the end for AEAD ciphers
*
* @throws {TypeError} Parameter types are incorrect.
* @throws {DataNotEncrypted} Unable to find the encryption prefix, likely data is not even encrypted.
* @throws {EncryptedDataSplitError} Data not stored correctly, cannot split on ':' enough times to be valid.
* @throws {EncryptedDataInvalidVersion} Not sure how to decrypt this data, how is it stored (which parts mean what)?
* @throws {UnsupportedCipher} Unsupported cipher used to encrypt the data
* @throws {InvalidDecryptionKey} Wrong decryption key used
* @throws {MissingDecryptionKey} Missing the decryption key
*
* @param {(string|Buffer)} encryptedText Encrypted data provided by the encrypt function (at some point).
* @param {string} key Key used to encrypt the data. HKDF used to derive actual encryption key.
* @return {string} Decrypted text
*/
NeptuneCrypto.decrypt = function(encryptedText, key) {
if (Buffer.isBuffer(encryptedText))
encryptedText = encryptedText.toString('utf8');
if (typeof encryptedText !== "string")
throw new TypeError("encryptedText expected string got " + (typeof encryptedText).toString());
if (typeof key !== "string" && key !== undefined && !Buffer.isBuffer(key)) // can't be undefined .. used to throw error if encrypted.
throw new TypeError("key expected string got " + (typeof key).toString());
if (encryptedText.trimStart().substring(0, encryptedPrefix.length) != encryptedPrefix) {// Check for prefix
//return encryptedText;
throw new DataNotEncrypted();
}
else if (key === undefined || key === "")
throw new MissingDecryptionKey();
let encryptedData = encryptedText.trimEnd().split(encryptedPrefix)[1].split(":");
var version = encryptedData[0];
var cipherAlgorithm = encryptedData[1];
if (cipherAlgorithm == "aes-256-gcm" || cipherAlgorithm == "chacha20-poly1305" || cipherAlgorithm == "aes-192-gcm" || cipherAlgorithm == "aes-128-gcm") {
if (encryptedData.length != 9) {
throw new EncryptedDataSplitError(encryptedData.length, "9");
}
} else {
if (encryptedData.length != 7) {
throw new EncryptedDataSplitError(encryptedData.length, "7");
}
}
// Extract some stuff
var hashAlgorithm = encryptedData[2];
var salt = convert(encryptedData[3], 'base64', 'utf8');
var garbage = convert(encryptedData[4], 'base64', 'utf8');
var iv = Buffer.from(encryptedData[5], 'hex');
var data = Buffer.from(encryptedData[6], 'base64');
var authTag;
var AAD;
if (encryptedData.length == 9) {
AAD = Buffer.from(encryptedData[7], 'base64')
authTag = Buffer.from(encryptedData[8], 'hex');
}
if (version != "1")
throw new EncryptedDataInvalidVersion(encryptedData[0]);
let keyParameters = encryptionCipherKeyLengths[cipherAlgorithm];
if (keyParameters == undefined)
throw new UnsupportedCipher(cipherAlgorithm);
try {
let encryptionKey = NeptuneCrypto.HKDF(key, salt, { hashAlgorithm: hashAlgorithm, keyLength: keyParameters[0], ivLength: keyParameters[1] });
let decipher = crypto.createDecipheriv(cipherAlgorithm, encryptionKey.key, iv, { authTagLength: 16 });
if (AAD !== undefined) {
decipher.setAAD(AAD);
decipher.setAuthTag(authTag);
}
let decrypted = Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
return decrypted;
} catch (err) {
throw new InvalidDecryptionKey();
}
}
NeptuneCrypto.isEncrypted = function(data) {
if (Buffer.isBuffer(data))
data = data.toString('utf8');
if (typeof data !== "string")
throw new TypeError("data expected string got " + (typeof data).toString());
return (data.substring(0, encryptedPrefix.length) === encryptedPrefix);
}
/**
* This will test each supported crypto function to make sure data can be encrypted and then decrypted correctly.
* @param {boolean} [simplePassFail=false] If false, an object containing the results of each supported cipher is returned. If true, `true` is returned if all ciphers pass, or `false` if one fails.
* @param {(string|number)} [msg] Data you would like to test with. If none provided, a random string of length 256 (or if an number provided, a string of that length) is used.
* @param {(string|number)} [key] Encryption key to use. If none provided, a random string of length 128 (or if an number provided, a string of that length) is used.
* @param {boolean} [testAllHashes = false] Very dangerous. Runs the tests for all supported ciphers AND hashing algorithms (a lot). Expect ~495 tests :)
*/
// NeptuneCrypto.testEncryption = function(simplePassFail, msg, key, testAllHashes) {
// if (typeof msg === "number")
// msg = NeptuneCrypto.randomString(msg);
// if (typeof key === "number")
// key = NeptuneCrypto.randomString(key);
// if (typeof msg !== "string" || msg === undefined)
// msg = NeptuneCrypto.randomString(256);
// if (typeof key !== "string" || key === undefined)
// key = NeptuneCrypto.randomString(128);
// var testedHashes = {}
// var allPassed = true;
// var executionTimeAverage = 0;
// var timesRan = 0;
// function runWithHash(hash) {
// let testedAlgorithms = {}
// for (const [oKey, oValue] of Object.entries(encryptionCipherKeyLengths)) {
// // start timer
// let hrstart = process.hrtime();
// let encryptedValue = NeptuneCrypto.encrypt(msg, key, undefined, { hashAlgorithm: hash, cipherAlgorithm: oKey });
// let decryptedValue = NeptuneCrypto.decrypt(encryptedValue, key);
// let exeTime = process.hrtime(hrstart);
// // end timer
// let valid = (decryptedValue == msg);
// testedAlgorithms[oKey] = {
// //encryptedValue: encryptedValue,
// valid: valid,
// executionTime: (exeTime[1]/1000000)
// }
// executionTimeAverage += (exeTime[1]/1000000);
// timesRan += 1;
// if (!valid) {
// allPassed = false;
// }
// }
// return testedAlgorithms
// }
// if (testAllHashes === true) {
// let supportedHashes = crypto.getHashes();
// for (var i = 0; i<supportedHashes.length; i++) {
// try {
// testedHashes[supportedHashes[i]] = runWithHash(supportedHashes[i]);
// } catch (e) {}
// }
// } else {
// testedHashes = runWithHash();
// }
// if (simplePassFail) {
// console.log("Runs: " + timesRan);
// console.log("Total time: " + (executionTimeAverage) + "ms");
// console.log("Average time: " + (executionTimeAverage/timesRan) + "ms");
// }
// else
// testedHashes["averageExecutionTime"] = executionTimeAverage/timesRan;
// return (simplePassFail === true)? allPassed : testedHashes;
// }
module.exports = NeptuneCrypto;