123 lines
3.4 KiB
TypeScript
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);
|
|
});
|
|
});
|