mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-06-18 04:51:07 +00:00
225 lines
6.7 KiB
TypeScript
225 lines
6.7 KiB
TypeScript
/**
|
|
* Crypto utilities for wallet encryption
|
|
* Uses Web Crypto API (AES-GCM)
|
|
*
|
|
* Security features:
|
|
* - AES-256-GCM encryption
|
|
* - PBKDF2 key derivation (600K iterations, OWASP 2023 recommendation)
|
|
* - 16-byte random salt per encryption
|
|
* - 12-byte random IV per encryption
|
|
* - Version header for future algorithm updates
|
|
*/
|
|
|
|
const SALT_LENGTH = 16;
|
|
const IV_LENGTH = 12;
|
|
const VERSION_LENGTH = 1;
|
|
const CURRENT_VERSION = 2; // v1: 100K iterations, v2: 600K iterations
|
|
|
|
// OWASP 2023 recommendation for PBKDF2-SHA256
|
|
const KEY_ITERATIONS_V2 = 600000;
|
|
const KEY_ITERATIONS_V1 = 100000; // Legacy for backward compatibility
|
|
|
|
/**
|
|
* Derive encryption key from password using PBKDF2
|
|
* @param password - User password
|
|
* @param salt - Random salt
|
|
* @param version - Encryption version (determines iteration count)
|
|
*/
|
|
async function deriveKey(
|
|
password: string,
|
|
salt: Uint8Array,
|
|
version: number = CURRENT_VERSION
|
|
): Promise<CryptoKey> {
|
|
const encoder = new TextEncoder();
|
|
const passwordKey = await crypto.subtle.importKey(
|
|
'raw',
|
|
encoder.encode(password),
|
|
'PBKDF2',
|
|
false,
|
|
['deriveBits', 'deriveKey']
|
|
);
|
|
|
|
// Use appropriate iteration count based on version
|
|
const iterations = version >= 2 ? KEY_ITERATIONS_V2 : KEY_ITERATIONS_V1;
|
|
|
|
return crypto.subtle.deriveKey(
|
|
{
|
|
name: 'PBKDF2',
|
|
salt: new Uint8Array(salt), // Create new Uint8Array for compatibility
|
|
iterations,
|
|
hash: 'SHA-256',
|
|
},
|
|
passwordKey,
|
|
{ name: 'AES-GCM', length: 256 },
|
|
false,
|
|
['encrypt', 'decrypt']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Encrypt data with password (AES-256-GCM)
|
|
* Format: version (1 byte) + salt (16 bytes) + iv (12 bytes) + ciphertext
|
|
*/
|
|
export async function encrypt(data: string, password: string): Promise<string> {
|
|
const encoder = new TextEncoder();
|
|
const version = new Uint8Array([CURRENT_VERSION]);
|
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
const key = await deriveKey(password, salt, CURRENT_VERSION);
|
|
|
|
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoder.encode(data));
|
|
|
|
// Combine version + salt + iv + encrypted data
|
|
const combined = new Uint8Array(VERSION_LENGTH + salt.length + iv.length + encrypted.byteLength);
|
|
combined.set(version, 0);
|
|
combined.set(salt, VERSION_LENGTH);
|
|
combined.set(iv, VERSION_LENGTH + salt.length);
|
|
combined.set(new Uint8Array(encrypted), VERSION_LENGTH + salt.length + iv.length);
|
|
|
|
// Return as base64
|
|
return btoa(String.fromCharCode(...combined));
|
|
}
|
|
|
|
/**
|
|
* Decrypt data with password
|
|
* Supports both v1 (legacy, no version byte) and v2 (with version byte) formats
|
|
*/
|
|
export async function decrypt(encryptedData: string, password: string): Promise<string> {
|
|
const decoder = new TextDecoder();
|
|
const combined = new Uint8Array(
|
|
atob(encryptedData)
|
|
.split('')
|
|
.map((c) => c.charCodeAt(0))
|
|
);
|
|
|
|
// Detect version: if first byte is 1 or 2, it's a version header
|
|
// Legacy v1 data starts with salt which would be random (unlikely to be 1 or 2)
|
|
let version: number;
|
|
let offset: number;
|
|
|
|
if (combined[0] === 1 || combined[0] === 2) {
|
|
// New format with version header
|
|
version = combined[0];
|
|
offset = VERSION_LENGTH;
|
|
} else {
|
|
// Legacy format (v1) without version header
|
|
version = 1;
|
|
offset = 0;
|
|
}
|
|
|
|
const salt = combined.slice(offset, offset + SALT_LENGTH);
|
|
const iv = combined.slice(offset + SALT_LENGTH, offset + SALT_LENGTH + IV_LENGTH);
|
|
const data = combined.slice(offset + SALT_LENGTH + IV_LENGTH);
|
|
|
|
const key = await deriveKey(password, salt, version);
|
|
|
|
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data);
|
|
|
|
return decoder.decode(decrypted);
|
|
}
|
|
|
|
/**
|
|
* Calculate password entropy (bits)
|
|
* Higher entropy = stronger password
|
|
*/
|
|
export function calculateEntropy(password: string): number {
|
|
let charsetSize = 0;
|
|
if (/[a-z]/.test(password)) charsetSize += 26;
|
|
if (/[A-Z]/.test(password)) charsetSize += 26;
|
|
if (/[0-9]/.test(password)) charsetSize += 10;
|
|
if (/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) charsetSize += 32;
|
|
if (/\s/.test(password)) charsetSize += 1;
|
|
|
|
if (charsetSize === 0) return 0;
|
|
return Math.floor(password.length * Math.log2(charsetSize));
|
|
}
|
|
|
|
export type PasswordStrength = 'weak' | 'medium' | 'strong' | 'very-strong';
|
|
|
|
/**
|
|
* Get password strength category based on entropy
|
|
*/
|
|
export function getPasswordStrength(password: string): PasswordStrength {
|
|
const entropy = calculateEntropy(password);
|
|
if (entropy < 50) return 'weak';
|
|
if (entropy < 70) return 'medium';
|
|
if (entropy < 90) return 'strong';
|
|
return 'very-strong';
|
|
}
|
|
|
|
/**
|
|
* Validate password strength
|
|
* Requires: 12+ chars, lowercase, uppercase, number, special char
|
|
* Minimum entropy: 60 bits
|
|
*/
|
|
export function validatePassword(password: string): {
|
|
valid: boolean;
|
|
message?: string;
|
|
entropy?: number;
|
|
strength?: PasswordStrength;
|
|
} {
|
|
const entropy = calculateEntropy(password);
|
|
const strength = getPasswordStrength(password);
|
|
|
|
if (password.length < 12) {
|
|
return { valid: false, message: 'Şîfre (password) herî kêm 12 tîp be', entropy, strength };
|
|
}
|
|
if (!/[a-z]/.test(password)) {
|
|
return {
|
|
valid: false,
|
|
message: 'Şîfre (password) herî kêm 1 tîpa biçûk hebe (a-z)',
|
|
entropy,
|
|
strength,
|
|
};
|
|
}
|
|
if (!/[A-Z]/.test(password)) {
|
|
return {
|
|
valid: false,
|
|
message: 'Şîfre (password) herî kêm 1 tîpa mezin hebe (A-Z)',
|
|
entropy,
|
|
strength,
|
|
};
|
|
}
|
|
if (!/[0-9]/.test(password)) {
|
|
return {
|
|
valid: false,
|
|
message: 'Şîfre (password) herî kêm 1 hejmar hebe (0-9)',
|
|
entropy,
|
|
strength,
|
|
};
|
|
}
|
|
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
|
|
return {
|
|
valid: false,
|
|
message: 'Şîfre (password) herî kêm 1 nîşana taybet hebe (!@#$%...)',
|
|
entropy,
|
|
strength,
|
|
};
|
|
}
|
|
if (entropy < 60) {
|
|
return {
|
|
valid: false,
|
|
message:
|
|
'Şîfre (password) ne têra qewî ye. Şîfreyek (password) dirêjtir bi tîpên cûrbecûr biceribîne.',
|
|
entropy,
|
|
strength,
|
|
};
|
|
}
|
|
return { valid: true, entropy, strength };
|
|
}
|
|
|
|
/**
|
|
* Detect common weak patterns in passwords
|
|
*/
|
|
export function hasWeakPatterns(password: string): boolean {
|
|
const weakPatterns = [
|
|
/^(.)\1+$/, // All same character
|
|
/^(012|123|234|345|456|567|678|789)+$/, // Sequential numbers
|
|
/^(abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz)+$/i, // Sequential letters
|
|
/^(qwerty|asdfgh|zxcvbn)/i, // Keyboard patterns
|
|
/^(password|şîfre|parola|123456|qwerty)/i, // Common passwords
|
|
];
|
|
|
|
return weakPatterns.some((pattern) => pattern.test(password.toLowerCase()));
|
|
}
|