Gitfed
bastien-mrq/bt-message/ Commits/ 0f96047

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>

Bastien MARQUES 2026-04-15 11:41 commit 0f96047089f2512a7d6189641a31451dfa7fa7eb
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
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d893c7 --- /dev/null +++ b/.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
diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..505e315 --- /dev/null +++ b/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
diff --git a/btmessage.xcodeproj/project.pbxproj b/btmessage.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7c982b4 --- /dev/null +++ b/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
diff --git a/btmessage.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/btmessage.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/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
diff --git a/btmessage/Crypto/GroupKeyManager.swift b/btmessage/Crypto/GroupKeyManager.swift new file mode 100644 index 0000000..25ded0f --- /dev/null +++ b/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
diff --git a/btmessage/Crypto/HybridCrypto.swift b/btmessage/Crypto/HybridCrypto.swift new file mode 100644 index 0000000..aefe2e1 --- /dev/null +++ b/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
diff --git a/btmessage/Crypto/IdentityManager.swift b/btmessage/Crypto/IdentityManager.swift new file mode 100644 index 0000000..b883c0c --- /dev/null +++ b/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
diff --git a/btmessage/Crypto/KyberKEM.swift b/btmessage/Crypto/KyberKEM.swift new file mode 100644 index 0000000..be49fed --- /dev/null +++ b/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
diff --git a/btmessage/Info.plist b/btmessage/Info.plist new file mode 100644 index 0000000..7fb6839 --- /dev/null +++ b/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
diff --git a/btmessage/Mesh/MeshManager.swift b/btmessage/Mesh/MeshManager.swift new file mode 100644 index 0000000..e502df4 --- /dev/null +++ b/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
diff --git a/btmessage/Mesh/MeshProtocol.swift b/btmessage/Mesh/MeshProtocol.swift new file mode 100644 index 0000000..3175ef9 --- /dev/null +++ b/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
diff --git a/btmessage/Models/ChatMessage.swift b/btmessage/Models/ChatMessage.swift new file mode 100644 index 0000000..813be01 --- /dev/null +++ b/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
diff --git a/btmessage/Models/GroupModel.swift b/btmessage/Models/GroupModel.swift new file mode 100644 index 0000000..f0238b8 --- /dev/null +++ b/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
diff --git a/btmessage/Models/PeerInfo.swift b/btmessage/Models/PeerInfo.swift new file mode 100644 index 0000000..3f9bffd --- /dev/null +++ b/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
diff --git a/btmessage/ViewModels/AppState.swift b/btmessage/ViewModels/AppState.swift new file mode 100644 index 0000000..67c73f0 --- /dev/null +++ b/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
diff --git a/btmessage/Views/ChatListView.swift b/btmessage/Views/ChatListView.swift new file mode 100644 index 0000000..b78a0ee --- /dev/null +++ b/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
diff --git a/btmessage/Views/ChatView.swift b/btmessage/Views/ChatView.swift new file mode 100644 index 0000000..683b3b1 --- /dev/null +++ b/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
diff --git a/btmessage/Views/ContentView.swift b/btmessage/Views/ContentView.swift new file mode 100644 index 0000000..b423867 --- /dev/null +++ b/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
diff --git a/btmessage/Views/CreateGroupView.swift b/btmessage/Views/CreateGroupView.swift new file mode 100644 index 0000000..0bd2853 --- /dev/null +++ b/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
diff --git a/btmessage/Views/MessageBubble.swift b/btmessage/Views/MessageBubble.swift new file mode 100644 index 0000000..5c8a10e --- /dev/null +++ b/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
diff --git a/btmessage/btmessageApp.swift b/btmessage/btmessageApp.swift new file mode 100644 index 0000000..2677c18 --- /dev/null +++ b/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) + } + } +}