1// ShamirBackup.swift2// SophaxChatCore3//4// Shamir's Secret Sharing backup for identity private keys.5//6// Protocol overview:7// • Creator splits their 64-byte identity secret (Ed25519 + X25519 private keys)8// into N shares using (M, N) threshold SSS over GF(256).9// • Each share is ECDH-encrypted for a specific trusted contact and delivered10// via .sssShareDelivery wire message.11// • Holder stores their plaintext share in the Keychain.12// • Recovery: creator requests shares from M contacts via .sssShareRequest.13// Contacts re-encrypt their stored share for the requester and respond14// via .sssShareResponse. Creator reconstructs the secret from M shares.15//16// Crypto:17// • SSS: GF(256) with irreducible polynomial x^8+x^4+x^3+x+1 (0x11B).18// • Share encryption: X25519 ECDH → HKDF-SHA256 → ChaChaPoly (same as sealed sender).19// • Share storage: iOS Keychain, kSecAttrAccessibleWhenUnlockedThisDeviceOnly.20//21// Security properties:22// • Any M−1 shares reveal zero information about the secret (information-theoretic).23// • Shares in transit are E2E-encrypted; relay nodes see only ciphertext.24// • Shares at rest are Keychain-protected (AES-256-GCM, device-only).2526import Foundation27import CryptoKit2829// MARK: - Types3031/// One Shamir share — x-coordinate plus y-values for every secret byte.32public struct SSSShare: Codable, Sendable {33/// UUID linking all N shares of a single backup instance.34 public let id: String35/// x-coordinate in GF(256) — never 0. Range 1..N.36 public let index: UInt837/// Minimum shares required for reconstruction.38 public let threshold: UInt839/// Total shares created.40 public let total: UInt841/// One GF(256) y-value per secret byte — same length as the original secret.42 public let data: Data43 public let createdAt: Date44}4546/// Creator's record of which trusted contacts hold which shares.47/// Stored in Keychain so the creator knows who to request recovery from.48public struct SSSBackupManifest: Codable, Sendable {49/// UUID identifying the backup instance (matches `SSSShare.id`).50 public let shareID: String51/// Minimum shares required for recovery.52 public let threshold: Int53/// PeerIDs of share holders, in index order: holderPeerIDs[i] holds share index i+1.54 public let holderPeerIDs:[String]55 public let createdAt: Date56}5758public enum ShamirBackupError: Error, LocalizedError {59 case invalidParameters(String)60 case notEnoughShares61 case inconsistentShares62 case encryptionFailed63 case decryptionFailed6465 public var errorDescription: String?{66 switch self {67 case .invalidParameters(let r): return "Invalid SSS parameters: \(r)"68 case .notEnoughShares: return "Not enough shares to reconstruct the secret."69 case .inconsistentShares: return "Shares belong to different backup instances."70 case .encryptionFailed: return "Share encryption failed."71 case .decryptionFailed: return "Share decryption failed."72}73}74}7576// MARK: - ShamirBackup7778public enum ShamirBackup {7980// MARK: - Public API8182/// Split `secret` into `n` shares with `m`-of-`n` threshold.83/// - Parameters:84/// - secret: The bytes to protect (typically 64B: Ed25519 + X25519 private keys).85/// - m: Minimum shares needed to reconstruct (threshold). Must be ≥ 2.86/// - n: Total shares to generate. Must be ≥ m and ≤ 255.87/// - Returns: Exactly `n` shares. Any `m` of them reconstruct `secret`.88 public static func split(secret: Data, m: Int, n: Int) throws ->[SSSShare]{89 guard m >= 2 else { throw ShamirBackupError.invalidParameters("threshold must be ≥ 2")}90 guard n >= m else { throw ShamirBackupError.invalidParameters("n must be ≥ m")}91 guard n <= 255 else { throw ShamirBackupError.invalidParameters("n must be ≤ 255")}92 guard !secret.isEmpty else { throw ShamirBackupError.invalidParameters("secret is empty")}9394 let shareID = UUID().uuidString95 let now = Date()96// shares[i][j] = share (i+1)'s y-value for secret byte j97 var shares =[[UInt8]](repeating:[UInt8](repeating: 0, count: secret.count), count: n)9899 for (byteIdx, secretByte) in secret.enumerated(){100// Random polynomial of degree m−1: f(x) = secretByte + a1*x + ... + a_{m-1}*x^{m-1}101 var coeffs =[UInt8](repeating: 0, count: m)102 coeffs[0]= secretByte103 for i in 1..<m {104 var rand: UInt8 = 0105 repeat { rand = UInt8.random(in: 0...255)} while i == m - 1 && rand == 0106 coeffs[i]= rand107}108 for xi in 1...n {109 shares[xi - 1][byteIdx]= evalPoly(coeffs: coeffs, x: UInt8(xi))110}111}112113 return (1...n).map { xi in114 SSSShare(115 id: shareID,116 index: UInt8(xi),117 threshold: UInt8(m),118 total: UInt8(n),119 data: Data(shares[xi - 1]),120 createdAt: now121)122}123}124125/// Reconstruct the secret from any `share.threshold` (or more) shares.126/// All shares must belong to the same backup instance (same `id`).127 public static func reconstruct(shares:[SSSShare]) throws -> Data {128 guard let first = shares.first else { throw ShamirBackupError.notEnoughShares }129 let m = Int(first.threshold)130 guard shares.count >= m else { throw ShamirBackupError.notEnoughShares }131 guard shares.allSatisfy({ $0.id == first.id && $0.data.count == first.data.count }) else {132 throw ShamirBackupError.inconsistentShares133}134// Duplicate x-coordinates cause division by zero in Lagrange interpolation,135// silently producing the wrong secret. Reject before touching any arithmetic.136 let used = Array(shares.prefix(m))137 let xs = used.map(\.index)138 guard Set(xs).count == xs.count else {139 throw ShamirBackupError.inconsistentShares140}141 let secretLen = first.data.count142 var secret =[UInt8](repeating: 0, count: secretLen)143 for byteIdx in 0..<secretLen {144 let ys = used.map { $0.data[byteIdx]}145 secret[byteIdx]= lagrangeAt0(xs: xs, ys: ys)146}147 return Data(secret)148}149150// MARK: - Share encryption / decryption151152 private static let hkdfInfo = Data("SophaxChat_SSS_Share_v1".utf8)153154 private static func deriveShareKey(from shared: SharedSecret)-> SymmetricKey {155 HKDF<SHA256>.deriveKey(156 inputKeyMaterial: SymmetricKey(data: shared.withUnsafeBytes { Data($0)}),157 info: hkdfInfo,158 outputByteCount: 32159)160}161162/// Encrypt `share` for a recipient identified by `recipientDHPublicKey` (32B X25519).163/// Returns (ephemeralPublicKey, ciphertext) to be sent in .sssShareDelivery.164 public static func encryptShare(_ share: SSSShare, recipientDHPublicKey: Data) throws ->(ephPublicKey: Data, ciphertext: Data){165 let ephPair = DHKeyPair()166 let recipKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: recipientDHPublicKey)167 let key = deriveShareKey(from: try ephPair.privateKey.sharedSecretFromKeyAgreement(with: recipKey))168 let sealed = try ChaChaPoly.seal(try JSONEncoder().encode(share), using: key)169 return (ephPublicKey: ephPair.publicKeyData, ciphertext: sealed.combined)170}171172/// Decrypt a share received in .sssShareDelivery using the local X25519 private key.173 public static func decryptShare(174 ephPublicKey: Data,175 ciphertext: Data,176 myDHPrivateKey: Curve25519.KeyAgreement.PrivateKey177) throws -> SSSShare {178 let ephKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: ephPublicKey)179 let key = deriveShareKey(from: try myDHPrivateKey.sharedSecretFromKeyAgreement(with: ephKey))180 do {181 let box = try ChaChaPoly.SealedBox(combined: ciphertext)182 return try JSONDecoder().decode(SSSShare.self, from: ChaChaPoly.open(box, using: key))183} catch {184 throw ShamirBackupError.decryptionFailed185}186}187188// MARK: - GF(256) arithmetic189// Irreducible polynomial: x^8 + x^4 + x^3 + x + 1 → reduction constant 0x1B.190191/// Multiplication in GF(256) via Russian-peasant method.192 static func gfMul(_ a: UInt8, _ b: UInt8)-> UInt8 {193 var p: UInt8 = 0194 var a = a, b = b195 for _ in 0..<8 {196 if b & 1 != 0 { p ^= a }197 let carry = a & 0x80 != 0198 a <<= 1199 if carry { a ^= 0x1B }200 b >>= 1201}202 return p203}204205/// Multiplicative inverse in GF(256): a^(2^8−2) = a^254 by Fermat's little theorem.206 static func gfInv(_ a: UInt8)-> UInt8 {207 guard a != 0 else { return 0 }208 return gfPow(a, 254)209}210211 private static func gfPow(_ base: UInt8, _ exp: Int)-> UInt8 {212 var result: UInt8 = 1213 var base = base, exp = exp214 while exp > 0 {215 if exp & 1 != 0 { result = gfMul(result, base)}216 base = gfMul(base, base)217 exp >>= 1218}219 return result220}221222/// Evaluate polynomial (Horner's method) at `x` in GF(256).223/// `coeffs[0]` is the constant term (= secret byte at x=0).224 private static func evalPoly(coeffs:[UInt8], x: UInt8)-> UInt8 {225 var result: UInt8 = 0226 for coeff in coeffs.reversed(){227 result = gfMul(result, x)^ coeff228}229 return result230}231232/// Lagrange interpolation at x=0 in GF(256).233/// Given `m` (x, y) pairs, returns f(0) = the secret byte.234 private static func lagrangeAt0(xs:[UInt8], ys:[UInt8])-> UInt8 {235 var secret: UInt8 = 0236 let n = xs.count237 for i in 0..<n {238 var num: UInt8 = 1 // ∏ (0 − x_j) = ∏ x_j (in GF, −x = x)239 var den: UInt8 = 1 // ∏ (x_i − x_j) = ∏ (x_i ⊕ x_j)240 for j in 0..<n where j != i {241 num = gfMul(num, xs[j])242 den = gfMul(den, xs[i]^ xs[j])243}244// ys[i] * num * den^(−1)245 secret ^= gfMul(ys[i], gfMul(num, gfInv(den)))246}247 return secret248}249}