diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 0000000..85b884a --- /dev/null +++ b/.periphery.yml @@ -0,0 +1 @@ +retain_public: true diff --git a/Sources/DistributedMLS/Client.swift b/Sources/DistributedMLS/Client.swift index 360dd5a..4ce8cca 100644 --- a/Sources/DistributedMLS/Client.swift +++ b/Sources/DistributedMLS/Client.swift @@ -10,12 +10,9 @@ import Foundation extension DiMLS { public protocol Client: Actor, Archivable { associatedtype Credential: DiMLSCredential - associatedtype Invitation: DiInvitation //where Invitation.Credential == Credential - associatedtype Group: DiGroup where Group.WelcomeOutput == WelcomeOutput + associatedtype Group: DiGroup + where Group.WelcomeOutput == WelcomeOutput, Group.Credential == Credential associatedtype WelcomeOutput: WelcomeOutputInterface - where - Group.Credential == Credential - // Invitation.WelcomeOutput == WelcomeOutput static func create(credential: Credential) throws -> Self @@ -38,18 +35,11 @@ extension DiMLS { func process( wireWelcome: Data, - keyPackageId: KeyPackageId? + keyPackageId: KeyPackageId?, + knownDependencies: [DiMLS.KeyedDependency] ) throws -> ( KeyPackageId, WelcomeOutput ) } } - -public protocol Archivable { - associatedtype Archive: Sendable, Codable - init(archive: Archive) throws - - //helps to define this in the protocol for Actor protocols - // var archive: Archive { get throws } -} diff --git a/Sources/DistributedMLS/DiInvitation.swift b/Sources/DistributedMLS/DiInvitation.swift deleted file mode 100644 index c9ec76c..0000000 --- a/Sources/DistributedMLS/DiInvitation.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// DiInvitation.swift -// DiMLS -// -// Created by Mark @ Germ on 1/4/26. -// - -import Foundation - -///An invitation is a logical entity that can be added to DiGroups -extension DiMLS { - public typealias KeyPackageId = Data - public protocol DiInvitation: Archivable { - // associatedtype Credential: DiMLSCredential - // associatedtype WelcomeOutput - - var currentKeyPackage: (keyPackageId: Data, keyPackageMessage: Data) { get throws } - - ///returns an atomic group (archive) - ///the application determines which DiGroup it should belong to based - ///on the resulting group's groupId - // func process(mlsWelcome: Data, keyPackageId: KeyPackageId?) throws -> ( - // KeyPackageId, - // WelcomeOutput - // ) - } -} diff --git a/Sources/DistributedMLS/DiMLS.swift b/Sources/DistributedMLS/DiMLS.swift index ddd9e0e..3d8710c 100644 --- a/Sources/DistributedMLS/DiMLS.swift +++ b/Sources/DistributedMLS/DiMLS.swift @@ -8,9 +8,9 @@ import Foundation public enum DiMLS { - //like send group id public typealias ReferenceID = Data public typealias EpochID = UInt64 + public typealias KeyPackageId = Data ///mirrors (is a) MLS private message in that it has an encrypted ///application message and plaintext metadata and auth data @@ -18,13 +18,13 @@ public enum DiMLS { ///of the collective group. public struct PrivateMessage: Sendable { public let body: Data //encoded MLS Private message - public let epoch: UInt64 //we may retry transmit after a later commit + public let epoch: EpochID //we may retry transmit after a later commit public let sender: C public let addressees: [C] public init( body: Data, - epoch: UInt64, + epoch: EpochID, sender: C, addressees: [C] ) { @@ -35,22 +35,17 @@ public enum DiMLS { } } - public enum DecryptOutput { - case control //todo: type out the control plane message - case application(plaintext: Data) - } - public struct EncryptOutput: Sendable { //Implementation may choose to staple the commit and/or //encrypt headers public let privateMessage: Data //if not stapled let the caller judge if it wants resend the commit - public let epoch: UInt64 + public let epoch: EpochID public let additionalCommits: [EpochCommit] public init( privateMessage: Data, - epoch: UInt64, + epoch: EpochID, additionalCommits: [EpochCommit] ) { self.privateMessage = privateMessage @@ -60,10 +55,10 @@ public enum DiMLS { } public struct EpochCommit: Sendable, Codable { - public let epoch: UInt64 + public let epoch: EpochID public let commit: Data - public init(epoch: UInt64, commit: Data) { + public init(epoch: EpochID, commit: Data) { self.epoch = epoch self.commit = commit } diff --git a/Sources/DistributedMLS/Group/CommitInput.swift b/Sources/DistributedMLS/Group/CommitInput.swift index c6d8b8f..1cecf38 100644 --- a/Sources/DistributedMLS/Group/CommitInput.swift +++ b/Sources/DistributedMLS/Group/CommitInput.swift @@ -5,33 +5,43 @@ // Created by Mark @ Germ on 1/11/26. // +import Foundation + extension DiMLS { public struct CommitInput { - public var proposals: Set> + public var localOps: Set> + public var followOps: Set> - //psk's - struct Dependency { - let pskSource: ReferenceID - let epoch: EpochID - } - var dependencies: [Dependency] + public var dependencies: [KeyedDependency] public var newSenderLeafNode: Bool public init() { - proposals = [] + localOps = [] + followOps = [] dependencies = [] newSenderLeafNode = false } + } + + //psk's + public struct Dependency: Codable, Sendable, Hashable { + public let pskSource: ReferenceID + public let epoch: EpochID + + public init(pskSource: ReferenceID, epoch: EpochID) { + self.pskSource = pskSource + self.epoch = epoch + } + } + + public struct KeyedDependency: Codable, Sendable { + public let dependency: Dependency + public let keyData: Data - private init( - proposals: [DiMLSOperations], - dependencies: [Dependency], - newSenderLeafNode: Bool - ) { - self.proposals = .init(proposals) - self.dependencies = dependencies - self.newSenderLeafNode = newSenderLeafNode + public init(dependency: Dependency, keyData: Data) { + self.dependency = dependency + self.keyData = keyData } } } diff --git a/Sources/DistributedMLS/Group/Decrypt/DecryptOutput.swift b/Sources/DistributedMLS/Group/Decrypt/DecryptOutput.swift new file mode 100644 index 0000000..01fe5b1 --- /dev/null +++ b/Sources/DistributedMLS/Group/Decrypt/DecryptOutput.swift @@ -0,0 +1,29 @@ +// +// DecryptOutput.swift +// DistributedMLS +// +// Created by Mark @ Germ on 1/19/26. +// + +import Foundation + +extension DiMLS { + public struct DecryptOutput: Sendable { + let appPlaintext: AppPlaintext + let controlMessage: ControlMessage? + } + + public struct ControlMessage: Sendable { + + } + + public struct AppPlaintext: Sendable { + public let application: Data + public let authenticating: Data + + public init(application: Data, authenticating: Data) { + self.application = application + self.authenticating = authenticating + } + } +} diff --git a/Sources/DistributedMLS/Group/DiGroup.swift b/Sources/DistributedMLS/Group/DiGroup.swift index 51d504f..08aabd8 100644 --- a/Sources/DistributedMLS/Group/DiGroup.swift +++ b/Sources/DistributedMLS/Group/DiGroup.swift @@ -13,7 +13,7 @@ extension DiMLS { associatedtype Credential: DiMLSCredential //State Types - associatedtype Receiver: ReceiveChannel + associatedtype Receiver: ReceiveChannel where Receiver.WelcomeOutput == WelcomeOutput associatedtype Sender: SendChannel where Sender.Credential == Credential associatedtype WelcomeOutput: WelcomeOutputInterface @@ -24,7 +24,8 @@ extension DiMLS { //associated State //mutable object - var totalGroup: DiGroupState { get } + nonisolated var diGroupId: Data { get } + var totalGroup: DiMLS.TotalGroup { get } var receivers: [ReferenceID: Receiver] { get set } var pendingState: PendingState { get } var lazySender: LazySendChannel { get set } @@ -35,41 +36,13 @@ extension DiMLS { //(add + dependency), so we let the implementation modify the pending //state to pop off the actions it can make progress on func prepareCommit() throws -> DiMLS.CommitInput + //different interface as it is initially handled by the init key + //corresponding to a keyPackage func received(welcome: WelcomeOutput) throws - - //Deprecate: - ///DiGgroup Operations - // associatedtype RemoteState: RemoteStateInterface - // var remoteStates: [ReferenceID: RemoteState] { get } - - //Local mutations only have meaning when I broadcast them into the world - // func stageAdd(member: DiMLS.CredentialedKeyPackage) throws - // func stageDelete(member: Credential) throws - // //application should drive if new key material is needed + //if we know we can process directly with the symmetric ratchet + func received(privateMessage: Data) throws -> AppPlaintext + func received(ciphertext: Data) throws -> DecryptOutput // func stageNewLocalKeyMaterial() throws - - ///DiGroup 1:1 Control Plane - ///expect sender crendential implicit in assigned transport path - // func receive(ciphertext: Data, from: Credential) throws -> DiMLS.DecryptOutput - - //let the application get and resend unack'd commits - //TODO: attach metadata to the result like an epoch no - // func inFlightFor(remote: Credential) throws -> [Data] - - //Application messages - //this will commit any pending changes - //TODO: also report state changes - //for now, authenticated data is included in the output - //(because MLS does in the PrivateMessage format) - //returns private message and recipients - // func encrypt(plaintext: Data, authenticating: Data) throws - // -> DiMLS.PrivateMessage - // func stapledEncrypt(plaintext: Data) throws - // -> DiMLS.PrivateMessage - - //process incoming, which may be an application message, commit - //or a welcome to another user's send group - } } @@ -91,8 +64,8 @@ extension DiMLS.DiGroup { credentialFetcher: @escaping CredentialKeyPackageFetcher, referenceIdFetcher: @escaping ReferenceIdKeyPackageFetcher ) throws -> Task { - guard case .queued = lazySender else { - if case .creating(let task) = lazySender { + guard case .queued(let dependency) = lazySender else { + if case .creating(let task, _) = lazySender { return task } throw DiMLSError.sendGroupNotReady @@ -101,6 +74,10 @@ extension DiMLS.DiGroup { do { let archive = try await createSendGroup( myCredential: myCredential, + members: + totalGroup + .membershipForCreating(sender: myCredential.referenceId), + dependency: dependency, credentialFetcher: credentialFetcher, referenceIdFetcher: referenceIdFetcher ) @@ -113,34 +90,35 @@ extension DiMLS.DiGroup { lazySender = .ready( try .init( archive: archive, + diGroupId: diGroupId, identityProvider: identityProvider ) ) } catch { print("error creating: \(error)") - lazySender = .queued + lazySender = .queued(dependency) throw error } } - lazySender = .creating(task) + lazySender = .creating(task, dependency) return task } private func createSendGroup( myCredential: Credential, + members: [DiMLS.ReferenceID: DiMLS.Participant], + dependency: DiMLS.KeyedDependency?, credentialFetcher: CredentialKeyPackageFetcher, referenceIdFetcher: ReferenceIdKeyPackageFetcher ) async throws -> Sender.Archive { - if case .ready = lazySender { - throw DiMLSError.disallowed - } + //capture a snapshot of what the group needs var remotes = [DiMLS.ReferenceID: SendChannelInputs.Remote]() - for member - in totalGroup + let members = + try totalGroup .membershipForCreating(sender: myCredential.referenceId) - { + for member in members { switch member.value { case .credential(let credential, let epoch): let keyPackage = try await credentialFetcher(credential) @@ -162,14 +140,15 @@ extension DiMLS.DiGroup { return try Sender.create( input: .init( - diGroupID: totalGroup.diGroupId, + diGroupID: diGroupId, myCredential: myCredential, remotes: remotes ), identityProvider: Sender.identityProvider( totalGroup: totalGroup, sender: myCredential - ) + ), + dependency: dependency ) } @@ -199,8 +178,6 @@ extension DiMLS.DiGroup { let input = try prepareCommit() return try commit(input: input, sender: sender) - - throw DiMLSError.notImplemented } private func commit( @@ -210,7 +187,7 @@ extension DiMLS.DiGroup { var newRemotes: [DiMLS.CredentialedKeyPackage] = [] //modify remotes and group - for action in input.proposals { + for action in input.localOps { switch action { case .add(let credentialKeyPackage): newRemotes.append(credentialKeyPackage) @@ -241,79 +218,24 @@ extension DiMLS.DiGroup { dependencies: [:] ) } -} -//(Deprecate) full-featured compound api's -extension DiMLS.DiGroup { - // public func encryptWithCommits( - // plaintext: Data, - // authenticating: Data, - // staplingCommit: Bool - // ) throws -> [(Credential, DiMLS.EncryptOutput)] { - // //use the ratchet tree - // let privateMessage = try encrypt( - // plaintext: plaintext, - // authenticating: authenticating - // ) - // - // return try privateMessage.addressees.map { credential in - // guard let remoteState = remoteStates[credential.referenceId] else { - // throw DiMLSError.missingRemoteState - // } - // - // return ( - // credential, - // try remoteState.package( - // privateMessage: privateMessage.body, - // epoch: privateMessage.epoch, - // staplingCommit: staplingCommit - // ) - // ) - // } - // } - - // public func received(welcome: WelcomeOutput) throws { - // guard welcome.diGroupId == totalGroup.diGroupId else { - // throw DiMLSError.mismatchedGroupId - // } - // - // let senderReferenceId = try welcome.senderReferenceId - // - // //is this a member of the group? - // if totalGroup.members.contains(senderReferenceId) { - // try expectedMember(welcome: welcome) - // } else { - // try newMember(welcome: welcome) - // } - // - // //is this a - // } - - private func newMember(welcome: WelcomeOutput) throws { + public func received(welcome: WelcomeOutput) throws { + guard welcome.diGroupId == diGroupId else { + throw DiMLSError.mismatchedGroupId + } + let senderReferenceId = try welcome.senderReferenceId - assert(!totalGroup.members.keys.contains(senderReferenceId)) - guard totalGroup.canAdd(try welcome.senderReferenceId) == nil else { - throw DiMLSError.duplicateSendGroup + //is this a member of the group? + try totalGroup + .readyToWelcome(member: welcome.senderReferenceId) + + guard receivers[senderReferenceId] == nil else { + throw DiMLSError.duplicateMember } - throw DiMLSError.notImplemented + receivers[senderReferenceId] = try .create(welcome: welcome) } - // private func expectedMember(welcome: WelcomeOutput) throws { - // let senderReferenceId = try welcome.senderReferenceId - // assert(totalGroup.members.contains(senderReferenceId)) - // - // //where do I process this new welcome? - // //do I already have a sendgroup for this sender? - // if let remoteState = remoteStates[senderReferenceId] { - // guard !remoteState.receivedWelcome else { - // throw DiMLSError.duplicateSendGroup - // } - // //can setup the remoteSTate - // } else { - // - // } - // } } //we have a generic (Credential) and a non-generic and could simplify them diff --git a/Sources/DistributedMLS/Group/PendingInterface.swift b/Sources/DistributedMLS/Group/PendingInterface.swift deleted file mode 100644 index 27fa47c..0000000 --- a/Sources/DistributedMLS/Group/PendingInterface.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// PendingInterface.swift -// DiMLS -// -// Created by Mark @ Germ on 1/7/26. -// - -import Foundation - -protocol PendingInterface: Archivable { - -} diff --git a/Sources/DistributedMLS/Group/ReceiveChannel.swift b/Sources/DistributedMLS/Group/ReceiveChannel.swift index ce404f6..575d20a 100644 --- a/Sources/DistributedMLS/Group/ReceiveChannel.swift +++ b/Sources/DistributedMLS/Group/ReceiveChannel.swift @@ -8,5 +8,6 @@ import Foundation public protocol ReceiveChannel { - + associatedtype WelcomeOutput: WelcomeOutputInterface + static func create(welcome: WelcomeOutput) throws -> Self } diff --git a/Sources/DistributedMLS/Group/SendChannel.swift b/Sources/DistributedMLS/Group/SendChannel.swift index a65164d..3b7975c 100644 --- a/Sources/DistributedMLS/Group/SendChannel.swift +++ b/Sources/DistributedMLS/Group/SendChannel.swift @@ -17,16 +17,21 @@ public protocol SendChannel { associatedtype Welcome static func identityProvider( - totalGroup: DiGroupState, + totalGroup: DiMLS.TotalGroup, sender: Credential ) -> IdentityProvider static func create( input: SendChannelInputs, - identityProvider: IdentityProvider + identityProvider: IdentityProvider, + dependency: DiMLS.KeyedDependency? ) throws -> Archive - init(archive: Archive, identityProvider: IdentityProvider) throws + init( + archive: Archive, + diGroupId: Data, + identityProvider: IdentityProvider + ) throws var archive: Archive { get throws } @@ -75,21 +80,26 @@ public struct SendChannelInputs { public enum LazySendChannel { case ready(R) - case queued + case queued(DiMLS.KeyedDependency?) //serves as a mutex on snapshot of Q state - case creating(Task) + case creating(Task, DiMLS.KeyedDependency?) public init( archive: Archive, + diGroupId: Data, identityProvider: R.IdentityProvider ) throws { switch archive { case .ready(let archive): self = .ready( - try .init(archive: archive, identityProvider: identityProvider) + try .init( + archive: archive, + diGroupId: diGroupId, + identityProvider: identityProvider + ) ) - case .queued: - self = .queued + case .queued(let dependency): + self = .queued(dependency) } } @@ -106,7 +116,7 @@ public enum LazySendChannel { extension LazySendChannel { public enum Archive: Sendable, Codable { case ready(R.Archive) - case queued + case queued(DiMLS.KeyedDependency?) } public var archive: Archive { @@ -114,10 +124,10 @@ extension LazySendChannel { switch self { case .ready(let r): try .ready(r.archive) - case .queued: - try .queued - case .creating: - try .queued + case .queued(let dependency): + .queued(dependency) + case .creating(_, let dependency): + .queued(dependency) } } } diff --git a/Sources/DistributedMLS/Helpers/Archivable.swift b/Sources/DistributedMLS/Helpers/Archivable.swift new file mode 100644 index 0000000..5d0f070 --- /dev/null +++ b/Sources/DistributedMLS/Helpers/Archivable.swift @@ -0,0 +1,14 @@ +// +// Archivable.swift +// DistributedMLS +// +// Created by Mark @ Germ on 1/17/26. +// + +public protocol Archivable { + associatedtype Archive: Sendable, Codable + init(archive: Archive) throws + + //helps to define this in the protocol for Actor protocols + // var archive: Archive { get throws } +} diff --git a/Sources/DistributedMLS/ConvenienceError.swift b/Sources/DistributedMLS/Helpers/ConvenienceError.swift similarity index 58% rename from Sources/DistributedMLS/ConvenienceError.swift rename to Sources/DistributedMLS/Helpers/ConvenienceError.swift index 8553ba5..e7c26b8 100644 --- a/Sources/DistributedMLS/ConvenienceError.swift +++ b/Sources/DistributedMLS/Helpers/ConvenienceError.swift @@ -23,14 +23,31 @@ extension Optional { } } +extension Array { + public var expectOne: Element { + get throws { + switch count { + case 0: throw ConvenienceError.emptyArray + case 1: try first.tryUnwrap + default: throw ConvenienceError.tooManyElements + } + } + } +} + enum ConvenienceError: Error { case missingOptional(String) + case emptyArray + case tooManyElements } extension ConvenienceError: LocalizedError { var errorDescription: String? { switch self { case .missingOptional(let type): "Expected to find an optional \(type), but didn't." + case .emptyArray: "Expected to find a value in an array, but the array was empty." + case .tooManyElements: + "Expected to find a single value in an array, but the array had multiple values." } } } diff --git a/Sources/DistributedMLS/Error.swift b/Sources/DistributedMLS/Helpers/Error.swift similarity index 81% rename from Sources/DistributedMLS/Error.swift rename to Sources/DistributedMLS/Helpers/Error.swift index c94138d..19c0b3a 100644 --- a/Sources/DistributedMLS/Error.swift +++ b/Sources/DistributedMLS/Helpers/Error.swift @@ -7,7 +7,7 @@ import Foundation -enum DiMLSError: Error { +public enum DiMLSError: Error { case missingRemoteState case mismatchedGroupId case duplicateSendGroup @@ -15,6 +15,7 @@ enum DiMLSError: Error { case disallowed case sendGroupNotReady case duplicateMember + case expecting(DiMLS.Dependency) } extension DiMLSError: LocalizedError { @@ -26,7 +27,8 @@ extension DiMLSError: LocalizedError { case .notImplemented: "Not implemented" case .disallowed: "Disallowed" case .sendGroupNotReady: "Send group not ready" - case .duplicateMember: "Duplicate memeber" + case .duplicateMember: "Duplicate member" + case .expecting: "Missing dependency" } } } diff --git a/Sources/DistributedMLS/Intefaces/DiMLSCredential.swift b/Sources/DistributedMLS/Interfaces/DiMLSCredential.swift similarity index 100% rename from Sources/DistributedMLS/Intefaces/DiMLSCredential.swift rename to Sources/DistributedMLS/Interfaces/DiMLSCredential.swift diff --git a/Sources/DistributedMLS/Intefaces/WelcomeOutputInterface.swift b/Sources/DistributedMLS/Interfaces/WelcomeOutputInterface.swift similarity index 72% rename from Sources/DistributedMLS/Intefaces/WelcomeOutputInterface.swift rename to Sources/DistributedMLS/Interfaces/WelcomeOutputInterface.swift index d7e5bac..3a67f64 100644 --- a/Sources/DistributedMLS/Intefaces/WelcomeOutputInterface.swift +++ b/Sources/DistributedMLS/Interfaces/WelcomeOutputInterface.swift @@ -10,4 +10,6 @@ import Foundation public protocol WelcomeOutputInterface: Sendable { var diGroupId: Data { get } var senderReferenceId: DiMLS.ReferenceID { get throws } + var keyedDependency: DiMLS.KeyedDependency? { get } + var appPrivateMessage: Data? { get } } diff --git a/Sources/DistributedMLS/TotalGroup/DiGroupState.swift b/Sources/DistributedMLS/TotalGroup/DiGroupState.swift deleted file mode 100644 index 8514899..0000000 --- a/Sources/DistributedMLS/TotalGroup/DiGroupState.swift +++ /dev/null @@ -1,194 +0,0 @@ -// -// TotalGroup.swift -// DiMLS -// -// Created by Mark @ Germ on 12/29/25. -// - -import Foundation - -//state of the observed group, including (permanently) removed members -public final class DiGroupState { - ///we can join at any time, and don't need to reconstruct adds from the group membership - public let diGroupId: Data - - public private(set) var members: [DiMLS.ReferenceID: Membership] - - public var count: Int { members.count } - - private init( - diGroupId: Data, - members: [DiMLS.ReferenceID: Membership], - ) { - self.diGroupId = diGroupId - self.members = members - } - - public convenience init(archive: Archive) throws { - self.init( - diGroupId: archive.diGroupId, - members: try archive.members.mapValues { try .init(archive: $0) }, - ) - } - - public func canAdd(_ member: DiMLS.ReferenceID) -> Bool { - !members.keys.contains(member) - } - - public func add(member: Credential) throws { - guard canAdd(member.referenceId) else { - throw DiMLSError.duplicateMember - } - assert(members[member.referenceId] == nil) - members[member.referenceId] = .new(credential: member) - } - - // public func added(member: DiMLS.ReferenceID) throws { - // guard canAdd(member) else { - // throw DiMLSError.disallowed - // } - // members.insert(member) - // } - - func membershipForCreating( - sender: DiMLS.ReferenceID - ) -> [DiMLS.ReferenceID: DiMLS.Participant] { - members.compactMapValues { membership in - guard membership.referenceId != sender else { - return nil - } - let epoch = membership.epochs.last - if let epoch { - return .credential(epoch.credential, epoch.epoch) - } else { - return .referenceId(membership.referenceId) - } - } - } -} - -extension DiMLS { - enum Operation { - //in this context, the creator added themselves implicitly - case add(ReferenceID) - } -} - -extension DiGroupState: Archivable { - public struct Archive: Codable, Sendable { - public let diGroupId: Data - public let members: [DiMLS.ReferenceID: Membership.Archive] - //array makes it easier to encode stably over the wire - - public init( - diGroupId: Data, - members: [DiMLS.ReferenceID: Membership.Archive] - ) { - self.diGroupId = diGroupId - self.members = members - } - } - - public var archive: Archive { - .init( - diGroupId: diGroupId, - members: members.mapValues(\.archive), - ) - } -} - -extension DiGroupState { - public struct Membership { - let referenceId: DiMLS.ReferenceID - //can prune, but should never prune to empty as - //empty indicates invited - public private(set) var epochs: [Epoch] //should be in increasing epoch order - - static func new(credential: Credential) -> Self { - .init( - referenceId: credential.referenceId, - epochs: [] - ) - } - - init(referenceId: DiMLS.ReferenceID, epochs: [Epoch]) { - self.referenceId = referenceId - self.epochs = epochs - } - - public struct Epoch: Archivable { - let epoch: UInt64 - public let credential: Credential - - init(epoch: UInt64, credential: Credential) { - self.epoch = epoch - self.credential = credential - } - - public init(archive: Archive) throws { - self.init( - epoch: archive.epoch, - credential: try .init(encoded: archive.credential) - ) - } - - public struct Archive: Codable, Sendable { - let epoch: UInt64 - let credential: Data - - public init(epoch: UInt64, credential: Data) { - self.epoch = epoch - self.credential = credential - } - } - - var archive: Archive { - .init(epoch: epoch, credential: credential.encoded) - } - } - } -} - -extension DiGroupState.Membership: Archivable { - public struct Archive: Codable, Sendable { - let referenceId: DiMLS.ReferenceID - let epochs: [Epoch.Archive] - - public init(referenceId: DiMLS.ReferenceID, epochs: [Epoch.Archive]) { - self.referenceId = referenceId - self.epochs = epochs - } - - public static func create(referenceId: DiMLS.ReferenceID) -> Self { - .init(referenceId: referenceId, epochs: []) - } - - public static func create( - referenceId: DiMLS.ReferenceID, - credential: Data, - epoch: UInt64 - ) -> Self { - .init( - referenceId: referenceId, - epochs: [ - .init( - epoch: epoch, - credential: credential - ) - ] - ) - } - } - - public init(archive: Archive) throws { - referenceId = archive.referenceId - epochs = try archive.epochs.map { try .init(archive: $0) } - } - - var archive: Archive { - .init( - referenceId: referenceId, - epochs: epochs.map(\.archive) - ) - } -} diff --git a/Sources/DistributedMLS/TotalGroup/TotalGroup.swift b/Sources/DistributedMLS/TotalGroup/TotalGroup.swift new file mode 100644 index 0000000..93cbb52 --- /dev/null +++ b/Sources/DistributedMLS/TotalGroup/TotalGroup.swift @@ -0,0 +1,176 @@ +// +// TotalGroup.swift +// DiMLS +// +// Created by Mark @ Germ on 12/29/25. +// + +import Foundation + +//state of the observed group, including (permanently) removed members +extension DiMLS { + public final class TotalGroup { + ///we can join at any time, and don't need to reconstruct adds from the group membership + public private(set) var members: [ReferenceID: Membership] + + public var count: Int { members.count } + + private init(members: [ReferenceID: Membership]) { + self.members = members + } + + public convenience init(archive: Archive) throws { + self.init( + members: try archive.members.mapValues { try .init(archive: $0) }, + ) + } + + public func canAdd(_ member: ReferenceID) -> Bool { + !members.keys.contains(member) + } + + public func add(member: Credential) throws { + guard canAdd(member.referenceId) else { + throw DiMLSError.duplicateMember + } + assert(members[member.referenceId] == nil) + members[member.referenceId] = .new(dependency: nil) + } + + // public func added(member: DiMLS.ReferenceID) throws { + // guard canAdd(member) else { + // throw DiMLSError.disallowed + // } + // members.insert(member) + // } + + func membershipForCreating( + sender: ReferenceID + ) throws -> [ReferenceID: Participant] { + try members.reduce(into: [:]) { + result, + pair in + guard pair.key != sender else { + return + } + assert(result[pair.key] == nil) + switch pair.value { + case .invited(let dependencies): + result[pair.key] = .referenceId(pair.key) + case .claimed(let epochs): + let epoch = try epochs.last.tryUnwrap + result[pair.key] = .credential( + epoch.credential, + epoch.epoch + ) + } + } + } + + public func readyToWelcome(member: ReferenceID) throws { + try members[member].tryUnwrap + .readyToWelcome(member: member) + } + } +} + +extension DiMLS.TotalGroup: Archivable { + public struct Archive: Codable, Sendable { + public let members: [DiMLS.ReferenceID: Membership.Archive] + //array makes it easier to encode stably over the wire + + public init(members: [DiMLS.ReferenceID: Membership.Archive]) { + self.members = members + } + } + + public var archive: Archive { + .init( + members: members.mapValues(\.archive), + ) + } +} + +extension DiMLS.TotalGroup { + public enum Membership { + //can be empty so that as an identity provider it allows me to add them, + //then lets me fill in the dependency + case invited([DiMLS.KeyedDependency]) + case claimed([Epoch]) + + static func new( + dependency: DiMLS.KeyedDependency? + ) -> Self { + .invited([dependency].compactMap(\.self)) + } + + public struct Epoch: Archivable { + let epoch: UInt64 + public let credential: Credential + + init(epoch: UInt64, credential: Credential) { + self.epoch = epoch + self.credential = credential + } + + public init(archive: Archive) throws { + self.init( + epoch: archive.epoch, + credential: try .init(encoded: archive.credential) + ) + } + + public struct Archive: Codable, Sendable { + let epoch: UInt64 + let credential: Data + + public init(epoch: UInt64, credential: Data) { + self.epoch = epoch + self.credential = credential + } + } + + var archive: Archive { + .init(epoch: epoch, credential: credential.encoded) + } + } + + func readyToWelcome(member: DiMLS.ReferenceID) throws { + guard case .invited = self else { + throw DiMLSError.duplicateMember + } + } + } +} + +extension DiMLS.TotalGroup.Membership: Archivable { + public enum Archive: Codable, Sendable { + case invited([DiMLS.KeyedDependency]) + case claimed([Epoch.Archive]) + + public static func create( + credential: Data, + epoch: UInt64 + ) -> Self { + .claimed([.init(epoch: epoch, credential: credential)]) + } + } + + public init(archive: Archive) throws { + switch archive { + case .claimed(let epochs): + self = .claimed(try epochs.map { try .init(archive: $0) }) + case .invited(let dependencies): + self = .invited(dependencies) + } + } + + var archive: Archive { + switch self { + case .claimed(let epochs): + .claimed(epochs.map(\.archive)) + case .invited(let dependencies): + .invited(dependencies) + } + } +} diff --git a/Sources/DistributedMLS/WireInterfaces.swift b/Sources/DistributedMLS/WireInterfaces.swift deleted file mode 100644 index 4a99546..0000000 --- a/Sources/DistributedMLS/WireInterfaces.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// WireInterfaces.swift -// DiMLS -// -// Created by Mark @ Germ on 12/31/25. -// - -import Foundation - -///Interfaces for DiGroup wireformats - -public protocol DiControlMessage: Sendable, Archivable { - associatedtype Welcome: DiWelcome - associatedtype Commit: DiCommit - - var asCommit: Commit? { get } - var asWelcome: Welcome? { get } - var epoch: UInt64 { get } -} - -public protocol DiWelcome: Sendable, Archivable { - //should hold onto init key of the recipent for header encryption - var recipientInitKeyData: Data { get } - var epoch: UInt64 { get } -} - -public protocol DiCommit: Sendable, Archivable { - var epoch: UInt64 { get } -} diff --git a/Tests/DistributedMLSTests/DiMLSTests.swift b/Tests/DistributedMLSTests/DiMLSTests.swift index bd4cbc2..2208da8 100644 --- a/Tests/DistributedMLSTests/DiMLSTests.swift +++ b/Tests/DistributedMLSTests/DiMLSTests.swift @@ -1,6 +1,6 @@ import Testing -@testable import DiMLS +@testable import DistributedMLS @Test func example() async throws { // Write your test here and use APIs like `#expect(...)` to check expected conditions.