import { ed25519, x25519 } from '@noble/curves/ed25519.js' import { hkdf } from '@noble/hashes/hkdf.js' import { pbkdf2 } from '@noble/hashes/pbkdf2.js' import { sha256 } from '@noble/hashes/sha2.js' import { gcm } from '@noble/ciphers/aes.js' const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' export function toB64(bytes: Uint8Array): string { let out = '' let i = 0 while (i < bytes.length) { const a = bytes[i++]! const b = bytes[i++] ?? 0 const c = bytes[i++] ?? 0 out += B64URL[a >> 2] out += B64URL[((a & 3) << 4) | (b >> 4)] out += B64URL[((b & 15) << 2) | (c >> 6)] out += B64URL[c & 63] } const pad = bytes.length % 3 return pad ? out.slice(0, pad - 3) : out } export function fromB64(str: string): Uint8Array { const lookup = new Uint8Array(128) for (let i = 0; i < B64URL.length; i++) lookup[B64URL.charCodeAt(i)] = i const pad = str.length % 4 if (pad) str += '='.repeat(4 - pad) const out = new Uint8Array((str.length * 3) >> 2) let pos = 0 for (let i = 0; i < str.length; i += 4) { const a = lookup[str.charCodeAt(i)]! const b = lookup[str.charCodeAt(i + 1)]! const c = lookup[str.charCodeAt(i + 2)] ?? 0 const d = lookup[str.charCodeAt(i + 3)] ?? 0 out[pos++] = (a << 2) | (b >> 4) out[pos++] = ((b & 15) << 4) | (c >> 2) out[pos++] = ((c & 3) << 6) | d } return out.slice(0, pos - (str.endsWith('==') ? 2 : str.endsWith('=') ? 1 : 0)) } function concat(...arrays: Uint8Array[]): Uint8Array { const total = arrays.reduce((s, a) => s + a.length, 0) const out = new Uint8Array(total) let off = 0 for (const a of arrays) { out.set(a, off) off += a.length } return out } function toArrayBuffer(u8: Uint8Array): ArrayBuffer { const copy = new Uint8Array(u8.length) copy.set(u8) return copy.buffer as ArrayBuffer } const encode = (s: string) => new TextEncoder().encode(s) export const decodeText = (b: Uint8Array) => new TextDecoder().decode(b) const _g = globalThis as Record const hasWebCrypto: boolean = typeof _g['crypto'] !== 'undefined' && typeof (_g['crypto'] as Record)['subtle'] !== 'undefined' const subtle: SubtleCrypto | null = hasWebCrypto ? (_g['crypto'] as { subtle: SubtleCrypto }).subtle : null function randomBytes(n: number): Uint8Array { if (hasWebCrypto) { const buf = new Uint8Array(n); (_g['crypto'] as Crypto).getRandomValues(buf) return buf } // what can i do. return new Uint8Array(new Array(n).fill(0).map(e => Math.floor(Math.random() * 256))) } const _wc: Record = {} async function wcSupports(alg: string): Promise { if (typeof _wc[alg] === 'boolean') return _wc[alg]! if (!subtle) return (_wc[alg] = false) try { switch (alg) { case 'Ed25519': await subtle.generateKey({ name: 'Ed25519' } as EcKeyGenParams, false, ['sign', 'verify']) break case 'X25519': await subtle.generateKey({ name: 'X25519' } as EcKeyGenParams, false, ['deriveBits']) break case 'AES-GCM': await subtle.generateKey({ name: 'AES-GCM', length: 256 } as AesKeyGenParams, false, ['encrypt', 'decrypt']) break case 'HKDF': await subtle.importKey('raw', new ArrayBuffer(32), { name: 'HKDF' }, false, ['deriveBits']) break case 'PBKDF2': await subtle.importKey('raw', toArrayBuffer(encode('probe')), { name: 'PBKDF2' }, false, ['deriveBits']) break default: return (_wc[alg] = false) } return (_wc[alg] = true) } catch { return (_wc[alg] = false) } } function toB64url(bytes: Uint8Array): string { let s = '' for (const b of bytes) s += String.fromCharCode(b) return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } function fromB64url(b64u: string): Uint8Array { const b64 = b64u.replace(/-/g, '+').replace(/_/g, '/') const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=') const bin = atob(padded) return Uint8Array.from({ length: bin.length }, (_, i) => bin.charCodeAt(i)) } export interface KeyPair { privateKey: string publicKey: string } export async function generateSigningKeyPair(): Promise { if (await wcSupports('Ed25519')) { const kp = await subtle!.generateKey({ name: 'Ed25519' } as EcKeyGenParams, true, ['sign', 'verify']) as CryptoKeyPair const privJwk = await subtle!.exportKey('jwk', kp.privateKey) const pubJwk = await subtle!.exportKey('jwk', kp.publicKey) return { privateKey: toB64(fromB64url(privJwk.d!)), publicKey: toB64(fromB64url(pubJwk.x!)), } } const priv = ed25519.utils.randomSecretKey() return { privateKey: toB64(priv), publicKey: toB64(ed25519.getPublicKey(priv)) } } export async function sign(privateKeyHex: string, message: Uint8Array): Promise { const priv = fromB64(privateKeyHex) const pub = ed25519.getPublicKey(priv) if (await wcSupports('Ed25519')) { const key = await subtle!.importKey( 'jwk', { kty: 'OKP', crv: 'Ed25519', d: toB64url(priv), x: toB64url(pub), key_ops: ['sign'] } as JsonWebKey, { name: 'Ed25519' } as EcKeyImportParams, false, ['sign'] ) const sig = await subtle!.sign({ name: 'Ed25519' } as Algorithm, key, toArrayBuffer(message)) return toB64(new Uint8Array(sig)) } return toB64(ed25519.sign(message, priv)) } export async function verify( publicKeyHex: string, message: Uint8Array, signatureHex: string ): Promise { const pub = fromB64(publicKeyHex) const sig = fromB64(signatureHex) if (await wcSupports('Ed25519')) { const key = await subtle!.importKey( 'jwk', { kty: 'OKP', crv: 'Ed25519', x: toB64url(pub), key_ops: ['verify'] } as JsonWebKey, { name: 'Ed25519' } as EcKeyImportParams, false, ['verify'] ) return subtle!.verify({ name: 'Ed25519' } as Algorithm, key, toArrayBuffer(sig), toArrayBuffer(message)) } try { return ed25519.verify(sig, message, pub) } catch { return false } } const ASYM_INFO = encode('x25519-aes256gcm-v1') const IV_LEN = 12 const TAG_LEN = 16 export async function generateEncryptionKeyPair(): Promise { if (await wcSupports('X25519')) { const kp = await subtle!.generateKey({ name: 'X25519' } as EcKeyGenParams, true, ['deriveBits']) as CryptoKeyPair const privJwk = await subtle!.exportKey('jwk', kp.privateKey) const pubJwk = await subtle!.exportKey('jwk', kp.publicKey) return { privateKey: toB64(fromB64url(privJwk.d!)), publicKey: toB64(fromB64url(pubJwk.x!)), } } const priv = x25519.utils.randomSecretKey() return { privateKey: toB64(priv), publicKey: toB64(x25519.getPublicKey(priv)) } } async function dhSharedSecret(priv: Uint8Array, peerPub: Uint8Array): Promise { if (await wcSupports('X25519')) { const myPub = x25519.getPublicKey(priv) const privKey = await subtle!.importKey( 'jwk', { kty: 'OKP', crv: 'X25519', d: toB64url(priv), x: toB64url(myPub) } as JsonWebKey, { name: 'X25519' } as EcKeyImportParams, false, ['deriveBits'] ) const pubKey = await subtle!.importKey( 'jwk', { kty: 'OKP', crv: 'X25519', x: toB64url(peerPub) } as JsonWebKey, { name: 'X25519' } as EcKeyImportParams, false, [] ) return new Uint8Array( await subtle!.deriveBits({ name: 'X25519', public: pubKey } as EcdhKeyDeriveParams, privKey, 256) ) } return x25519.getSharedSecret(priv, peerPub) } async function hkdfDerive(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, outLen = 32): Promise { if (await wcSupports('HKDF')) { const ikmKey = await subtle!.importKey('raw', toArrayBuffer(ikm), { name: 'HKDF' }, false, ['deriveBits']) return new Uint8Array( await subtle!.deriveBits( { name: 'HKDF', hash: 'SHA-256', salt: toArrayBuffer(salt), info: toArrayBuffer(info) } as HkdfParams, ikmKey, outLen * 8 ) ) } return hkdf(sha256, ikm, salt, info, outLen) } export async function encryptAsymmetric(recipientPublicKeyHex: string, plaintext: Uint8Array): Promise { const recipientPub = fromB64(recipientPublicKeyHex) let ephPriv: Uint8Array, ephPub: Uint8Array if (await wcSupports('X25519')) { const kp = await subtle!.generateKey({ name: 'X25519' } as EcKeyGenParams, true, ['deriveBits']) as CryptoKeyPair const privJwk = await subtle!.exportKey('jwk', kp.privateKey) const pubJwk = await subtle!.exportKey('jwk', kp.publicKey) ephPriv = fromB64url(privJwk.d!) ephPub = fromB64url(pubJwk.x!) } else { ephPriv = x25519.utils.randomSecretKey() ephPub = x25519.getPublicKey(ephPriv) } const shared = await dhSharedSecret(ephPriv, recipientPub) const aesKey = await hkdfDerive(shared, ephPub, ASYM_INFO, 32) const iv = randomBytes(IV_LEN) const enc = await aesEncrypt(aesKey, iv, plaintext) return toB64(concat(ephPub, iv, enc)) } export async function decryptAsymmetric(privateKeyHex: string, ciphertextHex: string): Promise { const priv = fromB64(privateKeyHex) const packed = fromB64(ciphertextHex) const minLen = 32 + IV_LEN + TAG_LEN if (packed.length < minLen) throw new Error(`Ciphertext too short: ${packed.length} < ${minLen} bytes`) let off = 0 const ephPub = packed.slice(off, (off += 32)) const iv = packed.slice(off, (off += IV_LEN)) const enc = packed.slice(off) const shared = await dhSharedSecret(priv, ephPub) const aesKey = await hkdfDerive(shared, ephPub, ASYM_INFO, 32) return aesDecrypt(aesKey, iv, enc) } export async function generateSymmetricKey(): Promise { if (await wcSupports('AES-GCM')) { const key = await subtle!.generateKey({ name: 'AES-GCM', length: 256 } as AesKeyGenParams, true, ['encrypt', 'decrypt']) as CryptoKey return toB64(new Uint8Array(await subtle!.exportKey('raw', key))) } return toB64(randomBytes(32)) } export async function encryptSymmetric(keyHex: string, plaintext: Uint8Array): Promise { const key = fromB64(keyHex) if (key.length !== 32) throw new Error('AES key must be 32 bytes (64 hex chars)') const iv = randomBytes(IV_LEN) return toB64(concat(iv, await aesEncrypt(key, iv, plaintext))) } export async function decryptSymmetric(keyHex: string, ciphertextHex: string): Promise { const key = fromB64(keyHex) if (key.length !== 32) throw new Error('AES key must be 32 bytes (64 hex chars)') const packed = fromB64(ciphertextHex) if (packed.length < IV_LEN + TAG_LEN) throw new Error(`Ciphertext too short: ${packed.length} < ${IV_LEN + TAG_LEN} bytes`) return aesDecrypt(key, packed.slice(0, IV_LEN), packed.slice(IV_LEN)) } export interface DerivedKey { key: string salt: string } export async function deriveKeyFromPassword( password: string, saltHex?: string, iterations = 310_000 ): Promise { const salt = saltHex ? fromB64(saltHex) : randomBytes(16) const pass = encode(password) let keyBytes: Uint8Array if (await wcSupports('PBKDF2')) { const baseKey = await subtle!.importKey('raw', toArrayBuffer(pass), { name: 'PBKDF2' }, false, ['deriveBits']) keyBytes = new Uint8Array( await subtle!.deriveBits( { name: 'PBKDF2', hash: 'SHA-256', salt: toArrayBuffer(salt), iterations } as Pbkdf2Params, baseKey, 256 ) ) } else { keyBytes = pbkdf2(sha256, pass, salt, { c: iterations, dkLen: 32 }) } return { key: toB64(keyBytes), salt: toB64(salt) } } export async function deriveKeyHKDF(ikmHex: string, info: string, saltHex?: string): Promise { const ikm = fromB64(ikmHex) const salt = saltHex ? fromB64(saltHex) : new Uint8Array(32) return toB64(await hkdfDerive(ikm, salt, encode(info), 32)) } async function aesEncrypt(key: Uint8Array, iv: Uint8Array, plaintext: Uint8Array): Promise { if (await wcSupports('AES-GCM')) { const ck = await subtle!.importKey('raw', toArrayBuffer(key), 'AES-GCM', false, ['encrypt']) return new Uint8Array( await subtle!.encrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) } as AesGcmParams, ck, toArrayBuffer(plaintext)) ) } return gcm(key, iv).encrypt(plaintext) } async function aesDecrypt(key: Uint8Array, iv: Uint8Array, ct: Uint8Array): Promise { if (await wcSupports('AES-GCM')) { const ck = await subtle!.importKey('raw', toArrayBuffer(key), 'AES-GCM', false, ['decrypt']) return new Uint8Array( await subtle!.decrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) } as AesGcmParams, ck, toArrayBuffer(ct)) ) } return gcm(key, iv).decrypt(ct) }