fernet-web
Version:
A library that implements the Fernet algorithm using the Web Crypto API.
235 lines (202 loc) • 8.19 kB
text/typescript
/**
* @author Rich Miles
* @date December 05, 2022
*
* This code provides an implementation of the Fernet symmetric encryption algorithm
* using the Web Crypto API.
*
* @warning This code is provided as is, without any warranty or guarantees. Use at your own risk.
*
* @example
* // Create a new Fernet instance using a secret key
* const fernet = await Fernet.create("-lf4DsgLkOaE1GbtIQKNGU1NPQByMDKP2a6Enl9rclE=");
*
* // Encrypt a message
* const encryptedToken = await fernet.encrypt("Hello world!");
*
* // Decrypt the encrypted message
* const decryptedMessage = await fernet.decrypt(encryptedToken);
*
* // Print the decrypted message
* console.log(decryptedMessage); // Hello world!
*/
/**
* Fernet is a simple and secure way to encrypt and decrypt messages using symmetric encryption.
*/
export default class Fernet {
private signingKey: CryptoKey;
private encryptionKey: CryptoKey;
/**
* Create a new Fernet instance with the provided signing and encryption keys.
* @param signingKey - A CryptoKey to use for signing messages.
* @param encryptionKey - A CryptoKey to use for encrypting and decrypting messages.
*/
private constructor(signingKey: CryptoKey, encryptionKey: CryptoKey) {
this.signingKey = signingKey
this.encryptionKey = encryptionKey
}
/**
* Generate signing and encryption keys from a secret key.
* @param secretKeyBuffer - A Uint8Array containing the secret key to use.
* @returns A tuple containing the signing and encryption keys.
*/
private static async initializeKeys(secretKeyBuffer: Uint8Array): Promise<{ signingKey: CryptoKey, encryptionKey: CryptoKey }> {
const signingKeyBuffer = secretKeyBuffer.slice(0, 16);
const encryptionKeyBuffer = secretKeyBuffer.slice(16);
const signingKey = await crypto.subtle.importKey(
"raw",
signingKeyBuffer,
{
name: "HMAC",
hash: "SHA-256",
},
false,
["verify", "sign"]
);
const encryptionKey = await crypto.subtle.importKey(
"raw",
encryptionKeyBuffer,
"AES-CBC",
false,
["encrypt", "decrypt"]
);
return { signingKey, encryptionKey }
}
/**
* Create a new Fernet instance with a secret key.
* @param secretKey_b64 - A base64-encoded string representation of the secret key to use.
* If null, a new secret key will be generated.
* @returns A new Fernet instance.
*/
public static async create(secretKey_b64: string | null): Promise<Fernet> {
var secretKeyBuffer = new Uint8Array(32)
if (secretKey_b64 != null) {
// Decode the URL-safe base64-encoded secret key
secretKeyBuffer = Uint8Array.from(window.atob(secretKey_b64.replace(/_/g, '/').replace(/-/g, '+')), c => c.charCodeAt(0));
}
else {
// Create a new secret key from scratch to use for this session
crypto.getRandomValues(secretKeyBuffer)
}
const keys = await Fernet.initializeKeys(secretKeyBuffer)
return new Fernet(keys.signingKey, keys.encryptionKey)
}
/**
Encrypt a message into a Fernet token.
@param plainText - The message to encrypt.
@returns A base64-encoded Fernet token.
*/
public async encrypt(plainText: string): Promise<string> {
const plainTextBuffer = new TextEncoder().encode(plainText)
const ivBuffer = new Uint8Array(16); // create a new array to store the IV
// generate the random IV
crypto.getRandomValues(ivBuffer);
// Encrypt the plain text
const encryptedMessage = await crypto.subtle.encrypt(
{ name: "AES-CBC", iv: ivBuffer },
this.encryptionKey,
plainTextBuffer
);
// Version is always the first byte and always 128 (0x80)
const version = new Uint8Array([0x80]);
// The time bytes are a big-endian encoded uint64 of unix epoch time (seconds)
const timeBuffer = new ArrayBuffer(8);
const view = new DataView(timeBuffer);
const currentTime = Date.now();
const unixEpochTime = Math.round(currentTime / 1000);
view.setBigUint64(0, BigInt(unixEpochTime), false);
const timestamp = new Uint8Array(timeBuffer);
// Convert the IV to a Uint8Array
const iv = new Uint8Array(ivBuffer);
// Convert the encrtyped message to a Uint8Array
const ciphertext = new Uint8Array(encryptedMessage);
// Concatenate the unsigned component
const unsigned_token = new Uint8Array([
...version,
...timestamp,
...iv,
...ciphertext,
])
// Compute the HMAC signature
const hmac = new Uint8Array(await crypto.subtle.sign(
{ name: "HMAC", hash: "SHA-256", },
this.signingKey,
unsigned_token)
);
// Concatenate the header with the signature
const signed_token = new Uint8Array([
...version,
...timestamp,
...iv,
...ciphertext,
...hmac
])
// Convert the signed token array to a URL-Safe base64 string
// Note: The signed_token array is a Uint8Array, but the btoa function expects a string.
// Originally, this was using String.fromCharCode(signed_token), but that was causing
// a stack overflow with large arrays. This is a workaround that avoids the stack overflow.
let tokenChars: string[] = [];
signed_token.forEach(byte => {
tokenChars.push(String.fromCharCode(byte));
});
const fernetToken = window.btoa(tokenChars.join(""))
.replace(/\+/g, "-")
.replace(/\//g, "_");
return fernetToken
}
/**
* Decrypt a Fernet token and return the plain text message.
* @param token_b64 - A base64-encoded Fernet token.
* @returns The decrypted message as a string.
*/
public async decrypt(token_b64: string): Promise<string> {
// Decode the base64-encoded secret key
// Convert the URL-safe base 64 value to a Uint8Array
const tokenBuffer = Uint8Array.from(window.atob(token_b64.replace(/_/g, '/').replace(/-/g, '+')), c => c.charCodeAt(0));
// Decompose the token into its various parts
const version = tokenBuffer.slice(0, 1);
const timestamp = tokenBuffer.slice(1, 9);
const iv = tokenBuffer.slice(9, 25);
const ciphertext = tokenBuffer.slice(25, -32);
const hmac = tokenBuffer.slice(-32);
// Decrypt the cipher text
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-CBC",
iv: iv,
},
this.encryptionKey,
ciphertext
);
const decryptedMessage = new TextDecoder().decode(decrypted);
// Verify the HMAC signature
const unsigned_token = new Uint8Array([
...version,
...timestamp,
...iv,
...ciphertext,
])
const verification = await crypto.subtle.verify(
{ name: "HMAC", hash: "SHA-256" },
this.signingKey,
hmac,
unsigned_token,
)
if (!verification) {
throw new InvalidTokenError("Token is invalid or has been tampered with");
}
return decryptedMessage;
}
}
/**
* The InvalidTokenError class is used to indicate that a Fernet token failed to verify.
*/
class InvalidTokenError extends Error {
/*
Create a new InvalidTokenError instance.
@param message - A string describing the error that occurred.
*/
constructor(message: string) {
super(message);
}
}