aboutsummaryrefslogtreecommitdiff
path: root/client/public/crypto.ts
diff options
context:
space:
mode:
authorGravatar Moon Flower <mforoud86@gmail.com> 2026-02-27 23:37:44 +0330
committerGravatar Moon Flower <mforoud86@gmail.com> 2026-02-27 23:37:44 +0330
commit8ee3112ca09fd70a50c2159629b40700beb2d5c3 (patch)
tree42a8d3e5d7898e9d084f77e1345be270be0f5969 /client/public/crypto.ts
parentsmall check (diff)
first client ver
Diffstat (limited to 'client/public/crypto.ts')
-rw-r--r--client/public/crypto.ts360
1 files changed, 360 insertions, 0 deletions
diff --git a/client/public/crypto.ts b/client/public/crypto.ts
new file mode 100644
index 0000000..ab83517
--- /dev/null
+++ b/client/public/crypto.ts
@@ -0,0 +1,360 @@
+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'
+import { randomBytes as nodeRandomBytes } from 'node:crypto'
+
+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<string, unknown>
+const hasWebCrypto: boolean =
+ typeof _g['crypto'] !== 'undefined' &&
+ typeof (_g['crypto'] as Record<string, unknown>)['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
+ }
+ return new Uint8Array(nodeRandomBytes(n))
+}
+
+const _wc: Record<string, boolean | undefined> = {}
+
+async function wcSupports(alg: string): Promise<boolean> {
+ 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<KeyPair> {
+ 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<string> {
+ 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<boolean> {
+ 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<KeyPair> {
+ 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<Uint8Array> {
+ 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<Uint8Array> {
+ 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<string> {
+ 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<Uint8Array> {
+ 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<string> {
+ 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<string> {
+ 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<Uint8Array> {
+ 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<DerivedKey> {
+ 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<string> {
+ 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<Uint8Array> {
+ 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<Uint8Array> {
+ 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)
+} \ No newline at end of file