Аудит безопасности · июль 2026 Security Audit · July 2026
// Асимметричный ratchet: X25519 ECDH + HKDF-SHA256 // Только стандартные Apple CryptoKit API, без сторонних библиотек import CryptoKit // X25519, HKDF, AES-GCM, Ed25519 — всё здесь // X25519 Diffie-Hellman let theirPublic = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: theirDHKey) let shared = try myPrivate.sharedSecretFromKeyAgreement(with: theirPublic) // HKDF-SHA256: из общего секрета + rootKey → новый rootKey + chainKey let derived = HKDF<SHA256>.deriveKey( inputKeyMaterial: SymmetricKey(data: shared.withUnsafeBytes { Data($0) }), salt: rootKey, info: Data("DoubleRatchet".utf8), outputByteCount: 64 // 32 → новый rootKey, 32 → новый chainKey ) // Эфемерный ключ сбрасывается — прошлые сессии скомпрометировать нельзя let newEphemeral = Curve25519.KeyAgreement.PrivateKey() // generate // AES-256-GCM шифрование с AAD — метаданные криптографически привязаны к тексту let nonce = deriveNonce(from: messageKey) // детерминирован из messageKey let aad = buildFramingAAD(ratchetKey: ratchetKey, messageNumber: messageNumber) let sealed = try AES.GCM.seal( plaintext, using: messageKey, nonce: nonce, authenticating: aad // ← подмена заголовка → authentication failure )
// Сервер получает зашифрованный content и пересылает его как есть // Приватные ключи для расшифровки существуют только на устройствах // Сервер видит: senderId, recipientId, timestamp, размер — не содержимое sendToClient(recipient.ws, { type: 'new_message', messageId, senderId, content, // ← AES-256-GCM ciphertext, сервер не может расшифровать ratchetKey, // ← публичный DH-ключ (не секрет, нужен для Double Ratchet) messageNumber, timestamp }); // В логах сервера: только технические метаданные // console.log(`📨 ${senderId} → ${recipientId}, id: ${messageId}`) // Содержимое сообщения в лог никогда не попадает
// Nonce производится от messageKey через SHA256 // Каждый messageKey уникален → nonce никогда не повторяется private func deriveNonce(from messageKey: SymmetricKey) -> AES.GCM.Nonce { let keyData = messageKey.withUnsafeBytes { Data($0) } let hash = SHA256.hash(data: keyData) let nonceData = Data(hash.prefix(12)) // 96-bit nonce для AES-GCM return try! AES.GCM.Nonce(data: nonceData) // Double Ratchet гарантирует что messageKey одноразовый → // один и тот же nonce никогда не используется дважды }
// При каждом сообщении — новый chainKey (симметричный ratchet) func ratchetSend() throws -> MessageKey { let messageKey = deriveMessageKey(chainKey) chainKey = deriveNextChainKey(chainKey) // старый ключ удаляется return messageKey } // При смене DH-ключа — ротация rootKey (асимметричный ratchet) func ratchetDH(theirPublicKey: PublicKey) { let dhOutput = X25519(myPrivateKey, theirPublicKey) (rootKey, chainKey) = HKDF(rootKey, dhOutput) // новые root + chain myPrivateKey = X25519PrivateKey.generate() // эфемерный ключ сброшен }
// AAD = version + hasRatchetKey + messageNumber + ratchetKey // Все framing-поля криптографически привязаны к ciphertext // Любая подмена заголовка → AES-GCM authentication failure private func buildFramingAAD(ratchetKey: Data?, messageNumber: UInt32) -> Data { var aad = Data() aad.append(0x02) // protocol version aad.append(ratchetKey != nil ? 0x01 : 0x00) aad.append(messageNumber.bigEndian) // sequence number if let key = ratchetKey { aad.append(key) } // DH ratchet key return aad } // Шифрование с привязкой AAD — без AAD расшифровка невозможна let sealedBox = try AES.GCM.seal(data, using: messageKey, nonce: nonce, authenticating: aad)
// Каждый файл — уникальный случайный ключ let mediaKey = SymmetricKey(size: .bits256) let sealedBox = try AES.GCM.seal(fileData, using: mediaKey) // SHA256 digest зашифрованного blob — отправляется в X-Digest при upload let digest = SHA256.hash(data: sealedBox.combined!) let digestString = Data(digest).base64EncodedString() // request.setValue(digestString, forHTTPHeaderField: "X-Digest") // При загрузке — проверка целостности ДО расшифровки if let expected = expectedDigest { let actual = Data(SHA256.hash(data: downloadedData)).base64EncodedString() guard actual == expected else { throw BlobError.uploadFailed("Digest mismatch") // ← подмена на сервере обнаружена } } // Ключ + digest передаются ТОЛЬКО внутри E2E-зашифрованного сообщения // Сервер видит: зашифрованный blob + digest — содержимое недоступно
// Персональный ключ аватара — 32 случайных байта, уникальный для каждого пользователя // Хранится в Keychain, создаётся один раз при первой загрузке аватара func getOrCreateAvatarKey() -> Data { if let existing = loadData(forKey: "avatar_encryption_key") { return existing } var newKey = Data(count: 32) SecRandomCopyBytes(kSecRandomDefault, 32, &newKey) // CSPRNG saveData(newKey, forKey: "avatar_encryption_key") return newKey } // Шифрование перед отправкой на сервер — случайный IV на каждую загрузку func encryptAvatar(_ imageData: Data) throws -> (encryptedData: Data, iv: Data, key: Data) { let key = getOrCreateAvatarKey() // 12-байтный IV (96 бит) из CSPRNG — новый на каждую загрузку var iv = Data(count: 12) let result = iv.withUnsafeMutableBytes { bytes in SecRandomCopyBytes(kSecRandomDefault, 12, bytes.baseAddress!) } guard result == errSecSuccess else { throw KeychainError.saveFailed(-1) } let sealed = try AES.GCM.seal(imageData, using: SymmetricKey(data: key), nonce: try AES.GCM.Nonce(data: iv)) // ⚠️ update_avatar отправляет на сервер ТРИ поля: avatarData, iv, avatarKey. // key уходит вместе с blob'ом — сквозного шифрования для аватаров нет. // Сервер раздаёт key только тем, кто добавил пользователя в контакты. return (sealed.combined!, iv, key) }
// PIN нигде не хранится — только соль и зашифрованный ключ БД func verifyPIN(_ pin: String) -> Bool { let derived = PBKDF2( password: Data(pin.utf8), salt: storedSalt, iterations: 200_000, // HMAC-SHA256 keyLength: 32 ) // Проверка = попытка расшифровки. Неверный PIN → ошибка AES-GCM guard let dbKey = try? AES.GCM.open(wrappedKey, using: SymmetricKey(data: derived)) else { return false } unwrappedKey = dbKey // ключ только в памяти return true } // При уходе в фон — ключ стирается из памяти func lockAndPurgeKey() { unwrappedKey = nil // ключ исчезает — данные физически недоступны database = nil }
privateKeyUsage — iOS требует подтверждение через Face ID, Touch ID или пасскод устройства. Форензик-инструмент не может запросить ключ без живого пользователя: биометрия проверяется внутри аппаратного чипа и не эмулируется программно.Private key operations in the Secure Enclave (identity decryption) are protected by the privateKeyUsage flag — iOS requires confirmation via Face ID, Touch ID, or device passcode. A forensic tool cannot request the key without a live user: biometric verification happens inside the hardware chip and cannot be emulated in software.// Ключ создаётся с флагом privateKeyUsage // Любая операция с приватным ключом требует Face ID / Touch ID / пасскод let access = SecAccessControlCreateWithFlags( nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .privateKeyUsage, // ← требует присутствие пользователя nil ) let seKey = try SecureEnclave.P256.KeyAgreement.PrivateKey( accessControl: access! ) // Приватный ключ физически не покидает Secure Enclave // dataRepresentation содержит только публичный ключ + токен-ссылку
// SQLite + WAL + SHM — все три файла исключены из iCloud for suffix in ["", "-wal", "-shm"] { var url = URL(fileURLWithPath: dbPath + suffix) var rv = URLResourceValues() rv.isExcludedFromBackup = true // зашифрованные данные не попадают в iCloud try? url.setResourceValues(rv) } // Аналогично: MediaCache, Avatars, Sessions/ // Шифрованный backup бесполезен без ключа, // но мы не передаём его в Apple вообще
// Каждое TLS-соединение проверяет SPKI hash сертификата сервера func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { guard let serverTrust = challenge.protectionSpace.serverTrust, let cert = (SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate])?.first else { completionHandler(.cancelAuthenticationChallenge, nil); return } let serverHash = getSPKIHash(from: cert) // SHA256(SPKI) if pinnedHashes.contains(serverHash) { completionHandler(.useCredential, URLCredential(trust: serverTrust)) } else { // Сертификат не совпадает — отклоняем, даже если он подписан доверенным CA completionHandler(.cancelAuthenticationChallenge, nil) NotificationCenter.post("SecurityAlertMITM") } }
// Токены — только в HTTP-заголовках, не в URL // URL-логи сервера (nginx, etc.) не содержат идентификаторов var request = URLRequest(url: serverURL) request.setValue(sessionId, forHTTPHeaderField: "X-Session-ID") request.setValue(userId, forHTTPHeaderField: "X-User-ID") // Не так: wss://server.com/ws?userId=XXX&sessionId=YYY ← утекает в логи // А так: wss://server.com/ws + headers ← URL чистый
// Уровень 3: сервер проверяет messageId через Redis // Повторная отправка одного messageId отклоняется const key = `msg_seen:${messageId}`; const seen = await redis.set(key, 1, 'EX', 86400, 'NX'); if (!seen) { // NX = set only if Not eXists → уже было → replay return ws.send(JSON.stringify({ type: 'error', reason: 'duplicate' })); }
// Перед первым сообщением — X3DH инициализация сессии // Шаг 1: верифицируем Ed25519-подпись signedPreKey контакта // Без верной подписи — сессия не создаётся, атака man-in-the-middle невозможна let theirSigning = try Curve25519.Signing.PublicKey(rawRepresentation: theirIdentityKey) guard theirSigning.isValidSignature(preKeySignature, for: preKeyPublic) else { throw SignalCryptoError.invalidSignature // ← сессия не создаётся } // Шаг 2: X3DH — три Diffie-Hellman обмена (+ опциональный 4-й с one-time prekey) let ephemeral = Curve25519.KeyAgreement.PrivateKey() // одноразовый эфемерный ключ let dh1 = try myIdentityKey.sharedSecretFromKeyAgreement(with: theirSignedPreKey) let dh2 = try ephemeral.sharedSecretFromKeyAgreement(with: theirIdentityKey) let dh3 = try ephemeral.sharedSecretFromKeyAgreement(with: theirSignedPreKey) // dh4 — если есть one-time prekey (повышает защиту от компрометации prekey) // Шаг 3: HKDF-SHA256 из объединения DH-секретов → rootKey + chainKey сессии let masterSecret = dh1 + dh2 + dh3 // + dh4 если есть let sessionKeys = HKDF<SHA256>.deriveKey( inputKeyMaterial: SymmetricKey(data: masterSecret), salt: Data("SignalX3DH".utf8), info: Data("SignalRootAndChainKey".utf8), outputByteCount: 64 // 32 → rootKey, 32 → первый chainKey ) // Эфемерный ключ сброшен после X3DH — компрометация identity ключа // не раскрывает прошлые сообщения (Perfect Forward Secrecy)
// Сервер хранит только публичный ключ пользователя const verified = crypto.verify( 'ed25519', challengeBuffer, { key: userPublicKey, format: 'raw', type: 'spki' }, signatureBuffer ); if (!verified) throw Error('Auth failed');
// Credentials привязаны к userId и истекают через 1 час // Перехваченные credentials нельзя использовать после TTL const expiresAt = Math.floor(Date.now() / 1000) + 3600; const turnUser = `${expiresAt}:${ws.userId}`; // timestamp:userId // HMAC-SHA1 подпись — секрет только на сервере const hmac = crypto.createHmac('sha1', TURN_SECRET); hmac.update(turnUser); const credential = hmac.digest('base64'); // TURN-сервер сам верифицирует подпись — сервер приложения не хранит сессию // После expiresAt credentials отклоняются TURN-сервером автоматически
// Каждое сообщение шифруется перед записью в SQLite // В БД хранится только encrypted_data — содержимое нечитаемо без ключа let messageData = try JSONEncoder().encode(message) let sealedBox = try AES.GCM.seal(messageData, using: encryptionKey) // SQL: содержимое — только зашифрованный blob // sender_id / receiver_id открыты только для индексирования let sql = """ INSERT OR REPLACE INTO messages (id, encrypted_data, sender_id, receiver_id, conversation_key, ...) VALUES (?, ?, ?, ?, ?, ...) """ // sqlite3_bind_blob → encrypted_data = sealedBox.combined // Без ключа шифрования содержимое любой строки нечитаемо // guard encryptionKey != nil else { throw error } — запись блокируется
// Единый формат "HCE1" для всех типов файлов на диске // Magic-байты позволяют отличить зашифрованный файл от нешифрованного private static let magic: [UInt8] = [0x48, 0x43, 0x45, 0x31] // "HCE1" // Запись: любой тип файла → зашифрованный HCE1-blob func encryptForDisk(_ data: Data) -> Data? { let sealed = try AES.GCM.seal(data, using: SymmetricKey(data: currentKey)) return Data(magic) + sealed.combined! // magic + nonce(12) + ct + tag(16) } // ↑ Используется для: фото, видео, аудио, документов, аватаров — всех одинаково // writeEncrypted(data, to: url) → saveMedia / saveAvatar / writeEncrypted // Чтение: расшифровываем только если есть ключ (PIN введён) func decryptFromDisk(_ data: Data) -> Data? { guard data.prefix(4).elementsEqual(magic) else { return data } // старый формат guard let key = getCurrentEncryptionKey() else { // ← PIN не введён → файл недоступен, ключа нет в памяти return nil } let box = try AES.GCM.SealedBox(combined: data.dropFirst(4)) return try AES.GCM.open(box, using: SymmetricKey(data: key)) } // Воспроизведение (AVPlayer): временная расшифрованная копия // Удаляется через 15 мин или при блокировке приложения func preparePlaybackCopy(of url: URL) -> URL? { guard let plain = decryptFromDisk(try! Data(contentsOf: url)) else { return nil } let tmp = FileManager.default.temporaryDirectory .appendingPathComponent("PlaybackCache/\(url.lastPathComponent)") try? plain.write(to: tmp, options: .atomic) // purgeAllPlaybackTempFiles() вызывается при lockAndPurgeKey() return tmp }
// Сервер не знает содержимого сообщения — шлёт только заглушку // Apple Push Notification Service не получает данные переписки const pushPayload = { aps: { alert: { title: senderName, // только имя отправителя body: '🔒 Зашифрованное сообщение' // всегда одинаковый текст }, badge: unreadCount, sound: 'message_incoming.caf' }, // messageId для дедупликации — без содержимого messageId: msgId };
// dlog существует только в Debug-сборках // В Release компилятор полностью удаляет весь код внутри #if DEBUG // → в production бинарнике нет ни одного вызова print/NSLog #if DEBUG func dlog(_ message: String) { print("📱 \(message)") } #else @inline(__always) func dlog(_ message: String) {} // no-op #endif
// ws.userId установлен при аутентификации (Ed25519 challenge-response) // senderId из сообщения ДОЛЖЕН совпадать с аутентифицированным userId if (!senderId || senderId !== ws.userId) { console.error('❌ senderId spoofing blocked'); return; // сообщение отброшено — нельзя отправить от чужого имени } // Пользователь может читать только те диалоги, в которых участвует сам // Доступ к чужим сообщениям физически невозможен — ключи только на устройствах
// Ограничение запросов на upload/download — защита от злоупотреблений const uploadLimiter = rateLimit({ windowMs: 60 * 1000, // 1 минута max: 60, // 60 запросов standardHeaders: true, handler: (req, res) => { res.status(429).json({ error: 'Too many requests' }); } }); app.use('/upload', uploadLimiter); app.use('/download', uploadLimiter);
// Middleware: upload только от зарегистрированного пользователя // с активной WS-сессией — анонимная загрузка невозможна function requireActiveUser(req, res, next) { const userId = req.headers['x-user-id']; if (!userId || !users.has(userId)) { return res.status(401).json({ error: 'Unauthorized' }); } next(); } // blobId = 64 hex-символа (32 случайных байта через CSPRNG) // Перебор невозможен: 2^256 вариантов const blobId = crypto.randomBytes(32).toString('hex'); // Каждый запрос к /api/media/download/:blobId валидируется function isValidBlobId(id) { return /^[a-f0-9]{64}$/.test(id); // reject всё кроме 64 hex } // Сервер хранит только зашифрованный blob // Ключ расшифровки передаётся только внутри E2E-сообщения
// Ключ уникален для каждого звонка: X25519 DH + HKDF + случайный callToken // Оба участника независимо вычисляют один и тот же ключ (нет передачи ключа) let sharedSecret = try myIdentityPrivKey.sharedSecretFromKeyAgreement(with: theirIdentityPubKey) let callKey = sharedSecret.hkdfDerivedSymmetricKey( using: SHA256.self, salt: callToken.data(using: .utf8)!, // ← UUID уникален для каждого звонка sharedInfo: "HomeChat-CallSignal-v2".data(using: .utf8)!, outputByteCount: 32 ) // SDP offer/answer/ICE → JSON → AES-256-GCM → base64 payload let innerData = try JSONSerialization.data(withJSONObject: ["signalType": "offer", "sdp": sdp]) let combined = try AES.GCM.seal(innerData, using: callKey).combined! // Сервер получает только: recipientId + encrypted:true + payload (blob) // SDP и ICE-кандидаты недоступны серверу let envelope: [String: Any] = [ "type": "webrtc_signal", "recipientId": contactId, "encrypted": true, "payload": combined.base64EncodedString(), "callToken": callToken ] // Звонок от незарегистрированного контакта → автоматическое отклонение // guard contacts.contains(where: { $0.id == callerId }) else { sendCallEnd("not_in_contacts") }
// ECDSA-сертификат создаётся один раз и хранится в Keychain // Стабильный fingerprint → TOFU работает корректно private func persistentDTLSCertificate() -> RTCCertificate? { if let cached = cachedLocalCertificate { return cached } // Восстанавливаем из Keychain если уже создавали if let pk = keychainReadString(account: "private_key"), let cert = keychainReadString(account: "certificate") { cachedLocalCertificate = RTCCertificate(privateKey: pk, certificate: cert) return cachedLocalCertificate } // Первый запуск — генерируем ECDSA и сохраняем guard let generated = RTCCertificate.generate(withParams: ["name": "ECDSA"]) else { return nil } keychainWriteString(generated.private_key, account: "private_key") keychainWriteString(generated.certificate, account: "certificate") cachedLocalCertificate = generated return generated // config.certificate = persistentDTLSCertificate() → передаётся в RTCPeerConnection }
// Первый звонок: сохраняем fingerprint из SDP (TOFU) // Последующие: проверяем — изменился → возможная MITM-атака private func verifyOrStoreDTLSFingerprint(from sdp: String, contactId: String) -> Bool { guard let fingerprint = extractDTLSFingerprint(from: sdp) else { return true } let key = "dtls_fp_\(contactId)" if let stored = UserDefaults.standard.string(forKey: key) { guard stored == fingerprint else { // fingerprint изменился → звонок завершается, уведомление пользователю NotificationCenter.default.post(name: "DTLSFingerprintChanged", userInfo: ["contactId": contactId, "newFingerprint": fingerprint]) return false } return true // ← совпадает } // Первый звонок — доверяем и запоминаем UserDefaults.standard.set(fingerprint, forKey: key) return true } // SDP проверяется и в offer и в answer: // if !verifyOrStoreDTLSFingerprint(from: offer.sdp, contactId:) → hangup
// Safety Numbers = хэш двух публичных ключей (отсортированных) // Результат одинаковый у обоих участников → можно сверить вслух func generateSafetyNumbers( myKey: PublicKey, theirKey: PublicKey ) -> String { let keys = [myKey.rawBytes, theirKey.rawBytes].sorted() let combined = keys[0] + keys[1] let hash = SHA256.hash(data: combined) // 60 цифр = 5 групп по 12, удобно читать вслух return formatAsGroups(Data(hash), groupSize: 12, groups: 5) }
// Тревожный код — тихое удаление данных без следов принуждения // Аккаунт сохраняется, приложение выглядит пустым и нормальным func executePanicWipe() { // 1. Сначала уведомляем сервер — пока соединение ещё активно viewModel.notifyServerContactsCleared() // 2. Удаляем все сообщения из БД и файлы БД с диска EncryptedMessageStore.shared.clearAllMessages() deleteDatabaseFiles() // 3. Удаляем Signal-сессии (ключи переписки) SignalCryptoManager.shared.clearAllSessions() // 4. Удаляем медиа, аватары, данные из App Group deleteMediaAndAvatars() // 5. Приложение разблокируется — выглядит как новое isLocked = false }