@ugo-code/streamline.js
Version:
A utility module which provides straight-forward, powerful functions for working with asynchronous JavaScript
302 lines (301 loc) • 14.2 kB
JavaScript
;
// --- Custom Error Handling ---
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CryptoError = void 0;
exports.encryptString = encryptString;
exports.decryptString = decryptString;
exports.hashObject = hashObject;
/**
* Custom error class for handling specific cryptographic failures.
* This allows for robust error handling using `instanceof` or by checking the `code`.
*/
class CryptoError extends Error {
constructor(code, message, cause) {
super(message);
this.name = "CryptoError";
this.code = code;
this.cause = cause;
}
}
exports.CryptoError = CryptoError;
// --- Cryptographic Constants ---
const ALGORITHM_NAME = "AES-GCM";
const KEY_DERIVATION_ALGORITHM = "PBKDF2";
const KEY_LENGTH_BITS = 256;
const SALT_LENGTH_BYTES = 16;
const IV_LENGTH_BYTES = 12;
const PBKDF2_HASH = "SHA-256";
const EXPIRATION_LENGTH_BYTES = 8; // 64-bit float for the timestamp
const ITERATIONS_LENGTH_BYTES = 4; // 32-bit unsigned integer for iterations
// --- Default Configuration ---
const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1 hour
const DEFAULT_PBKDF2_ITERATIONS = 100000;
// --- Helper Functions ---
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
const chunkSize = 8192; // Process in 8KB chunks
let result = "";
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
// String.fromCharCode.apply is more performant than a spread operator for this use case.
result += String.fromCharCode.apply(null, chunk);
}
return btoa(result);
}
function base64ToArrayBuffer(base64) {
const binaryString = atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
function ensureWebCryptoAvailable() {
if (typeof crypto === "undefined" || !crypto.subtle) {
throw new CryptoError("UNSUPPORTED_ENVIRONMENT", "Web Crypto API (crypto.subtle) is not available in this environment.");
}
}
/**
* Derives a cryptographic key from a secret string using PBKDF2.
* @internal
*/
function _deriveKey(secretKey, salt, iterations, usage) {
return __awaiter(this, void 0, void 0, function* () {
const passwordKeyMaterial = yield crypto.subtle.importKey("raw", new TextEncoder().encode(secretKey), { name: KEY_DERIVATION_ALGORITHM }, false, ["deriveKey"]);
return crypto.subtle.deriveKey({
name: KEY_DERIVATION_ALGORITHM,
salt,
iterations,
hash: PBKDF2_HASH,
}, passwordKeyMaterial, { name: ALGORITHM_NAME, length: KEY_LENGTH_BITS }, false, usage);
});
}
/**
* Encrypts a plaintext string using AES-256-GCM, embedding TTL and iteration count.
*
* @param plaintext The string to encrypt.
* @param secretKey The secret key for key derivation.
* @param options Configuration for TTL and PBKDF2 iterations.
* @param options.ttl The Time-To-Live in milliseconds. Pass `null` for no expiration. Defaults to 1 hour.
* @param options.pbkdf2Iterations The number of iterations for key derivation.
* **WARNING**: Reducing this from the default weakens security.
* @returns A Promise resolving to a URL-safe, Base64 encoded string.
* @throws {CryptoError} If the environment is unsupported or encryption fails.
*/
function encryptString(plaintext_1, secretKey_1) {
return __awaiter(this, arguments, void 0, function* (plaintext, secretKey, options = {}) {
ensureWebCryptoAvailable();
const { ttl = DEFAULT_TTL_MS, pbkdf2Iterations = DEFAULT_PBKDF2_ITERATIONS, } = options;
try {
// 1. Store the iteration count and expiration timestamp.
const iterationsBuffer = new ArrayBuffer(ITERATIONS_LENGTH_BYTES);
new DataView(iterationsBuffer).setUint32(0, pbkdf2Iterations, false); // Big-endian
const expirationTimestamp = ttl === null ? Infinity : Date.now() + ttl;
const expirationBuffer = new ArrayBuffer(EXPIRATION_LENGTH_BYTES);
new DataView(expirationBuffer).setFloat64(0, expirationTimestamp, false);
// 2. Generate salt and IV.
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH_BYTES));
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH_BYTES));
// 3. Derive the encryption key.
const derivedEncryptionKey = yield _deriveKey(secretKey, salt, pbkdf2Iterations, ["encrypt"]);
// 4. Encrypt the data.
const encodedPlaintext = new TextEncoder().encode(plaintext);
const ciphertext = yield crypto.subtle.encrypt({ name: ALGORITHM_NAME, iv }, derivedEncryptionKey, encodedPlaintext);
// 5. Combine [iterations, expiration, salt, iv, ciphertext] into a single buffer.
const combinedData = new Uint8Array(ITERATIONS_LENGTH_BYTES +
EXPIRATION_LENGTH_BYTES +
salt.length +
iv.length +
ciphertext.byteLength);
let offset = 0;
combinedData.set(new Uint8Array(iterationsBuffer), offset);
offset += ITERATIONS_LENGTH_BYTES;
combinedData.set(new Uint8Array(expirationBuffer), offset);
offset += EXPIRATION_LENGTH_BYTES;
combinedData.set(salt, offset);
offset += salt.length;
combinedData.set(iv, offset);
offset += iv.length;
combinedData.set(new Uint8Array(ciphertext), offset);
return encodeURIComponent(arrayBufferToBase64(combinedData.buffer));
}
catch (error) {
throw new CryptoError("ENCRYPTION_FAILED", `Encryption failed: ${error.message}`, error);
}
});
}
/**
* Decrypts a string that was encrypted with encryptString, checking its TTL.
* The iteration count is automatically extracted from the payload.
*
* @param encryptedDataB64 The Base64 encoded string from encryptString.
* @param secretKey The *same* secret key used for encryption.
* @returns A Promise resolving to the original plaintext string.
* @throws {CryptoError}
*/
function decryptString(encryptedDataB64, secretKey) {
return __awaiter(this, void 0, void 0, function* () {
ensureWebCryptoAvailable();
let combinedData;
try {
const combinedDataBuffer = base64ToArrayBuffer(decodeURIComponent(encryptedDataB64));
combinedData = new Uint8Array(combinedDataBuffer);
}
catch (error) {
throw new CryptoError("INVALID_DATA", "Invalid encrypted data: failed to decode base64 payload.", error);
}
const minLength = ITERATIONS_LENGTH_BYTES +
EXPIRATION_LENGTH_BYTES +
SALT_LENGTH_BYTES +
IV_LENGTH_BYTES;
if (combinedData.length < minLength) {
throw new CryptoError("INVALID_DATA", "Invalid encrypted data: payload is too short.");
}
// 1. Extract iterations and expiration.
let offset = 0;
const iterationsView = new DataView(combinedData.buffer, offset, ITERATIONS_LENGTH_BYTES);
const pbkdf2Iterations = iterationsView.getUint32(0, false);
offset += ITERATIONS_LENGTH_BYTES;
const expirationView = new DataView(combinedData.buffer, offset, EXPIRATION_LENGTH_BYTES);
const expirationTimestamp = expirationView.getFloat64(0, false);
offset += EXPIRATION_LENGTH_BYTES;
// 2. Check for expiration.
if (Date.now() > expirationTimestamp) {
throw new CryptoError("EXPIRED", "The encrypted data has expired.");
}
// 3. Extract salt, IV, and ciphertext.
const salt = combinedData.subarray(offset, offset + SALT_LENGTH_BYTES);
offset += SALT_LENGTH_BYTES;
const iv = combinedData.subarray(offset, offset + IV_LENGTH_BYTES);
offset += IV_LENGTH_BYTES;
const ciphertext = combinedData.subarray(offset);
// 4. Derive the decryption key using the extracted iteration count.
const derivedDecryptionKey = yield _deriveKey(secretKey, salt, pbkdf2Iterations, ["decrypt"]);
try {
// 5. Decrypt the ciphertext.
const decryptedBuffer = yield crypto.subtle.decrypt({ name: ALGORITHM_NAME, iv: iv }, derivedDecryptionKey, ciphertext);
return new TextDecoder().decode(decryptedBuffer);
}
catch (error) {
throw new CryptoError("DECRYPTION_FAILED", "Decryption failed. This is often caused by a wrong secret key or tampered data.", error);
}
});
}
/**
* Creates a deterministic, canonical string representation of any JavaScript value.
* This is used internally by `hashObject` to ensure consistent hashing.
*
* @param value The value to stringify.
* @param visited A set to track visited objects and handle circular references.
* @returns A canonical string representation of the value.
*/
const createCanonicalString = (value, visited = new Set()) => {
// Handle primitives and special values
if (value === null)
return "null";
if (typeof value === "undefined")
return '"[Undefined]"';
if (typeof value === "bigint")
return `"[BigInt]:${value.toString()}"`;
if (typeof value === "symbol")
return `"[Symbol]:${value.toString()}"`;
if (typeof value !== "object") {
// Handles string, number, boolean. JSON.stringify escapes strings correctly.
return JSON.stringify(value);
}
// Handle circular references
if (visited.has(value)) {
return '"[Circular Reference]"';
}
visited.add(value);
let result;
try {
// Handle specific object types
if (value instanceof Date) {
result = `"[Date]:${value.toISOString()}"`;
}
else if (value instanceof RegExp) {
result = `"[RegExp]:${value.toString()}"`;
}
else if (value instanceof Map) {
const mapEntries = Array.from(value.entries());
// Sort map entries by canonical key string to ensure order
mapEntries.sort((a, b) => {
const keyA = createCanonicalString(a[0], new Set(visited));
const keyB = createCanonicalString(b[0], new Set(visited));
return keyA.localeCompare(keyB);
});
const stringifiedEntries = mapEntries.map(([k, v]) => `${createCanonicalString(k, new Set(visited))}:${createCanonicalString(v, new Set(visited))}`);
result = `[Map]:{${stringifiedEntries.join(",")}}`;
}
else if (value instanceof Set) {
const setValues = Array.from(value);
// Sort set values by their canonical string to ensure order
const stringifiedValues = setValues
.map((v) => createCanonicalString(v, new Set(visited)))
.sort();
result = `[Set]:[${stringifiedValues.join(",")}]`;
}
else if (Array.isArray(value)) {
const arrayItems = value.map((item) => createCanonicalString(item, new Set(visited)));
result = `[${arrayItems.join(",")}]`;
}
else {
// Handle plain objects
const sortedKeys = Object.keys(value).sort();
const objectPairs = sortedKeys.map((key) => {
const stringifiedKey = JSON.stringify(key);
const stringifiedValue = createCanonicalString(value[key], new Set(visited));
return `${stringifiedKey}:${stringifiedValue}`;
});
result = `{${objectPairs.join(",")}}`;
}
}
finally {
// After processing, remove the object from the visited set for the current path.
// This allows the same object to be correctly processed if it appears in different branches of the data structure.
visited.delete(value);
}
return result;
};
/**
* Asynchronously calculates a deterministic SHA-256 hash of any JavaScript object.
*
* This function handles complex data structures including Maps, Sets, Dates,
* BigInts, and circular references. It creates a consistent, sorted string
* representation of the object, ensuring that the same logical object always
* produces the same hash, regardless of key order or environment.
*
* @param obj The object to hash. This can be any serializable JavaScript value.
* @returns A promise that resolves to a string containing the hexadecimal representation of the SHA-256 hash.
* @example
* const obj1 = { b: 2, a: { c: 3, d: new Set([1, 2]) } };
* const obj2 = { a: { d: new Set([2, 1]), c: 3 }, b: 2 };
* const hash1 = await hashObject(obj1);
* const hash2 = await hashObject(obj2);
* // hash1 will be identical to hash2
*/
function hashObject(obj) {
return __awaiter(this, void 0, void 0, function* () {
const objectString = createCanonicalString(obj);
// Use the Web Crypto API, which is available in modern browsers and Node.js (v15.7+)
const encoder = new TextEncoder();
const data = encoder.encode(objectString);
const hashBuffer = yield crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return hashHex;
});
}