const textEncoder = new TextEncoder(); const crypto = globalThis.crypto; export async function encrypt(secret, plaintext) { const sharedSecret = new Uint8Array(secret.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))); const hkdfKey = await crypto.subtle.importKey("raw", sharedSecret, "HKDF", false, ["deriveBits"]); const okm = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array([]), info: textEncoder.encode("x448-aes-gcm-no-overhead"), }, hkdfKey, 44 * 8); const okmBytes = new Uint8Array(okm); const keyBytes = okmBytes.slice(0, 32); const nonce = okmBytes.slice(32, 44); const aesKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt"]); const encryptedBuffer = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, aesKey, 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(secret, ciphertext) { 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, "HKDF", false, ["deriveBits"]); const okm = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array([]), info: textEncoder.encode("x448-aes-gcm-no-overhead"), }, hkdfKey, 44 * 8); const okmBytes = new Uint8Array(okm); const keyBytes = okmBytes.slice(0, 32); const nonce = okmBytes.slice(32, 44); const aesKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["decrypt"]); const decryptedBuffer = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce, }, aesKey, ciphertextBytes); return new TextDecoder().decode(decryptedBuffer); } /** * 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, ownPublicKey, otherPublicKey) { const otherJwk = { kty: "OKP", crv: "X448", x: otherPublicKey }; const ownJwk = { kty: "OKP", crv: "X448", x: ownPublicKey, d: ownPrivateKey, }; /** * Converts bytes to a lowercase hex string. * @param u8 Byte array. * @returns Hex string. */ const bytesToHex = (u8) => 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) => { const bin = atob(s); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; }; /** * Decodes URL-safe base64 text into bytes. * @param s Base64url string. * @returns Decoded bytes. */ const b64uToBytes = (s) => { 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) => { 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) => /[-_]/.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, off) => { const tag = view[off]; if (tag === undefined) throw new Error("DER: truncated"); off += 1; const lenByte = view[off]; if (lenByte === undefined) throw new Error("DER: truncated"); off += 1; let len = lenByte; if (lenByte & 0x80) { const n = lenByte & 0x7f; if (n === 0) throw new Error("DER: indefinite length not supported"); if (off + n > view.length) throw new Error("DER: truncated length"); len = 0; for (let i = 0; i < n; i++) { const b = view[off]; if (b === undefined) throw new Error("DER: truncated length"); len = (len << 8) | b; off += 1; } } const start = off; const end = off + len; if (end > view.length) throw new Error("DER: content truncated"); 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, start) => { const oid = readTLV(view, start); if (oid.tag !== 0x06) return false; const len = oid.end - oid.start; if (len !== 3) return false; return (view[oid.start] === 0x2b && view[oid.start + 1] === 0x65 && view[oid.start + 2] === 0x6f); }; /** * 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) => { const view = spkiBytes; const outer = readTLV(view, 0); if (outer.tag !== 0x30) throw new Error("SPKI: expected SEQUENCE"); const alg = readTLV(view, outer.start); if (alg.tag !== 0x30) throw new Error("SPKI: expected AlgorithmIdentifier"); if (!ensureOidX448(view, alg.start)) throw new Error("SPKI: not X448"); const bitstr = readTLV(view, alg.end); if (bitstr.tag !== 0x03) throw new Error("SPKI: expected BIT STRING"); const unusedBits = view[bitstr.start]; if (unusedBits !== 0x00) throw new Error("SPKI: unexpected unused bits"); const raw = view.subarray(bitstr.start + 1, bitstr.end); if (raw.length !== 56) throw new Error("SPKI: X448 public key must be 56 bytes"); 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) => { const view = pkcs8Bytes; const outer = readTLV(view, 0); if (outer.tag !== 0x30) throw new Error("PKCS8: expected SEQUENCE"); let off = outer.start; const version = readTLV(view, off); if (version.tag !== 0x02) throw new Error("PKCS8: expected version INTEGER"); off = version.end; const alg = readTLV(view, off); if (alg.tag !== 0x30) throw new Error("PKCS8: expected AlgorithmIdentifier"); if (!ensureOidX448(view, alg.start)) throw new Error("PKCS8: not X448"); off = alg.end; const priv = readTLV(view, off); if (priv.tag !== 0x04) throw new Error("PKCS8: expected privateKey OCTET STRING"); let raw = view.subarray(priv.start, priv.end); // Some encoders nest another OCTET STRING inside if (raw[0] === 0x04) { const inner = readTLV(raw, 0); if (inner.tag === 0x04) { raw = raw.subarray(inner.start, inner.end); } } if (raw.length !== 56) throw new Error("PKCS8: X448 private key must be 56 bytes"); 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, label) => { if (!jwk || jwk.kty !== "OKP" || jwk.crv !== "X448") { throw new Error(`${label}: expected OKP JWK with crv "X448"`); } const out = { ...jwk }; if (out.x) { const xBytes = decodeBase64Auto(out.x); let rawX; try { rawX = extractRawX448FromSPKI(xBytes); } catch { if (xBytes.length !== 56) { throw new Error(`${label}: "x" is not a valid X448 SPKI or raw 56-byte key`); } rawX = xBytes; } out.x = bytesToB64u(rawX); } if (out.d) { const dBytes = decodeBase64Auto(out.d); let rawD; try { rawD = extractRawX448FromPKCS8(dBytes); } catch { if (dBytes.length !== 56) { throw new Error(`${label}: "d" is not a valid X448 PKCS#8 or raw 56-byte key`); } rawD = dBytes; } out.d = bytesToB64u(rawD); } return out; }; /** * Returns WebCrypto subtle API when available. * @returns SubtleCrypto instance or undefined. */ const getSubtle = () => globalThis.crypto?.subtle; { /* const hkdfAesGcmFromShared = async ( sharedSecret: BufferSource, infoStr: string ): Promise => { const subtle = getSubtle(); if (!subtle) throw new Error("WebCrypto subtle not available"); const info = textEncoder.encode(infoStr); const baseKey = await subtle.importKey( "raw", sharedSecret, "HKDF", false, ["deriveKey"] ); return await subtle.deriveKey( { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info, }, baseKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] ); }; */ } const myJwk = normalizeOkpX448Jwk(ownJwk, "own_jwk"); const peerJwk = normalizeOkpX448Jwk(otherJwk, "other_jwk"); const subtle = getSubtle(); //const infoStr = `ECDH-X448-AES-GCM-v1|my=${myJwk.x}|peer=${peerJwk.x}`; if (subtle) { const algorithms = [{ name: "ECDH", namedCurve: "X448" }, { name: "X448" }]; for (const algorithm of algorithms) { try { const [myPriv, peerPub] = await Promise.all([ subtle.importKey("jwk", myJwk, algorithm, false, ["deriveBits"]), subtle.importKey("jwk", peerJwk, algorithm, false, []), ]); const sharedBits = await subtle.deriveBits({ name: algorithm.name, public: peerPub }, myPriv, 448); const sharedSecret = new Uint8Array(sharedBits); //const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr); return bytesToHex(sharedSecret); } catch { // Browser doesn't support this algorithm, try next or fall through to software fallback } } } const { d: dMyB64u } = myJwk; //const { x: xMyB64u, d: dMyB64u } = myJwk; const { x: xPeerB64u } = peerJwk; if (!dMyB64u || !xPeerB64u) { return "Failed to get shared secret due to missing keys"; } const [dRaw, xRawPeer] = [b64uToBytes(dMyB64u), b64uToBytes(xPeerB64u)]; if (dRaw.length !== 56 || xRawPeer.length !== 56) { return "Failed to get shared secret due to invalid key lengths"; } const { x448 } = await import("@noble/curves/ed448.js"); const sharedSecret = new Uint8Array(x448.getSharedSecret(dRaw, xRawPeer)); //const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr); return bytesToHex(sharedSecret); }