163 lines
5.8 KiB
Swift
163 lines
5.8 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|