timebasedcipher
Version:
Time-based key generation and AES encryption/decryption SDK
155 lines (154 loc) • 6.6 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.decrypt = exports.encrypt = exports.generateKey = void 0;
const crypto_js_1 = __importDefault(require("crypto-js"));
/**
* Decode the payload of a JWT without verifying its signature.
* Used only to read the `exp` claim (in seconds since epoch).
*
* @param token - The JWT string (header.payload.signature)
* @returns Parsed payload object.
* @throws If the token is malformed or payload is not valid JSON.
*/
const decodeJwtPayload = (token) => {
const parts = token.split(".");
if (parts.length < 2) {
throw new Error("Invalid JWT: missing payload part");
}
const base64Url = parts[1];
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
// Use CryptoJS to decode base64 → UTF-8
const wordArray = crypto_js_1.default.enc.Base64.parse(base64);
const json = wordArray.toString(crypto_js_1.default.enc.Utf8);
try {
return JSON.parse(json);
}
catch {
throw new Error("Invalid JWT payload JSON");
}
};
/**
* Generate a time-based rotating key (SHA256 hash of sharedSecret + current interval).
*
* The key is derived from:
* SHA256(`${secretInput}:${timeSlot}`)
* where:
* timeSlot = floor(Date.now() / (interval * 1000) + 1000)
*
* When `isJwt` is true:
* - `sharedSecretOrJwt` is treated as a JWT string
* - The JWT payload is decoded
* - The `exp` claim (if present) is checked against current time
* - If the JWT is expired, an error is thrown and no key is generated
* - The *raw JWT string* is still used as the secret input for key derivation
*
* @param sharedSecretOrJwt - A pre-shared secret string, or a JWT when `isJwt` is true.
* @param interval - The rotation interval in seconds. The timeSlot calculation
* is based on this value, so both sides must use the same interval.
* @param isJwt - Optional flag (default: false). When true, `sharedSecretOrJwt` is
* treated as a JWT and its `exp` is validated before deriving the key.
* @returns A 64-character hexadecimal SHA-256 hash string to be used as an AES-256 key.
* @throws If `isJwt` is true and the JWT is malformed or expired.
*/
const generateKey = (sharedSecretOrJwt, interval, isJwt = false) => {
let secretInput = sharedSecretOrJwt;
if (isJwt) {
const payload = decodeJwtPayload(sharedSecretOrJwt);
if (typeof payload.exp !== "number") {
throw new Error("JWT payload does not contain a valid 'exp' claim");
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp <= nowSeconds) {
throw new Error("JWT is expired");
}
}
const timeSlot = Math.floor(Date.now() / (interval * 1000) + 1000);
const input = `${secretInput}:${timeSlot}`;
// SHA-256 → hex string
return crypto_js_1.default.SHA256(input).toString(crypto_js_1.default.enc.Hex);
};
exports.generateKey = generateKey;
/**
* Encrypt an arbitrary JavaScript value using AES-256-CBC with a random IV.
*
* The function:
* - JSON stringifies the input `data`
* - Uses the provided hex-encoded key as AES-256 key material
* - Generates a random 16-byte IV
* - Encrypts using AES-256-CBC
* - Returns a concatenated string in the format: "cipherHex:ivHex"
*
* @param data - Any serializable JavaScript value (e.g. object, array, string) to encrypt.
* @param encryptionKeyHex - A 64-character hex string representing a 32-byte key
* (typically the output of {@link generateKey}).
* @returns A string in the format `"cipherHex:ivHex"` where both parts are hex-encoded.
* @throws If encryption fails or JSON serialization fails.
*/
const encrypt = (data, encryptionKeyHex) => {
const json = JSON.stringify(data);
try {
// key and IV as WordArray
const key = crypto_js_1.default.enc.Hex.parse(encryptionKeyHex); // 32 bytes
const iv = crypto_js_1.default.lib.WordArray.random(16); // 16 bytes
const encrypted = crypto_js_1.default.AES.encrypt(json, key, { iv });
const cipherHex = encrypted.ciphertext.toString(crypto_js_1.default.enc.Hex);
const ivHex = iv.toString(crypto_js_1.default.enc.Hex);
return `${cipherHex}:${ivHex}`;
}
catch (err) {
// eslint-disable-next-line no-console
console.error("Error in encrypt:", err);
throw err;
}
};
exports.encrypt = encrypt;
/**
* Decrypt an AES-256-CBC ciphertext string produced by {@link encrypt}.
*
* The function expects the input to be in the format:
* "cipherHex:ivHex"
* where:
* - cipherHex is the hex-encoded ciphertext
* - ivHex is the hex-encoded 16-byte IV
*
* It will:
* - Split and parse the hex components
* - Decrypt using AES-256-CBC with the provided key
* - Parse the resulting UTF-8 JSON back into the original JavaScript value
*
* @param cipherText - The ciphertext string in the format `"cipherHex:ivHex"`.
* @param encryptionKeyHex - A 64-character hex string representing a 32-byte key
* (must match the key used for encryption).
* @returns The decrypted JavaScript value (e.g. object, array, string).
* @throws If the format is invalid, decryption fails, or the decrypted output is empty/invalid JSON.
*/
const decrypt = (cipherText, encryptionKeyHex) => {
const [encryptedHex, ivHex] = cipherText.split(":");
if (!ivHex || !encryptedHex) {
throw new Error("Invalid cipher format, expected cipherHex:ivHex");
}
const key = crypto_js_1.default.enc.Hex.parse(encryptionKeyHex);
const iv = crypto_js_1.default.enc.Hex.parse(ivHex);
const ciphertext = crypto_js_1.default.enc.Hex.parse(encryptedHex);
// Proper CipherParams object
const cipherParams = crypto_js_1.default.lib.CipherParams.create({
ciphertext,
});
try {
const decrypted = crypto_js_1.default.AES.decrypt(cipherParams, key, { iv });
const utf8 = decrypted.toString(crypto_js_1.default.enc.Utf8);
if (!utf8) {
throw new Error("Decryption produced empty string");
}
return JSON.parse(utf8);
}
catch (err) {
// eslint-disable-next-line no-console
console.error("Error decrypting with current key:", err);
throw new Error("Unable to decrypt: no valid key found or invalid payload");
}
};
exports.decrypt = decrypt;