1// NetworkProtocol.swift2// SophaxChatCore3//4// All messages exchanged over the P2P mesh network.5//6// Protocol flow:7//8// [Discovery]9// Peers discover each other via MCNearbyServiceBrowser.10//11// [Handshake — immediately on MPC connection]12// A → B: .hello (A's PreKeyBundle)13// B → A: .hello (B's PreKeyBundle)14//15// [Session Initiation — A sends first encrypted message to B]16// A → B: .initiateSession (EK_A, used prekey IDs, first Double Ratchet message)17//18// [Normal messages — after session established]19// A ↔ B: .message (Double Ratchet encrypted)20//21// [Delivery confirmation]22// B → A: .ack23//24// [Multihop relay — A and B not directly connected]25// A → C: .relay (RelayEnvelope targeting B, TTL=random(4..6))26// C → B: .relay (RelayEnvelope, TTL decremented) [C forwards after checking target]27// B processes the inner message2829import Foundation3031// MARK: - Wire Message Envelope3233/// Top-level wrapper for all network messages.34/// Every WireMessage is signed with the sender's Ed25519 identity key.35public struct WireMessage: Codable, Sendable {36 public let type: WireMessageType37 public let payload: Data // JSON-encoded inner message38 public let senderID: String // Sender's peerID (deterministic hash of identity keys)39 public let timestamp: Date // Fixed at build time — same value in signingBytes()40 public let signature: Data // Ed25519(type || payload || senderID || timestamp)4142/// Use WireMessageBuilder.build() to construct — do NOT call directly.43/// The timestamp parameter must be passed by the builder so signing bytes44/// and the final message share the EXACT SAME timestamp.45 public init(46 type: WireMessageType,47 payload: Data,48 senderID: String,49 timestamp: Date,// ← explicit, not Date() here50 signature: Data51){52 self.type = type53 self.payload = payload54 self.senderID = senderID55 self.timestamp = timestamp56 self.signature = signature57}5859/// Cached formatter — `ISO8601DateFormatter` is expensive to allocate and is60/// called on every sign/verify path. Shared across all WireMessage instances.61/// `nonisolated(unsafe)`: ISO8601DateFormatter.string(from:) is thread-safe62/// for concurrent reads when the formatter's own properties are never mutated63/// after initialization — which is the case here.64 private nonisolated(unsafe) static let iso8601 = ISO8601DateFormatter()6566/// Canonical byte representation that is signed/verified.67/// MUST be deterministic — no Date() call here.68 public func signingBytes()-> Data {69 var data = Data()70 data.append(contentsOf: type.rawValue.utf8)71 data.append(payload)72 data.append(contentsOf: senderID.utf8)73 data.append(contentsOf: WireMessage.iso8601.string(from: timestamp).utf8)74 return data75}76}7778// MARK: - Message Types7980public enum WireMessageType: String, Codable, Sendable {81/// Initial handshake — contains the sender's PreKeyBundle.82 case hello83/// X3DH session initiation — contains EK_A and the first Double Ratchet message.84 case initiateSession85/// Normal Double Ratchet–encrypted message.86 case message87/// Delivery acknowledgment.88 case ack89/// Relay envelope for multihop delivery.90 case relay91/// Typing indicator (unencrypted, metadata-only).92 case typing93/// Sealed sender — wraps an encrypted WireMessage so relay nodes cannot read the inner type or payload.94 case sealed95/// Read receipt — tells the sender that we have read their messages.96 case readReceipt97/// Emoji reaction on a specific message.98 case reaction99/// Group chat message encrypted with the shared group symmetric key.100 case groupMessage101/// Emoji reaction on a specific group message.102 case groupReaction103/// A member voluntarily left a group — triggers sender-key rotation in remaining members.104 case groupMemberLeft105/// The group creator is dissolving the group for all members.106 case groupDeleted107/// Read receipt for a specific group message — unicast from receiver to original sender.108 case groupReadReceipt109/// Request a directly-connected relay peer to hold a sealed message for an offline target.110 case storeAndForward111/// Relay peer delivers stored messages when the target peer comes online.112 case storeAndForwardDelivery113/// Channel discovery — broadcast by a group creator to announce a group to nearby peers.114/// Unsigned broadcast; recipients may request a group invite from the creator.115 case channelAnnouncement116/// Dead drop — sealed message flooded over the mesh for a specific recipient.117/// Relay nodes cannot read the content (sealed). Stored for 12h at each node.118 case deadDrop119/// Edit a previously sent message — replaces the body text in-place.120 case editMessage121/// Edit a previously sent group message — fanned out to all group members.122 case groupEditMessage123/// Request a peer to re-send their SenderKeyDistributionMessage for a specific group.124/// Sent when the receiver has no key for that sender or the stored key is stale.125 case senderKeyRequest126127// MARK: MLS (Group v3 — RFC 9420)128129/// MLS Welcome for a new group member — unicast, DR-encrypted.130 case mlsWelcome131/// MLS Commit broadcast from the group coordinator.132 case mlsCommit133/// MLS application message (replaces groupMessage for MLS groups).134 case mlsMessage135/// Non-coordinator member requesting the coordinator to issue a Commit — DR-encrypted unicast.136 case mlsCommitRequest137/// MLS-encrypted emoji reaction on a group message.138 case mlsReaction139/// Current MLS coordinator transfers commit authority to another member — broadcast unicast.140 case mlsCoordinatorHandoff141/// Edit a previously sent MLS group message — MLS-encrypted replacement body.142 case mlsGroupEditMessage143/// QR-based device link invite from an existing device to a new device.144 case deviceLinkRequest145/// Forwarded copy of a message from the primary device to a linked device.146 case deviceSyncMessage147148// MARK: Shamir Secret Sharing backup149150/// Creator distributes one ECDH-encrypted SSS share to a trusted contact.151/// DR-encrypted unicast so relay nodes cannot read share index or ciphertext.152 case sssShareDelivery153/// Recovery: requester asks a contact to return their stored share.154 case sssShareRequest155/// Contact responds with the stored share re-encrypted for the requester.156 case sssShareResponse157158// MARK: Remote Wipe159160/// A trusted contact requests remote account wipe.161/// Receiver verifies Ed25519 signature and that sender is in trustedWipePeers.162 case remoteWipe163164// MARK: Kademlia DHT (peer discovery over Tor)165166/// Liveness check — sent to a DHT contact to verify it's still reachable.167 case dhtPing168/// Reply to dhtPing.169 case dhtPong170/// Iterative lookup: return up to k nodes closest to targetNodeID.171 case dhtFindNode172/// Response to dhtFindNode — contains up to k closest known contacts.173 case dhtFindNodeResp174/// Store a PreKeyBundle at this node (we are among the k closest to the key).175 case dhtStore176/// Lookup: return stored PreKeyBundle for targetNodeID, or closest nodes if unknown.177 case dhtFindValue178/// Response to dhtFindValue — contains the bundle (if found) or closest nodes.179 case dhtFindValueResp180181// MARK: Channel join182183/// A peer requests to be invited to a group they discovered via ChannelAnnouncement.184/// DR-encrypted unicast to the group creator — queued until creator is online.185 case channelJoinRequest186}187188// MARK: - Channel Join Request189190/// Sent by a prospective member to the group creator to request an invite.191/// The creator receives this as a `didReceiveGroupJoinRequest` delegate callback.192///193/// Security model: DR-encrypted unicast (creator's identity verified via X3DH).194/// The creator's approval step (add/invite) is a separate explicit action.195public struct ChannelJoinRequestMessage: Codable, Sendable {196/// Group the requester wants to join.197 public let groupID: String198/// Snapshot of the group name (so the creator's UI can show it without a lookup).199 public let groupName: String200/// peerID of the person making the request.201 public let requesterPeerID: String202/// Human-readable username for the creator's approval UI.203 public let requesterUsername: String204/// When the request was created — stale requests (>48 h) should be auto-dismissed.205 public let timestamp: Date206207 public init(groupID: String, groupName: String,208 requesterPeerID: String, requesterUsername: String){209 self.groupID = groupID210 self.groupName = groupName211 self.requesterPeerID = requesterPeerID212 self.requesterUsername = requesterUsername213 self.timestamp = Date()214}215}216217// MARK: - MLS Wire Messages218219/// Sent to a new MLS group member via DR-encrypted unicast (.mlsWelcome).220public struct MLSWelcomeMessage: Codable, Sendable {221 public let groupID: String222 public let groupName: String223/// All member peerIDs at time of creation.224 public let memberIDs:[String]225/// PeerID of the group creator (= MLS coordinator).226 public let creatorID: String227/// Raw mls-rs Welcome bytes — pass directly to MLSGroupManager.processWelcome.228 public let welcomeBytes: Data229/// Ratchet tree bytes — required to join without the ratchet_tree extension.230 public let ratchetTreeBytes: Data231/// Always .mls — carried so the receiver can dispatch correctly.232 public let cryptoVersion: GroupCryptoVersion233234 public init(235 groupID: String, groupName: String, memberIDs:[String], creatorID: String,236 welcomeBytes: Data, ratchetTreeBytes: Data237){238 self.groupID = groupID239 self.groupName = groupName240 self.memberIDs = memberIDs241 self.creatorID = creatorID242 self.welcomeBytes = welcomeBytes243 self.ratchetTreeBytes = ratchetTreeBytes244 self.cryptoVersion =.mls245}246}247248/// Broadcast by the MLS coordinator after every Commit (.mlsCommit).249/// Recipients MUST verify senderID == group.creatorID before processing.250public struct MLSCommitMessage: Codable, Sendable {251 public let groupID: String252/// MLS epoch after this commit — used for ordering / dedup.253 public let epoch: UInt64254/// Raw mls-rs Commit bytes — pass to MLSGroupManager.processCommit.255 public let commitBytes: Data256/// peerID of the coordinator — receivers drop the message if this != group.creatorID.257 public let coordinatorID: String258}259260/// Encrypted group message for MLS groups (.mlsMessage).261/// The `ciphertext` is the output of MLSGroupManager.encrypt — an opaque mls-rs blob.262public struct MLSApplicationMessage: Codable, Sendable {263 public let groupID: String264 public let messageID: String265 public let senderPeerID: String266 public let senderUsername: String267 public let timestamp: Date268/// MLS-encrypted body (caption text; empty string for attachment-only messages).269 public let ciphertext: Data270/// Auto-delete deadline (nil = persistent).271 public let expiresAt: Date?272/// MessageID of the message being replied to.273 public let replyToID: String?274/// MLS-encrypted attachment bytes (image / audio). nil = text-only message.275 public let attachmentCiphertext: Data?276/// MIME type of the attachment (e.g. "image/jpeg", "audio/m4a"). nil = text-only.277 public let mimeType: String?278/// Audio duration in seconds. Non-nil only for audio/m4a attachments.279 public let audioDuration: Double?280/// Sender's JPEG avatar (≤8 KB) for group-only contacts that haven't received a Hello.281 public let senderAvatarData: Data?282}283284/// Sent by a non-coordinator member requesting the coordinator to add or remove a peer.285/// Carried inside a DR-encrypted .mlsCommitRequest message.286public struct MLSCommitRequestMessage: Codable, Sendable {287 public enum Action: String, Codable, Sendable {288 case add, remove289}290 public let groupID: String291 public let action: Action292/// For .add: the new member's MLS KeyPackage bytes.293 public let keyPackage: Data?294/// For .remove: the peerID to remove.295 public let targetPeerID: String?296}297298// MARK: - MLS Reaction299300/// MLS-encrypted emoji reaction on a group message.301/// `ciphertext` decrypts to UTF-8 JSON: `{"emoji":"👍"}` or `{"emoji":null}` to remove.302public struct MLSReactionMessage: Codable, Sendable {303 public let groupID: String304 public let messageID: String // stable ID for this reaction wire message305 public let senderPeerID: String306 public let targetMessageID: String // the message being reacted to307 public let ciphertext: Data308 public let timestamp: Date309310 public init(groupID: String, messageID: String, senderPeerID: String,311 targetMessageID: String, ciphertext: Data, timestamp: Date){312 self.groupID = groupID313 self.messageID = messageID314 self.senderPeerID = senderPeerID315 self.targetMessageID = targetMessageID316 self.ciphertext = ciphertext317 self.timestamp = timestamp318}319}320321/// Current coordinator transfers commit authority to another group member (.mlsCoordinatorHandoff).322/// Broadcast as DR-encrypted unicast to every member so they update their local coordinator record.323public struct MLSCoordinatorHandoffMessage: Codable, Sendable {324/// Identifies the group.325 public let groupID: String326/// PeerID of the sender — must match the current coordinator; verified by recipients.327 public let fromCoordinatorID: String328/// PeerID of the member taking over as coordinator.329 public let newCoordinatorID: String330331 public init(groupID: String, fromCoordinatorID: String, newCoordinatorID: String){332 self.groupID = groupID333 self.fromCoordinatorID = fromCoordinatorID334 self.newCoordinatorID = newCoordinatorID335}336}337338// MARK: - Multi-Device Linking339340/// QR payload: device A broadcasts its PreKeyBundle so device B can open a DR session.341/// Transmitted via the `deviceLinkRequest` wire message type.342public struct DeviceLinkRequestMessage: Codable, Sendable {343/// Human-readable label for device A (e.g. "iPhone 16 Pro").344 public let deviceLabel: String345/// The PreKeyBundle of device A — device B uses this to initiate X3DH.346 public let bundle: PreKeyBundle347/// When this QR payload expires. nil = no expiry (reciprocal replies and legacy QR codes).348 public let expiresAt: Date?349350 public init(deviceLabel: String, bundle: PreKeyBundle, expiresAt: Date?= nil){351 self.deviceLabel = deviceLabel352 self.bundle = bundle353 self.expiresAt = expiresAt354}355}356357/// Forwarded message copy sent from the primary device to each linked device.358/// Re-encrypted inside a DR session so only the linked device can read it.359public struct DeviceSyncMessage: Codable, Sendable {360/// Conversation identifier: peerID for 1-to-1, "group.<groupID>" for groups.361 public let conversationID: String362/// JSON-encoded `StoredMessage` re-encrypted for the linked device.363 public let messageJSON: Data364/// Direction as seen by the primary device ("sent" | "received").365 public let direction: String366367 public init(conversationID: String, messageJSON: Data, direction: String){368 self.conversationID = conversationID369 self.messageJSON = messageJSON370 self.direction = direction371}372}373374// MARK: - Sender Key Request375376/// Sent to a group member whose sender key is missing or stale.377/// The recipient responds by re-sending their current SenderKeyDistributionMessage.378public struct SenderKeyRequestMessage: Codable, Sendable {379/// The group for which the sender key is needed.380 public let groupID: String381/// PeerID of the member being asked to re-send their SKD (always == recipient).382 public let targetPeerID: String383384 public init(groupID: String, targetPeerID: String){385 self.groupID = groupID386 self.targetPeerID = targetPeerID387}388}389390// MARK: - Edit Message391392/// Sent when the author edits a previously sent message.393/// Encrypted with Double Ratchet. Only the original sender can edit.394public struct EditMessagePayload: Codable, Sendable {395/// Stable ID of the message being edited.396 public let messageID: String397/// Replacement body text.398 public let newBody: String399/// Wall-clock time of the edit (set by sender).400 public let editedAt: Date401402 public init(messageID: String, newBody: String, editedAt: Date = Date()){403 self.messageID = messageID404 self.newBody = newBody405 self.editedAt = editedAt406}407}408409// MARK: - Hello (Handshake)410411/// Sent immediately after an MPC connection is established.412/// Contains the sender's full PreKeyBundle so the recipient can initiate X3DH.413public struct HelloMessage: Codable, Sendable {414 public let bundle: PreKeyBundle415}416417// MARK: - Session Initiation (X3DH)418419/// Sent by Alice to initiate a new encrypted session with Bob.420/// Contains everything Bob needs to:421/// 1. Reproduce the X3DH shared secret422/// 2. Initialise the Double Ratchet as responder423/// 3. Decrypt the first message424public struct InitiateSessionMessage: Codable, Sendable {425/// Alice's full PreKeyBundle — Bob stores this for future verification.426 public let senderBundle: PreKeyBundle427/// Alice's ephemeral public key (EK_A) from X3DH.428 public let ephemeralPublicKey: Data429/// Which of Bob's signed prekeys Alice used.430 public let usedSignedPreKeyId: UInt32431/// Which of Bob's one-time prekeys Alice used (nil if none available).432 public let usedOneTimePreKeyId: UInt32?433/// First Double Ratchet encrypted message.434 public let initialMessage: RatchetMessage435/// ML-KEM-768 encapsulated shared secret (1088 bytes). nil = sender is on iOS < 18436/// or Bob's bundle carries no PQ public key. Bob passes this to X3DH.initiateReceiver437/// so both sides derive the same hybrid shared secret.438 public let pqEncapsulatedKey: Data?439}440441// MARK: - Chat Message Payload442443/// Carries a Double Ratchet–encrypted message after session establishment.444public struct ChatMessagePayload: Codable, Sendable {445 public let ratchetMessage: RatchetMessage446/// Stable UUID for deduplication and ACK correlation.447 public let messageID: String448}449450/// Plaintext content encrypted inside the Double Ratchet ciphertext.451/// This is the ONLY place where message text lives unencrypted — in memory during processing.452public struct MessageContent: Codable, Sendable {453 public let body: String454 public let type: MessageType455 public let replyToID: String?456 public let timestamp: Date457/// nil = persistent; Date = auto-delete after this point458 public let expiresAt: Date?459/// Binary attachment (JPEG image or M4A audio). Encrypted with the Double Ratchet.460 public let attachmentData: Data?461/// MIME type: "image/jpeg" | "audio/m4a" | "application/pdf" | …462 public let attachmentMimeType: String?463/// Original filename for file attachments (type == .file). Nil for images and audio.464 public let attachmentFilename: String?465/// Audio duration in seconds (nil for non-audio).466 public let audioDuration: Double?467/// JSON-encoded GroupInvitePayload — only set when type == .groupInvite.468 public let groupInviteData: Data?469470 public enum MessageType: String, Codable, Sendable {471 case text472 case image473 case audio474/// Arbitrary file attachment — attachmentData carries the raw bytes; attachmentFilename the original name.475 case file476/// Group invite — body is the group name; groupInviteData carries GroupInvitePayload JSON.477 case groupInvite478/// Sender key distribution (v2 groups) — senderKeyData carries SenderKeyDistributionMessage JSON.479 case senderKeyDistribution480/// MLS commit request — non-coordinator asks coordinator to add/remove a peer.481/// Body is empty; mlsCommitRequestData carries MLSCommitRequestMessage JSON.482 case mlsCommitRequest483}484485/// JSON-encoded SenderKeyDistributionMessage — only set when type == .senderKeyDistribution.486 public let senderKeyData: Data?487488/// Populated when type == .edit — carries the edit payload.489 public let editPayload: EditMessagePayload?490491/// JSON-encoded MLSCommitRequestMessage — only set when type == .mlsCommitRequest.492 public let mlsCommitRequestData: Data?493494 public init(495 body: String,496 type: MessageType =.text,497 replyToID: String?= nil,498 expiresAt: Date?= nil,499 attachmentData: Data?= nil,500 attachmentMimeType: String?= nil,501 attachmentFilename: String?= nil,502 audioDuration: Double?= nil,503 groupInviteData: Data?= nil,504 senderKeyData: Data?= nil,505 editPayload: EditMessagePayload?= nil,506 mlsCommitRequestData: Data?= nil507){508 self.body = body509 self.type = type510 self.replyToID = replyToID511 self.timestamp = Date()512 self.expiresAt = expiresAt513 self.attachmentData = attachmentData514 self.attachmentMimeType = attachmentMimeType515 self.attachmentFilename = attachmentFilename516 self.audioDuration = audioDuration517 self.groupInviteData = groupInviteData518 self.senderKeyData = senderKeyData519 self.editPayload = editPayload520 self.mlsCommitRequestData = mlsCommitRequestData521}522}523524// MARK: - Multihop Relay525526/// Wraps any WireMessage for relay delivery through intermediate peers.527///528/// Flow:529/// Alice → Charlie: RelayEnvelope(target=Bob, ttl=5, message=chatMsg)530/// Charlie checks: Am I Bob? No → decrement TTL → forward to all peers except Alice531/// Bob receives: Am I Bob? Yes → process inner message532public struct RelayEnvelope: Codable, Sendable {533/// UUID — relay nodes use this to deduplicate (drop already-seen envelopes).534 public let id: String535/// Final destination peerID.536 public let targetPeerID: String537/// Original sender peerID (for reply routing).538 public let originPeerID: String539/// Decremented at every hop; dropped when it reaches 0.540 public let ttl: UInt8541/// Number of hops already taken (for UI display and analytics).542 public let hopCount: UInt8543/// The actual message for the target peer.544 public let message: WireMessage545546 public static let maxTTL: UInt8 = 6547548/// Random initial TTL in [4, 6] so relay nodes cannot infer sender distance549/// from the starting hop count. maxTTL = 6 remains as the validation ceiling.550 public static func randomInitialTTL()-> UInt8 {551 UInt8.random(in: 4...6)552}553554/// Returns a new envelope with TTL decremented and hopCount incremented.555 public func forwarded()-> RelayEnvelope {556 RelayEnvelope(557 id: id,558 targetPeerID: targetPeerID,559 originPeerID: originPeerID,560 ttl: ttl > 0 ? ttl - 1 : 0,561 hopCount: hopCount + 1,562 message: message563)564}565}566567// MARK: - Ack568569public struct AckMessage: Codable, Sendable {570 public let messageID: String571 public let status: AckStatus572573 public enum AckStatus: String, Codable, Sendable {574 case delivered575 case failed576}577}578579// MARK: - Typing580581public struct TypingMessage: Codable, Sendable {582 public let isTyping: Bool583}584585// MARK: - Read Receipt586587/// Sent when the local user views messages from a peer.588/// Allows the sender to upgrade their delivery tick to a "read" indicator.589public struct ReadReceiptMessage: Codable, Sendable {590/// IDs of the received messages being acknowledged as read.591 public let messageIDs:[String]592593 public init(messageIDs:[String]){594 self.messageIDs = messageIDs595}596}597598// MARK: - Group Message599600/// Wire message for a group chat message.601///602/// Encryption:603/// v1 (nil senderKeyIteration): ChaChaPoly with the shared group symmetric key.604/// v2 (non-nil senderKeyIteration): ChaChaPoly with a per-message key derived from605/// the sender's KDF chain at the given iteration (Signal-style Sender Keys).606///607/// Sent individually to each group member (via direct/relay/queue routing).608public struct GroupWireMessage: Codable, Sendable {609 public let groupID: String610 public let messageID: String611 public let senderPeerID: String612 public let senderUsername: String613 public let timestamp: Date614/// ChaChaPoly.combined = nonce(12 B) + body ciphertext + tag(16 B)615 public let ciphertext: Data616/// ChaChaPoly.combined for the binary attachment (nil = text-only message).617 public let attachmentCiphertext: Data?618/// MIME type: "image/jpeg" | "audio/m4a" | "application/pdf" | … — nil when no attachment.619 public let attachmentMimeType: String?620/// Original filename for file attachments. Nil for images and audio.621 public let attachmentFilename: String?622/// Audio duration in seconds (nil for non-audio).623 public let audioDuration: Double?624/// v2 Sender Keys: which KDF chain iteration produced the message key.625/// nil → v1 shared-key message (backward compat).626 public let senderKeyIteration: UInt32?627/// Disappearing message: auto-delete at this point; nil = persistent.628 public let expiresAt: Date?629/// Message ID this message is replying to (nil = not a reply).630 public let replyToID: String?631/// Sender's JPEG avatar (≤8 KB). Included so group-only contacts (no direct Hello)632/// can display an avatar. Nil once the receiver has already cached it.633 public let senderAvatarData: Data?634635 public init(636 groupID: String,637 messageID: String,638 senderPeerID: String,639 senderUsername: String,640 timestamp: Date,641 ciphertext: Data,642 attachmentCiphertext: Data?= nil,643 attachmentMimeType: String?= nil,644 attachmentFilename: String?= nil,645 audioDuration: Double?= nil,646 senderKeyIteration: UInt32?= nil,647 expiresAt: Date?= nil,648 replyToID: String?= nil,649 senderAvatarData: Data?= nil650){651 self.groupID = groupID652 self.messageID = messageID653 self.senderPeerID = senderPeerID654 self.senderUsername = senderUsername655 self.timestamp = timestamp656 self.ciphertext = ciphertext657 self.attachmentCiphertext = attachmentCiphertext658 self.attachmentMimeType = attachmentMimeType659 self.attachmentFilename = attachmentFilename660 self.audioDuration = audioDuration661 self.senderKeyIteration = senderKeyIteration662 self.expiresAt = expiresAt663 self.replyToID = replyToID664 self.senderAvatarData = senderAvatarData665}666}667668// MARK: - Group Reaction669670/// Sent when a peer reacts to (or removes a reaction from) a group message.671public struct GroupReactionMessage: Codable, Sendable {672 public let groupID: String673 public let targetMessageID: String674/// The emoji string, or nil to clear the reaction.675 public let emoji: String?676677 public init(groupID: String, targetMessageID: String, emoji: String?){678 self.groupID = groupID679 self.targetMessageID = targetMessageID680 self.emoji = emoji681}682}683684// MARK: - Group Edit Message685686/// Fanned out to all group members when the original author edits a message.687/// Unencrypted body (Sender Keys group); recipients validate against stored senderID.688public struct GroupEditMessagePayload: Codable, Sendable {689/// ID of the group this edit belongs to.690 public let groupID: String691/// Stable ID of the message being edited.692 public let messageID: String693/// Replacement body text (plaintext, will be Sender-Key–encrypted at the wire layer).694 public let newBody: String695/// Wall-clock time of the edit.696 public let editedAt: Date697698 public init(groupID: String, messageID: String, newBody: String, editedAt: Date = Date()){699 self.groupID = groupID700 self.messageID = messageID701 self.newBody = newBody702 self.editedAt = editedAt703}704}705706/// MLS-group variant: body is MLS-encrypted so only current epoch members can decrypt.707public struct MLSGroupEditMessage: Codable, Sendable {708 public let groupID: String709 public let messageID: String710/// MLS-encrypted replacement body text.711 public let ciphertext: Data712 public let editedAt: Date713714 public init(groupID: String, messageID: String, ciphertext: Data, editedAt: Date = Date()){715 self.groupID = groupID716 self.messageID = messageID717 self.ciphertext = ciphertext718 self.editedAt = editedAt719}720}721722// MARK: - Group Read Receipt723724/// Unicast from a group message recipient back to the original sender.725/// Lets the sender track delivery (isRead = false/nil) and true read state (isRead = true).726public struct GroupReadReceiptMessage: Codable, Sendable {727 public let groupID: String728 public let targetMessageID: String729/// True = user has viewed the message; false/nil = delivery-only signal (backward compat).730 public let isRead: Bool?731732 public init(groupID: String, targetMessageID: String, isRead: Bool?= nil){733 self.groupID = groupID734 self.targetMessageID = targetMessageID735 self.isRead = isRead736}737}738739// MARK: - Reaction740741/// Sent when a peer reacts to (or removes a reaction from) one of your messages.742/// `emoji` is nil to remove a previously set reaction.743public struct ReactionMessage: Codable, Sendable {744/// ID of the message being reacted to.745 public let targetMessageID: String746/// The emoji string, or nil to clear the reaction.747 public let emoji: String?748749 public init(targetMessageID: String, emoji: String?){750 self.targetMessageID = targetMessageID751 self.emoji = emoji752}753}754755// MARK: - Sealed Sender756757/// Wraps a WireMessage so that relay nodes cannot see the inner message type or payload.758///759/// Encryption scheme:760/// 1. Sender generates an ephemeral Curve25519 key pair (EK_s)761/// 2. sharedSecret = ECDH(EK_s_private, recipient_DH_public)762/// 3. sealingKey = HKDF-SHA256(sharedSecret, info="SophaxChat_SealedSender_v1", len=32)763/// 4. encryptedPayload = ChaCha20-Poly1305(sealingKey, JSON(innerWireMessage))764///765/// Only the intended recipient (who knows their DH private key) can decrypt.766/// Relay nodes see only: origin, target, an ephemeral public key, and ciphertext.767public struct SealedMessage: Codable, Sendable {768/// Sender's ephemeral Curve25519 public key (32 bytes) — one per sealed message.769 public let ephemeralPublicKey: Data770/// ChaCha20-Poly1305 ciphertext: nonce(12B) + encrypted WireMessage JSON + tag(16B).771 public let encryptedPayload: Data772}773774// MARK: - Channel Discovery775776/// Broadcast by a group creator to advertise a group to nearby peers who are NOT yet members.777///778/// This message is signed (via WireMessageBuilder) so recipients can verify the creator's identity.779/// It is flooded over the mesh so peers beyond direct range can discover groups.780/// A peer who discovers a channel can initiate a DM with the creator to request an invite.781///782/// Security properties:783/// - Does NOT contain any message content or encryption keys.784/// - Group membership and message content remain fully encrypted.785/// - A peer who sees this message knows only: the group name, creator, and approximate size.786public struct ChannelAnnouncement: Codable, Sendable {787/// Stable group UUID.788 public let groupID: String789/// Human-readable group name (as set by the creator).790 public let groupName: String791/// peerID of the group creator — the peer to contact to request an invite.792 public let creatorID: String793/// Approximate member count — lets prospective joiners gauge group size.794 public let memberCount: Int795/// When this announcement was generated — used to discard stale entries.796 public let timestamp: Date797798 public init(groupID: String, groupName: String, creatorID: String, memberCount: Int){799 self.groupID = groupID800 self.groupName = groupName801 self.creatorID = creatorID802 self.memberCount = memberCount803 self.timestamp = Date()804}805}806807// MARK: - Dead Drop808809/// A sealed message flooded over the entire mesh for a specific recipient.810///811/// Security properties:812/// - Content is sealed (ChaCha20-Poly1305) — relay nodes see only the target peerID and expiry.813/// - Any node that receives it re-broadcasts it and stores it locally for 12 hours.814/// - When the target peer appears in the mesh, any node that has the drop delivers it.815/// - `id` is a UUID used for deduplication across hops (same as RelayEnvelope pattern).816///817/// Use case: "I want to send you a message even though you're not online right now,818/// without any server involved."819public struct DeadDropEnvelope: Codable, Sendable {820/// Unique ID for deduplication — same across all hops.821 public let id: String822/// peerID of the intended recipient.823 public let targetPeerID: String824/// Sealed (encrypted for recipient's DH key) inner WireMessage.825 public let sealed: SealedMessage826/// When this drop expires — nodes discard it after this time.827 public let expiresAt: Date828829 public init(targetPeerID: String, sealed: SealedMessage, ttl: TimeInterval = 12 * 3600){830 self.id = UUID().uuidString831 self.targetPeerID = targetPeerID832 self.sealed = sealed833 self.expiresAt = Date().addingTimeInterval(ttl)834}835}836837// MARK: - WireMessage Builder838839/// Creates and verifies signed WireMessages.840public struct WireMessageBuilder {841 private let identity: IdentityManager842843 public init(identity: IdentityManager){844 self.identity = identity845}846847/// Build a signed WireMessage.848/// Uses a single `Date()` snapshot so signing bytes and the final849/// message carry the SAME timestamp — avoiding the previous bug where850/// two separate `init` calls produced two different timestamps.851 public func build<T: Codable>(_ type: WireMessageType, payload: T) throws -> WireMessage {852 let payloadData = try JSONEncoder().encode(payload)853 let senderID = identity.publicIdentity.peerID854 let timestamp = Date()// ← captured ONCE855856// Construct unsigned message to compute the canonical bytes to sign857 let unsigned = WireMessage(858 type: type, payload: payloadData,859 senderID: senderID, timestamp: timestamp, signature: Data()860)861 let signature = try identity.sign(unsigned.signingBytes())862863// Return the final message with the same timestamp864 return WireMessage(865 type: type, payload: payloadData,866 senderID: senderID, timestamp: timestamp, signature: signature867)868}869870/// Verify the Ed25519 signature on a received WireMessage.871 public static func verify(_ message: WireMessage, signingKeyPublic: Data) throws -> Bool {872 let bytes = message.signingBytes()873 return try IdentityManager.verify(874 signature: message.signature,875 for: bytes,876 signingKeyPublic: signingKeyPublic877)878}879880 public func decodePayload<T: Codable>(_ type: T.Type, from message: WireMessage) throws -> T {881 try JSONDecoder().decode(type, from: message.payload)882}883}884885// MARK: - Stored Message886887/// A message persisted in the local encrypted store.888public struct StoredMessage: Codable, Identifiable, Sendable {889 public let id: String890 public let peerID: String891 public let direction: Direction892 public let body: String893 public let timestamp: Date894 public var status: MessageStatus895 public let replyToID: String?896 public let expiresAt: Date?897/// Nil = delivered directly; >0 = relayed through N hops898 public let hopCount: UInt8?899/// Local file ID in AttachmentStore — nil means no attachment.900 public let attachmentID: String?901/// "image/jpeg" | "audio/m4a" | "application/pdf" | …902 public let attachmentMimeType: String?903/// Original filename for file attachments. Nil for images and audio.904 public let attachmentFilename: String?905/// Audio duration in seconds (nil for non-audio).906 public let audioDuration: Double?907/// Emoji reactions on this message, keyed by peerID. nil = no reactions.908/// Declared optional for backward-compatibility (old stored messages lack this key).909 public var reactions:[String: String]?910/// For group messages: the actual sender's peerID. nil for direct messages.911 public let senderID: String?912/// Wall-clock time when this device received/decrypted the message.913/// Used for display ordering instead of sender-supplied `timestamp` (which can be spoofed).914/// Nil for messages stored before this field was added (backward compat).915 public let receivedAt: Date?916/// Group messages only: peerIDs that have sent a groupReadReceipt back to us.917/// Nil for direct messages and for messages received before this field was added.918 public var deliveredBy:[String]?919/// Group messages only: peerIDs that have sent a true read receipt (viewed, not just received).920/// Nil for direct messages and legacy stored messages.921 public var readBy:[String]?922/// True if the message body was edited after initial delivery.923/// Backward-compatible: old stored messages decode this as false (missing key).924 public var isEdited: Bool925/// When the message was last edited. Nil for unedited messages.926 public var editedAt: Date?927928 public enum Direction: String, Codable, Sendable {929 case sent, received930}931932 public enum MessageStatus: String, Codable, Sendable {933 case sending, delivered, failed, read934}935936 public init(937 id: String = UUID().uuidString,938 peerID: String,939 direction: Direction,940 body: String,941 timestamp: Date = Date(),942 status: MessageStatus =.sending,943 replyToID: String?= nil,944 expiresAt: Date?= nil,945 hopCount: UInt8?= nil,946 attachmentID: String?= nil,947 attachmentMimeType: String?= nil,948 attachmentFilename: String?= nil,949 audioDuration: Double?= nil,950 reactions:[String: String]?= nil,951 senderID: String?= nil,952 receivedAt: Date?= nil,953 deliveredBy:[String]?= nil,954 readBy:[String]?= nil,955 isEdited: Bool = false,956 editedAt: Date?= nil957){958 self.id = id959 self.peerID = peerID960 self.direction = direction961 self.body = body962 self.timestamp = timestamp963 self.status = status964 self.replyToID = replyToID965 self.expiresAt = expiresAt966 self.hopCount = hopCount967 self.attachmentID = attachmentID968 self.attachmentMimeType = attachmentMimeType969 self.attachmentFilename = attachmentFilename970 self.audioDuration = audioDuration971 self.reactions = reactions972 self.senderID = senderID973 self.receivedAt = receivedAt974 self.deliveredBy = deliveredBy975 self.readBy = readBy976 self.isEdited = isEdited977 self.editedAt = editedAt978}979}980981// MARK: - Group Member Left982983/// Broadcast by a member who is voluntarily leaving a group.984/// Recipients use this to update their local member list and rotate their sender keys985/// so the leaver cannot decrypt future messages.986public struct GroupMemberLeftMessage: Codable, Sendable {987 public let groupID: String988 public let leavingPeerID: String989/// Remaining member peerIDs (does NOT include the leaver).990 public let remainingMemberIDs:[String]991/// For MLS groups: when the *coordinator* is leaving, this is pre-elected new992/// coordinator peerID. Receivers whose ID matches should issue a Remove Commit993/// (advancing the epoch) and update their local coordinator record — order-independent994/// of the separate `.mlsCoordinatorHandoff` message that may race this one.995/// nil = leaver is not the coordinator, or group is SKv2.996 public let pendingCoordinatorID: String?997998 public init(groupID: String, leavingPeerID: String, remainingMemberIDs:[String],999 pendingCoordinatorID: String?= nil){1000 self.groupID = groupID1001 self.leavingPeerID = leavingPeerID1002 self.remainingMemberIDs = remainingMemberIDs1003 self.pendingCoordinatorID = pendingCoordinatorID1004}1005}10061007// MARK: - Group Deleted10081009/// Broadcast by the group creator when dissolving a group for all members.1010/// Recipients must verify `deletedByPeerID == group.creatorID` before acting.1011public struct GroupDeletedMessage: Codable, Sendable {1012 public let groupID: String1013 public let deletedByPeerID: String10141015 public init(groupID: String, deletedByPeerID: String){1016 self.groupID = groupID1017 self.deletedByPeerID = deletedByPeerID1018}1019}10201021// MARK: - Store-and-Forward10221023/// Sent by Alice to a directly-connected relay peer Bob: "Please hold this sealed1024/// message for Carol until she connects to you."1025///1026/// The inner message is sealed (ChaCha20-Poly1305) for Carol's DH key, so Bob1027/// cannot read it. Bob stores it until Carol's peerID appears in a Hello handshake,1028/// then delivers via `.storeAndForwardDelivery`.1029public struct StoreAndForwardRequest: Codable, Sendable {1030/// The final recipient's peerID.1031 public let targetPeerID: String1032/// Stable dedup ID (same as the inner WireMessage's correlation ID).1033 public let messageID: String1034/// Pre-sealed WireMessage addressed to `targetPeerID`.1035 public let sealed: SealedMessage1036/// Relay peer drops this item after `expiresAt` to bound storage use.1037 public let expiresAt: Date10381039 public init(targetPeerID: String, messageID: String, sealed: SealedMessage, expiresAt: Date){1040 self.targetPeerID = targetPeerID1041 self.messageID = messageID1042 self.sealed = sealed1043 self.expiresAt = expiresAt1044}1045}10461047/// Sent by a relay peer Bob to Carol once she connects: delivers all stored items.1048public struct StoreAndForwardDelivery: Codable, Sendable {1049 public let items:[StoreAndForwardItem]10501051 public init(items:[StoreAndForwardItem]){ self.items = items }1052}10531054public struct StoreAndForwardItem: Codable, Sendable {1055 public let messageID: String1056 public let sealed: SealedMessage10571058 public init(messageID: String, sealed: SealedMessage){1059 self.messageID = messageID1060 self.sealed = sealed1061}1062}10631064// MARK: - Peer Trust Level10651066/// Whether the local user has accepted this peer.1067/// Absent in legacy JSON (peers stored before this field) → `.accepted` for backward compatibility.1068public enum PeerTrustLevel: String, Codable, Sendable {1069 case pending // received Hello; awaiting user decision1070 case accepted // user tapped Accept (or legacy peer — defaults to accepted)1071}10721073// MARK: - Known Peer10741075/// A peer whose identity keys we've received and cryptographically verified.1076public struct KnownPeer: Codable, Identifiable, Sendable {1077 public let id: String1078 public let username: String1079 public let signingKeyPublic: Data1080 public let dhKeyPublic: Data1081 public let safetyNumber: String1082 public var lastSeen: Date?1083 public var isOnline: Bool1084 public var isDirectlyConnected: Bool // false = reachable only via relay1085/// "host:port" if the peer advertises a TCP address in their PreKeyBundle; nil otherwise.1086 public var tcpAddress: String?1087/// JPEG avatar data received from the peer's PreKeyBundle. nil = no avatar set.1088 public var avatarData: Data?1089/// Accept/reject gate. Absent in legacy JSON → `.accepted` (backward compatible).1090 public var trustLevel: PeerTrustLevel1091/// SHA-256 fingerprint of the peer's ephemeral TLS session certificate (32 bytes).1092/// nil = peer does not advertise TCP cert pinning. Absent in legacy JSON → nil.1093 public var tlsCertFingerprint: Data?10941095 public init(from bundle: PreKeyBundle, safetyNumber: String, trustLevel: PeerTrustLevel =.pending){1096 self.id = bundle.peerID1097 self.username = bundle.username1098 self.signingKeyPublic = bundle.signingKeyPublic1099 self.dhKeyPublic = bundle.dhIdentityKeyPublic1100 self.safetyNumber = safetyNumber1101 self.lastSeen = Date()1102 self.isOnline = true1103 self.isDirectlyConnected = true1104 self.tcpAddress = bundle.tcpAddress1105 self.avatarData = bundle.avatarData1106 self.trustLevel = trustLevel1107 self.tlsCertFingerprint = bundle.tlsCertFingerprint1108}11091110/// Construct a KnownPeer directly (used when importing contacts via invite link).1111 public init(1112 id: String,1113 username: String,1114 signingKeyPublic: Data,1115 dhKeyPublic: Data,1116 safetyNumber: String,1117 lastSeen: Date?,1118 isOnline: Bool,1119 isDirectlyConnected: Bool,1120 tcpAddress: String?= nil,1121 avatarData: Data?= nil,1122 trustLevel: PeerTrustLevel =.accepted1123){1124 self.id = id1125 self.username = username1126 self.signingKeyPublic = signingKeyPublic1127 self.dhKeyPublic = dhKeyPublic1128 self.safetyNumber = safetyNumber1129 self.lastSeen = lastSeen1130 self.isOnline = isOnline1131 self.isDirectlyConnected = isDirectlyConnected1132 self.tcpAddress = tcpAddress1133 self.avatarData = avatarData1134 self.trustLevel = trustLevel1135}11361137// Custom decoder: default trustLevel to .accepted when absent (legacy peers are all accepted)1138 public init(from decoder: Decoder) throws {1139 let c = try decoder.container(keyedBy: CodingKeys.self)1140 id = try c.decode(String.self, forKey:.id)1141 username = try c.decode(String.self, forKey:.username)1142 signingKeyPublic = try c.decode(Data.self, forKey:.signingKeyPublic)1143 dhKeyPublic = try c.decode(Data.self, forKey:.dhKeyPublic)1144 safetyNumber = try c.decode(String.self, forKey:.safetyNumber)1145 lastSeen = try c.decodeIfPresent(Date.self, forKey:.lastSeen)1146 isOnline = try c.decodeIfPresent(Bool.self, forKey:.isOnline)?? false1147 isDirectlyConnected = try c.decodeIfPresent(Bool.self, forKey:.isDirectlyConnected)?? false1148 tcpAddress = try c.decodeIfPresent(String.self, forKey:.tcpAddress)1149 avatarData = try c.decodeIfPresent(Data.self, forKey:.avatarData)1150 trustLevel = try c.decodeIfPresent(PeerTrustLevel.self, forKey:.trustLevel)??.accepted1151 tlsCertFingerprint = try c.decodeIfPresent(Data.self, forKey:.tlsCertFingerprint)1152}1153}11541155// MARK: - SSS Backup Wire Messages11561157/// Creator → trusted contact: one ECDH-encrypted SSS share.1158/// Sent via DR-encrypted unicast so only the intended holder can read it.1159public struct SSSShareDeliveryMessage: Codable, Sendable {1160/// UUID identifying the backup instance — links all N shares together.1161 public let shareID: String1162/// Ephemeral X25519 public key used for ECDH-encryption of this share.1163 public let ephemeralPublicKey: Data1164/// ChaChaPoly ciphertext of the JSON-encoded SSSShare.1165 public let encryptedShare: Data1166/// peerID of the creator — lets holder verify on response.1167 public let senderPeerID: String1168/// Informational: minimum shares required (M).1169 public let threshold: UInt81170/// Informational: total shares created (N).1171 public let total: UInt81172}11731174/// Recovery requester → holder: ask to return a stored share.1175/// Sent via DR-encrypted unicast. Includes requester's current DH public key1176/// so the holder can re-encrypt the share for the requester.1177public struct SSSShareRequestMessage: Codable, Sendable {1178/// ID of the backup instance to recover.1179 public let shareID: String1180/// Requester's X25519 DH public key (32B) for response encryption.1181 public let requesterDHPublicKey: Data1182}11831184/// Holder → requester: the stored share, re-encrypted for the requester.1185public struct SSSShareResponseMessage: Codable, Sendable {1186/// Must match the shareID from the corresponding SSSShareRequestMessage.1187 public let shareID: String1188/// Ephemeral public key used to ECDH-encrypt the share for the requester.1189 public let ephemeralPublicKey: Data1190/// ChaChaPoly ciphertext of the JSON-encoded SSSShare.1191 public let encryptedShare: Data1192}11931194// MARK: - Remote Wipe11951196/// Signed request to wipe the receiver's account.1197/// The receiver verifies: (1) Ed25519 signature via normal wire dispatch,1198/// (2) sender is in trustedWipePeers.1199public struct RemoteWipeRequest: Codable, Sendable {1200/// UUID — prevents replay attacks.1201 public let requestID: String1202 public let issuedAt: Date12031204 public init(requestID: String = UUID().uuidString, issuedAt: Date = Date()){1205 self.requestID = requestID1206 self.issuedAt = issuedAt1207}1208}12091210// MARK: - Kademlia DHT Payloads12111212/// Contact info published in DHT routing tables and lookup responses.1213/// Field names must match Android counterpart exactly for cross-platform JSON interop.1214public struct DHTNodeInfo: Codable, Sendable {1215/// Full 256-bit SHA256(signingKey || dhKey) as lowercase hex (64 chars).1216 public let nodeID: String1217/// Tor v3 .onion hostname without port (62 chars, ending in ".onion").1218 public let onionAddress: String1219/// TCP port — always 25519 in practice.1220 public let port: UInt1612211222 public init(nodeID: String, onionAddress: String, port: UInt16 = 25519){1223 self.nodeID = nodeID1224 self.onionAddress = onionAddress1225 self.port = port1226}1227}12281229public struct DHTPingPayload: Codable, Sendable {1230 public let senderNodeID: String1231 public init(senderNodeID: String){ self.senderNodeID = senderNodeID }1232}12331234public struct DHTPongPayload: Codable, Sendable {1235 public let senderNodeID: String1236 public init(senderNodeID: String){ self.senderNodeID = senderNodeID }1237}12381239/// Ask the receiver for up to k nodes closest to targetNodeID.1240public struct DHTFindNodePayload: Codable, Sendable {1241 public let targetNodeID: String1242 public init(targetNodeID: String){ self.targetNodeID = targetNodeID }1243}12441245/// Up to k closest DHT contacts known to the responder.1246public struct DHTFindNodeRespPayload: Codable, Sendable {1247 public let closestNodes:[DHTNodeInfo]1248 public init(closestNodes:[DHTNodeInfo]){ self.closestNodes = closestNodes }1249}12501251/// Ask the receiver to store a PreKeyBundle (we computed they are among the k closest).1252public struct DHTStorePayload: Codable, Sendable {1253/// Full 256-bit node ID (hex 64 chars) — the DHT key under which the bundle is stored.1254 public let nodeID: String1255 public let bundle: PreKeyBundle1256/// Bundle expires and should be dropped after this date (typically now + 25h).1257 public let expiresAt: Date12581259 public init(nodeID: String, bundle: PreKeyBundle, expiresAt: Date){1260 self.nodeID = nodeID1261 self.bundle = bundle1262 self.expiresAt = expiresAt1263}1264}12651266/// Lookup a stored PreKeyBundle by full nodeID or 16-char peerID prefix.1267public struct DHTFindValuePayload: Codable, Sendable {1268/// Full 256-bit node ID (hex) — used for XOR routing to the correct bucket.1269 public let targetNodeID: String1270/// Optional 16-char prefix for prefix-based search when full ID is unknown.1271/// Receivers scan their local DHTStore for entries whose nodeID starts with this prefix.1272 public let peerIDPrefix: String?12731274 public init(targetNodeID: String, peerIDPrefix: String?= nil){1275 self.targetNodeID = targetNodeID1276 self.peerIDPrefix = peerIDPrefix1277}1278}12791280/// Response to dhtFindValue: either the bundle (hit) or the k closest nodes (miss).1281public struct DHTFindValueRespPayload: Codable, Sendable {1282/// Non-nil when the receiver holds the bundle for the requested node.1283 public let bundle: PreKeyBundle?1284/// Populated when bundle is nil — k closest nodes for the requester to query next.1285 public let closestNodes:[DHTNodeInfo]12861287 public init(bundle: PreKeyBundle?, closestNodes:[DHTNodeInfo]=[]){1288 self.bundle = bundle1289 self.closestNodes = closestNodes1290}1291}