ios_client
This commit is contained in:
162
ios_client 0.8.5/Kecalek/Crypto/ContactVerification.swift
Normal file
162
ios_client 0.8.5/Kecalek/Crypto/ContactVerification.swift
Normal file
@@ -0,0 +1,162 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Contact key verification: fingerprints, safety numbers, QR codes.
|
||||
/// Matches Python: crypto_utils.py compute_fingerprint, compute_safety_number, etc.
|
||||
enum ContactVerification {
|
||||
|
||||
/// Version byte for fingerprint computation (Signal's NumericFingerprint).
|
||||
private static let fingerprintVersion: UInt16 = 0
|
||||
|
||||
/// Number of SHA-512 iterations for fingerprint computation.
|
||||
private static let fingerprintIterations = 5200
|
||||
|
||||
// MARK: - Fingerprint
|
||||
|
||||
/// Compute a 32-byte fingerprint for a user's identity key.
|
||||
///
|
||||
/// Uses iterated SHA-512 (Signal's NumericFingerprint algorithm).
|
||||
/// Seed: version(2B big-endian) + identity_key(32B) + user_id(UTF-8).
|
||||
/// Each iteration: SHA-512(previous_hash + identity_key).
|
||||
/// Output: first 32 bytes of final hash.
|
||||
static func computeFingerprint(userId: String, identityKey: Data, iterations: Int = fingerprintIterations) -> Data {
|
||||
let versionBytes = fingerprintVersion.bigEndianData
|
||||
var data = versionBytes + identityKey + Data(userId.utf8)
|
||||
for _ in 0..<iterations {
|
||||
var hasher = SHA512()
|
||||
hasher.update(data: data)
|
||||
hasher.update(data: identityKey)
|
||||
let digest = hasher.finalize()
|
||||
data = Data(digest)
|
||||
}
|
||||
return Data(data.prefix(32))
|
||||
}
|
||||
|
||||
/// Format 32-byte fingerprint as 6 groups of 5 zero-padded digits (30 digits).
|
||||
///
|
||||
/// Each group: int(bytes[i*5:(i+1)*5], big-endian) % 100000.
|
||||
/// Output: two lines of 3 groups each, space-separated.
|
||||
static func formatFingerprint(_ fpBytes: Data) -> String {
|
||||
var groups: [String] = []
|
||||
for i in 0..<6 {
|
||||
let start = i * 5
|
||||
let end = min(start + 5, fpBytes.count)
|
||||
let slice = fpBytes[fpBytes.startIndex + start ..< fpBytes.startIndex + end]
|
||||
let num = bigEndianUInt64(slice) % 100000
|
||||
groups.append(String(format: "%05d", num))
|
||||
}
|
||||
return groups[0..<3].joined(separator: " ") + "\n" + groups[3..<6].joined(separator: " ")
|
||||
}
|
||||
|
||||
// MARK: - Safety Number
|
||||
|
||||
/// Compute a 60-digit safety number for a pair of users.
|
||||
///
|
||||
/// Both users see the same number regardless of who computes it.
|
||||
/// Lower user_id's fingerprint comes first (deterministic ordering).
|
||||
/// Output: 12 groups of 5 digits, formatted as 3 lines of 4 groups.
|
||||
static func computeSafetyNumber(
|
||||
myUserId: String, myIdentityKey: Data,
|
||||
theirUserId: String, theirIdentityKey: Data
|
||||
) -> String {
|
||||
let fpMine = computeFingerprint(userId: myUserId, identityKey: myIdentityKey)
|
||||
let fpTheirs = computeFingerprint(userId: theirUserId, identityKey: theirIdentityKey)
|
||||
|
||||
let combined: Data
|
||||
if myUserId < theirUserId {
|
||||
combined = fpMine + fpTheirs
|
||||
} else {
|
||||
combined = fpTheirs + fpMine
|
||||
}
|
||||
|
||||
// 64 bytes -> 12 groups of 5 digits
|
||||
var groups: [String] = []
|
||||
for i in 0..<12 {
|
||||
let start = i * 5
|
||||
let end = min(start + 5, combined.count)
|
||||
let slice = combined[combined.startIndex + start ..< combined.startIndex + end]
|
||||
let num = bigEndianUInt64(slice) % 100000
|
||||
groups.append(String(format: "%05d", num))
|
||||
}
|
||||
|
||||
return [
|
||||
groups[0..<4].joined(separator: " "),
|
||||
groups[4..<8].joined(separator: " "),
|
||||
groups[8..<12].joined(separator: " "),
|
||||
].joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - QR Code
|
||||
|
||||
/// Encode user identity for QR code verification.
|
||||
///
|
||||
/// Format: version(1B=0x01) + uid_len(1B) + uid(UTF-8) + identity_key(32B).
|
||||
static func encodeVerificationQR(userId: String, identityKey: Data) -> Data {
|
||||
let uidBytes = Data(userId.utf8)
|
||||
var data = Data([0x01, UInt8(uidBytes.count)])
|
||||
data.append(uidBytes)
|
||||
data.append(identityKey)
|
||||
return data
|
||||
}
|
||||
|
||||
/// Decode QR code verification payload.
|
||||
///
|
||||
/// Returns (userId, identityKey).
|
||||
/// Throws on invalid format.
|
||||
static func decodeVerificationQR(_ data: Data) throws -> (userId: String, identityKey: Data) {
|
||||
guard data.count >= 3 else {
|
||||
throw VerificationError.qrDataTooShort
|
||||
}
|
||||
guard data[data.startIndex] == 0x01 else {
|
||||
throw VerificationError.unknownQRVersion(data[data.startIndex])
|
||||
}
|
||||
let uidLen = Int(data[data.startIndex + 1])
|
||||
guard data.count >= 2 + uidLen + 32 else {
|
||||
throw VerificationError.qrDataTruncated
|
||||
}
|
||||
let uidData = data[data.startIndex + 2 ..< data.startIndex + 2 + uidLen]
|
||||
guard let userId = String(data: uidData, encoding: .utf8) else {
|
||||
throw VerificationError.invalidUTF8
|
||||
}
|
||||
let identityKey = Data(data[data.startIndex + 2 + uidLen ..< data.startIndex + 2 + uidLen + 32])
|
||||
return (userId, identityKey)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Convert up to 8 bytes to UInt64, big-endian.
|
||||
private static func bigEndianUInt64(_ data: Data) -> UInt64 {
|
||||
var result: UInt64 = 0
|
||||
for byte in data {
|
||||
result = result << 8 | UInt64(byte)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UInt16 Big-Endian
|
||||
|
||||
private extension UInt16 {
|
||||
var bigEndianData: Data {
|
||||
var value = self.bigEndian
|
||||
return Data(bytes: &value, count: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verification Errors
|
||||
|
||||
enum VerificationError: Error, LocalizedError {
|
||||
case qrDataTooShort
|
||||
case unknownQRVersion(UInt8)
|
||||
case qrDataTruncated
|
||||
case invalidUTF8
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .qrDataTooShort: return "QR data too short"
|
||||
case .unknownQRVersion(let v): return "Unknown QR version: \(v)"
|
||||
case .qrDataTruncated: return "QR data truncated"
|
||||
case .invalidUTF8: return "Invalid UTF-8 in QR data"
|
||||
}
|
||||
}
|
||||
}
|
||||
95
ios_client 0.8.5/Kecalek/Crypto/CryptoErrors.swift
Normal file
95
ios_client 0.8.5/Kecalek/Crypto/CryptoErrors.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
enum CryptoError: Error, LocalizedError {
|
||||
case invalidBase64
|
||||
case invalidHex
|
||||
case invalidKeyData(String)
|
||||
case invalidSignature
|
||||
case signatureVerificationFailed
|
||||
case encryptionFailed(String)
|
||||
case decryptionFailed(String)
|
||||
case invalidECP1Format
|
||||
case pbkdf2Failed
|
||||
case rsaKeyGenerationFailed
|
||||
case rsaOperationFailed(String)
|
||||
case x3dhFailed(String)
|
||||
case ratchetError(String)
|
||||
case senderKeyError(String)
|
||||
case maxSkipExceeded
|
||||
case duplicateMessage
|
||||
case invalidHeader(String)
|
||||
case stateImportFailed(String)
|
||||
case keyConversionFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidBase64: return "Invalid base64 encoding"
|
||||
case .invalidHex: return "Invalid hex encoding"
|
||||
case .invalidKeyData(let msg): return "Invalid key data: \(msg)"
|
||||
case .invalidSignature: return "Invalid signature format"
|
||||
case .signatureVerificationFailed: return "Signature verification failed"
|
||||
case .encryptionFailed(let msg): return "Encryption failed: \(msg)"
|
||||
case .decryptionFailed(let msg): return "Decryption failed: \(msg)"
|
||||
case .invalidECP1Format: return "Invalid ECP1 key format"
|
||||
case .pbkdf2Failed: return "PBKDF2 key derivation failed"
|
||||
case .rsaKeyGenerationFailed: return "RSA key generation failed"
|
||||
case .rsaOperationFailed(let msg): return "RSA operation failed: \(msg)"
|
||||
case .x3dhFailed(let msg): return "X3DH failed: \(msg)"
|
||||
case .ratchetError(let msg): return "Ratchet error: \(msg)"
|
||||
case .senderKeyError(let msg): return "Sender key error: \(msg)"
|
||||
case .maxSkipExceeded: return "Maximum message skip exceeded"
|
||||
case .duplicateMessage: return "Duplicate message detected"
|
||||
case .invalidHeader(let msg): return "Invalid header: \(msg)"
|
||||
case .stateImportFailed(let msg): return "State import failed: \(msg)"
|
||||
case .keyConversionFailed(let msg): return "Key conversion failed: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkError: Error, LocalizedError {
|
||||
case notConnected
|
||||
case connectionFailed(String)
|
||||
case timeout
|
||||
case serverError(String)
|
||||
case protocolError(String)
|
||||
case messageTooLarge
|
||||
case invalidResponse(String)
|
||||
case authenticationFailed(String)
|
||||
case alreadyConnected
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notConnected: return "Not connected to server"
|
||||
case .connectionFailed(let msg): return "Connection failed: \(msg)"
|
||||
case .timeout: return "Request timed out"
|
||||
case .serverError(let msg): return "Server error: \(msg)"
|
||||
case .protocolError(let msg): return "Protocol error: \(msg)"
|
||||
case .messageTooLarge: return "Message exceeds maximum size"
|
||||
case .invalidResponse(let msg): return "Invalid response: \(msg)"
|
||||
case .authenticationFailed(let msg): return "Authentication failed: \(msg)"
|
||||
case .alreadyConnected: return "Already connected"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatError: Error, LocalizedError {
|
||||
case notLoggedIn
|
||||
case conversationNotFound
|
||||
case membershipRequired
|
||||
case permissionDenied(String)
|
||||
case operationFailed(String)
|
||||
case fileError(String)
|
||||
case invalidData(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notLoggedIn: return "Not logged in"
|
||||
case .conversationNotFound: return "Conversation not found"
|
||||
case .membershipRequired: return "Must be a member of this conversation"
|
||||
case .permissionDenied(let msg): return "Permission denied: \(msg)"
|
||||
case .operationFailed(let msg): return "Operation failed: \(msg)"
|
||||
case .fileError(let msg): return "File error: \(msg)"
|
||||
case .invalidData(let msg): return "Invalid data: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
196
ios_client 0.8.5/Kecalek/Crypto/CryptoUtils.swift
Normal file
196
ios_client 0.8.5/Kecalek/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")
|
||||
}
|
||||
}
|
||||
}
|
||||
393
ios_client 0.8.5/Kecalek/Crypto/DoubleRatchet.swift
Normal file
393
ios_client 0.8.5/Kecalek/Crypto/DoubleRatchet.swift
Normal file
@@ -0,0 +1,393 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Ratchet header sent with each message
|
||||
struct RatchetHeader {
|
||||
let dhPub: Data // sender's current ratchet public key (32 bytes)
|
||||
let n: Int // message number in current sending chain
|
||||
let pn: Int // number of messages in previous sending chain
|
||||
|
||||
/// Serialize header to JSON bytes for use as AAD.
|
||||
/// Matches Python: RatchetHeader.serialize()
|
||||
/// IMPORTANT: Must match Python's json.dumps() format exactly (with spaces after : and ,)
|
||||
func serialize() -> Data {
|
||||
// Python json.dumps produces: {"dh_pub": "...", "n": 0, "pn": 0}
|
||||
// Note the spaces after colons and commas - this is critical for AAD matching
|
||||
let jsonString = "{\"dh_pub\": \"\(dhPub.hexString)\", \"n\": \(n), \"pn\": \(pn)}"
|
||||
return jsonString.data(using: .utf8)!
|
||||
}
|
||||
|
||||
/// Convert to dictionary for protocol.
|
||||
/// Matches Python: RatchetHeader.to_dict()
|
||||
func toDict() -> [String: Any] {
|
||||
[
|
||||
"dh_pub": dhPub.hexString,
|
||||
"n": n,
|
||||
"pn": pn,
|
||||
]
|
||||
}
|
||||
|
||||
/// Parse from dictionary.
|
||||
/// Matches Python: RatchetHeader.from_dict(d)
|
||||
static func fromDict(_ d: [String: Any]) throws -> RatchetHeader {
|
||||
guard let dhPubHex = d["dh_pub"] as? String,
|
||||
let dhPub = Data(hexString: dhPubHex),
|
||||
let n = d["n"] as? Int,
|
||||
let pn = d["pn"] as? Int else {
|
||||
throw CryptoError.invalidHeader("Missing or invalid header fields")
|
||||
}
|
||||
return RatchetHeader(dhPub: dhPub, n: n, pn: pn)
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal Double Ratchet implementation.
|
||||
/// Matches Python: DoubleRatchet class in crypto_utils.py
|
||||
class DoubleRatchet {
|
||||
|
||||
private(set) var dhPair: (privateKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
publicKey: Curve25519.KeyAgreement.PublicKey)?
|
||||
private(set) var dhRemote: Curve25519.KeyAgreement.PublicKey?
|
||||
private(set) var rootKey: Data = Data()
|
||||
private(set) var sendChainKey: Data?
|
||||
private(set) var recvChainKey: Data?
|
||||
private(set) var sendN: Int = 0
|
||||
private(set) var recvN: Int = 0
|
||||
private(set) var prevSendN: Int = 0
|
||||
// Skipped message keys: "dh_pub_hex:n" → message_key
|
||||
private(set) var skipped: [String: Data] = [:]
|
||||
|
||||
/// Attached X3DH header — set when creating a new session, consumed on first send.
|
||||
/// Matches Python: ratchet._x3dh_header
|
||||
var x3dhHeader: [String: Any]?
|
||||
|
||||
init() {}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Initialize as initiator (Alice) after X3DH.
|
||||
/// Matches Python: DoubleRatchet.init_alice(shared_secret, bob_spk_pub)
|
||||
static func initAlice(sharedSecret: Data, bobSpkPub: Curve25519.KeyAgreement.PublicKey) throws -> DoubleRatchet {
|
||||
let ratchet = DoubleRatchet()
|
||||
let (priv, pub) = X25519Crypto.generateKeypair()
|
||||
ratchet.dhPair = (priv, pub)
|
||||
ratchet.dhRemote = bobSpkPub
|
||||
|
||||
// Debug: print ratchet inputs (matching Python _dh_ratchet)
|
||||
#if DEBUG
|
||||
print("DEBUG initAlice: shared_secret (root_key) = \(sharedSecret.hexString)")
|
||||
print("DEBUG initAlice: my_dh_pub = \(X25519Crypto.serializePublic(pub).hexString)")
|
||||
print("DEBUG initAlice: remote_dh_pub (bob_spk) = \(X25519Crypto.serializePublic(bobSpkPub).hexString)")
|
||||
#endif
|
||||
|
||||
// Perform DH ratchet to derive send chain
|
||||
let dhOutput = try X25519Crypto.dh(priv, bobSpkPub)
|
||||
let (newRK, sendCK) = CryptoUtils.kdfRK(rootKey: sharedSecret, dhOutput: dhOutput)
|
||||
#if DEBUG
|
||||
print("DEBUG initAlice: dh_output = \(dhOutput.hexString)")
|
||||
print("DEBUG initAlice: new_root_key = \(newRK.hexString)")
|
||||
print("DEBUG initAlice: send_chain_key = \(sendCK.hexString)")
|
||||
#endif
|
||||
ratchet.rootKey = newRK
|
||||
ratchet.sendChainKey = sendCK
|
||||
ratchet.recvChainKey = nil
|
||||
ratchet.sendN = 0
|
||||
ratchet.recvN = 0
|
||||
ratchet.prevSendN = 0
|
||||
return ratchet
|
||||
}
|
||||
|
||||
/// Initialize as responder (Bob) after X3DH.
|
||||
/// Matches Python: DoubleRatchet.init_bob(shared_secret, spk_pair)
|
||||
static func initBob(
|
||||
sharedSecret: Data,
|
||||
spkPair: (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey)
|
||||
) -> DoubleRatchet {
|
||||
let ratchet = DoubleRatchet()
|
||||
ratchet.dhPair = spkPair
|
||||
ratchet.rootKey = sharedSecret
|
||||
ratchet.sendChainKey = nil
|
||||
ratchet.recvChainKey = nil
|
||||
ratchet.sendN = 0
|
||||
ratchet.recvN = 0
|
||||
ratchet.prevSendN = 0
|
||||
return ratchet
|
||||
}
|
||||
|
||||
// MARK: - Encrypt
|
||||
|
||||
/// Encrypt a message.
|
||||
/// Returns (header dict, ciphertext with tag, nonce).
|
||||
/// Matches Python: DoubleRatchet.encrypt(plaintext)
|
||||
func encrypt(_ plaintext: Data) throws -> (header: [String: Any], ciphertext: Data, nonce: Data) {
|
||||
guard sendChainKey != nil else {
|
||||
throw CryptoError.ratchetError("Send chain not initialized")
|
||||
}
|
||||
guard let dhPair = dhPair else {
|
||||
throw CryptoError.ratchetError("DH pair not set")
|
||||
}
|
||||
|
||||
let (newCK, messageKey) = CryptoUtils.kdfCK(chainKey: sendChainKey!)
|
||||
sendChainKey = newCK
|
||||
|
||||
let header = RatchetHeader(
|
||||
dhPub: X25519Crypto.serializePublic(dhPair.publicKey),
|
||||
n: sendN,
|
||||
pn: prevSendN
|
||||
)
|
||||
|
||||
let nonce = Data.randomBytes(12)
|
||||
let aad = header.serialize()
|
||||
|
||||
// Debug: print encrypt values (matching Python decrypt)
|
||||
#if DEBUG
|
||||
print("DEBUG encrypt: message_key = \(messageKey.hexString)")
|
||||
print("DEBUG encrypt: aad = \(aad.hexString)")
|
||||
print("DEBUG encrypt: aad_str = \(String(data: aad, encoding: .utf8) ?? "nil")")
|
||||
print("DEBUG encrypt: nonce = \(nonce.hexString)")
|
||||
#endif
|
||||
|
||||
let ctWithTag = try CryptoUtils.aesGcmEncrypt(plaintext, key: messageKey, nonce: nonce, aad: aad)
|
||||
#if DEBUG
|
||||
print("DEBUG encrypt: ciphertext_len = \(ctWithTag.count)")
|
||||
#endif
|
||||
|
||||
sendN += 1
|
||||
|
||||
return (header.toDict(), ctWithTag, nonce)
|
||||
}
|
||||
|
||||
// MARK: - Decrypt
|
||||
|
||||
/// Decrypt a message. Handles DH ratchet step if new dh_pub.
|
||||
/// State is snapshotted before modification and restored on failure (M9 fix).
|
||||
/// Matches Python: DoubleRatchet.decrypt(header_dict, ciphertext, nonce)
|
||||
func decrypt(headerDict: [String: Any], ciphertext: Data, nonce: Data) throws -> Data {
|
||||
let header = try RatchetHeader.fromDict(headerDict)
|
||||
let remoteDhPubBytes = header.dhPub
|
||||
|
||||
// Check if this is from a skipped message
|
||||
let skipKey = "\(remoteDhPubBytes.hexString):\(header.n)"
|
||||
if let mk = skipped[skipKey] {
|
||||
skipped.removeValue(forKey: skipKey)
|
||||
let aad = header.serialize()
|
||||
do {
|
||||
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
|
||||
} catch {
|
||||
// Restore skipped key on failure
|
||||
skipped[skipKey] = mk
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot state before modifications
|
||||
let snap = snapshot()
|
||||
|
||||
do {
|
||||
let remoteDhPub = try X25519Crypto.loadPublic(remoteDhPubBytes)
|
||||
let currentRemoteBytes: Data? = dhRemote.map { X25519Crypto.serializePublic($0) }
|
||||
|
||||
if currentRemoteBytes == nil || remoteDhPubBytes != currentRemoteBytes {
|
||||
// New DH ratchet step
|
||||
try skipMessages(until: header.pn)
|
||||
try dhRatchet(remoteDhPub: remoteDhPub)
|
||||
}
|
||||
|
||||
try skipMessages(until: header.n)
|
||||
|
||||
// Derive message key from receive chain
|
||||
guard recvChainKey != nil else {
|
||||
throw CryptoError.ratchetError("Receive chain key is nil")
|
||||
}
|
||||
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: recvChainKey!)
|
||||
recvChainKey = newCK
|
||||
recvN += 1
|
||||
|
||||
let aad = header.serialize()
|
||||
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
|
||||
} catch {
|
||||
restore(snap)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State Snapshot/Restore (M9)
|
||||
|
||||
private struct Snapshot {
|
||||
let dhPairPriv: Data?
|
||||
let dhPairPub: Data?
|
||||
let dhRemote: Data?
|
||||
let rootKey: Data
|
||||
let sendChainKey: Data?
|
||||
let recvChainKey: Data?
|
||||
let sendN: Int
|
||||
let recvN: Int
|
||||
let prevSendN: Int
|
||||
let skipped: [String: Data]
|
||||
}
|
||||
|
||||
private func snapshot() -> Snapshot {
|
||||
Snapshot(
|
||||
dhPairPriv: dhPair.map { X25519Crypto.serializePrivate($0.privateKey) },
|
||||
dhPairPub: dhPair.map { X25519Crypto.serializePublic($0.publicKey) },
|
||||
dhRemote: dhRemote.map { X25519Crypto.serializePublic($0) },
|
||||
rootKey: rootKey,
|
||||
sendChainKey: sendChainKey,
|
||||
recvChainKey: recvChainKey,
|
||||
sendN: sendN,
|
||||
recvN: recvN,
|
||||
prevSendN: prevSendN,
|
||||
skipped: skipped
|
||||
)
|
||||
}
|
||||
|
||||
private func restore(_ snap: Snapshot) {
|
||||
if let privData = snap.dhPairPriv, let pubData = snap.dhPairPub,
|
||||
let priv = try? X25519Crypto.loadPrivate(privData),
|
||||
let pub = try? X25519Crypto.loadPublic(pubData) {
|
||||
dhPair = (priv, pub)
|
||||
} else {
|
||||
dhPair = nil
|
||||
}
|
||||
if let remoteData = snap.dhRemote, let remote = try? X25519Crypto.loadPublic(remoteData) {
|
||||
dhRemote = remote
|
||||
} else {
|
||||
dhRemote = nil
|
||||
}
|
||||
rootKey = snap.rootKey
|
||||
sendChainKey = snap.sendChainKey
|
||||
recvChainKey = snap.recvChainKey
|
||||
sendN = snap.sendN
|
||||
recvN = snap.recvN
|
||||
prevSendN = snap.prevSendN
|
||||
skipped = snap.skipped
|
||||
}
|
||||
|
||||
// MARK: - Internal Ratchet Operations
|
||||
|
||||
private func skipMessages(until: Int) throws {
|
||||
guard recvChainKey != nil else { return }
|
||||
if until - recvN > Constants.maxSkip {
|
||||
throw CryptoError.maxSkipExceeded
|
||||
}
|
||||
while recvN < until {
|
||||
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: recvChainKey!)
|
||||
recvChainKey = newCK
|
||||
let remoteHex = dhRemote.map { X25519Crypto.serializePublic($0).hexString } ?? ""
|
||||
skipped["\(remoteHex):\(recvN)"] = mk
|
||||
recvN += 1
|
||||
}
|
||||
}
|
||||
|
||||
private func dhRatchet(remoteDhPub: Curve25519.KeyAgreement.PublicKey) throws {
|
||||
prevSendN = sendN
|
||||
sendN = 0
|
||||
recvN = 0
|
||||
dhRemote = remoteDhPub
|
||||
|
||||
// Derive new receive chain key
|
||||
guard let dhPair = dhPair else {
|
||||
throw CryptoError.ratchetError("DH pair not set")
|
||||
}
|
||||
let dhOutput1 = try X25519Crypto.dh(dhPair.privateKey, remoteDhPub)
|
||||
let (newRK1, recvCK) = CryptoUtils.kdfRK(rootKey: rootKey, dhOutput: dhOutput1)
|
||||
rootKey = newRK1
|
||||
recvChainKey = recvCK
|
||||
|
||||
// Generate new DH pair and derive new send chain key
|
||||
let (newPriv, newPub) = X25519Crypto.generateKeypair()
|
||||
self.dhPair = (newPriv, newPub)
|
||||
let dhOutput2 = try X25519Crypto.dh(newPriv, remoteDhPub)
|
||||
let (newRK2, sendCK) = CryptoUtils.kdfRK(rootKey: rootKey, dhOutput: dhOutput2)
|
||||
rootKey = newRK2
|
||||
sendChainKey = sendCK
|
||||
}
|
||||
|
||||
// MARK: - State Export/Import
|
||||
|
||||
/// Serialize full ratchet state for persistent storage.
|
||||
/// Produces JSON matching Python's DoubleRatchet.export_state() exactly.
|
||||
func exportState() throws -> Data {
|
||||
var state: [String: Any] = [:]
|
||||
|
||||
if let pair = dhPair {
|
||||
state["dh_priv"] = X25519Crypto.serializePrivate(pair.privateKey).hexString
|
||||
state["dh_pub"] = X25519Crypto.serializePublic(pair.publicKey).hexString
|
||||
} else {
|
||||
state["dh_priv"] = NSNull()
|
||||
state["dh_pub"] = NSNull()
|
||||
}
|
||||
|
||||
if let remote = dhRemote {
|
||||
state["dh_remote"] = X25519Crypto.serializePublic(remote).hexString
|
||||
} else {
|
||||
state["dh_remote"] = NSNull()
|
||||
}
|
||||
|
||||
state["root_key"] = rootKey.hexString
|
||||
state["send_ck"] = sendChainKey?.hexString ?? NSNull()
|
||||
state["recv_ck"] = recvChainKey?.hexString ?? NSNull()
|
||||
state["send_n"] = sendN
|
||||
state["recv_n"] = recvN
|
||||
state["prev_send_n"] = prevSendN
|
||||
|
||||
// Skipped keys: Python format is "dh_pub_hex:n" -> message_key_hex
|
||||
var skippedDict: [String: String] = [:]
|
||||
for (key, value) in skipped {
|
||||
skippedDict[key] = value.hexString
|
||||
}
|
||||
state["skipped"] = skippedDict
|
||||
|
||||
return try JSONSerialization.data(withJSONObject: state)
|
||||
}
|
||||
|
||||
/// Deserialize ratchet state.
|
||||
/// Matches Python: DoubleRatchet.import_state(data)
|
||||
static func importState(_ data: Data) throws -> DoubleRatchet {
|
||||
guard let state = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw CryptoError.stateImportFailed("Invalid JSON")
|
||||
}
|
||||
|
||||
let r = DoubleRatchet()
|
||||
|
||||
if let dhPrivHex = state["dh_priv"] as? String,
|
||||
let dhPubHex = state["dh_pub"] as? String,
|
||||
let privData = Data(hexString: dhPrivHex),
|
||||
let pubData = Data(hexString: dhPubHex) {
|
||||
let priv = try X25519Crypto.loadPrivate(privData)
|
||||
let pub = try X25519Crypto.loadPublic(pubData)
|
||||
r.dhPair = (priv, pub)
|
||||
}
|
||||
|
||||
if let dhRemoteHex = state["dh_remote"] as? String,
|
||||
let remoteData = Data(hexString: dhRemoteHex) {
|
||||
r.dhRemote = try X25519Crypto.loadPublic(remoteData)
|
||||
}
|
||||
|
||||
guard let rootKeyHex = state["root_key"] as? String,
|
||||
let rootKey = Data(hexString: rootKeyHex) else {
|
||||
throw CryptoError.stateImportFailed("Missing root_key")
|
||||
}
|
||||
r.rootKey = rootKey
|
||||
|
||||
if let sendCKHex = state["send_ck"] as? String, let ck = Data(hexString: sendCKHex) {
|
||||
r.sendChainKey = ck
|
||||
}
|
||||
if let recvCKHex = state["recv_ck"] as? String, let ck = Data(hexString: recvCKHex) {
|
||||
r.recvChainKey = ck
|
||||
}
|
||||
|
||||
r.sendN = state["send_n"] as? Int ?? 0
|
||||
r.recvN = state["recv_n"] as? Int ?? 0
|
||||
r.prevSendN = state["prev_send_n"] as? Int ?? 0
|
||||
|
||||
if let skippedDict = state["skipped"] as? [String: String] {
|
||||
for (key, valueHex) in skippedDict {
|
||||
if let value = Data(hexString: valueHex) {
|
||||
r.skipped[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
}
|
||||
73
ios_client 0.8.5/Kecalek/Crypto/Ed25519Crypto.swift
Normal file
73
ios_client 0.8.5/Kecalek/Crypto/Ed25519Crypto.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Ed25519 signing operations — Identity Key management
|
||||
enum Ed25519Crypto {
|
||||
|
||||
// MARK: - Key Generation
|
||||
|
||||
/// Generate Ed25519 keypair
|
||||
static func generateKeypair() -> (privateKey: Curve25519.Signing.PrivateKey, publicKey: Curve25519.Signing.PublicKey) {
|
||||
let privateKey = Curve25519.Signing.PrivateKey()
|
||||
return (privateKey, privateKey.publicKey)
|
||||
}
|
||||
|
||||
// MARK: - Serialization
|
||||
|
||||
/// Serialize Ed25519 private key. With password: raw 32B → ECP1. Without: raw 32B.
|
||||
/// Matches Python: serialize_ed25519_private(key, password=None)
|
||||
static func serializePrivate(_ key: Curve25519.Signing.PrivateKey, password: Data? = nil) throws -> Data {
|
||||
let raw = key.rawData // 32 bytes
|
||||
if let password = password {
|
||||
return try KeyEncryption.encrypt(raw, password: password)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/// Serialize Ed25519 public key to 32 raw bytes.
|
||||
/// Matches Python: serialize_ed25519_public(key)
|
||||
static func serializePublic(_ key: Curve25519.Signing.PublicKey) -> Data {
|
||||
key.rawData // 32 bytes
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
/// Load Ed25519 private key. Auto-detects ECP1 / raw 32B.
|
||||
/// Matches Python: load_ed25519_private(data, password=None)
|
||||
static func loadPrivate(_ data: Data, password: Data? = nil) throws -> Curve25519.Signing.PrivateKey {
|
||||
if KeyEncryption.isECP1Format(data) {
|
||||
guard let pwd = password else {
|
||||
throw CryptoError.invalidKeyData("ECP1 key requires password")
|
||||
}
|
||||
let raw = try KeyEncryption.decrypt(data, password: pwd)
|
||||
return try Curve25519.Signing.PrivateKey(rawRepresentation: raw)
|
||||
}
|
||||
if data.count == 32 {
|
||||
return try Curve25519.Signing.PrivateKey(rawRepresentation: data)
|
||||
}
|
||||
throw CryptoError.invalidKeyData("Cannot parse Ed25519 private key (\(data.count) bytes)")
|
||||
}
|
||||
|
||||
/// Load Ed25519 public key from 32 raw bytes.
|
||||
/// Matches Python: load_ed25519_public(data)
|
||||
static func loadPublic(_ data: Data) throws -> Curve25519.Signing.PublicKey {
|
||||
guard data.count == 32 else {
|
||||
throw CryptoError.invalidKeyData("Ed25519 public key must be 32 bytes, got \(data.count)")
|
||||
}
|
||||
return try Curve25519.Signing.PublicKey(rawRepresentation: data)
|
||||
}
|
||||
|
||||
// MARK: - Sign / Verify
|
||||
|
||||
/// Sign data with Ed25519. Returns 64-byte signature.
|
||||
/// Matches Python: ed25519_sign(private_key, data)
|
||||
static func sign(_ privateKey: Curve25519.Signing.PrivateKey, data: Data) throws -> Data {
|
||||
Data(try privateKey.signature(for: data))
|
||||
}
|
||||
|
||||
/// Verify Ed25519 signature.
|
||||
/// Matches Python: ed25519_verify(public_key, signature, data)
|
||||
static func verify(_ publicKey: Curve25519.Signing.PublicKey, signature: Data, data: Data) -> Bool {
|
||||
publicKey.isValidSignature(signature, for: data)
|
||||
}
|
||||
}
|
||||
231
ios_client 0.8.5/Kecalek/Crypto/FieldArithmetic.swift
Normal file
231
ios_client 0.8.5/Kecalek/Crypto/FieldArithmetic.swift
Normal file
@@ -0,0 +1,231 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure Swift GF(2^255-19) arithmetic for Ed25519 → X25519 public key conversion.
|
||||
///
|
||||
/// The conversion formula is: u = (1 + y) / (1 - y) mod p
|
||||
/// where p = 2^255 - 19, and y is the Ed25519 public key's y-coordinate.
|
||||
///
|
||||
/// Uses 4-limb UInt64 representation (little-endian).
|
||||
enum FieldArithmetic {
|
||||
|
||||
// p = 2^255 - 19
|
||||
static let p: [UInt64] = [
|
||||
0xFFFF_FFFF_FFFF_FFED, // limb 0 (least significant)
|
||||
0xFFFF_FFFF_FFFF_FFFF, // limb 1
|
||||
0xFFFF_FFFF_FFFF_FFFF, // limb 2
|
||||
0x7FFF_FFFF_FFFF_FFFF, // limb 3 (most significant, 2^63 - 1 accounting for -19)
|
||||
]
|
||||
|
||||
/// Load a 256-bit little-endian byte array into 4 UInt64 limbs
|
||||
static func load(_ bytes: Data) -> [UInt64] {
|
||||
precondition(bytes.count == 32)
|
||||
var limbs = [UInt64](repeating: 0, count: 4)
|
||||
for i in 0..<4 {
|
||||
var val: UInt64 = 0
|
||||
for j in 0..<8 {
|
||||
val |= UInt64(bytes[i * 8 + j]) << (j * 8)
|
||||
}
|
||||
limbs[i] = val
|
||||
}
|
||||
return limbs
|
||||
}
|
||||
|
||||
/// Store 4 UInt64 limbs as 32 little-endian bytes
|
||||
static func store(_ limbs: [UInt64]) -> Data {
|
||||
var bytes = Data(count: 32)
|
||||
for i in 0..<4 {
|
||||
for j in 0..<8 {
|
||||
bytes[i * 8 + j] = UInt8((limbs[i] >> (j * 8)) & 0xFF)
|
||||
}
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
/// a + b mod p
|
||||
static func add(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
|
||||
var result = [UInt64](repeating: 0, count: 4)
|
||||
var carry: UInt64 = 0
|
||||
for i in 0..<4 {
|
||||
let (sum1, c1) = a[i].addingReportingOverflow(b[i])
|
||||
let (sum2, c2) = sum1.addingReportingOverflow(carry)
|
||||
result[i] = sum2
|
||||
carry = (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||
}
|
||||
// Reduce mod p
|
||||
return reduceOnce(result, carry: carry)
|
||||
}
|
||||
|
||||
/// a - b mod p
|
||||
static func sub(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
|
||||
var result = [UInt64](repeating: 0, count: 4)
|
||||
var borrow: UInt64 = 0
|
||||
for i in 0..<4 {
|
||||
let (diff1, b1) = a[i].subtractingReportingOverflow(b[i])
|
||||
let (diff2, b2) = diff1.subtractingReportingOverflow(borrow)
|
||||
result[i] = diff2
|
||||
borrow = (b1 ? 1 : 0) + (b2 ? 1 : 0)
|
||||
}
|
||||
if borrow > 0 {
|
||||
// Add p back
|
||||
var c: UInt64 = 0
|
||||
for i in 0..<4 {
|
||||
let (s1, c1) = result[i].addingReportingOverflow(p[i])
|
||||
let (s2, c2) = s1.addingReportingOverflow(c)
|
||||
result[i] = s2
|
||||
c = (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Multiply two 256-bit numbers mod p using schoolbook multiplication
|
||||
static func mul(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
|
||||
// Full 512-bit product in 8 limbs
|
||||
var product = [UInt64](repeating: 0, count: 8)
|
||||
|
||||
for i in 0..<4 {
|
||||
var carry: UInt64 = 0
|
||||
for j in 0..<4 {
|
||||
let (hi, lo) = a[i].multipliedFullWidth(by: b[j])
|
||||
let (sum1, c1) = product[i + j].addingReportingOverflow(lo)
|
||||
let (sum2, c2) = sum1.addingReportingOverflow(carry)
|
||||
product[i + j] = sum2
|
||||
carry = hi + (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||
}
|
||||
product[i + 4] = carry
|
||||
}
|
||||
|
||||
// Reduce mod p using Barrett-like reduction
|
||||
// Since p = 2^255 - 19, for a 512-bit number we can use:
|
||||
// x mod p = (x_low + x_high * 2^256) mod p
|
||||
// Since 2^255 ≡ 19 (mod p), 2^256 ≡ 38 (mod p)
|
||||
return reduceFull(product)
|
||||
}
|
||||
|
||||
/// Reduce 512-bit product mod p using 2^256 ≡ 38 (mod p)
|
||||
private static func reduceFull(_ product: [UInt64]) -> [UInt64] {
|
||||
// Split: low = product[0..3], high = product[4..7]
|
||||
// result = low + high * 38
|
||||
var result = [UInt64](repeating: 0, count: 5)
|
||||
|
||||
// Start with low part
|
||||
for i in 0..<4 {
|
||||
result[i] = product[i]
|
||||
}
|
||||
|
||||
// Add high * 38
|
||||
var carry: UInt64 = 0
|
||||
for i in 0..<4 {
|
||||
let (hi, lo) = product[i + 4].multipliedFullWidth(by: 38)
|
||||
let (sum1, c1) = result[i].addingReportingOverflow(lo)
|
||||
let (sum2, c2) = sum1.addingReportingOverflow(carry)
|
||||
result[i] = sum2
|
||||
carry = hi + (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||
}
|
||||
result[4] = carry
|
||||
|
||||
// The result might still be >= p, so reduce once more
|
||||
// result[4] * 2^256 ≡ result[4] * 38 (mod p)
|
||||
let extra: UInt64 = result[4]
|
||||
result[4] = 0
|
||||
if extra > 0 {
|
||||
let (hi, lo) = extra.multipliedFullWidth(by: 38)
|
||||
let (sum1, c1) = result[0].addingReportingOverflow(lo)
|
||||
result[0] = sum1
|
||||
var c = hi + (c1 ? 1 : 0)
|
||||
for i in 1..<4 {
|
||||
let (s, cf) = result[i].addingReportingOverflow(c)
|
||||
result[i] = s
|
||||
c = cf ? 1 : 0
|
||||
}
|
||||
// One more round if carry
|
||||
if c > 0 {
|
||||
let (s, _) = result[0].addingReportingOverflow(c * 38)
|
||||
result[0] = s
|
||||
}
|
||||
}
|
||||
|
||||
var out = Array(result[0..<4])
|
||||
// Final reduction: if >= p, subtract p
|
||||
out = reduceOnce(out, carry: 0)
|
||||
return out
|
||||
}
|
||||
|
||||
/// If the number >= p, subtract p
|
||||
private static func reduceOnce(_ val: [UInt64], carry: UInt64) -> [UInt64] {
|
||||
if carry > 0 || isGreaterOrEqual(val, p) {
|
||||
var result = [UInt64](repeating: 0, count: 4)
|
||||
var borrow: UInt64 = 0
|
||||
for i in 0..<4 {
|
||||
let (diff1, b1) = val[i].subtractingReportingOverflow(p[i])
|
||||
let (diff2, b2) = diff1.subtractingReportingOverflow(borrow)
|
||||
result[i] = diff2
|
||||
borrow = (b1 ? 1 : 0) + (b2 ? 1 : 0)
|
||||
}
|
||||
// If borrow after subtracting p, the original was fine (shouldn't happen with carry)
|
||||
if borrow > 0 && carry == 0 {
|
||||
return val
|
||||
}
|
||||
return result
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
/// Compare a >= b
|
||||
private static func isGreaterOrEqual(_ a: [UInt64], _ b: [UInt64]) -> Bool {
|
||||
for i in stride(from: 3, through: 0, by: -1) {
|
||||
if a[i] > b[i] { return true }
|
||||
if a[i] < b[i] { return false }
|
||||
}
|
||||
return true // equal
|
||||
}
|
||||
|
||||
/// Modular inverse using Fermat's little theorem: a^(-1) = a^(p-2) mod p
|
||||
static func inverse(_ a: [UInt64]) -> [UInt64] {
|
||||
// p - 2 = 2^255 - 21
|
||||
let pMinus2 = sub(p, [2, 0, 0, 0])
|
||||
return power(a, pMinus2)
|
||||
}
|
||||
|
||||
/// Modular exponentiation using square-and-multiply
|
||||
static func power(_ base: [UInt64], _ exp: [UInt64]) -> [UInt64] {
|
||||
var result: [UInt64] = [1, 0, 0, 0] // 1
|
||||
var b = base
|
||||
|
||||
for i in 0..<4 {
|
||||
var limb = exp[i]
|
||||
let bits = (i == 3) ? 63 : 64 // top limb has 63 bits for p-2
|
||||
for _ in 0..<bits {
|
||||
if limb & 1 == 1 {
|
||||
result = mul(result, b)
|
||||
}
|
||||
b = mul(b, b)
|
||||
limb >>= 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Ed25519 → X25519 Public Key Conversion
|
||||
|
||||
/// Convert Ed25519 public key (32 bytes) to X25519 public key (32 bytes).
|
||||
/// Formula: u = (1 + y) * inverse(1 - y) mod p
|
||||
static func ed25519PublicToX25519(_ ed25519Pub: Data) -> Data {
|
||||
precondition(ed25519Pub.count == 32)
|
||||
|
||||
// Ed25519 public key is the y-coordinate with sign bit in the top bit of byte 31
|
||||
var keyBytes = ed25519Pub
|
||||
// Clear the sign bit
|
||||
keyBytes[31] &= 0x7F
|
||||
|
||||
let y = load(keyBytes)
|
||||
let one: [UInt64] = [1, 0, 0, 0]
|
||||
|
||||
let onePlusY = add(one, y)
|
||||
let oneMinusY = sub(one, y)
|
||||
let inv = inverse(oneMinusY)
|
||||
let u = mul(onePlusY, inv)
|
||||
|
||||
return store(u)
|
||||
}
|
||||
}
|
||||
106
ios_client 0.8.5/Kecalek/Crypto/KeyEncryption.swift
Normal file
106
ios_client 0.8.5/Kecalek/Crypto/KeyEncryption.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import CommonCrypto
|
||||
|
||||
/// ECP1 key encryption format: PBKDF2-HMAC-SHA256 (600k iterations) + AES-256-GCM
|
||||
/// Wire format: magic(4) + salt(16) + nonce(12) + ciphertext_with_tag(N+16)
|
||||
enum KeyEncryption {
|
||||
|
||||
/// Encrypt raw key bytes with password using ECP1 format
|
||||
static func encrypt(_ rawBytes: Data, password: Data) throws -> Data {
|
||||
let salt = Data.randomBytes(16)
|
||||
let derivedKey = try pbkdf2(password: password, salt: salt)
|
||||
|
||||
let nonce = Data.randomBytes(12)
|
||||
let symmetricKey = SymmetricKey(data: derivedKey)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
// AAD = ECP1 magic bytes (matching Python)
|
||||
let sealedBox = try AES.GCM.seal(
|
||||
rawBytes,
|
||||
using: symmetricKey,
|
||||
nonce: gcmNonce,
|
||||
authenticating: Constants.ecp1Magic
|
||||
)
|
||||
|
||||
// ciphertext + tag concatenated (matches Python's AESGCM.encrypt output)
|
||||
var result = Data()
|
||||
result.append(Constants.ecp1Magic) // 4 bytes
|
||||
result.append(salt) // 16 bytes
|
||||
result.append(nonce) // 12 bytes
|
||||
result.append(sealedBox.ciphertext) // N bytes
|
||||
result.append(sealedBox.tag) // 16 bytes
|
||||
return result
|
||||
}
|
||||
|
||||
/// Decrypt ECP1-encrypted key bytes with password
|
||||
static func decrypt(_ data: Data, password: Data) throws -> Data {
|
||||
guard data.count >= 48 else { // 4 + 16 + 12 + 16 minimum
|
||||
throw CryptoError.invalidECP1Format
|
||||
}
|
||||
guard data.prefix(4) == Constants.ecp1Magic else {
|
||||
throw CryptoError.invalidECP1Format
|
||||
}
|
||||
|
||||
let salt = data[4..<20]
|
||||
let nonce = data[20..<32]
|
||||
let ctWithTag = data[32...]
|
||||
|
||||
guard ctWithTag.count >= 16 else {
|
||||
throw CryptoError.invalidECP1Format
|
||||
}
|
||||
|
||||
let derivedKey = try pbkdf2(password: password, salt: Data(salt))
|
||||
let symmetricKey = SymmetricKey(data: derivedKey)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
// Split ciphertext and tag
|
||||
let ct = ctWithTag.prefix(ctWithTag.count - 16)
|
||||
let tag = ctWithTag.suffix(16)
|
||||
|
||||
let sealedBox = try AES.GCM.SealedBox(
|
||||
nonce: gcmNonce,
|
||||
ciphertext: ct,
|
||||
tag: tag
|
||||
)
|
||||
|
||||
do {
|
||||
return try AES.GCM.open(sealedBox, using: symmetricKey, authenticating: Constants.ecp1Magic)
|
||||
} catch {
|
||||
throw CryptoError.decryptionFailed("ECP1 decryption failed - wrong password?")
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if data starts with ECP1 magic
|
||||
static func isECP1Format(_ data: Data) -> Bool {
|
||||
data.count >= 4 && data.prefix(4) == Constants.ecp1Magic
|
||||
}
|
||||
|
||||
// MARK: - PBKDF2
|
||||
|
||||
/// Derive 32-byte key using PBKDF2-HMAC-SHA256 with 600k iterations
|
||||
static func pbkdf2(password: Data, salt: Data) throws -> Data {
|
||||
var derivedKey = Data(count: 32)
|
||||
let status = derivedKey.withUnsafeMutableBytes { derivedKeyPtr in
|
||||
password.withUnsafeBytes { passwordPtr in
|
||||
salt.withUnsafeBytes { saltPtr in
|
||||
CCKeyDerivationPBKDF(
|
||||
CCPBKDFAlgorithm(kCCPBKDF2),
|
||||
passwordPtr.baseAddress?.assumingMemoryBound(to: Int8.self),
|
||||
password.count,
|
||||
saltPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
|
||||
salt.count,
|
||||
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
|
||||
Constants.pbkdf2Iterations,
|
||||
derivedKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
|
||||
32
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
guard status == kCCSuccess else {
|
||||
throw CryptoError.pbkdf2Failed
|
||||
}
|
||||
return derivedKey
|
||||
}
|
||||
}
|
||||
59
ios_client 0.8.5/Kecalek/Crypto/MessagePadding.swift
Normal file
59
ios_client 0.8.5/Kecalek/Crypto/MessagePadding.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
/// Message padding for metadata privacy — hides plaintext length.
|
||||
/// Matches Python: crypto_utils.py pad_plaintext / unpad_plaintext
|
||||
enum MessagePadding {
|
||||
|
||||
/// Magic byte prefix to distinguish padded from legacy unpadded messages.
|
||||
private static let padMagic: UInt8 = 0x01
|
||||
|
||||
/// Bucket sizes for length hiding (64B to 64KB).
|
||||
private static let padBuckets = [64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536]
|
||||
|
||||
/// Pad plaintext to nearest bucket size to hide message length.
|
||||
///
|
||||
/// Format: `0x01 + plaintext + random_padding + pad_length(4B big-endian)`
|
||||
/// Prefix 0x01 distinguishes padded messages from legacy unpadded (which start with '{').
|
||||
static func pad(_ plaintext: Data) -> Data {
|
||||
var content = Data([padMagic])
|
||||
content.append(plaintext)
|
||||
|
||||
// +4 for the length suffix
|
||||
let minSize = content.count + 4
|
||||
let target = padBuckets.first(where: { $0 >= minSize }) ?? minSize
|
||||
let padLen = target - content.count
|
||||
|
||||
// random_padding (padLen - 4 bytes) + pad_length (4 bytes big-endian)
|
||||
var result = content
|
||||
result.append(Data.randomBytes(padLen - 4))
|
||||
result.append(UInt32(padLen).bigEndianData)
|
||||
return result
|
||||
}
|
||||
|
||||
/// Remove padding. Returns raw plaintext for both padded and legacy unpadded messages.
|
||||
static func unpad(_ data: Data) -> Data {
|
||||
guard !data.isEmpty else { return data }
|
||||
|
||||
// Legacy unpadded message (starts with '{' for JSON)
|
||||
guard data[data.startIndex] == padMagic else { return data }
|
||||
|
||||
// Too short to be validly padded (magic + at least 4 bytes for length)
|
||||
guard data.count >= 5 else { return data }
|
||||
|
||||
// Read pad_length from last 4 bytes (big-endian UInt32)
|
||||
let padLenOffset = data.count - 4
|
||||
let padLen = data.withUnsafeBytes { ptr -> UInt32 in
|
||||
var value: UInt32 = 0
|
||||
withUnsafeMutableBytes(of: &value) { dest in
|
||||
dest.copyBytes(from: UnsafeRawBufferPointer(rebasing: ptr[padLenOffset...]))
|
||||
}
|
||||
return UInt32(bigEndian: value)
|
||||
}
|
||||
|
||||
// Validate padding metadata
|
||||
guard padLen >= 4, padLen <= data.count - 1 else { return data }
|
||||
|
||||
// Strip: skip magic byte (index 0), take up to (data.count - padLen)
|
||||
return data[data.startIndex + 1 ..< data.startIndex + data.count - Int(padLen)]
|
||||
}
|
||||
}
|
||||
356
ios_client 0.8.5/Kecalek/Crypto/RSACrypto.swift
Normal file
356
ios_client 0.8.5/Kecalek/Crypto/RSACrypto.swift
Normal file
@@ -0,0 +1,356 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// RSA-4096 operations — used for login challenge-response ONLY
|
||||
enum RSACrypto {
|
||||
|
||||
// MARK: - Key Generation
|
||||
|
||||
/// Generate RSA-4096 keypair
|
||||
static func generateKeypair() throws -> (privateKey: SecKey, publicKey: SecKey) {
|
||||
let attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||
kSecAttrKeySizeInBits as String: 4096,
|
||||
]
|
||||
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
|
||||
throw CryptoError.rsaKeyGenerationFailed
|
||||
}
|
||||
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
|
||||
throw CryptoError.rsaKeyGenerationFailed
|
||||
}
|
||||
return (privateKey, publicKey)
|
||||
}
|
||||
|
||||
// MARK: - Serialization
|
||||
|
||||
/// Serialize RSA private key. With password: DER → ECP1. Without: PEM PKCS#8.
|
||||
static func serializePrivateKey(_ key: SecKey, password: Data? = nil) throws -> Data {
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let derData = SecKeyCopyExternalRepresentation(key, &error) as Data? else {
|
||||
throw CryptoError.rsaOperationFailed("Failed to export private key")
|
||||
}
|
||||
|
||||
// SecKey exports in PKCS#1 format on iOS — wrap in PKCS#8 for Python compat
|
||||
let pkcs8 = wrapRSAPrivateKeyPKCS8(derData)
|
||||
|
||||
if let password = password {
|
||||
return try KeyEncryption.encrypt(pkcs8, password: password)
|
||||
}
|
||||
|
||||
// PEM encode for Python compatibility
|
||||
return pemEncode(pkcs8, label: "PRIVATE KEY")
|
||||
}
|
||||
|
||||
/// Serialize RSA public key as PEM SubjectPublicKeyInfo (Python-compatible)
|
||||
static func serializePublicKey(_ key: SecKey) throws -> Data {
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let derData = SecKeyCopyExternalRepresentation(key, &error) as Data? else {
|
||||
throw CryptoError.rsaOperationFailed("Failed to export public key")
|
||||
}
|
||||
|
||||
// SecKey exports PKCS#1 on iOS — wrap in SubjectPublicKeyInfo
|
||||
let spki = wrapRSAPublicKeySPKI(derData)
|
||||
return pemEncode(spki, label: "PUBLIC KEY")
|
||||
}
|
||||
|
||||
/// Load RSA private key. Auto-detects ECP1 vs PEM format.
|
||||
static func loadPrivateKey(_ data: Data, password: Data? = nil) throws -> SecKey {
|
||||
let derData: Data
|
||||
|
||||
if KeyEncryption.isECP1Format(data) {
|
||||
guard let pwd = password else {
|
||||
throw CryptoError.invalidKeyData("ECP1 key requires password")
|
||||
}
|
||||
let raw = try KeyEncryption.decrypt(data, password: pwd)
|
||||
derData = unwrapPKCS8ToRSAPrivateKey(raw)
|
||||
} else {
|
||||
// PEM format
|
||||
let pem = String(data: data, encoding: .utf8) ?? ""
|
||||
derData = try pemDecode(pem, label: "PRIVATE KEY")
|
||||
.flatMap { unwrapPKCS8ToRSAPrivateKey($0) }
|
||||
?? pemDecode(pem, label: "RSA PRIVATE KEY")
|
||||
?? { throw CryptoError.invalidKeyData("Cannot parse RSA private key PEM") }()
|
||||
}
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
|
||||
]
|
||||
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let key = SecKeyCreateWithData(derData as CFData, attributes as CFDictionary, &error) else {
|
||||
throw CryptoError.invalidKeyData("Failed to create RSA private key from DER")
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
/// Load RSA public key from PEM
|
||||
static func loadPublicKey(_ pemData: Data) throws -> SecKey {
|
||||
let pem = String(data: pemData, encoding: .utf8) ?? ""
|
||||
|
||||
// Try SubjectPublicKeyInfo (PUBLIC KEY), unwrap to PKCS#1
|
||||
let derData: Data
|
||||
if let spki = pemDecode(pem, label: "PUBLIC KEY") {
|
||||
derData = unwrapSPKIToRSAPublicKey(spki)
|
||||
} else if let pkcs1 = pemDecode(pem, label: "RSA PUBLIC KEY") {
|
||||
derData = pkcs1
|
||||
} else {
|
||||
throw CryptoError.invalidKeyData("Cannot parse RSA public key PEM")
|
||||
}
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
||||
]
|
||||
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let key = SecKeyCreateWithData(derData as CFData, attributes as CFDictionary, &error) else {
|
||||
throw CryptoError.invalidKeyData("Failed to create RSA public key from DER")
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// MARK: - Sign / Verify
|
||||
|
||||
/// Sign data with RSA-PSS SHA-256.
|
||||
/// Note: iOS uses salt_length = hash_length (32). Server must use PSS.AUTO to verify.
|
||||
static func sign(_ privateKey: SecKey, data: Data) throws -> Data {
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let signature = SecKeyCreateSignature(
|
||||
privateKey,
|
||||
.rsaSignatureMessagePSSSHA256,
|
||||
data as CFData,
|
||||
&error
|
||||
) as Data? else {
|
||||
throw CryptoError.rsaOperationFailed("RSA signing failed")
|
||||
}
|
||||
return signature
|
||||
}
|
||||
|
||||
/// Verify RSA-PSS SHA-256 signature
|
||||
static func verify(_ publicKey: SecKey, signature: Data, data: Data) -> Bool {
|
||||
SecKeyVerifySignature(
|
||||
publicKey,
|
||||
.rsaSignatureMessagePSSSHA256,
|
||||
data as CFData,
|
||||
signature as CFData,
|
||||
nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - RSA-OAEP Encrypt / Decrypt (for device pairing)
|
||||
|
||||
/// Encrypt data with RSA-OAEP SHA-256 using a public key
|
||||
static func encrypt(_ publicKey: SecKey, plaintext: Data) throws -> Data {
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let encrypted = SecKeyCreateEncryptedData(
|
||||
publicKey,
|
||||
.rsaEncryptionOAEPSHA256,
|
||||
plaintext as CFData,
|
||||
&error
|
||||
) as Data? else {
|
||||
throw CryptoError.rsaOperationFailed("RSA-OAEP encryption failed")
|
||||
}
|
||||
return encrypted
|
||||
}
|
||||
|
||||
/// Decrypt data with RSA-OAEP SHA-256 using a private key
|
||||
static func decrypt(_ privateKey: SecKey, ciphertext: Data) throws -> Data {
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let decrypted = SecKeyCreateDecryptedData(
|
||||
privateKey,
|
||||
.rsaEncryptionOAEPSHA256,
|
||||
ciphertext as CFData,
|
||||
&error
|
||||
) as Data? else {
|
||||
throw CryptoError.rsaOperationFailed("RSA-OAEP decryption failed")
|
||||
}
|
||||
return decrypted
|
||||
}
|
||||
|
||||
/// Generate RSA-2048 keypair (for pairing temp keys — smaller for OAEP payload)
|
||||
static func generateKeypair2048() throws -> (privateKey: SecKey, publicKey: SecKey) {
|
||||
let attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||
kSecAttrKeySizeInBits as String: 2048,
|
||||
]
|
||||
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
|
||||
throw CryptoError.rsaKeyGenerationFailed
|
||||
}
|
||||
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
|
||||
throw CryptoError.rsaKeyGenerationFailed
|
||||
}
|
||||
return (privateKey, publicKey)
|
||||
}
|
||||
|
||||
// MARK: - PEM Helpers
|
||||
|
||||
private static func pemEncode(_ der: Data, label: String) -> Data {
|
||||
let base64 = der.base64EncodedString(options: .lineLength64Characters)
|
||||
let pem = "-----BEGIN \(label)-----\n\(base64)\n-----END \(label)-----\n"
|
||||
return Data(pem.utf8)
|
||||
}
|
||||
|
||||
private static func pemDecode(_ pem: String, label: String) -> Data? {
|
||||
let beginMarker = "-----BEGIN \(label)-----"
|
||||
let endMarker = "-----END \(label)-----"
|
||||
|
||||
guard let beginRange = pem.range(of: beginMarker),
|
||||
let endRange = pem.range(of: endMarker) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let base64String = pem[beginRange.upperBound..<endRange.lowerBound]
|
||||
.replacingOccurrences(of: "\n", with: "")
|
||||
.replacingOccurrences(of: "\r", with: "")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
|
||||
return Data(base64Encoded: base64String)
|
||||
}
|
||||
|
||||
// MARK: - ASN.1 PKCS#8 / SPKI Wrappers
|
||||
|
||||
// SecKey on iOS exports RSA keys in PKCS#1 format, but Python expects PKCS#8 / SPKI.
|
||||
// These functions add/remove the ASN.1 wrapping.
|
||||
|
||||
// RSA OID: 1.2.840.113549.1.1.1
|
||||
private static let rsaOID: [UInt8] = [0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01]
|
||||
private static let nullParam: [UInt8] = [0x05, 0x00]
|
||||
|
||||
/// Wrap PKCS#1 RSA private key in PKCS#8 PrivateKeyInfo envelope
|
||||
private static func wrapRSAPrivateKeyPKCS8(_ pkcs1: Data) -> Data {
|
||||
// PrivateKeyInfo ::= SEQUENCE {
|
||||
// version INTEGER (0),
|
||||
// algorithm AlgorithmIdentifier,
|
||||
// privateKey OCTET STRING (containing PKCS#1 key)
|
||||
// }
|
||||
let version = Data([0x02, 0x01, 0x00]) // INTEGER 0
|
||||
let algorithmSeq = asn1Sequence(Data(rsaOID) + Data(nullParam))
|
||||
let privateKeyOctet = asn1OctetString(pkcs1)
|
||||
return asn1Sequence(version + algorithmSeq + privateKeyOctet)
|
||||
}
|
||||
|
||||
/// Unwrap PKCS#8 to get PKCS#1 RSA private key
|
||||
private static func unwrapPKCS8ToRSAPrivateKey(_ pkcs8: Data) -> Data {
|
||||
// Parse SEQUENCE, skip version + algorithm, extract OCTET STRING
|
||||
guard pkcs8.count > 2 else { return pkcs8 }
|
||||
|
||||
var offset = 0
|
||||
// Outer SEQUENCE
|
||||
guard pkcs8[offset] == 0x30 else { return pkcs8 }
|
||||
offset += 1
|
||||
offset = skipASN1Length(pkcs8, offset: offset)
|
||||
|
||||
// Version INTEGER
|
||||
guard offset < pkcs8.count, pkcs8[offset] == 0x02 else { return pkcs8 }
|
||||
offset += 1
|
||||
let versionLen = readASN1Length(pkcs8, offset: &offset)
|
||||
offset += versionLen
|
||||
|
||||
// Algorithm SEQUENCE
|
||||
guard offset < pkcs8.count, pkcs8[offset] == 0x30 else { return pkcs8 }
|
||||
offset += 1
|
||||
let algoLen = readASN1Length(pkcs8, offset: &offset)
|
||||
offset += algoLen
|
||||
|
||||
// Private key OCTET STRING
|
||||
guard offset < pkcs8.count, pkcs8[offset] == 0x04 else { return pkcs8 }
|
||||
offset += 1
|
||||
let keyLen = readASN1Length(pkcs8, offset: &offset)
|
||||
guard offset + keyLen <= pkcs8.count else { return pkcs8 }
|
||||
return Data(pkcs8[offset..<(offset + keyLen)])
|
||||
}
|
||||
|
||||
/// Wrap PKCS#1 RSA public key in SubjectPublicKeyInfo
|
||||
private static func wrapRSAPublicKeySPKI(_ pkcs1: Data) -> Data {
|
||||
// SubjectPublicKeyInfo ::= SEQUENCE {
|
||||
// algorithm AlgorithmIdentifier,
|
||||
// subjectPublicKey BIT STRING (containing PKCS#1 key)
|
||||
// }
|
||||
let algorithmSeq = asn1Sequence(Data(rsaOID) + Data(nullParam))
|
||||
let bitString = asn1BitString(pkcs1)
|
||||
return asn1Sequence(algorithmSeq + bitString)
|
||||
}
|
||||
|
||||
/// Unwrap SubjectPublicKeyInfo to get PKCS#1 RSA public key
|
||||
private static func unwrapSPKIToRSAPublicKey(_ spki: Data) -> Data {
|
||||
guard spki.count > 2 else { return spki }
|
||||
|
||||
var offset = 0
|
||||
// Outer SEQUENCE
|
||||
guard spki[offset] == 0x30 else { return spki }
|
||||
offset += 1
|
||||
offset = skipASN1Length(spki, offset: offset)
|
||||
|
||||
// Algorithm SEQUENCE
|
||||
guard offset < spki.count, spki[offset] == 0x30 else { return spki }
|
||||
offset += 1
|
||||
let algoLen = readASN1Length(spki, offset: &offset)
|
||||
offset += algoLen
|
||||
|
||||
// BIT STRING
|
||||
guard offset < spki.count, spki[offset] == 0x03 else { return spki }
|
||||
offset += 1
|
||||
let bitLen = readASN1Length(spki, offset: &offset)
|
||||
// Skip the unused bits byte
|
||||
guard offset < spki.count, spki[offset] == 0x00 else { return spki }
|
||||
offset += 1
|
||||
let keyLen = bitLen - 1
|
||||
guard offset + keyLen <= spki.count else { return spki }
|
||||
return Data(spki[offset..<(offset + keyLen)])
|
||||
}
|
||||
|
||||
// MARK: - ASN.1 Primitives
|
||||
|
||||
private static func asn1Length(_ length: Int) -> Data {
|
||||
if length < 0x80 {
|
||||
return Data([UInt8(length)])
|
||||
} else if length <= 0xFF {
|
||||
return Data([0x81, UInt8(length)])
|
||||
} else if length <= 0xFFFF {
|
||||
return Data([0x82, UInt8(length >> 8), UInt8(length & 0xFF)])
|
||||
} else {
|
||||
return Data([0x83, UInt8(length >> 16), UInt8((length >> 8) & 0xFF), UInt8(length & 0xFF)])
|
||||
}
|
||||
}
|
||||
|
||||
private static func asn1Sequence(_ content: Data) -> Data {
|
||||
Data([0x30]) + asn1Length(content.count) + content
|
||||
}
|
||||
|
||||
private static func asn1OctetString(_ content: Data) -> Data {
|
||||
Data([0x04]) + asn1Length(content.count) + content
|
||||
}
|
||||
|
||||
private static func asn1BitString(_ content: Data) -> Data {
|
||||
// BIT STRING: tag + length + unused_bits(0) + content
|
||||
Data([0x03]) + asn1Length(content.count + 1) + Data([0x00]) + content
|
||||
}
|
||||
|
||||
private static func readASN1Length(_ data: Data, offset: inout Int) -> Int {
|
||||
guard offset < data.count else { return 0 }
|
||||
let first = data[offset]
|
||||
offset += 1
|
||||
if first < 0x80 {
|
||||
return Int(first)
|
||||
}
|
||||
let numBytes = Int(first & 0x7F)
|
||||
var length = 0
|
||||
for _ in 0..<numBytes {
|
||||
guard offset < data.count else { return length }
|
||||
length = (length << 8) | Int(data[offset])
|
||||
offset += 1
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
private static func skipASN1Length(_ data: Data, offset: Int) -> Int {
|
||||
var off = offset
|
||||
_ = readASN1Length(data, offset: &off)
|
||||
return off
|
||||
}
|
||||
}
|
||||
175
ios_client 0.8.5/Kecalek/Crypto/SenderKeyState.swift
Normal file
175
ios_client 0.8.5/Kecalek/Crypto/SenderKeyState.swift
Normal file
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Sender key chain for group messaging.
|
||||
/// Each sender in a group has their own chain. Others receive the initial key via pairwise ratchet.
|
||||
/// Matches Python: SenderKeyState class in crypto_utils.py
|
||||
class SenderKeyState {
|
||||
|
||||
let senderKey: Data
|
||||
let chainId: Data
|
||||
private(set) var chainKey: Data
|
||||
private(set) var n: Int
|
||||
private var knownKeys: [Int: Data]
|
||||
|
||||
/// Initialize with optional sender key (generates random 32B if nil).
|
||||
/// Matches Python: SenderKeyState.__init__(sender_key=None)
|
||||
init(senderKey: Data? = nil) {
|
||||
let key = senderKey ?? Data.randomBytes(32)
|
||||
self.senderKey = key
|
||||
self.chainId = Data(SHA256.hash(data: key))
|
||||
self.chainKey = CryptoUtils.hkdfDerive(
|
||||
inputKey: key,
|
||||
salt: Data(repeating: 0x00, count: 32),
|
||||
info: Data(Constants.senderKeyChainInfo.utf8),
|
||||
length: 32
|
||||
)
|
||||
self.n = 0
|
||||
self.knownKeys = [:]
|
||||
}
|
||||
|
||||
/// Private init for import
|
||||
private init(senderKey: Data, chainId: Data, chainKey: Data, n: Int, knownKeys: [Int: Data]) {
|
||||
self.senderKey = senderKey
|
||||
self.chainId = chainId
|
||||
self.chainKey = chainKey
|
||||
self.n = n
|
||||
self.knownKeys = knownKeys
|
||||
}
|
||||
|
||||
// MARK: - Encrypt
|
||||
|
||||
/// Encrypt with current chain key.
|
||||
/// Returns (chainId hex, n, ciphertext with tag, nonce).
|
||||
/// Matches Python: SenderKeyState.encrypt(plaintext)
|
||||
func encrypt(_ plaintext: Data) throws -> (chainIdHex: String, n: Int, ciphertext: Data, nonce: Data) {
|
||||
let (newCK, messageKey) = CryptoUtils.kdfCK(chainKey: chainKey)
|
||||
chainKey = newCK
|
||||
|
||||
let nonce = Data.randomBytes(12)
|
||||
// AAD = chainId + bigEndian(UInt32(n))
|
||||
let aad = chainId + UInt32(n).bigEndianData
|
||||
let ctWithTag = try CryptoUtils.aesGcmEncrypt(plaintext, key: messageKey, nonce: nonce, aad: aad)
|
||||
|
||||
let result = (chainIdHex: chainId.hexString, n: n, ciphertext: ctWithTag, nonce: nonce)
|
||||
n += 1
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Decrypt
|
||||
|
||||
/// Decrypt a group message. Fast-forwards the chain if needed.
|
||||
/// State is snapshotted before modification and restored on failure.
|
||||
/// Matches Python: SenderKeyState.decrypt(chain_id_hex, n, ciphertext, nonce)
|
||||
func decrypt(chainIdHex: String, n: Int, ciphertext: Data, nonce: Data) throws -> Data {
|
||||
guard let expectedChainId = Data(hexString: chainIdHex) else {
|
||||
throw CryptoError.senderKeyError("Invalid chain ID hex")
|
||||
}
|
||||
guard expectedChainId == chainId else {
|
||||
throw CryptoError.senderKeyError("Chain ID mismatch")
|
||||
}
|
||||
|
||||
if n - self.n > Constants.maxSenderKeySkip {
|
||||
throw CryptoError.senderKeyError("Sender key skip too large (\(n - self.n) > \(Constants.maxSenderKeySkip))")
|
||||
}
|
||||
|
||||
// Snapshot before fast-forward
|
||||
let snapChainKey = chainKey
|
||||
let snapN = self.n
|
||||
let snapKnown = knownKeys
|
||||
|
||||
do {
|
||||
// Fast-forward the chain to reach message n
|
||||
while self.n <= n {
|
||||
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: chainKey)
|
||||
chainKey = newCK
|
||||
knownKeys[self.n] = mk
|
||||
self.n += 1
|
||||
}
|
||||
|
||||
guard let mk = knownKeys.removeValue(forKey: n) else {
|
||||
throw CryptoError.senderKeyError("Message key for n=\(n) not available")
|
||||
}
|
||||
|
||||
let aad = chainId + UInt32(n).bigEndianData
|
||||
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
|
||||
} catch {
|
||||
// Restore state on failure
|
||||
chainKey = snapChainKey
|
||||
self.n = snapN
|
||||
knownKeys = snapKnown
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Key Export/Import
|
||||
|
||||
/// Export sender key for distribution to group members.
|
||||
/// Matches Python: SenderKeyState.export_key()
|
||||
func exportKey() -> Data {
|
||||
let dict: [String: Any] = ["sender_key": senderKey.hexString]
|
||||
return try! JSONSerialization.data(withJSONObject: dict)
|
||||
}
|
||||
|
||||
/// Initialize a receiving SenderKeyState from an exported key.
|
||||
/// Matches Python: SenderKeyState.from_key(exported_key)
|
||||
static func fromKey(_ exportedKey: Data) throws -> SenderKeyState {
|
||||
guard let dict = try JSONSerialization.jsonObject(with: exportedKey) as? [String: Any],
|
||||
let senderKeyHex = dict["sender_key"] as? String,
|
||||
let senderKey = Data(hexString: senderKeyHex) else {
|
||||
throw CryptoError.stateImportFailed("Invalid sender key export")
|
||||
}
|
||||
return SenderKeyState(senderKey: senderKey)
|
||||
}
|
||||
|
||||
// MARK: - Full State Export/Import
|
||||
|
||||
/// Serialize full state for persistent storage.
|
||||
/// Matches Python: SenderKeyState.export_state()
|
||||
func exportState() -> Data {
|
||||
var knownKeysDict: [String: String] = [:]
|
||||
for (k, v) in knownKeys {
|
||||
knownKeysDict[String(k)] = v.hexString
|
||||
}
|
||||
let state: [String: Any] = [
|
||||
"sender_key": senderKey.hexString,
|
||||
"chain_id": chainId.hexString,
|
||||
"chain_key": chainKey.hexString,
|
||||
"n": n,
|
||||
"known_keys": knownKeysDict,
|
||||
]
|
||||
return try! JSONSerialization.data(withJSONObject: state)
|
||||
}
|
||||
|
||||
/// Deserialize full state.
|
||||
/// Matches Python: SenderKeyState.import_state(data)
|
||||
static func importState(_ data: Data) throws -> SenderKeyState {
|
||||
guard let state = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let senderKeyHex = state["sender_key"] as? String,
|
||||
let senderKey = Data(hexString: senderKeyHex),
|
||||
let chainIdHex = state["chain_id"] as? String,
|
||||
let chainId = Data(hexString: chainIdHex),
|
||||
let chainKeyHex = state["chain_key"] as? String,
|
||||
let chainKey = Data(hexString: chainKeyHex),
|
||||
let n = state["n"] as? Int else {
|
||||
throw CryptoError.stateImportFailed("Invalid sender key state")
|
||||
}
|
||||
|
||||
var knownKeys: [Int: Data] = [:]
|
||||
if let knownKeysDict = state["known_keys"] as? [String: String] {
|
||||
for (k, v) in knownKeysDict {
|
||||
if let idx = Int(k), let data = Data(hexString: v) {
|
||||
knownKeys[idx] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SenderKeyState(
|
||||
senderKey: senderKey,
|
||||
chainId: chainId,
|
||||
chainKey: chainKey,
|
||||
n: n,
|
||||
knownKeys: knownKeys
|
||||
)
|
||||
}
|
||||
}
|
||||
77
ios_client 0.8.5/Kecalek/Crypto/X25519Crypto.swift
Normal file
77
ios_client 0.8.5/Kecalek/Crypto/X25519Crypto.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// X25519 Diffie-Hellman key agreement
|
||||
enum X25519Crypto {
|
||||
|
||||
// MARK: - Key Generation
|
||||
|
||||
/// Generate X25519 keypair
|
||||
static func generateKeypair() -> (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) {
|
||||
let privateKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
return (privateKey, privateKey.publicKey)
|
||||
}
|
||||
|
||||
// MARK: - Serialization
|
||||
|
||||
/// Serialize X25519 private key to 32 raw bytes
|
||||
static func serializePrivate(_ key: Curve25519.KeyAgreement.PrivateKey) -> Data {
|
||||
key.rawData // 32 bytes
|
||||
}
|
||||
|
||||
/// Serialize X25519 public key to 32 raw bytes
|
||||
static func serializePublic(_ key: Curve25519.KeyAgreement.PublicKey) -> Data {
|
||||
key.rawData // 32 bytes
|
||||
}
|
||||
|
||||
/// Load X25519 private key from 32 raw bytes
|
||||
static func loadPrivate(_ data: Data) throws -> Curve25519.KeyAgreement.PrivateKey {
|
||||
guard data.count == 32 else {
|
||||
throw CryptoError.invalidKeyData("X25519 private key must be 32 bytes")
|
||||
}
|
||||
return try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: data)
|
||||
}
|
||||
|
||||
/// Load X25519 public key from 32 raw bytes
|
||||
static func loadPublic(_ data: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||
guard data.count == 32 else {
|
||||
throw CryptoError.invalidKeyData("X25519 public key must be 32 bytes")
|
||||
}
|
||||
return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: data)
|
||||
}
|
||||
|
||||
// MARK: - Diffie-Hellman
|
||||
|
||||
/// Perform X25519 DH key agreement. Returns 32-byte shared secret.
|
||||
/// Matches Python: x25519_dh(private_key, public_key)
|
||||
static func dh(_ privateKey: Curve25519.KeyAgreement.PrivateKey, _ publicKey: Curve25519.KeyAgreement.PublicKey) throws -> Data {
|
||||
let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey)
|
||||
// Extract raw bytes from SharedSecret
|
||||
return sharedSecret.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
// MARK: - Ed25519 → X25519 Key Conversion
|
||||
|
||||
/// Convert Ed25519 private key to X25519 private key.
|
||||
/// SHA-512(seed) → take first 32 bytes → clamp per RFC 7748
|
||||
/// Matches Python: ed25519_private_to_x25519(ed_private)
|
||||
static func fromEd25519Private(_ edPrivate: Curve25519.Signing.PrivateKey) throws -> Curve25519.KeyAgreement.PrivateKey {
|
||||
let raw = edPrivate.rawData // 32 bytes seed
|
||||
// SHA-512 of the seed
|
||||
let hash = SHA512.hash(data: raw)
|
||||
var clamped = Data(hash.prefix(32))
|
||||
// Clamp per RFC 7748
|
||||
clamped[0] &= 248
|
||||
clamped[31] &= 127
|
||||
clamped[31] |= 64
|
||||
return try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: clamped)
|
||||
}
|
||||
|
||||
/// Convert Ed25519 public key to X25519 public key.
|
||||
/// Uses Montgomery birational map: u = (1+y)/(1-y) mod p
|
||||
/// Matches Python: ed25519_public_to_x25519(ed_public)
|
||||
static func fromEd25519Public(_ edPublic: Curve25519.Signing.PublicKey) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||
let x25519Bytes = FieldArithmetic.ed25519PublicToX25519(edPublic.rawData)
|
||||
return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: x25519Bytes)
|
||||
}
|
||||
}
|
||||
139
ios_client 0.8.5/Kecalek/Crypto/X3DH.swift
Normal file
139
ios_client 0.8.5/Kecalek/Crypto/X3DH.swift
Normal file
@@ -0,0 +1,139 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// X3DH key agreement protocol (Signal Protocol)
|
||||
enum X3DH {
|
||||
|
||||
// MARK: - Pre-Key Generation
|
||||
|
||||
/// Generate a signed pre-key (SPK).
|
||||
/// Returns (private, public, signature, id).
|
||||
/// Matches Python: generate_signed_prekey(identity_private)
|
||||
static func generateSignedPrekey(
|
||||
identityPrivate: Curve25519.Signing.PrivateKey
|
||||
) throws -> (privateKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
publicKey: Curve25519.KeyAgreement.PublicKey,
|
||||
signature: Data,
|
||||
id: String) {
|
||||
let (spkPriv, spkPub) = X25519Crypto.generateKeypair()
|
||||
let spkPubBytes = X25519Crypto.serializePublic(spkPub)
|
||||
let signature = try Ed25519Crypto.sign(identityPrivate, data: spkPubBytes)
|
||||
return (spkPriv, spkPub, signature, UUID().uuidString)
|
||||
}
|
||||
|
||||
/// Generate a batch of one-time pre-keys.
|
||||
/// Matches Python: generate_one_time_prekeys(count=50)
|
||||
static func generateOneTimePrekeys(count: Int = 50) -> [(privateKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
publicKey: Curve25519.KeyAgreement.PublicKey,
|
||||
id: String)] {
|
||||
(0..<count).map { _ in
|
||||
let (priv, pub) = X25519Crypto.generateKeypair()
|
||||
return (priv, pub, UUID().uuidString)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - X3DH Initiate (Alice)
|
||||
|
||||
/// Initiator side of X3DH.
|
||||
/// Returns (sharedSecret, ephemeralPrivate, ephemeralPublic).
|
||||
/// Matches Python: x3dh_initiate(ik_private_ed, ik_public_remote_ed, spk_remote, spk_signature, opk_remote?)
|
||||
static func initiate(
|
||||
ikPrivateEd: Curve25519.Signing.PrivateKey,
|
||||
ikPublicRemoteEd: Curve25519.Signing.PublicKey,
|
||||
spkRemote: Curve25519.KeyAgreement.PublicKey,
|
||||
spkSignature: Data,
|
||||
opkRemote: Curve25519.KeyAgreement.PublicKey? = nil
|
||||
) throws -> (sharedSecret: Data,
|
||||
ephemeralPrivate: Curve25519.KeyAgreement.PrivateKey,
|
||||
ephemeralPublic: Curve25519.KeyAgreement.PublicKey) {
|
||||
// Verify SPK signature
|
||||
let spkRemoteBytes = X25519Crypto.serializePublic(spkRemote)
|
||||
guard Ed25519Crypto.verify(ikPublicRemoteEd, signature: spkSignature, data: spkRemoteBytes) else {
|
||||
throw CryptoError.x3dhFailed("Invalid SPK signature")
|
||||
}
|
||||
|
||||
// Convert identity keys to X25519
|
||||
let ikX25519Private = try X25519Crypto.fromEd25519Private(ikPrivateEd)
|
||||
let ikX25519Remote = try X25519Crypto.fromEd25519Public(ikPublicRemoteEd)
|
||||
|
||||
// Generate ephemeral keypair
|
||||
let (ekPriv, ekPub) = X25519Crypto.generateKeypair()
|
||||
|
||||
// Debug: print key inputs (matching Python x3dh_respond)
|
||||
#if DEBUG
|
||||
print("DEBUG x3dh_initiate: ik_remote_ed = \(Ed25519Crypto.serializePublic(ikPublicRemoteEd).hexString)")
|
||||
print("DEBUG x3dh_initiate: ik_x25519_remote = \(X25519Crypto.serializePublic(ikX25519Remote).hexString)")
|
||||
print("DEBUG x3dh_initiate: ek_pub = \(X25519Crypto.serializePublic(ekPub).hexString)")
|
||||
print("DEBUG x3dh_initiate: spk_remote = \(spkRemoteBytes.hexString)")
|
||||
#endif
|
||||
|
||||
// DH computations
|
||||
let dh1 = try X25519Crypto.dh(ikX25519Private, spkRemote) // IK_A, SPK_B
|
||||
let dh2 = try X25519Crypto.dh(ekPriv, ikX25519Remote) // EK_A, IK_B
|
||||
let dh3 = try X25519Crypto.dh(ekPriv, spkRemote) // EK_A, SPK_B
|
||||
|
||||
// Debug: print DH outputs
|
||||
#if DEBUG
|
||||
print("DEBUG x3dh_initiate: dh1 = \(dh1.hexString)")
|
||||
print("DEBUG x3dh_initiate: dh2 = \(dh2.hexString)")
|
||||
print("DEBUG x3dh_initiate: dh3 = \(dh3.hexString)")
|
||||
#endif
|
||||
|
||||
var dhConcat = dh1 + dh2 + dh3
|
||||
if let opk = opkRemote {
|
||||
let dh4 = try X25519Crypto.dh(ekPriv, opk) // EK_A, OPK_B
|
||||
#if DEBUG
|
||||
print("DEBUG x3dh_initiate: dh4 = \(dh4.hexString)")
|
||||
#endif
|
||||
dhConcat += dh4
|
||||
}
|
||||
|
||||
// Derive shared secret
|
||||
let sharedSecret = CryptoUtils.hkdfDerive(
|
||||
inputKey: dhConcat,
|
||||
salt: Data(repeating: 0x00, count: 32),
|
||||
info: Data(Constants.x3dhInfo.utf8),
|
||||
length: 32
|
||||
)
|
||||
#if DEBUG
|
||||
print("DEBUG x3dh_initiate: shared_secret = \(sharedSecret.hexString)")
|
||||
#endif
|
||||
|
||||
return (sharedSecret, ekPriv, ekPub)
|
||||
}
|
||||
|
||||
// MARK: - X3DH Respond (Bob)
|
||||
|
||||
/// Responder side of X3DH.
|
||||
/// Returns sharedSecret.
|
||||
/// Matches Python: x3dh_respond(ik_private_ed, spk_private, ik_remote_ed, ek_remote, opk_private?)
|
||||
static func respond(
|
||||
ikPrivateEd: Curve25519.Signing.PrivateKey,
|
||||
spkPrivate: Curve25519.KeyAgreement.PrivateKey,
|
||||
ikRemoteEd: Curve25519.Signing.PublicKey,
|
||||
ekRemote: Curve25519.KeyAgreement.PublicKey,
|
||||
opkPrivate: Curve25519.KeyAgreement.PrivateKey? = nil
|
||||
) throws -> Data {
|
||||
let ikX25519Private = try X25519Crypto.fromEd25519Private(ikPrivateEd)
|
||||
let ikX25519Remote = try X25519Crypto.fromEd25519Public(ikRemoteEd)
|
||||
|
||||
let dh1 = try X25519Crypto.dh(spkPrivate, ikX25519Remote) // SPK_B, IK_A
|
||||
let dh2 = try X25519Crypto.dh(ikX25519Private, ekRemote) // IK_B, EK_A
|
||||
let dh3 = try X25519Crypto.dh(spkPrivate, ekRemote) // SPK_B, EK_A
|
||||
|
||||
var dhConcat = dh1 + dh2 + dh3
|
||||
if let opk = opkPrivate {
|
||||
let dh4 = try X25519Crypto.dh(opk, ekRemote) // OPK_B, EK_A
|
||||
dhConcat += dh4
|
||||
}
|
||||
|
||||
let sharedSecret = CryptoUtils.hkdfDerive(
|
||||
inputKey: dhConcat,
|
||||
salt: Data(repeating: 0x00, count: 32),
|
||||
info: Data(Constants.x3dhInfo.utf8),
|
||||
length: 32
|
||||
)
|
||||
|
||||
return sharedSecret
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user