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
+ }
+ }
}
}