import { describe, expect, test } from "vitest"; import { x448 } from "@noble/curves/ed448.js"; import { decrypt, decryptText, encrypt, encryptText, getSharedSecret, } from "./worker"; /** * Encodes bytes to URL-safe base64 without padding. * @param value Input bytes. * @returns Base64url string. */ function bytesToB64u(value: Uint8Array): string { const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let output = ""; for (let index = 0; index < value.length; index += 3) { const first = value[index] ?? 0; const second = value[index + 1] ?? 0; const third = value[index + 2] ?? 0; const chunk = (first << 16) | (second << 8) | third; output += alphabet[(chunk >> 18) & 63]; output += alphabet[(chunk >> 12) & 63]; output += index + 1 < value.length ? alphabet[(chunk >> 6) & 63] : "="; output += index + 2 < value.length ? alphabet[chunk & 63] : "="; } return output.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } /** * Converts bytes to lowercase hex. * @param value Input bytes. * @returns Hex string. */ function bytesToHex(value: Uint8Array): string { return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join( "", ); } /** * Creates deterministic 56-byte private key material for tests. * @param seed Offset seed used to vary generated bytes. * @returns Deterministic private key bytes. */ function createPrivateKey(seed: number): Uint8Array { const output = new Uint8Array(56); for (let index = 0; index < output.length; index += 1) { output[index] = (seed + index) % 255; } return output; } describe("crypto worker", () => { const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); test("encrypt/decrypt byte round-trip returns original plaintext", async () => { const secret = "0f".repeat(56); const input = "hello encrypted world"; const encryptedContent = await encrypt(secret, textEncoder.encode(input)); const decryptedContent = await decrypt(secret, encryptedContent); expect(textDecoder.decode(decryptedContent)).toBe(input); }); test("encryptText/decryptText round-trip returns original plaintext", async () => { const secret = "0f".repeat(56); const input = "hello encrypted world"; const ciphertext = await encryptText(secret, input); const plaintext = await decryptText(secret, ciphertext); expect(plaintext).toBe(input); }); test("decrypt fails with wrong shared secret", async () => { const secret = "0f".repeat(56); const wrongSecret = "f0".repeat(56); const input = "sensitive"; const ciphertext = await encrypt(secret, textEncoder.encode(input)); let failed = false; try { await decrypt(wrongSecret, ciphertext); } catch { failed = true; } expect(failed).toBe(true); }); test("getSharedSecret matches noble x448 derivation", async () => { const ownPrivateBytes = createPrivateKey(7); const peerPrivateBytes = createPrivateKey(23); const ownPublicBytes = x448.getPublicKey(ownPrivateBytes); const peerPublicBytes = x448.getPublicKey(peerPrivateBytes); const expected = bytesToHex( new Uint8Array(x448.getSharedSecret(ownPrivateBytes, peerPublicBytes)), ); const actual = await getSharedSecret( bytesToB64u(ownPrivateBytes), bytesToB64u(ownPublicBytes), bytesToB64u(peerPublicBytes), ); expect(actual).toBe(expected); }); });