ff1-js
Version:
FF1 (Format-Preserving Encryption) implementation in JavaScript/TypeScript
352 lines (348 loc) • 13.8 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('crypto')) :
typeof define === 'function' && define.amd ? define(['exports', 'crypto'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.FF1 = {}, global.crypto));
})(this, (function (exports, crypto) { 'use strict';
/**
* FF1 (Format-Preserving Encryption) implementation
* Based on NIST SP 800-38G specification
*/
class FF1 {
/**
* Creates a new FF1 instance
* @param key - The encryption key (must be 16, 24, or 32 bytes)
* @param tweak - The tweak value (must be between 0 and 2^104 bytes)
* @param radix - The radix (base) of the alphabet (2-256)
* @param alphabet - The alphabet string (must have length equal to radix)
* @param minLength - Minimum input length (default: 2)
* @param maxLength - Maximum input length (default: 100)
*/
constructor(key, tweak, radix, alphabet, minLength = 2, maxLength = 100) {
// Validate and convert key
if (typeof key === 'string') {
this.key = Buffer.from(key, 'utf8');
}
else {
this.key = key;
}
// Validate and convert tweak
if (typeof tweak === 'string') {
this.tweak = Buffer.from(tweak, 'utf8');
}
else {
this.tweak = tweak;
}
this.radix = radix;
this.alphabet = alphabet;
this.minLength = minLength;
this.maxLength = maxLength;
this.validateParameters();
}
/**
* Validates the constructor parameters
*/
validateParameters() {
// Validate key length
if (this.key.length !== 16 && this.key.length !== 24 && this.key.length !== 32) {
throw new Error('Key must be 16, 24, or 32 bytes long');
}
// Validate radix
if (this.radix < 2 || this.radix > 256) {
throw new Error('Radix must be between 2 and 256');
}
// Validate alphabet
if (this.alphabet.length !== this.radix) {
throw new Error(`Alphabet length (${this.alphabet.length}) must equal radix (${this.radix})`);
}
// Validate tweak length
const maxTweakLength = Math.pow(2, 104);
if (this.tweak.length > maxTweakLength) {
throw new Error(`Tweak length (${this.tweak.length}) exceeds maximum (${maxTweakLength})`);
}
// Validate length constraints
if (this.minLength < 2) {
throw new Error('Minimum length must be at least 2');
}
if (this.maxLength < this.minLength) {
throw new Error('Maximum length must be greater than or equal to minimum length');
}
}
/**
* Converts a string to a bigint using the specified radix and alphabet
*/
number(str) {
let result = 0n;
for (let i = 0; i < str.length; i++) {
const char = str[i];
const index = this.alphabet.indexOf(char);
if (index === -1) {
throw new Error(`Invalid character in input: ${char}`);
}
result = result * BigInt(this.radix) + BigInt(index);
}
return result;
}
/**
* Converts a bigint to a string using the specified radix and alphabet
*/
str(length, num) {
let result = '';
for (let i = 0; i < length; i++) {
const remainder = Number(num % BigInt(this.radix));
result = this.alphabet[remainder] + result;
num = num / BigInt(this.radix);
}
return result;
}
/**
* Pseudo-random function (PRF) using HMAC-SHA256
*/
prf(output, outputOffset, input, inputOffset, length) {
const hmac = crypto.createHmac('sha256', this.key);
hmac.update(input.subarray(inputOffset, inputOffset + length));
const digest = hmac.digest();
digest.copy(output, outputOffset);
}
/**
* AES encryption in ECB mode
*/
ciph(output, outputOffset, input, inputOffset) {
// Ensure we have a 32-byte key for AES-256
let key = this.key;
if (key.length === 16) {
// For AES-128, we need to pad or use a different approach
// For now, let's use AES-128 if key is 16 bytes
const cipher = crypto.createCipheriv('aes-128-ecb', key, '');
cipher.setAutoPadding(false);
const encrypted = cipher.update(input.subarray(inputOffset, inputOffset + 16));
encrypted.copy(output, outputOffset);
}
else if (key.length === 24) {
// For AES-192
const cipher = crypto.createCipheriv('aes-192-ecb', key, '');
cipher.setAutoPadding(false);
const encrypted = cipher.update(input.subarray(inputOffset, inputOffset + 16));
encrypted.copy(output, outputOffset);
}
else {
// For AES-256 (32 bytes)
const cipher = crypto.createCipheriv('aes-256-ecb', key, '');
cipher.setAutoPadding(false);
const encrypted = cipher.update(input.subarray(inputOffset, inputOffset + 16));
encrypted.copy(output, outputOffset);
}
}
/**
* XOR operation between two buffers
*/
xor(result, resultOffset, a, aOffset, b, bOffset, length) {
for (let i = 0; i < length; i++) {
result[resultOffset + i] = a[aOffset + i] ^ b[bOffset + i];
}
}
/**
* Converts bigint to bytes
*/
bigIntToBytes(num) {
const hex = num.toString(16);
const paddedHex = hex.length % 2 === 0 ? hex : '0' + hex;
return Buffer.from(paddedHex, 'hex');
}
/**
* Converts bytes to bigint
*/
bytesToBigInt(bytes) {
return BigInt('0x' + bytes.toString('hex'));
}
/**
* Main FF1 cipher function
*/
cipher(X, twk, encrypt) {
const n = X.length;
const u = Math.floor(n / 2);
const v = n - u;
// Validate input length
if (n < this.minLength || n > this.maxLength) {
throw new Error(`Input length ${n} is outside allowed range [${this.minLength}, ${this.maxLength}]`);
}
const b = Math.ceil((Math.log2(this.radix) * v + 7) / 8);
const d = 4 * Math.ceil((b + 3) / 4) + 4;
const p = 16;
const r = Math.ceil((d + 15) / 16) * 16;
// The number of bytes in Q
const q = Math.ceil((twk.length + b + 1 + 15) / 16) * 16;
let A, B;
const PQ = Buffer.alloc(p + q);
const R = Buffer.alloc(r);
// Use default tweak if none is supplied
if (!twk) {
twk = this.tweak;
}
// Step 2: Split input
if (encrypt) {
A = X.substring(0, u);
B = X.substring(u);
}
else {
B = X.substring(0, u);
A = X.substring(u);
}
// Step 5: Construct P
PQ[0] = 1;
PQ[1] = 2;
PQ[2] = 1;
PQ[3] = (this.radix >> 16) & 0xff;
PQ[4] = (this.radix >> 8) & 0xff;
PQ[5] = this.radix & 0xff;
PQ[6] = 10;
PQ[7] = u;
PQ[8] = (n >> 24) & 0xff;
PQ[9] = (n >> 16) & 0xff;
PQ[10] = (n >> 8) & 0xff;
PQ[11] = n & 0xff;
PQ[12] = (twk.length >> 24) & 0xff;
PQ[13] = (twk.length >> 16) & 0xff;
PQ[14] = (twk.length >> 8) & 0xff;
PQ[15] = twk.length & 0xff;
// Step 6i: Copy tweak to Q
twk.copy(PQ, p);
// Main FF1 loop (10 rounds)
for (let i = 0; i < 10; i++) {
// Step 6v: Determine m
const m = ((i + (encrypt ? 1 : 0)) % 2 === 1) ? u : v;
// Step 6i: Set round number
PQ[PQ.length - b - 1] = encrypt ? i : (9 - i);
// Convert B to integer and store in Q
const c = this.number(B);
const numb = this.bigIntToBytes(c);
if (b <= numb.length) {
numb.copy(PQ, PQ.length - b, 0, b);
}
else {
// Pad on the left with zeros
PQ.fill(0, PQ.length - b, PQ.length - numb.length);
numb.copy(PQ, PQ.length - numb.length);
}
// Step 6ii: PRF
this.prf(R, 0, PQ, 0, PQ.length);
// Step 6iii: Generate additional blocks if needed
for (let j = 1; j < r / 16; j++) {
const l = j * 16;
R.fill(0, l, l + 12);
R[l + 12] = (j >> 24) & 0xff;
R[l + 13] = (j >> 16) & 0xff;
R[l + 14] = (j >> 8) & 0xff;
R[l + 15] = j & 0xff;
this.xor(R, l, R, 0, R, l, 16);
this.ciph(R, l, R, l);
}
// Step 6vi: Calculate A +/- y mod radix^m
const y = this.bytesToBigInt(R.subarray(0, d));
const yMod = y % (1n << BigInt(8 * d));
const cA = this.number(A);
let result;
if (encrypt) {
result = (cA + yMod) % BigInt(this.radix) ** BigInt(m);
}
else {
result = (cA - yMod) % BigInt(this.radix) ** BigInt(m);
if (result < 0n) {
result += BigInt(this.radix) ** BigInt(m);
}
}
// Step 6viii: Swap A and B
A = B;
// Step 6vii, 6ix: Convert result back to string
B = this.str(m, result);
}
// Step 7: Return final result
return encrypt ? (A + B) : (B + A);
}
/**
* Encrypts a string using FF1
* @param input - The string to encrypt
* @param tweak - Optional tweak value (uses default if not provided)
* @returns The encrypted string
*/
encrypt(input, tweak) {
let twk = tweak;
if (typeof twk === 'string') {
twk = Buffer.from(twk, 'utf8');
}
else if (!twk) {
twk = this.tweak;
}
return this.cipher(input, twk, true);
}
/**
* Decrypts a string using FF1
* @param input - The string to decrypt
* @param tweak - Optional tweak value (uses default if not provided)
* @returns The decrypted string
*/
decrypt(input, tweak) {
let twk = tweak;
if (typeof twk === 'string') {
twk = Buffer.from(twk, 'utf8');
}
else if (!twk) {
twk = this.tweak;
}
return this.cipher(input, twk, false);
}
/**
* Gets the current configuration
*/
getConfig() {
return {
radix: this.radix,
alphabet: this.alphabet,
minLength: this.minLength,
maxLength: this.maxLength,
keyLength: this.key.length,
tweakLength: this.tweak.length
};
}
}
/**
* Predefined alphabets for common use cases
*/
const Alphabets = {
/** Numeric alphabet (0-9) */
NUMERIC: '0123456789',
/** Lowercase alphabet (a-z) */
LOWERCASE: 'abcdefghijklmnopqrstuvwxyz',
/** Uppercase alphabet (A-Z) */
UPPERCASE: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
/** Alphanumeric alphabet (0-9, a-z, A-Z) */
ALPHANUMERIC: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
/** Hexadecimal alphabet (0-9, a-f) */
HEXADECIMAL: '0123456789abcdef',
/** Base64 alphabet */
BASE64: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
};
/**
* Creates an FF1 instance with common configurations
*/
function createFF1(key, tweak, alphabet = Alphabets.ALPHANUMERIC, minLength = 2, maxLength = 100) {
return new FF1(key, tweak, alphabet.length, alphabet, minLength, maxLength);
}
/**
* Creates an FF1 instance for numeric encryption
*/
function createNumericFF1(key, tweak, minLength = 2, maxLength = 100) {
return new FF1(key, tweak, 10, Alphabets.NUMERIC, minLength, maxLength);
}
/**
* Creates an FF1 instance for alphanumeric encryption
*/
function createAlphanumericFF1(key, tweak, minLength = 2, maxLength = 100) {
return new FF1(key, tweak, 62, Alphabets.ALPHANUMERIC, minLength, maxLength);
}
exports.Alphabets = Alphabets;
exports.FF1 = FF1;
exports.createAlphanumericFF1 = createAlphanumericFF1;
exports.createFF1 = createFF1;
exports.createNumericFF1 = createNumericFF1;
}));
//# sourceMappingURL=ff1.js.map