initial commit
This commit is contained in:
196
ios_client/EncryptedChat/Crypto/CryptoUtils.swift
Normal file
196
ios_client/EncryptedChat/Crypto/CryptoUtils.swift
Normal file
@@ -0,0 +1,196 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Core cryptographic utilities: AES-GCM, HKDF, KDF helpers
|
||||
enum CryptoUtils {
|
||||
|
||||
// MARK: - AES-256-GCM
|
||||
|
||||
/// Encrypt with AES-256-GCM. Returns (key, nonce, ciphertext, tag) — all as Data.
|
||||
/// If key is nil, generates a random 256-bit key.
|
||||
/// Matches Python: aes_encrypt(plaintext, key=None)
|
||||
static func aesEncrypt(_ plaintext: Data, key: Data? = nil) throws -> (key: Data, nonce: Data, ciphertext: Data, tag: Data) {
|
||||
let keyData = key ?? Data.randomBytes(32)
|
||||
let symmetricKey = SymmetricKey(data: keyData)
|
||||
let nonceData = Data.randomBytes(12)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonceData)
|
||||
|
||||
let sealedBox = try AES.GCM.seal(plaintext, using: symmetricKey, nonce: gcmNonce)
|
||||
|
||||
return (
|
||||
key: keyData,
|
||||
nonce: nonceData,
|
||||
ciphertext: Data(sealedBox.ciphertext),
|
||||
tag: Data(sealedBox.tag)
|
||||
)
|
||||
}
|
||||
|
||||
/// Decrypt with AES-256-GCM.
|
||||
/// Matches Python: aes_decrypt(key, nonce, ciphertext, tag)
|
||||
static func aesDecrypt(key: Data, nonce: Data, ciphertext: Data, tag: Data) throws -> Data {
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
let sealedBox = try AES.GCM.SealedBox(
|
||||
nonce: gcmNonce,
|
||||
ciphertext: ciphertext,
|
||||
tag: tag
|
||||
)
|
||||
|
||||
do {
|
||||
return try AES.GCM.open(sealedBox, using: symmetricKey)
|
||||
} catch {
|
||||
throw CryptoError.decryptionFailed("AES-GCM decryption failed")
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt with AES-256-GCM using AAD. Returns ciphertext with tag appended.
|
||||
/// Used by Double Ratchet and Sender Keys.
|
||||
static func aesGcmEncrypt(_ plaintext: Data, key: Data, nonce: Data, aad: Data) throws -> Data {
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
let sealedBox = try AES.GCM.seal(
|
||||
plaintext,
|
||||
using: symmetricKey,
|
||||
nonce: gcmNonce,
|
||||
authenticating: aad
|
||||
)
|
||||
|
||||
// Return ciphertext + tag concatenated (matches Python AESGCM.encrypt)
|
||||
return Data(sealedBox.ciphertext) + Data(sealedBox.tag)
|
||||
}
|
||||
|
||||
/// Decrypt AES-256-GCM with AAD. Input ciphertext has tag appended (last 16 bytes).
|
||||
static func aesGcmDecrypt(_ ctWithTag: Data, key: Data, nonce: Data, aad: Data) throws -> Data {
|
||||
guard ctWithTag.count >= 16 else {
|
||||
throw CryptoError.decryptionFailed("Ciphertext too short")
|
||||
}
|
||||
|
||||
let ct = ctWithTag.prefix(ctWithTag.count - 16)
|
||||
let tag = ctWithTag.suffix(16)
|
||||
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
let sealedBox = try AES.GCM.SealedBox(
|
||||
nonce: gcmNonce,
|
||||
ciphertext: ct,
|
||||
tag: tag
|
||||
)
|
||||
|
||||
do {
|
||||
return try AES.GCM.open(sealedBox, using: symmetricKey, authenticating: aad)
|
||||
} catch {
|
||||
throw CryptoError.decryptionFailed("AES-GCM decryption with AAD failed")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HKDF
|
||||
|
||||
/// HKDF-SHA256 key derivation.
|
||||
/// Matches Python: hkdf_derive(input_key, salt, info, length=32)
|
||||
static func hkdfDerive(inputKey: Data, salt: Data, info: Data, length: Int = 32) -> Data {
|
||||
let symmetricKey = SymmetricKey(data: inputKey)
|
||||
let derived = HKDF<SHA256>.deriveKey(
|
||||
inputKeyMaterial: symmetricKey,
|
||||
salt: salt,
|
||||
info: info,
|
||||
outputByteCount: length
|
||||
)
|
||||
return derived.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
// MARK: - KDF for Double Ratchet
|
||||
|
||||
/// Root key KDF. Returns (newRootKey, chainKey).
|
||||
/// HKDF with rootKey as salt and DH output as input. Derives 64 bytes, split in half.
|
||||
/// Matches Python: kdf_rk(root_key, dh_output)
|
||||
static func kdfRK(rootKey: Data, dhOutput: Data) -> (newRootKey: Data, chainKey: Data) {
|
||||
let derived = hkdfDerive(
|
||||
inputKey: dhOutput,
|
||||
salt: rootKey,
|
||||
info: Data(Constants.rootKeyInfo.utf8),
|
||||
length: 64
|
||||
)
|
||||
return (derived.prefix(32), Data(derived.suffix(32)))
|
||||
}
|
||||
|
||||
/// Chain key KDF. Returns (newChainKey, messageKey).
|
||||
/// HMAC-SHA256: messageKey = HMAC(chainKey, 0x01), newChainKey = HMAC(chainKey, 0x02)
|
||||
/// Matches Python: kdf_ck(chain_key)
|
||||
static func kdfCK(chainKey: Data) -> (newChainKey: Data, messageKey: Data) {
|
||||
let symmetricKey = SymmetricKey(data: chainKey)
|
||||
let messageKey = Data(HMAC<SHA256>.authenticationCode(for: Data([0x01]), using: symmetricKey))
|
||||
let newChainKey = Data(HMAC<SHA256>.authenticationCode(for: Data([0x02]), using: symmetricKey))
|
||||
return (newChainKey, messageKey)
|
||||
}
|
||||
|
||||
// MARK: - Self-Encryption Key
|
||||
|
||||
/// Derive static AES-256 key from identity key for self-encrypted message copies.
|
||||
/// Matches Python: derive_self_encryption_key(identity_private)
|
||||
static func deriveSelfEncryptionKey(identityPrivateRaw: Data) -> Data {
|
||||
hkdfDerive(
|
||||
inputKey: identityPrivateRaw,
|
||||
salt: Data(Constants.selfEncryptionSalt.utf8),
|
||||
info: Data(Constants.selfEncryptionInfo.utf8),
|
||||
length: 32
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Local Storage Key
|
||||
|
||||
/// Derive AES-256 key for encrypting local session/sender key files.
|
||||
/// Matches Python: derive_local_storage_key(identity_private)
|
||||
static func deriveLocalStorageKey(identityPrivateRaw: Data) -> Data {
|
||||
hkdfDerive(
|
||||
inputKey: identityPrivateRaw,
|
||||
salt: Data(Constants.localStorageSalt.utf8),
|
||||
info: Data(Constants.localStorageInfo.utf8),
|
||||
length: 32
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Local File Encryption
|
||||
|
||||
/// Encrypt data for local storage. Format: nonce(12) + tag(16) + ciphertext
|
||||
/// Matches Python: _encrypt_local(data, key)
|
||||
static func encryptLocal(_ data: Data, key: Data) throws -> Data {
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let sealedBox = try AES.GCM.seal(data, using: symmetricKey)
|
||||
|
||||
var result = Data()
|
||||
result.append(Data(sealedBox.nonce)) // 12 bytes
|
||||
result.append(Data(sealedBox.tag)) // 16 bytes
|
||||
result.append(Data(sealedBox.ciphertext)) // N bytes
|
||||
return result
|
||||
}
|
||||
|
||||
/// Decrypt locally stored data. Format: nonce(12) + tag(16) + ciphertext
|
||||
/// Matches Python: _decrypt_local(raw, key)
|
||||
static func decryptLocal(_ raw: Data, key: Data) throws -> Data {
|
||||
guard raw.count >= 28 else { // 12 + 16 minimum
|
||||
throw CryptoError.decryptionFailed("Local encrypted data too short")
|
||||
}
|
||||
|
||||
let nonce = raw[0..<12]
|
||||
let tag = raw[12..<28]
|
||||
let ct = raw[28...]
|
||||
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
let sealedBox = try AES.GCM.SealedBox(
|
||||
nonce: gcmNonce,
|
||||
ciphertext: ct,
|
||||
tag: tag
|
||||
)
|
||||
|
||||
do {
|
||||
return try AES.GCM.open(sealedBox, using: symmetricKey)
|
||||
} catch {
|
||||
throw CryptoError.decryptionFailed("Local storage decryption failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user