1// MLSGroupManager.swift2// SophaxChatCore3//4// Swift actor wrapper around the UniFFI-generated mls-rs bindings.5//6// Design — stateless Rust layer, stateful Swift layer:7// - Every UniFFI function takes a serialised group_state blob and returns a new one.8// - MLSGroupManager owns persistence: Keychain via KeychainManager ("mls.state.<groupID>").9// - MlsClientHandle is a thin Arc<> in Rust; safe to hold long-term in the actor.10//11// Threading: all methods are actor-isolated — no external locking needed.1213import Foundation1415/// Coordinator-aware MLS group manager.16///17/// Only the group creator (coordinator) may call `createGroup`, `addMember`, `removeMember`.18/// All members call `processWelcome`, `processCommit`, `encrypt`, `decrypt`.19public actor MLSGroupManager {2021 private let client: MlsClientHandle22 private let keychain: KeychainManager2324// MARK: - Init2526/// - Parameters:27/// - peerID: Local peer's identifier (UTF-8, same as used in identity layer).28/// - signingKeyBytes: Raw 32-byte Ed25519 private key seed from IdentityManager.29/// - keychain: Shared KeychainManager for group-state persistence.30 public init(peerID: String, signingKeyBytes: Data, keychain: KeychainManager) throws {31 self.client = try MlsClientHandle(peerId: peerID, signingKeyBytes: signingKeyBytes)32 self.keychain = keychain33}3435// MARK: - KeyPackage3637/// Generate a fresh single-use MLS KeyPackage for this identity.38/// The caller should include this in the next Hello/PreKeyBundle so the39/// group coordinator can add this peer to an MLS group.40 public func generateKeyPackage() throws -> Data {41 try client.generateKeyPackage()42}4344// MARK: - Coordinator-only: group lifecycle4546/// Create a new MLS group and produce a Welcome for each initial member.47///48/// - Parameters:49/// - groupID: UUID string identifying the group (same as GroupInfo.id).50/// - memberKeyPackages: Raw KeyPackage bytes for each invited peer51/// (obtained from their PreKeyBundle.mlsKeyPackage).52/// - Returns: `CreateGroupOutput` containing the coordinator's initial group state53/// and one `MemberWelcome` per invitee. Send each Welcome via DR unicast.54 public func createGroup(groupID: String, memberKeyPackages:[Data]) throws -> CreateGroupOutput {55 let groupIdData = groupIDData(groupID)56 let output = try mlsCreateGroup(client: client, groupId: groupIdData, memberKeyPackages: memberKeyPackages)57 try saveState(output.groupState, groupID: groupID)58 return output59}6061/// Add a member to an existing MLS group (coordinator only).62///63/// - Parameters:64/// - groupID: Group to update.65/// - keyPackage: The new member's MLS KeyPackage bytes.66/// - Returns: `CommitOutput` with `commitBytes` to broadcast and `welcomeBytes` to67/// unicast to the new member.68 public func addMember(groupID: String, keyPackage: Data) throws -> CommitOutput {69 let state = try loadState(groupID: groupID)70 let groupIdData = groupIDData(groupID)71 let output = try mlsAddMember(client: client, groupId: groupIdData, groupState: state, keyPackageBytes: keyPackage)72 try saveState(output.newGroupState, groupID: groupID)73 return output74}7576/// Remove a member from an existing MLS group (coordinator only).77///78/// - Parameters:79/// - groupID: Group to update.80/// - peerID: PeerID of the member to remove.81/// - Returns: `CommitOutput` with `commitBytes` to broadcast. `welcomeBytes` is always nil.82 public func removeMember(groupID: String, peerID: String) throws -> CommitOutput {83 let state = try loadState(groupID: groupID)84 let groupIdData = groupIDData(groupID)85 let output = try mlsRemoveMember(client: client, groupId: groupIdData, groupState: state, peerId: peerID)86 try saveState(output.newGroupState, groupID: groupID)87 return output88}8990// MARK: - Any member: join & update9192/// Process a Welcome message received via DR unicast and join the group.93///94/// - Parameters:95/// - welcomeBytes: Raw bytes from `MemberWelcome.welcomeBytes`.96/// - ratchetTree: Raw bytes from `MemberWelcome.ratchetTreeBytes` (may be nil).97/// - Returns: The groupID as `Data` (UTF-8 string bytes as used in the group).98@discardableResult99 public func processWelcome(_ welcomeBytes: Data, ratchetTree: Data?) throws -> Data {100 let newState = try mlsProcessWelcome(client: client, welcomeBytes: welcomeBytes, ratchetTreeBytes: ratchetTree)101// Extract groupID: we need it to key the stored state.102// The state blob is opaque; we must ask the caller to provide the groupID103// from the accompanying MLSWelcomeMessage envelope.104// Store under a temp key — callers MUST call storeWelcomeState after.105 pendingWelcomeState = newState106 return newState // caller uses the accompanying groupID from the wire message107}108109/// Temporary storage for a just-processed Welcome state, before the caller110/// knows which groupID to associate it with.111/// Call `commitWelcomeState(groupID:)` immediately after `processWelcome`.112 private var pendingWelcomeState: Data?113114/// Persist the Welcome state under the given groupID.115/// Must be called once after every `processWelcome` call.116 public func commitWelcomeState(groupID: String) throws {117 guard let state = pendingWelcomeState else {118 throw SophaxError.invalidMessageFormat("No pending MLS welcome state to commit")119}120 try saveState(state, groupID: groupID)121 pendingWelcomeState = nil122}123124/// Process a Commit broadcast from the coordinator.125///126/// - Parameters:127/// - groupID: Group this commit belongs to.128/// - commitBytes: Raw Commit message bytes from `MLSCommitMessage.commitBytes`.129/// - Returns: `ProcessedCommit` containing added/removed peerIDs and the new epoch.130 public func processCommit(groupID: String, commitBytes: Data) throws -> ProcessedCommit {131 let state = try loadState(groupID: groupID)132 let groupIdData = groupIDData(groupID)133 let result = try mlsProcessCommit(client: client, groupId: groupIdData, groupState: state, commitBytes: commitBytes)134 try saveState(result.newGroupState, groupID: groupID)135 return result136}137138// MARK: - Any member: encrypt / decrypt139140/// Encrypt an application message for the group.141///142/// - Parameters:143/// - groupID: Target group.144/// - plaintext: Raw message bytes (typically UTF-8 JSON envelope).145/// - Returns: Opaque MLS ciphertext to broadcast as `MLSApplicationMessage.ciphertext`.146 public func encrypt(groupID: String, plaintext: Data) throws -> Data {147 let state = try loadState(groupID: groupID)148 let groupIdData = groupIDData(groupID)149 let output = try mlsEncrypt(client: client, groupId: groupIdData, groupState: state, plaintext: plaintext)150 try saveState(output.newGroupState, groupID: groupID)151 return output.ciphertext152}153154/// Decrypt an MLS application message.155///156/// - Parameters:157/// - groupID: Group this message belongs to.158/// - ciphertext: Raw ciphertext from `MLSApplicationMessage.ciphertext`.159/// - Returns: Decrypted plaintext bytes.160 public func decrypt(groupID: String, ciphertext: Data) throws -> Data {161 let state = try loadState(groupID: groupID)162 let groupIdData = groupIDData(groupID)163 let output = try mlsDecrypt(client: client, groupId: groupIdData, groupState: state, ciphertext: ciphertext)164 try saveState(output.newGroupState, groupID: groupID)165 return output.plaintext166}167168// MARK: - State lifecycle169170/// Delete persisted MLS state for a group (called on leave or account wipe).171 public func deleteGroupState(groupID: String) throws {172 try keychain.deleteMlsGroupState(groupID: groupID)173}174175/// Returns true if a persisted MLS state exists for the given group.176 public func hasGroup(groupID: String)-> Bool {177 keychain.loadMlsGroupState(groupID: groupID)!= nil178}179180// MARK: - Private helpers181182 private func loadState(groupID: String) throws -> Data {183 guard let state = keychain.loadMlsGroupState(groupID: groupID) else {184 throw SophaxError.invalidMessageFormat("No MLS group state for group \(groupID)")185}186 return state187}188189 private func saveState(_ state: Data, groupID: String) throws {190 try keychain.saveMlsGroupState(state, groupID: groupID)191}192193 private func groupIDData(_ id: String)-> Data {194 id.data(using:.utf8)?? Data(id.utf8)195}196}