client/packages/crypto/src/worker.test.ts
2026-04-19 02:35:37 +02:00

123 lines
3.4 KiB
TypeScript

import { describe, expect, test } from "bun:test";
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);
});
});