Created
May 17, 2025 08:02
-
-
Save buren/46fbb9ed407dd788395474044499ea8c to your computer and use it in GitHub Desktop.
Encrypts a string using AES-GCM with the Web Crypto API
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
}); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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)); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// --- 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