ios_client

This commit is contained in:
Filip
2026-03-14 12:43:56 +01:00
parent 5fd80e6dd6
commit 214da18779
74 changed files with 13136 additions and 284 deletions

View 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"
}
}
}

View 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)"
}
}
}

View 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")
}
}
}

View 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
}
}

View 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)
}
}

View 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)
}
}

View 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
}
}

View 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)]
}
}

View 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
}
}

View 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
)
}
}

View 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)
}
}

View 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
}
}