UNPKG

@ugo-code/streamline.js

Version:

A utility module which provides straight-forward, powerful functions for working with asynchronous JavaScript

295 lines (294 loc) 13.9 kB
// --- 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()); }); }; /** * Custom error class for handling specific cryptographic failures. * This allows for robust error handling using `instanceof` or by checking the `code`. */ export class CryptoError extends Error { constructor(code, message, cause) { super(message); this.name = "CryptoError"; this.code = code; this.cause = cause; } } // --- 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. */ export 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} */ export 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 */ export 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; }); }