Big Updated, added some tests, added comments for all functions, I forgot the rest

This commit is contained in:
Alois 2026-03-15 14:52:09 +01:00
commit 90a1059cc8
59 changed files with 1816 additions and 203 deletions

View file

@ -10,11 +10,11 @@
"scripts": {
"format": "bunx prettier --write .",
"lint": "eslint src",
"build": "tsc -p tsconfig.json --noEmit"
"test": "bun test",
"build": "bun run test && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@noble/curves": "^2.0.1",
"@tensamin/ui": "workspace:*",
"comlink": "^4.4.2",
"react": "^19.2.0",
"react-dom": "^19.2.0"

11
packages/crypto/src/bun-test.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
declare module "bun:test" {
export const describe: (...args: unknown[]) => unknown;
export const test: (...args: unknown[]) => unknown;
export const it: (...args: unknown[]) => unknown;
export const expect: (value: unknown) => {
toBe: (expected: unknown) => void;
toEqual: (expected: unknown) => void;
toContain: (expected: unknown) => void;
toThrow: (expected?: unknown) => void;
};
}

View file

@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import { createCryptoActions } from "./context";
/**
* Creates a rejected API getter used to verify initialization guards.
* @returns Null API reference.
*/
function getUninitializedApi(): null {
return null;
}
describe("createCryptoActions", () => {
test("throws when API is not initialized", async () => {
const actions = createCryptoActions(getUninitializedApi);
let failed = false;
try {
await actions.encrypt("ab", "plain");
} catch (error) {
failed = (error as Error).message.includes("API not initialized");
}
expect(failed).toBe(true);
});
test("delegates encrypt/decrypt/getSharedSecret to API reference", async () => {
const api = {
encrypt: async (secret: string, plaintext: string): Promise<string> =>
`${secret}:${plaintext}`,
decrypt: async (secret: string, ciphertext: string): Promise<string> =>
`${secret}|${ciphertext}`,
getSharedSecret: async (
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
): Promise<string> =>
`${ownPrivateKey}.${ownPublicKey}.${otherPublicKey}`,
};
const actions = createCryptoActions(() => api);
expect(await actions.encrypt("s", "p")).toBe("s:p");
expect(await actions.decrypt("s", "c")).toBe("s|c");
expect(await actions.getSharedSecret("a", "b", "c")).toBe("a.b.c");
});
});

View file

@ -1,14 +1,39 @@
import * as React from "react";
import * as Comlink from "comlink";
import Loading from "@tensamin/ui/screens/loading";
export const context = React.createContext<contextType | undefined>(undefined);
type CryptoContextType = {
decrypt: (secret: string, ciphertext: string) => Promise<string>;
encrypt: (secret: string, plaintext: string) => Promise<string>;
getSharedSecret: (
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
) => Promise<string>;
};
type ApiRef = {
encrypt: (secret: string, plaintext: string) => Promise<string>;
decrypt: (secret: string, ciphertext: string) => Promise<string>;
getSharedSecret: (
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
) => Promise<string>;
};
export const context = React.createContext<CryptoContextType | undefined>(
undefined,
);
/**
* Provides cryptographic actions backed by a worker without coupling to UI state.
* @param props Component props with children.
* @returns Crypto context provider JSX.
*/
export default function Provider(props: { children: React.ReactNode }) {
const apiRef = React.useRef<ApiRef | null>(null);
const [isWorkerReady, setIsWorkerReady] = React.useState(false);
const { encrypt, decrypt, get_shared_secret } = React.useMemo(
const value = React.useMemo(
() => createCryptoActions(() => apiRef.current),
[],
);
@ -18,83 +43,84 @@ export default function Provider(props: { children: React.ReactNode }) {
type: "module",
});
apiRef.current = Comlink.wrap(worker);
setIsWorkerReady(true);
apiRef.current = Comlink.wrap<ApiRef>(worker);
return () => {
apiRef.current = null;
worker.terminate();
setIsWorkerReady(false);
};
}, []);
if (!isWorkerReady) {
return <Loading progress={10} />;
}
return (
<context.Provider value={{ encrypt, decrypt, get_shared_secret }}>
{props.children}
</context.Provider>
);
return <context.Provider value={value}>{props.children}</context.Provider>;
}
type contextType = {
decrypt: (secret: string, data: string) => Promise<string>;
encrypt: (secret: string, data: string) => Promise<string>;
get_shared_secret: (
/**
* Creates crypto action functions that safely delegate to the worker API.
* @param getApiRef Function that returns worker API reference when initialized.
* @returns Typed crypto action functions.
*/
export function createCryptoActions(
getApiRef: () => ApiRef | null,
): CryptoContextType {
/**
* Encrypts plaintext by delegating to the crypto worker API.
* @param secret Hex-encoded shared secret.
* @param plaintext Plaintext to encrypt.
* @returns Encrypted ciphertext.
*/
const encrypt = async (
secret: string,
plaintext: string,
): Promise<string> => {
const apiRef = getApiRef();
if (!apiRef) throw new Error("API not initialized");
return await apiRef.encrypt(secret, plaintext);
};
/**
* Decrypts ciphertext by delegating to the crypto worker API.
* @param secret Hex-encoded shared secret.
* @param ciphertext Ciphertext to decrypt.
* @returns Decrypted plaintext.
*/
const decrypt = async (
secret: string,
ciphertext: string,
): Promise<string> => {
const apiRef = getApiRef();
if (!apiRef) throw new Error("API not initialized");
return await apiRef.decrypt(secret, ciphertext);
};
/**
* Derives a shared secret from local and peer key material via the worker API.
* @param ownPrivateKey Local private key.
* @param ownPublicKey Local public key.
* @param otherPublicKey Peer public key.
* @returns Hex-encoded shared secret.
*/
const getSharedSecret = async (
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
) => Promise<string>;
};
type ApiRef = {
encrypt: (secret: string, message: string) => Promise<string>;
decrypt: (secret: string, encryptedMessage: string) => Promise<string>;
get_shared_secret: (
own_private_key: string,
own_public_key: string,
other_public_key: string,
) => Promise<string>;
};
export function createCryptoActions(
getApiRef: () => ApiRef | null,
): contextType {
const encrypt = async (secret: string, message: string): Promise<string> => {
const apiRef = getApiRef();
if (!apiRef) throw new Error("API not initialized");
return await apiRef.encrypt(secret, message);
};
const decrypt = async (
secret: string,
encryptedMessage: string,
): Promise<string> => {
const apiRef = getApiRef();
if (!apiRef) throw new Error("API not initialized");
return await apiRef.decrypt(secret, encryptedMessage);
};
const get_shared_secret = async (
own_private_key: string,
own_public_key: string,
other_public_key: string,
): Promise<string> => {
const apiRef = getApiRef();
if (!apiRef) throw new Error("API not initialized");
return await apiRef.get_shared_secret(
own_private_key,
own_public_key,
other_public_key,
return await apiRef.getSharedSecret(
ownPrivateKey,
ownPublicKey,
otherPublicKey,
);
};
return { encrypt, decrypt, get_shared_secret };
return { encrypt, decrypt, getSharedSecret };
}
export function useCrypto(): contextType {
/**
* Returns the crypto actions from the nearest provider.
* Throws when used outside of the crypto provider tree.
*/
export function useCrypto(): CryptoContextType {
const ctx = React.useContext(context);
if (!ctx) {
throw new Error("useCrypto must be used within a CryptoProvider");

View file

@ -0,0 +1,88 @@
import { describe, expect, test } from "bun:test";
import { x448 } from "@noble/curves/ed448.js";
import { decrypt, encrypt, getSharedSecret } from "./worker";
/**
* Encodes bytes to URL-safe base64 without padding.
* @param value Input bytes.
* @returns Base64url string.
*/
function bytesToB64u(value: Uint8Array): string {
const b64 = Buffer.from(value).toString("base64");
return b64.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", () => {
test("encrypt/decrypt round-trip returns original plaintext", async () => {
const secret = "a1".repeat(56);
const plaintext = "hello encrypted world";
const ciphertext = await encrypt(secret, plaintext);
const decrypted = await decrypt(secret, ciphertext);
expect(decrypted).toBe(plaintext);
});
test("decrypt fails with wrong shared secret", async () => {
const secret = "0f".repeat(56);
const wrongSecret = "f0".repeat(56);
const plaintext = "sensitive";
const ciphertext = await encrypt(secret, plaintext);
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);
});
});

View file

@ -12,12 +12,18 @@ type JWK = {
const textEncoder = new TextEncoder();
const crypto = globalThis.crypto;
/**
* Encrypts plaintext 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.
*/
export async function encrypt(
password: string,
input: string,
secret: string,
plaintext: string,
): Promise<string> {
const sharedSecret = new Uint8Array(
password.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
);
const hkdfKey = await crypto.subtle.importKey(
@ -54,21 +60,29 @@ export async function encrypt(
const encryptedBuffer = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce },
aesKey,
textEncoder.encode(input),
textEncoder.encode(plaintext),
);
return btoa(String.fromCharCode(...new Uint8Array(encryptedBuffer)));
}
/**
* Decrypts base64 ciphertext 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.
*/
export async function decrypt(
password: string,
input: Base64URLString | string,
secret: string,
ciphertext: Base64URLString | string,
): Promise<string> {
const sharedSecret = new Uint8Array(
password.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
);
const ciphertext = Uint8Array.from(atob(input), (c) => c.charCodeAt(0));
const ciphertextBytes = Uint8Array.from(atob(ciphertext), (c) =>
c.charCodeAt(0),
);
const hkdfKey = await crypto.subtle.importKey(
"raw",
@ -107,28 +121,45 @@ export async function decrypt(
iv: nonce,
},
aesKey,
ciphertext,
ciphertextBytes,
);
return new TextDecoder().decode(decryptedBuffer);
}
export async function get_shared_secret(
own_private_key: string,
own_public_key: string,
other_public_key: string,
/**
* Computes an X448 shared secret from local and peer key material.
* @param ownPrivateKey Local private key in raw/base64/base64url or PKCS#8-wrapped form.
* @param ownPublicKey Local public key in raw/base64/base64url or SPKI-wrapped form.
* @param otherPublicKey Peer public key in raw/base64/base64url or SPKI-wrapped form.
* @returns Hex-encoded shared secret, or a failure message when key material is missing/invalid.
*/
export async function getSharedSecret(
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
): Promise<string> {
const other_jwk: JWK = { kty: "OKP", crv: "X448", x: other_public_key };
const own_jwk: JWK = {
const otherJwk: JWK = { kty: "OKP", crv: "X448", x: otherPublicKey };
const ownJwk: JWK = {
kty: "OKP",
crv: "X448",
x: own_public_key,
d: own_private_key,
x: ownPublicKey,
d: ownPrivateKey,
};
/**
* Converts bytes to a lowercase hex string.
* @param u8 Byte array.
* @returns Hex string.
*/
const bytesToHex = (u8: Uint8Array): string =>
Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join("");
/**
* Decodes standard base64 text into bytes.
* @param s Base64 string.
* @returns Decoded bytes.
*/
const b64ToBytes = (s: Base64URLString): Uint8Array => {
const bin = atob(s);
const out = new Uint8Array(bin.length);
@ -136,20 +167,41 @@ export async function get_shared_secret(
return out;
};
/**
* Decodes URL-safe base64 text into bytes.
* @param s Base64url string.
* @returns Decoded bytes.
*/
const b64uToBytes = (s: Base64URLString): Uint8Array => {
const b64 =
s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
return b64ToBytes(b64);
};
/**
* Encodes bytes as URL-safe base64 without padding.
* @param u8 Byte array.
* @returns Base64url string.
*/
const bytesToB64u = (u8: Uint8Array): string => {
const b64 = btoa(String.fromCharCode(...u8));
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
};
/**
* Decodes either base64 or base64url text into bytes.
* @param s Base64/base64url string.
* @returns Decoded bytes.
*/
const decodeBase64Auto = (s: string): Uint8Array =>
/[-_]/.test(s) ? b64uToBytes(s) : b64ToBytes(s);
/**
* Reads a DER TLV item from the provided offset.
* @param view DER-encoded bytes.
* @param off Start offset.
* @returns Parsed TLV metadata with tag, length, and boundaries.
*/
const readTLV = (view: Uint8Array, off: number) => {
const tag = view[off++];
if (off >= view.length) throw new Error("DER: truncated");
@ -167,6 +219,12 @@ export async function get_shared_secret(
return { tag, len, start, end };
};
/**
* Validates that a DER OID matches X448.
* @param view DER-encoded bytes.
* @param start Offset of the OID TLV.
* @returns True when the OID is X448.
*/
const ensureOidX448 = (view: Uint8Array, start: number): boolean => {
const oid = readTLV(view, start);
if (oid.tag !== 0x06) return false;
@ -179,6 +237,11 @@ export async function get_shared_secret(
);
};
/**
* Extracts raw 56-byte X448 public key material from SPKI bytes.
* @param spkiBytes DER-encoded SPKI bytes.
* @returns Raw X448 public key bytes.
*/
const extractRawX448FromSPKI = (spkiBytes: Uint8Array): Uint8Array => {
const view = spkiBytes;
const outer = readTLV(view, 0);
@ -196,6 +259,11 @@ export async function get_shared_secret(
return raw;
};
/**
* Extracts raw 56-byte X448 private key material from PKCS#8 bytes.
* @param pkcs8Bytes DER-encoded PKCS#8 bytes.
* @returns Raw X448 private key bytes.
*/
const extractRawX448FromPKCS8 = (pkcs8Bytes: Uint8Array): Uint8Array => {
const view = pkcs8Bytes;
const outer = readTLV(view, 0);
@ -230,6 +298,12 @@ export async function get_shared_secret(
return raw;
};
/**
* Normalizes X448 JWK fields into raw base64url key material.
* @param jwk Candidate JWK.
* @param label Error label for diagnostics.
* @returns Normalized JWK suitable for WebCrypto import.
*/
const normalizeOkpX448Jwk = (jwk: JWK, label: string): JWK => {
if (!jwk || jwk.kty !== "OKP" || jwk.crv !== "X448") {
throw new Error(`${label}: expected OKP JWK with crv "X448"`);
@ -271,6 +345,10 @@ export async function get_shared_secret(
return out;
};
/**
* Returns WebCrypto subtle API when available.
* @returns SubtleCrypto instance or undefined.
*/
const getSubtle = () => globalThis.crypto?.subtle;
{
@ -305,8 +383,8 @@ export async function get_shared_secret(
*/
}
const myJwk: JWK = normalizeOkpX448Jwk(own_jwk, "own_jwk");
const peerJwk: JWK = normalizeOkpX448Jwk(other_jwk, "other_jwk");
const myJwk: JWK = normalizeOkpX448Jwk(ownJwk, "own_jwk");
const peerJwk: JWK = normalizeOkpX448Jwk(otherJwk, "other_jwk");
const subtle = getSubtle();
//const infoStr = `ECDH-X448-AES-GCM-v1|my=${myJwk.x}|peer=${peerJwk.x}`;
@ -357,8 +435,33 @@ export async function get_shared_secret(
return bytesToHex(sharedSecret);
}
Comlink.expose({
encrypt,
decrypt,
get_shared_secret,
});
/**
* @deprecated Use getSharedSecret instead.
* @param ownPrivateKey Local private key.
* @param ownPublicKey Local public key.
* @param otherPublicKey Peer public key.
* @returns Shared secret derived by getSharedSecret.
*/
export async function get_shared_secret(
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
): Promise<string> {
return await getSharedSecret(ownPrivateKey, ownPublicKey, otherPublicKey);
}
/**
* Checks whether the current runtime context is a worker global scope.
* @returns True when executed inside a worker-like runtime.
*/
function isWorkerRuntime(): boolean {
return "postMessage" in globalThis && "importScripts" in globalThis;
}
if (isWorkerRuntime()) {
Comlink.expose({
encrypt,
decrypt,
getSharedSecret,
});
}