HomeChat

HomeChat

Аудит безопасности · июль 2026 Security Audit · July 2026

35
выполненоpassed
4
инфоinfo
0
проблемissues
Все пункты проверены по исходному коду приложения и сервера. All items verified against application and server source code.
Блок 1 — Криптография Block 1 — Cryptography
Стандартные алгоритмыStandard algorithms
X25519, Ed25519, AES-256-GCM, HKDF-SHA256 — через Apple CryptoKit. Собственная реализация Double Ratchet (вдохновлена Signal Protocol) без сторонних библиотек — только нативные Apple API.X25519, Ed25519, AES-256-GCM, HKDF-SHA256 — via Apple CryptoKit. Custom Double Ratchet implementation (inspired by Signal Protocol) with no third-party libraries — native Apple APIs only.
Swift · DH Ratchet + AES-256-GCM + AAD — Apple CryptoKit only
// Асимметричный 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
)
Сервер не видит сообщенияServer cannot read messages
Приватные ключи хранятся только на устройстве.Private keys are stored on the device only.
Node.js (сервер) · Message relay
// Сервер получает зашифрованный 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 не повторяютсяNonces never repeat
Детерминированы от уникального messageKey через SHA256.Derived deterministically from a unique messageKey via SHA256.
Swift · Deterministic nonce
// 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 никогда не используется дважды
}
Forward SecrecyForward Secrecy
Double Ratchet v2: chainKey + DHRatchet + rootKey rotation. Компрометация одного ключа не раскрывает прошлые сообщения.Double Ratchet v2: chainKey + DHRatchet + rootKey rotation. Compromise of one key does not expose past messages.
Swift · Double Ratchet key rotation
// При каждом сообщении — новый 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)Per-message authentication (AAD)
Метаданные (версия, номер, DH-ключ) криптографически привязаны к содержимому. Подмена в транзите обнаруживается немедленно.Metadata (version, sequence, DH key) is cryptographically bound to content. Any in-transit tampering is detected immediately.
Swift · Additional Authenticated Data (AAD)
// 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)
Безопасная ротация ключейSecure key rotation
Gap-защита maxAllowedGap=500 исключает десинхронизацию сессии при потере пакетов.Gap protection (maxAllowedGap=500) prevents session desynchronisation on packet loss.
Медиашифрование AES-256-GCMMedia encryption AES-256-GCM
Каждый файл шифруется отдельным случайным ключом (32 байта). Ключ передаётся только внутри E2E-зашифрованного сообщения. Integrity check: SHA256(blob) == digest.Each file is encrypted with a separate random key (32 bytes). The key is transmitted only inside the E2E-encrypted message. Integrity check: SHA256(blob) == digest.
Swift · Media encryption + SHA256 integrity (MediaBlobService)
// Каждый файл — уникальный случайный ключ
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 — содержимое недоступно
Шифрование аватаровAvatar encryption
Изображение профиля шифруется отдельным случайным ключом (AES-256-GCM), уникальным для каждого пользователя. Сервер хранит зашифрованный файл и ключ к нему, а раздаёт ключ только контактам, которые добавили пользователя. В отличие от переписки, аватары не защищены сквозным шифрованием: ключ проходит через сервер, поэтому сервер технически способен расшифровать изображение профиля. (Будет устранено в следующих релизах: ключ станет передаваться каждому контакту отдельно, зашифрованным в его криптографической сессии.)The profile picture is encrypted with a separate random key (AES-256-GCM), unique to each user. The server stores both the encrypted file and its key, and releases the key only to contacts who have added that user. Unlike messages, avatars are not end-to-end encrypted: the key passes through the server, so the server is technically able to decrypt a profile picture. (This will be addressed in a future release: the key will instead be delivered to each contact individually, encrypted within their cryptographic session.)
Swift · Avatar key + encryption
// Персональный ключ аватара — 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)
}
ℹ️
HMAC вместо HKDF для медиаключейHMAC instead of HKDF for media keys
Академическое замечание: HKDF нужен для нормализации неравномерной энтропии. Поскольку mediaKey — 32 байта криптографически случайных данных, HMAC как PRF эквивалентен HKDF в данном контексте. Реальной уязвимости нет.Academic note: HKDF is needed to normalise uneven entropy. Since mediaKey is 32 bytes of cryptographically random data, HMAC as a PRF is equivalent to HKDF in this context. No real vulnerability.
ИнформационноInformational
Блок 2 — Хранение ключей Block 2 — Key Storage
Ключи только в KeychainKeys stored in Keychain only
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly — ключи физически не покидают устройство.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly — keys physically cannot leave the device.
Ключ базы данных, медиа и сессий производится от PIN-кодаDatabase, media, and session key is PIN-derived
Если включена блокировка приложения, ключ шифрования локальной базы, медиафайлов, аватаров и Signal-сессий не хранится нигде — он производится из PIN (PBKDF2-HMAC-SHA256, 200 000 итераций) в момент разблокировки и стирается из памяти при уходе в фон. Сам PIN нигде не хранится, даже хэшем — проверка происходит через попытку расшифровки.When app lock is enabled, the encryption key for the local database, media files, avatars, and Signal sessions is not stored anywhere — it is derived from the PIN (PBKDF2-HMAC-SHA256, 200,000 iterations) at unlock time and wiped from memory when the app backgrounds. The PIN itself is never stored anywhere, not even hashed — verification happens by attempting decryption.
Swift · PIN key derivation
// 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
}
Ключ идентификации защищён Secure EnclaveIdentity key protected by Secure Enclave
Дополнительно к Keychain, ключ идентификации хранится и используется внутри аппаратного модуля Secure Enclave — изолированного чипа устройства. Приватный ключ физически не может быть извлечён даже при полном доступе к устройству.In addition to the Keychain, the identity key is stored and used inside the Secure Enclave — an isolated hardware chip on the device. The private key cannot be physically extracted even with full device access.
SE-ключ требует подтверждения присутствия пользователяSE key requires user presence confirmation
Операции с приватным ключом Secure Enclave (расшифровка identity) защищены флагом 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.
Swift · Secure Enclave key creation
// Ключ создаётся с флагом privateKeyUsage
// Любая операция с приватным ключом требует Face ID / Touch ID / пасскод
let access = SecAccessControlCreateWithFlags(
    nil,
    kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
    .privateKeyUsage,   // ← требует присутствие пользователя
    nil
)
let seKey = try SecureEnclave.P256.KeyAgreement.PrivateKey(
    accessControl: access!
)
// Приватный ключ физически не покидает Secure Enclave
// dataRepresentation содержит только публичный ключ + токен-ссылку
Ключи не в базе данных и не в логахKeys not in database or logs
Сессии зашифрованы AES-GCM. При включённой блокировке приложения ключ шифрования сессий производится от PIN-кода (см. выше), а не хранится независимо в Keychain.Sessions are encrypted with AES-GCM. When app lock is enabled, the session encryption key is derived from the PIN (see above) rather than stored independently in the Keychain.
Исключено из iCloud BackupExcluded from iCloud Backup
MediaCache, Avatars, SQLite+WAL+SHM, Sessions — isExcludedFromBackup.MediaCache, Avatars, SQLite+WAL+SHM, Sessions — isExcludedFromBackup.
Swift · Exclude from iCloud Backup
// 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 вообще
Безопасная переустановкаSafe reinstallation
Обнаружение orphaned Keychain → уведомление сервера → полная очистка данных.Orphaned Keychain detection → server notification → full data wipe.
ℹ️
Список контактов пока не зашифрован PIN-ключомContact list not yet PIN-encrypted
Список контактов и профиль пользователя хранятся на устройстве без привязки к PIN-производному ключу (в отличие от сообщений, медиа и Signal-сессий). Содержимое переписки это не затрагивает. Известное направление для дальнейшей работы.The contact list and user profile are stored on-device without being tied to the PIN-derived key (unlike messages, media, and Signal sessions). This does not affect conversation content. A known area for future work.
ИнформационноInformational
Блок 3 — Сеть Block 3 — Network
TLS 1.2+
Все соединения через TLS (iOS ATS по умолчанию). WebSocket через WSS.All connections via TLS (iOS ATS by default). WebSocket over WSS.
Certificate Pinning
SPKI hash pinning через URLSessionDelegate (SHA256, EC P-256). Поддельный сертификат от любого CA не принимается.SPKI hash pinning via URLSessionDelegate (SHA256, EC P-256). Fraudulent certificates from any CA are rejected.
Swift · SPKI hash pinning
// Каждое 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")
    }
}
MITM не позволяет читать сообщенияMITM cannot expose messages
Соединение разрывается при подозрении на MITM. Double Ratchet ciphertext без ключей бесполезен.Connection is terminated on MITM suspicion. Double Ratchet ciphertext is useless without keys.
Токены сессии в заголовкахSession tokens in headers
sessionId и userId перенесены из URL в X-Session-ID / X-User-ID заголовки. URL-логи сервера не содержат токенов.sessionId and userId moved from URL to X-Session-ID / X-User-ID headers. Server URL logs contain no tokens.
Swift · WebSocket headers
// Токены — только в 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 чистый
Блок 4 — Аутентификация Block 4 — Authentication
Защита от replay-атак (3 уровня)Replay attack protection (3 layers)
1) Double Ratchet: ключ одноразовый. 2) Клиент: дедупликация по messageId. 3) Сервер: Redis-проверка по messageId.1) Double Ratchet: one-time key. 2) Client: deduplication by messageId. 3) Server: Redis check by messageId.
Node.js (сервер) · Redis deduplication
// Уровень 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' }));
}
Захват аккаунта невозможенAccount takeover is impossible
Ed25519 challenge-response: сервер выдаёт случайный challenge → клиент подписывает приватным ключом → сервер верифицирует. Без приватного ключа аутентификация невозможна.Ed25519 challenge-response: server issues a random challenge → client signs with private key → server verifies. Authentication is impossible without the private key.
Swift (клиент) · X3DH session init + Ed25519 verification
// Перед первым сообщением — 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)
Node.js (сервер) · Верификация challenge
// Сервер хранит только публичный ключ пользователя
const verified = crypto.verify(
    'ed25519',
    challengeBuffer,
    { key: userPublicKey, format: 'raw', type: 'spki' },
    signatureBuffer
);
if (!verified) throw Error('Auth failed');
TURN credentials одноразовыеTURN credentials are one-time
HMAC-SHA1, TTL 1 час, два независимых сервера. Перехваченные credentials невозможно использовать повторно.HMAC-SHA1, 1-hour TTL, two independent servers. Intercepted credentials cannot be reused.
Node.js (сервер) · TURN credentials
// 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-сервером автоматически
ℹ️
ICE host-кандидатыICE host candidates
Локальный IP виден только доверенному собеседнику (не серверу). Сознательное решение — альтернатива .relay сломает P2P. Приемлемо для модели угроз HomeChat.Local IP is visible only to the trusted contact (not the server). Deliberate choice — .relay would break P2P. Acceptable for HomeChat's threat model.
ИнформационноInformational
Блок 5 — Локальное хранение Block 5 — Local Storage
База данных зашифрованаDatabase is encrypted
SQLite хранит только зашифрованные блобы (AES-GCM). При включённой блокировке приложения ключ производится от PIN-кода и не существует без него (см. Блок 2).SQLite stores only encrypted blobs (AES-GCM). When app lock is enabled, the key is derived from the PIN and does not exist without it (see Block 2).
Swift · Message encryption before SQLite insert
// Каждое сообщение шифруется перед записью в 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 } — запись блокируется
Медиа, аватары и документы зашифрованы на дискеMedia, avatars, and documents encrypted at rest
Фото, видео, голосовые сообщения, документы и аватары шифруются (AES-GCM) тем же PIN-производным ключом перед записью на диск. Для воспроизведения (AVPlayer и т.п.) создаётся временная расшифрованная копия, которая удаляется при блокировке приложения.Photos, videos, voice messages, documents, and avatars are encrypted (AES-GCM) with the same PIN-derived key before being written to disk. For playback (AVPlayer, etc.), a temporary decrypted copy is created and deleted when the app locks.
Swift · Unified disk encryption — фото, видео, аудио, документы, аватары
// Единый формат "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
}
Push не раскрывают текстPush notifications hide content
Body всегда «🔒 Зашифрованное сообщение» — без текста, без медиа, без данных переписки.Body is always "🔒 Encrypted message" — no text, no media, no conversation data.
Node.js (сервер) · Push payload
// Сервер не знает содержимого сообщения — шлёт только заглушку
// Apple Push Notification Service не получает данные переписки
const pushPayload = {
    aps: {
        alert: {
            title: senderName,        // только имя отправителя
            body: '🔒 Зашифрованное сообщение'  // всегда одинаковый текст
        },
        badge: unreadCount,
        sound: 'message_incoming.caf'
    },
    // messageId для дедупликации — без содержимого
    messageId: msgId
};
Логи без чувствительных данныхLogs contain no sensitive data
dlog обёрнут в #if DEBUG — в Release полностью вырезается компилятором.dlog is wrapped in #if DEBUG — completely removed by the compiler in Release builds.
Swift · Debug-only logging
// 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
Блок 6 — API и сервер Block 6 — API & Server
Нет IDORNo IDOR
Права проверяются на каждый запрос. Пользователь не может получить данные чужого диалога.Access rights are checked on every request. A user cannot access data from another's conversation.
Node.js (сервер) · Sender spoofing protection
// ws.userId установлен при аутентификации (Ed25519 challenge-response)
// senderId из сообщения ДОЛЖЕН совпадать с аутентифицированным userId
if (!senderId || senderId !== ws.userId) {
    console.error('❌ senderId spoofing blocked');
    return;  // сообщение отброшено — нельзя отправить от чужого имени
}
// Пользователь может читать только те диалоги, в которых участвует сам
// Доступ к чужим сообщениям физически невозможен — ключи только на устройствах
Rate limiting на HTTP APIHTTP API rate limiting
60 запросов/мин на upload и download. При превышении — 429.60 requests/min on upload and download endpoints. Returns 429 when exceeded.
Node.js (сервер) · Rate limiting
// Ограничение запросов на 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);
Аутентификация загрузки медиаMedia upload authentication
Upload принимается только от авторизованного пользователя с активным WebSocket сеансом. Download защищён 64-символьным SHA256 идентификатором + AES-GCM шифрованием контента.Upload is only accepted from an authorised user with an active WebSocket session. Download is protected by a 64-char SHA256 identifier and AES-GCM content encryption.
Node.js (сервер) · Upload auth + unguessable blobId
// 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-сообщения
Звонки — шифрование и защита Calls — encryption and protection
Аудио и видео зашифрованы DTLS-SRTPAudio and video encrypted with DTLS-SRTP
WebRTC использует DTLS 1.2 + SRTP для шифрования медиапотока напрямую между устройствами. Сервер не проходит через медиапоток и не может его прослушать — только сигнализация (offer/answer) проходит через сервер, и она дополнительно зашифрована Double Ratchet E2E.WebRTC uses DTLS 1.2 + SRTP to encrypt the media stream directly between devices. The server does not pass through the media stream and cannot listen to it — only signaling (offer/answer) passes through the server, and it is additionally encrypted with Double Ratchet E2E.
Сигнализация зашифрована — отдельный ключ на звонокSignaling encrypted — separate key per call
SDP offer/answer и ICE-кандидаты шифруются AES-256-GCM ключом, уникальным для каждого звонка. Ключ производится через X25519 DH из identity-ключей обоих участников + случайный callToken. Сервер видит только зашифрованный payload — не может подменить SDP. Звонки от незарегистрированных контактов отклоняются автоматически.SDP offer/answer and ICE candidates are encrypted with AES-256-GCM using a key unique to each call. The key is derived via X25519 DH from the identity keys of both parties + a random callToken. The server sees only the encrypted payload — it cannot substitute the SDP. Calls from unregistered contacts are automatically rejected.
Swift · Call signal key derivation + AES-GCM encryption
// Ключ уникален для каждого звонка: 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") }
Постоянный DTLS-сертификат в KeychainPersistent DTLS certificate in Keychain
ECDSA-сертификат генерируется один раз и сохраняется в Keychain. Fingerprint стабилен между звонками — без этого TOFU-проверка невозможна (каждый звонок давал бы новый fingerprint и ложный MITM-алерт).An ECDSA certificate is generated once and stored in the Keychain. The fingerprint is stable between calls — without this, TOFU verification is impossible (each call would produce a new fingerprint and a false MITM alert).
Swift · Persistent DTLS certificate (Keychain)
// 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
}
DTLS Fingerprint TOFU
При первом звонке с контактом приложение сохраняет DTLS fingerprint. При последующих звонках — сверяет. Несовпадение: звонок автоматически завершается с предупреждением.On the first call with a contact, the app saves the DTLS fingerprint. On subsequent calls, it is verified. Mismatch: the call is automatically terminated with a warning.
Defence in depthDefence in depth
Swift · DTLS TOFU verification
// Первый звонок: сохраняем 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
P2P звонок через STUN, TURN как fallbackP2P call via STUN, TURN as fallback
При возможности медиапоток идёт напрямую между устройствами (P2P через STUN). TURN-сервер используется только при NAT, недоступном для прямого соединения — и в этом случае ретранслирует только зашифрованный SRTP-поток, который не может расшифровать.When possible, the media stream goes directly between devices (P2P via STUN). The TURN server is used only when NAT blocks direct connection — and in that case it only relays the encrypted SRTP stream, which it cannot decrypt.
Блок 7 — Верификация Block 7 — Verification
Safety Numbers
60-цифровой код для верификации собеседника вне приложения. Совпадение кодов доказывает что ключи шифрования не подменялись.60-digit code to verify your contact out of band. Matching codes prove that encryption keys have not been substituted.
Swift · Safety Numbers generation
// 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)
}
Алерт при смене ключаKey change alert
При смене ключа безопасности собеседника приложение немедленно показывает предупреждение с инструкцией сверить Safety Numbers.When a contact's security key changes, the app immediately displays a warning with instructions to verify Safety Numbers.
Сервер не может подменить ключServer cannot substitute a key
IdentityKey собеседника берётся из локального хранилища. Любая подмена вызывает authenticationFailure при расшифровке.The contact's IdentityKey is taken from local storage. Any substitution triggers authenticationFailure during decryption.
Блок 8 — Приложение Block 8 — Application
Нет секретов в кодеNo secrets in code
Secrets.plist вне git. TURN credentials только в .env на сервере. SPKI hash — публичный хэш публичного ключа, не секрет.Secrets.plist is outside git. TURN credentials only in .env on the server. SPKI hash is a public hash of a public key — not a secret.
Блокировка приложения PIN-кодом и тревожный кодApp lock with PIN code and panic code
PIN-код не просто скрывает экран — он криптографически защищает данные (см. Блок 2 и Блок 5): без верного PIN ключ шифрования локальных данных не существует. Независим от пароля iOS. Тревожный код при вводе тихо и безвозвратно удаляет контакты, переписку и медиафайлы с устройства, сохраняя регистрацию аккаунта.The PIN doesn't just hide a screen — it cryptographically protects the data itself (see Block 2 and Block 5): without the correct PIN, the local encryption key does not exist. Independent of the iOS passcode. Entering the panic code silently and irreversibly wipes contacts, chat history, and media files from the device while keeping the account registered.
Swift · Panic wipe
// Тревожный код — тихое удаление данных без следов принуждения
// Аккаунт сохраняется, приложение выглядит пустым и нормальным
func executePanicWipe() {
    // 1. Сначала уведомляем сервер — пока соединение ещё активно
    viewModel.notifyServerContactsCleared()

    // 2. Удаляем все сообщения из БД и файлы БД с диска
    EncryptedMessageStore.shared.clearAllMessages()
    deleteDatabaseFiles()

    // 3. Удаляем Signal-сессии (ключи переписки)
    SignalCryptoManager.shared.clearAllSessions()

    // 4. Удаляем медиа, аватары, данные из App Group
    deleteMediaAndAvatars()

    // 5. Приложение разблокируется — выглядит как новое
    isLocked = false
}
Логи отключены в ReleaseLogs disabled in Release
Функция dlog обёрнута в #if DEBUG — компилятор полностью удаляет её в Production сборке.The dlog function is wrapped in #if DEBUG — the compiler fully removes it in Production builds.
ℹ️
ОбфускацияObfuscation
Стандартная Swift компиляция с оптимизацией -O. Дополнительная обфускация не применяется.Standard Swift compilation with -O optimisation. No additional obfuscation is applied.
ИнформационноInformational
Дополнительная защита Defence in Depth
🔒
UI-алерты безопасностиSecurity UI alerts
Три независимых предупреждения: смена ключа безопасности, изменение отпечатка шифрования звонка, устаревший клиент при загрузке медиа.Three independent warnings: security key change, call encryption fingerprint change, outdated client on media upload.
Defence in depthDefence in depth