Gitfed
bastien-mrq/bt-message/ Commits/ c97c5a4

security: audit fixes - Keychain storage, thread-safety, error handling

CRITICAL FIXES: - GroupKeyManager: Migrate group AES keys from UserDefaults to Keychain (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) - Add thread-safe access with concurrent dispatch queue - Add remove() method for secure group deletion IdentityManager: - Replace force-try with proper error handling - Add structured logging with os.log - Fix keychain accessibility level MeshProtocol: - Add NSLock to SeenMessageCache for thread-safety - Add clear() method MeshManager: - route() now returns Result<Void, RoutingError> - Add anti-spoofing check: senderID must match peer displayName - Add ACK tracking with onMessageDelivered callback - Remove silent try? errors AppState/ChatMessage: - Make ChatMessage.id mutable for ACK tracking - Add markMessageAsDelivered() for delivery receipts PROTOCOL.md: - Update X25519 -> XWingMLKEM768X25519 documentation - Add security considerations section - Document key sizes (1216 bytes XWing public key) - Add complete message flow examples 7 files changed, 422 insertions(+), 143 deletions(-)

Bastien MARQUES 2026-04-15 14:33 commit c97c5a450b99a60dbf354bc868fb9e5469be0a18 parent fea900c696b4e30c12783c6e08f167168a49f49f
7 files changed +422 −143
M PROTOCOL.md +147 −70
M btmessage/Crypto/GroupKeyManager.swift +81 −21
M btmessage/Crypto/IdentityManager.swift +79 −32
M btmessage/Mesh/MeshManager.swift +76 −16
M btmessage/Mesh/MeshProtocol.swift +17 −2
M btmessage/Models/ChatMessage.swift +1 −1
M btmessage/ViewModels/AppState.swift +21 −1
PROTOCOL.md
diff --git a/PROTOCOL.md b/PROTOCOL.md index 505e315..42107ea 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -60,7 +60,7 @@ Every packet exchanged on the mesh is a **JSON-encoded `MeshEnvelope`**. | `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 | +| `recipientID` | String | Destination peer UUID, `"*"` for broadcast, or groupID | | `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 | @@ -69,14 +69,16 @@ Encoding: `JSONEncoder` / `JSONDecoder` (Swift default). `Data` fields are base6 ### 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 | +| Value | Name | Default TTL | Payload | +|-------|------------------|-------------|---------------------------------------| +| `1` | `chat` | 7 | `ChatPayload` (see §3.3) | +| `2` | `keyExchange` | 5 | Raw public key bytes (XWing, 1216b) | +| `3` | `ack` | 5 | Message ID (UTF-8 string) | +| `4` | `discovery` | 5 | Empty | +| `5` | `groupChat` | 7 | `GroupChatPayload` | +| `6` | `groupKeyDistrib`| 5 | `GroupKeyPayload` | -### 3.3 ChatPayload structure +### 3.3 ChatPayload structure (DM) The `payload` field of a `chat` envelope contains a JSON-encoded `ChatPayload`: @@ -84,8 +86,34 @@ The `payload` field of a `chat` envelope contains a JSON-encoded `ChatPayload`: { "messageID": "uuid-string", "encryptedMessage": { - "ephemeralPublicKey": "<base64 — 32 bytes>", - "aesCiphertext": "<base64 — 12 + N + 16 bytes>" + "kemCiphertext": "<base64 — ~1120 bytes>", + "aesCiphertext": "<base64 — 12 + N + 16 bytes>" + } +} +``` + +### 3.4 GroupChatPayload structure + +```json +{ + "groupID": "uuid-string", + "messageID": "uuid-string", + "aesCiphertext": "<base64 — 12 + N + 16 bytes>" +} +``` + +### 3.5 GroupKeyPayload structure + +Used to distribute group AES keys to members (encrypted with recipient's XWing key): + +```json +{ + "groupID": "uuid-string", + "groupName": "Group Name", + "memberIDs": ["peer-id-1", "peer-id-2"], + "encryptedGroupKey": { + "kemCiphertext": "<base64>", + "aesCiphertext": "<base64>" } } ``` @@ -131,58 +159,64 @@ Device A Device B - `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 +### Public key format (XWingMLKEM768X25519) -The `payload` of a `keyExchange` envelope is the **raw 32-byte Curve25519 public key** (little-endian, RFC 7748 format). +The `payload` of a `keyExchange` envelope is the **raw 1216-byte XWing public key** (post-quantum hybrid combining ML-KEM-768 and X25519). ``` -payload = curve25519_public_key // exactly 32 bytes +payload = xwing_public_key // exactly 1216 bytes ``` +> **Note:** Previous versions used 32-byte X25519 keys. The protocol has been upgraded to use XWingMLKEM768X25519 for post-quantum security. + --- ## 6. Encryption -### Algorithm +### Algorithm (XWing KEM) + +**XWingMLKEM768X25519 + HKDF-SHA256 + AES-256-GCM** -**X25519 ECDH + HKDF-SHA256 + AES-256-GCM** -(same construction as Signal Protocol / Apple iMessage) +XWing is a hybrid post-quantum Key Encapsulation Mechanism combining: +- **ML-KEM-768** (Kyber) — lattice-based, quantum-resistant +- **X25519** — elliptic curve Diffie-Hellman ### Encrypt (sender side) ``` inputs: plaintext — UTF-8 message bytes - recipient_pub_key — 32-byte Curve25519 public key of recipient + recipient_pub_key — 1216-byte XWing public key steps: - 1. Generate ephemeral Curve25519 key pair: - ephemeral_priv, ephemeral_pub = X25519.generateKeyPair() + 1. Load recipient XWing public key: + recipient_pub = XWingMLKEM768X25519.PublicKey(rawRepresentation: recipient_pub_key) - 2. ECDH: - shared_secret = X25519(ephemeral_priv, recipient_pub_key) - // 32-byte raw shared secret + 2. Encapsulate (generates shared secret + ciphertext): + encap_result = recipient_pub.encapsulate() + shared_secret = encap_result.sharedSecret // SymmetricKey + kem_ciphertext = encap_result.encapsulated // ~1120 bytes 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 + ikm = shared_secret bytes, + salt = nil (or ephemeral data), + info = "btmessage-v1", + len = 32 ) 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 + // GCM tag is 16 bytes, appended 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 + kemCiphertext: kem_ciphertext // ~1120 bytes + aesCiphertext: aesCiphertext // 28 + N bytes } ``` @@ -191,16 +225,15 @@ output: ``` inputs: encrypted_message — EncryptedMessage struct - recipient_priv_key — 32-byte Curve25519 private key + recipient_priv_key — XWing private key steps: - 1. ECDH: - shared_secret = X25519(recipient_priv_key, encrypted_message.ephemeralPublicKey) + 1. Decapsulate: + shared_secret = recipient_priv_key.decapsulate(encrypted_message.kemCiphertext) 2. HKDF-SHA256: symmetric_key = HKDF-SHA256( - ikm = shared_secret, - salt = encrypted_message.ephemeralPublicKey, // same salt as encryption + ikm = shared_secret bytes, info = "btmessage-v1", len = 32 ) @@ -211,48 +244,75 @@ steps: 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 + plaintext = AES-256-GCM.decrypt(key=symmetric_key, nonce=nonce, + ciphertext=ciphertext, tag=tag) ``` +### Group Encryption + +Group messages use **symmetric AES-256-GCM** with a shared group key: + +1. Group creator generates random 32-byte AES key +2. Key is distributed to members via `groupKeyDistrib` (encrypted with each member's XWing key) +3. Group messages encrypted directly with AES-256-GCM (no KEM overhead) + ### 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 | +| Element | Size | +|--------------------------|------------| +| XWing public key | 1216 bytes | +| XWing private key | 2432 bytes | +| KEM ciphertext | ~1120 bytes| +| AES-256 key (derived) | 32 bytes | +| AES-GCM nonce | 12 bytes | +| AES-GCM tag | 16 bytes | --- -## 7. Android implementation notes +## 7. Security considerations -### Recommended libraries +### Key Storage -| 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 | +| Key Type | Storage Method | Protection Level | +|---------------|----------------|------------------| +| Identity (XWing private) | iOS Keychain | kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | +| Group keys (AES) | iOS Keychain | kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | +| Peer public keys | In-memory only | None (ephemeral) | -### Tink (Google) — simplest option +### Forward Secrecy -Google's [Tink](https://github.com/google/tink) library provides X25519, HKDF, and AES-GCM in a single dependency and matches the algorithm exactly: +Current implementation uses static XWing keys for identity. **Forward secrecy is limited** — if a private key is compromised, past messages may be decrypted. Future versions should implement ephemeral key exchange per session. + +### Authentication + +The current protocol does not include message signatures. `senderID` in envelopes is not cryptographically verified. A malicious peer could spoof another peer's ID in the mesh. Applications requiring strong authentication should add Ed25519 signatures to envelopes. + +--- + +## 8. Android implementation notes + +### Recommended libraries + +| Component | Android library | +|-----------------|----------------------------------------------| +| XWing KEM | BouncyCastle 1.78+ (supports ML-KEM + X25519)| +| HKDF-SHA256 | BouncyCastle or standard `javax.crypto` | +| AES-256-GCM | `javax.crypto.Cipher` (standard JCE) | +| JSON | `kotlinx.serialization` or Gson | + +### BouncyCastle XWing ```kotlin -// HKDF -val hkdf = HkdfPrfKey.builder() - .setParams(HkdfPrfParams.newBuilder().setHash(HashType.SHA256).build()) - ... +// BouncyCastle 1.78+ supports ML-KEM and X25519 +// You'll need to implement XWing hybrid construction -// AES-GCM -val aesKey = AesGcmKey.newBuilder().setKeyValue(ByteString.copyFrom(symmetricKey)).build() +val kemParams = MLKEMParameters.ml_kem_768 +val x25519Params = X25519KeyGenerationParameters(secureRandom) +// Combine according to XWing specification ``` +> **Note:** As of 2024, native XWing support in BouncyCastle is pending. You may need to implement the hybrid construction manually using ML-KEM-768 + X25519. + ### Transport for iOS ↔ Android MultipeerConnectivity is iOS/macOS only. For cross-platform: @@ -263,22 +323,40 @@ MultipeerConnectivity is iOS/macOS only. For cross-platform: --- -## 8. Complete message flow example +## 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"`). + +### Protocol Versions + +| Version | Encryption | Status | +|---------|-----------|--------| +| v1 (current) | XWingMLKEM768X25519 | Active | +| v0 (legacy) | X25519 only | Deprecated | + +--- + +## 10. Complete message flow example ``` Alice (iOS) relay/mesh Bob (Android) | | - |--- keyExchange: Alice_pub_key (32 bytes) ---------->| - |<-- keyExchange: Bob_pub_key (32 bytes) -------------| + |--- keyExchange: Alice_xwing_pub (1216 bytes) ------>| + |<-- keyExchange: Bob_xwing_pub (1216 bytes) ---------| | | | Alice types "Hello Bob" | - | → encrypt("Hello Bob", Bob_pub_key) | + | → encapsulate with Bob's XWing key | + | → derive AES key via HKDF | + | → AES-256-GCM encrypt | | | |--- chat envelope: ChatPayload --------------------- >| - | ephemeralPublicKey: E_pub (32 bytes) | - | aesCiphertext: nonce+ct+tag | + | kemCiphertext: XWing encapsulation (~1120 bytes) | + | aesCiphertext: nonce+ct+tag | | | - | Bob decrypts with Bob_priv_key | + | Bob decapsulates with priv key | + | → derives AES key | + | → decrypts | | → "Hello Bob" | | | |<-- ack envelope: messageID -------------------------| @@ -286,7 +364,6 @@ Alice (iOS) relay/mesh Bob (Android) --- -## 9. Versioning +## License -The HKDF `info` string `"btmessage-v1"` acts as a protocol version discriminator. -Future breaking changes must increment this value (e.g. `"btmessage-v2"`). +This protocol specification is provided under the same license as the btmessage project (Bastien-mrq License v1.0.0).
btmessage/Crypto/GroupKeyManager.swift
diff --git a/btmessage/Crypto/GroupKeyManager.swift b/btmessage/Crypto/GroupKeyManager.swift index 25ded0f..b144fed 100644 --- a/btmessage/Crypto/GroupKeyManager.swift +++ b/btmessage/Crypto/GroupKeyManager.swift @@ -1,16 +1,18 @@ // GroupKeyManager.swift -// Stores group AES-256 keys and group metadata in UserDefaults. +// Stores group AES-256 keys in Keychain (secure) and group metadata in UserDefaults. import Foundation import CryptoKit +import Security public class GroupKeyManager: ObservableObject { public static let shared = GroupKeyManager() - // groupID → 32-byte AES key + // groupID → 32-byte AES key (en mémoire uniquement, persistance dans Keychain) private var keys: [String: SymmetricKey] = [:] // groupID → GroupModel private var groupMap: [String: GroupModel] = [:] + private let queue = DispatchQueue(label: "btmessage.groupkey", attributes: .concurrent) private init() { load() @@ -18,54 +20,112 @@ public class GroupKeyManager: ObservableObject { // MARK: - Read - public func hasKey(for groupID: String) -> Bool { keys[groupID] != nil } + public func hasKey(for groupID: String) -> Bool { + queue.sync { keys[groupID] != nil } + } - public func groupKey(for groupID: String) -> SymmetricKey? { keys[groupID] } + public func groupKey(for groupID: String) -> SymmetricKey? { + queue.sync { keys[groupID] } + } - public func group(for groupID: String) -> GroupModel? { groupMap[groupID] } + public func group(for groupID: String) -> GroupModel? { + queue.sync { groupMap[groupID] } + } - public var allGroups: [GroupModel] { Array(groupMap.values) } + public var allGroups: [GroupModel] { + queue.sync { Array(groupMap.values) } + } // MARK: - Write public func store(group: GroupModel, key: SymmetricKey) { - keys[group.id] = key - groupMap[group.id] = group - persist() + queue.async(flags: .barrier) { [weak self] in + self?.keys[group.id] = key + self?.groupMap[group.id] = group + self?.persist() + } } public func store(group: GroupModel, keyData: Data) { store(group: group, key: SymmetricKey(data: keyData)) } - // MARK: - Persistence (UserDefaults) + public func remove(groupID: String) { + queue.async(flags: .barrier) { [weak self] in + self?.keys.removeValue(forKey: groupID) + self?.groupMap.removeValue(forKey: groupID) + self?.persist() + self?.keychainDelete(groupID: groupID) + } + } + + // MARK: - Persistence - private static let keysUD = "btmessage.groupKeys" + // Clés dans Keychain (sécurisé), métadonnées dans UserDefaults + private static let keysKeychainPrefix = "btmessage.groupkey." private static let groupsUD = "btmessage.groups" private func persist() { - // Keys: [groupID: Data(32 bytes)] - var rawKeys: [String: Data] = [:] + // 1. Sauvegarder les clés dans Keychain for (id, key) in keys { - rawKeys[id] = key.withUnsafeBytes { Data($0) } + let keyData = key.withUnsafeBytes { Data($0) } + keychainSave(groupID: id, data: keyData) } - UserDefaults.standard.set(rawKeys, forKey: GroupKeyManager.keysUD) - // Groups: JSON-encoded [String: GroupModel] + // 2. Sauvegarder les métadonnées dans UserDefaults (non sensible) 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) - } - } + // 1. Charger les métadonnées if let data = UserDefaults.standard.data(forKey: GroupKeyManager.groupsUD), let decoded = try? JSONDecoder().decode([String: GroupModel].self, from: data) { groupMap = decoded } + + // 2. Charger les clés depuis Keychain pour chaque groupe connu + for id in groupMap.keys { + if let keyData = keychainLoad(groupID: id), keyData.count == 32 { + keys[id] = SymmetricKey(data: keyData) + } + } + } + + // MARK: - Keychain Helpers + + private func keychainSave(groupID: String, data: Data) { + let key = GroupKeyManager.keysKeychainPrefix + groupID + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) + } + + private func keychainLoad(groupID: String) -> Data? { + let key = GroupKeyManager.keysKeychainPrefix + groupID + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil } + return item as? Data + } + + private func keychainDelete(groupID: String) { + let key = GroupKeyManager.keysKeychainPrefix + groupID + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) } }
btmessage/Crypto/IdentityManager.swift
diff --git a/btmessage/Crypto/IdentityManager.swift b/btmessage/Crypto/IdentityManager.swift index b883c0c..c699aa6 100644 --- a/btmessage/Crypto/IdentityManager.swift +++ b/btmessage/Crypto/IdentityManager.swift @@ -3,27 +3,39 @@ import Foundation import CryptoKit import Security +import os.log + +private let log = Logger(subsystem: "com.btmessage.app", category: "Identity") public class IdentityManager: ObservableObject { public static let shared = IdentityManager() @Published public private(set) var localPeerID: String @Published public private(set) var publicKey: HybridPublicKey + @Published public private(set) var error: IdentityError? private var privateKey: HybridPrivateKey private init() { if let (peerID, pub, priv) = IdentityManager.loadIdentity() { self.localPeerID = peerID - self.publicKey = pub - self.privateKey = priv + self.publicKey = pub + self.privateKey = priv + log.info("Identity loaded: \(peerID.prefix(8))...") } 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) + log.info("Creating new identity...") + do { + 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) + log.info("New identity created: \(peerID.prefix(8))...") + } catch { + log.fault("CRITICAL: Failed to generate identity: \(error)") + fatalError("Cannot create identity: \(error)") + } } } @@ -37,44 +49,79 @@ public class IdentityManager: ObservableObject { // MARK: - Keychain + private static let peerIDKey = "btmessage.identity.peerID" + private static let xwingKey = "btmessage.identity.xwing" + 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) + let peerIDData = keychainLoad(key: peerIDKey), + let peerID = String(data: peerIDData, encoding: .utf8), + let keyData = keychainLoad(key: xwingKey) + else { + log.debug("No existing identity found in keychain") + return nil + } + + do { + let xwing = try XWingMLKEM768X25519.PrivateKey(integrityCheckedRepresentation: keyData) + let pub = HybridPublicKey(rawBytes: xwing.publicKey.rawRepresentation, peerID: peerID) + let priv = HybridPrivateKey(xwing: xwing) + return (peerID, pub, priv) + } catch { + log.error("Failed to load identity: \(error)") + return nil + } } 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)) + let keyData = Data(privKey.integrityCheckedRepresentation) + + let peerSaved = keychainSave(key: peerIDKey, data: peerID.data(using: .utf8)!) + let keySaved = keychainSave(key: xwingKey, data: keyData) + + if peerSaved && keySaved { + log.info("Identity saved to keychain") + } else { + log.error("Failed to save identity - peer:\(peerSaved) key:\(keySaved)") + } } - 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 + @discardableResult + private static func keychainSave(key: String, data: Data) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly ] - SecItemDelete(q as CFDictionary) - SecItemAdd(q as CFDictionary, nil) + SecItemDelete(query as CFDictionary) + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess } private static func keychainLoad(key: String) -> Data? { - let q: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne ] var item: CFTypeRef? - guard SecItemCopyMatching(q as CFDictionary, &item) == errSecSuccess else { return nil } + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil } return item as? Data } } + +public enum IdentityError: Error, LocalizedError { + case keyGenerationFailed(Error) + case keychainSaveFailed + case keychainLoadFailed + + public var errorDescription: String? { + switch self { + case .keyGenerationFailed(let err): return "Key generation failed: \(err.localizedDescription)" + case .keychainSaveFailed: return "Failed to save to keychain" + case .keychainLoadFailed: return "Failed to load from keychain" + } + } +}
btmessage/Mesh/MeshManager.swift
diff --git a/btmessage/Mesh/MeshManager.swift b/btmessage/Mesh/MeshManager.swift index e502df4..2ab08eb 100644 --- a/btmessage/Mesh/MeshManager.swift +++ b/btmessage/Mesh/MeshManager.swift @@ -66,15 +66,16 @@ public class MeshManager: NSObject, ObservableObject { // MARK: - Send DM - public func sendMessage(text: String, to recipientPeerID: String) throws { + public func sendMessage(text: String, to recipientPeerID: String) throws -> String { 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 messageID = UUID().uuidString let plaintext = Data(text.utf8) let encrypted = try identity.encrypt(message: plaintext, for: recipientKey) - let chatPayload = ChatPayload(messageID: UUID().uuidString, encryptedMessage: encrypted) + let chatPayload = ChatPayload(messageID: messageID, encryptedMessage: encrypted) guard let payloadData = chatPayload.encoded() else { throw MeshError.encodingFailed } let envelope = MeshEnvelope.makeChat( @@ -82,7 +83,16 @@ public class MeshManager: NSObject, ObservableObject { recipientID: recipientPeerID, payload: payloadData ) - route(envelope: envelope) + + // Tracker pour ACK + pendingAcks.insert(messageID) + + let result = route(envelope: envelope) + if case .failure = result { + pendingAcks.remove(messageID) + } + + return messageID } // MARK: - Send Group Message @@ -127,28 +137,64 @@ public class MeshManager: NSObject, ObservableObject { // MARK: - Routing - func route(envelope: MeshEnvelope) { - guard !seenCache.contains(envelope.id) else { return } + /// Erreurs de routage + enum RoutingError: Error, LocalizedError { + case noConnectedPeers + case sendFailed(Error) + case encodingFailed + + var errorDescription: String? { + switch self { + case .noConnectedPeers: return "Aucun pair connecté" + case .sendFailed(let err): return "Échec d'envoi: \(err.localizedDescription)" + case .encodingFailed: return "Échec d'encodage" + } + } + } + + @discardableResult + func route(envelope: MeshEnvelope) -> Result<Void, RoutingError> { + guard !seenCache.contains(envelope.id) else { return .success(()) } seenCache.insert(envelope.id) - guard let data = envelope.encoded() else { return } + guard let data = envelope.encoded() else { + log.error("route: encoding failed for envelope \(envelope.id)") + return .failure(.encodingFailed) + } let peers = session.connectedPeers guard !peers.isEmpty else { - log.error("route: no connected peers, dropping type=\(envelope.type.rawValue)") - return + log.warning("route: no connected peers, dropping type=\(envelope.type.rawValue)") + return .failure(.noConnectedPeers) } + let targetPeers: [MCPeerID] 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) + targetPeers = [directPeer] } else { log.info("route: flood → \(peers.count) peers \(data.count)b") - try? session.send(data, toPeers: peers, with: .reliable) + targetPeers = peers + } + + do { + try session.send(data, toPeers: targetPeers, with: .reliable) + return .success(()) + } catch { + log.error("route: send failed: \(error)") + return .failure(.sendFailed(error)) } } private func handleReceived(envelope: MeshEnvelope, from sender: MCPeerID) { guard !seenCache.contains(envelope.id) else { return } + + // Vérification d'intégrité basique: senderID doit correspondre au peer connecté + // (empêche le spoofing trivial d'identité) + guard envelope.senderID == sender.displayName else { + log.warning("Identity spoofing detected: declared \(envelope.senderID) but sent from \(sender.displayName)") + return + } + seenCache.insert(envelope.id) switch envelope.type { @@ -158,7 +204,7 @@ public class MeshManager: NSObject, ObservableObject { deliverChatMessage(envelope: envelope) sendAck(messageID: envelope.id, to: envelope.senderID) } else { - if let fwd = envelope.forwarded() { route(envelope: fwd) } + if let fwd = envelope.forwarded() { _ = route(envelope: fwd) } } case .keyExchange: @@ -172,29 +218,43 @@ public class MeshManager: NSObject, ObservableObject { } else { log.error("keyExchange: invalid key from \(peerID), payloadBytes=\(envelope.payload.count) expected=1216") } - if let fwd = envelope.forwarded() { route(envelope: fwd) } + if let fwd = envelope.forwarded() { _ = route(envelope: fwd) } case .ack: - break + handleAck(envelope: envelope) case .discovery: broadcastPublicKey() - if let fwd = envelope.forwarded() { route(envelope: fwd) } + 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) } + 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) } + if let fwd = envelope.forwarded() { _ = route(envelope: fwd) } } } } + // MARK: - ACK Handling + + private var pendingAcks: Set<String> = [] + public var onMessageDelivered: ((String) -> Void)? // Callback pour ACK reçus + + private func handleAck(envelope: MeshEnvelope) { + guard let messageID = String(data: envelope.payload, encoding: .utf8) else { return } + let wasPending = pendingAcks.remove(messageID) != nil + if wasPending { + log.info("ACK received for message \(messageID.prefix(8))...") + onMessageDelivered?(messageID) + } + } + // MARK: - Delivery private func deliverChatMessage(envelope: MeshEnvelope) {
btmessage/Mesh/MeshProtocol.swift
diff --git a/btmessage/Mesh/MeshProtocol.swift b/btmessage/Mesh/MeshProtocol.swift index 3175ef9..7ef2dc1 100644 --- a/btmessage/Mesh/MeshProtocol.swift +++ b/btmessage/Mesh/MeshProtocol.swift @@ -109,16 +109,24 @@ struct GroupKeyPayload: Codable { static func decode(_ data: Data) -> GroupKeyPayload? { try? JSONDecoder().decode(GroupKeyPayload.self, from: data) } } -// MARK: - Dedup Cache (LRU-ish) +// MARK: - Dedup Cache (Thread-safe LRU) class SeenMessageCache { private var seen = Set<String>() private var queue = [String]() private let maxSize = 1000 + private let lock = NSLock() - func contains(_ id: String) -> Bool { seen.contains(id) } + func contains(_ id: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return seen.contains(id) + } func insert(_ id: String) { + lock.lock() + defer { lock.unlock() } + guard !seen.contains(id) else { return } seen.insert(id) queue.append(id) @@ -127,4 +135,11 @@ class SeenMessageCache { seen.remove(old) } } + + func clear() { + lock.lock() + defer { lock.unlock() } + seen.removeAll() + queue.removeAll() + } }
btmessage/Models/ChatMessage.swift
diff --git a/btmessage/Models/ChatMessage.swift b/btmessage/Models/ChatMessage.swift index 813be01..7275541 100644 --- a/btmessage/Models/ChatMessage.swift +++ b/btmessage/Models/ChatMessage.swift @@ -3,7 +3,7 @@ import Foundation public struct ChatMessage: Identifiable, Codable { - public let id: String + public var id: String public let conversationID: String // peerID of the other participant public let senderID: String public let text: String
btmessage/ViewModels/AppState.swift
diff --git a/btmessage/ViewModels/AppState.swift b/btmessage/ViewModels/AppState.swift index 67c73f0..f6e2322 100644 --- a/btmessage/ViewModels/AppState.swift +++ b/btmessage/ViewModels/AppState.swift @@ -38,7 +38,11 @@ public class AppState: ObservableObject { public func sendMessage(text: String, to peerID: String) { appendOutgoing(text: text, conversationID: peerID, senderID: identity.localPeerID) do { - try mesh.sendMessage(text: text, to: peerID) + let messageID = try mesh.sendMessage(text: text, to: peerID) + // Mettre à jour le message avec l'ID pour tracking ACK + if let lastIndex = conversations[peerID]?.indices.last { + conversations[peerID]?[lastIndex].id = messageID + } } catch { errorMessage = error.localizedDescription } @@ -171,5 +175,21 @@ public class AppState: ObservableObject { } } } + + mesh.onMessageDelivered = { [weak self] messageID in + Task { @MainActor [weak self] in + self?.markMessageAsDelivered(messageID: messageID) + } + } + } + + private func markMessageAsDelivered(messageID: String) { + for (conversationID, var messages) in conversations { + if let index = messages.firstIndex(where: { $0.id == messageID }) { + messages[index].delivered = true + conversations[conversationID] = messages + break + } + } } }