Skip to content

Instantly share code, notes, and snippets.

@buren
Created May 17, 2025 08:02
Show Gist options
  • Save buren/46fbb9ed407dd788395474044499ea8c to your computer and use it in GitHub Desktop.
Save buren/46fbb9ed407dd788395474044499ea8c to your computer and use it in GitHub Desktop.
Encrypts a string using AES-GCM with the Web Crypto API
import {
encryptAESGCM,
decryptAESGCM,
generateSecretKey,
base64UrlDecode,
base64UrlEncode
} from "./aes-crypto";
describe("AES-GCM Encryption and Decryption", () => {
const secretKey = "VGVzdEtleTI1NiBiaXRzIG11c3QgYmUgMzIgYnl0ZXM="; // Base64 encoded 32 bytes
const plaintext = "This is a secret message.";
it("should encrypt and decrypt correctly", async () => {
const ciphertext = await encryptAESGCM(plaintext, secretKey);
expect(ciphertext).not.toBe("");
expect(ciphertext).not.toBe(plaintext);
const decryptedText = await decryptAESGCM(ciphertext, secretKey);
expect(decryptedText).toBe(plaintext);
});
it("should throw an error on encryption with an invalid base64 key", async () => {
const invalidKey = "abc!"; // Invalid base64 key
await expect(encryptAESGCM(plaintext, invalidKey)).rejects.toThrow(); // Any error: atob error
});
it("should throw an error on decryption with an invalid base64 key", async () => {
const ciphertext = await encryptAESGCM(plaintext, secretKey); // Valid encryption
const invalidKey = "abc!"; // Invalid base64 key
await expect(decryptAESGCM(ciphertext, invalidKey)).rejects.toThrow(); // Any error: atob error
});
it("should handle non-ASCII characters correctly", async () => {
const plaintextWithEmoji = "Hello 😊 World!";
const ciphertext = await encryptAESGCM(plaintextWithEmoji, secretKey);
const decryptedText = await decryptAESGCM(ciphertext, secretKey);
expect(decryptedText).toBe(plaintextWithEmoji);
});
it("should handle empty string input", async () => {
const ciphertext = await encryptAESGCM("", secretKey);
const decryptedText = await decryptAESGCM(ciphertext, secretKey);
expect(decryptedText).toBe("");
});
it("should throw a specific error if key is the incorrect length (encrypt)", async () => {
const shortKey = "c2hvcnRfa2V5"; // "short_key" base64 encoded (valid base64, wrong length)
await expect(encryptAESGCM(plaintext, shortKey)).rejects.toThrow(
"Secret key must be 32 bytes long for AES-256 (when base64 decoded)"
);
});
it("should throw a specific error if key is the incorrect length (decrypt)", async () => {
const shortKey = "c2hvcnRfa2V5"; // "short_key" base64 encoded (valid base64, wrong length)
// Use dummy ciphertext, we are testing key validation here.
await expect(decryptAESGCM("some_ciphertext", shortKey)).rejects.toThrow(
"Secret key must be 32 bytes long for AES-256 (when base64 decoded)"
);
});
it("should re-throw the same error on encryption failure", async () => {
const expectedError = new Error("Some unexpected encryption error");
jest.spyOn(crypto.subtle, "importKey").mockRejectedValue(expectedError);
await expect(encryptAESGCM(plaintext, secretKey)).rejects.toThrow(
expectedError
);
jest.restoreAllMocks();
});
it("should re-throw the same error on decryption failure", async () => {
const ciphertext = await encryptAESGCM(plaintext, secretKey); // Valid
const expectedError = new Error("Some unexpected decryption error");
jest.spyOn(crypto.subtle, "importKey").mockRejectedValue(expectedError);
await expect(decryptAESGCM(ciphertext, secretKey)).rejects.toThrow(
expectedError
);
jest.restoreAllMocks();
});
it("should re-throw atob error if base64UrlDecode fails during encryption", async () => {
// Mock atob to throw an error
const expectedError = new Error("base64 decode error");
jest.spyOn(global, "atob").mockImplementation(() => {
throw expectedError;
});
await expect(encryptAESGCM(plaintext, "invalid-base64")).rejects.toThrow(
expectedError
);
// Restore atob
jest.restoreAllMocks();
});
it("should re-throw atob error if base64UrlDecode fails during decryption", async () => {
const ciphertext = await encryptAESGCM(plaintext, secretKey); //Valid
const expectedError = new Error("base64 decode error");
// Mock atob to throw an error
jest.spyOn(global, "atob").mockImplementation(() => {
throw expectedError;
});
await expect(decryptAESGCM(ciphertext, "invalid-base64")).rejects.toThrow(
expectedError
);
// Restore atob
jest.restoreAllMocks();
});
});
describe("Key Generation", () => {
it("should generate a valid 32-byte key", () => {
const key = generateSecretKey();
expect(key).toBeDefined();
expect(typeof key).toBe("string");
// Decode the key and check its length
const decodedKey = base64UrlDecode(key);
expect(decodedKey.byteLength).toBe(32);
});
it("should generate different keys each time", () => {
const key1 = generateSecretKey();
const key2 = generateSecretKey();
expect(key1).not.toBe(key2);
});
it("Generated Key should be a valid key to encrypt and decrypt", async () => {
const key = generateSecretKey();
const plaintext = "My test";
const encrypted = await encryptAESGCM(plaintext, key);
const decrypted = await decryptAESGCM(encrypted, key);
expect(plaintext).toBe(decrypted);
});
});
describe("Base64 URL Encoding and Decoding", () => {
it("should encode and decode a Uint8Array correctly", () => {
const originalData = new Uint8Array([
72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100,
]); // "Hello World"
const encoded = base64UrlEncode(originalData);
const decoded = base64UrlDecode(encoded);
expect(encoded).toBe("SGVsbG8gV29ybGQ"); // Expected URL-safe Base64
expect(decoded).toEqual(originalData); // Decoded should match original
});
it("should handle empty input", () => {
const emptyData = new Uint8Array([]);
const encoded = base64UrlEncode(emptyData);
const decoded = base64UrlDecode(encoded);
expect(encoded).toBe("");
expect(decoded).toEqual(emptyData);
});
it("should handle data with padding characters", () => {
const data = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const encoded = base64UrlEncode(data);
const decoded = base64UrlDecode(encoded);
expect(encoded).toBe("AAECAwQFBgcICQo"); // Expected URL-safe, no padding
expect(decoded).toEqual(data);
});
it("should handle data requiring padding correctly (1 byte)", () => {
const data = new Uint8Array([1]); // Requires padding
const encoded = base64UrlEncode(data);
const decoded = base64UrlDecode(encoded);
expect(encoded).toBe("AQ"); //URL Safe
expect(decoded).toEqual(data);
});
it("should handle data requiring padding correctly (2 bytes)", () => {
const data = new Uint8Array([1, 2]); // Requires padding
const encoded = base64UrlEncode(data);
const decoded = base64UrlDecode(encoded);
expect(encoded).toBe("AQI"); // URL Safe
expect(decoded).toEqual(data);
});
it("should correctly encode and decode URL-safe Base64", () => {
const originalData = new Uint8Array([126, 77, 41]); // Some binary data
const encoded = base64UrlEncode(originalData); // Generate a proper encoded string
const decoded = base64UrlDecode(encoded); // Decode it back
expect(decoded).toEqual(originalData);
expect(base64UrlEncode(decoded)).toBe(encoded);
});
it("should throw an error for invalid Base64 characters", () => {
const invalidBase64 = "abc!"; // Contains "!"
expect(() => base64UrlDecode(invalidBase64)).toThrow();
});
it("should handle already standard base64 (no url safe chars)", () => {
const data = new Uint8Array([
72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100,
]); // "Hello World"
const encoded = btoa(String.fromCharCode(...data)); // Standard base 64
const decoded = base64UrlDecode(encoded);
expect(new TextDecoder().decode(decoded)).toBe("Hello World");
});
it("should correctly decode a string that includes padding", () => {
const strWithPadding = "SGVsbG8gV29ybGQ="; // "Hello World" with padding.
const data = new Uint8Array([
72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100,
]); // "Hello World"
const decoded = base64UrlDecode(strWithPadding);
expect(decoded).toEqual(data);
});
});
/**
* Encrypts a string using AES-GCM with the Web Crypto API.
*
* @param plaintext - The string to encrypt.
* @param secretKey - The secret key, as a base64 encoded string. Must be 32 bytes long (256 bits) when decoded.
* @returns A URL-safe Base64-encoded string representing the combined nonce and ciphertext. Returns an empty string on error.
* @throws If the secret key is not the correct length.
*/
export async function encryptAESGCM(
plaintext: string,
secretKey: string
): Promise<string> {
try {
// Decode the secret key from Base64
const keyData = base64UrlDecode(secretKey);
if (keyData.byteLength !== 32) {
throw new Error(
"Secret key must be 32 bytes long for AES-256 (when base64 decoded)"
);
}
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "AES-GCM", length: 256 },
false, // Not extractable
["encrypt"]
);
// Generate a random 96-bit (12-byte) nonce
const nonce = crypto.getRandomValues(new Uint8Array(12));
// Encode the plaintext as a Uint8Array
const plaintextBuffer = new TextEncoder().encode(plaintext);
// Encrypt the data
const ciphertextBuffer = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: nonce,
tagLength: 128, // Tag length in bits (16 bytes)
},
key,
plaintextBuffer
);
const fullCiphertext = new Uint8Array(
nonce.byteLength + ciphertextBuffer.byteLength
);
fullCiphertext.set(nonce, 0);
fullCiphertext.set(new Uint8Array(ciphertextBuffer), nonce.byteLength);
// Encode combined nonce and ciphertext as URL-safe Base64
return base64UrlEncode(fullCiphertext);
} catch (error) {
throw error;
}
}
/**
* Decrypts a URL-safe Base64-encoded string using AES-GCM with the Web Crypto API.
*
* @param encodedData - The URL-safe Base64-encoded string representing the combined nonce and ciphertext.
* @param secretKey - The secret key, as a base64 encoded string. Must be 32 bytes (256 bits) long when decoded.
* @returns The decrypted plaintext. Returns an empty string on error.
* @throws If the secret key is not the correct length.
*/
export async function decryptAESGCM(
encodedData: string,
secretKey: string
): Promise<string> {
try {
// Decode the secret key from Base64
const keyData = base64UrlDecode(secretKey);
if (keyData.byteLength !== 32) {
throw new Error(
"Secret key must be 32 bytes long for AES-256 (when base64 decoded)"
);
}
// Import the key
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "AES-GCM", length: 256 },
false, // Not extractable
["decrypt"]
);
// Decode the combined nonce and ciphertext from URL-safe Base64
const combinedData = base64UrlDecode(encodedData);
// Extract the nonce (first 12 bytes)
const nonce = combinedData.slice(0, 12);
// Extract the ciphertext (remaining bytes)
const ciphertextBuffer = combinedData.slice(12);
// Decrypt the data
const plaintextBuffer = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: nonce,
tagLength: 128, // Tag length in bits (16 bytes)
},
key,
ciphertextBuffer
);
// Decode the plaintext from the Uint8Array
return new TextDecoder().decode(plaintextBuffer);
} catch (error) {
throw error;
}
}
/**
* Generates a cryptographically secure random secret key for AES-256 encryption.
*
* @returns A URL-safe Base64 encoded 32-byte (256-bit) secret key.
*/
export function generateSecretKey(): string {
// Generate 32 random bytes (256 bits)
const keyBytes = crypto.getRandomValues(new Uint8Array(32));
return base64UrlEncode(keyBytes);
}
/**
* Encodes a Uint8Array as a URL-safe Base64 string.
* @param data - The data to encode.
* @returns The URL-safe Base64-encoded string.
*/
export function base64UrlEncode(data: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...data));
// Convert standard Base64 to URL-safe Base64
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/**
* Decodes a URL-safe Base64 string to a Uint8Array.
* @param str - The URL-safe Base64-encoded string.
* @returns The decoded data as a Uint8Array.
*/
export function base64UrlDecode(str: string): Uint8Array {
// Convert URL-safe Base64 to standard Base64
str = str.replace(/-/g, "+").replace(/_/g, "/");
// Pad the string with '=' if necessary
while (str.length % 4) {
str += "=";
}
const binaryString = atob(str);
return Uint8Array.from(binaryString, (char) => char.charCodeAt(0));
}
// --- Example Usage (For Testing) ---
const secretKeyBase64 = "VGVzdEtleTI1NiBiaXRzIG11c3QgYmUgMzIgYnl0ZXM="; // Your base64 encoded 32-byte key
const email = "[email protected]";
const encryptedEmail = await encryptAESGCM(email, secretKeyBase64);
console.log("Encrypted Email:", encryptedEmail);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment