(feat): updated calls

This commit is contained in:
Alois 2026-04-19 02:35:37 +02:00
commit ab36992bdd
15 changed files with 318 additions and 99 deletions

View file

@ -10,12 +10,15 @@ function getUninitializedApi(): null {
}
describe("createCryptoActions", () => {
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
test("throws when API is not initialized", async () => {
const actions = createCryptoActions(getUninitializedApi);
let failed = false;
try {
await actions.encrypt("ab", "plain");
await actions.encrypt("ab", new TextEncoder().encode("plain"));
} catch (error) {
failed = (error as Error).message.includes("API not initialized");
}
@ -25,10 +28,22 @@ describe("createCryptoActions", () => {
test("delegates encrypt/decrypt/getSharedSecret to API reference", async () => {
const api = {
encrypt: async (secret: string, plaintext: string): Promise<string> =>
encrypt: async (
secret: string,
input: Uint8Array<ArrayBuffer>,
): Promise<Uint8Array<ArrayBuffer>> =>
textEncoder.encode(`${secret}:${textDecoder.decode(input)}`),
decrypt: async (
secret: string,
input: Uint8Array<ArrayBuffer>,
): Promise<Uint8Array<ArrayBuffer>> =>
textEncoder.encode(`${secret}|${textDecoder.decode(input)}`),
encryptText: async (secret: string, plaintext: string): Promise<string> =>
`${secret}:${plaintext}`,
decrypt: async (secret: string, ciphertext: string): Promise<string> =>
`${secret}|${ciphertext}`,
decryptText: async (
secret: string,
ciphertext: string,
): Promise<string> => `${secret}|${ciphertext}`,
getSharedSecret: async (
ownPrivateKey: string,
ownPublicKey: string,
@ -39,8 +54,14 @@ describe("createCryptoActions", () => {
const actions = createCryptoActions(() => api);
expect(await actions.encrypt("s", "p")).toBe("s:p");
expect(await actions.decrypt("s", "c")).toBe("s|c");
expect(
textDecoder.decode(await actions.encrypt("s", textEncoder.encode("p"))),
).toBe("s:p");
expect(
textDecoder.decode(await actions.decrypt("s", textEncoder.encode("c"))),
).toBe("s|c");
expect(await actions.encryptText("s", "p")).toBe("s:p");
expect(await actions.decryptText("s", "c")).toBe("s|c");
expect(await actions.getSharedSecret("a", "b", "c")).toBe("a.b.c");
});
});

View file

@ -2,8 +2,16 @@ import * as React from "react";
import * as Comlink from "comlink";
type CryptoContextType = {
decrypt: (secret: string, ciphertext: string) => Promise<string>;
encrypt: (secret: string, plaintext: string) => Promise<string>;
decrypt: (
secret: string,
input: Uint8Array<ArrayBuffer>,
) => Promise<Uint8Array<ArrayBuffer>>;
decryptText: (secret: string, ciphertext: string) => Promise<string>;
encrypt: (
secret: string,
input: Uint8Array<ArrayBuffer>,
) => Promise<Uint8Array<ArrayBuffer>>;
encryptText: (secret: string, plaintext: string) => Promise<string>;
getSharedSecret: (
ownPrivateKey: string,
ownPublicKey: string,
@ -12,8 +20,16 @@ type CryptoContextType = {
};
type ApiRef = {
encrypt: (secret: string, plaintext: string) => Promise<string>;
decrypt: (secret: string, ciphertext: string) => Promise<string>;
encrypt: (
secret: string,
input: Uint8Array<ArrayBuffer>,
) => Promise<Uint8Array<ArrayBuffer>>;
decrypt: (
secret: string,
input: Uint8Array<ArrayBuffer>,
) => Promise<Uint8Array<ArrayBuffer>>;
decryptText: (secret: string, ciphertext: string) => Promise<string>;
encryptText: (secret: string, plaintext: string) => Promise<string>;
getSharedSecret: (
ownPrivateKey: string,
ownPublicKey: string,
@ -21,6 +37,19 @@ type ApiRef = {
) => Promise<string>;
};
export function bytesToBase64(bytes: Uint8Array<ArrayBuffer>): string {
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary);
}
export function base64ToBytes(base64: string): Uint8Array<ArrayBuffer> {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
export const context = React.createContext<CryptoContextType | undefined>(
undefined,
);
@ -45,6 +74,16 @@ export default function Provider(props: { children: React.ReactNode }) {
if (!api) throw new Error("API not initialized");
return await api.decrypt(secret, ciphertext);
},
encryptText: async (secret, plaintext) => {
const api = apiRef.current;
if (!api) throw new Error("API not initialized");
return await api.encryptText(secret, plaintext);
},
decryptText: async (secret, ciphertext) => {
const api = apiRef.current;
if (!api) throw new Error("API not initialized");
return await api.decryptText(secret, ciphertext);
},
getSharedSecret: async (ownPrivateKey, ownPublicKey, otherPublicKey) => {
const api = apiRef.current;
if (!api) throw new Error("API not initialized");
@ -83,33 +122,63 @@ export function createCryptoActions(
getApiRef: () => ApiRef | null,
): CryptoContextType {
/**
* Encrypts plaintext by delegating to the crypto worker API.
* Encrypts bytes by delegating to the crypto worker API.
* @param secret Hex-encoded shared secret.
* @param plaintext Plaintext to encrypt.
* @returns Encrypted ciphertext.
* @param input Plaintext bytes to encrypt.
* @returns Ciphertext bytes.
*/
const encrypt = async (
secret: string,
input: Uint8Array<ArrayBuffer>,
): Promise<Uint8Array<ArrayBuffer>> => {
const api = getApiRef();
if (!api) throw new Error("API not initialized");
return await api.encrypt(secret, input);
};
/**
* Decrypts bytes by delegating to the crypto worker API.
* @param secret Hex-encoded shared secret.
* @param input Ciphertext bytes to decrypt.
* @returns Plaintext bytes.
*/
const decrypt = async (
secret: string,
input: Uint8Array<ArrayBuffer>,
): Promise<Uint8Array<ArrayBuffer>> => {
const api = getApiRef();
if (!api) throw new Error("API not initialized");
return await api.decrypt(secret, input);
};
/**
* Encrypts plaintext text by delegating to the crypto worker API.
* @param secret Hex-encoded shared secret.
* @param plaintext Plaintext to encrypt.
* @returns Base64 ciphertext.
*/
const encryptText = async (
secret: string,
plaintext: string,
): Promise<string> => {
const api = getApiRef();
if (!api) throw new Error("API not initialized");
return await api.encrypt(secret, plaintext);
return await api.encryptText(secret, plaintext);
};
/**
* Decrypts ciphertext by delegating to the crypto worker API.
* Decrypts base64 ciphertext text by delegating to the crypto worker API.
* @param secret Hex-encoded shared secret.
* @param ciphertext Ciphertext to decrypt.
* @param ciphertext Base64 ciphertext to decrypt.
* @returns Decrypted plaintext.
*/
const decrypt = async (
const decryptText = async (
secret: string,
ciphertext: string,
): Promise<string> => {
const api = getApiRef();
if (!api) throw new Error("API not initialized");
return await api.decrypt(secret, ciphertext);
return await api.decryptText(secret, ciphertext);
};
/**
@ -133,7 +202,7 @@ export function createCryptoActions(
);
};
return { encrypt, decrypt, getSharedSecret };
return { encrypt, decrypt, encryptText, decryptText, getSharedSecret };
}
/**

View file

@ -1,6 +1,12 @@
import { describe, expect, test } from "bun:test";
import { x448 } from "@noble/curves/ed448.js";
import { decrypt, encrypt, getSharedSecret } from "./worker";
import {
decrypt,
decryptText,
encrypt,
encryptText,
getSharedSecret,
} from "./worker";
/**
* Encodes bytes to URL-safe base64 without padding.
@ -55,22 +61,35 @@ function createPrivateKey(seed: number): Uint8Array {
}
describe("crypto worker", () => {
test("encrypt/decrypt round-trip returns original plaintext", async () => {
const secret = "a1".repeat(56);
const plaintext = "hello encrypted world";
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const ciphertext = await encrypt(secret, plaintext);
const decrypted = await decrypt(secret, ciphertext);
test("encrypt/decrypt byte round-trip returns original plaintext", async () => {
const secret = "0f".repeat(56);
const input = "hello encrypted world";
expect(decrypted).toBe(plaintext);
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 plaintext = "sensitive";
const input = "sensitive";
const ciphertext = await encrypt(secret, plaintext);
const ciphertext = await encrypt(secret, textEncoder.encode(input));
let failed = false;
try {

View file

@ -10,18 +10,44 @@ type JWK = {
};
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const crypto = globalThis.crypto;
/**
* Encrypts plaintext with a symmetric key derived from a hex shared secret.
* Encodes bytes as standard base64 text.
* @param bytes Bytes to encode.
* @returns Base64 string.
*/
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
/**
* Decodes standard base64 text into bytes.
* @param base64 Base64 string.
* @returns Decoded bytes.
*/
function base64ToBytes(base64: string): Uint8Array<ArrayBuffer> {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
/**
* Encrypts bytes with a symmetric key derived from a hex shared secret.
* @param secret Hex-encoded shared secret.
* @param plaintext UTF-8 plaintext to encrypt.
* @returns Base64-encoded ciphertext.
* @param input Plaintext bytes to encrypt.
* @returns Ciphertext bytes.
*/
export async function encrypt(
secret: string,
plaintext: string,
): Promise<string> {
input: Uint8Array<ArrayBuffer>,
): Promise<Uint8Array<ArrayBuffer>> {
const sharedSecret = new Uint8Array(
secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
);
@ -60,30 +86,26 @@ export async function encrypt(
const encryptedBuffer = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce },
aesKey,
textEncoder.encode(plaintext),
input,
);
return btoa(String.fromCharCode(...new Uint8Array(encryptedBuffer)));
return new Uint8Array(encryptedBuffer);
}
/**
* Decrypts base64 ciphertext with a symmetric key derived from a hex shared secret.
* Decrypts bytes with a symmetric key derived from a hex shared secret.
* @param secret Hex-encoded shared secret.
* @param ciphertext Base64 ciphertext to decrypt.
* @returns Decrypted UTF-8 plaintext.
* @param input Ciphertext bytes to decrypt.
* @returns Plaintext bytes.
*/
export async function decrypt(
secret: string,
ciphertext: Base64URLString | string,
): Promise<string> {
input: Uint8Array<ArrayBuffer>,
): Promise<Uint8Array<ArrayBuffer>> {
const sharedSecret = new Uint8Array(
secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
);
const ciphertextBytes = Uint8Array.from(atob(ciphertext), (c) =>
c.charCodeAt(0),
);
const hkdfKey = await crypto.subtle.importKey(
"raw",
sharedSecret,
@ -121,10 +143,38 @@ export async function decrypt(
iv: nonce,
},
aesKey,
ciphertextBytes,
input,
);
return new TextDecoder().decode(decryptedBuffer);
return new Uint8Array(decryptedBuffer);
}
/**
* Encrypts UTF-8 text and returns base64 ciphertext for easy transport/storage.
* @param secret Hex-encoded shared secret.
* @param plaintext Text to encrypt.
* @returns Base64 ciphertext.
*/
export async function encryptText(
secret: string,
plaintext: string,
): Promise<string> {
const encrypted = await encrypt(secret, textEncoder.encode(plaintext));
return bytesToBase64(encrypted);
}
/**
* Decrypts base64 ciphertext into UTF-8 text.
* @param secret Hex-encoded shared secret.
* @param ciphertext Base64 ciphertext.
* @returns Decrypted text.
*/
export async function decryptText(
secret: string,
ciphertext: string,
): Promise<string> {
const decrypted = await decrypt(secret, base64ToBytes(ciphertext));
return textDecoder.decode(decrypted);
}
/**
@ -462,6 +512,8 @@ if (isWorkerRuntime()) {
Comlink.expose({
encrypt,
decrypt,
encryptText,
decryptText,
getSharedSecret,
});
}