// ==UserScript== // @name VK P2P AES-GCM // @namespace local // @version 5.1.3 // @description P2P шифрование VK: seed-фраза, сохранение ключей, пользовательские ключи, автошифрование, emoji-шифротекст // @author VKEncrypt // @match https://vk.com/* // @match https://m.vk.com/* // @match https://vk.ru/* // @match https://m.vk.ru/* // @match https://web.vk.me/* // @match https://m.web.vk.me/* // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_xmlhttpRequest // @connect vk.com // @connect m.vk.com // @connect vk.ru // @connect m.vk.ru // @connect *.vk.com // @connect *.vk.ru // @connect userapi.com // @connect *.userapi.com // @connect mycdn.me // @connect *.mycdn.me // @run-at document-idle // @updateURL https://raw.githubusercontent.com/megamen32/vkencrypt/master/extension/vkencrypt.user.js // @downloadURL https://raw.githubusercontent.com/megamen32/vkencrypt/master/extension/vkencrypt.user.js // ==/UserScript== (function () { 'use strict'; // ============================================================ // VK P2P AES-GCM v5.1.3 // // Что умеет: // - НЕ показывает модалку сразу после установки. // - Пока ключей нет, кнопки возле поля ввода открывают настройку. // - В seed-модалках есть "глаз" для просмотра вводимой фразы. // - Из seed-фразы детерминированно генерирует k1..k4. // - Сохраняет НЕ seed-фразу, а только производные ключи. // - Поддерживает пользовательские ключи 64 hex. // - Поддерживает временный ключ только в памяти. // - Умеет автошифровать при клике отправки и при Enter. // - Shift+Enter оставляет как перенос строки. // - При включённом автошифровании ручной замок скрывается. // - Опционально кодирует payload в emoji-алфавит. // ============================================================ const APP_NAME = 'VK P2P AES-GCM'; const APP_VERSION = '5.1.3'; const FORMAT_START = '𓁗'; const FORMAT_MID = 'Ⰴ'; const FORMAT_PAYLOAD = 'Ⱑ'; const CODEC_MARKERS = { base64: '𐌁', emoji: '𐌄', cyrillic: '𐌓' }; const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; // 64 emoji для замены Base64-символов. // Важно: режим emoji опциональный, Base64 надёжнее для копирования/пересылки. const EMOJI_ALPHABET = [ '😀','😁','😂','🤣','😃','😄','😅','😆', '😉','😊','😋','😎','😍','😘','🥰','😗', '😙','😚','🙂','🤗','🤩','🤔','🤨','😐', '😑','😶','🙄','😏','😣','😥','😮','🤐', '😯','😪','😫','🥱','😴','😌','😛','😜', '😝','🤤','😒','😓','😔','😕','🙃','🤑', '😲','😡','🤬','😖','😞','😟','😤','😢', '😭','😦','😧','😨','😩','🤯','😬','😰' ]; const EMOJI_PAD = '🟰'; const CYRILLIC_ALPHABET = [ 'А','Б','В','Г','Д','Е','Ж','З', 'И','Й','К','Л','М','Н','О','П', 'Р','С','Т','У','Ф','Х','Ц','Ч', 'Ш','Щ','Ъ','Ы','Ь','Э','Ю','Я', 'а','б','в','г','д','е','ж','з', 'и','й','к','л','м','н','о','п', 'р','с','т','у','ф','х','ц','ч', 'ш','щ','ъ','ы','ь','э','ю','я' ]; const CIPHER_CODECS = { base64: { shortCode: CODEC_MARKERS.base64, label: 'Base64' }, emoji: { shortCode: CODEC_MARKERS.emoji, label: 'Emoji' }, cyrillic: { shortCode: CODEC_MARKERS.cyrillic, label: 'Русский алфавит' } }; const README_URL = 'https://github.com/megamen32/vkencrypt#readme'; const INSTALL_URL = 'https://raw.githubusercontent.com/megamen32/vkencrypt/master/extension/vkencrypt.user.js'; const CYBERCHEF_URL = 'https://gchq.github.io/CyberChef/'; const ONE_TIME_NOTE_SERVICES = [ 'PrivateBin: https://privatebin.net/', 'Onetime Secret: https://onetimesecret.com/', 'Password Pusher: https://pwpush.com/' ]; const MEDIA_CONTAINER_MAGIC = 'VKEM1'; const MEDIA_CONTAINER_EXT = '.vke'; const MEDIA_ENCRYPTED_MIME = 'application/octet-stream'; const IV_LEN = 12; const TAG_LEN = 16; const DEFAULT_KEY_SLOT = 'k1'; const STORAGE_KEYS = { DERIVED_KEYS: 'vk_p2p_derived_keys_v1', CUSTOM_KEYS: 'vk_p2p_custom_keys_v1', SETTINGS: 'vk_p2p_settings_v1' }; const KDF_SALT = 'vk-p2p-aes-gcm-v1'; const KDF_ITERATIONS = 250000; let DERIVED_KEYS = null; let CUSTOM_KEYS = {}; let TEMP_KEY = null; let currentKeySlot = DEFAULT_KEY_SLOT; let settings = { autoEncrypt: false, saveDerivedKeys: true, autoDecrypt: true, cipherCodec: 'emoji', encryptMediaUploads: true }; let isAutoSending = false; let skipNextAutoEncrypt = false; let lastEncryptedAt = 0; let scanTimer = null; let mediaPreviewObserver = null; const MEDIA_DECRYPT_CACHE = new Map(); const STORAGE_FALLBACK_PREFIX = 'vk-p2p-fallback:'; const RUNTIME_PLATFORM = detectRuntimePlatform(); // ============================================================ // Platform // ============================================================ function detectRuntimePlatform() { const ua = navigator.userAgent || ''; const vendor = navigator.vendor || ''; const host = location.hostname.toLowerCase(); const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); const isAndroid = /Android/.test(ua); const isSafari = /Safari\//.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|Firefox|FxiOS/.test(ua) && /Apple/i.test(vendor || ua); const hasGMStorage = typeof GM_getValue === 'function' && typeof GM_setValue === 'function' && typeof GM_deleteValue === 'function'; const hasGMNetwork = typeof GM_xmlhttpRequest === 'function'; const siteFamily = host.endsWith('.vk.me') ? 'vk.me' : 'vk'; return { ua, host, siteFamily, isIOS, isAndroid, isSafari, hasGMStorage, hasGMNetwork, }; } function getPlatformDisplayName() { if (RUNTIME_PLATFORM.isSafari && RUNTIME_PLATFORM.isIOS) return 'Safari на iPhone/iPad'; if (RUNTIME_PLATFORM.isSafari) return 'Safari'; if (RUNTIME_PLATFORM.isAndroid) return 'Android'; if (RUNTIME_PLATFORM.siteFamily === 'vk.me') return 'VK Me'; return 'эта платформа'; } function getCrossOriginMediaBlockReason(url) { if (!/^https?:/i.test(url || '')) return ''; if (String(url).startsWith(location.origin)) return ''; if (RUNTIME_PLATFORM.hasGMNetwork) return ''; if (RUNTIME_PLATFORM.isSafari) { return `${getPlatformDisplayName()} не даёт расшифровать вложения на этом сайте`; } return 'Эта платформа не даёт расшифровать вложения на этом сайте'; } function applyMediaPlatformBlock(box, reason) { if (!box) return; const meta = box.querySelector('.vk-p2p-media-meta'); const error = box.querySelector('.vk-p2p-media-error'); const decryptBtn = box.querySelector('.vk-p2p-media-btn'); const downloadLink = box.querySelector('.vk-p2p-media-download'); if (meta) { meta.textContent = reason; } if (error) { error.textContent = ''; } if (downloadLink) { downloadLink.hidden = true; downloadLink.removeAttribute('href'); downloadLink.removeAttribute('download'); } if (decryptBtn) { decryptBtn.hidden = true; decryptBtn.disabled = false; decryptBtn.title = reason; decryptBtn.textContent = '🔓 Расшифровать вложение'; } box.dataset.vkP2PPlatformBlocked = 'true'; } // ============================================================ // Storage // ============================================================ function safeJsonParse(value, fallback) { try { if (!value) return fallback; return JSON.parse(value); } catch { return fallback; } } function canUseLocalStorage() { try { const probeKey = `${STORAGE_FALLBACK_PREFIX}probe`; localStorage.setItem(probeKey, '1'); localStorage.removeItem(probeKey); return true; } catch { return false; } } function gmGetValueCompat(key, fallback) { if (typeof GM_getValue === 'function') { return GM_getValue(key, fallback); } if (!canUseLocalStorage()) { return fallback; } const value = localStorage.getItem(`${STORAGE_FALLBACK_PREFIX}${key}`); return value === null ? fallback : value; } function gmSetValueCompat(key, value) { if (typeof GM_setValue === 'function') { GM_setValue(key, value); return; } if (!canUseLocalStorage()) { return; } localStorage.setItem(`${STORAGE_FALLBACK_PREFIX}${key}`, value); } function gmDeleteValueCompat(key) { if (typeof GM_deleteValue === 'function') { GM_deleteValue(key); return; } if (!canUseLocalStorage()) { return; } localStorage.removeItem(`${STORAGE_FALLBACK_PREFIX}${key}`); } function gmGetJson(key, fallback) { return safeJsonParse(gmGetValueCompat(key, null), fallback); } function gmSetJson(key, value) { gmSetValueCompat(key, JSON.stringify(value)); } function loadSettings() { const saved = gmGetJson(STORAGE_KEYS.SETTINGS, null); if (saved && typeof saved === 'object') { const normalized = { ...saved }; if (typeof normalized.autoDecrypt !== 'boolean' && typeof normalized.decryptIncoming === 'boolean') { normalized.autoDecrypt = normalized.decryptIncoming; } if (!Object.prototype.hasOwnProperty.call(normalized, 'cipherCodec')) { normalized.cipherCodec = normalized.emojiCipher ? 'emoji' : 'base64'; } settings = { ...settings, ...normalized }; } } function saveSettings() { gmSetJson(STORAGE_KEYS.SETTINGS, { ...settings, decryptIncoming: settings.autoDecrypt, emojiCipher: settings.cipherCodec === 'emoji' }); } function isValidKeyHex(hex) { return typeof hex === 'string' && /^[0-9a-f]{64}$/i.test(hex); } function areValidDerivedKeys(keys) { return Boolean( keys && isValidKeyHex(keys.k1) && isValidKeyHex(keys.k2) && isValidKeyHex(keys.k3) && isValidKeyHex(keys.k4) ); } function normalizeKeyObject(obj) { const out = {}; for (const [k, v] of Object.entries(obj || {})) { if (isValidKeyHex(v)) out[k] = String(v).toLowerCase(); } return out; } function normalizeCustomKeyEntry(raw) { if (!raw) return null; if (typeof raw === 'string') { if (!isValidKeyHex(raw)) return null; return { key: raw.toLowerCase(), label: '' }; } if (typeof raw === 'object') { if (!isValidKeyHex(raw.key)) return null; const label = typeof raw.label === 'string' ? raw.label.trim().slice(0, 64) : ''; return { key: String(raw.key).toLowerCase(), label }; } return null; } function loadDerivedKeys() { const saved = gmGetJson(STORAGE_KEYS.DERIVED_KEYS, null); if (areValidDerivedKeys(saved)) return normalizeKeyObject(saved); return null; } function saveDerivedKeys(keys) { if (!areValidDerivedKeys(keys)) return; gmSetJson(STORAGE_KEYS.DERIVED_KEYS, normalizeKeyObject(keys)); } function clearDerivedKeys() { gmDeleteValueCompat(STORAGE_KEYS.DERIVED_KEYS); DERIVED_KEYS = null; } function loadCustomKeys() { const saved = gmGetJson(STORAGE_KEYS.CUSTOM_KEYS, {}); const out = {}; for (const [slot, raw] of Object.entries(saved || {})) { const normalized = normalizeCustomKeyEntry(raw); if (normalized) out[slot] = normalized; } CUSTOM_KEYS = out; } function saveCustomKeys() { gmSetJson(STORAGE_KEYS.CUSTOM_KEYS, CUSTOM_KEYS); } function resetAllKeys() { clearDerivedKeys(); gmDeleteValueCompat(STORAGE_KEYS.CUSTOM_KEYS); CUSTOM_KEYS = {}; TEMP_KEY = null; currentKeySlot = DEFAULT_KEY_SLOT; updateEncryptButtonsTitle(); showSeedSetupModal(); } // ============================================================ // Crypto helpers // ============================================================ function hexToBytes(hex) { if (!isValidKeyHex(hex)) throw new Error('Invalid key hex'); const arr = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { arr[i / 2] = parseInt(hex.substr(i, 2), 16); } return arr; } function bytesToHex(bytes) { return Array.from(bytes) .map(b => b.toString(16).padStart(2, '0')) .join(''); } function bytesToBase64(bytes) { let binary = ''; const chunkSize = 0x8000; for (let i = 0; i < bytes.length; i += chunkSize) { const chunk = bytes.subarray(i, i + chunkSize); binary += String.fromCharCode.apply(null, chunk); } return btoa(binary); } function utf8ToBytes(text) { return new TextEncoder().encode(text); } function bytesToUtf8(bytes) { return new TextDecoder().decode(bytes); } function concatBytes(parts) { const total = parts.reduce((sum, part) => sum + part.length, 0); const out = new Uint8Array(total); let offset = 0; parts.forEach(part => { out.set(part, offset); offset += part.length; }); return out; } function uint32ToBytes(value) { const out = new Uint8Array(4); new DataView(out.buffer).setUint32(0, value, false); return out; } function bytesToUint32(bytes, offset = 0) { return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, false); } function base64ToBytes(b64) { const bin = atob(b64); const data = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) { data[i] = bin.charCodeAt(i); } return data; } function encodeBase64ToAlphabet(b64, alphabet, padChar = '=') { let out = ''; for (const ch of b64) { if (ch === '=') { continue; } const idx = BASE64_ALPHABET.indexOf(ch); if (idx === -1) throw new Error('Invalid base64 char: ' + ch); out += alphabet[idx]; } return out; } function decodeAlphabetToBase64(payload, alphabet, padChar = '=') { let out = ''; for (const symbol of Array.from(payload)) { if (symbol === padChar) { out += '='; continue; } const idx = alphabet.indexOf(symbol); if (idx === -1) throw new Error('Invalid cipher symbol: ' + symbol); out += BASE64_ALPHABET[idx]; } return out + '='.repeat((4 - (out.length % 4)) % 4); } function encodeBase64ToEmoji(b64) { return encodeBase64ToAlphabet(b64, EMOJI_ALPHABET, EMOJI_PAD); } function decodeEmojiToBase64(payload) { return decodeAlphabetToBase64(payload, EMOJI_ALPHABET, EMOJI_PAD); } function encodeBase64ToCyrillic(b64) { return encodeBase64ToAlphabet(b64, CYRILLIC_ALPHABET); } function decodeCyrillicToBase64(payload) { return decodeAlphabetToBase64(payload, CYRILLIC_ALPHABET); } function getCipherCodecConfig(codecId) { return CIPHER_CODECS[codecId] || CIPHER_CODECS.emoji; } function normalizeCodecId(codecId) { return Object.prototype.hasOwnProperty.call(CIPHER_CODECS, codecId) ? codecId : 'emoji'; } function encodePayloadForCodec(b64, codecId) { switch (normalizeCodecId(codecId)) { case 'base64': return b64.replace(/=+$/u, ''); case 'cyrillic': return encodeBase64ToCyrillic(b64); case 'emoji': default: return encodeBase64ToEmoji(b64); } } function decodePayloadForCodec(payload, codecId) { switch (normalizeCodecId(codecId)) { case 'base64': return payload + '='.repeat((4 - (payload.length % 4)) % 4); case 'cyrillic': return decodeCyrillicToBase64(payload); case 'emoji': default: return decodeEmojiToBase64(payload); } } function isValidBase64Payload(payload) { return typeof payload === 'string' && payload.length >= 4 && payload.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(payload); } function isPlausibleEncodedPayload(payload, codecId) { if (typeof payload !== 'string' || !payload) return false; try { const b64 = decodePayloadForCodec(payload, codecId); return isValidBase64Payload(b64); } catch { return false; } } function toCompactKeyId(slotId) { const match = /^k([1-4])$/.exec(slotId); return match ? match[1] : slotId; } function fromCompactKeyId(compactId) { return /^[1-4]$/.test(compactId) ? `k${compactId}` : compactId; } function formatEncryptedMessage(slotId, payload, codecId) { const codec = getCipherCodecConfig(codecId); return `${FORMAT_START}${toCompactKeyId(slotId)}${FORMAT_MID}${codec.shortCode}${FORMAT_PAYLOAD}${payload}`; } function parseEncryptedMessage(text) { const trimmed = (text || '').trim(); const compactMatch = new RegExp(`^${FORMAT_START}(.+?)${FORMAT_MID}([${CODEC_MARKERS.base64}${CODEC_MARKERS.emoji}${CODEC_MARKERS.cyrillic}])${FORMAT_PAYLOAD}(.+)$`, 'su').exec(trimmed); if (!compactMatch) return null; const parsed = { originalText: trimmed, keyId: fromCompactKeyId(compactMatch[1]), codecId: compactMatch[2] === CODEC_MARKERS.emoji ? 'emoji' : compactMatch[2] === CODEC_MARKERS.cyrillic ? 'cyrillic' : 'base64', encodedPayload: compactMatch[3] }; return isPlausibleEncodedPayload(parsed.encodedPayload, parsed.codecId) ? parsed : null; } async function deriveKeyMaterialFromSeed(seedText) { const encoder = new TextEncoder(); const baseKey = await crypto.subtle.importKey( 'raw', encoder.encode(seedText), 'PBKDF2', false, ['deriveBits'] ); const bits = await crypto.subtle.deriveBits( { name: 'PBKDF2', salt: encoder.encode(KDF_SALT), iterations: KDF_ITERATIONS, hash: 'SHA-256' }, baseKey, 1024 ); const bytes = new Uint8Array(bits); return { k1: bytesToHex(bytes.slice(0, 32)), k2: bytesToHex(bytes.slice(32, 64)), k3: bytesToHex(bytes.slice(64, 96)), k4: bytesToHex(bytes.slice(96, 128)) }; } async function deriveKeyFromName(name) { if (!name || !name.trim()) { throw new Error('Пустое слово'); } const hash = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(name.trim()) ); return bytesToHex(new Uint8Array(hash)); } async function encryptAESGCM(plainText, keyHex) { const key = await crypto.subtle.importKey( 'raw', hexToBytes(keyHex), { name: 'AES-GCM' }, false, ['encrypt'] ); const iv = crypto.getRandomValues(new Uint8Array(IV_LEN)); const data = new TextEncoder().encode(plainText); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv, tagLength: 128 }, key, data ); const encryptedArr = new Uint8Array(encrypted); const payload = new Uint8Array(iv.length + encryptedArr.length); payload.set(iv); payload.set(encryptedArr, iv.length); return bytesToBase64(payload); } async function decryptAESGCM(b64Payload, keyHex) { const data = base64ToBytes(b64Payload); if (data.length < IV_LEN + TAG_LEN) { throw new Error('Data too short'); } const iv = data.slice(0, IV_LEN); const ciphertextWithTag = data.slice(IV_LEN); const key = await crypto.subtle.importKey( 'raw', hexToBytes(keyHex), { name: 'AES-GCM' }, false, ['decrypt'] ); const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv, tagLength: 128 }, key, ciphertextWithTag ); return new TextDecoder().decode(decrypted); } async function encryptBinaryAESGCM(dataBytes, keyHex) { const key = await crypto.subtle.importKey( 'raw', hexToBytes(keyHex), { name: 'AES-GCM' }, false, ['encrypt'] ); const iv = crypto.getRandomValues(new Uint8Array(IV_LEN)); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv, tagLength: 128 }, key, dataBytes ); return concatBytes([iv, new Uint8Array(encrypted)]); } async function decryptBinaryAESGCM(payloadBytes, keyHex) { if (payloadBytes.length < IV_LEN + TAG_LEN) { throw new Error('Media payload too short'); } const key = await crypto.subtle.importKey( 'raw', hexToBytes(keyHex), { name: 'AES-GCM' }, false, ['decrypt'] ); const iv = payloadBytes.slice(0, IV_LEN); const ciphertextWithTag = payloadBytes.slice(IV_LEN); const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv, tagLength: 128 }, key, ciphertextWithTag ); return new Uint8Array(decrypted); } function buildEncryptedMediaName(originalName) { const clean = String(originalName || 'media.bin').trim() || 'media.bin'; return clean.endsWith(MEDIA_CONTAINER_EXT) ? clean : `${clean}${MEDIA_CONTAINER_EXT}`; } function isEncryptedMediaName(name) { return new RegExp(`${MEDIA_CONTAINER_EXT}(?:$|[?#])`, 'i').test(String(name || '')); } function isEncryptableMediaFile(file) { return Boolean( file && typeof file.type === 'string' && /^(image|audio|video)\//i.test(file.type) ); } async function buildEncryptedMediaFile(file, keyHex, slotId) { const sourceBytes = new Uint8Array(await file.arrayBuffer()); const encryptedPayload = await encryptBinaryAESGCM(sourceBytes, keyHex); const metadata = { version: 1, keyId: slotId, mime: file.type || 'application/octet-stream', originalName: file.name || 'media.bin', originalSize: file.size || sourceBytes.length }; const metaBytes = utf8ToBytes(JSON.stringify(metadata)); const header = concatBytes([ utf8ToBytes(MEDIA_CONTAINER_MAGIC), uint32ToBytes(metaBytes.length), metaBytes ]); const containerBytes = concatBytes([header, encryptedPayload]); return new File( [containerBytes], buildEncryptedMediaName(file.name), { type: MEDIA_ENCRYPTED_MIME, lastModified: Date.now() } ); } function parseEncryptedMediaContainer(bytes) { const magicBytes = utf8ToBytes(MEDIA_CONTAINER_MAGIC); if (bytes.length < magicBytes.length + 4 + IV_LEN + TAG_LEN) { throw new Error('Encrypted media container too short'); } const actualMagic = bytesToUtf8(bytes.slice(0, magicBytes.length)); if (actualMagic !== MEDIA_CONTAINER_MAGIC) { throw new Error('Unknown encrypted media format'); } const metaLength = bytesToUint32(bytes, magicBytes.length); const metaStart = magicBytes.length + 4; const metaEnd = metaStart + metaLength; if (metaEnd > bytes.length) { throw new Error('Broken encrypted media metadata'); } const metadata = JSON.parse(bytesToUtf8(bytes.slice(metaStart, metaEnd))); return { metadata, encryptedPayload: bytes.slice(metaEnd) }; } function getAllKeys() { const all = {}; if (DERIVED_KEYS) Object.assign(all, DERIVED_KEYS); if (CUSTOM_KEYS) { for (const [slot, info] of Object.entries(CUSTOM_KEYS)) { if (info && typeof info === 'object' && info.key) { all[slot] = info.key; } else if (typeof info === 'string') { all[slot] = info; } } } if (TEMP_KEY) all['@temp'] = TEMP_KEY; return all; } function getCustomKeyLabel(slot) { const info = CUSTOM_KEYS[slot]; if (!info || typeof info !== 'object') return ''; return info.label || ''; } function getCurrentKeyHex() { return getAllKeys()[currentKeySlot] || null; } function hasAnyKeys() { return Boolean(DERIVED_KEYS || Object.keys(CUSTOM_KEYS).length || TEMP_KEY); } // ============================================================ // Styles // ============================================================ function injectStyles() { if (document.getElementById('vk-p2p-styles')) return; const style = document.createElement('style'); style.id = 'vk-p2p-styles'; style.textContent = ` @keyframes vkP2PFadeIn { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes vkP2PToastOut { 0% { opacity: 1; transform: translate(-50%, 0); } 75% { opacity: 1; transform: translate(-50%, 0); } 100% { opacity: 0; transform: translate(-50%, 12px); } } .vk-p2p-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.62); z-index: 999999; display: flex; align-items: center; justify-content: center; padding: 16px; box-sizing: border-box; backdrop-filter: blur(4px); } .vk-p2p-modal { width: min(480px, 100%); max-height: calc(100vh - 32px); overflow-y: auto; background: #ffffff; color: #111827; border-radius: 18px; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); padding: 20px; box-sizing: border-box; animation: vkP2PFadeIn 0.18s ease-out; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; } .vk-p2p-modal h3 { margin: 0 0 8px; font-size: 18px; line-height: 1.25; font-weight: 700; } .vk-p2p-modal p { margin: 0 0 12px; font-size: 13px; line-height: 1.45; color: #4b5563; } .vk-p2p-row { display: flex; gap: 8px; align-items: center; } .vk-p2p-input, .vk-p2p-select, .vk-p2p-textarea { width: 100%; box-sizing: border-box; border: 1px solid #d1d5db; background: #fff; color: #111827; border-radius: 10px; padding: 11px 12px; font-size: 14px; outline: none; transition: border-color 0.15s, box-shadow 0.15s; } .vk-p2p-input:focus, .vk-p2p-select:focus, .vk-p2p-textarea:focus { border-color: #2688eb; box-shadow: 0 0 0 3px rgba(38, 136, 235, 0.15); } .vk-p2p-textarea { min-height: 84px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } .vk-p2p-check { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; line-height: 1.35; color: #374151; margin: 8px 0 12px; user-select: none; } .vk-p2p-check input { margin-top: 2px; } .vk-p2p-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; flex-wrap: wrap; } .vk-p2p-btn { border: none; border-radius: 10px; padding: 9px 13px; font-size: 13px; cursor: pointer; transition: transform 0.08s, opacity 0.15s, background 0.15s; white-space: nowrap; } .vk-p2p-btn:active { transform: translateY(1px); } .vk-p2p-btn:disabled { opacity: 0.55; cursor: default; } .vk-p2p-btn-primary { background: #2688eb; color: #fff; } .vk-p2p-btn-secondary { background: #f3f4f6; color: #111827; } .vk-p2p-btn-danger { background: #fee2e2; color: #991b1b; } .vk-p2p-eye-btn { min-width: 44px; padding-left: 10px; padding-right: 10px; } .vk-p2p-error { display: none; color: #b91c1c !important; font-size: 12px !important; margin-top: 8px !important; } .vk-p2p-note { border-radius: 12px; padding: 10px 12px; background: #f3f7ff; color: #31527a !important; font-size: 12px !important; } .vk-p2p-controls { display: inline-flex; align-items: center; justify-content: center; gap: 2px; margin-right: 2px; align-self: flex-end; min-width: 36px; min-height: 36px; vertical-align: middle; } .vk-p2p-icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; background: transparent; border: none; cursor: pointer; color: inherit; opacity: 0.58; padding: 0; border-radius: 8px; line-height: 1; transition: opacity 0.15s, background 0.15s; vertical-align: middle; } .vk-p2p-icon-glyph { display: inline-flex; align-items: center; justify-content: center; width: 100%; height: 100%; line-height: 1; transform: translateY(-1px); pointer-events: none; } .vk-p2p-icon-btn:hover { opacity: 1; background: rgba(127, 127, 127, 0.10); } .vk-p2p-icon-btn-main { font-size: 18px; } .vk-p2p-icon-btn-small { font-size: 18px; } .vk-p2p-menu { position: fixed; z-index: 999999; box-sizing: border-box; width: min(340px, calc(100vw - 16px)); max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); overflow-y: auto; padding: 8px; border-radius: 14px; background: #ffffff; color: #111827; box-shadow: 0 18px 48px rgba(0,0,0,0.24); border: 1px solid rgba(0,0,0,0.10); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; font-size: 13px; animation: vkP2PFadeIn 0.12s ease-out; } .vk-p2p-menu-title { padding: 7px 9px 6px; color: #6b7280; font-size: 12px; } .vk-p2p-menu-item { display: block; width: 100%; border: none; background: transparent; color: inherit; text-align: left; padding: 9px 10px; border-radius: 9px; cursor: pointer; font: inherit; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; } .vk-p2p-menu-field { display: grid; gap: 6px; padding: 8px 10px; } .vk-p2p-menu-label { color: #4b5563; font-size: 12px; } .vk-p2p-menu-select { width: 100%; box-sizing: border-box; border: 1px solid #d1d5db; border-radius: 9px; padding: 8px 10px; background: #fff; color: #111827; font: inherit; } .vk-p2p-menu-item:hover { background: #f3f4f6; } .vk-p2p-menu-item-active { background: #e8f1ff; color: #155aa3; } .vk-p2p-menu-sep { border-top: 1px solid #eef0f3; margin: 6px 0; } .vk-p2p-menu-danger { color: #b91c1c; } .vk-p2p-toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); background: #1f2937; color: #fff; padding: 10px 14px; border-radius: 12px; font-size: 13px; z-index: 1000000; box-shadow: 0 8px 28px rgba(0,0,0,0.25); animation: vkP2PToastOut 2.4s forwards; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; } .vk-dec-content { white-space: pre-wrap; } .vk-dec-toggle { display: inline-block; margin-left: 8px; font-size: 11px; text-decoration: underline; cursor: pointer; opacity: 0.65; user-select: none; color: inherit; } .vk-dec-toggle:hover { opacity: 1; } .vk-dec-error { display: block; margin-top: 6px; font-size: 12px; line-height: 1.35; color: rgba(255, 255, 255, 0.72); } .vk-p2p-media-box { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; padding: 10px 12px; border-radius: 12px; background: rgba(38, 136, 235, 0.10); max-width: min(520px, 100%); } .vk-p2p-media-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .vk-p2p-media-btn, .vk-p2p-media-download { border: none; border-radius: 999px; padding: 6px 10px; font-size: 12px; line-height: 1.2; cursor: pointer; background: rgba(38, 136, 235, 0.18); color: inherit; text-decoration: none; } .vk-p2p-media-download[hidden] { display: none !important; } .vk-p2p-media-meta { font-size: 12px; opacity: 0.72; word-break: break-word; } .vk-p2p-media-preview img, .vk-p2p-media-preview video { display: block; max-width: min(420px, 100%); border-radius: 10px; } .vk-p2p-media-preview audio { width: min(420px, 100%); } .vk-p2p-media-error { font-size: 12px; line-height: 1.35; color: #b91c1c; } `; document.head.appendChild(style); } // ============================================================ // UI helpers // ============================================================ function showToast(text) { injectStyles(); const old = document.querySelector('.vk-p2p-toast'); if (old) old.remove(); const toast = document.createElement('div'); toast.className = 'vk-p2p-toast'; toast.textContent = text; document.body.appendChild(toast); setTimeout(() => toast.remove(), 2500); } function createModal({ title, bodyHtml, actionsHtml = '', closeOnOverlay = true }) { injectStyles(); const overlay = document.createElement('div'); overlay.className = 'vk-p2p-overlay'; const modal = document.createElement('div'); modal.className = 'vk-p2p-modal'; modal.innerHTML = `
Введите секретное слово, число или фразу. Из неё будут созданы одинаковые ключи k1–k4 на всех устройствах, где введена та же фраза.
Лучше использовать длинную фразу из нескольких слов. Простые числа вроде 1234 легко перебираются. Фраза не сохраняется — сохраняются только производные ключи.
Введи имя для слота и 64 hex-символа — или просто любое слово (например, «собака»). Из слова скрипт детерминированно выведет 256-битный ключ. Собеседнику нужно ввести то же слово.
Имя слота может быть и на кириллице. Подходят буквы любого алфавита, цифры, _, -, . и @.
`, actionsHtml: ` ` }); const nameInput = modal.querySelector('#vk-p2p-custom-name'); const keyInput = modal.querySelector('#vk-p2p-custom-key'); const error = modal.querySelector('#vk-p2p-custom-error'); const saveBtn = modal.querySelector('#vk-p2p-custom-save'); const cancelBtn = modal.querySelector('#vk-p2p-custom-cancel'); setTimeout(() => nameInput.focus(), 80); cancelBtn.addEventListener('click', () => overlay.remove()); async function handleSave() { let name = nameInput.value.trim(); const keyOrWord = keyInput.value.trim(); if (!name) { error.textContent = 'Введите имя ключа.'; error.style.display = 'block'; return; } name = name.replace(/\s+/g, '_'); if (['k1', 'k2', 'k3', 'k4', '@temp'].includes(name)) { error.textContent = 'Это имя зарезервировано. Используй другое.'; error.style.display = 'block'; return; } if (!/^[\p{L}\p{N}_.@-]{1,32}$/u.test(name)) { error.textContent = 'Имя может содержать буквы любого алфавита, цифры, _, -, . и @. До 32 символов.'; error.style.display = 'block'; return; } if (!keyOrWord) { error.textContent = 'Введите 64 hex-символа или любое слово.'; error.style.display = 'block'; return; } saveBtn.disabled = true; saveBtn.textContent = 'Создаю...'; try { let keyHex; let label = ''; if (isValidKeyHex(keyOrWord)) { keyHex = keyOrWord.toLowerCase(); } else { keyHex = await deriveKeyFromName(keyOrWord); label = keyOrWord; } CUSTOM_KEYS[name] = { key: keyHex, label }; saveCustomKeys(); currentKeySlot = name; overlay.remove(); updateEncryptButtonsTitle(); scan(); const tag = label ? ` «${truncateForDisplay(label, 24)}»` : ''; showToast(`✅ ${name}${tag} сохранён`); } catch (err) { error.textContent = 'Ошибка: ' + err.message; error.style.display = 'block'; } finally { saveBtn.disabled = false; saveBtn.textContent = 'Сохранить'; } } saveBtn.addEventListener('click', handleSave); keyInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); handleSave(); } }); } async function generateTempKey() { const bytes = crypto.getRandomValues(new Uint8Array(32)); const keyHex = bytesToHex(bytes); TEMP_KEY = keyHex; currentKeySlot = '@temp'; updateEncryptButtonsTitle(); scan(); try { await navigator.clipboard.writeText(keyHex); showToast('✅ Временный ключ создан и скопирован'); } catch { showGeneratedKeyModal(keyHex); } } function showGeneratedKeyModal(keyHex) { const { overlay, modal } = createModal({ title: '⚡ Новый временный ключ', bodyHtml: `Ключ создан и применён. Скопируй его и передай собеседнику. Он исчезнет при перезагрузке страницы.
`, actionsHtml: ` ` }); const output = modal.querySelector('#vk-p2p-generated-key'); setTimeout(() => { output.focus(); output.select(); }, 80); modal.querySelector('#vk-p2p-generated-close').addEventListener('click', () => overlay.remove()); modal.querySelector('#vk-p2p-generated-copy').addEventListener('click', async () => { try { await navigator.clipboard.writeText(keyHex); overlay.remove(); showToast('✅ Ключ скопирован'); } catch { output.focus(); output.select(); } }); } function showSeedChangeModal() { const { overlay, modal } = createModal({ title: '🔄 Сменить seed-фразу', bodyHtml: `Будут заново созданы ключи k1–k4. Старые сохранённые k1–k4 будут заменены. Пользовательские ключи не удаляются.
Сообщение будет отправлено открытым текстом, чтобы собеседник смог установить VKEncrypt. Ключ в это сообщение не добавляется.
Автосоздание одноразовой заметки пока выключено: публичные сервисы отличаются API/CORS. Ключ лучше отправлять отдельно и только после проверки сервиса.
`, actionsHtml: ` ` }); function getText() { return buildShareInstructionText({ includeInstallUrl: modal.querySelector('#vk-p2p-share-install-url').checked, includeCyberChef: modal.querySelector('#vk-p2p-share-cyberchef').checked, includeNoteServices: modal.querySelector('#vk-p2p-share-note-services').checked }); } modal.querySelector('#vk-p2p-share-cancel').addEventListener('click', () => overlay.remove()); modal.querySelector('#vk-p2p-share-insert').addEventListener('click', () => { const text = getText(); overlay.remove(); sendPlainTextMessage(text, { sendNow: false }); }); modal.querySelector('#vk-p2p-share-send').addEventListener('click', () => { const text = getText(); overlay.remove(); sendPlainTextMessage(text, { sendNow: true }); }); } function showMainMenu(anchorBtn) { closeMenus(); const menu = document.createElement('div'); menu.className = 'vk-p2p-menu'; menu.style.left = '8px'; menu.style.top = '8px'; menu.style.visibility = 'hidden'; const title = document.createElement('div'); title.className = 'vk-p2p-menu-title'; title.textContent = `${APP_NAME} v${APP_VERSION}`; menu.appendChild(title); const allKeys = getAllKeys(); const keyNames = Object.keys(allKeys); if (keyNames.length) { const keyTitle = document.createElement('div'); keyTitle.className = 'vk-p2p-menu-title'; keyTitle.textContent = 'Ключ шифрования'; menu.appendChild(keyTitle); keyNames.forEach(slotId => { const item = document.createElement('button'); item.type = 'button'; item.className = 'vk-p2p-menu-item'; if (slotId === currentKeySlot) { item.classList.add('vk-p2p-menu-item-active'); } item.textContent = formatKeyDisplay(slotId); item.title = slotId === '@temp' ? 'Временный ключ (только в памяти)' : getCustomKeyLabel(slotId) ? `${slotId} — ${getCustomKeyLabel(slotId)}` : slotId; item.addEventListener('click', () => { currentKeySlot = slotId; closeMenus(); updateEncryptButtonsTitle(); showToast(`✅ Выбран ключ: ${formatKeyDisplay(slotId)}`); }); menu.appendChild(item); }); } addMenuSeparator(menu); addMenuItem(menu, settings.autoEncrypt ? '🟢 Автошифрование: включено' : '⚪ Автошифрование: выключено', () => { settings.autoEncrypt = !settings.autoEncrypt; saveSettings(); closeMenus(); updateEncryptButtonsTitle(); scan(); showToast(settings.autoEncrypt ? '✅ Автошифрование включено' : '⏸️ Автошифрование выключено'); }); addMenuItem(menu, settings.encryptMediaUploads ? '🎞️ Шифровать вложения: включено' : '🎞️ Шифровать вложения: выключено', () => { settings.encryptMediaUploads = !settings.encryptMediaUploads; saveSettings(); closeMenus(); showToast(settings.encryptMediaUploads ? '✅ Шифрование вложений включено' : '⏸️ Шифрование вложений выключено'); }); addMenuSelect( menu, 'Кодирование шифротекста', 'vk-p2p-cipher-codec-select', normalizeCodecId(settings.cipherCodec), [ { value: 'emoji', label: 'Emoji' }, { value: 'cyrillic', label: 'Русский алфавит' }, { value: 'base64', label: 'Base64' } ], value => { settings.cipherCodec = normalizeCodecId(value); saveSettings(); showToast(`✅ Новые сообщения будут в формате: ${getCipherCodecConfig(settings.cipherCodec).label}`); } ); addMenuItem(menu, settings.autoDecrypt ? '👁️ Авто-расшифровка: включена' : '🙈 Авто-расшифровка: выключена', () => { settings.autoDecrypt = !settings.autoDecrypt; saveSettings(); closeMenus(); if (!settings.autoDecrypt) { restoreAllIncomingMessages(); restoreAllIncomingMedia(); } showToast(settings.autoDecrypt ? '✅ Авто-расшифровка включена' : '⏸️ Авто-расшифровка выключена'); scan(); }); addMenuSeparator(menu); addMenuItem(menu, '➕ Добавить пользовательский ключ', () => { closeMenus(); showAddCustomKeyModal(); }); addMenuItem(menu, '⚡ Сгенерировать временный ключ', () => { closeMenus(); generateTempKey(); }); addMenuItem(menu, '🔄 Сменить seed-фразу k1–k4', () => { closeMenus(); showSeedChangeModal(); }); addMenuItem(menu, '📨 Скинуть инструкцию по установке', () => { closeMenus(); showShareInstructionModal(); }); if (TEMP_KEY) { addMenuItem(menu, '🧹 Удалить временный ключ', () => { TEMP_KEY = null; if (currentKeySlot === '@temp') currentKeySlot = DEFAULT_KEY_SLOT; closeMenus(); updateEncryptButtonsTitle(); showToast('✅ Временный ключ удалён'); }); } const customKeyNames = Object.keys(CUSTOM_KEYS); if (customKeyNames.length) { addMenuSeparator(menu); customKeyNames.forEach(name => { const label = getCustomKeyLabel(name); const display = label ? `${name} (${truncateForDisplay(label)})` : name; addMenuItem(menu, `🗑️ Удалить ключ ${display}`, () => { if (!confirm(`Удалить пользовательский ключ "${name}"?`)) return; delete CUSTOM_KEYS[name]; saveCustomKeys(); if (currentKeySlot === name) currentKeySlot = DEFAULT_KEY_SLOT; closeMenus(); updateEncryptButtonsTitle(); showToast(`✅ Ключ ${name} удалён`); }, true); }); } addMenuSeparator(menu); addMenuItem(menu, '🧨 Сбросить все сохранённые ключи', () => { if (!confirm('Удалить сохранённые k1–k4 и все пользовательские ключи?')) return; closeMenus(); resetAllKeys(); showToast('✅ Сохранённые ключи сброшены'); }, true); document.body.appendChild(menu); positionMenu(menu, anchorBtn); setTimeout(() => { document.addEventListener('click', function closeOnce(e) { if (!menu.contains(e.target) && e.target !== anchorBtn) { menu.remove(); } }, { once: true }); }, 0); } function addMenuItem(menu, text, onClick, danger = false) { const item = document.createElement('button'); item.type = 'button'; item.className = 'vk-p2p-menu-item'; if (danger) item.classList.add('vk-p2p-menu-danger'); item.textContent = text; item.addEventListener('click', onClick); menu.appendChild(item); return item; } function addMenuSelect(menu, label, id, value, options, onChange) { const field = document.createElement('div'); field.className = 'vk-p2p-menu-field'; const labelEl = document.createElement('label'); labelEl.className = 'vk-p2p-menu-label'; labelEl.htmlFor = id; labelEl.textContent = label; const select = document.createElement('select'); select.className = 'vk-p2p-menu-select'; select.id = id; options.forEach(option => { const item = document.createElement('option'); item.value = option.value; item.textContent = option.label; select.appendChild(item); }); select.value = value; select.addEventListener('change', () => onChange(select.value)); field.appendChild(labelEl); field.appendChild(select); menu.appendChild(field); return select; } function addMenuSeparator(menu) { const sep = document.createElement('div'); sep.className = 'vk-p2p-menu-sep'; menu.appendChild(sep); } function positionMenu(menu, anchorBtn) { const rect = anchorBtn.getBoundingClientRect(); const margin = 8; const availableHeight = window.innerHeight - margin * 2; menu.style.maxHeight = `${availableHeight}px`; const menuRect = menu.getBoundingClientRect(); const width = menuRect.width; const height = Math.min(menuRect.height, availableHeight); if (menuRect.height > availableHeight) { menu.style.height = `${availableHeight}px`; } else { menu.style.height = ''; } const left = Math.max(margin, Math.min(rect.right - width, window.innerWidth - width - margin)); const preferredTop = rect.top - height - margin; const fallbackTop = rect.bottom + margin; const top = preferredTop >= margin ? preferredTop : Math.min(fallbackTop, window.innerHeight - height - margin); menu.style.left = `${left}px`; menu.style.top = `${Math.max(margin, top)}px`; menu.style.visibility = 'visible'; } // ============================================================ // Scan loop // ============================================================ function scan() { injectStyles(); getIncomingMessageElements().forEach(el => processIncomingMessage(el)); decorateIncomingMediaLinks(); addEncryptButton(); } function scheduleScan(delay = 250) { if (scanTimer !== null) return; scanTimer = setTimeout(() => { scanTimer = null; scan(); }, delay); } function init() { injectStyles(); loadSettings(); loadCustomKeys(); DERIVED_KEYS = loadDerivedKeys(); if (!DERIVED_KEYS && Object.keys(CUSTOM_KEYS).length) { currentKeySlot = Object.keys(CUSTOM_KEYS)[0]; } console.log(`🔐 ${APP_NAME} v${APP_VERSION} loaded`); console.log('🔑 Derived keys:', DERIVED_KEYS ? 'yes' : 'no'); console.log('🔑 Custom keys:', Object.keys(CUSTOM_KEYS).join(', ') || 'none'); console.log('⚡ Temp key:', TEMP_KEY ? 'yes' : 'no'); scheduleScan(700); const observer = new MutationObserver(() => { scheduleScan(); }); observer.observe(document.body, { childList: true, subtree: true }); document.addEventListener('change', (e) => { handleMediaFileInputChange(e); }, true); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeMenus(); } }, true); } init(); })();