diff options
| author | 2026-02-27 23:37:44 +0330 | |
|---|---|---|
| committer | 2026-02-27 23:37:44 +0330 | |
| commit | 8ee3112ca09fd70a50c2159629b40700beb2d5c3 (patch) | |
| tree | 42a8d3e5d7898e9d084f77e1345be270be0f5969 /client/public/crypto.ts | |
| parent | small check (diff) | |
first client ver
Diffstat (limited to 'client/public/crypto.ts')
| -rw-r--r-- | client/public/crypto.ts | 360 |
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 |
