From 3e12257cf3f8581e8e50ebae9c85544800df0007 Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 5 Jul 2026 01:11:21 +0200 Subject: [PATCH] (feat): add crypto stuff to ts-sdk --- src/sdk/index.ts | 194 +++++++++++++++++++++++++++++++++++++++ wasm/src/crypto.rs | 91 +++++++++++++++++- wasm/types/mtp_wasm.d.ts | 15 +++ 3 files changed, 297 insertions(+), 3 deletions(-) diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 63977ae..3c8488d 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -26,6 +26,8 @@ export type ParsedFrame = RawBindings.ParsedFrame; export type Ed25519GenerateResult = ReturnType; +export type WasmEncapsulated = RawBindings.WasmEncapsulated; + export interface MTPCrypto { generateKeyring(): Uint8Array; generateEd25519(): Ed25519GenerateResult; @@ -35,6 +37,13 @@ export interface MTPCrypto { hkdfExpand(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, len: number): Uint8Array; sha256(data: Uint8Array): Uint8Array; sha256Double(data: Uint8Array): Uint8Array; + encrypt(secret: string, input: Uint8Array): Promise; + decrypt(secret: string, input: Uint8Array): Promise; + encryptText(secret: string, plaintext: string): Promise; + decryptText(secret: string, ciphertext: string): Promise; + encapsulate(otherPublicKey: Uint8Array): WasmEncapsulated; + decapsulate(ownPrivateKey: Uint8Array, ciphertext: Uint8Array): Uint8Array; + getSharedSecret(ownPrivateKey: string, ownPublicKey: string, otherPublicKey: string): Promise; } export const crypto: MTPCrypto = { @@ -46,6 +55,67 @@ export const crypto: MTPCrypto = { hkdfExpand: (ikm, salt, info, len) => bindings.wasm_hkdf_expand(ikm, salt, info, len), sha256: (data) => bindings.wasm_sha256(data), sha256Double: (data) => bindings.wasm_sha256_double(data), + + encrypt: async (secret, input) => { + const key = secretKeyFromString(secret); + const cipher = new bindings.WasmChaCha20Poly1305(key); + try { + return cipher.encrypt(input, new Uint8Array(0)); + } finally { + cipher.free(); + } + }, + + decrypt: async (secret, input) => { + const key = secretKeyFromString(secret); + const cipher = new bindings.WasmChaCha20Poly1305(key); + try { + return cipher.decrypt(input, new Uint8Array(0)); + } finally { + cipher.free(); + } + }, + + encryptText: async (secret, plaintext) => { + const key = secretKeyFromString(secret); + const cipher = new bindings.WasmChaCha20Poly1305(key); + try { + const ciphertext = cipher.encrypt(utf8Encode(plaintext), new Uint8Array(0)); + return bytesToBase64(ciphertext); + } finally { + cipher.free(); + } + }, + + decryptText: async (secret, ciphertext) => { + const key = secretKeyFromString(secret); + const cipher = new bindings.WasmChaCha20Poly1305(key); + try { + const decoded = bytesFromString(ciphertext, "ciphertext"); + const plaintext = cipher.decrypt(decoded, new Uint8Array(0)); + return utf8Decode(plaintext); + } finally { + cipher.free(); + } + }, + + encapsulate: (otherPublicKey) => bindings.wasm_kem_encapsulate(otherPublicKey), + + decapsulate: (ownPrivateKey, ciphertext) => + bindings.wasm_kem_decapsulate(ownPrivateKey, ciphertext), + + getSharedSecret: async (ownPrivateKey, ownPublicKey, otherPublicKey) => { + const ownPub = bytesFromString(ownPublicKey, "ownPublicKey"); + const otherPub = bytesFromString(otherPublicKey, "otherPublicKey"); + const enc = bindings.wasm_kem_encapsulate(otherPub); + try { + const sharedSecret = enc.shared_secret; + const derived = bindings.wasm_hkdf_expand(sharedSecret, ownPub, otherPub, 32); + return bytesToHex(derived); + } finally { + enc.free(); + } + }, }; export type MTPRawBindings = typeof bindings; @@ -250,6 +320,130 @@ function bytesFromString(value, name) { throw new TypeError(`${name} must be bytes, hex, or base64`); } +const HEX_DIGITS = "0123456789abcdef"; + +function bytesToHex(bytes) { + let out = ""; + for (let i = 0; i < bytes.length; i += 1) { + out += HEX_DIGITS[(bytes[i] >> 4) & 0xf] + HEX_DIGITS[bytes[i] & 0xf]; + } + return out; +} + +function bytesToBase64(bytes) { + if (typeof btoa === "function") { + let binary = ""; + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + if (typeof Buffer !== "undefined") { + return Buffer.from(bytes).toString("base64"); + } + throw new TypeError("base64 encoding is not available in this environment"); +} + +function utf8Encode(text) { + if (typeof TextEncoder !== "undefined") { + return new TextEncoder().encode(text); + } + if (typeof Buffer !== "undefined") { + return new Uint8Array(Buffer.from(text, "utf-8")); + } + const bytes = new Uint8Array(text.length * 4); + let len = 0; + for (let i = 0; i < text.length; i += 1) { + const code = text.codePointAt(i); + if (code < 0x80) { + bytes[len++] = code; + } else if (code < 0x800) { + bytes[len++] = 0xc0 | (code >> 6); + bytes[len++] = 0x80 | (code & 0x3f); + } else if (code < 0x10000) { + bytes[len++] = 0xe0 | (code >> 12); + bytes[len++] = 0x80 | ((code >> 6) & 0x3f); + bytes[len++] = 0x80 | (code & 0x3f); + } else { + bytes[len++] = 0xf0 | (code >> 18); + bytes[len++] = 0x80 | ((code >> 12) & 0x3f); + bytes[len++] = 0x80 | ((code >> 6) & 0x3f); + bytes[len++] = 0x80 | (code & 0x3f); + i += 1; + } + } + return bytes.subarray(0, len); +} + +function utf8Decode(bytes) { + if (typeof TextDecoder !== "undefined") { + return new TextDecoder().decode(bytes); + } + if (typeof Buffer !== "undefined") { + return Buffer.from(bytes).toString("utf-8"); + } + let out = ""; + let i = 0; + while (i < bytes.length) { + const b = bytes[i]; + if (b < 0x80) { + out += String.fromCharCode(b); + i += 1; + } else if (b < 0xc0) { + i += 1; + } else if (b < 0xe0) { + out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i + 1] & 0x3f)); + i += 2; + } else if (b < 0xf0) { + out += String.fromCharCode( + ((b & 0x0f) << 12) | ((bytes[i + 1] & 0x3f) << 6) | (bytes[i + 2] & 0x3f), + ); + i += 3; + } else { + const cp = + ((b & 0x07) << 18) | + ((bytes[i + 1] & 0x3f) << 12) | + ((bytes[i + 2] & 0x3f) << 6) | + (bytes[i + 3] & 0x3f); + out += String.fromCodePoint(cp); + i += 4; + } + } + return out; +} + +const SYMMETRIC_KEY_SALT = utf8Encode("mtp-symmetric-key"); + +function secretKeyFromString(secret) { + if (typeof secret !== "string" || !secret.trim()) { + throw new TypeError("secret must be a non-empty string"); + } + + const trimmed = secret.trim(); + const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, ""); + if (/^[0-9a-fA-F]+$/.test(hex) && hex.length === 64) { + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i += 1) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; + } + + if (typeof atob === "function" || typeof Buffer !== "undefined") { + try { + const decoded = bytesFromString(trimmed, "secret"); + if (decoded.length === 32) { + return decoded; + } + } catch { + // fall through to HKDF derivation + } + } + + const ikm = utf8Encode(trimmed); + return bindings.wasm_derive_encryption_key(ikm, SYMMETRIC_KEY_SALT, SYMMETRIC_KEY_SALT); +} + function normalizeBytes(value, name) { if (typeof value === "string") { return bytesFromString(value, name); diff --git a/wasm/src/crypto.rs b/wasm/src/crypto.rs index 48aa4dc..cfa1ebf 100644 --- a/wasm/src/crypto.rs +++ b/wasm/src/crypto.rs @@ -1,9 +1,9 @@ use wasm_bindgen::prelude::*; use mtp_crypto::{ - AeadDecrypt, AeadEncrypt, ChaCha20Poly1305, Ed25519Signer, KemPrivateKey, KemPublicKey, - Keyring, PublicKeyBundle, SignaturePqPrivateKey, SignaturePqPublicKey, SignaturePrivateKey, - SignaturePublicKey, SignatureScheme, sha256, sha256_double, + AeadDecrypt, AeadEncrypt, ChaCha20Poly1305, Ed25519Signer, HybridKem, KemPrivateKey, + KemPublicKey, Keyring, PublicKeyBundle, SignaturePqPrivateKey, SignaturePqPublicKey, + SignaturePrivateKey, SignaturePublicKey, SignatureScheme, sha256, sha256_double, }; use crate::error::js_error; @@ -110,6 +110,66 @@ impl WasmPublicKeyBundle { } } +// =========================================================================== +// Hybrid KEM (X25519 + ML-KEM-768) +// =========================================================================== + +/// KEM encapsulation result returned to JavaScript. +/// +/// `shared_secret` is the symmetric key both parties will derive; `ciphertext` +/// is the KEM ciphertext that must be sent to the recipient so they can +/// decapsulate and recover the same shared secret. +#[wasm_bindgen] +pub struct WasmEncapsulated { + inner_shared_secret: Vec, + inner_ciphertext: Vec, +} + +#[wasm_bindgen] +impl WasmEncapsulated { + /// Symmetric secret derived during encapsulation. + #[wasm_bindgen(getter)] + pub fn shared_secret(&self) -> Vec { + self.inner_shared_secret.clone() + } + + /// KEM ciphertext to transmit to the recipient. + #[wasm_bindgen(getter)] + pub fn ciphertext(&self) -> Vec { + self.inner_ciphertext.clone() + } +} + +/// Encapsulate a fresh shared secret for `recipient_public_key`. +/// +/// Returns a [`WasmEncapsulated`] containing the shared secret and the KEM +/// ciphertext that the recipient needs to recover it via +/// [`wasm_kem_decapsulate`]. +#[wasm_bindgen] +pub fn wasm_kem_encapsulate(recipient_public_key: &[u8]) -> Result { + let pk = KemPublicKey::new(recipient_public_key.to_vec()); + let enc = HybridKem::encapsulate(&pk) + .map_err(|e| js_error(&format!("kem_encapsulate failed: {}", e)))?; + Ok(WasmEncapsulated { + inner_shared_secret: enc.shared_secret, + inner_ciphertext: enc.ciphertext, + }) +} + +/// Decapsulate a KEM `ciphertext` with the recipient's `private_key`. +/// +/// Returns the same shared secret the initiator obtained from +/// [`wasm_kem_encapsulate`]. +#[wasm_bindgen] +pub fn wasm_kem_decapsulate( + recipient_private_key: &[u8], + ciphertext: &[u8], +) -> Result, JsValue> { + let sk = KemPrivateKey::new(recipient_private_key.to_vec()); + HybridKem::decapsulate(&sk, ciphertext) + .map_err(|e| js_error(&format!("kem_decapsulate failed: {}", e))) +} + // =========================================================================== // ChaCha20-Poly1305 AEAD // =========================================================================== @@ -326,6 +386,31 @@ mod tests { assert_eq!(restored.sig_cl_public_key(), pk); } + // ------------------------------------------------------------------ + // KEM encapsulate / decapsulate + // ------------------------------------------------------------------ + + #[wasm_bindgen_test] + fn kem_encapsulate_decapsulate_roundtrip() { + let (sk, pk) = HybridKem::generate_keypair(); + let enc = wasm_kem_encapsulate(pk.as_bytes()).expect("encapsulate failed"); + let ss = wasm_kem_decapsulate(sk.as_bytes(), &enc.ciphertext()).expect("decapsulate failed"); + assert_eq!(enc.shared_secret(), ss); + } + + #[wasm_bindgen_test] + fn kem_encapsulate_invalid_public_key_fails() { + let bad = vec![0u8; 16]; + assert!(wasm_kem_encapsulate(&bad).is_err()); + } + + #[wasm_bindgen_test] + fn kem_decapsulate_invalid_ciphertext_fails() { + let (sk, _pk) = HybridKem::generate_keypair(); + let bad = vec![0u8; 32]; + assert!(wasm_kem_decapsulate(sk.as_bytes(), &bad).is_err()); + } + // ------------------------------------------------------------------ // ChaCha20-Poly1305 // ------------------------------------------------------------------ diff --git a/wasm/types/mtp_wasm.d.ts b/wasm/types/mtp_wasm.d.ts index b7ac775..47b16f7 100644 --- a/wasm/types/mtp_wasm.d.ts +++ b/wasm/types/mtp_wasm.d.ts @@ -75,6 +75,19 @@ export class WasmChaCha20Poly1305 implements DisposableWasmObject { encrypt(plaintext: Uint8Array, aad: Uint8Array): Uint8Array; } +export interface KemEncapsulateResult { + shared_secret: Uint8Array; + ciphertext: Uint8Array; +} + +export class WasmEncapsulated implements DisposableWasmObject { + private constructor(); + free(): void; + [Symbol.dispose](): void; + readonly shared_secret: Uint8Array; + readonly ciphertext: Uint8Array; +} + export class WasmClient implements DisposableWasmObject { constructor( on_state_change: StateChangeCallback, @@ -166,6 +179,8 @@ export function parse_auth_response(response: Uint8Array): AuthResponse; export function parse_frame(frame: Uint8Array): ParsedFrame; export function wasm_derive_encryption_key(ikm: Uint8Array, salt: Uint8Array, context: Uint8Array): Uint8Array; export function wasm_hkdf_expand(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, len: number): Uint8Array; +export function wasm_kem_decapsulate(recipient_private_key: Uint8Array, ciphertext: Uint8Array): Uint8Array; +export function wasm_kem_encapsulate(recipient_public_key: Uint8Array): WasmEncapsulated; export function wasm_sha256(data: Uint8Array): Uint8Array; export function wasm_sha256_double(data: Uint8Array): Uint8Array;