Initial commit — btmessage iOS app
XWing PQC (ML-KEM-768 + X25519) + AES-256-GCM E2EE mesh chat. Group messaging with shared symmetric AES-256 key. iOS 26 deployment target. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
21 files changed
+2263 −0
A
.gitignore
+33 −0
A
PROTOCOL.md
+292 −0
A
btmessage.xcodeproj/project.pbxproj
+434 −0
A
btmessage.xcodeproj/project.xcworkspace/contents.xcworkspacedata
+7 −0
A
btmessage/Crypto/GroupKeyManager.swift
+71 −0
A
btmessage/Crypto/HybridCrypto.swift
+124 −0
A
btmessage/Crypto/IdentityManager.swift
+80 −0
A
btmessage/Crypto/KyberKEM.swift
+5 −0
A
btmessage/Info.plist
+52 −0
A
btmessage/Mesh/MeshManager.swift
+380 −0
A
btmessage/Mesh/MeshProtocol.swift
+130 −0
A
btmessage/Models/ChatMessage.swift
+16 −0
A
btmessage/Models/GroupModel.swift
+10 −0
A
btmessage/Models/PeerInfo.swift
+12 −0
A
btmessage/ViewModels/AppState.swift
+175 −0
A
btmessage/Views/ChatListView.swift
+183 −0
A
btmessage/Views/ChatView.swift
+106 −0
A
btmessage/Views/ContentView.swift
+12 −0
A
btmessage/Views/CreateGroupView.swift
+90 −0
A
btmessage/Views/MessageBubble.swift
+36 −0
A
btmessage/btmessageApp.swift
+15 −0
.gitignore
@@ -0,0 +1,33 @@
+# macOS
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Xcode build
+build/
+DerivedData/
+*.xcarchive
+
+# Xcode user data (machine-specific, not shared)
+xcuserdata/
+*.xcuserstate
+*.moved-aside
+
+# Swift Package Manager
+.build/
+.swiftpm/
+*.resolved
+
+# CocoaPods
+Pods/
+Podfile.lock
+
+# Fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots/**/*.png
+fastlane/test_output
+
+# Misc
+*.swp
+*.lock
PROTOCOL.md
@@ -0,0 +1,292 @@
+# btmessage — Protocol Specification
+
+Reference for implementing a compatible Android (or any platform) client.
+
+---
+
+## 1. Transport layer
+
+### iOS — MultipeerConnectivity
+
+iOS uses Apple's **MultipeerConnectivity** framework (Bluetooth LE + WiFi Direct / AWDL).
+Service type: `btmsg-pqc` (Bonjour service name).
+
+### Android — required equivalent
+
+Android must use **Wi-Fi Direct** (P2P) or **Bluetooth Classic / BLE** to discover and connect to peers on the same local network or in proximity.
+
+The simplest cross-platform path is to run both sides over a **local Wi-Fi network** using plain TCP sockets or a lightweight protocol (e.g. Bonjour/mDNS for discovery + TCP for data). A dedicated bridging layer would be needed to bridge MultipeerConnectivity ↔ Android transport.
+
+> **Note:** Direct iOS ↔ Android Bluetooth is not natively compatible at the MultipeerConnectivity level. For a fully cross-platform implementation, replace the transport with a shared protocol such as:
+> - mDNS discovery + TCP/TLS over Wi-Fi
+> - A relay server (TURN-like) over the internet
+
+---
+
+## 2. Peer identity
+
+Each device generates a **UUID v4** string on first launch and persists it as its permanent peer ID.
+
+```
+peerID = UUID().uuidString // e.g. "3F2A1B4C-5D6E-7F8A-9B0C-1D2E3F4A5B6C"
+```
+
+- The peer ID is the canonical identifier for routing and key lookup.
+- It is also used as the `displayName` for MultipeerConnectivity.
+- It is stored in the device Keychain alongside the long-term private key.
+
+---
+
+## 3. Wire protocol — MeshEnvelope
+
+Every packet exchanged on the mesh is a **JSON-encoded `MeshEnvelope`**.
+
+### 3.1 Envelope structure
+
+```json
+{
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "type": 1,
+ "senderID": "3F2A1B4C-...",
+ "recipientID": "7A8B9C0D-...",
+ "ttl": 7,
+ "payload": "<base64>",
+ "timestamp": 1712600000.0
+}
+```
+
+| Field | Type | Description |
+|---------------|----------|----------------------------------------------------------|
+| `id` | String | UUID — used for deduplication across hops |
+| `type` | UInt8 | Message type (see §3.2) |
+| `senderID` | String | Originating peer UUID |
+| `recipientID` | String | Destination peer UUID, or `"*"` for broadcast |
+| `ttl` | UInt8 | Time-to-live; decremented at each hop, dropped at 0 |
+| `payload` | Data | JSON base64 — content depends on `type` |
+| `timestamp` | Double | Unix timestamp (seconds since epoch) of origination |
+
+Encoding: `JSONEncoder` / `JSONDecoder` (Swift default). `Data` fields are base64-encoded by `Codable`.
+
+### 3.2 Message types
+
+| Value | Name | Default TTL | Payload |
+|-------|---------------|-------------|-----------------------------|
+| `1` | `chat` | 7 | `ChatPayload` (see §3.3) |
+| `2` | `keyExchange` | 5 | Raw public key bytes |
+| `3` | `ack` | 5 | Message ID (UTF-8 string) |
+| `4` | `discovery` | 5 | Empty |
+
+### 3.3 ChatPayload structure
+
+The `payload` field of a `chat` envelope contains a JSON-encoded `ChatPayload`:
+
+```json
+{
+ "messageID": "uuid-string",
+ "encryptedMessage": {
+ "ephemeralPublicKey": "<base64 — 32 bytes>",
+ "aesCiphertext": "<base64 — 12 + N + 16 bytes>"
+ }
+}
+```
+
+---
+
+## 4. Routing
+
+### Flood with TTL
+
+1. On receipt of any envelope, check `id` against a **deduplication cache** (1000-entry LRU).
+ If already seen → discard.
+2. Insert `id` into the cache.
+3. If `recipientID` matches the local peer ID → deliver locally.
+4. Otherwise → decrement TTL; if TTL > 0 forward to all connected peers.
+
+### Direct delivery optimisation
+
+If the recipient is a directly connected peer, send only to that peer (skip flood).
+
+### Invite tie-breaking (iOS-specific)
+
+To prevent both peers from simultaneously inviting each other:
+only the peer whose `displayName` is **lexicographically smaller** initiates the connection.
+
+---
+
+## 5. Key exchange
+
+### Flow
+
+```
+Device A Device B
+ | |
+ |--- keyExchange (TTL=5) --------->|
+ | payload = A's public key |
+ | |
+ |<-- keyExchange (TTL=5) ----------|
+ | payload = B's public key |
+```
+
+- On connection, each peer broadcasts a `keyExchange` envelope to all peers.
+- `keyExchange` envelopes are also re-broadcast with decremented TTL (mesh propagation).
+- When a peer receives a `discovery` envelope, it responds by broadcasting its own public key.
+
+### Public key format
+
+The `payload` of a `keyExchange` envelope is the **raw 32-byte Curve25519 public key** (little-endian, RFC 7748 format).
+
+```
+payload = curve25519_public_key // exactly 32 bytes
+```
+
+---
+
+## 6. Encryption
+
+### Algorithm
+
+**X25519 ECDH + HKDF-SHA256 + AES-256-GCM**
+(same construction as Signal Protocol / Apple iMessage)
+
+### Encrypt (sender side)
+
+```
+inputs:
+ plaintext — UTF-8 message bytes
+ recipient_pub_key — 32-byte Curve25519 public key of recipient
+
+steps:
+ 1. Generate ephemeral Curve25519 key pair:
+ ephemeral_priv, ephemeral_pub = X25519.generateKeyPair()
+
+ 2. ECDH:
+ shared_secret = X25519(ephemeral_priv, recipient_pub_key)
+ // 32-byte raw shared secret
+
+ 3. HKDF-SHA256:
+ symmetric_key = HKDF-SHA256(
+ ikm = shared_secret, // input key material
+ salt = ephemeral_pub, // 32 bytes
+ info = "btmessage-v1", // UTF-8
+ len = 32 // output: 256-bit AES key
+ )
+
+ 4. AES-256-GCM encrypt:
+ nonce = random 12 bytes
+ ciphertext = AES-256-GCM.encrypt(key=symmetric_key, nonce=nonce, plaintext=plaintext)
+ // GCM tag is 16 bytes, appended automatically
+
+ 5. Serialize aesCiphertext:
+ aesCiphertext = nonce (12 bytes) || ciphertext (N bytes) || tag (16 bytes)
+
+output:
+ EncryptedMessage {
+ ephemeralPublicKey: ephemeral_pub // 32 bytes
+ aesCiphertext: aesCiphertext // 28 + N bytes
+ }
+```
+
+### Decrypt (recipient side)
+
+```
+inputs:
+ encrypted_message — EncryptedMessage struct
+ recipient_priv_key — 32-byte Curve25519 private key
+
+steps:
+ 1. ECDH:
+ shared_secret = X25519(recipient_priv_key, encrypted_message.ephemeralPublicKey)
+
+ 2. HKDF-SHA256:
+ symmetric_key = HKDF-SHA256(
+ ikm = shared_secret,
+ salt = encrypted_message.ephemeralPublicKey, // same salt as encryption
+ info = "btmessage-v1",
+ len = 32
+ )
+
+ 3. Deserialize aesCiphertext:
+ nonce = aesCiphertext[0:12]
+ tag = aesCiphertext[-16:]
+ ciphertext = aesCiphertext[12:-16]
+
+ 4. AES-256-GCM decrypt:
+ plaintext = AES-256-GCM.decrypt(key=symmetric_key, nonce=nonce, ciphertext=ciphertext, tag=tag)
+ // fails with authentication error if key is wrong or data is tampered
+```
+
+### Key sizes summary
+
+| Element | Size |
+|----------------------|-----------|
+| Curve25519 public key | 32 bytes |
+| Curve25519 private key | 32 bytes |
+| Ephemeral public key | 32 bytes |
+| AES-256 key (derived) | 32 bytes |
+| AES-GCM nonce | 12 bytes |
+| AES-GCM tag | 16 bytes |
+
+---
+
+## 7. Android implementation notes
+
+### Recommended libraries
+
+| Component | Android library |
+|---------------|----------------------------------------------|
+| X25519 ECDH | `androidx.security.crypto` or BouncyCastle |
+| HKDF-SHA256 | BouncyCastle (`HKDFBytesGenerator`) or Tink |
+| AES-256-GCM | `javax.crypto.Cipher` (standard JCE) |
+| JSON | `kotlinx.serialization` or Gson |
+
+### Tink (Google) — simplest option
+
+Google's [Tink](https://github.com/google/tink) library provides X25519, HKDF, and AES-GCM in a single dependency and matches the algorithm exactly:
+
+```kotlin
+// HKDF
+val hkdf = HkdfPrfKey.builder()
+ .setParams(HkdfPrfParams.newBuilder().setHash(HashType.SHA256).build())
+ ...
+
+// AES-GCM
+val aesKey = AesGcmKey.newBuilder().setKeyValue(ByteString.copyFrom(symmetricKey)).build()
+```
+
+### Transport for iOS ↔ Android
+
+MultipeerConnectivity is iOS/macOS only. For cross-platform:
+
+1. **Same Wi-Fi LAN**: Use mDNS (Android `NsdManager`) to advertise the service `_btmsg-pqc._tcp`, then connect via TCP. The JSON envelope format remains identical.
+
+2. **Over internet**: Use a lightweight relay (WebSocket server) — peers exchange envelopes via the relay, same JSON format, same crypto.
+
+---
+
+## 8. Complete message flow example
+
+```
+Alice (iOS) relay/mesh Bob (Android)
+ | |
+ |--- keyExchange: Alice_pub_key (32 bytes) ---------->|
+ |<-- keyExchange: Bob_pub_key (32 bytes) -------------|
+ | |
+ | Alice types "Hello Bob" |
+ | → encrypt("Hello Bob", Bob_pub_key) |
+ | |
+ |--- chat envelope: ChatPayload --------------------- >|
+ | ephemeralPublicKey: E_pub (32 bytes) |
+ | aesCiphertext: nonce+ct+tag |
+ | |
+ | Bob decrypts with Bob_priv_key |
+ | → "Hello Bob" |
+ | |
+ |<-- ack envelope: messageID -------------------------|
+```
+
+---
+
+## 9. Versioning
+
+The HKDF `info` string `"btmessage-v1"` acts as a protocol version discriminator.
+Future breaking changes must increment this value (e.g. `"btmessage-v2"`).
btmessage.xcodeproj/project.pbxproj
@@ -0,0 +1,434 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ AA000001 /* btmessageApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100001 /* btmessageApp.swift */; };
+ AA000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100002 /* ContentView.swift */; };
+ AA000003 /* KyberKEM.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100003 /* KyberKEM.swift */; };
+ AA000004 /* HybridCrypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100004 /* HybridCrypto.swift */; };
+ AA000005 /* IdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100005 /* IdentityManager.swift */; };
+ AA000006 /* MeshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100006 /* MeshManager.swift */; };
+ AA000007 /* MeshProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100007 /* MeshProtocol.swift */; };
+ AA000008 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100008 /* ChatMessage.swift */; };
+ AA000009 /* PeerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100009 /* PeerInfo.swift */; };
+ AA000010 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100010 /* AppState.swift */; };
+ AA000011 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100011 /* ChatListView.swift */; };
+ AA000012 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100012 /* ChatView.swift */; };
+ AA000013 /* MessageBubble.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100013 /* MessageBubble.swift */; };
+ AA000021 /* GroupModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100021 /* GroupModel.swift */; };
+ AA000022 /* GroupKeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100022 /* GroupKeyManager.swift */; };
+ AA000023 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100023 /* CreateGroupView.swift */; };
+ AA000020 /* MultipeerConnectivity.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA100020 /* MultipeerConnectivity.framework */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ AA100001 /* btmessageApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = btmessageApp.swift; sourceTree = "<group>"; };
+ AA100002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
+ AA100003 /* KyberKEM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KyberKEM.swift; sourceTree = "<group>"; };
+ AA100004 /* HybridCrypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HybridCrypto.swift; sourceTree = "<group>"; };
+ AA100005 /* IdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityManager.swift; sourceTree = "<group>"; };
+ AA100006 /* MeshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshManager.swift; sourceTree = "<group>"; };
+ AA100007 /* MeshProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshProtocol.swift; sourceTree = "<group>"; };
+ AA100008 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = "<group>"; };
+ AA100009 /* PeerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerInfo.swift; sourceTree = "<group>"; };
+ AA100010 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
+ AA100011 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = "<group>"; };
+ AA100012 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
+ AA100013 /* MessageBubble.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBubble.swift; sourceTree = "<group>"; };
+ AA100021 /* GroupModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupModel.swift; sourceTree = "<group>"; };
+ AA100022 /* GroupKeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupKeyManager.swift; sourceTree = "<group>"; };
+ AA100023 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = "<group>"; };
+ AA100020 /* MultipeerConnectivity.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MultipeerConnectivity.framework; path = System/Library/Frameworks/MultipeerConnectivity.framework; sourceTree = SDKROOT; };
+ AA100030 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
+ AA100031 /* btmessage.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = btmessage.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ AA200001 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ AA000020 /* MultipeerConnectivity.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ AA300000 /* Root */ = {
+ isa = PBXGroup;
+ children = (
+ AA300001 /* btmessage */,
+ AA300010 /* Frameworks */,
+ AA300020 /* Products */,
+ );
+ sourceTree = "<group>";
+ };
+ AA300001 /* btmessage */ = {
+ isa = PBXGroup;
+ children = (
+ AA100001 /* btmessageApp.swift */,
+ AA100030 /* Info.plist */,
+ AA300002 /* Crypto */,
+ AA300003 /* Mesh */,
+ AA300004 /* Models */,
+ AA300005 /* ViewModels */,
+ AA300006 /* Views */,
+ );
+ path = btmessage;
+ sourceTree = "<group>";
+ };
+ AA300002 /* Crypto */ = {
+ isa = PBXGroup;
+ children = (
+ AA100003 /* KyberKEM.swift */,
+ AA100004 /* HybridCrypto.swift */,
+ AA100005 /* IdentityManager.swift */,
+ AA100022 /* GroupKeyManager.swift */,
+ );
+ path = Crypto;
+ sourceTree = "<group>";
+ };
+ AA300003 /* Mesh */ = {
+ isa = PBXGroup;
+ children = (
+ AA100006 /* MeshManager.swift */,
+ AA100007 /* MeshProtocol.swift */,
+ );
+ path = Mesh;
+ sourceTree = "<group>";
+ };
+ AA300004 /* Models */ = {
+ isa = PBXGroup;
+ children = (
+ AA100008 /* ChatMessage.swift */,
+ AA100009 /* PeerInfo.swift */,
+ AA100021 /* GroupModel.swift */,
+ );
+ path = Models;
+ sourceTree = "<group>";
+ };
+ AA300005 /* ViewModels */ = {
+ isa = PBXGroup;
+ children = (
+ AA100010 /* AppState.swift */,
+ );
+ path = ViewModels;
+ sourceTree = "<group>";
+ };
+ AA300006 /* Views */ = {
+ isa = PBXGroup;
+ children = (
+ AA100002 /* ContentView.swift */,
+ AA100011 /* ChatListView.swift */,
+ AA100012 /* ChatView.swift */,
+ AA100013 /* MessageBubble.swift */,
+ AA100023 /* CreateGroupView.swift */,
+ );
+ path = Views;
+ sourceTree = "<group>";
+ };
+ AA300010 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ AA100020 /* MultipeerConnectivity.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "<group>";
+ };
+ AA300020 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ AA100031 /* btmessage.app */,
+ );
+ name = Products;
+ sourceTree = "<group>";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ AA400001 /* btmessage */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = AA500001 /* Build configuration list for PBXNativeTarget "btmessage" */;
+ buildPhases = (
+ AA200000 /* Sources */,
+ AA200001 /* Frameworks */,
+ AA200002 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = btmessage;
+ productName = btmessage;
+ productReference = AA100031 /* btmessage.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ AA600001 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1500;
+ LastUpgradeCheck = 1500;
+ TargetAttributes = {
+ AA400001 = {
+ CreatedOnToolsVersion = 15.0;
+ };
+ };
+ };
+ buildConfigurationList = AA500000 /* Build configuration list for PBXProject "btmessage" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = AA300000;
+ productRefGroup = AA300020 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ AA400001 /* btmessage */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ AA200002 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ AA200000 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ AA000001 /* btmessageApp.swift in Sources */,
+ AA000002 /* ContentView.swift in Sources */,
+ AA000003 /* KyberKEM.swift in Sources */,
+ AA000004 /* HybridCrypto.swift in Sources */,
+ AA000005 /* IdentityManager.swift in Sources */,
+ AA000006 /* MeshManager.swift in Sources */,
+ AA000007 /* MeshProtocol.swift in Sources */,
+ AA000008 /* ChatMessage.swift in Sources */,
+ AA000009 /* PeerInfo.swift in Sources */,
+ AA000010 /* AppState.swift in Sources */,
+ AA000011 /* ChatListView.swift in Sources */,
+ AA000012 /* ChatView.swift in Sources */,
+ AA000013 /* MessageBubble.swift in Sources */,
+ AA000021 /* GroupModel.swift in Sources */,
+ AA000022 /* GroupKeyManager.swift in Sources */,
+ AA000023 /* CreateGroupView.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ AA700001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_CYCLE = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ AA700002 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_CYCLE = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ AA700003 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = btmessage/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.btmessage.app;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ AA700004 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = btmessage/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.btmessage.app;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ AA500000 /* Build configuration list for PBXProject "btmessage" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ AA700001 /* Debug */,
+ AA700002 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ AA500001 /* Build configuration list for PBXNativeTarget "btmessage" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ AA700003 /* Debug */,
+ AA700004 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+ };
+ rootObject = AA600001 /* Project object */;
+}
btmessage.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+ version = "1.0">
+ <FileRef
+ location = "self:">
+ </FileRef>
+</Workspace>
btmessage/Crypto/GroupKeyManager.swift
@@ -0,0 +1,71 @@
+// GroupKeyManager.swift
+// Stores group AES-256 keys and group metadata in UserDefaults.
+
+import Foundation
+import CryptoKit
+
+public class GroupKeyManager: ObservableObject {
+ public static let shared = GroupKeyManager()
+
+ // groupID → 32-byte AES key
+ private var keys: [String: SymmetricKey] = [:]
+ // groupID → GroupModel
+ private var groupMap: [String: GroupModel] = [:]
+
+ private init() {
+ load()
+ }
+
+ // MARK: - Read
+
+ public func hasKey(for groupID: String) -> Bool { keys[groupID] != nil }
+
+ public func groupKey(for groupID: String) -> SymmetricKey? { keys[groupID] }
+
+ public func group(for groupID: String) -> GroupModel? { groupMap[groupID] }
+
+ public var allGroups: [GroupModel] { Array(groupMap.values) }
+
+ // MARK: - Write
+
+ public func store(group: GroupModel, key: SymmetricKey) {
+ keys[group.id] = key
+ groupMap[group.id] = group
+ persist()
+ }
+
+ public func store(group: GroupModel, keyData: Data) {
+ store(group: group, key: SymmetricKey(data: keyData))
+ }
+
+ // MARK: - Persistence (UserDefaults)
+
+ private static let keysUD = "btmessage.groupKeys"
+ private static let groupsUD = "btmessage.groups"
+
+ private func persist() {
+ // Keys: [groupID: Data(32 bytes)]
+ var rawKeys: [String: Data] = [:]
+ for (id, key) in keys {
+ rawKeys[id] = key.withUnsafeBytes { Data($0) }
+ }
+ UserDefaults.standard.set(rawKeys, forKey: GroupKeyManager.keysUD)
+
+ // Groups: JSON-encoded [String: GroupModel]
+ if let data = try? JSONEncoder().encode(groupMap) {
+ UserDefaults.standard.set(data, forKey: GroupKeyManager.groupsUD)
+ }
+ }
+
+ private func load() {
+ if let rawKeys = UserDefaults.standard.dictionary(forKey: GroupKeyManager.keysUD) as? [String: Data] {
+ for (id, data) in rawKeys where data.count == 32 {
+ keys[id] = SymmetricKey(data: data)
+ }
+ }
+ if let data = UserDefaults.standard.data(forKey: GroupKeyManager.groupsUD),
+ let decoded = try? JSONDecoder().decode([String: GroupModel].self, from: data) {
+ groupMap = decoded
+ }
+ }
+}
btmessage/Crypto/HybridCrypto.swift
@@ -0,0 +1,124 @@
+// HybridCrypto.swift
+// XWingMLKEM768X25519 (ML-KEM-768 + X25519) — iOS 26+
+// KEM encapsulation → HKDF-SHA256 → AES-256-GCM
+
+import Foundation
+import CryptoKit
+
+// MARK: - Public Key
+
+public struct HybridPublicKey: Codable, Equatable, Hashable {
+ public let rawBytes: Data // 1216 bytes — XWing public key
+ public let peerID: String
+
+ public var encoded: Data { rawBytes }
+
+ public static func from(encoded: Data, peerID: String) -> HybridPublicKey? {
+ guard encoded.count == 1216 else { return nil }
+ return HybridPublicKey(rawBytes: Data(encoded), peerID: peerID)
+ }
+}
+
+// MARK: - Private Key
+
+public struct HybridPrivateKey {
+ public let xwing: XWingMLKEM768X25519.PrivateKey
+}
+
+// MARK: - Encrypted Message (pairwise — KEM-based)
+
+public struct EncryptedMessage: Codable {
+ public let kemCiphertext: Data // XWing encapsulated ciphertext (~1120 bytes)
+ public let aesCiphertext: Data // nonce(12) + ciphertext + tag(16)
+}
+
+// MARK: - Crypto Engine
+
+public enum HybridCrypto {
+
+ public static func generateKeyPair(peerID: String) throws -> (publicKey: HybridPublicKey, privateKey: HybridPrivateKey) {
+ let priv = try XWingMLKEM768X25519.PrivateKey()
+ let pub = HybridPublicKey(rawBytes: priv.publicKey.rawRepresentation, peerID: peerID)
+ return (pub, HybridPrivateKey(xwing: priv))
+ }
+
+ // MARK: Pairwise encrypt (for DMs and group key distribution)
+
+ public static func encrypt(message: Data, recipientPublicKey: HybridPublicKey) throws -> EncryptedMessage {
+ let recipientPub = try XWingMLKEM768X25519.PublicKey(rawRepresentation: recipientPublicKey.rawBytes)
+ let encapResult = try recipientPub.encapsulate()
+
+ let symmetricKey = deriveAESKey(from: encapResult.sharedSecret)
+
+ let nonce = AES.GCM.Nonce()
+ let sealed = try AES.GCM.seal(message, using: symmetricKey, nonce: nonce)
+
+ var aesCT = Data()
+ aesCT.append(sealed.nonce.withUnsafeBytes { Data($0) })
+ aesCT.append(sealed.ciphertext)
+ aesCT.append(sealed.tag)
+
+ return EncryptedMessage(kemCiphertext: encapResult.encapsulated, aesCiphertext: aesCT)
+ }
+
+ public static func decrypt(message: EncryptedMessage, recipientPrivateKey: HybridPrivateKey) throws -> Data {
+ let sharedSecret = try recipientPrivateKey.xwing.decapsulate(message.kemCiphertext)
+ let symmetricKey = deriveAESKey(from: sharedSecret)
+
+ return try aesGCMDecrypt(aesCiphertext: message.aesCiphertext, key: symmetricKey)
+ }
+
+ // MARK: Group symmetric encrypt/decrypt
+
+ public static func encryptGroup(plaintext: Data, groupKey: SymmetricKey) throws -> Data {
+ let nonce = AES.GCM.Nonce()
+ let sealed = try AES.GCM.seal(plaintext, using: groupKey, nonce: nonce)
+
+ var aesCT = Data()
+ aesCT.append(sealed.nonce.withUnsafeBytes { Data($0) })
+ aesCT.append(sealed.ciphertext)
+ aesCT.append(sealed.tag)
+ return aesCT
+ }
+
+ public static func decryptGroup(aesCiphertext: Data, groupKey: SymmetricKey) throws -> Data {
+ try aesGCMDecrypt(aesCiphertext: aesCiphertext, key: groupKey)
+ }
+
+ // MARK: - Internals
+
+ private static func deriveAESKey(from sharedSecret: SymmetricKey) -> SymmetricKey {
+ // Extract raw bytes then run HKDF-SHA256
+ let raw = sharedSecret.withUnsafeBytes { Data($0) }
+ return HKDF<SHA256>.deriveKey(
+ inputKeyMaterial: SymmetricKey(data: raw),
+ info: Data("btmessage-v1".utf8),
+ outputByteCount: 32
+ )
+ }
+
+ private static func aesGCMDecrypt(aesCiphertext: Data, key: SymmetricKey) throws -> Data {
+ guard aesCiphertext.count >= 28 else { throw CryptoError.decryptionFailed }
+ let nonceData = Data(aesCiphertext.prefix(12))
+ let tag = Data(aesCiphertext.suffix(16))
+ let ciphertext = Data(aesCiphertext[12..<(aesCiphertext.count - 16)])
+
+ let nonce = try AES.GCM.Nonce(data: nonceData)
+ let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag)
+ return try AES.GCM.open(sealedBox, using: key)
+ }
+}
+
+// MARK: - Errors
+
+public enum CryptoError: Error, LocalizedError {
+ case decryptionFailed
+ case invalidKey
+
+ public var errorDescription: String? {
+ switch self {
+ case .decryptionFailed: return "Decryption failed"
+ case .invalidKey: return "Invalid key"
+ }
+ }
+}
btmessage/Crypto/IdentityManager.swift
@@ -0,0 +1,80 @@
+// IdentityManager.swift
+
+import Foundation
+import CryptoKit
+import Security
+
+public class IdentityManager: ObservableObject {
+ public static let shared = IdentityManager()
+
+ @Published public private(set) var localPeerID: String
+ @Published public private(set) var publicKey: HybridPublicKey
+
+ private var privateKey: HybridPrivateKey
+
+ private init() {
+ if let (peerID, pub, priv) = IdentityManager.loadIdentity() {
+ self.localPeerID = peerID
+ self.publicKey = pub
+ self.privateKey = priv
+ } else {
+ let peerID = UUID().uuidString
+ let (pub, priv) = try! HybridCrypto.generateKeyPair(peerID: peerID)
+ self.localPeerID = peerID
+ self.publicKey = pub
+ self.privateKey = priv
+ IdentityManager.saveIdentity(peerID: peerID, privKey: priv.xwing)
+ }
+ }
+
+ public func encrypt(message: Data, for recipientPublicKey: HybridPublicKey) throws -> EncryptedMessage {
+ try HybridCrypto.encrypt(message: message, recipientPublicKey: recipientPublicKey)
+ }
+
+ public func decrypt(message: EncryptedMessage) throws -> Data {
+ try HybridCrypto.decrypt(message: message, recipientPrivateKey: privateKey)
+ }
+
+ // MARK: - Keychain
+
+ private static func loadIdentity() -> (String, HybridPublicKey, HybridPrivateKey)? {
+ guard
+ let peerIDData = keychainLoad(key: "btmessage.identity.peerID"),
+ let peerID = String(data: peerIDData, encoding: .utf8),
+ let keyData = keychainLoad(key: "btmessage.identity.xwing"),
+ let xwing = try? XWingMLKEM768X25519.PrivateKey(integrityCheckedRepresentation: keyData)
+ else { return nil }
+
+ let pub = HybridPublicKey(rawBytes: xwing.publicKey.rawRepresentation, peerID: peerID)
+ let priv = HybridPrivateKey(xwing: xwing)
+ return (peerID, pub, priv)
+ }
+
+ private static func saveIdentity(peerID: String, privKey: XWingMLKEM768X25519.PrivateKey) {
+ keychainSave(key: "btmessage.identity.peerID", data: peerID.data(using: .utf8)!)
+ keychainSave(key: "btmessage.identity.xwing", data: Data(privKey.integrityCheckedRepresentation))
+ }
+
+ private static func keychainSave(key: String, data: Data) {
+ let q: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrAccount as String: key,
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
+ ]
+ SecItemDelete(q as CFDictionary)
+ SecItemAdd(q as CFDictionary, nil)
+ }
+
+ private static func keychainLoad(key: String) -> Data? {
+ let q: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrAccount as String: key,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+ var item: CFTypeRef?
+ guard SecItemCopyMatching(q as CFDictionary, &item) == errSecSuccess else { return nil }
+ return item as? Data
+ }
+}
btmessage/Crypto/KyberKEM.swift
@@ -0,0 +1,5 @@
+// KyberKEM.swift
+// Replaced by native CryptoKit.XWingMLKEM768X25519 (iOS 26+)
+// This file is intentionally empty.
+
+import Foundation
btmessage/Info.plist
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
+ <key>CFBundleExecutable</key>
+ <string>$(EXECUTABLE_NAME)</string>
+ <key>CFBundleIdentifier</key>
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundleName</key>
+ <string>$(PRODUCT_NAME)</string>
+ <key>CFBundlePackageType</key>
+ <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
+ <key>CFBundleShortVersionString</key>
+ <string>1.0</string>
+ <key>CFBundleVersion</key>
+ <string>1</string>
+ <key>LSRequiresIPhoneOS</key>
+ <true/>
+ <key>NSBonjourServices</key>
+ <array>
+ <string>_btmsg-pqc._tcp</string>
+ <string>_btmsg-pqc._udp</string>
+ </array>
+ <key>NSLocalNetworkUsageDescription</key>
+ <string>btmessage uses the local network to discover and communicate with nearby peers over an encrypted Bluetooth/WiFi mesh.</string>
+ <key>NSBluetoothAlwaysUsageDescription</key>
+ <string>btmessage uses Bluetooth to discover and communicate with nearby peers.</string>
+ <key>NSBluetoothPeripheralUsageDescription</key>
+ <string>btmessage uses Bluetooth to communicate with nearby peers.</string>
+ <key>UIApplicationSceneManifest</key>
+ <dict>
+ <key>UIApplicationSupportsMultipleScenes</key>
+ <false/>
+ </dict>
+ <key>UILaunchScreen</key>
+ <dict/>
+ <key>UIRequiredDeviceCapabilities</key>
+ <array>
+ <string>bluetooth-le</string>
+ </array>
+ <key>UISupportedInterfaceOrientations</key>
+ <array>
+ <string>UIInterfaceOrientationPortrait</string>
+ <string>UIInterfaceOrientationLandscapeLeft</string>
+ <string>UIInterfaceOrientationLandscapeRight</string>
+ </array>
+</dict>
+</plist>
btmessage/Mesh/MeshManager.swift
@@ -0,0 +1,380 @@
+// MeshManager.swift
+// Bluetooth/WiFi mesh via MultipeerConnectivity
+// Gère DMs pairwise (XWing) et messages de groupe (AES symétrique)
+
+import Foundation
+import MultipeerConnectivity
+import Combine
+import CryptoKit
+import os.log
+
+private let log = Logger(subsystem: "com.btmessage.app", category: "Mesh")
+
+public class MeshManager: NSObject, ObservableObject {
+ // MARK: - Published State
+
+ @Published public var connectedPeers: [MCPeerID] = []
+ @Published public var knownPublicKeys: [String: HybridPublicKey] = [:]
+ @Published public var incomingMessages: [ReceivedMessage] = []
+
+ // MARK: - Internal
+
+ private let serviceType = "btmsg-pqc"
+ private let localPeerID: MCPeerID
+ private let session: MCSession
+ private let advertiser: MCNearbyServiceAdvertiser
+ private let browser: MCNearbyServiceBrowser
+ private let seenCache = SeenMessageCache()
+ private let identity = IdentityManager.shared
+
+ public var onMessageReceived: ((ReceivedMessage) -> Void)?
+ public var onGroupKeyReceived: ((GroupModel) -> Void)?
+
+ // MARK: - Init
+
+ public init(displayName: String) {
+ self.localPeerID = MCPeerID(displayName: displayName)
+ self.session = MCSession(peer: localPeerID,
+ securityIdentity: nil,
+ encryptionPreference: .required)
+ self.advertiser = MCNearbyServiceAdvertiser(peer: localPeerID,
+ discoveryInfo: nil,
+ serviceType: serviceType)
+ self.browser = MCNearbyServiceBrowser(peer: localPeerID,
+ serviceType: serviceType)
+ super.init()
+ session.delegate = self
+ advertiser.delegate = self
+ browser.delegate = self
+ }
+
+ // MARK: - Lifecycle
+
+ public func start() {
+ advertiser.startAdvertisingPeer()
+ browser.startBrowsingForPeers()
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
+ self?.broadcastPublicKey()
+ }
+ }
+
+ public func stop() {
+ advertiser.stopAdvertisingPeer()
+ browser.stopBrowsingForPeers()
+ session.disconnect()
+ }
+
+ // MARK: - Send DM
+
+ public func sendMessage(text: String, to recipientPeerID: String) throws {
+ log.info("sendMessage: to=\(recipientPeerID) keyKnown=\(self.knownPublicKeys[recipientPeerID] != nil)")
+ guard let recipientKey = knownPublicKeys[recipientPeerID] else {
+ log.error("sendMessage: no key for \(recipientPeerID)")
+ throw MeshError.unknownRecipient
+ }
+ let plaintext = Data(text.utf8)
+ let encrypted = try identity.encrypt(message: plaintext, for: recipientKey)
+ let chatPayload = ChatPayload(messageID: UUID().uuidString, encryptedMessage: encrypted)
+ guard let payloadData = chatPayload.encoded() else { throw MeshError.encodingFailed }
+
+ let envelope = MeshEnvelope.makeChat(
+ senderID: identity.localPeerID,
+ recipientID: recipientPeerID,
+ payload: payloadData
+ )
+ route(envelope: envelope)
+ }
+
+ // MARK: - Send Group Message
+
+ public func sendGroupMessage(text: String, to groupID: String) throws {
+ guard let groupKey = GroupKeyManager.shared.groupKey(for: groupID) else {
+ throw MeshError.unknownRecipient
+ }
+ let plaintext = Data(text.utf8)
+ let aesCT = try HybridCrypto.encryptGroup(plaintext: plaintext, groupKey: groupKey)
+ let groupPayload = GroupChatPayload(
+ groupID: groupID,
+ messageID: UUID().uuidString,
+ aesCiphertext: aesCT
+ )
+ guard let payloadData = groupPayload.encoded() else { throw MeshError.encodingFailed }
+ let envelope = MeshEnvelope.makeGroupChat(senderID: identity.localPeerID, groupID: groupID, payload: payloadData)
+ route(envelope: envelope)
+ }
+
+ // MARK: - Distribute Group Key (créateur → membres)
+
+ public func sendGroupKeyDistrib(group: GroupModel, groupKeyData: Data, to recipientPeerID: String) throws {
+ guard let recipientKey = knownPublicKeys[recipientPeerID] else {
+ throw MeshError.unknownRecipient
+ }
+ let encryptedKey = try identity.encrypt(message: groupKeyData, for: recipientKey)
+ let payload = GroupKeyPayload(
+ groupID: group.id,
+ groupName: group.name,
+ memberIDs: group.memberIDs,
+ encryptedGroupKey: encryptedKey
+ )
+ guard let payloadData = payload.encoded() else { throw MeshError.encodingFailed }
+ let envelope = MeshEnvelope.makeGroupKeyDistrib(
+ senderID: identity.localPeerID,
+ recipientID: recipientPeerID,
+ payload: payloadData
+ )
+ route(envelope: envelope)
+ }
+
+ // MARK: - Routing
+
+ func route(envelope: MeshEnvelope) {
+ guard !seenCache.contains(envelope.id) else { return }
+ seenCache.insert(envelope.id)
+ guard let data = envelope.encoded() else { return }
+
+ let peers = session.connectedPeers
+ guard !peers.isEmpty else {
+ log.error("route: no connected peers, dropping type=\(envelope.type.rawValue)")
+ return
+ }
+
+ if let directPeer = peers.first(where: { $0.displayName == envelope.recipientID }) {
+ log.info("route: direct → \(directPeer.displayName) \(data.count)b")
+ try? session.send(data, toPeers: [directPeer], with: .reliable)
+ } else {
+ log.info("route: flood → \(peers.count) peers \(data.count)b")
+ try? session.send(data, toPeers: peers, with: .reliable)
+ }
+ }
+
+ private func handleReceived(envelope: MeshEnvelope, from sender: MCPeerID) {
+ guard !seenCache.contains(envelope.id) else { return }
+ seenCache.insert(envelope.id)
+
+ switch envelope.type {
+
+ case .chat:
+ if envelope.recipientID == identity.localPeerID {
+ deliverChatMessage(envelope: envelope)
+ sendAck(messageID: envelope.id, to: envelope.senderID)
+ } else {
+ if let fwd = envelope.forwarded() { route(envelope: fwd) }
+ }
+
+ case .keyExchange:
+ let peerID = envelope.senderID
+ log.info("keyExchange: from=\(peerID) payloadBytes=\(envelope.payload.count)")
+ if let pub = HybridPublicKey.from(encoded: envelope.payload, peerID: peerID) {
+ log.info("keyExchange: stored key for \(peerID)")
+ DispatchQueue.main.async { [weak self] in
+ self?.knownPublicKeys[peerID] = pub
+ }
+ } else {
+ log.error("keyExchange: invalid key from \(peerID), payloadBytes=\(envelope.payload.count) expected=1216")
+ }
+ if let fwd = envelope.forwarded() { route(envelope: fwd) }
+
+ case .ack:
+ break
+
+ case .discovery:
+ broadcastPublicKey()
+ if let fwd = envelope.forwarded() { route(envelope: fwd) }
+
+ case .groupChat:
+ deliverGroupMessage(envelope: envelope)
+ // Toujours forwarder — d'autres membres peuvent être derrière des hops
+ if let fwd = envelope.forwarded() { route(envelope: fwd) }
+
+ case .groupKeyDistrib:
+ if envelope.recipientID == identity.localPeerID {
+ receiveGroupKey(envelope: envelope)
+ } else {
+ if let fwd = envelope.forwarded() { route(envelope: fwd) }
+ }
+ }
+ }
+
+ // MARK: - Delivery
+
+ private func deliverChatMessage(envelope: MeshEnvelope) {
+ guard let payload = ChatPayload.decode(envelope.payload) else {
+ log.error("deliverChat: decode failed")
+ return
+ }
+ do {
+ let plaintext = try identity.decrypt(message: payload.encryptedMessage)
+ let text = String(data: plaintext, encoding: .utf8) ?? "<binary>"
+ let msg = ReceivedMessage(
+ messageID: payload.messageID,
+ senderID: envelope.senderID,
+ conversationID: envelope.senderID,
+ text: text,
+ timestamp: Date(timeIntervalSince1970: envelope.timestamp)
+ )
+ DispatchQueue.main.async { [weak self] in
+ self?.incomingMessages.append(msg)
+ self?.onMessageReceived?(msg)
+ }
+ } catch {
+ log.error("deliverChat: decryption failed: \(error)")
+ }
+ }
+
+ private func deliverGroupMessage(envelope: MeshEnvelope) {
+ guard let payload = GroupChatPayload.decode(envelope.payload) else {
+ log.error("deliverGroup: decode failed")
+ return
+ }
+ // Ignorer si on n'est pas membre (pas de clé pour ce groupe)
+ guard let groupKey = GroupKeyManager.shared.groupKey(for: payload.groupID) else { return }
+ // Ignorer les messages qu'on a nous-mêmes envoyés (déjà dans la UI)
+ guard envelope.senderID != identity.localPeerID else { return }
+
+ do {
+ let plaintext = try HybridCrypto.decryptGroup(aesCiphertext: payload.aesCiphertext, groupKey: groupKey)
+ let text = String(data: plaintext, encoding: .utf8) ?? "<binary>"
+ let msg = ReceivedMessage(
+ messageID: payload.messageID,
+ senderID: envelope.senderID,
+ conversationID: payload.groupID,
+ text: text,
+ timestamp: Date(timeIntervalSince1970: envelope.timestamp)
+ )
+ DispatchQueue.main.async { [weak self] in
+ self?.incomingMessages.append(msg)
+ self?.onMessageReceived?(msg)
+ }
+ } catch {
+ log.error("deliverGroup: decryption failed: \(error)")
+ }
+ }
+
+ private func receiveGroupKey(envelope: MeshEnvelope) {
+ guard let payload = GroupKeyPayload.decode(envelope.payload) else {
+ log.error("receiveGroupKey: decode failed")
+ return
+ }
+ do {
+ let groupKeyData = try identity.decrypt(message: payload.encryptedGroupKey)
+ guard groupKeyData.count == 32 else {
+ log.error("receiveGroupKey: invalid key length \(groupKeyData.count)")
+ return
+ }
+ let group = GroupModel(
+ id: payload.groupID,
+ name: payload.groupName,
+ memberIDs: payload.memberIDs,
+ creatorID: envelope.senderID
+ )
+ GroupKeyManager.shared.store(group: group, keyData: groupKeyData)
+ log.info("receiveGroupKey: joined group '\(payload.groupName)' id=\(payload.groupID)")
+ DispatchQueue.main.async { [weak self] in
+ self?.onGroupKeyReceived?(group)
+ }
+ } catch {
+ log.error("receiveGroupKey: decryption failed: \(error)")
+ }
+ }
+
+ // MARK: - Helpers
+
+ private func broadcastPublicKey() {
+ let encoded = identity.publicKey.encoded
+ let envelope = MeshEnvelope.makeKeyExchange(
+ senderID: identity.localPeerID,
+ publicKeyEncoded: encoded
+ )
+ guard let data = envelope.encoded() else { return }
+ let peers = session.connectedPeers
+ guard !peers.isEmpty else { return }
+ try? session.send(data, toPeers: peers, with: .reliable)
+ log.info("broadcastPublicKey: \(data.count) bytes → \(peers.count) peer(s)")
+ }
+
+ private func sendAck(messageID: String, to recipientID: String) {
+ route(envelope: MeshEnvelope.makeAck(
+ senderID: identity.localPeerID,
+ recipientID: recipientID,
+ messageID: messageID
+ ))
+ }
+}
+
+// MARK: - MCSessionDelegate
+
+extension MeshManager: MCSessionDelegate {
+ public func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
+ DispatchQueue.main.async { [weak self] in
+ self?.connectedPeers = session.connectedPeers
+ }
+ log.info("peerState: \(peerID.displayName) → \(state.rawValue)")
+ if state == .connected { broadcastPublicKey() }
+ }
+
+ public func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
+ guard let envelope = MeshEnvelope.decode(data) else {
+ log.error("didReceive: failed to decode from \(peerID.displayName)")
+ return
+ }
+ handleReceived(envelope: envelope, from: peerID)
+ }
+
+ public func session(_ session: MCSession, didReceive stream: InputStream,
+ withName streamName: String, fromPeer peerID: MCPeerID) {}
+ public func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String,
+ fromPeer peerID: MCPeerID, with progress: Progress) {}
+ public func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String,
+ fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) {}
+}
+
+// MARK: - MCNearbyServiceAdvertiserDelegate
+
+extension MeshManager: MCNearbyServiceAdvertiserDelegate {
+ public func advertiser(_ advertiser: MCNearbyServiceAdvertiser,
+ didReceiveInvitationFromPeer peerID: MCPeerID,
+ withContext context: Data?,
+ invitationHandler: @escaping (Bool, MCSession?) -> Void) {
+ invitationHandler(true, session)
+ }
+}
+
+// MARK: - MCNearbyServiceBrowserDelegate
+
+extension MeshManager: MCNearbyServiceBrowserDelegate {
+ public func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID,
+ withDiscoveryInfo info: [String: String]?) {
+ guard !session.connectedPeers.contains(peerID) else { return }
+ guard localPeerID.displayName < peerID.displayName else {
+ log.debug("foundPeer: \(peerID.displayName) — waiting for invite")
+ return
+ }
+ log.info("foundPeer: inviting \(peerID.displayName)")
+ browser.invitePeer(peerID, to: session, withContext: nil, timeout: 10)
+ }
+
+ public func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {}
+}
+
+// MARK: - Supporting Types
+
+public struct ReceivedMessage: Identifiable {
+ public let id = UUID()
+ public let messageID: String
+ public let senderID: String
+ public let conversationID: String // peerID pour DM, groupID pour groupe
+ public let text: String
+ public let timestamp: Date
+}
+
+public enum MeshError: Error, LocalizedError {
+ case unknownRecipient
+ case encodingFailed
+
+ public var errorDescription: String? {
+ switch self {
+ case .unknownRecipient: return "Clé publique inconnue pour ce destinataire"
+ case .encodingFailed: return "Échec d'encodage du message"
+ }
+ }
+}
btmessage/Mesh/MeshProtocol.swift
@@ -0,0 +1,130 @@
+// MeshProtocol.swift
+// Wire protocol for the mesh network: routing, deduplication, TTL
+
+import Foundation
+
+// MARK: - Message Types
+
+enum MeshMessageType: UInt8, Codable {
+ case chat = 0x01 // message chiffré pairwise (DM)
+ case keyExchange = 0x02 // annonce de clé publique
+ case ack = 0x03 // accusé de réception
+ case discovery = 0x04 // ping de découverte
+ case groupChat = 0x05 // message chiffré avec la clé de groupe
+ case groupKeyDistrib = 0x06 // distribution unicast de la clé de groupe
+}
+
+// MARK: - Mesh Envelope
+
+struct MeshEnvelope: Codable {
+ let id: String // UUID – déduplication
+ let type: MeshMessageType
+ let senderID: String
+ let recipientID: String // peerID, groupID, ou "*"
+ let ttl: UInt8
+ let payload: Data
+ let timestamp: Double
+
+ static func makeChat(
+ senderID: String,
+ recipientID: String,
+ payload: Data,
+ ttl: UInt8 = 7
+ ) -> MeshEnvelope {
+ MeshEnvelope(id: UUID().uuidString, type: .chat,
+ senderID: senderID, recipientID: recipientID,
+ ttl: ttl, payload: payload,
+ timestamp: Date().timeIntervalSince1970)
+ }
+
+ static func makeKeyExchange(senderID: String, publicKeyEncoded: Data) -> MeshEnvelope {
+ MeshEnvelope(id: UUID().uuidString, type: .keyExchange,
+ senderID: senderID, recipientID: "*",
+ ttl: 5, payload: publicKeyEncoded,
+ timestamp: Date().timeIntervalSince1970)
+ }
+
+ static func makeAck(senderID: String, recipientID: String, messageID: String) -> MeshEnvelope {
+ MeshEnvelope(id: UUID().uuidString, type: .ack,
+ senderID: senderID, recipientID: recipientID,
+ ttl: 5, payload: messageID.data(using: .utf8) ?? Data(),
+ timestamp: Date().timeIntervalSince1970)
+ }
+
+ static func makeGroupChat(senderID: String, groupID: String, payload: Data) -> MeshEnvelope {
+ MeshEnvelope(id: UUID().uuidString, type: .groupChat,
+ senderID: senderID, recipientID: groupID,
+ ttl: 7, payload: payload,
+ timestamp: Date().timeIntervalSince1970)
+ }
+
+ static func makeGroupKeyDistrib(senderID: String, recipientID: String, payload: Data) -> MeshEnvelope {
+ MeshEnvelope(id: UUID().uuidString, type: .groupKeyDistrib,
+ senderID: senderID, recipientID: recipientID,
+ ttl: 5, payload: payload,
+ timestamp: Date().timeIntervalSince1970)
+ }
+
+ func forwarded() -> MeshEnvelope? {
+ guard ttl > 1 else { return nil }
+ return MeshEnvelope(id: id, type: type,
+ senderID: senderID, recipientID: recipientID,
+ ttl: ttl - 1, payload: payload, timestamp: timestamp)
+ }
+
+ func encoded() -> Data? { try? JSONEncoder().encode(self) }
+ static func decode(_ data: Data) -> MeshEnvelope? { try? JSONDecoder().decode(MeshEnvelope.self, from: data) }
+}
+
+// MARK: - Chat Payload (DM pairwise)
+
+struct ChatPayload: Codable {
+ let messageID: String
+ let encryptedMessage: EncryptedMessage
+
+ func encoded() -> Data? { try? JSONEncoder().encode(self) }
+ static func decode(_ data: Data) -> ChatPayload? { try? JSONDecoder().decode(ChatPayload.self, from: data) }
+}
+
+// MARK: - Group Chat Payload
+
+struct GroupChatPayload: Codable {
+ let groupID: String
+ let messageID: String
+ let aesCiphertext: Data // nonce(12) + AES-GCM(groupKey, plaintext) + tag(16)
+
+ func encoded() -> Data? { try? JSONEncoder().encode(self) }
+ static func decode(_ data: Data) -> GroupChatPayload? { try? JSONDecoder().decode(GroupChatPayload.self, from: data) }
+}
+
+// MARK: - Group Key Distribution Payload
+
+struct GroupKeyPayload: Codable {
+ let groupID: String
+ let groupName: String
+ let memberIDs: [String]
+ let encryptedGroupKey: EncryptedMessage // 32 octets de clé AES chiffrés avec la clé XWing du destinataire
+
+ func encoded() -> Data? { try? JSONEncoder().encode(self) }
+ static func decode(_ data: Data) -> GroupKeyPayload? { try? JSONDecoder().decode(GroupKeyPayload.self, from: data) }
+}
+
+// MARK: - Dedup Cache (LRU-ish)
+
+class SeenMessageCache {
+ private var seen = Set<String>()
+ private var queue = [String]()
+ private let maxSize = 1000
+
+ func contains(_ id: String) -> Bool { seen.contains(id) }
+
+ func insert(_ id: String) {
+ guard !seen.contains(id) else { return }
+ seen.insert(id)
+ queue.append(id)
+ if queue.count > maxSize {
+ let old = queue.removeFirst()
+ seen.remove(old)
+ }
+ }
+}
btmessage/Models/ChatMessage.swift
@@ -0,0 +1,16 @@
+// ChatMessage.swift
+
+import Foundation
+
+public struct ChatMessage: Identifiable, Codable {
+ public let id: String
+ public let conversationID: String // peerID of the other participant
+ public let senderID: String
+ public let text: String
+ public let timestamp: Date
+ public var delivered: Bool
+
+ public var isLocal: Bool {
+ senderID == IdentityManager.shared.localPeerID
+ }
+}
btmessage/Models/GroupModel.swift
@@ -0,0 +1,10 @@
+// GroupModel.swift
+
+import Foundation
+
+public struct GroupModel: Identifiable, Codable, Hashable {
+ public let id: String // UUID — aussi utilisé comme recipientID dans les enveloppes
+ public var name: String
+ public var memberIDs: [String] // peerIDs de tous les membres (créateur inclus)
+ public let creatorID: String
+}
btmessage/Models/PeerInfo.swift
@@ -0,0 +1,12 @@
+// PeerInfo.swift
+
+import Foundation
+
+public struct PeerInfo: Identifiable, Codable, Hashable {
+ public let id: String // peerID
+ public let displayName: String
+ public var publicKey: HybridPublicKey?
+ public var lastSeen: Date
+
+ public var isKeyKnown: Bool { publicKey != nil }
+}
btmessage/ViewModels/AppState.swift
@@ -0,0 +1,175 @@
+// AppState.swift
+
+import Foundation
+import Combine
+import CryptoKit
+
+@MainActor
+public class AppState: ObservableObject {
+ // MARK: - Published
+
+ @Published public var peers: [PeerInfo] = []
+ @Published public var groups: [GroupModel] = []
+ @Published public var conversations: [String: [ChatMessage]] = [:] // peerID ou groupID → messages
+ @Published public var selectedConversationID: String? = nil
+ @Published public var errorMessage: String? = nil
+
+ // MARK: - Dependencies
+
+ public let mesh: MeshManager
+ private let identity = IdentityManager.shared
+ private var cancellables = Set<AnyCancellable>()
+
+ // MARK: - Init
+
+ public init() {
+ let displayName = IdentityManager.shared.localPeerID
+ self.mesh = MeshManager(displayName: displayName)
+
+ // Charger les groupes persistés
+ self.groups = GroupKeyManager.shared.allGroups
+
+ setupBindings()
+ mesh.start()
+ }
+
+ // MARK: - DM
+
+ public func sendMessage(text: String, to peerID: String) {
+ appendOutgoing(text: text, conversationID: peerID, senderID: identity.localPeerID)
+ do {
+ try mesh.sendMessage(text: text, to: peerID)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ // MARK: - Groupe
+
+ public func createGroup(name: String, memberIDs: [String]) {
+ let groupID = UUID().uuidString
+ let groupKey = SymmetricKey(size: .bits256)
+ let groupKeyData = groupKey.withUnsafeBytes { Data($0) }
+ let allMembers = Array(Set(memberIDs + [identity.localPeerID]))
+ let group = GroupModel(id: groupID, name: name, memberIDs: allMembers, creatorID: identity.localPeerID)
+
+ GroupKeyManager.shared.store(group: group, key: groupKey)
+ groups.append(group)
+
+ // Distribuer la clé chiffrée à chaque membre (sauf soi-même)
+ for memberID in memberIDs where memberID != identity.localPeerID {
+ do {
+ try mesh.sendGroupKeyDistrib(group: group, groupKeyData: groupKeyData, to: memberID)
+ } catch {
+ errorMessage = "Erreur distribution clé à \(memberID.prefix(8)): \(error.localizedDescription)"
+ }
+ }
+ }
+
+ public func sendGroupMessage(text: String, to groupID: String) {
+ appendOutgoing(text: text, conversationID: groupID, senderID: identity.localPeerID)
+ do {
+ try mesh.sendGroupMessage(text: text, to: groupID)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ /// Point d'entrée unifié — route vers DM ou groupe automatiquement
+ public func send(text: String, to conversationID: String) {
+ if isGroup(conversationID) {
+ sendGroupMessage(text: text, to: conversationID)
+ } else {
+ sendMessage(text: text, to: conversationID)
+ }
+ }
+
+ public func messages(for conversationID: String) -> [ChatMessage] {
+ conversations[conversationID] ?? []
+ }
+
+ public func isGroup(_ id: String) -> Bool {
+ groups.contains(where: { $0.id == id })
+ }
+
+ // MARK: - Private
+
+ private func appendOutgoing(text: String, conversationID: String, senderID: String) {
+ let msg = ChatMessage(
+ id: UUID().uuidString,
+ conversationID: conversationID,
+ senderID: senderID,
+ text: text,
+ timestamp: Date(),
+ delivered: false
+ )
+ conversations[conversationID, default: []].append(msg)
+ }
+
+ private func setupBindings() {
+ mesh.$connectedPeers
+ .sink { [weak self] mcPeers in
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ for mcPeer in mcPeers {
+ if !self.peers.contains(where: { $0.id == mcPeer.displayName }) {
+ self.peers.append(PeerInfo(
+ id: mcPeer.displayName,
+ displayName: mcPeer.displayName,
+ publicKey: nil,
+ lastSeen: Date()
+ ))
+ }
+ }
+ }
+ }
+ .store(in: &cancellables)
+
+ mesh.$knownPublicKeys
+ .sink { [weak self] keyMap in
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ for (peerID, pubKey) in keyMap {
+ if let idx = self.peers.firstIndex(where: { $0.id == peerID }) {
+ self.peers[idx].publicKey = pubKey
+ self.peers[idx].lastSeen = Date()
+ } else {
+ self.peers.append(PeerInfo(
+ id: peerID,
+ displayName: peerID,
+ publicKey: pubKey,
+ lastSeen: Date()
+ ))
+ }
+ }
+ }
+ }
+ .store(in: &cancellables)
+
+ mesh.$incomingMessages
+ .sink { [weak self] received in
+ Task { @MainActor [weak self] in
+ guard let self, let last = received.last else { return }
+ let msg = ChatMessage(
+ id: last.messageID,
+ conversationID: last.conversationID,
+ senderID: last.senderID,
+ text: last.text,
+ timestamp: last.timestamp,
+ delivered: true
+ )
+ self.conversations[last.conversationID, default: []].append(msg)
+ }
+ }
+ .store(in: &cancellables)
+
+ mesh.onGroupKeyReceived = { [weak self] group in
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ if !self.groups.contains(where: { $0.id == group.id }) {
+ self.groups.append(group)
+ }
+ }
+ }
+ }
+}
btmessage/Views/ChatListView.swift
@@ -0,0 +1,183 @@
+// ChatListView.swift
+
+import SwiftUI
+
+struct ChatListView: View {
+ @EnvironmentObject var appState: AppState
+ @State private var showCreateGroup = false
+
+ var body: some View {
+ List {
+ // MARK: Pairs (DMs)
+ if !appState.peers.isEmpty {
+ Section("Contacts") {
+ ForEach(appState.peers) { peer in
+ NavigationLink(destination: ChatView(conversationID: peer.id, title: String(peer.displayName.prefix(8)).uppercased())) {
+ PeerRow(peer: peer, lastMessage: appState.conversations[peer.id]?.last)
+ }
+ }
+ }
+ }
+
+ // MARK: Groupes
+ if !appState.groups.isEmpty {
+ Section("Groupes") {
+ ForEach(appState.groups) { group in
+ NavigationLink(destination: ChatView(conversationID: group.id, title: group.name)) {
+ GroupRow(group: group, lastMessage: appState.conversations[group.id]?.last)
+ }
+ }
+ }
+ }
+
+ // MARK: Placeholder si vide
+ if appState.peers.isEmpty && appState.groups.isEmpty {
+ VStack(spacing: 12) {
+ Image(systemName: "antenna.radiowaves.left.and.right")
+ .font(.system(size: 44))
+ .foregroundColor(.secondary)
+ Text("Recherche de pairs…")
+ .foregroundColor(.secondary)
+ Text("Active Bluetooth et WiFi.\nLes appareils avec btmessage apparaîtront ici.")
+ .font(.caption)
+ .multilineTextAlignment(.center)
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 40)
+ .listRowBackground(Color.clear)
+ }
+ }
+ .navigationTitle("btmessage")
+ .toolbar {
+ ToolbarItemGroup(placement: .navigationBarTrailing) {
+ Button(action: { showCreateGroup = true }) {
+ Image(systemName: "person.3.fill")
+ }
+ ConnectionIndicator(count: appState.peers.count)
+ }
+ }
+ .sheet(isPresented: $showCreateGroup) {
+ CreateGroupView()
+ .environmentObject(appState)
+ }
+ .alert("Erreur",
+ isPresented: Binding(
+ get: { appState.errorMessage != nil },
+ set: { if !$0 { appState.errorMessage = nil } }
+ )
+ ) {
+ Button("OK") { appState.errorMessage = nil }
+ } message: {
+ Text(appState.errorMessage ?? "")
+ }
+ }
+}
+
+// MARK: - PeerRow
+
+private struct PeerRow: View {
+ let peer: PeerInfo
+ let lastMessage: ChatMessage?
+
+ var body: some View {
+ HStack(spacing: 12) {
+ ZStack {
+ Circle()
+ .fill(peer.isKeyKnown ? Color.green.opacity(0.2) : Color.orange.opacity(0.2))
+ .frame(width: 44, height: 44)
+ Image(systemName: peer.isKeyKnown ? "lock.fill" : "lock.open")
+ .foregroundColor(peer.isKeyKnown ? .green : .orange)
+ }
+
+ VStack(alignment: .leading, spacing: 2) {
+ HStack {
+ Text(String(peer.displayName.prefix(8)).uppercased())
+ .font(.headline)
+ Spacer()
+ if let msg = lastMessage {
+ Text(msg.timestamp, style: .time)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ if let msg = lastMessage {
+ Text(msg.text)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ } else if peer.isKeyKnown {
+ Text("Clé PQC échangée")
+ .font(.caption)
+ .foregroundColor(.green)
+ } else {
+ Text("Échange de clé en cours…")
+ .font(.caption)
+ .foregroundColor(.orange)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
+
+// MARK: - GroupRow
+
+private struct GroupRow: View {
+ let group: GroupModel
+ let lastMessage: ChatMessage?
+
+ var body: some View {
+ HStack(spacing: 12) {
+ ZStack {
+ Circle()
+ .fill(Color.blue.opacity(0.15))
+ .frame(width: 44, height: 44)
+ Image(systemName: "person.3.fill")
+ .font(.system(size: 16))
+ .foregroundColor(.blue)
+ }
+
+ VStack(alignment: .leading, spacing: 2) {
+ HStack {
+ Text(group.name)
+ .font(.headline)
+ Spacer()
+ if let msg = lastMessage {
+ Text(msg.timestamp, style: .time)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ if let msg = lastMessage {
+ Text(msg.text)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ } else {
+ Text("\(group.memberIDs.count) membres · chiffré AES-256")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
+
+// MARK: - ConnectionIndicator
+
+private struct ConnectionIndicator: View {
+ let count: Int
+
+ var body: some View {
+ HStack(spacing: 4) {
+ Circle()
+ .fill(count > 0 ? Color.green : Color.gray)
+ .frame(width: 8, height: 8)
+ Text("\(count)")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+}
btmessage/Views/ChatView.swift
@@ -0,0 +1,106 @@
+// ChatView.swift
+
+import SwiftUI
+
+struct ChatView: View {
+ @EnvironmentObject var appState: AppState
+ let conversationID: String
+ let title: String
+
+ @State private var inputText = ""
+
+ var messages: [ChatMessage] {
+ appState.messages(for: conversationID)
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Badge de sécurité
+ SecurityBadge(conversationID: conversationID)
+ .environmentObject(appState)
+
+ Divider()
+
+ // Liste des messages
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(spacing: 4) {
+ ForEach(messages) { msg in
+ MessageBubble(message: msg)
+ .id(msg.id)
+ }
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ }
+ .onAppear { scrollToBottom(proxy: proxy) }
+ .onChange(of: messages.count) { _ in scrollToBottom(proxy: proxy) }
+ }
+
+ Divider()
+
+ // Barre de saisie
+ HStack(spacing: 8) {
+ TextField("Message", text: $inputText, axis: .vertical)
+ .textFieldStyle(.roundedBorder)
+ .lineLimit(1...5)
+ .onSubmit { send() }
+
+ Button(action: send) {
+ Image(systemName: "paperplane.fill")
+ .foregroundColor(inputText.isEmpty ? .gray : .blue)
+ }
+ .disabled(inputText.isEmpty)
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ .background(Color(.systemBackground))
+ }
+ .navigationTitle(title)
+ .navigationBarTitleDisplayMode(.inline)
+ }
+
+ private func send() {
+ let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty else { return }
+ inputText = ""
+ appState.send(text: text, to: conversationID)
+ }
+
+ private func scrollToBottom(proxy: ScrollViewProxy) {
+ if let last = messages.last {
+ withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
+ }
+ }
+}
+
+// MARK: - Security Badge
+
+private struct SecurityBadge: View {
+ @EnvironmentObject var appState: AppState
+ let conversationID: String
+
+ var body: some View {
+ HStack(spacing: 6) {
+ if appState.isGroup(conversationID) {
+ Image(systemName: "lock.fill")
+ .font(.caption)
+ .foregroundColor(.blue)
+ Text("Groupe · AES-256-GCM")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ } else {
+ let peer = appState.peers.first(where: { $0.id == conversationID })
+ Image(systemName: peer?.isKeyKnown == true ? "lock.fill" : "lock.open")
+ .font(.caption)
+ .foregroundColor(peer?.isKeyKnown == true ? .green : .orange)
+ Text(peer?.isKeyKnown == true
+ ? "XWing (ML-KEM-768 + X25519) · AES-256-GCM"
+ : "Échange de clé en cours…")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
btmessage/Views/ContentView.swift
@@ -0,0 +1,12 @@
+// ContentView.swift
+
+import SwiftUI
+
+struct ContentView: View {
+ var body: some View {
+ NavigationStack {
+ ChatListView()
+ }
+ // AppState flows down automatically via environmentObject set in btmessageApp
+ }
+}
btmessage/Views/CreateGroupView.swift
@@ -0,0 +1,90 @@
+// CreateGroupView.swift
+
+import SwiftUI
+
+struct CreateGroupView: View {
+ @EnvironmentObject var appState: AppState
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var groupName = ""
+ @State private var selectedPeerIDs: Set<String> = []
+
+ /// Seuls les pairs avec une clé connue peuvent être ajoutés au groupe
+ private var eligiblePeers: [PeerInfo] {
+ appState.peers.filter { $0.isKeyKnown }
+ }
+
+ private var canCreate: Bool {
+ !groupName.trimmingCharacters(in: .whitespaces).isEmpty && !selectedPeerIDs.isEmpty
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Nom du groupe") {
+ TextField("Ex: Les amis, Équipe…", text: $groupName)
+ }
+
+ Section("Membres") {
+ if eligiblePeers.isEmpty {
+ Text("Aucun pair avec clé échangée disponible.\nConnecte-toi à au moins un pair d'abord.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ } else {
+ ForEach(eligiblePeers) { peer in
+ Button(action: { toggle(peer.id) }) {
+ HStack {
+ Image(systemName: selectedPeerIDs.contains(peer.id)
+ ? "checkmark.circle.fill" : "circle")
+ .foregroundColor(selectedPeerIDs.contains(peer.id) ? .blue : .secondary)
+ Text(String(peer.displayName.prefix(8)).uppercased())
+ .foregroundColor(.primary)
+ Spacer()
+ Image(systemName: "lock.fill")
+ .font(.caption)
+ .foregroundColor(.green)
+ }
+ }
+ }
+ }
+ }
+
+ if !selectedPeerIDs.isEmpty {
+ Section {
+ Text("\(selectedPeerIDs.count + 1) membre(s) au total (toi inclus)")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Text("Chiffrement : AES-256-GCM avec clé de groupe partagée via XWing PQC")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+ .navigationTitle("Nouveau groupe")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Annuler") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Créer") {
+ appState.createGroup(
+ name: groupName.trimmingCharacters(in: .whitespaces),
+ memberIDs: Array(selectedPeerIDs)
+ )
+ dismiss()
+ }
+ .disabled(!canCreate)
+ }
+ }
+ }
+ }
+
+ private func toggle(_ peerID: String) {
+ if selectedPeerIDs.contains(peerID) {
+ selectedPeerIDs.remove(peerID)
+ } else {
+ selectedPeerIDs.insert(peerID)
+ }
+ }
+}
btmessage/Views/MessageBubble.swift
@@ -0,0 +1,36 @@
+// MessageBubble.swift
+
+import SwiftUI
+
+struct MessageBubble: View {
+ let message: ChatMessage
+
+ var body: some View {
+ HStack {
+ if message.isLocal { Spacer() }
+
+ VStack(alignment: message.isLocal ? .trailing : .leading, spacing: 2) {
+ Text(message.text)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(message.isLocal ? Color.blue : Color(.systemGray5))
+ .foregroundColor(message.isLocal ? .white : .primary)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+
+ HStack(spacing: 4) {
+ Text(message.timestamp, style: .time)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ if message.isLocal {
+ Image(systemName: message.delivered ? "checkmark.circle.fill" : "clock")
+ .font(.caption2)
+ .foregroundColor(message.delivered ? .green : .secondary)
+ }
+ }
+ }
+ .frame(maxWidth: 280, alignment: message.isLocal ? .trailing : .leading)
+
+ if !message.isLocal { Spacer() }
+ }
+ }
+}
btmessage/btmessageApp.swift
@@ -0,0 +1,15 @@
+// btmessageApp.swift
+
+import SwiftUI
+
+@main
+struct btmessageApp: App {
+ @StateObject private var appState = AppState()
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .environmentObject(appState)
+ }
+ }
+}